From 908e4f190cdda28199e14a93d8ed2cc477b42ed1 Mon Sep 17 00:00:00 2001 From: mojsior Date: Sun, 25 Jan 2026 12:18:37 +0100 Subject: [PATCH] Implement SoundPool for navigation cues Play up to 3 nearest emitters (items/monsters/doors/chests) and keep interact cue one-shot. --- Source/CMakeLists.txt | 34 +- Source/engine/sound.cpp | 46 ++- Source/engine/sound_pool.cpp | 301 ++++++++++++++ Source/engine/sound_pool.hpp | 58 +++ Source/engine/sound_pool_stubs.cpp | 54 +++ Source/utils/proximity_audio.cpp | 618 +++++++++-------------------- 6 files changed, 641 insertions(+), 470 deletions(-) create mode 100644 Source/engine/sound_pool.cpp create mode 100644 Source/engine/sound_pool.hpp create mode 100644 Source/engine/sound_pool_stubs.cpp diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 5ff6ad7c1..93bd22a71 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -739,14 +739,15 @@ target_link_dependencies(libdevilutionx_utf8 PRIVATE SheenBidi::SheenBidi ) -if(NOSOUND) - add_devilutionx_object_library(libdevilutionx_sound - effects_stubs.cpp - engine/sound_stubs.cpp - ) - target_link_dependencies(libdevilutionx_sound PUBLIC - DevilutionX::SDL - fmt::fmt +if(NOSOUND) + add_devilutionx_object_library(libdevilutionx_sound + effects_stubs.cpp + engine/sound_pool_stubs.cpp + engine/sound_stubs.cpp + ) + target_link_dependencies(libdevilutionx_sound PUBLIC + DevilutionX::SDL + fmt::fmt magic_enum::magic_enum tl unordered_dense::unordered_dense @@ -754,14 +755,15 @@ if(NOSOUND) libdevilutionx_random libdevilutionx_sdl2_to_1_2_backports ) -else() - add_devilutionx_object_library(libdevilutionx_sound - effects.cpp - engine/sound.cpp - utils/soundsample.cpp - ) - if(USE_SDL3) - target_link_dependencies(libdevilutionx_sound PUBLIC +else() + add_devilutionx_object_library(libdevilutionx_sound + effects.cpp + engine/sound_pool.cpp + engine/sound.cpp + utils/soundsample.cpp + ) + if(USE_SDL3) + target_link_dependencies(libdevilutionx_sound PUBLIC SDL3_mixer::SDL3_mixer ) else() diff --git a/Source/engine/sound.cpp b/Source/engine/sound.cpp index d5dcd20ed..9d20d0340 100644 --- a/Source/engine/sound.cpp +++ b/Source/engine/sound.cpp @@ -3,11 +3,11 @@ * * Implementation of functions setting up the audio pipeline. */ -#include "engine/sound.h" - -#include -#include -#include +#include "engine/sound.h" + +#include +#include +#include #include #include #include @@ -23,15 +23,16 @@ #include #include #endif -#include - -#include "appfat.h" -#include "engine/assets.hpp" -#include "game_mode.hpp" -#include "options.h" -#include "utils/log.hpp" -#include "utils/math.h" -#include "utils/sdl_mutex.h" +#include + +#include "appfat.h" +#include "engine/assets.hpp" +#include "engine/sound_pool.hpp" +#include "game_mode.hpp" +#include "options.h" +#include "utils/log.hpp" +#include "utils/math.h" +#include "utils/sdl_mutex.h" #include "utils/status_macros.hpp" #include "utils/stdcompat/shared_ptr_array.hpp" #include "utils/str_cat.hpp" @@ -281,14 +282,15 @@ void snd_init() gbSndInited = true; } -void snd_deinit() -{ - if (gbSndInited) { -#ifdef USE_SDL3 - const AudioOptions &audioOptions = GetOptions().Audio; - SDL_CloseAudioDevice(audioOptions.device.id()); -#else - Aulib::quit(); +void snd_deinit() +{ + if (gbSndInited) { + SoundPool::Get().Clear(); +#ifdef USE_SDL3 + const AudioOptions &audioOptions = GetOptions().Audio; + SDL_CloseAudioDevice(audioOptions.device.id()); +#else + Aulib::quit(); #endif duplicateSoundsMutex = std::nullopt; } diff --git a/Source/engine/sound_pool.cpp b/Source/engine/sound_pool.cpp new file mode 100644 index 000000000..5ff4c20b7 --- /dev/null +++ b/Source/engine/sound_pool.cpp @@ -0,0 +1,301 @@ +#include "engine/sound_pool.hpp" + +#include +#include +#include +#include +#include +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include "engine/assets.hpp" +#include "engine/sound.h" +#include "engine/sound_position.hpp" +#include "utils/stdcompat/shared_ptr_array.hpp" + +namespace devilution { + +namespace { + +constexpr size_t MaxEmitters = 3; +constexpr size_t SoundIdCount = static_cast(SoundPool::SoundId::COUNT); + +struct CachedSoundData { + ArraySharedPtr data; + size_t size; + bool isMp3; +}; + +[[nodiscard]] size_t ToIndex(SoundPool::SoundId id) +{ + return static_cast(id); +} + +[[nodiscard]] bool EndsWithCaseInsensitive(std::string_view str, std::string_view suffix) +{ + if (str.size() < suffix.size()) + return false; + const std::string_view tail { str.data() + (str.size() - suffix.size()), suffix.size() }; + return std::equal(tail.begin(), tail.end(), suffix.begin(), suffix.end(), [](char a, char b) { + return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); + }); +} + +[[nodiscard]] bool IsMp3Path(std::string_view path) +{ + return EndsWithCaseInsensitive(path, ".mp3"); +} + +} // namespace + +struct SoundPool::Impl { + struct ActiveEmitter { + uint32_t emitterId; + SoundId sound; + SoundSample sample; + uint32_t lastPlayMs; + }; + + std::array, SoundIdCount> cachedSounds; + std::array, MaxEmitters> activeEmitters; + + std::optional oneShotSoundId; + SoundSample oneShotSample; + + void StopEmitter(ActiveEmitter &emitter) + { + if (emitter.sample.IsLoaded()) + emitter.sample.Stop(); + emitter.sample.Release(); + } + + void StopOneShot() + { + if (oneShotSample.IsLoaded()) + oneShotSample.Stop(); + oneShotSample.Release(); + oneShotSoundId = std::nullopt; + } + + void StopAllEmitters() + { + for (auto &slot : activeEmitters) { + if (!slot) + continue; + StopEmitter(*slot); + slot = std::nullopt; + } + } + + [[nodiscard]] bool EnsureSampleLoaded(SoundSample &sample, SoundId id) + { + const std::optional &cached = cachedSounds[ToIndex(id)]; + if (!cached) + return false; + + const int error = sample.SetChunk(cached->data, cached->size, cached->isMp3); + return error == 0; + } + + [[nodiscard]] bool PlaySampleAt(SoundSample &sample, Point position) + { + if (!sample.IsLoaded()) + return false; + + int logVolume = 0; + int logPan = 0; + if (!CalculateSoundPosition(position, &logVolume, &logPan)) + return false; + + // Restart to keep tempo readable. + if (sample.IsPlaying()) + sample.Stop(); + + return sample.PlayWithVolumeAndPan(logVolume, sound_get_or_set_sound_volume(/*volume=*/1), logPan); + } +}; + +SoundPool &SoundPool::Get() +{ + static SoundPool instance; + return instance; +} + +SoundPool::SoundPool() + : impl_(std::make_unique()) +{ +} + +SoundPool::~SoundPool() = default; + +void SoundPool::Clear() +{ + if (impl_ == nullptr) + return; + + impl_->StopAllEmitters(); + impl_->StopOneShot(); + impl_->cachedSounds = {}; +} + +bool SoundPool::EnsureLoaded(SoundId id, std::initializer_list candidatePaths) +{ + if (impl_ == nullptr) + return false; + if (id == SoundId::COUNT) + return false; + + std::optional &cached = impl_->cachedSounds[ToIndex(id)]; + if (cached) + return true; + + // Match the old proximity-audio behavior: try multiple file types/paths and keep the first one + // that successfully decodes in the current audio pipeline. This avoids caching an asset we can + // locate but cannot decode (e.g. OGG on setups without an OGG decoder). + for (const std::string_view path : candidatePaths) { + AssetRef ref = FindAsset(path); + if (!ref.ok()) + continue; + + const size_t size = ref.size(); + if (size == 0) + continue; + + AssetHandle handle = OpenAsset(std::move(ref), /*threadsafe=*/true); + if (!handle.ok()) + continue; + + auto fileData = MakeArraySharedPtr(size); + if (!handle.read(fileData.get(), size)) + continue; + + const bool isMp3 = IsMp3Path(path); + SoundSample testSample; + if (testSample.SetChunk(fileData, size, isMp3) != 0) + continue; + + cached = CachedSoundData { .data = std::move(fileData), .size = size, .isMp3 = isMp3 }; + return true; + } + + return false; +} + +bool SoundPool::IsLoaded(SoundId id) const +{ + if (impl_ == nullptr) + return false; + if (id == SoundId::COUNT) + return false; + + return impl_->cachedSounds[ToIndex(id)].has_value(); +} + +void SoundPool::UpdateEmitters(std::span emitters, uint32_t nowMs) +{ + if (impl_ == nullptr) + return; + + if (!gbSndInited || !gbSoundOn) { + impl_->StopAllEmitters(); + return; + } + + assert(emitters.size() <= MaxEmitters); + + const auto isRequested = [&emitters](uint32_t emitterId) { + for (const auto &req : emitters) { + if (req.emitterId == emitterId) + return true; + } + return false; + }; + + for (auto &slot : impl_->activeEmitters) { + if (!slot) + continue; + if (isRequested(slot->emitterId)) + continue; + impl_->StopEmitter(*slot); + slot = std::nullopt; + } + + for (const auto &req : emitters) { + std::optional slotIndex; + for (size_t i = 0; i < impl_->activeEmitters.size(); ++i) { + if (impl_->activeEmitters[i] && impl_->activeEmitters[i]->emitterId == req.emitterId) { + slotIndex = i; + break; + } + } + + bool isNew = false; + if (!slotIndex) { + for (size_t i = 0; i < impl_->activeEmitters.size(); ++i) { + if (!impl_->activeEmitters[i]) { + impl_->activeEmitters[i].emplace(); + impl_->activeEmitters[i]->emitterId = req.emitterId; + impl_->activeEmitters[i]->sound = SoundId::COUNT; + impl_->activeEmitters[i]->lastPlayMs = nowMs; + impl_->activeEmitters[i]->sample.Release(); + slotIndex = i; + isNew = true; + break; + } + } + } + + if (!slotIndex) + continue; + + Impl::ActiveEmitter &active = *impl_->activeEmitters[*slotIndex]; + + if (active.sound != req.sound || !active.sample.IsLoaded()) { + active.sample.Release(); + active.sound = req.sound; + if (!impl_->EnsureSampleLoaded(active.sample, req.sound)) { + impl_->StopEmitter(active); + impl_->activeEmitters[*slotIndex] = std::nullopt; + continue; + } + } + + const bool shouldPlay = isNew || (req.intervalMs != 0 && nowMs - active.lastPlayMs >= req.intervalMs); + if (!shouldPlay) + continue; + + if (impl_->PlaySampleAt(active.sample, req.position)) + active.lastPlayMs = nowMs; + } +} + +void SoundPool::PlayOneShot(SoundId id, Point position, bool stopEmitters, uint32_t nowMs) +{ + if (impl_ == nullptr) + return; + + if (!gbSndInited || !gbSoundOn) + return; + + if (stopEmitters) + impl_->StopAllEmitters(); + + if (impl_->oneShotSoundId != id || !impl_->oneShotSample.IsLoaded()) { + impl_->oneShotSample.Release(); + impl_->oneShotSoundId = id; + if (!impl_->EnsureSampleLoaded(impl_->oneShotSample, id)) { + impl_->StopOneShot(); + return; + } + } + + (void)nowMs; + impl_->PlaySampleAt(impl_->oneShotSample, position); +} + +} // namespace devilution diff --git a/Source/engine/sound_pool.hpp b/Source/engine/sound_pool.hpp new file mode 100644 index 000000000..2d791608d --- /dev/null +++ b/Source/engine/sound_pool.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "engine/point.hpp" + +namespace devilution { + +class SoundPool { +public: + enum class SoundId : uint8_t { + WeaponItem, + ArmorItem, + GoldItem, + Chest, + Door, + Monster, + Interact, + COUNT, + }; + + struct EmitterRequest { + uint32_t emitterId; + SoundId sound; + Point position; + uint32_t intervalMs; + }; + + static SoundPool &Get(); + + SoundPool(const SoundPool &) = delete; + SoundPool &operator=(const SoundPool &) = delete; + + void Clear(); + + [[nodiscard]] bool EnsureLoaded(SoundId id, std::initializer_list candidatePaths); + [[nodiscard]] bool IsLoaded(SoundId id) const; + + void UpdateEmitters(std::span emitters, uint32_t nowMs); + + // For one-shot navigation cues (not counted in the 3 emitter limit). + void PlayOneShot(SoundId id, Point position, bool stopEmitters, uint32_t nowMs); + +private: + struct Impl; + + SoundPool(); + ~SoundPool(); + + std::unique_ptr impl_; +}; + +} // namespace devilution + diff --git a/Source/engine/sound_pool_stubs.cpp b/Source/engine/sound_pool_stubs.cpp new file mode 100644 index 000000000..f36dadcb0 --- /dev/null +++ b/Source/engine/sound_pool_stubs.cpp @@ -0,0 +1,54 @@ +// Stubbed implementation of SoundPool for the NOSOUND mode. +#include "engine/sound_pool.hpp" + +namespace devilution { + +struct SoundPool::Impl { +}; + +SoundPool &SoundPool::Get() +{ + static SoundPool instance; + return instance; +} + +SoundPool::SoundPool() + : impl_(std::make_unique()) +{ +} + +SoundPool::~SoundPool() = default; + +void SoundPool::Clear() +{ +} + +bool SoundPool::EnsureLoaded(SoundId id, std::initializer_list candidatePaths) +{ + (void)id; + (void)candidatePaths; + return false; +} + +bool SoundPool::IsLoaded(SoundId id) const +{ + (void)id; + return false; +} + +void SoundPool::UpdateEmitters(std::span emitters, uint32_t nowMs) +{ + (void)emitters; + (void)nowMs; +} + +void SoundPool::PlayOneShot(SoundId id, Point position, bool stopEmitters, uint32_t nowMs) +{ + (void)id; + (void)position; + (void)stopEmitters; + (void)nowMs; +} + +} // namespace devilution + diff --git a/Source/utils/proximity_audio.cpp b/Source/utils/proximity_audio.cpp index a6a4086ab..b310b3dce 100644 --- a/Source/utils/proximity_audio.cpp +++ b/Source/utils/proximity_audio.cpp @@ -2,12 +2,11 @@ #include #include -#include #include +#include #include -#include #include -#include +#include #ifdef USE_SDL3 #include @@ -16,10 +15,9 @@ #endif #include "controls/plrctrls.h" -#include "engine/assets.hpp" #include "engine/path.h" #include "engine/sound.h" -#include "engine/sound_position.hpp" +#include "engine/sound_pool.hpp" #include "inv.h" #include "items.h" #include "levels/gendung.h" @@ -27,10 +25,7 @@ #include "monster.h" #include "objects.h" #include "player.h" -#include "utils/is_of.hpp" -#include "utils/math.h" #include "utils/screen_reader.hpp" -#include "utils/stdcompat/shared_ptr_array.hpp" namespace devilution { @@ -46,56 +41,15 @@ namespace { constexpr int MaxCueDistanceTiles = 12; constexpr int InteractDistanceTiles = 1; - -// Pitch shifting via resampling caused audible glitches on some setups; keep cues at normal pitch for stability. -constexpr size_t PitchLevels = 1; +constexpr size_t MaxEmitters = 3; constexpr uint32_t MinIntervalMs = 250; constexpr uint32_t MaxIntervalMs = 1000; -// snd_play_snd has an internal 80ms throttle (TSnd::start_tc). +// Monster movement already provides a lot of information; keep the tempo a bit faster. constexpr uint32_t MinMonsterIntervalMs = 100; constexpr uint32_t MaxMonsterIntervalMs = 1000; -// Extra attenuation applied on top of CalculateSoundPosition(). -// Kept at 0 because stronger attenuation makes distant proximity cues too quiet and feel "glitchy"/missing. -constexpr int ExtraAttenuationMax = 0; - -struct CueSound { - std::array, PitchLevels> variants; - - [[nodiscard]] bool IsLoaded() const - { - for (const auto &variant : variants) { - if (variant != nullptr && variant->DSB.IsLoaded()) - return true; - } - return false; - } - - [[nodiscard]] bool IsAnyPlaying() const - { - for (const auto &variant : variants) { - if (variant != nullptr && variant->DSB.IsLoaded() && variant->DSB.IsPlaying()) - return true; - } - return false; - } -}; - -std::optional WeaponItemCue; -std::optional ArmorItemCue; -std::optional GoldItemCue; -std::optional ChestCue; -std::optional DoorCue; -std::optional MonsterCue; -std::optional InteractCue; - -std::array LastObjectCueTimeMs {}; -uint32_t LastMonsterCueTimeMs = 0; std::optional LastInteractableId; -uint32_t LastWeaponItemCueTimeMs = 0; -uint32_t LastArmorItemCueTimeMs = 0; -uint32_t LastGoldItemCueTimeMs = 0; enum class InteractTargetType : uint8_t { Item, @@ -108,32 +62,15 @@ struct InteractTarget { Point position; }; -[[nodiscard]] bool EndsWithCaseInsensitive(std::string_view str, std::string_view suffix) -{ - if (str.size() < suffix.size()) - return false; - const std::string_view tail { str.data() + (str.size() - suffix.size()), suffix.size() }; - return std::equal(tail.begin(), tail.end(), suffix.begin(), suffix.end(), [](char a, char b) { - return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); - }); -} - -[[nodiscard]] bool IsMp3Path(std::string_view path) -{ - return EndsWithCaseInsensitive(path, ".mp3"); -} - -[[nodiscard]] float PlaybackRateForPitchLevel(size_t level) -{ - (void)level; - return 1.0F; -} +enum class EmitterType : uint8_t { + Item = 1, + Object = 2, + Monster = 3, +}; -[[nodiscard]] size_t PitchLevelForDistance(int distance, int maxDistance) +[[nodiscard]] constexpr uint32_t MakeEmitterId(EmitterType type, uint32_t id) { - (void)distance; - (void)maxDistance; - return 0; + return (static_cast(type) << 24) | (id & 0x00FFFFFF); } [[nodiscard]] uint32_t IntervalMsForDistance(int distance, int maxDistance, uint32_t minIntervalMs, uint32_t maxIntervalMs) @@ -147,243 +84,6 @@ struct InteractTarget { return static_cast(std::lround(interval)); } -[[nodiscard]] uint32_t IntervalMsForDistance(int distance, int maxDistance) -{ - return IntervalMsForDistance(distance, maxDistance, MinIntervalMs, MaxIntervalMs); -} - -void StopCueSound(const CueSound &cue) -{ - for (const auto &variant : cue.variants) { - if (variant != nullptr && variant->DSB.IsLoaded()) - variant->DSB.Stop(); - } -} - -void StopAllCuesExcept(const CueSound *cueToKeep) -{ - const auto stopIfOther = [cueToKeep](const std::optional &cue) { - if (cue && &*cue != cueToKeep) - StopCueSound(*cue); - }; - - stopIfOther(WeaponItemCue); - stopIfOther(ArmorItemCue); - stopIfOther(GoldItemCue); - stopIfOther(ChestCue); - stopIfOther(DoorCue); - stopIfOther(MonsterCue); - stopIfOther(InteractCue); -} - -[[nodiscard]] bool IsAnyOtherCuePlaying(const CueSound *cueToIgnore) -{ - const auto isOtherPlaying = [cueToIgnore](const std::optional &cue) { - return cue && cue->IsAnyPlaying() && &*cue != cueToIgnore; - }; - - return isOtherPlaying(WeaponItemCue) || isOtherPlaying(ArmorItemCue) || isOtherPlaying(GoldItemCue) || isOtherPlaying(ChestCue) || isOtherPlaying(DoorCue) - || isOtherPlaying(MonsterCue) || isOtherPlaying(InteractCue); -} - -std::optional TryLoadCueSound(std::initializer_list candidatePaths) -{ - if (!gbSndInited) - return std::nullopt; - - for (std::string_view path : candidatePaths) { - AssetRef ref = FindAsset(path); - if (!ref.ok()) - continue; - - const size_t size = ref.size(); - if (size == 0) - continue; - - AssetHandle handle = OpenAsset(std::move(ref), /*threadsafe=*/true); - if (!handle.ok()) - continue; - - auto fileData = MakeArraySharedPtr(size); - if (!handle.read(fileData.get(), size)) - continue; - - CueSound cue {}; - bool ok = true; - - for (size_t i = 0; i < PitchLevels; ++i) { - auto snd = std::make_unique(); - snd->start_tc = SDL_GetTicks() - 80 - 1; -#ifndef NOSOUND - const bool isMp3 = IsMp3Path(path); - if (snd->DSB.SetChunk(fileData, size, isMp3, PlaybackRateForPitchLevel(i)) != 0) { - ok = false; - break; - } -#endif - cue.variants[i] = std::move(snd); - } - - if (ok) - return cue; - } - - return std::nullopt; -} - -void EnsureCuesLoaded() -{ - static bool loaded = false; - if (loaded) - return; - - WeaponItemCue = TryLoadCueSound({ "audio\\weapon.ogg", "..\\audio\\weapon.ogg", "audio\\weapon.wav", "..\\audio\\weapon.wav", "audio\\weapon.mp3", "..\\audio\\weapon.mp3" }); - ArmorItemCue = TryLoadCueSound({ "audio\\armor.ogg", "..\\audio\\armor.ogg", "audio\\armor.wav", "..\\audio\\armor.wav", "audio\\armor.mp3", "..\\audio\\armor.mp3" }); - GoldItemCue = TryLoadCueSound({ "audio\\coin.ogg", "..\\audio\\coin.ogg", "audio\\coin.wav", "..\\audio\\coin.wav", "audio\\coin.mp3", "..\\audio\\coin.mp3" }); - - ChestCue = TryLoadCueSound({ "audio\\chest.ogg", "..\\audio\\chest.ogg", "audio\\chest.wav", "..\\audio\\chest.wav", "audio\\chest.mp3", "..\\audio\\chest.mp3" }); - DoorCue = TryLoadCueSound({ "audio\\door.ogg", "..\\audio\\door.ogg", "audio\\door.wav", "..\\audio\\door.wav", "audio\\Door.wav", "..\\audio\\Door.wav", "audio\\door.mp3", "..\\audio\\door.mp3" }); - - MonsterCue = TryLoadCueSound({ "audio\\monster.ogg", "..\\audio\\monster.ogg", "audio\\monster.wav", "..\\audio\\monster.wav", "audio\\monster.mp3", "..\\audio\\monster.mp3" }); - - InteractCue = TryLoadCueSound({ - "audio\\interactispossible.ogg", - "audio\\interactionispossible.ogg", - "..\\audio\\interactispossible.ogg", - "..\\audio\\interactionispossible.ogg", - "audio\\interactispossible.wav", - "audio\\interactionispossible.wav", - "..\\audio\\interactispossible.wav", - "..\\audio\\interactionispossible.wav", - "audio\\interactispossible.mp3", - "audio\\interactionispossible.mp3", - "..\\audio\\interactispossible.mp3", - "..\\audio\\interactionispossible.mp3", - }); - - loaded = true; -} - -[[nodiscard]] bool IsAnyCuePlaying() -{ - const auto isAnyPlaying = [](const std::optional &cue) { - return cue && cue->IsAnyPlaying(); - }; - return isAnyPlaying(WeaponItemCue) || isAnyPlaying(ArmorItemCue) || isAnyPlaying(GoldItemCue) || isAnyPlaying(ChestCue) || isAnyPlaying(DoorCue) - || isAnyPlaying(MonsterCue) || isAnyPlaying(InteractCue); -} - -[[nodiscard]] bool PlayCueAt(const CueSound &cue, Point position, int distance, int maxDistance) -{ - if (!gbSndInited || !gbSoundOn) - return false; - - // Allow the same cue to restart (for tempo control), but don't overlap different cue types. - if (IsAnyOtherCuePlaying(&cue)) - return false; - - int logVolume = 0; - int logPan = 0; - if (!CalculateSoundPosition(position, &logVolume, &logPan)) - return false; - - const int extraAttenuation = static_cast(std::lround(math::Remap(0, maxDistance, 0, ExtraAttenuationMax, distance))); - logVolume = std::max(ATTENUATION_MIN, logVolume - extraAttenuation); - if (logVolume <= ATTENUATION_MIN) - return false; - - const size_t pitchLevel = std::min(PitchLevels - 1, PitchLevelForDistance(distance, maxDistance)); - TSnd *snd = cue.variants[pitchLevel].get(); - if (snd == nullptr || !snd->DSB.IsLoaded()) - return false; - - // Restart the cue if it's already playing so we can control tempo based on distance. - // Stop all variants to avoid overlaps when pitch levels are enabled. - if (cue.IsAnyPlaying()) { - for (const auto &variant : cue.variants) { - if (variant != nullptr && variant->DSB.IsLoaded()) - variant->DSB.Stop(); - } - } - - snd_play_snd(snd, logVolume, logPan); - return true; -} - -[[nodiscard]] bool UpdateItemCues(const Point playerPosition, uint32_t now) -{ - struct Candidate { - item_class itemClass; - int distance; - Point position; - }; - - std::optional nearest; - - for (uint8_t i = 0; i < ActiveItemCount; i++) { - const int itemId = ActiveItems[i]; - const Item &item = Items[itemId]; - - switch (item._iClass) { - case ICLASS_WEAPON: - break; - case ICLASS_ARMOR: - break; - case ICLASS_GOLD: - break; - default: - continue; - } - - const int distance = playerPosition.ApproxDistance(item.position); - if (distance > MaxCueDistanceTiles) - continue; - - if (!nearest || distance < nearest->distance) - nearest = Candidate { item._iClass, distance, item.position }; - } - - if (!nearest) - return false; - - const CueSound *cue = nullptr; - uint32_t *lastTimeMs = nullptr; - switch (nearest->itemClass) { - case ICLASS_WEAPON: - if (WeaponItemCue && WeaponItemCue->IsLoaded()) - cue = &*WeaponItemCue; - lastTimeMs = &LastWeaponItemCueTimeMs; - break; - case ICLASS_ARMOR: - if (ArmorItemCue && ArmorItemCue->IsLoaded()) - cue = &*ArmorItemCue; - lastTimeMs = &LastArmorItemCueTimeMs; - break; - case ICLASS_GOLD: - if (GoldItemCue && GoldItemCue->IsLoaded()) - cue = &*GoldItemCue; - lastTimeMs = &LastGoldItemCueTimeMs; - break; - default: - return false; - } - - if (cue == nullptr || lastTimeMs == nullptr) - return false; - - const int distance = nearest->distance; - const uint32_t intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles); - if (now - *lastTimeMs < intervalMs) - return false; - - if (PlayCueAt(*cue, nearest->position, distance, MaxCueDistanceTiles)) { - *lastTimeMs = now; - return true; - } - - return false; -} - [[nodiscard]] int GetRotaryDistanceForInteractTarget(const Player &player, Point destination) { if (player.position.future == destination) @@ -483,112 +183,8 @@ std::optional FindInteractTargetInRange(const Player &player, Po return best; } -[[nodiscard]] bool UpdateObjectCues(const Point playerPosition, uint32_t now) -{ - struct Candidate { - int objectId; - int distance; - const CueSound *cue; - }; - - std::optional nearest; - - for (int i = 0; i < ActiveObjectCount; i++) { - const int objectId = ActiveObjects[i]; - const Object &object = Objects[objectId]; - if (!object.canInteractWith()) - continue; - if (!object.isDoor() && !object.IsChest()) - continue; - - const int distance = playerPosition.ApproxDistance(object.position); - if (distance > MaxCueDistanceTiles) - continue; - - const CueSound *cue = nullptr; - if (object.IsChest()) { - if (ChestCue && ChestCue->IsLoaded()) - cue = &*ChestCue; - } else if (object.isDoor()) { - if (DoorCue && DoorCue->IsLoaded()) - cue = &*DoorCue; - } - - if (cue == nullptr) - continue; - - if (!nearest || distance < nearest->distance) - nearest = Candidate { objectId, distance, cue }; - } - - if (!nearest) - return false; - - const int objectId = nearest->objectId; - const int distance = nearest->distance; - const uint32_t intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles); - if (now - LastObjectCueTimeMs[objectId] < intervalMs) - return false; - - if (PlayCueAt(*nearest->cue, Objects[objectId].position, distance, MaxCueDistanceTiles)) { - LastObjectCueTimeMs[objectId] = now; - return true; - } - - return false; -} - -[[nodiscard]] bool UpdateMonsterCue(const Point playerPosition, uint32_t now) -{ - if (!MonsterCue || !MonsterCue->IsLoaded()) - return false; - - std::optional> nearest; - - for (size_t i = 0; i < ActiveMonsterCount; i++) { - const int monsterId = static_cast(ActiveMonsters[i]); - const Monster &monster = Monsters[monsterId]; - - if (monster.isInvalid) - continue; - if ((monster.flags & MFLAG_HIDDEN) != 0) - continue; - if (monster.hitPoints <= 0) - continue; - - // Use the future position for distance/tempo so cues react immediately when a monster starts moving - // towards or away from the player (tile position updates later). - const Point monsterSoundPosition { monster.position.tile }; - const Point monsterDistancePosition { monster.position.future }; - const int distance = playerPosition.ApproxDistance(monsterDistancePosition); - if (distance > MaxCueDistanceTiles) - continue; - - if (!nearest || distance < nearest->first) { - nearest = { distance, monsterSoundPosition }; - } - } - - if (!nearest) - return false; - - const int distance = nearest->first; - const uint32_t intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinMonsterIntervalMs, MaxMonsterIntervalMs); - if (now - LastMonsterCueTimeMs < intervalMs) - return false; - - if (!PlayCueAt(*MonsterCue, nearest->second, distance, MaxCueDistanceTiles)) - return false; - - LastMonsterCueTimeMs = now; - return true; -} - [[nodiscard]] bool UpdateInteractCue(const Point playerPosition, uint32_t now) { - if (!InteractCue || !InteractCue->IsLoaded()) - return false; - if (MyPlayer == nullptr) return false; @@ -621,19 +217,67 @@ std::optional FindInteractTargetInRange(const Player &player, Po } } - // Make the interact cue reliably audible even when another proximity cue is playing. - // (The new distance-based tempo restarts cues frequently.) - StopAllCuesExcept(InteractCue ? &*InteractCue : nullptr); - - if (!InteractCue || !InteractCue->IsLoaded()) - return true; + SoundPool &pool = SoundPool::Get(); + if (pool.IsLoaded(SoundPool::SoundId::Interact)) + pool.PlayOneShot(SoundPool::SoundId::Interact, target->position, /*stopEmitters=*/true, now); - if (!PlayCueAt(*InteractCue, target->position, /*distance=*/0, /*maxDistance=*/1)) - return true; - (void)now; return true; } +void EnsureNavigationSoundsLoaded(SoundPool &pool) +{ + (void)pool.EnsureLoaded(SoundPool::SoundId::WeaponItem, { "audio\\weapon.ogg", "..\\audio\\weapon.ogg", "audio\\weapon.wav", "..\\audio\\weapon.wav", "audio\\weapon.mp3", "..\\audio\\weapon.mp3" }); + (void)pool.EnsureLoaded(SoundPool::SoundId::ArmorItem, { "audio\\armor.ogg", "..\\audio\\armor.ogg", "audio\\armor.wav", "..\\audio\\armor.wav", "audio\\armor.mp3", "..\\audio\\armor.mp3" }); + (void)pool.EnsureLoaded(SoundPool::SoundId::GoldItem, { "audio\\coin.ogg", "..\\audio\\coin.ogg", "audio\\coin.wav", "..\\audio\\coin.wav", "audio\\coin.mp3", "..\\audio\\coin.mp3" }); + + (void)pool.EnsureLoaded(SoundPool::SoundId::Chest, { "audio\\chest.ogg", "..\\audio\\chest.ogg", "audio\\chest.wav", "..\\audio\\chest.wav", "audio\\chest.mp3", "..\\audio\\chest.mp3" }); + (void)pool.EnsureLoaded(SoundPool::SoundId::Door, { "audio\\door.ogg", "..\\audio\\door.ogg", "audio\\door.wav", "..\\audio\\door.wav", "audio\\Door.wav", "..\\audio\\Door.wav", "audio\\door.mp3", "..\\audio\\door.mp3" }); + + (void)pool.EnsureLoaded(SoundPool::SoundId::Monster, { "audio\\monster.ogg", "..\\audio\\monster.ogg", "audio\\monster.wav", "..\\audio\\monster.wav", "audio\\monster.mp3", "..\\audio\\monster.mp3" }); + + (void)pool.EnsureLoaded(SoundPool::SoundId::Interact, { + "audio\\interactispossible.ogg", + "audio\\interactionispossible.ogg", + "..\\audio\\interactispossible.ogg", + "..\\audio\\interactionispossible.ogg", + "audio\\interactispossible.wav", + "audio\\interactionispossible.wav", + "..\\audio\\interactispossible.wav", + "..\\audio\\interactionispossible.wav", + "audio\\interactispossible.mp3", + "audio\\interactionispossible.mp3", + "..\\audio\\interactispossible.mp3", + "..\\audio\\interactionispossible.mp3", + }); +} + +struct CandidateEmitter { + uint32_t emitterId; + SoundPool::SoundId sound; + Point position; + int distance; + uint32_t intervalMs; +}; + +[[nodiscard]] bool IsBetterCandidate(const CandidateEmitter &a, const CandidateEmitter &b) +{ + if (a.distance != b.distance) + return a.distance < b.distance; + return a.emitterId < b.emitterId; +} + +void ConsiderCandidate(std::array, MaxEmitters> &best, CandidateEmitter candidate) +{ + for (size_t i = 0; i < best.size(); ++i) { + if (!best[i] || IsBetterCandidate(candidate, *best[i])) { + for (size_t j = best.size() - 1; j > i; --j) + best[j] = best[j - 1]; + best[i] = std::move(candidate); + return; + } + } +} + } // namespace void UpdateProximityAudioCues() @@ -647,19 +291,129 @@ void UpdateProximityAudioCues() if (InGameMenu()) return; - EnsureCuesLoaded(); + SoundPool &pool = SoundPool::Get(); + EnsureNavigationSoundsLoaded(pool); const uint32_t now = SDL_GetTicks(); const Point playerPosition { MyPlayer->position.future }; - // Keep cues readable and reduce overlap/glitches by playing at most one per tick (priority order). + // Interact cue is a one-shot that should be clearly audible and not counted in the 3-emitter limit. if (UpdateInteractCue(playerPosition, now)) return; - if (UpdateMonsterCue(playerPosition, now)) - return; - if (UpdateItemCues(playerPosition, now)) - return; - (void)UpdateObjectCues(playerPosition, now); + + std::array, MaxEmitters> best; + best.fill(std::nullopt); + + for (uint8_t i = 0; i < ActiveItemCount; i++) { + const int itemId = ActiveItems[i]; + const Item &item = Items[itemId]; + + SoundPool::SoundId soundId; + switch (item._iClass) { + case ICLASS_WEAPON: + soundId = SoundPool::SoundId::WeaponItem; + break; + case ICLASS_ARMOR: + soundId = SoundPool::SoundId::ArmorItem; + break; + case ICLASS_GOLD: + soundId = SoundPool::SoundId::GoldItem; + break; + default: + continue; + } + + if (!pool.IsLoaded(soundId)) + continue; + + const int distance = playerPosition.ApproxDistance(item.position); + if (distance > MaxCueDistanceTiles) + continue; + + ConsiderCandidate(best, CandidateEmitter { + .emitterId = MakeEmitterId(EmitterType::Item, static_cast(itemId)), + .sound = soundId, + .position = item.position, + .distance = distance, + .intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinIntervalMs, MaxIntervalMs), + }); + } + + for (int i = 0; i < ActiveObjectCount; i++) { + const int objectId = ActiveObjects[i]; + const Object &object = Objects[objectId]; + if (!object.canInteractWith()) + continue; + if (!object.isDoor() && !object.IsChest()) + continue; + + SoundPool::SoundId soundId; + if (object.IsChest()) { + soundId = SoundPool::SoundId::Chest; + } else { + soundId = SoundPool::SoundId::Door; + } + + if (!pool.IsLoaded(soundId)) + continue; + + const int distance = playerPosition.ApproxDistance(object.position); + if (distance > MaxCueDistanceTiles) + continue; + + ConsiderCandidate(best, CandidateEmitter { + .emitterId = MakeEmitterId(EmitterType::Object, static_cast(objectId)), + .sound = soundId, + .position = object.position, + .distance = distance, + .intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinIntervalMs, MaxIntervalMs), + }); + } + + for (size_t i = 0; i < ActiveMonsterCount; i++) { + const int monsterId = static_cast(ActiveMonsters[i]); + const Monster &monster = Monsters[monsterId]; + + if (monster.isInvalid) + continue; + if ((monster.flags & MFLAG_HIDDEN) != 0) + continue; + if (monster.hitPoints <= 0) + continue; + + if (!pool.IsLoaded(SoundPool::SoundId::Monster)) + continue; + + // Use the future position for distance/tempo so cues react immediately when a monster starts moving. + const Point monsterSoundPosition { monster.position.tile }; + const Point monsterDistancePosition { monster.position.future }; + const int distance = playerPosition.ApproxDistance(monsterDistancePosition); + if (distance > MaxCueDistanceTiles) + continue; + + ConsiderCandidate(best, CandidateEmitter { + .emitterId = MakeEmitterId(EmitterType::Monster, static_cast(monsterId)), + .sound = SoundPool::SoundId::Monster, + .position = monsterSoundPosition, + .distance = distance, + .intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinMonsterIntervalMs, MaxMonsterIntervalMs), + }); + } + + std::array requests; + size_t requestCount = 0; + for (const auto &entry : best) { + if (!entry) + continue; + requests[requestCount++] = SoundPool::EmitterRequest { + .emitterId = entry->emitterId, + .sound = entry->sound, + .position = entry->position, + .intervalMs = entry->intervalMs, + }; + } + + pool.UpdateEmitters(std::span(requests.data(), requestCount), now); } #endif // NOSOUND