#include "game/scenes/game_scene.hpp" #include #include #include #include #include #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/input/global_inputs.hpp" #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" #include "core/rendering/notifier.hpp" #include "core/rendering/render_info.hpp" #include "core/rendering/screen.hpp" #include "core/rendering/text.hpp" #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" #include "core/system/event_buffer.hpp" #include "core/ui/ui_config.hpp" #include "external/fkyaml_node.hpp" #include "game/entities/concrete/dust.hpp" #include "game/entities/concrete/whip_attack.hpp" #include "game/entities/entity.hpp" #include "game/entities/entity_factory.hpp" #include "game/entities/entity_loader.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" #include "game/levels/level_manager.hpp" #include "game/map/map.hpp" #include "game/options.hpp" #include "game/ui/hud.hpp" #include "game/ui/service_menu.hpp" #include "utils/defaults.hpp" #include "utils/defines.hpp" #include "utils/easing.hpp" #include "utils/log.hpp" namespace { // La geometria ha de quedar inicialitzada abans que Tilemap parsejar el // mateix YAML i abans que el ctor de Player consulte Map::playfield_*. // Construïm el Tilemap via un helper que primer carrega la geometria // i després retorna el Tilemap per valor (move-construït). auto makeTilemap(const std::string& yaml_path) -> Tilemap { Map::loadFrom(yaml_path); return Tilemap(yaml_path); } // Llegeix un camp sencer opcional del YAML del mapa. Si no hi és, // retorna `fallback` (per defecte -1, que al Player indica "no pinta"). // S'usa per `player1_paint_tile` i `player2_paint_tile`: cada jugador // pinta amb l'índex declarat al mapa, i un mapa sense aquests camps // simplement no deixa rastre visual. auto readOptionalInt(const std::string& yaml_path, const std::string& key, int fallback = -1) -> int { auto bytes = Resource::Helper::loadFile(yaml_path); if (bytes.empty()) { return fallback; } const auto* begin = reinterpret_cast(bytes.data()); const auto* end = begin + bytes.size(); auto yaml = fkyaml::node::deserialize(begin, end); if (!yaml.contains(key)) { return fallback; } return yaml[key].get_value(); } auto readOptionalFloat(const std::string& yaml_path, const std::string& key, float fallback) -> float { auto bytes = Resource::Helper::loadFile(yaml_path); if (bytes.empty()) { return fallback; } const auto* begin = reinterpret_cast(bytes.data()); const auto* end = begin + bytes.size(); auto yaml = fkyaml::node::deserialize(begin, end); if (!yaml.contains(key)) { return fallback; } return yaml[key].get_value(); } // Comprova si `a` i `b` se solapen amb una tolerància positiva al // contacte perfecte (cantos que es toquen = no-solapament). SDL3 ja // ofereix `SDL_HasRectIntersectionFloat`, però aquí volem la mateixa // semàntica que al Player::collidesWithWorld. auto rectsOverlap(const SDL_FRect& a, const SDL_FRect& b) -> bool { return SDL_HasRectIntersectionFloat(&a, &b); } // Resolver jugador↔jugador. Opera sobre les velocitats intencionades // (escrites per `Player::tick`, abans d'`applyMovement`) per a // implementar les regles: // - Sense overlap previst → no toca res. // - Ambdós amb input que els faria col·lidir → head-on: velocitats // anul·lades per als dos. // - Un quiet, l'altre empentant-lo → push: ambdues velocitats // escalades a `push_factor` i el pushed hereta la direcció del // pusher. Si el pushed no pot avançar (mur/sarc), `tryMove` ja ho // gestionarà a `applyMovement` mogent la distància parcial possible. // Amb dos jugadors només és un parell; si en un futur n'hi ha més, // la mateixa lògica es pot aplicar pairwise. // Important: el criteri "mou-se vs parat" consulta INPUT cru (no la // velocitat física), així un jugador que coasta per inèrcia sense // input compta com a parat — ni dispara head-on ni empeny l'altre. // L'overlap predictiu sí que usa velocitat física (intended), perquè // és el desplaçament real d'aquest frame. void resolvePlayerVsPlayer(const std::vector& ps, float dt, float push_factor) { if (ps.size() < 2) { return; } auto* a = ps[0]; auto* b = ps[1]; if (a == nullptr || b == nullptr) { return; } const bool A_AIR = a->isAirborne(); const bool B_AIR = b->isAirborne(); // HURT a un costat o l'altre: el resolver no s'activa — el HURT // surt projectat sense control i els dos es travessen. Coincideix // amb la regla d'`Player::collidesWithWorld`. if (a->isHurt() || b->isHurt()) { return; } // EXITED/DISABLED: jugadors fora del joc, no participen al resolver. // Si hi entrés un, la seua presència física frenaria l'altre (per // overlap predictiu) tot i que `applyMovement` és no-op. Sortir // d'aquí garanteix que el jugador actiu pot creuar la porta o // moure's lliurement sense interferència d'un slot inactiu. if (a->isOutOfPlay() || b->isOutOfPlay()) { return; } // Aire ↔ tierra: el saltador es travessa net del caminant. Sense // resolver, sense head-on, sense push. Els dos continuen amb la // velocitat que portaven. Important: això NO és el filtre per // altura del cos — és binari. Coincideix amb la regla d'`Player:: // collidesWithWorld`: durant un salt no hi ha mai col·lisió amb // un caminant, ni baix ni alt. if (A_AIR != B_AIR) { return; } // Calculem l'overlap previst en l'espai de la hit-box petita: // els jugadors han de poder acostar-se molt (caixes de moviment // 16×16 quasi tocant) sense disparar el resolver; només quan el // contacte real (hit-box 8×8) s'activaria, neutralitzem o apliquem // push (a terra) o invertim velocitats (en l'aire). const SDL_FRect BOX_A = a->getHitBox(); const SDL_FRect BOX_B = b->getHitBox(); const SDL_FRect NEXT_A{ .x = BOX_A.x + (a->getIntendedVX() * dt), .y = BOX_A.y + (a->getIntendedVY() * dt), .w = BOX_A.w, .h = BOX_A.h, }; const SDL_FRect NEXT_B{ .x = BOX_B.x + (b->getIntendedVX() * dt), .y = BOX_B.y + (b->getIntendedVY() * dt), .w = BOX_B.w, .h = BOX_B.h, }; if (!rectsOverlap(NEXT_A, NEXT_B)) { return; } // Tots dos en l'aire (A_AIR == B_AIR == true aquí, ja descartat // l'altre cas a dalt): rebot mid-aire si els rangs verticals // [z, z+body_height] solapen. Si la diferència d'alçades és // massa gran, ja no hi ha contacte real (un està molt més amunt // que l'altre) i no fem res. Si solapen, invertim ambdues // velocitats horitzontals i els deixem rebotar fins a aterrar // — els dos cauen en stun, com a càstig per abusar del salt. if (A_AIR) { // cppcheck-suppress knownConditionTrueFalse const auto BODY_H = static_cast(Player::body_height); // cppcheck-suppress knownConditionTrueFalse if (BODY_H <= 0.0F) { return; } if (std::fabs(a->getJumpZ() - b->getJumpZ()) >= BODY_H) { return; } // Saltadors estàtics horitzontalment (cas degenerat: tots dos // jumpejant verticals al mateix x): no hi ha velocitat a // invertir, deixem-los — el predictiu no tornarà a disparar // perquè en el següent frame ja no hi haurà velocitat // d'acostament. const float REL_VX = a->getIntendedVX() - b->getIntendedVX(); const float REL_VY = a->getIntendedVY() - b->getIntendedVY(); if (REL_VX == 0.0F && REL_VY == 0.0F) { return; } a->setIntendedVelocity(-a->getIntendedVX(), -a->getIntendedVY()); b->setIntendedVelocity(-b->getIntendedVX(), -b->getIntendedVY()); return; } const bool MOVING_A = (a->getInputVX() != 0.0F) || (a->getInputVY() != 0.0F); const bool MOVING_B = (b->getInputVX() != 0.0F) || (b->getInputVY() != 0.0F); if (MOVING_A && MOVING_B) { // Head-on o encreuament amb col·lisió: cap dels dos es mou. a->setIntendedVelocity(0.0F, 0.0F); b->setIntendedVelocity(0.0F, 0.0F); return; } auto* pusher = MOVING_A ? a : b; auto* pushed = MOVING_A ? b : a; const float VX = pusher->getIntendedVX() * push_factor; const float VY = pusher->getIntendedVY() * push_factor; pusher->setIntendedVelocity(VX, VY); pushed->setIntendedVelocity(VX, VY); // El pushed es mourà sense input propi: marquem-ho perquè // `applyMovement` no active la variant `walk` de l'animació. pushed->setBeingPushed(true); } // Ordre d'`applyMovement`: en push, el pushed ha de moure's abans // perquè quan el pusher faça `tryMove` veja el pushed a la seua // posició nova i no li bloqueje. Heurística barata: el jugador amb // `intended*dt` que entra a l'altre és el pusher; però com a totes // les configuracions push els dos tenen la mateixa velocitat resultant // i comparteixen direcció, usem el producte (dot) del vector d'acció // i el vector cap a l'altre jugador: positiu = pusher. auto orderedForMovement(const std::vector& ps) -> std::vector { std::vector out = ps; if (out.size() < 2 || out[0] == nullptr || out[1] == nullptr) { return out; } const auto* a = out[0]; const auto* b = out[1]; const SDL_FRect BOX_A = a->getBaseBox(); const SDL_FRect BOX_B = b->getBaseBox(); const float DX = (BOX_B.x + (BOX_B.w / 2.0F)) - (BOX_A.x + (BOX_A.w / 2.0F)); const float DY = (BOX_B.y + (BOX_B.h / 2.0F)) - (BOX_A.y + (BOX_A.h / 2.0F)); // dot(v_a, dir_a→b): positiu = a empenta a b → b es mou primer. const float DOT_A = (a->getIntendedVX() * DX) + (a->getIntendedVY() * DY); if (DOT_A > 0.0F) { std::swap(out[0], out[1]); } return out; } } // namespace auto GameScene::defaultConfig() -> Config { // El path del mapa el dicta el LevelManager (que ja ha llegit `levels.yaml` // a la primera invocació). Així `defaultConfig()` no coneix cap fitxer // concret de sala — només cap a on apunta la progressió ara mateix. return { .map_yaml = Levels::LevelManager::get().currentPath(), .player_sprite = Defaults::Pyramid::PLAYER_SAM_GIF, .player_yaml = Defaults::Pyramid::PLAYER_SAM_YAML, .player2_sprite = Defaults::Pyramid::PLAYER_MORCUS_GIF, .player2_yaml = Defaults::Pyramid::PLAYER_MORCUS_YAML, .music_track = Defaults::Pyramid::MUSIC_TRACK, .starting_players = DebugConfig::StartingPlayers::BOTH, }; } GameScene::GameScene(Config config) : config_(std::move(config)), tilemap_(makeTilemap(config_.map_yaml)) { // El text d'INTRO i el HUD demanaran piràmide/sala directament al // LevelManager — `GameScene` ja no manté un mirror local. La // sincronització "una vegada per càrrega de sala" la fa `loadRoom` // (per al HUD) i `renderRoomText` (per al text d'INTRO, llegint el // valor a cada frame de la transició). // Convenció: el Resource::Cache indexa per basename del fitxer, perquè // la mateixa clau l'usa el parseador d'animació (YAML > tileSetFile). // Lambda perquè tenim dos conjunts d'assets (sam/morcus) i l'alta al // cache és idèntica per als dos; la clau d'animació ens la quedem per // passar-la al ctor del Player corresponent. auto load_player_assets = [](const std::string& sprite, const std::string& yaml) -> std::string { const auto SPRITE_KEY = std::filesystem::path(sprite).filename().string(); const auto ANIM_KEY = std::filesystem::path(yaml).filename().string(); Resource::Cache::get().loadSurface(SPRITE_KEY, sprite); Resource::Cache::get().loadAnimation(ANIM_KEY, yaml); return ANIM_KEY; }; const bool USE_P1 = (config_.starting_players == DebugConfig::StartingPlayers::P1 || config_.starting_players == DebugConfig::StartingPlayers::BOTH); const bool USE_P2 = (config_.starting_players == DebugConfig::StartingPlayers::P2 || config_.starting_players == DebugConfig::StartingPlayers::BOTH); players_starts_active_ = {USE_P1, USE_P2}; // Carreguem assets per als DOS jugadors sempre, encara que algun no // arranque actiu: l'slot existeix igualment al vector com a DISABLED // i podria reactivar-se amb un futur "join in progress". El cost de // tenir el sprite cachejat sense usar-se és negligible (cap render). const std::string ANIM_P1 = load_player_assets(config_.player_sprite, config_.player_yaml); const std::string ANIM_P2 = load_player_assets(config_.player2_sprite, config_.player2_yaml); // Geometria de col·lisió del jugador (caixa 16×16, base 12): carregada // del mateix YAML que l'animació. Ha d'estar feta abans d'instanciar // Player perquè el spawn consulta `collision_*`. Els membres de Player // són `static inline`, compartits per tots els jugadors; carregar des // del YAML de P1 és suficient (els dos personatges comparteixen // geometria — morcus és un repaletat de sam). Player::loadFrom(config_.player_yaml); // Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia, // etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa // `makeTilemap`), perquè consulta `Map::tile_size` i `getSarcRect`. // Les surfaces de les entitats es carreguen al Resource::Cache aquí. entity_manager_.loadFromLevel(config_.map_yaml); // Subscripcions bàsiques al bus del nivell. `level_complete` també // dispara la transició a ROOM_OUTRO si encara estem a PLAYING; les // re-emissions es filtren per estat per no re-entrar mai des d'una // transició ja en marxa. entity_manager_.getBus().subscribe("level_complete", [this](const Event& ev) { Log::info("LEVEL COMPLETE", "player_id={}", ev.player_id); if (state_ == RoomFsm::State::PLAYING) { setState(RoomFsm::State::ROOM_OUTRO); } }); entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) { const auto* dmg = std::get_if(&ev.payload); Log::info("PLAYER HIT", "player_id={} dmg={}", ev.player_id, dmg != nullptr ? *dmg : 0); }); // `player_killed` el publica un hazard letal (mòmia amb // `kills_outright`) després del `player_hit`. Aquí resolem el // player_id a la instància concreta i disparem el cicle DYING. El // hazard no toca cap símbol de Player directament, així els binaris // de tests d'entitats no han d'arrossegar tot l'stack del jugador. entity_manager_.getBus().subscribe("player_killed", [this](const Event& ev) { dispatchPlayerKilled(ev.player_id); }); // Polvet: precarreguem el surface del Dust una sola vegada perquè // els spawns runtime de l'entitat no facin disc al primer salt. // També ens subscrivim a `player_jumped` i `player_landed` per // spawnejar el polvet de despegue / aterratge a la posició dels // peus que el Player ens dóna al payload. Resource::Cache::get().loadSurface("dust.gif", Defaults::Paths::DUST_GIF); entity_manager_.getBus().subscribe("player_jumped", [this](const Event& ev) { const auto* pos = std::get_if(&ev.payload); if (pos != nullptr) { spawnDust(pos->x, pos->y, false); } }); entity_manager_.getBus().subscribe("player_landed", [this](const Event& ev) { const auto* pos = std::get_if(&ev.payload); if (pos != nullptr) { spawnDust(pos->x, pos->y, true); } }); // Whip: precarreguem les 4 surfaces (una per cardinal) abans del // primer spawn perquè el Player no facte disc al primer atac. // Subscriu `whip_spawn` per crear el `WhipAttack` corresponent. Resource::Cache::get().loadSurface("whip_east.gif", Defaults::Paths::WHIP_EAST_GIF); Resource::Cache::get().loadSurface("whip_west.gif", Defaults::Paths::WHIP_WEST_GIF); Resource::Cache::get().loadSurface("whip_north.gif", Defaults::Paths::WHIP_NORTH_GIF); Resource::Cache::get().loadSurface("whip_south.gif", Defaults::Paths::WHIP_SOUTH_GIF); entity_manager_.getBus().subscribe("whip_spawn", [this](const Event& ev) { const auto* p = std::get_if(&ev.payload); if (p != nullptr) { spawnWhip(*p); } }); // Controls per jugador: P1 amb fletxes, P2 amb WASD per defecte. Les // vinculacions actives venen d'`Options::controls()`, que s'inicialitza // amb els defaults i pot ser sobreescrita pel menú de servei (i // persistida a `config.yaml`). const auto& kb_p1 = Options::controls().keyboard_p1; const auto& kb_p2 = Options::controls().keyboard_p2; const Player::Controls CONTROLS_P1{ .up = kb_p1.up, .down = kb_p1.down, .left = kb_p1.left, .right = kb_p1.right, .jump = kb_p1.jump, .attack = kb_p1.attack, .start = Defaults::KeysGame::START, }; const Player::Controls CONTROLS_P2{ .up = kb_p2.up, .down = kb_p2.down, .left = kb_p2.left, .right = kb_p2.right, .jump = kb_p2.jump, .attack = kb_p2.attack, .start = Defaults::KeysGameP2::START, }; // Spawn lligat a la porta: tots els jugadors apareixen davant del // passadís de sortida. `computeSpawn` deriva les coordenades de // `Map::door_col` perquè canviar la columna de la porta al YAML // moga automàticament l'spawn — no hi ha cap valor hardcoded. const auto SPAWN = computeSpawn(); const bool BOTH_ACTIVE = (USE_P1 && USE_P2); // Índex de tile amb què pinta cada jugador — llegit del YAML del mapa // (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no // hi són, `paint_tile_index_ = -1` i el Player no deixa rastre. // Ids canònics dels jugadors: 0 = P1, 1 = P2. Els events del bus els // porten per permetre dispatch correcte al HUD i a futures regles de // puntuació (veure `Event::player_id`). constexpr int ID_P1 = 0; constexpr int ID_P2 = 1; const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1); const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1); // Sempre instanciem els dos slots. Mode single-player: l'únic actiu // queda centrat sota la porta (= SPAWN.p1_x + tile_size); l'inactiu // ocupa la posició BOTH per simetria, però com es queda DISABLED tot // el cicle no es nota. Així `players_[0]` sempre és P1 i `players_[1]` // sempre és P2, independentment de quins juguen. const auto TILE_F = static_cast(Map::tile_size); float p1_x = SPAWN.p1_x; float p2_x = SPAWN.p2_x; if (!BOTH_ACTIVE) { if (USE_P1) { p1_x = SPAWN.p1_x + TILE_F; } if (USE_P2) { p2_x = SPAWN.p2_x - TILE_F; } } players_.emplace_back(std::make_unique(ANIM_P1, ID_P1, p1_x, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT)); players_.emplace_back(std::make_unique(ANIM_P2, ID_P2, p2_x, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT)); // Tots els slots arrenquen DISABLED. Els actius els reactivarà el // callback de fi de música d'INTRO via `completeIntro()` (spawn aeri // simultani) en arribar a la fi de la pista `room_intro.ogg`. Els // inactius es queden DISABLED tot el cicle de sala. for (auto& p : players_) { p->disable(); } // PaintLayer: font única del pintat del nivell. Es crea després de // `loadFromLevel` perquè l'EntityManager ja ha de tenir els sarcòfags // registrats (tot i que el sarc escolta via fan-out, no directament // al bus; l'ordre de subscripcions aquí és irrellevant per la // correcció, però sí pel log d'arrencada). Connectat al bus del // manager per subscriure's a `on_tile_entered` i publicar `tile_painted`. paint_layer_ = std::make_unique(tilemap_, &entity_manager_.getBus()); // Connectem tots els jugadors al bus perquè emeten `on_tile_entered`; // el PaintLayer (subscrit ja) processa el pintat i el fan-out cap als // sarcòfags passa per `tile_painted`. for (auto& p : players_) { p->setEventBus(&entity_manager_.getBus()); } // També injectem el manager perquè `integrateJump` pugui consultar // `findFloorTriggerAt` a l'aterratge (caure dins d'un Hole en lloc // del LANDING_STUN normal). for (auto& p : players_) { p->setEntityManager(&entity_manager_); } // Física del contacte entre jugadors. El factor viu al YAML del mapa // com a propietat de la piràmide (0.30 per defecte). La vista plana // `players_view_` i el `setOtherPlayers` s'han de fer ara perquè a // cada `applyMovement` la callback de col·lisió consulti els altres // jugadors com a sòlids. push_factor_ = readOptionalFloat(config_.map_yaml, "push_factor", Defaults::Player::PUSH_FACTOR); players_view_.reserve(players_.size()); std::ranges::transform(players_, std::back_inserter(players_view_), [](const auto& p) { return p.get(); }); for (auto& p : players_) { p->setOtherPlayers(&players_view_); } // Comptador d'EXITED a EntityManager: publica `level_complete` quan // tots els jugadors *actius* han travessat la porta. Els slots DISABLED // permanents (single-player) NO compten — d'altra manera la sala mai // es completaria, perquè un slot DISABLED no pot eixir per la porta. const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true); entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT); // El snap també empeny lateralment si l'altre jugador ocupa el carril: // passem el mateix factor perquè vaja al mateix ritme que el resolver. for (auto& p : players_) { p->setPushFactor(push_factor_); } // Marcador (HUD) de la franja superior. Reutilitza la font 8bithud i // la paleta "ui" ja precarregades pel Director — no toca disc des // d'aquí. Subscrit al bus per reaccionar a `player_hit`, // `level_complete`, etc. amb shake/flash i futurs canvis de mode. const std::string HUD_FONT_GIF_KEY = UiConfig::get().hudFont().name + ".gif"; auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY); auto hud_font = std::make_shared(hud_font_surface, UiConfig::get().hudFont().fnt); hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning); hud_ = std::make_unique(entity_manager_.getBus(), hud_font); // Si hi ha P2 actiu, activem el seu bloc (per defecte el HUD naix amb // només P1). Mentre els events del bus no porten `player_id`, els hits // segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit). if (USE_P2) { hud_->activatePlayer(2); } // Sincronització inicial dels comptadors de piràmide/sala del HUD amb // el LevelManager (després de cada `loadRoom` també es refresca; ací // és el primer ajust per a la sala d'arrencada). { const auto& mgr = Levels::LevelManager::get(); hud_->setPyramidRoom(mgr.currentPyramid(), mgr.currentRoom()); } // La música de fons no s'arrenca ací: la sala comença a ROOM_INTRO, // que és silenciós per disseny. La música la posa en marxa // `setState(PLAYING)` quan la cinemàtica acaba (i la torna a aturar // `enterRoomIntro` quan entrem a una nova sala). El track ja està // al Resource::Cache (preload al Director), per tant la primera // crida a playMusic és immediata. // Font de l'overlay de pausa: reutilitzem la Surface ja cachejada al // Director (PLACEHOLDER_NAME + ".gif"). Text només parseja el .fnt a // l'arrencada de l'escena, un sol cop. const std::string PAUSE_FONT_KEY = UiConfig::get().placeholderFont().name + ".gif"; auto pause_surface = Resource::Cache::get().getSurface(PAUSE_FONT_KEY); pause_font_ = std::make_shared(pause_surface, UiConfig::get().placeholderFont().fnt); pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning); // Font del rètol de transició de sala. Mateixa convenció que la pausa // (Surface preloaded pel Director, només construïm el `Text` ací). const std::string ROOM_TEXT_FONT_KEY = UiConfig::get().roomTextFont().name + ".gif"; auto room_text_surface = Resource::Cache::get().getSurface(ROOM_TEXT_FONT_KEY); room_text_font_ = std::make_shared(room_text_surface, UiConfig::get().roomTextFont().fnt); room_text_font_->setDefaultKerning(UiConfig::get().roomTextFont().kerning); // Timings i corbes de la FSM de sala. Llegits del YAML una sola // vegada; canvis requereixen reiniciar el joc. transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::GAME_YAML); // Cinemàtica d'entrada de la primera sala: arrenca la pista // `room_intro.ogg` i arma el callback de fi que farà spawn + salt // a PLAYING. Cal cridar `enterRoomIntro` ací (i no només des // d'`advanceToNextRoom`) perquè la primera entrada a la GameScene // no passa pel flux d'avanç de sala. enterRoomIntro(); } GameScene::~GameScene() = default; auto GameScene::computeSpawn() -> PlayerSpawn { // Centre de la columna del passadís en píxels canvas. La caixa de // col·lisió del jugador (12 px) cap dins del passadís (16 px) amb 2 // px de marge a banda i banda; alinear el sprite al centre de la // columna garanteix que la base del jugador i la del passadís // coincideixin verticalment. const float DOOR_CENTER_X = static_cast((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast(Map::tile_size) / 2.0F); const float CENTER_TL_X = DOOR_CENTER_X - (static_cast(Player::sprite_w) / 2.0F); // Y: centre del corredor exterior superior (mateix valor que abans; // el corredor és simètric respecte a `playfield_y`). const float CORRIDOR_CENTER_Y = static_cast(Map::playfield_y) + (static_cast(Map::outer_corridor) / 2.0F); const float SPAWN_Y = CORRIDOR_CENTER_Y - static_cast(Player::collision_y_offset) - (static_cast(Player::collision_size) / 2.0F); // Mode BOTH: P1 a -tile_size, P2 a +tile_size respecte al centre. // Els callsites individuals decideixen si usar aquests valors o si // estem en mode single-player (un sol jugador → centrat exactament). return { .p1_x = CENTER_TL_X - static_cast(Map::tile_size), .p2_x = CENTER_TL_X + static_cast(Map::tile_size), .y = SPAWN_Y, }; } void GameScene::loadRoom(const std::string& yaml_path) { // Recarregar les entitats des del YAML donat: la porta torna a // CLOSED (amb l'open=false a Map al loadFrom), els sarcs tornen a // CLOSED amb visited buit, momies i altres actors a la posició inicial. config_.map_yaml = yaml_path; Map::loadFrom(yaml_path); // Tilemap pre-blita els tiles a un buffer ARGB intern al ctor — sense // reconstruir-lo, la imatge de la sala anterior es queda fixada per a // sempre. PaintLayer captura `tileset_path_`/`tileset_cols_` per valor // del Tilemap a la seua construcció, així que també cal recrear-lo // perquè el rastre del jugador faça servir el tileset nou. tilemap_ = Tilemap(yaml_path); paint_layer_ = std::make_unique(tilemap_, &entity_manager_.getBus()); entity_manager_.reload(yaml_path); // Refresquem la còpia local de Map::Info als players: la geometria // pot haver canviat (p.ex. door_col del editor), i sense això // `clampToPlayfield` continuaria usant el corridor antic i el // jugador no podria sortir per la nova posició de la porta. const auto INFO = Map::makeInfo(); for (auto& p : players_) { p->setMapInfo(INFO); } // Tornem a fixar el comptador de "esperats" amb només els slots // actius — un slot DISABLED permanent (single-player) no compta. const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true); entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT); // Tornem tots els slots a DISABLED. Els actius els reactivarà el // trigger d'spawn aeri dins de `updateRoomTransition` quan torni a // disparar-se el delay del nou cicle d'INTRO. Així cada cicle de // sala repeteix la cinemàtica de caiguda de la mateixa manera que // el primer. for (auto& p : players_) { p->disable(); } // Refresca el marcador (piràmide / sala) amb el LevelManager. El // GameScene no manté els valors a part: la lectura sempre és a // través del singleton, així no es desincronitzen mai entre // hotkeys de salt, debug.yaml i creuament natural de porta. if (hud_) { const auto& mgr = Levels::LevelManager::get(); hud_->setPyramidRoom(mgr.currentPyramid(), mgr.currentRoom()); } Log::info("ROOM", "load {}: entities reloaded, paint cleared, {} active player(s) disabled (awaiting spawn cinematic)", yaml_path, ACTIVE_COUNT); } void GameScene::advanceToNextRoom() { auto& mgr = Levels::LevelManager::get(); if (!mgr.advance()) { // Política temporal mentre no hi haja pantalla de "Fi de joc": // arribar al final del levels.yaml fa loop a la primera sala. Log::info("ROOM", "final del levels.yaml — bucle a la primera sala"); mgr.restart(); } loadRoom(mgr.currentPath()); enterRoomIntro(); } void GameScene::enterRoomIntro() { // Anotem la transició al log si veníem d'un altre estat; si ja // estàvem a INTRO (cas típic d'un salt debug) també queda ben clar. if (state_ != RoomFsm::State::ROOM_INTRO) { Log::info("ROOM", "{} -> ROOM_INTRO", RoomFsm::stateName(state_)); } else { Log::info("ROOM", "ROOM_INTRO restart (cinemàtica reiniciada)"); } state_ = RoomFsm::State::ROOM_INTRO; transition_ = {}; intro_completed_ = false; intro_spawn_elapsed_ms_ = 0; players_spawned_in_intro_.fill(false); // Reset visual: arrenquem el FADE_IN d'INTRO i deixem el d'OUTRO // inactiu (es re-armarà a `setState(ROOM_OUTRO)` i, dins d'aquell // estat, a l'entrada de la fase de fade_out via `outro_fade_started_`). intro_fade_.begin(transition_config_.intro_fade_in_ms, Transitions::FadeToBlack::Direction::FADE_IN); outro_fade_.begin(0); outro_fade_started_ = false; // Per defecte, sense música el `music_tail` és 0: la fase es consumeix // instantàniament i l'INTRO acaba al final del text. Si la pista // arrenca, ajustem el tail perquè la fi de phase 5 coincidesca amb // el final del .ogg precarregat. const int TEXT_TOTAL_MS = transition_config_.intro_fade_in_ms + transition_config_.intro_text_delay_ms + transition_config_.intro_text_enter_ms + transition_config_.intro_text_hold_ms + transition_config_.intro_text_exit_ms; transition_config_.intro_music_tail_ms = 0; auto* audio = Audio::get(); if (audio == nullptr) { Log::warn("ROOM", "INTRO: Audio absent — sense música, timeline només contra text"); return; } audio->stopMusic(); audio->setOnMusicEnded({}); // defensiu: en el nou model no usem callbacks de fi audio->playMusic("room_intro.ogg", 0); if (Audio::getMusicState() == Audio::MusicState::PLAYING) { const int DUR_MS = Audio::getMusicDurationMs("room_intro.ogg"); transition_config_.intro_music_tail_ms = std::max(0, DUR_MS - TEXT_TOTAL_MS); Log::info("ROOM", "INTRO: música {} ms, music_tail {} ms", DUR_MS, transition_config_.intro_music_tail_ms); } else { Log::warn("ROOM", "INTRO: música no arrencada — timeline només contra text"); } } #ifdef _DEBUG void GameScene::respawnPlayersInstant() { // Variant sense cinemàtica de l'spawn de l'INTRO: per a l'editor // volem que la sala torni a un estat jugable d'immediat (sense // música de sala, sense caiguda aèria). Reusem `computeSpawn` i el // mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)` // (teleport instantani) en lloc de `spawnFromAir(x, y)`. const auto SPAWN = computeSpawn(); const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]); const float SINGLE_X = SPAWN.p1_x + static_cast(Map::tile_size); for (std::size_t i = 0; i < players_.size(); ++i) { if (!players_starts_active_[i]) { continue; } float x = SINGLE_X; if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; } players_[i]->setSpawnPoint(x, SPAWN.y); players_[i]->respawn(x, SPAWN.y); } state_ = RoomFsm::State::PLAYING; intro_completed_ = true; transition_ = {}; Log::info("ROOM", "post-editor: spawn instantani, estat -> PLAYING"); } #endif void GameScene::triggerPlayerSpawn(std::size_t slot_index) { if (slot_index >= players_.size()) { return; } if (!players_starts_active_[slot_index]) { return; } if (players_spawned_in_intro_[slot_index]) { return; } players_spawned_in_intro_[slot_index] = true; // Recalcula spawn per si door_col canvia entre sales (avui no, però // la lògica ja queda preparada per a l'itinerari). const auto SPAWN = computeSpawn(); const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]); // Mode single-player: l'únic actiu queda centrat sota la porta. Els // valors de SPAWN.p1_x/p2_x estan fets per al cas BOTH (offset ±tile); // amb un sol actiu, el reposicionem al centre exacte. const float SINGLE_X = SPAWN.p1_x + static_cast(Map::tile_size); float x = SINGLE_X; if (BOTH_ACTIVE) { x = (slot_index == 0) ? SPAWN.p1_x : SPAWN.p2_x; } // Memoritzem aquestes coordenades com a punt de respawn post-mort. // El Player les usa al final del cicle DYING per teleportar-se. Les // refresquem cada vegada que disparem un spawn aeri perquè la sala // pot canviar de geometria de porta entre cicles. players_[slot_index]->setSpawnPoint(x, SPAWN.y); players_[slot_index]->spawnFromAir(x, SPAWN.y); // Arma el reverb del primer LAND com a part de la cinemàtica // d'INTRO. Una sola vegada per spawn aeri: el respawn post-mort // (que no passa per ací) cau al camí sec. Si el preset està buit // al YAML, l'arma queda buida i el LAND és sec igualment. players_[slot_index]->armNextLandReverb(transition_config_.intro_land_reverb_preset); Log::info("ROOM", "spawn cinematic P{}: airborne entry", slot_index + 1); } void GameScene::dispatchPlayerKilled(int player_id) { Log::info("PLAYER KILLED", "player_id={}", player_id); const auto IT = std::ranges::find_if(players_, [player_id](const auto& p) { return p && p->getId() == player_id; }); if (IT != players_.end()) { (*IT)->enterDying(); } } void GameScene::completeIntro() { if (intro_completed_) { return; } intro_completed_ = true; intro_spawn_elapsed_ms_ = 0; // Spawn dels slots amb delay <= 0 (el cas habitual de P1). Els // altres cauen frame a frame dins `updateRoomTransition` a mesura // que `intro_spawn_elapsed_ms_` supera el seu delay. Així P1 cau // alhora que la música acaba i la transició a PLAYING; P2 (en // mode BOTH) entra uns frames més tard amb el seu offset propi. spawnDuePlayers(); setState(RoomFsm::State::PLAYING); } void GameScene::spawnDuePlayers() { if (!intro_completed_) { return; } const bool BOTH_ACTIVE = players_starts_active_[0] && players_starts_active_[1]; for (std::size_t i = 0; i < players_starts_active_.size(); ++i) { if (!players_starts_active_[i]) { continue; } if (players_spawned_in_intro_[i]) { continue; } // Single-player: el slot actiu (P1 o P2) sempre fa servir // `player_spawn_delay_ms`. Mode BOTH: P1 fa player_spawn_delay_ms, // P2 fa player2_spawn_delay_ms (offset respecte P1). const int DELAY = (i == 1 && BOTH_ACTIVE) ? transition_config_.player2_spawn_delay_ms : transition_config_.player_spawn_delay_ms; if (intro_spawn_elapsed_ms_ >= DELAY) { triggerPlayerSpawn(i); } } } void GameScene::setState(RoomFsm::State new_state) { if (new_state == state_) { return; } Log::info("ROOM", "{} -> {}", RoomFsm::stateName(state_), RoomFsm::stateName(new_state)); state_ = new_state; transition_ = {}; // Reset defensiu dels flags de cicle quan tornem a INTRO via aquest // camí (no canònic — `enterRoomIntro` és el punt d'entrada normal). if (new_state == RoomFsm::State::ROOM_INTRO) { intro_completed_ = false; intro_spawn_elapsed_ms_ = 0; players_spawned_in_intro_.fill(false); } auto* audio = Audio::get(); // PLAYING: arrenca la pista de joc en loop infinit. Netegem el // callback de fi residual perquè un futur stopMusic no dispari // lògica obsoleta (defensiu — ja no en registrem cap al nou model). if (new_state == RoomFsm::State::PLAYING && audio != nullptr) { audio->setOnMusicEnded({}); audio->playMusic(config_.music_track, -1); } // ROOM_OUTRO: corte sec de `playing.ogg`, arrenca `room_outro.ogg` // (loop=0) i resol el `outro_music_tail_ms` perquè la fase de // fade_out caigui just al final del .ogg. Sense callback de fi — // la FSM avança per timeline pur. Si la música no arrenca, la cua // és 0 i el fade segueix immediatament al text_exit. if (new_state == RoomFsm::State::ROOM_OUTRO) { outro_fade_.begin(0); outro_fade_started_ = false; const int TEXT_TOTAL_MS = transition_config_.outro_text_enter_ms + transition_config_.outro_text_hold_ms + transition_config_.outro_text_exit_ms; transition_config_.outro_music_tail_ms = 0; if (audio == nullptr) { Log::warn("ROOM", "OUTRO: Audio absent — sense música, timeline només contra text"); } else { audio->stopMusic(); audio->setOnMusicEnded({}); audio->playMusic("room_outro.ogg", 0); if (Audio::getMusicState() == Audio::MusicState::PLAYING) { const int DUR_MS = Audio::getMusicDurationMs("room_outro.ogg"); transition_config_.outro_music_tail_ms = std::max(0, DUR_MS - TEXT_TOTAL_MS); Log::info("ROOM", "OUTRO: música {} ms, music_tail {} ms", DUR_MS, transition_config_.outro_music_tail_ms); } else { Log::warn("ROOM", "OUTRO: música no arrencada — timeline només contra text"); } } } // L'avanç de sala (OUTRO → INTRO) NO viu ací: el dispara // `updateRoomTransition` quan la FSM detecta el final de l'OUTRO, // cridant directament `advanceToNextRoom`. Això manté `setState` // com una transició pura, sense efectes col·laterals que // recarreguen sales — necessari per als salts debug que també // entren a INTRO sense passar per OUTRO. } void GameScene::updateRoomTransition(int delta_ms) { // Tick dels fades visuals. Tots dos viuen sempre actius però són // no-op si no s'han començat (FadeToBlack::tick sobre un fade no // començat és no-op gràcies al `duration_ms_ <= 0`). intro_fade_.tick(delta_ms); outro_fade_.tick(delta_ms); // Tick del timer d'spawn aeri escalonat. Comença al moment "música // d'INTRO acabada" (= setState(PLAYING) via timer dins `completeIntro`) // i segueix corrent fins que tots els slots actius hagin caigut. // Cap cost extra un cop tots han caigut (`spawnDuePlayers` és no-op). if (intro_completed_) { intro_spawn_elapsed_ms_ += delta_ms; spawnDuePlayers(); } const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms); // OUTRO: arrenquem `outro_fade_` la primera vegada que entrem a la // fase de fade_out (phase 4 del timeline d'OUTRO). Si l'`advance` // ha consumit fases buides en aquest mateix frame i ja portem // `transition_.elapsed_ms` dins de la fase 4, sincronitzem el fade // a aquesta posició perquè el render no comenci des de zero amb // delay artificial. if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 4 && !outro_fade_started_) { outro_fade_.begin(transition_config_.outro_fade_out_ms, Transitions::FadeToBlack::Direction::FADE_OUT); if (transition_.elapsed_ms > 0) { outro_fade_.tick(transition_.elapsed_ms); } outro_fade_started_ = true; } if (NEXT != state_) { // INTRO → PLAYING per timer: l'única ruta canònica al nou model. // `completeIntro` agrupa l'spawn aeri inicial i el `setState`. if (state_ == RoomFsm::State::ROOM_INTRO && NEXT == RoomFsm::State::PLAYING) { completeIntro(); return; } // El final de l'OUTRO no es resol amb un setState normal: cal // demanar al LevelManager el següent nivell i carregar-lo abans // d'entrar a INTRO. `advanceToNextRoom` fa tot el bloc i acaba // amb la FSM a ROOM_INTRO (via `enterRoomIntro`), així que no // fa falta el setState d'aquesta branca. if (NEXT == RoomFsm::State::ROOM_INTRO && state_ == RoomFsm::State::ROOM_OUTRO) { advanceToNextRoom(); } else { setState(NEXT); } } } void GameScene::renderRoomFade() { // Delegació als FadeToBlack: les durades, la direcció i el progrés // viuen dins de cada instància. Comportament: // ROOM_INTRO: `intro_fade_` (FADE_IN) pinta alpha 255→0 durant la // primera fase i, un cop finished, `render()` pinta // alpha=0 (no-op) fins al canvi d'estat. // ROOM_OUTRO: `outro_fade_` només té `duration_ms_>0` quan la FSM // ha entrat a la fase de fade_out (`outro_fade_started_`). // Mentre està actiu pinta 0→255; un cop finished // segueix pintant alpha=255 i això cobreix de manera // natural la fase final de `black_hold` sense lògica // especial. if (state_ == RoomFsm::State::ROOM_INTRO) { intro_fade_.render(); } else if (state_ == RoomFsm::State::ROOM_OUTRO) { outro_fade_.render(); } } void GameScene::renderRoomText() { // Orquestrador del rètol de transició de sala. La feina pesada // (animació de X, animació d'altura del panel i pintat) la fan // quatre helpers privats; aquesta funció només decideix quin // text dibuixar i amb quines fases segons l'estat. // // Mapatge per estat (només els estats amb text actiu hi entren): // ROOM_INTRO: phases 2..4 → enter / hold / exit; dues línies // (piràmide damunt, sala davall) que travessen la // pantalla en direccions oposades. // ROOM_OUTRO: phases 0..2 → enter / hold / exit; dues línies // oposades ("fase" / "completada"). Phase 3 és el // fade-out i 4 la pausa en negre — cap text en cap // de les dues. if (!room_text_font_) { return; } constexpr int KERNING = 0; // Centre vertical de la zona de joc (canvas sense la HUD). Reemplaça // l'antic GameCanvas::CENTER_Y per descomptar la franja del marcador // i tenir el rètol equilibrat sobre el playfield. const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2; // Colors del rètol via UiConfig (`data/config/ui.yaml > colors.room_text` i // `room_text_outline`). Independents dels colors de notificació. const Uint8 FILL = UiConfig::get().roomText(); const Uint8 OUTLINE = UiConfig::get().roomTextOutline(); const int LINE_HEIGHT = room_text_font_->getLineHeight(); if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) { const int DUR_ENTER = transition_config_.intro_text_enter_ms; const int DUR_EXIT = transition_config_.intro_text_exit_ms; // Panel primer (queda sota el text gràcies a l'ordre de blits). drawRoomTextPanel(PLAY_CENTER_Y, computeRoomTextPanelHeight(2, 3, 4, DUR_ENTER, DUR_EXIT)); const auto& mgr = Levels::LevelManager::get(); const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid()); const std::string LINE_ROOM = RoomFsm::formatIntroRoomLine(mgr.currentRoom()); // Direccions oposades: piràmide entra per l'esquerra i surt per // la dreta; sala fa el contrari. const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING), 2, 4, DUR_ENTER, DUR_EXIT, /*from_right=*/false); const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING), 2, 4, DUR_ENTER, DUR_EXIT, /*from_right=*/true); // Bloc de dues línies centrat al voltant del PLAY_CENTER_Y. El // gap (`intro_text_line_gap_px`) es reparteix simètricament: // mig amunt a la piràmide, mig avall a la sala. const int GAP = transition_config_.intro_text_line_gap_px; drawRoomTextLine(LINE_PYRAMID, X_PYRAMID, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE); drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE); } else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) { const int DUR_ENTER = transition_config_.outro_text_enter_ms; const int DUR_EXIT = transition_config_.outro_text_exit_ms; drawRoomTextPanel(PLAY_CENTER_Y, computeRoomTextPanelHeight(0, 1, 2, DUR_ENTER, DUR_EXIT)); const std::string LINE_1 = Locale::get().text("game.room_complete_line1"); const std::string LINE_2 = Locale::get().text("game.room_complete_line2"); const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING), 0, 2, DUR_ENTER, DUR_EXIT, /*from_right=*/false); const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING), 0, 2, DUR_ENTER, DUR_EXIT, /*from_right=*/true); const int GAP = transition_config_.outro_text_line_gap_px; drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE); drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE); } } auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int { // X animada d'una línia: entra des de fora de pantalla, s'atura al // CENTER_X durant el hold (fora del rang d'aquesta funció — el caller // garanteix que cau a la branca return final) i surt pel costat // oposat. `from_right=false` és el patró clàssic (esquerra → dreta); // `true` el flipa. const int OFF_LEFT = -width; const int OFF_RIGHT = GameCanvas::WIDTH; const int OFF_ENTER = from_right ? OFF_RIGHT : OFF_LEFT; const int OFF_EXIT = from_right ? OFF_LEFT : OFF_RIGHT; if (transition_.phase == phase_enter) { const float T = (dur_enter > 0) ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(dur_enter), 0.0F, 1.0F) : 1.0F; const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear; return Easing::lerpInt(OFF_ENTER, GameCanvas::CENTER_X, CURVE(T)); } if (transition_.phase == phase_exit) { const float T = (dur_exit > 0) ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(dur_exit), 0.0F, 1.0F) : 1.0F; const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear; return Easing::lerpInt(GameCanvas::CENTER_X, OFF_EXIT, CURVE(T)); } return GameCanvas::CENTER_X; } auto GameScene::computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int { // Altura animada del panel translúcid sota el rètol. Creix de 0 a // `text_panel_height_px` amb `text_enter_curve` durant l'enter, es // manté plena durant el hold i decreix amb `text_exit_curve` // durant l'exit. Fora d'aquestes tres fases retorna 0. const int MAX = transition_config_.text_panel_height_px; if (transition_.phase == phase_enter) { const float T = (dur_enter > 0) ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(dur_enter), 0.0F, 1.0F) : 1.0F; const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear; return Easing::lerpInt(0, MAX, CURVE(T)); } if (transition_.phase == phase_hold) { return MAX; } if (transition_.phase == phase_exit) { const float T = (dur_exit > 0) ? std::clamp(static_cast(transition_.elapsed_ms) / static_cast(dur_exit), 0.0F, 1.0F) : 1.0F; const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear; return Easing::lerpInt(MAX, 0, CURVE(T)); } return 0; } void GameScene::drawRoomTextPanel(int center_y, int panel_height) const { // No-op si l'altura és nul·la o l'alpha està a zero — així evitem // un blendRect inútil i deixem el caller cridar incondicionalment. if (panel_height <= 0 || transition_config_.text_panel_alpha == 0) { return; } Screen::get().blendRect(0, center_y - (panel_height / 2), GameCanvas::WIDTH, panel_height, transition_config_.text_panel_color, transition_config_.text_panel_alpha); } void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const { constexpr int KERNING = 0; constexpr Uint8 STROKE_DIST = 1; room_text_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG, x, y, text, KERNING, fill_color, STROKE_DIST, outline_color); } void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) { // Mida del sprite de Dust llegida del seu YAML. Hardcoded per // simplificar — si canvia, només cal tocar dust.yaml + aquesta // constant. La caixa de render es centra horitzontalment al peu i // s'alinea al sòl amb un pixel de marge cap amunt perquè la sombra // quede pintada per davant del polvet i el polvet, en l'y-sort, // entre per davant del jugador (mateixa lògica que la sombra). constexpr float DUST_W = 40.0F; constexpr float DUST_H = 16.0F; auto dust = std::make_unique(EntityLoader::loadTemplate("dust")); dust->setVariant(is_landing ? Dust::Variant::LAND : Dust::Variant::JUMP); dust->setSurface(Resource::Cache::get().getSurface("dust.gif")); // Recolzat al sòl, amb la base del rect 1 px per sobre del peu del // jugador → en l'y-sort surt amb feet_y un pèl menor i, per // estabilitat de stable_sort, queda darrere del jugador. dust->setPixelRect({ .x = feet_x_center - (DUST_W * 0.5F), .y = feet_y - DUST_H - 1.0F, .w = DUST_W, .h = DUST_H, }); entity_manager_.add(std::move(dust)); } void GameScene::spawnWhip(const WhipSpawnPayload& payload) { // El YAML ja viu cacheat al `EntityLoader`; les 4 surfaces les hem // precarregat al ctor. `configure()` selecciona la mida segons // direcció i posa pixel_rect; aquí resolem la surface via // `Resource::Cache` (mateix patró que `spawnDust`) per evitar que // l'entitat depengui directament del cache. auto whip_yaml = EntityLoader::loadTemplate("whip_attack"); auto whip = std::make_unique(whip_yaml); whip->setOwner(payload.owner); whip->configure( static_cast(payload.direction), payload.owner_box, payload.range_px); whip->setSurface(Resource::Cache::get().getSurface(whip->getSurfaceKey())); // Donem accés a Hazards i jugadors per a la fase de dany. Sense això // el whip queda merament visual. whip->setWorldContext(&entity_manager_, &players_view_); entity_manager_.add(std::move(whip)); } void GameScene::handlePauseInput() { #ifdef _DEBUG // Mutex amb l'editor: si està actiu, ignorem la tecla de pausa. if (editor_.active()) { return; } #endif const SDL_Scancode PAUSE_SC = KeyConfig::get().scancode("pause"); for (const auto& event : EventBuffer::events) { const bool KEY_HIT = (event.type == SDL_EVENT_KEY_DOWN && PAUSE_SC != SDL_SCANCODE_UNKNOWN && event.key.scancode == PAUSE_SC); if (!KEY_HIT) { continue; } // Determinem cap a quin estat anem. Si estem quiets (IDLE o // PAUSED) arranquem la transició contrària. Si estem en marxa // (ENTERING o EXITING) invertim: la `scale` actual es manté com // a `from_scale` nou i la durada es recalcula proporcional a la // distància restant — així mai es produeix un salt de velocitat. const bool GOING_TO_PAUSE = (pause_.state == PauseState::IDLE || pause_.state == PauseState::EXITING); // Reprenem l'stream ABANS de començar a pujar el volum: quan // venim de PAUSED l'àudio està aturat de debò (ho fem al final // d'ENTERING, veure updatePauseTransition); reprenem just al // moment de començar EXITING per no perdre el primer frame // d'stream. A les inversions mid-flight (ENTERING → EXITING // sense tocar PAUSED) l'stream no s'ha aturat mai, així que no // cal reprendre. if (!GOING_TO_PAUSE && pause_.state == PauseState::PAUSED) { Audio::get()->resumeMusic(); } pause_.from_scale = pause_.scale; pause_.to_scale = GOING_TO_PAUSE ? 0.0F : 1.0F; const int TARGET_MS = GOING_TO_PAUSE ? UiConfig::get().pauseEnterMs() : UiConfig::get().pauseExitMs(); const float DISTANCE = std::fabs(pause_.to_scale - pause_.from_scale); pause_.duration_ms = std::max(1, static_cast(static_cast(TARGET_MS) * DISTANCE)); pause_.elapsed_ms = 0; pause_.curve = GOING_TO_PAUSE ? UiConfig::get().pauseEnterCurve() : UiConfig::get().pauseExitCurve(); pause_.state = GOING_TO_PAUSE ? PauseState::ENTERING : PauseState::EXITING; Log::info("PAUSE", "{} from_scale={} duration_ms={}", GOING_TO_PAUSE ? "entering" : "exiting", pause_.from_scale, pause_.duration_ms); } } void GameScene::updatePauseTransition(int delta_ms) { if (pause_.state != PauseState::ENTERING && pause_.state != PauseState::EXITING) { return; } pause_.elapsed_ms += delta_ms; bool just_reached_pause = false; if (pause_.elapsed_ms >= pause_.duration_ms) { // Transició completa: enganxem al valor objectiu exacte i // promocionem l'estat al terminal corresponent. pause_.scale = pause_.to_scale; const PauseState NEXT = (pause_.to_scale == 0.0F) ? PauseState::PAUSED : PauseState::IDLE; just_reached_pause = (NEXT == PauseState::PAUSED); pause_.state = NEXT; } else { const float T = static_cast(pause_.elapsed_ms) / static_cast(pause_.duration_ms); const auto CURVE = (pause_.curve != nullptr) ? pause_.curve : &Easing::linear; pause_.scale = std::lerp(pause_.from_scale, pause_.to_scale, CURVE(T)); } // Fade del volum de música durant la transició. La base és el que // l'usuari ha configurat a Options; multipliquem per la `scale` per // obtenir el volum efectiu aquest frame. const float BASE_VOLUME = Options::audio().music.volume; Audio::get()->setMusicVolume(BASE_VOLUME * pause_.scale); // En arribar a PAUSED aturem l'stream: així quan despausem la música // tornarà des d'on s'ha quedat, no des d'on hauria anat a parar // durant el temps que el joc ha estat en pausa. El `resumeMusic` es // fa a `handlePauseInput` al començar l'EXITING, perquè la música // sone ja mentre el volum puja des de 0. if (just_reached_pause) { Audio::get()->pauseMusic(); } } #ifdef _DEBUG namespace { auto detectEditorToggle() -> bool { const SDL_Scancode SC = KeyConfig::get().scancode("editor_toggle"); if (SC == SDL_SCANCODE_UNKNOWN) { return false; } return std::ranges::any_of(EventBuffer::events, [SC](const SDL_Event& ev) { return ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == SC; }); } // Retorna -1 si s'ha premut prev_room, +1 si next_room, 0 si cap. // Si tots dos arriben en el mateix frame (poc probable amb teclat), // prev té prioritat — l'usuari sempre pot tornar a prémer next. auto detectRoomJump() -> int { const SDL_Scancode PREV_SC = KeyConfig::get().scancode("prev_room"); const SDL_Scancode NEXT_SC = KeyConfig::get().scancode("next_room"); for (const auto& ev : EventBuffer::events) { if (ev.type != SDL_EVENT_KEY_DOWN) { continue; } if (PREV_SC != SDL_SCANCODE_UNKNOWN && ev.key.scancode == PREV_SC) { return -1; } if (NEXT_SC != SDL_SCANCODE_UNKNOWN && ev.key.scancode == NEXT_SC) { return +1; } } return 0; } auto detectOpenDoor() -> bool { const SDL_Scancode SC = KeyConfig::get().scancode("open_door"); if (SC == SDL_SCANCODE_UNKNOWN) { return false; } return std::ranges::any_of(EventBuffer::events, [SC](const SDL_Event& ev) { return ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == SC; }); } } // namespace #endif void GameScene::update(int delta_ms) { handlePauseInput(); // Congelació instantània mentre el menú de servei està obert. A // diferència de la pausa de gameplay (que és un dial amb easing i // fade de música), aquesta és binària: el món no avança gens i la // música no es toca. També posem en pausa el dial de la pausa // regular si estava a mig camí, perquè no continui mentre l'usuari // navega el menú. const bool MENU_FROZEN = ServiceMenu::get().isOpen(); if (!MENU_FROZEN) { updatePauseTransition(delta_ms); } // Transició de sala: viu en temps real (no escalada per la pausa) // perquè és UI. Avança avans del bloc de món perquè quan PLAYING // arrenque ja sigui efectiu en el mateix frame. updateRoomTransition(delta_ms); #ifdef _DEBUG // Toggle de l'editor: només si no hi ha cap transició de pausa ni // pausa consolidada. L'editor mateix no consumeix la tecla — la // llegim ací per decidir la coexistència. Tant a l'entrada com a la // sortida fem `loadRoom` complet per partir sempre del YAML del // disc: les entitats tornen a la posició inicial (momies, sarcs), // el paint layer es buida i els jugadors es desactiven. Així // l'editor sempre veu l'estat-base de la sala (no el d'una partida // a meitat) i el joc, en sortir, arrenca fresc amb les edicions ja // persistides al YAML. if (pause_.state == PauseState::IDLE && detectEditorToggle()) { const bool WAS_ACTIVE = editor_.active(); editor_.toggle(tilemap_, entity_manager_); const bool NOW_ACTIVE = editor_.active(); if (!WAS_ACTIVE && NOW_ACTIVE) { // Acabem d'entrar: carreguem fresh des del disc. Els // jugadors es queden DISABLED (l'editor no els pinta de // totes maneres) i es reactivaran amb el spawn instantani // a la sortida. loadRoom(Levels::LevelManager::get().currentPath()); } else if (WAS_ACTIVE && !NOW_ACTIVE) { // Acabem de sortir: el writer ja ha persistit el YAML. // Carreguem fresh i fem spawn instantani sense cinemàtica. loadRoom(Levels::LevelManager::get().currentPath()); respawnPlayersInstant(); } } // Salts debug entre sales (`[` / `]`). Mútuament excloents amb la // pausa i amb l'editor: si qualsevol està actiu, ignorem la tecla // perquè el salt recarrega entitats i invalidaria l'estat editat. if (pause_.state == PauseState::IDLE && !editor_.active()) { const int DELTA = detectRoomJump(); if (DELTA != 0) { auto& mgr = Levels::LevelManager::get(); mgr.setIndex(mgr.index() + DELTA); loadRoom(mgr.currentPath()); enterRoomIntro(); } } // Força l'obertura de la porta (Keypad 1). Útil per saltar el // pintat/sarcs durant proves de progressió. La resta del flux // (creuar → player_exited → level_complete → OUTRO) es manté. // Mateixos gates que el salt entre sales. if (pause_.state == PauseState::IDLE && !editor_.active() && detectOpenDoor()) { Log::info("DEBUG", "força obertura de la porta"); entity_manager_.forceOpenDoor(); } #endif GlobalInputs::update(delta_ms); if (GlobalInputs::wantsQuit()) { wants_quit_ = true; } // El món avança amb `delta_ms * pause_.scale`: scale=1 corre a velocitat // real, scale=0 el congela. Quan l'editor de debug està actiu també // es salta completament (no l'escalem, el saltem). Notifier, // RenderInfo i HUD reben el delta real, per això viuen fora d'aquest // bloc i no es ralenteixen amb el dial de pausa. #ifdef _DEBUG const bool EDITOR_FROZEN = editor_.active(); #else constexpr bool EDITOR_FROZEN = false; #endif const int SCALED_DELTA_MS = static_cast(static_cast(delta_ms) * pause_.scale); if (!EDITOR_FROZEN && !MENU_FROZEN && SCALED_DELTA_MS > 0) { const float DT = static_cast(SCALED_DELTA_MS) / 1000.0F; // Pipeline de moviment en tres passes per poder intercalar la // resolució de contactes jugador↔jugador entre input i posició: // 1) tick: cada jugador calcula la velocitat intencionada // (teclat + mando + facing). // 2) resolve: si els dos es trobarien, anul·lem velocitats // (head-on) o escalem a push_factor (empenta). // 3) applyMovement: en l'ordre "pushed primer" perquè quan el // pusher faça `tryMove`, el pushed ja estiga fora de la // seua caixa. La callback també tracta l'altre jugador com // a sòlid: xarxa de seguretat davant de qualsevol inexactitud. // Cap gate per estat de sala: els jugadors fora del joc (DISABLED // durant l'INTRO abans del trigger d'spawn, o EXITED post-porta) // s'auto-salten a `tick`/`applyMovement`/`render`. Així la // cinemàtica de spawn aeri pot transcorrer mentre la fase d'INTRO // encara es mou. for (auto& player : players_) { player->tick(DT); } resolvePlayerVsPlayer(players_view_, DT, push_factor_); for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); } // El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha // emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat // als sarcòfags via la subscripció del bus, i aquest `update` pot // aplicar la transició OPENING → OPEN en el mateix frame. entity_manager_.update(DT); // Detecció de contacte player↔entitat. `checkPlayerCollisions` ja // filtra jugadors fora del joc (`isOutOfPlay`), així que durant // l'INTRO previ a l'spawn no es disparen contactes amb les // entitats que ja s'animen al fons. entity_manager_.checkPlayerCollisions(players_view_); } #ifdef _DEBUG editor_.update(delta_ms, tilemap_, entity_manager_); #endif // El HUD continua avançant les seues animacions encara que el món // estigui congelat (pausa o editor): així l'animació d'entrada acaba // sola i quan es despausa ja està al seu lloc, igual que el Notifier. if (hud_) { hud_->update(delta_ms); } RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow())); Notifier::get().update(delta_ms); } void GameScene::render() { const auto BG_ARGB = Resource::Cache::get().getPalette("ui")[UiConfig::get().canvasBg()]; Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BG_ARGB); // Mapa visual: textura prerenderitzada del tilemap. Els sarcòfags // queden "foradats" (cel·les transparents) perquè passaran a ser // actors independents; Map::collides encara els bloqueja per a les // col·lisions del jugador. tilemap_.render(); #ifdef _DEBUG // En mode editor amaguem jugadors, sombres, paint layer, fades, HUD // i textos de transició: el tilemap queda net per editar i només es // pinten les entitats (congelades, sense tick) per que el dissenyador // veja on estan. const bool EDITOR_ACTIVE = editor_.active(); #else constexpr bool EDITOR_ACTIVE = false; #endif #ifdef _DEBUG const bool SHOW_ENTITIES_IN_EDITOR = editor_.shouldShowEntities(); #else constexpr bool SHOW_ENTITIES_IN_EDITOR = false; #endif if (SHOW_ENTITIES_IN_EDITOR) { // Render de les entitats amb el mateix y-sort que el joc normal // (sense jugadors): així una mòmia situada davant d'un sarc en // falsa perspectiva queda dibuixada per damunt — i no a l'inrevés // segons l'ordre del YAML. Reusem `EntityManager::renderEntity` // (estàtic) i sortegem per `getFeetY()` ascendent. auto sorted = entity_manager_.getEntities(); std::ranges::stable_sort(sorted, [](const Entity* a, const Entity* b) { return a->getFeetY() < b->getFeetY(); }); for (const auto* e : sorted) { EntityManager::renderEntity(*e); } } // Capa de pintat: va entre el tilemap base i els actors, perquè els // sarcòfags i el jugador la tapen. S'amaga al mode editor (decisió // explícita: l'editor edita el tilemap, no la capa de pintat). if (!EDITOR_ACTIVE && paint_layer_) { paint_layer_->render(); } if (!EDITOR_ACTIVE) { // Passada de sombres dels jugadors saltant. Va per davant del // pintat (al damunt) però per darrere de TOTS els actors — // entitats i jugadors, inclòs el saltador mateix— així la sombra // queda al sòl sense tapar res. `renderShadow` és no-op si el // jugador no està a l'aire (i, com `render()`, també no-op si // `isOutOfPlay`). Cap gate per estat de sala: durant la // cinemàtica d'spawn aeri ja volem que la sombra es vegi. for (const auto& p : players_) { p->renderShadow(); } // Z-order interleaving entitats ↔ players per `getFeetY()` ascendent: // els actors amb els peus més amunt queden dibuixats primer (al fons), // els més avall al davant. Així un sarc situat per darrere d'un player // queda tapat per ell, i un sarc a davant el tapa a ell. struct Renderable { float feet_y; Entity* entity; // nullable; si és nullptr, s'usa `player`. Player* player; // nullable; si és nullptr, s'usa `entity`. }; std::vector order; order.reserve(entity_manager_.entityCount() + players_.size()); const auto ENTITIES = entity_manager_.getEntities(); std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) { return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr}; }); // Tots els jugadors entren al z-sort. Els fora de joc fan no-op a // `render()`; els actius (inclosos AIRBORNE durant l'spawn aeri) // es pinten amb el seu feet_y normal. La sombra que afecta la // sombra del salt s'ha pintat ja a la passada anterior. std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) { return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()}; }); std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) { return a.feet_y < b.feet_y; }); for (const auto& r : order) { if (r.entity != nullptr) { EntityManager::renderEntity(*r.entity); } else if (r.player != nullptr) { r.player->render(); } } // Fade del món: pintat ENTRE actors i HUD perquè el marcador, les // notificacions i el service menu queden per damunt sense afectar-se. // Inactiu si l'estat és PLAYING. renderRoomFade(); // HUD: a sobre dels actors (i del fade) perquè la franja superior // tape qualsevol sprite que (durant l'animació d'entrada) puga // sortir més amunt del playfield. S'amaga en mode editor però // segueix actualitzant-se per fora. if (hud_) { hud_->render(); } // Text de la transició de sala (entrada o sortida): per damunt del // HUD perquè és UI de transició i ha de ser sempre llegible. renderRoomText(); } #ifdef _DEBUG editor_.render(tilemap_, entity_manager_); #endif // Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X // depenent de l'estat del dial. Entra lliscant des de l'esquerra // (ENTERING), es queda al centre (PAUSED) i surt cap a la dreta // (EXITING). La X es deriva directament de `pause_.scale`, que ja // porta la corba aplicada — així text i món frenen alhora. if (pause_.state != PauseState::IDLE && pause_font_) { constexpr Uint8 OUTLINE_WHITE = 255; constexpr Uint8 STROKE_DIST = 1; constexpr int PAUSE_KERNING = 0; const std::string TEXT = Locale::get().text("input.paused"); const int TEXT_WIDTH = pause_font_->length(TEXT, PAUSE_KERNING); const int OFF_LEFT = -TEXT_WIDTH; const int OFF_RIGHT = GameCanvas::WIDTH + TEXT_WIDTH; int x = GameCanvas::CENTER_X; if (pause_.state == PauseState::ENTERING) { x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, 1.0F - pause_.scale); } else if (pause_.state == PauseState::EXITING) { x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, pause_.scale); } const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2); pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG, x, Y, TEXT, PAUSE_KERNING, UiConfig::get().notificationText(), STROKE_DIST, OUTLINE_WHITE); } Notifier::get().render(); RenderInfo::get().render(); Screen::get().present(); }