refactor(room-fsm): timeline pur amb music_tail; fades INTRO/OUTRO via Transitions::FadeToBlack i sense callbacks de fi de música

This commit is contained in:
JailDesigner committed 2026-05-06 19:25:19 +02:00
1 parent 3f09858203
commit 29cb4c2211
4 files changed
+137 -97

No files matched your search

+80 -81
View File
@@ -585,32 +585,36 @@ void GameScene::enterRoomIntro() {
intro_completed_ = false; intro_completed_ = false;
intro_spawn_elapsed_ms_ = 0; intro_spawn_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false); players_spawned_in_intro_.fill(false);
outro_music_ended_ = false;
// INTRO arrenca amb la pista `room_intro.ogg` (loop=0, una sola // Reset visual: arrenquem el FADE_IN d'INTRO i deixem el d'OUTRO
// passada). El callback de fi de música és l'únic responsable de // inactiu (es re-armarà a `setState(ROOM_OUTRO)` i, dins d'aquell
// disparar el spawn dels jugadors i el salt a PLAYING — la durada // estat, a l'entrada de la fase de fade_out via `outro_fade_started_`).
// real ve marcada per la pista, no per cap timer numèric. 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(); auto* audio = Audio::get();
if (audio == nullptr) { if (audio == nullptr) {
// Sense Audio inicialitzat, no podem esperar cap callback. std::cout << "[ROOM] INTRO: Audio absent — sense música, timeline només contra text\n";
// Completem síncronament perquè la sala no quede bloquejada.
std::cout << "[ROOM] INTRO: Audio absent — completant síncronament\n";
completeIntro();
return; return;
} }
audio->stopMusic(); audio->stopMusic();
audio->setOnMusicEnded([this]() { completeIntro(); }); audio->setOnMusicEnded({}); // defensiu: en el nou model no usem callbacks de fi
audio->playMusic("room_intro.ogg", 0); audio->playMusic("room_intro.ogg", 0);
if (Audio::getMusicState() != Audio::MusicState::PLAYING) { if (Audio::getMusicState() == Audio::MusicState::PLAYING) {
// Fallback: la pista no s'ha pogut arrencar (resource absent, const int DUR_MS = Audio::getMusicDurationMs("room_intro.ogg");
// música deshabilitada, etc.). Netegem el callback per no transition_config_.intro_music_tail_ms = std::max(0, DUR_MS - TEXT_TOTAL_MS);
// deixar-lo penjat i fem el spawn + transició com si el track std::cout << "[ROOM] INTRO: música " << DUR_MS << " ms, music_tail "
// hagués acabat ja. Així cap fallada d'asset deixa la sala << transition_config_.intro_music_tail_ms << " ms\n";
// congelada al fons negre de l'INTRO. } else {
std::cout << "[ROOM] INTRO: música no arrencada — completant síncronament\n"; std::cout << "[ROOM] INTRO: música no arrencada — timeline només contra text\n";
audio->setOnMusicEnded({});
completeIntro();
} }
} }
@@ -718,34 +722,38 @@ void GameScene::setState(RoomFsm::State new_state) {
intro_completed_ = false; intro_completed_ = false;
intro_spawn_elapsed_ms_ = 0; intro_spawn_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false); players_spawned_in_intro_.fill(false);
outro_music_ended_ = false;
} }
auto* audio = Audio::get(); auto* audio = Audio::get();
// PLAYING: arrenca la pista de joc en loop infinit. Netegem el // PLAYING: arrenca la pista de joc en loop infinit. Netegem el
// callback de fi (residual d'INTRO, ja consumit) perquè un futur // callback de fi residual perquè un futur stopMusic no dispari
// stopMusic no dispari lògica obsoleta. // lògica obsoleta (defensiu — ja no en registrem cap al nou model).
if (new_state == RoomFsm::State::PLAYING && audio != nullptr) { if (new_state == RoomFsm::State::PLAYING && audio != nullptr) {
audio->setOnMusicEnded({}); audio->setOnMusicEnded({});
audio->playMusic(config_.music_track, -1); audio->playMusic(config_.music_track, -1);
} }
// ROOM_OUTRO: corte sec de `playing.ogg`, arrenca `room_outro.ogg` // ROOM_OUTRO: corte sec de `playing.ogg`, arrenca `room_outro.ogg`
// (loop=0). El callback de fi marca `outro_music_ended_ = true`, // (loop=0) i resol el `outro_music_tail_ms` perquè la fase de
// que junt amb el final del text_exit gata el fade visual a negre // fade_out caigui just al final del .ogg. Sense callback de fi —
// dins `updateRoomTransition`. Si la música no arrenca, marquem el // la FSM avança per timeline pur. Si la música no arrenca, la cua
// flag immediatament perquè el fade quedi gata només contra el text. // és 0 i el fade segueix immediatament al text_exit.
if (new_state == RoomFsm::State::ROOM_OUTRO) { 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) { if (audio == nullptr) {
std::cout << "[ROOM] OUTRO: Audio absent — fade gata només contra text\n"; std::cout << "[ROOM] OUTRO: Audio absent — sense música, timeline només contra text\n";
outro_music_ended_ = true;
} else { } else {
audio->stopMusic(); audio->stopMusic();
audio->setOnMusicEnded([this]() { outro_music_ended_ = true; });
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({}); audio->setOnMusicEnded({});
outro_music_ended_ = true; audio->playMusic("room_outro.ogg", 0);
if (Audio::getMusicState() == Audio::MusicState::PLAYING) {
const int DUR_MS = Audio::getMusicDurationMs("room_outro.ogg");
transition_config_.outro_music_tail_ms = std::max(0, DUR_MS - TEXT_TOTAL_MS);
std::cout << "[ROOM] OUTRO: música " << DUR_MS << " ms, music_tail "
<< transition_config_.outro_music_tail_ms << " ms\n";
} else {
std::cout << "[ROOM] OUTRO: música no arrencada — timeline només contra text\n";
} }
} }
} }
@@ -758,11 +766,16 @@ void GameScene::setState(RoomFsm::State new_state) {
} }
void GameScene::updateRoomTransition(int delta_ms) { void GameScene::updateRoomTransition(int delta_ms) {
// Tick del timer d'spawn aeri escalonat. Comença al callback de fi // Tick dels fades visuals. Tots dos viuen sempre actius però són
// de música d'INTRO (`completeIntro` posa intro_completed_=true) i // no-op si no s'han començat (FadeToBlack::tick sobre un fade no
// segueix corrent cap a PLAYING fins que tots els slots actius hagin // començat és no-op gràcies al `duration_ms_ <= 0`).
// caigut. Cap cost extra un cop tots han caigut (`spawnDuePlayers` intro_fade_.tick(delta_ms);
// és no-op llavors). 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_) { if (intro_completed_) {
intro_spawn_elapsed_ms_ += delta_ms; intro_spawn_elapsed_ms_ += delta_ms;
spawnDuePlayers(); spawnDuePlayers();
@@ -770,24 +783,24 @@ void GameScene::updateRoomTransition(int delta_ms) {
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms); const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
// OUTRO: gate del fade visual sobre la doble condició (música // OUTRO: arrenquem `outro_fade_` la primera vegada que entrem a la
// acabada AND text fora). Si la FSM ha avançat a phase 3 (fade_out) // fase de fade_out (phase 4 del timeline d'OUTRO). Si l'`advance`
// pel timer del text però el callback de fi de música encara no // ha consumit fases buides en aquest mateix frame i ja portem
// ha disparat, congelem el comptador a 0 perquè el fade no comence // `transition_.elapsed_ms` dins de la fase 4, sincronitzem el fade
// a pintar el negre. Quan el callback alliberi `outro_music_ended_`, // a aquesta posició perquè el render no comenci des de zero amb
// les pròximes crides a `advance` tornaran a acumular delta_ms i // delay artificial.
// el fade arrencarà des de zero, polit. if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 4 && !outro_fade_started_) {
if (state_ == RoomFsm::State::ROOM_OUTRO && outro_fade_.begin(transition_config_.outro_fade_out_ms, Transitions::FadeToBlack::Direction::FADE_OUT);
transition_.phase == 3 && if (transition_.elapsed_ms > 0) { outro_fade_.tick(transition_.elapsed_ms); }
!outro_music_ended_) { outro_fade_started_ = true;
transition_.elapsed_ms = 0;
} }
if (NEXT != state_) { if (NEXT != state_) {
// INTRO → PLAYING per timer: ignorat. El callback de fi de // INTRO → PLAYING per timer: l'única ruta canònica al nou model.
// música d'INTRO és l'únic responsable d'aquesta transició. // `completeIntro` agrupa l'spawn aeri inicial i el `setState`.
if (state_ == RoomFsm::State::ROOM_INTRO && if (state_ == RoomFsm::State::ROOM_INTRO &&
NEXT == RoomFsm::State::PLAYING) { NEXT == RoomFsm::State::PLAYING) {
completeIntro();
return; return;
} }
// El final de l'OUTRO no es resol amb un setState normal: cal // 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() { void GameScene::renderRoomFade() {
// Fade negre sobre el món, pintat un cop els actors ja estan al // Delegació als FadeToBlack: les durades, la direcció i el progrés
// canvas i abans del HUD. Tres casos: // viuen dins de cada instància. Comportament:
// ROOM_INTRO/fase 0: 255 → 0 (entrada de sala, món emergent) // ROOM_INTRO: `intro_fade_` (FADE_IN) pinta alpha 255→0 durant la
// ROOM_OUTRO/fase 3: 0 → 255 (sortida de sala, món s'apaga) // primera fase i, un cop finished, `render()` pinta
// ROOM_OUTRO/fase 4: alpha=255 fix (pausa en negre opac abans del // alpha=0 (no-op) fins al canvi d'estat.
// rellançament de la pròxima sala) // ROOM_OUTRO: `outro_fade_` només té `duration_ms_>0` quan la FSM
if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 4) { // ha entrat a la fase de fade_out (`outro_fade_started_`).
Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, 255); // Mentre està actiu pinta 0→255; un cop finished
return; // 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<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() { void GameScene::renderRoomText() {
+27 -12
View File
@@ -6,6 +6,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/rendering/transitions/fade_to_black.hpp"
#include "core/scene/scene.hpp" #include "core/scene/scene.hpp"
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "game/debug_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_). // caigut tots o si encara no estem dins la finestra (intro_completed_).
void spawnDuePlayers(); void spawnDuePlayers();
// Hi ha un únic slot global de callback de fi de música a `Ja::Engine`. // Sentinel que marca el moment "música d'INTRO acabada" dins del
// Cada estat (INTRO, OUTRO) ha de registrar el seu en entrar i pot // timeline. Ja no ve d'un callback d'àudio: el setja el GameScene
// sobreescriure'l amb el del següent estat. Aquests flags marquen // quan la FSM ha consumit l'última fase d'INTRO (la `music_tail`,
// si el callback corresponent ja ha disparat: són la frontera entre // calculada perquè coincideixi amb el final del .ogg). Habilita el
// el thread d'audio (que invoca el callback dins `Engine::update`) // tick de `intro_spawn_elapsed_ms_` i el spawn aeri escalonat dels
// i la lògica de transició (`updateRoomTransition`). // slots actius.
bool intro_completed_{false}; bool intro_completed_{false};
bool outro_music_ended_{false};
// Mil·lisegons acumulats des del callback de fi de música d'INTRO, // Mil·lisegons acumulats des del callback de fi de música d'INTRO,
// per gatejar l'spawn aeri escalonat dels slots actius. P1 fa // per gatejar l'spawn aeri escalonat dels slots actius. P1 fa
@@ -236,6 +236,19 @@ class GameScene : public Scene {
// reseteja a {0, 0}. // reseteja a {0, 0}.
RoomFsm::Transition transition_{}; 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. // 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 — // Cridats des de `render()` entre el bloc d'actors i el HUD —
// així marcador, notificacions i service menu queden per damunt. // així marcador, notificacions i service menu queden per damunt.
@@ -283,11 +296,13 @@ class GameScene : public Scene {
void advanceToNextRoom(); void advanceToNextRoom();
// Inicia (o reinicia) el cicle d'entrada de sala: posa l'estat a // Inicia (o reinicia) el cicle d'entrada de sala: posa l'estat a
// ROOM_INTRO, reseteja `transition_` i `intro_completed_`, i comença // ROOM_INTRO, reseteja `transition_` i `intro_completed_`, comença
// la pista `room_intro.ogg` amb un callback de fi que dispararà // la pista `room_intro.ogg` i resol el `intro_music_tail_ms` perquè
// `completeIntro()`. Si la càrrega de la música falla, dispara // el timeline acabi just al final del .ogg. Sense callback de fi de
// `completeIntro()` síncronament perquè la sala no quedi bloquejada. // música — la transició a PLAYING la dispara `updateRoomTransition`
// Idempotent quan ja s'estava a INTRO — útil per als salts debug. // 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(); void enterRoomIntro();
#ifdef _DEBUG #ifdef _DEBUG
+10 -2
View File
@@ -95,12 +95,16 @@ namespace RoomFsm {
t.elapsed_ms += delta_ms; t.elapsed_ms += delta_ms;
if (current == State::ROOM_INTRO) { if (current == State::ROOM_INTRO) {
const std::array<int, 5> DURS = { const std::array<int, 6> DURS = {
cfg.intro_fade_in_ms, cfg.intro_fade_in_ms,
cfg.intro_text_delay_ms, cfg.intro_text_delay_ms,
cfg.intro_text_enter_ms, cfg.intro_text_enter_ms,
cfg.intro_text_hold_ms, cfg.intro_text_hold_ms,
cfg.intro_text_exit_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<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) { while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase]; t.elapsed_ms -= DURS[t.phase];
@@ -111,10 +115,14 @@ namespace RoomFsm {
} }
// ROOM_OUTRO // ROOM_OUTRO
const std::array<int, 5> DURS = { const std::array<int, 6> DURS = {
cfg.outro_text_enter_ms, cfg.outro_text_enter_ms,
cfg.outro_text_hold_ms, cfg.outro_text_hold_ms,
cfg.outro_text_exit_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, cfg.outro_fade_out_ms,
// Pausa final en negre, després del fade-out i abans del // Pausa final en negre, després del fade-out i abans del
// canvi a INTRO. Si és 0 ms, el while-loop la consumeix // canvi a INTRO. Si és 0 ms, el while-loop la consumeix
+20 -2
View File
@@ -19,9 +19,17 @@ namespace RoomFsm {
// Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a // 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: // {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_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold,
// ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=fade_out, 4=black_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. // 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 { struct Transition {
int phase{0}; int phase{0};
int elapsed_ms{0}; int elapsed_ms{0};
@@ -47,6 +55,16 @@ namespace RoomFsm {
// sense recompilar — útil per donar respir abans del nou rètol. // sense recompilar — útil per donar respir abans del nou rètol.
int outro_black_hold_ms{0}; 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 // 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 // (`room_intro.ogg`) en què cau l'spawn aeri del primer slot
// actiu. 0 = cau al moment exacte que la música acaba. El // actiu. 0 = cau al moment exacte que la música acaba. El