Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a909b58d | ||
|
|
17cf6e0636 | ||
|
|
7d0aca506a | ||
|
|
7cd7b4dc4f | ||
|
|
6d4f606eeb | ||
|
|
1ccd444aad | ||
|
|
3bfca35f3a | ||
|
|
3e5217066f | ||
|
|
4da50571eb | ||
|
|
d4593d0686 | ||
|
|
a53cf4b797 | ||
|
|
bcbf990a5f | ||
|
|
27cf2f032d |
No files matched your search
+5
-1
@@ -50,6 +50,7 @@ set(ENTITY_SOURCES_BASE
|
|||||||
source/game/entities/concrete/rolling_rock.cpp
|
source/game/entities/concrete/rolling_rock.cpp
|
||||||
source/game/entities/concrete/wall_trap.cpp
|
source/game/entities/concrete/wall_trap.cpp
|
||||||
source/game/entities/concrete/floor_trap.cpp
|
source/game/entities/concrete/floor_trap.cpp
|
||||||
|
source/game/entities/concrete/hole.cpp
|
||||||
source/game/entities/concrete/dust.cpp
|
source/game/entities/concrete/dust.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,9 +119,10 @@ set(APP_SOURCES
|
|||||||
source/game/map/paint_layer.cpp
|
source/game/map/paint_layer.cpp
|
||||||
source/game/levels/level_manager.cpp
|
source/game/levels/level_manager.cpp
|
||||||
# Mòdul editor (només activa contingut en Debug via `#ifdef _DEBUG`).
|
# Mòdul editor (només activa contingut en Debug via `#ifdef _DEBUG`).
|
||||||
# En Release el fitxer compila a un object buit i l'enllaçador no
|
# En Release els fitxers compilen a object buit i l'enllaçador no
|
||||||
# arrossega cap símbol del joc final.
|
# arrossega cap símbol del joc final.
|
||||||
source/game/editor/editor.cpp
|
source/game/editor/editor.cpp
|
||||||
|
source/game/editor/editor_config.cpp
|
||||||
${ENTITY_SOURCES}
|
${ENTITY_SOURCES}
|
||||||
# Fitxer només del binari principal: `loadFromLevel` depèn de
|
# Fitxer només del binari principal: `loadFromLevel` depèn de
|
||||||
# Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar
|
# Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar
|
||||||
@@ -348,6 +350,7 @@ option(BUILD_TESTS "Build smoke tests" ON)
|
|||||||
if(BUILD_TESTS)
|
if(BUILD_TESTS)
|
||||||
add_executable(test_entities_smoke
|
add_executable(test_entities_smoke
|
||||||
tests/test_entities_smoke.cpp
|
tests/test_entities_smoke.cpp
|
||||||
|
tests/hole_player_stubs.cpp
|
||||||
source/core/resources/resource_helper.cpp
|
source/core/resources/resource_helper.cpp
|
||||||
source/core/resources/resource_pack.cpp
|
source/core/resources/resource_pack.cpp
|
||||||
source/game/map/map.cpp
|
source/game/map/map.cpp
|
||||||
@@ -371,6 +374,7 @@ if(BUILD_TESTS)
|
|||||||
add_executable(test_whip_attack
|
add_executable(test_whip_attack
|
||||||
tests/test_whip_attack.cpp
|
tests/test_whip_attack.cpp
|
||||||
tests/whip_attack_player_stubs.cpp
|
tests/whip_attack_player_stubs.cpp
|
||||||
|
tests/hole_player_stubs.cpp
|
||||||
source/core/resources/resource_helper.cpp
|
source/core/resources/resource_helper.cpp
|
||||||
source/core/resources/resource_pack.cpp
|
source/core/resources/resource_pack.cpp
|
||||||
source/game/map/map.cpp
|
source/game/map/map.cpp
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Tema visual de l'editor de tilemap (només Debug). Carregat per
|
||||||
|
# EditorConfig a l'arrencada; les claus absents cauen als valors compilats
|
||||||
|
# a Defaults::Editor (veure source/utils/defaults.hpp).
|
||||||
|
|
||||||
|
picker:
|
||||||
|
margin: 8 # px entre canvas i caixa del picker
|
||||||
|
spacing: 1 # px de separació entre tiles dins del picker
|
||||||
|
|
||||||
|
# Colors ARGB en hex. Accepta enter (0xFF2244FF) o cadena ("#FF2244FF").
|
||||||
|
colors:
|
||||||
|
border: "0xFF2244FF" # vora 1 px que envolta canvas i picker
|
||||||
|
picker_bg: "0xFF101824" # fons del picker
|
||||||
|
picker_hover: "0xFFFFFFFF" # vora 1 px del tile sota el cursor
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 182 B |
@@ -0,0 +1,26 @@
|
|||||||
|
name: hole
|
||||||
|
type: static_hazard
|
||||||
|
sprite:
|
||||||
|
path: hole.gif
|
||||||
|
size: [16, 16]
|
||||||
|
tile_size: [1, 1]
|
||||||
|
collision:
|
||||||
|
# Forat: ocupa el tile sencer (16×16). `height: 0` deixa que un salt
|
||||||
|
# AIRBORNE el creui sense disparar el contacte (el filtre d'altura de
|
||||||
|
# `checkPlayerCollisions` el descarta quan `jump_z_ > height`). El
|
||||||
|
# bucle de contacte admet l'entitat per `flags.triggers_on_overlap`,
|
||||||
|
# encara que `flags.collides` sigui false.
|
||||||
|
size: [16, 16]
|
||||||
|
height: 0
|
||||||
|
flags:
|
||||||
|
collides: false
|
||||||
|
interactable: false
|
||||||
|
# Avui el loader no llig el bloc `flags:` (és declaratiu); el flag
|
||||||
|
# real es fixa al constructor C++ de `Hole`. Mantenim el camp per
|
||||||
|
# coherència amb la resta de plantilles i per quan es cablege la
|
||||||
|
# lectura genèrica de flags.
|
||||||
|
triggers_on_overlap: true
|
||||||
|
animations:
|
||||||
|
idle: { frames: [0], fps: 1, loop: true }
|
||||||
|
stats:
|
||||||
|
damage: 0
|
||||||
+15
-3
@@ -90,6 +90,7 @@ entities:
|
|||||||
position: [12, 2]
|
position: [12, 2]
|
||||||
- type: torch
|
- type: torch
|
||||||
position: [16, 2]
|
position: [16, 2]
|
||||||
|
|
||||||
- type: sarcophagus
|
- type: sarcophagus
|
||||||
sarc_position: [0, 0]
|
sarc_position: [0, 0]
|
||||||
- type: sarcophagus
|
- type: sarcophagus
|
||||||
@@ -130,11 +131,22 @@ entities:
|
|||||||
sarc_position: [2, 4]
|
sarc_position: [2, 4]
|
||||||
- type: sarcophagus
|
- type: sarcophagus
|
||||||
sarc_position: [3, 4]
|
sarc_position: [3, 4]
|
||||||
|
|
||||||
- type: door
|
- type: door
|
||||||
# Sense `position`: la porta sempre viu al mur trasser. La columna
|
|
||||||
# transitable la dicta `geometry.door_col`; el render i la caixa
|
|
||||||
# de col·lisió es deriven d'aquesta dada al loadFromLevel.
|
|
||||||
- type: mummy
|
- type: mummy
|
||||||
position: [10, 7]
|
position: [10, 7]
|
||||||
- type: mummy
|
- type: mummy
|
||||||
position: [7, 9]
|
position: [7, 9]
|
||||||
|
|
||||||
|
- type: hole
|
||||||
|
position: [11, 5]
|
||||||
|
- type: hole
|
||||||
|
position: [4, 7]
|
||||||
|
- type: hole
|
||||||
|
position: [8, 11]
|
||||||
|
- type: hole
|
||||||
|
position: [15, 11]
|
||||||
|
- type: hole
|
||||||
|
position: [15, 5]
|
||||||
@@ -73,6 +73,13 @@ animations:
|
|||||||
frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280]
|
frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280]
|
||||||
loop: false
|
loop: false
|
||||||
|
|
||||||
|
# Mort per caiguda a un agujero (DeathCause::HOLE). `loop: false` perquè
|
||||||
|
# `animationIsCompleted()` dispari el respawn timer determinísticament.
|
||||||
|
death_hole:
|
||||||
|
frames: [15]
|
||||||
|
frame_ms: 60
|
||||||
|
loop: false
|
||||||
|
|
||||||
# Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra
|
# Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra
|
||||||
# tocs curts (passets d'un tile sense rellisquera).
|
# tocs curts (passets d'un tile sense rellisquera).
|
||||||
inertia:
|
inertia:
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ animations:
|
|||||||
frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280]
|
frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280]
|
||||||
loop: false
|
loop: false
|
||||||
|
|
||||||
|
# Mort per caiguda a un agujero (DeathCause::HOLE). `loop: false` perquè
|
||||||
|
# `animationIsCompleted()` dispari el respawn timer determinísticament.
|
||||||
|
death_hole:
|
||||||
|
frames: [15]
|
||||||
|
frame_ms: 60
|
||||||
|
loop: false
|
||||||
|
|
||||||
# Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra
|
# Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra
|
||||||
# tocs curts (passets d'un tile sense rellisquera).
|
# tocs curts (passets d'un tile sense rellisquera).
|
||||||
inertia:
|
inertia:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -29,6 +29,9 @@
|
|||||||
#include "core/system/event_buffer.hpp"
|
#include "core/system/event_buffer.hpp"
|
||||||
#include "core/ui/ui_config.hpp"
|
#include "core/ui/ui_config.hpp"
|
||||||
#include "game/debug_config.hpp"
|
#include "game/debug_config.hpp"
|
||||||
|
#ifdef _DEBUG
|
||||||
|
#include "game/editor/editor_config.hpp"
|
||||||
|
#endif
|
||||||
#include "game/levels/level_manager.hpp"
|
#include "game/levels/level_manager.hpp"
|
||||||
#include "game/options.hpp"
|
#include "game/options.hpp"
|
||||||
#include "game/scenes/game_scene.hpp"
|
#include "game/scenes/game_scene.hpp"
|
||||||
@@ -119,6 +122,12 @@ Director::Director() {
|
|||||||
// data/ui.yaml cau al valor compilat a Defaults::UI.
|
// data/ui.yaml cau al valor compilat a Defaults::UI.
|
||||||
UiConfig::get().load("data/ui.yaml");
|
UiConfig::get().load("data/ui.yaml");
|
||||||
|
|
||||||
|
#ifdef _DEBUG
|
||||||
|
// Tema visual de l'editor de tilemap (Debug-only). Claus absents
|
||||||
|
// cauen als valors compilats a Defaults::Editor.
|
||||||
|
EditorConfig::get().load("data/editor/editor.yaml");
|
||||||
|
#endif
|
||||||
|
|
||||||
// Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els
|
// Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els
|
||||||
// overlays perquè tots els reaprofiten sense duplicar la lògica. Els
|
// overlays perquè tots els reaprofiten sense duplicar la lògica. Els
|
||||||
// noms de font viuen al fitxer data/ui.yaml via UiConfig.
|
// noms de font viuen al fitxer data/ui.yaml via UiConfig.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "core/resources/resource_cache.hpp"
|
#include "core/resources/resource_cache.hpp"
|
||||||
#include "core/resources/resource_helper.hpp"
|
#include "core/resources/resource_helper.hpp"
|
||||||
#include "core/system/event_buffer.hpp"
|
#include "core/system/event_buffer.hpp"
|
||||||
|
#include "game/editor/editor_config.hpp"
|
||||||
#include "game/levels/level_manager.hpp"
|
#include "game/levels/level_manager.hpp"
|
||||||
#include "game/map/tilemap.hpp"
|
#include "game/map/tilemap.hpp"
|
||||||
#include "utils/defaults.hpp"
|
#include "utils/defaults.hpp"
|
||||||
@@ -21,11 +22,20 @@
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
// Gruix del border 1 px del picker: reservat dins del rectangle perquè
|
||||||
|
// no envaeixi els tiles ni se surti fora de la caixa. La cella efectiva
|
||||||
|
// de cada tile té `pitch = tile_size + spacing` i la graella s'inicia
|
||||||
|
// un píxel cap dins de cada vora.
|
||||||
|
constexpr int PICKER_BORDER = 1;
|
||||||
|
|
||||||
// Geometria del TilePicker en espai canvas. `cols × rows_visible` tiles
|
// Geometria del TilePicker en espai canvas. `cols × rows_visible` tiles
|
||||||
// encapsulats dins d'un rectangle amb `PICKER_MARGIN` per vora. Com que
|
// encapsulats dins d'un rectangle amb `picker.margin` per vora i un
|
||||||
// el tileset real (p.ex. 32×16 tiles de 16 px = 512×256) no cabria a
|
// border 1 px que el delimita. Si el tileset cap en amplada (la suma
|
||||||
// 1× dins del canvas 336×240, linealitzem els índexs i els repartim
|
// de tiles + spacing + border ≤ INNER_W), en respectem la graella
|
||||||
// en una graella que SÍ cap, amb scroll vertical per veure la resta.
|
// natural — els tiles surten ordenats igual que al GIF; si no,
|
||||||
|
// linealitzem els índexs en una graella més ampla que sí cap. Quan el
|
||||||
|
// contingut cap sense scroll, la caixa s'arrossega a alçada mínima i
|
||||||
|
// es centra verticalment dins de l'àrea interna.
|
||||||
struct PickerLayout {
|
struct PickerLayout {
|
||||||
int x{0};
|
int x{0};
|
||||||
int y{0};
|
int y{0};
|
||||||
@@ -34,6 +44,8 @@ namespace {
|
|||||||
int cols{0};
|
int cols{0};
|
||||||
int rows_visible{0};
|
int rows_visible{0};
|
||||||
int tile_size{0};
|
int tile_size{0};
|
||||||
|
int spacing{0};
|
||||||
|
int pitch{0}; // tile_size + spacing — pas entre orígens de tiles veïns.
|
||||||
int total_tiles{0};
|
int total_tiles{0};
|
||||||
int max_scroll{0};
|
int max_scroll{0};
|
||||||
};
|
};
|
||||||
@@ -41,27 +53,59 @@ namespace {
|
|||||||
auto layoutPicker(const Tilemap& tm) -> PickerLayout {
|
auto layoutPicker(const Tilemap& tm) -> PickerLayout {
|
||||||
PickerLayout lay{};
|
PickerLayout lay{};
|
||||||
lay.tile_size = tm.getTileSize();
|
lay.tile_size = tm.getTileSize();
|
||||||
const int M = Defaults::Editor::PICKER_MARGIN;
|
lay.spacing = std::max(0, EditorConfig::get().pickerSpacing());
|
||||||
|
lay.pitch = lay.tile_size + lay.spacing;
|
||||||
|
const int M = EditorConfig::get().pickerMargin();
|
||||||
const int INNER_W = GameCanvas::WIDTH - (2 * M);
|
const int INNER_W = GameCanvas::WIDTH - (2 * M);
|
||||||
const int INNER_H = GameCanvas::HEIGHT - (2 * M);
|
const int INNER_H = GameCanvas::HEIGHT - (2 * M);
|
||||||
if (lay.tile_size <= 0) { return lay; }
|
if (lay.tile_size <= 0 || lay.pitch <= 0) { return lay; }
|
||||||
lay.cols = INNER_W / lay.tile_size;
|
|
||||||
lay.rows_visible = INNER_H / lay.tile_size;
|
// Espai útil per a tiles, descomptat el border 1 px de cada vora.
|
||||||
lay.x = M;
|
// Inversió de N·ts + (N-1)·spacing ≤ AVAIL → N ≤ (AVAIL+spacing)/pitch.
|
||||||
lay.y = M;
|
const int AVAIL_W = INNER_W - (2 * PICKER_BORDER);
|
||||||
lay.w = lay.cols * lay.tile_size;
|
const int AVAIL_H = INNER_H - (2 * PICKER_BORDER);
|
||||||
lay.h = lay.rows_visible * lay.tile_size;
|
const int MAX_COLS = std::max(0, (AVAIL_W + lay.spacing) / lay.pitch);
|
||||||
|
const int MAX_ROWS = std::max(0, (AVAIL_H + lay.spacing) / lay.pitch);
|
||||||
|
|
||||||
|
int tcols = 0;
|
||||||
const auto TILESET = Resource::Cache::get().getSurface(tm.getTilesetPath());
|
const auto TILESET = Resource::Cache::get().getSurface(tm.getTilesetPath());
|
||||||
if (TILESET != nullptr) {
|
if (TILESET != nullptr) {
|
||||||
const int TCOLS = TILESET->getWidth() / lay.tile_size;
|
tcols = TILESET->getWidth() / lay.tile_size;
|
||||||
const int TROWS = TILESET->getHeight() / lay.tile_size;
|
const int TROWS = TILESET->getHeight() / lay.tile_size;
|
||||||
lay.total_tiles = TCOLS * TROWS;
|
lay.total_tiles = tcols * TROWS;
|
||||||
}
|
}
|
||||||
|
// Com que els índexs del tileset són row-major (idx = row·TCOLS + col),
|
||||||
|
// fer servir cols = TCOLS reprodueix exactament la disposició del GIF.
|
||||||
|
const bool FITS_W = (tcols > 0 && tcols <= MAX_COLS);
|
||||||
|
lay.cols = FITS_W ? tcols : MAX_COLS;
|
||||||
|
|
||||||
const int TOTAL_ROWS = (lay.cols > 0) ? ((lay.total_tiles + lay.cols - 1) / lay.cols) : 0;
|
const int TOTAL_ROWS = (lay.cols > 0) ? ((lay.total_tiles + lay.cols - 1) / lay.cols) : 0;
|
||||||
|
// Si tot el contingut cap sense scroll, encongim la caixa a les
|
||||||
|
// files reals; així podem centrar-la verticalment.
|
||||||
|
lay.rows_visible = std::min(MAX_ROWS, TOTAL_ROWS);
|
||||||
lay.max_scroll = std::max(0, TOTAL_ROWS - lay.rows_visible);
|
lay.max_scroll = std::max(0, TOTAL_ROWS - lay.rows_visible);
|
||||||
|
|
||||||
|
// Mida real de la caixa: tiles + spacing intern + 1 px de border per cara.
|
||||||
|
const int TILES_W = (lay.cols > 0) ? ((lay.cols * lay.tile_size) + ((lay.cols - 1) * lay.spacing)) : 0;
|
||||||
|
const int TILES_H = (lay.rows_visible > 0) ? ((lay.rows_visible * lay.tile_size) + ((lay.rows_visible - 1) * lay.spacing)) : 0;
|
||||||
|
lay.w = TILES_W + (2 * PICKER_BORDER);
|
||||||
|
lay.h = TILES_H + (2 * PICKER_BORDER);
|
||||||
|
// Centrat horitzontal i vertical dins de l'àrea interna; quan el
|
||||||
|
// picker arriba a l'alçada màxima, el centrat vertical degenera a
|
||||||
|
// origen=M (no hi ha espai extra que repartir).
|
||||||
|
lay.x = M + ((INNER_W - lay.w) / 2);
|
||||||
|
lay.y = M + ((INNER_H - lay.h) / 2);
|
||||||
return lay;
|
return lay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Origen (cantonada superior-esquerra) del tile a la cel·la (vc, vr)
|
||||||
|
// dins del picker. Centralitzat perquè render i hit-test no divergeixin.
|
||||||
|
auto pickerTileOrigin(const PickerLayout& lay, int vc, int vr) -> std::pair<int, int> {
|
||||||
|
const int PX = lay.x + PICKER_BORDER + (vc * lay.pitch);
|
||||||
|
const int PY = lay.y + PICKER_BORDER + (vr * lay.pitch);
|
||||||
|
return {PX, PY};
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void Editor::enter() {
|
void Editor::enter() {
|
||||||
@@ -171,11 +215,21 @@ void Editor::updateMap(Tilemap& tilemap, int canvas_x, int canvas_y) {
|
|||||||
void Editor::updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
|
void Editor::updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
|
||||||
const auto LAY = layoutPicker(tilemap);
|
const auto LAY = layoutPicker(tilemap);
|
||||||
picker_hover_ = -1;
|
picker_hover_ = -1;
|
||||||
if (canvas_x >= LAY.x && canvas_x < LAY.x + LAY.w && canvas_y >= LAY.y && canvas_y < LAY.y + LAY.h && LAY.tile_size > 0) {
|
// Espai útil per als tiles dins de la caixa (descomptat el border 1 px).
|
||||||
const int VC = (canvas_x - LAY.x) / LAY.tile_size;
|
const int INNER_X = LAY.x + PICKER_BORDER;
|
||||||
const int VR = (canvas_y - LAY.y) / LAY.tile_size;
|
const int INNER_Y = LAY.y + PICKER_BORDER;
|
||||||
const int IDX = ((picker_scroll_ + VR) * LAY.cols) + VC;
|
const int RX = canvas_x - INNER_X;
|
||||||
if (IDX < LAY.total_tiles) { picker_hover_ = IDX; }
|
const int RY = canvas_y - INNER_Y;
|
||||||
|
if (RX >= 0 && RY >= 0 && LAY.tile_size > 0 && LAY.pitch > 0 && LAY.cols > 0) {
|
||||||
|
const int VC = RX / LAY.pitch;
|
||||||
|
const int VR = RY / LAY.pitch;
|
||||||
|
// Píxels de spacing entre cel·les: el clic cau a la "vora morta".
|
||||||
|
const bool IN_SPACING_X = (RX % LAY.pitch) >= LAY.tile_size;
|
||||||
|
const bool IN_SPACING_Y = (RY % LAY.pitch) >= LAY.tile_size;
|
||||||
|
if (!IN_SPACING_X && !IN_SPACING_Y && VC < LAY.cols && VR < LAY.rows_visible) {
|
||||||
|
const int IDX = ((picker_scroll_ + VR) * LAY.cols) + VC;
|
||||||
|
if (IDX < LAY.total_tiles) { picker_hover_ = IDX; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& ev : EventBuffer::events) {
|
for (const auto& ev : EventBuffer::events) {
|
||||||
@@ -206,7 +260,7 @@ void Editor::render(const Tilemap& tilemap) const {
|
|||||||
tilemap.renderTilePreview(PX, PY, brush_);
|
tilemap.renderTilePreview(PX, PY, brush_);
|
||||||
}
|
}
|
||||||
|
|
||||||
constexpr auto BORDER = Defaults::Editor::BORDER_ARGB;
|
const auto BORDER = EditorConfig::get().borderArgb();
|
||||||
screen.fillRect(0, 0, W, 1, BORDER);
|
screen.fillRect(0, 0, W, 1, BORDER);
|
||||||
screen.fillRect(0, H - 1, W, 1, BORDER);
|
screen.fillRect(0, H - 1, W, 1, BORDER);
|
||||||
screen.fillRect(0, 0, 1, H, BORDER);
|
screen.fillRect(0, 0, 1, H, BORDER);
|
||||||
@@ -215,34 +269,43 @@ void Editor::render(const Tilemap& tilemap) const {
|
|||||||
|
|
||||||
void Editor::renderPicker(const Tilemap& tilemap) const {
|
void Editor::renderPicker(const Tilemap& tilemap) const {
|
||||||
const auto LAY = layoutPicker(tilemap);
|
const auto LAY = layoutPicker(tilemap);
|
||||||
|
if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; }
|
||||||
auto& screen = Screen::get();
|
auto& screen = Screen::get();
|
||||||
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, Defaults::Editor::PICKER_BG_ARGB);
|
const auto& cfg = EditorConfig::get();
|
||||||
|
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb());
|
||||||
|
|
||||||
for (int vr = 0; vr < LAY.rows_visible; ++vr) {
|
for (int vr = 0; vr < LAY.rows_visible; ++vr) {
|
||||||
const int LOGICAL_ROW = picker_scroll_ + vr;
|
const int LOGICAL_ROW = picker_scroll_ + vr;
|
||||||
for (int vc = 0; vc < LAY.cols; ++vc) {
|
for (int vc = 0; vc < LAY.cols; ++vc) {
|
||||||
const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
|
const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
|
||||||
if (IDX >= LAY.total_tiles) { break; }
|
if (IDX >= LAY.total_tiles) { break; }
|
||||||
const int PX = LAY.x + (vc * LAY.tile_size);
|
const auto [PX, PY] = pickerTileOrigin(LAY, vc, vr);
|
||||||
const int PY = LAY.y + (vr * LAY.tile_size);
|
|
||||||
tilemap.renderTilePreview(PX, PY, IDX);
|
tilemap.renderTilePreview(PX, PY, IDX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (picker_hover_ >= 0 && LAY.cols > 0) {
|
if (picker_hover_ >= 0) {
|
||||||
const int VR = (picker_hover_ / LAY.cols) - picker_scroll_;
|
const int VR = (picker_hover_ / LAY.cols) - picker_scroll_;
|
||||||
const int VC = picker_hover_ % LAY.cols;
|
const int VC = picker_hover_ % LAY.cols;
|
||||||
if (VR >= 0 && VR < LAY.rows_visible) {
|
if (VR >= 0 && VR < LAY.rows_visible) {
|
||||||
const int PX = LAY.x + (VC * LAY.tile_size);
|
const auto [PX, PY] = pickerTileOrigin(LAY, VC, VR);
|
||||||
const int PY = LAY.y + (VR * LAY.tile_size);
|
|
||||||
const int TS = LAY.tile_size;
|
const int TS = LAY.tile_size;
|
||||||
constexpr auto C = Defaults::Editor::PICKER_HOVER_ARGB;
|
const auto C = cfg.pickerHoverArgb();
|
||||||
screen.fillRect(PX, PY, TS, 1, C);
|
screen.fillRect(PX, PY, TS, 1, C);
|
||||||
screen.fillRect(PX, PY + TS - 1, TS, 1, C);
|
screen.fillRect(PX, PY + TS - 1, TS, 1, C);
|
||||||
screen.fillRect(PX, PY, 1, TS, C);
|
screen.fillRect(PX, PY, 1, TS, C);
|
||||||
screen.fillRect(PX + TS - 1, PY, 1, TS, C);
|
screen.fillRect(PX + TS - 1, PY, 1, TS, C);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Border 1 px del picker, dibuixat al damunt del fons i dels tiles
|
||||||
|
// perquè delimite clarament la caixa també quan hi ha tiles que
|
||||||
|
// arriben a la vora.
|
||||||
|
const auto BORDER = cfg.borderArgb();
|
||||||
|
screen.fillRect(LAY.x, LAY.y, LAY.w, 1, BORDER);
|
||||||
|
screen.fillRect(LAY.x, LAY.y + LAY.h - 1, LAY.w, 1, BORDER);
|
||||||
|
screen.fillRect(LAY.x, LAY.y, 1, LAY.h, BORDER);
|
||||||
|
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif // _DEBUG
|
#endif // _DEBUG
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#include "game/editor/editor_config.hpp"
|
||||||
|
|
||||||
|
#ifdef _DEBUG
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/resources/resource_helper.hpp"
|
||||||
|
#include "external/fkyaml_node.hpp"
|
||||||
|
#include "utils/defaults.hpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
void readField(const fkyaml::node& node, const char* key, T& dst) {
|
||||||
|
if (node.contains(key)) { dst = node[key].get_value<T>(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Llegeix un color ARGB. Accepta enter (0xFF2244FF al YAML) o cadena
|
||||||
|
// ("0xFF2244FF" / "#FF2244FF"). std::stoul amb base=0 detecta el prefix
|
||||||
|
// "0x" automàticament; si el valor és invàlid, deixem `dst` intacte i
|
||||||
|
// avisem perquè l'error no passi en silenci.
|
||||||
|
void readArgb(const fkyaml::node& node, const char* key, std::uint32_t& dst) {
|
||||||
|
if (!node.contains(key)) { return; }
|
||||||
|
try {
|
||||||
|
const auto& v = node[key];
|
||||||
|
if (v.is_integer()) {
|
||||||
|
dst = static_cast<std::uint32_t>(v.get_value<std::uint64_t>());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto s = v.get_value<std::string>();
|
||||||
|
if (!s.empty() && s.front() == '#') { s.erase(0, 1); }
|
||||||
|
dst = static_cast<std::uint32_t>(std::stoul(s, nullptr, 0));
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::cerr << "EditorConfig: color '" << key << "' invàlid (" << e.what() << ") — ignorat\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
EditorConfig::EditorConfig()
|
||||||
|
: picker_margin_(Defaults::Editor::PICKER_MARGIN),
|
||||||
|
picker_spacing_(Defaults::Editor::PICKER_SPACING),
|
||||||
|
border_argb_(Defaults::Editor::BORDER_ARGB),
|
||||||
|
picker_bg_argb_(Defaults::Editor::PICKER_BG_ARGB),
|
||||||
|
picker_hover_argb_(Defaults::Editor::PICKER_HOVER_ARGB) {
|
||||||
|
}
|
||||||
|
|
||||||
|
auto EditorConfig::get() -> EditorConfig& {
|
||||||
|
static EditorConfig instance_;
|
||||||
|
return instance_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EditorConfig::load(const std::string& file_path) {
|
||||||
|
auto bytes = Resource::Helper::loadFile(file_path);
|
||||||
|
if (bytes.empty()) {
|
||||||
|
std::cerr << "EditorConfig: no s'ha pogut obrir " << file_path << " — s'usaran els defaults compilats\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const auto* begin = reinterpret_cast<const char*>(bytes.data());
|
||||||
|
const auto* end = begin + bytes.size();
|
||||||
|
auto yaml = fkyaml::node::deserialize(begin, end);
|
||||||
|
|
||||||
|
if (yaml.contains("picker")) {
|
||||||
|
const auto& p = yaml["picker"];
|
||||||
|
readField(p, "margin", picker_margin_);
|
||||||
|
readField(p, "spacing", picker_spacing_);
|
||||||
|
}
|
||||||
|
if (yaml.contains("colors")) {
|
||||||
|
const auto& c = yaml["colors"];
|
||||||
|
readArgb(c, "border", border_argb_);
|
||||||
|
readArgb(c, "picker_bg", picker_bg_argb_);
|
||||||
|
readArgb(c, "picker_hover", picker_hover_argb_);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << "EditorConfig: carregat des de " << file_path << '\n';
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::cerr << "EditorConfig: error parsejant " << file_path << ": " << e.what() << '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // _DEBUG
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#ifdef _DEBUG
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// Configuració visual de l'editor de tilemap (Debug-only). Carregada des
|
||||||
|
// de data/editor/editor.yaml; les claus absents cauen als valors compilats
|
||||||
|
// a Defaults::Editor. Mateix patró que UiConfig (Meyers singleton + load).
|
||||||
|
class EditorConfig {
|
||||||
|
public:
|
||||||
|
static auto get() -> EditorConfig&;
|
||||||
|
|
||||||
|
EditorConfig(const EditorConfig&) = delete;
|
||||||
|
EditorConfig(EditorConfig&&) = delete;
|
||||||
|
auto operator=(const EditorConfig&) -> EditorConfig& = delete;
|
||||||
|
auto operator=(EditorConfig&&) -> EditorConfig& = delete;
|
||||||
|
|
||||||
|
void load(const std::string& file_path);
|
||||||
|
|
||||||
|
[[nodiscard]] auto pickerMargin() const -> int { return picker_margin_; }
|
||||||
|
[[nodiscard]] auto pickerSpacing() const -> int { return picker_spacing_; }
|
||||||
|
[[nodiscard]] auto borderArgb() const -> std::uint32_t { return border_argb_; }
|
||||||
|
[[nodiscard]] auto pickerBgArgb() const -> std::uint32_t { return picker_bg_argb_; }
|
||||||
|
[[nodiscard]] auto pickerHoverArgb() const -> std::uint32_t { return picker_hover_argb_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
EditorConfig();
|
||||||
|
~EditorConfig() = default;
|
||||||
|
|
||||||
|
int picker_margin_;
|
||||||
|
int picker_spacing_;
|
||||||
|
std::uint32_t border_argb_;
|
||||||
|
std::uint32_t picker_bg_argb_;
|
||||||
|
std::uint32_t picker_hover_argb_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // _DEBUG
|
||||||
@@ -10,6 +10,10 @@ FloorTrap::FloorTrap(const fkyaml::node& yaml) {
|
|||||||
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("floor_trap");
|
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("floor_trap");
|
||||||
collides_ = false;
|
collides_ = false;
|
||||||
interactable_ = false;
|
interactable_ = false;
|
||||||
|
// Trigger d'àrea: la trampa no bloqueja el pas però vol rebre
|
||||||
|
// `onPlayerContact` quan el jugador la solapa. Tanca el gap latent
|
||||||
|
// que la deixava muda (`checkPlayerCollisions` filtrava pel `collides`).
|
||||||
|
triggers_on_overlap_ = true;
|
||||||
|
|
||||||
if (yaml.contains("cycle")) {
|
if (yaml.contains("cycle")) {
|
||||||
const auto& c = yaml["cycle"];
|
const auto& c = yaml["cycle"];
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#include "game/entities/concrete/hole.hpp"
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "game/entities/entity_factory.hpp"
|
||||||
|
#include "game/entities/player.hpp"
|
||||||
|
|
||||||
|
Hole::Hole(const fkyaml::node& yaml) {
|
||||||
|
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("hole");
|
||||||
|
// Sense xoc físic ni interacció — només trigger d'àrea. El bucle de
|
||||||
|
// contacte ens admet via `triggers_on_overlap_=true`. Damage queda a
|
||||||
|
// 0 (no publiquem `player_hit`); la mort la dispara `integrateJump`
|
||||||
|
// del jugador quan supera `HOLE_FALL_DEPTH`.
|
||||||
|
collides_ = false;
|
||||||
|
interactable_ = false;
|
||||||
|
triggers_on_overlap_ = true;
|
||||||
|
// Decal de sòl: `Entity::getFeetY()` torna sentinel mínim si aquest
|
||||||
|
// flag està actiu — així el forat es pinta abans que qualsevol actor.
|
||||||
|
is_floor_decal_ = true;
|
||||||
|
setDamage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Hole::onPlayerContact(Player* player) {
|
||||||
|
if (player == nullptr) { return; }
|
||||||
|
// Només mossega quan el jugador trepitja el forat de peu (GROUNDED).
|
||||||
|
// En l'aire, el filtre d'altura del bucle ja l'ha rebutjat (height_=0
|
||||||
|
// i jump_z_>0 prou per superar-ho); però mantenim el gat per defensa
|
||||||
|
// davant de futurs canvis al filtre.
|
||||||
|
if (player->getState() != Player::State::GROUNDED) { return; }
|
||||||
|
|
||||||
|
// Re-verificació estricta: la hit-box interna del jugador (8×8) ha
|
||||||
|
// d'estar COMPLETAMENT continguda dins del rect del forat (16×16). El
|
||||||
|
// bucle de col·lisió del manager ja ens ha cridat per qualsevol solape;
|
||||||
|
// ací filtrem perquè la caiguda només es dispari quan els "dos peus"
|
||||||
|
// estan dins del forat, no quan només la vora frega — sinó es percep
|
||||||
|
// com un imant que succiona el jugador al centre.
|
||||||
|
const SDL_FRect HIT = player->getHitBox();
|
||||||
|
const SDL_FRect BOX = getCollisionRect();
|
||||||
|
const bool CONTAINED = HIT.x >= BOX.x && HIT.y >= BOX.y && HIT.x + HIT.w <= BOX.x + BOX.w && HIT.y + HIT.h <= BOX.y + BOX.h;
|
||||||
|
if (!CONTAINED) { return; }
|
||||||
|
|
||||||
|
// Snap al centre XY del forat + Y de la base del tile (per al clip
|
||||||
|
// rect del sprite mentre cau). `enterHoleFall` rep coordenades
|
||||||
|
// absolutes en el canvas — el player s'encarrega de convertir a la
|
||||||
|
// seua convenció (cantó superior-esquerra del sprite).
|
||||||
|
const float CENTER_X = BOX.x + (BOX.w * 0.5F);
|
||||||
|
const float CENTER_Y = BOX.y + (BOX.h * 0.5F);
|
||||||
|
const float FLOOR_Y = BOX.y + BOX.h;
|
||||||
|
player->enterHoleFall(CENTER_X, CENTER_Y, FLOOR_Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
REGISTER_ENTITY("hole", Hole)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "external/fkyaml_node.hpp"
|
||||||
|
#include "game/entities/hazard.hpp"
|
||||||
|
|
||||||
|
// Forat al sòl: hazard estàtic de 16×16 que NO bloqueja el pas però
|
||||||
|
// detecta quan el jugador hi cau a sobre. Característiques:
|
||||||
|
// - `collides_=false` + `triggers_on_overlap_=true`: rep `onPlayerContact`
|
||||||
|
// pel bucle de `EntityManager::checkPlayerCollisions` sense bloquejar
|
||||||
|
// el moviment del jugador.
|
||||||
|
// - `height_=0`: el filtre per altura del bucle (`jump_z > height`) NO
|
||||||
|
// s'activa amb height a zero, així que el bucle de contacte SÍ crida
|
||||||
|
// `onPlayerContact` també per a jugadors AIRBORNE. Qui rebutja els
|
||||||
|
// estats distints de GROUNDED (saltant, caient, HURT, DYING...) és el
|
||||||
|
// guard explícit dins de `Hole::onPlayerContact`. El filtre d'altura
|
||||||
|
// és una optimització ortogonal del bucle, no la defensa principal.
|
||||||
|
// - Damage 0: el contacte no publica `player_hit`. En lloc d'això,
|
||||||
|
// `onPlayerContact` invoca `Player::enterHoleFall`, que transiciona
|
||||||
|
// el jugador a AIRBORNE amb subestat `falling_through_hole_`.
|
||||||
|
// - Verificació amb hit-box interna: `onPlayerContact` re-verifica
|
||||||
|
// l'overlap amb `Player::getHitBox()` (8×8 centrada al sprite),
|
||||||
|
// així només es dispara quan el centre del jugador entra al tile.
|
||||||
|
// - Z-order: `is_floor_decal_=true` (heretat) — `Entity::getFeetY()`
|
||||||
|
// retorna sentinel mínim, així el forat es pinta abans que qualsevol
|
||||||
|
// actor i les momies passen per davant. L'efecte "el sòl es traga el
|
||||||
|
// jugador" no es fa amb Y-sort: el `Player::render` aplica un clip
|
||||||
|
// rect mentre `falling_through_hole_=true` que retalla el sprite per
|
||||||
|
// sota de la base del tile del forat.
|
||||||
|
class Hole : public StaticHazard {
|
||||||
|
public:
|
||||||
|
explicit Hole(const fkyaml::node& yaml);
|
||||||
|
|
||||||
|
// No publica `player_hit`. En el seu lloc, dispara
|
||||||
|
// `Player::enterHoleFall(center_x, center_y, floor_y)` perquè el
|
||||||
|
// jugador armi el clip rect a la Y de la base del forat.
|
||||||
|
void onPlayerContact(Player* player) override;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO(hole-feature): afegir reset de `falling_through_hole_` i `hole_clip_bottom_y_` a `Player::disable()` per simetria.
|
||||||
|
// TODO(hole-feature): afegir tests dedicats del comportament de la mòmia amb forats (no requereixen Player real).
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <random>
|
#include <random>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "game/entities/entity_factory.hpp"
|
#include "game/entities/entity_factory.hpp"
|
||||||
|
#include "game/entities/entity_manager.hpp"
|
||||||
#include "game/entities/event_bus.hpp"
|
#include "game/entities/event_bus.hpp"
|
||||||
#include "game/entities/player.hpp"
|
#include "game/entities/player.hpp"
|
||||||
#include "game/map/map.hpp"
|
#include "game/map/map.hpp"
|
||||||
@@ -197,7 +199,15 @@ void Mummy::snapToPlayfieldEdge(float dx, float dy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Mummy::step(float delta_time) {
|
void Mummy::step(float delta_time) {
|
||||||
// Desplaçament nominal en aquest frame segons la direcció actual.
|
// Detecció reactiva al canvi de tile: si la mòmia ha entrat aquest
|
||||||
|
// tick a un tile nou amb un decal trigger (Hole), dispara mitja volta
|
||||||
|
// abans de calcular el desplaçament — així el frame ja consumeix la
|
||||||
|
// nova direcció sense esperar al següent. La primera crida només
|
||||||
|
// registra el tile de spawn (lazy init).
|
||||||
|
reactToCurrentTile();
|
||||||
|
|
||||||
|
// Desplaçament nominal en aquest frame segons la direcció actual
|
||||||
|
// (potser ja invertida per `reactToCurrentTile`).
|
||||||
const float DX = dir_x_ * speed_ * delta_time;
|
const float DX = dir_x_ * speed_ * delta_time;
|
||||||
const float DY = dir_y_ * speed_ * delta_time;
|
const float DY = dir_y_ * speed_ * delta_time;
|
||||||
|
|
||||||
@@ -211,6 +221,9 @@ void Mummy::step(float delta_time) {
|
|||||||
// centre; el remanent queda per al pròxim frame, ja amb la (potser
|
// centre; el remanent queda per al pròxim frame, ja amb la (potser
|
||||||
// nova) direcció aplicada. Així la caixa 16×16 queda sempre alineada
|
// nova) direcció aplicada. Així la caixa 16×16 queda sempre alineada
|
||||||
// a la graella de corredors i els girs no xoquen per drift sub-píxel.
|
// a la graella de corredors i els girs no xoquen per drift sub-píxel.
|
||||||
|
// L'evitació de forats en pasillos rectes la cobreix
|
||||||
|
// `reactToCurrentTile` (al començament d'aquest mètode), no aquesta
|
||||||
|
// branca — aquí només es decideix entre direccions geomètriques.
|
||||||
if (const auto SNAP = detectLaneCrossing(DX, DY); SNAP.has_value()) {
|
if (const auto SNAP = detectLaneCrossing(DX, DY); SNAP.has_value()) {
|
||||||
if (DX != 0.0F) {
|
if (DX != 0.0F) {
|
||||||
pixel_rect_.x += *SNAP;
|
pixel_rect_.x += *SNAP;
|
||||||
@@ -233,14 +246,86 @@ void Mummy::step(float delta_time) {
|
|||||||
// Bloqueig per la vora del playfield: amarrem la caja a la vora exacta.
|
// Bloqueig per la vora del playfield: amarrem la caja a la vora exacta.
|
||||||
if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); }
|
if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); }
|
||||||
|
|
||||||
// Cal girar. Preferim perpendiculars; només fem U-turn si no hi ha cap
|
// Cal girar. Cascada de tres nivells:
|
||||||
// altra opció (comportament típic d'Oh Mummy!: no es recula sense motiu).
|
// Nivell 1 — perpendiculars sense decal (preferides; el comportament
|
||||||
const auto POOLS = collectDirectionPools(speed_ * delta_time);
|
// "natural" d'Oh Mummy!: girar abans que recular).
|
||||||
const auto& pool = !POOLS.perp.empty() ? POOLS.perp : POOLS.uturn;
|
// Nivell 2 — mitja volta sense decal (rescat: callejón sense salida o
|
||||||
applyChoice(chooseDirection(pool));
|
// tots els girs porten a forat). Preferim recular abans
|
||||||
|
// que caure al forat — un forat al costat NO ha de fer
|
||||||
|
// que la mòmia salti per damunt seu cap a una perpendicular
|
||||||
|
// amb forat només perquè la mitja volta era l'única neta.
|
||||||
|
// Nivell 3 — pool sense filtre de decals (cas degenerat: rodejada de
|
||||||
|
// forats). Permet caure al forat com a últim recurs; les
|
||||||
|
// mòmies no moren pels forats, així que és comportament
|
||||||
|
// tolerable en sales mal dissenyades.
|
||||||
|
// TODO(refactor): mateixa cascada a `decideAtIntersection` amb la
|
||||||
|
// variant "afegir recta a Nivell 1". Factoritzable
|
||||||
|
// quan tinga sentit (3+ callsites).
|
||||||
|
auto pools = collectDirectionPools(speed_ * delta_time, /*avoid_decals=*/true);
|
||||||
|
const std::vector<Direction>* chosen = nullptr;
|
||||||
|
if (!pools.perp.empty()) {
|
||||||
|
chosen = &pools.perp;
|
||||||
|
} else if (!pools.uturn.empty()) {
|
||||||
|
chosen = &pools.uturn;
|
||||||
|
}
|
||||||
|
DirectionPools pools_unfiltered;
|
||||||
|
if (chosen == nullptr) {
|
||||||
|
pools_unfiltered = collectDirectionPools(speed_ * delta_time, /*avoid_decals=*/false);
|
||||||
|
if (!pools_unfiltered.perp.empty()) {
|
||||||
|
chosen = &pools_unfiltered.perp;
|
||||||
|
} else if (!pools_unfiltered.uturn.empty()) {
|
||||||
|
chosen = &pools_unfiltered.uturn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyChoice((chosen != nullptr) ? chooseDirection(*chosen) : Direction::NONE);
|
||||||
// No avancem aquest frame: la direcció nova s'aplicarà al pròxim step.
|
// No avancem aquest frame: la direcció nova s'aplicarà al pròxim step.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Mummy::reactToCurrentTile() {
|
||||||
|
// Calculem el tile que toca la **vora davantera** de la BOX en la
|
||||||
|
// direcció de marxa, no el centre. Així la mitja volta es dispara
|
||||||
|
// tan aviat com el primer píxel del cos de la mòmia entra al tile-
|
||||||
|
// forat ("asoma el peu"), no quan ja hi ha mig cos a dins. Per a una
|
||||||
|
// direcció positiva en un eix, la vora davantera és el costat major
|
||||||
|
// de l'AABB (`x + w - 1`, exclusive→inclusive convention en píxels);
|
||||||
|
// per a una direcció negativa, el menor (`x`); per a zero, qualsevol
|
||||||
|
// (no hi ha avenç en aquest eix).
|
||||||
|
const auto BOX = getCollisionRect();
|
||||||
|
const float LEAD_X = (dir_x_ > 0.0F) ? (BOX.x + BOX.w - 1.0F) : BOX.x;
|
||||||
|
const float LEAD_Y = (dir_y_ > 0.0F) ? (BOX.y + BOX.h - 1.0F) : BOX.y;
|
||||||
|
const auto [CUR_COL, CUR_ROW] = Map::pixelToTile(LEAD_X, LEAD_Y);
|
||||||
|
|
||||||
|
// Sense canvi de tile no hi ha res a fer — això filtra també tots els
|
||||||
|
// ticks intra-tile (la majoria, ~16 frames per tile a velocitats típiques).
|
||||||
|
if (CUR_COL == last_tile_col_ && CUR_ROW == last_tile_row_) { return; }
|
||||||
|
|
||||||
|
// Primera crida després del spawn: registrem el tile sense reaccionar.
|
||||||
|
// Així no disparem mitja volta espuria si la mòmia spawneja sobre un
|
||||||
|
// decal (defensa contra mapes mal dissenyats) o si pixel_rect_ encara
|
||||||
|
// no estava posat al constructor.
|
||||||
|
const bool FIRST_TIME = (last_tile_col_ == INT_MIN || last_tile_row_ == INT_MIN);
|
||||||
|
last_tile_col_ = CUR_COL;
|
||||||
|
last_tile_row_ = CUR_ROW;
|
||||||
|
if (FIRST_TIME) { return; }
|
||||||
|
|
||||||
|
// Sense manager injectat (smoke tests), no podem consultar decals.
|
||||||
|
if (entity_manager_ == nullptr) { return; }
|
||||||
|
|
||||||
|
// Si el tile nou conté un decal trigger (Hole o futur pinchos plans,
|
||||||
|
// plaques, etc.), mitja volta immediata. `changeDirection` també
|
||||||
|
// selecciona l'animació cardinal corresponent.
|
||||||
|
const auto TS = static_cast<float>(Map::tile_size);
|
||||||
|
const SDL_FRect TILE_RECT{
|
||||||
|
.x = (static_cast<float>(CUR_COL) * TS) + static_cast<float>(Map::tilemap_origin_x),
|
||||||
|
.y = (static_cast<float>(CUR_ROW) * TS) + static_cast<float>(Map::tilemap_origin_y),
|
||||||
|
.w = TS,
|
||||||
|
.h = TS,
|
||||||
|
};
|
||||||
|
if (entity_manager_->findFloorTriggerAt(TILE_RECT) != nullptr) {
|
||||||
|
changeDirection(-dir_x_, -dir_y_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auto Mummy::detectLaneCrossing(float dx, float dy) const -> std::optional<float> {
|
auto Mummy::detectLaneCrossing(float dx, float dy) const -> std::optional<float> {
|
||||||
const auto BOX = getCollisionRect();
|
const auto BOX = getCollisionRect();
|
||||||
if (dx != 0.0F) {
|
if (dx != 0.0F) {
|
||||||
@@ -258,26 +343,54 @@ auto Mummy::currentDirection() const -> Direction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Mummy::decideAtIntersection(float step_px) {
|
void Mummy::decideAtIntersection(float step_px) {
|
||||||
// Pool base: perpendiculars lliures (collectDirectionPools ja exclou la
|
// Cascada de tres nivells (paral·lela a la branca de bloqueig de
|
||||||
// direcció actual i la mitja volta va al seu propi calaix, que ací
|
// `step`). Aquí el Nivell 1 inclou la recta com a candidata addicional
|
||||||
// ignorem — mai fem U-turn a un creuament).
|
// (a un creuament la mòmia pot continuar recta si segueix lliure i no
|
||||||
auto pools = collectDirectionPools(step_px);
|
// apunta a un decal); el Nivell 2 admet la mitja volta — abans no es
|
||||||
std::vector<Direction> pool = std::move(pools.perp);
|
// permetia mai en creuaments, però és preferible recular que caure al
|
||||||
|
// forat quan totes les altres surten a decal.
|
||||||
|
// TODO(refactor): cascada paral·lela a la branca de bloqueig de `step`.
|
||||||
|
auto pools = collectDirectionPools(step_px, /*avoid_decals=*/true);
|
||||||
|
|
||||||
// Afegim la recta si segueix essent lliure. A un creuament "net" ho és
|
// Nivell 1: perpendiculars (filtrades) + recta si lliure i sense decal.
|
||||||
// sempre (la mòmia hi ha arribat per aquest corredor), però als creua-
|
std::vector<Direction> level1 = std::move(pools.perp);
|
||||||
// ments de la vora del playfield la recta pot estar al límit i no
|
|
||||||
// permetre un pas més — en aquest cas la descartem.
|
|
||||||
const auto STRAIGHT_BOX = candidateBox(dir_x_ * step_px, dir_y_ * step_px);
|
const auto STRAIGHT_BOX = candidateBox(dir_x_ * step_px, dir_y_ * step_px);
|
||||||
if (Map::insidePlayfield(STRAIGHT_BOX) && !Map::collides(STRAIGHT_BOX)) {
|
const bool STRAIGHT_OPEN = Map::insidePlayfield(STRAIGHT_BOX) && !Map::collides(STRAIGHT_BOX);
|
||||||
pool.push_back(currentDirection());
|
if (STRAIGHT_OPEN && !isDecalAheadInDir(dir_x_, dir_y_)) {
|
||||||
|
level1.push_back(currentDirection());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pool.empty()) { return; }
|
// Nivell 2: mitja volta sense decal — rescat quan Nivell 1 ja no té cap
|
||||||
applyChoice(chooseDirection(pool));
|
// opció neta. Un forat al final del passadís ha de fer recular la
|
||||||
|
// mòmia, no convertir-se en l'opció triada per defecte.
|
||||||
|
const std::vector<Direction>* chosen = nullptr;
|
||||||
|
if (!level1.empty()) {
|
||||||
|
chosen = &level1;
|
||||||
|
} else if (!pools.uturn.empty()) {
|
||||||
|
chosen = &pools.uturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nivell 3: cas degenerat (rodejada de forats). Pool sense filtre amb
|
||||||
|
// perpendiculars, recta si lliure i mitja volta. Permet caure al forat
|
||||||
|
// com a últim recurs.
|
||||||
|
DirectionPools pools_unfiltered;
|
||||||
|
std::vector<Direction> level3;
|
||||||
|
if (chosen == nullptr) {
|
||||||
|
pools_unfiltered = collectDirectionPools(step_px, /*avoid_decals=*/false);
|
||||||
|
level3 = std::move(pools_unfiltered.perp);
|
||||||
|
if (STRAIGHT_OPEN) { level3.push_back(currentDirection()); }
|
||||||
|
if (!level3.empty()) {
|
||||||
|
chosen = &level3;
|
||||||
|
} else if (!pools_unfiltered.uturn.empty()) {
|
||||||
|
chosen = &pools_unfiltered.uturn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chosen == nullptr) { return; }
|
||||||
|
applyChoice(chooseDirection(*chosen));
|
||||||
}
|
}
|
||||||
|
|
||||||
auto Mummy::collectDirectionPools(float step) const -> DirectionPools {
|
auto Mummy::collectDirectionPools(float step, bool avoid_decals) const -> DirectionPools {
|
||||||
struct Candidate {
|
struct Candidate {
|
||||||
float dx;
|
float dx;
|
||||||
float dy;
|
float dy;
|
||||||
@@ -297,6 +410,11 @@ auto Mummy::collectDirectionPools(float step) const -> DirectionPools {
|
|||||||
if (IS_CURRENT) { continue; }
|
if (IS_CURRENT) { continue; }
|
||||||
const auto TEST = candidateBox(c.dx, c.dy);
|
const auto TEST = candidateBox(c.dx, c.dy);
|
||||||
if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; }
|
if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; }
|
||||||
|
// Filtre de decals (Hole i futurs decals trigger): comprova el
|
||||||
|
// tile cardinal contigu; si conté un trigger, descartem la
|
||||||
|
// direcció. El caller pot demanar `avoid_decals=false` com a
|
||||||
|
// fallback quan totes les filtrades estan bloquejades.
|
||||||
|
if (avoid_decals && isDecalAheadInDir(c.dx, c.dy)) { continue; }
|
||||||
const bool IS_UTURN = (c.dx * dir_x_ < 0.0F) || (c.dy * dir_y_ < 0.0F);
|
const bool IS_UTURN = (c.dx * dir_x_ < 0.0F) || (c.dy * dir_y_ < 0.0F);
|
||||||
if (IS_UTURN) {
|
if (IS_UTURN) {
|
||||||
pools.uturn.push_back(c.dir);
|
pools.uturn.push_back(c.dir);
|
||||||
@@ -307,6 +425,41 @@ auto Mummy::collectDirectionPools(float step) const -> DirectionPools {
|
|||||||
return pools;
|
return pools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto Mummy::isDecalAheadInDir(float dx_signed, float dy_signed) const -> bool {
|
||||||
|
// Sense manager injectat (smoke tests sense `EntityManager::add`), el
|
||||||
|
// filtre és no-op: la mòmia es comporta exactament com abans del feature.
|
||||||
|
if (entity_manager_ == nullptr) { return false; }
|
||||||
|
if (dx_signed == 0.0F && dy_signed == 0.0F) { return false; }
|
||||||
|
// El rect a consultar és el AABB del tile cardinal contigu (mida
|
||||||
|
// `Map::tile_size`), ancorat al tile actual de la mòmia. Calculem el
|
||||||
|
// tile a partir del **centre** de la BOX, no de la cantonada superior-
|
||||||
|
// esquerra: amb una BOX de mida `tile_size`, la cantonada esquerra
|
||||||
|
// entra al tile següent un sub-píxel abans que el cos de la mòmia hi
|
||||||
|
// sigui majoritàriament. Si féssim servir `BOX.x` directament, una
|
||||||
|
// mòmia a `BOX.x = 199.5` (cobrint [199.5, 215.5], 0.5 px al tile 12
|
||||||
|
// i 15.5 px al tile 13) seria classificada al tile 12 i giraria un
|
||||||
|
// tile abans del veritablement adjacent al decal.
|
||||||
|
const auto BOX = getCollisionRect();
|
||||||
|
const auto TS = static_cast<float>(Map::tile_size);
|
||||||
|
const auto SIGN_OF = [](float v) -> float {
|
||||||
|
if (v > 0.0F) { return 1.0F; }
|
||||||
|
if (v < 0.0F) { return -1.0F; }
|
||||||
|
return 0.0F;
|
||||||
|
};
|
||||||
|
const float CENTER_X = BOX.x + (BOX.w * 0.5F);
|
||||||
|
const float CENTER_Y = BOX.y + (BOX.h * 0.5F);
|
||||||
|
const auto [CUR_COL, CUR_ROW] = Map::pixelToTile(CENTER_X, CENTER_Y);
|
||||||
|
const float TILE_X = (static_cast<float>(CUR_COL) * TS) + static_cast<float>(Map::tilemap_origin_x);
|
||||||
|
const float TILE_Y = (static_cast<float>(CUR_ROW) * TS) + static_cast<float>(Map::tilemap_origin_y);
|
||||||
|
const SDL_FRect NEXT{
|
||||||
|
.x = TILE_X + (SIGN_OF(dx_signed) * TS),
|
||||||
|
.y = TILE_Y + (SIGN_OF(dy_signed) * TS),
|
||||||
|
.w = TS,
|
||||||
|
.h = TS,
|
||||||
|
};
|
||||||
|
return entity_manager_->findFloorTriggerAt(NEXT) != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
void Mummy::applyChoice(Direction dir) {
|
void Mummy::applyChoice(Direction dir) {
|
||||||
switch (dir) {
|
switch (dir) {
|
||||||
case Direction::NORTH:
|
case Direction::NORTH:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <climits>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -85,12 +86,25 @@ class Mummy : public DynamicHazard {
|
|||||||
// Recull les direccions alternatives viables des de la posició actual:
|
// Recull les direccions alternatives viables des de la posició actual:
|
||||||
// `perpendiculars` (girs) i `uturn` (inversa). La direcció actual s'exclou
|
// `perpendiculars` (girs) i `uturn` (inversa). La direcció actual s'exclou
|
||||||
// perquè ja sabem que està bloquejada. Cada candidata es valida amb
|
// perquè ja sabem que està bloquejada. Cada candidata es valida amb
|
||||||
// `Map::insidePlayfield` + `Map::collides` sobre la caja resultant.
|
// `Map::insidePlayfield` + `Map::collides` sobre la caja resultant. Si
|
||||||
|
// `avoid_decals` és true i hi ha `EntityManager` injectat, també es
|
||||||
|
// descarten les direccions que apunten a un tile amb un decal trigger
|
||||||
|
// (Hole o futurs pinchos plans/charcos). El caller és responsable de
|
||||||
|
// re-cridar amb `avoid_decals=false` com a fallback si totes les
|
||||||
|
// direccions filtrades estan bloquejades.
|
||||||
struct DirectionPools {
|
struct DirectionPools {
|
||||||
std::vector<Direction> perp;
|
std::vector<Direction> perp;
|
||||||
std::vector<Direction> uturn;
|
std::vector<Direction> uturn;
|
||||||
};
|
};
|
||||||
[[nodiscard]] auto collectDirectionPools(float step) const -> DirectionPools;
|
[[nodiscard]] auto collectDirectionPools(float step, bool avoid_decals = true) const -> DirectionPools;
|
||||||
|
|
||||||
|
// True si el tile cardinal contigu en la direcció (signe de dx, signe
|
||||||
|
// de dy) conté un decal trigger (`is_floor_decal_ && triggers_on_overlap_`)
|
||||||
|
// segons `EntityManager::findFloorTriggerAt`. Captura `Hole` i qualsevol
|
||||||
|
// futur decal trigger sense hardcodejar tipus. Si `entity_manager_` no
|
||||||
|
// està injectat (smoke tests sense manager), retorna false silenciosament
|
||||||
|
// — la mòmia es comporta com abans del feature.
|
||||||
|
[[nodiscard]] auto isDecalAheadInDir(float dx_signed, float dy_signed) const -> bool;
|
||||||
|
|
||||||
// Canvi de direcció + selecció de l'animació corresponent (un cardinal
|
// Canvi de direcció + selecció de l'animació corresponent (un cardinal
|
||||||
// per branch). Tots els camins que trien nova direcció (ctor amb
|
// per branch). Tots els camins que trien nova direcció (ctor amb
|
||||||
@@ -130,6 +144,16 @@ class Mummy : public DynamicHazard {
|
|||||||
// totes ho estan, retorna `NONE` (la mòmia queda quieta en HURT).
|
// totes ho estan, retorna `NONE` (la mòmia queda quieta en HURT).
|
||||||
[[nodiscard]] auto chooseHurtDirection(Direction preferred) const -> Direction;
|
[[nodiscard]] auto chooseHurtDirection(Direction preferred) const -> Direction;
|
||||||
|
|
||||||
|
// Detecció reactiva al canvi de tile (substitueix el lookahead sub-tile
|
||||||
|
// entre creuaments). Es crida al principi de cada `step()`. Si el tile
|
||||||
|
// que ocupa la mòmia (calculat des del centre de la BOX) ha canviat
|
||||||
|
// respecte a l'última crida, comprova si el tile nou conté un decal
|
||||||
|
// trigger; si el conté, dispara mitja volta immediata. La primera crida
|
||||||
|
// (last_tile_col_ == INT_MIN) només registra el tile actual sense
|
||||||
|
// reaccionar — evita un falç positiu si la mòmia spawneja sobre un
|
||||||
|
// decal o si el setup encara no és estable.
|
||||||
|
void reactToCurrentTile();
|
||||||
|
|
||||||
// Animacions carregades del YAML, una per cada cardinal. La variant
|
// Animacions carregades del YAML, una per cada cardinal. La variant
|
||||||
// `walk_west` reaprofita els frames de l'est però amb `flip_h: true`
|
// `walk_west` reaprofita els frames de l'est però amb `flip_h: true`
|
||||||
// declarat al YAML — el motor d'animació aplica el flip al sprite
|
// declarat al YAML — el motor d'animació aplica el flip al sprite
|
||||||
@@ -153,4 +177,12 @@ class Mummy : public DynamicHazard {
|
|||||||
// bloc `stats.kills_outright` del YAML; default true per coherència
|
// bloc `stats.kills_outright` del YAML; default true per coherència
|
||||||
// amb el comportament d'Oh Mummy! (les mòmies maten).
|
// amb el comportament d'Oh Mummy! (les mòmies maten).
|
||||||
bool kills_outright_{true};
|
bool kills_outright_{true};
|
||||||
|
|
||||||
|
// Tile que ocupava la mòmia a l'última crida a `reactToCurrentTile`.
|
||||||
|
// Inicialitzats a `INT_MIN` perquè la primera crida (lazy init: no
|
||||||
|
// podem fer-ho al constructor perquè `pixel_rect_` encara no té
|
||||||
|
// posició; setPixelRect del manager arriba després) registra el tile
|
||||||
|
// de spawn sense reaccionar.
|
||||||
|
int last_tile_col_{INT_MIN};
|
||||||
|
int last_tile_row_{INT_MIN};
|
||||||
};
|
};
|
||||||
@@ -10,6 +10,9 @@ WallTrap::WallTrap(const fkyaml::node& yaml) {
|
|||||||
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("wall_trap");
|
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("wall_trap");
|
||||||
collides_ = false;
|
collides_ = false;
|
||||||
interactable_ = false;
|
interactable_ = false;
|
||||||
|
// Trigger d'àrea: igual que FloorTrap. Tanca el gap latent del filtre
|
||||||
|
// de `checkPlayerCollisions` que ho deixava mut.
|
||||||
|
triggers_on_overlap_ = true;
|
||||||
|
|
||||||
if (yaml.contains("cycle")) {
|
if (yaml.contains("cycle")) {
|
||||||
const auto& c = yaml["cycle"];
|
const auto& c = yaml["cycle"];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "game/entities/entity.hpp"
|
#include "game/entities/entity.hpp"
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
Entity::Entity(std::string name, TilePos tile, SDL_FRect pixel_rect)
|
Entity::Entity(std::string name, TilePos tile, SDL_FRect pixel_rect)
|
||||||
@@ -38,6 +39,12 @@ auto Entity::getCollisionRect() const -> SDL_FRect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto Entity::getFeetY() const -> float {
|
auto Entity::getFeetY() const -> float {
|
||||||
|
// Decal de sòl: sentinel mínim per quedar al principi del Y-sort
|
||||||
|
// ascendent de `GameScene` (stable_sort) i així pintar-se ABANS de
|
||||||
|
// qualsevol actor. Sense passades de render extra.
|
||||||
|
if (is_floor_decal_) {
|
||||||
|
return std::numeric_limits<float>::lowest();
|
||||||
|
}
|
||||||
const auto RECT = getCollisionRect();
|
const auto RECT = getCollisionRect();
|
||||||
return RECT.y + RECT.h;
|
return RECT.y + RECT.h;
|
||||||
}
|
}
|
||||||
@@ -12,9 +12,13 @@
|
|||||||
// `source/game/entities/player.hpp` i només es referencia per referència
|
// `source/game/entities/player.hpp` i només es referencia per referència
|
||||||
// dins `onPlayerContact`; `EventBus` és opcional a cada entitat i viu al
|
// dins `onPlayerContact`; `EventBus` és opcional a cada entitat i viu al
|
||||||
// seu header propi. `Surface` és el backend de render paletitzat del motor.
|
// seu header propi. `Surface` és el backend de render paletitzat del motor.
|
||||||
|
// `EntityManager` només es referencia per punter (injectat opcionalment
|
||||||
|
// per a entitats que necessiten consultar el món d'entitats — p.ex. la
|
||||||
|
// mòmia per saber si el pròxim tile té un decal trigger tipus Hole).
|
||||||
class Player;
|
class Player;
|
||||||
class EventBus;
|
class EventBus;
|
||||||
class Surface;
|
class Surface;
|
||||||
|
class EntityManager;
|
||||||
|
|
||||||
// Entitat genèrica del món. Actua com a dada en comú per a tots els actors
|
// Entitat genèrica del món. Actua com a dada en comú per a tots els actors
|
||||||
// del joc (decoratius, interactables, perills mòbils i estàtics): manté
|
// del joc (decoratius, interactables, perills mòbils i estàtics): manté
|
||||||
@@ -88,6 +92,27 @@ class Entity {
|
|||||||
[[nodiscard]] auto collides() const -> bool { return collides_; }
|
[[nodiscard]] auto collides() const -> bool { return collides_; }
|
||||||
[[nodiscard]] auto interactable() const -> bool { return interactable_; }
|
[[nodiscard]] auto interactable() const -> bool { return interactable_; }
|
||||||
|
|
||||||
|
// Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`)
|
||||||
|
// però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat
|
||||||
|
// per a hazards "no físics" (forats al sòl, picos retràctils, plaques
|
||||||
|
// de pressió). El bucle de contacte (`checkPlayerCollisions`) admet
|
||||||
|
// l'entitat si compleix `collides_ || triggers_on_overlap_`. El filtre
|
||||||
|
// d'altura per salt (`height_`) i la caixa de col·lisió funcionen igual.
|
||||||
|
[[nodiscard]] auto triggersOnOverlap() const -> bool { return triggers_on_overlap_; }
|
||||||
|
void setTriggersOnOverlap(bool v) { triggers_on_overlap_ = v; }
|
||||||
|
|
||||||
|
// Decal de sòl: hazards estàtics sense presència vertical (forats,
|
||||||
|
// plaques de pressió, charcos). Es pinten ABANS de qualsevol actor
|
||||||
|
// dins del Y-sort de `GameScene` perquè conceptualment són "pintura
|
||||||
|
// sobre el tile". Implementació: `getFeetY()` retorna un sentinel
|
||||||
|
// mínim (`std::numeric_limits<float>::lowest()`) per quedar al
|
||||||
|
// principi del `stable_sort` ascendent — així no calen passades de
|
||||||
|
// render extra. Les subclasses (p.ex. `Hole`) poden sobreescriure
|
||||||
|
// `getFeetY` per saltar-se el sentinel en moments concrets (p.ex.
|
||||||
|
// un forat que està tragant un jugador i ha de pintar-se al damunt).
|
||||||
|
[[nodiscard]] auto isFloorDecal() const -> bool { return is_floor_decal_; }
|
||||||
|
void setFloorDecal(bool v) { is_floor_decal_ = v; }
|
||||||
|
|
||||||
// Marca per a entitats transitòries: efectes (polvet, espurnes,
|
// Marca per a entitats transitòries: efectes (polvet, espurnes,
|
||||||
// explosions...) que viuen una sola animació i s'autoeliminen quan
|
// explosions...) que viuen una sola animació i s'autoeliminen quan
|
||||||
// acaba. L'`EntityManager` purga totes les entitats amb `dead_=true`
|
// acaba. L'`EntityManager` purga totes les entitats amb `dead_=true`
|
||||||
@@ -101,6 +126,14 @@ class Entity {
|
|||||||
// estàtiques de moment) el poden ignorar.
|
// estàtiques de moment) el poden ignorar.
|
||||||
void setEventBus(EventBus* bus) { bus_ = bus; }
|
void setEventBus(EventBus* bus) { bus_ = bus; }
|
||||||
|
|
||||||
|
// Injecció opcional del manager d'entitats (mateix patró que
|
||||||
|
// `setEventBus`: el manager s'auto-injecta a `add`/`setDoor`/
|
||||||
|
// `addSarcophagus`). El consumeixen entitats que volen consultar el
|
||||||
|
// món d'entitats — avui la mòmia, per saber si el tile següent
|
||||||
|
// conté un decal trigger (Hole) i evitar caure-hi. Entitats que no
|
||||||
|
// l'usen el poden ignorar.
|
||||||
|
void setEntityManager(EntityManager* manager) { entity_manager_ = manager; }
|
||||||
|
|
||||||
// Assigna la caixa de render en píxels. La invoca `EntityManager` durant
|
// Assigna la caixa de render en píxels. La invoca `EntityManager` durant
|
||||||
// `loadFromLevel` un cop sap la posició definitiva de l'entitat al mapa.
|
// `loadFromLevel` un cop sap la posició definitiva de l'entitat al mapa.
|
||||||
void setPixelRect(SDL_FRect rect) { pixel_rect_ = rect; }
|
void setPixelRect(SDL_FRect rect) { pixel_rect_ = rect; }
|
||||||
@@ -172,8 +205,11 @@ class Entity {
|
|||||||
std::shared_ptr<Surface> surface_{nullptr};
|
std::shared_ptr<Surface> surface_{nullptr};
|
||||||
bool collides_{false};
|
bool collides_{false};
|
||||||
bool interactable_{false};
|
bool interactable_{false};
|
||||||
|
bool triggers_on_overlap_{false};
|
||||||
|
bool is_floor_decal_{false};
|
||||||
int height_{0};
|
int height_{0};
|
||||||
bool dead_{false};
|
bool dead_{false};
|
||||||
SDL_FlipMode flip_{SDL_FLIP_NONE};
|
SDL_FlipMode flip_{SDL_FLIP_NONE};
|
||||||
EventBus* bus_{nullptr};
|
EventBus* bus_{nullptr};
|
||||||
|
EntityManager* entity_manager_{nullptr};
|
||||||
};
|
};
|
||||||
@@ -72,6 +72,7 @@ void EntityManager::forceOpenDoor() {
|
|||||||
auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
|
auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
|
||||||
if (entity == nullptr) { return nullptr; }
|
if (entity == nullptr) { return nullptr; }
|
||||||
entity->setEventBus(&bus_);
|
entity->setEventBus(&bus_);
|
||||||
|
entity->setEntityManager(this);
|
||||||
Entity* raw = entity.get();
|
Entity* raw = entity.get();
|
||||||
entities_.push_back(std::move(entity));
|
entities_.push_back(std::move(entity));
|
||||||
// cppcheck-suppress returnDanglingLifetime
|
// cppcheck-suppress returnDanglingLifetime
|
||||||
@@ -80,12 +81,16 @@ auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
|
|||||||
|
|
||||||
void EntityManager::setDoor(Door* door) {
|
void EntityManager::setDoor(Door* door) {
|
||||||
door_ = door;
|
door_ = door;
|
||||||
if (door_ != nullptr) { door_->setEventBus(&bus_); }
|
if (door_ != nullptr) {
|
||||||
|
door_->setEventBus(&bus_);
|
||||||
|
door_->setEntityManager(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcophagus* {
|
auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcophagus* {
|
||||||
if (sarc == nullptr) { return nullptr; }
|
if (sarc == nullptr) { return nullptr; }
|
||||||
sarc->setEventBus(&bus_);
|
sarc->setEventBus(&bus_);
|
||||||
|
sarc->setEntityManager(this);
|
||||||
Sarcophagus* raw = sarc.get();
|
Sarcophagus* raw = sarc.get();
|
||||||
entities_.push_back(std::move(sarc));
|
entities_.push_back(std::move(sarc));
|
||||||
++total_sarcs_;
|
++total_sarcs_;
|
||||||
@@ -110,6 +115,24 @@ void EntityManager::notifyPlayerTilePainted(int col, int row) {
|
|||||||
for (auto& e : entities_) { e->notifyTilePainted(col, row); }
|
for (auto& e : entities_) { e->notifyTilePainted(col, row); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* {
|
||||||
|
// Cerca lineal sobre `entities_`: cost menyspreable amb poques
|
||||||
|
// desenes d'entitats per sala i una crida per frame des dels
|
||||||
|
// consumidors actius (Player a l'aterratge, Mòmia a la decisió de
|
||||||
|
// direcció). Si esdevé calent, es podria indexar per cel·la del
|
||||||
|
// tilemap. La doble condició filtra exclusivament decals de sòl que
|
||||||
|
// volen rebre triggers — avui Hole; en el futur pinchos plans,
|
||||||
|
// plaques de pressió, charcos.
|
||||||
|
for (const auto& entity : entities_) {
|
||||||
|
if (!entity->triggersOnOverlap()) { continue; }
|
||||||
|
if (!entity->isFloorDecal()) { continue; }
|
||||||
|
const auto BOX = entity->getCollisionRect();
|
||||||
|
const bool OVERLAP = rect.x + rect.w > BOX.x && BOX.x + BOX.w > rect.x && rect.y + rect.h > BOX.y && BOX.y + BOX.h > rect.y;
|
||||||
|
if (OVERLAP) { return entity.get(); }
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
auto EntityManager::getEntities() const -> std::vector<Entity*> {
|
auto EntityManager::getEntities() const -> std::vector<Entity*> {
|
||||||
std::vector<Entity*> out;
|
std::vector<Entity*> out;
|
||||||
out.reserve(entities_.size());
|
out.reserve(entities_.size());
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -74,6 +76,16 @@ class EntityManager {
|
|||||||
// reflecteixi l'estat del frame actual.
|
// reflecteixi l'estat del frame actual.
|
||||||
void checkPlayerCollisions(const std::vector<Player*>& players);
|
void checkPlayerCollisions(const std::vector<Player*>& players);
|
||||||
|
|
||||||
|
// Cerca la primera entitat que (a) sigui decal de sòl
|
||||||
|
// (`is_floor_decal_=true`), (b) admeti triggers d'àrea
|
||||||
|
// (`triggers_on_overlap_=true`), i (c) la seva caixa de col·lisió
|
||||||
|
// solape amb el rect donat. Pensat per a `Player::integrateJump`,
|
||||||
|
// que la consulta a l'aterratge per detectar caigudes sobre Hole
|
||||||
|
// (i en el futur pinchos plans, plaques de pressió, charcos...) i
|
||||||
|
// saltar-se el LANDING_STUN normal. Retorna `nullptr` si no hi ha
|
||||||
|
// cap match — el caller usa el camí d'aterratge habitual.
|
||||||
|
[[nodiscard]] auto findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity*;
|
||||||
|
|
||||||
// Fan-out del hook de pintat: per a cada sarcòfag, cridar
|
// Fan-out del hook de pintat: per a cada sarcòfag, cridar
|
||||||
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
|
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
|
||||||
// (veure Entity::notifyTilePainted). Invocat com a reacció a
|
// (veure Entity::notifyTilePainted). Invocat com a reacció a
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ void EntityManager::renderAll() {
|
|||||||
void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
|
void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
|
||||||
if (players.empty()) { return; }
|
if (players.empty()) { return; }
|
||||||
for (const auto& entity : entities_) {
|
for (const auto& entity : entities_) {
|
||||||
if (!entity->collides()) { continue; }
|
// Acceptem entitats sòlides (xoc físic) i triggers d'àrea (no
|
||||||
|
// bloquegen però volen rebre `onPlayerContact`). Tanquem el gap
|
||||||
|
// latent de FloorTrap/WallTrap (que tenien `collides_=false` i
|
||||||
|
// mai no es disparaven) i obrim la porta a hazards no-físics
|
||||||
|
// com `Hole`.
|
||||||
|
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
|
||||||
const auto BOX = entity->getCollisionRect();
|
const auto BOX = entity->getCollisionRect();
|
||||||
const int E_HEIGHT = entity->getHeight();
|
const int E_HEIGHT = entity->getHeight();
|
||||||
for (auto* p : players) {
|
for (auto* p : players) {
|
||||||
@@ -69,6 +74,19 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
|
|||||||
// contacte amb hazards (un segon toc de mòmia mentre la roda
|
// contacte amb hazards (un segon toc de mòmia mentre la roda
|
||||||
// ja és no-op; cap altra entitat l'ha de fer transicionar).
|
// ja és no-op; cap altra entitat l'ha de fer transicionar).
|
||||||
if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; }
|
if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; }
|
||||||
|
// Mentre el jugador cau per un forat, ignorem qualsevol altre
|
||||||
|
// hazard: la transició a `enterDying()` ja la dispara
|
||||||
|
// `integrateJump` quan supera `HOLE_FALL_DEPTH`.
|
||||||
|
//
|
||||||
|
// NOTA: aquest skip és la defensa que evita que un jugador
|
||||||
|
// caient per un forat reba contacte d'altres hazards
|
||||||
|
// "subterranis" durant la caiguda. La cadena de mort per
|
||||||
|
// forat (`enterDying(HOLE)` → respawn) depén de no ser
|
||||||
|
// interrompuda per un event `player_hit`/`player_killed`
|
||||||
|
// mentre `falling_through_hole_=true`. Si el dia que es
|
||||||
|
// toque aquest skip s'elimina, cal revisar la cadena
|
||||||
|
// mort/respawn per forat.
|
||||||
|
if (p->isFallingThroughHole()) { continue; }
|
||||||
// Filtre per altura: si el jugador està en l'aire i està per
|
// Filtre per altura: si el jugador està en l'aire i està per
|
||||||
// damunt de l'alçada del cos de l'entitat, no hi ha contacte.
|
// damunt de l'alçada del cos de l'entitat, no hi ha contacte.
|
||||||
// És el mecanisme del salt per esquivar momies, roques, etc.
|
// És el mecanisme del salt per esquivar momies, roques, etc.
|
||||||
@@ -402,6 +420,32 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
|
|||||||
if (OK) { ++loaded; }
|
if (OK) { ++loaded; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast inicial: marca com a "pintats" tots els tiles ocupats per
|
||||||
|
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...).
|
||||||
|
// Imprescindible perquè el jugador no pot trepitjar aquests tiles
|
||||||
|
// (cauria al forat) i els sarcòfags vecins no podrien completar el
|
||||||
|
// perímetre — la sala seria incompletable. Usa el canal semàntic
|
||||||
|
// `tile_painted` (lògica de joc), no `on_tile_entered` (presència
|
||||||
|
// física del jugador). Ho fem aquí, després del bucle de spawns,
|
||||||
|
// perquè tots els sarcòfags ja estan registrats al fan-out (la
|
||||||
|
// subscripció del manager és al ctor, anterior a qualsevol load).
|
||||||
|
int decal_broadcast = 0;
|
||||||
|
for (const auto& entity : entities_) {
|
||||||
|
if (!entity->isFloorDecal()) { continue; }
|
||||||
|
const auto BOX = entity->getCollisionRect();
|
||||||
|
const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y);
|
||||||
|
bus_.publish(Event{
|
||||||
|
.name = "tile_painted",
|
||||||
|
.payload = TilePos{.col = COL, .row = ROW},
|
||||||
|
.player_id = -1,
|
||||||
|
});
|
||||||
|
++decal_broadcast;
|
||||||
|
}
|
||||||
|
if (decal_broadcast > 0) {
|
||||||
|
std::cout << "EntityManager: " << decal_broadcast
|
||||||
|
<< " tile_painted broadcast inicial (decals de sòl)\n";
|
||||||
|
}
|
||||||
|
|
||||||
std::cout << "EntityManager: " << loaded << " entitats carregades des de "
|
std::cout << "EntityManager: " << loaded << " entitats carregades des de "
|
||||||
<< level_yaml_path << '\n';
|
<< level_yaml_path << '\n';
|
||||||
}
|
}
|
||||||
+147
-15
@@ -12,10 +12,13 @@
|
|||||||
#include "core/input/gamepad_bindings.hpp"
|
#include "core/input/gamepad_bindings.hpp"
|
||||||
#include "core/input/gamepad_manager.hpp"
|
#include "core/input/gamepad_manager.hpp"
|
||||||
#include "core/input/input_state.hpp"
|
#include "core/input/input_state.hpp"
|
||||||
|
#include "core/rendering/canvas.hpp"
|
||||||
#include "core/rendering/screen.hpp"
|
#include "core/rendering/screen.hpp"
|
||||||
#include "core/resources/resource_cache.hpp"
|
#include "core/resources/resource_cache.hpp"
|
||||||
#include "core/resources/resource_helper.hpp"
|
#include "core/resources/resource_helper.hpp"
|
||||||
#include "external/fkyaml_node.hpp"
|
#include "external/fkyaml_node.hpp"
|
||||||
|
#include "game/entities/entity.hpp"
|
||||||
|
#include "game/entities/entity_manager.hpp"
|
||||||
#include "game/entities/event_bus.hpp"
|
#include "game/entities/event_bus.hpp"
|
||||||
#include "game/options.hpp"
|
#include "game/options.hpp"
|
||||||
#include "utils/defaults.hpp"
|
#include "utils/defaults.hpp"
|
||||||
@@ -910,7 +913,40 @@ auto Player::integrateJump(float delta_time) -> bool {
|
|||||||
if (state_ != State::AIRBORNE) { return false; }
|
if (state_ != State::AIRBORNE) { return false; }
|
||||||
jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time;
|
jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time;
|
||||||
jump_z_ += jump_vz_ * delta_time;
|
jump_z_ += jump_vz_ * delta_time;
|
||||||
|
// Caiguda per agujero: el sòl ja no és present sota el jugador, així
|
||||||
|
// que NO clampem `jump_z_` a 0. Deixem baixar a negatius (visualment
|
||||||
|
// el sprite "s'enfonsa" sota el sòl) i, en superar `HOLE_FALL_DEPTH`,
|
||||||
|
// disparem la mort. La cinemàtica de respawn (`enterDying →
|
||||||
|
// spawnFromAir`) reseteja el flag i la posició al spawn point.
|
||||||
|
if (falling_through_hole_) {
|
||||||
|
if (jump_z_ <= -HOLE_FALL_DEPTH) {
|
||||||
|
enterDying(DeathCause::HOLE);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (jump_z_ <= 0.0F) {
|
if (jump_z_ <= 0.0F) {
|
||||||
|
// Aterratge sobre decal-trigger (Hole, futurs pinchos plans...):
|
||||||
|
// saltem el LANDING_STUN i transicionem directament a la caiguda
|
||||||
|
// pel forat. Sense aquesta detecció, l'aterratge entraria a
|
||||||
|
// LANDING_STUN (~200 ms flotant sobre el forat) i només quan
|
||||||
|
// tornés a GROUNDED `Hole::onPlayerContact` ho dispararia. La
|
||||||
|
// hit-box (8×8 centrada al sprite) és la mateixa que `Hole`
|
||||||
|
// usa al seu re-check; coherent. Si el manager no s'ha
|
||||||
|
// injectat (tests stand-alone), el camí d'aterratge segueix sent
|
||||||
|
// el habitual.
|
||||||
|
if (entity_manager_ != nullptr) {
|
||||||
|
const Entity* trigger = entity_manager_->findFloorTriggerAt(getHitBox());
|
||||||
|
if (trigger != nullptr) {
|
||||||
|
const auto BOX = trigger->getCollisionRect();
|
||||||
|
const float CENTER_X = BOX.x + (BOX.w * 0.5F);
|
||||||
|
const float CENTER_Y = BOX.y + (BOX.h * 0.5F);
|
||||||
|
const float FLOOR_Y = BOX.y + BOX.h;
|
||||||
|
enterHoleFall(CENTER_X, CENTER_Y, FLOOR_Y);
|
||||||
|
// No retornem `true`: no és un aterratge "normal" i
|
||||||
|
// `applyMovement` no ha de cridar `resolveLandingOverlap`.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
jump_z_ = 0.0F;
|
jump_z_ = 0.0F;
|
||||||
jump_vz_ = 0.0F;
|
jump_vz_ = 0.0F;
|
||||||
state_ = State::LANDING_STUN;
|
state_ = State::LANDING_STUN;
|
||||||
@@ -1075,7 +1111,7 @@ void Player::applyMovement(float delta_time) {
|
|||||||
if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); }
|
if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
void Player::enterDying() {
|
void Player::enterDying(DeathCause cause) {
|
||||||
// Idempotent. Un segon contacte de momia mentre el jugador ja està
|
// Idempotent. Un segon contacte de momia mentre el jugador ja està
|
||||||
// en DYING no reinicia l'animació ni el comptador. Tampoc disparem
|
// en DYING no reinicia l'animació ni el comptador. Tampoc disparem
|
||||||
// el cicle si el jugador està fora de joc (EXITED al passar la
|
// el cicle si el jugador està fora de joc (EXITED al passar la
|
||||||
@@ -1091,6 +1127,8 @@ void Player::enterDying() {
|
|||||||
being_pushed_ = false;
|
being_pushed_ = false;
|
||||||
jump_z_ = 0.0F;
|
jump_z_ = 0.0F;
|
||||||
jump_vz_ = 0.0F;
|
jump_vz_ = 0.0F;
|
||||||
|
falling_through_hole_ = false;
|
||||||
|
hole_clip_bottom_y_ = 0.0F;
|
||||||
landing_stun_remaining_ms_ = 0.0F;
|
landing_stun_remaining_ms_ = 0.0F;
|
||||||
attacking_remaining_ms_ = 0.0F;
|
attacking_remaining_ms_ = 0.0F;
|
||||||
turning_remaining_ms_ = 0.0F;
|
turning_remaining_ms_ = 0.0F;
|
||||||
@@ -1102,7 +1140,18 @@ void Player::enterDying() {
|
|||||||
|
|
||||||
death_animation_complete_ = false;
|
death_animation_complete_ = false;
|
||||||
respawn_remaining_ms_ = 0.0F;
|
respawn_remaining_ms_ = 0.0F;
|
||||||
setCurrentAnimation("death_spinning");
|
// Presentació segons la causa. Cada `DeathCause` té la seua animació
|
||||||
|
// dedicada al YAML del player; `applyDyingMovement` arma el respawn
|
||||||
|
// timer quan `animationIsCompleted()` flanqueja, igual per a totes.
|
||||||
|
switch (cause) {
|
||||||
|
case DeathCause::HOLE:
|
||||||
|
setCurrentAnimation("death_hole");
|
||||||
|
break;
|
||||||
|
case DeathCause::GENERIC:
|
||||||
|
default:
|
||||||
|
setCurrentAnimation("death_spinning");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// So del cop letal: reaprofitem `hurt.wav` (ja precarregat per
|
// So del cop letal: reaprofitem `hurt.wav` (ja precarregat per
|
||||||
// `readHurtBlock`). El bloc `death:` no té camp de so propi —
|
// `readHurtBlock`). El bloc `death:` no té camp de so propi —
|
||||||
@@ -1204,19 +1253,46 @@ void Player::render() {
|
|||||||
if (state_ == State::DYING && death_animation_complete_) { return; }
|
if (state_ == State::DYING && death_animation_complete_) { return; }
|
||||||
|
|
||||||
// Mentre el jugador està enlaire, el sprite es desplaça visualment
|
// Mentre el jugador està enlaire, el sprite es desplaça visualment
|
||||||
// cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera
|
// `jump_z_` píxels: cap amunt si és positiu (salt), cap avall si és
|
||||||
|
// negatiu (caiguda per `Hole`, que deixa baixar `jump_z_` a negatius
|
||||||
|
// fins a `-HOLE_FALL_DEPTH`). Modifiquem `pos_.y` (la posició entera
|
||||||
// de render) abans de cridar el render base, i la restaurem després
|
// de render) abans de cridar el render base, i la restaurem després
|
||||||
// perquè l'estat seguent del frame ho recalcule des de `y_` (la font
|
// perquè l'estat seguent del frame ho recalcule des de `y_` (la font
|
||||||
// de veritat float). El sòl sota el jugador no es pinta des d'ací:
|
// de veritat float). El sòl sota el jugador no es pinta des d'ací:
|
||||||
// això ho fa el GameScene en una passada de sombres a banda.
|
// això ho fa el GameScene en una passada de sombres a banda. El cas
|
||||||
if (jump_z_ <= 0.0F) {
|
// `jump_z_ == 0` (a terra) surt per l'early-return per evitar tocar
|
||||||
AnimatedSprite::render();
|
// `pos_.y` innecessàriament.
|
||||||
return;
|
// Caiguda per agujero: el sprite es "menja" pel sòl. Fixem un clip
|
||||||
|
// global a la franja [0, hole_clip_bottom_y_] del canvas (el blitter
|
||||||
|
// de `Surface` respecta `Rendering::activeClip()`); a mesura que
|
||||||
|
// `pos_.y - jump_z_` baixa, els píxels del sprite que creuen la
|
||||||
|
// línia deixen de pintar-se. Fem el push/pop al voltant de tot el
|
||||||
|
// render del sprite per cobrir tant el cas `jump_z_==0` (caiguda
|
||||||
|
// just iniciada) com els frames posteriors. `pushClip` ja clamp
|
||||||
|
// a la mida del canvas, així que un `h` negatiu es traduiria en
|
||||||
|
// una franja buida i el sprite quedaria invisible — comportament
|
||||||
|
// correcte si el clip ja ha "tapat" tota la pantalla.
|
||||||
|
const bool CLIP_HOLE = falling_through_hole_;
|
||||||
|
if (CLIP_HOLE) {
|
||||||
|
const auto CANVAS = Rendering::activeCanvas();
|
||||||
|
Rendering::pushClip({
|
||||||
|
.x = 0,
|
||||||
|
.y = 0,
|
||||||
|
.w = CANVAS.width,
|
||||||
|
.h = static_cast<int>(hole_clip_bottom_y_),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const float SAVED_Y = pos_.y;
|
|
||||||
pos_.y = SAVED_Y - jump_z_;
|
if (jump_z_ == 0.0F) {
|
||||||
AnimatedSprite::render();
|
AnimatedSprite::render();
|
||||||
pos_.y = SAVED_Y;
|
} else {
|
||||||
|
const float SAVED_Y = pos_.y;
|
||||||
|
pos_.y = SAVED_Y - jump_z_;
|
||||||
|
AnimatedSprite::render();
|
||||||
|
pos_.y = SAVED_Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CLIP_HOLE) { Rendering::popClip(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
void Player::renderShadow() const {
|
void Player::renderShadow() const {
|
||||||
@@ -1224,6 +1300,10 @@ void Player::renderShadow() const {
|
|||||||
// que poden cridar `renderShadow` per a tots els jugadors sense
|
// que poden cridar `renderShadow` per a tots els jugadors sense
|
||||||
// discriminar.
|
// discriminar.
|
||||||
if (!isAirborne()) { return; }
|
if (!isAirborne()) { return; }
|
||||||
|
// Caient per un agujero: el cos s'enfonsa sota el sòl, dibuixar una
|
||||||
|
// sombra al ras del sòl trenca la il·lusió. Suprimim la sombra fins
|
||||||
|
// que `enterDying` netegi el flag.
|
||||||
|
if (falling_through_hole_) { return; }
|
||||||
|
|
||||||
// Sombra centrada al sprite, recolzada a la línia dels peus reals
|
// Sombra centrada al sprite, recolzada a la línia dels peus reals
|
||||||
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura
|
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura
|
||||||
@@ -1289,10 +1369,9 @@ auto Player::statePrefix(State state, bool moving, bool in_attack_swing) -> cons
|
|||||||
case State::ATTACKING:
|
case State::ATTACKING:
|
||||||
return in_attack_swing ? "attack" : "recover";
|
return in_attack_swing ? "attack" : "recover";
|
||||||
case State::DYING:
|
case State::DYING:
|
||||||
// L'animació concreta (`death_spinning`) la fixa `enterDying`;
|
// L'animació concreta (`death_spinning` o `death_hole`) la
|
||||||
// `applyDyingMovement` no torna a cridar `setCurrentAnimation`,
|
// selecciona `enterDying` segons `DeathCause`; aquest retorn
|
||||||
// així que aquest valor no s'usa mai. El retornem només per
|
// només cobreix exhaustivitat del switch i no s'usa al runtime.
|
||||||
// exhaustivitat del switch.
|
|
||||||
return "death";
|
return "death";
|
||||||
case State::GROUNDED:
|
case State::GROUNDED:
|
||||||
case State::EXITED:
|
case State::EXITED:
|
||||||
@@ -1359,6 +1438,8 @@ void Player::respawn(float x, float y) {
|
|||||||
being_pushed_ = false;
|
being_pushed_ = false;
|
||||||
jump_z_ = 0.0F;
|
jump_z_ = 0.0F;
|
||||||
jump_vz_ = 0.0F;
|
jump_vz_ = 0.0F;
|
||||||
|
falling_through_hole_ = false;
|
||||||
|
hole_clip_bottom_y_ = 0.0F;
|
||||||
landing_stun_remaining_ms_ = 0.0F;
|
landing_stun_remaining_ms_ = 0.0F;
|
||||||
attacking_remaining_ms_ = 0.0F;
|
attacking_remaining_ms_ = 0.0F;
|
||||||
turning_remaining_ms_ = 0.0F;
|
turning_remaining_ms_ = 0.0F;
|
||||||
@@ -1396,6 +1477,57 @@ void Player::disable() {
|
|||||||
last_input_cardinal_ = Cardinal::NONE;
|
last_input_cardinal_ = Cardinal::NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Player::enterHoleFall(float center_x, float center_y, float floor_y) {
|
||||||
|
// Cap guard de state ací: la responsabilitat la té el caller perquè
|
||||||
|
// hi ha dos callers vàlids amb states distints:
|
||||||
|
// - `Hole::onPlayerContact` (state GROUNDED, el jugador camina i
|
||||||
|
// trepitja el tile del forat).
|
||||||
|
// - `Player::integrateJump` (state AIRBORNE, en aterrar damunt
|
||||||
|
// d'un decal-trigger en lloc d'entrar a LANDING_STUN).
|
||||||
|
// Posar `if (state_ != GROUNDED) return` ací bloquejaria el segon
|
||||||
|
// cas i obligaria a hacks tipus "forçar GROUNDED abans de cridar".
|
||||||
|
|
||||||
|
// Snap del centre de la hit-box a la coordenada central del forat.
|
||||||
|
// Resolem `pos = center - (hit_offset + hit_size/2)` per a cada eix:
|
||||||
|
// així el centre exacte de la caja de contacte queda alineat amb
|
||||||
|
// (center_x, center_y), independentment de la geometria interna del
|
||||||
|
// sprite. `setPosX/Y` és l'API canònica per teleportar el jugador
|
||||||
|
// (la usen `respawn` i `spawnFromAir`).
|
||||||
|
const float HIT_HALF = static_cast<float>(hit_size) * 0.5F;
|
||||||
|
setPosX(center_x - static_cast<float>(hit_x_offset) - HIT_HALF);
|
||||||
|
setPosY(center_y - static_cast<float>(hit_y_offset) - HIT_HALF);
|
||||||
|
|
||||||
|
// Frenem qualsevol inèrcia prèvia: la caiguda ha de ser estàtica al
|
||||||
|
// centre del forat.
|
||||||
|
intended_vx_ = 0.0F;
|
||||||
|
intended_vy_ = 0.0F;
|
||||||
|
input_vx_ = 0.0F;
|
||||||
|
input_vy_ = 0.0F;
|
||||||
|
motion_time_ms_ = 0.0F;
|
||||||
|
being_pushed_ = false;
|
||||||
|
setVelX(0.0F);
|
||||||
|
setVelY(0.0F);
|
||||||
|
|
||||||
|
jump_z_ = 0.0F;
|
||||||
|
jump_vz_ = 0.0F;
|
||||||
|
state_ = State::AIRBORNE;
|
||||||
|
falling_through_hole_ = true;
|
||||||
|
// Línia visual on el clip rect ha de retallar el sprite. Restem
|
||||||
|
// `SPRITE_FLOOR_OFFSET` perquè la base del sprite té píxels
|
||||||
|
// transparents — el retall ha de coincidir amb la silueta visible,
|
||||||
|
// no amb la vora del rect del sprite.
|
||||||
|
hole_clip_bottom_y_ = floor_y - SPRITE_FLOOR_OFFSET;
|
||||||
|
|
||||||
|
// Animació de caiguda: respectem el `facing_` que ja portava el jugador
|
||||||
|
// (no té sentit reorientar-lo cap al sud només perquè caigui en un
|
||||||
|
// forat). Com `state_` ja és AIRBORNE, `animationName(false)` resol el
|
||||||
|
// prefix `jump_` i hi afegeix el sufix cardinal correcte —
|
||||||
|
// `jump_north`, `jump_south`, `jump_east` o `jump_west` segons el
|
||||||
|
// facing actual. Si demà cal una animació de "cara de pànic", només
|
||||||
|
// cal canviar aquí pel `setCurrentAnimation` corresponent.
|
||||||
|
setCurrentAnimation(animationName(false));
|
||||||
|
}
|
||||||
|
|
||||||
void Player::spawnFromAir(float x, float y) {
|
void Player::spawnFromAir(float x, float y) {
|
||||||
// Net total: equival a un `respawn` seguit d'una entrada en l'aire.
|
// Net total: equival a un `respawn` seguit d'una entrada en l'aire.
|
||||||
// Així el flag de gir, l'animació, els flancs del salt i les
|
// Així el flag de gir, l'animació, els flancs del salt i les
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
#include "game/map/map.hpp"
|
#include "game/map/map.hpp"
|
||||||
|
|
||||||
class EventBus;
|
class EventBus;
|
||||||
|
class EntityManager;
|
||||||
|
|
||||||
class Player : public AnimatedSprite {
|
class Player : public AnimatedSprite {
|
||||||
public:
|
public:
|
||||||
@@ -40,6 +41,16 @@ class Player : public AnimatedSprite {
|
|||||||
// que decau per inèrcia fins a parar (~2 tiles).
|
// que decau per inèrcia fins a parar (~2 tiles).
|
||||||
// No col·lisiona amb cap altre jugador. Quan
|
// No col·lisiona amb cap altre jugador. Quan
|
||||||
// s'atura, torna a GROUNDED.
|
// s'atura, torna a GROUNDED.
|
||||||
|
// Causa de la mort actual. La passa el caller a `enterDying(cause)` per
|
||||||
|
// triar la presentació visual (`death_<key>` al YAML del player). El
|
||||||
|
// default `GENERIC` cobreix tots els callsites antics (mòmies, etc.)
|
||||||
|
// sense haver de tocar-los; els nous (caiguda per `Hole`, en el futur
|
||||||
|
// pinchos / timeout / etc.) passen la seua causa explícita.
|
||||||
|
enum class DeathCause : std::uint8_t {
|
||||||
|
GENERIC, // mort estàndard — animació `death_spinning`
|
||||||
|
HOLE, // caiguda per agujero — animació `death_hole`
|
||||||
|
};
|
||||||
|
|
||||||
enum class State : std::uint8_t {
|
enum class State : std::uint8_t {
|
||||||
GROUNDED,
|
GROUNDED,
|
||||||
AIRBORNE,
|
AIRBORNE,
|
||||||
@@ -226,6 +237,29 @@ class Player : public AnimatedSprite {
|
|||||||
return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
|
return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// El jugador està caient per un agujero (`Hole`): subestat dins
|
||||||
|
// d'AIRBORNE amb gravetat aplicada SENSE clamp a 0 — `jump_z_` baixa
|
||||||
|
// a negatius fins a `HOLE_FALL_DEPTH`, llavors `enterDying()`. Mentre
|
||||||
|
// dura, `checkPlayerCollisions` salta aquest jugador (cap més hazard
|
||||||
|
// l'ha de tocar). Es neteja a `enterDying()` i a `respawn()`.
|
||||||
|
[[nodiscard]] auto isFallingThroughHole() const -> bool { return falling_through_hole_; }
|
||||||
|
|
||||||
|
// Activa la caída per agujero: snap del jugador al centre (cx, cy) en
|
||||||
|
// coordenades absolutes del canvas, transició a AIRBORNE amb
|
||||||
|
// `jump_z_=0` i `falling_through_hole_=true`, i arma el clip rect
|
||||||
|
// visual a partir de `floor_y` (Y de la base del tile del forat,
|
||||||
|
// canvas píxels). Desactiva input direccional reusant la branca
|
||||||
|
// AIRBORNE existent. No-op si el jugador no està a GROUNDED.
|
||||||
|
//
|
||||||
|
// CONTRACTE: `floor_y` ha de correspondre a la vora inferior de
|
||||||
|
// l'AABB del decal trigger (`hole.y + hole.h`). Els dos callers
|
||||||
|
// actuals — `Hole::onPlayerContact` i `Player::integrateJump` —
|
||||||
|
// passen exactament aquest valor. Qualsevol caller futur ha de
|
||||||
|
// respectar la convenció: el clip rect del render es calcula com
|
||||||
|
// `floor_y - SPRITE_FLOOR_OFFSET`, i un valor incorrecte desplaçaria
|
||||||
|
// visualment el "sòl" sobre el qual el sprite del jugador desapareix.
|
||||||
|
void enterHoleFall(float center_x, float center_y, float floor_y);
|
||||||
|
|
||||||
// Despawn lògic: el jugador acaba de creuar la porta de sortida.
|
// Despawn lògic: el jugador acaba de creuar la porta de sortida.
|
||||||
// Idempotent — només la primera crida fa la transició; les
|
// Idempotent — només la primera crida fa la transició; les
|
||||||
// posteriors són no-op. La publicació del `player_exited` al bus
|
// posteriors són no-op. La publicació del `player_exited` al bus
|
||||||
@@ -254,7 +288,7 @@ class Player : public AnimatedSprite {
|
|||||||
// momia mentre la roda no la reinicia). Quan l'animació acaba i
|
// momia mentre la roda no la reinicia). Quan l'animació acaba i
|
||||||
// `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a
|
// `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a
|
||||||
// GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius).
|
// GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius).
|
||||||
void enterDying();
|
void enterDying(DeathCause cause = DeathCause::GENERIC);
|
||||||
|
|
||||||
// Coordenades del spawn de respawn (cantó superior-esquerra del
|
// Coordenades del spawn de respawn (cantó superior-esquerra del
|
||||||
// sprite, mateixa convenció que `respawn(x, y)`). El GameScene les
|
// sprite, mateixa convenció que `respawn(x, y)`). El GameScene les
|
||||||
@@ -320,6 +354,14 @@ class Player : public AnimatedSprite {
|
|||||||
// correspongui (sarcòfags). Si el bus no s'injecta, no emet res.
|
// correspongui (sarcòfags). Si el bus no s'injecta, no emet res.
|
||||||
void setEventBus(EventBus* bus) { bus_ = bus; }
|
void setEventBus(EventBus* bus) { bus_ = bus; }
|
||||||
|
|
||||||
|
// Injecta el manager d'entitats del nivell. L'usa `integrateJump` per
|
||||||
|
// detectar aterratges sobre decals-trigger (forats, plaques de
|
||||||
|
// pressió...) i disparar la transició corresponent en lloc del
|
||||||
|
// LANDING_STUN normal. Propietat externa (mateixa convenció que
|
||||||
|
// `setEventBus`); si no s'injecta, l'aterratge segueix el camí
|
||||||
|
// normal (els tests stand-alone del Player no necessiten injectar-lo).
|
||||||
|
void setEntityManager(EntityManager* manager) { entity_manager_ = manager; }
|
||||||
|
|
||||||
// Tile actual del jugador (centre de `baseBox()` convertit a
|
// Tile actual del jugador (centre de `baseBox()` convertit a
|
||||||
// coordenades de tile del tilemap, usant Map::tile_size i
|
// coordenades de tile del tilemap, usant Map::tile_size i
|
||||||
// Map::tilemap_origin_*). Retorna (col, row).
|
// Map::tilemap_origin_*). Retorna (col, row).
|
||||||
@@ -655,6 +697,31 @@ class Player : public AnimatedSprite {
|
|||||||
float jump_z_{0.0F};
|
float jump_z_{0.0F};
|
||||||
float jump_vz_{0.0F};
|
float jump_vz_{0.0F};
|
||||||
|
|
||||||
|
// Subestat dins d'AIRBORNE: `enterHoleFall` el posa a true; mentre dura,
|
||||||
|
// `integrateJump` no clampa `jump_z_` a 0 — deixa baixar a negatius i
|
||||||
|
// dispara `enterDying()` quan creua `HOLE_FALL_DEPTH`. Es neteja a
|
||||||
|
// `enterDying()` i `respawn()`.
|
||||||
|
bool falling_through_hole_{false};
|
||||||
|
|
||||||
|
// Y absoluta (canvas píxels) per sota de la qual el `Player::render`
|
||||||
|
// retalla el sprite mentre `falling_through_hole_=true`. La fixa
|
||||||
|
// `enterHoleFall` a `floor_y - SPRITE_FLOOR_OFFSET` i la neteja
|
||||||
|
// `respawn`. Sense ús quan no estem caient per un forat.
|
||||||
|
float hole_clip_bottom_y_{0.0F};
|
||||||
|
|
||||||
|
// Profunditat (en píxels per sota del sòl) que ha de superar el jugador
|
||||||
|
// mentre cau per un agujero abans que `integrateJump` el mate. Constant
|
||||||
|
// per ara (ajustable a posteriori si el feel no és bo).
|
||||||
|
static constexpr float HOLE_FALL_DEPTH = 20.0F;
|
||||||
|
|
||||||
|
// Píxels transparents a la base del sprite del jugador (16×32). El
|
||||||
|
// sprite "camina pel mig del passadís en falsa perspectiva", així que
|
||||||
|
// la silueta no toca la vora inferior del rect del sprite — hi queda
|
||||||
|
// un marge transparent. Quan armem el clip rect del forat, restem
|
||||||
|
// aquest offset a la `floor_y` perquè la línia de retall coincideixi
|
||||||
|
// visualment amb la base del tile, no amb la vora del sprite.
|
||||||
|
static constexpr float SPRITE_FLOOR_OFFSET = 3.0F;
|
||||||
|
|
||||||
// Temps restant de stun d'aterratge (ms). Mentre > 0, l'estat és
|
// Temps restant de stun d'aterratge (ms). Mentre > 0, l'estat és
|
||||||
// LANDING_STUN i tot l'input queda bloquejat.
|
// LANDING_STUN i tot l'input queda bloquejat.
|
||||||
float landing_stun_remaining_ms_{0.0F};
|
float landing_stun_remaining_ms_{0.0F};
|
||||||
@@ -765,6 +832,12 @@ class Player : public AnimatedSprite {
|
|||||||
// Player només en guarda un punter per publicar events.
|
// Player només en guarda un punter per publicar events.
|
||||||
EventBus* bus_{nullptr};
|
EventBus* bus_{nullptr};
|
||||||
|
|
||||||
|
// Manager d'entitats del nivell (opcional). Propietat externa
|
||||||
|
// (GameScene); l'usa `integrateJump` per consultar
|
||||||
|
// `findFloorTriggerAt` a l'aterratge i decidir si el frame del LAND
|
||||||
|
// ha de disparar `enterHoleFall` en lloc d'un LANDING_STUN normal.
|
||||||
|
EntityManager* entity_manager_{nullptr};
|
||||||
|
|
||||||
// Últim tile publicat. Inicialitzats a un sentinel fora de rang perquè
|
// Últim tile publicat. Inicialitzats a un sentinel fora de rang perquè
|
||||||
// el primer frame en què el bus estigui connectat ja emeti un event.
|
// el primer frame en què el bus estigui connectat ja emeti un event.
|
||||||
int last_tile_col_{-9999};
|
int last_tile_col_{-9999};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "game/map/map.hpp"
|
#include "game/map/map.hpp"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
@@ -150,6 +151,13 @@ namespace Map {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto pixelToTile(float px, float py) -> std::pair<int, int> {
|
||||||
|
const auto TS = static_cast<float>(tile_size);
|
||||||
|
const int COL = static_cast<int>(std::floor((px - static_cast<float>(tilemap_origin_x)) / TS));
|
||||||
|
const int ROW = static_cast<int>(std::floor((py - static_cast<float>(tilemap_origin_y)) / TS));
|
||||||
|
return {COL, ROW};
|
||||||
|
}
|
||||||
|
|
||||||
auto makeInfo() -> Info {
|
auto makeInfo() -> Info {
|
||||||
return {
|
return {
|
||||||
.playfield_x = playfield_x,
|
.playfield_x = playfield_x,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
// Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels
|
// Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels
|
||||||
// quatre costats i graella de sarcòfags envoltada per un passadís a cada
|
// quatre costats i graella de sarcòfags envoltada per un passadís a cada
|
||||||
@@ -105,6 +106,17 @@ namespace Map {
|
|||||||
// usa el `PaintLayer` per filtrar quines cel·les accepten pintat.
|
// usa el `PaintLayer` per filtrar quines cel·les accepten pintat.
|
||||||
[[nodiscard]] auto isPaintable(int col, int row) -> bool;
|
[[nodiscard]] auto isPaintable(int col, int row) -> bool;
|
||||||
|
|
||||||
|
// Converteix una coordenada píxel del canvas en (col, row) del
|
||||||
|
// tilemap. Assumeix `tile_size > 0`; si la coordenada cau fora del
|
||||||
|
// playfield, retorna el tile encara que sigui negatiu o estigui fora
|
||||||
|
// de rang — el caller valida si li cal. El càlcul és el típic
|
||||||
|
// `floor((p - origin) / tile_size)` que apareixia en diversos llocs;
|
||||||
|
// factoritzat aquí per coherència. Consumidors que tinguen un
|
||||||
|
// snapshot de `Map::Info` (p.ex. el Player, decoupling COUP-02) no
|
||||||
|
// han d'usar aquesta funció — han de seguir treballant amb el
|
||||||
|
// snapshot per no re-acoblar-se al namespace global.
|
||||||
|
[[nodiscard]] auto pixelToTile(float px, float py) -> std::pair<int, int>;
|
||||||
|
|
||||||
// Snapshot plà de la geometria del mapa + punters a `collides` /
|
// Snapshot plà de la geometria del mapa + punters a `collides` /
|
||||||
// `insidePlayfield`. Pensat per ser passat per valor (POD petit,
|
// `insidePlayfield`. Pensat per ser passat per valor (POD petit,
|
||||||
// trivialment copiable). Un cop construïda, llegeix els valors
|
// trivialment copiable). Un cop construïda, llegeix els valors
|
||||||
|
|||||||
@@ -12,6 +12,27 @@
|
|||||||
#include "game/map/map.hpp"
|
#include "game/map/map.hpp"
|
||||||
#include "game/map/tilemap.hpp"
|
#include "game/map/tilemap.hpp"
|
||||||
|
|
||||||
|
// CONTRACTE BUS DE PINTAT
|
||||||
|
// -----------------------
|
||||||
|
// `on_tile_entered` — senyal FÍSICA: un jugador acaba d'entrar a un tile.
|
||||||
|
// La publica `Player::publishTileEnteredIfChanged`. La consumeix aquest
|
||||||
|
// `PaintLayer` per decidir si renderitzar el tile com a pintat (filtra
|
||||||
|
// per `Map::isPaintable` i aplica la regla "primer que pinta guanya").
|
||||||
|
//
|
||||||
|
// `tile_painted` — senyal LÒGICA: un tile compta com a pintat per a la
|
||||||
|
// lògica de joc (sarcòfags i, en el futur, HUD %completat, events
|
||||||
|
// derivats). La publica el `PaintLayer` quan confirma un pintat físic,
|
||||||
|
// i també la publica `EntityManager::loadFromLevel` com a broadcast
|
||||||
|
// inicial per als tiles ocupats per decals de sòl (Hole, etc.) — que
|
||||||
|
// el jugador mai podrà trepitjar perquè cauria al forat i els
|
||||||
|
// sarcòfags vecins necessiten que aquests tiles comptin com a pintats
|
||||||
|
// per completar el perímetre.
|
||||||
|
//
|
||||||
|
// REGLA: NO connectar `PaintLayer` al canal `tile_painted` per
|
||||||
|
// renderitzar. Si ho féssim, els broadcasts inicials de decals pintarien
|
||||||
|
// visualment els tiles d'agujero, incoherent amb la representació
|
||||||
|
// gràfica del decal.
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Blit d'un tile del tileset sobre un buffer ARGB de destí, respectant
|
// Blit d'un tile del tileset sobre un buffer ARGB de destí, respectant
|
||||||
|
|||||||
@@ -407,6 +407,10 @@ GameScene::GameScene(Config config)
|
|||||||
// el PaintLayer (subscrit ja) processa el pintat i el fan-out cap als
|
// el PaintLayer (subscrit ja) processa el pintat i el fan-out cap als
|
||||||
// sarcòfags passa per `tile_painted`.
|
// sarcòfags passa per `tile_painted`.
|
||||||
for (auto& p : players_) { p->setEventBus(&entity_manager_.getBus()); }
|
for (auto& p : players_) { p->setEventBus(&entity_manager_.getBus()); }
|
||||||
|
// També injectem el manager perquè `integrateJump` pugui consultar
|
||||||
|
// `findFloorTriggerAt` a l'aterratge (caure dins d'un Hole en lloc
|
||||||
|
// del LANDING_STUN normal).
|
||||||
|
for (auto& p : players_) { p->setEntityManager(&entity_manager_); }
|
||||||
|
|
||||||
// Física del contacte entre jugadors. El factor viu al YAML del mapa
|
// Física del contacte entre jugadors. El factor viu al YAML del mapa
|
||||||
// com a propietat de la piràmide (0.30 per defecte). La vista plana
|
// com a propietat de la piràmide (0.30 per defecte). La vista plana
|
||||||
|
|||||||
@@ -228,6 +228,10 @@ namespace Defaults {
|
|||||||
// les vores. Prou perquè el border blau de l'editor no es
|
// les vores. Prou perquè el border blau de l'editor no es
|
||||||
// superpose amb la graella del picker.
|
// superpose amb la graella del picker.
|
||||||
constexpr int PICKER_MARGIN = 8;
|
constexpr int PICKER_MARGIN = 8;
|
||||||
|
// Píxels de separació entre tiles dins de la graella del picker.
|
||||||
|
// 1 px deixa veure clarament on acaba un tile i on comença l'altre,
|
||||||
|
// útil sobretot quan dos tiles veïns tenen colors molt similars.
|
||||||
|
constexpr int PICKER_SPACING = 1;
|
||||||
// Fons del TilePicker (blau fosc, prou contrastat amb els tiles
|
// Fons del TilePicker (blau fosc, prou contrastat amb els tiles
|
||||||
// de color clar i clarament diferent del canvas).
|
// de color clar i clarament diferent del canvas).
|
||||||
constexpr std::uint32_t PICKER_BG_ARGB = 0xFF101824U;
|
constexpr std::uint32_t PICKER_BG_ARGB = 0xFF101824U;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace Texts {
|
namespace Texts {
|
||||||
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
|
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
|
||||||
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
|
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
|
||||||
constexpr const char* VERSION = "0.30.0"; // Single source of truth — Makefile el grep
|
constexpr const char* VERSION = "0.31.0"; // Single source of truth — Makefile el grep
|
||||||
} // namespace Texts
|
} // namespace Texts
|
||||||
|
|
||||||
namespace GameCanvas {
|
namespace GameCanvas {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Stubs de Player per als binaris `test_entities_smoke` i `test_whip_attack`.
|
||||||
|
//
|
||||||
|
// `Hole` referencia `Player::getHitBox()` i `Player::enterHoleFall()` dins
|
||||||
|
// de `onPlayerContact` (camí no exercit pels tests stand-alone). Linkar
|
||||||
|
// `player.cpp` arrossegaria audio/gamepad/input/screen/cache, una explosió
|
||||||
|
// de dependències que els smokes intenten evitar — mateixa decisió que
|
||||||
|
// `whip_attack_player_stubs.cpp`. Aquests stubs proporcionen els símbols
|
||||||
|
// com a no-op perquè T1/T7 puguin fer `factory.create("hole", ...)`.
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include "game/entities/player.hpp"
|
||||||
|
|
||||||
|
auto Player::getHitBox() const -> SDL_FRect {
|
||||||
|
return SDL_FRect{0.0F, 0.0F, 0.0F, 0.0F};
|
||||||
|
}
|
||||||
|
|
||||||
|
void Player::enterHoleFall(float /*center_x*/, float /*center_y*/, float /*floor_y*/) {
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
#include "game/entities/animation_component.hpp"
|
#include "game/entities/animation_component.hpp"
|
||||||
#include "game/entities/concrete/door.hpp"
|
#include "game/entities/concrete/door.hpp"
|
||||||
#include "game/entities/concrete/floor_trap.hpp"
|
#include "game/entities/concrete/floor_trap.hpp"
|
||||||
|
#include "game/entities/concrete/hole.hpp"
|
||||||
#include "game/entities/concrete/mummy.hpp"
|
#include "game/entities/concrete/mummy.hpp"
|
||||||
#include "game/entities/concrete/rolling_rock.hpp"
|
#include "game/entities/concrete/rolling_rock.hpp"
|
||||||
#include "game/entities/concrete/sarcophagus.hpp"
|
#include "game/entities/concrete/sarcophagus.hpp"
|
||||||
@@ -74,7 +75,7 @@ namespace {
|
|||||||
|
|
||||||
const std::vector<std::string> TYPES = {
|
const std::vector<std::string> TYPES = {
|
||||||
"torch", "sarcophagus", "door", "mummy",
|
"torch", "sarcophagus", "door", "mummy",
|
||||||
"rolling_rock", "wall_trap", "floor_trap",
|
"rolling_rock", "wall_trap", "floor_trap", "hole",
|
||||||
};
|
};
|
||||||
for (const auto& type : TYPES) {
|
for (const auto& type : TYPES) {
|
||||||
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
|
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
|
||||||
@@ -251,7 +252,7 @@ namespace {
|
|||||||
std::cout << "[T7] plantilles YAML\n";
|
std::cout << "[T7] plantilles YAML\n";
|
||||||
const std::vector<std::string> TYPES = {
|
const std::vector<std::string> TYPES = {
|
||||||
"torch", "sarcophagus", "door", "mummy",
|
"torch", "sarcophagus", "door", "mummy",
|
||||||
"rolling_rock", "wall_trap", "floor_trap",
|
"rolling_rock", "wall_trap", "floor_trap", "hole",
|
||||||
};
|
};
|
||||||
for (const auto& type : TYPES) {
|
for (const auto& type : TYPES) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user