From e6b612d164c1d7405413461b4f2d2c905cafd8c9 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 21 Apr 2026 19:39:02 +0200 Subject: [PATCH] feat(entities): bloc collision i anchor:feet per entitats amb falsa perspectiva --- data/entities/mummy/mummy.yaml | 7 ++ data/player/player.gif | Bin 828 -> 1802 bytes docs/entities-system.md | 16 +++++ source/game/entities/entity.cpp | 15 ++++ source/game/entities/entity.hpp | 29 ++++++++ .../entities/entity_manager_loadlevel.cpp | 65 ++++++++++++++++-- tests/test_entities_smoke.cpp | 35 ++++++++++ 7 files changed, 162 insertions(+), 5 deletions(-) diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index 4b2d81a..52c831b 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -4,6 +4,13 @@ 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. +anchor: feet +collision: + size: [12, 12] flags: collides: true interactable: false diff --git a/data/player/player.gif b/data/player/player.gif index 0b80dbb64fe6e49d0c3c847df165dc43036174ef..ab20f75034cb44e20614809e9d53770255b35f90 100644 GIT binary patch literal 1802 zcmXw&dr;GN62RB`2#Okf0TD_Nkm#X_f*26&4~&pjlwd$X5JD&qOAf+wf(IBPLZl!; zUO@pvOV02xM5T(zd7;WfN=Z23go-2rN`{y8v^Eu|`CfFUGrP0@>>r=~>@NK)UpFdy z4}1xF0ECcUuU}kT)ai6ut#)K&M6Fi$_Vy~3%C@#Pg+d{d$s`hqSS%KaL_(pE&*$@a zJT8|T7Z=B3v4Vqx84L!UPRB5eN~KaL6cUL} z6eL#)z9~M-&QA$F7tc(OI+752C??P&+|MP5=5*B4f#zbR%=Bwbr8jeF^&G7FYY!2N zDhzhv2U2(pCk~y&qS*&xL_ew(MzL^l+T>)9M{`;!H{8CF&XknaLyLe36QW6VoV`?C7b}@ zAx6@#e&4+CWe<9Xc6u^zQvK?jX(hWob9T5U+3$|DaSR~Xcx|@WgU8#h!QoeEP($y_ za{1$&3OOgMG$fjL_(2#>42g&8As0gK|A|?~<-HR+OjWov40jL1(0Mr0n0N?;H{}M(8ApPYG zXIzPzuWsYENmvpgL&(Lr!IWT(h>`H5ZOGK$Cn%8S9pK|k-m`p}@m{yRU$J_PvF`h4DZD~*CF2cj2oyphzz};@9s63agsy#?F3=@WR{?~j3j@y^abpQF@L}+(uDQzaG zIRJNLV9kp&fr?`tuh%-Aj0Jz{Or5P9}f7? zH(GvpH{bHl2~DxX&eB3HIb-p4t)4os>CDw%Ul_}qc@vOE0IikLYU6!6ZTFRIr+XsD|JFY=_RQ@Km1SSSj zqyT@&Eh5+Kz(uq3o1@Ou<-zHc)!R*uPi_-_R*+HPWpm!Gq+rLnjPC?C*W3b%b~lZF zU$jr<7A5qk9JN11{cLA+p!<4hKp;K8!l87%AZ^1DLhbN|ul!TeF8eVf8lN1s{l%e7 z?3&zIvEIF;eXuH~@|U2iv37m2Cu6Vo1=K{}C+M!GEtSUwWzP0puT7afB@0-Y>^fbS zVfRzO>B6QhA@$KS=H_hfy6F*i%Bic3?7VEpD>8AyN!q${gKK=_+1hU#8>KDxpEfix zQDsw;d|)dlPSFz|eCB3p(^kbzRZ?j9gE!sJ*LF9Vhuyl_STUcdT9s*i`^kE}&Dz1$ zxaQ=i55)c6}efzPm8Z*FQoIhIr=x^ zI^~C!UfZ1CH2QJgvI7k)o|*IMiSzisT_kpGO1-zIdsCv@-aJF8Tk_-0BbJlJn{Pj^ zzXT06lZANmKKo+w?3b-Y-l{BT)uEp}UzRvKrA^oPZ9TQ`@P;eDIg}qs-M8JB`9(N$ zIP}>M%&Mr+?6Rl6mG0v;5iar5BdhZUTD)Q<2OUE=W#e~8neBady`oj)cgFqOL++^!X35c}aa%h)77{CeZP&JJntwVY z)x6NS@)IvSnUjyx=Gn;e=gp4Kv%ALWiU*y`$exrx5cB!9uGyh}#fM>!EJL<|snGOT zms%@&?OV~m43-b8iZ#ny3w;_g$G=-~ktwxXHT|=|HU5Rr{14{~gFAdT-XN@si>+Um Nk`(LOaoh{Q`D&1gO5kWTx#R7^f3XJ#{^ zsYlh|OZ)$Z7FtXrkYgUabCKI=&EIS>HaIsqMilBtAFvbvB9US$i4j}EV!FyXn6 z_~J4%Q%Fj8O=YxAo92(x|3xW3Yd&?5Ua6>r^Xjebbv#s}OK;C*f9-&ogB)GMBvkNE zc=myT{HKup%dnFT10a7?02lxO2LC{S5Kt%(hr@zFSu7%n%VjeeZ5okGsZ*G>Vyn|{ zaVgdsuTbXn40fWs>$LeO_r8}tA5)z7J-K*!PfUG(X@z`(gMog31qhG`Xorx9fCWC4 ziiDbtmzfBNoS}-8q@F~N0SEzGT$rquubBasMYOW1xTUwTrMiE3`Pm;X?3F^z*2{_Cxj_#2y}o z{`mv=q+gGJ0SzJ?^6$ex4-OFtJV+49!;2L$J}js)V@Hk}hxqFgqL`qL08gSMh|yt1 z04o_@bP4jI!+n2wOol*o&?Q9w4}~6v7#dp2D+upDc*TJJazWTd3&Zu2 z`G^}hZ?Soe1zY9o2ixMm-sBE0Y}myU&WBqh_Q9EM9?pMA`fw(k`gCDKio=0Mvih*0 z)t*12*1h^qZ{NR#;6@CbuxKQQYa SDL_FRect { + if (!collision_box_.has_value()) { return pixel_rect_; } + return { + .x = pixel_rect_.x + static_cast(collision_box_->offset_x), + .y = pixel_rect_.y + static_cast(collision_box_->offset_y), + .w = static_cast(collision_box_->width), + .h = static_cast(collision_box_->height), + }; +} + +auto Entity::getFeetY() const -> float { + const auto RECT = getCollisionRect(); + return RECT.y + RECT.h; +} diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index a4292ca..2a12d28 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -37,6 +37,18 @@ class Entity { int row{0}; }; + // Sub-rectangle de col·lisió dins de `pixel_rect_`. Pensat per a + // personatges amb falsa perspectiva (sprite alt, caixa AABB petita + // encastada a baix) — mateixa convenció que el Player. `offset_x/y` + // són relatius a la cantonada superior-esquerra del sprite. Si l'entitat + // no té `CollisionBox`, `getCollisionRect()` retorna tot el `pixel_rect_`. + struct CollisionBox { + int offset_x{0}; + int offset_y{0}; + int width{0}; + int height{0}; + }; + Entity() = default; Entity(std::string name, TilePos tile, SDL_FRect pixel_rect); virtual ~Entity() = default; @@ -100,11 +112,28 @@ class Entity { // Entitats sense animació retornen `nullopt` → frame 0 per defecte. [[nodiscard]] auto getAnimation() const -> const std::optional& { return anim_; } + // Caixa de col·lisió: per defecte és el `pixel_rect_` sencer (entitats + // AABB-aligned). Si s'ha configurat un `CollisionBox`, retorna el + // sub-rect dins del sprite — vida útil: validar col·lisions amb el + // jugador o el mapa sense que la caixa inclogui "l'alçada" del sprite. + [[nodiscard]] auto getCollisionRect() const -> SDL_FRect; + + // Y dels peus: la vora inferior de la caixa de col·lisió. La usarà el + // Z-order per ordenar entitats i jugador de menys a més profunditat + // (REND-01, quan s'implemente). + [[nodiscard]] auto getFeetY() const -> float; + + // El loader li assigna una caixa explícita llegida del YAML; sense + // aquesta crida, l'entitat queda sense `CollisionBox` i la caixa de + // col·lisió coincideix amb el sprite. + void setCollisionBox(CollisionBox box) { collision_box_ = box; } + protected: std::string name_; TilePos tile_{}; SDL_FRect pixel_rect_{}; std::optional anim_; + std::optional collision_box_; std::shared_ptr surface_{nullptr}; bool collides_{false}; bool interactable_{false}; diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 3e54cdc..0752795 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -88,6 +88,41 @@ 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). + // 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 { + if (!tmpl.contains("collision")) { return std::nullopt; } + const auto& c = tmpl["collision"]; + if (!c.contains("size")) { return std::nullopt; } + const auto& sz = c["size"]; + if (!sz.is_sequence() || sz.size() != 2) { return std::nullopt; } + const int W = sz[0].get_value(); + 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("offset")) { + const auto& o = c["offset"]; + if (o.is_sequence() && o.size() == 2) { + off_x = o[0].get_value(); + off_y = o[1].get_value(); + } + } + return Entity::CollisionBox{.offset_x = off_x, .offset_y = off_y, .width = W, .height = H}; + } + + // Ancoratge d'spawn. "feet" vol dir que el `position: [col, row]` del + // map és el tile dels peus: el sprite es centra horitzontalment dins + // del tile i s'alinea la seua vora inferior amb la vora inferior del + // tile. "top_left" (per defecte) posa el sprite amb el cantonal + // superior-esquerre a la cantonada superior-esquerra del tile. + auto readAnchor(const fkyaml::node& tmpl) -> std::string { + if (!tmpl.contains("anchor")) { return "top_left"; } + return tmpl["anchor"].get_value(); + } + // Coordenades píxel (canvas) d'una cel·la (col, row) del tilemap. El // grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que // pot ser negatiu per overscan CPC. @@ -115,7 +150,10 @@ namespace { // Crea l'entitat per factory i li adjunta la surface si n'hi ha. Retorna // nullptr si la plantilla no es pot carregar o si el tipus no està // registrat al factory — el loop principal ja imprimeix el missatge. - auto buildEntity(const std::string& type, SpriteInfo& sprite_out) -> std::unique_ptr { + // També llegeix el bloc `collision:` de la plantilla (si existeix) i + // el bota a l'entitat, i retorna l'`anchor` per a que el loader sàpiga + // com col·locar el sprite a l'spawn ("feet" o "top_left"). + auto buildEntity(const std::string& type, SpriteInfo& sprite_out, std::string& anchor_out) -> std::unique_ptr { fkyaml::node tmpl; try { tmpl = EntityLoader::loadTemplate(type); @@ -133,6 +171,10 @@ namespace { Resource::Cache::get().loadSurface(sprite_out.key, sprite_out.full_path); entity->setSurface(Resource::Cache::get().getSurface(sprite_out.key)); } + if (const auto BOX = readCollisionBox(tmpl, sprite_out.width, sprite_out.height); BOX.has_value()) { + entity->setCollisionBox(*BOX); + } + anchor_out = readAnchor(tmpl); return entity; } @@ -180,14 +222,26 @@ namespace { return true; } - auto placeGeneric(EntityManager& manager, std::unique_ptr entity, const std::string& type, const SpriteInfo& sprite, const fkyaml::node& spawn) -> bool { + auto placeGeneric(EntityManager& manager, std::unique_ptr entity, const std::string& type, const SpriteInfo& sprite, const std::string& anchor, const fkyaml::node& spawn) -> bool { if (!spawn.contains("position")) { std::cerr << "EntityManager: " << type << " sense position\n"; return false; } const auto& p = spawn["position"]; const auto PX = tileToPixel(p[0].get_value(), p[1].get_value()); - entity->setPixelRect({.x = PX.x, .y = PX.y, .w = sprite.width, .h = sprite.height}); + + // 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. + 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; + } + entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height}); if (type == "door") { auto* door_ptr = dynamic_cast(entity.get()); @@ -209,12 +263,13 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) { const auto TYPE = spawn["type"].get_value(); SpriteInfo sprite; - auto entity = buildEntity(TYPE, sprite); + std::string anchor; + auto entity = buildEntity(TYPE, sprite, anchor); if (entity == nullptr) { continue; } const bool OK = (TYPE == "sarcophagus") ? placeSarcophagus(*this, std::move(entity), sprite, spawn) - : placeGeneric(*this, std::move(entity), TYPE, sprite, spawn); + : placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn); if (OK) { ++loaded; } } diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index 44e8bfd..f5ca255 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -14,6 +14,10 @@ // T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame. // T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva: // el sprite.height no canvia la caixa lògica 48×16. +// T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa +// retornada coincideix amb les dimensions del bloc +// `collision:` del YAML; sense caixa, coincideix amb el +// sprite sencer (pixel_rect). // // Execució (des de la ruta del repo): // make build && cd build && ctest --output-on-failure @@ -342,6 +346,36 @@ namespace { REQUIRE(sarc->getPerimeterSize() == 8); } + void testCollisionBox() { + std::cout << "[T12] collision box encastada al sprite\n"; + // Torch sense bloc `collision:` → getCollisionRect == pixel_rect. + fkyaml::node torch_tmpl = EntityLoader::loadTemplate("torch"); + Torch torch(torch_tmpl); + torch.setPixelRect({.x = 100.0F, .y = 200.0F, .w = 16.0F, .h = 16.0F}); + const auto TRECT = torch.getCollisionRect(); + REQUIRE(static_cast(TRECT.w) == 16); + REQUIRE(static_cast(TRECT.h) == 16); + REQUIRE(static_cast(TRECT.x) == 100); + REQUIRE(static_cast(TRECT.y) == 200); + + // Mummy amb `collision: { size: [12, 12] }` i sprite 12×24 → caixa + // auto-centrada bottom dels peus: offset_x = (12-12)/2 = 0, + // offset_y = 24 - 12 = 12. Amb pixel_rect a (100, 200, 12, 24), + // la caixa ha de sortir a (100, 212, 12, 12) i feet_y = 224. + fkyaml::node mummy_tmpl = EntityLoader::loadTemplate("mummy"); + Mummy mummy(mummy_tmpl); + // Apliquem el CollisionBox com fa el loader (readCollisionBox) + // amb sprite 12×24, offset calculat per defecte. + mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); + mummy.setPixelRect({.x = 100.0F, .y = 200.0F, .w = 12.0F, .h = 24.0F}); + const auto MRECT = mummy.getCollisionRect(); + REQUIRE(static_cast(MRECT.x) == 100); + REQUIRE(static_cast(MRECT.y) == 212); + REQUIRE(static_cast(MRECT.w) == 12); + REQUIRE(static_cast(MRECT.h) == 12); + REQUIRE(static_cast(mummy.getFeetY()) == 224); + } + void testTorchAnimation() { std::cout << "[T10] torch animation wired from yaml\n"; // Regressió: el torch.yaml declarava `frames: [0]` i el render no @@ -379,6 +413,7 @@ auto main() -> int { testDecorationFlags(); testLoadFromLevel(); testSarcophagusYaml(); + testCollisionBox(); testTorchAnimation(); if (g_failures == 0) {