diff --git a/CMakeLists.txt b/CMakeLists.txt index cc6f284..b772dd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,6 +93,7 @@ set(APP_SOURCES source/core/rendering/sprite/animated_sprite.cpp source/core/rendering/sprite/animated_sprite_parser.cpp source/core/rendering/effects/heat_haze.cpp + source/core/rendering/transitions/mosaic.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 c034648..049df67 100644 --- a/data/ui.yaml +++ b/data/ui.yaml @@ -120,6 +120,12 @@ title: # finestra, el comptador es reseteja i s'arrenca amb BOTH. starting_ms: 3000 starting_blink_speedup: 2.0 + # Fade-out estil "mosaic dissolve" durant l'última part de STARTING. + # `fade_out_ms` ha de ser <= starting_ms (els últims X ms del STARTING + # es fan amb el dissolve actiu fins a quedar negre). `fade_out_max_block` + # és la mida màxima de bloc en píxels al final del fade. + fade_out_ms: 800 + fade_out_max_block: 32 # 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/mosaic.cpp b/source/core/rendering/transitions/mosaic.cpp new file mode 100644 index 0000000..3afdc36 --- /dev/null +++ b/source/core/rendering/transitions/mosaic.cpp @@ -0,0 +1,71 @@ +#include "core/rendering/transitions/mosaic.hpp" + +#include +#include +#include + +#include "core/rendering/screen.hpp" + +namespace Transitions { + + Mosaic::Mosaic(Params params) + : params_(params) {} + + void Mosaic::begin(int duration_ms) { + duration_ms_ = duration_ms; + elapsed_ms_ = 0; + } + + void Mosaic::tick(int delta_ms) { + if (duration_ms_ <= 0) { return; } + elapsed_ms_ = std::min(elapsed_ms_ + delta_ms, duration_ms_); + } + + auto Mosaic::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 Mosaic::render() const { + if (duration_ms_ <= 0) { return; } + const float P = progress(); + const int MAX_BLOCK = std::max(1, params_.max_block_size); + // Blocs de mida creixent al llarg del fade. A p=0 → 1 px (sense + // efecte visible); a p=1 → max_block. Es fa cap-amunt amb ceil + // perquè el primer increment arribi aviat (el salt visual de + // 1→2 ja es nota). + const int BLOCK = std::clamp(static_cast(std::ceil(static_cast(MAX_BLOCK) * P)), 1, MAX_BLOCK); + + auto& screen = Screen::get(); + std::uint32_t* canvas = screen.getCanvasArgb(); + const int W = screen.getCanvasWidth(); + const int H = screen.getCanvasHeight(); + + if (BLOCK > 1) { + // Sample-and-fill in-place. Per a cada bloc, mostregem el + // píxel central (les vores poden caure fora del canvas en + // blocs de la última columna/fila → clamp). + for (int by = 0; by < H; by += BLOCK) { + const int Y_END = std::min(by + BLOCK, H); + const int SY = std::min(by + (BLOCK / 2), H - 1); + for (int bx = 0; bx < W; bx += BLOCK) { + const int X_END = std::min(bx + BLOCK, W); + const int SX = std::min(bx + (BLOCK / 2), W - 1); + const std::uint32_t COLOR = canvas[(SY * W) + SX]; + for (int yy = by; yy < Y_END; ++yy) { + std::uint32_t* row = canvas + (static_cast(yy) * W); + std::fill(row + bx, row + X_END, COLOR); + } + } + } + } + + // Fade-to-black quadràtic per damunt: la mascara fosca arriba + // tard (a p=0.5 només 25% d'alpha) perquè es vegin els blocs. + const auto ALPHA = static_cast(std::round(255.0F * P * P)); + if (ALPHA > 0) { + screen.blendRect(0, 0, W, H, 0x00000000U, ALPHA); + } + } + +} // namespace Transitions diff --git a/source/core/rendering/transitions/mosaic.hpp b/source/core/rendering/transitions/mosaic.hpp new file mode 100644 index 0000000..9966691 --- /dev/null +++ b/source/core/rendering/transitions/mosaic.hpp @@ -0,0 +1,45 @@ +#pragma once + +namespace Transitions { + + // Fade-out estil "mosaic dissolve": al llarg del temps els píxels del + // canvas es reagrupen en blocs cada cop més grans (1×1 → max_block_size× + // max_block_size). Cada bloc es pinta amb el píxel central del què + // tenia abans, donant aquell aspecte chunky d'arcade. Al damunt + // s'aplica un fade-to-black quadràtic (alpha = p² × 255) perquè el + // resultat acabi en negre opac. + // + // La classe NO sap res del Screen abans de `render()`. El caller hi + // crida després de pintar la seva escena: l'efecte llegeix els + // píxels actuals del canvas, els rebina i escriu pixels modificats + // al mateix canvas. És, per tant, un post-procés in-place. + class Mosaic { + public: + struct Params { + int max_block_size; + }; + + explicit Mosaic(Params params); + + // 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; + + // Aplica el mosaic + alpha negre sobre el canvas actual de Screen. + // Cridar després del render normal de l'escena, abans de + // `Screen::present()`. + void render() const; + + private: + Params params_; + int duration_ms_{0}; + int elapsed_ms_{0}; + }; + +} // namespace Transitions diff --git a/source/core/ui/ui_config.cpp b/source/core/ui/ui_config.cpp index c543ef2..60d73f4 100644 --- a/source/core/ui/ui_config.cpp +++ b/source/core/ui/ui_config.cpp @@ -104,6 +104,8 @@ UiConfig::UiConfig() title_blink_hidden_ms_(Defaults::UI::Title::BLINK_HIDDEN_MS), title_starting_ms_(Defaults::UI::Title::STARTING_MS), title_starting_blink_speedup_(Defaults::UI::Title::STARTING_BLINK_SPEEDUP), + title_fade_out_ms_(Defaults::UI::Title::FADE_OUT_MS), + title_fade_out_max_block_(Defaults::UI::Title::FADE_OUT_MAX_BLOCK), title_heat_haze_{ .enabled = Defaults::UI::Title::HeatHaze::ENABLED, .amplitude_px = Defaults::UI::Title::HeatHaze::AMPLITUDE_PX, @@ -228,6 +230,8 @@ void UiConfig::load(const std::string& file_path) { readField(t, "blink_hidden_ms", title_blink_hidden_ms_); readField(t, "starting_ms", title_starting_ms_); readField(t, "starting_blink_speedup", title_starting_blink_speedup_); + readField(t, "fade_out_ms", title_fade_out_ms_); + readField(t, "fade_out_max_block", title_fade_out_max_block_); 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 1426697..2c32e28 100644 --- a/source/core/ui/ui_config.hpp +++ b/source/core/ui/ui_config.hpp @@ -97,6 +97,8 @@ class UiConfig { [[nodiscard]] auto titleBlinkHiddenMs() const -> int { return title_blink_hidden_ms_; } [[nodiscard]] auto titleStartingMs() const -> int { return title_starting_ms_; } [[nodiscard]] auto titleStartingBlinkSpeedup() const -> float { return title_starting_blink_speedup_; } + [[nodiscard]] auto titleFadeOutMs() const -> int { return title_fade_out_ms_; } + [[nodiscard]] auto titleFadeOutMaxBlock() const -> int { return title_fade_out_max_block_; } [[nodiscard]] auto titleHeatHaze() const -> const TitleHeatHaze& { return title_heat_haze_; } // Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa @@ -171,6 +173,8 @@ class UiConfig { int title_blink_hidden_ms_; int title_starting_ms_; float title_starting_blink_speedup_; + int title_fade_out_ms_; + int title_fade_out_max_block_; 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 55b498f..22e4102 100644 --- a/source/game/scenes/title_scene.cpp +++ b/source/game/scenes/title_scene.cpp @@ -87,6 +87,9 @@ TitleScene::TitleScene(std::shared_ptr display_font, std::shared_ptr .speed_hz = hh.speed_hz, }); } + fade_out_.emplace(Transitions::Mosaic::Params{ + .max_block_size = UiConfig::get().titleFadeOutMaxBlock(), + }); } TitleScene::~TitleScene() = default; @@ -119,25 +122,38 @@ void TitleScene::update(int delta_ms) { } else if (P2_START) { enterStarting(DebugConfig::StartingPlayers::P2); } else if (timer_ms_ <= 0) { - // Attract: ningú ha premut. Saltem a la següent escena - // amb BOTH per defecte. Demà, quan tinguem una DemoScene, - // ací anirà la transició a demo en lloc de a intro. - advanceTo(DebugConfig::StartingPlayers::BOTH); + // 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); } break; } case Phase::STARTING: { starting_timer_ms_ -= delta_ms; - // Si l'altre jugador prem START dins de la finestra, pugem - // a BOTH i resetegem el comptador. Pulsacions repetides del - // mateix jugador ja "starting" no fan res. + // 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); - if (OTHER_PRESSED) { + const bool FADE_LOCKED = fade_out_ && (fade_out_->isActive() || fade_out_->isFinished()); + if (OTHER_PRESSED && !FADE_LOCKED) { 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_); } @@ -245,6 +261,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())) { + fade_out_->render(); + } + RenderInfo::get().render(); Screen::get().present(); } diff --git a/source/game/scenes/title_scene.hpp b/source/game/scenes/title_scene.hpp index a040fab..3af0aa8 100644 --- a/source/game/scenes/title_scene.hpp +++ b/source/game/scenes/title_scene.hpp @@ -6,6 +6,7 @@ #include #include "core/rendering/effects/heat_haze.hpp" +#include "core/rendering/transitions/mosaic.hpp" #include "core/scene/scene.hpp" #include "game/debug_config.hpp" @@ -51,4 +52,5 @@ class TitleScene : public Scene { Phase phase_{Phase::IDLE}; int starting_timer_ms_{0}; DebugConfig::StartingPlayers pending_players_{DebugConfig::StartingPlayers::BOTH}; + std::optional fade_out_; }; diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 411f4d2..289083a 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -266,6 +266,14 @@ namespace Defaults { constexpr int STARTING_MS = 3000; constexpr float STARTING_BLINK_SPEEDUP = 2.0F; + // Fade-out estil mosaic dissolve dins l'última part de la + // fase STARTING. `FADE_OUT_MS` ha de ser <= STARTING_MS; + // si és més gran, comença ja a t=0 i acaba abans del salt. + // `FADE_OUT_MAX_BLOCK` és la mida màxima de bloc en píxels + // (32 al canvas de 320×240 dóna 10×8 blocs al final). + constexpr int FADE_OUT_MS = 800; + constexpr int FADE_OUT_MAX_BLOCK = 32; + // 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