You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
487 lines
16 KiB
487 lines
16 KiB
#include "utils/proximity_audio.hpp" |
|
|
|
#include <algorithm> |
|
#include <array> |
|
#include <cmath> |
|
#include <cstddef> |
|
#include <cstdint> |
|
#include <optional> |
|
#include <span> |
|
|
|
#ifdef USE_SDL3 |
|
#include <SDL3/SDL_timer.h> |
|
#else |
|
#include <SDL.h> |
|
#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<uint32_t> 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<uint32_t>(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<float>(distance) / static_cast<float>(maxDistance), 0.0F, 1.0F); |
|
const float closeness = 1.0F - t; |
|
const float interval = static_cast<float>(maxIntervalMs) - closeness * static_cast<float>(maxIntervalMs - minIntervalMs); |
|
return static_cast<uint32_t>(std::lround(interval)); |
|
} |
|
|
|
[[nodiscard]] int GetRotaryDistanceForInteractTarget(const Player &player, Point destination) |
|
{ |
|
if (player.position.future == destination) |
|
return -1; |
|
|
|
const int d1 = static_cast<int>(player._pdir); |
|
const int d2 = static_cast<int>(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<int>(maxSteps)) |
|
return false; |
|
|
|
std::array<int8_t, InteractDistanceTiles> 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<int>(maxSteps); |
|
} |
|
|
|
std::optional<InteractTarget> FindInteractTargetInRange(const Player &player, Point playerPosition) |
|
{ |
|
int rotations = 5; |
|
std::optional<InteractTarget> 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<int>(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<InteractTarget> target = FindInteractTargetInRange(player, playerPosition); |
|
if (!target) { |
|
LastInteractableId = std::nullopt; |
|
return false; |
|
} |
|
|
|
const uint32_t id = target->type == InteractTargetType::Item |
|
? (1U << 16) | static_cast<uint32_t>(target->id) |
|
: (2U << 16) | static_cast<uint32_t>(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<std::optional<CandidateEmitter>, 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<std::optional<CandidateEmitter>, 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<uint32_t>(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<uint32_t>(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<uint32_t>(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<int>(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<uint32_t>(monsterId)), |
|
.sound = SoundPool::SoundId::Monster, |
|
.position = monsterSoundPosition, |
|
.distance = distance, |
|
.intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinMonsterIntervalMs, MaxMonsterIntervalMs), |
|
}); |
|
} |
|
|
|
std::array<SoundPool::EmitterRequest, MaxEmitters> 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<const SoundPool::EmitterRequest>(requests.data(), requestCount), now); |
|
} |
|
|
|
#endif // NOSOUND |
|
|
|
} // namespace devilution
|
|
|