From 1af5ef861f9f186af1aa8a839c495e91d8e4fbec Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 21 Apr 2026 19:11:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(entities):=20clipping=20per=20frame,=20fal?= =?UTF-8?q?sa=20perspectiva=20del=20sarc=C3=B2fag=20i=20pir=C3=A0mide=20am?= =?UTF-8?q?b=2020=20sarcs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/entities/door/door.gif | Bin 832 -> 507 bytes data/entities/door/door.yaml | 6 +- data/entities/sarcophagus/sarcophagus.gif | Bin 850 -> 848 bytes data/entities/sarcophagus/sarcophagus.yaml | 5 +- data/entities/torch/torch.gif | Bin 832 -> 217 bytes data/entities/torch/torch.yaml | 4 +- data/maps/pyramid.yaml | 56 ++++++++++++++-- docs/entities-system.md | 2 +- source/game/entities/entity.hpp | 5 ++ .../entities/entity_manager_loadlevel.cpp | 47 +++++++++++-- tests/test_entities_smoke.cpp | 62 ++++++++++++++++-- 11 files changed, 162 insertions(+), 25 deletions(-) diff --git a/data/entities/door/door.gif b/data/entities/door/door.gif index b28dbf00fa79ffca25d3a9d3ed6f8029225677b0..bf2c5237b6cd3248884e13d1d4326c063cfaffe7 100644 GIT binary patch literal 507 zcmVNk%v~VK4wd0J8u90000P78W2J93~+lDB{nZBSvwJOOcqQ* zJWN78nQT1(0000000000A^8Le3IG5AEC2ui05AYR000F45XecZy*TU5yZ>M)j$~<` zXsWJk>%Op8%)o3H^^MbNu^bEEZzu!;g*a5fz&sQV2d2RZl~SY0(o$+D5V+kh*DKAA z3WKzI&2FpBqQOC-I1B>#yk0M-+f4&gc6)?-a!~_-Oaym@jss$eC2Wq5Yk+=@m4mOYWMRV~4B?R2GUZ8>CKjz^lo)~{6Eb6{#FP=q@dFw+C{dR5(5KD-1TbUD zl-csnBtb6`Fi5~)XHT9!VTRHb;uRyEK9d3kia?bpKx>dL{YjNU(@T>wNv%57YD=OZ zhHh0_wJAy1YyeKq>cr#*UN?(jFPwKQpKUjpbvUJOFQjrSqi#2%a5bK9L7H$!mU30Oe?gy*Jf@8; zq>eehjX0!`I=zrKz>G7^o;J{*H_V|l)~7h&tv{WPM4gdAyM{rkmrb^eP^p+ux~f^O zsBN^VYObkrv8r*wjX}$tLDa2F;jBg2u6W?DeeKRx-^^w2(`@+Ra@Ws?>(GV&^_lq+tXE0}~SxaRZZkHy$26nl8AyCniKJcRq)OVPZXXlr$ZC zGy}3FG(N_ffr1+xI-NN~#lSv3bv@!eGh)geIpTV>VlXf_4-Yvy^g1IW*g8HQz9V8Y z-@xGr=)<2O6)JA1V>TuSwFL|2)gvKd13e$t04$?{pn%7ZAU_5OP+-=cV1JkqL_v$* zyFnci6bRvnMu-psZic84=jMq200s(BP;?`~n*!fz5Ty4Y#R?tFV$Q62Gv`g76?_5} zO4KL?q&}1eWa<>^o|p(^IKnX^1Oc5jT*T1rAW4hoYViaSt|l&~f0vgpH6=ryiLA&pH~x%rUE%f#Ho5 zG-d?o@NY)$4l%q4CpP1QmmbVS`AEa8`OS!96g=21hDB5m&C6Av5h6Eoc0OLTc->sY zhzErpLPargmG|~gEIKY5`0l4DIdo(~p zUwvN4qDKV}%rM{|f^e{H0e}Vq5WocyM9@G33|^q31t&&DODXy3@x}}hpvU128+=$| zhz$1VV*v3%Fh>W=I77o51_~HpJSFrf;*U@=7=aw`RnUk4_(kdD5L*hNB?c;5XlDR{E9=d{^e8bJ0wXR26o0ZXf;_DQB0zbLCpIiR+%BN(HOFJ)qNCjs##whxY+QW2U%|Oc#&gq>lf8_L4AuY>{~C1w diff --git a/data/entities/sarcophagus/sarcophagus.yaml b/data/entities/sarcophagus/sarcophagus.yaml index fd18029..7193f62 100644 --- a/data/entities/sarcophagus/sarcophagus.yaml +++ b/data/entities/sarcophagus/sarcophagus.yaml @@ -2,7 +2,10 @@ name: sarcophagus type: interactable sprite: path: sarcophagus.gif - size: [48, 16] + size: [48, 32] +# Footprint lògic en tiles: 3×1 (48×16 baix del sprite). Els 16 px de dalt +# són "alçada" en falsa perspectiva, sense col·lisió. Perímetre ortogonal +# de 8 tiles (3 N + 3 S + 1 O + 1 E). tile_size: [3, 1] flags: collides: true diff --git a/data/entities/torch/torch.gif b/data/entities/torch/torch.gif index e3538320975ac374ac0624877de09113e5aded0f..7b56bb249726be02137eefd1a84e56562f52565a 100644 GIT binary patch literal 217 zcmZ?wbh9u|6ku>**vtS03NkXfDk?rswrOc;^?7M8d>H;$F#P|<@c&mAObL`${K>+} z1!U`h_#iVFSlR?mc&=uBo>k&?E%=b)0uvRlNjy&lG`tQpF(pd01TdBE`^<9Rf5C6b z{u4`zP99OPJl6b*xoBaEF7p|K%UAN8qBcalnt1y8vZ}Lr?)^c@j1w1Rt=af4bwOys zX)}vVu{7soH%~hJ_O4hdCdgn70LQ{g ADF6Tf literal 832 zcmZ?wbhEHb6krfwXlDR{|4YHtC@~rWBQFFLf3h%we60f_KzYJ}LzaP=L&jsnf`iQ* a!dfvWHY_~cE}-l+$7AE7qm7J=4Aua%r4a%E diff --git a/data/entities/torch/torch.yaml b/data/entities/torch/torch.yaml index aa26a0e..6b00340 100644 --- a/data/entities/torch/torch.yaml +++ b/data/entities/torch/torch.yaml @@ -7,5 +7,7 @@ tile_size: [1, 1] flags: collides: false interactable: false +# El torch.gif és un strip vertical 16x64 amb 4 frames apilats. +# El render clipa cada frame per `anim.currentFrame()` × 16 píxels en Y. animations: - idle: { frames: [0], fps: 8, loop: true } + idle: { frames: [0, 1, 2, 3], fps: 8, loop: true } diff --git a/data/maps/pyramid.yaml b/data/maps/pyramid.yaml index 2f5ec8d..566455b 100644 --- a/data/maps/pyramid.yaml +++ b/data/maps/pyramid.yaml @@ -52,15 +52,15 @@ data: | YHJJJJJJJJJJJJJJJJJIY YZWWWWWWWWWWWWWWWWWTY YZDDADDBBDDADDDDBDDTY - YZD...D...D...B...DTY + YZDAAADAAADAAABAAADTY YZDDDCCDDDDDADDDBDDTY - YZD...D...A...D...DTY + YZDAAADAAAAAAADAAADTY YZDDCCDDDDDADDBDDDDTY - YZD...D...D...D...DTY + YZDAAADAAADAAADAAADTY YZDDDDCCDDDDADDDDDDTY - YZD...D...D...D...DTY + YZDAAADAAADAAADAAADTY YZDDDABDDDDADCDDBDDTY - YZD...D...D...D...DTY + YZDAAADAAADAAADAAADTY YZDDBBDDADDDBCDDDDDTY YRXXXXXXXXXXXXXXXXXGY @@ -79,12 +79,54 @@ data: | # superior esquerre per verificar render d'una entitat decorativa. entities: - type: torch - position: [2, 4] + position: [4, 2] + - type: torch + position: [8, 2] + - type: torch + position: [12, 2] + - type: torch + position: [16, 2] - type: sarcophagus sarc_position: [0, 0] + - type: sarcophagus + sarc_position: [1, 0] + - type: sarcophagus + sarc_position: [2, 0] + - type: sarcophagus + sarc_position: [3, 0] + - type: sarcophagus + sarc_position: [0, 1] + - type: sarcophagus + sarc_position: [1, 1] + - type: sarcophagus + sarc_position: [2, 1] + - type: sarcophagus + sarc_position: [3, 1] + - type: sarcophagus + sarc_position: [0, 2] + - type: sarcophagus + sarc_position: [1, 2] + - type: sarcophagus + sarc_position: [2, 2] + - type: sarcophagus + sarc_position: [3, 2] + - type: sarcophagus + sarc_position: [0, 3] + - type: sarcophagus + sarc_position: [1, 3] + - type: sarcophagus + sarc_position: [2, 3] + - type: sarcophagus + sarc_position: [3, 3] + - type: sarcophagus + sarc_position: [0, 4] + - type: sarcophagus + sarc_position: [1, 4] + - type: sarcophagus + sarc_position: [2, 4] - type: sarcophagus sarc_position: [3, 4] - type: door - position: [10, 14] + position: [9, 1] - type: mummy position: [10, 7] diff --git a/docs/entities-system.md b/docs/entities-system.md index 9056330..1c33287 100644 --- a/docs/entities-system.md +++ b/docs/entities-system.md @@ -180,7 +180,7 @@ Es respecta tant a la fase 1 com a la 2. | ID | Descripció | Estat | |---|---|---| | REND-01 | **Z-order fi** entre entitats i Player (interleaving per `feetY`). Ara les entitats sempre queden sota el Player. Veure comentari a [game_scene.cpp:109-110](../source/game/scenes/game_scene.cpp#L109-L110). | DEFERRED | -| REND-02 | **Animacions multi-frame**. Actualment els placeholders són 1 frame; el blit és directe a `pixel_rect_`. Quan arribi l'art real, `renderAll` haurà de clipar per `anim_.currentFrame()` × `sprite.size`. | DEFERRED | +| REND-02 | **Animacions multi-frame**. `renderAll` clipa el frame actual via `src_rect` calculat amb `Entity::getAnimation()->currentFrame()` sobre una graella left-to-right / top-to-bottom; columnes deduïdes de `surface->getWidth() / pixel_rect.w`. Así un strip 1×N, N×1 o N×M funciona igual. Torch primer consumidor real (4 frames @ 8 fps). | DONE | | REND-03 | **Paleta compartida** amb el tilemap. Cada GIF placeholder té la seua paleta pròpia (Pillow); funciona perquè `Surface` desa paleta per surface. Amb l'art real pot caldre unificar per evitar mismatch cromàtic. | DEFERRED | ### Perímetre i col·lisió diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index b19cc0a..a4292ca 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -95,6 +95,11 @@ class Entity { // manager per tal de confinar la dependència cap a `Surface`. [[nodiscard]] auto getSurface() const -> const std::shared_ptr& { return surface_; } + // Accessor del component d'animació. `EntityManager::renderAll` el + // consulta per decidir quin frame del strip vertical clipa al blit. + // Entitats sense animació retornen `nullopt` → frame 0 per defecte. + [[nodiscard]] auto getAnimation() const -> const std::optional& { return anim_; } + protected: std::string name_; TilePos tile_{}; diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index e191abf..3e54cdc 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -25,12 +25,33 @@ // `Surface`. Les entitats exposen `getSurface()` + `getPixelRect()` i el // manager dispatcha el blit. Si una entitat no té surface (trigger // invisible, encara sense sprite) simplement es salta. +// +// Convenció dels GIFs d'actors: els frames s'estenen en una graella +// left-to-right, top-to-bottom, amb mida de frame igual a (pixel_rect.w, +// pixel_rect.h). El nombre de columnes es dedueix dividint l'amplada del +// surface per l'amplada del frame — així un GIF de 4 frames pot venir com +// a 1×4, 4×1 o 2×2 indistintament. `renderAll` clipa la cel·la del frame +// actual amb un `src_rect`. Entitats sense animació es dibuixen al frame 0. void EntityManager::renderAll() { for (const auto& e : entities_) { const auto& surface = e->getSurface(); if (surface == nullptr) { continue; } const auto RECT = e->getPixelRect(); - surface->render(static_cast(RECT.x), static_cast(RECT.y)); + const auto& anim = e->getAnimation(); + const int FRAME = anim.has_value() ? anim->currentFrame() : 0; + const int FRAME_W = static_cast(RECT.w); + const int FRAME_H = static_cast(RECT.h); + const int COLS = (FRAME_W > 0) ? (surface->getWidth() / FRAME_W) : 1; + const int COLS_SAFE = (COLS > 0) ? COLS : 1; + const int COL = FRAME % COLS_SAFE; + const int ROW = FRAME / COLS_SAFE; + const SDL_FRect SRC{ + .x = static_cast(COL * FRAME_W), + .y = static_cast(ROW * FRAME_H), + .w = RECT.w, + .h = RECT.h, + }; + surface->render(static_cast(RECT.x), static_cast(RECT.y), &SRC); } } @@ -122,15 +143,29 @@ namespace { // Membre privat amagat dins del TU: `EntityManager::` qualifica, però com // que no hi ha declaració al header, només és visible a aquest fitxer. namespace { - auto placeSarcophagus(EntityManager& manager, std::unique_ptr entity, const fkyaml::node& spawn) -> bool { + auto placeSarcophagus(EntityManager& manager, std::unique_ptr entity, const SpriteInfo& sprite, const fkyaml::node& spawn) -> bool { if (!spawn.contains("sarc_position")) { std::cerr << "EntityManager: sarcophagus sense sarc_position\n"; return false; } const auto& sp = spawn["sarc_position"]; - const auto RECT = Map::getSarcRect(sp[0].get_value(), sp[1].get_value()); - const auto ANCHOR = pixelToTileAnchor(RECT); - entity->setPixelRect(RECT); + const auto LOGICAL = Map::getSarcRect(sp[0].get_value(), sp[1].get_value()); + const auto ANCHOR = pixelToTileAnchor(LOGICAL); + + // Convenció de falsa perspectiva (igual que el jugador): el rect de + // render és bottom-aligned amb la caixa lògica de col·lisió. Si el + // sprite fa sprite.height > LOGICAL.h (p.ex. 48×32 sobre caixa 48×16), + // els píxels sobresortits queden per damunt sense afectar la + // col·lisió ni el perímetre — només decoren en alçada. Si sprite.h + // coincideix amb LOGICAL.h, el render coincideix exactament amb la + // caixa. + const SDL_FRect RENDER{ + .x = LOGICAL.x, + .y = LOGICAL.y + LOGICAL.h - sprite.height, + .w = sprite.width, + .h = sprite.height, + }; + entity->setPixelRect(RENDER); // Downcast segur: la factory ens garanteix que "sarcophagus" crea // un `Sarcophagus`. Usant el retorn de `release` al ctor del nou @@ -178,7 +213,7 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) { if (entity == nullptr) { continue; } const bool OK = (TYPE == "sarcophagus") - ? placeSarcophagus(*this, std::move(entity), spawn) + ? placeSarcophagus(*this, std::move(entity), sprite, spawn) : placeGeneric(*this, std::move(entity), TYPE, sprite, spawn); if (OK) { ++loaded; } } diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index c1b4edc..44e8bfd 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -11,6 +11,9 @@ // T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1. // T7 plantilles — carrega els 7 YAML d'entitat sense excepcions. // T8 decoration — Torch no col·lisiona ni és interactable. +// 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. // // Execució (des de la ruta del repo): // make build && cd build && ctest --output-on-failure @@ -268,9 +271,12 @@ namespace { REQUIRE(Map::tilemap_origin_x == -8); REQUIRE(Map::tilemap_origin_y == 0); - // 2) El pyramid.yaml conté 5 spawns amb els tipus esperats. + // 2) El pyramid.yaml conté spawns dels tipus esperats. Comptem al + // menys un de cada tipus que el nivell ha de tenir sí o sí (no + // fixem quants torches exactes perquè és decoratiu i pot + // variar). Així afegir decoració al YAML no trenca el smoke. const auto SPAWNS = EntityLoader::readSpawns("data/maps/pyramid.yaml"); - REQUIRE(SPAWNS.size() == 5); + REQUIRE(!SPAWNS.empty()); int torches = 0; int sarcs = 0; int doors = 0; @@ -282,10 +288,10 @@ namespace { else if (T == "door") { ++doors; } else if (T == "mummy") { ++mummies; } } - REQUIRE(torches == 1); - REQUIRE(sarcs == 2); - REQUIRE(doors == 1); - REQUIRE(mummies == 1); + REQUIRE(torches >= 1); + REQUIRE(sarcs >= 1); + REQUIRE(doors >= 1); + REQUIRE(mummies >= 1); // 3) La conversió sarc-grid (0,0) → tile anchor (3,4) (i.e. la // fórmula que usa loadFromLevel). Sarc rect = (40, 64, 48, 16). @@ -316,6 +322,48 @@ namespace { REQUIRE(sarc_raw->getState() == Sarcophagus::State::OPENING); } + void testSarcophagusYaml() { + std::cout << "[T11] sarcofag YAML + falsa perspectiva\n"; + // El template ha de declarar tile_size [3,1]: el footprint lògic + // són els 48×16 inferiors del sprite, encara que el sprite sencer + // fa 48×32 (16 px de "alçada" per damunt, sense col·lisió). + fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus"); + REQUIRE(tmpl.contains("tile_size")); + REQUIRE(tmpl["tile_size"][0].get_value() == 3); + REQUIRE(tmpl["tile_size"][1].get_value() == 1); + REQUIRE(tmpl["sprite"]["size"][0].get_value() == 48); + REQUIRE(tmpl["sprite"]["size"][1].get_value() == 32); + + // Amb tile_size [3,1], un sarc a ancoratge (3,4) conserva els 8 + // tiles de perímetre (no es converteix en 10 per culpa del [3,2] + // antic). Això blinda la geometria contra regressions al YAML. + auto sarc = std::make_unique(tmpl); + sarc->place(3, 4, {}); + REQUIRE(sarc->getPerimeterSize() == 8); + } + + void testTorchAnimation() { + std::cout << "[T10] torch animation wired from yaml\n"; + // Regressió: el torch.yaml declarava `frames: [0]` i el render no + // consultava `currentFrame()`, així que una antorxa animada no es + // veia viva. Ara el YAML ha de portar 4 frames i l'Entity::update + // ha d'avançar el frame via el component d'animació. + fkyaml::node tmpl = EntityLoader::loadTemplate("torch"); + Torch torch(tmpl); + const auto& ANIM = torch.getAnimation(); + REQUIRE(ANIM.has_value()); + REQUIRE(ANIM->frameCount() == 4); + REQUIRE(ANIM->currentFrame() == 0); + + // Amb fps=8, el pas entre frames és 0.125s. Un tick d'aquest valor + // ha de fer saltar current_ a 1 (i, com que `frames: [0..3]`, el + // valor retornat pel currentFrame coincideix amb l'índex). + torch.update(0.125F); + REQUIRE(torch.getAnimation()->currentFrame() == 1); + torch.update(0.125F); + REQUIRE(torch.getAnimation()->currentFrame() == 2); + } + } // namespace auto main() -> int { @@ -330,6 +378,8 @@ auto main() -> int { testAllTemplatesLoad(); testDecorationFlags(); testLoadFromLevel(); + testSarcophagusYaml(); + testTorchAnimation(); if (g_failures == 0) { std::cout << "ALL OK\n";