From b6d6ee471cb56cc68c5ad61e61ef3d8faec5ad89 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 6 May 2026 18:33:31 +0200 Subject: [PATCH 1/4] =?UTF-8?q?refactor(scenes):=20flux=20centralitzat=20a?= =?UTF-8?q?mb=20SceneId+ExitRequest;=20TitleScene=20attract=20=E2=86=92=20?= =?UTF-8?q?LOGO=20amb=20fade-to-black?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 1 + data/ui.yaml | 4 + .../rendering/transitions/fade_to_black.cpp | 39 +++++ .../rendering/transitions/fade_to_black.hpp | 37 +++++ source/core/scene/scene.hpp | 12 +- source/core/system/director.cpp | 72 ++++++--- source/core/system/director.hpp | 20 +++ source/core/ui/ui_config.cpp | 2 + source/core/ui/ui_config.hpp | 2 + source/game/scenes/intro_scene.cpp | 13 +- source/game/scenes/intro_scene.hpp | 12 +- source/game/scenes/logo_scene.cpp | 10 +- source/game/scenes/logo_scene.hpp | 12 +- source/game/scenes/scene_flow.hpp | 63 ++++++++ source/game/scenes/title_scene.cpp | 151 +++++++++++------- source/game/scenes/title_scene.hpp | 32 ++-- source/utils/defaults.hpp | 7 + 17 files changed, 368 insertions(+), 121 deletions(-) create mode 100644 source/core/rendering/transitions/fade_to_black.cpp create mode 100644 source/core/rendering/transitions/fade_to_black.hpp create mode 100644 source/game/scenes/scene_flow.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b772dd1..f0c6856 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/data/ui.yaml b/data/ui.yaml index af88a41..ef52b96 100644 --- a/data/ui.yaml +++ b/data/ui.yaml @@ -135,6 +135,10 @@ 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 # 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. diff --git a/source/core/rendering/transitions/fade_to_black.cpp b/source/core/rendering/transitions/fade_to_black.cpp new file mode 100644 index 0000000..3904bcd --- /dev/null +++ b/source/core/rendering/transitions/fade_to_black.cpp @@ -0,0 +1,39 @@ +#include "core/rendering/transitions/fade_to_black.hpp" + +#include +#include +#include + +#include "core/rendering/screen.hpp" + +namespace Transitions { + + void FadeToBlack::begin(int duration_ms) { + duration_ms_ = duration_ms; + elapsed_ms_ = 0; + } + + 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(elapsed_ms_) / static_cast(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. + const auto ALPHA = static_cast(std::round(255.0F * P)); + if (ALPHA == 0) { return; } + + auto& screen = Screen::get(); + screen.blendRect(0, 0, screen.getCanvasWidth(), screen.getCanvasHeight(), 0x00000000U, ALPHA); + } + +} // namespace Transitions diff --git a/source/core/rendering/transitions/fade_to_black.hpp b/source/core/rendering/transitions/fade_to_black.hpp new file mode 100644 index 0000000..59c6d4f --- /dev/null +++ b/source/core/rendering/transitions/fade_to_black.hpp @@ -0,0 +1,37 @@ +#pragma once + +namespace Transitions { + + // Fade-out lineal a negre. A diferència de `Mosaic`, no rebina cap + // píxel; només posa una capa negra amb alpha creixent (0 → 255) al + // damunt del canvas. És la transició "neta" per defecte; ideal per a + // sortides on no convé l'estètica chunky de l'arcade dissolve. + // + // 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: + 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); + + 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; + + // Pinta la capa negra amb alpha proporcional al `progress()`. + // 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}; + }; + +} // namespace Transitions diff --git a/source/core/scene/scene.hpp b/source/core/scene/scene.hpp index d9efe32..56d0d3b 100644 --- a/source/core/scene/scene.hpp +++ b/source/core/scene/scene.hpp @@ -1,7 +1,13 @@ #pragma once -#include +#include +#include +// 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 { return std::move(next_scene_); } + [[nodiscard]] auto takeExitRequest() -> std::any { return std::exchange(exit_request_, {}); } protected: bool wants_quit_{false}; - std::unique_ptr next_scene_; + std::any exit_request_; }; diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 7e60531..2bb4c85 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -10,6 +11,7 @@ #include #include #include +#include #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(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 { - auto cfg = GameScene::defaultConfig(); - cfg.starting_players = players; - return std::make_unique(std::move(cfg)); - }; - auto make_intro = [placeholder_text, make_game](DebugConfig::StartingPlayers players) -> std::unique_ptr { - return std::make_unique(placeholder_text, players, make_game); - }; - auto make_title = [menu_title_text, placeholder_text, title_bg_surface, mini_logo_surface, make_intro]() -> std::unique_ptr { - return std::make_unique(menu_title_text, placeholder_text, title_bg_surface, mini_logo_surface, make_intro); - }; - switch (START) { case DebugConfig::StartScene::LOGO: - current_scene_ = std::make_unique(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(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 { + // 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(placeholder_text_); + case SceneId::TITLE: + return std::make_unique(menu_title_text_, placeholder_text_, title_bg_surface_, mini_logo_surface_); + case SceneId::INTRO: { + const auto& p = std::get(params); + return std::make_unique(placeholder_text_, p.starting_players); + } + case SceneId::GAME: { + const auto& p = std::get(params); + auto cfg = GameScene::defaultConfig(); + cfg.starting_players = p.starting_players; + return std::make_unique(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 diff --git a/source/core/system/director.hpp b/source/core/system/director.hpp index 1d82c8d..467326b 100644 --- a/source/core/system/director.hpp +++ b/source/core/system/director.hpp @@ -6,7 +6,11 @@ #include #include +#include "game/scenes/scene_flow.hpp" + class Scene; +class Surface; +class Text; class Director { public: @@ -20,9 +24,25 @@ class Director { std::unique_ptr 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 placeholder_text_; + std::shared_ptr menu_title_text_; + std::shared_ptr title_bg_surface_; + std::shared_ptr 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; }; diff --git a/source/core/ui/ui_config.cpp b/source/core/ui/ui_config.cpp index 54e93f3..e8a63af 100644 --- a/source/core/ui/ui_config.cpp +++ b/source/core/ui/ui_config.cpp @@ -107,6 +107,7 @@ 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_heat_haze_{ .enabled = Defaults::UI::Title::HeatHaze::ENABLED, .amplitude_px = Defaults::UI::Title::HeatHaze::AMPLITUDE_PX, @@ -236,6 +237,7 @@ 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_); if (t.contains("heat_haze")) { const auto& hh = t["heat_haze"]; readField(hh, "enabled", title_heat_haze_.enabled); diff --git a/source/core/ui/ui_config.hpp b/source/core/ui/ui_config.hpp index ddbef1c..98c8cc8 100644 --- a/source/core/ui/ui_config.hpp +++ b/source/core/ui/ui_config.hpp @@ -100,6 +100,7 @@ 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 titleHeatHaze() const -> const TitleHeatHaze& { return title_heat_haze_; } // Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa @@ -178,6 +179,7 @@ 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_; TitleHeatHaze title_heat_haze_; std::string menu_select_sound_; diff --git a/source/game/scenes/intro_scene.cpp b/source/game/scenes/intro_scene.cpp index 6f318b6..0e28039 100644 --- a/source/game/scenes/intro_scene.cpp +++ b/source/game/scenes/intro_scene.cpp @@ -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 font, DebugConfig::StartingPlayers starting_players, SceneFactory next_factory) +IntroScene::IntroScene(std::shared_ptr 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); diff --git a/source/game/scenes/intro_scene.hpp b/source/game/scenes/intro_scene.hpp index ec941bb..bf2a6b6 100644 --- a/source/game/scenes/intro_scene.hpp +++ b/source/game/scenes/intro_scene.hpp @@ -1,6 +1,5 @@ #pragma once -#include #include #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(DebugConfig::StartingPlayers)>; - static constexpr int DURATION_MS = 5000; - IntroScene(std::shared_ptr font, DebugConfig::StartingPlayers starting_players, SceneFactory next_factory); + IntroScene(std::shared_ptr 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 font_; DebugConfig::StartingPlayers starting_players_; - SceneFactory next_factory_; int timer_ms_{DURATION_MS}; }; diff --git a/source/game/scenes/logo_scene.cpp b/source/game/scenes/logo_scene.cpp index 6d7b287..189fc2e 100644 --- a/source/game/scenes/logo_scene.cpp +++ b/source/game/scenes/logo_scene.cpp @@ -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 font, SceneFactory next_factory) - : font_(std::move(font)), - next_factory_(std::move(next_factory)) { +LogoScene::LogoScene(std::shared_ptr 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); diff --git a/source/game/scenes/logo_scene.hpp b/source/game/scenes/logo_scene.hpp index d9023b8..51fc314 100644 --- a/source/game/scenes/logo_scene.hpp +++ b/source/game/scenes/logo_scene.hpp @@ -1,6 +1,5 @@ #pragma once -#include #include #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()>; - static constexpr int DURATION_MS = 5000; - LogoScene(std::shared_ptr font, SceneFactory next_factory); + explicit LogoScene(std::shared_ptr font); ~LogoScene() override; void update(int delta_ms) override; @@ -25,6 +22,5 @@ class LogoScene : public Scene { private: std::shared_ptr font_; - SceneFactory next_factory_; int timer_ms_{DURATION_MS}; }; diff --git a/source/game/scenes/scene_flow.hpp b/source/game/scenes/scene_flow.hpp new file mode 100644 index 0000000..e43212c --- /dev/null +++ b/source/game/scenes/scene_flow.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +#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; + +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 "?"; +} diff --git a/source/game/scenes/title_scene.cpp b/source/game/scenes/title_scene.cpp index 26bf114..49c65a4 100644 --- a/source/game/scenes/title_scene.cpp +++ b/source/game/scenes/title_scene.cpp @@ -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 display_font, std::shared_ptr condensed_font, std::shared_ptr background, std::shared_ptr mini_logo, SceneFactory next_factory) +TitleScene::TitleScene(std::shared_ptr display_font, std::shared_ptr condensed_font, std::shared_ptr background, std::shared_ptr 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,12 @@ TitleScene::TitleScene(std::shared_ptr display_font, std::shared_ptr fade_out_.emplace(Transitions::Mosaic::Params{ .max_block_size = UiConfig::get().titleFadeOutMaxBlock(), }); + attract_fade_.emplace(); // 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); } @@ -131,53 +133,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 +165,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 +336,14 @@ 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())) { + // 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(); } diff --git a/source/game/scenes/title_scene.hpp b/source/game/scenes/title_scene.hpp index 3af0aa8..14a12a9 100644 --- a/source/game/scenes/title_scene.hpp +++ b/source/game/scenes/title_scene.hpp @@ -1,11 +1,11 @@ #pragma once #include -#include #include #include #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(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 display_font, std::shared_ptr condensed_font, std::shared_ptr background, std::shared_ptr mini_logo, SceneFactory next_factory); + TitleScene(std::shared_ptr display_font, std::shared_ptr condensed_font, std::shared_ptr background, std::shared_ptr 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 display_font_; std::shared_ptr condensed_font_; std::shared_ptr background_; std::shared_ptr mini_logo_; - SceneFactory next_factory_; int timer_ms_; int blink_elapsed_ms_{0}; std::optional heat_haze_; @@ -53,4 +60,5 @@ class TitleScene : public Scene { int starting_timer_ms_{0}; DebugConfig::StartingPlayers pending_players_{DebugConfig::StartingPlayers::BOTH}; std::optional fade_out_; + std::optional attract_fade_; }; diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 75b1f7c..0218a7f 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -287,6 +287,13 @@ 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; + // 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 From 3f098582033c27fe7f071f84bc4056796aeb078f Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 6 May 2026 19:10:18 +0200 Subject: [PATCH 2/4] =?UTF-8?q?refactor(audio,transitions):=20durada=20de?= =?UTF-8?q?=20m=C3=BAsica=20pre-calculada=20al=20loadMusic=20i=20Direction?= =?UTF-8?q?=20(FADE=5FIN/FADE=5FOUT)=20a=20FadeToBlack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/core/audio/audio.cpp | 8 +++++ source/core/audio/audio.hpp | 5 ++++ source/core/audio/jail_audio.cpp | 10 +++++++ source/core/audio/jail_audio.hpp | 6 ++++ .../rendering/transitions/fade_to_black.cpp | 9 ++++-- .../rendering/transitions/fade_to_black.hpp | 30 ++++++++++++++----- 6 files changed, 58 insertions(+), 10 deletions(-) diff --git a/source/core/audio/audio.cpp b/source/core/audio/audio.cpp index c77c3b1..3aca318 100644 --- a/source/core/audio/audio.cpp +++ b/source/core/audio/audio.cpp @@ -194,6 +194,14 @@ void Audio::setOnMusicEnded(std::function 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). diff --git a/source/core/audio/audio.hpp b/source/core/audio/audio.hpp index 50171d8..9c4c387 100644 --- a/source/core/audio/audio.hpp +++ b/source/core/audio/audio.hpp @@ -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 --- diff --git a/source/core/audio/jail_audio.cpp b/source/core/audio/jail_audio.cpp index 5ad4fb0..9c9a3ac 100644 --- a/source/core/audio/jail_audio.cpp +++ b/source/core/audio/jail_audio.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -564,6 +565,15 @@ namespace Ja { music->spec.channels = static_cast(INFO.channels); music->spec.freq = static_cast(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((static_cast(SAMPLES) * 1000ULL) / INFO.sample_rate); + } music->state = MusicState::STOPPED; return music.release(); diff --git a/source/core/audio/jail_audio.hpp b/source/core/audio/jail_audio.hpp index 85f9900..aba0ba9 100644 --- a/source/core/audio/jail_audio.hpp +++ b/source/core/audio/jail_audio.hpp @@ -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}; }; diff --git a/source/core/rendering/transitions/fade_to_black.cpp b/source/core/rendering/transitions/fade_to_black.cpp index 3904bcd..0925adc 100644 --- a/source/core/rendering/transitions/fade_to_black.cpp +++ b/source/core/rendering/transitions/fade_to_black.cpp @@ -8,9 +8,10 @@ namespace Transitions { - void FadeToBlack::begin(int duration_ms) { + void FadeToBlack::begin(int duration_ms, Direction direction) { duration_ms_ = duration_ms; elapsed_ms_ = 0; + direction_ = direction; } void FadeToBlack::tick(int delta_ms) { @@ -29,7 +30,11 @@ namespace Transitions { // 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. - const auto ALPHA = static_cast(std::round(255.0F * P)); + // 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::round(255.0F * ALPHA_F)); if (ALPHA == 0) { return; } auto& screen = Screen::get(); diff --git a/source/core/rendering/transitions/fade_to_black.hpp b/source/core/rendering/transitions/fade_to_black.hpp index 59c6d4f..880eea4 100644 --- a/source/core/rendering/transitions/fade_to_black.hpp +++ b/source/core/rendering/transitions/fade_to_black.hpp @@ -1,37 +1,51 @@ #pragma once +#include + namespace Transitions { - // Fade-out lineal a negre. A diferència de `Mosaic`, no rebina cap - // píxel; només posa una capa negra amb alpha creixent (0 → 255) al - // damunt del canvas. És la transició "neta" per defecte; ideal per a - // sortides on no convé l'estètica chunky de l'arcade dissolve. + // 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); + 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 proporcional al `progress()`. - // Cridar després del render normal de l'escena, abans de - // `Screen::present()`. + // 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 From 29cb4c22112cd63b686864b30c40fbdf785a108e Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 6 May 2026 19:25:19 +0200 Subject: [PATCH 3/4] =?UTF-8?q?refactor(room-fsm):=20timeline=20pur=20amb?= =?UTF-8?q?=20music=5Ftail;=20fades=20INTRO/OUTRO=20via=20Transitions::Fad?= =?UTF-8?q?eToBlack=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 From 1431f27cfa1fe2dd9f5bb4c0efdeb84fa23765c8 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Wed, 6 May 2026 19:34:59 +0200 Subject: [PATCH 4/4] feat(title): fade-in inicial de negre a escena (500 ms, configurable a ui.yaml) --- data/ui.yaml | 4 ++++ source/core/ui/ui_config.cpp | 2 ++ source/core/ui/ui_config.hpp | 2 ++ source/game/scenes/title_scene.cpp | 16 ++++++++++++++++ source/game/scenes/title_scene.hpp | 5 +++++ source/utils/defaults.hpp | 7 +++++++ 6 files changed, 36 insertions(+) diff --git a/data/ui.yaml b/data/ui.yaml index ef52b96..1fa63b0 100644 --- a/data/ui.yaml +++ b/data/ui.yaml @@ -139,6 +139,10 @@ title: # 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. diff --git a/source/core/ui/ui_config.cpp b/source/core/ui/ui_config.cpp index e8a63af..536c37e 100644 --- a/source/core/ui/ui_config.cpp +++ b/source/core/ui/ui_config.cpp @@ -108,6 +108,7 @@ UiConfig::UiConfig() 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, @@ -238,6 +239,7 @@ void UiConfig::load(const std::string& file_path) { 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); diff --git a/source/core/ui/ui_config.hpp b/source/core/ui/ui_config.hpp index 98c8cc8..193facf 100644 --- a/source/core/ui/ui_config.hpp +++ b/source/core/ui/ui_config.hpp @@ -101,6 +101,7 @@ class UiConfig { [[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 @@ -180,6 +181,7 @@ class UiConfig { 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_; diff --git a/source/game/scenes/title_scene.cpp b/source/game/scenes/title_scene.cpp index 49c65a4..9b25de2 100644 --- a/source/game/scenes/title_scene.cpp +++ b/source/game/scenes/title_scene.cpp @@ -101,6 +101,10 @@ TitleScene::TitleScene(std::shared_ptr display_font, std::shared_ptr .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 @@ -122,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 @@ -336,6 +345,13 @@ void TitleScene::render() { Notifier::get().render(); + // 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 diff --git a/source/game/scenes/title_scene.hpp b/source/game/scenes/title_scene.hpp index 14a12a9..61197e3 100644 --- a/source/game/scenes/title_scene.hpp +++ b/source/game/scenes/title_scene.hpp @@ -61,4 +61,9 @@ class TitleScene : public Scene { DebugConfig::StartingPlayers pending_players_{DebugConfig::StartingPlayers::BOTH}; std::optional fade_out_; std::optional 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_; }; diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 0218a7f..413688b 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -294,6 +294,13 @@ namespace Defaults { // 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