Files
aee-arcade/source/game/scenes/game_scene.cpp
T

1418 lines
71 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "game/scenes/game_scene.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <iterator>
#include <utility>
#include <variant>
#include <vector>
#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"
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<const char*>(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<int>();
}
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<const char*>(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<float>();
}
// 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<Player*>& 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<float>(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<Player*>& ps) -> std::vector<Player*> {
std::vector<Player*> 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_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)) {
// 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) {
std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n';
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<int>(&ev.payload);
std::cout << "[PLAYER HIT] player_id=" << ev.player_id
<< " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n';
});
// `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", "data/entities/dust/dust.gif");
entity_manager_.getBus().subscribe("player_jumped", [this](const Event& ev) {
const auto* pos = std::get_if<WorldPos>(&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<WorldPos>(&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", "data/entities/whip_attack/whip_east.gif");
Resource::Cache::get().loadSurface("whip_west.gif", "data/entities/whip_attack/whip_west.gif");
Resource::Cache::get().loadSurface("whip_north.gif", "data/entities/whip_attack/whip_north.gif");
Resource::Cache::get().loadSurface("whip_south.gif", "data/entities/whip_attack/whip_south.gif");
entity_manager_.getBus().subscribe("whip_spawn", [this](const Event& ev) {
const auto* p = std::get_if<WhipSpawnPayload>(&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<float>(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<Player>(ANIM_P1, ID_P1, p1_x, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
players_.emplace_back(std::make_unique<Player>(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<PaintLayer>(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<Text>(hud_font_surface, UiConfig::get().hudFont().fnt);
hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning);
hud_ = std::make_unique<Hud>(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<Text>(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<Text>(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::CONFIG_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<float>((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast<float>(Map::tile_size) / 2.0F);
const float CENTER_TL_X = DOOR_CENTER_X - (static_cast<float>(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<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
const float SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(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<float>(Map::tile_size),
.p2_x = CENTER_TL_X + static_cast<float>(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<PaintLayer>(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());
}
std::cout << "[ROOM] load " << yaml_path << ": entities reloaded, paint cleared, "
<< ACTIVE_COUNT << " active player(s) disabled (awaiting spawn cinematic)\n";
}
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.
std::cout << "[ROOM] final del levels.yaml — bucle a la primera sala\n";
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) {
std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> ROOM_INTRO\n";
} else {
std::cout << "[ROOM] ROOM_INTRO restart (cinemàtica reiniciada)\n";
}
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) {
std::cout << "[ROOM] INTRO: Audio absent — sense música, timeline només contra text\n";
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);
std::cout << "[ROOM] INTRO: música " << DUR_MS << " ms, music_tail "
<< transition_config_.intro_music_tail_ms << " ms\n";
} else {
std::cout << "[ROOM] INTRO: música no arrencada — timeline només contra text\n";
}
}
#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<float>(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_ = {};
std::cout << "[ROOM] post-editor: spawn instantani, estat -> PLAYING\n";
}
#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<float>(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);
std::cout << "[ROOM] spawn cinematic P" << (slot_index + 1) << ": airborne entry\n";
}
void GameScene::dispatchPlayerKilled(int player_id) {
std::cout << "[PLAYER KILLED] player_id=" << player_id << '\n';
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; }
std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> " << RoomFsm::stateName(new_state) << '\n';
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) {
std::cout << "[ROOM] OUTRO: Audio absent — sense música, timeline només contra text\n";
} 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);
std::cout << "[ROOM] OUTRO: música " << DUR_MS << " ms, music_tail "
<< transition_config_.outro_music_tail_ms << " ms\n";
} else {
std::cout << "[ROOM] OUTRO: música no arrencada — timeline només contra text\n";
}
}
}
// 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/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<float>(transition_.elapsed_ms) / static_cast<float>(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<float>(transition_.elapsed_ms) / static_cast<float>(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<float>(transition_.elapsed_ms) / static_cast<float>(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<float>(transition_.elapsed_ms) / static_cast<float>(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<Dust>(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<WhipAttack>(whip_yaml);
whip->setOwner(payload.owner);
whip->configure(
static_cast<WhipAttack::Direction>(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<int>(static_cast<float>(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<float>(pause_.elapsed_ms) / static_cast<float>(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()) {
std::cout << "[DEBUG] força obertura de la porta\n";
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<int>(static_cast<float>(delta_ms) * pause_.scale);
if (!EDITOR_FROZEN && !MENU_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(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<Renderable> 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();
}