Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eaa91f309 | ||
|
|
23fdf6e1bd | ||
|
|
1431f27cfa | ||
|
|
29cb4c2211 | ||
|
|
3f09858203 | ||
|
|
b6d6ee471c | ||
|
|
25ce524470 | ||
|
|
921e10bbb3 | ||
|
|
36fc496226 | ||
|
|
0618452224 | ||
|
|
ca64115bfe | ||
|
|
1fc3ab8e31 | ||
|
|
9233bd8d75 | ||
|
|
e7a024bcdb | ||
|
|
158968e2c2 | ||
|
|
dadbee9bad | ||
|
|
5710568af8 | ||
|
|
165499b9d4 |
No files matched your search
@@ -92,6 +92,9 @@ 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/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
|
||||||
|
|||||||
@@ -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 # ©
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -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 # ©
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 494 B |
|
After Width: | Height: | Size: 200 B |
|
After Width: | Height: | Size: 99 B |
|
After Width: | Height: | Size: 14 KiB |
@@ -98,3 +98,58 @@ 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"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
copyright_color: 246
|
||||||
|
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
|
||||||
|
# Fade-in inicial: capa negra al començament que s'esvaeix cap al fons.
|
||||||
|
# Mateix valor que `room_intro.fade_in_ms` (data/scenes/game.yaml) perquè
|
||||||
|
# l'arribada a la TITLE i a una sala de joc tinguin el mateix ritme.
|
||||||
|
intro_fade_in_ms: 500
|
||||||
|
# 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
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -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_;
|
||||||
};
|
};
|
||||||
@@ -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,10 @@ 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);
|
||||||
|
|
||||||
// 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 +198,7 @@ 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 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 +213,23 @@ 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());
|
||||||
|
menu_title_text_ = menu_title_text;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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 +264,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,13 +393,46 @@ void Director::processMenuInput() {
|
|||||||
|
|
||||||
void Director::switchSceneIfRequested() {
|
void Director::switchSceneIfRequested() {
|
||||||
if (!current_scene_) { return; }
|
if (!current_scene_) { return; }
|
||||||
if (auto next = current_scene_->nextScene()) {
|
// L'escena ha publicat un `ExitRequest` dins de `exit_request_`.
|
||||||
// TODO: migrar a logger unificat (INC-03 a docs/audit-core-rendering.md).
|
// Director el desempaqueta i construeix la nova escena via
|
||||||
std::cout << "Director: scene transition\n";
|
// `buildScene`. Si la `std::any_cast` falla (tipus inesperat), el
|
||||||
current_scene_ = std::move(next);
|
// 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:
|
||||||
|
return std::make_unique<LogoScene>(placeholder_text_);
|
||||||
|
case SceneId::TITLE:
|
||||||
|
return std::make_unique<TitleScene>(menu_title_text_, placeholder_text_, title_bg_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 {
|
||||||
// Ocultació automàtica del cursor: observem els events abans que els
|
// Ocultació automàtica del cursor: observem els events abans que els
|
||||||
// altres steps els consumeixin; Mouse no els modifica, només reacciona
|
// altres steps els consumeixin; Mouse no els modifica, només reacciona
|
||||||
|
|||||||
@@ -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,25 @@ 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> menu_title_text_;
|
||||||
|
std::shared_ptr<Surface> title_bg_surface_;
|
||||||
|
std::shared_ptr<Surface> mini_logo_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>;
|
||||||
};
|
};
|
||||||
@@ -96,8 +96,28 @@ 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_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),
|
||||||
|
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,
|
||||||
|
},
|
||||||
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),
|
||||||
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},
|
||||||
@@ -202,6 +222,31 @@ 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_);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_);
|
||||||
|
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_);
|
||||||
|
readField(t, "intro_fade_in_ms", title_intro_fade_in_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("notifier")) {
|
if (yaml.contains("notifier")) {
|
||||||
|
|||||||
@@ -81,6 +81,29 @@ 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[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 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 titleIntroFadeInMs() const -> int { return title_intro_fade_in_ms_; }
|
||||||
|
[[nodiscard]] auto titleHeatHaze() const -> const TitleHeatHaze& { return title_heat_haze_; }
|
||||||
|
|
||||||
// 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 +119,7 @@ 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_; }
|
||||||
|
|
||||||
// Fonts de UI.
|
// Fonts de UI.
|
||||||
[[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; }
|
[[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; }
|
||||||
@@ -145,8 +169,24 @@ 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_;
|
||||||
|
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_;
|
||||||
|
int title_intro_fade_in_ms_;
|
||||||
|
TitleHeatHaze title_heat_haze_;
|
||||||
|
|
||||||
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_;
|
||||||
|
|
||||||
Font hud_font_;
|
Font hud_font_;
|
||||||
Font placeholder_font_;
|
Font placeholder_font_;
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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};
|
||||||
};
|
};
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -31,9 +32,8 @@ namespace {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
LogoScene::LogoScene(std::shared_ptr<Text> font, SceneFactory next_factory)
|
LogoScene::LogoScene(std::shared_ptr<Text> font)
|
||||||
: font_(std::move(font)),
|
: font_(std::move(font)) {
|
||||||
next_factory_(std::move(next_factory)) {
|
|
||||||
std::cout << "[scene] " << SCENE_NAME << " start\n";
|
std::cout << "[scene] " << SCENE_NAME << " start\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@ void LogoScene::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::TITLE, .params = TitleParams{}};
|
||||||
}
|
}
|
||||||
|
|
||||||
Notifier::get().update(delta_ms);
|
Notifier::get().update(delta_ms);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
#include "core/scene/scene.hpp"
|
#include "core/scene/scene.hpp"
|
||||||
@@ -8,16 +7,14 @@
|
|||||||
class Text;
|
class Text;
|
||||||
|
|
||||||
// Escena placeholder del logotip. Durada fixa (5 s) o skip amb qualsevol tecla
|
// Escena placeholder del logotip. Durada fixa (5 s) o skip amb qualsevol tecla
|
||||||
// no reservada. Quan expira, encadena amb la `SceneFactory` que se li injecta
|
// no reservada. Quan expira, publica un `ExitRequest` cap a TITLE; Director
|
||||||
// al ctor — així l'escena no coneix el tipus concret de la següent, i es pot
|
// és qui construeix la nova escena (l'escena no coneix els altres tipus i
|
||||||
// exercir en tests sense arrossegar GameScene.
|
// es pot exercir en tests sense arrossegar dependències).
|
||||||
class LogoScene : public Scene {
|
class LogoScene : public Scene {
|
||||||
public:
|
public:
|
||||||
using SceneFactory = std::function<std::unique_ptr<Scene>()>;
|
|
||||||
|
|
||||||
static constexpr int DURATION_MS = 5000;
|
static constexpr int DURATION_MS = 5000;
|
||||||
|
|
||||||
LogoScene(std::shared_ptr<Text> font, SceneFactory next_factory);
|
explicit LogoScene(std::shared_ptr<Text> font);
|
||||||
~LogoScene() override;
|
~LogoScene() override;
|
||||||
|
|
||||||
void update(int delta_ms) override;
|
void update(int delta_ms) override;
|
||||||
@@ -25,6 +22,5 @@ class LogoScene : public Scene {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
std::shared_ptr<Text> font_;
|
std::shared_ptr<Text> font_;
|
||||||
SceneFactory next_factory_;
|
|
||||||
int timer_ms_{DURATION_MS};
|
int timer_ms_{DURATION_MS};
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#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 {};
|
||||||
|
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 "?";
|
||||||
|
}
|
||||||
@@ -8,32 +8,111 @@
|
|||||||
#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"
|
||||||
|
|
||||||
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";
|
||||||
|
|
||||||
|
// Textos hardcodejats fins que tinguem artwork. Quan apareguen com
|
||||||
|
// a sprites, aquesta secció desapareix sencera.
|
||||||
|
const char* const TITLE_LINE_1 = "Aventures en Egipte";
|
||||||
|
const char* const TITLE_LINE_2 = "A R C A D E";
|
||||||
|
|
||||||
|
constexpr int TITLE_Y_PERCENT = 25;
|
||||||
|
constexpr int PROMPT_Y_PERCENT = 60;
|
||||||
|
constexpr int COPYRIGHT_Y_PERCENT = 90;
|
||||||
|
constexpr int LOGO_GAP_PX = 2; // Separació vertical entre el minilogo i el text de copyright.
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separació vertical entre la primera i segona línia del títol. Una
|
||||||
|
// alçada de línia és l'opció més llegible al canvas de 240 px sense
|
||||||
|
// entrar a configuració extra; si demà cal afinar-ho, mou-se a
|
||||||
|
// ui.yaml.
|
||||||
|
[[nodiscard]] auto titleLineGap(const Text& font) -> int {
|
||||||
|
return font.getLineHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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> display_font, std::shared_ptr<Text> condensed_font, std::shared_ptr<Surface> background, std::shared_ptr<Surface> mini_logo)
|
||||||
: font_(std::move(font)),
|
: display_font_(std::move(display_font)),
|
||||||
next_factory_(std::move(next_factory)) {
|
condensed_font_(std::move(condensed_font)),
|
||||||
|
background_(std::move(background)),
|
||||||
|
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().titleIntroFadeInMs(), 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 +124,245 @@ 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
// 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";
|
||||||
|
}
|
||||||
|
|
||||||
if (font_) {
|
void TitleScene::tickIdle(int delta_ms, bool p1_start, bool p2_start) {
|
||||||
const int SECS_LEFT = std::max(1, (timer_ms_ + 999) / 1000);
|
timer_ms_ -= delta_ms;
|
||||||
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
|
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& ui_config = UiConfig::get();
|
||||||
|
const auto& palette = Resource::Cache::get().getPalette("ui");
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, palette[ui_config.titleBgColor()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Uint8 TEXT_COLOR = ui_config.titleTextColor();
|
||||||
|
const Uint8 OUTLINE_COLOR = ui_config.titleCopyrightColor();
|
||||||
|
constexpr Uint8 OUTLINE_DIST = 1;
|
||||||
|
constexpr Uint8 STROKE4 = Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG;
|
||||||
|
|
||||||
|
if (display_font_) {
|
||||||
|
const int TITLE_Y = (GameCanvas::HEIGHT * TITLE_Y_PERCENT) / 100;
|
||||||
|
display_font_->writeDX(STROKE4,
|
||||||
GameCanvas::CENTER_X,
|
GameCanvas::CENTER_X,
|
||||||
GameCanvas::CENTER_Y + NAME_Y_OFFSET,
|
TITLE_Y,
|
||||||
SCENE_NAME,
|
TITLE_LINE_1,
|
||||||
1,
|
display_font_->getDefaultKerning(),
|
||||||
UiConfig::get().notificationText());
|
TEXT_COLOR,
|
||||||
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
|
OUTLINE_DIST,
|
||||||
|
OUTLINE_COLOR);
|
||||||
|
display_font_->writeDX(STROKE4,
|
||||||
GameCanvas::CENTER_X,
|
GameCanvas::CENTER_X,
|
||||||
GameCanvas::CENTER_Y + TIMER_Y_OFFSET,
|
TITLE_Y + titleLineGap(*display_font_),
|
||||||
std::to_string(SECS_LEFT),
|
TITLE_LINE_2,
|
||||||
1,
|
display_font_->getDefaultKerning(),
|
||||||
UiConfig::get().notificationText());
|
TEXT_COLOR,
|
||||||
|
OUTLINE_DIST,
|
||||||
|
OUTLINE_COLOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condensed_font_) {
|
||||||
|
// 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".
|
||||||
|
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 VISIBLE = (cycle_ms == 0) || ((blink_elapsed_ms_ % cycle_ms) < visible_ms);
|
||||||
|
|
||||||
|
if (VISIBLE) {
|
||||||
|
const int PROMPT_Y = (GameCanvas::HEIGHT * PROMPT_Y_PERCENT) / 100;
|
||||||
|
condensed_font_->writeDX(STROKE4,
|
||||||
|
GameCanvas::CENTER_X,
|
||||||
|
PROMPT_Y,
|
||||||
|
Locale::get().text("title.press_start"),
|
||||||
|
condensed_font_->getDefaultKerning() - 1,
|
||||||
|
TEXT_COLOR,
|
||||||
|
OUTLINE_DIST,
|
||||||
|
OUTLINE_COLOR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer: minilogo centrat horitzontalment damunt del © de
|
||||||
|
// copyright. El text va al 90% Y, el logo just damunt amb un petit
|
||||||
|
// gap (LOGO_GAP_PX). Tots dos blocs són hardcodejats fins que
|
||||||
|
// tinguem artwork final.
|
||||||
|
if (condensed_font_) {
|
||||||
|
const int COPY_Y = (GameCanvas::HEIGHT * COPYRIGHT_Y_PERCENT) / 100;
|
||||||
|
if (mini_logo_) {
|
||||||
|
const int LOGO_X = GameCanvas::CENTER_X - (mini_logo_->getWidth() / 2);
|
||||||
|
const int LOGO_Y = COPY_Y - mini_logo_->getHeight() - LOGO_GAP_PX;
|
||||||
|
mini_logo_->render(LOGO_X, LOGO_Y);
|
||||||
|
}
|
||||||
|
condensed_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
|
||||||
|
GameCanvas::CENTER_X,
|
||||||
|
COPY_Y,
|
||||||
|
"© 2026 JailDesigner",
|
||||||
|
condensed_font_->getDefaultKerning() - 1,
|
||||||
|
ui_config.titleCopyrightColor());
|
||||||
}
|
}
|
||||||
|
|
||||||
Notifier::get().render();
|
Notifier::get().render();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
RenderInfo::get().render();
|
RenderInfo::get().render();
|
||||||
Screen::get().present();
|
Screen::get().present();
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,69 @@
|
|||||||
#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 "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> display_font, std::shared_ptr<Text> condensed_font, std::shared_ptr<Surface> background, 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();
|
||||||
|
|
||||||
|
std::shared_ptr<Text> display_font_;
|
||||||
|
std::shared_ptr<Text> condensed_font_;
|
||||||
|
std::shared_ptr<Surface> background_;
|
||||||
|
std::shared_ptr<Surface> mini_logo_;
|
||||||
|
int timer_ms_;
|
||||||
|
int blink_elapsed_ms_{0};
|
||||||
|
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_;
|
||||||
};
|
};
|
||||||
@@ -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 {
|
||||||
@@ -242,6 +244,76 @@ 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";
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
constexpr Uint8 COPYRIGHT_COLOR = 246; // (218,218,255) off-white amb un mínim de fred.
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Fade-in inicial de l'escena: capa negra al començament que
|
||||||
|
// s'esvaeix cap a la imatge de fons. Lineal, sense mosaic —
|
||||||
|
// mateixa convenció que el `room_intro.fade_in_ms` del joc
|
||||||
|
// perquè l'arribada a la TITLE i l'arribada a una sala de
|
||||||
|
// joc tinguin el mateix ritme visual.
|
||||||
|
constexpr int INTRO_FADE_IN_MS = 500;
|
||||||
|
|
||||||
|
// 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
|
||||||
} // namespace UI
|
} // namespace UI
|
||||||
|
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
|
|||||||
@@ -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.34.0"; // Single source of truth — Makefile el grep
|
||||||
} // namespace Texts
|
} // namespace Texts
|
||||||
|
|
||||||
namespace GameCanvas {
|
namespace GameCanvas {
|
||||||
|
|||||||