Files
aee-arcade/source/game/entities/entity_manager_loadlevel.cpp
T
JailDesigner 28ff425c89 refactor(entities): polimorfisme per substituir dynamic_casts
Quatre virtuals nous a `Entity` capten decisions que abans vivien al
manager com a discriminacions de tipus:

- `onWhipHit(WhipAttack*)` → bool. Default no-op/false. Override a
  `Mummy` per entrar HURT si és whippable. WhipAttack::applyDamage
  passa de `dynamic_cast<Mummy*>` + `isWhippable()` a una crida
  polimòrfica neta. RollingRock no override → immune al látigo per
  disseny, sense flag.

- `onJustSpawned()`. Default no-op. Override a `DynamicHazard` per
  disparar `triggerSpawnExit()`. `spawnPendingActors` i `spawnActor`
  passen de `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()`
  a `observer->onJustSpawned()`.

- `onRemoved()`. Default no-op. Override a `Sarcophagus` per cridar
  `entity_manager_->onSarcophagusRemoved()`. `removeEntity` passa de
  `getName() == "sarcophagus"` a dispatch polimòrfic.

- `shouldReceiveContact()`. Default `collides() || triggersOnOverlap()`.
  Override a `DynamicHazard` per excloure SPAWN. `checkPlayerCollisions`
  passa de tres condicions inline per a sacs distints a una crida
  unificada.

`whipDirToMummyDir` es mou de whip_attack.cpp a mummy.cpp — el mapping
viu on s'usa (dins de `Mummy::onWhipHit`).

`hitMummyIfApplicable` es renomena a `hitEntityIfApplicable`: ja no
discrimina tipus, aplica `entity->onWhipHit` i marca `already_hit_`
segons el retorn.

Comportament idèntic, tests verds. Aquest commit prepara el següent:
amb el polimorfisme madurat, el sac dual ja no aporta valor i podem
tornar a un únic `entities_`.
2026-05-11 13:01:26 +02:00

532 lines
25 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <filesystem>
#include <memory>
#include <string>
#include <utility>
#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<int>(RECT.w);
const int FRAME_H = static_cast<int>(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<float>(COL * FRAME_W),
.y = static_cast<float>(ROW * FRAME_H),
.w = RECT.w,
.h = RECT.h,
};
surface->render(static_cast<int>(RECT.x), static_cast<int>(RECT.y), &SRC, e.getFlip());
}
void EntityManager::renderAll() {
// 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<float>(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<Player*>& 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); }
}
};
// Tots dos sacs apliquen el mateix filtre polimòrfic
// `shouldReceiveContact()`. Per defecte equival a `collides() ||
// triggersOnOverlap()`; `DynamicHazard` ho afina afegint el filtre de
// SPAWN (mentre està emergint torna false). Sense dynamic_cast,
// sense filtres específics al callsite.
for (const auto& entity : map_entities_) {
if (!entity->shouldReceiveContact()) { continue; }
apply_contacts(entity.get());
}
for (const auto& actor : actors_) {
if (!actor->shouldReceiveContact()) { continue; }
apply_contacts(actor.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<std::string>();
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<float>(sz[0].get_value<int>());
info.height = static_cast<float>(sz[1].get_value<int>());
}
}
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<int>();
}
// 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<Entity::CollisionBox> {
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<int>();
const int H = sz[1].get_value<int>();
int off_x = static_cast<int>((sprite_w - static_cast<float>(W)) / 2.0F);
int off_y = static_cast<int>(sprite_h - static_cast<float>(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<int>();
// 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<int>(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<int>();
off_y = o[1].get_value<int>();
}
}
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<std::string>();
}
// 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<std::string>(); }
} 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<float>((col * Map::tile_size) + Map::tilemap_origin_x),
.y = static_cast<float>((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<int>(rect.x) - Map::tilemap_origin_x) / Map::tile_size,
.row = (static_cast<int>(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<Entity> {
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));
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> 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<int>(), sp[1].get_value<int>());
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<int>(LOGICAL.y - RENDER.y),
.width = static_cast<int>(LOGICAL.w),
.height = static_cast<int>(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<Sarcophagus*>(entity.get()) == nullptr) {
Log::error("EntityManager", "la factory no ha creat un Sarcophagus");
return false;
}
std::unique_ptr<Sarcophagus> sarc(static_cast<Sarcophagus*>(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> 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<float>((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast<float>(Map::tile_size) / 2.0F);
const SDL_FRect RENDER{
.x = CENTER_X - (sprite.width / 2.0F),
.y = static_cast<float>(Map::playfield_y) - sprite.height,
.w = sprite.width,
.h = sprite.height,
};
entity->setPixelRect(RENDER);
auto* door_ptr = dynamic_cast<Door*>(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> 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<int>(), p[1].get_value<int>());
// 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<float>(box_opt->offset_x);
rect_y = PX.y - static_cast<float>(box_opt->offset_y);
} else {
rect_x = PX.x + (static_cast<float>(Map::tile_size) - sprite.width) / 2.0F;
rect_y = PX.y + static_cast<float>(Map::tile_size) - sprite.height;
}
}
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
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.
map_entities_.clear();
actors_.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<std::string>();
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<std::string>();
// 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...).
// Imprescindible perquè el jugador no pot trepitjar aquests tiles
// (cauria al forat) i els sarcòfags vecins no podrien completar el
// perímetre — la sala seria incompletable. Usa el canal semàntic
// `tile_painted` (lògica de joc), no `on_tile_entered` (presència
// física del jugador). Ho fem 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;
// 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);
bus_.publish(Event{
.name = "tile_painted",
.payload = TilePos{.col = COL, .row = ROW},
.player_id = -1,
});
++decal_broadcast;
}
if (decal_broadcast > 0) {
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast);
}
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_`. `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();
}
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<DynamicHazard*>(observer);
}