From 674978035620b53fb975ea2a4279a033c527f0f2 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 13:03:35 +0200 Subject: [PATCH] feat(entities): afegeix stone i column com a muros decoratius --- CMakeLists.txt | 1 + data/entities/column/column.yaml | 22 +++ data/entities/stone/stone.yaml | 22 +++ data/maps/p1/r1.yaml | 6 + source/game/editor/editor.cpp | 178 +++++++++++++++++- source/game/editor/editor.hpp | 38 +++- source/game/entities/concrete/solid_props.cpp | 27 +++ source/game/entities/entity.hpp | 18 ++ source/game/entities/entity_manager.cpp | 23 +++ source/game/entities/entity_manager.hpp | 5 + .../entities/entity_manager_loadlevel.cpp | 65 +++++-- source/game/entities/entity_writer.cpp | 3 + source/game/entities/entity_writer.hpp | 4 + source/game/entities/solid_decoration.hpp | 26 +++ source/game/map/map.cpp | 17 ++ source/game/map/map.hpp | 11 +- source/game/scenes/game_scene.cpp | 16 +- tests/test_entities_smoke.cpp | 2 + 18 files changed, 448 insertions(+), 36 deletions(-) create mode 100644 data/entities/column/column.yaml create mode 100644 data/entities/stone/stone.yaml create mode 100644 source/game/entities/concrete/solid_props.cpp create mode 100644 source/game/entities/solid_decoration.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 77dada9..233d03d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,6 +52,7 @@ set(ENTITY_SOURCES_BASE source/game/entities/concrete/floor_trap.cpp source/game/entities/concrete/hole.cpp source/game/entities/concrete/dust.cpp + source/game/entities/concrete/solid_props.cpp ) # Entitats que criden directament símbols de Player (com WhipAttack via diff --git a/data/entities/column/column.yaml b/data/entities/column/column.yaml new file mode 100644 index 0000000..bba46f1 --- /dev/null +++ b/data/entities/column/column.yaml @@ -0,0 +1,22 @@ +name: column +type: decoration +editable: true +# Sub-picker de frames a l'editor: 6 variants (atlas 2×3 de sub-sprites 16×32). +editable_variants: true +sprite: + path: ../debris/column.gif + size: [16, 32] +# Ancoratge "feet": el tile [col, row] del spawn és la base de la columna. +# El sprite de 16×32 sobresurt 16 px cap amunt sense afectar col·lisió ni +# z-order (la caixa lògica és el tile inferior). +anchor: feet +tile_size: [1, 1] +collision: + size: [16, 16] + # Mur infranquejable al salt: el jugador no pot passar pel damunt. + height: 999 +flags: + collides: true + interactable: false +animations: + default: { frames: [0], fps: 1, loop: false } diff --git a/data/entities/stone/stone.yaml b/data/entities/stone/stone.yaml new file mode 100644 index 0000000..c3bd0ee --- /dev/null +++ b/data/entities/stone/stone.yaml @@ -0,0 +1,22 @@ +name: stone +type: decoration +editable: true +# Sub-picker de frames a l'editor: ofereix una graella amb les 24 variants +# del atlas (6×4 sub-sprites 16×16) per escollir quina representa cada +# instància col·locada. +editable_variants: true +sprite: + path: ../debris/stone.gif + size: [16, 16] +tile_size: [1, 1] +collision: + size: [16, 16] + # Mur infranquejable al salt: el jugador no la pot saltar per damunt. + height: 999 +flags: + collides: true + interactable: false +# Animació "default" amb un sol frame: el spawn YAML pot sobreescriure-la +# via `frame: N` per fixar la variant escollida al editor. +animations: + default: { frames: [0], fps: 1, loop: false } diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index 4073d81..4db9337 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -131,5 +131,11 @@ entities: - type: torch position: [4, 2] - type: door + - type: column + position: [6, 5] + frame: 2 - type: mummy position: [10, 11] + - type: stone + position: [10, 5] + frame: 16 diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 9d2155c..d57fb93 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -176,6 +176,23 @@ namespace { return {PX, PY}; } + // Compta sub-sprites del atlas i decideix si el template demana + // sub-picker de frames. Extret de `ensureEditableEntities` per acotar + // la complexitat cognitiva del callsite. Per a strips d'1 frame + // (la majoria del catàleg) retorna (1, false). + struct AtlasVariants { + int frame_count; + bool has_variants; + }; + + auto computeAtlasVariants(const Surface& surface, int frame_w, int frame_h, const fkyaml::node& tmpl) -> AtlasVariants { + const int COLS = (frame_w > 0) ? (surface.getWidth() / frame_w) : 1; + const int ROWS = (frame_h > 0) ? (surface.getHeight() / frame_h) : 1; + const int FRAME_COUNT = std::max(1, COLS * ROWS); + const bool FLAG = tmpl.contains("editable_variants") && tmpl["editable_variants"].get_value(); + return {.frame_count = FRAME_COUNT, .has_variants = FLAG && FRAME_COUNT > 1}; + } + } // namespace void Editor::enter() { @@ -190,6 +207,10 @@ void Editor::enter() { picker_ = Picker::NONE; brush_tile_ = -1; brush_entity_.clear(); + brush_entity_frame_ = -1; + entity_frame_picker_source_ = nullptr; + entity_frame_picker_hover_ = -1; + entity_frame_picker_scroll_ = 0; hand_selected_idx_ = -1; hand_hover_idx_ = -1; ensureEditableEntities(); @@ -256,7 +277,15 @@ void Editor::ensureEditableEntities() { } if (surface == nullptr) { continue; } - editable_entities_.push_back({.name = name, .sprite = surface, .frame_w = FRAME_W, .frame_h = FRAME_H}); + const auto [FRAME_COUNT, HAS_VARIANTS] = computeAtlasVariants(*surface, FRAME_W, FRAME_H, tmpl); + editable_entities_.push_back({ + .name = name, + .sprite = surface, + .frame_w = FRAME_W, + .frame_h = FRAME_H, + .frame_count = FRAME_COUNT, + .has_variants = HAS_VARIANTS, + }); } } @@ -301,6 +330,13 @@ namespace { s.col = (static_cast(R.x) - Map::tilemap_origin_x) / Map::tile_size; s.row = (static_cast(R.y) - Map::tilemap_origin_y) / Map::tile_size; } + // Variant del atlas: si l'entitat té un AnimationComponent + // d'un sol frame, és una entitat amb variant escollida + // (debris, columna). Persistim l'índex perquè la pròxima + // càrrega restauri la mateixa cara del sub-sprite. + if (const auto& anim = e->getAnimation(); anim.has_value() && anim->frameCount() == 1) { + s.frame = anim->currentFrame(); + } } out.push_back(std::move(s)); } @@ -374,6 +410,7 @@ auto Editor::applyHotkey(int scancode) -> bool { // tracte per a la seva acció de confirmar-eixida. if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) { picker_ = Picker::NONE; + entity_frame_picker_source_ = nullptr; return true; } return false; @@ -426,6 +463,8 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap, EntityManager& entities) updateTilePicker(tilemap, CX, CY); } else if (picker_ == Picker::ENTITY) { updateEntityPicker(CX, CY); + } else if (picker_ == Picker::ENTITY_FRAME) { + updateEntityFramePicker(CX, CY); } else { switch (mode_) { case Mode::TILE: @@ -514,7 +553,9 @@ namespace { // Composa el spawn YAML per al brush actual. Retorna empty si el snap // no és vàlid (fora de rejilla, solapament, o duplicat de porta). - auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector& existing) -> std::string { + // `frame` (-1 = no emetre) afegeix el camp `frame: N` per a entitats + // amb variants estàtiques (debris, columna). + auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector& existing, int frame) -> std::string { std::ostringstream y; y << "type: " << brush << '\n'; if (brush == "sarcophagus") { @@ -530,6 +571,7 @@ namespace { const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size); if (cellOccupied(existing, DEST)) { return {}; } y << "position: [" << cursor_col << ", " << cursor_row << "]\n"; + if (frame >= 0) { y << "frame: " << frame << '\n'; } return y.str(); } @@ -546,6 +588,7 @@ void Editor::updateEntityBrush(EntityManager& entities, int /*canvas_x*/, int /* // H (mà): manté la simetria amb el comportament del brush // de tiles. brush_entity_.clear(); + brush_entity_frame_ = -1; } } } @@ -665,7 +708,14 @@ auto Editor::tryMoveSelected(const Tilemap& tilemap, EntityManager& entities) co filtered.reserve(list.size() - 1); std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; }); - const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered); + // Si l'entitat moguda té una variant fixada (AnimationComponent d'un + // sol frame, convenció per a debris/columnes), la preservem al spawn + // reconstruït — així moure no perd la cara escollida. + int frame = -1; + if (const auto& anim = target->getAnimation(); anim.has_value() && anim->frameCount() == 1) { + frame = anim->currentFrame(); + } + const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered, frame); if (STR.empty()) { return false; } if (!entities.removeEntity(target)) { return false; } try { @@ -806,7 +856,7 @@ void Editor::renderEntityFrame(const Entity& e, std::uint32_t argb) { auto Editor::tryPlaceEntity(EntityManager& entities) -> bool { if (brush_entity_.empty() || cursor_col_ < 0 || cursor_row_ < 0) { return false; } - const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities()); + const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities(), brush_entity_frame_); if (STR.empty()) { return false; } try { auto node = fkyaml::node::deserialize(STR.begin(), STR.end()); @@ -840,9 +890,21 @@ void Editor::updateEntityPicker(int canvas_x, int canvas_y) { entity_picker_scroll_ = std::clamp(entity_picker_scroll_, 0, LAY.max_scroll); } else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) { if (entity_picker_hover_ >= 0 && entity_picker_hover_ < static_cast(editable_entities_.size())) { - brush_entity_ = editable_entities_[entity_picker_hover_].name; - mode_ = Mode::ENTITY; - picker_ = Picker::NONE; + const auto& sel = editable_entities_[entity_picker_hover_]; + if (sel.has_variants) { + // Entitat amb variants: encara no fixem brush. Obrim + // el sub-picker de frames; el brush es fixarà només + // quan l'usuari trii un frame concret allà. + entity_frame_picker_source_ = &sel; + entity_frame_picker_scroll_ = 0; + entity_frame_picker_hover_ = -1; + picker_ = Picker::ENTITY_FRAME; + } else { + brush_entity_ = sel.name; + brush_entity_frame_ = -1; + mode_ = Mode::ENTITY; + picker_ = Picker::NONE; + } } } } @@ -896,6 +958,106 @@ void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const { screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER); } +void Editor::updateEntityFramePicker(int canvas_x, int canvas_y) { + // Si la font ha desaparegut (no hauria de passar mentre el picker + // estigui obert, però defensem el cas), tanquem el sub-picker en lloc + // de fer crash. + if (entity_frame_picker_source_ == nullptr) { + picker_ = Picker::ENTITY; + return; + } + const auto LAY = layoutEntityPicker(entity_frame_picker_source_->frame_count); + entity_frame_picker_hover_ = -1; + 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.pitch > 0 && LAY.cols > 0) { + const int VC = RX / LAY.pitch; + const int VR = RY / LAY.pitch; + const bool IN_SPACING_X = (RX % LAY.pitch) >= ENTITY_CELL; + const bool IN_SPACING_Y = (RY % LAY.pitch) >= ENTITY_CELL; + if (!IN_SPACING_X && !IN_SPACING_Y && VC < LAY.cols && VR < LAY.rows_visible) { + const int IDX = ((entity_frame_picker_scroll_ + VR) * LAY.cols) + VC; + if (IDX < LAY.total_items) { entity_frame_picker_hover_ = IDX; } + } + } + + for (const auto& ev : EventBuffer::events) { + if (ev.type == SDL_EVENT_MOUSE_WHEEL) { + entity_frame_picker_scroll_ -= static_cast(ev.wheel.y); + entity_frame_picker_scroll_ = std::clamp(entity_frame_picker_scroll_, 0, LAY.max_scroll); + } else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) { + if (entity_frame_picker_hover_ >= 0) { + brush_entity_ = entity_frame_picker_source_->name; + brush_entity_frame_ = entity_frame_picker_hover_; + mode_ = Mode::ENTITY; + picker_ = Picker::NONE; + } + } else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_RIGHT) { + // Botó dret: tanca el sub-picker i torna al picker d'entitats + // sense fixar brush. Coherent amb la convenció de "right = + // cancel·la l'acció actual" dels altres modes. + picker_ = Picker::ENTITY; + entity_frame_picker_source_ = nullptr; + } + } +} + +void Editor::renderEntityFramePicker() const { + if (entity_frame_picker_source_ == nullptr) { return; } + const auto& src = *entity_frame_picker_source_; + const auto LAY = layoutEntityPicker(src.frame_count); + if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; } + auto& screen = Screen::get(); + const auto& cfg = EditorConfig::get(); + screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb()); + + // Cols del atlas (sub-sprites per fila): es dedueix de la mida del + // surface i del frame, igual que fa `EntityManager::renderEntity`. + const int ATLAS_COLS = (src.frame_w > 0) ? (src.sprite->getWidth() / src.frame_w) : 1; + const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1; + + for (int vr = 0; vr < LAY.rows_visible; ++vr) { + const int LOGICAL_ROW = entity_frame_picker_scroll_ + vr; + for (int vc = 0; vc < LAY.cols; ++vc) { + const int IDX = (LOGICAL_ROW * LAY.cols) + vc; + if (IDX >= LAY.total_items) { break; } + const auto [PX, PY] = entityCellOrigin(LAY, vc, vr); + const int OX = PX + ((ENTITY_CELL - src.frame_w) / 2); + const int OY = PY + ((ENTITY_CELL - src.frame_h) / 2); + const int A_COL = IDX % ATLAS_COLS_SAFE; + const int A_ROW = IDX / ATLAS_COLS_SAFE; + const SDL_FRect SRC{ + .x = static_cast(A_COL * src.frame_w), + .y = static_cast(A_ROW * src.frame_h), + .w = static_cast(src.frame_w), + .h = static_cast(src.frame_h), + }; + src.sprite->render(OX, OY, &SRC, SDL_FLIP_NONE); + } + } + + if (entity_frame_picker_hover_ >= 0) { + const int VR = (entity_frame_picker_hover_ / LAY.cols) - entity_frame_picker_scroll_; + const int VC = entity_frame_picker_hover_ % LAY.cols; + if (VR >= 0 && VR < LAY.rows_visible) { + const auto [PX, PY] = entityCellOrigin(LAY, VC, VR); + const auto C = cfg.pickerHoverArgb(); + screen.fillRect(PX, PY, ENTITY_CELL, 1, C); + screen.fillRect(PX, PY + ENTITY_CELL - 1, ENTITY_CELL, 1, C); + screen.fillRect(PX, PY, 1, ENTITY_CELL, C); + screen.fillRect(PX + ENTITY_CELL - 1, PY, 1, ENTITY_CELL, C); + } + } + + 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); +} + void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) { const auto LAY = layoutPicker(tilemap); tile_picker_hover_ = -1; @@ -940,6 +1102,8 @@ void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const renderTilePicker(tilemap); } else if (picker_ == Picker::ENTITY) { renderEntityPicker(tilemap); + } else if (picker_ == Picker::ENTITY_FRAME) { + renderEntityFramePicker(); } else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) { // Preview del brush sota el cursor (només quan el picker és tancat). const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize()); diff --git a/source/game/editor/editor.hpp b/source/game/editor/editor.hpp index 6e7d814..cdf99f9 100644 --- a/source/game/editor/editor.hpp +++ b/source/game/editor/editor.hpp @@ -78,9 +78,13 @@ class Editor { HAND }; // Quin picker està obert (si n'hi ha). Mútuament excloents. + // - ENTITY_FRAME: sub-picker que apareix després de clicar una entitat + // amb variants al picker principal. Mostra tots els sub-sprites del + // atlas (índex frame) per a triar quina cara representa la instància. enum class Picker : std::uint8_t { NONE, TILE, - ENTITY }; + ENTITY, + ENTITY_FRAME }; // Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`. // Es crida al principi d'`update` perquè altres handlers no vegin @@ -100,6 +104,7 @@ class Editor { void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y); void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y); void updateEntityPicker(int canvas_x, int canvas_y); + void updateEntityFramePicker(int canvas_x, int canvas_y); // Intent de col·locació segons brush_entity_ a la cel·la del cursor. // Construeix un node spawn sintètic, fa el snap concret per tipus @@ -138,6 +143,7 @@ class Editor { void renderTilePicker(const Tilemap& tilemap) const; void renderEntityPicker(const Tilemap& tilemap) const; + void renderEntityFramePicker() const; bool active_{false}; Mode mode_{Mode::TILE}; @@ -153,21 +159,22 @@ class Editor { int tile_picker_scroll_{0}; int tile_picker_hover_{-1}; - // Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap). - std::string brush_entity_; - // Estat de l'EntityPicker. - int entity_picker_scroll_{0}; - int entity_picker_hover_{-1}; // Índex a la llista de noms. - // Entrada editable del picker. Cada item recorda el nom (clau de // factory + YAML), el surface del sprite (frame 0 com a icona) i la // mida de cada frame al GIF — necessària per clipar el primer frame // quan el GIF és un strip multi-frame. + // - `frame_count` és el nombre total de sub-sprites del atlas + // (cols × rows segons les dimensions del surface i del frame). + // - `has_variants` és `true` quan el template declara `editable_variants: + // true` i hi ha més d'un frame: aleshores clicar al picker obre el + // sub-picker de frames en lloc de fixar el brush directament. struct EditableEntity { std::string name; std::shared_ptr sprite; int frame_w{0}; int frame_h{0}; + int frame_count{1}; + bool has_variants{false}; }; // Construïda lazy al primer enter() i preservada entre sessions: @@ -178,6 +185,23 @@ class Editor { std::vector editable_entities_; + // Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap). + std::string brush_entity_; + // Frame escollit per al brush actiu (-1 = no aplicable: entitat sense + // variants o brush encara per fixar). El emet el sub-picker en clicar + // un frame; `tryPlaceEntity` el passa al spawn YAML com a `frame: N`. + int brush_entity_frame_{-1}; + // Estat de l'EntityPicker. + int entity_picker_scroll_{0}; + int entity_picker_hover_{-1}; // Índex a la llista de noms. + // Estat del sub-picker de frames. `entity_frame_picker_source_` apunta + // a la entitat activa (vista al EntityPicker) per a la qual el + // sub-picker mostra les variants. Quan el sub-picker es tanca sense + // selecció, ho deixem a nullptr i `brush_entity_frame_ = -1`. + int entity_frame_picker_scroll_{0}; + int entity_frame_picker_hover_{-1}; + const EditableEntity* entity_frame_picker_source_{nullptr}; + // Font compartida amb el Notifier/HUD: el surface el carrega Director // a l'arrencada, ací només prenem la referència del cache. shared_ptr // perquè `Text` accepta el surface per shared_ptr i així no copiem. diff --git a/source/game/entities/concrete/solid_props.cpp b/source/game/entities/concrete/solid_props.cpp new file mode 100644 index 0000000..40aeddc --- /dev/null +++ b/source/game/entities/concrete/solid_props.cpp @@ -0,0 +1,27 @@ +#include + +#include "external/fkyaml_node.hpp" +#include "game/entities/entity_factory.hpp" +#include "game/entities/solid_decoration.hpp" + +// Murs decoratius estàtics que comparteixen el mateix codi C++ però es +// registren amb claus de factory diferents per a que el YAML els pugui +// referenciar pel seu tipus i el editor els pugui distingir al picker. +// La selecció del sub-sprite (frame del atlas) viatja al spawn YAML +// (`frame: N`); l'aplica `placeGeneric` via `Entity::setAnimation`. Aquí no +// cal cap lògica específica per a la variant. +namespace { + + class StaticSolid : public SolidDecoration { + public: + explicit StaticSolid(const fkyaml::node& yaml) { + if (yaml.contains("name")) { + name_ = yaml["name"].get_value(); + } + } + }; + +} // namespace + +REGISTER_ENTITY_AS("stone", StaticSolid, Stone) +REGISTER_ENTITY_AS("column", StaticSolid, Column) diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 3a949d7..23f11ed 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -125,6 +125,15 @@ class Entity { [[nodiscard]] auto collides() const -> bool { return collides_; } [[nodiscard]] auto interactable() const -> bool { return interactable_; } + // Marca d'entitat que ha de bloquejar el moviment del jugador (com un + // mur). `collides_=true` només garanteix que l'entitat reb el callback + // `onPlayerContact` quan el jugador la toca, no que el seu rect sigui + // sòlid: dynamic hazards (mòmies, roques) tenen `collides_=true` però + // no han de bloquejar el pas — el contacte resol el dany i la roca es + // mou independentment. Per a "murs passius" (sarcs, columnes, debris) + // posem aquest flag a true i `Map::collides` els tracta com a obstacle. + [[nodiscard]] auto blocksMovement() const -> bool { return blocks_movement_; } + // 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 @@ -187,6 +196,14 @@ class Entity { // Entitats sense animació retornen `nullopt` → frame 0 per defecte. [[nodiscard]] auto getAnimation() const -> const std::optional& { return anim_; } + // Sobreescriu l'`AnimationComponent` actual. L'usa el loader del nivell + // per aplicar el camp opcional `frame: N` del spawn a entitats amb + // variants estàtiques (debris, columnes, etc.): el spawn fixa una + // animació de 1 frame al índex escollit i el render del manager clipa + // el sub-sprite corresponent. També és útil per a tests que volen + // controlar el frame mostrat sense passar pel YAML. + void setAnimation(AnimationComponent anim) { anim_ = std::move(anim); } + // Alçada del cos de l'entitat en píxels. La consulten els filtres // de col·lisió per altura: si un jugador en l'aire passa per damunt // d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat — @@ -240,6 +257,7 @@ class Entity { bool interactable_{false}; bool triggers_on_overlap_{false}; bool is_floor_decal_{false}; + bool blocks_movement_{false}; int height_{0}; bool dead_{false}; SDL_FlipMode flip_{SDL_FLIP_NONE}; diff --git a/source/game/entities/entity_manager.cpp b/source/game/entities/entity_manager.cpp index 2bdce84..d995d32 100644 --- a/source/game/entities/entity_manager.cpp +++ b/source/game/entities/entity_manager.cpp @@ -7,6 +7,7 @@ #include "game/entities/concrete/door.hpp" #include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/entity.hpp" +#include "game/map/map.hpp" // NOTA: `loadFromLevel` viu a `entity_manager_loadlevel.cpp` per evitar // arrossegar `Resource::Cache`, `Surface` i les seues dependències (GIF, @@ -51,12 +52,23 @@ EntityManager::EntityManager() { }); } }); + + // Connectar amb el check global de col·lisió del Map: les entitats + // sòlides genèriques (debris, columnes, futurs murs) viuen al manager, + // no a la graella fixa de sarcs, així que el `Map::collides` del Player + // les ha de consultar via callback. Es desregistra al dtor. + Map::setEntityCollider([this](const SDL_FRect& box) { + return entityCollidesAt(box); + }); } EntityManager::~EntityManager() { bus_.unsubscribe(sarc_sub_id_); bus_.unsubscribe(tile_sub_id_); bus_.unsubscribe(exit_sub_id_); + // Desconnectar del Map: si quedem en namespace global apuntant a un + // `this` que mor, qualsevol `Map::collides` posterior cauria en UB. + Map::setEntityCollider(nullptr); } void EntityManager::setExpectedPlayerCount(int n) { @@ -153,6 +165,17 @@ auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Ent return nullptr; } +auto EntityManager::entityCollidesAt(const SDL_FRect& box) const -> bool { + // Iteració lineal sobre entitats amb `blocksMovement()`. La convenció + // d'overlap és la mateixa que `Map::collides` (estricta: el toc al + // píxel no compta) per coherència en els passadissos del laberint. + return std::ranges::any_of(entities_, [&box](const auto& e) { + if (!e->blocksMovement()) { return false; } + const auto R = e->getCollisionRect(); + return (R.x + R.w) > box.x && (box.x + box.w) > R.x && (R.y + R.h) > box.y && (box.y + box.h) > R.y; + }); +} + auto EntityManager::getEntities() const -> std::vector { std::vector out; out.reserve(entities_.size()); diff --git a/source/game/entities/entity_manager.hpp b/source/game/entities/entity_manager.hpp index 6432762..9aa2b09 100644 --- a/source/game/entities/entity_manager.hpp +++ b/source/game/entities/entity_manager.hpp @@ -181,6 +181,11 @@ class EntityManager { } private: + // Suport per al callback registrat a `Map::setEntityCollider`. Itera + // les entitats amb `blocksMovement()` i fa overlap estricte (mateixa + // convenció que `Map::collides`). El callback el registra el ctor. + [[nodiscard]] auto entityCollidesAt(const SDL_FRect& box) const -> bool; + EventBus bus_; // Un sol contenidor lògic per a totes les entitats instanciades. La // discriminació de tipus la fan els virtuals (`onPlayerContact`, diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index cda17b6..8ce57ed 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -395,6 +395,17 @@ namespace { } entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height}); + // Variant del atlas: el spawn pot fixar un sub-sprite estàtic via + // `frame: N`. El render del manager ja sap clipar (col, row) a + // partir d'un índex lineal i la mida del frame; ací només cal + // muntar un `AnimationComponent` d'un sol frame i imposar-lo a la + // entitat. Cap entitat existent al joc emet aquest camp; només + // els muros decoratius amb variants (stone, column, ...) l'usen. + if (spawn.contains("frame")) { + const int FRAME = spawn["frame"].get_value(); + entity->setAnimation(AnimationComponent({FRAME}, 1.0F, false)); + } + manager.add(std::move(entity)); return true; } @@ -459,31 +470,45 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) { if (addSpawn(spawn) != nullptr) { ++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). + // Broadcast inicial: marca com a "pintats" tots els tiles que el + // jugador no pot trepitjar mai, de manera que el perímetre dels + // sarcòfags vecins ja els consideri visitats i la sala sigui + // completable. Inclou dues famílies: + // - Decals de sòl (Hole, i futurs pinchos plans, plaques, charcos): + // trigger-on-overlap que mataria/atraparia al jugador en trepitjar. + // - Entitats sòlides genèriques (debris, columnes...): obstacles + // físics que `Map::collides` bloqueja, així `isPaintable` retorna + // false per als seus tiles i ningú els pot pintar. + // Usa el canal semàntic `tile_painted` (lògica de joc), no + // `on_tile_entered` (presència física). Es fa 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; - // Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards; - // iterem tot `entities_` i el filtre per `isFloorDecal()` els tria. for (const auto& entity : entities_) { - if (!entity->isFloorDecal()) { continue; } + const bool IS_DECAL = entity->isFloorDecal(); + const bool IS_SOLID = entity->blocksMovement(); + if (!IS_DECAL && !IS_SOLID) { 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; + // Per a entitats que ocupen més d'un tile (futurs murs 32×16, + // 16×32, etc.), iterem tota la zona coberta pel rect. Per a decals + // i blocs d'un sol tile cau a un sol broadcast (mateix comportament + // que abans). + const auto [COL0, ROW0] = Map::pixelToTile(BOX.x, BOX.y); + const int COLS = (Map::tile_size > 0) ? std::max(1, static_cast(BOX.w) / Map::tile_size) : 1; + const int ROWS = (Map::tile_size > 0) ? std::max(1, static_cast(BOX.h) / Map::tile_size) : 1; + for (int dr = 0; dr < ROWS; ++dr) { + for (int dc = 0; dc < COLS; ++dc) { + bus_.publish(Event{ + .name = "tile_painted", + .payload = TilePos{.col = COL0 + dc, .row = ROW0 + dr}, + .player_id = -1, + }); + ++decal_broadcast; + } + } } if (decal_broadcast > 0) { - Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast); + Log::info("EntityManager", "{} tile_painted broadcast inicial (decals + murs)", decal_broadcast); } Log::info("EntityManager", "{} entitats + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path); diff --git a/source/game/entities/entity_writer.cpp b/source/game/entities/entity_writer.cpp index 7195ab5..67711b5 100644 --- a/source/game/entities/entity_writer.cpp +++ b/source/game/entities/entity_writer.cpp @@ -60,6 +60,9 @@ namespace EntityWriter { } else if (s.col.has_value() && s.row.has_value()) { out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n"; } + if (s.frame.has_value()) { + out += " frame: " + std::to_string(*s.frame) + "\n"; + } } return out; } diff --git a/source/game/entities/entity_writer.hpp b/source/game/entities/entity_writer.hpp index bf003f6..05171a3 100644 --- a/source/game/entities/entity_writer.hpp +++ b/source/game/entities/entity_writer.hpp @@ -35,12 +35,16 @@ namespace EntityWriter { // Cada spawn duplica l'estructura mínima del que el lector espera: // type sempre, i un dels (position, sarc_position) o cap (cas door). + // `frame` és opcional i només l'emeten les entitats amb variants + // estàtiques (debris, columnes); fixa quin sub-sprite del atlas + // representa aquesta instància concreta. struct Spawn { std::string type; std::optional col; std::optional row; std::optional sarc_col; std::optional sarc_row; + std::optional frame; }; auto saveEntitiesBlock(const std::string& path, const std::vector& spawns) -> bool; diff --git a/source/game/entities/solid_decoration.hpp b/source/game/entities/solid_decoration.hpp new file mode 100644 index 0000000..98de292 --- /dev/null +++ b/source/game/entities/solid_decoration.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "game/entities/entity.hpp" + +// Mur passiu: bloqueja moviment i salt però no té ni estat ni acció. Paral·lel +// a `Decoration` (sense col·lisió) i `Interactable` (col·lisiona i té +// estat/interact). Pensat per a debris/obstacles purament visuals que sí +// han de detenir el jugador: pedres, columnes, restes. +// +// Diferència amb `Interactable`: no override `interact()` ni +// `notifyTilePainted`. La caixa de col·lisió i alçada efectiva surten del +// YAML (`collision.size`, `collision.height`); aquest ctor només fixa els +// flags i un default raonable per a "mur infranquejable". +class SolidDecoration : public Entity { + public: + using Entity::Entity; + + SolidDecoration() { + collides_ = true; + interactable_ = false; + height_ = 999; + // Sí bloqueja el moviment del jugador: `Map::collides` el consulta + // via callback per parar el moviment com si fos una paret sòlida. + blocks_movement_ = true; + } +}; diff --git a/source/game/map/map.cpp b/source/game/map/map.cpp index a01e3de..798441b 100644 --- a/source/game/map/map.cpp +++ b/source/game/map/map.cpp @@ -21,6 +21,18 @@ namespace { namespace Map { + namespace { + // Predicat extra que el `collides` consulta com a sobre-capa, + // injectat des de `EntityManager` per a entitats sòlides genèriques + // (debris, columnes). El TU local manté l'estat sense exposar-lo + // al header. + std::function g_entity_collider; + } // namespace + + void setEntityCollider(std::function fn) { + g_entity_collider = std::move(fn); + } + void loadFrom(const std::string& yaml_path) { auto bytes = Resource::Helper::loadFile(yaml_path); if (bytes.empty()) { @@ -123,6 +135,11 @@ namespace Map { }; if (overlapStrict(base_box, CORRIDOR)) { return true; } } + // Capa final: entitats sòlides genèriques registrades pel manager. + // S'avalua al final perquè els obstacles fixos (sarcs, porta) són + // l'origen del laberint i el camí més curt; les entitats addicionals + // són normalment poques. + if (g_entity_collider && g_entity_collider(base_box)) { return true; } return false; } diff --git a/source/game/map/map.hpp b/source/game/map/map.hpp index d9560c6..f04254e 100644 --- a/source/game/map/map.hpp +++ b/source/game/map/map.hpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -91,9 +92,17 @@ namespace Map { // Mapa de colisions (separat del mapa de dibuix): retorna true si el // rectangle `base_box` (la base del jugador, per exemple) s'encavalca - // amb cap sarcòfag. + // amb cap sarcòfag, el passadís de la porta tancada, o cap entitat + // sòlida genèrica registrada via `setEntityCollider`. [[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool; + // Registre d'un predicat extern que `collides` consulta com a sobre- + // capa: l'usa `EntityManager` per a entitats amb `blocksMovement()` + // (debris, columnes...) que no estan a la graella fixa de sarcòfags. + // Passa `nullptr` per netejar. Reset implícit a `loadFrom` (cada + // càrrega de nivell sol portar un manager nou). + void setEntityCollider(std::function fn); + // true si la caixa `box` queda completament dins del playfield (zona // interior, sense tocar els murs exteriors). El Player ja té clamp // propi que aplica el mateix constrenyiment; això serveix a les diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index fe85cdf..f30d941 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -1449,7 +1449,21 @@ void GameScene::update(int delta_ms) { // perquè són UI. Avancen abans del bloc de món perquè quan PLAYING // arrenque ja sigui efectiu en el mateix frame. updateRoomTransition(delta_ms); - updateGameOverFlow(delta_ms); + // Game-over flow pausat mentre el menú de servei o l'editor estan + // oberts. L'editor fa `loadRoom` complet i deixa els jugadors DISABLED + // (`isOutOfPlay()==true`) esperant respawn instantani a la sortida; + // si el flux corre durant aquest interval, `activePlayerCount()==0` + // mentre `state_==PLAYING` dispara un CONTINUE_PROMPT espuri que + // acaba portant al LOGO. La pausa aquí és simètrica amb com el menú + // i l'editor congelen la resta del món. +#ifdef _DEBUG + const bool GAME_OVER_PAUSED = MENU_FROZEN || editor_.active(); +#else + const bool GAME_OVER_PAUSED = MENU_FROZEN; +#endif + if (!GAME_OVER_PAUSED) { + updateGameOverFlow(delta_ms); + } #ifdef _DEBUG // Toggle de l'editor. L'editor mateix no consumeix la tecla — la diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index 1736918..0bf7ab7 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -87,6 +87,7 @@ namespace { const std::vector TYPES = { "torch", "sarcophagus", "door", "mummy", "rolling_rock", "wall_trap", "floor_trap", "hole", + "stone", "column", }; for (const auto& type : TYPES) { fkyaml::node tmpl = EntityLoader::loadTemplate(type); @@ -271,6 +272,7 @@ namespace { const std::vector TYPES = { "torch", "sarcophagus", "door", "mummy", "rolling_rock", "wall_trap", "floor_trap", "hole", + "stone", "column", }; for (const auto& type : TYPES) { try {