From 1142ac53f6a019701f17e070eeb8e715cb6180e2 Mon Sep 17 00:00:00 2001 From: mojsior Date: Mon, 26 Jan 2026 06:13:37 +0100 Subject: [PATCH] Add location/durability/boss HP announcements - Mute proximity sound cues while inventory is open - Speak current dungeon+floor on entry and via L - Warn when equipped items are near breaking - Announce boss HP every 10% drop - Move Chat Log off L and migrate old bindings --- Source/diablo.cpp | 262 ++++++++++++++++++++++++++++--- Source/options.cpp | 35 +++-- Source/utils/proximity_audio.cpp | 4 + Translations/pl.po | 16 ++ 4 files changed, 286 insertions(+), 31 deletions(-) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 6a987a80c..2041eb7cd 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -1746,6 +1746,156 @@ void UpdatePlayerLowHpWarningSound() } #endif // NOSOUND +namespace { + +[[nodiscard]] bool IsBossMonsterForHpAnnouncement(const Monster &monster) +{ + return monster.isUnique() || monster.ai == MonsterAIID::Diablo; +} + +} // namespace + +void UpdateLowDurabilityWarnings() +{ + static std::array WarnedSeeds {}; + static std::array HasWarned {}; + + if (MyPlayer == nullptr) + return; + if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) + return; + + std::vector newlyLow; + newlyLow.reserve(NUM_INVLOC); + + for (int slot = 0; slot < NUM_INVLOC; ++slot) { + const Item &item = MyPlayer->InvBody[slot]; + if (item.isEmpty() || item._iMaxDur <= 0 || item._iMaxDur == DUR_INDESTRUCTIBLE || item._iDurability == DUR_INDESTRUCTIBLE) { + HasWarned[slot] = false; + continue; + } + + const int maxDur = item._iMaxDur; + const int durability = item._iDurability; + if (durability <= 0) { + HasWarned[slot] = false; + continue; + } + + int threshold = std::max(2, maxDur / 10); + threshold = std::clamp(threshold, 1, maxDur); + + const bool isLow = durability <= threshold; + if (!isLow) { + HasWarned[slot] = false; + continue; + } + + if (HasWarned[slot] && WarnedSeeds[slot] == item._iSeed) + continue; + + HasWarned[slot] = true; + WarnedSeeds[slot] = item._iSeed; + + const StringOrView name = item.getName(); + if (!name.empty()) + newlyLow.emplace_back(name.str().data(), name.str().size()); + } + + if (newlyLow.empty()) + return; + + // Add ordinal numbers for duplicates (e.g. two rings with the same name). + for (size_t i = 0; i < newlyLow.size(); ++i) { + int total = 0; + for (size_t j = 0; j < newlyLow.size(); ++j) { + if (newlyLow[j] == newlyLow[i]) + ++total; + } + if (total <= 1) + continue; + + int ordinal = 1; + for (size_t j = 0; j < i; ++j) { + if (newlyLow[j] == newlyLow[i]) + ++ordinal; + } + newlyLow[i] = fmt::format("{} {}", newlyLow[i], ordinal); + } + + std::string joined; + for (size_t i = 0; i < newlyLow.size(); ++i) { + if (i != 0) + joined += ", "; + joined += newlyLow[i]; + } + + SpeakText(fmt::format(fmt::runtime(_("Low durability: {:s}")), joined), /*force=*/true); +} + +void UpdateBossHealthAnnouncements() +{ + static dungeon_type LastLevelType = DTYPE_NONE; + static int LastCurrLevel = -1; + static bool LastSetLevel = false; + static _setlevels LastSetLevelNum = SL_NONE; + static std::array LastAnnouncedBucket {}; + + if (MyPlayer == nullptr) + return; + if (leveltype == DTYPE_TOWN) + return; + + const bool levelChanged = LastLevelType != leveltype || LastCurrLevel != currlevel || LastSetLevel != setlevel || LastSetLevelNum != setlvlnum; + if (levelChanged) { + LastAnnouncedBucket.fill(-1); + LastLevelType = leveltype; + LastCurrLevel = currlevel; + LastSetLevel = setlevel; + LastSetLevelNum = setlvlnum; + } + + for (size_t monsterId = 0; monsterId < MaxMonsters; ++monsterId) { + if (LastAnnouncedBucket[monsterId] < 0) + continue; + + const Monster &monster = Monsters[monsterId]; + if (monster.isInvalid || monster.hitPoints <= 0 || !IsBossMonsterForHpAnnouncement(monster)) + LastAnnouncedBucket[monsterId] = -1; + } + + 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 (!IsBossMonsterForHpAnnouncement(monster)) + continue; + if (monster.hitPoints <= 0 || monster.maxHitPoints <= 0) + continue; + + const int64_t hp = std::clamp(monster.hitPoints, 0, monster.maxHitPoints); + const int64_t maxHp = monster.maxHitPoints; + const int hpPercent = static_cast(std::clamp(hp * 100 / maxHp, 0, 100)); + const int bucket = ((hpPercent + 9) / 10) * 10; + + int8_t &lastBucket = LastAnnouncedBucket[monsterId]; + if (lastBucket < 0) { + lastBucket = static_cast(((hpPercent + 9) / 10) * 10); + continue; + } + + if (bucket >= lastBucket) + continue; + + lastBucket = static_cast(bucket); + SpeakText(fmt::format(fmt::runtime(_("{:s} health: {:d}%")), monster.name(), bucket), /*force=*/false); + } +} + void GameLogic() { if (!ProcessInput()) { @@ -1756,11 +1906,12 @@ void GameLogic() ProcessPlayers(); UpdateAutoWalkTownNpc(); UpdateAutoWalkTracker(); + UpdateLowDurabilityWarnings(); } if (leveltype != DTYPE_TOWN) { - gGameLogicStep = GameLogicStep::ProcessMonsters; -#ifdef _DEBUG - if (!DebugInvisible) + gGameLogicStep = GameLogicStep::ProcessMonsters; +#ifdef _DEBUG + if (!DebugInvisible) #endif ProcessMonsters(); gGameLogicStep = GameLogicStep::ProcessObjects; @@ -1771,6 +1922,7 @@ void GameLogic() ProcessItems(); ProcessLightList(); ProcessVisionList(); + UpdateBossHealthAnnouncements(); UpdateProximityAudioCues(); } else { gGameLogicStep = GameLogicStep::ProcessTowners; @@ -4049,6 +4201,66 @@ void SpeakExperienceToNextLevelKeyPressed() /*force=*/true); } +namespace { +std::string BuildCurrentLocationForSpeech() +{ + // Quest Level Name + if (setlevel) { + const char *const questLevelName = QuestLevelNames[setlvlnum]; + if (questLevelName == nullptr || questLevelName[0] == '\0') + return std::string { _("Set level") }; + + return fmt::format("{:s}: {:s}", _("Set level"), _(questLevelName)); + } + + // Dungeon Name + constexpr std::array DungeonStrs = { + N_("Town"), + N_("Cathedral"), + N_("Catacombs"), + N_("Caves"), + N_("Hell"), + N_("Nest"), + N_("Crypt"), + }; + std::string dungeonStr; + if (leveltype >= DTYPE_TOWN && leveltype <= DTYPE_LAST) { + dungeonStr = _(DungeonStrs[static_cast(leveltype)]); + } else { + dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None"); + } + + if (leveltype == DTYPE_TOWN || currlevel <= 0) + return dungeonStr; + + // Dungeon Level + int level = currlevel; + if (leveltype == DTYPE_CATACOMBS) + level -= 4; + else if (leveltype == DTYPE_CAVES) + level -= 8; + else if (leveltype == DTYPE_HELL) + level -= 12; + else if (leveltype == DTYPE_NEST) + level -= 16; + else if (leveltype == DTYPE_CRYPT) + level -= 20; + + if (level <= 0) + return dungeonStr; + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: dungeon type and floor number i.e. "Cathedral 3"*/ "{} {}")), dungeonStr, level); +} +} // namespace + +void SpeakCurrentLocationKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + + SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); +} + void InventoryKeyPressed() { if (IsPlayerInStore()) @@ -4682,18 +4894,26 @@ void InitKeymapActions() }, nullptr, CanPlayerTakeAction); - options.Keymapper.AddAction( - "ChatLog", - N_("Chat Log"), - N_("Displays chat log."), - 'L', - [] { - ToggleChatLog(); - }); - options.Keymapper.AddAction( - "SortInv", - N_("Sort Inventory"), - N_("Sorts the inventory."), + options.Keymapper.AddAction( + "ChatLog", + N_("Chat Log"), + N_("Displays chat log."), + SDLK_INSERT, + [] { + ToggleChatLog(); + }); + options.Keymapper.AddAction( + "SpeakCurrentLocation", + N_("Location"), + N_("Speaks the current dungeon and floor."), + 'L', + SpeakCurrentLocationKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SortInv", + N_("Sort Inventory"), + N_("Sorts the inventory."), 'R', [] { ReorganizeInventory(*MyPlayer); @@ -6026,11 +6246,13 @@ tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir) #endif LoadGameLevelStartMusic(neededTrack); - CompleteProgress(); - - LoadGameLevelCalculateCursor(); - return {}; -} + CompleteProgress(); + + LoadGameLevelCalculateCursor(); + if (leveltype != DTYPE_TOWN) + SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); + return {}; +} bool game_loop(bool bStartup) { diff --git a/Source/options.cpp b/Source/options.cpp index 2081209d4..ae5b2cabf 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -1194,6 +1194,14 @@ void KeymapperOptions::Action::LoadFromIni(std::string_view category) const std::string_view iniValue = iniValues.back().value; if (iniValue.empty()) { + if (key == "SpeakCurrentLocation") { + const std::span chatLogValues = ini->get(category, "ChatLog"); + if (!chatLogValues.empty() && chatLogValues.back().value == "L") { + SetValue(defaultKey); + return; + } + } + // Migration: some actions were previously saved as unbound because their default // keys were not supported by the keymapper. If we see an explicit empty mapping // for these actions, treat it as "use default". @@ -1207,17 +1215,22 @@ void KeymapperOptions::Action::LoadFromIni(std::string_view category) } auto keyIt = GetOptions().Keymapper.keyNameToKeyID.find(iniValue); - if (keyIt == GetOptions().Keymapper.keyNameToKeyID.end()) { - // Use the default key if the key is unknown. - Log("Keymapper: unknown key '{}'", iniValue); - SetValue(defaultKey); - return; - } - - // Store the key in action.key and in the map so we can save() the - // actions while keeping the same order as they have been added. - SetValue(keyIt->second); -} + if (keyIt == GetOptions().Keymapper.keyNameToKeyID.end()) { + // Use the default key if the key is unknown. + Log("Keymapper: unknown key '{}'", iniValue); + SetValue(defaultKey); + return; + } + + if (key == "ChatLog" && iniValue == "L") { + SetValue(defaultKey); + return; + } + + // Store the key in action.key and in the map so we can save() the + // actions while keeping the same order as they have been added. + SetValue(keyIt->second); +} void KeymapperOptions::Action::SaveToIni(std::string_view category) const { if (boundKey == SDLK_UNKNOWN) { diff --git a/Source/utils/proximity_audio.cpp b/Source/utils/proximity_audio.cpp index b310b3dce..1d1f0969b 100644 --- a/Source/utils/proximity_audio.cpp +++ b/Source/utils/proximity_audio.cpp @@ -290,6 +290,10 @@ void UpdateProximityAudioCues() return; if (InGameMenu()) return; + if (invflag) { + SoundPool::Get().UpdateEmitters({}, SDL_GetTicks()); + return; + } SoundPool &pool = SoundPool::Get(); EnsureNavigationSoundsLoaded(pool); diff --git a/Translations/pl.po b/Translations/pl.po index 1c702fe5b..eee778696 100644 --- a/Translations/pl.po +++ b/Translations/pl.po @@ -6496,6 +6496,22 @@ msgstr "EXP do poziomu" msgid "Speaks how much experience remains to reach the next level." msgstr "Czyta, ile doświadczenia brakuje do następnego poziomu." +#: Source/diablo.cpp +msgid "Location" +msgstr "Lokalizacja" + +#: Source/diablo.cpp +msgid "Speaks the current dungeon and floor." +msgstr "Czyta nazwę lochu i numer poziomu." + +#: Source/diablo.cpp +msgid "Low durability: {:s}" +msgstr "Niska trwałość: {:s}" + +#: Source/diablo.cpp +msgid "{:s} health: {:d}%" +msgstr "{:s} zdrowie: {:d}%" + #: Source/diablo.cpp msgid "Max level." msgstr "Maksymalny poziom."