Files
aee-arcade/source/game/scenes/game_scene.hpp
T
JailDesignerandClaude Opus 4.7 3b378e2b29 feat(room): allibera DynamicHazards del SPAWN al final del text-INTRO
GameScene ara crida `triggerHazardSpawnExit()` quan ROOM_INTRO entra a
la fase 5 (music_tail, immediatament després del text-exit): itera els
DynamicHazards de la sala i els arma el comptador `spawn.duration_s`.

Així la cinemàtica queda:
  fade-in → text-enter → text-hold → text-exit
    └─> mòmies surten "del terra" (durada `spawn.duration_s` del YAML)
        └─> jugadors cauen del cel (delay player_spawn_delay_ms)

`respawnPlayersInstant` (salt debug a sala) també crida el trigger
perquè les mòmies no quedin congelades quan es curt-circuita la
cinemàtica.

Flag `hazards_spawn_triggered_` garanteix idempotència dins el cicle;
es reseteja a `enterRoomIntro` i al `setState(ROOM_INTRO)` defensiu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:56:14 +02:00

436 lines
21 KiB
C++

#pragma once
#include <array>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "core/rendering/transitions/fade_to_black.hpp"
#include "core/scene/scene.hpp"
#include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/map/paint_layer.hpp"
#include "game/map/tilemap.hpp"
#include "game/scenes/game_over_fsm.hpp"
#include "game/scenes/room_fsm.hpp"
#ifdef _DEBUG
#include "game/editor/editor.hpp"
#endif
class Hud;
class Player;
class Text;
class GameScene : public Scene {
public:
// Conjunt de rutes que defineixen una piràmide concreta (mapa + tilemap
// + sprites dels jugadors + música). Cada piràmide serà un Config distint;
// `defaultConfig()` retorna la primera (Defaults::Pyramid). `starting_players`
// controla quins jugadors instancia el ctor: només P1, només P2 o els dos.
struct Config {
std::string map_yaml;
std::string player_sprite;
std::string player_yaml;
std::string player2_sprite;
std::string player2_yaml;
std::string music_track;
DebugConfig::StartingPlayers starting_players{DebugConfig::StartingPlayers::BOTH};
};
[[nodiscard]] static auto defaultConfig() -> Config;
explicit GameScene(Config config = defaultConfig());
~GameScene() override;
void update(int delta_ms) override;
void render() override;
private:
// Punt centralitzat per canviar d'estat de sala. Imprimeix la
// transició i reinicialitza els acumuladors de la `RoomFsm::Transition`.
// Cap estat s'ha de canviar fora d'aquí.
void setState(RoomFsm::State new_state);
// Avança la transició activa via `RoomFsm::advance`. S'executa cada
// frame amb delta real (no escalat per la pausa) perquè la transició
// viu en temps de UI. Si l'estat retornat és diferent de l'actual,
// crida `setState` (que reseteja la `Transition`).
void updateRoomTransition(int delta_ms);
// Consumeix events d'aquest frame (F11 del KeyConfig "pause") per
// disparar les transicions de pausa. El mando ja no participa: el
// botó START és ara una acció remapeable per dispositiu i el menú
// de servei fa de pausa quan cal. No alterna un flag binari: inicia
// (o inverteix mid-flight) un "dial" de temps que frena el món amb
// easing.
void handlePauseInput();
// Avança la transició activa, recalcula `pause_.scale` i aplica el
// fade de volum de música. S'executa cada frame amb el delta real
// (no escalat) perquè la transició viu en temps real.
void updatePauseTransition(int delta_ms);
// Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`).
// Cridat per la subscripció del bus a `player_jumped` /
// `player_landed` per pintar el polvet de despegue / aterratge.
// `is_landing` selecciona la variant de l'animació al `Dust`.
void spawnDust(float feet_x_center, float feet_y, bool is_landing);
// Spawna un `WhipAttack` a partir del payload publicat al bus quan
// un Player entra a `ATTACKING`. Llig la plantilla cacheada del YAML
// i delega la mida + surface al `WhipAttack::configure()` segons
// direcció.
void spawnWhip(const struct WhipSpawnPayload& payload);
Config config_;
Tilemap tilemap_;
// Gestor d'entitats del nivell (sarcòfags, porta, momia, antorxa,
// etc.). Viu aquí perquè la seua vida coincideix amb la de la escena;
// cada entitat del nivell es carrega al ctor via loadFromLevel.
EntityManager entity_manager_;
// Vector des del principi: AEE suportarà multijugador local; cada Player
// té els seus propis Controls i entra al vector amb una crida extra.
// Sempre conté **dos** entrades (P1 a [0], P2 a [1]) tant en mode BOTH
// com en single-player: l'slot inactiu es queda en estat DISABLED i
// pot entrar mid-partida via "join in progress" (`handleJoinInput`).
std::vector<std::unique_ptr<Player>> players_;
// Quins slots estan en partida ARA MATEIX (jugant o DYING). Mutable
// durant la sala: el ctor el sembra des de `config_.starting_players`,
// `joinPlayer` el posa a true quan un slot inactiu pulsa START, i la
// subscripció a `player_disabled` el torna a false quan un slot
// esgota vides. `loadRoom` recalcula `setExpectedPlayerCount` a
// partir d'aquesta vista — un slot DISABLED al final d'una sala no
// entra automàticament a la següent (el jugador ha de re-pulsar
// START si vol re-entrar).
std::array<bool, 2> players_active_{false, false};
// Cinemàtica de spawn d'un slot concret. Posa el slot indicat en
// AIRBORNE des d'una altura fora de pantalla. Cridat per
// `spawnDuePlayers` quan el timer post-callback supera el delay
// del slot. Idempotent (consulta `players_spawned_in_intro_`).
// També és el punt al qual es connectarà el respawn post-mort en
// el futur.
void triggerPlayerSpawn(std::size_t slot_index);
// Allibera tots els DynamicHazards de la sala (típicament mòmies)
// del seu estat SPAWN inicial: arma el comptador via
// `DynamicHazard::triggerSpawnExit()`. Es crida a `updateRoomTransition`
// quan ROOM_INTRO entra a la fase 5 (post text-exit), perquè les mòmies
// surten visualment "del terra" just després que el text desapareix
// i abans que caiguin els jugadors. Idempotent dins el cicle
// (`hazards_spawn_triggered_`); reset a `enterRoomIntro`. També
// cridada des de `respawnPlayersInstant` perquè el salt debug
// a sala no deixi les mòmies congelades.
void triggerHazardSpawnExit();
// Cos del callback de fi de música d'INTRO. Idempotent: si ja s'ha
// disparat aquest cicle (`intro_completed_`), no fa res. Marca el
// flag, reseteja el timer post-callback i intenta spawn dels slots
// actius amb delay <= 0 ara mateix; els altres cauen frame a frame
// dins `updateRoomTransition` quan el timer els assoleix. Finalment
// commuta a PLAYING — la música del joc arrenca amb el primer slot.
void completeIntro();
// Dispatch del cicle DYING al jugador identificat per `player_id`.
// Crida `Player::enterDying()` (idempotent — un segon impacte mentre
// ja és DYING és no-op). Subscriptor de l'event `player_killed`
// publicat pels hazards letals (mòmia amb `kills_outright`).
// Extret a un mètode per acotar la complexitat cognitiva del ctor.
void dispatchPlayerKilled(int player_id);
// Handler de l'event `player_disabled` (un slot acaba d'esgotar
// vides). Apaga `players_active_[id]` perquè el `loadRoom` següent
// no compti aquest slot i perquè el "join in progress" del frame
// següent torni a oferir-li `setJoinPrompt(CAN_JOIN)`. Extret a
// mètode per acotar la complexitat cognitiva del subscriber/ctor.
void onPlayerDisabled(int player_id);
// Pre-load dels recursos d'àudio que s'usen mid-partida i no els
// toca el Director (so del countdown, etc.). Si la clau és buida
// o ja està al cache, és no-op. Extret a mètode per acotar la
// complexitat cognitiva del ctor.
void preloadGameOverAssets() const;
// Itera els slots actius i, si el delay configurat ha expirat des
// del callback de fi de música d'INTRO, dispara `triggerPlayerSpawn`.
// Cridada cada frame durant la fase d'spawn escalonat (entre el
// callback i la caiguda de l'últim slot). No fa res si ja han
// caigut tots o si encara no estem dins la finestra (intro_completed_).
void spawnDuePlayers();
// Atén el "join in progress" (regla 1 de la FSM de joc): si un slot
// està DISABLED i el jugador corresponent pulsa START dins de PLAYING,
// dispara `joinPlayer`. La detecció de tecla/botó la fa
// `Input::detect::pressedStart`. Ignorat fora de PLAYING (durant
// INTRO/OUTRO el slot inactiu mostra "ESPERA / PARA JUGAR" al HUD).
void handleJoinInput();
// Materialitza l'entrada d'un slot inactiu: spawn aeri al costat
// corresponent de la porta (`computeSpawn().pX_x`), reset de vides
// al valor inicial via `Player::resetForJoin`, marcatge de
// `players_active_[i]=true` i activació del bloc del HUD. La
// cinemàtica reaprofita `spawnFromAir` (mateixa caiguda visual que
// l'INTRO de sala i el respawn post-mort).
void joinPlayer(std::size_t slot_index);
// Recompte de jugadors "en partida" (no DISABLED ni EXITED, o bé
// DYING — DYING encara compta com a jugant fins que es resol). Hook
// exposat per a la futura sub-FSM CONTINUE? + GAME OVER, que vigila
// la transició a 0 jugadors actius. No s'usa dins de la Fase 1.
[[nodiscard]] auto activePlayerCount() const -> int;
// Refresca el prompt mostrat al bloc HUD de cada slot inactiu. Es
// crida cada frame des d'`update()`. La decisió viu ací (que coneix
// `state_` de la RoomFsm); el HUD només pinta. Per a slots actius,
// posa el prompt a NONE per ser defensiu davant de canvis d'estat.
void refreshJoinPrompt();
// Sentinel que marca el moment "música d'INTRO acabada" dins del
// timeline. Ja no ve d'un callback d'àudio: el setja el GameScene
// quan la FSM ha consumit l'última fase d'INTRO (la `music_tail`,
// calculada perquè coincideixi amb el final del .ogg). Habilita el
// tick de `intro_spawn_elapsed_ms_` i el spawn aeri escalonat dels
// slots actius.
bool intro_completed_{false};
// Mil·lisegons acumulats des del callback de fi de música d'INTRO,
// per gatejar l'spawn aeri escalonat dels slots actius. P1 fa
// servir `player_spawn_delay_ms` (típicament 0); P2 en mode BOTH
// fa servir `player2_spawn_delay_ms` (offset respecte P1). Es
// reseteja a 0 dins `completeIntro` i no torna a córrer fins al
// següent cicle d'INTRO.
int intro_spawn_elapsed_ms_{0};
// Per slot: ja ha caigut aquest cicle d'INTRO? Garanteix
// idempotència de `triggerPlayerSpawn` davant múltiples ticks del
// timer mentre el delay no s'ha mogut. Es reseteja a `enterRoomIntro`
// i a `setState(ROOM_INTRO)` (camí defensiu no canònic).
std::array<bool, 2> players_spawned_in_intro_{false, false};
// Idempotència de `triggerHazardSpawnExit` dins d'un cicle d'INTRO.
// Reset a `enterRoomIntro` i a `setState(ROOM_INTRO)`.
bool hazards_spawn_triggered_{false};
// Vista plana de punters crus — la fa servir Player com a llista
// d'obstacles mòbils a la callback de col·lisió. Es construeix un sol
// cop al ctor (els punters són estables perquè `players_` no es
// redimensiona dins de la vida de l'escena).
std::vector<Player*> players_view_;
// Factor multiplicatiu del push inter-jugador. Carregat de la clau
// `push_factor` del YAML del mapa; 0.30 per defecte (veure
// `Defaults::Player::PUSH_FACTOR`). Constant dins de la vida de
// l'escena — una piràmide, una física.
float push_factor_{0.30F};
// Capa de "pintat" del terra. Escolta `on_tile_entered` al bus,
// filtra pintabilitat (sòl transitable) i re-publica `tile_painted`
// per als sarcòfags. `unique_ptr` per controlar l'ordre de
// construcció/destrucció: es crea al ctor un cop `entity_manager_` i
// `tilemap_` estan llestos, i es destrueix abans que `entity_manager_`
// (on viu el bus al qual està subscrit). Declarat després d'aquests
// dos membres per garantir l'ordre invers a la destrucció.
std::unique_ptr<PaintLayer> paint_layer_;
// Marcador de la franja superior (24 px). `unique_ptr` per controlar
// l'ordre de construcció/destrucció igual que `paint_layer_`: es crea
// al ctor un cop `entity_manager_` ja té el bus, i es destrueix abans
// que `entity_manager_` (on viu el bus al qual està subscrit).
// Declarat després d'`entity_manager_` per garantir l'ordre invers a
// la destrucció.
std::unique_ptr<Hud> hud_;
// Pausa de gameplay com a "dial" de temps. En lloc d'un interruptor,
// `pause_.scale` va de 1 (món corrent) a 0 (món congelat) amb una
// corba d'easing durant ENTERING, i al revés durant EXITING. El
// `delta_ms` del món (players + EntityManager) es multiplica per
// aquesta escala; HUD, Notifier i GlobalInputs reben sempre el
// delta real. Les inversions mid-flight capturen la `scale` actual
// com a `from_scale` i escalen la durada a la distància restant per
// mantenir la sensació de velocitat. La font és la mateixa que
// abans — ja cachejada al Director.
std::shared_ptr<Text> pause_font_;
// Font del rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
// Distinta de `pause_font_` perquè el rètol de fase té estètica
// pròpia (CaesarDressing per defecte, configurable via
// `data/config/ui.yaml > fonts.room_text`). La pausa segueix amb la
// placeholder.
std::shared_ptr<Text> room_text_font_;
enum class PauseState : std::uint8_t {
IDLE, // Joc corrent; cap transició en marxa. scale == 1.
ENTERING, // Frenant cap a la pausa. scale decreix a 0.
PAUSED, // Món congelat. scale == 0.
EXITING, // Accelerant de tornada. scale creix a 1.
};
struct PauseTransition {
PauseState state{PauseState::IDLE};
float scale{1.0F}; // 0 = parat, 1 = corrent.
float from_scale{1.0F}; // Valor de `scale` al començar la transició.
float to_scale{1.0F}; // Valor objectiu (0.0 o 1.0).
int elapsed_ms{0}; // Temps transcorregut dins la transició.
int duration_ms{0}; // Durada total (escalada a la distància).
UiConfig::CurveFn curve{nullptr}; // Corba activa.
} pause_;
// Configuració de la FSM de sala (intro/outro). Carregada al ctor des
// de `data/scenes/game/game.yaml`; valors per defecte si manca el YAML.
RoomFsm::Config transition_config_{};
// Estat actual de la FSM de sala. S'inicialitza a ROOM_INTRO al ctor
// perquè cada vegada que entres al joc passes per la presentació de
// la sala. La transició a PLAYING l'orquestra `updateRoomTransition`.
RoomFsm::State state_{RoomFsm::State::ROOM_INTRO};
// Sub-fase i temps acumulat dins de l'estat actual. `setState` el
// reseteja a {0, 0}.
RoomFsm::Transition transition_{};
// Fade-in d'INTRO: alpha 255 → 0 durant `intro_fade_in_ms`. Es comença
// a `enterRoomIntro` i tica cada frame; un cop finished, `render()`
// pinta alpha=0 (no-op) durant la resta del cicle.
Transitions::FadeToBlack intro_fade_;
// Fade-out d'OUTRO: alpha 0 → 255 durant `outro_fade_out_ms`, només
// arrencat quan la FSM entra a la fase corresponent (detectada via
// `outro_fade_started_`). Un cop finished, `render()` segueix
// pintant alpha=255: això cobreix de manera natural la fase final
// de `black_hold` sense lògica especial.
Transitions::FadeToBlack outro_fade_;
bool outro_fade_started_{false};
// Píxels del fade negre i del text de la transició de sala.
// Cridats des de `render()` entre el bloc d'actors i el HUD —
// així marcador, notificacions i service menu queden per damunt.
void renderRoomFade();
void renderRoomText();
// Configuració, estat i transició de la sub-FSM de game over
// (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la
// RoomFsm via `GameOverFsm::loadConfig`.
GameOverFsm::Config game_over_config_{};
GameOverFsm::State game_over_state_{GameOverFsm::State::OFF};
GameOverFsm::Transition game_over_transition_{};
// Fade final cap a LOGO. Independent del `outro_fade_` perquè el
// game over passa per damunt de la RoomFsm i pot disparar-se durant
// PLAYING sense haver d'arrencar un OUTRO de sala. Comença a la
// fase 0 de FADE_OUT i es manté pintant alpha=255 durant el
// `black_hold` final (mateix patró que `outro_fade_`).
Transitions::FadeToBlack game_over_fade_;
bool game_over_fade_started_{false};
// Últim valor del countdown reproduït amb so. -1 com a sentinel
// perquè la primera detecció del valor inicial (`continue_count_start`)
// dispari el "tick". Es reseteja a -1 cada vegada que entrem a
// CONTINUE_PROMPT (vegeu `setGameOverState`).
int last_countdown_value_{-1};
// Avança la sub-FSM de game over consumint `delta_ms`. Detecta la
// transició de "tots els jugadors fora de partida" durant PLAYING
// (entrada a CONTINUE_PROMPT), arma el fade de FADE_OUT i, en
// arribar a DONE, demana la transició a `LOGO`.
void updateGameOverFlow(int delta_ms);
// Substitueix `setState` per a la sub-FSM de game over: log
// d'entrada/sortida i reset de la `Transition`. També arma el
// `game_over_fade_` quan entrem a FADE_OUT, en analogia amb
// `outro_fade_`.
void setGameOverState(GameOverFsm::State new_state);
// Pintat del rètol de game over (CONTINUE? + countdown durant
// CONTINUE_PROMPT; "GAME OVER" durant GAME_OVER). Reaprofita el
// panel translúcid i la font del room text.
void renderGameOverText();
// Pintat del fade negre final (FADE_OUT i black hold). Cridat
// després del HUD perquè cobreixi tota la pantalla, inclòs el
// marcador.
void renderGameOverFade();
// Comprova si dins de CONTINUE_PROMPT phase 1 el número visible
// està al rang de skip i, si un jugador pulsa salt o atac, fa
// saltar la fase al següent número via `GameOverFsm::skipToNextCountdown`.
// Extret a mètode separat per acotar la complexitat cognitiva
// d'`updateGameOverFlow`.
void handleCountdownSkip();
// Detecta canvis de número del countdown i dispara el so
// configurat un cop per cada nou valor. També extret per
// mantenir `updateGameOverFlow` per sota del llindar de tidy.
void emitCountdownTickIfChanged();
// Helpers d'animació del rètol de transició. Reutilitzats pel
// rètol de sala (`renderRoomText`) i pel de game over
// (`renderGameOverText`). Reben `phase` i `elapsed_ms` per
// paràmetre perquè la mateixa lògica visual serveix les dues
// sub-FSMs sense saber a quina pertanyen — només es lliguen al
// `transition_config_` per a corbes i geometria del panel.
[[nodiscard]] auto computeRoomTextX(int width, int phase, int elapsed_ms, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int;
[[nodiscard]] auto computeRoomTextPanelHeight(int phase, int elapsed_ms, int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int;
void drawRoomTextPanel(int center_y, int panel_height) const;
void drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const;
// Posició inicial dels jugadors derivada de `Map::door_col`. Tots
// els jugadors comparteixen Y (centre del corredor exterior superior);
// X depèn del nombre actiu: amb un jugador queda centrat sota la
// porta; amb dos, separats ±tile_size respecte al centre. Estructura
// exposada perquè els tests puguen verificar-la sense entrar al ctor
// sencer.
struct PlayerSpawn {
float p1_x{0.0F};
float p2_x{0.0F};
float y{0.0F};
};
[[nodiscard]] static auto computeSpawn() -> PlayerSpawn;
// Recarrega la sala apuntada per `yaml_path`: refresca Map (geometria,
// porta tancada), entitats (sarcs/porta/momies des de zero), neteja
// el PaintLayer i deixa els jugadors a DISABLED esperant la cinemàtica
// d'spawn aeri del nou cicle d'INTRO. També actualitza
// `config_.map_yaml` perquè futurs consumidors (l'Editor en guardar,
// logs, etc.) sàpiguen quina sala està viva.
void loadRoom(const std::string& yaml_path);
// Demana al LevelManager el següent nivell i el carrega via
// `loadRoom`, deixant la FSM al començament d'un nou cicle d'INTRO.
// Si ja era a l'última entrada, fa loop a la primera (política
// temporal mentre no hi haja pantalla de fi de joc). Cridat des de
// `updateRoomTransition` quan l'OUTRO arriba a la fase final.
void advanceToNextRoom();
// Inicia (o reinicia) el cicle d'entrada de sala: posa l'estat a
// ROOM_INTRO, reseteja `transition_` i `intro_completed_`, comença
// la pista `room_intro.ogg` i resol el `intro_music_tail_ms` perquè
// el timeline acabi just al final del .ogg. Sense callback de fi de
// música — la transició a PLAYING la dispara `updateRoomTransition`
// quan la FSM consumeix l'última fase. Si la música no arrenca, la
// cua queda a 0 i l'INTRO acaba al final del text. Idempotent quan
// ja s'estava a INTRO — útil per als salts debug.
void enterRoomIntro();
#ifdef _DEBUG
// Spawn instantani sense cinemàtica: usat al sortir del editor per
// fer un "fresh start" de la sala (jugadors al spawn, sense la
// cinemàtica `room_intro.ogg`). Posa l'estat directament a PLAYING.
void respawnPlayersInstant();
#endif
#ifdef _DEBUG
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i
// viceversa. Quan `editor_.active()` és true, saltem entitats i
// jugador (update i render) i deixem el tilemap visible perquè es
// pugui editar.
Editor editor_;
#endif
};