diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index ef1232d..3c42890 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -40,6 +40,14 @@ ai: type: corridor_wanderer direction_choice: random initial_direction: east + +# Estat SPAWN: la mòmia s'instancia visible al pixel_rect_ inicial just +# quan GameScene crida `spawnPendingActors()` (a la subfase text-exit +# de ROOM_INTRO, fase 4). El `triggerSpawnExit` es dispara llavors +# mateix; passats `duration_s` segons transita a ROAMING i empieza a +# deambular. Sense aquest bloc, default 0 ⇒ ACTIVE immediat. +spawn: + duration_s: 1.0 stats: speed: 30 damage: 1 diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index cb4d554..11d54d1 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -122,6 +122,17 @@ Mummy::Mummy(const fkyaml::node& yaml) changeDirection(0.0F, 1.0F); } + // Estat SPAWN inicial: la mòmia naix visible i quieta, esperant que el + // GameScene li armi el comptador via `triggerSpawnExit()` (cridat al + // final de la subfase text-exit de ROOM_INTRO). Si el YAML no declara + // `spawn.duration_s`, default 0 ⇒ al primer update post-trigger passa + // directament a ACTIVE. + float spawn_duration_s = 0.0F; + if (yaml.contains("spawn") && yaml["spawn"].contains("duration_s")) { + spawn_duration_s = yaml["spawn"]["duration_s"].get_value(); + } + enterSpawn(spawn_duration_s); + // Configuració de l'estat HURT: opcional. Si el bloc no apareix, la // mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la // pot ignorar amb `isWhippable() == false` si volem que no reacció. @@ -510,6 +521,9 @@ void Mummy::applyChoice(Direction dir) { } void Mummy::onPlayerContact(Player* player) { + // Defensiu: el filtre central a EntityManager::checkPlayerCollisions ja + // salta DynamicHazards en SPAWN, però blindem la crida directa. + if (isSpawning()) { return; } if (bus_ == nullptr) { return; } const int PID = (player != nullptr) ? player->getId() : -1; bus_->publish(Event{ @@ -534,6 +548,10 @@ void Mummy::onPlayerContact(Player* player) { } void Mummy::enterHurt(Direction flee_dir) { + // Defensiu: el whip no hauria de reaccionar contra una mòmia en SPAWN + // (el filtre de col·lisió la salta), però blindem per si algun camí + // futur crida directament. + if (isSpawning()) { return; } state_ = State::HURT; hurt_remaining_s_ = hurt_config_.duration_s; hurt_speed_ = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_; diff --git a/source/game/entities/concrete/whip_attack.cpp b/source/game/entities/concrete/whip_attack.cpp index e75b16e..90f49ed 100644 --- a/source/game/entities/concrete/whip_attack.cpp +++ b/source/game/entities/concrete/whip_attack.cpp @@ -299,19 +299,22 @@ namespace { // Tracta una mòmia candidata: si és whippable i la seua caja solapa // amb `damage_rect`, la posa a HURT i la marca al set. Extret per // acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar). + // Rep un `DynamicHazard*` (l'iterador d'`applyDamage` ja filtra el + // sac d'actors); només cal el dynamic_cast a Mummy per descartar + // altres hermanes (RollingRock i futurs DynamicHazards no whippables). void hitMummyIfApplicable( - Entity* e, + DynamicHazard* actor, const SDL_FRect& damage_rect, WhipAttack::Direction dir, std::unordered_set& already_hit) { - if (e == nullptr || e->isDead()) { return; } - if (already_hit.contains(static_cast(e))) { return; } - auto* mummy = dynamic_cast(e); + if (actor == nullptr || actor->isDead()) { return; } + if (already_hit.contains(static_cast(actor))) { return; } + auto* mummy = dynamic_cast(actor); if (mummy == nullptr || !mummy->isWhippable()) { return; } const auto BOX = mummy->getCollisionRect(); if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; } mummy->enterHurt(whipDirToMummyDir(dir)); - already_hit.insert(static_cast(e)); + already_hit.insert(static_cast(actor)); } void hitPlayerIfApplicable( @@ -336,8 +339,12 @@ namespace { void WhipAttack::applyDamage() { if (entity_manager_ != nullptr) { - for (auto* e : entity_manager_->getEntities()) { - hitMummyIfApplicable(e, damage_rect_, direction_, already_hit_); + // Només els actors poden rebre el fuetejo (decoracions, sarcs, + // forats... no són hits del whip). Iterem el sac `actors_` + // directament — sense recórrer tot el mapa per descartar-ne la + // major part. + for (auto* actor : entity_manager_->getActors()) { + hitMummyIfApplicable(actor, damage_rect_, direction_, already_hit_); } } if (players_ != nullptr) { diff --git a/source/game/entities/entity_manager.cpp b/source/game/entities/entity_manager.cpp index 45b67bb..7e838e2 100644 --- a/source/game/entities/entity_manager.cpp +++ b/source/game/entities/entity_manager.cpp @@ -74,7 +74,19 @@ auto EntityManager::add(std::unique_ptr entity) -> Entity* { entity->setEventBus(&bus_); entity->setEntityManager(this); Entity* raw = entity.get(); - entities_.push_back(std::move(entity)); + // Enrutament per categoria semàntica: si l'entitat és (o deriva de) + // DynamicHazard va al sac `actors_`, la resta al sac `map_entities_`. + // Així els consumidors que només volen actors itera el sac petit i + // ens estalviem dynamic_casts dispersos al codi (colisió, whip...). + if (dynamic_cast(entity.get()) != nullptr) { + // Transferim l'ownership al unique_ptr tipat fort. + // El `release()` consumeix el seu valor com a argument del ctor — + // sense crida nua que el linter pugui marcar com a ignorada. + std::unique_ptr hazard{static_cast(entity.release())}; + actors_.push_back(std::move(hazard)); + } else { + map_entities_.push_back(std::move(entity)); + } // cppcheck-suppress returnDanglingLifetime return raw; } @@ -91,9 +103,15 @@ auto EntityManager::removeEntity(Entity* observer) -> bool { if (observer == nullptr) { return false; } const bool WAS_DOOR = (door_ == observer); const bool WAS_SARC = (observer->getName() == "sarcophagus"); - auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; }); - if (it == entities_.end()) { return false; } - entities_.erase(it); + // Busquem en els dos sacs. El sarc i la door sempre viuen a + // `map_entities_` per disseny; un actor (DynamicHazard) viu a `actors_`. + if (auto it = std::ranges::find_if(map_entities_, [observer](const auto& up) { return up.get() == observer; }); it != map_entities_.end()) { + map_entities_.erase(it); + } else if (auto it_a = std::ranges::find_if(actors_, [observer](const auto& up) { return up.get() == observer; }); it_a != actors_.end()) { + actors_.erase(it_a); + } else { + return false; + } if (WAS_DOOR) { door_ = nullptr; } if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; } // No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot @@ -107,7 +125,8 @@ auto EntityManager::addSarcophagus(std::unique_ptr sarc) -> Sarcoph sarc->setEventBus(&bus_); sarc->setEntityManager(this); Sarcophagus* raw = sarc.get(); - entities_.push_back(std::move(sarc)); + // El sarc és Interactable, viu al sac `map_entities_`. + map_entities_.push_back(std::move(sarc)); ++total_sarcs_; // cppcheck-suppress returnDanglingLifetime return raw; @@ -115,30 +134,34 @@ auto EntityManager::addSarcophagus(std::unique_ptr sarc) -> Sarcoph void EntityManager::update(float delta_time) { // ATENCIÓ: si en el futur una entitat publica al bus events que - // resulten en `entity_manager_.add()` (push_back a `entities_`), açò + // resulten en `entity_manager_.add()` (push_back als sacs), açò // invalidaria l'iterador de la ranged-for. Avui no passa: els spawns // runtime venen de fora del cicle d'`update` (p.ex. el polvet del // saltador es spawneja durant `Player::tick`, abans d'aquest mètode). - for (auto& e : entities_) { e->update(delta_time); } + for (auto& e : map_entities_) { e->update(delta_time); } + for (auto& a : actors_) { a->update(delta_time); } // Purga d'entitats marcades mortes aquest frame (efectes acabats, // p.ex. polvet d'aterratge). Les persistents (sarcs, momies, etc.) // mai no es marquen. - std::erase_if(entities_, [](const auto& e) { return e->isDead(); }); + std::erase_if(map_entities_, [](const auto& e) { return e->isDead(); }); + std::erase_if(actors_, [](const auto& a) { return a->isDead(); }); } void EntityManager::notifyPlayerTilePainted(int col, int row) { - for (auto& e : entities_) { e->notifyTilePainted(col, row); } + // Fan-out als dos sacs. Avui només Sarcophagus consumeix (viu a + // map_entities_), però els actors també reben la notificació per + // si en el futur volen reaccionar (cost: una crida virtual buida). + for (auto& e : map_entities_) { e->notifyTilePainted(col, row); } + for (auto& a : actors_) { a->notifyTilePainted(col, row); } } auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* { - // Cerca lineal sobre `entities_`: cost menyspreable amb poques - // desenes d'entitats per sala i una crida per frame des dels - // consumidors actius (Player a l'aterratge, Mòmia a la decisió de - // direcció). Si esdevé calent, es podria indexar per cel·la del - // tilemap. La doble condició filtra exclusivament decals de sòl que - // volen rebre triggers — avui Hole; en el futur pinchos plans, - // plaques de pressió, charcos. - for (const auto& entity : entities_) { + // Cerca lineal sobre `map_entities_`: els floor decals trigger són + // exclusivament StaticHazards (Hole; en el futur pinchos plans, + // plaques, charcos). Els actors no qualifiquen com a decals — saltem + // directament aquest sac, sense `triggersOnOverlap`/`isFloorDecal` per + // a cada un. La doble condició filtra dins de map_entities_. + for (const auto& entity : map_entities_) { if (!entity->triggersOnOverlap()) { continue; } if (!entity->isFloorDecal()) { continue; } const auto BOX = entity->getCollisionRect(); @@ -149,9 +172,27 @@ auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Ent } auto EntityManager::getEntities() const -> std::vector { + // Concatenació map + actors per retro-compat (editor, z-sort debug). + // Els callers que ja saben quin sac volen poden cridar + // `getMapEntities` o `getActors` i estalviar la construcció del temp. std::vector out; - out.reserve(entities_.size()); - std::ranges::transform(entities_, std::back_inserter(out), [](const auto& e) { return e.get(); }); + out.reserve(map_entities_.size() + actors_.size()); + std::ranges::transform(map_entities_, std::back_inserter(out), [](const auto& e) { return e.get(); }); + std::ranges::transform(actors_, std::back_inserter(out), [](const auto& a) -> Entity* { return a.get(); }); + return out; +} + +auto EntityManager::getMapEntities() const -> std::vector { + std::vector out; + out.reserve(map_entities_.size()); + std::ranges::transform(map_entities_, std::back_inserter(out), [](const auto& e) { return e.get(); }); + return out; +} + +auto EntityManager::getActors() const -> std::vector { + std::vector out; + out.reserve(actors_.size()); + std::ranges::transform(actors_, std::back_inserter(out), [](const auto& a) { return a.get(); }); return out; } diff --git a/source/game/entities/entity_manager.hpp b/source/game/entities/entity_manager.hpp index 77b3c25..affc0b5 100644 --- a/source/game/entities/entity_manager.hpp +++ b/source/game/entities/entity_manager.hpp @@ -9,6 +9,7 @@ #include "external/fkyaml_node.hpp" #include "game/entities/event_bus.hpp" +#include "game/entities/hazard.hpp" class Entity; class Sarcophagus; @@ -74,7 +75,13 @@ class EntityManager { // Accés als punters observacionals a les entitats per a consumidors // externs que necessiten construir una llista ordenada (z-order). + // `getEntities()` concatena els dos sacs interns (mapa + actors) per + // a consumidors retro-compat (editor, z-sort debug). `getMapEntities` + // i `getActors` exposen cada sac per separat — útil per a iteracions + // que ja saben quin tipus volen i estalvien filtres O(n). [[nodiscard]] auto getEntities() const -> std::vector; + [[nodiscard]] auto getMapEntities() const -> std::vector; + [[nodiscard]] auto getActors() const -> std::vector; // Comprova contacte del(s) jugador(s) amb entitats `collides()=true`. // Si la caixa base del jugador solapa amb la caixa de col·lisió de @@ -119,6 +126,23 @@ class EntityManager { // d'una posició del cursor i el passa. auto addSpawn(const fkyaml::node& spawn) -> Entity*; + // Instancia tots els actors que estan en `pending_actors_` (cua que + // `loadFromLevel` omple amb les DynamicHazards declarades al YAML del + // mapa). Cada actor neix en estat SPAWN (per defecte des de la + // plantilla; veure `Mummy::Mummy`) i s'arma immediatament el + // `triggerSpawnExit()` perquè la fase EMERGING corri els seus segons + // de `spawn.duration_s` i transiti a ACTIVE. Vacia la cua. Cridada + // des de GameScene en una subfase concreta de ROOM_INTRO i des de + // `respawnPlayersInstant` (salt debug). + void spawnPendingActors(); + + // Crea un actor on-demand i el deixa en EMERGING (com a + // `spawnPendingActors` però per a un sol). Pensat per a futurs + // generadors (StaticHazards que produeixen DynamicHazards en runtime, + // p.ex. gotes que cauen, roques que rodolen). Retorna el punter + // observacional o `nullptr` si la plantilla o el factory fallen. + auto spawnActor(const std::string& type, int col, int row) -> DynamicHazard*; + // Reset complet del nivell: destrueix totes les entitats, reseteja // els comptadors interns (sarcòfags oberts, total, exited, flag de // level_complete) i torna a cridar `loadFromLevel` per regenerar les @@ -136,7 +160,8 @@ class EntityManager { void setExpectedPlayerCount(int n); [[nodiscard]] auto getBus() -> EventBus& { return bus_; } - [[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); } + [[nodiscard]] auto entityCount() const -> std::size_t { return map_entities_.size() + actors_.size(); } + [[nodiscard]] auto pendingActorCount() const -> std::size_t { return pending_actors_.size(); } [[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; } [[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; } [[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; } @@ -150,7 +175,27 @@ class EntityManager { private: EventBus bus_; - std::vector> entities_; + // Dos sacs lògics: + // `map_entities_` — decoració, static_hazards (Hole, FloorTrap, + // WallTrap) i interactables (Sarcophagus, Door). + // Cargades al loadFromLevel i persistents durant + // tota la sala. + // `actors_` — DynamicHazards (Mummy, RollingRock, futurs + // enemics). Separats per eliminar dynamic_casts + // i preparar el spawn diferit (commit posterior). + // `add()` enruta automàticament segons sigui o no DynamicHazard. + std::vector> map_entities_; + std::vector> actors_; + // Cua d'actors declarats al YAML del mapa però encara no instanciats: + // `loadFromLevel` hi empeny tots els DynamicHazards. `spawnPendingActors` + // o `spawnActor` consumeixen aquesta cua (o creen un actor on-demand) + // i ho buiden. El `fkyaml::node` original es guarda perquè conté la + // posició i qualsevol override del map respecte a la plantilla. + struct PendingActor { + std::string type; + fkyaml::node spawn_node; + }; + std::vector pending_actors_; Door* door_{nullptr}; std::size_t total_sarcs_{0}; std::size_t opened_sarcs_{0}; diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 091a5c8..d3b2bf8 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -18,6 +18,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/entity_loader.hpp" #include "game/entities/entity_manager.hpp" +#include "game/entities/hazard.hpp" #include "game/entities/player.hpp" #include "game/map/map.hpp" #include "utils/defaults.hpp" @@ -56,52 +57,64 @@ void EntityManager::renderEntity(const Entity& e) { } void EntityManager::renderAll() { - for (const auto& e : entities_) { renderEntity(*e); } + // Sense ordenació per z: l'ordre és el d'inserció dins de cada sac. + // El render del joc real (GameScene) construeix el seu propi vector + // ordenat per `feet_y`; aquest helper queda per al editor i tests. + for (const auto& e : map_entities_) { renderEntity(*e); } + for (const auto& a : actors_) { renderEntity(*a); } } +namespace { + // Filtres "per què aquest jugador no rep contacte d'aquest hazard ara + // mateix": fora de joc, DYING, caient per forat (la transició a mort + // per forat la mana `integrateJump`, no la pot interrompre cap + // hazard), o salt per damunt de l'alçada del cos. Extret a funció + // per acotar la complexitat cognitiva del callsite. Veure la nota a + // `isFallingThroughHole`: si algun dia s'elimina aquest skip, cal + // revisar la cadena DYING(HOLE) → respawn. + auto playerSkipsHazard(const Player& p, int hazard_height_px) -> bool { + if (p.isOutOfPlay() || p.isDying()) { return true; } + if (p.isFallingThroughHole()) { return true; } + return p.isAirborne() && hazard_height_px > 0 && p.getJumpZ() > static_cast(hazard_height_px); + } + + auto playerOverlapsEntityBox(const Player& p, const SDL_FRect& box) -> bool { + // Hit-box petita del jugador (`getHitBox`) en lloc de la caja base + // 16×16 — per a "tocar una entitat" volem precisió, no rebot físic. + const auto PBOX = p.getHitBox(); + return PBOX.x + PBOX.w > box.x && box.x + box.w > PBOX.x && PBOX.y + PBOX.h > box.y && box.y + box.h > PBOX.y; + } +} // namespace + void EntityManager::checkPlayerCollisions(const std::vector& players) { if (players.empty()) { return; } - for (const auto& entity : entities_) { - // Acceptem entitats sòlides (xoc físic) i triggers d'àrea (no - // bloquegen però volen rebre `onPlayerContact`). Tanquem el gap - // latent de FloorTrap/WallTrap (que tenien `collides_=false` i - // mai no es disparaven) i obrim la porta a hazards no-físics - // com `Hole`. - if (!entity->collides() && !entity->triggersOnOverlap()) { continue; } + // Helper local: aplica el contacte d'una entitat sobre tots els + // jugadors, amb els filtres per-jugador a `playerSkipsHazard`. Es + // comparteix entre els dos sacs (map_entities_ i actors_). + auto apply_contacts = [&players](Entity* entity) { const auto BOX = entity->getCollisionRect(); const int E_HEIGHT = entity->getHeight(); for (auto* p : players) { - // Filtrem també DYING: el jugador en cicle de mort no rep - // contacte amb hazards (un segon toc de mòmia mentre la roda - // ja és no-op; cap altra entitat l'ha de fer transicionar). - if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; } - // Mentre el jugador cau per un forat, ignorem qualsevol altre - // hazard: la transició a `enterDying()` ja la dispara - // `integrateJump` quan supera `HOLE_FALL_DEPTH`. - // - // NOTA: aquest skip és la defensa que evita que un jugador - // caient per un forat reba contacte d'altres hazards - // "subterranis" durant la caiguda. La cadena de mort per - // forat (`enterDying(HOLE)` → respawn) depén de no ser - // interrompuda per un event `player_hit`/`player_killed` - // mentre `falling_through_hole_=true`. Si el dia que es - // toque aquest skip s'elimina, cal revisar la cadena - // mort/respawn per forat. - if (p->isFallingThroughHole()) { continue; } - // Filtre per altura: si el jugador està en l'aire i està per - // damunt de l'alçada del cos de l'entitat, no hi ha contacte. - // És el mecanisme del salt per esquivar momies, roques, etc. - // Quan height és 0 (entitat sense altura declarada), el filtre - // no es dispara mai i la col·lisió funciona com abans. - if (p->isAirborne() && E_HEIGHT > 0 && p->getJumpZ() > static_cast(E_HEIGHT)) { continue; } - // Contacte actor-actor amb hit-box petita: la caixa base - // 16×16 servia per moure (xoc amb murs), però per a l'event - // "toca una entitat" volem precisió — que la momia mate quan - // els píxels centrals es solapen, no quan els caps es freguen. - const auto PBOX = p->getHitBox(); - const bool OVERLAP = PBOX.x + PBOX.w > BOX.x && BOX.x + BOX.w > PBOX.x && PBOX.y + PBOX.h > BOX.y && BOX.y + BOX.h > PBOX.y; - if (OVERLAP) { entity->onPlayerContact(p); } + if (p == nullptr || playerSkipsHazard(*p, E_HEIGHT)) { continue; } + if (playerOverlapsEntityBox(*p, BOX)) { entity->onPlayerContact(p); } } + }; + + // Sac de mapa: filtre per col·lisió / trigger. Tanquem el gap latent + // de FloorTrap/WallTrap (que tenien `collides_=false` i mai no es + // disparaven) i obrim la porta a hazards no-físics com `Hole`. + for (const auto& entity : map_entities_) { + if (!entity->collides() && !entity->triggersOnOverlap()) { continue; } + apply_contacts(entity.get()); + } + // Sac d'actors (DynamicHazards): tots fan dany quan són actius. + // Saltem els que estan en SPAWN: visibles però sense interacció + // durant la fase inicial de ROOM_INTRO i durant futurs spawns en + // runtime. Sense dynamic_cast: el tipus ja és DynamicHazard. + for (const auto& actor : actors_) { + if (actor->isSpawning()) { continue; } + if (!actor->collides() && !actor->triggersOnOverlap()) { continue; } + apply_contacts(actor.get()); } } @@ -202,6 +215,24 @@ namespace { return tmpl["anchor"].get_value(); } + // Llig el camp `type:` de la plantilla (la categoria semàntica: + // "decoration", "static_hazard", "interactable", "dynamic_hazard", + // "effect"). Tornarà buit si la plantilla no es pot carregar o no + // declara el camp. S'usa al loader per decidir quin sac rep l'entitat. + auto readTemplateCategory(const std::string& type) -> std::string { + try { + const auto TMPL = EntityLoader::loadTemplate(type); + if (TMPL.contains("type")) { return TMPL["type"].get_value(); } + } catch (const std::exception& e) { + // Plantilla absent / parse error: deixem el caller que ho + // gestione (típicament `buildEntity` també fallarà i deixarà + // el seu propi log). Aquí només avisem perquè el silenci + // total amaga problemes de YAML mal formats. + Log::warn("EntityManager", "readTemplateCategory({}) ha fallat: {}", type, e.what()); + } + return {}; + } + // 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. @@ -382,7 +413,9 @@ void EntityManager::reload(const std::string& level_yaml_path) { // subscripcions implícites desapareixen amb la seua vida (cap d'elles // se subscriu directament al bus — només publiquen). El bus i les // subscripcions externes (PaintLayer, HUD, GameScene) no es toquen. - entities_.clear(); + map_entities_.clear(); + actors_.clear(); + pending_actors_.clear(); door_ = nullptr; total_sarcs_ = 0; opened_sarcs_ = 0; @@ -419,8 +452,21 @@ auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* { void EntityManager::loadFromLevel(const std::string& level_yaml_path) { const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path); - const int LOADED = static_cast(std::ranges::count_if(SPAWNS, - [this](const fkyaml::node& spawn) { return addSpawn(spawn) != nullptr; })); + int loaded = 0; + for (const auto& spawn : SPAWNS) { + if (!spawn.contains("type")) { continue; } + const auto TYPE = spawn["type"].get_value(); + // Spawn diferit: els DynamicHazards (mòmies, roques, futurs + // enemics) no s'instancien aquí. GameScene crida + // `spawnPendingActors()` al final del text-exit de ROOM_INTRO + // (i, defensivament, en salts debug). La instanciació = la + // seua aparició visual. + if (readTemplateCategory(TYPE) == "dynamic_hazard") { + pending_actors_.push_back(PendingActor{.type = TYPE, .spawn_node = spawn}); + continue; + } + if (addSpawn(spawn) != nullptr) { ++loaded; } + } // Broadcast inicial: marca com a "pintats" tots els tiles ocupats per // decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...). @@ -432,7 +478,9 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) { // perquè tots els sarcòfags ja estan registrats al fan-out (la // subscripció del manager és al ctor, anterior a qualsevol load). int decal_broadcast = 0; - for (const auto& entity : entities_) { + // Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards + // i viuen exclusivament a `map_entities_`. Saltem `actors_` per estalviar. + for (const auto& entity : map_entities_) { if (!entity->isFloorDecal()) { continue; } const auto BOX = entity->getCollisionRect(); const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y); @@ -447,5 +495,42 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) { Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast); } - Log::info("EntityManager", "{} entitats carregades des de {}", LOADED, level_yaml_path); + Log::info("EntityManager", "{} map entities + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path); +} + +void EntityManager::spawnPendingActors() { + // Itera els pending i els instancia via `addSpawn`. El dispatch d'`add()` + // els enruta a `actors_`. El `triggerSpawnExit()` posterior arma el + // comptador de SPAWN — la mòmia neix ja en EMERGING, sense esperes. + for (const auto& pa : pending_actors_) { + Entity* observer = addSpawn(pa.spawn_node); + if (observer == nullptr) { continue; } + // Per categoria YAML (`type: dynamic_hazard`) sabem que és + // DynamicHazard — el dynamic_cast aquí val zero i evita + // assumptions implícites sobre la jerarquia. + if (auto* dh = dynamic_cast(observer); dh != nullptr) { + dh->triggerSpawnExit(); + } + } + pending_actors_.clear(); +} + +auto EntityManager::spawnActor(const std::string& type, int col, int row) -> DynamicHazard* { + // Construïm un node de spawn sintètic amb el mínim que `placeGeneric` + // necessita: `type` i `position: [col, row]`. Si el caller vol + // overrides addicionals (orientació inicial, durada de spawn...), + // afegirà una variant amb fkyaml::node pre-construït més endavant. + fkyaml::node spawn; + try { + const std::string YAML_TEXT = "type: " + type + "\nposition: [" + std::to_string(col) + ", " + std::to_string(row) + "]\n"; + spawn = fkyaml::node::deserialize(YAML_TEXT.begin(), YAML_TEXT.end()); + } catch (const std::exception&) { + Log::error("EntityManager", "spawnActor: no s'ha pogut construir spawn YAML per {}", type); + return nullptr; + } + Entity* observer = addSpawn(spawn); + if (observer == nullptr) { return nullptr; } + auto* dh = dynamic_cast(observer); + if (dh != nullptr) { dh->triggerSpawnExit(); } + return dh; } diff --git a/source/game/entities/hazard.hpp b/source/game/entities/hazard.hpp index 79c5cce..641dd4c 100644 --- a/source/game/entities/hazard.hpp +++ b/source/game/entities/hazard.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include "game/entities/entity.hpp" // Base d'entitats perilloses: al contacte amb el jugador, apliquen dany. @@ -56,8 +58,20 @@ class StaticHazard : public Hazard { // Perill dinàmic: es mou. Velocitat expressada en píxels/segon i una // direcció (vec2). Les subclasses decideixen la política de moviment // (IA corridor_wanderer, línia recta, etc.) sobreescribint `step(dt)`. +// +// Estat SPAWN: tota DynamicHazard pot estar en SPAWNING (visible, sense +// moviment ni dany) fins que algú extern arme el countdown amb +// `triggerSpawnExit()`. Quan el comptador arriba a zero passa a ACTIVE i +// recupera la lògica normal (`step()` per frame). Per defecte arrenca en +// ACTIVE — backward-compat amb subclasses que encara no usen el mecanisme. +// El filtre central de col·lisió a `EntityManager::checkPlayerCollisions` +// salta les entitats en SPAWN: per això el contacte no fa dany ni dispara +// triggers durant aquesta fase. class DynamicHazard : public Hazard { public: + enum class SpawnState : std::uint8_t { SPAWNING, + ACTIVE }; + DynamicHazard() = default; void setSpeed(float px_per_s) { speed_ = px_per_s; } @@ -70,6 +84,21 @@ class DynamicHazard : public Hazard { [[nodiscard]] auto getDirX() const -> float { return dir_x_; } [[nodiscard]] auto getDirY() const -> float { return dir_y_; } + // Entra en SPAWNING amb una duració determinada. El comptador NO + // descompta fins que algú crida `triggerSpawnExit()` — l'entitat queda + // visible i quieta esperant indefinidament. + void enterSpawn(float duration_s) { + spawn_state_ = SpawnState::SPAWNING; + spawn_duration_s_ = duration_s; + spawn_remaining_s_ = duration_s; + spawn_exit_armed_ = false; + } + // Arma el comptador del SPAWN. A partir d'aquí, cada `update(dt)` + // descompta `dt` fins arribar a 0 i transitar a ACTIVE. + void triggerSpawnExit() { spawn_exit_armed_ = true; } + [[nodiscard]] auto isSpawning() const -> bool { return spawn_state_ == SpawnState::SPAWNING; } + [[nodiscard]] auto getSpawnDurationS() const -> float { return spawn_duration_s_; } + // Política de moviment per frame. La base desplaça el pixel_rect_ // proporcionalment a velocitat i direcció; les subclasses amb IA la // sobreescriuen per triar nova direcció a les interseccions o rebotar. @@ -79,6 +108,19 @@ class DynamicHazard : public Hazard { } void update(float delta_time) override { + if (spawn_state_ == SpawnState::SPAWNING) { + // Sense `step()`: l'entitat es queda quieta al pixel_rect_ + // inicial. `Entity::update` tic d'animació igual: si en algun + // moment posem un GIF de spawn aquí ja anirà. + Entity::update(delta_time); + if (spawn_exit_armed_) { + spawn_remaining_s_ -= delta_time; + if (spawn_remaining_s_ <= 0.0F) { + spawn_state_ = SpawnState::ACTIVE; + } + } + return; + } Entity::update(delta_time); step(delta_time); } @@ -87,4 +129,8 @@ class DynamicHazard : public Hazard { float speed_{0.0F}; float dir_x_{0.0F}; float dir_y_{0.0F}; + SpawnState spawn_state_{SpawnState::ACTIVE}; + float spawn_duration_s_{0.0F}; + float spawn_remaining_s_{0.0F}; + bool spawn_exit_armed_{false}; }; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 022078d..1ad5b06 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -31,6 +31,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/entity_loader.hpp" #include "game/entities/event_bus.hpp" +#include "game/entities/hazard.hpp" #include "game/entities/player.hpp" #include "game/gameplay_config.hpp" #include "game/levels/level_manager.hpp" @@ -622,6 +623,7 @@ void GameScene::enterRoomIntro() { intro_completed_ = false; intro_spawn_elapsed_ms_ = 0; players_spawned_in_intro_.fill(false); + pending_actors_spawned_ = false; // Reset visual: arrenquem el FADE_IN d'INTRO i deixem el d'OUTRO // inactiu (es re-armarà a `setState(ROOM_OUTRO)` i, dins d'aquell @@ -674,6 +676,12 @@ void GameScene::respawnPlayersInstant() { state_ = RoomFsm::State::PLAYING; intro_completed_ = true; transition_ = {}; + // El salt debug a sala curt-circuit'a la cinemàtica d'INTRO i, amb + // ella, l'spawn diferit dels DynamicHazards que viu a la fase 4 + // d'INTRO. Si no els instanciàvem ací també, les mòmies de la sala + // entrada per editor no apareixerien mai. + entity_manager_.spawnPendingActors(); + pending_actors_spawned_ = true; Log::info("ROOM", "post-editor: spawn instantani, estat -> PLAYING"); } #endif @@ -827,6 +835,15 @@ void GameScene::completeIntro() { if (intro_completed_) { return; } intro_completed_ = true; intro_spawn_elapsed_ms_ = 0; + // Fallback defensiu: si per qualsevol camí (config de fases amb tots + // els intervals molt curts, salt manual) hem arribat a PLAYING sense + // haver passat per la fase 4 que dispara l'spawn diferit, ho fem ara + // mateix. Sense això, les mòmies pendents quedarien per sempre a + // `pending_actors_` i la sala no tindria enemics. + if (!pending_actors_spawned_) { + entity_manager_.spawnPendingActors(); + pending_actors_spawned_ = true; + } // Spawn dels slots amb delay <= 0 (el cas habitual de P1). Els // altres cauen frame a frame dins `updateRoomTransition` a mesura // que `intro_spawn_elapsed_ms_` supera el seu delay. Així P1 cau @@ -863,6 +880,7 @@ void GameScene::setState(RoomFsm::State new_state) { intro_completed_ = false; intro_spawn_elapsed_ms_ = 0; players_spawned_in_intro_.fill(false); + pending_actors_spawned_ = false; } auto* audio = Audio::get(); // PLAYING: arrenca la pista de joc en loop infinit. Netegem el @@ -923,6 +941,23 @@ void GameScene::updateRoomTransition(int delta_ms) { const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms); + // ROOM_INTRO: en entrar a la fase 5 (music_tail, immediatament després + // del text-exit) alliberem els DynamicHazards del seu estat SPAWN. Les + // mòmies surten visualment "del terra" just després que el text desapareix + // i abans que caiguin els jugadors — la sala arrenca amb tots els actors + // gairebé alhora (les mòmies amb un petit head-start `spawn.duration_s`). + // ROOM_INTRO, fase 4 = text-exit (el text està desapareixent). Instanciem + // els actors diferits (mòmies) — `EntityManager::spawnPendingActors` els + // crea ja en EMERGING. Visualment, "surten del terra" alhora que el text + // surt de pantalla. Amb spawn.duration_s ~ 1.0 s, queden ACTIVE + // coincidint amb la caiguda dels jugadors a music_tail. + if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 4 && !pending_actors_spawned_) { + const std::size_t PENDING = entity_manager_.pendingActorCount(); + entity_manager_.spawnPendingActors(); + pending_actors_spawned_ = true; + Log::info("ROOM", "INTRO fase 4: spawn diferit de {} actors", PENDING); + } + // OUTRO: arrenquem `outro_fade_` la primera vegada que entrem a la // fase de fade_out (phase 4 del timeline d'OUTRO). Si l'`advance` // ha consumit fases buides en aquest mateix frame i ja portem @@ -1758,10 +1793,18 @@ void GameScene::render() { }; std::vector order; order.reserve(entity_manager_.entityCount() + players_.size()); - const auto ENTITIES = entity_manager_.getEntities(); - std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) { + // Mergem els dos sacs (map + actors) i els jugadors en un sol + // vector temporal — el `stable_sort` per `feet_y` posterior fa el + // z-order interleaving entre els tres orígens sense importar el + // sac d'on venen. + const auto MAP = entity_manager_.getMapEntities(); + std::ranges::transform(MAP, std::back_inserter(order), [](Entity* e) { return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr}; }); + const auto ACTORS = entity_manager_.getActors(); + std::ranges::transform(ACTORS, std::back_inserter(order), [](DynamicHazard* a) -> Renderable { + return Renderable{.feet_y = a->getFeetY(), .entity = a, .player = nullptr}; + }); // Tots els jugadors entren al z-sort. Els fora de joc fan no-op a // `render()`; els actius (inclosos AIRBORNE durant l'spawn aeri) // es pinten amb el seu feet_y normal. La sombra que afecta la diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 95ec21f..2abd872 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -202,6 +202,14 @@ class GameScene : public Scene { // i a `setState(ROOM_INTRO)` (camí defensiu no canònic). std::array players_spawned_in_intro_{false, false}; + // Idempotència de la crida a `entity_manager_.spawnPendingActors()` + // dins d'un cicle d'INTRO. Es dispara una sola vegada quan + // ROOM_INTRO entra a la subfase text-exit (fase 4) i, defensivament, + // a `respawnPlayersInstant` i al fallback de `completeIntro` per a + // camins no canònics. Reset a `enterRoomIntro` i a + // `setState(ROOM_INTRO)`. + bool pending_actors_spawned_{false}; + // Vista plana de punters crus — la fa servir Player com a llista // d'obstacles mòbils a la callback de col·lisió. Es construeix un sol // cop al ctor (els punters són estables perquè `players_` no es diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index 73acf4a..d94e13b 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -69,6 +69,15 @@ namespace { #define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) + // Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra). + // Equival al que fa GameScene al final del text-exit de ROOM_INTRO: arma + // el comptador i consumeix la durada amb un update. Tests que operin + // directament sobre HURT/ROAMING l'han de cridar abans. + void exitMummySpawn(Mummy& mummy) { + mummy.triggerSpawnExit(); + mummy.update(mummy.getSpawnDurationS() + 0.001F); + } + void testFactory() { std::cout << "[T1] factory\n"; auto& factory = EntityFactory::instance(); @@ -442,6 +451,7 @@ namespace { // Mateixa col·locació que testMummyStep: corredor exterior // superior, lliure de sarcs, prou marge per fugir cap a la dreta. mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); + exitMummySpawn(mummy); // Direcció preferida: dreta. Mòmia ha d'entrar en HURT i avançar // a la dreta (la velocitat de hurt és més alta que la base). @@ -474,6 +484,7 @@ namespace { // la paret i fer-la sortir de HURT. const float RIGHT_WALL = static_cast(Map::playfield_x + Map::playfield_w); mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); + exitMummySpawn(mummy); // La direcció preferida (RIGHT) està bloquejada al test // d'`is_open`; entrarem amb una perpendicular o quieta. @@ -517,6 +528,7 @@ namespace { // expiri el timer (60 px/s · 0.6 s = 36 px de runway). mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F}); REQUIRE(mummy.getCollisionRect().x == 40.0F); + exitMummySpawn(mummy); mummy.enterHurt(Mummy::Direction::WEST); REQUIRE(mummy.getState() == Mummy::State::HURT); @@ -571,6 +583,7 @@ namespace { mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16}); // Mateix runway que T18. mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F}); + exitMummySpawn(mummy); mummy.enterHurt(Mummy::Direction::WEST); for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) { diff --git a/tests/test_whip_attack.cpp b/tests/test_whip_attack.cpp index 9b25b3d..cf43c07 100644 --- a/tests/test_whip_attack.cpp +++ b/tests/test_whip_attack.cpp @@ -32,6 +32,14 @@ namespace { #define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) + // Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra). Els + // tests del whip necessiten que la mòmia estigui ACTIVE per reaccionar + // al fueteig — sense això, `enterHurt` torna early per `isSpawning()`. + void exitMummySpawn(Mummy& mummy) { + mummy.triggerSpawnExit(); + mummy.update(mummy.getSpawnDurationS() + 0.001F); + } + // Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y). // El damage_rect del whip s'alinearà al feet box (els últims 16 px), // i el draw_rect respectarà els offsets de la taula del disseny. @@ -200,12 +208,15 @@ namespace { // = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap. mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F}); auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); + exitMummySpawn(*mummy_raw); auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); whip->setWorldContext(&manager, nullptr); - // 60 ms → frame 1 (active). active_frames: [1, 2]. - whip->update(0.06F); + // frame_ms=80 al YAML → frame 0 viu [0, 80] ms, frame 1 [80, 160] ms. + // active_frames=[1, 2], així que cal arribar com a mínim a 80 ms + // perquè `isFrameActive()` retorne true i `applyDamage()` corra. + whip->update(0.09F); REQUIRE(mummy_raw->getState() == Mummy::State::HURT); } @@ -258,12 +269,14 @@ namespace { // = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap. mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F}); auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); + exitMummySpawn(*mummy_raw); auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); whip->setWorldContext(&manager, nullptr); - // Primera passada (frame 1, actiu) → mòmia a HURT. - whip->update(0.06F); + // frame_ms=80 → frame 0 [0, 80] ms; cal ≥ 80 ms per entrar al + // frame 1 (primer dels active_frames=[1, 2]). + whip->update(0.09F); REQUIRE(mummy_raw->getState() == Mummy::State::HURT); const float HURT_INITIAL = mummy_raw->getHurtRemainingS(); @@ -275,7 +288,9 @@ namespace { const float HURT_AFTER_MUMMY_TICK = mummy_raw->getHurtRemainingS(); REQUIRE(HURT_AFTER_MUMMY_TICK < HURT_INITIAL); - whip->update(0.05F); // frame 2, encara actiu + // Segon tick del whip: acumulat 0.09 + 0.05 = 0.14 s, encara + // dins del frame 1 ([80, 160] ms). already_hit_ ha de bloquejar. + whip->update(0.05F); REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK); }