From 32dcf264c465925b796c2c18ac821098663435cd Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 21 Apr 2026 23:21:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(entities):=20caja-peus=20conc=C3=A8ntrica?= =?UTF-8?q?=20i=20animacions=20direccionals=20a=20la=20momia,=20c=C3=B2pia?= =?UTF-8?q?=20de=20data/=20sempre=20sincronitzada;=20v0.19.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 13 +- data/entities/mummy/mummy.gif | Bin 833 -> 1795 bytes data/entities/mummy/mummy.yaml | 25 ++- data/maps/pyramid.yaml | 2 + docs/entities-system.md | 32 +++- source/game/entities/concrete/mummy.cpp | 142 ++++++++++++++---- source/game/entities/concrete/mummy.hpp | 33 ++++ source/game/entities/entity.hpp | 13 ++ .../entities/entity_manager_loadlevel.cpp | 49 ++++-- 9 files changed, 251 insertions(+), 58 deletions(-) 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 88d5c45e6fe7dc05ea59fe9db39078aee2307e2b..d085a11ff6735c05e3b06dd3d00eae860fa74eed 100644 GIT binary patch literal 1795 zcmXw&dpOj27r@U}62(*$q9%z+%8<(l^-JRtVq`GQxcuBTMvd4Es~WLya-9m1OOw!O zHjT0+dAG6qhOubM-L{#^Wmc5RTk<|n`+cXLXP@Vs=lpa2_?*u<$HUXzff8W_2VemJ zA+)ftFgG`+R4Nq;#qjX3TrThI?37BS&CSgci9{q435CMa(o%syke!{)=ks|y9+%6F zi;Kf?oWWqwXfzKG4-CU76bhM4CXq-)B2iCIk3b-(tE&TmR3vD=dQMW7Av&Kb%sGDP z47)Hb;%pM@biDtGNUx);{V{Z>P;Z+6s>MO)tyCLBX}Wh)vd2$cY7Gagh;kC(lx&6# zpHAk{EI1w{oN7YHh~5-KjI3v4p<`h}Kr;$7-jAMjqW)GSZ$h0SB&|TLMW`uj;ivSu znq*}KM^O|toEI$5qIdIY=hmag02|@ydxCqe@A|pgV2;_2*NI)O}E*EGtAd`VWQ1vIzJYx*EQvXvy zmZTFaf_1W~>d6*>6bdAjBB2oR`3T1mh9M#m0n7m?0OTrB01^NpfB=9Gzy-hoXaE=h z8Gr~tQ0=R^dA_DbDG)qMPw(b%q`|>WG@1y*3N7I*2~KZ@69#Z}EyS#ZP&Ehy;2~P# zJ8$+2_lPGhtTt!=MvC{`Yz)~g z3IyM+SeEz#mMmMUv%$HnG2+)uEuHOU=bjj6Wv--CY9W^=-0p%|6S(%Z+BTO46aHU3t@@151kY<2p_nF=zemtysG_pLM28hCa!){?t=HlGssq(WFL~ zqklZ2Ety0&(=w=)KizYN^?32;qn@&p$pUxBdF#%}UP6=BFR9hunG&b%H?>En`!6g# zLq-9?UmShk99Z3I5V$vMjCc zjp#+@IrE{Q_}g|RL{`C?bz#9>Rn0h4>sU#s>t~aIcgpO%YW_Qem{mdLwAI7oW zeoJ*n{~C!QpV5deh|3)`y{K`By|pNGuV-KJk$8vmm%_@+3{peF%o&xoqXuiPGIP^J zA=y*Rae>!aH8bIs7W45^Ue5PVii_@yO!|1(=S)|p{xC3I89p|;^>Rw+4+f`VZ~gwN zl3R#c*fsE(JyCM0_m80x(M!kJSn9?bQ#Ee>i?3r6Se_HE9YYUhLw^=)a@^XrV`6U& z-D|%o?hTr`e$O?jweId%BxmBnf2&)gugQ|)ch(NRW!8x(?G{Jwi(h-j6y@v{8{c?s z7Nn@(^xo-uX{*3cJFMgsY z>iY){ht=LS>mLkiQU-?Sr#&wl+1xi7xj(k(encX6D8)WE>%Hq>N^p(Tx8 zT&jw{lDGU)l#tPGG!;J27@8rC?S7DXXGZxk>ArMqpr1DC>-fi=8@>N~r`_@;W#kAx z5t5<%;g`iQTNk3;M-Ed#Nvj;JJ301dQR{NS!;QyY4b7hF=<|x2R|{smwUeLDuE2Ri zc>_68U2okm!YB0cne5m;U7>oz0nR*qwL7b}?KgIT<0|I4AWc$<=G*Aw{5&82!+Ym5 H0G$30`gcBh literal 833 zcmZ?wbhEHb 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});