Compare commits

...
28 Commits
Author SHA1 Message Date
JailDesigner 7d5a4cee8e chore(version): bump 0.34.0 -> 0.35.0 2026-05-07 14:07:44 +02:00
JailDesigner d560623e00 merge: feature/logo-scanline-reveal 2026-05-07 14:07:26 +02:00
JailDesigner 3f88dfaaee feat(logo): afegit data/sounds/glitch.wav 2026-05-07 14:07:21 +02:00
JailDesigner e1bd75e00d feat(logo): so glitch.wav sincronitzat amb el scanline reveal 2026-05-07 14:04:48 +02:00
JailDesigner e32cff9dac feat(logo): scanline reveal CRT amb glitch, bursts VHS i pulse de banda 2026-05-07 13:52:57 +02:00
JailDesigner 243ced7498 feat(title): seqüència d'intro temporitzada amb slide-in del peu 2026-05-07 13:08:13 +02:00
JailDesigner fceeb11cdd feat(title): cosmètics i posicions a YAML, font Tiny5 al copyright 2026-05-07 11:51:00 +02:00
JailDesigner 233fcb8226 feat(title): game_logo.gif al lloc del text i font PixeloidSans-Bold per al copyright 2026-05-06 22:35:27 +02:00
JailDesigner 8bc45ccedc feat(logo): FSM amb fade-in/middle/fade-out (500/5000/500 ms) i música del títol a 2,5 s del centre 2026-05-06 20:01:31 +02:00
JailDesigner f7ab6a641a feat(logo,menu): reiniciar des de servei → LOGO amb reset (atura música) i logo_jailgames.gif centrat 2026-05-06 19:54:30 +02:00
JailDesigner 8eaa91f309 chore(version): bump 0.33.0 -> 0.34.0 2026-05-06 19:38:46 +02:00
JailDesigner 23fdf6e1bd merge: refactor escena → flux centralitzat (SceneId/ExitRequest), timeline pur a RoomFsm, FadeToBlack reutilitzat i fade-in inicial a TITLE 2026-05-06 19:36:09 +02:00
JailDesigner 1431f27cfa feat(title): fade-in inicial de negre a escena (500 ms, configurable a ui.yaml) 2026-05-06 19:34:59 +02:00
JailDesigner 29cb4c2211 refactor(room-fsm): timeline pur amb music_tail; fades INTRO/OUTRO via Transitions::FadeToBlack i sense callbacks de fi de música 2026-05-06 19:25:19 +02:00
JailDesigner 3f09858203 refactor(audio,transitions): durada de música pre-calculada al loadMusic i Direction (FADE_IN/FADE_OUT) a FadeToBlack 2026-05-06 19:10:18 +02:00
JailDesigner b6d6ee471c refactor(scenes): flux centralitzat amb SceneId+ExitRequest; TitleScene attract → LOGO amb fade-to-black 2026-05-06 18:33:31 +02:00
JailDesigner 25ce524470 merge: feat/scene-title — TITLE com a escena jugable amb música, SFX i FSM IDLE/STARTING 2026-05-06 18:15:17 +02:00
JailDesigner 921e10bbb3 feat(title): música title.ogg amb fade-out, SFX player_start al START i bombament continu del stream sortint per evitar talls quan SDL drena a 2x 2026-05-06 17:44:30 +02:00
JailDesigner 36fc496226 feat(title): parpadeig STARTING amb cicle 50/50 i període configurable 2026-05-06 14:04:27 +02:00
JailDesigner 0618452224 feat(transitions): mosaic dissolve fade-out al final de la fase STARTING (configurable a ui.yaml) 2026-05-06 13:54:09 +02:00
JailDesigner ca64115bfe feat(title): FSM IDLE/STARTING amb START per jugador i propagació de StartingPlayers 2026-05-06 13:28:34 +02:00
JailDesigner 1fc3ab8e31 feat(effects): heat-haze sinusoidal al fons del títol (configurable a ui.yaml) 2026-05-06 11:48:29 +02:00
JailDesigner 9233bd8d75 feat(title): fons title_bg.gif i outline 4-dir off-white als textos taronges 2026-05-06 11:27:45 +02:00
JailDesigner e7a024bcdb fix(fonts): regenerar ari amb box_height auto (14, no 11) — el override forçat tallava els glifos 2026-05-06 10:15:49 +02:00
JailDesigner 158968e2c2 feat(title): peu amb minilogo i copyright; fonts ari regenerades amb © 2026-05-06 10:00:30 +02:00
JailDesigner dadbee9bad feat(title): primera passada — fons casi negre, títol+ARCADE i prompt START intermitent (configurable a ui.yaml) 2026-05-06 09:01:28 +02:00
JailDesigner 5710568af8 feat(input): binding START per jugador (teclat 1/2 fix, mando remapejable; pausa només per F11) 2026-05-06 08:23:01 +02:00
JailDesigner 165499b9d4 Merge branch 'feat/player-immunity' 2026-05-05 13:19:56 +02:00
54 changed files with 2281 additions and 245 deletions

No files matched your search

