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

#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