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 0b80dbb..ab20f75 100644 Binary files a/data/player/player.gif and b/data/player/player.gif differ diff --git a/docs/entities-system.md b/docs/entities-system.md index 1c33287..14b2c60 100644 --- a/docs/entities-system.md +++ b/docs/entities-system.md @@ -4,6 +4,22 @@ Registre del disseny, els treballs fets i els pendents per al sistema d'entitats Estats possibles: `DONE`, `WIP`, `DEFERRED`, `WONTFIX`. +## Convenció de col·lisió i anchors (fase 3) + +- **`sprite.size`** (YAML entitat): mida en píxels del frame de render. +- **`tile_size`** (YAML entitat): footprint lògic en tiles. Només el + consumeix Sarcophagus per al perímetre; a la resta és declaratiu. +- **`collision: { size: [w, h], offset: [x, y] }`** (opcional al YAML + entitat): sub-rect de col·lisió dins del sprite. Si `offset` falta, es + calcula automàticament bottom-center (falsa perspectiva típica). + Sense bloc, `Entity::getCollisionRect()` = `pixel_rect_` sencer. +- **`anchor`** (opcional al YAML entitat): `top_left` (per defecte) o + `feet`. `feet` vol dir que el `position: [col, row]` del map és el + tile dels peus; el sprite es centra horitzontalment sobre el tile i + s'alinea la vora inferior del sprite amb la vora inferior del tile. + Els sarcòfags tenen bottom-align hardcoded i no usen `anchor`. + + --- ## 1. Context i objectiu inicial diff --git a/source/game/entities/entity.cpp b/source/game/entities/entity.cpp index 2230163..0966ec8 100644 --- a/source/game/entities/entity.cpp +++ b/source/game/entities/entity.cpp @@ -26,3 +26,18 @@ void Entity::onPlayerContact(Player* /*player*/) { void Entity::notifyTileEntered(int /*col*/, int /*row*/) { // No-op per defecte; només `Sarcophagus` l'usa. } + +auto Entity::getCollisionRect() const -> 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) {