From 62f4b857ef13ab9a875e4f1a23940e4a17a78fbd Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Mon, 11 May 2026 10:23:44 +0200 Subject: [PATCH] refactor(em): separa entitats en dos sacs (map_entities_/actors_) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EntityManager guanya dues llistes lògiques: - `map_entities_` — decoració, static_hazards (Hole, FloorTrap, WallTrap) i interactables (Sarcophagus, Door). Persistents tota la sala. - `actors_` — DynamicHazards (Mummy, RollingRock, futurs enemics). Tipats com `unique_ptr`, sense dynamic_cast. `add()` enruta automàticament segons sigui DynamicHazard o no. `update`, `notifyPlayerTilePainted`, `findFloorTriggerAt`, `removeEntity`, `reload` i `renderAll` iteren el(s) sac(s) que toca. `checkPlayerCollisions`: el filtre de SPAWN sobre el sac d'actors ja no necessita dynamic_cast — `actor->isSpawning()` és directe. El filtre per floor decals només toca map_entities_ (els decals viuen exclusivament allà). Filtres per-jugador extrets a helpers per acotar complexitat. API nova: `getMapEntities()` i `getActors()` exposen cada sac per separat. `getEntities()` es manté com a concatenació per retro-compat (editor, z-sort debug). `GameScene::triggerHazardSpawnExit` itera `getActors()` directe — fora dynamic_cast. `GameScene::render` mergeja els tres orígens (map + actors + players) en un sol vector temporal abans del stable_sort per `feet_y` — mateix patró que ja s'usava amb players, ara amb una transformació més. Comportament idèntic: les momies segueixen instanciant-se al loadFromLevel. La preparació per al spawn diferit ve en el commit següent. Co-Authored-By: Claude Opus 4.7 (1M context) --- source/game/entities/entity_manager.cpp | 79 +++++++++++---- source/game/entities/entity_manager.hpp | 21 +++- .../entities/entity_manager_loadlevel.cpp | 99 ++++++++++--------- source/game/scenes/game_scene.cpp | 20 +++- 4 files changed, 149 insertions(+), 70 deletions(-) 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..be5003e 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 @@ -136,7 +143,7 @@ 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 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 +157,17 @@ 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_; 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 be45798..e5db232 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -57,56 +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; } - // DynamicHazard en SPAWN: visible però "no juga" — no dispara - // onPlayerContact, així no fa dany ni triggers durant la fase - // inicial de ROOM_INTRO (o futurs spawns en runtime). - if (const auto* dh = dynamic_cast(entity.get()); dh != nullptr && dh->isSpawning()) { 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()); } } @@ -387,7 +395,8 @@ 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(); door_ = nullptr; total_sarcs_ = 0; opened_sarcs_ = 0; @@ -437,7 +446,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); diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index fa5b6b6..38aad3a 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -688,9 +688,11 @@ void GameScene::respawnPlayersInstant() { void GameScene::triggerHazardSpawnExit() { int count = 0; - for (auto* entity : entity_manager_.getEntities()) { - if (auto* hazard = dynamic_cast(entity); hazard != nullptr && hazard->isSpawning()) { - hazard->triggerSpawnExit(); + // `getActors()` retorna directament `DynamicHazard*` — sense + // dynamic_cast (els actors viuen en el seu sac dedicat). + for (auto* actor : entity_manager_.getActors()) { + if (actor->isSpawning()) { + actor->triggerSpawnExit(); ++count; } } @@ -1788,10 +1790,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