From a0ce2845a412a1d910c243df5daf3ed794fdeca1 Mon Sep 17 00:00:00 2001 From: mojsior Date: Sat, 24 Jan 2026 14:39:10 +0100 Subject: [PATCH] Add low HP warning sound and speak tracker target names --- README.md | 25 +++++++ Source/diablo.cpp | 164 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 169 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9197b559c..5771052b6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,31 @@ Upstream project: https://github.com/diasurgical/devilutionX For a full list of changes, see our [changelog](docs/CHANGELOG.md). +# Features + +- Screen reader / TTS integration (Windows: NVDA/JAWS via Tolk; Linux: speech-dispatcher). +- Proximity audio cues (items, monsters, doors/chests, interactable targets). +- Spoken announcements for many UI elements and interactions. +- Tracker navigation that speaks the tracked target and directions to reach it. +- Low HP warning sound that starts at 50% HP and speeds up every 10% down to 0% (stops on death). + +# Keybinds (defaults) + +Keybinds are configurable, but these are the defaults most players will use: + +- `T` - cycle tracker target (items / chests / monsters). +- `N` - tracker directions to the nearest target (speaks target name + directions). +- `Shift`+`N` - retarget the tracker. +- `Ctrl`+`N` - clear the tracker target. +- `H` - speak nearest unexplored space. +- `E` - speak nearest exit. +- `,` - speak nearest stairs up. +- `.` - speak nearest stairs down. +- `F4` - list town NPCs. +- `PageUp` / `PageDown` - select previous / next town NPC. +- `End` - speak selected town NPC. +- `Home` - walk to selected town NPC (town only). + # How to Install Note: You'll need access to the data from the original game. If you don't have an original CD, you can [buy Diablo from GoG.com](https://www.gog.com/game/diablo) or Battle.net. Alternatively, you can use `spawn.mpq` from the [shareware](https://github.com/diasurgical/devilutionx-assets/releases/latest/download/spawn.mpq) [[2]](http://ftp.blizzard.com/pub/demos/diablosw.exe) version, in place of `DIABDAT.MPQ`, to play the shareware portion of the game. diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 0ae6104af..6749fb774 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -1604,11 +1604,11 @@ void UnstuckChargers() } } -void UpdateMonsterLights() -{ - for (size_t i = 0; i < ActiveMonsterCount; i++) { - Monster &monster = Monsters[ActiveMonsters[i]]; - +void UpdateMonsterLights() +{ + for (size_t i = 0; i < ActiveMonsterCount; i++) { + Monster &monster = Monsters[ActiveMonsters[i]]; + if ((monster.flags & MFLAG_BERSERK) != 0) { const int lightRadius = leveltype == DTYPE_NEST ? 9 : 3; monster.lightId = AddLight(monster.position.tile, lightRadius); @@ -1625,14 +1625,129 @@ void UpdateMonsterLights() ChangeLightXY(monster.lightId, monster.position.tile); } } - } -} - -void GameLogic() -{ - if (!ProcessInput()) { - return; - } + } +} + +#ifdef NOSOUND +void UpdatePlayerLowHpWarningSound() +{ +} +#else +namespace { + +std::unique_ptr PlayerLowHpWarningSound; +bool TriedLoadingPlayerLowHpWarningSound = false; + +TSnd *GetPlayerLowHpWarningSound() +{ + if (TriedLoadingPlayerLowHpWarningSound) + return PlayerLowHpWarningSound.get(); + TriedLoadingPlayerLowHpWarningSound = true; + + if (!gbSndInited) + return nullptr; + + PlayerLowHpWarningSound = std::make_unique(); + PlayerLowHpWarningSound->start_tc = SDL_GetTicks() - 80 - 1; + + // Support both the new "playerhaslowhp" name and the older underscore version. + if (PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\playerhaslowhp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\playerhaslowhp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\player_has_low_hp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\player_has_low_hp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\playerhaslowhp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\playerhaslowhp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\player_has_low_hp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\player_has_low_hp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\playerhaslowhp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\playerhaslowhp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\player_has_low_hp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\player_has_low_hp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0) { + PlayerLowHpWarningSound = nullptr; + } + + return PlayerLowHpWarningSound.get(); +} + +void StopPlayerLowHpWarningSound() +{ + if (PlayerLowHpWarningSound != nullptr) + PlayerLowHpWarningSound->DSB.Stop(); +} + +[[nodiscard]] uint32_t LowHpIntervalMs(int hpPercent) +{ + // The sound starts at 50% HP (slow) and speeds up every 10% down to 0%. + if (hpPercent > 40) + return 1500; + if (hpPercent > 30) + return 1200; + if (hpPercent > 20) + return 900; + if (hpPercent > 10) + return 600; + return 300; +} + +} // namespace + +void UpdatePlayerLowHpWarningSound() +{ + static uint32_t LastWarningStartMs = 0; + + if (!gbSndInited || !gbSoundOn || MyPlayer == nullptr || InGameMenu()) { + StopPlayerLowHpWarningSound(); + LastWarningStartMs = 0; + return; + } + + // Stop immediately when dead. + if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) { + StopPlayerLowHpWarningSound(); + LastWarningStartMs = 0; + return; + } + + const int maxHp = MyPlayer->_pMaxHP; + if (maxHp <= 0) { + StopPlayerLowHpWarningSound(); + LastWarningStartMs = 0; + return; + } + + const int hp = std::clamp(MyPlayer->_pHitPoints, 0, maxHp); + const int hpPercent = std::clamp(hp * 100 / maxHp, 0, 100); + + // Only play below (or equal to) 50% and above 0%. + if (hpPercent > 50 || hpPercent <= 0) { + StopPlayerLowHpWarningSound(); + LastWarningStartMs = 0; + return; + } + + TSnd *snd = GetPlayerLowHpWarningSound(); + if (snd == nullptr || !snd->DSB.IsLoaded()) + return; + + const uint32_t now = SDL_GetTicks(); + const uint32_t intervalMs = LowHpIntervalMs(hpPercent); + if (LastWarningStartMs == 0) + LastWarningStartMs = now - intervalMs; + if (now - LastWarningStartMs < intervalMs) + return; + + // Restart the cue even if it's already playing so the "tempo" is controlled by HP. + snd->DSB.Stop(); + snd_play_snd(snd, /*lVolume=*/0, /*lPan=*/0); + LastWarningStartMs = now; +} +#endif // NOSOUND + +void GameLogic() +{ + if (!ProcessInput()) { + return; + } if (gbProcessPlayers) { gGameLogicStep = GameLogicStep::ProcessPlayers; ProcessPlayers(); @@ -1659,13 +1774,16 @@ void GameLogic() ProcessTowners(); gGameLogicStep = GameLogicStep::ProcessItemsTown; ProcessItems(); - gGameLogicStep = GameLogicStep::ProcessMissilesTown; - ProcessMissiles(); - } - gGameLogicStep = GameLogicStep::None; - -#ifdef _DEBUG - if (DebugScrollViewEnabled && (SDL_GetModState() & SDL_KMOD_SHIFT) != 0) { + gGameLogicStep = GameLogicStep::ProcessMissilesTown; + ProcessMissiles(); + } + + UpdatePlayerLowHpWarningSound(); + + gGameLogicStep = GameLogicStep::None; + +#ifdef _DEBUG + if (DebugScrollViewEnabled && (SDL_GetModState() & SDL_KMOD_SHIFT) != 0) { ScrollView(); } #endif @@ -2339,6 +2457,7 @@ void NavigateToTrackerTargetKeyPressed() std::optional targetId; std::optional targetPosition; + StringOrView targetName; switch (SelectedTrackerTargetCategory) { case TrackerTargetCategory::Items: { @@ -2361,6 +2480,7 @@ void NavigateToTrackerTargetKeyPressed() lockedTargetId = *targetId; const Item &tracked = Items[*targetId]; + targetName = tracked.getName(); targetPosition = tracked.position; break; } @@ -2388,6 +2508,7 @@ void NavigateToTrackerTargetKeyPressed() lockedTargetId = *targetId; const Object &tracked = Objects[*targetId]; + targetName = tracked.name(); targetPosition = FindBestAdjacentApproachTile(*MyPlayer, playerPosition, tracked.position); if (!targetPosition) { SpeakText(_("Can't find a nearby tile to walk to."), true); @@ -2420,6 +2541,7 @@ void NavigateToTrackerTargetKeyPressed() lockedTargetId = *targetId; const Monster &tracked = Monsters[*targetId]; + targetName = tracked.name(); const Point monsterPosition { tracked.position.tile }; targetPosition = FindBestAdjacentApproachTile(*MyPlayer, playerPosition, monsterPosition); if (!targetPosition) { @@ -2436,6 +2558,8 @@ void NavigateToTrackerTargetKeyPressed() const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, playerPosition, *targetPosition); std::string message; + if (!targetName.empty()) + StrAppend(message, targetName, "\n"); if (!path) { AppendDirectionalFallback(message, *targetPosition - playerPosition); } else {