// 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 #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/player.hpp" #include "game/map/map.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() { for (const auto& e : entities_) { renderEntity(*e); } } void EntityManager::checkPlayerCollisions(const std::vector& players) { if (players.empty()) { return; } for (const auto& entity : entities_) { if (!entity->collides()) { continue; } const auto BOX = entity->getCollisionRect(); const int E_HEIGHT = entity->getHeight(); for (auto* p : players) { if (p == nullptr || p->isOutOfPlay()) { continue; } // Filtre per altura: si el jugador està en l'aire i està per // damunt de l'alçada del cos de l'entitat, no hi ha contacte. // És el mecanisme del salt per esquivar momies, roques, etc. // Quan height és 0 (entitat sense altura declarada), el filtre // no es dispara mai i la col·lisió funciona com abans. if (p->isAirborne() && E_HEIGHT > 0 && p->getJumpZ() > static_cast(E_HEIGHT)) { continue; } // Contacte actor-actor amb hit-box petita: la caixa base // 16×16 servia per moure (xoc amb murs), però per a l'event // "toca una entitat" volem precisió — que la momia mate quan // els píxels centrals es solapen, no quan els caps es freguen. const auto PBOX = p->getHitBox(); const bool OVERLAP = PBOX.x + PBOX.w > BOX.x && BOX.x + BOX.w > PBOX.x && PBOX.y + PBOX.h > BOX.y && BOX.y + BOX.h > PBOX.y; if (OVERLAP) { entity->onPlayerContact(p); } } } } 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 = "data/entities/" + 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 `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(); } // 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) { std::cerr << "EntityManager: " << e.what() << '\n'; return nullptr; } auto entity = EntityFactory::instance().create(type, tmpl); if (entity == nullptr) { std::cerr << "EntityManager: tipus desconegut '" << type << "'\n"; 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, const SpriteInfo& sprite, const fkyaml::node& spawn) -> bool { if (!spawn.contains("sarc_position")) { std::cerr << "EntityManager: sarcophagus sense sarc_position\n"; 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) { std::cerr << "EntityManager: la factory no ha creat un Sarcophagus\n"; 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) { std::cerr << "EntityManager: door requereix `geometry.door_col` definit al mapa\n"; 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")) { std::cerr << "EntityManager: " << type << " sense position\n"; 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}); 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(); 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); } 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(); SpriteInfo sprite; std::string anchor; auto entity = buildEntity(TYPE, sprite, anchor); if (entity == nullptr) { continue; } 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); } const bool OK = ok; if (OK) { ++loaded; } } std::cout << "EntityManager: " << loaded << " entitats carregades des de " << level_yaml_path << '\n'; }