Compare commits

...
3 Commits
8 changed files with 205 additions and 77 deletions

No files matched your search

+33 -6
View File
@@ -50,6 +50,8 @@ namespace Effects {
burst_remaining_ms_ = 0;
burst_y_norm_ = 0.0F;
burst_dx_ = 0;
tail_started_ = false;
tail_remaining_ms_ = 0;
}
void ScanReveal::tick(int delta_ms) {
@@ -63,9 +65,13 @@ namespace Effects {
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).
// 3) Spawn d'un nou burst. Permès durant el scan i durant el
// glitch tail post-flash; bloquejat exactament durant el
// flash perquè aquell és un punt visual de tancament net.
// 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 bool TAIL_ACTIVE = tail_started_ && tail_remaining_ms_ > 0;
const bool SCAN_ONGOING = elapsed_ms_ < params_.duration_ms;
if (burst_remaining_ms_ == 0 && (SCAN_ONGOING || TAIL_ACTIVE) && 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) {
@@ -86,6 +92,17 @@ namespace Effects {
if (flash_remaining_ms_ > 0) {
flash_remaining_ms_ = std::max(0, flash_remaining_ms_ - delta_ms);
}
// 5) Glitch tail: arranca un sol cop quan el scan ha acabat i el
// flash s'ha esgotat. Si no hi havia flash configurat, també
// arranca tot just acabar el scan.
if (!tail_started_ && elapsed_ms_ >= params_.duration_ms && flash_remaining_ms_ <= 0 && params_.tail_glitch_ms > 0) {
tail_started_ = true;
tail_remaining_ms_ = params_.tail_glitch_ms;
}
if (tail_remaining_ms_ > 0) {
tail_remaining_ms_ = std::max(0, tail_remaining_ms_ - delta_ms);
}
}
auto ScanReveal::isActive() const -> bool {
@@ -94,8 +111,12 @@ namespace Effects {
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;
// Acabat = scan al final + flash exhaurit + glitch tail
// exhaurit (o sense tail configurat).
const bool SCAN_DONE = elapsed_ms_ >= params_.duration_ms;
const bool FLASH_DONE = flash_remaining_ms_ <= 0;
const bool TAIL_DONE = (params_.tail_glitch_ms <= 0) || (tail_started_ && tail_remaining_ms_ <= 0);
return SCAN_DONE && FLASH_DONE && TAIL_DONE;
}
void ScanReveal::render(const Surface& src, int x, int y) const {
@@ -118,15 +139,21 @@ namespace Effects {
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;
// Durant el glitch tail post-flash, BAND_TOP queda just damunt
// del peu del logo, fet que (amb el codi de jitter que segueix)
// generaria jitter residual a les últimes files. Aquí el
// suprimim explícitament: el tail només viu de bursts VHS.
const bool TAIL_ACTIVE = tail_started_ && tail_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.
// lineal cap als marges del radi de glitch. Inactiu durant
// el glitch tail (no hi ha banda).
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) {
if (!TAIL_ACTIVE && 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(
+15 -2
View File
@@ -18,8 +18,11 @@ namespace Effects {
// 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í.
// dispara un flash blanc breu. Si `tail_glitch_ms > 0`, després del
// flash el logo queda totalment visible mentre els bursts VHS
// continuen saltant durant aquest temps (glitch tail). Quan el tail
// s'exhaureix `isFinished()=true`. El caller decideix què fa amb el
// control de fase a partir d'aquí.
//
// Ús:
// ScanReveal sr;
@@ -43,6 +46,10 @@ namespace Effects {
int vhs_burst_height_px;
int vhs_burst_amplitude_px;
int flash_ms;
// Glitch tail post-flash: el logo queda visible sencer i els
// bursts VHS continuen saltant durant aquest temps. 0 ho
// desactiva (efecte acaba al flash).
int tail_glitch_ms;
};
ScanReveal();
@@ -75,6 +82,12 @@ namespace Effects {
float burst_y_norm_{0.0F};
int burst_dx_{0};
// Glitch tail post-flash: arranca quan el flash s'esgota i
// permet que els bursts continuen saltant amb el logo ja del tot
// visible. `isFinished()` espera al final d'aquest tail.
bool tail_started_{false};
int tail_remaining_ms_{0};
// RNG per als bursts. Seed des de random_device a `begin()`
// perquè cada execució sigui visualment diferent.
std::minstd_rand rng_;
+17
View File
@@ -131,6 +131,7 @@ UiConfig::UiConfig()
},
logo_scan_{
.duration_ms = Defaults::UI::Logo::Scan::DURATION_MS,
.tail_glitch_ms = Defaults::UI::Logo::Scan::TAIL_GLITCH_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,
@@ -143,6 +144,13 @@ UiConfig::UiConfig()
.vhs_burst_amplitude_px = Defaults::UI::Logo::Scan::VHS_BURST_AMPLITUDE_PX,
.flash_ms = Defaults::UI::Logo::Scan::FLASH_MS,
},
logo_timeline_{
.black_intro_ms = Defaults::UI::Logo::Timeline::BLACK_INTRO_MS,
.hold_silent_ms = Defaults::UI::Logo::Timeline::HOLD_SILENT_MS,
.hold_with_music_ms = Defaults::UI::Logo::Timeline::HOLD_WITH_MUSIC_MS,
.fade_out_ms = Defaults::UI::Logo::Timeline::FADE_OUT_MS,
.black_outro_ms = Defaults::UI::Logo::Timeline::BLACK_OUTRO_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),
@@ -301,6 +309,7 @@ void UiConfig::load(const std::string& file_path) {
if (l.contains("scan")) {
const auto& s = l["scan"];
readField(s, "duration_ms", logo_scan_.duration_ms);
readField(s, "tail_glitch_ms", logo_scan_.tail_glitch_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);
@@ -313,6 +322,14 @@ void UiConfig::load(const std::string& file_path) {
readField(s, "vhs_burst_amplitude_px", logo_scan_.vhs_burst_amplitude_px);
readField(s, "flash_ms", logo_scan_.flash_ms);
}
if (l.contains("timeline")) {
const auto& tl = l["timeline"];
readField(tl, "black_intro_ms", logo_timeline_.black_intro_ms);
readField(tl, "hold_silent_ms", logo_timeline_.hold_silent_ms);
readField(tl, "hold_with_music_ms", logo_timeline_.hold_with_music_ms);
readField(tl, "fade_out_ms", logo_timeline_.fade_out_ms);
readField(tl, "black_outro_ms", logo_timeline_.black_outro_ms);
}
}
if (yaml.contains("notifier")) {
+18 -1
View File
@@ -124,9 +124,12 @@ class UiConfig {
[[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).
// `duration_ms` és la durada de la línia descendent; `tail_glitch_ms`
// és el temps post-flash en què el logo queda totalment visible
// però els bursts VHS encara hi salten (glitch tail).
struct LogoScan {
int duration_ms;
int tail_glitch_ms;
int band_height_px;
int band_alpha;
float band_pulse_hz;
@@ -142,6 +145,19 @@ class UiConfig {
[[nodiscard]] auto logoScan() const -> const LogoScan& { return logo_scan_; }
// Timeline lineal de la LogoScene. Cada camp és la durada (ms) d'una
// fase. La SCAN no apareix aquí perquè la marca el propi
// `LogoScan` (duration_ms + tail_glitch_ms + flash_ms).
struct LogoTimeline {
int black_intro_ms;
int hold_silent_ms;
int hold_with_music_ms;
int fade_out_ms;
int black_outro_ms;
};
[[nodiscard]] auto logoTimeline() const -> const LogoTimeline& { return logo_timeline_; }
// 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_; }
@@ -229,6 +245,7 @@ class UiConfig {
TitleIntro title_intro_;
TitleHeatHaze title_heat_haze_;
LogoScan logo_scan_;
LogoTimeline logo_timeline_;
std::string menu_select_sound_;
std::string menu_accept_sound_;
+66 -38
View File
@@ -38,6 +38,21 @@ LogoScene::LogoScene(std::shared_ptr<Surface> logo, bool reset)
audio->stopAllSounds();
}
}
// Arrenquem en BLACK_INTRO: ni so ni efecte. El glitch sound i el
// begin() de scan_ es disparen quan entrem a SCAN.
}
LogoScene::~LogoScene() = default;
auto LogoScene::wantsSkip() -> bool {
return std::ranges::any_of(EventBuffer::events, [](const SDL_Event& ev) {
return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode);
});
}
void LogoScene::enterScan() {
phase_ = Phase::SCAN;
phase_elapsed_ms_ = 0;
// So del scanline reveal: una sola pulsació al començament de la
// fase SCAN, en grup INTERFACE perquè no es vegi afectat pel volum
// de sons del joc i quedi sempre audible.
@@ -60,26 +75,36 @@ LogoScene::LogoScene(std::shared_ptr<Surface> logo, bool reset)
.vhs_burst_height_px = s.vhs_burst_height_px,
.vhs_burst_amplitude_px = s.vhs_burst_amplitude_px,
.flash_ms = s.flash_ms,
.tail_glitch_ms = s.tail_glitch_ms,
});
}
LogoScene::~LogoScene() = default;
auto LogoScene::wantsSkip() -> bool {
return std::ranges::any_of(EventBuffer::events, [](const SDL_Event& ev) {
return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode);
});
}
void LogoScene::enterMiddle() {
phase_ = Phase::MIDDLE;
void LogoScene::enterHoldSilent() {
phase_ = Phase::HOLD_SILENT;
phase_elapsed_ms_ = 0;
}
void LogoScene::enterHoldMusic() {
phase_ = Phase::HOLD_MUSIC;
phase_elapsed_ms_ = 0;
// Música del títol al començament d'aquesta fase. La crida és
// idempotent (Audio detecta same-track + same-loop i no reinicia),
// així que TitleScene podrà fer el seu propi playMusic sense
// interrompre la pista durant el FADE_OUT i el BLACK_OUTRO.
if (auto* audio = Audio::get(); audio != nullptr) {
audio->playMusic("title.ogg", -1);
}
}
void LogoScene::enterFadeOut() {
phase_ = Phase::FADE_OUT;
phase_elapsed_ms_ = 0;
fade_.begin(FADE_OUT_MS, Transitions::FadeToBlack::Direction::FADE_OUT);
fade_.begin(UiConfig::get().logoTimeline().fade_out_ms, Transitions::FadeToBlack::Direction::FADE_OUT);
}
void LogoScene::enterBlackOutro() {
phase_ = Phase::BLACK_OUTRO;
phase_elapsed_ms_ = 0;
}
void LogoScene::enterDone() {
@@ -103,32 +128,32 @@ 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.
// Tick dels efectes actius. Tots dos són no-op si el seu
// duration_ms intern està a 0; només cal cridar-los si la fase
// corresponent està viva o just acabada.
scan_.tick(delta_ms);
fade_.tick(delta_ms);
}
const auto& tl = UiConfig::get().logoTimeline();
switch (phase_) {
case Phase::SCAN:
if (scan_.isFinished()) { enterMiddle(); }
case Phase::BLACK_INTRO:
if (phase_elapsed_ms_ >= tl.black_intro_ms) { enterScan(); }
break;
case Phase::MIDDLE:
// Música del títol al cor de la fase central. La crida és
// idempotent (Audio detecta same-track + same-loop i no
// reinicia), així que TitleScene podrà fer el seu propi
// playMusic sense interrompre la pista.
if (!music_started_ && phase_elapsed_ms_ >= MUSIC_TRIGGER_MS) {
music_started_ = true;
if (auto* audio = Audio::get(); audio != nullptr) {
audio->playMusic("title.ogg", -1);
}
}
if (phase_elapsed_ms_ >= MIDDLE_MS) { enterFadeOut(); }
case Phase::SCAN:
if (scan_.isFinished()) { enterHoldSilent(); }
break;
case Phase::HOLD_SILENT:
if (phase_elapsed_ms_ >= tl.hold_silent_ms) { enterHoldMusic(); }
break;
case Phase::HOLD_MUSIC:
if (phase_elapsed_ms_ >= tl.hold_with_music_ms) { enterFadeOut(); }
break;
case Phase::FADE_OUT:
if (phase_elapsed_ms_ >= FADE_OUT_MS) { enterDone(); }
if (phase_elapsed_ms_ >= tl.fade_out_ms) { enterBlackOutro(); }
break;
case Phase::BLACK_OUTRO:
if (phase_elapsed_ms_ >= tl.black_outro_ms) { enterDone(); }
break;
case Phase::DONE:
break;
@@ -142,25 +167,28 @@ void LogoScene::render() {
constexpr std::uint32_t BLACK_ARGB = 0xFF000000U;
Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BLACK_ARGB);
if (logo_) {
// BLACK_INTRO i BLACK_OUTRO no pinten logo: només el negre del
// fillRect d'aquí dalt. La resta de fases sí.
const bool SHOW_LOGO = phase_ != Phase::BLACK_INTRO && phase_ != Phase::BLACK_OUTRO;
if (logo_ && SHOW_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.
// Revelat CRT + glitch tail: la pròpia ScanReveal s'encarrega
// del blit fila a fila, de la banda lluminosa, dels glitches,
// del flash final i dels bursts del tail.
scan_.render(*logo_, X, Y);
} else {
// MIDDLE i FADE_OUT: render normal, el logo ja està a
// pantalla complet i estable.
// HOLD_SILENT, HOLD_MUSIC, FADE_OUT: render normal, el logo
// ja està a pantalla complet i estable.
logo_->render(X, Y);
}
}
Notifier::get().render();
// 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).
// Capa de fosa: només pinta durant FADE_OUT (alpha creixent). En
// BLACK_OUTRO ja deixem la pantalla negra del fillRect, així que no
// cal ni que `fade_` segueixi viu.
fade_.render();
RenderInfo::get().render();
Screen::get().present();
+26 -23
View File
@@ -9,14 +9,22 @@
class Surface;
// 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).
// Escena del logotip de l'editora. Timeline lineal en 7 fases visibles
// (durades configurables des de `logo.timeline.*` al YAML, excepte SCAN
// que la marca `logo.scan.duration_ms` + `tail_glitch_ms` + `flash_ms`):
//
// `BLACK_INTRO` — negre pur abans d'arrencar res.
// `SCAN` — revelat scanline CRT amb glitch tail. So
// `logoGlitchSound` es dispara just al començar
// aquesta fase (no al constructor).
// `HOLD_SILENT` — logo estable, en silenci, marge entre el final
// del glitch i l'arrencada de la música.
// `HOLD_MUSIC` — logo estable amb la música del títol sonant.
// `FADE_OUT` — fade lineal a negre.
// `BLACK_OUTRO` — negre amb la música encara sonant. TitleScene
// continuarà la pista de manera idempotent (Audio
// detecta same-track + same-loop i no reinicia).
// `DONE` — terminal; dispara la transició cap a TITLE.
//
// Skip: qualsevol tecla no reservada salta directament a la transició
// (sense fade-out) — el TitleScene té el seu propi `intro.fade_in_ms`,
@@ -27,14 +35,6 @@ class Surface;
// silenciós encara que veníssem del joc en marxa.
class LogoScene : public Scene {
public:
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;
// triat perquè quedi clarament al cor de la fase central (logo
// estable) i prou abans del fade-out per donar continuïtat sonora a
// TitleScene.
static constexpr int MUSIC_TRIGGER_MS = 2500;
LogoScene(std::shared_ptr<Surface> logo, bool reset);
~LogoScene() override;
@@ -42,22 +42,25 @@ class LogoScene : public Scene {
void render() override;
private:
enum class Phase : std::uint8_t { SCAN,
MIDDLE,
enum class Phase : std::uint8_t { BLACK_INTRO,
SCAN,
HOLD_SILENT,
HOLD_MUSIC,
FADE_OUT,
BLACK_OUTRO,
DONE };
void enterMiddle();
void enterScan();
void enterHoldSilent();
void enterHoldMusic();
void enterFadeOut();
void enterBlackOutro();
void enterDone();
[[nodiscard]] static auto wantsSkip() -> bool;
std::shared_ptr<Surface> logo_;
Phase phase_{Phase::SCAN};
Phase phase_{Phase::BLACK_INTRO};
int phase_elapsed_ms_{0};
bool music_started_{false};
// 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_;
};
+29 -6
View File
@@ -360,14 +360,37 @@ namespace Defaults {
} // 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).
// Escena LOGO (logotip de l'editora). La timeline és lineal i
// composta per fases independents amb durades configurables des
// del YAML (`logo.timeline.*`). L'efecte CRT de revelat (scan
// line + glitch tail) ho gestiona `Effects::ScanReveal` i les
// seves constants viuen al sub-namespace `Scan` d'aquí baix.
namespace Logo::Timeline {
// Negre inicial abans que comence el revelat. Permet entrar
// a l'escena des d'una transició/fade extern sense talls.
constexpr int BLACK_INTRO_MS = 2000;
// Silenci amb logo estable abans d'arrencar la música del
// títol — espai per "respirar" entre el glitch i la pista.
constexpr int HOLD_SILENT_MS = 1000;
// Logo estable amb música del títol sonant. Quan acaba,
// comença el fade-out.
constexpr int HOLD_WITH_MUSIC_MS = 2500;
// Fade lineal a negre.
constexpr int FADE_OUT_MS = 500;
// Negre final amb música encara sonant — la TitleScene
// continuarà la pista de manera idempotent.
constexpr int BLACK_OUTRO_MS = 1000;
} // namespace Logo::Timeline
// Constants del revelat CRT pròpiament dit. La banda lluminosa
// baixa de dalt a baix amb glitch al seu voltant i bursts VHS
// ocasionals damunt la part ja revelada. Quan la banda toca el
// peu del logo, un flash blanc remata l'efecte; després un
// "tail" manté el logo visible amb bursts durant `TAIL_GLITCH_MS`
// abans que `isFinished()` retorne true.
namespace Logo::Scan {
constexpr int DURATION_MS = 1500;
constexpr int TAIL_GLITCH_MS = 1500;
constexpr int BAND_HEIGHT_PX = 4;
constexpr int BAND_ALPHA = 200;
// Pulse de brillantor sinusoidal sobre l'alpha base de la
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.35.0"; // Single source of truth — Makefile el grep
constexpr const char* VERSION = "0.36.0"; // Single source of truth — Makefile el grep
} // namespace Texts
namespace GameCanvas {