From 29cb4c22112cd63b686864b30c40fbdf785a108e Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 6 May 2026 19:25:19 +0200 Subject: [PATCH] =?UTF-8?q?refactor(room-fsm):=20timeline=20pur=20amb=20mu?= =?UTF-8?q?sic=5Ftail;=20fades=20INTRO/OUTRO=20via=20Transitions::FadeToBl?= =?UTF-8?q?ack=20i=20sense=20callbacks=20de=20fi=20de=20m=C3=BAsica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/scenes/game_scene.cpp | 161 +++++++++++++++--------------- source/game/scenes/game_scene.hpp | 39 +++++--- source/game/scenes/room_fsm.cpp | 12 ++- source/game/scenes/room_fsm.hpp | 22 +++- 4 files changed, 137 insertions(+), 97 deletions(-) diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 89861fa..4b0afd5 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -585,32 +585,36 @@ void GameScene::enterRoomIntro() { intro_completed_ = false; intro_spawn_elapsed_ms_ = 0; players_spawned_in_intro_.fill(false); - outro_music_ended_ = false; - // INTRO arrenca amb la pista `room_intro.ogg` (loop=0, una sola - // passada). El callback de fi de música és l'únic responsable de - // disparar el spawn dels jugadors i el salt a PLAYING — la durada - // real ve marcada per la pista, no per cap timer numèric. + // 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) { - // Sense Audio inicialitzat, no podem esperar cap callback. - // Completem síncronament perquè la sala no quede bloquejada. - std::cout << "[ROOM] INTRO: Audio absent — completant síncronament\n"; - completeIntro(); + std::cout << "[ROOM] INTRO: Audio absent — sense música, timeline només contra text\n"; return; } audio->stopMusic(); - audio->setOnMusicEnded([this]() { completeIntro(); }); + audio->setOnMusicEnded({}); // defensiu: en el nou model no usem callbacks de fi audio->playMusic("room_intro.ogg", 0); - if (Audio::getMusicState() != Audio::MusicState::PLAYING) { - // Fallback: la pista no s'ha pogut arrencar (resource absent, - // música deshabilitada, etc.). Netegem el callback per no - // deixar-lo penjat i fem el spawn + transició com si el track - // hagués acabat ja. Així cap fallada d'asset deixa la sala - // congelada al fons negre de l'INTRO. - std::cout << "[ROOM] INTRO: música no arrencada — completant síncronament\n"; - audio->setOnMusicEnded({}); - completeIntro(); + 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"; } } @@ -718,34 +722,38 @@ void GameScene::setState(RoomFsm::State new_state) { intro_completed_ = false; intro_spawn_elapsed_ms_ = 0; players_spawned_in_intro_.fill(false); - outro_music_ended_ = false; } auto* audio = Audio::get(); // PLAYING: arrenca la pista de joc en loop infinit. Netegem el - // callback de fi (residual d'INTRO, ja consumit) perquè un futur - // stopMusic no dispari lògica obsoleta. + // 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). El callback de fi marca `outro_music_ended_ = true`, - // que junt amb el final del text_exit gata el fade visual a negre - // dins `updateRoomTransition`. Si la música no arrenca, marquem el - // flag immediatament perquè el fade quedi gata només contra el text. + // (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_music_ended_ = false; + 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 — fade gata només contra text\n"; - outro_music_ended_ = true; + std::cout << "[ROOM] OUTRO: Audio absent — sense música, timeline només contra text\n"; } else { audio->stopMusic(); - audio->setOnMusicEnded([this]() { outro_music_ended_ = true; }); + audio->setOnMusicEnded({}); audio->playMusic("room_outro.ogg", 0); - if (Audio::getMusicState() != Audio::MusicState::PLAYING) { - std::cout << "[ROOM] OUTRO: música no arrencada — fade gata només contra text\n"; - audio->setOnMusicEnded({}); - outro_music_ended_ = true; + 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"; } } } @@ -758,11 +766,16 @@ void GameScene::setState(RoomFsm::State new_state) { } void GameScene::updateRoomTransition(int delta_ms) { - // Tick del timer d'spawn aeri escalonat. Comença al callback de fi - // de música d'INTRO (`completeIntro` posa intro_completed_=true) i - // segueix corrent cap a PLAYING fins que tots els slots actius hagin - // caigut. Cap cost extra un cop tots han caigut (`spawnDuePlayers` - // és no-op llavors). + // 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(); @@ -770,24 +783,24 @@ void GameScene::updateRoomTransition(int delta_ms) { const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms); - // OUTRO: gate del fade visual sobre la doble condició (música - // acabada AND text fora). Si la FSM ha avançat a phase 3 (fade_out) - // pel timer del text però el callback de fi de música encara no - // ha disparat, congelem el comptador a 0 perquè el fade no comence - // a pintar el negre. Quan el callback alliberi `outro_music_ended_`, - // les pròximes crides a `advance` tornaran a acumular delta_ms i - // el fade arrencarà des de zero, polit. - if (state_ == RoomFsm::State::ROOM_OUTRO && - transition_.phase == 3 && - !outro_music_ended_) { - transition_.elapsed_ms = 0; + // 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: ignorat. El callback de fi de - // música d'INTRO és l'únic responsable d'aquesta transició. + // 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 @@ -804,36 +817,22 @@ void GameScene::updateRoomTransition(int delta_ms) { } 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; + // 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(); } - - 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(transition_.elapsed_ms) / static_cast(dur), 0.0F, 1.0F) - : 1.0F; - const float ALPHA_F = fade_in ? (1.0F - t) : t; - const auto ALPHA = static_cast(255.0F * ALPHA_F); - if (ALPHA == 0) { return; } - Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, ALPHA); } void GameScene::renderRoomText() { diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index f20a493..46aab54 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -6,6 +6,7 @@ #include #include +#include "core/rendering/transitions/fade_to_black.hpp" #include "core/scene/scene.hpp" #include "core/ui/ui_config.hpp" #include "game/debug_config.hpp" @@ -136,14 +137,13 @@ class GameScene : public Scene { // caigut tots o si encara no estem dins la finestra (intro_completed_). void spawnDuePlayers(); - // Hi ha un únic slot global de callback de fi de música a `Ja::Engine`. - // Cada estat (INTRO, OUTRO) ha de registrar el seu en entrar i pot - // sobreescriure'l amb el del següent estat. Aquests flags marquen - // si el callback corresponent ja ha disparat: són la frontera entre - // el thread d'audio (que invoca el callback dins `Engine::update`) - // i la lògica de transició (`updateRoomTransition`). + // 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}; - bool outro_music_ended_{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 @@ -236,6 +236,19 @@ class GameScene : public Scene { // 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. @@ -283,11 +296,13 @@ class GameScene : public Scene { void advanceToNextRoom(); // Inicia (o reinicia) el cicle d'entrada de sala: posa l'estat a - // ROOM_INTRO, reseteja `transition_` i `intro_completed_`, i comença - // la pista `room_intro.ogg` amb un callback de fi que dispararà - // `completeIntro()`. Si la càrrega de la música falla, dispara - // `completeIntro()` síncronament perquè la sala no quedi bloquejada. - // Idempotent quan ja s'estava a INTRO — útil per als salts debug. + // 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 diff --git a/source/game/scenes/room_fsm.cpp b/source/game/scenes/room_fsm.cpp index 0b980f8..a8b6511 100644 --- a/source/game/scenes/room_fsm.cpp +++ b/source/game/scenes/room_fsm.cpp @@ -95,12 +95,16 @@ namespace RoomFsm { t.elapsed_ms += delta_ms; if (current == State::ROOM_INTRO) { - const std::array DURS = { + const std::array DURS = { cfg.intro_fade_in_ms, cfg.intro_text_delay_ms, cfg.intro_text_enter_ms, cfg.intro_text_hold_ms, cfg.intro_text_exit_ms, + // Cua silenciosa fins al final de room_intro.ogg. La + // calcula GameScene després de `playMusic`. Si la música + // no arrenca, és 0 i la fase es consumeix sense esperar. + cfg.intro_music_tail_ms, }; while (t.phase < static_cast(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) { t.elapsed_ms -= DURS[t.phase]; @@ -111,10 +115,14 @@ namespace RoomFsm { } // ROOM_OUTRO - const std::array DURS = { + const std::array DURS = { cfg.outro_text_enter_ms, cfg.outro_text_hold_ms, cfg.outro_text_exit_ms, + // Cua silenciosa fins al final de room_outro.ogg, abans + // d'arrencar el fade-out. Així el negre cau just al final + // de la pista; sense cua si la música no arrenca. + cfg.outro_music_tail_ms, cfg.outro_fade_out_ms, // Pausa final en negre, després del fade-out i abans del // canvi a INTRO. Si és 0 ms, el while-loop la consumeix diff --git a/source/game/scenes/room_fsm.hpp b/source/game/scenes/room_fsm.hpp index f2d5069..7ca8a60 100644 --- a/source/game/scenes/room_fsm.hpp +++ b/source/game/scenes/room_fsm.hpp @@ -19,9 +19,17 @@ namespace RoomFsm { // Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a // {0, 0} quan es canvia d'estat. Significat de `phase` per estat: - // ROOM_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold, 4=text_exit - // ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=fade_out, 4=black_hold + // ROOM_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold, + // 4=text_exit, 5=music_tail + // ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=music_tail, + // 4=fade_out, 5=black_hold // PLAYING: no s'usa. + // Les fases `music_tail` són la cua silenciosa que sincronitza el + // timeline visual amb el final del .ogg de música corresponent: el + // GameScene les calcula a `enterRoomIntro` / `setState(ROOM_OUTRO)` + // a partir de la durada precarregada del .ogg menys el sumatori de + // les fases de text. Si la música no arrenca, la cua és 0 i la fase + // es consumeix instantàniament (la transició no s'embussa mai). struct Transition { int phase{0}; int elapsed_ms{0}; @@ -47,6 +55,16 @@ namespace RoomFsm { // sense recompilar — útil per donar respir abans del nou rètol. int outro_black_hold_ms{0}; + // Cua silenciosa entre el text d'INTRO/OUTRO i la fi del .ogg de + // música corresponent. NO es llig del YAML — el GameScene la + // calcula a `enterRoomIntro` / `setState(ROOM_OUTRO)` com + // `max(0, music_duration - text_total)`. Així el timeline acaba + // exactament quan la música drena i no cal cap callback de fi + // de música. 0 quan no hi ha música o quan el text dura més que + // la pista. + int intro_music_tail_ms{0}; + int outro_music_tail_ms{0}; + // Mil·lisegons des del callback de fi de la música d'INTRO // (`room_intro.ogg`) en què cau l'spawn aeri del primer slot // actiu. 0 = cau al moment exacte que la música acaba. El