diff --git a/CMakeLists.txt b/CMakeLists.txt index b3dcc80..3ac0f68 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,7 +55,9 @@ set(APP_SOURCES source/core/system/director.cpp source/core/audio/audio.cpp source/core/audio/audio_adapter.cpp + source/core/audio/audio_effects.cpp source/core/audio/jail_audio.cpp + source/core/audio/sound_effects_config.cpp source/core/locale/locale.cpp source/core/ui/ui_config.cpp source/core/input/key_config.cpp diff --git a/data/scenes/game.yaml b/data/scenes/game.yaml index f23a211..d1d0dfe 100644 --- a/data/scenes/game.yaml +++ b/data/scenes/game.yaml @@ -13,6 +13,11 @@ room_intro: line_gap_px: -4 # px extra entre les dues línies (piràmide / sala) player_spawn_delay_ms: 2400 # ms des de l'inici d'INTRO al spawn aeri. player2_spawn_delay_ms: 2700 # només mode BOTH. Trenca la simultaneïtat. + # Preset de reverb (catàleg a `data/sounds.yaml`) aplicat al primer + # LAND de cada jugador durant la cinemàtica d'entrada. La sala dona + # "més detall" sonor a l'INTRO; els salts del joc i els respawns + # post-mort cauen al so sec. Buit o absent = sense reverb. + land_reverb: stone_hall room_outro: text_enter_ms: 400 diff --git a/data/sounds.yaml b/data/sounds.yaml new file mode 100644 index 0000000..474b2ce --- /dev/null +++ b/data/sounds.yaml @@ -0,0 +1,37 @@ +# Presets d'efectes per a sons puntuals. Les crides +# Audio::playSoundWithEcho(name, preset, group) i +# Audio::playSoundWithReverb(name, preset, group) referencien aquests +# presets pel seu nom (camp `preset`). Si el nom no existeix, el so es +# reprodueix sec amb un avís a stderr. +# +# Camps: +# echo: +# delay_ms → temps fins al primer rebot, en mil·lisegons. +# feedback → reinjecció (0..0.95). >0.7 sona "metàl·lic". +# wet → mescla del senyal humit (0..1). 1 = només eco. +# reverb: +# room_size → mida percebuda de la sala (0..1). +# damping → atenuació de freqüències altes en cada rebot (0..1). +# wet → mescla humida (0..1). +# +# Els processadors limiten la cua a 800 ms (echo) i 1500 ms (reverb). + +echo: + cave: + delay_ms: 220 + feedback: 0.45 + wet: 0.35 + short: + delay_ms: 80 + feedback: 0.30 + wet: 0.25 + +reverb: + stone_hall: + room_size: 0.78 + damping: 0.50 + wet: 0.40 + small_room: + room_size: 0.40 + damping: 0.70 + wet: 0.30 diff --git a/source/core/audio/audio.cpp b/source/core/audio/audio.cpp index e9904ca..c77c3b1 100644 --- a/source/core/audio/audio.cpp +++ b/source/core/audio/audio.cpp @@ -5,9 +5,10 @@ #include // Per a clamp #include // Per a std::fprintf -#include "core/audio/audio_adapter.hpp" // Per a AudioResource::getMusic/getSound -#include "core/audio/jail_audio.hpp" // Per a Ja::* (motor jailgames) -#include "utils/defaults.hpp" // Per a Defaults::Audio::FREQUENCY +#include "core/audio/audio_adapter.hpp" // Per a AudioResource::getMusic/getSound +#include "core/audio/jail_audio.hpp" // Per a Ja::* (motor jailgames) +#include "core/audio/sound_effects_config.hpp" // Per a SoundEffectsConfig +#include "utils/defaults.hpp" // Per a Defaults::Audio::FREQUENCY // Invariant compile-time: tots els valors d'Audio::Group han de cabre als slots // de volum per grup que manté l'engine. Si s'afegeix una nova entrada a Group @@ -128,6 +129,47 @@ void Audio::playSound(Ja::Sound* sound, Group group) { } } +// Reprodueix un so processat per un eco definit a sounds.yaml. Si el preset no +// existeix o l'engine retorna -1 (cap de canals d'efecte plé), cau a playSound +// sec — l'usuari sent el so encara que la cua no s'apliqui. +// NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_ +void Audio::playSoundWithEcho(const std::string& name, const std::string& preset_name, Group group) { + if (!sound_enabled_) { return; } + auto* sound = AudioResource::getSound(name); + if (sound == nullptr) { return; } + + const auto* params = SoundEffectsConfig::get().findEcho(preset_name); + if (params == nullptr) { + std::fprintf(stderr, "Audio: preset d'eco '%s' desconegut — so '%s' es reprodueix sec\n", preset_name.c_str(), name.c_str()); + engine_->playSound(sound, 0, static_cast(group)); + return; + } + + if (engine_->playSoundWithEcho(sound, *params, static_cast(group)) < 0) { + engine_->playSound(sound, 0, static_cast(group)); + } +} + +// Reprodueix un so processat per un reverb definit a sounds.yaml. Mateix +// fallback que playSoundWithEcho. +// NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_ +void Audio::playSoundWithReverb(const std::string& name, const std::string& preset_name, Group group) { + if (!sound_enabled_) { return; } + auto* sound = AudioResource::getSound(name); + if (sound == nullptr) { return; } + + const auto* params = SoundEffectsConfig::get().findReverb(preset_name); + if (params == nullptr) { + std::fprintf(stderr, "Audio: preset de reverb '%s' desconegut — so '%s' es reprodueix sec\n", preset_name.c_str(), name.c_str()); + engine_->playSound(sound, 0, static_cast(group)); + return; + } + + if (engine_->playSoundWithReverb(sound, *params, static_cast(group)) < 0) { + engine_->playSound(sound, 0, static_cast(group)); + } +} + // Atura tots els sons // NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_; unique_ptr::operator-> no propaga const void Audio::stopAllSounds() { diff --git a/source/core/audio/audio.hpp b/source/core/audio/audio.hpp index 09e8fed..50171d8 100644 --- a/source/core/audio/audio.hpp +++ b/source/core/audio/audio.hpp @@ -80,7 +80,13 @@ class Audio { // --- Control de sons --- void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja) void playSound(Ja::Sound* sound, Group group = Group::GAME); // Reproduir so puntual per punter (muta globals de Ja) - void stopAllSounds(); // Aturar tots els sons (muta globals de Ja) + // Reprodueix un so processat per un efecte definit a data/sounds.yaml + // (preset_name busca a SoundEffectsConfig). Si el preset no existeix + // o el motor està al cap de canals amb efecte, fa fallback a playSound + // sec — l'usuari sent el so igualment, sense la cua. + void playSoundWithEcho(const std::string& name, const std::string& preset_name, Group group = Group::GAME); + void playSoundWithReverb(const std::string& name, const std::string& preset_name, Group group = Group::GAME); + void stopAllSounds(); // Aturar tots els sons (muta globals de Ja) // --- Control de volum (API interna: float 0.0..1.0) --- void setSoundVolume(float volume, Group group = Group::ALL); // Ajusta el volum dels efectes diff --git a/source/core/audio/audio_effects.cpp b/source/core/audio/audio_effects.cpp new file mode 100644 index 0000000..aeaec81 --- /dev/null +++ b/source/core/audio/audio_effects.cpp @@ -0,0 +1,250 @@ +#include "core/audio/audio_effects.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "core/audio/jail_audio.hpp" + +namespace AudioEffects { + + namespace { + + // --- Caps de cua --- + constexpr float ECHO_TAIL_MS = 800.0F; + constexpr float REVERB_TAIL_MS = 1500.0F; + + // --- Constants Freeverb --- + // Delays de comb i allpass tunats per a 44.1 kHz; els reescalem per + // freqüència real de la font. + constexpr int COMB_REFERENCE_RATE = 44100; + constexpr std::array COMB_DELAYS_L = {1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617}; + constexpr std::array ALLPASS_DELAYS_L = {556, 441, 341, 225}; + constexpr int STEREO_SPREAD = 23; + + // Mapeig de Schroeder/Dattorro/Freeverb estàndard. + constexpr float FIXED_GAIN = 0.015F; + constexpr float SCALE_ROOM = 0.28F; + constexpr float OFFSET_ROOM = 0.7F; + constexpr float SCALE_DAMP = 0.4F; + + // --- Decodificació a float -1..1 --- + // Suporta U8/S16, mono/estèreo. Mono es duplica a L i R (la cadena + // d'efectes treballa sempre amb dos canals per simplicitat). + auto decodeToStereoFloat(const Ja::Sound& src, std::vector& left, std::vector& right) -> bool { + const auto& spec = src.spec; + const Uint8* buf = src.buffer.get(); + if (buf == nullptr || src.length == 0) { return false; } + + int bytes_per_sample = 0; + if (spec.format == SDL_AUDIO_S16) { + bytes_per_sample = 2; + } else if (spec.format == SDL_AUDIO_U8) { + bytes_per_sample = 1; + } else { + std::cerr << "AudioEffects: format de so no suportat (només U8 o S16)\n"; + return false; + } + if (spec.channels < 1 || spec.channels > 2) { + std::cerr << "AudioEffects: el so ha de ser mono o estèreo\n"; + return false; + } + + const std::size_t TOTAL_FRAMES = src.length / static_cast(bytes_per_sample * spec.channels); + left.resize(TOTAL_FRAMES); + right.resize(TOTAL_FRAMES); + + for (std::size_t i = 0; i < TOTAL_FRAMES; ++i) { + float sample_l = 0.0F; + float sample_r = 0.0F; + if (spec.format == SDL_AUDIO_S16) { + const auto* p = reinterpret_cast(buf + (i * spec.channels * 2)); + sample_l = static_cast(p[0]) / 32768.0F; + sample_r = (spec.channels == 2) ? static_cast(p[1]) / 32768.0F : sample_l; + } else { // U8 + const Uint8* p = buf + (i * spec.channels); + sample_l = (static_cast(p[0]) - 128.0F) / 128.0F; + sample_r = (spec.channels == 2) ? (static_cast(p[1]) - 128.0F) / 128.0F : sample_l; + } + left[i] = sample_l; + right[i] = sample_r; + } + return true; + } + + // Empaqueta dos canals float (-1..1) a S16 entrellaçat. + void encodeStereoS16(const std::vector& left, const std::vector& right, std::vector& out) { + const std::size_t LEN = left.size(); + out.resize(LEN * 2 * sizeof(std::int16_t)); + auto* dst = reinterpret_cast(out.data()); + for (std::size_t i = 0; i < LEN; ++i) { + const float L = std::clamp(left[i], -1.0F, 1.0F); + const float R = std::clamp(right[i], -1.0F, 1.0F); + dst[(i * 2) + 0] = static_cast(std::lround(L * 32767.0F)); + dst[(i * 2) + 1] = static_cast(std::lround(R * 32767.0F)); + } + } + + // Reescala un delay de la taula de Freeverb per a la freqüència real. + auto scaledDelay(int reference_delay, int rate) -> int { + const long SCALED = std::lround(static_cast(reference_delay) * static_cast(rate) / static_cast(COMB_REFERENCE_RATE)); + return std::max(1, static_cast(SCALED)); + } + + // --- Filtres bàsics --- + struct Comb { + std::vector buf; + std::size_t idx{0}; + float feedback{0.0F}; + float damp1{0.0F}; + float damp2{1.0F}; + float store{0.0F}; + + void init(int delay, float fb, float damping) { + buf.assign(static_cast(delay), 0.0F); + idx = 0; + feedback = fb; + damp1 = damping; + damp2 = 1.0F - damping; + store = 0.0F; + } + auto tick(float in) -> float { + const float OUT = buf[idx]; + store = (OUT * damp2) + (store * damp1); + buf[idx] = in + (store * feedback); + idx = (idx + 1) % buf.size(); + return OUT; + } + }; + + struct Allpass { + std::vector buf; + std::size_t idx{0}; + + void init(int delay) { + buf.assign(static_cast(delay), 0.0F); + idx = 0; + } + auto tick(float in) -> float { + const float BUFOUT = buf[idx]; + const float OUT = -in + BUFOUT; + buf[idx] = in + (BUFOUT * 0.5F); + idx = (idx + 1) % buf.size(); + return OUT; + } + }; + + } // namespace + + auto applyEcho(const Ja::Sound& src, const Ja::EchoParams& params) -> std::optional { + std::vector left; + std::vector right; + if (!decodeToStereoFloat(src, left, right)) { return std::nullopt; } + + const int RATE = src.spec.freq; + const int DELAY_SAMPLES = std::max(1, static_cast(std::lround(params.delay_ms * 0.001F * static_cast(RATE)))); + const auto TAIL_SAMPLES = static_cast(std::lround(ECHO_TAIL_MS * 0.001F * static_cast(RATE))); + + const float FEEDBACK = std::clamp(params.feedback, 0.0F, 0.95F); + const float WET = std::clamp(params.wet, 0.0F, 1.0F); + const float DRY = 1.0F - WET; + + const std::size_t INPUT_LEN = left.size(); + const std::size_t TOTAL_LEN = INPUT_LEN + TAIL_SAMPLES; + + std::vector ring_l(static_cast(DELAY_SAMPLES), 0.0F); + std::vector ring_r(static_cast(DELAY_SAMPLES), 0.0F); + std::size_t cursor = 0; + + std::vector out_l(TOTAL_LEN); + std::vector out_r(TOTAL_LEN); + + for (std::size_t i = 0; i < TOTAL_LEN; ++i) { + const float IN_L = (i < INPUT_LEN) ? left[i] : 0.0F; + const float IN_R = (i < INPUT_LEN) ? right[i] : 0.0F; + + const float DELAYED_L = ring_l[cursor]; + const float DELAYED_R = ring_r[cursor]; + + out_l[i] = (DRY * IN_L) + (WET * DELAYED_L); + out_r[i] = (DRY * IN_R) + (WET * DELAYED_R); + + ring_l[cursor] = IN_L + (DELAYED_L * FEEDBACK); + ring_r[cursor] = IN_R + (DELAYED_R * FEEDBACK); + cursor = (cursor + 1) % static_cast(DELAY_SAMPLES); + } + + ProcessedSound result; + result.spec = SDL_AudioSpec{.format = SDL_AUDIO_S16, .channels = 2, .freq = RATE}; + encodeStereoS16(out_l, out_r, result.bytes); + return result; + } + + auto applyReverb(const Ja::Sound& src, const Ja::ReverbParams& params) -> std::optional { + std::vector left; + std::vector right; + if (!decodeToStereoFloat(src, left, right)) { return std::nullopt; } + + const int RATE = src.spec.freq; + const auto TAIL_SAMPLES = static_cast(std::lround(REVERB_TAIL_MS * 0.001F * static_cast(RATE))); + + const float ROOM_SIZE = std::clamp(params.room_size, 0.0F, 1.0F); + const float DAMPING = std::clamp(params.damping, 0.0F, 1.0F); + const float WET = std::clamp(params.wet, 0.0F, 1.0F); + const float DRY = 1.0F - WET; + + const float FEEDBACK = (ROOM_SIZE * SCALE_ROOM) + OFFSET_ROOM; // 0.7..0.98 + const float DAMP1 = DAMPING * SCALE_DAMP; // 0..0.4 + + // Inicialitza els 8 comb filters per cada canal i els 4 allpass. + std::array comb_l; + std::array comb_r; + for (std::size_t i = 0; i < COMB_DELAYS_L.size(); ++i) { + comb_l[i].init(scaledDelay(COMB_DELAYS_L[i], RATE), FEEDBACK, DAMP1); + comb_r[i].init(scaledDelay(COMB_DELAYS_L[i] + STEREO_SPREAD, RATE), FEEDBACK, DAMP1); + } + std::array allpass_l; + std::array allpass_r; + for (std::size_t i = 0; i < ALLPASS_DELAYS_L.size(); ++i) { + allpass_l[i].init(scaledDelay(ALLPASS_DELAYS_L[i], RATE)); + allpass_r[i].init(scaledDelay(ALLPASS_DELAYS_L[i] + STEREO_SPREAD, RATE)); + } + + const std::size_t INPUT_LEN = left.size(); + const std::size_t TOTAL_LEN = INPUT_LEN + TAIL_SAMPLES; + std::vector out_l(TOTAL_LEN); + std::vector out_r(TOTAL_LEN); + + for (std::size_t i = 0; i < TOTAL_LEN; ++i) { + const float IN_L = (i < INPUT_LEN) ? left[i] : 0.0F; + const float IN_R = (i < INPUT_LEN) ? right[i] : 0.0F; + const float MONO_INPUT = (IN_L + IN_R) * FIXED_GAIN; + + // 8 comb filters en paral·lel, sumats. + float wet_l = 0.0F; + float wet_r = 0.0F; + for (std::size_t k = 0; k < comb_l.size(); ++k) { + wet_l += comb_l[k].tick(MONO_INPUT); + wet_r += comb_r[k].tick(MONO_INPUT); + } + // 4 allpass en sèrie. + for (std::size_t k = 0; k < allpass_l.size(); ++k) { + wet_l = allpass_l[k].tick(wet_l); + wet_r = allpass_r[k].tick(wet_r); + } + + out_l[i] = (DRY * IN_L) + (WET * wet_l); + out_r[i] = (DRY * IN_R) + (WET * wet_r); + } + + ProcessedSound result; + result.spec = SDL_AudioSpec{.format = SDL_AUDIO_S16, .channels = 2, .freq = RATE}; + encodeStereoS16(out_l, out_r, result.bytes); + return result; + } + +} // namespace AudioEffects diff --git a/source/core/audio/audio_effects.hpp b/source/core/audio/audio_effects.hpp new file mode 100644 index 0000000..3133008 --- /dev/null +++ b/source/core/audio/audio_effects.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include +#include +#include + +// Forward-declaració per no incloure jail_audio.hpp (cicle d'inclusió: aquest +// header viu sota els params declarats a jail_audio.hpp, i alhora jail_audio +// usa applyEcho/applyReverb). +namespace Ja { + struct Sound; + struct EchoParams; + struct ReverbParams; +} // namespace Ja + +// Processadors d'efectes per a sons puntuals. Reben un Ja::Sound (qualsevol +// format suportat pel decodificador WAV: U8/S16, mono o estèreo) i tornen un +// buffer PCM en S16 + el seu spec, llest per empenyer a un SDL_AudioStream. +// +// El buffer de sortida inclou la cua (decay) generada per l'efecte: per al +// reverb, fins a 1500 ms; per a l'eco, fins a 800 ms. Aquests caps eviten +// allargar indefinidament la reproducció quan els paràmetres reinjecten molt. +// +// Si el format del so d'origen no es pot processar, retornen std::nullopt +// (el caller ha de fer fallback a reproducció seca). +namespace AudioEffects { + + struct ProcessedSound { + std::vector bytes; // PCM S16 entrellaçat (LRLRLR... si stereo) + SDL_AudioSpec spec; // Format/canals/freqüència del buffer + }; + + [[nodiscard]] auto applyEcho(const Ja::Sound& src, const Ja::EchoParams& params) -> std::optional; + [[nodiscard]] auto applyReverb(const Ja::Sound& src, const Ja::ReverbParams& params) -> std::optional; + +} // namespace AudioEffects diff --git a/source/core/audio/jail_audio.cpp b/source/core/audio/jail_audio.cpp index be1c110..ef4f1ac 100644 --- a/source/core/audio/jail_audio.cpp +++ b/source/core/audio/jail_audio.cpp @@ -4,6 +4,10 @@ #include #include #include +#include +#include + +#include "core/audio/audio_effects.hpp" // Només declaracions de stb_vorbis: STB_VORBIS_HEADER_ONLY omet el bloc // d'implementació. Les definicions les aporta source/external/stb_vorbis_impl.cpp @@ -397,13 +401,17 @@ namespace Ja { } void Engine::stopChannel(const int channel) { - forEachTargetChannel(channel, [](Channel& ch) { + forEachTargetChannel(channel, [this](Channel& ch) { if (ch.state != ChannelState::FREE) { if (ch.stream != nullptr) { SDL_DestroyAudioStream(ch.stream); } ch.stream = nullptr; ch.state = ChannelState::FREE; ch.pos = 0; ch.sound = nullptr; + if (ch.has_effect) { + ch.has_effect = false; + if (effect_channels_active_ > 0) { --effect_channels_active_; } + } } }); } @@ -438,6 +446,55 @@ namespace Ja { } } + auto Engine::playProcessedOnFreeChannel(const std::vector& bytes, const SDL_AudioSpec& spec, const int group) -> int { + // El cap de canals amb efecte es valida abans de reservar slot — + // així evitem crear i destruir un stream només per descartar el play. + if (effect_channels_active_ >= MAX_EFFECT_CHANNELS) { return -1; } + + int channel = 0; + while (channel < MAX_SIMULTANEOUS_CHANNELS && channels_[channel].state != ChannelState::FREE) { ++channel; } + if (channel == MAX_SIMULTANEOUS_CHANNELS) { channel = 0; } + + stopChannel(channel); + + // El stream es crea contra l'spec del buffer processat (S16, ...) + // perquè SDL faci el resampling cap a audio_spec_ del device. + channels_[channel].stream = SDL_CreateAudioStream(&spec, &audio_spec_); + if (channels_[channel].stream == nullptr) { + std::fprintf(stderr, "Ja::Engine::playProcessedOnFreeChannel: Failed to create audio stream!\n"); + return -1; + } + + channels_[channel].sound = nullptr; // El buffer no és propietat de cap Ja::Sound. + channels_[channel].times = 0; + channels_[channel].pos = 0; + const int CLAMPED_GROUP = (group >= 0 && group < MAX_GROUPS) ? group : 0; + channels_[channel].group = CLAMPED_GROUP; + channels_[channel].state = ChannelState::PLAYING; + channels_[channel].has_effect = true; + ++effect_channels_active_; + + SDL_PutAudioStreamData(channels_[channel].stream, bytes.data(), static_cast(bytes.size())); + SDL_SetAudioStreamGain(channels_[channel].stream, sound_volume_[CLAMPED_GROUP]); + SDL_BindAudioStream(sdl_audio_device_, channels_[channel].stream); + + return channel; + } + + auto Engine::playSoundWithEcho(const Sound* sound, const EchoParams& params, const int group) -> int { + if (sound == nullptr) { return -1; } + auto processed = AudioEffects::applyEcho(*sound, params); + if (!processed) { return -1; } + return playProcessedOnFreeChannel(processed->bytes, processed->spec, group); + } + + auto Engine::playSoundWithReverb(const Sound* sound, const ReverbParams& params, const int group) -> int { + if (sound == nullptr) { return -1; } + auto processed = AudioEffects::applyReverb(*sound, params); + if (!processed) { return -1; } + return playProcessedOnFreeChannel(processed->bytes, processed->spec, group); + } + // --- Factories i destructors (permanents) --- auto loadMusic(const Uint8* buffer, Uint32 length) -> Music* { diff --git a/source/core/audio/jail_audio.hpp b/source/core/audio/jail_audio.hpp index 7a88879..51d026d 100644 --- a/source/core/audio/jail_audio.hpp +++ b/source/core/audio/jail_audio.hpp @@ -48,6 +48,26 @@ namespace Ja { // --- Constants --- inline constexpr int MAX_SIMULTANEOUS_CHANNELS = 20; inline constexpr int MAX_GROUPS = 2; + // Cap superior de canals que poden estar simultàniament reproduint un so + // amb efecte (eco/reverb). Si està al límit, les noves crides amb efecte + // cauen al camí sec — l'usuari sent el so igualment, sense la cua. + inline constexpr int MAX_EFFECT_CHANNELS = 4; + + // --- Paràmetres d'efectes --- + // Els camps els fixa el caller (Audio) llegint sounds.yaml; el motor només + // els passa a AudioEffects::applyEcho/applyReverb. Els defaults són + // sensats però els presets els sobreescriuen. + struct EchoParams { + float delay_ms{220.0F}; // Temps fins al primer rebot. + float feedback{0.45F}; // Reinjecció (0..0.95). + float wet{0.35F}; // Mescla humida (0..1). + }; + + struct ReverbParams { + float room_size{0.7F}; // Mida percebuda (0..1). + float damping{0.5F}; // Atenuació d'aguts per rebot (0..1). + float wet{0.4F}; // Mescla humida (0..1). + }; // Spec de fallback del dispositiu. S'aplica abans que l'Engine s'iniciï i // com a valor inicial de Sound/Music. L'spec real d'ús l'imposa el ctor @@ -72,6 +92,10 @@ namespace Ja { int times{0}; int group{0}; ChannelState state{ChannelState::FREE}; + // Marca si aquest canal va arrencar amb so processat per un efecte. + // El motor compta canals actius amb efecte per fer complir + // MAX_EFFECT_CHANNELS i alliberar el comptador en parar. + bool has_effect{false}; }; struct Music { @@ -147,6 +171,12 @@ namespace Ja { // --- So --- auto playSound(Sound* sound, int loop = 0, int group = 0) -> int; auto playSoundOnChannel(Sound* sound, int channel, int loop = 0, int group = 0) -> int; + // Reproducció amb so processat per un efecte. Retorna el canal + // assignat o -1 si no queden slots d'efecte (MAX_EFFECT_CHANNELS). + // El sound original només s'usa per consultar el spec/buffer; el + // canal manipula el buffer ja processat (no reapunta a `sound`). + auto playSoundWithEcho(const Sound* sound, const EchoParams& params, int group = 0) -> int; + auto playSoundWithReverb(const Sound* sound, const ReverbParams& params, int group = 0) -> int; void pauseChannel(int channel); void resumeChannel(int channel); void stopChannel(int channel); @@ -161,6 +191,10 @@ namespace Ja { void updateIncomingFade(); void updateCurrentMusic(); void updateSoundChannels(); + // Empenta un buffer ja processat (S16) a un canal lliure i el deixa + // sonar sense bucle. Camí comú dels dos overloads playSoundWith*. + // Retorna el canal o -1 si no queden slots. + auto playProcessedOnFreeChannel(const std::vector& bytes, const SDL_AudioSpec& spec, int group) -> int; template void forEachTargetChannel(int channel, Fn&& fn); @@ -174,6 +208,9 @@ namespace Ja { OutgoingMusic outgoing_music_; FadeState incoming_fade_; std::function on_music_ended_; + // Comptador derivat de Channel::has_effect — evita haver-lo de + // recalcular cada vegada que algú demana un play amb efecte. + int effect_channels_active_{0}; // NOLINTNEXTLINE(readability-identifier-naming) — convenció projecte: private static amb sufix _ static Engine* active_; diff --git a/source/core/audio/sound_effects_config.cpp b/source/core/audio/sound_effects_config.cpp new file mode 100644 index 0000000..22bc1cc --- /dev/null +++ b/source/core/audio/sound_effects_config.cpp @@ -0,0 +1,77 @@ +#include "core/audio/sound_effects_config.hpp" + +#include +#include + +#include "core/resources/resource_helper.hpp" +#include "external/fkyaml_node.hpp" + +namespace { + + // Lector de camp amb fallback: deixa el destí intacte si la clau no + // existeix (els defaults dels Ja::*Params s'inicialitzen al ctor del + // struct, així que el comportament és "preset parcial = preset complet + // amb defaults per als camps que falten"). + template + void readField(const fkyaml::node& node, const char* key, T& dst) { + if (node.contains(key)) { dst = node[key].get_value(); } + } + +} // namespace + +auto SoundEffectsConfig::get() -> SoundEffectsConfig& { + static SoundEffectsConfig instance_; + return instance_; +} + +void SoundEffectsConfig::load(const std::string& file_path) { + auto bytes = Resource::Helper::loadFile(file_path); + if (bytes.empty()) { + std::cerr << "SoundEffectsConfig: no s'ha pogut obrir " << file_path << " — cap preset d'efecte disponible\n"; + return; + } + + try { + const auto* begin = reinterpret_cast(bytes.data()); + const auto* end = begin + bytes.size(); + auto yaml = fkyaml::node::deserialize(begin, end); + + if (yaml.contains("echo") && yaml["echo"].is_mapping()) { + for (auto it = yaml["echo"].begin(); it != yaml["echo"].end(); ++it) { + const auto NAME = it.key().get_value(); + const auto& node = it.value(); + Ja::EchoParams params{}; + readField(node, "delay_ms", params.delay_ms); + readField(node, "feedback", params.feedback); + readField(node, "wet", params.wet); + echoes_[NAME] = params; + } + } + + if (yaml.contains("reverb") && yaml["reverb"].is_mapping()) { + for (auto it = yaml["reverb"].begin(); it != yaml["reverb"].end(); ++it) { + const auto NAME = it.key().get_value(); + const auto& node = it.value(); + Ja::ReverbParams params{}; + readField(node, "room_size", params.room_size); + readField(node, "damping", params.damping); + readField(node, "wet", params.wet); + reverbs_[NAME] = params; + } + } + + std::cout << "SoundEffectsConfig: " << echoes_.size() << " preset(s) d'eco i " << reverbs_.size() << " de reverb des de " << file_path << '\n'; + } catch (const std::exception& e) { + std::cerr << "SoundEffectsConfig: error parsejant " << file_path << ": " << e.what() << '\n'; + } +} + +auto SoundEffectsConfig::findEcho(const std::string& name) const -> const Ja::EchoParams* { + const auto IT = echoes_.find(name); + return (IT == echoes_.end()) ? nullptr : &IT->second; +} + +auto SoundEffectsConfig::findReverb(const std::string& name) const -> const Ja::ReverbParams* { + const auto IT = reverbs_.find(name); + return (IT == reverbs_.end()) ? nullptr : &IT->second; +} diff --git a/source/core/audio/sound_effects_config.hpp b/source/core/audio/sound_effects_config.hpp new file mode 100644 index 0000000..c5878f8 --- /dev/null +++ b/source/core/audio/sound_effects_config.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include "core/audio/jail_audio.hpp" // Per a Ja::EchoParams / Ja::ReverbParams + +// Catàleg de presets d'efectes carregat des de data/sounds.yaml. La capa +// Audio (playSoundWithEcho/playSoundWithReverb) hi accedeix per nom: si el +// preset no existeix, el so es reprodueix sec amb un avís a stderr. +// +// Patró Meyers idèntic a UiConfig/Locale: un sol load() a l'arrencada, sense +// hot-reload. Si el fitxer no existeix, el catàleg queda buit (cap preset +// disponible) i tots els playSoundWith* es comporten com playSound dry. +class SoundEffectsConfig { + public: + static auto get() -> SoundEffectsConfig&; + + SoundEffectsConfig(const SoundEffectsConfig&) = delete; + SoundEffectsConfig(SoundEffectsConfig&&) = delete; + auto operator=(const SoundEffectsConfig&) -> SoundEffectsConfig& = delete; + auto operator=(SoundEffectsConfig&&) -> SoundEffectsConfig& = delete; + + void load(const std::string& file_path); + + // Retorna nullptr si el preset no existeix. + [[nodiscard]] auto findEcho(const std::string& name) const -> const Ja::EchoParams*; + [[nodiscard]] auto findReverb(const std::string& name) const -> const Ja::ReverbParams*; + + private: + SoundEffectsConfig() = default; + ~SoundEffectsConfig() = default; + + std::unordered_map echoes_; + std::unordered_map reverbs_; +}; diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 209a665..23da3ca 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -12,6 +12,7 @@ #include #include "core/audio/audio.hpp" +#include "core/audio/sound_effects_config.hpp" #include "core/input/gamepad_manager.hpp" #include "core/input/key_config.hpp" #include "core/input/mouse.hpp" @@ -135,6 +136,11 @@ Director::Director() { // perquè forma part del preload d'assets, com la font i la paleta. Resource::Cache::get().loadMusic("pyramid_1.ogg", "data/music/pyramid_1.ogg"); + // Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio + // perquè els primers playSoundWith* (al començament del joc) ja + // troben els presets disponibles. + SoundEffectsConfig::get().load("data/sounds.yaml"); + // Audio: singleton manual propietari d'un SDL audio device. Es destrueix // explícitament a ~Director abans que Resource::Cache (Meyers) buide els // JA_Music_t que continga, així que no queden streams orfes. diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index bb5e14a..5efb701 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -697,7 +697,15 @@ auto Player::integrateJump(float delta_time) -> bool { state_ = State::LANDING_STUN; landing_stun_remaining_ms_ = static_cast(jump_config.landing_stun_ms); if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) { - Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME); + // Consumeix el preset armat per GameScene al spawn aeri + // d'INTRO (single-shot): el primer LAND el sona reverberat, + // els salts següents cauen al camí sec. + if (next_land_reverb_preset_.empty()) { + Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME); + } else { + Audio::get()->playSoundWithReverb(jump_config.land_sound_key, next_land_reverb_preset_, Audio::Group::GAME); + next_land_reverb_preset_.clear(); + } } if (bus_ != nullptr) { bus_->publish(Event{ diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index cd57274..cd3b3db 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -259,6 +259,15 @@ class Player : public AnimatedSprite { // al respawn post-mort: mateixa entrada cinemàtica. void spawnFromAir(float x, float y); + // Arma un preset de reverb (catàleg a `data/sounds.yaml`) per al + // pròxim aterratge d'aquest jugador. Pensat per a la cinemàtica + // d'INTRO de sala: GameScene crida aquesta funció just després de + // `spawnFromAir`, i el primer LAND consumeix el preset (queda buit + // immediatament). Els salts posteriors del joc, els respawns + // post-mort i el reset general no rearmen res. `preset` buit + // desactiva el reverb del proper LAND (no afecta cap LAND posterior). + void armNextLandReverb(std::string preset) { next_land_reverb_preset_ = std::move(preset); } + // Alçada actual del salt (píxels visuals per damunt del sòl). // 0 quan està a terra. L'usa el render i el sistema de col·lisions // per altura. @@ -390,6 +399,14 @@ class Player : public AnimatedSprite { // gestiona `intended_vx_/vy_` escalats pel resolver. bool being_pushed_{false}; + // Preset de reverb (catàleg de `data/sounds.yaml`) que el pròxim + // LAND ha de fer servir; buit = so sec. L'arma `armNextLandReverb` + // (cridada per GameScene al spawn aeri d'INTRO) i el consumeix + // `integrateJump` quan el jugador toca terra. Cap altre punt + // (respawn, exit, disable) el toca: així només sona reverberat el + // primer LAND de la cinemàtica d'entrada. + std::string next_land_reverb_preset_; + // Escala d'empenta usada per `snapToLane` quan descobreix un blocker // (mateixa del resolver). 1.0 = sense escalar (comportament si // GameScene no ha cridat setPushFactor). diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 6aab345..d316662 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -556,6 +556,11 @@ void GameScene::triggerPlayerSpawn(std::size_t slot_index) { float x = SINGLE_X; if (BOTH_ACTIVE) { x = (slot_index == 0) ? SPAWN.p1_x : SPAWN.p2_x; } players_[slot_index]->spawnFromAir(x, SPAWN.y); + // Arma el reverb del primer LAND com a part de la cinemàtica + // d'INTRO. Una sola vegada per spawn aeri: el respawn post-mort + // (que no passa per ací) cau al camí sec. Si el preset està buit + // al YAML, l'arma queda buida i el LAND és sec igualment. + players_[slot_index]->armNextLandReverb(transition_config_.intro_land_reverb_preset); std::cout << "[ROOM] spawn cinematic P" << (slot_index + 1) << ": airborne entry\n"; } diff --git a/source/game/scenes/room_fsm.cpp b/source/game/scenes/room_fsm.cpp index a510d93..0b980f8 100644 --- a/source/game/scenes/room_fsm.cpp +++ b/source/game/scenes/room_fsm.cpp @@ -47,6 +47,9 @@ namespace RoomFsm { // El YAML pot trencar la simultaneïtat fixant un valor propi. cfg.player2_spawn_delay_ms = readChildInt(n, "player2_spawn_delay_ms", cfg.player_spawn_delay_ms); cfg.intro_text_line_gap_px = readChildInt(n, "line_gap_px", cfg.intro_text_line_gap_px); + if (n.contains("land_reverb")) { + cfg.intro_land_reverb_preset = n["land_reverb"].get_value(); + } } if (yaml.contains("room_outro")) { const auto& n = yaml["room_outro"]; diff --git a/source/game/scenes/room_fsm.hpp b/source/game/scenes/room_fsm.hpp index b10836f..e57b441 100644 --- a/source/game/scenes/room_fsm.hpp +++ b/source/game/scenes/room_fsm.hpp @@ -80,6 +80,13 @@ namespace RoomFsm { UiConfig::CurveFn text_enter_curve{nullptr}; UiConfig::CurveFn text_exit_curve{nullptr}; + // Preset de reverb (catàleg a `data/sounds.yaml`) que GameScene + // arma al jugador al spawn aeri d'INTRO. Es consumeix al primer + // aterratge cinemàtic; els salts posteriors del joc i els + // respawns post-mort cauen al so sec. Buit = sense reverb (la + // cinemàtica d'entrada sona igual que un salt qualsevol). + std::string intro_land_reverb_preset; + // Panel translúcid sota el rètol de transició (INTRO i OUTRO). // Centrat verticalment al centre del playfield, ocupa tota // l'amplada del canvas. Creix d'altura 0 → height_px durant la diff --git a/source/utils/defines.hpp b/source/utils/defines.hpp index 5edc3fa..bc4390c 100644 --- a/source/utils/defines.hpp +++ b/source/utils/defines.hpp @@ -3,7 +3,7 @@ namespace Texts { constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; - constexpr const char* VERSION = "0.27.0"; // Single source of truth — Makefile el grep + constexpr const char* VERSION = "0.28.0"; // Single source of truth — Makefile el grep } // namespace Texts namespace GameCanvas {