#include "game/scenes/game_scene.hpp" #include #include #include #include #include #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/input/gamepad_manager.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/entity.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" #include "game/map/map.hpp" #include "game/options.hpp" #include "game/ui/hud.hpp" #include "utils/defaults.hpp" #include "utils/defines.hpp" #include "utils/easing.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; } // Filtre per altura: si un dels dos està en l'aire per damunt // del cos de l'altre, no hi ha contacte possible — el saltador // sobrevola. Saltar fa de "moviment lliure" per al resolver. // `body_height` és estàtic carregat des del YAML; cppcheck el llig // com a sempre-zero perquè no veu la inicialització en runtime. const auto BODY_H = static_cast(Player::body_height); // cppcheck-suppress knownConditionTrueFalse if (BODY_H > 0.0F && a->isAirborne() && a->getJumpZ() > BODY_H) { return; } // cppcheck-suppress knownConditionTrueFalse if (BODY_H > 0.0F && b->isAirborne() && b->getJumpZ() > BODY_H) { 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. 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; } 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 { return { .map_yaml = Defaults::Pyramid::MAP_YAML, .player_sprite = Defaults::Pyramid::PLAYER_SPRITE, .player_yaml = Defaults::Pyramid::PLAYER_YAML, .player2_sprite = Defaults::Pyramid::PLAYER_SPRITE_P2, .player2_yaml = Defaults::Pyramid::PLAYER_YAML_P2, .music_track = Defaults::Pyramid::MUSIC_TRACK, .starting_players = DebugConfig::StartingPlayers::BOTH, }; } GameScene::GameScene(Config config) : config_(std::move(config)), tilemap_(makeTilemap(config_.map_yaml)) { // 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); std::string anim_p1; std::string anim_p2; if (USE_P1) { anim_p1 = load_player_assets(config_.player_sprite, config_.player_yaml); } if (USE_P2) { 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: cridem // `loadFrom` amb el YAML del primer jugador actiu (P1 o P2). Els dos // personatges usen la mateixa geometria (morcus és un repaletat de sam). Player::loadFrom(USE_P1 ? config_.player_yaml : config_.player2_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: de moment només logs, sense // tocar ni la vida del jugador ni l'estat de la escena. Serà la base // sobre la qual cablejar HUD i fi de nivell més endavant. entity_manager_.getBus().subscribe("level_complete", [](const Event& ev) { std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n'; }); entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) { const auto* dmg = std::get_if(&ev.payload); std::cout << "[PLAYER HIT] player_id=" << ev.player_id << " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n'; }); // Controls per jugador: P1 amb fletxes, P2 amb WASD. Cada Player // escolta les seues pròpies tecles dins del seu `update`, no hi ha // estat global d'input. const Player::Controls CONTROLS_P1{ .up = Defaults::KeysGame::UP, .down = Defaults::KeysGame::DOWN, .left = Defaults::KeysGame::LEFT, .right = Defaults::KeysGame::RIGHT, .jump = Defaults::KeysGame::JUMP, }; const Player::Controls CONTROLS_P2{ .up = Defaults::KeysGameP2::UP, .down = Defaults::KeysGameP2::DOWN, .left = Defaults::KeysGameP2::LEFT, .right = Defaults::KeysGameP2::RIGHT, .jump = Defaults::KeysGameP2::JUMP, }; // Spawn centrat al passadís exterior superior: la caixa de col·lisió // queda dins del passadís vertical del mig (entre columnes 1 i 2) i // centrada en Y al passadís exterior superior. Així no naix dins de // cap sarcòfag. constexpr auto CORRIDOR_CENTER_X = static_cast(GameCanvas::CENTER_X); const auto CORRIDOR_CENTER_Y = static_cast(Map::playfield_y) + (static_cast(Map::outer_corridor) / 2.0F); const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast(Player::sprite_w) / 2.0F); const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast(Player::collision_y_offset) - (static_cast(Player::collision_size) / 2.0F); // En mode BOTH separem els jugadors un tile a banda i banda del // centre perquè no naixen superposats. En mode single-player // (P1 o P2 aïllats per a proves de dev) l'únic jugador arrenca al // centre exacte. const bool BOTH = (USE_P1 && USE_P2); const auto P1_SPAWN_X = BOTH ? SPAWN_X - static_cast(Map::tile_size) : SPAWN_X; const auto P2_SPAWN_X = BOTH ? SPAWN_X + static_cast(Map::tile_size) : SPAWN_X; // Í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; if (USE_P1) { const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1); players_.emplace_back(std::make_unique(anim_p1, ID_P1, P1_SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo(), P1_PAINT)); } if (USE_P2) { const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1); players_.emplace_back(std::make_unique(anim_p2, ID_P2, P2_SPAWN_X, SPAWN_Y, CONTROLS_P2, Map::makeInfo(), P2_PAINT)); } // 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()); } // 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_); } // 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); } // Música de fons en bucle infinit. El track ja està al Resource::Cache // (preload al Director), així que playMusic només mou el punter cap a // jail_audio i obri l'stream. Audio::get() està garantit no-null: // Director::init crida Audio::init abans d'instanciar GameScene. Audio::get()->playMusic(config_.music_track, -1); // 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); } GameScene::~GameScene() = default; 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"); SDL_Gamepad* pad = GamepadManager::get().getPrimary(); 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); const bool PAD_HIT = (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && event.gbutton.which == SDL_GetGamepadID(pad) && event.gbutton.button == SDL_GAMEPAD_BUTTON_START); if (!KEY_HIT && !PAD_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; std::cout << "[PAUSE] " << (GOING_TO_PAUSE ? "entering" : "exiting") << " from_scale=" << pause_.from_scale << " duration_ms=" << pause_.duration_ms << '\n'; } } 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; }); } } // namespace #endif void GameScene::update(int delta_ms) { handlePauseInput(); updatePauseTransition(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. if (pause_.state == PauseState::IDLE && detectEditorToggle()) { editor_.toggle(tilemap_); } #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 && 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. 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 després que totes les entitats // hagin mogut aquest frame. Reutilitzem la vista plana que ja // manté el GameScene per al resolver player↔player. entity_manager_.checkPlayerCollisions(players_view_); } #ifdef _DEBUG editor_.update(delta_ms, tilemap_); #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 entitats i jugador: el tilemap queda net // per a poder clicar cel·la per cel·la sense interferència visual. // El HUD futur (quan s'afegeixi) també farà cas d'aquest flag. const bool SKIP_ACTORS = editor_.active(); #else constexpr bool SKIP_ACTORS = false; #endif // 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 (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } if (!SKIP_ACTORS) { // 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}; }); 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(); } } // HUD: a sobre dels actors 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 (l'editor edita el tilemap; // el HUD distrauria) però segueix actualitzant-se per fora. if (hud_) { hud_->render(); } } #ifdef _DEBUG editor_.render(tilemap_); #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(); }