+4
View File
@@ -92,6 +92,10 @@ set(APP_SOURCES
source/core/rendering/sprite/moving_sprite.cpp source/core/rendering/sprite/moving_sprite.cpp
source/core/rendering/sprite/animated_sprite.cpp source/core/rendering/sprite/animated_sprite.cpp
source/core/rendering/sprite/animated_sprite_parser.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 source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp
source/core/rendering/sdl3gpu/viewport.cpp source/core/rendering/sdl3gpu/viewport.cpp
source/core/rendering/sdl3gpu/pipelines/crtpi_pipeline.cpp source/core/rendering/sdl3gpu/pipelines/crtpi_pipeline.cpp
+133
View File
@@ -0,0 +1,133 @@
# Font: Tiny5-Regular — generado desde Tiny5-Regular.ttf size 8
# Generado con font_gen.py
box_width 6
box_height 9
columns 15
# codepoint_decimal ancho_visual
32 2 # U+0020
33 1 # !
34 3 # "
35 5 # #
36 3 # $
37 4 # %
38 4 # &
39 1 # '
40 2 # (
41 2 # )
42 3 # *
43 3 # +
44 2 # ,
45 3 # -
46 1 # .
47 2 # /
48 3 # 0
49 2 # 1
50 3 # 2
51 3 # 3
52 3 # 4
53 3 # 5
54 3 # 6
55 3 # 7
56 3 # 8
57 3 # 9
58 1 # :
59 2 # ;
60 3 # <
61 3 # =
62 3 # >
63 3 # ?
64 4 # @
65 4 # A
66 4 # B
67 4 # C
68 4 # D
69 4 # E
70 4 # F
71 4 # G
72 4 # H
73 1 # I
74 3 # J
75 4 # K
76 3 # L
77 5 # M
78 4 # N
79 4 # O
80 4 # P
81 4 # Q
82 4 # R
83 4 # S
84 3 # T
85 4 # U
86 3 # V
87 5 # W
88 3 # X
89 3 # Y
90 3 # Z
91 2 # [
92 2 # \
93 2 # ]
94 3 # ^
95 3 # _
96 2 # `
97 3 # a
98 3 # b
99 3 # c
100 3 # d
101 3 # e
102 2 # f
103 3 # g
104 3 # h
105 1 # i
106 1 # j
107 3 # k
108 1 # l
109 5 # m
110 3 # n
111 3 # o
112 3 # p
113 3 # q
114 2 # r
115 3 # s
116 2 # t
117 3 # u
118 3 # v
119 5 # w
120 3 # x
121 3 # y
122 3 # z
123 3 # {
124 1 # |
125 3 # }
126 2 # ~
192 4 # À
193 4 # Á
200 4 # È
201 4 # É
204 2 # Ì
205 2 # Í
210 4 # Ò
211 4 # Ó
217 4 # Ù
218 4 # Ú
209 4 # Ñ
199 4 # Ç
224 3 # à
225 3 # á
232 3 # è
233 3 # é
236 2 # ì
237 2 # í
242 3 # ò
243 3 # ó
249 3 # ù
250 3 # ú
241 3 # ñ
231 3 # ç
161 1 # ¡
191 3 # ¿
171 5 # «
187 5 # »
183 1 # ·
169 3 # ©
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+6 -5
View File
@@ -1,8 +1,8 @@
# Font: ari-w9500-condensed-display — generado desde ari-w9500-condensed-display.ttf size 11 # Font: ari-w9500-condensed-display — generado desde ari-w9500-condensed-display.ttf size 11
# Generado con tools/font_gen/font_gen.py # Generado con font_gen.py
box_width 12 box_width 12
box_height 11 box_height 14
columns 15 columns 15
# codepoint_decimal ancho_visual # codepoint_decimal ancho_visual
@@ -105,8 +105,8 @@ columns 15
193 8 # Á 193 8 # Á
200 7 # È 200 7 # È
201 7 # É 201 7 # É
204 3 # Ì 204 4 # Ì
205 3 # Í 205 4 # Í
210 9 # Ò 210 9 # Ò
211 9 # Ó 211 9 # Ó
217 7 # Ù 217 7 # Ù
@@ -118,7 +118,7 @@ columns 15
232 7 # è 232 7 # è
233 7 # é 233 7 # é
236 4 # ì 236 4 # ì
237 3 # í 237 4 # í
242 7 # ò 242 7 # ò
243 7 # ó 243 7 # ó
249 7 # ù 249 7 # ù
@@ -130,3 +130,4 @@ columns 15
171 10 # « 171 10 # «
187 10 # » 187 10 # »
183 3 # · 183 3 # ·
169 10 # ©
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+6 -5
View File
@@ -1,8 +1,8 @@
# Font: ari-w9500-display — generado desde ari-w9500-display.ttf size 11 # Font: ari-w9500-display — generado desde ari-w9500-display.ttf size 11
# Generado con tools/font_gen/font_gen.py # Generado con font_gen.py
box_width 12 box_width 12
box_height 11 box_height 14
columns 15 columns 15
# codepoint_decimal ancho_visual # codepoint_decimal ancho_visual
@@ -105,8 +105,8 @@ columns 15
193 9 # Á 193 9 # Á
200 8 # È 200 8 # È
201 8 # É 201 8 # É
204 3 # Ì 204 4 # Ì
205 3 # Í 205 4 # Í
210 10 # Ò 210 10 # Ò
211 10 # Ó 211 10 # Ó
217 8 # Ù 217 8 # Ù
@@ -118,7 +118,7 @@ columns 15
232 8 # è 232 8 # è
233 8 # é 233 8 # é
236 4 # ì 236 4 # ì
237 3 # í 237 4 # í
242 8 # ò 242 8 # ò
243 8 # ó 243 8 # ó
249 8 # ù 249 8 # ù
@@ -130,3 +130,4 @@ columns 15
171 10 # « 171 10 # «
187 10 # » 187 10 # »
183 3 # · 183 3 # ·
169 10 # ©
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+5
View File
@@ -39,6 +39,10 @@ hud:
pyramid: "Piramide" pyramid: "Piramide"
room: "Sala" room: "Sala"
# Textos de l'escena TITLE.
title:
press_start: "Polsa START per a començar"
# Textos de la transició d'entrada/sortida de sala (FSM de GameScene). # Textos de la transició d'entrada/sortida de sala (FSM de GameScene).
# Tant l'INTRO com l'OUTRO es renderitzen com a dues línies separades # Tant l'INTRO com l'OUTRO es renderitzen com a dues línies separades
# (cadascuna amb la seua animació). L'INTRO accepta els placeholders # (cadascuna amb la seua animació). L'INTRO accepta els placeholders
@@ -76,6 +80,7 @@ menu:
move_right: "Dreta" move_right: "Dreta"
jump: "Saltar" jump: "Saltar"
attack: "Atacar" attack: "Atacar"
start: "Start"
service: "Opcions" service: "Opcions"
gamepad_p1: "Comandament P1" gamepad_p1: "Comandament P1"
gamepad_p2: "Comandament P2" gamepad_p2: "Comandament P2"
+5
View File
@@ -39,6 +39,10 @@ hud:
pyramid: "Pyramid" pyramid: "Pyramid"
room: "Room" room: "Room"
# Texts of the TITLE scene.
title:
press_start: "Press START to begin"
# Room transition texts (GameScene FSM intro/outro). Both INTRO and # Room transition texts (GameScene FSM intro/outro). Both INTRO and
# OUTRO render as two independent lines with their own animation. # OUTRO render as two independent lines with their own animation.
# INTRO uses `{p}` (pyramid) and `{r}` (room) placeholders; OUTRO has # INTRO uses `{p}` (pyramid) and `{r}` (room) placeholders; OUTRO has
@@ -75,6 +79,7 @@ menu:
move_right: "Right" move_right: "Right"
jump: "Jump" jump: "Jump"
attack: "Attack" attack: "Attack"
start: "Start"
service: "Options" service: "Options"
gamepad_p1: "Gamepad P1" gamepad_p1: "Gamepad P1"
gamepad_p2: "Gamepad P2" gamepad_p2: "Gamepad P2"
Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+117
View File
@@ -58,6 +58,15 @@ fonts:
gif: "data/fonts/ari-w9500-condensed-display.gif" gif: "data/fonts/ari-w9500-condensed-display.gif"
fnt: "data/fonts/ari-w9500-condensed-display.fnt" fnt: "data/fonts/ari-w9500-condensed-display.fnt"
kerning: 0 kerning: 0
# Copyright de la TITLE: pixel-art estricte (Tiny5-Regular size 5,
# caixa 3×6 px) generat amb font-gen. Diferenciada de la `placeholder`
# que pinta el "press start" — així la jerarquia tipogràfica del peu
# queda neta.
copyright:
name: "Tiny5-Regular"
gif: "data/fonts/Tiny5-Regular.gif"
fnt: "data/fonts/Tiny5-Regular.fnt"
kerning: 1
hud: hud:
bg_enabled: false bg_enabled: false
@@ -98,3 +107,111 @@ pause:
sounds: sounds:
menu_select: "menu_select.wav" menu_select: "menu_select.wav"
menu_accept: "menu_accept.wav" menu_accept: "menu_accept.wav"
# Disparat quan un jugador prem START a la TITLE (primera pulsació o
# la del segon jugador dins de la finestra de STARTING).
player_start: "player_start.wav"
# Soroll/scratch sincronitzat amb la fase SCAN de LogoScene (scanline
# reveal). Es dispara al constructor de l'escena.
logo_glitch: "glitch.wav"
# Escena TITLE (placeholder visual mentre no hi haja artwork).
# `bg_color` i `text_color` són índexs sobre la paleta "ui" (uzebox.pal):
# 1 = (36,0,0) gairebé negre amb un mínim de càlid; 31 = (255,109,0)
# taronja pur. `duration_ms` és el timeout abans de saltar a la
# següent escena (skipable amb tecla no reservada). Els dos
# `blink_*_ms` defineixen el cicle del prompt "press start": el text
# es veu durant `visible_ms` i s'amaga durant `hidden_ms` repetint-se.
title:
duration_ms: 30000
bg_color: 1
text_color: 31
# Color del text del © de copyright (peu de la TITLE). Off-white càlid:
# índex 183 = (255,218,170), tipus crema/peach, ~90% blanc amb tint càlid.
copyright_color: 183
# Color de l'outline del prompt "PRESS START". Marró fosc càlid (índex
# 19 = 109,72,0), coherent amb la paleta del logo. Es manté separat de
# `copyright_color` perquè l'outline i el text del © tenen rols visuals
# diferents (contrast vs. peu informatiu).
press_start_outline_color: 19
# Posicions Y dels tres elements verticals de la TITLE, en percentatge
# sobre l'alçada del canvas (240 px). El logo s'ancora pel seu top
# (la imatge cau cap avall des d'aquest Y); el prompt i el copyright
# es centren verticalment sobre la línia.
logo_y_percent: 15
prompt_y_percent: 68
copyright_y_percent: 92
# Separació vertical en píxels entre el minilogo i el text del © al peu.
mini_logo_gap_px: 2
blink_visible_ms: 800
blink_hidden_ms: 200
# Fase STARTING (entre la primera pulsació de START i el salt a la
# següent escena). `starting_ms` és la durada fixa; durant aquesta
# fase el parpadeig accelera per `starting_blink_speedup` (2.0 = doble
# de freqüència). Si l'altre jugador prem START dins d'aquesta
# finestra, el comptador es reseteja i s'arrenca amb BOTH.
starting_ms: 3000
# Durant STARTING el prompt commuta a un cicle simètric 50/50
# (visible/ocult) amb aquest període total. 200 ms = 5 Hz, prou
# ràpid per llegir-se com a "pulsació confirmada".
starting_blink_period_ms: 200
# 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
# 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
# Seqüència d'aparició dels elements de la TITLE en fase IDLE. Tots
# els temps són absoluts des de l'entrada a l'escena. `fade_in_ms` és
# la capa negra inicial que s'esvaeix cap al fons; els altres marquen
# quan apareix cada element. Si l'usuari prem START en qualsevol
# moment, la seqüència es completa instantàniament.
intro:
fade_in_ms: 500 # fade-in negre inicial sobre el fons
game_logo_at_ms: 1500 # visibilitat binària del game_logo
mini_logo_at_ms: 2000 # inici del slide-in del mini-logo
mini_logo_slide_ms: 500 # durada del slide
copyright_at_ms: 2200 # inici del slide-in del copyright
copyright_slide_ms: 500 # durada del slide
slide_curve: outCubic # corba compartida per mini_logo i copyright
press_start_at_ms: 3000 # quan apareix PRESS START amb parpadeig
# 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.
# `speed_hz`: velocitat amb què avança la fase (cicles per segon).
# Si `enabled` és false, el fons es pinta sense distorsió.
heat_haze:
enabled: true
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
+8
View File
@@ -194,6 +194,14 @@ void Audio::setOnMusicEnded(std::function<void()> callback) {
if (engine_) { engine_->setOnMusicEnded(std::move(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 // 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 // exposa Audio (INVALID/DISABLED de Ja col·lapsen a STOPPED — la capa d'usuari
// només vol saber si està sonant, pausat o parat). // només vol saber si està sonant, pausat o parat).
+5
View File
@@ -121,6 +121,11 @@ class Audio {
[[nodiscard]] auto isMusicEnabled() const -> bool { return music_enabled_; } [[nodiscard]] auto isMusicEnabled() const -> bool { return music_enabled_; }
[[nodiscard]] static auto getMusicState() -> MusicState; // Estat real consultat a Ja:: [[nodiscard]] static auto getMusicState() -> MusicState; // Estat real consultat a Ja::
[[nodiscard]] auto getCurrentMusicName() const -> const std::string& { return music_.name; } [[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: private:
// --- Tipus anidats --- // --- Tipus anidats ---
+54 -1
View File
@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <memory> #include <memory>
#include <optional> #include <optional>
@@ -127,11 +128,44 @@ namespace Ja {
void Engine::updateOutgoingFade() { void Engine::updateOutgoingFade() {
if (outgoing_music_.stream == nullptr || !outgoing_music_.fade.active) { return; } if (outgoing_music_.stream == nullptr || !outgoing_music_.fade.active) { return; }
// Mentre la fosa està activa, mantenim el stream amb una reserva
// de samples per davant del cursor (mateix patró que pumpMusic
// per al current_music_). Així el stream no es buida ni quan SDL
// drena més ràpid del previst en haver sounds bound a la mateixa
// device. Si l'OGG arriba a EOF, rebobina (la fosa pot ser més
// llarga que la pista).
if (outgoing_music_.music != nullptr && outgoing_music_.music->vorbis != nullptr) {
const Music& music = *outgoing_music_.music;
const int BYTES_PER_SECOND = music.spec.freq * music.spec.channels * MUSIC_BYTES_PER_SAMPLE;
const int LOW_WATER = static_cast<int>(MUSIC_LOW_WATER_SECONDS * static_cast<float>(BYTES_PER_SECOND));
while (SDL_GetAudioStreamAvailable(outgoing_music_.stream) < LOW_WATER) {
short chunk[MUSIC_CHUNK_SHORTS];
const int SAMPLES = stb_vorbis_get_samples_short_interleaved(
music.vorbis,
music.spec.channels,
static_cast<short*>(chunk),
MUSIC_CHUNK_SHORTS);
if (SAMPLES <= 0) {
stb_vorbis_seek_start(music.vorbis);
continue;
}
const int BYTES = SAMPLES * music.spec.channels * MUSIC_BYTES_PER_SAMPLE;
SDL_PutAudioStreamData(outgoing_music_.stream, static_cast<const void*>(chunk), BYTES);
}
}
const float PROGRESS = fadeProgress(outgoing_music_.fade); const float PROGRESS = fadeProgress(outgoing_music_.fade);
if (PROGRESS >= 1.0F) { if (PROGRESS >= 1.0F) {
SDL_DestroyAudioStream(outgoing_music_.stream); SDL_DestroyAudioStream(outgoing_music_.stream);
outgoing_music_.stream = nullptr; outgoing_music_.stream = nullptr;
outgoing_music_.fade.active = false; outgoing_music_.fade.active = false;
// Deixem el Vorbis del Music original en un estat conegut per
// a la pròxima reproducció. (playMusic també fa seek_start,
// però fer-ho ací evita estats intermedis si algú consulta.)
if (outgoing_music_.music != nullptr && outgoing_music_.music->vorbis != nullptr) {
stb_vorbis_seek_start(outgoing_music_.music->vorbis);
}
outgoing_music_.music = nullptr;
} else { } else {
SDL_SetAudioStreamGain(outgoing_music_.stream, outgoing_music_.fade.initial_volume * (1.0F - PROGRESS)); SDL_SetAudioStreamGain(outgoing_music_.stream, outgoing_music_.fade.initial_volume * (1.0F - PROGRESS));
} }
@@ -191,7 +225,14 @@ namespace Ja {
} }
preFillOutgoing(current_music_, duration_ms); preFillOutgoing(current_music_, duration_ms);
outgoing_music_.stream = current_music_->stream; outgoing_music_.stream = current_music_->stream;
// Guardem la referència al Music perquè updateOutgoingFade puga
// seguir bombant Vorbis cap al stream durant tota la fosa. NO fem
// seek_start ací: la decompressió ha de continuar des d'on estava
// perquè el so siga continu. El seek_start es farà quan la fosa
// acabe (o quan playMusic la interrompi via stopMusic).
outgoing_music_.music = current_music_;
outgoing_music_.fade = { outgoing_music_.fade = {
.active = true, .active = true,
.start_time = SDL_GetTicks(), .start_time = SDL_GetTicks(),
@@ -200,7 +241,6 @@ namespace Ja {
}; };
current_music_->stream = nullptr; current_music_->stream = nullptr;
current_music_->state = MusicState::STOPPED; current_music_->state = MusicState::STOPPED;
if (current_music_->vorbis != nullptr) { stb_vorbis_seek_start(current_music_->vorbis); }
} }
template <typename Fn> template <typename Fn>
@@ -265,6 +305,10 @@ namespace Ja {
SDL_DestroyAudioStream(outgoing_music_.stream); SDL_DestroyAudioStream(outgoing_music_.stream);
outgoing_music_.stream = nullptr; outgoing_music_.stream = nullptr;
outgoing_music_.fade.active = false; outgoing_music_.fade.active = false;
if (outgoing_music_.music != nullptr && outgoing_music_.music->vorbis != nullptr) {
stb_vorbis_seek_start(outgoing_music_.music->vorbis);
}
outgoing_music_.music = nullptr;
} }
incoming_fade_.active = false; incoming_fade_.active = false;
@@ -521,6 +565,15 @@ namespace Ja {
music->spec.channels = static_cast<int>(INFO.channels); music->spec.channels = static_cast<int>(INFO.channels);
music->spec.freq = static_cast<int>(INFO.sample_rate); music->spec.freq = static_cast<int>(INFO.sample_rate);
music->spec.format = SDL_AUDIO_S16; 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<int>((static_cast<std::uint64_t>(SAMPLES) * 1000ULL) / INFO.sample_rate);
}
music->state = MusicState::STOPPED; music->state = MusicState::STOPPED;
return music.release(); return music.release();
+13
View File
@@ -112,6 +112,12 @@ namespace Ja {
std::string filename; std::string filename;
int times{0}; // loops restants (-1 = infinit, 0 = un sol play) 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}; SDL_AudioStream* stream{nullptr};
MusicState state{MusicState::INVALID}; MusicState state{MusicState::INVALID};
}; };
@@ -125,6 +131,13 @@ namespace Ja {
struct OutgoingMusic { struct OutgoingMusic {
SDL_AudioStream* stream{nullptr}; SDL_AudioStream* stream{nullptr};
// Referència al Music original perquè updateOutgoingFade puga
// continuar descomprimint des de Vorbis cap al stream durant
// tota la fosa. Sense això, només tenim el pre-fill puntual i
// SDL drena el stream més ràpid del previst quan hi ha sounds
// bound a la mateixa device (~2x), buidant-lo a meitat del
// fade i sentint-se com un tall sec.
Music* music{nullptr};
FadeState fade; FadeState fade;
}; };
+2 -1
View File
@@ -62,7 +62,7 @@ void DefineButtons::disable() {
} }
void DefineButtons::resetSequence() { void DefineButtons::resetSequence() {
// Ordre: ATTACK, JUMP, SERVICE — replica l'estructura de // Ordre: ATTACK, JUMP, START, SERVICE — replica l'estructura de
// `clearButtons()` de coffee_crisis_arcade_edition (seqüència ordenada // `clearButtons()` de coffee_crisis_arcade_edition (seqüència ordenada
// d'accions amb la SERVICE al final) adaptada al joc actual. La label // d'accions amb la SERVICE al final) adaptada al joc actual. La label
// de SERVICE va a una clau pròpia (`menu.items.service`) per no // de SERVICE va a una clau pròpia (`menu.items.service`) per no
@@ -70,6 +70,7 @@ void DefineButtons::resetSequence() {
sequence_.clear(); sequence_.clear();
sequence_.push_back({"menu.items.attack", GamepadBindings::Action::ATTACK, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)}); sequence_.push_back({"menu.items.attack", GamepadBindings::Action::ATTACK, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
sequence_.push_back({"menu.items.jump", GamepadBindings::Action::JUMP, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)}); sequence_.push_back({"menu.items.jump", GamepadBindings::Action::JUMP, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
sequence_.push_back({"menu.items.start", GamepadBindings::Action::START, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
sequence_.push_back({"menu.items.service", GamepadBindings::Action::SERVICE, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)}); sequence_.push_back({"menu.items.service", GamepadBindings::Action::SERVICE, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
index_ = 0; index_ = 0;
} }
+3
View File
@@ -24,6 +24,7 @@ namespace {
ActionPair{GamepadBindings::Action::JUMP, std::string_view{"jump"}}, ActionPair{GamepadBindings::Action::JUMP, std::string_view{"jump"}},
ActionPair{GamepadBindings::Action::ATTACK, std::string_view{"attack"}}, ActionPair{GamepadBindings::Action::ATTACK, std::string_view{"attack"}},
ActionPair{GamepadBindings::Action::SERVICE, std::string_view{"service"}}, ActionPair{GamepadBindings::Action::SERVICE, std::string_view{"service"}},
ActionPair{GamepadBindings::Action::START, std::string_view{"start"}},
}; };
using ButtonPair = std::pair<int, std::string_view>; using ButtonPair = std::pair<int, std::string_view>;
@@ -98,6 +99,8 @@ auto GamepadBindings::defaultButton(Action action) -> int {
return static_cast<int>(SDL_GAMEPAD_BUTTON_WEST); return static_cast<int>(SDL_GAMEPAD_BUTTON_WEST);
case Action::SERVICE: case Action::SERVICE:
return static_cast<int>(SDL_GAMEPAD_BUTTON_BACK); return static_cast<int>(SDL_GAMEPAD_BUTTON_BACK);
case Action::START:
return static_cast<int>(SDL_GAMEPAD_BUTTON_START);
} }
return static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID); return static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID);
} }
+1
View File
@@ -25,6 +25,7 @@ class GamepadBindings {
JUMP, JUMP,
ATTACK, ATTACK,
SERVICE, SERVICE,
START,
}; };
// Triggers L2/R2 representats com a "botons virtuals" — replica la // Triggers L2/R2 representats com a "botons virtuals" — replica la
@@ -0,0 +1,48 @@
#include "core/rendering/effects/heat_haze.hpp"
#include <SDL3/SDL.h>
#include <cmath>
#include <numbers>
#include "core/rendering/surface.hpp"
namespace Effects {
namespace {
constexpr float TWO_PI = 2.0F * std::numbers::pi_v<float>;
}
HeatHaze::HeatHaze(Params params)
: params_(params) {}
void HeatHaze::tick(int delta_ms) {
const float DT = static_cast<float>(delta_ms) / 1000.0F;
phase_ = std::fmod(phase_ + (TWO_PI * params_.speed_hz * DT), TWO_PI);
}
void HeatHaze::render(const Surface& src, int x, int y) const {
// Pas 1 — blit pla de tota la Surface al destí. És la base que
// tapa els marges que les files desplaçades deixarien al
// descobert, evitant haver de demanar al caller un pre-fill. Per
// a fons amb gradient continu (cels, sorres) la costura amb el
// pas 2 no es nota; per a fons d'alt contrast cal moderar
// l'amplitud.
src.render(x, y);
// Pas 2 — re-blit fila a fila amb desplaçament horitzontal sin.
// Wavelength <=0 dóna divisió absurda: cau a 1 px com a salvaguarda;
// a la pràctica el caller passa un valor >0 vingut d'ui.yaml.
const float WL = (params_.wavelength_px > 0.0F) ? params_.wavelength_px : 1.0F;
const int H = src.getHeight();
const auto W = static_cast<float>(src.getWidth());
for (int row = 0; row < H; ++row) {
const float ARG = (TWO_PI * static_cast<float>(row) / WL) + phase_;
const int DX = static_cast<int>(std::lround(params_.amplitude_px * std::sin(ARG)));
if (DX == 0) { continue; } // El pas 1 ja ha pintat aquesta fila on toca.
SDL_FRect row_rect{0.0F, static_cast<float>(row), W, 1.0F};
src.render(x + DX, y + row, &row_rect);
}
}
} // namespace Effects
@@ -0,0 +1,37 @@
#pragma once
class Surface;
namespace Effects {
// Distorsió sinusoidal horitzontal de cada fila: dx(y) =
// amplitude · sin(2π·y/wavelength + phase). La phase avança al
// ritme marcat per `speed_hz` (cicles per segon). Es pinta sobre
// qualsevol Surface — habitualment un fons de canvas — i manté el
// seu propi estat temporal, així dues instàncies poden tenir
// paràmetres i fases independents.
//
// `render()` fa dos passos: primer un blit pla de tota la Surface
// (perquè els marges no quedin descoberts pel desplaçament) i
// després el re-blit fila a fila amb el sin. La costura és
// invisible en fons amb gradient continu i pràcticament invisible
// en altres si l'amplitud és moderada.
class HeatHaze {
public:
struct Params {
float amplitude_px;
float wavelength_px;
float speed_hz;
};
explicit HeatHaze(Params params);
void tick(int delta_ms);
void render(const Surface& src, int x, int y) const;
private:
Params params_;
float phase_{0.0F};
};
} // namespace Effects
@@ -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
@@ -0,0 +1,44 @@
#include "core/rendering/transitions/fade_to_black.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include "core/rendering/screen.hpp"
namespace Transitions {
void FadeToBlack::begin(int duration_ms, Direction direction) {
duration_ms_ = duration_ms;
elapsed_ms_ = 0;
direction_ = direction;
}
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<float>(elapsed_ms_) / static_cast<float>(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.
// 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::uint8_t>(std::round(255.0F * ALPHA_F));
if (ALPHA == 0) { return; }
auto& screen = Screen::get();
screen.blendRect(0, 0, screen.getCanvasWidth(), screen.getCanvasHeight(), 0x00000000U, ALPHA);
}
} // namespace Transitions
@@ -0,0 +1,51 @@
#pragma once
#include <cstdint>
namespace Transitions {
// 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, 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 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
@@ -0,0 +1,71 @@
#include "core/rendering/transitions/mosaic.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#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<float>(elapsed_ms_) / static_cast<float>(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<int>(std::ceil(static_cast<float>(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<std::ptrdiff_t>(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::uint8_t>(std::round(255.0F * P * P));
if (ALPHA > 0) {
screen.blendRect(0, 0, W, H, 0x00000000U, ALPHA);
}
}
} // namespace Transitions
@@ -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
+9 -3
View File
@@ -1,7 +1,13 @@
#pragma once #pragma once
#include <memory> #include <any>
#include <utility>
// 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 { class Scene {
public: public:
virtual ~Scene() = default; virtual ~Scene() = default;
@@ -10,9 +16,9 @@ class Scene {
virtual void render() = 0; virtual void render() = 0;
[[nodiscard]] auto wantsQuit() const -> bool { return wants_quit_; } [[nodiscard]] auto wantsQuit() const -> bool { return wants_quit_; }
[[nodiscard]] auto nextScene() -> std::unique_ptr<Scene> { return std::move(next_scene_); } [[nodiscard]] auto takeExitRequest() -> std::any { return std::exchange(exit_request_, {}); }
protected: protected:
bool wants_quit_{false}; bool wants_quit_{false};
std::unique_ptr<Scene> next_scene_; std::any exit_request_;
}; };
+107 -26
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <any>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
@@ -10,6 +11,7 @@
#include <memory> #include <memory>
#include <string> #include <string>
#include <utility> #include <utility>
#include <variant>
#include "core/audio/audio.hpp" #include "core/audio/audio.hpp"
#include "core/audio/sound_effects_config.hpp" #include "core/audio/sound_effects_config.hpp"
@@ -153,6 +155,9 @@ Director::Director() {
Resource::Cache::get().loadMusic("room_intro.ogg", "data/music/room_intro.ogg"); Resource::Cache::get().loadMusic("room_intro.ogg", "data/music/room_intro.ogg");
Resource::Cache::get().loadMusic("playing.ogg", "data/music/playing.ogg"); Resource::Cache::get().loadMusic("playing.ogg", "data/music/playing.ogg");
Resource::Cache::get().loadMusic("room_outro.ogg", "data/music/room_outro.ogg"); Resource::Cache::get().loadMusic("room_outro.ogg", "data/music/room_outro.ogg");
// Música de la TITLE: arrenca al constructor de l'escena i es fa
// fade-out quan l'escena entra a STARTING.
Resource::Cache::get().loadMusic("title.ogg", "data/music/title.ogg");
// Sons del menú de servei. Els noms venen de `data/ui.yaml` (amb // Sons del menú de servei. Els noms venen de `data/ui.yaml` (amb
// fallback a `Defaults::UI::MENU_*_SOUND`); el ServiceMenu els // fallback a `Defaults::UI::MENU_*_SOUND`); el ServiceMenu els
@@ -161,6 +166,14 @@ Director::Director() {
const auto& menu_accept = UiConfig::get().menuAcceptSound(); const auto& menu_accept = UiConfig::get().menuAcceptSound();
Resource::Cache::get().loadSound(menu_select, "data/sounds/" + menu_select); Resource::Cache::get().loadSound(menu_select, "data/sounds/" + menu_select);
Resource::Cache::get().loadSound(menu_accept, "data/sounds/" + menu_accept); Resource::Cache::get().loadSound(menu_accept, "data/sounds/" + menu_accept);
// So del START a la TITLE: el dispara TitleScene quan un jugador
// prem START (vore enterStarting i el reset del segon jugador).
const auto& player_start = UiConfig::get().playerStartSound();
Resource::Cache::get().loadSound(player_start, "data/sounds/" + player_start);
// Soroll de LogoScene (scanline reveal). El dispara LogoScene al
// ctor; amb fallback al nom de Defaults si la clau YAML no hi és.
const auto& logo_glitch = UiConfig::get().logoGlitchSound();
Resource::Cache::get().loadSound(logo_glitch, "data/sounds/" + logo_glitch);
// Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio // Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio
// perquè els primers playSoundWith* (al començament del joc) ja // perquè els primers playSoundWith* (al començament del joc) ja
@@ -189,6 +202,17 @@ Director::Director() {
auto placeholder_font_surface = Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY); auto placeholder_font_surface = Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY);
auto placeholder_text = std::make_shared<Text>(placeholder_font_surface, UiConfig::get().placeholderFont().fnt); auto placeholder_text = std::make_shared<Text>(placeholder_font_surface, UiConfig::get().placeholderFont().fnt);
placeholder_text->setDefaultKerning(UiConfig::get().placeholderFont().kerning); placeholder_text->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
placeholder_text_ = placeholder_text;
// Font del copyright de la TITLE (Tiny5-Regular size 8). Pixel-art
// estricte; va amb la paleta UI per compartir índex de color amb la
// resta de textos placeholder.
const std::string COPYRIGHT_FONT_GIF_KEY = UiConfig::get().copyrightFont().name + ".gif";
Resource::Cache::get().loadSurface(COPYRIGHT_FONT_GIF_KEY, UiConfig::get().copyrightFont().gif);
Resource::Cache::get().getSurface(COPYRIGHT_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
auto copyright_text = std::make_shared<Text>(Resource::Cache::get().getSurface(COPYRIGHT_FONT_GIF_KEY), UiConfig::get().copyrightFont().fnt);
copyright_text->setDefaultKerning(UiConfig::get().copyrightFont().kerning);
copyright_text_ = copyright_text;
// Font del menú de servei (F9). El títol va amb la variant `display` // 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 // i els items amb `condensed-display` (més compacte perquè caben més
@@ -203,6 +227,36 @@ Director::Director() {
}; };
auto menu_title_text = load_menu_font(UiConfig::get().menuTitleFont()); auto menu_title_text = load_menu_font(UiConfig::get().menuTitleFont());
auto menu_items_text = load_menu_font(UiConfig::get().menuItemsFont()); auto menu_items_text = load_menu_font(UiConfig::get().menuItemsFont());
// 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
// sobreescrivim amb la "ui" perquè el GIF ja porta els colors finals
// del logo).
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;
// Logo sencer per a l'escena LOGO (centrat damunt fons negre). També
// conserva la seua paleta nadiua: els colors finals del logo viuen
// dins del propi GIF i no han de passar per la paleta UI.
constexpr const char* LOGO_FULL_KEY = "logo_jailgames.gif";
Resource::Cache::get().loadSurface(LOGO_FULL_KEY, "data/logo/logo_jailgames.gif");
logo_full_surface_ = Resource::Cache::get().getSurface(LOGO_FULL_KEY);
// Logotip del joc per a la TITLE (substitueix el text "Aventures en
// Egipte ARCADE"). Igual que la resta de logos, paleta nadiua —
// l'art ja porta els colors finals i no passa per la UI.
constexpr const char* GAME_LOGO_KEY = "game_logo.gif";
Resource::Cache::get().loadSurface(GAME_LOGO_KEY, "data/title/game_logo.gif");
game_logo_surface_ = Resource::Cache::get().getSurface(GAME_LOGO_KEY);
// 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); ServiceMenu::get().init(menu_title_text, menu_items_text, ui_palette);
// Overlay hook: Screen::present() cridarà aquesta lambda al principi, // Overlay hook: Screen::present() cridarà aquesta lambda al principi,
@@ -237,36 +291,18 @@ Director::Director() {
std::cout << "Director: start_scene=" << DebugConfig::toString(START) std::cout << "Director: start_scene=" << DebugConfig::toString(START)
<< " starting_players=" << DebugConfig::toString(STARTING_PLAYERS) << '\n'; << " starting_players=" << DebugConfig::toString(STARTING_PLAYERS) << '\n';
// El branch Release inicialitza STARTING_PLAYERS amb una constant
// literal i clang considera la captura redundant; en Debug el valor és
// runtime i la captura sí és imprescindible. La lambda viu dins de
// IntroScene, més enllà del scope d'aquest ctor — capturar per
// referència seria UB.
// NOLINTNEXTLINE(clang-diagnostic-unused-lambda-capture)
auto make_game = [STARTING_PLAYERS]() -> std::unique_ptr<Scene> {
auto cfg = GameScene::defaultConfig();
cfg.starting_players = STARTING_PLAYERS;
return std::make_unique<GameScene>(std::move(cfg));
};
auto make_intro = [placeholder_text, make_game]() -> std::unique_ptr<Scene> {
return std::make_unique<IntroScene>(placeholder_text, make_game);
};
auto make_title = [placeholder_text, make_intro]() -> std::unique_ptr<Scene> {
return std::make_unique<TitleScene>(placeholder_text, make_intro);
};
switch (START) { switch (START) {
case DebugConfig::StartScene::LOGO: case DebugConfig::StartScene::LOGO:
current_scene_ = std::make_unique<LogoScene>(placeholder_text, make_title); current_scene_ = buildScene(SceneId::LOGO, LogoParams{});
break; break;
case DebugConfig::StartScene::TITLE: case DebugConfig::StartScene::TITLE:
current_scene_ = make_title(); current_scene_ = buildScene(SceneId::TITLE, TitleParams{});
break; break;
case DebugConfig::StartScene::INTRO: case DebugConfig::StartScene::INTRO:
current_scene_ = make_intro(); current_scene_ = buildScene(SceneId::INTRO, IntroParams{.starting_players = STARTING_PLAYERS});
break; break;
case DebugConfig::StartScene::GAME: case DebugConfig::StartScene::GAME:
current_scene_ = make_game(); current_scene_ = buildScene(SceneId::GAME, GameParams{.starting_players = STARTING_PLAYERS});
break; break;
} }
last_ticks_ms_ = SDL_GetTicks(); last_ticks_ms_ = SDL_GetTicks();
@@ -384,11 +420,56 @@ void Director::processMenuInput() {
void Director::switchSceneIfRequested() { void Director::switchSceneIfRequested() {
if (!current_scene_) { return; } if (!current_scene_) { return; }
if (auto next = current_scene_->nextScene()) { // Prioritat al menú: si un item del ServiceMenu ha encuat una
// TODO: migrar a logger unificat (INC-03 a docs/audit-core-rendering.md). // transició (típicament "Reiniciar" → LOGO amb reset), s'aplica
std::cout << "Director: scene transition\n"; // primer. Substitueix l'`ExitRequest` que pugui haver publicat
current_scene_ = std::move(next); // l'escena en aquest mateix frame — l'usuari ha cancel·lat l'estat
// de joc explícitament i no ens interessa mantenir-ne la sortida.
if (auto menu_req = ServiceMenu::get().takePendingSceneRequest(); menu_req.has_value()) {
std::cout << "Director: scene transition (menu) → " << toString(menu_req->next) << '\n';
current_scene_ = buildScene(menu_req->next, menu_req->params);
return;
} }
// 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<const ExitRequest&>(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<Scene> {
// 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: {
const auto& p = std::get<LogoParams>(params);
return std::make_unique<LogoScene>(logo_full_surface_, p.reset);
}
case SceneId::TITLE:
return std::make_unique<TitleScene>(placeholder_text_, copyright_text_, title_bg_surface_, game_logo_surface_, mini_logo_surface_);
case SceneId::INTRO: {
const auto& p = std::get<IntroParams>(params);
return std::make_unique<IntroScene>(placeholder_text_, p.starting_players);
}
case SceneId::GAME: {
const auto& p = std::get<GameParams>(params);
auto cfg = GameScene::defaultConfig();
cfg.starting_players = p.starting_players;
return std::make_unique<GameScene>(std::move(cfg));
}
}
return nullptr;
} }
auto Director::iterate() -> SDL_AppResult { auto Director::iterate() -> SDL_AppResult {
+22
View File
@@ -6,7 +6,11 @@
#include <memory> #include <memory>
#include <string> #include <string>
#include "game/scenes/scene_flow.hpp"
class Scene; class Scene;
class Surface;
class Text;
class Director { class Director {
public: public:
@@ -20,9 +24,27 @@ class Director {
std::unique_ptr<Scene> current_scene_; std::unique_ptr<Scene> current_scene_;
std::uint64_t last_ticks_ms_{0}; 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<Text> placeholder_text_;
std::shared_ptr<Text> copyright_text_;
std::shared_ptr<Surface> title_bg_surface_;
std::shared_ptr<Surface> game_logo_surface_;
std::shared_ptr<Surface> mini_logo_surface_;
std::shared_ptr<Surface> logo_full_surface_;
void createSystemFolder(); void createSystemFolder();
void processEvents(); void processEvents();
static void processGamepadEvents(); static void processGamepadEvents();
static void processMenuInput(); static void processMenuInput();
void switchSceneIfRequested(); 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<Scene>;
}; };
+111
View File
@@ -96,13 +96,63 @@ UiConfig::UiConfig()
pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS), pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS),
pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)), pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)),
pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)), pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)),
title_duration_ms_(Defaults::UI::Title::DURATION_MS),
title_bg_color_(Defaults::UI::Title::BG_COLOR),
title_text_color_(Defaults::UI::Title::TEXT_COLOR),
title_copyright_color_(Defaults::UI::Title::COPYRIGHT_COLOR),
title_press_start_outline_color_(Defaults::UI::Title::PRESS_START_OUTLINE_COLOR),
title_logo_y_percent_(Defaults::UI::Title::LOGO_Y_PERCENT),
title_prompt_y_percent_(Defaults::UI::Title::PROMPT_Y_PERCENT),
title_copyright_y_percent_(Defaults::UI::Title::COPYRIGHT_Y_PERCENT),
title_mini_logo_gap_px_(Defaults::UI::Title::MINI_LOGO_GAP_PX),
title_blink_visible_ms_(Defaults::UI::Title::BLINK_VISIBLE_MS),
title_blink_hidden_ms_(Defaults::UI::Title::BLINK_HIDDEN_MS),
title_starting_ms_(Defaults::UI::Title::STARTING_MS),
title_starting_blink_period_ms_(Defaults::UI::Title::STARTING_BLINK_PERIOD_MS),
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_intro_{
.fade_in_ms = Defaults::UI::Title::Intro::FADE_IN_MS,
.game_logo_at_ms = Defaults::UI::Title::Intro::GAME_LOGO_AT_MS,
.mini_logo_at_ms = Defaults::UI::Title::Intro::MINI_LOGO_AT_MS,
.mini_logo_slide_ms = Defaults::UI::Title::Intro::MINI_LOGO_SLIDE_MS,
.copyright_at_ms = Defaults::UI::Title::Intro::COPYRIGHT_AT_MS,
.copyright_slide_ms = Defaults::UI::Title::Intro::COPYRIGHT_SLIDE_MS,
.slide_curve = curveByName(Defaults::UI::Title::Intro::SLIDE_CURVE),
.press_start_at_ms = Defaults::UI::Title::Intro::PRESS_START_AT_MS,
},
title_heat_haze_{
.enabled = Defaults::UI::Title::HeatHaze::ENABLED,
.amplitude_px = Defaults::UI::Title::HeatHaze::AMPLITUDE_PX,
.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_select_sound_(Defaults::UI::MENU_SELECT_SOUND),
menu_accept_sound_(Defaults::UI::MENU_ACCEPT_SOUND), menu_accept_sound_(Defaults::UI::MENU_ACCEPT_SOUND),
player_start_sound_(Defaults::UI::PLAYER_START_SOUND),
logo_glitch_sound_(Defaults::UI::LOGO_GLITCH_SOUND),
hud_font_{.name = Defaults::UI::Font::HUD_NAME, .gif = Defaults::UI::Font::HUD_GIF, .fnt = Defaults::UI::Font::HUD_FNT, .kerning = Defaults::UI::Font::HUD_KERNING}, hud_font_{.name = Defaults::UI::Font::HUD_NAME, .gif = Defaults::UI::Font::HUD_GIF, .fnt = Defaults::UI::Font::HUD_FNT, .kerning = Defaults::UI::Font::HUD_KERNING},
placeholder_font_{.name = Defaults::UI::Font::PLACEHOLDER_NAME, .gif = Defaults::UI::Font::PLACEHOLDER_GIF, .fnt = Defaults::UI::Font::PLACEHOLDER_FNT, .kerning = Defaults::UI::Font::PLACEHOLDER_KERNING}, placeholder_font_{.name = Defaults::UI::Font::PLACEHOLDER_NAME, .gif = Defaults::UI::Font::PLACEHOLDER_GIF, .fnt = Defaults::UI::Font::PLACEHOLDER_FNT, .kerning = Defaults::UI::Font::PLACEHOLDER_KERNING},
room_text_font_{.name = Defaults::UI::Font::ROOM_TEXT_NAME, .gif = Defaults::UI::Font::ROOM_TEXT_GIF, .fnt = Defaults::UI::Font::ROOM_TEXT_FNT, .kerning = Defaults::UI::Font::ROOM_TEXT_KERNING}, room_text_font_{.name = Defaults::UI::Font::ROOM_TEXT_NAME, .gif = Defaults::UI::Font::ROOM_TEXT_GIF, .fnt = Defaults::UI::Font::ROOM_TEXT_FNT, .kerning = Defaults::UI::Font::ROOM_TEXT_KERNING},
menu_title_font_{.name = Defaults::UI::Font::MENU_TITLE_NAME, .gif = Defaults::UI::Font::MENU_TITLE_GIF, .fnt = Defaults::UI::Font::MENU_TITLE_FNT, .kerning = Defaults::UI::Font::MENU_TITLE_KERNING}, menu_title_font_{.name = Defaults::UI::Font::MENU_TITLE_NAME, .gif = Defaults::UI::Font::MENU_TITLE_GIF, .fnt = Defaults::UI::Font::MENU_TITLE_FNT, .kerning = Defaults::UI::Font::MENU_TITLE_KERNING},
menu_items_font_{.name = Defaults::UI::Font::MENU_ITEMS_NAME, .gif = Defaults::UI::Font::MENU_ITEMS_GIF, .fnt = Defaults::UI::Font::MENU_ITEMS_FNT, .kerning = Defaults::UI::Font::MENU_ITEMS_KERNING}, menu_items_font_{.name = Defaults::UI::Font::MENU_ITEMS_NAME, .gif = Defaults::UI::Font::MENU_ITEMS_GIF, .fnt = Defaults::UI::Font::MENU_ITEMS_FNT, .kerning = Defaults::UI::Font::MENU_ITEMS_KERNING},
copyright_font_{.name = Defaults::UI::Font::COPYRIGHT_NAME, .gif = Defaults::UI::Font::COPYRIGHT_GIF, .fnt = Defaults::UI::Font::COPYRIGHT_FNT, .kerning = Defaults::UI::Font::COPYRIGHT_KERNING},
notifier_{ notifier_{
.padding_h = Defaults::UI::Notifier::PADDING_H, .padding_h = Defaults::UI::Notifier::PADDING_H,
.padding_top = Defaults::UI::Notifier::PADDING_TOP, .padding_top = Defaults::UI::Notifier::PADDING_TOP,
@@ -180,6 +230,7 @@ void UiConfig::load(const std::string& file_path) {
readFont(f, "room_text", room_text_font_); readFont(f, "room_text", room_text_font_);
readFont(f, "menu_title", menu_title_font_); readFont(f, "menu_title", menu_title_font_);
readFont(f, "menu_items", menu_items_font_); readFont(f, "menu_items", menu_items_font_);
readFont(f, "copyright", copyright_font_);
} }
if (yaml.contains("hud")) { if (yaml.contains("hud")) {
@@ -202,6 +253,66 @@ void UiConfig::load(const std::string& file_path) {
const auto& s = yaml["sounds"]; const auto& s = yaml["sounds"];
readField(s, "menu_select", menu_select_sound_); readField(s, "menu_select", menu_select_sound_);
readField(s, "menu_accept", menu_accept_sound_); readField(s, "menu_accept", menu_accept_sound_);
readField(s, "player_start", player_start_sound_);
readField(s, "logo_glitch", logo_glitch_sound_);
}
if (yaml.contains("title")) {
const auto& t = yaml["title"];
readField(t, "duration_ms", title_duration_ms_);
readColorIndex(t, "bg_color", title_bg_color_);
readColorIndex(t, "text_color", title_text_color_);
readColorIndex(t, "copyright_color", title_copyright_color_);
readColorIndex(t, "press_start_outline_color", title_press_start_outline_color_);
readField(t, "logo_y_percent", title_logo_y_percent_);
readField(t, "prompt_y_percent", title_prompt_y_percent_);
readField(t, "copyright_y_percent", title_copyright_y_percent_);
readField(t, "mini_logo_gap_px", title_mini_logo_gap_px_);
readField(t, "blink_visible_ms", title_blink_visible_ms_);
readField(t, "blink_hidden_ms", title_blink_hidden_ms_);
readField(t, "starting_ms", title_starting_ms_);
readField(t, "starting_blink_period_ms", title_starting_blink_period_ms_);
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("intro")) {
const auto& in = t["intro"];
readField(in, "fade_in_ms", title_intro_.fade_in_ms);
readField(in, "game_logo_at_ms", title_intro_.game_logo_at_ms);
readField(in, "mini_logo_at_ms", title_intro_.mini_logo_at_ms);
readField(in, "mini_logo_slide_ms", title_intro_.mini_logo_slide_ms);
readField(in, "copyright_at_ms", title_intro_.copyright_at_ms);
readField(in, "copyright_slide_ms", title_intro_.copyright_slide_ms);
readCurve(in, "slide_curve", title_intro_.slide_curve);
readField(in, "press_start_at_ms", title_intro_.press_start_at_ms);
}
if (t.contains("heat_haze")) {
const auto& hh = t["heat_haze"];
readField(hh, "enabled", title_heat_haze_.enabled);
readField(hh, "amplitude_px", title_heat_haze_.amplitude_px);
readField(hh, "wavelength_px", title_heat_haze_.wavelength_px);
readField(hh, "speed_hz", title_heat_haze_.speed_hz);
}
}
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")) { if (yaml.contains("notifier")) {
+88
View File
@@ -81,6 +81,67 @@ class UiConfig {
[[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; } [[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; }
[[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; } [[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; }
// Escena TITLE (placeholder visual fins que tinguem artwork).
struct TitleHeatHaze {
bool enabled;
float amplitude_px;
float wavelength_px;
float speed_hz;
};
// Seqüència d'aparició dels elements en fase IDLE: tots els temps
// són absoluts des de l'entrada a l'escena. `slide_curve` es
// comparteix entre el slide-in del mini-logo i el del copyright.
struct TitleIntro {
int fade_in_ms;
int game_logo_at_ms;
int mini_logo_at_ms;
int mini_logo_slide_ms;
int copyright_at_ms;
int copyright_slide_ms;
CurveFn slide_curve;
int press_start_at_ms;
};
[[nodiscard]] auto titleDurationMs() const -> int { return title_duration_ms_; }
[[nodiscard]] auto titleBgColor() const -> Uint8 { return title_bg_color_; }
[[nodiscard]] auto titleTextColor() const -> Uint8 { return title_text_color_; }
[[nodiscard]] auto titleCopyrightColor() const -> Uint8 { return title_copyright_color_; }
[[nodiscard]] auto titlePressStartOutlineColor() const -> Uint8 { return title_press_start_outline_color_; }
[[nodiscard]] auto titleLogoYPercent() const -> int { return title_logo_y_percent_; }
[[nodiscard]] auto titlePromptYPercent() const -> int { return title_prompt_y_percent_; }
[[nodiscard]] auto titleCopyrightYPercent() const -> int { return title_copyright_y_percent_; }
[[nodiscard]] auto titleMiniLogoGapPx() const -> int { return title_mini_logo_gap_px_; }
[[nodiscard]] auto titleBlinkVisibleMs() const -> int { return title_blink_visible_ms_; }
[[nodiscard]] auto titleBlinkHiddenMs() const -> int { return title_blink_hidden_ms_; }
[[nodiscard]] auto titleStartingMs() const -> int { return title_starting_ms_; }
[[nodiscard]] auto titleStartingBlinkPeriodMs() const -> int { return title_starting_blink_period_ms_; }
[[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 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 // Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa
// Easing::linear com a fallback. // Easing::linear com a fallback.
[[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; } [[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; }
@@ -96,6 +157,8 @@ class UiConfig {
// fitxers viuen a `data/sounds/`). // fitxers viuen a `data/sounds/`).
[[nodiscard]] auto menuSelectSound() const -> const std::string& { return menu_select_sound_; } [[nodiscard]] auto menuSelectSound() const -> const std::string& { return menu_select_sound_; }
[[nodiscard]] auto menuAcceptSound() const -> const std::string& { return menu_accept_sound_; } [[nodiscard]] auto menuAcceptSound() const -> const std::string& { return menu_accept_sound_; }
[[nodiscard]] auto playerStartSound() const -> const std::string& { return player_start_sound_; }
[[nodiscard]] auto logoGlitchSound() const -> const std::string& { return logo_glitch_sound_; }
// Fonts de UI. // Fonts de UI.
[[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; } [[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; }
@@ -103,6 +166,7 @@ class UiConfig {
[[nodiscard]] auto roomTextFont() const -> const Font& { return room_text_font_; } [[nodiscard]] auto roomTextFont() const -> const Font& { return room_text_font_; }
[[nodiscard]] auto menuTitleFont() const -> const Font& { return menu_title_font_; } [[nodiscard]] auto menuTitleFont() const -> const Font& { return menu_title_font_; }
[[nodiscard]] auto menuItemsFont() const -> const Font& { return menu_items_font_; } [[nodiscard]] auto menuItemsFont() const -> const Font& { return menu_items_font_; }
[[nodiscard]] auto copyrightFont() const -> const Font& { return copyright_font_; }
// Geometria del Notifier. // Geometria del Notifier.
[[nodiscard]] auto notifier() const -> const Notifier& { return notifier_; } [[nodiscard]] auto notifier() const -> const Notifier& { return notifier_; }
@@ -145,14 +209,38 @@ class UiConfig {
CurveFn pause_enter_curve_; CurveFn pause_enter_curve_;
CurveFn pause_exit_curve_; CurveFn pause_exit_curve_;
int title_duration_ms_;
Uint8 title_bg_color_;
Uint8 title_text_color_;
Uint8 title_copyright_color_;
Uint8 title_press_start_outline_color_;
int title_logo_y_percent_;
int title_prompt_y_percent_;
int title_copyright_y_percent_;
int title_mini_logo_gap_px_;
int title_blink_visible_ms_;
int title_blink_hidden_ms_;
int title_starting_ms_;
int title_starting_blink_period_ms_;
int title_fade_out_ms_;
int title_fade_out_max_block_;
int title_music_fade_out_ms_;
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_select_sound_;
std::string menu_accept_sound_; std::string menu_accept_sound_;
std::string player_start_sound_;
std::string logo_glitch_sound_;
Font hud_font_; Font hud_font_;
Font placeholder_font_; Font placeholder_font_;
Font room_text_font_; Font room_text_font_;
Font menu_title_font_; Font menu_title_font_;
Font menu_items_font_; Font menu_items_font_;
Font copyright_font_;
Notifier notifier_; Notifier notifier_;
+2
View File
@@ -27,6 +27,8 @@ class Player : public AnimatedSprite {
SDL_Scancode right; SDL_Scancode right;
SDL_Scancode jump; SDL_Scancode jump;
SDL_Scancode attack; SDL_Scancode attack;
// No remapejable des del menú de servei: P1 → "1", P2 → "2".
SDL_Scancode start;
}; };
// Estat d'alt nivell del jugador. Ortogonal a `Facing`/`Cardinal` // Estat d'alt nivell del jugador. Ortogonal a `Facing`/`Cardinal`
+83 -85
View File
@@ -14,7 +14,6 @@
#include <vector> #include <vector>
#include "core/audio/audio.hpp" #include "core/audio/audio.hpp"
#include "core/input/gamepad_manager.hpp"
#include "core/input/global_inputs.hpp" #include "core/input/global_inputs.hpp"
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
@@ -348,6 +347,7 @@ GameScene::GameScene(Config config)
.right = kb_p1.right, .right = kb_p1.right,
.jump = kb_p1.jump, .jump = kb_p1.jump,
.attack = kb_p1.attack, .attack = kb_p1.attack,
.start = Defaults::KeysGame::START,
}; };
const Player::Controls CONTROLS_P2{ const Player::Controls CONTROLS_P2{
.up = kb_p2.up, .up = kb_p2.up,
@@ -356,6 +356,7 @@ GameScene::GameScene(Config config)
.right = kb_p2.right, .right = kb_p2.right,
.jump = kb_p2.jump, .jump = kb_p2.jump,
.attack = kb_p2.attack, .attack = kb_p2.attack,
.start = Defaults::KeysGameP2::START,
}; };
// Spawn lligat a la porta: tots els jugadors apareixen davant del // Spawn lligat a la porta: tots els jugadors apareixen davant del
// passadís de sortida. `computeSpawn` deriva les coordenades de // passadís de sortida. `computeSpawn` deriva les coordenades de
@@ -584,32 +585,36 @@ void GameScene::enterRoomIntro() {
intro_completed_ = false; intro_completed_ = false;
intro_spawn_elapsed_ms_ = 0; intro_spawn_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false); players_spawned_in_intro_.fill(false);
outro_music_ended_ = false;
// INTRO arrenca amb la pista `room_intro.ogg` (loop=0, una sola // Reset visual: arrenquem el FADE_IN d'INTRO i deixem el d'OUTRO
// passada). El callback de fi de música és l'únic responsable de // inactiu (es re-armarà a `setState(ROOM_OUTRO)` i, dins d'aquell
// disparar el spawn dels jugadors i el salt a PLAYING — la durada // estat, a l'entrada de la fase de fade_out via `outro_fade_started_`).
// real ve marcada per la pista, no per cap timer numèric. 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(); auto* audio = Audio::get();
if (audio == nullptr) { if (audio == nullptr) {
// Sense Audio inicialitzat, no podem esperar cap callback. std::cout << "[ROOM] INTRO: Audio absent — sense música, timeline només contra text\n";
// Completem síncronament perquè la sala no quede bloquejada.
std::cout << "[ROOM] INTRO: Audio absent — completant síncronament\n";
completeIntro();
return; return;
} }
audio->stopMusic(); audio->stopMusic();
audio->setOnMusicEnded([this]() { completeIntro(); }); audio->setOnMusicEnded({}); // defensiu: en el nou model no usem callbacks de fi
audio->playMusic("room_intro.ogg", 0); audio->playMusic("room_intro.ogg", 0);
if (Audio::getMusicState() != Audio::MusicState::PLAYING) { if (Audio::getMusicState() == Audio::MusicState::PLAYING) {
// Fallback: la pista no s'ha pogut arrencar (resource absent, const int DUR_MS = Audio::getMusicDurationMs("room_intro.ogg");
// música deshabilitada, etc.). Netegem el callback per no transition_config_.intro_music_tail_ms = std::max(0, DUR_MS - TEXT_TOTAL_MS);
// deixar-lo penjat i fem el spawn + transició com si el track std::cout << "[ROOM] INTRO: música " << DUR_MS << " ms, music_tail "
// hagués acabat ja. Així cap fallada d'asset deixa la sala << transition_config_.intro_music_tail_ms << " ms\n";
// congelada al fons negre de l'INTRO. } else {
std::cout << "[ROOM] INTRO: música no arrencada — completant síncronament\n"; std::cout << "[ROOM] INTRO: música no arrencada — timeline només contra text\n";
audio->setOnMusicEnded({});
completeIntro();
} }
} }
@@ -717,34 +722,38 @@ void GameScene::setState(RoomFsm::State new_state) {
intro_completed_ = false; intro_completed_ = false;
intro_spawn_elapsed_ms_ = 0; intro_spawn_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false); players_spawned_in_intro_.fill(false);
outro_music_ended_ = false;
} }
auto* audio = Audio::get(); auto* audio = Audio::get();
// PLAYING: arrenca la pista de joc en loop infinit. Netegem el // PLAYING: arrenca la pista de joc en loop infinit. Netegem el
// callback de fi (residual d'INTRO, ja consumit) perquè un futur // callback de fi residual perquè un futur stopMusic no dispari
// stopMusic no dispari lògica obsoleta. // lògica obsoleta (defensiu — ja no en registrem cap al nou model).
if (new_state == RoomFsm::State::PLAYING && audio != nullptr) { if (new_state == RoomFsm::State::PLAYING && audio != nullptr) {
audio->setOnMusicEnded({}); audio->setOnMusicEnded({});
audio->playMusic(config_.music_track, -1); audio->playMusic(config_.music_track, -1);
} }
// ROOM_OUTRO: corte sec de `playing.ogg`, arrenca `room_outro.ogg` // ROOM_OUTRO: corte sec de `playing.ogg`, arrenca `room_outro.ogg`
// (loop=0). El callback de fi marca `outro_music_ended_ = true`, // (loop=0) i resol el `outro_music_tail_ms` perquè la fase de
// que junt amb el final del text_exit gata el fade visual a negre // fade_out caigui just al final del .ogg. Sense callback de fi —
// dins `updateRoomTransition`. Si la música no arrenca, marquem el // la FSM avança per timeline pur. Si la música no arrenca, la cua
// flag immediatament perquè el fade quedi gata només contra el text. // és 0 i el fade segueix immediatament al text_exit.
if (new_state == RoomFsm::State::ROOM_OUTRO) { 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) { if (audio == nullptr) {
std::cout << "[ROOM] OUTRO: Audio absent — fade gata només contra text\n"; std::cout << "[ROOM] OUTRO: Audio absent — sense música, timeline només contra text\n";
outro_music_ended_ = true;
} else { } else {
audio->stopMusic(); audio->stopMusic();
audio->setOnMusicEnded([this]() { outro_music_ended_ = true; }); audio->setOnMusicEnded({});
audio->playMusic("room_outro.ogg", 0); audio->playMusic("room_outro.ogg", 0);
if (Audio::getMusicState() != Audio::MusicState::PLAYING) { if (Audio::getMusicState() == Audio::MusicState::PLAYING) {
std::cout << "[ROOM] OUTRO: música no arrencada — fade gata només contra text\n"; const int DUR_MS = Audio::getMusicDurationMs("room_outro.ogg");
audio->setOnMusicEnded({}); transition_config_.outro_music_tail_ms = std::max(0, DUR_MS - TEXT_TOTAL_MS);
outro_music_ended_ = true; 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";
} }
} }
} }
@@ -757,11 +766,16 @@ void GameScene::setState(RoomFsm::State new_state) {
} }
void GameScene::updateRoomTransition(int delta_ms) { void GameScene::updateRoomTransition(int delta_ms) {
// Tick del timer d'spawn aeri escalonat. Comença al callback de fi // Tick dels fades visuals. Tots dos viuen sempre actius però són
// de música d'INTRO (`completeIntro` posa intro_completed_=true) i // no-op si no s'han començat (FadeToBlack::tick sobre un fade no
// segueix corrent cap a PLAYING fins que tots els slots actius hagin // començat és no-op gràcies al `duration_ms_ <= 0`).
// caigut. Cap cost extra un cop tots han caigut (`spawnDuePlayers` intro_fade_.tick(delta_ms);
// és no-op llavors). 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_) { if (intro_completed_) {
intro_spawn_elapsed_ms_ += delta_ms; intro_spawn_elapsed_ms_ += delta_ms;
spawnDuePlayers(); spawnDuePlayers();
@@ -769,24 +783,24 @@ void GameScene::updateRoomTransition(int delta_ms) {
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms); const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
// OUTRO: gate del fade visual sobre la doble condició (música // OUTRO: arrenquem `outro_fade_` la primera vegada que entrem a la
// acabada AND text fora). Si la FSM ha avançat a phase 3 (fade_out) // fase de fade_out (phase 4 del timeline d'OUTRO). Si l'`advance`
// pel timer del text però el callback de fi de música encara no // ha consumit fases buides en aquest mateix frame i ja portem
// ha disparat, congelem el comptador a 0 perquè el fade no comence // `transition_.elapsed_ms` dins de la fase 4, sincronitzem el fade
// a pintar el negre. Quan el callback alliberi `outro_music_ended_`, // a aquesta posició perquè el render no comenci des de zero amb
// les pròximes crides a `advance` tornaran a acumular delta_ms i // delay artificial.
// el fade arrencarà des de zero, polit. if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 4 && !outro_fade_started_) {
if (state_ == RoomFsm::State::ROOM_OUTRO && outro_fade_.begin(transition_config_.outro_fade_out_ms, Transitions::FadeToBlack::Direction::FADE_OUT);
transition_.phase == 3 && if (transition_.elapsed_ms > 0) { outro_fade_.tick(transition_.elapsed_ms); }
!outro_music_ended_) { outro_fade_started_ = true;
transition_.elapsed_ms = 0;
} }
if (NEXT != state_) { if (NEXT != state_) {
// INTRO → PLAYING per timer: ignorat. El callback de fi de // INTRO → PLAYING per timer: l'única ruta canònica al nou model.
// música d'INTRO és l'únic responsable d'aquesta transició. // `completeIntro` agrupa l'spawn aeri inicial i el `setState`.
if (state_ == RoomFsm::State::ROOM_INTRO && if (state_ == RoomFsm::State::ROOM_INTRO &&
NEXT == RoomFsm::State::PLAYING) { NEXT == RoomFsm::State::PLAYING) {
completeIntro();
return; return;
} }
// El final de l'OUTRO no es resol amb un setState normal: cal // El final de l'OUTRO no es resol amb un setState normal: cal
@@ -803,36 +817,22 @@ void GameScene::updateRoomTransition(int delta_ms) {
} }
void GameScene::renderRoomFade() { void GameScene::renderRoomFade() {
// Fade negre sobre el món, pintat un cop els actors ja estan al // Delegació als FadeToBlack: les durades, la direcció i el progrés
// canvas i abans del HUD. Tres casos: // viuen dins de cada instància. Comportament:
// ROOM_INTRO/fase 0: 255 → 0 (entrada de sala, món emergent) // ROOM_INTRO: `intro_fade_` (FADE_IN) pinta alpha 255→0 durant la
// ROOM_OUTRO/fase 3: 0 → 255 (sortida de sala, món s'apaga) // primera fase i, un cop finished, `render()` pinta
// ROOM_OUTRO/fase 4: alpha=255 fix (pausa en negre opac abans del // alpha=0 (no-op) fins al canvi d'estat.
// rellançament de la pròxima sala) // ROOM_OUTRO: `outro_fade_` només té `duration_ms_>0` quan la FSM
if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 4) { // ha entrat a la fase de fade_out (`outro_fade_started_`).
Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, 255); // Mentre està actiu pinta 0→255; un cop finished
return; // 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<float>(transition_.elapsed_ms) / static_cast<float>(dur), 0.0F, 1.0F)
: 1.0F;
const float ALPHA_F = fade_in ? (1.0F - t) : t;
const auto ALPHA = static_cast<std::uint8_t>(255.0F * ALPHA_F);
if (ALPHA == 0) { return; }
Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, ALPHA);
} }
void GameScene::renderRoomText() { void GameScene::renderRoomText() {
@@ -1044,11 +1044,9 @@ void GameScene::handlePauseInput() {
if (editor_.active()) { return; } if (editor_.active()) { return; }
#endif #endif
const SDL_Scancode PAUSE_SC = KeyConfig::get().scancode("pause"); const SDL_Scancode PAUSE_SC = KeyConfig::get().scancode("pause");
SDL_Gamepad* pad = GamepadManager::get().getPrimary();
for (const auto& event : EventBuffer::events) { for (const auto& event : EventBuffer::events) {
const bool KEY_HIT = (event.type == SDL_EVENT_KEY_DOWN && PAUSE_SC != SDL_SCANCODE_UNKNOWN && event.key.scancode == PAUSE_SC); const bool KEY_HIT = (event.type == SDL_EVENT_KEY_DOWN && PAUSE_SC != SDL_SCANCODE_UNKNOWN && event.key.scancode == PAUSE_SC);
const bool PAD_HIT = (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && event.gbutton.which == SDL_GetGamepadID(pad) && event.gbutton.button == SDL_GAMEPAD_BUTTON_START); if (!KEY_HIT) { continue; }
if (!KEY_HIT && !PAD_HIT) { continue; }
// Determinem cap a quin estat anem. Si estem quiets (IDLE o // Determinem cap a quin estat anem. Si estem quiets (IDLE o
// PAUSED) arranquem la transició contrària. Si estem en marxa // PAUSED) arranquem la transició contrària. Si estem en marxa
+33 -16
View File
@@ -6,6 +6,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/rendering/transitions/fade_to_black.hpp"
#include "core/scene/scene.hpp" #include "core/scene/scene.hpp"
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp" #include "game/debug_config.hpp"
@@ -58,10 +59,12 @@ class GameScene : public Scene {
// crida `setState` (que reseteja la `Transition`). // crida `setState` (que reseteja la `Transition`).
void updateRoomTransition(int delta_ms); void updateRoomTransition(int delta_ms);
// Consumeix events d'aquest frame (F11 del KeyConfig "pause" o botó // Consumeix events d'aquest frame (F11 del KeyConfig "pause") per
// START del gamepad primari) per disparar les transicions de pausa. // disparar les transicions de pausa. El mando ja no participa: el
// No alterna un flag binari: inicia (o inverteix mid-flight) un // botó START és ara una acció remapeable per dispositiu i el menú
// "dial" de temps que frena el món amb easing. // de servei fa de pausa quan cal. No alterna un flag binari: inicia
// (o inverteix mid-flight) un "dial" de temps que frena el món amb
// easing.
void handlePauseInput(); void handlePauseInput();
// Avança la transició activa, recalcula `pause_.scale` i aplica el // Avança la transició activa, recalcula `pause_.scale` i aplica el
@@ -134,14 +137,13 @@ class GameScene : public Scene {
// caigut tots o si encara no estem dins la finestra (intro_completed_). // caigut tots o si encara no estem dins la finestra (intro_completed_).
void spawnDuePlayers(); void spawnDuePlayers();
// Hi ha un únic slot global de callback de fi de música a `Ja::Engine`. // Sentinel que marca el moment "música d'INTRO acabada" dins del
// Cada estat (INTRO, OUTRO) ha de registrar el seu en entrar i pot // timeline. Ja no ve d'un callback d'àudio: el setja el GameScene
// sobreescriure'l amb el del següent estat. Aquests flags marquen // quan la FSM ha consumit l'última fase d'INTRO (la `music_tail`,
// si el callback corresponent ja ha disparat: són la frontera entre // calculada perquè coincideixi amb el final del .ogg). Habilita el
// el thread d'audio (que invoca el callback dins `Engine::update`) // tick de `intro_spawn_elapsed_ms_` i el spawn aeri escalonat dels
// i la lògica de transició (`updateRoomTransition`). // slots actius.
bool intro_completed_{false}; bool intro_completed_{false};
bool outro_music_ended_{false};
// Mil·lisegons acumulats des del callback de fi de música d'INTRO, // Mil·lisegons acumulats des del callback de fi de música d'INTRO,
// per gatejar l'spawn aeri escalonat dels slots actius. P1 fa // per gatejar l'spawn aeri escalonat dels slots actius. P1 fa
@@ -234,6 +236,19 @@ class GameScene : public Scene {
// reseteja a {0, 0}. // reseteja a {0, 0}.
RoomFsm::Transition transition_{}; 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. // 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 — // Cridats des de `render()` entre el bloc d'actors i el HUD —
// així marcador, notificacions i service menu queden per damunt. // així marcador, notificacions i service menu queden per damunt.
@@ -281,11 +296,13 @@ class GameScene : public Scene {
void advanceToNextRoom(); void advanceToNextRoom();
// Inicia (o reinicia) el cicle d'entrada de sala: posa l'estat a // Inicia (o reinicia) el cicle d'entrada de sala: posa l'estat a
// ROOM_INTRO, reseteja `transition_` i `intro_completed_`, i comença // ROOM_INTRO, reseteja `transition_` i `intro_completed_`, comença
// la pista `room_intro.ogg` amb un callback de fi que dispararà // la pista `room_intro.ogg` i resol el `intro_music_tail_ms` perquè
// `completeIntro()`. Si la càrrega de la música falla, dispara // el timeline acabi just al final del .ogg. Sense callback de fi de
// `completeIntro()` síncronament perquè la sala no quedi bloquejada. // música — la transició a PLAYING la dispara `updateRoomTransition`
// Idempotent quan ja s'estava a INTRO — útil per als salts debug. // 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(); void enterRoomIntro();
#ifdef _DEBUG #ifdef _DEBUG
+9 -5
View File
@@ -17,6 +17,7 @@
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp" #include "game/scenes/placeholder_input.hpp"
#include "game/scenes/scene_flow.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
#include "utils/defines.hpp" #include "utils/defines.hpp"
@@ -30,10 +31,10 @@ namespace {
} // namespace } // namespace
IntroScene::IntroScene(std::shared_ptr<Text> font, SceneFactory next_factory) IntroScene::IntroScene(std::shared_ptr<Text> font, DebugConfig::StartingPlayers starting_players)
: font_(std::move(font)), : font_(std::move(font)),
next_factory_(std::move(next_factory)) { starting_players_(starting_players) {
std::cout << "[scene] " << SCENE_NAME << " start\n"; std::cout << "[scene] " << SCENE_NAME << " start (players=" << DebugConfig::toString(starting_players_) << ")\n";
} }
IntroScene::~IntroScene() = default; IntroScene::~IntroScene() = default;
@@ -51,8 +52,11 @@ void IntroScene::update(int delta_ms) {
return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode); return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode);
}); });
if ((timer_ms_ <= 0 || SKIPPED) && next_factory_) { if (timer_ms_ <= 0 || SKIPPED) {
next_scene_ = next_factory_(); exit_request_ = ExitRequest{
.next = SceneId::GAME,
.params = GameParams{.starting_players = starting_players_},
};
} }
Notifier::get().update(delta_ms); Notifier::get().update(delta_ms);
+9 -7
View File
@@ -1,22 +1,24 @@
#pragma once #pragma once
#include <functional>
#include <memory> #include <memory>
#include "core/scene/scene.hpp" #include "core/scene/scene.hpp"
#include "game/debug_config.hpp"
class Text; class Text;
// Escena placeholder de la introducció. Com la resta de placeholders, durada // Escena placeholder de la introducció. Com la resta de placeholders, durada
// fixa i skip amb tecla no reservada; quan expira encadena a la factory // fixa i skip amb tecla no reservada; quan expira publica un `ExitRequest`
// injectada (en producció: GameScene). // 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 per `ExitRequest`; quan demà
// l'intro tinga contingut real podrà variar segons qui jugue.
class IntroScene : public Scene { class IntroScene : public Scene {
public: public:
using SceneFactory = std::function<std::unique_ptr<Scene>()>;
static constexpr int DURATION_MS = 5000; static constexpr int DURATION_MS = 5000;
IntroScene(std::shared_ptr<Text> font, SceneFactory next_factory); IntroScene(std::shared_ptr<Text> font, DebugConfig::StartingPlayers starting_players);
~IntroScene() override; ~IntroScene() override;
void update(int delta_ms) override; void update(int delta_ms) override;
@@ -24,6 +26,6 @@ class IntroScene : public Scene {
private: private:
std::shared_ptr<Text> font_; std::shared_ptr<Text> font_;
SceneFactory next_factory_; DebugConfig::StartingPlayers starting_players_;
int timer_ms_{DURATION_MS}; int timer_ms_{DURATION_MS};
}; };
+110 -29
View File
@@ -5,25 +5,23 @@
#include <algorithm> #include <algorithm>
#include <cstdint> #include <cstdint>
#include <iostream> #include <iostream>
#include <string>
#include <utility> #include <utility>
#include "core/audio/audio.hpp"
#include "core/input/global_inputs.hpp" #include "core/input/global_inputs.hpp"
#include "core/rendering/notifier.hpp" #include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp" #include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp" #include "core/rendering/surface.hpp"
#include "core/system/event_buffer.hpp" #include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp" #include "game/scenes/placeholder_input.hpp"
#include "utils/defaults.hpp" #include "game/scenes/scene_flow.hpp"
#include "utils/defines.hpp" #include "utils/defines.hpp"
namespace { namespace {
constexpr int NAME_Y_OFFSET = -8;
constexpr int TIMER_Y_OFFSET = 8;
// TODO: migrar a un logger unificat quan es decideixi (veure INC-03 a // TODO: migrar a un logger unificat quan es decideixi (veure INC-03 a
// docs/audit-core-rendering.md). De moment std::cout per rastrejar el // docs/audit-core-rendering.md). De moment std::cout per rastrejar el
// flux d'escenes als smoke runs. // flux d'escenes als smoke runs.
@@ -31,14 +29,64 @@ namespace {
} // namespace } // namespace
LogoScene::LogoScene(std::shared_ptr<Text> font, SceneFactory next_factory) LogoScene::LogoScene(std::shared_ptr<Surface> logo, bool reset)
: font_(std::move(font)), : logo_(std::move(logo)) {
next_factory_(std::move(next_factory)) { std::cout << "[scene] " << SCENE_NAME << (reset ? " start (reset)\n" : " start\n");
std::cout << "[scene] " << SCENE_NAME << " start\n"; if (reset) {
if (auto* audio = Audio::get(); audio != nullptr) {
audio->stopMusic();
audio->stopAllSounds();
}
}
// 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.
if (auto* audio = Audio::get(); audio != nullptr) {
audio->playSound(UiConfig::get().logoGlitchSound(), Audio::Group::INTERFACE);
}
// 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; 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;
phase_elapsed_ms_ = 0;
}
void LogoScene::enterFadeOut() {
phase_ = Phase::FADE_OUT;
phase_elapsed_ms_ = 0;
fade_.begin(FADE_OUT_MS, Transitions::FadeToBlack::Direction::FADE_OUT);
}
void LogoScene::enterDone() {
phase_ = Phase::DONE;
exit_request_ = ExitRequest{.next = SceneId::TITLE, .params = TitleParams{}};
}
void LogoScene::update(int delta_ms) { void LogoScene::update(int delta_ms) {
GlobalInputs::update(delta_ms); GlobalInputs::update(delta_ms);
if (GlobalInputs::wantsQuit()) { if (GlobalInputs::wantsQuit()) {
@@ -46,14 +94,44 @@ void LogoScene::update(int delta_ms) {
return; return;
} }
timer_ms_ -= delta_ms; // Skip: qualsevol tecla no reservada salta directament a la
// transició cap a TITLE. El propi TitleScene fa el seu fade-in
// inicial des de negre, així que la sortida no queda abrupta.
if (phase_ != Phase::DONE && wantsSkip()) {
enterDone();
}
const bool SKIPPED = std::ranges::any_of(EventBuffer::events, [](const SDL_Event& ev) { if (phase_ != Phase::DONE) {
return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode); 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);
}
if ((timer_ms_ <= 0 || SKIPPED) && next_factory_) { switch (phase_) {
next_scene_ = next_factory_(); 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
// 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(); }
break;
case Phase::FADE_OUT:
if (phase_elapsed_ms_ >= FADE_OUT_MS) { enterDone(); }
break;
case Phase::DONE:
break;
} }
Notifier::get().update(delta_ms); Notifier::get().update(delta_ms);
@@ -64,23 +142,26 @@ void LogoScene::render() {
constexpr std::uint32_t BLACK_ARGB = 0xFF000000U; constexpr std::uint32_t BLACK_ARGB = 0xFF000000U;
Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BLACK_ARGB); Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BLACK_ARGB);
if (font_) { if (logo_) {
const int SECS_LEFT = std::max(1, (timer_ms_ + 999) / 1000); const int X = (GameCanvas::WIDTH - logo_->getWidth()) / 2;
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, const int Y = (GameCanvas::HEIGHT - logo_->getHeight()) / 2;
GameCanvas::CENTER_X, if (phase_ == Phase::SCAN) {
GameCanvas::CENTER_Y + NAME_Y_OFFSET, // Revelat CRT: la pròpia ScanReveal s'encarrega del blit
SCENE_NAME, // fila a fila, de la banda lluminosa, dels glitches i del
1, // flash final.
UiConfig::get().notificationText()); scan_.render(*logo_, X, Y);
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, } else {
GameCanvas::CENTER_X, // MIDDLE i FADE_OUT: render normal, el logo ja està a
GameCanvas::CENTER_Y + TIMER_Y_OFFSET, // pantalla complet i estable.
std::to_string(SECS_LEFT), logo_->render(X, Y);
1, }
UiConfig::get().notificationText());
} }
Notifier::get().render(); 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).
fade_.render();
RenderInfo::get().render(); RenderInfo::get().render();
Screen::get().present(); Screen::get().present();
} }
+46 -13
View File
@@ -1,30 +1,63 @@
#pragma once #pragma once
#include <functional> #include <cstdint>
#include <memory> #include <memory>
#include "core/rendering/effects/scan_reveal.hpp"
#include "core/rendering/transitions/fade_to_black.hpp"
#include "core/scene/scene.hpp" #include "core/scene/scene.hpp"
class Text; class Surface;
// Escena placeholder del logotip. Durada fixa (5 s) o skip amb qualsevol tecla // Escena del logotip de l'editora. FSM amb tres fases visibles:
// no reservada. Quan expira, encadena amb la `SceneFactory` que se li injecta // `SCAN` — revelat tipus scanline CRT (durada YAML, ~1500 ms)
// al ctor — així l'escena no coneix el tipus concret de la següent, i es pot // `MIDDLE` — logo estable (`MIDDLE_MS`, 5 s)
// exercir en tests sense arrossegar GameScene. // `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`,
// així que la transició queda visualment continua igualment.
//
// Si arriba amb `reset=true` (acció "Reiniciar" del menú de servei),
// atura música i tots els sons al constructor — el següent LOGO arrenca
// silenciós encara que veníssem del joc en marxa.
class LogoScene : public Scene { class LogoScene : public Scene {
public: public:
using SceneFactory = std::function<std::unique_ptr<Scene>()>; 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;
static constexpr int DURATION_MS = 5000; LogoScene(std::shared_ptr<Surface> logo, bool reset);
LogoScene(std::shared_ptr<Text> font, SceneFactory next_factory);
~LogoScene() override; ~LogoScene() override;
void update(int delta_ms) override; void update(int delta_ms) override;
void render() override; void render() override;
private: private:
std::shared_ptr<Text> font_; enum class Phase : std::uint8_t { SCAN,
SceneFactory next_factory_; MIDDLE,
int timer_ms_{DURATION_MS}; FADE_OUT,
DONE };
void enterMiddle();
void enterFadeOut();
void enterDone();
[[nodiscard]] static auto wantsSkip() -> bool;
std::shared_ptr<Surface> logo_;
Phase phase_{Phase::SCAN};
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_;
}; };
+10 -2
View File
@@ -95,12 +95,16 @@ namespace RoomFsm {
t.elapsed_ms += delta_ms; t.elapsed_ms += delta_ms;
if (current == State::ROOM_INTRO) { if (current == State::ROOM_INTRO) {
const std::array<int, 5> DURS = { const std::array<int, 6> DURS = {
cfg.intro_fade_in_ms, cfg.intro_fade_in_ms,
cfg.intro_text_delay_ms, cfg.intro_text_delay_ms,
cfg.intro_text_enter_ms, cfg.intro_text_enter_ms,
cfg.intro_text_hold_ms, cfg.intro_text_hold_ms,
cfg.intro_text_exit_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<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) { while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase]; t.elapsed_ms -= DURS[t.phase];
@@ -111,10 +115,14 @@ namespace RoomFsm {
} }
// ROOM_OUTRO // ROOM_OUTRO
const std::array<int, 5> DURS = { const std::array<int, 6> DURS = {
cfg.outro_text_enter_ms, cfg.outro_text_enter_ms,
cfg.outro_text_hold_ms, cfg.outro_text_hold_ms,
cfg.outro_text_exit_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, cfg.outro_fade_out_ms,
// Pausa final en negre, després del fade-out i abans del // Pausa final en negre, després del fade-out i abans del
// canvi a INTRO. Si és 0 ms, el while-loop la consumeix // canvi a INTRO. Si és 0 ms, el while-loop la consumeix
+20 -2
View File
@@ -19,9 +19,17 @@ namespace RoomFsm {
// Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a // 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: // {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_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold,
// ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=fade_out, 4=black_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. // 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 { struct Transition {
int phase{0}; int phase{0};
int elapsed_ms{0}; int elapsed_ms{0};
@@ -47,6 +55,16 @@ namespace RoomFsm {
// sense recompilar — útil per donar respir abans del nou rètol. // sense recompilar — útil per donar respir abans del nou rètol.
int outro_black_hold_ms{0}; 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 // 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 // (`room_intro.ogg`) en què cau l'spawn aeri del primer slot
// actiu. 0 = cau al moment exacte que la música acaba. El // actiu. 0 = cau al moment exacte que la música acaba. El
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <cstdint>
#include <variant>
#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 {
// Quan val `true`, indica que aquesta entrada al LOGO és una reinicialització
// completa (acció "Reiniciar" del menú de servei): l'escena haurà d'aturar
// música i sons abans de mostrar-se. En el flux normal LOGO→TITLE→… es
// queda a `false` perquè qualsevol música prèvia (p. ex. la pista de
// TITLE durant l'attract-out) continui sonant fins que la nova escena la
// substituïsca.
bool reset{false};
};
struct TitleParams {};
struct IntroParams {
DebugConfig::StartingPlayers starting_players{DebugConfig::StartingPlayers::BOTH};
};
struct GameParams {
DebugConfig::StartingPlayers starting_players{DebugConfig::StartingPlayers::BOTH};
};
using SceneParams = std::variant<LogoParams, TitleParams, IntroParams, GameParams>;
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 "?";
}
+365 -29
View File
@@ -8,32 +8,95 @@
#include <string> #include <string>
#include <utility> #include <utility>
#include "core/audio/audio.hpp"
#include "core/input/gamepad_bindings.hpp"
#include "core/input/gamepad_manager.hpp"
#include "core/input/global_inputs.hpp" #include "core/input/global_inputs.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/notifier.hpp" #include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp" #include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/rendering/text.hpp" #include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/system/event_buffer.hpp" #include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp" #include "game/scenes/scene_flow.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
#include "utils/defines.hpp" #include "utils/defines.hpp"
#include "utils/easing.hpp"
namespace { namespace {
constexpr int NAME_Y_OFFSET = -8;
constexpr int TIMER_Y_OFFSET = 8;
// TODO: migrar a un logger unificat quan es decideixi (veure INC-03 a // TODO: migrar a un logger unificat quan es decideixi (veure INC-03 a
// docs/audit-core-rendering.md). // docs/audit-core-rendering.md).
const char* const SCENE_NAME = "TITLE"; const char* const SCENE_NAME = "TITLE";
// Detecta una pulsació de START dins el frame actual per al jugador
// indicat. Combina events de teclat (scancode hardcoded a Defaults::
// KeysGame[P2]) i events de gamepad (botó remapejable via
// GamepadBindings, lligat al pad assignat al jugador via Options).
auto pressedStart(int player_id) -> bool {
const SDL_Scancode KB = (player_id == 0) ? Defaults::KeysGame::START : Defaults::KeysGameP2::START;
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0;
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
const int START_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::START);
return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) {
if (ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == KB) { return true; }
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID && static_cast<int>(ev.gbutton.button) == START_BTN) {
return true;
}
return false;
});
}
// Reproducció del so de START. Centralitzat ací per disparar-lo des
// dels punts on una pulsació té efecte real (entrada a STARTING o
// upgrade a BOTH); l'attract timeout no l'ha de tocar.
void playStartSound() {
if (auto* audio = Audio::get(); audio != nullptr) {
audio->playSound(UiConfig::get().playerStartSound(), Audio::Group::INTERFACE);
}
}
} // namespace } // namespace
TitleScene::TitleScene(std::shared_ptr<Text> font, SceneFactory next_factory) TitleScene::TitleScene(std::shared_ptr<Text> condensed_font, std::shared_ptr<Text> copyright_font, std::shared_ptr<Surface> background, std::shared_ptr<Surface> game_logo, std::shared_ptr<Surface> mini_logo)
: font_(std::move(font)), : condensed_font_(std::move(condensed_font)),
next_factory_(std::move(next_factory)) { copyright_font_(std::move(copyright_font)),
background_(std::move(background)),
game_logo_(std::move(game_logo)),
mini_logo_(std::move(mini_logo)),
timer_ms_(UiConfig::get().titleDurationMs()) {
std::cout << "[scene] " << SCENE_NAME << " start\n"; std::cout << "[scene] " << SCENE_NAME << " start\n";
const auto& hh = UiConfig::get().titleHeatHaze();
if (hh.enabled) {
heat_haze_.emplace(Effects::HeatHaze::Params{
.amplitude_px = hh.amplitude_px,
.wavelength_px = hh.wavelength_px,
.speed_hz = hh.speed_hz,
});
}
fade_out_.emplace(Transitions::Mosaic::Params{
.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().titleIntro().fade_in_ms, 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
// 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);
}
} }
TitleScene::~TitleScene() = default; TitleScene::~TitleScene() = default;
@@ -45,41 +108,314 @@ void TitleScene::update(int delta_ms) {
return; return;
} }
timer_ms_ -= delta_ms; if (heat_haze_) { heat_haze_->tick(delta_ms); }
const bool SKIPPED = std::ranges::any_of(EventBuffer::events, [](const SDL_Event& ev) { // Fade-in inicial: tica sempre. Un cop finished, FadeToBlack::tick
return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode); // és idempotent (clamp intern al duration_ms_) i `render()` pinta
}); // alpha=0 (no-op) durant la resta del cicle.
intro_fade_.tick(delta_ms);
if ((timer_ms_ <= 0 || SKIPPED) && next_factory_) { // Comptador del parpadeig: avança sempre amb `delta_ms` cru. La
next_scene_ = next_factory_(); // lectura (cicle/visible) la decideix `render()` segons la fase:
// IDLE usa visible_ms / hidden_ms del yaml (asimètric, 80/20 per
// defecte); STARTING usa un cicle simètric 50/50 amb període
// `starting_blink_period_ms`.
blink_elapsed_ms_ += delta_ms;
// Rellotge de la seqüència d'intro. Avança sempre — `enterStarting()`
// ja el força al final de la seqüència, així el seu valor a STARTING
// i ATTRACT_OUT és inofensiu (els consumidors clampen).
intro_elapsed_ms_ += delta_ms;
if (!press_start_armed_) {
const auto& intro = UiConfig::get().titleIntro();
if (intro_elapsed_ms_ >= intro.press_start_at_ms) {
// Resetegem el cicle del parpadeig a l'instant en què el
// PRESS START passa a ser visible per primera vegada, perquè
// el primer frame caigui en estat "on" (si no, el modulus
// amb el contador acumulat des de t=0 podria caure mig
// cicle i donar un primer "off" estrany).
press_start_armed_ = true;
blink_elapsed_ms_ = 0;
}
}
const bool P1_START = pressedStart(0);
const bool P2_START = pressedStart(1);
switch (phase_) {
case Phase::IDLE:
tickIdle(delta_ms, P1_START, P2_START);
break;
case Phase::STARTING:
tickStarting(delta_ms, P1_START, P2_START);
break;
case Phase::ATTRACT_OUT:
tickAttract(delta_ms);
break;
} }
Notifier::get().update(delta_ms); Notifier::get().update(delta_ms);
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow())); RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
} }
void TitleScene::render() { void TitleScene::enterStarting(DebugConfig::StartingPlayers players) {
constexpr std::uint32_t BLACK_ARGB = 0xFF000000U; phase_ = Phase::STARTING;
Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BLACK_ARGB); pending_players_ = players;
starting_timer_ms_ = UiConfig::get().titleStartingMs();
// Resetem el comptador del parpadeig perquè el primer frame de
// STARTING comenci visible — sense això, el modulus amb el nou
// període podria caure mig cicle i donar un primer "off" estrany.
blink_elapsed_ms_ = 0;
if (font_) { // Shortcut: si l'usuari ha premut START durant la intro, saltem al
const int SECS_LEFT = std::max(1, (timer_ms_ + 999) / 1000); // final de la seqüència perquè el frame de STARTING comenci amb
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, // tot a la posició final i visible (sense salts ni elements en
GameCanvas::CENTER_X, // moviment a meitat). FadeToBlack::tick clampeja internament a
GameCanvas::CENTER_Y + NAME_Y_OFFSET, // duration_ms, així que un delta gegant l'avança a finished sense
SCENE_NAME, // efectes secundaris.
1, const auto& intro = UiConfig::get().titleIntro();
UiConfig::get().notificationText()); const int INTRO_END_MS = std::max({
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, intro.game_logo_at_ms,
GameCanvas::CENTER_X, intro.mini_logo_at_ms + intro.mini_logo_slide_ms,
GameCanvas::CENTER_Y + TIMER_Y_OFFSET, intro.copyright_at_ms + intro.copyright_slide_ms,
std::to_string(SECS_LEFT), intro.press_start_at_ms,
1, });
UiConfig::get().notificationText()); intro_elapsed_ms_ = std::max(intro_elapsed_ms_, INTRO_END_MS);
press_start_armed_ = true;
intro_fade_.tick(INTRO_END_MS);
// Fade-out de la música. Si l'altre jugador prem START i el timer
// es reseteja, no rearranquem la música: es manté la fosa iniciada
// ací (consistent amb el fade visual, que tampoc es reverteix).
if (auto* audio = Audio::get(); audio != nullptr) {
audio->fadeOutMusic(UiConfig::get().titleMusicFadeOutMs());
}
std::cout << "[scene] " << SCENE_NAME << " STARTING (players=" << DebugConfig::toString(players) << ")\n";
}
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& intro = UiConfig::get().titleIntro();
renderBackground();
renderGameLogo(intro);
renderPressStart(intro);
renderFooter(intro);
Notifier::get().render(); Notifier::get().render();
renderTransitions();
RenderInfo::get().render(); RenderInfo::get().render();
Screen::get().present(); Screen::get().present();
} }
void TitleScene::renderBackground() {
// Fons: la imatge si està disponible (cobreix tot el canvas amb la
// seua paleta nadiua), opcionalment amb heat-haze; en cas contrari,
// color pla de la paleta UI com a fallback defensiu.
if (background_) {
if (heat_haze_) {
heat_haze_->render(*background_, 0, 0);
} else {
background_->render(0, 0);
}
return;
}
const auto& ui_config = UiConfig::get();
const auto& palette = Resource::Cache::get().getPalette("ui");
Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, palette[ui_config.titleBgColor()]);
}
void TitleScene::renderGameLogo(const UiConfig::TitleIntro& intro) {
// Visibilitat binària gestionada per la seqüència d'intro: a IDLE
// només es pinta a partir de `game_logo_at_ms`; fora d'IDLE sempre.
// TODO: aplicar fade-in alpha quan Surface suporti renderWithAlpha.
const bool VISIBLE = phase_ != Phase::IDLE || intro_elapsed_ms_ >= intro.game_logo_at_ms;
if (!game_logo_ || !VISIBLE) { return; }
const auto& ui_config = UiConfig::get();
const int LOGO_Y = (GameCanvas::HEIGHT * ui_config.titleLogoYPercent()) / 100;
const int LOGO_X = GameCanvas::CENTER_X - (game_logo_->getWidth() / 2);
game_logo_->render(LOGO_X, LOGO_Y);
}
void TitleScene::renderPressStart(const UiConfig::TitleIntro& intro) {
// A IDLE només apareix a partir de `press_start_at_ms`; fora d'IDLE
// (STARTING o ATTRACT_OUT) sempre, com fins ara.
const bool VISIBLE = phase_ != Phase::IDLE || intro_elapsed_ms_ >= intro.press_start_at_ms;
if (!condensed_font_ || !VISIBLE) { return; }
// Cicle de parpadeig: a IDLE pren visible_ms+hidden_ms del yaml
// (asimètric, 80/20 per defecte); a STARTING commuta a 50/50 amb
// `starting_blink_period_ms` per donar feedback de "polsat".
// Si el cicle val 0 (configuració degenerada), cau a "sempre visible".
const auto& ui_config = UiConfig::get();
int cycle_ms = 0;
int visible_ms = 0;
if (phase_ == Phase::STARTING) {
cycle_ms = std::max(0, ui_config.titleStartingBlinkPeriodMs());
visible_ms = cycle_ms / 2;
} else {
visible_ms = std::max(0, ui_config.titleBlinkVisibleMs());
cycle_ms = visible_ms + std::max(0, ui_config.titleBlinkHiddenMs());
}
const bool BLINK_ON = (cycle_ms == 0) || ((blink_elapsed_ms_ % cycle_ms) < visible_ms);
if (!BLINK_ON) { return; }
constexpr Uint8 OUTLINE_DIST = 1;
constexpr Uint8 STROKE4 = Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG;
const int PROMPT_Y = (GameCanvas::HEIGHT * ui_config.titlePromptYPercent()) / 100;
condensed_font_->writeDX(STROKE4,
GameCanvas::CENTER_X,
PROMPT_Y,
Locale::get().text("title.press_start"),
condensed_font_->getDefaultKerning() - 1,
ui_config.titleTextColor(),
OUTLINE_DIST,
ui_config.titlePressStartOutlineColor());
}
void TitleScene::renderFooter(const UiConfig::TitleIntro& intro) {
// Footer: minilogo centrat horitzontalment damunt del © de
// copyright. El text va al `title.copyright_y_percent`, el logo just
// damunt amb un petit gap (`title.mini_logo_gap_px`). El copyright fa
// servir una font pixel-art pròpia (Tiny5-Regular size 8) — més neta
// i petita que la del "press start" perquè la jerarquia tipogràfica
// del peu quedi clara.
if (!copyright_font_) { return; }
const auto& ui_config = UiConfig::get();
const int COPY_Y = (GameCanvas::HEIGHT * ui_config.titleCopyrightYPercent()) / 100;
const bool IDLE = phase_ == Phase::IDLE;
// Mini-logo: slide-in vertical des del marge inferior amb la corba
// `slide_curve`. Fora d'IDLE (o un cop completat) la Y és la final.
if (mini_logo_ && (!IDLE || intro_elapsed_ms_ >= intro.mini_logo_at_ms)) {
const int FINAL_Y = COPY_Y - mini_logo_->getHeight() - ui_config.titleMiniLogoGapPx();
int y = FINAL_Y;
if (IDLE && intro.mini_logo_slide_ms > 0) {
const float T = std::clamp(
static_cast<float>(intro_elapsed_ms_ - intro.mini_logo_at_ms) / static_cast<float>(intro.mini_logo_slide_ms),
0.0F,
1.0F);
y = Easing::lerpInt(GameCanvas::HEIGHT, FINAL_Y, intro.slide_curve(T));
}
const int LOGO_X = GameCanvas::CENTER_X - (mini_logo_->getWidth() / 2);
mini_logo_->render(LOGO_X, y);
}
// Copyright: mateix patró amb el seu propi tempo. La diferència
// de temps entre `mini_logo_at_ms` i `copyright_at_ms` controla
// el decalatge entre les dues entrades (per defecte 200 ms).
if (!IDLE || intro_elapsed_ms_ >= intro.copyright_at_ms) {
int y = COPY_Y;
if (IDLE && intro.copyright_slide_ms > 0) {
const float T = std::clamp(
static_cast<float>(intro_elapsed_ms_ - intro.copyright_at_ms) / static_cast<float>(intro.copyright_slide_ms),
0.0F,
1.0F);
y = Easing::lerpInt(GameCanvas::HEIGHT, COPY_Y, intro.slide_curve(T));
}
copyright_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
y,
"© 2026 JailDesigner",
copyright_font_->getDefaultKerning(),
ui_config.titleCopyrightColor());
}
}
void TitleScene::renderTransitions() {
// 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
// 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();
}
}
+69 -12
View File
@@ -1,30 +1,87 @@
#pragma once #pragma once
#include <functional> #include <cstdint>
#include <memory> #include <memory>
#include <optional>
#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 "core/scene/scene.hpp"
#include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp"
class Surface;
class Text; class Text;
// Escena placeholder del títol. Mateix comportament que LogoScene — duració // Escena TITLE provisional. Pinta dos blocs de text sobre un fons gairebé
// fixa de 5 s i skip amb tecla no reservada —, però classe separada perquè // negre i un prompt "press start" intermitent fins que apareix l'artwork
// quan divergeixi en contingut real (menú, seleccions, animacions) creixi en // real. Tota la cosmètica (colors, durada, ritme del parpadeig) viu a
// el seu propi fitxer sense haver de refactoritzar cap base comuna. // data/ui.yaml secció `title:` per poder afinar-la sense recompilar.
class TitleScene : public Scene { class TitleScene : public Scene {
public: public:
using SceneFactory = std::function<std::unique_ptr<Scene>()>; // Fases de l'escena.
// - 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ó 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,
ATTRACT_OUT };
static constexpr int DURATION_MS = 5000; TitleScene(std::shared_ptr<Text> condensed_font, std::shared_ptr<Text> copyright_font, std::shared_ptr<Surface> background, std::shared_ptr<Surface> game_logo, std::shared_ptr<Surface> mini_logo);
TitleScene(std::shared_ptr<Text> font, SceneFactory next_factory);
~TitleScene() override; ~TitleScene() override;
void update(int delta_ms) override; void update(int delta_ms) override;
void render() override; void render() override;
private: private:
std::shared_ptr<Text> font_; void tickIdle(int delta_ms, bool p1_start, bool p2_start);
SceneFactory next_factory_; void tickStarting(int delta_ms, bool p1_start, bool p2_start);
int timer_ms_{DURATION_MS}; void tickAttract(int delta_ms);
void enterStarting(DebugConfig::StartingPlayers players);
void enterAttract();
void advanceToIntro(DebugConfig::StartingPlayers players);
void advanceToLogo();
// Helpers de `render()` extrets per evitar que la funció gran acumuli
// massa branques. Cadascun pinta un bloc independent del frame.
void renderBackground();
void renderGameLogo(const UiConfig::TitleIntro& intro);
void renderPressStart(const UiConfig::TitleIntro& intro);
void renderFooter(const UiConfig::TitleIntro& intro);
void renderTransitions();
std::shared_ptr<Text> condensed_font_;
std::shared_ptr<Text> copyright_font_;
std::shared_ptr<Surface> background_;
std::shared_ptr<Surface> game_logo_;
std::shared_ptr<Surface> mini_logo_;
int timer_ms_;
int blink_elapsed_ms_{0};
// Rellotge de la seqüència d'intro (vegeu `title.intro` a ui.yaml).
// Avança sempre amb `delta_ms`. La visibilitat i el slide de cada
// element es deriven d'ell a `render()` — no cal cap altre estat.
int intro_elapsed_ms_{0};
// Detector de l'instant en què `intro_elapsed_ms_` creua per primera
// vegada `press_start_at_ms`: en aquell frame es reseteja
// `blink_elapsed_ms_` perquè el text aparegui en estat visible.
bool press_start_armed_{false};
std::optional<Effects::HeatHaze> heat_haze_;
Phase phase_{Phase::IDLE};
int starting_timer_ms_{0};
DebugConfig::StartingPlayers pending_players_{DebugConfig::StartingPlayers::BOTH};
std::optional<Transitions::Mosaic> fade_out_;
std::optional<Transitions::FadeToBlack> 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_;
}; };
+9 -3
View File
@@ -1168,12 +1168,18 @@ auto ServiceMenu::buildSystem() -> Page {
page.items.push_back(makeLabel( page.items.push_back(makeLabel(
std::string("ver.") + Texts::VERSION + " (" + Version::GIT_HASH + ")")); std::string("ver.") + Texts::VERSION + " (" + Version::GIT_HASH + ")"));
// Reiniciar el joc: placeholder sense efecte fins que Director exposi // Reiniciar el joc: tornem al LOGO amb `reset=true` perquè LogoScene
// un pas de restart en calent (fora d'abast d'aquesta iteració). // ature música i sons abans de mostrar-se. Tanquem el menú a la
// mateixa acció — Director recollirà la transició al final del frame
// via `takePendingSceneRequest()` i reconstruirà l'escena.
page.items.push_back(makeAction( page.items.push_back(makeAction(
"menu.items.restart", "menu.items.restart",
[]() { []() {
// TODO: connectar amb un hipotètic Director::requestRestart(). ServiceMenu::get().requestSceneTransition(ExitRequest{
.next = SceneId::LOGO,
.params = LogoParams{.reset = true},
});
ServiceMenu::get().close();
})); }));
page.items.push_back(makeAction( page.items.push_back(makeAction(
+20
View File
@@ -6,11 +6,13 @@
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/input/define_buttons.hpp" #include "core/input/define_buttons.hpp"
#include "core/rendering/surface.hpp" // Palette #include "core/rendering/surface.hpp" // Palette
#include "game/scenes/scene_flow.hpp"
class Text; class Text;
@@ -73,6 +75,19 @@ class ServiceMenu {
// `define_buttons_->enable(...)` de coffee_crisis_arcade_edition. // `define_buttons_->enable(...)` de coffee_crisis_arcade_edition.
void startDefineButtons(SDL_Gamepad* pad, const std::string& name, const std::string& path); void startDefineButtons(SDL_Gamepad* pad, const std::string& name, const std::string& path);
// Bústia de transició d'escena des del menú: alguns items (p.ex.
// "Reiniciar" a Sistema) no es poden expressar com a `ExitRequest` de
// l'escena actual perquè el menú és global i no té handle a l'escena.
// Director consulta `takePendingSceneRequest()` cada frame i, si hi ha
// valor, força la transició — el mateix camí que per a `ExitRequest`
// d'una escena, però el dispara el menú directament.
void requestSceneTransition(ExitRequest req) { pending_request_ = req; }
[[nodiscard]] auto takePendingSceneRequest() -> std::optional<ExitRequest> {
auto out = pending_request_;
pending_request_.reset();
return out;
}
private: private:
// --- Struct d'un item d'una pàgina --- // --- Struct d'un item d'una pàgina ---
// //
@@ -289,4 +304,9 @@ class ServiceMenu {
// persisteix la nova configuració via `GamepadBindings`. Replica el // persisteix la nova configuració via `GamepadBindings`. Replica el
// patró `define_buttons_` de coffee_crisis_arcade_edition. // patró `define_buttons_` de coffee_crisis_arcade_edition.
DefineButtons define_buttons_; DefineButtons define_buttons_;
// Sol·licitud de canvi d'escena pendent (vore `requestSceneTransition`).
// La consumeix Director al final del frame; si l'item no força la
// transició, queda buida.
std::optional<ExitRequest> pending_request_;
}; };
+154
View File
@@ -79,6 +79,7 @@ namespace Defaults {
constexpr SDL_Scancode RIGHT = SDL_SCANCODE_RIGHT; constexpr SDL_Scancode RIGHT = SDL_SCANCODE_RIGHT;
constexpr SDL_Scancode JUMP = SDL_SCANCODE_M; constexpr SDL_Scancode JUMP = SDL_SCANCODE_M;
constexpr SDL_Scancode ATTACK = SDL_SCANCODE_N; constexpr SDL_Scancode ATTACK = SDL_SCANCODE_N;
constexpr SDL_Scancode START = SDL_SCANCODE_1;
constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE; constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE;
} // namespace KeysGame } // namespace KeysGame
@@ -93,6 +94,7 @@ namespace Defaults {
constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D; constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D;
constexpr SDL_Scancode JUMP = SDL_SCANCODE_V; constexpr SDL_Scancode JUMP = SDL_SCANCODE_V;
constexpr SDL_Scancode ATTACK = SDL_SCANCODE_C; constexpr SDL_Scancode ATTACK = SDL_SCANCODE_C;
constexpr SDL_Scancode START = SDL_SCANCODE_2;
} // namespace KeysGameP2 } // namespace KeysGameP2
namespace Player { namespace Player {
@@ -154,6 +156,16 @@ namespace Defaults {
constexpr const char* MENU_ITEMS_GIF = "data/fonts/ari-w9500-condensed-display.gif"; constexpr const char* MENU_ITEMS_GIF = "data/fonts/ari-w9500-condensed-display.gif";
constexpr const char* MENU_ITEMS_FNT = "data/fonts/ari-w9500-condensed-display.fnt"; constexpr const char* MENU_ITEMS_FNT = "data/fonts/ari-w9500-condensed-display.fnt";
constexpr int MENU_ITEMS_KERNING = 0; constexpr int MENU_ITEMS_KERNING = 0;
// Copyright al peu de la TITLE: pixel-art estricte, generat amb
// font-gen a partir de Tiny5-Regular size 5 (caixa 3×6 px). La
// separem de la `placeholder` (que la fa servir el "press start")
// per diferenciar tipogràficament jerarquia: prompt destacat amb
// outline gros, copyright net i micro a baix.
constexpr const char* COPYRIGHT_NAME = "Tiny5-Regular";
constexpr const char* COPYRIGHT_GIF = "data/fonts/Tiny5-Regular.gif";
constexpr const char* COPYRIGHT_FNT = "data/fonts/Tiny5-Regular.fnt";
constexpr int COPYRIGHT_KERNING = 0;
} // namespace Font } // namespace Font
// Overlay de RenderInfo (F12). // Overlay de RenderInfo (F12).
@@ -242,6 +254,148 @@ namespace Defaults {
// Els fitxers viuen a `data/sounds/`; només es referencia el nom. // Els fitxers viuen a `data/sounds/`; només es referencia el nom.
constexpr const char* MENU_SELECT_SOUND = "menu_select.wav"; constexpr const char* MENU_SELECT_SOUND = "menu_select.wav";
constexpr const char* MENU_ACCEPT_SOUND = "menu_accept.wav"; constexpr const char* MENU_ACCEPT_SOUND = "menu_accept.wav";
// So disparat quan un jugador prem START a la TITLE (tant la
// primera pulsació que entra a STARTING com la del segon
// jugador que reseteja a BOTH dins de la finestra). Fitxer a
// `data/sounds/`.
constexpr const char* PLAYER_START_SOUND = "player_start.wav";
// So curt de "soroll/scratch" disparat al constructor de LogoScene
// i sincronitzat amb la fase SCAN del scanline reveal. Playback
// únic via Audio::Group::INTERFACE; si dura més que el scan, la
// cua sona pinçada al començament de MIDDLE — pretès com a
// "eco" sonor de l'efecte.
constexpr const char* LOGO_GLITCH_SOUND = "glitch.wav";
// Escena TITLE (provisional fins que tinguem artwork). Colors
// referits a la paleta "ui" (uzebox.pal): BG_COLOR és el to més
// proper al negre sense ser-ho (índex 1 = 36,0,0); TEXT_COLOR és
// el taronja més pur de la paleta (índex 31 = 255,109,0). Els
// ms del cicle de parpadeig sumen el cicle complet del prompt
// "press start" — 80% visible / 20% ocult per defecte.
namespace Title {
constexpr int DURATION_MS = 30000;
constexpr Uint8 BG_COLOR = 1;
constexpr Uint8 TEXT_COLOR = 31;
// Color del text de "© 2026 JailDesigner": off-white càlid (índex
// 183 = 255,218,170, peach/cream) — ~90% blanc amb tint càlid.
constexpr Uint8 COPYRIGHT_COLOR = 183;
// Color de l'outline del prompt "PRESS START" (no del copyright,
// tot i compartir-se a versions antigues). Marró fosc càlid
// (índex 19 = 109,72,0) coherent amb la paleta del logo.
constexpr Uint8 PRESS_START_OUTLINE_COLOR = 19;
// Posicions Y dels tres elements verticals de la TITLE, en
// percentatge sobre `GameCanvas::HEIGHT`. El logo s'ancora pel
// seu top (la imatge cau cap avall des d'aquest Y); el prompt
// i el copyright es centren verticalment sobre la línia.
constexpr int LOGO_Y_PERCENT = 15;
constexpr int PROMPT_Y_PERCENT = 68;
constexpr int COPYRIGHT_Y_PERCENT = 92;
// Separació vertical entre el minilogo i el text del © al peu.
constexpr int MINI_LOGO_GAP_PX = 2;
constexpr int BLINK_VISIBLE_MS = 800;
constexpr int BLINK_HIDDEN_MS = 200;
// Fase STARTING: durada fixa entre la primera pulsació de
// START i la transició a la següent escena. Durant aquesta
// fase, el parpadeig del prompt commuta a un cicle simètric
// 50/50 (visible/ocult) amb període `STARTING_BLINK_PERIOD_MS`,
// pensat per a un ritme molt més ràpid que l'animació
// atràctil del IDLE (200 ms = 5 Hz).
constexpr int STARTING_MS = 3000;
constexpr int STARTING_BLINK_PERIOD_MS = 200;
// 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;
// Fade-out de la música de la TITLE en entrar a STARTING.
// Independent del visual: no cal que coincidisca amb FADE_OUT_MS
// (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;
// Seqüència d'aparició dels elements de la TITLE en fase IDLE.
// Tots els temps són absoluts des de l'entrada a l'escena.
// `FADE_IN_MS` és la capa negra inicial que s'esvaeix cap al fons
// (mateixa convenció que `room_intro.fade_in_ms` del joc perquè
// l'arribada a la TITLE i a una sala de joc tinguin el mateix
// ritme). Els altres temps marquen quan apareix cada element;
// `SLIDE_*` defineixen el slide-in des del marge inferior amb
// la corba `SLIDE_CURVE`. Si l'usuari prem START durant la
// seqüència, aquesta es completa instantàniament.
namespace Intro {
constexpr int FADE_IN_MS = 500;
constexpr int GAME_LOGO_AT_MS = 1500;
constexpr int MINI_LOGO_AT_MS = 2000;
constexpr int MINI_LOGO_SLIDE_MS = 500;
constexpr int COPYRIGHT_AT_MS = 2200;
constexpr int COPYRIGHT_SLIDE_MS = 500;
constexpr const char* SLIDE_CURVE = "outCubic";
constexpr int PRESS_START_AT_MS = 3000;
} // namespace Intro
// 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
// vertical entre dos pics; `speed_hz` la velocitat amb què
// avança la fase (cicles/segon). Si `enabled` és false el
// fons es pinta sense distorsió.
namespace HeatHaze {
constexpr bool ENABLED = true;
constexpr float AMPLITUDE_PX = 1.5F;
constexpr float WAVELENGTH_PX = 32.0F;
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 } // namespace UI
#ifdef _DEBUG #ifdef _DEBUG
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.33.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.35.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {