feat(logo): scanline reveal CRT amb glitch, bursts VHS i pulse de banda

This commit is contained in:
JailDesigner committed 2026-05-07 13:52:57 +02:00
1 parent 243ced7498
commit e32cff9dac
9 files changed
+439 -19

No files matched your search

+1
View File
@@ -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
+24
View File
@@ -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
@@ -0,0 +1,192 @@
#include "core/rendering/effects/scan_reveal.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <numbers>
#include <random>
#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<std::uint32_t>((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<float>(x) / static_cast<float>(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<float>(params_.vhs_burst_per_sec) / 1000.0F) * static_cast<float>(delta_ms);
std::uniform_real_distribution<float> 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<int> 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<float>(src.getWidth());
const float T = static_cast<float>(elapsed_ms_) / static_cast<float>(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<float>(params_.band_height_px) + (T * static_cast<float>(H + params_.band_height_px));
const int BAND_TOP = static_cast<int>(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<int>(burst_y_norm_ * static_cast<float>(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<float>(DIST_TO_BAND) / static_cast<float>(params_.glitch_band_radius_px));
const float JIT = rowJitter(row, TIME_BUCKET);
dx += static_cast<int>(std::lround(
FALLOFF * static_cast<float>(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<float>(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<float>;
const float PHASE = TWO_PI * params_.band_pulse_hz * (static_cast<float>(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<float>(params_.band_alpha) * MULT;
const auto ALPHA = static_cast<std::uint8_t>(
std::clamp(static_cast<int>(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<float>(flash_remaining_ms_) / static_cast<float>(params_.flash_ms);
const auto ALPHA = static_cast<std::uint8_t>(
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
@@ -0,0 +1,83 @@
#pragma once
#include <cstdint>
#include <random>
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
+33
View File
@@ -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);
+20
View File
@@ -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_;
+35 -8
View File
@@ -38,9 +38,23 @@ LogoScene::LogoScene(std::shared_ptr<Surface> 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;
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();
+14 -11
View File
@@ -3,17 +3,20 @@
#include <cstdint>
#include <memory>
#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<Surface> 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_;
};
+37
View File
@@ -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