merge: refactor escena → flux centralitzat (SceneId/ExitRequest), timeline pur a RoomFsm, FadeToBlack reutilitzat i fade-in inicial a TITLE

This commit is contained in:
JailDesigner committed 2026-05-06 19:36:09 +02:00
commit 23fdf6e1bd
25 files changed
+589 -218

No files matched your search

+1
View File
@@ -94,6 +94,7 @@ set(APP_SOURCES
source/core/rendering/sprite/animated_sprite_parser.cpp
source/core/rendering/effects/heat_haze.cpp
source/core/rendering/transitions/mosaic.cpp
source/core/rendering/transitions/fade_to_black.cpp
source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp
source/core/rendering/sdl3gpu/viewport.cpp
source/core/rendering/sdl3gpu/pipelines/crtpi_pipeline.cpp
+8
View File
@@ -135,6 +135,14 @@ title:
# Fade-out de `title.ogg` en entrar a STARTING. Independent del visual:
# la música pot acabar abans o després del mosaic dissolve.
music_fade_out_ms: 3000
# Fade-to-black per a la sortida d'attract (timeout cap a LOGO). NO és
# mosaic — capa negra lineal damunt del canvas. La música no es toca:
# `title.ogg` continua sonant cap a LOGO, que ja veurà què en fa.
attract_fade_to_black_ms: 800
# Fade-in inicial: capa negra al començament que s'esvaeix cap al fons.
# Mateix valor que `room_intro.fade_in_ms` (data/scenes/game.yaml) perquè
# l'arribada a la TITLE i a una sala de joc tinguin el mateix ritme.
intro_fade_in_ms: 500
# Distorsió sinusoidal del fons (efecte de calor del desert).
# `amplitude_px`: desplaçament màxim de cada fila en píxels.
# `wavelength_px`: distància vertical entre dos pics de l'ona.
+8
View File
@@ -194,6 +194,14 @@ void Audio::setOnMusicEnded(std::function<void()> callback) {
if (engine_) { engine_->setOnMusicEnded(std::move(callback)); }
}
// Resol el nom contra el cache de recursos i retorna la durada pre-calculada
// al `loadMusic`. 0 si la pista no existeix — així el caller pot decidir
// fallback (p. ex. usar un timeout fix) sense haver de propagar errors.
auto Audio::getMusicDurationMs(const std::string& name) -> int {
auto* music = AudioResource::getMusic(name);
return (music != nullptr) ? music->duration_ms : 0;
}
// Consulta directament l'estat a Ja i el projecta al subconjunt d'estats que
// exposa Audio (INVALID/DISABLED de Ja col·lapsen a STOPPED — la capa d'usuari
// només vol saber si està sonant, pausat o parat).
+5
View File
@@ -121,6 +121,11 @@ class Audio {
[[nodiscard]] auto isMusicEnabled() const -> bool { return music_enabled_; }
[[nodiscard]] static auto getMusicState() -> MusicState; // Estat real consultat a Ja::
[[nodiscard]] auto getCurrentMusicName() const -> const std::string& { return music_.name; }
// Durada de la pista resolta per nom (mil·lisegons). 0 si la pista no
// existeix al cache de recursos o si el seu header OGG no permet
// calcular-la. Pensat per a clients que necessiten un timeline
// determinista (p. ex. RoomFsm) sense dependre de callbacks de fi.
[[nodiscard]] static auto getMusicDurationMs(const std::string& name) -> int;
private:
// --- Tipus anidats ---
+10
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <memory>
#include <optional>
@@ -564,6 +565,15 @@ namespace Ja {
music->spec.channels = static_cast<int>(INFO.channels);
music->spec.freq = static_cast<int>(INFO.sample_rate);
music->spec.format = SDL_AUDIO_S16;
// Pre-càlcul de la durada en ms a partir del header. stb_vorbis ja
// ha decodificat la informació necessària a `stb_vorbis_open_memory`;
// aquesta consulta no descodifica àudio, només llig el comptador
// de samples. Si el sample_rate fos 0 (header malmès) deixem
// duration_ms a 0.
if (INFO.sample_rate > 0) {
const auto SAMPLES = stb_vorbis_stream_length_in_samples(music->vorbis);
music->duration_ms = static_cast<int>((static_cast<std::uint64_t>(SAMPLES) * 1000ULL) / INFO.sample_rate);
}
music->state = MusicState::STOPPED;
return music.release();
+6
View File
@@ -112,6 +112,12 @@ namespace Ja {
std::string filename;
int times{0}; // loops restants (-1 = infinit, 0 = un sol play)
// Durada total de la pista en mil·lisegons, mesurada via
// `stb_vorbis_stream_length_in_samples / sample_rate` al
// `loadMusic`. 0 si el càlcul no és possible (header malmès).
// L'usen consumidors que necessiten un timeline pre-calculat —
// p. ex. la FSM de sala — sense dependre de callbacks de fi.
int duration_ms{0};
SDL_AudioStream* stream{nullptr};
MusicState state{MusicState::INVALID};
};
@@ -0,0 +1,44 @@
#include "core/rendering/transitions/fade_to_black.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include "core/rendering/screen.hpp"
namespace Transitions {
void FadeToBlack::begin(int duration_ms, Direction direction) {
duration_ms_ = duration_ms;
elapsed_ms_ = 0;
direction_ = direction;
}
void FadeToBlack::tick(int delta_ms) {
if (duration_ms_ <= 0) { return; }
elapsed_ms_ = std::min(elapsed_ms_ + delta_ms, duration_ms_);
}
auto FadeToBlack::progress() const -> float {
if (duration_ms_ <= 0) { return 0.0F; }
return std::clamp(static_cast<float>(elapsed_ms_) / static_cast<float>(duration_ms_), 0.0F, 1.0F);
}
void FadeToBlack::render() const {
if (duration_ms_ <= 0) { return; }
const float P = progress();
// Alpha lineal — sense corba quadràtica (això és prerogativa del
// dissolve mosaic, on volem que la mascara fosca arribe tard
// perquè es vegen els blocs). Per al fade net, lineal 0..255.
// Direcció FADE_IN inverteix la rampa: el negre comença opac i
// s'esvaeix cap a transparent, deixant veure l'escena pintada
// darrere abans de `render()`.
const float ALPHA_F = (direction_ == Direction::FADE_IN) ? (1.0F - P) : P;
const auto ALPHA = static_cast<std::uint8_t>(std::round(255.0F * ALPHA_F));
if (ALPHA == 0) { return; }
auto& screen = Screen::get();
screen.blendRect(0, 0, screen.getCanvasWidth(), screen.getCanvasHeight(), 0x00000000U, ALPHA);
}
} // namespace Transitions
@@ -0,0 +1,51 @@
#pragma once
#include <cstdint>
namespace Transitions {
// Fade lineal contra negre. A diferència de `Mosaic`, no rebina cap
// píxel; només posa una capa negra al damunt del canvas amb alpha
// que evoluciona linealment al llarg de la durada. És la transició
// "neta" per defecte; ideal per a entrades/sortides on no convé
// l'estètica chunky de l'arcade dissolve.
//
// La direcció controla el sentit de l'alpha:
// FADE_OUT: alpha 0 → 255 (món visible → negre opac)
// FADE_IN: alpha 255 → 0 (negre opac → món visible)
// En tots dos casos `progress()` segueix sent monotònic 0 → 1
// (mateixa semàntica per al caller); només el render inverteix.
//
// Mateixa interfície que `Mosaic` (begin/tick/render/isActive/
// isFinished/progress) perquè els llocs de crida puguen escollir-ne
// una o l'altra sense canviar el patró d'ús.
class FadeToBlack {
public:
enum class Direction : std::uint8_t { FADE_OUT,
FADE_IN };
FadeToBlack() = default;
// Arrenca un fade nou. Si ja n'hi havia un actiu, el reemplaça.
// `duration_ms <= 0` deixa l'efecte inactiu.
void begin(int duration_ms, Direction direction = Direction::FADE_OUT);
void tick(int delta_ms);
[[nodiscard]] auto isActive() const -> bool { return duration_ms_ > 0 && elapsed_ms_ < duration_ms_; }
[[nodiscard]] auto isFinished() const -> bool { return duration_ms_ > 0 && elapsed_ms_ >= duration_ms_; }
[[nodiscard]] auto progress() const -> float;
[[nodiscard]] auto direction() const -> Direction { return direction_; }
// Pinta la capa negra amb alpha derivat del `progress()` i la
// direcció. Cridar després del render normal de l'escena,
// abans de `Screen::present()`.
void render() const;
private:
int duration_ms_{0};
int elapsed_ms_{0};
Direction direction_{Direction::FADE_OUT};
};
} // namespace Transitions
+9 -3
View File
@@ -1,7 +1,13 @@
#pragma once
#include <memory>
#include <any>
#include <utility>
// Base de totes les escenes. La transició entre escenes va per
// `exit_request_`: l'escena hi posa un `ExitRequest` (definit a
// `game/scenes/scene_flow.hpp`) amagat dins d'un `std::any` perquè
// `core/scene/scene.hpp` no ha de saber dels tipus del joc. `Director`
// fa el `std::any_cast` al canvi de frame i construeix la nova escena.
class Scene {
public:
virtual ~Scene() = default;
@@ -10,9 +16,9 @@ class Scene {
virtual void render() = 0;
[[nodiscard]] auto wantsQuit() const -> bool { return wants_quit_; }
[[nodiscard]] auto nextScene() -> std::unique_ptr<Scene> { return std::move(next_scene_); }
[[nodiscard]] auto takeExitRequest() -> std::any { return std::exchange(exit_request_, {}); }
protected:
bool wants_quit_{false};
std::unique_ptr<Scene> next_scene_;
std::any exit_request_;
};
+47 -25
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <any>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
@@ -10,6 +11,7 @@
#include <memory>
#include <string>
#include <utility>
#include <variant>
#include "core/audio/audio.hpp"
#include "core/audio/sound_effects_config.hpp"
@@ -196,6 +198,7 @@ Director::Director() {
auto placeholder_font_surface = Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY);
auto placeholder_text = std::make_shared<Text>(placeholder_font_surface, UiConfig::get().placeholderFont().fnt);
placeholder_text->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
placeholder_text_ = placeholder_text;
// Font del menú de servei (F9). El títol va amb la variant `display`
// i els items amb `condensed-display` (més compacte perquè caben més
@@ -210,6 +213,7 @@ Director::Director() {
};
auto menu_title_text = load_menu_font(UiConfig::get().menuTitleFont());
auto menu_items_text = load_menu_font(UiConfig::get().menuItemsFont());
menu_title_text_ = menu_title_text;
// Mini-logo del peu de la TITLE. Es preloda ací per consistència
// amb la resta d'assets d'UI; manté la seua paleta original (no la
@@ -218,12 +222,14 @@ Director::Director() {
constexpr const char* MINI_LOGO_KEY = "logo_jailgames_mini.gif";
Resource::Cache::get().loadSurface(MINI_LOGO_KEY, "data/logo/logo_jailgames_mini.gif");
auto mini_logo_surface = Resource::Cache::get().getSurface(MINI_LOGO_KEY);
mini_logo_surface_ = mini_logo_surface;
// Fons sencer de la TITLE (320×240). Igual que el minilogo, conserva
// la seua paleta nadiua perquè el GIF ja porta els colors finals.
constexpr const char* TITLE_BG_KEY = "title_bg.gif";
Resource::Cache::get().loadSurface(TITLE_BG_KEY, "data/title/title_bg.gif");
auto title_bg_surface = Resource::Cache::get().getSurface(TITLE_BG_KEY);
title_bg_surface_ = title_bg_surface;
ServiceMenu::get().init(menu_title_text, menu_items_text, ui_palette);
// Overlay hook: Screen::present() cridarà aquesta lambda al principi,
@@ -258,35 +264,18 @@ Director::Director() {
std::cout << "Director: start_scene=" << DebugConfig::toString(START)
<< " starting_players=" << DebugConfig::toString(STARTING_PLAYERS) << '\n';
// `make_game` i `make_intro` reben `StartingPlayers` com a paràmetre
// perquè la TitleScene tria al moment quins jugadors entren (P1, P2,
// BOTH) segons la pulsació. La IntroScene ho reenvia a la GameScene.
// El valor de `STARTING_PLAYERS` només s'usa com a punt d'entrada en
// mode Debug quan saltem directament a INTRO o GAME (saltant TITLE).
auto make_game = [](DebugConfig::StartingPlayers players) -> std::unique_ptr<Scene> {
auto cfg = GameScene::defaultConfig();
cfg.starting_players = players;
return std::make_unique<GameScene>(std::move(cfg));
};
auto make_intro = [placeholder_text, make_game](DebugConfig::StartingPlayers players) -> std::unique_ptr<Scene> {
return std::make_unique<IntroScene>(placeholder_text, players, make_game);
};
auto make_title = [menu_title_text, placeholder_text, title_bg_surface, mini_logo_surface, make_intro]() -> std::unique_ptr<Scene> {
return std::make_unique<TitleScene>(menu_title_text, placeholder_text, title_bg_surface, mini_logo_surface, make_intro);
};
switch (START) {
case DebugConfig::StartScene::LOGO:
current_scene_ = std::make_unique<LogoScene>(placeholder_text, make_title);
current_scene_ = buildScene(SceneId::LOGO, LogoParams{});
break;
case DebugConfig::StartScene::TITLE:
current_scene_ = make_title();
current_scene_ = buildScene(SceneId::TITLE, TitleParams{});
break;
case DebugConfig::StartScene::INTRO:
current_scene_ = make_intro(STARTING_PLAYERS);
current_scene_ = buildScene(SceneId::INTRO, IntroParams{.starting_players = STARTING_PLAYERS});
break;
case DebugConfig::StartScene::GAME:
current_scene_ = make_game(STARTING_PLAYERS);
current_scene_ = buildScene(SceneId::GAME, GameParams{.starting_players = STARTING_PLAYERS});
break;
}
last_ticks_ms_ = SDL_GetTicks();
@@ -404,13 +393,46 @@ void Director::processMenuInput() {
void Director::switchSceneIfRequested() {
if (!current_scene_) { return; }
if (auto next = current_scene_->nextScene()) {
// TODO: migrar a logger unificat (INC-03 a docs/audit-core-rendering.md).
std::cout << "Director: scene transition\n";
current_scene_ = std::move(next);
// L'escena ha publicat un `ExitRequest` dins de `exit_request_`.
// Director el desempaqueta i construeix la nova escena via
// `buildScene`. Si la `std::any_cast` falla (tipus inesperat), el
// log avisa i no canviem d'escena.
auto request = current_scene_->takeExitRequest();
if (!request.has_value()) { return; }
try {
const auto& req = std::any_cast<const ExitRequest&>(request);
std::cout << "Director: scene transition → " << toString(req.next) << '\n';
current_scene_ = buildScene(req.next, req.params);
} catch (const std::bad_any_cast&) {
std::cerr << "Director: exit_request_ amb tipus inesperat — ignorant\n";
}
}
auto Director::buildScene(SceneId id, const SceneParams& params) -> std::unique_ptr<Scene> {
// Durant la migració, les escenes que encara fan servir factories
// legacy reben lambdes primes que tornen a delegar a `buildScene`.
// Així el motor d'escena ja és Director-driven encara que els
// ctors antics no l'utilitzen directament. Quan totes estiguen
// migrades a `ExitRequest`, aquestes lambdes desapareixeran.
switch (id) {
case SceneId::LOGO:
return std::make_unique<LogoScene>(placeholder_text_);
case SceneId::TITLE:
return std::make_unique<TitleScene>(menu_title_text_, placeholder_text_, title_bg_surface_, mini_logo_surface_);
case SceneId::INTRO: {
const auto& p = std::get<IntroParams>(params);
return std::make_unique<IntroScene>(placeholder_text_, p.starting_players);
}
case SceneId::GAME: {
const auto& p = std::get<GameParams>(params);
auto cfg = GameScene::defaultConfig();
cfg.starting_players = p.starting_players;
return std::make_unique<GameScene>(std::move(cfg));
}
}
return nullptr;
}
auto Director::iterate() -> SDL_AppResult {
// Ocultació automàtica del cursor: observem els events abans que els
// altres steps els consumeixin; Mouse no els modifica, només reacciona
+20
View File
@@ -6,7 +6,11 @@
#include <memory>
#include <string>
#include "game/scenes/scene_flow.hpp"
class Scene;
class Surface;
class Text;
class Director {
public:
@@ -20,9 +24,25 @@ class Director {
std::unique_ptr<Scene> current_scene_;
std::uint64_t last_ticks_ms_{0};
// Recursos compartits per la fabricació d'escenes. S'instancien una
// vegada al ctor (parsejant fnt's, retenint shared_ptrs del
// Resource::Cache) i `buildScene` els passa pel valor a cada nova
// escena. No són `static`/Meyers perquè depenen de l'inici d'SDL i
// de Resource::Cache.
std::shared_ptr<Text> placeholder_text_;
std::shared_ptr<Text> menu_title_text_;
std::shared_ptr<Surface> title_bg_surface_;
std::shared_ptr<Surface> mini_logo_surface_;
void createSystemFolder();
void processEvents();
static void processGamepadEvents();
static void processMenuInput();
void switchSceneIfRequested();
// Fabrica una escena nova a partir d'un identificador i els seus
// paràmetres. És l'únic punt del codi que coneix com construir cada
// tipus concret d'escena, així les escenes només declaren `ExitRequest`
// i no s'enllacen entre si.
[[nodiscard]] auto buildScene(SceneId id, const SceneParams& params) -> std::unique_ptr<Scene>;
};
+4
View File
@@ -107,6 +107,8 @@ UiConfig::UiConfig()
title_fade_out_ms_(Defaults::UI::Title::FADE_OUT_MS),
title_fade_out_max_block_(Defaults::UI::Title::FADE_OUT_MAX_BLOCK),
title_music_fade_out_ms_(Defaults::UI::Title::MUSIC_FADE_OUT_MS),
title_attract_fade_to_black_ms_(Defaults::UI::Title::ATTRACT_FADE_TO_BLACK_MS),
title_intro_fade_in_ms_(Defaults::UI::Title::INTRO_FADE_IN_MS),
title_heat_haze_{
.enabled = Defaults::UI::Title::HeatHaze::ENABLED,
.amplitude_px = Defaults::UI::Title::HeatHaze::AMPLITUDE_PX,
@@ -236,6 +238,8 @@ void UiConfig::load(const std::string& file_path) {
readField(t, "fade_out_ms", title_fade_out_ms_);
readField(t, "fade_out_max_block", title_fade_out_max_block_);
readField(t, "music_fade_out_ms", title_music_fade_out_ms_);
readField(t, "attract_fade_to_black_ms", title_attract_fade_to_black_ms_);
readField(t, "intro_fade_in_ms", title_intro_fade_in_ms_);
if (t.contains("heat_haze")) {
const auto& hh = t["heat_haze"];
readField(hh, "enabled", title_heat_haze_.enabled);
+4
View File
@@ -100,6 +100,8 @@ class UiConfig {
[[nodiscard]] auto titleFadeOutMs() const -> int { return title_fade_out_ms_; }
[[nodiscard]] auto titleFadeOutMaxBlock() const -> int { return title_fade_out_max_block_; }
[[nodiscard]] auto titleMusicFadeOutMs() const -> int { return title_music_fade_out_ms_; }
[[nodiscard]] auto titleAttractFadeToBlackMs() const -> int { return title_attract_fade_to_black_ms_; }
[[nodiscard]] auto titleIntroFadeInMs() const -> int { return title_intro_fade_in_ms_; }
[[nodiscard]] auto titleHeatHaze() const -> const TitleHeatHaze& { return title_heat_haze_; }
// Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa
@@ -178,6 +180,8 @@ class UiConfig {
int title_fade_out_ms_;
int title_fade_out_max_block_;
int title_music_fade_out_ms_;
int title_attract_fade_to_black_ms_;
int title_intro_fade_in_ms_;
TitleHeatHaze title_heat_haze_;
std::string menu_select_sound_;
+80 -81
View File
@@ -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->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;
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) {
// 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<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() {
+27 -12
View File
@@ -6,6 +6,7 @@
#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"
@@ -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
+8 -5
View File
@@ -17,6 +17,7 @@
#include "core/ui/ui_config.hpp"
#include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp"
#include "game/scenes/scene_flow.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
@@ -30,10 +31,9 @@ namespace {
} // namespace
IntroScene::IntroScene(std::shared_ptr<Text> font, DebugConfig::StartingPlayers starting_players, SceneFactory next_factory)
IntroScene::IntroScene(std::shared_ptr<Text> font, DebugConfig::StartingPlayers starting_players)
: font_(std::move(font)),
starting_players_(starting_players),
next_factory_(std::move(next_factory)) {
starting_players_(starting_players) {
std::cout << "[scene] " << SCENE_NAME << " start (players=" << DebugConfig::toString(starting_players_) << ")\n";
}
@@ -52,8 +52,11 @@ void IntroScene::update(int delta_ms) {
return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode);
});
if ((timer_ms_ <= 0 || SKIPPED) && next_factory_) {
next_scene_ = next_factory_(starting_players_);
if (timer_ms_ <= 0 || SKIPPED) {
exit_request_ = ExitRequest{
.next = SceneId::GAME,
.params = GameParams{.starting_players = starting_players_},
};
}
Notifier::get().update(delta_ms);
+4 -8
View File
@@ -1,6 +1,5 @@
#pragma once
#include <functional>
#include <memory>
#include "core/scene/scene.hpp"
@@ -9,19 +8,17 @@
class Text;
// Escena placeholder de la introducció. Com la resta de placeholders, durada
// fixa i skip amb tecla no reservada; quan expira encadena a la factory
// injectada (en producció: GameScene).
// fixa i skip amb tecla no reservada; quan expira publica un `ExitRequest`
// cap a GAME amb el `starting_players` rebut de TitleScene.
//
// `starting_players` és el resultat triat a la TitleScene (P1, P2 o BOTH).
// L'IntroScene només el guarda i el reenvia a la seva factory; quan demà
// L'IntroScene només el guarda i el reenvia per `ExitRequest`; quan demà
// l'intro tinga contingut real podrà variar segons qui jugue.
class IntroScene : public Scene {
public:
using SceneFactory = std::function<std::unique_ptr<Scene>(DebugConfig::StartingPlayers)>;
static constexpr int DURATION_MS = 5000;
IntroScene(std::shared_ptr<Text> font, DebugConfig::StartingPlayers starting_players, SceneFactory next_factory);
IntroScene(std::shared_ptr<Text> font, DebugConfig::StartingPlayers starting_players);
~IntroScene() override;
void update(int delta_ms) override;
@@ -30,6 +27,5 @@ class IntroScene : public Scene {
private:
std::shared_ptr<Text> font_;
DebugConfig::StartingPlayers starting_players_;
SceneFactory next_factory_;
int timer_ms_{DURATION_MS};
};
+5 -5
View File
@@ -17,6 +17,7 @@
#include "core/ui/ui_config.hpp"
#include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp"
#include "game/scenes/scene_flow.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
@@ -31,9 +32,8 @@ namespace {
} // namespace
LogoScene::LogoScene(std::shared_ptr<Text> font, SceneFactory next_factory)
: font_(std::move(font)),
next_factory_(std::move(next_factory)) {
LogoScene::LogoScene(std::shared_ptr<Text> font)
: font_(std::move(font)) {
std::cout << "[scene] " << SCENE_NAME << " start\n";
}
@@ -52,8 +52,8 @@ void LogoScene::update(int delta_ms) {
return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode);
});
if ((timer_ms_ <= 0 || SKIPPED) && next_factory_) {
next_scene_ = next_factory_();
if (timer_ms_ <= 0 || SKIPPED) {
exit_request_ = ExitRequest{.next = SceneId::TITLE, .params = TitleParams{}};
}
Notifier::get().update(delta_ms);
+4 -8
View File
@@ -1,6 +1,5 @@
#pragma once
#include <functional>
#include <memory>
#include "core/scene/scene.hpp"
@@ -8,16 +7,14 @@
class Text;
// Escena placeholder del logotip. Durada fixa (5 s) o skip amb qualsevol tecla
// no reservada. Quan expira, encadena amb la `SceneFactory` que se li injecta
// al ctor — així l'escena no coneix el tipus concret de la següent, i es pot
// exercir en tests sense arrossegar GameScene.
// no reservada. Quan expira, publica un `ExitRequest` cap a TITLE; Director
// és qui construeix la nova escena (l'escena no coneix els altres tipus i
// es pot exercir en tests sense arrossegar dependències).
class LogoScene : public Scene {
public:
using SceneFactory = std::function<std::unique_ptr<Scene>()>;
static constexpr int DURATION_MS = 5000;
LogoScene(std::shared_ptr<Text> font, SceneFactory next_factory);
explicit LogoScene(std::shared_ptr<Text> font);
~LogoScene() override;
void update(int delta_ms) override;
@@ -25,6 +22,5 @@ class LogoScene : public Scene {
private:
std::shared_ptr<Text> font_;
SceneFactory next_factory_;
int timer_ms_{DURATION_MS};
};
+10 -2
View File
@@ -95,12 +95,16 @@ namespace RoomFsm {
t.elapsed_ms += delta_ms;
if (current == State::ROOM_INTRO) {
const std::array<int, 5> DURS = {
const std::array<int, 6> 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<int>(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<int, 5> DURS = {
const std::array<int, 6> 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
+20 -2
View File
@@ -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
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <cstdint>
#include <variant>
#include "game/debug_config.hpp"
// Sistema centralitzat de transicions entre escenes. Cada escena, en lloc
// de construir directament la següent, declara una `ExitRequest` amb un
// identificador i els paràmetres que la nova escena necessite per
// arrencar; `Director::buildScene` és l'únic punt que sap com instanciar
// cada tipus i té tots els recursos a mà.
//
// Afegir una escena nova:
// 1. Una entrada al `enum SceneId`.
// 2. Si necessita paràmetres, un struct `*Params` i afegir-lo al variant.
// 3. Un cas a `Director::buildScene` que el construïsca.
//
// Una escena només pot demanar transicionar a un altre `SceneId` que
// haja estat enregistrat al variant — el compilador ja descarta noms
// erronis.
enum class SceneId : std::uint8_t {
LOGO,
TITLE,
INTRO,
GAME,
};
// Estructures de paràmetres per escena. Buides quan la nova escena no
// rep cap dada de l'anterior; en cas contrari, només els camps que
// fluixen pel canal de transició (els recursos, defaults i config
// interna els resol Director quan construeix).
struct LogoParams {};
struct TitleParams {};
struct IntroParams {
DebugConfig::StartingPlayers starting_players{DebugConfig::StartingPlayers::BOTH};
};
struct GameParams {
DebugConfig::StartingPlayers starting_players{DebugConfig::StartingPlayers::BOTH};
};
using SceneParams = std::variant<LogoParams, TitleParams, IntroParams, GameParams>;
struct ExitRequest {
SceneId next;
SceneParams params;
};
// Helper per a logs / debug. Implementat inline per evitar un .cpp per a
// una funció de tres línies.
[[nodiscard]] inline auto toString(SceneId id) -> const char* {
switch (id) {
case SceneId::LOGO:
return "LOGO";
case SceneId::TITLE:
return "TITLE";
case SceneId::INTRO:
return "INTRO";
case SceneId::GAME:
return "GAME";
}
return "?";
}
+112 -55
View File
@@ -22,6 +22,7 @@
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/options.hpp"
#include "game/scenes/scene_flow.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
@@ -81,12 +82,11 @@ namespace {
} // namespace
TitleScene::TitleScene(std::shared_ptr<Text> display_font, std::shared_ptr<Text> condensed_font, std::shared_ptr<Surface> background, std::shared_ptr<Surface> mini_logo, SceneFactory next_factory)
TitleScene::TitleScene(std::shared_ptr<Text> display_font, std::shared_ptr<Text> condensed_font, std::shared_ptr<Surface> background, std::shared_ptr<Surface> mini_logo)
: display_font_(std::move(display_font)),
condensed_font_(std::move(condensed_font)),
background_(std::move(background)),
mini_logo_(std::move(mini_logo)),
next_factory_(std::move(next_factory)),
timer_ms_(UiConfig::get().titleDurationMs()) {
std::cout << "[scene] " << SCENE_NAME << " start\n";
const auto& hh = UiConfig::get().titleHeatHaze();
@@ -100,10 +100,16 @@ TitleScene::TitleScene(std::shared_ptr<Text> display_font, std::shared_ptr<Text>
fade_out_.emplace(Transitions::Mosaic::Params{
.max_block_size = UiConfig::get().titleFadeOutMaxBlock(),
});
attract_fade_.emplace();
// Fade-in inicial: arrenquem en negre opac i ens esvaïm cap a la
// imatge de fons. Sense lligar a cap fase — viu sempre al frame i
// un cop finished, `render()` és no-op fins que la TitleScene mor.
intro_fade_.begin(UiConfig::get().titleIntroFadeInMs(), Transitions::FadeToBlack::Direction::FADE_IN);
// Música de fons en bucle. Es preloda al Director, així que la
// primera crida és immediata. El fade-out arrenca en entrar a
// STARTING (vore enterStarting).
// STARTING (vore enterStarting); en ATTRACT_OUT NO es toca, la
// música segueix sonant cap a LOGO.
if (auto* audio = Audio::get(); audio != nullptr) {
audio->playMusic("title.ogg", -1);
}
@@ -120,6 +126,11 @@ void TitleScene::update(int delta_ms) {
if (heat_haze_) { heat_haze_->tick(delta_ms); }
// Fade-in inicial: tica sempre. Un cop finished, FadeToBlack::tick
// és idempotent (clamp intern al duration_ms_) i `render()` pinta
// alpha=0 (no-op) durant la resta del cicle.
intro_fade_.tick(delta_ms);
// Comptador del parpadeig: avança sempre amb `delta_ms` cru. La
// lectura (cicle/visible) la decideix `render()` segons la fase:
// IDLE usa visible_ms / hidden_ms del yaml (asimètric, 80/20 per
@@ -131,53 +142,15 @@ void TitleScene::update(int delta_ms) {
const bool P2_START = pressedStart(1);
switch (phase_) {
case Phase::IDLE: {
timer_ms_ -= delta_ms;
if (P1_START) {
playStartSound();
enterStarting(DebugConfig::StartingPlayers::P1);
} else if (P2_START) {
playStartSound();
enterStarting(DebugConfig::StartingPlayers::P2);
} else if (timer_ms_ <= 0) {
// Attract: ningú ha premut. Entrem a STARTING amb BOTH
// perquè el fade-out de transició també hi compti.
// Demà, quan tinguem una DemoScene, ací anirà la
// transició a demo en lloc de a intro.
enterStarting(DebugConfig::StartingPlayers::BOTH);
}
case Phase::IDLE:
tickIdle(delta_ms, P1_START, P2_START);
break;
}
case Phase::STARTING: {
starting_timer_ms_ -= delta_ms;
// Si l'altre jugador prem START dins de la finestra (i el
// fade encara no ha arrencat), pugem a BOTH i resetegem el
// comptador. Una vegada el fade ja està actiu, ignorem
// pulsacions per no saltar visualment la transició.
const bool OTHER_PRESSED =
(pending_players_ == DebugConfig::StartingPlayers::P1 && P2_START) ||
(pending_players_ == DebugConfig::StartingPlayers::P2 && P1_START);
const bool FADE_LOCKED = fade_out_ && (fade_out_->isActive() || fade_out_->isFinished());
if (OTHER_PRESSED && !FADE_LOCKED) {
playStartSound();
pending_players_ = DebugConfig::StartingPlayers::BOTH;
starting_timer_ms_ = UiConfig::get().titleStartingMs();
}
// Arrenca el fade quan al comptador li queden <= fade_out_ms,
// i només una vegada (ni en repeticions ni si l'altre jugador
// ha resetejat el timer).
const int FADE_MS = UiConfig::get().titleFadeOutMs();
if (fade_out_ && !fade_out_->isActive() && !fade_out_->isFinished() && starting_timer_ms_ <= FADE_MS && FADE_MS > 0) {
fade_out_->begin(FADE_MS);
}
if (fade_out_ && fade_out_->isActive()) { fade_out_->tick(delta_ms); }
if (starting_timer_ms_ <= 0) {
advanceTo(pending_players_);
}
case Phase::STARTING:
tickStarting(delta_ms, P1_START, P2_START);
break;
case Phase::ATTRACT_OUT:
tickAttract(delta_ms);
break;
}
}
Notifier::get().update(delta_ms);
@@ -201,12 +174,85 @@ void TitleScene::enterStarting(DebugConfig::StartingPlayers players) {
std::cout << "[scene] " << SCENE_NAME << " STARTING (players=" << DebugConfig::toString(players) << ")\n";
}
void TitleScene::advanceTo(DebugConfig::StartingPlayers players) {
if (next_factory_) {
next_scene_ = next_factory_(players);
void TitleScene::tickIdle(int delta_ms, bool p1_start, bool p2_start) {
timer_ms_ -= delta_ms;
if (p1_start) {
playStartSound();
enterStarting(DebugConfig::StartingPlayers::P1);
} else if (p2_start) {
playStartSound();
enterStarting(DebugConfig::StartingPlayers::P2);
} else if (timer_ms_ <= 0) {
// Attract timeout: ningú ha premut. Sortida silenciosa cap a
// LOGO amb fade-to-black; música i parpadeig intactes.
enterAttract();
}
}
void TitleScene::tickStarting(int delta_ms, bool p1_start, bool p2_start) {
starting_timer_ms_ -= delta_ms;
// Si l'altre jugador prem START dins de la finestra (i el fade
// encara no ha arrencat), pugem a BOTH i resetegem el comptador.
// Una vegada el fade ja està actiu, ignorem pulsacions per no
// saltar visualment la transició.
const bool OTHER_PRESSED =
(pending_players_ == DebugConfig::StartingPlayers::P1 && p2_start) ||
(pending_players_ == DebugConfig::StartingPlayers::P2 && p1_start);
const bool FADE_LOCKED = fade_out_ && (fade_out_->isActive() || fade_out_->isFinished());
if (OTHER_PRESSED && !FADE_LOCKED) {
playStartSound();
pending_players_ = DebugConfig::StartingPlayers::BOTH;
starting_timer_ms_ = UiConfig::get().titleStartingMs();
}
// Arrenca el fade quan al comptador li queden <= fade_out_ms, i
// només una vegada (ni en repeticions ni si l'altre jugador ha
// resetejat el timer).
const int FADE_MS = UiConfig::get().titleFadeOutMs();
if (fade_out_ && !fade_out_->isActive() && !fade_out_->isFinished() && starting_timer_ms_ <= FADE_MS && FADE_MS > 0) {
fade_out_->begin(FADE_MS);
}
if (fade_out_ && fade_out_->isActive()) { fade_out_->tick(delta_ms); }
if (starting_timer_ms_ <= 0) {
advanceToIntro(pending_players_);
}
}
void TitleScene::tickAttract(int delta_ms) {
// Sortida d'attract: només avancem el fade-to-black. Quan s'acaba
// demanem transició a LOGO. Cap input ni música ni canvi de
// freqüència de parpadeig — el text "press start" segueix amb el
// cicle d'IDLE per damunt del fade.
if (!attract_fade_) { return; }
attract_fade_->tick(delta_ms);
if (attract_fade_->isFinished()) {
advanceToLogo();
}
}
void TitleScene::enterAttract() {
phase_ = Phase::ATTRACT_OUT;
if (attract_fade_) {
attract_fade_->begin(UiConfig::get().titleAttractFadeToBlackMs());
}
std::cout << "[scene] " << SCENE_NAME << " ATTRACT_OUT\n";
}
void TitleScene::advanceToIntro(DebugConfig::StartingPlayers players) {
exit_request_ = ExitRequest{
.next = SceneId::INTRO,
.params = IntroParams{.starting_players = players},
};
}
void TitleScene::advanceToLogo() {
exit_request_ = ExitRequest{
.next = SceneId::LOGO,
.params = LogoParams{},
};
}
void TitleScene::render() {
const auto& ui_config = UiConfig::get();
const auto& palette = Resource::Cache::get().getPalette("ui");
@@ -299,10 +345,21 @@ void TitleScene::render() {
Notifier::get().render();
// El fade-out cobreix l'escena i el Notifier (forma part de la
// transició), però NO el RenderInfo (overlay de debug, sempre
// visible).
if (fade_out_ && (fade_out_->isActive() || fade_out_->isFinished())) {
// Fade-in inicial: capa negra que s'esvaeix cap a l'escena durant
// els primers `title.intro_fade_in_ms` ms. `render()` és no-op un
// cop finished, així que es queda penjat aquí sense lògica
// condicional. Va per damunt del Notifier — coherent amb la resta
// de transicions — però per davall del RenderInfo de debug.
intro_fade_.render();
// Capa de transició final segons la fase. Sempre cobreix l'escena
// i el Notifier, mai el RenderInfo (overlay de debug, sempre
// visible). STARTING usa mosaic dissolve; ATTRACT_OUT, fade-to-black
// lineal (sense efecte chunky perquè la sortida silenciosa s'integre
// amb la pista visual del LOGO).
if (phase_ == Phase::ATTRACT_OUT && attract_fade_) {
attract_fade_->render();
} else if (fade_out_ && (fade_out_->isActive() || fade_out_->isFinished())) {
fade_out_->render();
}
+25 -12
View File
@@ -1,11 +1,11 @@
#pragma once
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include "core/rendering/effects/heat_haze.hpp"
#include "core/rendering/transitions/fade_to_black.hpp"
#include "core/rendering/transitions/mosaic.hpp"
#include "core/scene/scene.hpp"
#include "game/debug_config.hpp"
@@ -19,33 +19,40 @@ class Text;
// data/ui.yaml secció `title:` per poder afinar-la sense recompilar.
class TitleScene : public Scene {
public:
using SceneFactory = std::function<std::unique_ptr<Scene>(DebugConfig::StartingPlayers)>;
// Fases de l'escena.
// - IDLE: animació atràctil. Cap tecla salta; només P1 START o
// P2 START fan transició. El timeout (`title.duration_ms`)
// salta a la següent escena amb BOTH (modus attract).
// - IDLE: animació atràctil. Només P1 START o P2 START hi fan
// transició; un timeout `title.duration_ms` porta a ATTRACT_OUT.
// - STARTING: comptador fix entre la primera pulsació de START i
// la transició. Si l'altre jugador prem START dins d'aquesta
// finestra, el comptador es reseteja amb `pending_players_=BOTH`.
// la transició cap a INTRO. Si l'altre jugador prem START dins
// d'aquesta finestra, el comptador es reseteja amb
// `pending_players_=BOTH`. Visual: mosaic dissolve + fade-out
// de música.
// - ATTRACT_OUT: fase de sortida silenciosa cap a LOGO quan
// expira el timeout sense pulsacions. Visual: fade-to-black net.
// NO toca la música ni canvia la freqüència del parpadeig.
enum class Phase : std::uint8_t { IDLE,
STARTING };
STARTING,
ATTRACT_OUT };
TitleScene(std::shared_ptr<Text> display_font, std::shared_ptr<Text> condensed_font, std::shared_ptr<Surface> background, std::shared_ptr<Surface> mini_logo, SceneFactory next_factory);
TitleScene(std::shared_ptr<Text> display_font, std::shared_ptr<Text> condensed_font, std::shared_ptr<Surface> background, std::shared_ptr<Surface> mini_logo);
~TitleScene() override;
void update(int delta_ms) override;
void render() override;
private:
void tickIdle(int delta_ms, bool p1_start, bool p2_start);
void tickStarting(int delta_ms, bool p1_start, bool p2_start);
void tickAttract(int delta_ms);
void enterStarting(DebugConfig::StartingPlayers players);
void advanceTo(DebugConfig::StartingPlayers players);
void enterAttract();
void advanceToIntro(DebugConfig::StartingPlayers players);
void advanceToLogo();
std::shared_ptr<Text> display_font_;
std::shared_ptr<Text> condensed_font_;
std::shared_ptr<Surface> background_;
std::shared_ptr<Surface> mini_logo_;
SceneFactory next_factory_;
int timer_ms_;
int blink_elapsed_ms_{0};
std::optional<Effects::HeatHaze> heat_haze_;
@@ -53,4 +60,10 @@ class TitleScene : public Scene {
int starting_timer_ms_{0};
DebugConfig::StartingPlayers pending_players_{DebugConfig::StartingPlayers::BOTH};
std::optional<Transitions::Mosaic> fade_out_;
std::optional<Transitions::FadeToBlack> attract_fade_;
// Fade-in inicial: capa negra que s'esvaeix cap a la imatge de fons
// durant els primers `title.intro_fade_in_ms` ms. Sempre activa al
// ctor; un cop finished, `render()` pinta alpha=0 (no-op) la resta
// del cicle de l'escena.
Transitions::FadeToBlack intro_fade_;
};
+14
View File
@@ -287,6 +287,20 @@ namespace Defaults {
// (la música pot acabar abans/després del mosaic).
constexpr int MUSIC_FADE_OUT_MS = 2000;
// Fade-to-black per a la sortida d'attract (timeout). És una
// capa negra lineal sobre el canvas — sense mosaic — perquè
// l'eixida silenciosa cap a LOGO siga neta. NO toca la música:
// el title.ogg continua sonant durant la transició; LOGO ja
// veurà què en fa.
constexpr int ATTRACT_FADE_TO_BLACK_MS = 800;
// Fade-in inicial de l'escena: capa negra al començament que
// s'esvaeix cap a la imatge de fons. Lineal, sense mosaic —
// mateixa convenció que el `room_intro.fade_in_ms` del joc
// perquè l'arribada a la TITLE i l'arribada a una sala de
// joc tinguin el mateix ritme visual.
constexpr int INTRO_FADE_IN_MS = 500;
// Distorsió sinusoidal horitzontal de l'imatge de fons
// (efecte de calor del desert). `amplitude_px` és el
// desplaçament màxim per fila; `wavelength_px` la distància