// Implementació d'`EntityManager::loadFromLevel`. Viu en un TU separat // perquè aquesta és l'única via per la qual el manager toca // `Resource::Cache`, `Surface` i les seues dependències (GIF, àudio). Els // consumidors que només volen la base del manager (p.ex. el smoke test // stand-alone) enllacen `entity_manager.cpp` sense arrossegar aquest fitxer. #include #include #include #include #include "core/rendering/surface.hpp" #include "core/resources/resource_cache.hpp" #include "external/fkyaml_node.hpp" #include "game/entities/concrete/door.hpp" #include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/entity.hpp" #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" #include "utils/log.hpp" // Render centralitzat al manager per confinar la dependència cap a // `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::renderEntity(const Entity& e) { const auto& surface = e.getSurface(); if (surface == nullptr) { return; } const auto RECT = e.getPixelRect(); 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, e.getFlip()); } void EntityManager::renderAll() { // Sense ordenació per z: l'ordre és el d'inserció al vector. 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 : entities_) { renderEntity(*e); } } 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; } // 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) { if (p == nullptr || playerSkipsHazard(*p, E_HEIGHT)) { continue; } if (playerOverlapsEntityBox(*p, BOX)) { entity->onPlayerContact(p); } } }; // Filtre polimòrfic unificat: cada entitat decideix si ha de rebre // contacte (`shouldReceiveContact`). Per defecte equival a `collides() // || triggersOnOverlap()`; `DynamicHazard` afegeix el filtre de SPAWN // (mentre està emergint torna false). El callsite no fa dynamic_cast // ni distingeix categories. for (const auto& entity : entities_) { if (!entity->shouldReceiveContact()) { continue; } apply_contacts(entity.get()); } } namespace { // Llegeix `sprite.size` o `sprite.path` d'una plantilla i retorna // (surface_key, pixel_size). Si falta algun camp, torna valors // raonables (empty key → no carrega; size [16,16] per defecte). struct SpriteInfo { std::string key; // clau al Resource::Cache (basename del GIF) std::string full_path; // ruta absoluta per a loadSurface float width{16.0F}; float height{16.0F}; bool valid{false}; }; auto readSpriteInfo(const fkyaml::node& tmpl, const std::string& type) -> SpriteInfo { SpriteInfo info; if (!tmpl.contains("sprite")) { return info; } const auto& s = tmpl["sprite"]; if (s.contains("path")) { const auto REL = s["path"].get_value(); info.key = std::filesystem::path(REL).filename().string(); info.full_path = std::string{Defaults::Paths::ENTITIES_DIR} + type + "/" + REL; info.valid = true; } if (s.contains("size")) { const auto& sz = s["size"]; if (sz.is_sequence() && sz.size() == 2) { info.width = static_cast(sz[0].get_value()); info.height = static_cast(sz[1].get_value()); } } return info; } // Llig `collision.height` (alçada del cos en píxels) de la plantilla. // 0 si no està declarada — ho usen els filtres de col·lisió per altura // del salt: un jugador per damunt d'aquest valor no col·lisiona amb // l'entitat. Per a obstacles "tipus mur" (sarc, porta) el YAML els // dóna un valor molt alt per fer-los infranquejables. auto readHeight(const fkyaml::node& tmpl) -> int { if (!tmpl.contains("collision")) { return 0; } const auto& c = tmpl["collision"]; if (!c.contains("height")) { return 0; } return c["height"].get_value(); } // Bloc opcional `light: { radius: N, flicker: bool }` de la plantilla. // Si manca, el radi és 0 i l'entitat no entra a la pasada d'il·luminació. // `flicker` activa la modulació "tipus foc" del radi (només antorxes // l'haurien d'usar). La forma del gradient (3 anells) i els paràmetres // del flicker viuen a data/config/lighting.yaml — no cal declarar-los // per entitat. struct LightTemplate { int radius{0}; bool flicker{false}; }; auto readLight(const fkyaml::node& tmpl) -> LightTemplate { if (!tmpl.contains("light")) { return {}; } const auto& l = tmpl["light"]; LightTemplate out; if (l.contains("radius")) { out.radius = l["radius"].get_value(); } if (l.contains("flicker")) { out.flicker = l["flicker"].get_value(); } return out; } // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }` // de la plantilla d'entitat. // - Sense `base_size` ni `offset`: la caja queda alineada a la vora // inferior del sprite i centrada horitzontalment (convenció antiga). // - Amb `base_size: [bw, bh]`: la caja queda concèntrica amb el // rectangle inferior bw×bh del sprite (els "peus" visuals), igual // que fa el Player. Pot sobresortir per sobre/sota/costats del // rectangle-base simètricament — així la caja representa el volum // sòlid al voltant dels peus, no la silueta de tot el sprite. // - `offset: [x, y]`: si apareix, sobreescriu el càlcul anterior. // 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("base_size")) { const auto& bs = c["base_size"]; if (bs.is_sequence() && bs.size() == 2) { const int BH = bs[1].get_value(); // Centre vertical del rectangle-base (alineat a la vora // inferior del sprite): sprite_h − bh/2. Caja centrada hi: // off_y = sprite_h − bh/2 − H/2 = sprite_h − (bh + H)/2. off_y = static_cast(sprite_h) - ((BH + H) / 2); } } 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(); } // 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. auto tileToPixel(int col, int row) -> SDL_FPoint { return { .x = static_cast((col * Map::tile_size) + Map::tilemap_origin_x), .y = static_cast((row * Map::tile_size) + Map::tilemap_origin_y), }; } // Converteix un rect píxel (canvas) a la seua cantonada superior-esquerra // en coordenades de tile del tilemap. Només per a sarcòfags, que naixen // ancorats a una cel·la exacta de la graella. struct TileAnchor { int col; int row; }; auto pixelToTileAnchor(const SDL_FRect& rect) -> TileAnchor { return { .col = (static_cast(rect.x) - Map::tilemap_origin_x) / Map::tile_size, .row = (static_cast(rect.y) - Map::tilemap_origin_y) / Map::tile_size, }; } // 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. // 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); } catch (const std::exception& e) { Log::error("EntityManager", "{}", e.what()); return nullptr; } auto entity = EntityFactory::instance().create(type, tmpl); if (entity == nullptr) { Log::error("EntityManager", "tipus desconegut '{}'", type); return nullptr; } sprite_out = readSpriteInfo(tmpl, type); if (sprite_out.valid) { 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); } entity->setHeight(readHeight(tmpl)); const auto LIGHT = readLight(tmpl); entity->setLightRadius(LIGHT.radius); entity->setLightFlicker(LIGHT.flicker); anchor_out = readAnchor(tmpl); return entity; } } // namespace // Col·loca i afegeix un Sarcophagus a partir del spawn. `entity` es consumeix // en èxit (ownership transferit al manager); en error es destrueix aquí. // 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 SpriteInfo& sprite, const fkyaml::node& spawn) -> bool { if (!spawn.contains("sarc_position")) { Log::error("EntityManager", "sarcophagus sense sarc_position"); return false; } const auto& sp = spawn["sarc_position"]; 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); // La caixa de col·lisió del sarc és la lògica (48×16 inferior), no el // rect del sprite sencer: si un jugador passa just per damunt (pels // 16 px superiors del sprite, que són "alçada" sense cos), no s'ha // de disparar cap contacte. Offset relatiu al render rect. entity->setCollisionBox({ .offset_x = 0, .offset_y = static_cast(LOGICAL.y - RENDER.y), .width = static_cast(LOGICAL.w), .height = static_cast(LOGICAL.h), }); // Downcast segur: la factory ens garanteix que "sarcophagus" crea // un `Sarcophagus`. Usant el retorn de `release` al ctor del nou // `unique_ptr` evitem el lint `bugprone-unused-return-value`. if (dynamic_cast(entity.get()) == nullptr) { Log::error("EntityManager", "la factory no ha creat un Sarcophagus"); return false; } std::unique_ptr sarc(static_cast(entity.release())); sarc->place(ANCHOR.col, ANCHOR.row, {}); manager.addSarcophagus(std::move(sarc)); return true; } // Col·loca la porta al mur trasser segons `Map::door_col`. La porta // viu sempre al `wall_north`; només la columna varia per nivell. El // sprite (típicament 48×32) es centra horitzontalment sobre la // columna transitable i s'alinea verticalment de manera que la seua // vora inferior coincideixi amb la base del strip del mur trasser // (= `playfield_y`). Amb sprite 48×32 i `back_wall_h = 24` això vol // dir que els 8 px superiors del sprite queden dins del HUD i hi // queden tapats — comportament intencional. auto placeDoor(EntityManager& manager, std::unique_ptr entity, const SpriteInfo& sprite) -> bool { if (Map::door_col <= 0 || Map::tile_size <= 0) { Log::error("EntityManager", "door requereix `geometry.door_col` definit al mapa"); return false; } const float CENTER_X = static_cast((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast(Map::tile_size) / 2.0F); const SDL_FRect RENDER{ .x = CENTER_X - (sprite.width / 2.0F), .y = static_cast(Map::playfield_y) - sprite.height, .w = sprite.width, .h = sprite.height, }; entity->setPixelRect(RENDER); auto* door_ptr = dynamic_cast(entity.get()); manager.add(std::move(entity)); if (door_ptr != nullptr) { manager.setDoor(door_ptr); } return true; } 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")) { Log::error("EntityManager", "{} sense position", type); return false; } const auto& p = spawn["position"]; const auto PX = tileToPixel(p[0].get_value(), p[1].get_value()); // Anchor "feet": el tile [col,row] és el tile dels peus de l'actor. // Si l'entitat té caixa de col·lisió explícita, la caixa s'alinea // exactament amb el tile (el sprite queda col·locat per als offsets // que la caja defineix — típicament sobresurt cap amunt per falsa // perspectiva). Això és coherent amb el jugador, que també centra // la seua 16×16 sobre el rectangle-base; la caja del tile i la del // mapeig són la mateixa. Sense caixa, fallback històric: sprite // centrat horitzontalment i amb la vora inferior a la vora inferior // del tile. float rect_x = PX.x; float rect_y = PX.y; if (anchor == "feet") { const auto& box_opt = entity->getCollisionBox(); if (box_opt.has_value()) { rect_x = PX.x - static_cast(box_opt->offset_x); rect_y = PX.y - static_cast(box_opt->offset_y); } else { 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}); // Variant del atlas: el spawn pot fixar un sub-sprite estàtic via // `frame: N`. El render del manager ja sap clipar (col, row) a // partir d'un índex lineal i la mida del frame; ací només cal // muntar un `AnimationComponent` d'un sol frame i imposar-lo a la // entitat. Cap entitat existent al joc emet aquest camp; només // els muros decoratius amb variants (stone, column, ...) l'usen. if (spawn.contains("frame")) { const int FRAME = spawn["frame"].get_value(); entity->setAnimation(AnimationComponent::uniform({FRAME}, 1000.0F, false)); } manager.add(std::move(entity)); return true; } } // namespace void EntityManager::reload(const std::string& level_yaml_path) { // Destrucció ordenada: les entitats moren aquí; les seues // 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(); pending_actors_.clear(); door_ = nullptr; total_sarcs_ = 0; opened_sarcs_ = 0; // No reset de `expected_players_`: GameScene el tornarà a fixar // (amb el mateix valor o un de nou) després del reload, com a part // del respawn dels jugadors. Reseteig dels comptadors d'EXITED i // del flag d'emissió perquè el cicle següent pugui reagregar net. exited_players_ = 0; level_complete_emitted_ = false; loadFromLevel(level_yaml_path); } auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* { if (!spawn.contains("type")) { return nullptr; } const auto TYPE = spawn["type"].get_value(); SpriteInfo sprite; std::string anchor; auto entity = buildEntity(TYPE, sprite, anchor); if (entity == nullptr) { return nullptr; } Entity* observer = entity.get(); bool ok = false; if (TYPE == "sarcophagus") { ok = placeSarcophagus(*this, std::move(entity), sprite, spawn); } else if (TYPE == "door") { ok = placeDoor(*this, std::move(entity), sprite); } else { ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn); } return ok ? observer : nullptr; } void EntityManager::loadFromLevel(const std::string& level_yaml_path) { const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path); 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 que el // jugador no pot trepitjar mai, de manera que el perímetre dels // sarcòfags vecins ja els consideri visitats i la sala sigui // completable. Inclou dues famílies: // - Decals de sòl (Hole, i futurs pinchos plans, plaques, charcos): // trigger-on-overlap que mataria/atraparia al jugador en trepitjar. // - Entitats sòlides genèriques (debris, columnes...): obstacles // físics que `Map::collides` bloqueja, així `isPaintable` retorna // false per als seus tiles i ningú els pot pintar. // Usa el canal semàntic `tile_painted` (lògica de joc), no // `on_tile_entered` (presència física). Es fa aquí, després del bucle // de spawns, 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_) { const bool IS_DECAL = entity->isFloorDecal(); const bool IS_SOLID = entity->blocksMovement(); if (!IS_DECAL && !IS_SOLID) { continue; } const auto BOX = entity->getCollisionRect(); // Per a entitats que ocupen més d'un tile (futurs murs 32×16, // 16×32, etc.), iterem tota la zona coberta pel rect. Per a decals // i blocs d'un sol tile cau a un sol broadcast (mateix comportament // que abans). const auto [COL0, ROW0] = Map::pixelToTile(BOX.x, BOX.y); const int COLS = (Map::tile_size > 0) ? std::max(1, static_cast(BOX.w) / Map::tile_size) : 1; const int ROWS = (Map::tile_size > 0) ? std::max(1, static_cast(BOX.h) / Map::tile_size) : 1; for (int dr = 0; dr < ROWS; ++dr) { for (int dc = 0; dc < COLS; ++dc) { bus_.publish(Event{ .name = "tile_painted", .payload = TilePos{.col = COL0 + dc, .row = ROW0 + dr}, .player_id = -1, }); ++decal_broadcast; } } } if (decal_broadcast > 0) { Log::info("EntityManager", "{} tile_painted broadcast inicial (decals + murs)", decal_broadcast); } Log::info("EntityManager", "{} entitats + {} 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_`. `onJustSpawned()` és polimòrfic: la base // no fa res, DynamicHazard l'override per disparar `triggerSpawnExit()` // i entrar a EMERGING. Sense dynamic_cast: la decisió viu dins de // cada entitat. for (const auto& pa : pending_actors_) { Entity* observer = addSpawn(pa.spawn_node); if (observer != nullptr) { observer->onJustSpawned(); } } pending_actors_.clear(); } void EntityManager::materializePendingActorsStatic() { // Igual que `spawnPendingActors` però sense `onJustSpawned`: l'actor // queda en el seu estat inicial (mòmia: ROAMING, walk_east) en lloc // d'EMERGING. Vegeu el comentari del header per la motivació. for (const auto& pa : pending_actors_) { addSpawn(pa.spawn_node); } 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; } // `onJustSpawned` polimòrfic: per a DynamicHazards arma EMERGING; per // a un type erroni (no dynamic_hazard) queda no-op i retornem nullptr // mitjançant el cast final, deixant la signatura `DynamicHazard*` neta. observer->onJustSpawned(); return dynamic_cast(observer); }