581 lines
28 KiB
C++
581 lines
28 KiB
C++
// 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);
|
||
}
|