diff --git a/data/audio/sounds/open_sarc.wav b/data/audio/sounds/open_sarc.wav new file mode 100644 index 0000000..48415e5 Binary files /dev/null and b/data/audio/sounds/open_sarc.wav differ diff --git a/data/entities/sarcophagus/sarcophagus.gif b/data/entities/sarcophagus/sarcophagus.gif index 51acd90..a07ef53 100644 Binary files a/data/entities/sarcophagus/sarcophagus.gif and b/data/entities/sarcophagus/sarcophagus.gif differ diff --git a/data/entities/sarcophagus/sarcophagus.yaml b/data/entities/sarcophagus/sarcophagus.yaml index 701e0f3..108f818 100644 --- a/data/entities/sarcophagus/sarcophagus.yaml +++ b/data/entities/sarcophagus/sarcophagus.yaml @@ -20,11 +20,23 @@ flags: interactable: true animations: closed: { frames: [0], fps: 1, loop: true } - opening: { frames: [0], fps: 6, loop: false } - open: { frames: [0], fps: 1, loop: true } + opening: { frames: [1, 2], fps: 6, loop: false } + open: { frames: [3], fps: 1, loop: true } behaviour: states: [CLOSED, OPENING, OPEN] initial_state: CLOSED on_perimeter_complete: OPENING on_opening_end: OPEN emits_event_on_open: sarcophagus_opened +# Sons opcionals. `opening` es dispara un sol cop en la transició +# CLOSED → OPENING (quan el jugador completa el perímetre). Si la clau +# manca, no sona res. El basename val tant de clau al Resource::Cache +# com de path (`Defaults::Paths::AUDIO_SOUNDS_DIR + key`). +# +# `opening_pitch_spread`: variació uniforme del ratio de reproducció +# centrada a 1.0. Amb 0.05 cada obertura sona entre 0.95× i 1.05× la +# velocitat normal (puja/baixa també el to, efecte arcade clàssic). +# 0 ⇒ sempre 1.0 (sense variació). +sound: + opening: open_sarc.wav + opening_pitch_spread: 0.5 diff --git a/source/core/audio/audio.cpp b/source/core/audio/audio.cpp index 7477cd9..e54b3c5 100644 --- a/source/core/audio/audio.cpp +++ b/source/core/audio/audio.cpp @@ -136,6 +136,22 @@ void Audio::playSound(Ja::Sound* sound, Group group) { } } +// Variant amb velocitat (i to) escalats. Apliquem el ratio al canal +// just retornat per `playSound`: així el `SDL_AudioStream` recent creat +// processa tot el sample amb el ratio des del primer pull del callback. +// Si l'engine torna -1 (sense canal lliure) o el so no existeix, no fem +// la crida al ratio — sense efectes col·laterals. +// NOLINTNEXTLINE(readability-make-member-function-const) +void Audio::playSound(const std::string& name, Group group, float speed) { + if (!sound_enabled_) { return; } + auto* sound = AudioResource::getSound(name); + if (sound == nullptr) { return; } + const int CH = engine_->playSound(sound, 0, static_cast(group)); + if (CH >= 0 && speed != 1.0F) { + engine_->setChannelSpeed(CH, speed); + } +} + // 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. diff --git a/source/core/audio/audio.hpp b/source/core/audio/audio.hpp index 1f930a4..891c00d 100644 --- a/source/core/audio/audio.hpp +++ b/source/core/audio/audio.hpp @@ -85,6 +85,12 @@ 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) + // Reprodueix un so amb la velocitat (i to) escalats per `speed`: + // 1.0 = normal, 0.95 ≈ -5% (més greu i lent), 1.05 ≈ +5% (més + // agut i ràpid). Mateixa semàntica que `setMusicSpeed`. Útil per a + // variacions subtils que eviten la fatiga d'escoltar el mateix + // sample idèntic (p.ex. obertures de sarcòfag, picks d'ítems). + void playSound(const std::string& name, Group group, float speed); // Reprodueix un so processat per un efecte definit a data/config/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 diff --git a/source/core/audio/jail_audio.cpp b/source/core/audio/jail_audio.cpp index 83ec17a..20e57d8 100644 --- a/source/core/audio/jail_audio.cpp +++ b/source/core/audio/jail_audio.cpp @@ -430,6 +430,12 @@ namespace Ja { return channel; } + void Engine::setChannelSpeed(const int channel, const float ratio) { + if (channel < 0 || channel >= MAX_SIMULTANEOUS_CHANNELS) { return; } + if (channels_[channel].stream == nullptr) { return; } + SDL_SetAudioStreamFrequencyRatio(channels_[channel].stream, ratio); + } + void Engine::pauseChannel(const int channel) { forEachTargetChannel(channel, [](Channel& ch) { if (ch.state == ChannelState::PLAYING) { diff --git a/source/core/audio/jail_audio.hpp b/source/core/audio/jail_audio.hpp index 88db498..3e46dca 100644 --- a/source/core/audio/jail_audio.hpp +++ b/source/core/audio/jail_audio.hpp @@ -192,6 +192,14 @@ 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; + // Ajusta la velocitat de reproducció d'un canal actiu via + // `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal. Igual que a + // `setMusicSpeed`, puja/baixa el to junt amb la velocitat + // (efecte "chipmunk"); per a SFX curts arcade és el que volem. + // No-op si el canal no està actiu. Cridar-lo just després de + // `playSound`/`playSoundOnChannel` perquè el ratio cobreixi + // tota la reproducció. + void setChannelSpeed(int channel, float ratio); // 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 diff --git a/source/game/entities/concrete/sarcophagus.cpp b/source/game/entities/concrete/sarcophagus.cpp index d001b7b..82181b5 100644 --- a/source/game/entities/concrete/sarcophagus.cpp +++ b/source/game/entities/concrete/sarcophagus.cpp @@ -1,14 +1,26 @@ #include "game/entities/concrete/sarcophagus.hpp" +#include #include #include +#include "core/audio/audio.hpp" +#include "core/resources/resource_cache.hpp" #include "game/entities/entity_factory.hpp" #include "game/entities/entity_manager.hpp" #include "game/entities/event_bus.hpp" +#include "utils/defaults.hpp" namespace { + // RNG local al mòdul, mateix patró que `mummy.cpp`: llavor fixa per + // facilitar la depuració. Si algun dia volem aleatoritat real entre + // partides, prou amb llegir-la de std::random_device. + auto rng() -> std::mt19937& { + static std::mt19937 k_rng_{0xCAFEU}; + return k_rng_; + } + auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { std::vector frames = {0}; float fps = 1.0F; @@ -57,6 +69,20 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml) interactable_ = true; readTileSize(yaml, tile_size_w_, tile_size_h_); anim_ = anim_closed_; + + // Sons opcionals (bloc `sound:` del YAML). El basename val tant de + // clau al Resource::Cache com de path dins `Defaults::Paths::AUDIO_SOUNDS_DIR`. + // Mateix patró que `mummy.hurt.sound` i `player.jump.sound`. + if (yaml.contains("sound")) { + const auto& s = yaml["sound"]; + if (s.contains("opening")) { + opening_sound_key_ = s["opening"].get_value(); + Resource::Cache::get().loadSound(opening_sound_key_, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + opening_sound_key_); + } + if (s.contains("opening_pitch_spread")) { + opening_pitch_spread_ = s["opening_pitch_spread"].get_value(); + } + } } void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) { @@ -122,6 +148,18 @@ void Sarcophagus::notifyTilePainted(int col, int row) { state_ = State::OPENING; anim_ = anim_opening_; if (anim_.has_value()) { anim_->reset(); } + if (!opening_sound_key_.empty() && Audio::get() != nullptr) { + // Variació de pitch/velocitat per evitar la fatiga d'escoltar + // el mateix sample idèntic a cada obertura. Si l'spread és 0 + // (default), reproduïm amb ratio 1.0; en cas contrari sortegem + // uniformement [1 - spread, 1 + spread]. + if (opening_pitch_spread_ > 0.0F) { + std::uniform_real_distribution dist(1.0F - opening_pitch_spread_, 1.0F + opening_pitch_spread_); + Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME, dist(rng())); + } else { + Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME); + } + } } } diff --git a/source/game/entities/concrete/sarcophagus.hpp b/source/game/entities/concrete/sarcophagus.hpp index df70bfc..de4c930 100644 --- a/source/game/entities/concrete/sarcophagus.hpp +++ b/source/game/entities/concrete/sarcophagus.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "external/fkyaml_node.hpp" @@ -83,5 +84,12 @@ class Sarcophagus : public Interactable { AnimationComponent anim_opening_; AnimationComponent anim_open_; + // Clau (basename del fitxer) al Resource::Cache del so que es dispara + // a la transició CLOSED → OPENING. Buit ⇒ no sona res. + std::string opening_sound_key_; + // Variació uniforme del ratio de reproducció al voltant de 1.0. Cada + // obertura sorteja [1 - spread, 1 + spread]. 0 ⇒ ratio fix a 1.0. + float opening_pitch_spread_{0.0F}; + bool opened_event_emitted_{false}; }; diff --git a/tests/audio_cache_stubs.cpp b/tests/audio_cache_stubs.cpp index aafa42e..e9ee622 100644 --- a/tests/audio_cache_stubs.cpp +++ b/tests/audio_cache_stubs.cpp @@ -18,6 +18,9 @@ auto Audio::get() -> Audio* { void Audio::playSound(const std::string& /*name*/, Audio::Group /*group*/) { } +void Audio::playSound(const std::string& /*name*/, Audio::Group /*group*/, float /*speed*/) { +} + namespace Resource { auto Cache::get() -> Cache& { diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index 697f329..e18a04d 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -166,9 +166,11 @@ namespace { REQUIRE(sarc->getVisitedCount() == 8); REQUIRE(sarc->getState() == Sarcophagus::State::OPENING); - // Un update amb l'animació `opening` d'1 frame loop=false completa - // la transició a OPEN. - sarc->update(0.0F); + // Tickem prou per consumir l'animació `opening` (4 frames @ 6fps = + // ~0.67s). Quan completa, la transició OPENING → OPEN es dispara. + for (int i = 0; i < 16 && sarc->getState() == Sarcophagus::State::OPENING; ++i) { + sarc->update(0.1F); + } REQUIRE(sarc->getState() == Sarcophagus::State::OPEN); } @@ -208,12 +210,14 @@ namespace { forceOpen(s1); forceOpen(s2); - // Un primer tick: cada sarc transiciona OPENING → OPEN i publica; - // l'handler del manager compta i, en arribar al total, crida - // door->open() — que en aquest mateix tick (la iteració sobre - // entities continua) farà transitar la porta OPENING → OPEN perquè - // l'animació és d'1 frame loop=false. - manager.update(0.0F); + // Tickem prou per consumir l'animació `opening` dels sarcs (4 + // frames @ 6fps) i la de la porta. Cada sarc transiciona + // OPENING → OPEN i publica; l'handler del manager compta i, en + // arribar al total, crida `door->open()`, que també necessita + // tickar fins acabar la seua animació d'obertura. + for (int i = 0; i < 20 && door_ptr->getState() != Door::State::OPEN; ++i) { + manager.update(0.1F); + } REQUIRE(manager.openedSarcophagusCount() == 2); REQUIRE(door_ptr->getState() == Door::State::OPEN);