Files
aee-arcade/source/game/entities/entity_manager_loadlevel.cpp
T

581 lines
28 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ó 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<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); }
}
};
// 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<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 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<int>(); }
if (l.contains("flicker")) { out.flicker = l["flicker"].get_value<bool>(); }
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<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));
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> 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});
// 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<int>();
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<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 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<int>(BOX.w) / Map::tile_size) : 1;
const int ROWS = (Map::tile_size > 0) ? std::max(1, static_cast<int>(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<DynamicHazard*>(observer);
}