diff --git a/CMakeLists.txt b/CMakeLists.txt index e442c23..deeca2f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -150,14 +150,19 @@ target_include_directories(${PROJECT_NAME} PRIVATE target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3) -# Copia recursius de la carpeta data/ al costat del binari per que les rutes -# relatives ("data/player/player.gif") funcionen sense desplegament. -add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD +# Còpia recursiva de data/ al costat del binari perquè les rutes relatives +# ("data/player/player.gif") funcionin sense desplegament. Es defineix com +# a custom target amb `ALL` — s'executa a cada `cmake --build` encara que +# el binari no hagi canviat, de manera que editar YAML/GIF sense tocar codi +# igualment sincronitza el build. Abans era un POST_BUILD, i si només es +# modificava una dada la còpia no es disparava. +add_custom_target(sync_data ALL COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/data" - "$/data" + "${CMAKE_BINARY_DIR}/data" COMMENT "Copying data/ next to the binary" ) +add_dependencies(${PROJECT_NAME} sync_data) target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic) target_compile_definitions(${PROJECT_NAME} PRIVATE diff --git a/data/entities/mummy/mummy.gif b/data/entities/mummy/mummy.gif index 88d5c45..d085a11 100644 Binary files a/data/entities/mummy/mummy.gif and b/data/entities/mummy/mummy.gif differ diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index 4068151..4639414 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -4,23 +4,32 @@ sprite: path: mummy.gif size: [12, 24] tile_size: [1, 1] -# La momia usa falsa perspectiva: el sprite fa 24 px d'alt però la caixa -# AABB només ocupa els 12×12 de baix (els "peus"). L'spawn `position` del -# map és el tile dels peus: el sprite es centra horitzontalment i la -# seua vora inferior s'alinea amb la vora inferior del tile. +# La momia usa falsa perspectiva: el sprite fa 12×24 (dos 12×12: cap i +# peus) però la caja de col·lisió amb el mapa és 16×16, concèntrica amb +# el rectangle-base 12×12 dels peus (igual que fa el jugador). Així la +# caixa s'estén 2 px a cada costat i 2 px per sota dels peus visuals, +# i amb `anchor: feet` s'alinea exactament amb el tile 16×16 on naix — +# la momia va pel centre del corredor i la caja ocupa la cel·la completa. anchor: feet collision: - size: [12, 12] + size: [16, 16] + base_size: [12, 12] flags: collides: true interactable: false +# Strip de 12 frames en graella 12×1 (mateix esquema que el jugador): +# 0..3 → walk_down (cara avall) +# 4..7 → walk_up (cara amunt) +# 8..11 → walk (lateral; flip horitzontal segons el sentit) +# La momia no té variants `stand_*` perquè mai no para. animations: - idle: { frames: [0], fps: 1, loop: true } - walk: { frames: [0], fps: 8, loop: true } + walk_down: { frames: [0, 1, 2, 3], fps: 8, loop: true } + walk_up: { frames: [4, 5, 6, 7], fps: 8, loop: true } + walk: { frames: [8, 9, 10, 11], fps: 8, loop: true } ai: type: corridor_wanderer direction_choice: random initial_direction: right stats: - speed: 40 + speed: 30 damage: 1 diff --git a/data/maps/pyramid.yaml b/data/maps/pyramid.yaml index 566455b..f80bd28 100644 --- a/data/maps/pyramid.yaml +++ b/data/maps/pyramid.yaml @@ -130,3 +130,5 @@ entities: position: [9, 1] - type: mummy position: [10, 7] + - type: mummy + position: [7, 9] diff --git a/docs/entities-system.md b/docs/entities-system.md index 8682c46..29efea5 100644 --- a/docs/entities-system.md +++ b/docs/entities-system.md @@ -248,9 +248,37 @@ Tanca els hooks d'IA i col·lisió que quedaven apuntalats a les fases 1–3. - **Smoke tests**: T12 (CollisionBox), T13 (Mummy::step), T11 (sarc YAML), T10 (torch anim). Total 14/14. -## 7. Estat global +## 7. Fase 5 — Convenció de caixa "peus" com el jugador + animacions direccionals (DONE) -- **Andamiatge, integració, render animat, falsa perspectiva, moviment**: DONE. Els 26 actors del `pyramid.yaml` (4 torches, 20 sarcs, 1 door, 1 mummy) es carreguen, animen, es col·loquen amb l'anchor correcte, col·lisionen segons caixa lògica i el z-order interleava per peus. +Alinea la momia amb la convenció de col·lisió i animació del Player, i soluciona regressions de navegació derivades del canvi de caixa. + +### Caixa concèntrica amb el rectangle-base + +- **`collision.base_size: [bw, bh]`** (nou camp YAML, opcional). Si apareix, la caja no s'alinea a la vora inferior del sprite sinó que queda **concèntrica amb el rectangle-base `bw×bh` dels peus** (els `bh` píxels inferiors del sprite). Fórmula: `off_y = sprite_h − (base_h + H) / 2`. Sense `base_size`, comportament antic (alineada a la vora inferior del sprite). +- **`anchor: feet` ara alinea la caja, no el sprite**. Si l'entitat té `CollisionBox`, el loader col·loca `pixel_rect_` perquè la caja quedi exactament al tile. Sense caja, fallback a la convenció antiga (sprite centrat horitzontalment + vora inferior al tile). Per la momia: sprite 12×24 a (tile_x+2, tile_y−10); caja 16×16 a (tile_x, tile_y) — omple el tile sencer com fa el jugador. +- **`mummy.yaml`**: `collision: { size: [16, 16], base_size: [12, 12] }`. Abans era `[12, 12]` alineada a baix; visualment quedava "enganxada al mur inferior" dins dels corredors horitzontals. + +### Snap-to-edge per girar al playfield + +La caja 16×16 té exactament l'amplada del corredor (16 px). Amb el moviment contínu `speed × delta_time` (~0.66 px/frame), la momia s'aturava a una posició subpíxel dins de l'última cel·la (p. ex. `x=279.87` en lloc de `x=280`) i qualsevol gir perpendicular col·lidia amb el sarc adjacent al limit. [`Mummy::snapToPlayfieldEdge`](../source/game/entities/concrete/mummy.cpp) redueix el `dx/dy` fins a la vora exacta quan el candidat sortiria del playfield; el gir posterior ja parteix d'una posició alineada amb el lane. + +### Animacions direccionals + +- **`mummy.gif` a 12 frames** (graella 12×1) amb el mateix esquema que el jugador: 0–3 walk_down, 4–7 walk_up, 8–11 walk lateral. Sense variants `stand_*` (mai no para). +- **`Mummy` té tres `AnimationComponent`** privats + un helper `changeDirection(dx, dy)` (únic punt d'entrada per canviar direcció) que selecciona l'animació i aplica el flip: `dx ≠ 0` → lateral amb flip horitzontal segons signe; `dy < 0` → up; `dy > 0` → down. +- **`Entity::flip_` a la base** + `getFlip()`. `renderEntity` el passa a `Surface::render`, que ja suportava `SDL_FlipMode`. Les entitats estàtiques queden a `SDL_FLIP_NONE` automàticament. + +### Pipeline de build: sincronització de `data/` + +Canvi a [CMakeLists.txt](../CMakeLists.txt): la còpia de `data/` al build era un `add_custom_command(POST_BUILD)`, que només es dispara quan el binari es relinkeja. Si s'editava només un YAML o un GIF sense tocar codi, `build/data/` quedava obsolet. Ara és un `add_custom_target(sync_data ALL ...)` amb dependència del target principal — els custom targets sense BYPRODUCTS es consideren sempre out-of-date, així que la còpia s'executa a cada `cmake --build`. + +### Refactors de suport + +- `Mummy::step` partit: `snapToPlayfieldEdge`, `collectDirectionPools`, `applyChoice` — per mantenir la complexitat cognitiva sota el llindar de clang-tidy (< 25). + +## 8. Estat global + +- **Andamiatge, integració, render animat, falsa perspectiva, moviment, animació direccional de momia**: DONE. El `pyramid.yaml` actual (4 torches, 20 sarcs, 1 door, 2 mummies) es carrega, anima, col·loca amb l'anchor correcte, col·lisiona segons caixa lògica, el z-order interleava per peus, i la momia gira visualment a cada intersecció. - **Pendents majors**: ENT-HAZ-01 (sistema de vides), ENT-DOOR-01 (transició d'escena), ENT-IA-02 (bias IA), PER-01 (perímetre amb blocked_predicate quan els sarcs siguin veïns). - **Smoke tests**: 14/14 passant; regla durable de cobrir amb smoke tota lògica no trivial respectada a totes les fases. diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index 06638e4..9047e33 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -1,5 +1,6 @@ #include "game/entities/concrete/mummy.hpp" +#include #include #include #include @@ -20,9 +21,36 @@ namespace { return k_rng_; } + // Llegeix un bloc d'animació del YAML amb fallbacks tolerants (mateix + // patró que Door/Sarcophagus). Si la clau no apareix, retorna una + // animació d'1 frame loopant, neutra — la momia es podrà moure però + // visualment es quedarà estàtica. + auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { + std::vector frames = {0}; + float fps = 8.0F; + bool loop = true; + if (yaml.contains("animations") && yaml["animations"].contains(key)) { + const auto& anim = yaml["animations"][key]; + if (anim.contains("frames")) { + frames.clear(); + for (const auto& f : anim["frames"]) { + // cppcheck-suppress useStlAlgorithm + frames.push_back(f.get_value()); + } + if (frames.empty()) { frames.push_back(0); } + } + if (anim.contains("fps")) { fps = anim["fps"].get_value(); } + if (anim.contains("loop")) { loop = anim["loop"].get_value(); } + } + return {frames, fps, loop}; + } + } // namespace -Mummy::Mummy(const fkyaml::node& yaml) { +Mummy::Mummy(const fkyaml::node& yaml) + : anim_walk_down_(buildAnimation(yaml, "walk_down")), + anim_walk_up_(buildAnimation(yaml, "walk_up")), + anim_walk_lateral_(buildAnimation(yaml, "walk")) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("mummy"); collides_ = true; interactable_ = false; @@ -43,16 +71,36 @@ Mummy::Mummy(const fkyaml::node& yaml) { initial = yaml["ai"]["initial_direction"].get_value(); } if (initial == "right") { - setDirection(1.0F, 0.0F); + changeDirection(1.0F, 0.0F); } else if (initial == "left") { - setDirection(-1.0F, 0.0F); + changeDirection(-1.0F, 0.0F); } else if (initial == "up") { - setDirection(0.0F, -1.0F); + changeDirection(0.0F, -1.0F); } else if (initial == "down") { - setDirection(0.0F, 1.0F); + changeDirection(0.0F, 1.0F); } } +void Mummy::changeDirection(float dx, float dy) { + setDirection(dx, dy); + // Prevalença lateral: si hi ha component horitzontal s'usa l'anim + // lateral + flip segons el signe. Només direccions purament verticals + // disparen walk_up/walk_down. Quan la direcció queda (0,0) (cul-de-sac + // amb NONE) no toquem l'animació: la momia es quedarà al frame actual + // fins que step() li torni a assignar direcció. + if (dx != 0.0F) { + anim_ = anim_walk_lateral_; + flip_ = (dx < 0.0F) ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE; + } else if (dy < 0.0F) { + anim_ = anim_walk_up_; + flip_ = SDL_FLIP_NONE; + } else if (dy > 0.0F) { + anim_ = anim_walk_down_; + flip_ = SDL_FLIP_NONE; + } + if (anim_.has_value()) { anim_->reset(); } +} + auto Mummy::chooseDirection(const std::vector& allowed) -> Direction { if (allowed.empty()) { return Direction::NONE; } std::uniform_int_distribution dist(0, allowed.size() - 1); @@ -66,6 +114,31 @@ auto Mummy::candidateBox(float dx, float dy) const -> SDL_FRect { return box; } +void Mummy::snapToPlayfieldEdge(float dx, float dy) { + // La caja 16×16 té exactament l'amplada del corredor; si ens aturàvem + // a una posició subpíxel dins de l'última cel·la, qualsevol gir + // perpendicular col·lidiria amb el sarc adjacent al limit. Per això + // reduïm el dx/dy fins a la vora exacta del playfield en l'eix actiu + // (l'altre sempre és 0 perquè la momia només es mou en 1D per frame). + const auto BOX = getCollisionRect(); + float snap_dx = 0.0F; + float snap_dy = 0.0F; + if (dx > 0.0F) { + snap_dx = std::max(0.0F, static_cast(Map::playfield_x + Map::playfield_w) - BOX.x - BOX.w); + } else if (dx < 0.0F) { + snap_dx = std::min(0.0F, static_cast(Map::playfield_x) - BOX.x); + } + if (dy > 0.0F) { + snap_dy = std::max(0.0F, static_cast(Map::playfield_y + Map::playfield_h) - BOX.y - BOX.h); + } else if (dy < 0.0F) { + snap_dy = std::min(0.0F, static_cast(Map::playfield_y) - BOX.y); + } + if (!Map::collides(candidateBox(snap_dx, snap_dy))) { + pixel_rect_.x += snap_dx; + pixel_rect_.y += snap_dy; + } +} + void Mummy::step(float delta_time) { // Desplaçament nominal en aquest frame segons la direcció actual. const float DX = dir_x_ * speed_ * delta_time; @@ -84,60 +157,63 @@ void Mummy::step(float delta_time) { return; } - // Bloqueig: cal girar. Recollim les direccions perpendiculars a l'actual - // que tinguin un pròxim tram lliure. Evitem U-turn llevat que no hi hagi - // cap alternativa (comportament típic d'Oh Mummy!: no es recula sense - // motiu). Si la direcció perpendicular també està bloquejada, no s'hi - // inclou; si totes les perpendiculars estan bloquejades, afegim l'opcio - // oposada com a últim recurs (cul-de-sac). - const float STEP = speed_ * 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). + const auto POOLS = collectDirectionPools(speed_ * delta_time); + 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. +} + +auto Mummy::collectDirectionPools(float step) const -> DirectionPools { struct Candidate { float dx; float dy; Direction dir; }; const std::array ALL{{ - {.dx = STEP, .dy = 0.0F, .dir = Direction::RIGHT}, - {.dx = -STEP, .dy = 0.0F, .dir = Direction::LEFT}, - {.dx = 0.0F, .dy = -STEP, .dir = Direction::UP}, - {.dx = 0.0F, .dy = STEP, .dir = Direction::DOWN}, + {.dx = step, .dy = 0.0F, .dir = Direction::RIGHT}, + {.dx = -step, .dy = 0.0F, .dir = Direction::LEFT}, + {.dx = 0.0F, .dy = -step, .dir = Direction::UP}, + {.dx = 0.0F, .dy = step, .dir = Direction::DOWN}, }}; - - std::vector perp_allowed; - std::vector uturn_allowed; + DirectionPools pools; for (const auto& c : ALL) { - const bool IS_CURRENT = (c.dx > 0.0F && dir_x_ > 0.0F) || (c.dx < 0.0F && dir_x_ < 0.0F) || (c.dy > 0.0F && dir_y_ > 0.0F) || (c.dy < 0.0F && dir_y_ < 0.0F); - const bool IS_UTURN = (c.dx > 0.0F && dir_x_ < 0.0F) || (c.dx < 0.0F && dir_x_ > 0.0F) || (c.dy > 0.0F && dir_y_ < 0.0F) || (c.dy < 0.0F && dir_y_ > 0.0F); - if (IS_CURRENT) { continue; } // ja hem vist que està bloquejada + const bool IS_CURRENT = (c.dx * dir_x_ > 0.0F) || (c.dy * dir_y_ > 0.0F); + if (IS_CURRENT) { continue; } // ja sabem que està bloquejada const auto TEST = candidateBox(c.dx, c.dy); if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; } + const bool IS_UTURN = (c.dx * dir_x_ < 0.0F) || (c.dy * dir_y_ < 0.0F); if (IS_UTURN) { - uturn_allowed.push_back(c.dir); + pools.uturn.push_back(c.dir); } else { - perp_allowed.push_back(c.dir); + pools.perp.push_back(c.dir); } } + return pools; +} - const auto& pool = !perp_allowed.empty() ? perp_allowed : uturn_allowed; - const auto CHOICE = chooseDirection(pool); - switch (CHOICE) { +void Mummy::applyChoice(Direction dir) { + switch (dir) { case Direction::UP: - setDirection(0.0F, -1.0F); + changeDirection(0.0F, -1.0F); break; case Direction::DOWN: - setDirection(0.0F, 1.0F); + changeDirection(0.0F, 1.0F); break; case Direction::LEFT: - setDirection(-1.0F, 0.0F); + changeDirection(-1.0F, 0.0F); break; case Direction::RIGHT: - setDirection(1.0F, 0.0F); + changeDirection(1.0F, 0.0F); break; case Direction::NONE: - setDirection(0.0F, 0.0F); + changeDirection(0.0F, 0.0F); break; } - // No avancem aquest frame: la direcció nova s'aplicarà al pròxim step. } void Mummy::onPlayerContact(Player* /*player*/) { diff --git a/source/game/entities/concrete/mummy.hpp b/source/game/entities/concrete/mummy.hpp index 5006665..e5c99d1 100644 --- a/source/game/entities/concrete/mummy.hpp +++ b/source/game/entities/concrete/mummy.hpp @@ -4,6 +4,7 @@ #include #include "external/fkyaml_node.hpp" +#include "game/entities/animation_component.hpp" #include "game/entities/hazard.hpp" // Mòmia: perill dinàmic que deambula pels passadís. A cada intersecció @@ -44,4 +45,36 @@ class Mummy : public DynamicHazard { private: // Caixa de col·lisió candidata si l'entitat es desplaçara (dx, dy). [[nodiscard]] auto candidateBox(float dx, float dy) const -> SDL_FRect; + + // Amarra la caja a la vora exacta del playfield quan el candidat hi + // sortiria — sense això, amb la caja 16×16 iguala el corredor, la + // posició subpíxel dins de l'última cel·la impediria girar a cap + // perpendicular (col·lisionaria amb el sarc adjacent). Es crida abans + // de la lògica de gir. + void snapToPlayfieldEdge(float dx, float dy); + + // 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. + struct DirectionPools { + std::vector perp; + std::vector uturn; + }; + [[nodiscard]] auto collectDirectionPools(float step) const -> DirectionPools; + + // Canvi de direcció + aplicació de l'animació i flip corresponents. + // Tots els camins que trien nova direcció (ctor amb `initial_direction`, + // gir després d'un bloqueig) passen per ací per mantenir anim_ i + // flip_ en sincronia amb (dir_x_, dir_y_). + void changeDirection(float dx, float dy); + + // Aplica la direcció triada (wrapper del switch en una sola crida). + void applyChoice(Direction dir); + + // Animacions carregades del YAML, una per cada facing. Per a horitzontal + // només en cal una (`walk`) i el flip ja diferencia esquerra/dreta. + AnimationComponent anim_walk_down_; + AnimationComponent anim_walk_up_; + AnimationComponent anim_walk_lateral_; }; diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 2a12d28..ce6922d 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -128,6 +128,18 @@ class Entity { // col·lisió coincideix amb el sprite. void setCollisionBox(CollisionBox box) { collision_box_ = box; } + // Accés a la caixa crua (offsets + mida) — el loader la consulta per + // a col·locar el sprite quan `anchor: feet` ha d'alinear la caja (no + // el sprite) amb el tile. Nullopt si l'entitat no en té. + [[nodiscard]] auto getCollisionBox() const -> const std::optional& { return collision_box_; } + + // Flip del sprite. Les entitats estàtiques mai no el toquen (queda a + // SDL_FLIP_NONE); les dinàmiques que miren cap als costats (p. ex. + // Mummy en direcció horitzontal) el canvien quan gira. El manager + // el passa a Surface::render. + [[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; } + void setFlip(SDL_FlipMode flip) { flip_ = flip; } + protected: std::string name_; TilePos tile_{}; @@ -137,5 +149,6 @@ class Entity { std::shared_ptr surface_{nullptr}; bool collides_{false}; bool interactable_{false}; + SDL_FlipMode flip_{SDL_FLIP_NONE}; EventBus* bus_{nullptr}; }; diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 8c4c6f8..e1edc64 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -51,7 +51,7 @@ void EntityManager::renderEntity(const Entity& e) { .w = RECT.w, .h = RECT.h, }; - surface->render(static_cast(RECT.x), static_cast(RECT.y), &SRC); + surface->render(static_cast(RECT.x), static_cast(RECT.y), &SRC, e.getFlip()); } void EntityManager::renderAll() { @@ -105,9 +105,16 @@ namespace { return info; } - // Bloc `collision: { size: [w, h], offset: [x, y] }` de la plantilla - // d'entitat. `offset` és opcional: si no apareix, s'entén "la caixa - // queda bottom-center del sprite" (convenció de falsa perspectiva). + // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }` + // de la plantilla d'entitat. + // - Sense `base_size` ni `offset`: la caja queda alineada a la vora + // inferior del sprite i centrada horitzontalment (convenció antiga). + // - Amb `base_size: [bw, bh]`: la caja queda concèntrica amb el + // rectangle inferior bw×bh del sprite (els "peus" visuals), igual + // que fa el Player. Pot sobresortir per sobre/sota/costats del + // rectangle-base simètricament — així la caja representa el volum + // sòlid al voltant dels peus, no la silueta de tot el sprite. + // - `offset: [x, y]`: si apareix, sobreescriu el càlcul anterior. // Retorna nullopt si no hi ha bloc, i l'entitat es queda sense caixa // explícita (getCollisionRect torna tot el sprite). auto readCollisionBox(const fkyaml::node& tmpl, float sprite_w, float sprite_h) -> std::optional { @@ -120,6 +127,16 @@ namespace { const int H = sz[1].get_value(); int off_x = static_cast((sprite_w - static_cast(W)) / 2.0F); int off_y = static_cast(sprite_h - static_cast(H)); + if (c.contains("base_size")) { + const auto& bs = c["base_size"]; + if (bs.is_sequence() && bs.size() == 2) { + const int BH = bs[1].get_value(); + // Centre vertical del rectangle-base (alineat a la vora + // inferior del sprite): sprite_h − bh/2. Caja centrada hi: + // off_y = sprite_h − bh/2 − H/2 = sprite_h − (bh + H)/2. + off_y = static_cast(sprite_h) - ((BH + H) / 2); + } + } if (c.contains("offset")) { const auto& o = c["offset"]; if (o.is_sequence() && o.size() == 2) { @@ -258,16 +275,26 @@ namespace { const auto& p = spawn["position"]; const auto PX = tileToPixel(p[0].get_value(), p[1].get_value()); - // Anchor "feet": el tile [col,row] és el tile dels peus. El sprite - // es centra horitzontalment sobre el tile i la seua vora inferior - // s'alinea amb la vora inferior del tile. Els píxels que sobresurten - // (alçada del sprite per damunt del tile dels peus) queden "per - // sobre" sense afectar cap col·lisió — falsa perspectiva. + // Anchor "feet": el tile [col,row] és el tile dels peus de l'actor. + // Si l'entitat té caixa de col·lisió explícita, la caixa s'alinea + // exactament amb el tile (el sprite queda col·locat per als offsets + // que la caja defineix — típicament sobresurt cap amunt per falsa + // perspectiva). Això és coherent amb el jugador, que també centra + // la seua 16×16 sobre el rectangle-base; la caja del tile i la del + // mapeig són la mateixa. Sense caixa, fallback històric: sprite + // centrat horitzontalment i amb la vora inferior a la vora inferior + // del tile. float rect_x = PX.x; float rect_y = PX.y; if (anchor == "feet") { - rect_x = PX.x + (static_cast(Map::tile_size) - sprite.width) / 2.0F; - rect_y = PX.y + static_cast(Map::tile_size) - sprite.height; + const auto& box_opt = entity->getCollisionBox(); + if (box_opt.has_value()) { + rect_x = PX.x - static_cast(box_opt->offset_x); + rect_y = PX.y - static_cast(box_opt->offset_y); + } else { + rect_x = PX.x + (static_cast(Map::tile_size) - sprite.width) / 2.0F; + rect_y = PX.y + static_cast(Map::tile_size) - sprite.height; + } } entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});