From e32cff9daca6bda8bd558e78c35a2d2294395281 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 7 May 2026 13:52:57 +0200 Subject: [PATCH] feat(logo): scanline reveal CRT amb glitch, bursts VHS i pulse de banda --- CMakeLists.txt | 1 + data/ui.yaml | 24 +++ source/core/rendering/effects/scan_reveal.cpp | 192 ++++++++++++++++++ source/core/rendering/effects/scan_reveal.hpp | 83 ++++++++ source/core/ui/ui_config.cpp | 33 +++ source/core/ui/ui_config.hpp | 20 ++ source/game/scenes/logo_scene.cpp | 45 +++- source/game/scenes/logo_scene.hpp | 25 ++- source/utils/defaults.hpp | 37 ++++ 9 files changed, 440 insertions(+), 20 deletions(-) create mode 100644 source/core/rendering/effects/scan_reveal.cpp create mode 100644 source/core/rendering/effects/scan_reveal.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f0c6856..c67e675 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/effects/scan_reveal.cpp source/core/rendering/transitions/mosaic.cpp source/core/rendering/transitions/fade_to_black.cpp source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp diff --git a/data/ui.yaml b/data/ui.yaml index f286c7d..2c77699 100644 --- a/data/ui.yaml +++ b/data/ui.yaml @@ -188,3 +188,27 @@ title: amplitude_px: 1.5 wavelength_px: 32.0 speed_hz: 0.6 + +# Escena LOGO (logotip de l'editora). El revelat del logo a la fase SCAN +# imita un escaneig CRT: una banda lluminosa baixa de dalt a baix; per +# damunt es veu la imatge ja "impressa", per sota encara és negre. Al +# voltant de la banda hi ha glitch (offset horitzontal de files amb +# falloff per distància) i ocasionalment apareixen bursts VHS (franges +# desplaçades) damunt la part ja revelada. Quan la banda toca el peu del +# logo, un flash blanc tanca l'efecte i s'entra a MIDDLE (logo estable). +logo: + scan: + duration_ms: 1500 # durada total del scan top→bottom + band_height_px: 4 # alçada (px) de la banda lluminosa + band_alpha: 200 # opacitat (0..255) de la capa blanca de la banda + band_pulse_hz: 20.0 # freqüència del pulse de brillantor (Hz) + band_pulse_depth: 0.45 # profunditat del pulse (0..1; 0 = sense pulse) + # Glitch d'impacte (files al voltant de la banda) + glitch_amplitude_px: 6 # offset horitzontal màxim a la banda + glitch_band_radius_px: 12 # files damunt/sota la banda amb glitch (falloff lineal) + # Bursts VHS sobre la part ja revelada + vhs_burst_per_sec: 3 # bursts/segon mitjà + vhs_burst_duration_ms: 80 # durada de cada burst + vhs_burst_height_px: 8 # alçada de la franja afectada + vhs_burst_amplitude_px: 8 # offset horitzontal del burst + flash_ms: 100 # flash blanc final quan acaba el scan diff --git a/source/core/rendering/effects/scan_reveal.cpp b/source/core/rendering/effects/scan_reveal.cpp new file mode 100644 index 0000000..756ea65 --- /dev/null +++ b/source/core/rendering/effects/scan_reveal.cpp @@ -0,0 +1,192 @@ +#include "core/rendering/effects/scan_reveal.hpp" + +#include + +#include +#include +#include +#include +#include + +#include "core/rendering/screen.hpp" +#include "core/rendering/surface.hpp" + +namespace Effects { + + namespace { + + // Hash determinista per a (row, time_bucket): retorna float a + // [-1, 1]. Usat per generar el jitter horitzontal de la banda + // sense necessitat d'un RNG d'estat (cada frame el resultat + // canvia perquè el time_bucket canvia, però dins d'un mateix + // bucket dues files diferents donen valors distints). + auto rowJitter(int row, int time_bucket) -> float { + const auto SEED = static_cast((row * 73856093) ^ (time_bucket * 19349663)); + // Mix barato (xorshift) per descorrelacionar bits. + std::uint32_t x = SEED; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + // Normalitzem a [-1, 1]. + const float NORM = static_cast(x) / static_cast(UINT32_MAX); + return (NORM * 2.0F) - 1.0F; + } + + // Bucket de temps per al jitter: agrupant en blocs de ~50ms el + // jitter "salta" 20 cops per segon — visualment "viu" sense + // ser caos pur a 60fps. + constexpr int JITTER_TIME_BUCKET_MS = 50; + + } // namespace + + ScanReveal::ScanReveal() + : rng_(std::random_device{}()) {} + + void ScanReveal::begin(const Params& params) { + params_ = params; + elapsed_ms_ = 0; + flash_remaining_ms_ = 0; + flash_started_ = false; + burst_remaining_ms_ = 0; + burst_y_norm_ = 0.0F; + burst_dx_ = 0; + } + + void ScanReveal::tick(int delta_ms) { + if (params_.duration_ms <= 0) { return; } + + // 1) Avanç del scan (clampejat a duration_ms). + elapsed_ms_ = std::min(elapsed_ms_ + delta_ms, params_.duration_ms); + + // 2) Tick del burst VHS actiu, si n'hi ha. + if (burst_remaining_ms_ > 0) { + burst_remaining_ms_ = std::max(0, burst_remaining_ms_ - delta_ms); + } + + // 3) Spawn d'un nou burst (només durant el scan, no al flash). + // Probabilitat per frame ≈ (per_sec/1000) · delta_ms. + if (burst_remaining_ms_ == 0 && elapsed_ms_ < params_.duration_ms && params_.vhs_burst_per_sec > 0 && params_.vhs_burst_duration_ms > 0) { + const float P = (static_cast(params_.vhs_burst_per_sec) / 1000.0F) * static_cast(delta_ms); + std::uniform_real_distribution dist01(0.0F, 1.0F); + if (dist01(rng_) < P) { + burst_remaining_ms_ = params_.vhs_burst_duration_ms; + burst_y_norm_ = dist01(rng_); + std::uniform_int_distribution dist_sign(0, 1); + const int SIGN = (dist_sign(rng_) == 0) ? 1 : -1; + burst_dx_ = SIGN * params_.vhs_burst_amplitude_px; + } + } + + // 4) Dispara el flash quan el scan acaba (una sola vegada). + if (!flash_started_ && elapsed_ms_ >= params_.duration_ms && params_.flash_ms > 0) { + flash_started_ = true; + flash_remaining_ms_ = params_.flash_ms; + } + // Decrement del flash mentre estiga actiu. + if (flash_remaining_ms_ > 0) { + flash_remaining_ms_ = std::max(0, flash_remaining_ms_ - delta_ms); + } + } + + auto ScanReveal::isActive() const -> bool { + return params_.duration_ms > 0 && !isFinished(); + } + + auto ScanReveal::isFinished() const -> bool { + if (params_.duration_ms <= 0) { return true; } + // Acabat = scan al final + flash exhaurit (o sense flash). + return elapsed_ms_ >= params_.duration_ms && flash_remaining_ms_ <= 0; + } + + void ScanReveal::render(const Surface& src, int x, int y) const { + if (params_.duration_ms <= 0) { + // Sense efecte: render pla. + src.render(x, y); + return; + } + + const int H = src.getHeight(); + const auto W_F = static_cast(src.getWidth()); + const float T = static_cast(elapsed_ms_) / static_cast(params_.duration_ms); + + // Banda: comença a y=-band_height i acaba a y=H. Així al t=0 cap + // fila és visible i al t=1 totes ho són. + const float SWEEP_F = -static_cast(params_.band_height_px) + (T * static_cast(H + params_.band_height_px)); + const int BAND_TOP = static_cast(std::floor(SWEEP_F)); + const int BAND_BOT = BAND_TOP + params_.band_height_px; + + const int TIME_BUCKET = elapsed_ms_ / JITTER_TIME_BUCKET_MS; + const int BURST_Y_PX = static_cast(burst_y_norm_ * static_cast(H)); + const bool BURST_ACTIVE = burst_remaining_ms_ > 0; + + // Render fila a fila (només files revelades). + for (int row = 0; row <= BAND_BOT && row < H; ++row) { + int dx = 0; + + // Jitter de banda: amplitud màxima a la banda, falloff + // lineal cap als marges del radi de glitch. + const int DIST_TO_BAND = std::abs(row - BAND_TOP); + if (params_.glitch_band_radius_px > 0 && DIST_TO_BAND <= params_.glitch_band_radius_px && params_.glitch_amplitude_px > 0) { + const float FALLOFF = 1.0F - (static_cast(DIST_TO_BAND) / static_cast(params_.glitch_band_radius_px)); + const float JIT = rowJitter(row, TIME_BUCKET); + dx += static_cast(std::lround( + FALLOFF * static_cast(params_.glitch_amplitude_px) * JIT)); + } + + // Burst VHS: només damunt la part ja revelada (row < BAND_TOP) + // perquè per sota encara no hi ha imatge "estable" que poder + // distorsionar visualment. + if (BURST_ACTIVE && row < BAND_TOP && row >= BURST_Y_PX && row < BURST_Y_PX + params_.vhs_burst_height_px) { + dx += burst_dx_; + } + + SDL_FRect row_rect{0.0F, static_cast(row), W_F, 1.0F}; + src.render(x + dx, y + row, &row_rect); + } + + auto& screen = Screen::get(); + + // Capa lluminosa de la banda. Verticalment només cobreix les + // files del logo (és on viu el scan), però horitzontalment + // travessa tot el canvas com un feix CRT, així sobre fons negre + // no es veuen les costures del rectangle del Surface. L'alpha + // base es modula amb un pulse sinusoidal a `band_pulse_hz` per + // simular el ritme de refresc d'un CRT. + if (params_.band_alpha > 0 && params_.band_height_px > 0) { + const int VISIBLE_TOP = std::clamp(BAND_TOP, 0, H); + const int VISIBLE_BOT = std::clamp(BAND_BOT, 0, H); + const int VISIBLE_H = VISIBLE_BOT - VISIBLE_TOP; + if (VISIBLE_H > 0) { + // Pulse: fracció ∈ [0,1] avançada a la freqüència donada, + // mapejada a un multiplicador ∈ [1-depth, 1] sobre l'alpha + // base. Amb depth=0 el pulse és inactiu (multiplicador 1). + const float PULSE_DEPTH = std::clamp(params_.band_pulse_depth, 0.0F, 1.0F); + const float TWO_PI = 2.0F * std::numbers::pi_v; + const float PHASE = TWO_PI * params_.band_pulse_hz * (static_cast(elapsed_ms_) / 1000.0F); + const float WAVE = 0.5F + (0.5F * std::sin(PHASE)); // 0..1 + const float MULT = (1.0F - PULSE_DEPTH) + (PULSE_DEPTH * WAVE); + const float ALPHA_F = static_cast(params_.band_alpha) * MULT; + const auto ALPHA = static_cast( + std::clamp(static_cast(std::round(ALPHA_F)), 0, 255)); + if (ALPHA > 0) { + screen.blendRect(0, y + VISIBLE_TOP, screen.getCanvasWidth(), VISIBLE_H, 0xFFFFFFFFU, ALPHA); + } + } + } + + // Flash final: capa blanca damunt de TOT el canvas (no només + // del rectangle del logo) — així el flash banya l'escena + // sencera com un CRT i no es veuen les costures del Surface + // sobre fons negre. Alpha proporcional a `flash_remaining_ms_ + // / flash_ms` (decreix suau cap a 0 al final del flash). + if (flash_remaining_ms_ > 0 && params_.flash_ms > 0) { + const float FLASH_T = static_cast(flash_remaining_ms_) / static_cast(params_.flash_ms); + const auto ALPHA = static_cast( + std::round(255.0F * std::clamp(FLASH_T, 0.0F, 1.0F))); + if (ALPHA > 0) { + screen.blendRect(0, 0, screen.getCanvasWidth(), screen.getCanvasHeight(), 0xFFFFFFFFU, ALPHA); + } + } + } + +} // namespace Effects diff --git a/source/core/rendering/effects/scan_reveal.hpp b/source/core/rendering/effects/scan_reveal.hpp new file mode 100644 index 0000000..50cff26 --- /dev/null +++ b/source/core/rendering/effects/scan_reveal.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include +#include + +class Surface; + +namespace Effects { + + // Revelat tipus "scanline CRT" d'una Surface. Una banda lluminosa + // baixa des de dalt cap a baix de la imatge: les files damunt de la + // banda es renderitzen normalment (ja revelades), les files sota + // queden invisibles, i a la banda mateixa s'aplica un overlay + // blanc semi-transparent. Al voltant de la banda les files reben un + // jitter horitzontal aleatori (falloff lineal amb la distància) i + // damunt la part ja revelada apareixen ocasionalment "bursts VHS" — + // franges horitzontals desplaçades durant uns ms — per a un toc + // demoscene/VHS deteriorat. + // + // Quan la banda surt per sota del logo i s'esgota `duration_ms`, + // dispara un flash blanc breu i passa a `isFinished()=true`. El + // caller decideix què fa amb el control de fase a partir d'aquí. + // + // Ús: + // ScanReveal sr; + // sr.begin({.duration_ms = 1500, ...}); + // // cada frame: + // sr.tick(delta_ms); + // sr.render(*logo, x, y); + // if (sr.isFinished()) { ... canvi de fase ... } + class ScanReveal { + public: + struct Params { + int duration_ms; + int band_height_px; + int band_alpha; + float band_pulse_hz; + float band_pulse_depth; + int glitch_amplitude_px; + int glitch_band_radius_px; + int vhs_burst_per_sec; + int vhs_burst_duration_ms; + int vhs_burst_height_px; + int vhs_burst_amplitude_px; + int flash_ms; + }; + + ScanReveal(); + + void begin(const Params& params); + void tick(int delta_ms); + + // Renderitza un frame del revelat sobre la Surface `src`. El + // caller s'encarrega de pintar negre per sota (les files no + // revelades simplement no es dibuixen — queda el que hi haja al + // canvas, normalment negre). + void render(const Surface& src, int x, int y) const; + + [[nodiscard]] auto isActive() const -> bool; + [[nodiscard]] auto isFinished() const -> bool; + + private: + Params params_{}; + int elapsed_ms_{0}; + // Flash post-scan: arranca quan el scan toca fons; decreix amb + // `delta_ms` fins a 0. Mentre `flash_active_` és true encara no + // hem reportat finished (donant marge per pintar el flash sencer). + int flash_remaining_ms_{0}; + bool flash_started_{false}; + + // Burst VHS actiu (com a màxim un alhora — simplifica i ja és + // suficient per al ritme visual). y_norm ∈ [0,1] es resol en + // píxels al `render()` perquè aquí no coneixem l'alçada del logo. + int burst_remaining_ms_{0}; + float burst_y_norm_{0.0F}; + int burst_dx_{0}; + + // RNG per als bursts. Seed des de random_device a `begin()` + // perquè cada execució sigui visualment diferent. + std::minstd_rand rng_; + }; + +} // namespace Effects diff --git a/source/core/ui/ui_config.cpp b/source/core/ui/ui_config.cpp index a23546f..2a8b4c0 100644 --- a/source/core/ui/ui_config.cpp +++ b/source/core/ui/ui_config.cpp @@ -129,6 +129,20 @@ UiConfig::UiConfig() .wavelength_px = Defaults::UI::Title::HeatHaze::WAVELENGTH_PX, .speed_hz = Defaults::UI::Title::HeatHaze::SPEED_HZ, }, + logo_scan_{ + .duration_ms = Defaults::UI::Logo::Scan::DURATION_MS, + .band_height_px = Defaults::UI::Logo::Scan::BAND_HEIGHT_PX, + .band_alpha = Defaults::UI::Logo::Scan::BAND_ALPHA, + .band_pulse_hz = Defaults::UI::Logo::Scan::BAND_PULSE_HZ, + .band_pulse_depth = Defaults::UI::Logo::Scan::BAND_PULSE_DEPTH, + .glitch_amplitude_px = Defaults::UI::Logo::Scan::GLITCH_AMPLITUDE_PX, + .glitch_band_radius_px = Defaults::UI::Logo::Scan::GLITCH_BAND_RADIUS_PX, + .vhs_burst_per_sec = Defaults::UI::Logo::Scan::VHS_BURST_PER_SEC, + .vhs_burst_duration_ms = Defaults::UI::Logo::Scan::VHS_BURST_DURATION_MS, + .vhs_burst_height_px = Defaults::UI::Logo::Scan::VHS_BURST_HEIGHT_PX, + .vhs_burst_amplitude_px = Defaults::UI::Logo::Scan::VHS_BURST_AMPLITUDE_PX, + .flash_ms = Defaults::UI::Logo::Scan::FLASH_MS, + }, menu_select_sound_(Defaults::UI::MENU_SELECT_SOUND), menu_accept_sound_(Defaults::UI::MENU_ACCEPT_SOUND), player_start_sound_(Defaults::UI::PLAYER_START_SOUND), @@ -280,6 +294,25 @@ void UiConfig::load(const std::string& file_path) { } } + if (yaml.contains("logo")) { + const auto& l = yaml["logo"]; + if (l.contains("scan")) { + const auto& s = l["scan"]; + readField(s, "duration_ms", logo_scan_.duration_ms); + readField(s, "band_height_px", logo_scan_.band_height_px); + readField(s, "band_alpha", logo_scan_.band_alpha); + readField(s, "band_pulse_hz", logo_scan_.band_pulse_hz); + readField(s, "band_pulse_depth", logo_scan_.band_pulse_depth); + readField(s, "glitch_amplitude_px", logo_scan_.glitch_amplitude_px); + readField(s, "glitch_band_radius_px", logo_scan_.glitch_band_radius_px); + readField(s, "vhs_burst_per_sec", logo_scan_.vhs_burst_per_sec); + readField(s, "vhs_burst_duration_ms", logo_scan_.vhs_burst_duration_ms); + readField(s, "vhs_burst_height_px", logo_scan_.vhs_burst_height_px); + readField(s, "vhs_burst_amplitude_px", logo_scan_.vhs_burst_amplitude_px); + readField(s, "flash_ms", logo_scan_.flash_ms); + } + } + if (yaml.contains("notifier")) { const auto& n = yaml["notifier"]; readField(n, "padding_h", notifier_.padding_h); diff --git a/source/core/ui/ui_config.hpp b/source/core/ui/ui_config.hpp index a108e0a..b5b5b84 100644 --- a/source/core/ui/ui_config.hpp +++ b/source/core/ui/ui_config.hpp @@ -123,6 +123,25 @@ class UiConfig { [[nodiscard]] auto titleIntro() const -> const TitleIntro& { return title_intro_; } [[nodiscard]] auto titleHeatHaze() const -> const TitleHeatHaze& { return title_heat_haze_; } + // Escena LOGO — paràmetres del scanline reveal del logo de l'editora. + // Tot s'aplica només a la fase SCAN (substitueix l'antic FADE_IN). + struct LogoScan { + int duration_ms; + int band_height_px; + int band_alpha; + float band_pulse_hz; + float band_pulse_depth; + int glitch_amplitude_px; + int glitch_band_radius_px; + int vhs_burst_per_sec; + int vhs_burst_duration_ms; + int vhs_burst_height_px; + int vhs_burst_amplitude_px; + int flash_ms; + }; + + [[nodiscard]] auto logoScan() const -> const LogoScan& { return logo_scan_; } + // Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa // Easing::linear com a fallback. [[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; } @@ -208,6 +227,7 @@ class UiConfig { int title_attract_fade_to_black_ms_; TitleIntro title_intro_; TitleHeatHaze title_heat_haze_; + LogoScan logo_scan_; std::string menu_select_sound_; std::string menu_accept_sound_; diff --git a/source/game/scenes/logo_scene.cpp b/source/game/scenes/logo_scene.cpp index 172173f..68d47b8 100644 --- a/source/game/scenes/logo_scene.cpp +++ b/source/game/scenes/logo_scene.cpp @@ -38,9 +38,23 @@ LogoScene::LogoScene(std::shared_ptr logo, bool reset) audio->stopAllSounds(); } } - // Arranca el fade-in: el primer frame ja pinta negre opac que - // s'esvaeix cap a la imatge durant FADE_IN_MS. - fade_.begin(FADE_IN_MS, Transitions::FadeToBlack::Direction::FADE_IN); + // Arranca el revelat CRT: el primer frame ja pinta zero files (banda + // damunt del logo) i avança fins a tenir-lo visible al final. + const auto& s = UiConfig::get().logoScan(); + scan_.begin({ + .duration_ms = s.duration_ms, + .band_height_px = s.band_height_px, + .band_alpha = s.band_alpha, + .band_pulse_hz = s.band_pulse_hz, + .band_pulse_depth = s.band_pulse_depth, + .glitch_amplitude_px = s.glitch_amplitude_px, + .glitch_band_radius_px = s.glitch_band_radius_px, + .vhs_burst_per_sec = s.vhs_burst_per_sec, + .vhs_burst_duration_ms = s.vhs_burst_duration_ms, + .vhs_burst_height_px = s.vhs_burst_height_px, + .vhs_burst_amplitude_px = s.vhs_burst_amplitude_px, + .flash_ms = s.flash_ms, + }); } LogoScene::~LogoScene() = default; @@ -83,12 +97,16 @@ void LogoScene::update(int delta_ms) { if (phase_ != Phase::DONE) { phase_elapsed_ms_ += delta_ms; + // Tick dels efectes actius. `scan_` només fa feina visible + // durant Phase::SCAN; `fade_` només durant Phase::FADE_OUT. Tots + // dos són no-op si el seu duration_ms intern està a 0. + scan_.tick(delta_ms); fade_.tick(delta_ms); } switch (phase_) { - case Phase::FADE_IN: - if (phase_elapsed_ms_ >= FADE_IN_MS) { enterMiddle(); } + case Phase::SCAN: + if (scan_.isFinished()) { enterMiddle(); } break; case Phase::MIDDLE: // Música del títol al cor de la fase central. La crida és @@ -121,13 +139,22 @@ void LogoScene::render() { if (logo_) { const int X = (GameCanvas::WIDTH - logo_->getWidth()) / 2; const int Y = (GameCanvas::HEIGHT - logo_->getHeight()) / 2; - logo_->render(X, Y); + if (phase_ == Phase::SCAN) { + // Revelat CRT: la pròpia ScanReveal s'encarrega del blit + // fila a fila, de la banda lluminosa, dels glitches i del + // flash final. + scan_.render(*logo_, X, Y); + } else { + // MIDDLE i FADE_OUT: render normal, el logo ja està a + // pantalla complet i estable. + logo_->render(X, Y); + } } Notifier::get().render(); - // Capa de fosa: durant FADE_IN i FADE_OUT pinta el negre semitransparent - // per damunt del logo i del Notifier (no del RenderInfo, que és overlay - // de debug i ha de mantenir-se llegible). + // Capa de fosa: només FADE_OUT pinta negre semitransparent (el + // FADE_IN s'ha substituït per `scan_`). Va per damunt del logo i + // del Notifier, però per sota de RenderInfo (overlay de debug). fade_.render(); RenderInfo::get().render(); Screen::get().present(); diff --git a/source/game/scenes/logo_scene.hpp b/source/game/scenes/logo_scene.hpp index 6f418ea..6c407be 100644 --- a/source/game/scenes/logo_scene.hpp +++ b/source/game/scenes/logo_scene.hpp @@ -3,17 +3,20 @@ #include #include +#include "core/rendering/effects/scan_reveal.hpp" #include "core/rendering/transitions/fade_to_black.hpp" #include "core/scene/scene.hpp" class Surface; -// Escena del logotip de l'editora. FSM amb tres fases visibles -// (`FADE_IN` 500 ms → `MIDDLE` 5 s → `FADE_OUT` 500 ms) i una terminal -// (`DONE`) que dispara la transició cap a TITLE. A 2,5 s dins de -// `MIDDLE` arrenca la música del títol perquè entri al cor de l'escena -// i continue cap a TitleScene sense talls (Audio ja és idempotent quan -// es demana la mateixa pista que ja sona). +// Escena del logotip de l'editora. FSM amb tres fases visibles: +// `SCAN` — revelat tipus scanline CRT (durada YAML, ~1500 ms) +// `MIDDLE` — logo estable (`MIDDLE_MS`, 5 s) +// `FADE_OUT` — fade lineal a negre (`FADE_OUT_MS`, 500 ms) +// I una terminal `DONE` que dispara la transició cap a TITLE. A 2,5 s +// dins de `MIDDLE` arrenca la música del títol perquè entri al cor de +// l'escena i continue cap a TitleScene sense talls (Audio ja és +// idempotent quan es demana la mateixa pista que ja sona). // // Skip: qualsevol tecla no reservada salta directament a la transició // (sense fade-out) — el TitleScene té el seu propi `intro.fade_in_ms`, @@ -24,7 +27,6 @@ class Surface; // silenciós encara que veníssem del joc en marxa. class LogoScene : public Scene { public: - static constexpr int FADE_IN_MS = 500; static constexpr int MIDDLE_MS = 5000; static constexpr int FADE_OUT_MS = 500; // Offset dins de MIDDLE en què comença a sonar la pista del títol; @@ -40,7 +42,7 @@ class LogoScene : public Scene { void render() override; private: - enum class Phase : std::uint8_t { FADE_IN, + enum class Phase : std::uint8_t { SCAN, MIDDLE, FADE_OUT, DONE }; @@ -51,10 +53,11 @@ class LogoScene : public Scene { [[nodiscard]] static auto wantsSkip() -> bool; std::shared_ptr logo_; - Phase phase_{Phase::FADE_IN}; + Phase phase_{Phase::SCAN}; int phase_elapsed_ms_{0}; bool music_started_{false}; - // Capa de fosa reutilitzada: comença amb FADE_IN al ctor i es - // reaprofita amb FADE_OUT en entrar a la fase corresponent. + // Capa de fosa per al FADE_OUT cap a TITLE. El FADE_IN inicial s'ha + // substituït per `scan_` (revelat CRT amb glitch). Transitions::FadeToBlack fade_; + Effects::ScanReveal scan_; }; diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 263105e..e8722c4 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -352,6 +352,43 @@ namespace Defaults { constexpr float SPEED_HZ = 0.6F; } // namespace HeatHaze } // namespace Title + + // Escena LOGO (logotip de l'editora). El revelat del logo a la + // fase SCAN imita un escaneig CRT amb una banda lluminosa que + // baixa de dalt a baix, glitch al voltant de la banda i bursts + // VHS ocasionals damunt la part ja revelada. Quan la banda toca + // el peu del logo, un flash blanc breu remata l'efecte i s'entra + // a MIDDLE (logo estable). + namespace Logo::Scan { + constexpr int DURATION_MS = 1500; + constexpr int BAND_HEIGHT_PX = 4; + constexpr int BAND_ALPHA = 200; + // Pulse de brillantor sinusoidal sobre l'alpha base de la + // banda — simula el ritme de refresc d'un CRT. La fase + // avança a `BAND_PULSE_HZ` cicles/segon i la profunditat + // marca quant cau l'alpha al ventre (0 = sense pulse, + // 1 = la banda arriba a alpha 0 als ventres). Valors per + // defecte: 20 Hz pulsacional ben visible a 60 fps sense + // aliasing dur, profunditat 0.45 (alpha oscil·la entre el + // 55% i el 100% de BAND_ALPHA). + constexpr float BAND_PULSE_HZ = 20.0F; + constexpr float BAND_PULSE_DEPTH = 0.45F; + // Glitch a la banda d'impacte: les files dins del radi + // reben un offset horitzontal aleatori d'amplitud + // decreixent amb la distància a la banda. + constexpr int GLITCH_AMPLITUDE_PX = 6; + constexpr int GLITCH_BAND_RADIUS_PX = 12; + // Bursts VHS damunt la part ja revelada: ocasionalment + // (~per_sec mitjà) una franja horitzontal es desplaça + // durant `duration_ms` per simular tracking error. + constexpr int VHS_BURST_PER_SEC = 3; + constexpr int VHS_BURST_DURATION_MS = 80; + constexpr int VHS_BURST_HEIGHT_PX = 8; + constexpr int VHS_BURST_AMPLITUDE_PX = 8; + // Flash blanc final quan la banda surt per sota — punt + // visual de tancament del scan abans d'entrar a MIDDLE. + constexpr int FLASH_MS = 100; + } // namespace Logo::Scan } // namespace UI #ifdef _DEBUG