feat(title): música title.ogg amb fade-out, SFX player_start al START i bombament continu del stream sortint per evitar talls quan SDL drena a 2x

This commit is contained in:
JailDesigner committed 2026-05-06 17:44:30 +02:00
1 parent 36fc496226
commit 921e10bbb3
10 files changed
+109 -1

No files matched your search

Binary file not shown.
Binary file not shown.
+6
View File
@@ -98,6 +98,9 @@ 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). # Escena TITLE (placeholder visual mentre no hi haja artwork).
# `bg_color` i `text_color` són índexs sobre la paleta "ui" (uzebox.pal): # `bg_color` i `text_color` són índexs sobre la paleta "ui" (uzebox.pal):
@@ -129,6 +132,9 @@ title:
# és la mida màxima de bloc en píxels al final del fade. # és la mida màxima de bloc en píxels al final del fade.
fade_out_ms: 800 fade_out_ms: 800
fade_out_max_block: 32 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
# Distorsió sinusoidal del fons (efecte de calor del desert). # Distorsió sinusoidal del fons (efecte de calor del desert).
# `amplitude_px`: desplaçament màxim de cada fila en píxels. # `amplitude_px`: desplaçament màxim de cada fila en píxels.
# `wavelength_px`: distància vertical entre dos pics de l'ona. # `wavelength_px`: distància vertical entre dos pics de l'ona.
+44 -1
View File
@@ -127,11 +127,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 +224,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 +240,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 +304,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;
+7
View File
@@ -125,6 +125,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;
}; };
+7
View File
@@ -153,6 +153,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 +164,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
+4
View File
@@ -106,6 +106,7 @@ UiConfig::UiConfig()
title_starting_blink_period_ms_(Defaults::UI::Title::STARTING_BLINK_PERIOD_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_ms_(Defaults::UI::Title::FADE_OUT_MS),
title_fade_out_max_block_(Defaults::UI::Title::FADE_OUT_MAX_BLOCK), 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_heat_haze_{ title_heat_haze_{
.enabled = Defaults::UI::Title::HeatHaze::ENABLED, .enabled = Defaults::UI::Title::HeatHaze::ENABLED,
.amplitude_px = Defaults::UI::Title::HeatHaze::AMPLITUDE_PX, .amplitude_px = Defaults::UI::Title::HeatHaze::AMPLITUDE_PX,
@@ -114,6 +115,7 @@ UiConfig::UiConfig()
}, },
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},
@@ -218,6 +220,7 @@ 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")) { if (yaml.contains("title")) {
@@ -232,6 +235,7 @@ void UiConfig::load(const std::string& file_path) {
readField(t, "starting_blink_period_ms", title_starting_blink_period_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_ms", title_fade_out_ms_);
readField(t, "fade_out_max_block", title_fade_out_max_block_); readField(t, "fade_out_max_block", title_fade_out_max_block_);
readField(t, "music_fade_out_ms", title_music_fade_out_ms_);
if (t.contains("heat_haze")) { if (t.contains("heat_haze")) {
const auto& hh = t["heat_haze"]; const auto& hh = t["heat_haze"];
readField(hh, "enabled", title_heat_haze_.enabled); readField(hh, "enabled", title_heat_haze_.enabled);
+4
View File
@@ -99,6 +99,7 @@ class UiConfig {
[[nodiscard]] auto titleStartingBlinkPeriodMs() const -> int { return title_starting_blink_period_ms_; } [[nodiscard]] auto titleStartingBlinkPeriodMs() const -> int { return title_starting_blink_period_ms_; }
[[nodiscard]] auto titleFadeOutMs() const -> int { return title_fade_out_ms_; } [[nodiscard]] auto titleFadeOutMs() const -> int { return title_fade_out_ms_; }
[[nodiscard]] auto titleFadeOutMaxBlock() const -> int { return title_fade_out_max_block_; } [[nodiscard]] auto titleFadeOutMaxBlock() const -> int { return title_fade_out_max_block_; }
[[nodiscard]] auto titleMusicFadeOutMs() const -> int { return title_music_fade_out_ms_; }
[[nodiscard]] auto titleHeatHaze() const -> const TitleHeatHaze& { return title_heat_haze_; } [[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
@@ -116,6 +117,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_; }
@@ -175,10 +177,12 @@ class UiConfig {
int title_starting_blink_period_ms_; int title_starting_blink_period_ms_;
int title_fade_out_ms_; int title_fade_out_ms_;
int title_fade_out_max_block_; int title_fade_out_max_block_;
int title_music_fade_out_ms_;
TitleHeatHaze title_heat_haze_; 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_;
+26
View File
@@ -8,6 +8,7 @@
#include <string> #include <string>
#include <utility> #include <utility>
#include "core/audio/audio.hpp"
#include "core/input/gamepad_bindings.hpp" #include "core/input/gamepad_bindings.hpp"
#include "core/input/gamepad_manager.hpp" #include "core/input/gamepad_manager.hpp"
#include "core/input/global_inputs.hpp" #include "core/input/global_inputs.hpp"
@@ -69,6 +70,15 @@ namespace {
return font.getLineHeight(); 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> display_font, std::shared_ptr<Text> condensed_font, std::shared_ptr<Surface> background, std::shared_ptr<Surface> mini_logo, 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, SceneFactory next_factory)
@@ -90,6 +100,13 @@ TitleScene::TitleScene(std::shared_ptr<Text> display_font, std::shared_ptr<Text>
fade_out_.emplace(Transitions::Mosaic::Params{ fade_out_.emplace(Transitions::Mosaic::Params{
.max_block_size = UiConfig::get().titleFadeOutMaxBlock(), .max_block_size = UiConfig::get().titleFadeOutMaxBlock(),
}); });
// 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).
if (auto* audio = Audio::get(); audio != nullptr) {
audio->playMusic("title.ogg", -1);
}
} }
TitleScene::~TitleScene() = default; TitleScene::~TitleScene() = default;
@@ -117,8 +134,10 @@ void TitleScene::update(int delta_ms) {
case Phase::IDLE: { case Phase::IDLE: {
timer_ms_ -= delta_ms; timer_ms_ -= delta_ms;
if (P1_START) { if (P1_START) {
playStartSound();
enterStarting(DebugConfig::StartingPlayers::P1); enterStarting(DebugConfig::StartingPlayers::P1);
} else if (P2_START) { } else if (P2_START) {
playStartSound();
enterStarting(DebugConfig::StartingPlayers::P2); enterStarting(DebugConfig::StartingPlayers::P2);
} else if (timer_ms_ <= 0) { } else if (timer_ms_ <= 0) {
// Attract: ningú ha premut. Entrem a STARTING amb BOTH // Attract: ningú ha premut. Entrem a STARTING amb BOTH
@@ -140,6 +159,7 @@ void TitleScene::update(int delta_ms) {
(pending_players_ == DebugConfig::StartingPlayers::P2 && P1_START); (pending_players_ == DebugConfig::StartingPlayers::P2 && P1_START);
const bool FADE_LOCKED = fade_out_ && (fade_out_->isActive() || fade_out_->isFinished()); const bool FADE_LOCKED = fade_out_ && (fade_out_->isActive() || fade_out_->isFinished());
if (OTHER_PRESSED && !FADE_LOCKED) { if (OTHER_PRESSED && !FADE_LOCKED) {
playStartSound();
pending_players_ = DebugConfig::StartingPlayers::BOTH; pending_players_ = DebugConfig::StartingPlayers::BOTH;
starting_timer_ms_ = UiConfig::get().titleStartingMs(); starting_timer_ms_ = UiConfig::get().titleStartingMs();
} }
@@ -172,6 +192,12 @@ void TitleScene::enterStarting(DebugConfig::StartingPlayers players) {
// STARTING comenci visible — sense això, el modulus amb el nou // STARTING comenci visible — sense això, el modulus amb el nou
// període podria caure mig cicle i donar un primer "off" estrany. // període podria caure mig cicle i donar un primer "off" estrany.
blink_elapsed_ms_ = 0; 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"; std::cout << "[scene] " << SCENE_NAME << " STARTING (players=" << DebugConfig::toString(players) << ")\n";
} }
+11
View File
@@ -245,6 +245,12 @@ namespace Defaults {
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 // Escena TITLE (provisional fins que tinguem artwork). Colors
// referits a la paleta "ui" (uzebox.pal): BG_COLOR és el to més // 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 // proper al negre sense ser-ho (índex 1 = 36,0,0); TEXT_COLOR és
@@ -276,6 +282,11 @@ namespace Defaults {
constexpr int FADE_OUT_MS = 800; constexpr int FADE_OUT_MS = 800;
constexpr int FADE_OUT_MAX_BLOCK = 32; 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;
// Distorsió sinusoidal horitzontal de l'imatge de fons // Distorsió sinusoidal horitzontal de l'imatge de fons
// (efecte de calor del desert). `amplitude_px` és el // (efecte de calor del desert). `amplitude_px` és el
// desplaçament màxim per fila; `wavelength_px` la distància // desplaçament màxim per fila; `wavelength_px` la distància