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

1247 lines
62 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/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/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 "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';
});
// 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. 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,
.attack = Defaults::KeysGame::ATTACK,
};
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,
.attack = Defaults::KeysGameP2::ATTACK,
};
// 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à
// `updateRoomTransition` quan s'esgote el delay corresponent dins
// de ROOM_INTRO (`player_spawn_delay_ms` per al primer, i
// `player2_spawn_delay_ms` per al segon en mode BOTH). 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()); }
// 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);
// Preload del so del rètol d'INTRO (no hi ha manifest de sons; el
// pack agafa tot `data/` automàticament i ací registrem la clau
// al Resource::Cache perquè Audio::playSound la trobe). Si el camp
// del YAML està buit, no carreguem res — el dispar consultarà el
// cache i caurà al silenci sense crash.
if (!transition_config_.intro_text_sound.empty()) {
Resource::Cache::get().loadSound(
transition_config_.intro_text_sound,
"data/sounds/" + transition_config_.intro_text_sound);
}
// Log d'estat inicial: la FSM arrenca a ROOM_INTRO. En aquesta fita
// les transicions són instantànies (sense fades ni text) i el primer
// tick d'`updateRoomTransition` ja saltarà a PLAYING.
std::cout << "[ROOM] state=ROOM_INTRO (initial)" << '\n';
}
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);
entity_manager_.reload(yaml_path);
// 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);
if (paint_layer_) { paint_layer_->clear(); }
// 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_ = {};
room_intro_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false);
intro_text_sound_played_ = false;
// La cinemàtica d'entrada és silenciosa per disseny: és INTRO qui
// posseeix l'aturada. L'OUTRO no toca la música — així el cicle
// OUTRO → INTRO transmet la sensació de "tall" en entrar a la sala
// nova, no de "fade-out" anterior. Si ja no sona res, stopMusic és
// no-op.
if (Audio::get() != nullptr) { Audio::get()->stopMusic(); }
}
void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
if (slot_index >= players_spawned_in_intro_.size()) { 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; }
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::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_ = {};
// En entrar a un nou cicle d'INTRO, resetegem el timer i el flag de
// l'spawn aeri perquè es torne a disparar al delay configurat.
if (new_state == RoomFsm::State::ROOM_INTRO) {
room_intro_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false);
intro_text_sound_played_ = false;
}
// En sortir d'INTRO cap a PLAYING, la música arrenca: és el moment
// en què la cinemàtica d'entrada acaba i el jugador agafa control.
// L'OUTRO no atura la música; ho fa el següent `enterRoomIntro`.
if (new_state == RoomFsm::State::PLAYING && Audio::get() != nullptr) {
Audio::get()->playMusic(config_.music_track, -1);
}
// 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) {
// Disparador d'spawn aeri: timer propi (independent de les fases de
// la FSM) que arrenca a l'entrada de ROOM_INTRO. La separació deixa
// el delay desacoblat del fade-in i, demà, reutilitzable per al
// respawn post-mort sense que la FSM de sala hi haja d'intervenir.
if (state_ == RoomFsm::State::ROOM_INTRO) {
room_intro_elapsed_ms_ += delta_ms;
// Cada slot actiu pot caure en un moment distint quan els dos
// estan actius (mode BOTH): P1 amb player_spawn_delay_ms i P2
// amb player2_spawn_delay_ms. En single-player (P1 o P2 sols)
// l'slot actiu fa servir player_spawn_delay_ms — el segon valor
// queda inactiu, com ha demanat el disseny.
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; }
const int DELAY = (i == 1 && BOTH_ACTIVE)
? transition_config_.player2_spawn_delay_ms
: transition_config_.player_spawn_delay_ms;
if (room_intro_elapsed_ms_ >= DELAY) {
triggerPlayerSpawn(i);
}
}
}
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
// Disparador del so del rètol: la FSM acaba d'avançar; si estem a
// INTRO i ja hem deixat enrere phase 0 (fade_in), reproduïm el so
// una sola vegada. El so es dispara al final del fade-in, anticipant
// el text que apareix a phase 2 (text_enter): així sembla que el so
// "anuncia" l'arribada del rètol enlloc de coincidir amb la seva
// entrada visual.
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 1 && !intro_text_sound_played_ && !transition_config_.intro_text_sound.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(transition_config_.intro_text_sound, Audio::Group::GAME);
intro_text_sound_played_ = true;
}
if (NEXT != state_) {
// 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() {
// Fade negre sobre el món, pintat un cop els actors ja estan al
// canvas i abans del HUD. Tres casos:
// ROOM_INTRO/fase 0: 255 → 0 (entrada de sala, món emergent)
// ROOM_OUTRO/fase 3: 0 → 255 (sortida de sala, món s'apaga)
// ROOM_OUTRO/fase 4: alpha=255 fix (pausa en negre opac abans del
// rellançament de la pròxima sala)
if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 4) {
Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, 255);
return;
}
int dur = 0;
float t = 0.0F;
bool fade_in = false;
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase == 0) {
dur = transition_config_.intro_fade_in_ms;
fade_in = true;
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 3) {
dur = transition_config_.outro_fade_out_ms;
fade_in = false;
} else {
return;
}
t = (dur > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur), 0.0F, 1.0F)
: 1.0F;
const float ALPHA_F = fade_in ? (1.0F - t) : t;
const auto ALPHA = static_cast<std::uint8_t>(255.0F * ALPHA_F);
if (ALPHA == 0) { return; }
Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, ALPHA);
}
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");
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<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();
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.
if (pause_.state == PauseState::IDLE && detectEditorToggle()) {
editor_.toggle(tilemap_);
}
// 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 && 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_);
#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) {
// 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_);
#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();
}