From bcbf990a5f0fa2ef77f7565bb09063a0e4b956fc Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 09:36:24 +0200 Subject: [PATCH 01/10] feat(editor): tilepicker respecta l'amplada del tileset si hi cap --- source/game/editor/editor.cpp | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 5a26db9..431d9f5 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -22,10 +22,11 @@ namespace { // Geometria del TilePicker en espai canvas. `cols × rows_visible` tiles - // encapsulats dins d'un rectangle amb `PICKER_MARGIN` per vora. Com que - // el tileset real (p.ex. 32×16 tiles de 16 px = 512×256) no cabria a - // 1× dins del canvas 336×240, linealitzem els índexs i els repartim - // en una graella que SÍ cap, amb scroll vertical per veure la resta. + // encapsulats dins d'un rectangle amb `PICKER_MARGIN` per vora. Si el + // tileset cap en amplada (TCOLS·tile_size ≤ INNER_W), en respectem la + // graella natural — els tiles surten ordenats igual que al GIF, només + // amb scroll vertical si cal. Si no, linealitzem els índexs en una + // graella més ampla que sí cap, amb scroll vertical per veure la resta. struct PickerLayout { int x{0}; int y{0}; @@ -45,18 +46,25 @@ namespace { const int INNER_W = GameCanvas::WIDTH - (2 * M); const int INNER_H = GameCanvas::HEIGHT - (2 * M); if (lay.tile_size <= 0) { return lay; } - lay.cols = INNER_W / lay.tile_size; + const int MAX_COLS = INNER_W / lay.tile_size; lay.rows_visible = INNER_H / lay.tile_size; - lay.x = M; - lay.y = M; - lay.w = lay.cols * lay.tile_size; - lay.h = lay.rows_visible * lay.tile_size; + int tcols = 0; const auto TILESET = Resource::Cache::get().getSurface(tm.getTilesetPath()); 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; - 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; + lay.w = lay.cols * lay.tile_size; + lay.h = lay.rows_visible * lay.tile_size; + // Centrat horitzontalment quan la graella natural és més estreta; + // verticalment mantenim el marge superior i deixem scroll si cal. + lay.x = M + ((INNER_W - lay.w) / 2); + lay.y = M; const int TOTAL_ROWS = (lay.cols > 0) ? ((lay.total_tiles + lay.cols - 1) / lay.cols) : 0; lay.max_scroll = std::max(0, TOTAL_ROWS - lay.rows_visible); return lay; From a53cf4b797d6a807d0b66ec5a6495d41d699fdcb Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 09:51:30 +0200 Subject: [PATCH 02/10] feat(editor): spacing, border i centrat vertical del tilepicker via editor.yaml --- CMakeLists.txt | 3 +- data/editor/editor.yaml | 13 ++++ source/core/system/director.cpp | 9 +++ source/game/editor/editor.cpp | 111 ++++++++++++++++++++------- source/game/editor/editor_config.cpp | 85 ++++++++++++++++++++ source/game/editor/editor_config.hpp | 39 ++++++++++ source/utils/defaults.hpp | 4 + 7 files changed, 235 insertions(+), 29 deletions(-) create mode 100644 data/editor/editor.yaml create mode 100644 source/game/editor/editor_config.cpp create mode 100644 source/game/editor/editor_config.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 86680a8..68f68d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -118,9 +118,10 @@ set(APP_SOURCES source/game/map/paint_layer.cpp source/game/levels/level_manager.cpp # 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. source/game/editor/editor.cpp + source/game/editor/editor_config.cpp ${ENTITY_SOURCES} # Fitxer només del binari principal: `loadFromLevel` depèn de # Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar diff --git a/data/editor/editor.yaml b/data/editor/editor.yaml new file mode 100644 index 0000000..26fb9b6 --- /dev/null +++ b/data/editor/editor.yaml @@ -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 diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 13b84f6..dc935e7 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -29,6 +29,9 @@ #include "core/system/event_buffer.hpp" #include "core/ui/ui_config.hpp" #include "game/debug_config.hpp" +#ifdef _DEBUG +#include "game/editor/editor_config.hpp" +#endif #include "game/levels/level_manager.hpp" #include "game/options.hpp" #include "game/scenes/game_scene.hpp" @@ -119,6 +122,12 @@ Director::Director() { // data/ui.yaml cau al valor compilat a Defaults::UI. 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 // overlays perquè tots els reaprofiten sense duplicar la lògica. Els // noms de font viuen al fitxer data/ui.yaml via UiConfig. diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 431d9f5..6c81f85 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -14,6 +14,7 @@ #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" #include "core/system/event_buffer.hpp" +#include "game/editor/editor_config.hpp" #include "game/levels/level_manager.hpp" #include "game/map/tilemap.hpp" #include "utils/defaults.hpp" @@ -21,12 +22,20 @@ 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 - // encapsulats dins d'un rectangle amb `PICKER_MARGIN` per vora. Si el - // tileset cap en amplada (TCOLS·tile_size ≤ INNER_W), en respectem la - // graella natural — els tiles surten ordenats igual que al GIF, només - // amb scroll vertical si cal. Si no, linealitzem els índexs en una - // graella més ampla que sí cap, amb scroll vertical per veure la resta. + // encapsulats dins d'un rectangle amb `picker.margin` per vora i un + // border 1 px que el delimita. Si el tileset cap en amplada (la suma + // de tiles + spacing + border ≤ INNER_W), en respectem la graella + // 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 { int x{0}; int y{0}; @@ -35,6 +44,8 @@ namespace { int cols{0}; int rows_visible{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 max_scroll{0}; }; @@ -42,12 +53,20 @@ namespace { auto layoutPicker(const Tilemap& tm) -> PickerLayout { PickerLayout lay{}; 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_H = GameCanvas::HEIGHT - (2 * M); - if (lay.tile_size <= 0) { return lay; } - const int MAX_COLS = INNER_W / lay.tile_size; - lay.rows_visible = INNER_H / lay.tile_size; + if (lay.tile_size <= 0 || lay.pitch <= 0) { return lay; } + + // Espai útil per a tiles, descomptat el border 1 px de cada vora. + // Inversió de N·ts + (N-1)·spacing ≤ AVAIL → N ≤ (AVAIL+spacing)/pitch. + const int AVAIL_W = INNER_W - (2 * PICKER_BORDER); + const int AVAIL_H = INNER_H - (2 * PICKER_BORDER); + 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()); if (TILESET != nullptr) { @@ -59,17 +78,34 @@ namespace { // 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; - lay.w = lay.cols * lay.tile_size; - lay.h = lay.rows_visible * lay.tile_size; - // Centrat horitzontalment quan la graella natural és més estreta; - // verticalment mantenim el marge superior i deixem scroll si cal. - lay.x = M + ((INNER_W - lay.w) / 2); - lay.y = M; + 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); + + // 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; } + // 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 { + const int PX = lay.x + PICKER_BORDER + (vc * lay.pitch); + const int PY = lay.y + PICKER_BORDER + (vr * lay.pitch); + return {PX, PY}; + } + } // namespace void Editor::enter() { @@ -179,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) { const auto LAY = layoutPicker(tilemap); 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) { - const int VC = (canvas_x - LAY.x) / LAY.tile_size; - const int VR = (canvas_y - LAY.y) / LAY.tile_size; - const int IDX = ((picker_scroll_ + VR) * LAY.cols) + VC; - if (IDX < LAY.total_tiles) { picker_hover_ = IDX; } + // Espai útil per als tiles dins de la caixa (descomptat el border 1 px). + const int INNER_X = LAY.x + PICKER_BORDER; + const int INNER_Y = LAY.y + PICKER_BORDER; + const int RX = canvas_x - INNER_X; + 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) { @@ -214,7 +260,7 @@ void Editor::render(const Tilemap& tilemap) const { 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, H - 1, W, 1, BORDER); screen.fillRect(0, 0, 1, H, BORDER); @@ -223,34 +269,43 @@ void Editor::render(const Tilemap& tilemap) const { void Editor::renderPicker(const Tilemap& tilemap) const { const auto LAY = layoutPicker(tilemap); + if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; } 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) { const int LOGICAL_ROW = picker_scroll_ + vr; for (int vc = 0; vc < LAY.cols; ++vc) { const int IDX = (LOGICAL_ROW * LAY.cols) + vc; if (IDX >= LAY.total_tiles) { break; } - const int PX = LAY.x + (vc * LAY.tile_size); - const int PY = LAY.y + (vr * LAY.tile_size); + const auto [PX, PY] = pickerTileOrigin(LAY, vc, vr); 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 VC = picker_hover_ % LAY.cols; if (VR >= 0 && VR < LAY.rows_visible) { - const int PX = LAY.x + (VC * LAY.tile_size); - const int PY = LAY.y + (VR * LAY.tile_size); + const auto [PX, PY] = pickerTileOrigin(LAY, VC, VR); 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, TS, 1, C); screen.fillRect(PX, 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 diff --git a/source/game/editor/editor_config.cpp b/source/game/editor/editor_config.cpp new file mode 100644 index 0000000..9f696d2 --- /dev/null +++ b/source/game/editor/editor_config.cpp @@ -0,0 +1,85 @@ +#include "game/editor/editor_config.hpp" + +#ifdef _DEBUG + +#include +#include +#include +#include + +#include "core/resources/resource_helper.hpp" +#include "external/fkyaml_node.hpp" +#include "utils/defaults.hpp" + +namespace { + + template + void readField(const fkyaml::node& node, const char* key, T& dst) { + if (node.contains(key)) { dst = node[key].get_value(); } + } + + // 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(v.get_value()); + return; + } + auto s = v.get_value(); + if (!s.empty() && s.front() == '#') { s.erase(0, 1); } + dst = static_cast(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(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 diff --git a/source/game/editor/editor_config.hpp b/source/game/editor/editor_config.hpp new file mode 100644 index 0000000..57673b5 --- /dev/null +++ b/source/game/editor/editor_config.hpp @@ -0,0 +1,39 @@ +#pragma once + +#ifdef _DEBUG + +#include +#include + +// 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 diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index f182fa1..83a4e2b 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -228,6 +228,10 @@ namespace Defaults { // les vores. Prou perquè el border blau de l'editor no es // superpose amb la graella del picker. 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 // de color clar i clarament diferent del canvas). constexpr std::uint32_t PICKER_BG_ARGB = 0xFF101824U; From 4da50571eb86eaffad720c4e149e49b30872cac9 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 13:32:17 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(hazard):=20nou=20hazard=20Hole=20?= =?UTF-8?q?=E2=80=94=20caiguda=20per=20forat=20amb=20clip=20rect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 3 + data/entities/hole/hole.gif | Bin 0 -> 182 bytes data/entities/hole/hole.yaml | 26 ++++ data/maps/p1/r1.yaml | 6 + data/player/morcus.yaml | 10 ++ data/player/sam.yaml | 10 ++ source/game/entities/concrete/floor_trap.cpp | 4 + source/game/entities/concrete/hole.cpp | 51 +++++++ source/game/entities/concrete/hole.hpp | 34 +++++ source/game/entities/concrete/wall_trap.cpp | 3 + source/game/entities/entity.cpp | 7 + source/game/entities/entity.hpp | 23 ++++ .../entities/entity_manager_loadlevel.cpp | 11 +- source/game/entities/player.cpp | 128 ++++++++++++++++-- source/game/entities/player.hpp | 52 ++++++- tests/hole_player_stubs.cpp | 18 +++ tests/test_entities_smoke.cpp | 5 +- 17 files changed, 376 insertions(+), 15 deletions(-) create mode 100644 data/entities/hole/hole.gif create mode 100644 data/entities/hole/hole.yaml create mode 100644 source/game/entities/concrete/hole.cpp create mode 100644 source/game/entities/concrete/hole.hpp create mode 100644 tests/hole_player_stubs.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 68f68d6..84f3ebb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,6 +50,7 @@ set(ENTITY_SOURCES_BASE source/game/entities/concrete/rolling_rock.cpp source/game/entities/concrete/wall_trap.cpp source/game/entities/concrete/floor_trap.cpp + source/game/entities/concrete/hole.cpp source/game/entities/concrete/dust.cpp ) @@ -349,6 +350,7 @@ option(BUILD_TESTS "Build smoke tests" ON) if(BUILD_TESTS) add_executable(test_entities_smoke tests/test_entities_smoke.cpp + tests/hole_player_stubs.cpp source/core/resources/resource_helper.cpp source/core/resources/resource_pack.cpp source/game/map/map.cpp @@ -372,6 +374,7 @@ if(BUILD_TESTS) add_executable(test_whip_attack tests/test_whip_attack.cpp tests/whip_attack_player_stubs.cpp + tests/hole_player_stubs.cpp source/core/resources/resource_helper.cpp source/core/resources/resource_pack.cpp source/game/map/map.cpp diff --git a/data/entities/hole/hole.gif b/data/entities/hole/hole.gif new file mode 100644 index 0000000000000000000000000000000000000000..9805e8a79a32286f92009407772555fbe311690c GIT binary patch literal 182 zcmZ?wbh9u|6krfwIKlt|l44>CGBRq4irPv_+Nw&rDk=sVs)pL?_WEkZ+8Sp1+7|jc z_U6Vujy66{wrSqBk-qLlAujc4k@b0LGh3<}BI0IM)<+iASM|>Xn} zL_u~kuo^E=WJ 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 diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index eb123f1..3555088 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -138,3 +138,9 @@ entities: position: [10, 7] - type: mummy position: [7, 9] + # Forat de prova: tile de sòl pla a la galeria superior, 2 tiles a + # l'est del punt d'aterratge del jugador (door_col=10). Lluny dels + # sarcs i de les momies; accessible caminant cap a l'est tot just + # després del spawn. + - type: hole + position: [12, 3] diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index aedb8e1..53fd579 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -73,6 +73,16 @@ animations: frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] loop: false + # Mort per caiguda a un agujero (DeathCause::HOLE). Frame i durada + # **placeholders** — pendents de Sergio: idealment l'índex de la + # casella buida del spritesheet i una durada curta. `loop: false` + # com `death_spinning` 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 # tocs curts (passets d'un tile sense rellisquera). inertia: diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 867899f..5fad03a 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -73,6 +73,16 @@ animations: frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] loop: false + # Mort per caiguda a un agujero (DeathCause::HOLE). Frame i durada + # **placeholders** — pendents de Sergio: idealment l'índex de la + # casella buida del spritesheet i una durada curta. `loop: false` + # com `death_spinning` 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 # tocs curts (passets d'un tile sense rellisquera). inertia: diff --git a/source/game/entities/concrete/floor_trap.cpp b/source/game/entities/concrete/floor_trap.cpp index fa451f6..501430a 100644 --- a/source/game/entities/concrete/floor_trap.cpp +++ b/source/game/entities/concrete/floor_trap.cpp @@ -10,6 +10,10 @@ FloorTrap::FloorTrap(const fkyaml::node& yaml) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("floor_trap"); collides_ = 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")) { const auto& c = yaml["cycle"]; diff --git a/source/game/entities/concrete/hole.cpp b/source/game/entities/concrete/hole.cpp new file mode 100644 index 0000000..4d3092c --- /dev/null +++ b/source/game/entities/concrete/hole.cpp @@ -0,0 +1,51 @@ +#include "game/entities/concrete/hole.hpp" + +#include + +#include + +#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("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 amb la hit-box interna del jugador (8×8): + // així el contacte només es dispara quan el centre del jugador ha + // entrat al tile del forat, no quan només el frega per la vora. + const SDL_FRect HIT = player->getHitBox(); + const SDL_FRect BOX = getCollisionRect(); + const bool OVERLAP = HIT.x + HIT.w > BOX.x && BOX.x + BOX.w > HIT.x && HIT.y + HIT.h > BOX.y && BOX.y + BOX.h > HIT.y; + if (!OVERLAP) { 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) diff --git a/source/game/entities/concrete/hole.hpp b/source/game/entities/concrete/hole.hpp new file mode 100644 index 0000000..3f05bb3 --- /dev/null +++ b/source/game/entities/concrete/hole.hpp @@ -0,0 +1,34 @@ +#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 de col·lisió per altura existent deixa que +// un jugador AIRBORNE amb `jump_z_>0` el creui sense disparar res +// (saltar per damunt d'un forat). +// - 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; +}; diff --git a/source/game/entities/concrete/wall_trap.cpp b/source/game/entities/concrete/wall_trap.cpp index 5b385f4..1b14e47 100644 --- a/source/game/entities/concrete/wall_trap.cpp +++ b/source/game/entities/concrete/wall_trap.cpp @@ -10,6 +10,9 @@ WallTrap::WallTrap(const fkyaml::node& yaml) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("wall_trap"); collides_ = 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")) { const auto& c = yaml["cycle"]; diff --git a/source/game/entities/entity.cpp b/source/game/entities/entity.cpp index fb0e3e9..092451a 100644 --- a/source/game/entities/entity.cpp +++ b/source/game/entities/entity.cpp @@ -1,5 +1,6 @@ #include "game/entities/entity.hpp" +#include #include 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 { + // 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::lowest(); + } const auto RECT = getCollisionRect(); return RECT.y + RECT.h; } diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index fb44080..7f2c937 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -88,6 +88,27 @@ class Entity { [[nodiscard]] auto collides() const -> bool { return collides_; } [[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::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, // explosions...) que viuen una sola animació i s'autoeliminen quan // acaba. L'`EntityManager` purga totes les entitats amb `dead_=true` @@ -172,6 +193,8 @@ class Entity { std::shared_ptr surface_{nullptr}; bool collides_{false}; bool interactable_{false}; + bool triggers_on_overlap_{false}; + bool is_floor_decal_{false}; int height_{0}; bool dead_{false}; SDL_FlipMode flip_{SDL_FLIP_NONE}; diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 99dee18..7d25c40 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -61,7 +61,12 @@ void EntityManager::renderAll() { void EntityManager::checkPlayerCollisions(const std::vector& players) { if (players.empty()) { return; } 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 int E_HEIGHT = entity->getHeight(); for (auto* p : players) { @@ -69,6 +74,10 @@ void EntityManager::checkPlayerCollisions(const std::vector& players) { // contacte amb hazards (un segon toc de mòmia mentre la roda // ja és no-op; cap altra entitat l'ha de fer transicionar). 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`. + if (p->isFallingThroughHole()) { continue; } // 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. // És el mecanisme del salt per esquivar momies, roques, etc. diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 1187d49..2a673b5 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -12,6 +12,7 @@ #include "core/input/gamepad_bindings.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/input_state.hpp" +#include "core/rendering/canvas.hpp" #include "core/rendering/screen.hpp" #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" @@ -910,6 +911,17 @@ auto Player::integrateJump(float delta_time) -> bool { if (state_ != State::AIRBORNE) { return false; } jump_vz_ -= jump_config.gravity_px_per_s2 * 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) { jump_z_ = 0.0F; jump_vz_ = 0.0F; @@ -1075,7 +1087,7 @@ void Player::applyMovement(float delta_time) { if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); } } -void Player::enterDying() { +void Player::enterDying(DeathCause cause) { // Idempotent. Un segon contacte de momia mentre el jugador ja està // en DYING no reinicia l'animació ni el comptador. Tampoc disparem // el cicle si el jugador està fora de joc (EXITED al passar la @@ -1091,6 +1103,8 @@ void Player::enterDying() { being_pushed_ = false; jump_z_ = 0.0F; jump_vz_ = 0.0F; + falling_through_hole_ = false; + hole_clip_bottom_y_ = 0.0F; landing_stun_remaining_ms_ = 0.0F; attacking_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F; @@ -1102,7 +1116,18 @@ void Player::enterDying() { death_animation_complete_ = false; 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 // `readHurtBlock`). El bloc `death:` no té camp de so propi — @@ -1204,19 +1229,46 @@ void Player::render() { if (state_ == State::DYING && death_animation_complete_) { return; } // 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 // 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í: - // això ho fa el GameScene en una passada de sombres a banda. - if (jump_z_ <= 0.0F) { - AnimatedSprite::render(); - return; + // això ho fa el GameScene en una passada de sombres a banda. El cas + // `jump_z_ == 0` (a terra) surt per l'early-return per evitar tocar + // `pos_.y` innecessàriament. + // 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(hole_clip_bottom_y_), + }); } - const float SAVED_Y = pos_.y; - pos_.y = SAVED_Y - jump_z_; - AnimatedSprite::render(); - pos_.y = SAVED_Y; + + if (jump_z_ == 0.0F) { + AnimatedSprite::render(); + } 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 { @@ -1224,6 +1276,10 @@ void Player::renderShadow() const { // que poden cridar `renderShadow` per a tots els jugadors sense // discriminar. 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 // (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura @@ -1359,6 +1415,8 @@ void Player::respawn(float x, float y) { being_pushed_ = false; jump_z_ = 0.0F; jump_vz_ = 0.0F; + falling_through_hole_ = false; + hole_clip_bottom_y_ = 0.0F; landing_stun_remaining_ms_ = 0.0F; attacking_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F; @@ -1396,6 +1454,54 @@ void Player::disable() { last_input_cardinal_ = Cardinal::NONE; } +void Player::enterHoleFall(float center_x, float center_y, float floor_y) { + // Defensa: només transicionem des de GROUNDED. Saltadors, AIRBORNE, + // HURT, DYING, etc. no han de caure pel forat (entre altres coses, + // perquè `checkPlayerCollisions` ja filtra airborne via altura, però + // afegim el gat ací per coherència amb la resta de transicions). + if (state_ != State::GROUNDED) { return; } + + // 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(hit_size) * 0.5F; + setPosX(center_x - static_cast(hit_x_offset) - HIT_HALF); + setPosY(center_y - static_cast(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) { // 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 diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index d17ca82..b564463 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -40,6 +40,16 @@ class Player : public AnimatedSprite { // que decau per inèrcia fins a parar (~2 tiles). // No col·lisiona amb cap altre jugador. Quan // s'atura, torna a GROUNDED. + // Causa de la mort actual. La passa el caller a `enterDying(cause)` per + // triar la presentació visual (`death_` 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 { GROUNDED, AIRBORNE, @@ -226,6 +236,21 @@ class Player : public AnimatedSprite { 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. + void enterHoleFall(float center_x, float center_y, float floor_y); + // Despawn lògic: el jugador acaba de creuar la porta de sortida. // Idempotent — només la primera crida fa la transició; les // posteriors són no-op. La publicació del `player_exited` al bus @@ -254,7 +279,7 @@ class Player : public AnimatedSprite { // momia mentre la roda no la reinicia). Quan l'animació acaba i // `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a // 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 // sprite, mateixa convenció que `respawn(x, y)`). El GameScene les @@ -655,6 +680,31 @@ class Player : public AnimatedSprite { float jump_z_{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 // LANDING_STUN i tot l'input queda bloquejat. float landing_stun_remaining_ms_{0.0F}; diff --git a/tests/hole_player_stubs.cpp b/tests/hole_player_stubs.cpp new file mode 100644 index 0000000..314d513 --- /dev/null +++ b/tests/hole_player_stubs.cpp @@ -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 + +#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*/) { +} diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index f11190d..584f781 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -44,6 +44,7 @@ #include "game/entities/animation_component.hpp" #include "game/entities/concrete/door.hpp" #include "game/entities/concrete/floor_trap.hpp" +#include "game/entities/concrete/hole.hpp" #include "game/entities/concrete/mummy.hpp" #include "game/entities/concrete/rolling_rock.hpp" #include "game/entities/concrete/sarcophagus.hpp" @@ -74,7 +75,7 @@ namespace { const std::vector TYPES = { "torch", "sarcophagus", "door", "mummy", - "rolling_rock", "wall_trap", "floor_trap", + "rolling_rock", "wall_trap", "floor_trap", "hole", }; for (const auto& type : TYPES) { fkyaml::node tmpl = EntityLoader::loadTemplate(type); @@ -251,7 +252,7 @@ namespace { std::cout << "[T7] plantilles YAML\n"; const std::vector TYPES = { "torch", "sarcophagus", "door", "mummy", - "rolling_rock", "wall_trap", "floor_trap", + "rolling_rock", "wall_trap", "floor_trap", "hole", }; for (const auto& type : TYPES) { try { From 3e5217066fce6da5fe73ce32c632fcc60ee403e8 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 13:53:26 +0200 Subject: [PATCH 04/10] fix(hazard): aterrar damunt d'un Hole salta el LANDING_STUN --- source/game/entities/entity_manager.hpp | 12 ++++++ .../entities/entity_manager_loadlevel.cpp | 17 +++++++++ source/game/entities/player.cpp | 37 ++++++++++++++++--- source/game/entities/player.hpp | 15 ++++++++ source/game/scenes/game_scene.cpp | 4 ++ 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/source/game/entities/entity_manager.hpp b/source/game/entities/entity_manager.hpp index 6893cb3..a2d2515 100644 --- a/source/game/entities/entity_manager.hpp +++ b/source/game/entities/entity_manager.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -74,6 +76,16 @@ class EntityManager { // reflecteixi l'estat del frame actual. void checkPlayerCollisions(const std::vector& 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 // notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte // (veure Entity::notifyTilePainted). Invocat com a reacció a diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 7d25c40..9078964 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -58,6 +58,23 @@ void EntityManager::renderAll() { for (const auto& e : entities_) { renderEntity(*e); } } +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 sola crida per frame i per + // jugador (a l'aterratge). 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; +} + void EntityManager::checkPlayerCollisions(const std::vector& players) { if (players.empty()) { return; } for (const auto& entity : entities_) { diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 2a673b5..4cc909f 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -17,6 +17,8 @@ #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.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/options.hpp" #include "utils/defaults.hpp" @@ -923,6 +925,28 @@ auto Player::integrateJump(float delta_time) -> bool { return false; } 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_vz_ = 0.0F; state_ = State::LANDING_STUN; @@ -1455,11 +1479,14 @@ void Player::disable() { } void Player::enterHoleFall(float center_x, float center_y, float floor_y) { - // Defensa: només transicionem des de GROUNDED. Saltadors, AIRBORNE, - // HURT, DYING, etc. no han de caure pel forat (entre altres coses, - // perquè `checkPlayerCollisions` ja filtra airborne via altura, però - // afegim el gat ací per coherència amb la resta de transicions). - if (state_ != State::GROUNDED) { return; } + // 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: diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index b564463..e269240 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -12,6 +12,7 @@ #include "game/map/map.hpp" class EventBus; +class EntityManager; class Player : public AnimatedSprite { public: @@ -345,6 +346,14 @@ class Player : public AnimatedSprite { // correspongui (sarcòfags). Si el bus no s'injecta, no emet res. 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 // coordenades de tile del tilemap, usant Map::tile_size i // Map::tilemap_origin_*). Retorna (col, row). @@ -815,6 +824,12 @@ class Player : public AnimatedSprite { // Player només en guarda un punter per publicar events. 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è // el primer frame en què el bus estigui connectat ja emeti un event. int last_tile_col_{-9999}; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 7df6a0a..9fac4dc 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -407,6 +407,10 @@ GameScene::GameScene(Config config) // el PaintLayer (subscrit ja) processa el pintat i el fan-out cap als // sarcòfags passa per `tile_painted`. 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 // com a propietat de la piràmide (0.30 per defecte). La vista plana From 3bfca35f3ac4354edd4e39ead9715d5bc7540080 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 16:50:26 +0200 Subject: [PATCH 05/10] =?UTF-8?q?feat(mummy):=20evita=20tiles=20amb=20deca?= =?UTF-8?q?l=20trigger=20(Hole)=20en=20decidir=20direcci=C3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/entities/concrete/mummy.cpp | 66 +++++++++++++++++-- source/game/entities/concrete/mummy.hpp | 17 ++++- source/game/entities/entity.hpp | 13 ++++ source/game/entities/entity_manager.cpp | 25 ++++++- .../entities/entity_manager_loadlevel.cpp | 17 ----- 5 files changed, 111 insertions(+), 27 deletions(-) diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index b13cd29..5612428 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -8,6 +8,7 @@ #include #include "game/entities/entity_factory.hpp" +#include "game/entities/entity_manager.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" #include "game/map/map.hpp" @@ -235,8 +236,15 @@ void Mummy::step(float delta_time) { // Cal girar. Preferim perpendiculars; només fem U-turn si no hi ha cap // altra opció (comportament típic d'Oh Mummy!: no es recula sense motiu). - const auto POOLS = collectDirectionPools(speed_ * delta_time); - const auto& pool = !POOLS.perp.empty() ? POOLS.perp : POOLS.uturn; + // Primer intentem amb el filtre de decals (Hole) actiu; si totes les + // alternatives apunten a un decal trigger, fallback al pool sense filtre + // — millor caure al forat que quedar-se paralitzada en una sala mal + // dissenyada (la mòmia no mor pels forats). + auto pools = collectDirectionPools(speed_ * delta_time, /*avoid_decals=*/true); + if (pools.perp.empty() && pools.uturn.empty()) { + pools = collectDirectionPools(speed_ * delta_time, /*avoid_decals=*/false); + } + const auto& pool = !pools.perp.empty() ? pools.perp : pools.uturn; applyChoice(chooseDirection(pool)); // No avancem aquest frame: la direcció nova s'aplicarà al pròxim step. } @@ -260,24 +268,38 @@ auto Mummy::currentDirection() const -> Direction { void Mummy::decideAtIntersection(float step_px) { // Pool base: perpendiculars lliures (collectDirectionPools ja exclou la // direcció actual i la mitja volta va al seu propi calaix, que ací - // ignorem — mai fem U-turn a un creuament). - auto pools = collectDirectionPools(step_px); + // ignorem — mai fem U-turn a un creuament). Primera passa amb filtre + // de decals (Hole) actiu. + auto pools = collectDirectionPools(step_px, /*avoid_decals=*/true); std::vector pool = std::move(pools.perp); // Afegim la recta si segueix essent lliure. A un creuament "net" ho és // sempre (la mòmia hi ha arribat per aquest corredor), però als creua- // 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. + // permetre un pas més — en aquest cas la descartem. També la descartem + // si apunta a un decal trigger: aquesta és la regla "evita forats" en + // mode "seguir recte". 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); + if (STRAIGHT_OPEN && !isDecalAheadInDir(dir_x_, dir_y_)) { pool.push_back(currentDirection()); } + // Fallback: si totes les opcions filtrades porten a decal o estan + // bloquejades, repetim el pool sense filtre i tornem a afegir la recta + // si és lliure (encara que vaja a forat). Així la mòmia mai es queda + // paralitzada en un creuament rodejat de forats. + if (pool.empty()) { + auto pools_unfiltered = collectDirectionPools(step_px, /*avoid_decals=*/false); + pool = std::move(pools_unfiltered.perp); + if (STRAIGHT_OPEN) { pool.push_back(currentDirection()); } + } + if (pool.empty()) { return; } applyChoice(chooseDirection(pool)); } -auto Mummy::collectDirectionPools(float step) const -> DirectionPools { +auto Mummy::collectDirectionPools(float step, bool avoid_decals) const -> DirectionPools { struct Candidate { float dx; float dy; @@ -297,6 +319,11 @@ auto Mummy::collectDirectionPools(float step) const -> DirectionPools { if (IS_CURRENT) { continue; } const auto TEST = candidateBox(c.dx, c.dy); 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); if (IS_UTURN) { pools.uturn.push_back(c.dir); @@ -307,6 +334,31 @@ auto Mummy::collectDirectionPools(float step) const -> DirectionPools { 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`), NO el AABB de la mòmia desplaçat un pas — així + // captura "si la mòmia estiguera centrada en aquell tile, hi pisaria + // un decal?" amb marge. + const auto BOX = getCollisionRect(); + const auto TS = static_cast(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 SDL_FRect NEXT{ + .x = BOX.x + (SIGN_OF(dx_signed) * TS), + .y = BOX.y + (SIGN_OF(dy_signed) * TS), + .w = TS, + .h = TS, + }; + return entity_manager_->findFloorTriggerAt(NEXT) != nullptr; +} + void Mummy::applyChoice(Direction dir) { switch (dir) { case Direction::NORTH: diff --git a/source/game/entities/concrete/mummy.hpp b/source/game/entities/concrete/mummy.hpp index 9e78db3..d408cca 100644 --- a/source/game/entities/concrete/mummy.hpp +++ b/source/game/entities/concrete/mummy.hpp @@ -85,12 +85,25 @@ class Mummy : public DynamicHazard { // Recull les direccions alternatives viables des de la posició actual: // `perpendiculars` (girs) i `uturn` (inversa). La direcció actual s'exclou // 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 { std::vector perp; std::vector 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 // per branch). Tots els camins que trien nova direcció (ctor amb diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 7f2c937..88808a3 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -12,9 +12,13 @@ // `source/game/entities/player.hpp` i només es referencia per referència // dins `onPlayerContact`; `EventBus` és opcional a cada entitat i viu al // 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 EventBus; class Surface; +class EntityManager; // 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é @@ -122,6 +126,14 @@ class Entity { // estàtiques de moment) el poden ignorar. 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 // `loadFromLevel` un cop sap la posició definitiva de l'entitat al mapa. void setPixelRect(SDL_FRect rect) { pixel_rect_ = rect; } @@ -199,4 +211,5 @@ class Entity { bool dead_{false}; SDL_FlipMode flip_{SDL_FLIP_NONE}; EventBus* bus_{nullptr}; + EntityManager* entity_manager_{nullptr}; }; diff --git a/source/game/entities/entity_manager.cpp b/source/game/entities/entity_manager.cpp index 319b061..0a679c6 100644 --- a/source/game/entities/entity_manager.cpp +++ b/source/game/entities/entity_manager.cpp @@ -72,6 +72,7 @@ void EntityManager::forceOpenDoor() { auto EntityManager::add(std::unique_ptr entity) -> Entity* { if (entity == nullptr) { return nullptr; } entity->setEventBus(&bus_); + entity->setEntityManager(this); Entity* raw = entity.get(); entities_.push_back(std::move(entity)); // cppcheck-suppress returnDanglingLifetime @@ -80,12 +81,16 @@ auto EntityManager::add(std::unique_ptr entity) -> Entity* { void EntityManager::setDoor(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 sarc) -> Sarcophagus* { if (sarc == nullptr) { return nullptr; } sarc->setEventBus(&bus_); + sarc->setEntityManager(this); Sarcophagus* raw = sarc.get(); entities_.push_back(std::move(sarc)); ++total_sarcs_; @@ -110,6 +115,24 @@ void EntityManager::notifyPlayerTilePainted(int col, int 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 { std::vector out; out.reserve(entities_.size()); diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 9078964..7d25c40 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -58,23 +58,6 @@ void EntityManager::renderAll() { for (const auto& e : entities_) { renderEntity(*e); } } -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 sola crida per frame i per - // jugador (a l'aterratge). 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; -} - void EntityManager::checkPlayerCollisions(const std::vector& players) { if (players.empty()) { return; } for (const auto& entity : entities_) { From 1ccd444aade35e01983374eb176e762d721aeef5 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 16:56:56 +0200 Subject: [PATCH 06/10] =?UTF-8?q?fix(hazard):=20el=20jugador=20nom=C3=A9s?= =?UTF-8?q?=20cau=20al=20Hole=20si=20la=20hit-box=20hi=20cap=20del=20tot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/entities/concrete/hole.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/source/game/entities/concrete/hole.cpp b/source/game/entities/concrete/hole.cpp index 4d3092c..2aa3bdf 100644 --- a/source/game/entities/concrete/hole.cpp +++ b/source/game/entities/concrete/hole.cpp @@ -30,13 +30,16 @@ void Hole::onPlayerContact(Player* player) { // davant de futurs canvis al filtre. if (player->getState() != Player::State::GROUNDED) { return; } - // Re-verificació estricta amb la hit-box interna del jugador (8×8): - // així el contacte només es dispara quan el centre del jugador ha - // entrat al tile del forat, no quan només el frega per la vora. + // 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 OVERLAP = HIT.x + HIT.w > BOX.x && BOX.x + BOX.w > HIT.x && HIT.y + HIT.h > BOX.y && BOX.y + BOX.h > HIT.y; - if (!OVERLAP) { return; } + 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 From 6d4f606eeba2e8dcabb192feaca9d2a574f33d1e Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 18:30:03 +0200 Subject: [PATCH 07/10] =?UTF-8?q?feat(mummy):=20evita=20forats=20per=20det?= =?UTF-8?q?ecci=C3=B3=20reactiva=20al=20canvi=20de=20tile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/maps/p1/r1.yaml | 22 +-- source/game/entities/concrete/mummy.cpp | 181 +++++++++++++++++++----- source/game/entities/concrete/mummy.hpp | 19 +++ 3 files changed, 176 insertions(+), 46 deletions(-) diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index 3555088..eac3d3a 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -90,6 +90,7 @@ entities: position: [12, 2] - type: torch position: [16, 2] + - type: sarcophagus sarc_position: [0, 0] - type: sarcophagus @@ -130,17 +131,22 @@ entities: sarc_position: [2, 4] - type: sarcophagus sarc_position: [3, 4] + - 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 position: [10, 7] - type: mummy position: [7, 9] - # Forat de prova: tile de sòl pla a la galeria superior, 2 tiles a - # l'est del punt d'aterratge del jugador (door_col=10). Lluny dels - # sarcs i de les momies; accessible caminant cap a l'est tot just - # després del spawn. + - type: hole - position: [12, 3] + position: [11, 5] + - type: hole + position: [4, 7] + - type: hole + position: [8, 11] + - type: hole + position: [15, 11] + - type: hole + position: [15, 5] diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index 5612428..40e08ab 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -198,7 +199,15 @@ void Mummy::snapToPlayfieldEdge(float dx, float dy) { } 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 DY = dir_y_ * speed_ * delta_time; @@ -212,6 +221,9 @@ void Mummy::step(float delta_time) { // centre; el remanent queda per al pròxim frame, ja amb la (potser // 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. + // 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 (DX != 0.0F) { pixel_rect_.x += *SNAP; @@ -234,21 +246,89 @@ void Mummy::step(float delta_time) { // Bloqueig per la vora del playfield: amarrem la caja a la vora exacta. if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); } - // Cal girar. Preferim perpendiculars; només fem U-turn si no hi ha cap - // altra opció (comportament típic d'Oh Mummy!: no es recula sense motiu). - // Primer intentem amb el filtre de decals (Hole) actiu; si totes les - // alternatives apunten a un decal trigger, fallback al pool sense filtre - // — millor caure al forat que quedar-se paralitzada en una sala mal - // dissenyada (la mòmia no mor pels forats). + // Cal girar. Cascada de tres nivells: + // Nivell 1 — perpendiculars sense decal (preferides; el comportament + // "natural" d'Oh Mummy!: girar abans que recular). + // Nivell 2 — mitja volta sense decal (rescat: callejón sense salida o + // 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); - if (pools.perp.empty() && pools.uturn.empty()) { - pools = collectDirectionPools(speed_ * delta_time, /*avoid_decals=*/false); + const std::vector* chosen = nullptr; + if (!pools.perp.empty()) { + chosen = &pools.perp; + } else if (!pools.uturn.empty()) { + chosen = &pools.uturn; } - const auto& pool = !pools.perp.empty() ? pools.perp : pools.uturn; - applyChoice(chooseDirection(pool)); + 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. } +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 auto TS = static_cast(Map::tile_size); + const auto ORIG_X = static_cast(Map::tilemap_origin_x); + const auto ORIG_Y = static_cast(Map::tilemap_origin_y); + 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 int CUR_COL = static_cast(std::floor((LEAD_X - ORIG_X) / TS)); + const int CUR_ROW = static_cast(std::floor((LEAD_Y - ORIG_Y) / TS)); + + // 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 SDL_FRect TILE_RECT{ + .x = (static_cast(CUR_COL) * TS) + ORIG_X, + .y = (static_cast(CUR_ROW) * TS) + ORIG_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 { const auto BOX = getCollisionRect(); if (dx != 0.0F) { @@ -266,37 +346,51 @@ auto Mummy::currentDirection() const -> Direction { } void Mummy::decideAtIntersection(float step_px) { - // Pool base: perpendiculars lliures (collectDirectionPools ja exclou la - // direcció actual i la mitja volta va al seu propi calaix, que ací - // ignorem — mai fem U-turn a un creuament). Primera passa amb filtre - // de decals (Hole) actiu. + // Cascada de tres nivells (paral·lela a la branca de bloqueig de + // `step`). Aquí el Nivell 1 inclou la recta com a candidata addicional + // (a un creuament la mòmia pot continuar recta si segueix lliure i no + // apunta a un decal); el Nivell 2 admet la mitja volta — abans no es + // 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); - std::vector pool = std::move(pools.perp); - // Afegim la recta si segueix essent lliure. A un creuament "net" ho és - // sempre (la mòmia hi ha arribat per aquest corredor), però als creua- - // 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. També la descartem - // si apunta a un decal trigger: aquesta és la regla "evita forats" en - // mode "seguir recte". + // Nivell 1: perpendiculars (filtrades) + recta si lliure i sense decal. + std::vector level1 = std::move(pools.perp); const auto STRAIGHT_BOX = candidateBox(dir_x_ * step_px, dir_y_ * step_px); const bool STRAIGHT_OPEN = Map::insidePlayfield(STRAIGHT_BOX) && !Map::collides(STRAIGHT_BOX); if (STRAIGHT_OPEN && !isDecalAheadInDir(dir_x_, dir_y_)) { - pool.push_back(currentDirection()); + level1.push_back(currentDirection()); } - // Fallback: si totes les opcions filtrades porten a decal o estan - // bloquejades, repetim el pool sense filtre i tornem a afegir la recta - // si és lliure (encara que vaja a forat). Així la mòmia mai es queda - // paralitzada en un creuament rodejat de forats. - if (pool.empty()) { - auto pools_unfiltered = collectDirectionPools(step_px, /*avoid_decals=*/false); - pool = std::move(pools_unfiltered.perp); - if (STRAIGHT_OPEN) { pool.push_back(currentDirection()); } + // Nivell 2: mitja volta sense decal — rescat quan Nivell 1 ja no té cap + // 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* chosen = nullptr; + if (!level1.empty()) { + chosen = &level1; + } else if (!pools.uturn.empty()) { + chosen = &pools.uturn; } - if (pool.empty()) { return; } - applyChoice(chooseDirection(pool)); + // 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 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, bool avoid_decals) const -> DirectionPools { @@ -340,19 +434,30 @@ auto Mummy::isDecalAheadInDir(float dx_signed, float dy_signed) const -> bool { 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`), NO el AABB de la mòmia desplaçat un pas — així - // captura "si la mòmia estiguera centrada en aquell tile, hi pisaria - // un decal?" amb marge. + // `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(Map::tile_size); + const auto ORIG_X = static_cast(Map::tilemap_origin_x); + const auto ORIG_Y = static_cast(Map::tilemap_origin_y); 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 float TILE_X = (std::floor((CENTER_X - ORIG_X) / TS) * TS) + ORIG_X; + const float TILE_Y = (std::floor((CENTER_Y - ORIG_Y) / TS) * TS) + ORIG_Y; const SDL_FRect NEXT{ - .x = BOX.x + (SIGN_OF(dx_signed) * TS), - .y = BOX.y + (SIGN_OF(dy_signed) * TS), + .x = TILE_X + (SIGN_OF(dx_signed) * TS), + .y = TILE_Y + (SIGN_OF(dy_signed) * TS), .w = TS, .h = TS, }; diff --git a/source/game/entities/concrete/mummy.hpp b/source/game/entities/concrete/mummy.hpp index d408cca..41505ab 100644 --- a/source/game/entities/concrete/mummy.hpp +++ b/source/game/entities/concrete/mummy.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -143,6 +144,16 @@ class Mummy : public DynamicHazard { // totes ho estan, retorna `NONE` (la mòmia queda quieta en HURT). [[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 // `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 @@ -166,4 +177,12 @@ class Mummy : public DynamicHazard { // bloc `stats.kills_outright` del YAML; default true per coherència // amb el comportament d'Oh Mummy! (les mòmies maten). 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}; }; From 7cd7b4dc4f0c592a692bf5be8cf0900cbc164249 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 18:52:49 +0200 Subject: [PATCH 08/10] chore(hole): neteja de comentaris obsolets i notes sobre riscos latents --- data/player/morcus.yaml | 7 ++----- data/player/sam.yaml | 7 ++----- source/game/entities/concrete/hole.hpp | 13 ++++++++++--- source/game/entities/entity_manager_loadlevel.cpp | 9 +++++++++ source/game/entities/player.cpp | 7 +++---- source/game/entities/player.hpp | 8 ++++++++ 6 files changed, 34 insertions(+), 17 deletions(-) diff --git a/data/player/morcus.yaml b/data/player/morcus.yaml index 53fd579..07823bb 100644 --- a/data/player/morcus.yaml +++ b/data/player/morcus.yaml @@ -73,11 +73,8 @@ animations: frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] loop: false - # Mort per caiguda a un agujero (DeathCause::HOLE). Frame i durada - # **placeholders** — pendents de Sergio: idealment l'índex de la - # casella buida del spritesheet i una durada curta. `loop: false` - # com `death_spinning` perquè `animationIsCompleted()` dispari el - # respawn timer determinísticament. + # 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 diff --git a/data/player/sam.yaml b/data/player/sam.yaml index 5fad03a..7c33023 100644 --- a/data/player/sam.yaml +++ b/data/player/sam.yaml @@ -73,11 +73,8 @@ animations: frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] loop: false - # Mort per caiguda a un agujero (DeathCause::HOLE). Frame i durada - # **placeholders** — pendents de Sergio: idealment l'índex de la - # casella buida del spritesheet i una durada curta. `loop: false` - # com `death_spinning` perquè `animationIsCompleted()` dispari el - # respawn timer determinísticament. + # 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 diff --git a/source/game/entities/concrete/hole.hpp b/source/game/entities/concrete/hole.hpp index 3f05bb3..1dbda64 100644 --- a/source/game/entities/concrete/hole.hpp +++ b/source/game/entities/concrete/hole.hpp @@ -8,9 +8,12 @@ // - `collides_=false` + `triggers_on_overlap_=true`: rep `onPlayerContact` // pel bucle de `EntityManager::checkPlayerCollisions` sense bloquejar // el moviment del jugador. -// - `height_=0`: el filtre de col·lisió per altura existent deixa que -// un jugador AIRBORNE amb `jump_z_>0` el creui sense disparar res -// (saltar per damunt d'un forat). +// - `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_`. @@ -32,3 +35,7 @@ class Hole : public StaticHazard { // 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): si apareix un quart callsite del càlcul "pixel→tile" o "tileRect", factoritzar a `Map::` (avui: Mummy::reactToCurrentTile, Mummy::isDecalAheadInDir, Player::integrateJump). +// TODO(hole-feature): afegir tests dedicats del comportament de la mòmia amb forats (no requereixen Player real). diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 7d25c40..f776f17 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -77,6 +77,15 @@ void EntityManager::checkPlayerCollisions(const std::vector& players) { // 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 // damunt de l'alçada del cos de l'entitat, no hi ha contacte. diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 4cc909f..ec7c2d2 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -1369,10 +1369,9 @@ auto Player::statePrefix(State state, bool moving, bool in_attack_swing) -> cons case State::ATTACKING: return in_attack_swing ? "attack" : "recover"; case State::DYING: - // L'animació concreta (`death_spinning`) la fixa `enterDying`; - // `applyDyingMovement` no torna a cridar `setCurrentAnimation`, - // així que aquest valor no s'usa mai. El retornem només per - // exhaustivitat del switch. + // L'animació concreta (`death_spinning` o `death_hole`) la + // selecciona `enterDying` segons `DeathCause`; aquest retorn + // només cobreix exhaustivitat del switch i no s'usa al runtime. return "death"; case State::GROUNDED: case State::EXITED: diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index e269240..c1a8617 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -250,6 +250,14 @@ class Player : public AnimatedSprite { // 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. From 7d0aca506a2a88f32688128ef1544d22bfe4612e Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 19:16:21 +0200 Subject: [PATCH 09/10] =?UTF-8?q?feat(decals):=20tiles=20de=20decal=20de?= =?UTF-8?q?=20s=C3=B2l=20compten=20com=20a=20pintats=20des=20del=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/entities/concrete/hole.hpp | 1 - source/game/entities/concrete/mummy.cpp | 18 +++++-------- .../entities/entity_manager_loadlevel.cpp | 26 +++++++++++++++++++ source/game/map/map.cpp | 8 ++++++ source/game/map/map.hpp | 12 +++++++++ source/game/map/paint_layer.cpp | 21 +++++++++++++++ 6 files changed, 74 insertions(+), 12 deletions(-) diff --git a/source/game/entities/concrete/hole.hpp b/source/game/entities/concrete/hole.hpp index 1dbda64..13fcb25 100644 --- a/source/game/entities/concrete/hole.hpp +++ b/source/game/entities/concrete/hole.hpp @@ -37,5 +37,4 @@ class Hole : public StaticHazard { }; // TODO(hole-feature): afegir reset de `falling_through_hole_` i `hole_clip_bottom_y_` a `Player::disable()` per simetria. -// TODO(hole-feature): si apareix un quart callsite del càlcul "pixel→tile" o "tileRect", factoritzar a `Map::` (avui: Mummy::reactToCurrentTile, Mummy::isDecalAheadInDir, Player::integrateJump). // TODO(hole-feature): afegir tests dedicats del comportament de la mòmia amb forats (no requereixen Player real). diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index 40e08ab..90c0eca 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -291,13 +291,9 @@ void Mummy::reactToCurrentTile() { // per a una direcció negativa, el menor (`x`); per a zero, qualsevol // (no hi ha avenç en aquest eix). const auto BOX = getCollisionRect(); - const auto TS = static_cast(Map::tile_size); - const auto ORIG_X = static_cast(Map::tilemap_origin_x); - const auto ORIG_Y = static_cast(Map::tilemap_origin_y); 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 int CUR_COL = static_cast(std::floor((LEAD_X - ORIG_X) / TS)); - const int CUR_ROW = static_cast(std::floor((LEAD_Y - ORIG_Y) / TS)); + 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). @@ -318,9 +314,10 @@ void Mummy::reactToCurrentTile() { // 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(Map::tile_size); const SDL_FRect TILE_RECT{ - .x = (static_cast(CUR_COL) * TS) + ORIG_X, - .y = (static_cast(CUR_ROW) * TS) + ORIG_Y, + .x = (static_cast(CUR_COL) * TS) + static_cast(Map::tilemap_origin_x), + .y = (static_cast(CUR_ROW) * TS) + static_cast(Map::tilemap_origin_y), .w = TS, .h = TS, }; @@ -444,8 +441,6 @@ auto Mummy::isDecalAheadInDir(float dx_signed, float dy_signed) const -> bool { // tile abans del veritablement adjacent al decal. const auto BOX = getCollisionRect(); const auto TS = static_cast(Map::tile_size); - const auto ORIG_X = static_cast(Map::tilemap_origin_x); - const auto ORIG_Y = static_cast(Map::tilemap_origin_y); const auto SIGN_OF = [](float v) -> float { if (v > 0.0F) { return 1.0F; } if (v < 0.0F) { return -1.0F; } @@ -453,8 +448,9 @@ auto Mummy::isDecalAheadInDir(float dx_signed, float dy_signed) const -> bool { }; const float CENTER_X = BOX.x + (BOX.w * 0.5F); const float CENTER_Y = BOX.y + (BOX.h * 0.5F); - const float TILE_X = (std::floor((CENTER_X - ORIG_X) / TS) * TS) + ORIG_X; - const float TILE_Y = (std::floor((CENTER_Y - ORIG_Y) / TS) * TS) + ORIG_Y; + const auto [CUR_COL, CUR_ROW] = Map::pixelToTile(CENTER_X, CENTER_Y); + const float TILE_X = (static_cast(CUR_COL) * TS) + static_cast(Map::tilemap_origin_x); + const float TILE_Y = (static_cast(CUR_ROW) * TS) + static_cast(Map::tilemap_origin_y); const SDL_FRect NEXT{ .x = TILE_X + (SIGN_OF(dx_signed) * TS), .y = TILE_Y + (SIGN_OF(dy_signed) * TS), diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index f776f17..0eb4181 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -420,6 +420,32 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) { 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 " << level_yaml_path << '\n'; } diff --git a/source/game/map/map.cpp b/source/game/map/map.cpp index b55912d..8271c0e 100644 --- a/source/game/map/map.cpp +++ b/source/game/map/map.cpp @@ -1,5 +1,6 @@ #include "game/map/map.hpp" +#include #include #include @@ -150,6 +151,13 @@ namespace Map { return true; } + auto pixelToTile(float px, float py) -> std::pair { + const auto TS = static_cast(tile_size); + const int COL = static_cast(std::floor((px - static_cast(tilemap_origin_x)) / TS)); + const int ROW = static_cast(std::floor((py - static_cast(tilemap_origin_y)) / TS)); + return {COL, ROW}; + } + auto makeInfo() -> Info { return { .playfield_x = playfield_x, diff --git a/source/game/map/map.hpp b/source/game/map/map.hpp index 62cf463..d9560c6 100644 --- a/source/game/map/map.hpp +++ b/source/game/map/map.hpp @@ -3,6 +3,7 @@ #include #include +#include // 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 @@ -105,6 +106,17 @@ namespace Map { // usa el `PaintLayer` per filtrar quines cel·les accepten pintat. [[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; + // Snapshot plà de la geometria del mapa + punters a `collides` / // `insidePlayfield`. Pensat per ser passat per valor (POD petit, // trivialment copiable). Un cop construïda, llegeix els valors diff --git a/source/game/map/paint_layer.cpp b/source/game/map/paint_layer.cpp index 5d79b20..ced3edc 100644 --- a/source/game/map/paint_layer.cpp +++ b/source/game/map/paint_layer.cpp @@ -12,6 +12,27 @@ #include "game/map/map.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 { // Blit d'un tile del tileset sobre un buffer ARGB de destí, respectant From 17cf6e06365fb4f0d5346bfe7d958466eb3f3573 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 19:29:53 +0200 Subject: [PATCH 10/10] =?UTF-8?q?chore:=20bump=20v0.31.0=20=E2=80=94=20haz?= =?UTF-8?q?ard=20Hole,=20IA=20d'evitaci=C3=B3=20de=20m=C3=B2mies=20i=20bro?= =?UTF-8?q?adcast=20de=20decals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/utils/defines.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/utils/defines.hpp b/source/utils/defines.hpp index 4254547..58e84dc 100644 --- a/source/utils/defines.hpp +++ b/source/utils/defines.hpp @@ -3,7 +3,7 @@ namespace Texts { constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — 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 GameCanvas {