#include "utils/proximity_audio.hpp" #include #include #include #include #include #include #include #ifdef USE_SDL3 #include #else #include #endif #include "controls/plrctrls.h" #include "engine/path.h" #include "engine/sound.h" #include "engine/sound_pool.hpp" #include "inv.h" #include "items.h" #include "levels/gendung.h" #include "levels/tile_properties.hpp" #include "monster.h" #include "objects.h" #include "player.h" #include "levels/trigs.h" #include "utils/is_of.hpp" #include "utils/screen_reader.hpp" namespace devilution { #ifdef NOSOUND void UpdateProximityAudioCues() { } #else namespace { constexpr int MaxCueDistanceTiles = 12; constexpr int InteractDistanceTiles = 1; constexpr size_t MaxEmitters = 3; constexpr uint32_t MinIntervalMs = 250; constexpr uint32_t MaxIntervalMs = 1000; // Monster movement already provides a lot of information; keep the tempo a bit faster. constexpr uint32_t MinMonsterIntervalMs = 100; constexpr uint32_t MaxMonsterIntervalMs = 1000; std::optional LastInteractableId; enum class InteractTargetType : uint8_t { Item, Object, }; struct InteractTarget { InteractTargetType type; int id; Point position; }; enum class EmitterType : uint8_t { Item = 1, Object = 2, Monster = 3, Trigger = 4, }; [[nodiscard]] constexpr uint32_t MakeEmitterId(EmitterType type, uint32_t id) { return (static_cast(type) << 24) | (id & 0x00FFFFFF); } [[nodiscard]] constexpr bool IsPotion(const Item &item) { switch (item._iMiscId) { case IMISC_FULLHEAL: case IMISC_HEAL: case IMISC_MANA: case IMISC_FULLMANA: case IMISC_REJUV: case IMISC_FULLREJUV: case IMISC_ELIXSTR: case IMISC_ELIXMAG: case IMISC_ELIXDEX: case IMISC_ELIXVIT: case IMISC_SPECELIX: case IMISC_ARENAPOT: return true; default: return false; } } [[nodiscard]] constexpr bool IsScroll(const Item &item) { return item._iMiscId == IMISC_SCROLL || item._iMiscId == IMISC_SCROLLT; } [[nodiscard]] uint32_t IntervalMsForDistance(int distance, int maxDistance, uint32_t minIntervalMs, uint32_t maxIntervalMs) { if (maxDistance <= 0) return minIntervalMs; const float t = std::clamp(static_cast(distance) / static_cast(maxDistance), 0.0F, 1.0F); const float closeness = 1.0F - t; const float interval = static_cast(maxIntervalMs) - closeness * static_cast(maxIntervalMs - minIntervalMs); return static_cast(std::lround(interval)); } [[nodiscard]] int GetRotaryDistanceForInteractTarget(const Player &player, Point destination) { if (player.position.future == destination) return -1; const int d1 = static_cast(player._pdir); const int d2 = static_cast(GetDirection(player.position.future, destination)); const int d = std::abs(d1 - d2); if (d > 4) return 4 - (d % 4); return d; } [[nodiscard]] bool IsReachableWithinSteps(const Player &player, Point start, Point destination, size_t maxSteps) { if (maxSteps == 0) return start == destination; if (start == destination) return true; if (start.WalkingDistance(destination) > static_cast(maxSteps)) return false; std::array path; path.fill(WALK_NONE); const int steps = FindPath(CanStep, [&player](Point position) { return PosOkPlayer(player, position); }, start, destination, path.data(), path.size()); return steps != 0 && steps <= static_cast(maxSteps); } std::optional FindInteractTargetInRange(const Player &player, Point playerPosition) { int rotations = 5; std::optional best; for (int dx = -1; dx <= 1; ++dx) { for (int dy = -1; dy <= 1; ++dy) { const Point targetPosition { playerPosition.x + dx, playerPosition.y + dy }; if (!InDungeonBounds(targetPosition)) continue; const int itemId = dItem[targetPosition.x][targetPosition.y] - 1; if (itemId < 0) continue; const Item &item = Items[itemId]; if (item.isEmpty() || item.selectionRegion == SelectionRegion::None) continue; const int newRotations = GetRotaryDistanceForInteractTarget(player, targetPosition); if (rotations < newRotations) continue; if (targetPosition != playerPosition && !IsReachableWithinSteps(player, playerPosition, targetPosition, InteractDistanceTiles)) continue; rotations = newRotations; best = InteractTarget { .type = InteractTargetType::Item, .id = itemId, .position = targetPosition }; } } if (best) return best; rotations = 5; for (int dx = -1; dx <= 1; ++dx) { for (int dy = -1; dy <= 1; ++dy) { const Point targetPosition { playerPosition.x + dx, playerPosition.y + dy }; if (!InDungeonBounds(targetPosition)) continue; Object *object = FindObjectAtPosition(targetPosition); if (object == nullptr || !object->canInteractWith()) continue; if (!object->isDoor() && !object->IsChest()) continue; if (object->IsDisabled()) continue; if (targetPosition == playerPosition && object->_oDoorFlag) continue; const int newRotations = GetRotaryDistanceForInteractTarget(player, targetPosition); if (rotations < newRotations) continue; if (targetPosition != playerPosition && !IsReachableWithinSteps(player, playerPosition, targetPosition, InteractDistanceTiles)) continue; const int objectId = static_cast(object - Objects); rotations = newRotations; best = InteractTarget { .type = InteractTargetType::Object, .id = objectId, .position = targetPosition }; } } return best; } [[nodiscard]] bool UpdateInteractCue(const Point playerPosition, uint32_t now) { if (MyPlayer == nullptr) return false; const Player &player = *MyPlayer; const std::optional target = FindInteractTargetInRange(player, playerPosition); if (!target) { LastInteractableId = std::nullopt; return false; } const uint32_t id = target->type == InteractTargetType::Item ? (1U << 16) | static_cast(target->id) : (2U << 16) | static_cast(target->id); if (LastInteractableId && *LastInteractableId == id) return false; LastInteractableId = id; if (!invflag) { if (target->type == InteractTargetType::Item) { const Item &item = Items[target->id]; const StringOrView name = item.getName(); if (!name.empty()) SpeakText(name.str(), /*force=*/true); } else { const Object &object = Objects[target->id]; const StringOrView name = object.name(); if (!name.empty()) SpeakText(name.str(), /*force=*/true); } } SoundPool &pool = SoundPool::Get(); if (pool.IsLoaded(SoundPool::SoundId::Interact)) pool.PlayOneShot(SoundPool::SoundId::Interact, target->position, /*stopEmitters=*/true, 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::PotionItem, { "audio\\potion.ogg", "..\\audio\\potion.ogg", "audio\\potion.wav", "..\\audio\\potion.wav", "audio\\Potion.wav", "..\\audio\\Potion.wav", "audio\\potion.mp3", "..\\audio\\potion.mp3" }); (void)pool.EnsureLoaded(SoundPool::SoundId::ScrollItem, { "audio\\scroll.ogg", "..\\audio\\scroll.ogg", "audio\\scroll.wav", "..\\audio\\scroll.wav", "audio\\Scroll.wav", "..\\audio\\Scroll.wav", "audio\\scroll.mp3", "..\\audio\\scroll.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::Stairs, { "audio\\stairs.ogg", "..\\audio\\stairs.ogg", "audio\\stairs.wav", "..\\audio\\stairs.wav", "audio\\Stairs.wav", "..\\audio\\Stairs.wav", "audio\\stairs.mp3", "..\\audio\\stairs.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() { if (!gbSndInited || !gbSoundOn) return; if (leveltype == DTYPE_TOWN) return; if (MyPlayer == nullptr || MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH) return; if (InGameMenu()) return; if (invflag) { SoundPool::Get().UpdateEmitters({}, SDL_GetTicks()); return; } SoundPool &pool = SoundPool::Get(); EnsureNavigationSoundsLoaded(pool); const uint32_t now = SDL_GetTicks(); const Point playerPosition { MyPlayer->position.future }; // Interact cue is a one-shot that should be clearly audible and not counted in the 3-emitter limit. if (UpdateInteractCue(playerPosition, now)) return; 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; case ICLASS_MISC: if (IsPotion(item)) { soundId = SoundPool::SoundId::PotionItem; } else if (IsScroll(item)) { soundId = SoundPool::SoundId::ScrollItem; } else { continue; } 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 (int i = 0; i < numtrigs; ++i) { if (!IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABPREVLVL)) continue; if (!pool.IsLoaded(SoundPool::SoundId::Stairs)) continue; const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; const int distance = playerPosition.ApproxDistance(triggerPosition); if (distance > MaxCueDistanceTiles) continue; ConsiderCandidate(best, CandidateEmitter { .emitterId = MakeEmitterId(EmitterType::Trigger, static_cast(i)), .sound = SoundPool::SoundId::Stairs, .position = triggerPosition, .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 } // namespace devilution