27 changed files with 4757 additions and 936 deletions
@ -1,17 +1,26 @@
|
||||
#pragma once |
||||
|
||||
#include <string> |
||||
|
||||
#include <expected.hpp> |
||||
|
||||
#include "engine/clx_sprite.hpp" |
||||
#include "engine/surface.hpp" |
||||
|
||||
namespace devilution { |
||||
|
||||
tl::expected<void, std::string> InitSpellBook(); |
||||
void FreeSpellBook(); |
||||
void CheckSBook(); |
||||
void DrawSpellBook(const Surface &out); |
||||
|
||||
} // namespace devilution
|
||||
#pragma once |
||||
|
||||
#include <optional> |
||||
#include <string> |
||||
#include <vector> |
||||
|
||||
#include <expected.hpp> |
||||
|
||||
#include "engine/clx_sprite.hpp" |
||||
#include "engine/surface.hpp" |
||||
#include "tables/spelldat.h" |
||||
|
||||
namespace devilution { |
||||
|
||||
struct Player; |
||||
|
||||
tl::expected<void, std::string> InitSpellBook(); |
||||
void FreeSpellBook(); |
||||
void CheckSBook(); |
||||
void DrawSpellBook(const Surface &out); |
||||
|
||||
std::vector<SpellID> GetSpellBookAvailableSpells(int tab, const Player &player); |
||||
std::optional<SpellID> GetSpellBookFirstAvailableSpell(int tab, const Player &player); |
||||
std::optional<SpellID> GetSpellBookAdjacentAvailableSpell(int tab, const Player &player, SpellID currentSpell, int delta); |
||||
|
||||
} // namespace devilution
|
||||
|
||||
@ -0,0 +1,597 @@
|
||||
#include "utils/proximity_audio.hpp" |
||||
|
||||
#include <algorithm> |
||||
#include <array> |
||||
#include <cctype> |
||||
#include <cmath> |
||||
#include <cstdint> |
||||
#include <memory> |
||||
#include <optional> |
||||
#include <string_view> |
||||
|
||||
#ifdef USE_SDL3 |
||||
#include <SDL3/SDL_timer.h> |
||||
#else |
||||
#include <SDL.h> |
||||
#endif |
||||
|
||||
#include "controls/plrctrls.h" |
||||
#include "engine/assets.hpp" |
||||
#include "engine/path.h" |
||||
#include "engine/sound.h" |
||||
#include "engine/sound_position.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 "utils/is_of.hpp" |
||||
#include "utils/math.h" |
||||
#include "utils/screen_reader.hpp" |
||||
#include "utils/stdcompat/shared_ptr_array.hpp" |
||||
|
||||
namespace devilution { |
||||
|
||||
#ifdef NOSOUND |
||||
|
||||
void UpdateProximityAudioCues() |
||||
{ |
||||
} |
||||
|
||||
#else |
||||
|
||||
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 uint32_t MinIntervalMs = 250; |
||||
constexpr uint32_t MaxIntervalMs = 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<std::unique_ptr<TSnd>, 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<CueSound> WeaponItemCue; |
||||
std::optional<CueSound> ArmorItemCue; |
||||
std::optional<CueSound> GoldItemCue; |
||||
std::optional<CueSound> ChestCue; |
||||
std::optional<CueSound> DoorCue; |
||||
std::optional<CueSound> MonsterCue; |
||||
std::optional<CueSound> InteractCue; |
||||
|
||||
std::array<uint32_t, MAXOBJECTS> LastObjectCueTimeMs {}; |
||||
uint32_t LastMonsterCueTimeMs = 0; |
||||
std::optional<uint32_t> LastInteractableId; |
||||
uint32_t LastWeaponItemCueTimeMs = 0; |
||||
uint32_t LastArmorItemCueTimeMs = 0; |
||||
uint32_t LastGoldItemCueTimeMs = 0; |
||||
|
||||
enum class InteractTargetType : uint8_t { |
||||
Item, |
||||
Object, |
||||
}; |
||||
|
||||
struct InteractTarget { |
||||
InteractTargetType type; |
||||
int id; |
||||
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<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b)); |
||||
}); |
||||
} |
||||
|
||||
[[nodiscard]] bool IsMp3Path(std::string_view path) |
||||
{ |
||||
return EndsWithCaseInsensitive(path, ".mp3"); |
||||
} |
||||
|
||||
[[nodiscard]] float PlaybackRateForPitchLevel(size_t level) |
||||
{ |
||||
(void)level; |
||||
return 1.0F; |
||||
} |
||||
|
||||
[[nodiscard]] size_t PitchLevelForDistance(int distance, int maxDistance) |
||||
{ |
||||
(void)distance; |
||||
(void)maxDistance; |
||||
return 0; |
||||
} |
||||
|
||||
[[nodiscard]] uint32_t IntervalMsForDistance(int distance, int maxDistance) |
||||
{ |
||||
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)); |
||||
} |
||||
|
||||
std::optional<CueSound> TryLoadCueSound(std::initializer_list<std::string_view> 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<std::uint8_t>(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<TSnd>(); |
||||
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\\interactispossible.ogg", "audio\\interactispossible.wav", "..\\audio\\interactispossible.wav", "audio\\interactispossible.mp3", "..\\audio\\interactispossible.mp3" }); |
||||
|
||||
loaded = true; |
||||
} |
||||
|
||||
[[nodiscard]] bool IsAnyCuePlaying() |
||||
{ |
||||
const auto isAnyPlaying = [](const std::optional<CueSound> &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; |
||||
|
||||
// Proximity cues are meant to guide the player; overlapping the same cue can create audio glitches/noise.
|
||||
if (cue.IsAnyPlaying()) |
||||
return false; |
||||
|
||||
int logVolume = 0; |
||||
int logPan = 0; |
||||
if (!CalculateSoundPosition(position, &logVolume, &logPan)) |
||||
return false; |
||||
|
||||
const int extraAttenuation = static_cast<int>(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; |
||||
|
||||
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<Candidate> 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) |
||||
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 UpdateObjectCues(const Point playerPosition, uint32_t now) |
||||
{ |
||||
struct Candidate { |
||||
int objectId; |
||||
int distance; |
||||
const CueSound *cue; |
||||
}; |
||||
|
||||
std::optional<Candidate> 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<std::pair<int, Point>> nearest; |
||||
|
||||
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; |
||||
|
||||
const Point monsterPosition { monster.position.tile }; |
||||
const int distance = playerPosition.ApproxDistance(monsterPosition); |
||||
if (distance > MaxCueDistanceTiles) |
||||
continue; |
||||
|
||||
if (!nearest || distance < nearest->first) { |
||||
nearest = { distance, monsterPosition }; |
||||
} |
||||
} |
||||
|
||||
if (!nearest) |
||||
return false; |
||||
|
||||
const int distance = nearest->first; |
||||
const uint32_t intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles); |
||||
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; |
||||
|
||||
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); |
||||
} |
||||
} |
||||
|
||||
if (!PlayCueAt(*InteractCue, target->position, /*distance=*/0, /*maxDistance=*/1)) |
||||
return true; |
||||
(void)now; |
||||
return true; |
||||
} |
||||
|
||||
} // namespace
|
||||
|
||||
void UpdateProximityAudioCues() |
||||
{ |
||||
if (!gbSndInited || !gbSoundOn) |
||||
return; |
||||
if (leveltype == DTYPE_TOWN) |
||||
return; |
||||
if (MyPlayer == nullptr || MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH) |
||||
return; |
||||
if (InGameMenu()) |
||||
return; |
||||
|
||||
EnsureCuesLoaded(); |
||||
|
||||
const uint32_t now = SDL_GetTicks(); |
||||
const Point playerPosition { MyPlayer->position.future }; |
||||
|
||||
// Don't start another cue while one is playing (helps avoid overlap-related stutter/noise).
|
||||
if (IsAnyCuePlaying()) |
||||
return; |
||||
|
||||
// Keep cues readable and reduce overlap/glitches by playing at most one per tick (priority order).
|
||||
if (UpdateInteractCue(playerPosition, now)) |
||||
return; |
||||
if (UpdateMonsterCue(playerPosition, now)) |
||||
return; |
||||
if (UpdateItemCues(playerPosition, now)) |
||||
return; |
||||
(void)UpdateObjectCues(playerPosition, now); |
||||
} |
||||
|
||||
#endif // NOSOUND
|
||||
|
||||
} // namespace devilution
|
||||
@ -0,0 +1,8 @@
|
||||
#pragma once |
||||
|
||||
namespace devilution { |
||||
|
||||
void UpdateProximityAudioCues(); |
||||
|
||||
} // namespace devilution
|
||||
|
||||
@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3 |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import argparse |
||||
import re |
||||
import struct |
||||
from pathlib import Path |
||||
|
||||
|
||||
_ESCAPE_RE = re.compile(r"\\(n|t|r|\\|\"|[0-7]{1,3}|x[0-9a-fA-F]{2})") |
||||
|
||||
|
||||
def _unescape(value: str) -> str: |
||||
def repl(match: re.Match[str]) -> str: |
||||
escape = match.group(1) |
||||
if escape == "n": |
||||
return "\n" |
||||
if escape == "t": |
||||
return "\t" |
||||
if escape == "r": |
||||
return "\r" |
||||
if escape == "\\": |
||||
return "\\" |
||||
if escape == '"': |
||||
return '"' |
||||
if escape.startswith("x"): |
||||
return chr(int(escape[1:], 16)) |
||||
return chr(int(escape, 8)) |
||||
|
||||
return _ESCAPE_RE.sub(repl, value) |
||||
|
||||
|
||||
def _parse_quoted(rest: str) -> str: |
||||
rest = rest.strip() |
||||
if not (rest.startswith('"') and rest.endswith('"')): |
||||
raise ValueError(f"Invalid PO string: {rest!r}") |
||||
return _unescape(rest[1:-1]) |
||||
|
||||
|
||||
def parse_po(path: Path) -> dict[str, str]: |
||||
messages: list[tuple[str | None, str, str | None, dict[int, str], set[str]]] = [] |
||||
|
||||
msgctxt: str | None = None |
||||
msgid: str | None = None |
||||
msgid_plural: str | None = None |
||||
msgstr: dict[int, str] = {} |
||||
flags: set[str] = set() |
||||
active: tuple[str, int | None] | None = None |
||||
|
||||
def flush() -> None: |
||||
nonlocal msgctxt, msgid, msgid_plural, msgstr, flags, active |
||||
if msgid is None: |
||||
msgctxt = None |
||||
msgid_plural = None |
||||
msgstr = {} |
||||
flags = set() |
||||
active = None |
||||
return |
||||
|
||||
messages.append((msgctxt, msgid, msgid_plural, dict(msgstr), set(flags))) |
||||
|
||||
msgctxt = None |
||||
msgid = None |
||||
msgid_plural = None |
||||
msgstr = {} |
||||
flags = set() |
||||
active = None |
||||
|
||||
with path.open("r", encoding="utf-8", errors="replace", newline="") as file: |
||||
for raw_line in file: |
||||
line = raw_line.rstrip("\n") |
||||
|
||||
if not line.strip(): |
||||
flush() |
||||
continue |
||||
|
||||
if line.startswith("#,"): |
||||
for flag in line[2:].split(","): |
||||
flag = flag.strip() |
||||
if flag: |
||||
flags.add(flag) |
||||
continue |
||||
|
||||
if line.startswith("#"): |
||||
continue |
||||
|
||||
if line.startswith("msgctxt"): |
||||
msgctxt = _parse_quoted(line[len("msgctxt") :]) |
||||
active = ("msgctxt", None) |
||||
continue |
||||
|
||||
if line.startswith("msgid_plural"): |
||||
msgid_plural = _parse_quoted(line[len("msgid_plural") :]) |
||||
active = ("msgid_plural", None) |
||||
continue |
||||
|
||||
if line.startswith("msgid"): |
||||
msgid = _parse_quoted(line[len("msgid") :]) |
||||
active = ("msgid", None) |
||||
continue |
||||
|
||||
if line.startswith("msgstr["): |
||||
close = line.find("]") |
||||
index = int(line[len("msgstr[") : close]) |
||||
msgstr[index] = _parse_quoted(line[close + 1 :]) |
||||
active = ("msgstr", index) |
||||
continue |
||||
|
||||
if line.startswith("msgstr"): |
||||
msgstr[0] = _parse_quoted(line[len("msgstr") :]) |
||||
active = ("msgstr", 0) |
||||
continue |
||||
|
||||
if line.lstrip().startswith('"'): |
||||
value = _parse_quoted(line) |
||||
if active is None: |
||||
continue |
||||
kind, index = active |
||||
if kind == "msgctxt": |
||||
msgctxt = (msgctxt or "") + value |
||||
elif kind == "msgid": |
||||
msgid = (msgid or "") + value |
||||
elif kind == "msgid_plural": |
||||
msgid_plural = (msgid_plural or "") + value |
||||
elif kind == "msgstr": |
||||
assert index is not None |
||||
msgstr[index] = msgstr.get(index, "") + value |
||||
continue |
||||
|
||||
flush() |
||||
|
||||
catalog: dict[str, str] = {} |
||||
for msgctxt, msgid, msgid_plural, msgstrs, flags in messages: |
||||
if "fuzzy" in flags: |
||||
continue |
||||
|
||||
if msgid_plural is not None: |
||||
key = msgid + "\x00" + msgid_plural |
||||
max_index = max(msgstrs.keys(), default=0) |
||||
value = "\x00".join(msgstrs.get(i, "") for i in range(max_index + 1)) |
||||
else: |
||||
key = msgid |
||||
value = msgstrs.get(0, "") |
||||
|
||||
if msgctxt: |
||||
key = msgctxt + "\x04" + key |
||||
|
||||
catalog[key] = value |
||||
|
||||
catalog.setdefault("", "") |
||||
return catalog |
||||
|
||||
|
||||
def write_mo(catalog: dict[str, str], out_file: Path) -> None: |
||||
entries = sorted(catalog.items(), key=lambda kv: kv[0]) |
||||
ids = [key.encode("utf-8") for key, _ in entries] |
||||
strs = [value.encode("utf-8") for _, value in entries] |
||||
|
||||
count = len(entries) |
||||
header_size = 7 * 4 |
||||
table_size = count * 8 |
||||
originals_offset = header_size |
||||
translations_offset = originals_offset + table_size |
||||
string_offset = translations_offset + table_size |
||||
|
||||
offsets_ids: list[tuple[int, int]] = [] |
||||
offsets_strs: list[tuple[int, int]] = [] |
||||
pool = bytearray() |
||||
|
||||
for value in ids: |
||||
offsets_ids.append((len(value), string_offset + len(pool))) |
||||
pool.extend(value) |
||||
pool.append(0) |
||||
|
||||
for value in strs: |
||||
offsets_strs.append((len(value), string_offset + len(pool))) |
||||
pool.extend(value) |
||||
pool.append(0) |
||||
|
||||
out_file.parent.mkdir(parents=True, exist_ok=True) |
||||
with out_file.open("wb") as file: |
||||
file.write( |
||||
struct.pack( |
||||
"<Iiiiiii", |
||||
0x950412DE, # magic |
||||
0, # version |
||||
count, |
||||
originals_offset, |
||||
translations_offset, |
||||
0, # hash table size |
||||
0, # hash table offset |
||||
) |
||||
) |
||||
for length, offset in offsets_ids: |
||||
file.write(struct.pack("<II", length, offset)) |
||||
for length, offset in offsets_strs: |
||||
file.write(struct.pack("<II", length, offset)) |
||||
file.write(pool) |
||||
|
||||
|
||||
def main() -> int: |
||||
parser = argparse.ArgumentParser(description="Compile a .po file into a GNU .mo/.gmo file.") |
||||
parser.add_argument("input", type=Path, help="Input .po file") |
||||
parser.add_argument("-o", "--output", type=Path, required=True, help="Output .mo/.gmo file") |
||||
args = parser.parse_args() |
||||
|
||||
catalog = parse_po(args.input) |
||||
write_mo(catalog, args.output) |
||||
return 0 |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
raise SystemExit(main()) |
||||
|
||||
Loading…
Reference in new issue