From 89f8d5e3e8f7afa68cfc02be83598c9b74e3340c Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:17:24 -0500 Subject: [PATCH 01/13] access: extract accessibility subsystems into dedicated modules Move ~4,100 lines of accessibility code from diablo.cpp into 6 new modules, reducing diablo.cpp from ~7,700 to ~3,660 lines. New modules: - controls/accessibility_keys: UI key handlers and guard functions (IsPlayerDead, IsGameRunning, CanPlayerTakeAction, etc.) - utils/walk_path_speech: BFS pathfinding, PosOk variants, walk direction helpers, and path-to-speech formatting - utils/accessibility_announcements: periodic announcements for low HP warning sound, durability, boss health, monsters, doors - controls/town_npc_nav: town NPC selection cycling and auto-walk - utils/navigation_speech: exit/stairs/portal/unexplored speech and keyboard walk key handlers - controls/tracker: target category cycling, candidate finding, pathfinding navigation, and auto-walk tracker Co-Authored-By: Claude Opus 4.5 --- Source/controls/accessibility_keys.cpp | 313 ++++ Source/controls/accessibility_keys.hpp | 30 + Source/controls/town_npc_nav.cpp | 332 ++++ Source/controls/town_npc_nav.hpp | 19 + Source/controls/tracker.cpp | 1748 ++++++++++++++++++ Source/controls/tracker.hpp | 31 + Source/utils/accessibility_announcements.cpp | 489 +++++ Source/utils/accessibility_announcements.hpp | 22 + Source/utils/navigation_speech.cpp | 685 +++++++ Source/utils/navigation_speech.hpp | 21 + Source/utils/walk_path_speech.cpp | 604 ++++++ Source/utils/walk_path_speech.hpp | 44 + 12 files changed, 4338 insertions(+) create mode 100644 Source/controls/accessibility_keys.cpp create mode 100644 Source/controls/accessibility_keys.hpp create mode 100644 Source/controls/town_npc_nav.cpp create mode 100644 Source/controls/town_npc_nav.hpp create mode 100644 Source/controls/tracker.cpp create mode 100644 Source/controls/tracker.hpp create mode 100644 Source/utils/accessibility_announcements.cpp create mode 100644 Source/utils/accessibility_announcements.hpp create mode 100644 Source/utils/navigation_speech.cpp create mode 100644 Source/utils/navigation_speech.hpp create mode 100644 Source/utils/walk_path_speech.cpp create mode 100644 Source/utils/walk_path_speech.hpp diff --git a/Source/controls/accessibility_keys.cpp b/Source/controls/accessibility_keys.cpp new file mode 100644 index 000000000..39dd716ef --- /dev/null +++ b/Source/controls/accessibility_keys.cpp @@ -0,0 +1,313 @@ +/** + * @file controls/accessibility_keys.cpp + * + * UI accessibility key handlers and action-guard helpers. + */ +#include "controls/accessibility_keys.hpp" + +#include +#include +#include +#include + +#include + +#include "control/control.hpp" +#include "controls/plrctrls.h" +#include "diablo.h" +#include "gamemenu.h" +#include "help.h" +#include "inv.h" +#include "levels/gendung.h" +#include "levels/setmaps.h" +#include "minitext.h" +#include "options.h" +#include "panels/charpanel.hpp" +#include "panels/partypanel.hpp" +#include "panels/spell_book.hpp" +#include "panels/spell_list.hpp" +#include "player.h" +#include "qol/chatlog.h" +#include "qol/stash.h" +#include "quests.h" +#include "stores.h" +#include "utils/format_int.hpp" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" + +namespace devilution { + +void SpeakPlayerHealthPercentageKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (MyPlayer == nullptr) + return; + + const int maxHp = MyPlayer->_pMaxHP; + if (maxHp <= 0) + return; + + const int currentHp = std::max(MyPlayer->_pHitPoints, 0); + int hpPercent = static_cast((static_cast(currentHp) * 100 + maxHp / 2) / maxHp); + hpPercent = std::clamp(hpPercent, 0, 100); + SpeakText(fmt::format("{:d}%", hpPercent), /*force=*/true); +} + +void SpeakExperienceToNextLevelKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (MyPlayer == nullptr) + return; + + const Player &myPlayer = *MyPlayer; + if (myPlayer.isMaxCharacterLevel()) { + SpeakText(_("Max level."), /*force=*/true); + return; + } + + const uint32_t nextExperienceThreshold = myPlayer.getNextExperienceThreshold(); + const uint32_t currentExperience = myPlayer._pExperience; + const uint32_t remainingExperience = currentExperience >= nextExperienceThreshold ? 0 : nextExperienceThreshold - currentExperience; + const int nextLevel = myPlayer.getCharacterLevel() + 1; + SpeakText( + fmt::format(fmt::runtime(_("{:s} to Level {:d}")), FormatInteger(remainingExperience), nextLevel), + /*force=*/true); +} + +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); +} + +void SpeakCurrentLocationKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + + SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); +} + +void InventoryKeyPressed() +{ + if (IsPlayerInStore()) + return; + invflag = !invflag; + if (!IsLeftPanelOpen() && CanPanelsCoverView()) { + if (!invflag) { // We closed the inventory + if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); + } + } else if (!SpellbookFlag) { // We opened the inventory + if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition - Displacement { 160, 0 }); + } + } + } + SpellbookFlag = false; + CloseGoldWithdraw(); + CloseStash(); + if (invflag) + FocusOnInventory(); +} + +void CharacterSheetKeyPressed() +{ + if (IsPlayerInStore()) + return; + if (!IsRightPanelOpen() && CanPanelsCoverView()) { + if (CharFlag) { // We are closing the character sheet + if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition - Displacement { 160, 0 }); + } + } else if (!QuestLogIsOpen) { // We opened the character sheet + if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); + } + } + } + ToggleCharPanel(); +} + +void PartyPanelSideToggleKeyPressed() +{ + PartySidePanelOpen = !PartySidePanelOpen; +} + +void QuestLogKeyPressed() +{ + if (IsPlayerInStore()) + return; + if (!QuestLogIsOpen) { + StartQuestlog(); + } else { + QuestLogIsOpen = false; + } + if (!IsRightPanelOpen() && CanPanelsCoverView()) { + if (!QuestLogIsOpen) { // We closed the quest log + if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition - Displacement { 160, 0 }); + } + } else if (!CharFlag) { // We opened the character quest log + if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); + } + } + } + CloseCharPanel(); + CloseGoldWithdraw(); + CloseStash(); +} + +void SpeakSelectedSpeedbookSpell() +{ + for (const auto &spellListItem : GetSpellListItems()) { + if (spellListItem.isSelected) { + SpeakText(pgettext("spell", GetSpellData(spellListItem.id).sNameText), /*force=*/true); + return; + } + } + SpeakText(_("No spell selected."), /*force=*/true); +} + +void DisplaySpellsKeyPressed() +{ + if (IsPlayerInStore()) + return; + CloseCharPanel(); + QuestLogIsOpen = false; + CloseInventory(); + SpellbookFlag = false; + if (!SpellSelectFlag) { + DoSpeedBook(); + SpeakSelectedSpeedbookSpell(); + } else { + SpellSelectFlag = false; + } + LastPlayerAction = PlayerActionType::None; +} + +void SpellBookKeyPressed() +{ + if (IsPlayerInStore()) + return; + SpellbookFlag = !SpellbookFlag; + if (SpellbookFlag && MyPlayer != nullptr) { + const Player &player = *MyPlayer; + if (IsValidSpell(player._pRSpell)) { + SpeakText(pgettext("spell", GetSpellData(player._pRSpell).sNameText), /*force=*/true); + } else { + SpeakText(_("No spell selected."), /*force=*/true); + } + } + if (!IsLeftPanelOpen() && CanPanelsCoverView()) { + if (!SpellbookFlag) { // We closed the inventory + if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); + } + } else if (!invflag) { // We opened the inventory + if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition - Displacement { 160, 0 }); + } + } + } + CloseInventory(); +} + +void CycleSpellHotkeys(bool next) +{ + StaticVector validHotKeyIndexes; + std::optional currentIndex; + for (size_t slot = 0; slot < NumHotkeys; slot++) { + if (!IsValidSpeedSpell(slot)) + continue; + if (MyPlayer->_pRSpell == MyPlayer->_pSplHotKey[slot] && MyPlayer->_pRSplType == MyPlayer->_pSplTHotKey[slot]) { + // found current + currentIndex = validHotKeyIndexes.size(); + } + validHotKeyIndexes.emplace_back(slot); + } + if (validHotKeyIndexes.empty()) + return; + + size_t newIndex; + if (!currentIndex) { + newIndex = next ? 0 : (validHotKeyIndexes.size() - 1); + } else if (next) { + newIndex = (*currentIndex == validHotKeyIndexes.size() - 1) ? 0 : (*currentIndex + 1); + } else { + newIndex = *currentIndex == 0 ? (validHotKeyIndexes.size() - 1) : (*currentIndex - 1); + } + ToggleSpell(validHotKeyIndexes[newIndex]); +} + +bool IsPlayerDead() +{ + return MyPlayer->_pmode == PM_DEATH || MyPlayerIsDead; +} + +bool IsGameRunning() +{ + return PauseMode != 2; +} + +bool CanPlayerTakeAction() +{ + return !IsPlayerDead() && IsGameRunning(); +} + +bool CanAutomapBeToggledOff() +{ + return !QuestLogIsOpen && !IsWithdrawGoldOpen && !IsStashOpen && !CharFlag + && !SpellbookFlag && !invflag && !isGameMenuOpen && !qtextflag && !SpellSelectFlag + && !ChatLogFlag && !HelpFlag; +} + +} // namespace devilution diff --git a/Source/controls/accessibility_keys.hpp b/Source/controls/accessibility_keys.hpp new file mode 100644 index 000000000..694a438db --- /dev/null +++ b/Source/controls/accessibility_keys.hpp @@ -0,0 +1,30 @@ +/** + * @file controls/accessibility_keys.hpp + * + * UI accessibility key handlers and action-guard helpers. + */ +#pragma once + +#include + +namespace devilution { + +void SpeakPlayerHealthPercentageKeyPressed(); +void SpeakExperienceToNextLevelKeyPressed(); +std::string BuildCurrentLocationForSpeech(); +void SpeakCurrentLocationKeyPressed(); +void InventoryKeyPressed(); +void CharacterSheetKeyPressed(); +void PartyPanelSideToggleKeyPressed(); +void QuestLogKeyPressed(); +void SpeakSelectedSpeedbookSpell(); +void DisplaySpellsKeyPressed(); +void SpellBookKeyPressed(); +void CycleSpellHotkeys(bool next); + +bool IsPlayerDead(); +bool IsGameRunning(); +bool CanPlayerTakeAction(); +bool CanAutomapBeToggledOff(); + +} // namespace devilution diff --git a/Source/controls/town_npc_nav.cpp b/Source/controls/town_npc_nav.cpp new file mode 100644 index 000000000..c7b50d065 --- /dev/null +++ b/Source/controls/town_npc_nav.cpp @@ -0,0 +1,332 @@ +/** + * @file controls/town_npc_nav.cpp + * + * Town NPC navigation for accessibility. + */ +#include "controls/town_npc_nav.hpp" + +#include +#include +#include +#include +#include + +#include + +#include "controls/accessibility_keys.hpp" +#include "controls/plrctrls.h" +#include "diablo.h" +#include "engine/path.h" +#include "help.h" +#include "levels/gendung.h" +#include "levels/tile_properties.hpp" +#include "multi.h" +#include "options.h" +#include "player.h" +#include "qol/chatlog.h" +#include "stores.h" +#include "towners.h" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" +#include "utils/walk_path_speech.hpp" + +namespace devilution { + +namespace { + +std::vector TownNpcOrder; +int SelectedTownNpc = -1; +int AutoWalkTownNpcTarget = -1; + +void ResetTownNpcSelection() +{ + TownNpcOrder.clear(); + SelectedTownNpc = -1; +} + +void RefreshTownNpcOrder(bool selectFirst = false) +{ + TownNpcOrder.clear(); + if (leveltype != DTYPE_TOWN) + return; + + const Point playerPosition = MyPlayer->position.future; + + for (size_t i = 0; i < GetNumTowners(); ++i) { + const Towner &towner = Towners[i]; + if (!IsTownerPresent(towner._ttype)) + continue; + if (towner._ttype == TOWN_COW) + continue; + TownNpcOrder.push_back(static_cast(i)); + } + + if (TownNpcOrder.empty()) { + SelectedTownNpc = -1; + return; + } + + std::sort(TownNpcOrder.begin(), TownNpcOrder.end(), [&playerPosition](int a, int b) { + const Towner &townerA = Towners[a]; + const Towner &townerB = Towners[b]; + const int distanceA = playerPosition.WalkingDistance(townerA.position); + const int distanceB = playerPosition.WalkingDistance(townerB.position); + if (distanceA != distanceB) + return distanceA < distanceB; + return townerA.name < townerB.name; + }); + + if (selectFirst) { + SelectedTownNpc = TownNpcOrder.front(); + return; + } + + const auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); + if (it == TownNpcOrder.end()) + SelectedTownNpc = TownNpcOrder.front(); +} + +void EnsureTownNpcOrder() +{ + if (leveltype != DTYPE_TOWN) { + ResetTownNpcSelection(); + return; + } + if (TownNpcOrder.empty()) { + RefreshTownNpcOrder(true); + return; + } + if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { + RefreshTownNpcOrder(true); + return; + } + const auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); + if (it == TownNpcOrder.end()) + SelectedTownNpc = TownNpcOrder.front(); +} + +void SelectTownNpcRelative(int delta) +{ + if (!IsTownNpcActionAllowed()) + return; + + EnsureTownNpcOrder(); + if (TownNpcOrder.empty()) { + SpeakText(_("No town NPCs found."), true); + return; + } + + auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); + int currentIndex = (it != TownNpcOrder.end()) ? static_cast(it - TownNpcOrder.begin()) : 0; + + const int size = static_cast(TownNpcOrder.size()); + int newIndex = (currentIndex + delta) % size; + if (newIndex < 0) + newIndex += size; + SelectedTownNpc = TownNpcOrder[static_cast(newIndex)]; + SpeakSelectedTownNpc(); +} + +} // namespace + +bool IsTownNpcActionAllowed() +{ + return CanPlayerTakeAction() + && leveltype == DTYPE_TOWN + && !IsPlayerInStore() + && !ChatLogFlag + && !HelpFlag; +} + +void SpeakSelectedTownNpc() +{ + EnsureTownNpcOrder(); + + if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { + SpeakText(_("No NPC selected."), true); + return; + } + + const Towner &towner = Towners[SelectedTownNpc]; + const Point playerPosition = MyPlayer->position.future; + const int distance = playerPosition.WalkingDistance(towner.position); + + std::string msg; + StrAppend(msg, towner.name); + StrAppend(msg, "\n", _("Distance: "), distance); + StrAppend(msg, "\n", _("Position: "), towner.position.x, ", ", towner.position.y); + SpeakText(msg, true); +} + +void SelectNextTownNpcKeyPressed() +{ + SelectTownNpcRelative(+1); +} + +void SelectPreviousTownNpcKeyPressed() +{ + SelectTownNpcRelative(-1); +} + +void GoToSelectedTownNpcKeyPressed() +{ + if (!IsTownNpcActionAllowed()) + return; + + EnsureTownNpcOrder(); + if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { + SpeakText(_("No NPC selected."), true); + return; + } + + const Towner &towner = Towners[SelectedTownNpc]; + + std::string msg; + StrAppend(msg, _("Going to: "), towner.name); + SpeakText(msg, true); + + AutoWalkTownNpcTarget = SelectedTownNpc; + UpdateAutoWalkTownNpc(); +} + +void UpdateAutoWalkTownNpc() +{ + if (AutoWalkTownNpcTarget < 0) + return; + if (leveltype != DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag) { + AutoWalkTownNpcTarget = -1; + return; + } + if (!CanPlayerTakeAction()) + return; + + if (MyPlayer->_pmode != PM_STAND) + return; + if (MyPlayer->walkpath[0] != WALK_NONE) + return; + if (MyPlayer->destAction != ACTION_NONE) + return; + + if (AutoWalkTownNpcTarget >= static_cast(GetNumTowners())) { + AutoWalkTownNpcTarget = -1; + SpeakText(_("No NPC selected."), true); + return; + } + + const Towner &towner = Towners[AutoWalkTownNpcTarget]; + if (!IsTownerPresent(towner._ttype) || towner._ttype == TOWN_COW) { + AutoWalkTownNpcTarget = -1; + SpeakText(_("No NPC selected."), true); + return; + } + + Player &myPlayer = *MyPlayer; + const Point playerPosition = myPlayer.position.future; + if (playerPosition.WalkingDistance(towner.position) < 2) { + const int townerIdx = AutoWalkTownNpcTarget; + AutoWalkTownNpcTarget = -1; + NetSendCmdLocParam1(true, CMD_TALKXY, towner.position, static_cast(townerIdx)); + return; + } + + constexpr size_t MaxAutoWalkPathLength = 512; + std::array path; + path.fill(WALK_NONE); + + const int steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, towner.position, path.data(), path.size()); + if (steps == 0) { + AutoWalkTownNpcTarget = -1; + std::string error; + StrAppend(error, _("Can't find a path to: "), towner.name); + SpeakText(error, true); + return; + } + + // FindPath returns 0 if the path length is equal to the maximum. + // The player walkpath buffer is MaxPathLengthPlayer, so keep segments strictly shorter. + if (steps < static_cast(MaxPathLengthPlayer)) { + const int townerIdx = AutoWalkTownNpcTarget; + AutoWalkTownNpcTarget = -1; + NetSendCmdLocParam1(true, CMD_TALKXY, towner.position, static_cast(townerIdx)); + return; + } + + const int segmentSteps = std::min(steps - 1, static_cast(MaxPathLengthPlayer - 1)); + const Point waypoint = PositionAfterWalkPathSteps(playerPosition, path.data(), segmentSteps); + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, waypoint); +} + +void ResetAutoWalkTownNpc() +{ + AutoWalkTownNpcTarget = -1; +} + +void ListTownNpcsKeyPressed() +{ + if (leveltype != DTYPE_TOWN) { + ResetTownNpcSelection(); + SpeakText(_("Not in town."), true); + return; + } + if (IsPlayerInStore()) + return; + + std::vector townNpcs; + std::vector cows; + + townNpcs.reserve(Towners.size()); + cows.reserve(Towners.size()); + + const Point playerPosition = MyPlayer->position.future; + + for (const Towner &towner : Towners) { + if (!IsTownerPresent(towner._ttype)) + continue; + + if (towner._ttype == TOWN_COW) { + cows.push_back(&towner); + continue; + } + + townNpcs.push_back(&towner); + } + + if (townNpcs.empty() && cows.empty()) { + ResetTownNpcSelection(); + SpeakText(_("No town NPCs found."), true); + return; + } + + std::sort(townNpcs.begin(), townNpcs.end(), [&playerPosition](const Towner *a, const Towner *b) { + const int distanceA = playerPosition.WalkingDistance(a->position); + const int distanceB = playerPosition.WalkingDistance(b->position); + if (distanceA != distanceB) + return distanceA < distanceB; + return a->name < b->name; + }); + + std::string output; + StrAppend(output, _("Town NPCs:")); + for (size_t i = 0; i < townNpcs.size(); ++i) { + StrAppend(output, "\n", i + 1, ". ", townNpcs[i]->name); + } + if (!cows.empty()) { + StrAppend(output, "\n", _("Cows: "), static_cast(cows.size())); + } + + RefreshTownNpcOrder(true); + if (SelectedTownNpc >= 0 && SelectedTownNpc < static_cast(GetNumTowners())) { + const Towner &towner = Towners[SelectedTownNpc]; + StrAppend(output, "\n", _("Selected: "), towner.name); + StrAppend(output, "\n", _("PageUp/PageDown: select. Home: go. End: repeat.")); + } + const std::string_view exitKey = GetOptions().Keymapper.KeyNameForAction("SpeakNearestExit"); + if (!exitKey.empty()) { + StrAppend(output, "\n", fmt::format(fmt::runtime(_("Cathedral entrance: press {:s}.")), exitKey)); + } + + SpeakText(output, true); +} + +} // namespace devilution diff --git a/Source/controls/town_npc_nav.hpp b/Source/controls/town_npc_nav.hpp new file mode 100644 index 000000000..c282af802 --- /dev/null +++ b/Source/controls/town_npc_nav.hpp @@ -0,0 +1,19 @@ +/** + * @file controls/town_npc_nav.hpp + * + * Town NPC navigation for accessibility. + */ +#pragma once + +namespace devilution { + +void SelectNextTownNpcKeyPressed(); +void SelectPreviousTownNpcKeyPressed(); +void GoToSelectedTownNpcKeyPressed(); +void SpeakSelectedTownNpc(); +void ListTownNpcsKeyPressed(); +void UpdateAutoWalkTownNpc(); +bool IsTownNpcActionAllowed(); +void ResetAutoWalkTownNpc(); + +} // namespace devilution diff --git a/Source/controls/tracker.cpp b/Source/controls/tracker.cpp new file mode 100644 index 000000000..c983107fd --- /dev/null +++ b/Source/controls/tracker.cpp @@ -0,0 +1,1748 @@ +/** + * @file controls/tracker.cpp + * + * Tracker system for accessibility: target cycling, pathfinding, and auto-walk. + */ +#include "controls/tracker.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include "appfat.h" +#include "automap.h" +#include "controls/accessibility_keys.hpp" +#include "controls/plrctrls.h" +#include "diablo.h" +#include "engine/path.h" +#include "gamemenu.h" +#include "help.h" +#include "items.h" +#include "levels/gendung.h" +#include "levels/tile_properties.hpp" +#include "monster.h" +#include "multi.h" +#include "objects.h" +#include "player.h" +#include "qol/chatlog.h" +#include "stores.h" +#include "utils/accessibility_announcements.hpp" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/sdl_compat.h" +#include "utils/str_cat.hpp" +#include "utils/string_or_view.hpp" +#include "utils/walk_path_speech.hpp" + +namespace devilution { + +TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; + +namespace { + +TrackerTargetCategory AutoWalkTrackerTargetCategory = TrackerTargetCategory::Items; ///< Category of the active auto-walk target. +int AutoWalkTrackerTargetId = -1; ///< ID of the target being auto-walked to, or -1 if inactive. + +/// Maximum Chebyshev distance (in tiles) at which the player is considered +/// close enough to interact with a tracker target. +constexpr int TrackerInteractDistanceTiles = 1; +constexpr int TrackerCycleDistanceTiles = 12; + +int LockedTrackerItemId = -1; +int LockedTrackerChestId = -1; +int LockedTrackerDoorId = -1; +int LockedTrackerShrineId = -1; +int LockedTrackerObjectId = -1; +int LockedTrackerBreakableId = -1; +int LockedTrackerMonsterId = -1; +int LockedTrackerDeadBodyId = -1; + +struct TrackerLevelKey { + dungeon_type levelType; + int currLevel; + bool isSetLevel; + int setLevelNum; +}; + +std::optional LockedTrackerLevelKey; + +void ClearTrackerLocks() +{ + LockedTrackerItemId = -1; + LockedTrackerChestId = -1; + LockedTrackerDoorId = -1; + LockedTrackerShrineId = -1; + LockedTrackerObjectId = -1; + LockedTrackerBreakableId = -1; + LockedTrackerMonsterId = -1; + LockedTrackerDeadBodyId = -1; +} + +void EnsureTrackerLocksMatchCurrentLevel() +{ + const TrackerLevelKey current { + .levelType = leveltype, + .currLevel = currlevel, + .isSetLevel = setlevel, + .setLevelNum = setlvlnum, + }; + + if (!LockedTrackerLevelKey || LockedTrackerLevelKey->levelType != current.levelType || LockedTrackerLevelKey->currLevel != current.currLevel + || LockedTrackerLevelKey->isSetLevel != current.isSetLevel || LockedTrackerLevelKey->setLevelNum != current.setLevelNum) { + ClearTrackerLocks(); + LockedTrackerLevelKey = current; + } +} + +int &LockedTrackerTargetId(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return LockedTrackerItemId; + case TrackerTargetCategory::Chests: + return LockedTrackerChestId; + case TrackerTargetCategory::Doors: + return LockedTrackerDoorId; + case TrackerTargetCategory::Shrines: + return LockedTrackerShrineId; + case TrackerTargetCategory::Objects: + return LockedTrackerObjectId; + case TrackerTargetCategory::Breakables: + return LockedTrackerBreakableId; + case TrackerTargetCategory::Monsters: + return LockedTrackerMonsterId; + case TrackerTargetCategory::DeadBodies: + return LockedTrackerDeadBodyId; + } + app_fatal("Invalid TrackerTargetCategory"); +} + +std::string_view TrackerTargetCategoryLabel(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("items"); + case TrackerTargetCategory::Chests: + return _("chests"); + case TrackerTargetCategory::Doors: + return _("doors"); + case TrackerTargetCategory::Shrines: + return _("shrines"); + case TrackerTargetCategory::Objects: + return _("objects"); + case TrackerTargetCategory::Breakables: + return _("breakables"); + case TrackerTargetCategory::Monsters: + return _("monsters"); + case TrackerTargetCategory::DeadBodies: + return _("dead bodies"); + default: + return _("items"); + } +} + +void SpeakTrackerTargetCategory() +{ + std::string message; + StrAppend(message, _("Tracker target: "), TrackerTargetCategoryLabel(SelectedTrackerTargetCategory)); + SpeakText(message, true); +} + +std::optional FindNearestGroundItemId(Point playerPosition) +{ + std::optional bestId; + int bestDistance = 0; + + for (int y = 0; y < MAXDUNY; ++y) { + for (int x = 0; x < MAXDUNX; ++x) { + const int itemId = std::abs(dItem[x][y]) - 1; + if (itemId < 0 || itemId > MAXITEMS) + continue; + + const Item &item = Items[itemId]; + if (item.isEmpty() || item._iClass == ICLASS_NONE) + continue; + + const int distance = playerPosition.WalkingDistance(Point { x, y }); + if (!bestId || distance < bestDistance) { + bestId = itemId; + bestDistance = distance; + } + } + } + + return bestId; +} + +[[nodiscard]] constexpr int CorpseTrackerIdForPosition(Point position) +{ + return position.x + position.y * MAXDUNX; +} + +[[nodiscard]] constexpr Point CorpsePositionForTrackerId(int corpseId) +{ + return { corpseId % MAXDUNX, corpseId / MAXDUNX }; +} + +std::optional FindNearestCorpseId(Point playerPosition) +{ + std::optional bestId; + int bestDistance = 0; + + for (int y = 0; y < MAXDUNY; ++y) { + for (int x = 0; x < MAXDUNX; ++x) { + if (dCorpse[x][y] == 0) + continue; + + const Point position { x, y }; + const int distance = playerPosition.WalkingDistance(position); + if (!bestId || distance < bestDistance) { + bestId = CorpseTrackerIdForPosition(position); + bestDistance = distance; + } + } + } + + return bestId; +} + +struct TrackerCandidate { + int id; + int distance; + StringOrView name; +}; + +[[nodiscard]] bool IsBetterTrackerCandidate(const TrackerCandidate &a, const TrackerCandidate &b) +{ + if (a.distance != b.distance) + return a.distance < b.distance; + return a.id < b.id; +} + +[[nodiscard]] std::vector CollectNearbyItemTrackerCandidates(Point playerPosition, int maxDistance) +{ + std::vector result; + result.reserve(ActiveItemCount); + + const int minX = std::max(0, playerPosition.x - maxDistance); + const int minY = std::max(0, playerPosition.y - maxDistance); + const int maxX = std::min(MAXDUNX - 1, playerPosition.x + maxDistance); + const int maxY = std::min(MAXDUNY - 1, playerPosition.y + maxDistance); + + std::array seen {}; + + for (int y = minY; y <= maxY; ++y) { + for (int x = minX; x <= maxX; ++x) { + const int itemId = std::abs(dItem[x][y]) - 1; + if (itemId < 0 || itemId > MAXITEMS) + continue; + if (seen[itemId]) + continue; + seen[itemId] = true; + + const Item &item = Items[itemId]; + if (item.isEmpty() || item._iClass == ICLASS_NONE) + continue; + + const int distance = playerPosition.WalkingDistance(Point { x, y }); + if (distance > maxDistance) + continue; + + result.push_back(TrackerCandidate { + .id = itemId, + .distance = distance, + .name = item.getName(), + }); + } + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + return result; +} + +[[nodiscard]] std::vector CollectNearbyCorpseTrackerCandidates(Point playerPosition, int maxDistance) +{ + std::vector result; + + const int minX = std::max(0, playerPosition.x - maxDistance); + const int minY = std::max(0, playerPosition.y - maxDistance); + const int maxX = std::min(MAXDUNX - 1, playerPosition.x + maxDistance); + const int maxY = std::min(MAXDUNY - 1, playerPosition.y + maxDistance); + + for (int y = minY; y <= maxY; ++y) { + for (int x = minX; x <= maxX; ++x) { + if (dCorpse[x][y] == 0) + continue; + + const Point position { x, y }; + const int distance = playerPosition.WalkingDistance(position); + if (distance > maxDistance) + continue; + + result.push_back(TrackerCandidate { + .id = CorpseTrackerIdForPosition(position), + .distance = distance, + .name = _("Dead body"), + }); + } + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + return result; +} + +[[nodiscard]] constexpr bool IsTrackedChestObject(const Object &object) +{ + return object.canInteractWith() && (object.IsChest() || object._otype == _object_id::OBJ_SIGNCHEST); +} + +[[nodiscard]] constexpr bool IsTrackedDoorObject(const Object &object) +{ + return object.isDoor() && object.canInteractWith(); +} + +[[nodiscard]] constexpr bool IsShrineLikeObject(const Object &object) +{ + return object.canInteractWith() + && (object.IsShrine() + || IsAnyOf(object._otype, _object_id::OBJ_BLOODFTN, _object_id::OBJ_PURIFYINGFTN, _object_id::OBJ_GOATSHRINE, _object_id::OBJ_CAULDRON, + _object_id::OBJ_MURKYFTN, _object_id::OBJ_TEARFTN)); +} + +[[nodiscard]] constexpr bool IsTrackedBreakableObject(const Object &object) +{ + return object.IsBreakable(); +} + +[[nodiscard]] constexpr bool IsTrackedMiscInteractableObject(const Object &object) +{ + if (!object.canInteractWith()) + return false; + if (object.IsChest() || object._otype == _object_id::OBJ_SIGNCHEST) + return false; + if (object.isDoor()) + return false; + if (IsShrineLikeObject(object)) + return false; + if (object.IsBreakable()) + return false; + return true; +} + +[[nodiscard]] bool IsTrackedMonster(const Monster &monster) +{ + return !monster.isInvalid + && (monster.flags & MFLAG_HIDDEN) == 0 + && monster.hitPoints > 0; +} + +template +[[nodiscard]] std::vector CollectNearbyObjectTrackerCandidates(Point playerPosition, int maxDistance, Predicate predicate) +{ + std::vector result; + result.reserve(ActiveObjectCount); + + const int minX = std::max(0, playerPosition.x - maxDistance); + const int minY = std::max(0, playerPosition.y - maxDistance); + const int maxX = std::min(MAXDUNX - 1, playerPosition.x + maxDistance); + const int maxY = std::min(MAXDUNY - 1, playerPosition.y + maxDistance); + + std::array bestDistanceById {}; + bestDistanceById.fill(std::numeric_limits::max()); + + for (int y = minY; y <= maxY; ++y) { + for (int x = minX; x <= maxX; ++x) { + const int objectId = std::abs(dObject[x][y]) - 1; + if (objectId < 0 || objectId >= MAXOBJECTS) + continue; + + const Object &object = Objects[objectId]; + if (object._otype == OBJ_NULL) + continue; + if (!predicate(object)) + continue; + + const int distance = playerPosition.WalkingDistance(Point { x, y }); + if (distance > maxDistance) + continue; + + int &bestDistance = bestDistanceById[objectId]; + if (distance < bestDistance) + bestDistance = distance; + } + } + + for (int objectId = 0; objectId < MAXOBJECTS; ++objectId) { + const int distance = bestDistanceById[objectId]; + if (distance == std::numeric_limits::max()) + continue; + + const Object &object = Objects[objectId]; + result.push_back(TrackerCandidate { + .id = objectId, + .distance = distance, + .name = object.name(), + }); + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + return result; +} + +template +[[nodiscard]] std::optional FindNearestObjectId(Point playerPosition, Predicate predicate) +{ + std::array bestDistanceById {}; + bestDistanceById.fill(std::numeric_limits::max()); + + for (int y = 0; y < MAXDUNY; ++y) { + for (int x = 0; x < MAXDUNX; ++x) { + const int objectId = std::abs(dObject[x][y]) - 1; + if (objectId < 0 || objectId >= MAXOBJECTS) + continue; + + const Object &object = Objects[objectId]; + if (object._otype == OBJ_NULL) + continue; + if (!predicate(object)) + continue; + + const int distance = playerPosition.WalkingDistance(Point { x, y }); + int &bestDistance = bestDistanceById[objectId]; + if (distance < bestDistance) + bestDistance = distance; + } + } + + std::optional bestId; + int bestDistance = 0; + for (int objectId = 0; objectId < MAXOBJECTS; ++objectId) { + const int distance = bestDistanceById[objectId]; + if (distance == std::numeric_limits::max()) + continue; + + if (!bestId || distance < bestDistance) { + bestId = objectId; + bestDistance = distance; + } + } + + return bestId; +} + +[[nodiscard]] std::vector CollectNearbyChestTrackerCandidates(Point playerPosition, int maxDistance) +{ + return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedChestObject); +} + +[[nodiscard]] std::vector CollectNearbyDoorTrackerCandidates(Point playerPosition, int maxDistance) +{ + return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedDoorObject); +} + +[[nodiscard]] std::vector CollectNearbyShrineTrackerCandidates(Point playerPosition, int maxDistance) +{ + return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsShrineLikeObject); +} + +[[nodiscard]] std::vector CollectNearbyBreakableTrackerCandidates(Point playerPosition, int maxDistance) +{ + return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedBreakableObject); +} + +[[nodiscard]] std::vector CollectNearbyObjectInteractableTrackerCandidates(Point playerPosition, int maxDistance) +{ + return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedMiscInteractableObject); +} + +[[nodiscard]] std::vector CollectNearbyMonsterTrackerCandidates(Point playerPosition, int maxDistance) +{ + std::vector result; + result.reserve(ActiveMonsterCount); + + 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 (monster.hitPoints <= 0) + continue; + + const Point monsterDistancePosition { monster.position.future }; + const int distance = playerPosition.ApproxDistance(monsterDistancePosition); + if (distance > maxDistance) + continue; + + result.push_back(TrackerCandidate { + .id = monsterId, + .distance = distance, + .name = monster.name(), + }); + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + return result; +} + +[[nodiscard]] std::optional FindNextTrackerCandidateId(const std::vector &candidates, int currentId) +{ + if (candidates.empty()) + return std::nullopt; + if (currentId < 0) + return candidates.front().id; + + const auto it = std::find_if(candidates.begin(), candidates.end(), [currentId](const TrackerCandidate &c) { return c.id == currentId; }); + if (it == candidates.end()) + return candidates.front().id; + + if (candidates.size() <= 1) + return std::nullopt; + + const size_t idx = static_cast(it - candidates.begin()); + const size_t nextIdx = (idx + 1) % candidates.size(); + return candidates[nextIdx].id; +} + +void DecorateTrackerTargetNameWithOrdinalIfNeeded(int targetId, StringOrView &targetName, const std::vector &candidates) +{ + if (targetName.empty()) + return; + + const std::string_view baseName = targetName.str(); + int total = 0; + for (const TrackerCandidate &c : candidates) { + if (c.name.str() == baseName) + ++total; + } + if (total <= 1) + return; + + int ordinal = 0; + int seen = 0; + for (const TrackerCandidate &c : candidates) { + if (c.name.str() != baseName) + continue; + ++seen; + if (c.id == targetId) { + ordinal = seen; + break; + } + } + if (ordinal <= 0) + return; + + std::string decorated; + StrAppend(decorated, baseName, " ", ordinal); + targetName = std::move(decorated); +} + +[[nodiscard]] bool IsGroundItemPresent(int itemId) +{ + if (itemId < 0 || itemId > MAXITEMS) + return false; + + for (uint8_t i = 0; i < ActiveItemCount; ++i) { + if (ActiveItems[i] == itemId) + return true; + } + + return false; +} + +[[nodiscard]] bool IsCorpsePresent(int corpseId) +{ + if (corpseId < 0 || corpseId >= MAXDUNX * MAXDUNY) + return false; + + const Point position = CorpsePositionForTrackerId(corpseId); + return InDungeonBounds(position) && dCorpse[position.x][position.y] != 0; +} + +std::optional FindNearestUnopenedChestObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedChestObject); +} + +std::optional FindNearestDoorObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedDoorObject); +} + +std::optional FindNearestShrineObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsShrineLikeObject); +} + +std::optional FindNearestBreakableObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedBreakableObject); +} + +std::optional FindNearestMiscInteractableObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedMiscInteractableObject); +} + +std::optional FindNearestMonsterId(Point playerPosition) +{ + std::optional bestId; + int bestDistance = 0; + + 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 (monster.hitPoints <= 0) + continue; + + const Point monsterDistancePosition { monster.position.future }; + const int distance = playerPosition.ApproxDistance(monsterDistancePosition); + if (!bestId || distance < bestDistance) { + bestId = monsterId; + bestDistance = distance; + } + } + + return bestId; +} + +std::optional FindBestAdjacentApproachTile(const Player &player, Point playerPosition, Point targetPosition) +{ + std::optional best; + size_t bestPathLength = 0; + int bestDistance = 0; + + std::optional bestFallback; + int bestFallbackDistance = 0; + + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + if (dx == 0 && dy == 0) + continue; + + const Point tile { targetPosition.x + dx, targetPosition.y + dy }; + if (!PosOkPlayer(player, tile)) + continue; + + const int distance = playerPosition.WalkingDistance(tile); + + if (!bestFallback || distance < bestFallbackDistance) { + bestFallback = tile; + bestFallbackDistance = distance; + } + + const std::optional> path = FindKeyboardWalkPathForSpeech(player, playerPosition, tile); + if (!path) + continue; + + const size_t pathLength = path->size(); + if (!best || pathLength < bestPathLength || (pathLength == bestPathLength && distance < bestDistance)) { + best = tile; + bestPathLength = pathLength; + bestDistance = distance; + } + } + } + + if (best) + return best; + + return bestFallback; +} + +std::optional FindBestApproachTileForObject(const Player &player, Point playerPosition, const Object &object) +{ + if (!object._oSolidFlag && PosOkPlayer(player, object.position)) + return object.position; + + std::optional best; + size_t bestPathLength = 0; + int bestDistance = 0; + + std::optional bestFallback; + int bestFallbackDistance = 0; + + const auto considerTile = [&](Point tile) { + if (!PosOkPlayerIgnoreDoors(player, tile)) + return; + + const int distance = playerPosition.WalkingDistance(tile); + if (!bestFallback || distance < bestFallbackDistance) { + bestFallback = tile; + bestFallbackDistance = distance; + } + + const std::optional> path = FindKeyboardWalkPathForSpeech(player, playerPosition, tile); + if (!path) + return; + + const size_t pathLength = path->size(); + if (!best || pathLength < bestPathLength || (pathLength == bestPathLength && distance < bestDistance)) { + best = tile; + bestPathLength = pathLength; + bestDistance = distance; + } + }; + + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + if (dx == 0 && dy == 0) + continue; + considerTile(object.position + Displacement { dx, dy }); + } + } + + if (FindObjectAtPosition(object.position + Direction::NorthEast) == &object) { + for (int dx = -1; dx <= 1; ++dx) { + considerTile(object.position + Displacement { dx, -2 }); + } + } + + if (best) + return best; + + return bestFallback; +} + +struct DoorBlockInfo { + Point beforeDoor; + Point doorPosition; +}; + +std::optional FindFirstClosedDoorOnWalkPath(Point startPosition, const int8_t *path, int steps) +{ + Point position = startPosition; + for (int i = 0; i < steps; ++i) { + const Point next = NextPositionForWalkDirection(position, path[i]); + Object *object = FindObjectAtPosition(next); + if (object != nullptr && object->isDoor() && object->_oVar4 == DOOR_CLOSED) { + return DoorBlockInfo { .beforeDoor = position, .doorPosition = object->position }; + } + position = next; + } + return std::nullopt; +} + +enum class TrackerPathBlockType : uint8_t { + Door, + Monster, + Breakable, +}; + +struct TrackerPathBlockInfo { + TrackerPathBlockType type; + size_t stepIndex; + Point beforeBlock; + Point blockPosition; +}; + +[[nodiscard]] std::optional FindFirstTrackerPathBlock(Point startPosition, const int8_t *path, size_t steps, bool considerDoors, bool considerMonsters, bool considerBreakables, Point targetPosition) +{ + Point position = startPosition; + for (size_t i = 0; i < steps; ++i) { + const Point next = NextPositionForWalkDirection(position, path[i]); + if (next == targetPosition) { + position = next; + continue; + } + + Object *object = FindObjectAtPosition(next); + if (considerDoors && object != nullptr && object->isDoor() && object->_oVar4 == DOOR_CLOSED) { + return TrackerPathBlockInfo { + .type = TrackerPathBlockType::Door, + .stepIndex = i, + .beforeBlock = position, + .blockPosition = object->position, + }; + } + if (considerBreakables && object != nullptr && object->_oSolidFlag && object->IsBreakable()) { + return TrackerPathBlockInfo { + .type = TrackerPathBlockType::Breakable, + .stepIndex = i, + .beforeBlock = position, + .blockPosition = next, + }; + } + + if (considerMonsters && leveltype != DTYPE_TOWN && dMonster[next.x][next.y] != 0) { + const int monsterRef = dMonster[next.x][next.y]; + const int monsterId = std::abs(monsterRef) - 1; + const bool blocks = monsterRef <= 0 || (monsterId >= 0 && monsterId < static_cast(MaxMonsters) && !Monsters[monsterId].hasNoLife()); + if (blocks) { + return TrackerPathBlockInfo { + .type = TrackerPathBlockType::Monster, + .stepIndex = i, + .beforeBlock = position, + .blockPosition = next, + }; + } + } + + position = next; + } + + return std::nullopt; +} + +/** + * Validates an object-category auto-walk target and computes the walk destination. + */ +template +bool ValidateAutoWalkObjectTarget( + const Player &myPlayer, Point playerPosition, + Predicate isValid, const char *goneMessage, const char *inRangeMessage, + std::optional &destination) +{ + const int objectId = AutoWalkTrackerTargetId; + if (objectId < 0 || objectId >= MAXOBJECTS) { + AutoWalkTrackerTargetId = -1; + SpeakText(_(goneMessage), true); + return false; + } + const Object &object = Objects[objectId]; + if (!isValid(object)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_(goneMessage), true); + return false; + } + if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_(inRangeMessage), true); + return false; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); + return true; +} + +/** + * Resolves which object to walk toward for the given tracker category. + */ +template +std::optional ResolveObjectTrackerTarget( + int &lockedTargetId, Point playerPosition, + Predicate isValid, FindNearest findNearest, GetName getName, + const char *notFoundMessage, StringOrView &targetName) +{ + std::optional targetId; + if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = findNearest(playerPosition); + } + if (!targetId) { + SpeakText(_(notFoundMessage), true); + return std::nullopt; + } + if (!isValid(Objects[*targetId])) { + lockedTargetId = -1; + targetId = findNearest(playerPosition); + if (!targetId) { + SpeakText(_(notFoundMessage), true); + return std::nullopt; + } + if (!isValid(Objects[*targetId])) { + SpeakText(_(notFoundMessage), true); + return std::nullopt; + } + } + lockedTargetId = *targetId; + targetName = getName(*targetId); + return targetId; +} + +} // namespace + +void CycleTrackerTargetKeyPressed() +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + + AutoWalkTrackerTargetId = -1; + + const SDL_Keymod modState = SDL_GetModState(); + const bool cyclePrevious = (modState & SDL_KMOD_SHIFT) != 0; + + if (cyclePrevious) { + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: + SelectedTrackerTargetCategory = TrackerTargetCategory::DeadBodies; + break; + case TrackerTargetCategory::Chests: + SelectedTrackerTargetCategory = TrackerTargetCategory::Items; + break; + case TrackerTargetCategory::Doors: + SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; + break; + case TrackerTargetCategory::Shrines: + SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; + break; + case TrackerTargetCategory::Objects: + SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; + break; + case TrackerTargetCategory::Breakables: + SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; + break; + case TrackerTargetCategory::Monsters: + SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; + break; + case TrackerTargetCategory::DeadBodies: + default: + SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; + break; + } + } else { + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: + SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; + break; + case TrackerTargetCategory::Chests: + SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; + break; + case TrackerTargetCategory::Doors: + SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; + break; + case TrackerTargetCategory::Shrines: + SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; + break; + case TrackerTargetCategory::Objects: + SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; + break; + case TrackerTargetCategory::Breakables: + SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; + break; + case TrackerTargetCategory::Monsters: + SelectedTrackerTargetCategory = TrackerTargetCategory::DeadBodies; + break; + case TrackerTargetCategory::DeadBodies: + default: + SelectedTrackerTargetCategory = TrackerTargetCategory::Items; + break; + } + } + + SpeakTrackerTargetCategory(); +} + +void NavigateToTrackerTargetKeyPressed() +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + if (leveltype == DTYPE_TOWN && IsNoneOf(SelectedTrackerTargetCategory, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies)) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + EnsureTrackerLocksMatchCurrentLevel(); + + const SDL_Keymod modState = SDL_GetModState(); + const bool cycleTarget = (modState & SDL_KMOD_SHIFT) != 0; + const bool clearTarget = (modState & SDL_KMOD_CTRL) != 0; + + const Point playerPosition = MyPlayer->position.future; + AutoWalkTrackerTargetId = -1; + + int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); + if (clearTarget) { + lockedTargetId = -1; + SpeakText(_("Tracker target cleared."), true); + return; + } + + std::optional targetId; + std::optional targetPosition; + std::optional alternateTargetPosition; + StringOrView targetName; + + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + const std::vector nearbyCandidates = CollectNearbyItemTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No items found."), true); + else + SpeakText(_("No next item."), true); + return; + } + } else if (IsGroundItemPresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestGroundItemId(playerPosition); + } + if (!targetId) { + SpeakText(_("No items found."), true); + return; + } + + if (!IsGroundItemPresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No items found."), true); + return; + } + + lockedTargetId = *targetId; + const Item &tracked = Items[*targetId]; + + targetName = tracked.getName(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + targetPosition = tracked.position; + break; + } + case TrackerTargetCategory::Chests: { + const std::vector nearbyCandidates = CollectNearbyChestTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No chests found."), true); + else + SpeakText(_("No next chest."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestUnopenedChestObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No chests found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsTrackedChestObject(object)) { + lockedTargetId = -1; + targetId = FindNearestUnopenedChestObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No chests found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Doors: { + std::vector nearbyCandidates = CollectNearbyDoorTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + for (TrackerCandidate &c : nearbyCandidates) { + if (c.id < 0 || c.id >= MAXOBJECTS) + continue; + c.name = DoorLabelForSpeech(Objects[c.id]); + } + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No doors found."), true); + else + SpeakText(_("No next door."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestDoorObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No doors found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsTrackedDoorObject(object)) { + lockedTargetId = -1; + targetId = FindNearestDoorObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No doors found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = DoorLabelForSpeech(tracked); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Shrines: { + const std::vector nearbyCandidates = CollectNearbyShrineTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No shrines found."), true); + else + SpeakText(_("No next shrine."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestShrineObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No shrines found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsShrineLikeObject(object)) { + lockedTargetId = -1; + targetId = FindNearestShrineObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No shrines found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Objects: { + const std::vector nearbyCandidates = CollectNearbyObjectInteractableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No objects found."), true); + else + SpeakText(_("No next object."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestMiscInteractableObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No objects found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsTrackedMiscInteractableObject(object)) { + lockedTargetId = -1; + targetId = FindNearestMiscInteractableObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No objects found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Breakables: { + const std::vector nearbyCandidates = CollectNearbyBreakableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No breakables found."), true); + else + SpeakText(_("No next breakable."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestBreakableObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No breakables found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsTrackedBreakableObject(object)) { + lockedTargetId = -1; + targetId = FindNearestBreakableObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No breakables found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Monsters: { + const std::vector nearbyCandidates = CollectNearbyMonsterTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No monsters found."), true); + else + SpeakText(_("No next monster."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < static_cast(MaxMonsters)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestMonsterId(playerPosition); + } + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + + const Monster &monster = Monsters[*targetId]; + if (!IsTrackedMonster(monster)) { + lockedTargetId = -1; + targetId = FindNearestMonsterId(playerPosition); + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Monster &tracked = Monsters[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position.tile; + } + break; + } + case TrackerTargetCategory::DeadBodies: { + const std::vector nearbyCandidates = CollectNearbyCorpseTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No dead bodies found."), true); + else + SpeakText(_("No next dead body."), true); + return; + } + } else if (IsCorpsePresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestCorpseId(playerPosition); + } + if (!targetId) { + SpeakText(_("No dead bodies found."), true); + return; + } + + if (!IsCorpsePresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No dead bodies found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = _("Dead body"); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = CorpsePositionForTrackerId(*targetId); + } + break; + } + } + + if (cycleTarget) { + SpeakText(targetName.str(), /*force=*/true); + return; + } + + if (!targetPosition) { + SpeakText(_("Can't find a nearby tile to walk to."), true); + return; + } + + Point chosenTargetPosition = *targetPosition; + enum class TrackerPathMode : uint8_t { + RespectDoorsAndMonsters, + IgnoreDoors, + IgnoreMonsters, + IgnoreDoorsAndMonsters, + Lenient, + }; + + auto findPathToTarget = [&](Point destination, TrackerPathMode mode) -> std::optional> { + const bool allowDestinationNonWalkable = !PosOkPlayer(*MyPlayer, destination); + switch (mode) { + case TrackerPathMode::RespectDoorsAndMonsters: + return FindKeyboardWalkPathForSpeechRespectingDoors(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::IgnoreDoors: + return FindKeyboardWalkPathForSpeech(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::IgnoreMonsters: + return FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::IgnoreDoorsAndMonsters: + return FindKeyboardWalkPathForSpeechIgnoringMonsters(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::Lenient: + return FindKeyboardWalkPathForSpeechLenient(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + default: + return std::nullopt; + } + }; + + std::optional> spokenPath; + bool pathIgnoresDoors = false; + bool pathIgnoresMonsters = false; + bool pathIgnoresBreakables = false; + + const auto considerDestination = [&](Point destination, TrackerPathMode mode) { + const std::optional> candidate = findPathToTarget(destination, mode); + if (!candidate) + return; + if (!spokenPath || candidate->size() < spokenPath->size()) { + spokenPath = *candidate; + chosenTargetPosition = destination; + + pathIgnoresDoors = mode == TrackerPathMode::IgnoreDoors || mode == TrackerPathMode::IgnoreDoorsAndMonsters || mode == TrackerPathMode::Lenient; + pathIgnoresMonsters = mode == TrackerPathMode::IgnoreMonsters || mode == TrackerPathMode::IgnoreDoorsAndMonsters || mode == TrackerPathMode::Lenient; + pathIgnoresBreakables = mode == TrackerPathMode::Lenient; + } + }; + + considerDestination(*targetPosition, TrackerPathMode::RespectDoorsAndMonsters); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::RespectDoorsAndMonsters); + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::IgnoreDoors); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreDoors); + } + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::IgnoreMonsters); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreMonsters); + } + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::IgnoreDoorsAndMonsters); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreDoorsAndMonsters); + } + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::Lenient); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::Lenient); + } + + bool showUnreachableWarning = false; + if (!spokenPath) { + showUnreachableWarning = true; + Point closestPosition; + spokenPath = FindKeyboardWalkPathToClosestReachableForSpeech(*MyPlayer, playerPosition, chosenTargetPosition, closestPosition); + pathIgnoresDoors = true; + pathIgnoresMonsters = false; + pathIgnoresBreakables = false; + } + + if (spokenPath && !showUnreachableWarning && !PosOkPlayer(*MyPlayer, chosenTargetPosition)) { + if (!spokenPath->empty()) + spokenPath->pop_back(); + } + + if (spokenPath && (pathIgnoresDoors || pathIgnoresMonsters || pathIgnoresBreakables)) { + const std::optional block = FindFirstTrackerPathBlock(playerPosition, spokenPath->data(), spokenPath->size(), pathIgnoresDoors, pathIgnoresMonsters, pathIgnoresBreakables, chosenTargetPosition); + if (block) { + if (playerPosition.WalkingDistance(block->blockPosition) <= TrackerInteractDistanceTiles) { + switch (block->type) { + case TrackerPathBlockType::Door: + SpeakText(_("A door is blocking the path. Open it and try again."), true); + return; + case TrackerPathBlockType::Monster: + SpeakText(_("A monster is blocking the path. Clear it and try again."), true); + return; + case TrackerPathBlockType::Breakable: + SpeakText(_("A breakable object is blocking the path. Destroy it and try again."), true); + return; + } + } + + spokenPath = std::vector(spokenPath->begin(), spokenPath->begin() + block->stepIndex); + } + } + + std::string message; + if (!targetName.empty()) + StrAppend(message, targetName, "\n"); + if (showUnreachableWarning) { + message.append(_("Can't find a path to the target.")); + if (spokenPath && !spokenPath->empty()) + message.append("\n"); + } + if (spokenPath) { + if (!showUnreachableWarning || !spokenPath->empty()) + AppendKeyboardWalkPathForSpeech(message, *spokenPath); + } + + SpeakText(message, true); +} + +void AutoWalkToTrackerTargetKeyPressed() +{ + if (AutoWalkTrackerTargetId >= 0) { + CancelAutoWalk(); + SpeakText(_("Walk cancelled."), true); + return; + } + + if (!CanPlayerTakeAction() || InGameMenu()) + return; + + if (leveltype == DTYPE_TOWN) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) { + SpeakText(_("Cannot walk right now."), true); + return; + } + + EnsureTrackerLocksMatchCurrentLevel(); + + const Point playerPosition = MyPlayer->position.future; + int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); + + std::optional targetId; + StringOrView targetName; + + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + if (IsGroundItemPresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestGroundItemId(playerPosition); + } + if (!targetId) { + SpeakText(_("No items found."), true); + return; + } + if (!IsGroundItemPresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No items found."), true); + return; + } + lockedTargetId = *targetId; + targetName = Items[*targetId].getName(); + break; + } + case TrackerTargetCategory::Chests: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedChestObject, FindNearestUnopenedChestObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No chests found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Doors: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedDoorObject, FindNearestDoorObjectId, [](int id) -> StringOrView { return DoorLabelForSpeech(Objects[id]); }, N_("No doors found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Shrines: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsShrineLikeObject, FindNearestShrineObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No shrines found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Objects: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedMiscInteractableObject, FindNearestMiscInteractableObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No objects found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Breakables: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedBreakableObject, FindNearestBreakableObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No breakables found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Monsters: { + if (lockedTargetId >= 0 && lockedTargetId < static_cast(MaxMonsters)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestMonsterId(playerPosition); + } + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + const Monster &monster = Monsters[*targetId]; + if (!IsTrackedMonster(monster)) { + lockedTargetId = -1; + targetId = FindNearestMonsterId(playerPosition); + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + } + lockedTargetId = *targetId; + targetName = Monsters[*targetId].name(); + break; + } + case TrackerTargetCategory::DeadBodies: { + if (IsCorpsePresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestCorpseId(playerPosition); + } + if (!targetId) { + SpeakText(_("No dead bodies found."), true); + return; + } + if (!IsCorpsePresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No dead bodies found."), true); + return; + } + lockedTargetId = *targetId; + targetName = _("Dead body"); + break; + } + } + + std::string msg; + StrAppend(msg, _("Going to: "), targetName); + SpeakText(msg, true); + + AutoWalkTrackerTargetId = *targetId; + AutoWalkTrackerTargetCategory = SelectedTrackerTargetCategory; + UpdateAutoWalkTracker(); +} + +void UpdateAutoWalkTracker() +{ + if (AutoWalkTrackerTargetId < 0) + return; + if (leveltype == DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag || InGameMenu()) { + AutoWalkTrackerTargetId = -1; + return; + } + if (!CanPlayerTakeAction()) + return; + + if (MyPlayer == nullptr) { + SpeakText(_("Cannot walk right now."), true); + return; + } + if (MyPlayer->_pmode != PM_STAND) + return; + if (MyPlayer->walkpath[0] != WALK_NONE) + return; + if (MyPlayer->destAction != ACTION_NONE) + return; + + Player &myPlayer = *MyPlayer; + const Point playerPosition = myPlayer.position.future; + + std::optional destination; + + switch (AutoWalkTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + const int itemId = AutoWalkTrackerTargetId; + if (itemId < 0 || itemId > MAXITEMS) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target item is gone."), true); + return; + } + if (!IsGroundItemPresent(itemId)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target item is gone."), true); + return; + } + const Item &item = Items[itemId]; + if (playerPosition.WalkingDistance(item.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Item in range."), true); + return; + } + destination = item.position; + break; + } + case TrackerTargetCategory::Chests: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, + IsTrackedChestObject, N_("Target chest is gone."), N_("Chest in range."), destination)) + return; + break; + case TrackerTargetCategory::Doors: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedDoorObject, N_("Target door is gone."), N_("Door in range."), destination)) + return; + break; + case TrackerTargetCategory::Shrines: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsShrineLikeObject, N_("Target shrine is gone."), N_("Shrine in range."), destination)) + return; + break; + case TrackerTargetCategory::Objects: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedMiscInteractableObject, N_("Target object is gone."), N_("Object in range."), destination)) + return; + break; + case TrackerTargetCategory::Breakables: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedBreakableObject, N_("Target breakable is gone."), N_("Breakable in range."), destination)) + return; + break; + case TrackerTargetCategory::Monsters: { + const int monsterId = AutoWalkTrackerTargetId; + if (monsterId < 0 || monsterId >= static_cast(MaxMonsters)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target monster is gone."), true); + return; + } + const Monster &monster = Monsters[monsterId]; + if (!IsTrackedMonster(monster)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target monster is gone."), true); + return; + } + const Point monsterPosition { monster.position.tile }; + if (playerPosition.WalkingDistance(monsterPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Monster in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, monsterPosition); + break; + } + case TrackerTargetCategory::DeadBodies: { + const int corpseId = AutoWalkTrackerTargetId; + if (!IsCorpsePresent(corpseId)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target dead body is gone."), true); + return; + } + + const Point corpsePosition = CorpsePositionForTrackerId(corpseId); + if (playerPosition.WalkingDistance(corpsePosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Dead body in range."), true); + return; + } + + destination = corpsePosition; + break; + } + } + + if (!destination) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Can't find a nearby tile to walk to."), true); + return; + } + + constexpr size_t MaxAutoWalkPathLength = 512; + std::array path; + path.fill(WALK_NONE); + + int steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, *destination, path.data(), path.size()); + if (steps == 0) { + std::array ignoreDoorPath; + ignoreDoorPath.fill(WALK_NONE); + + const int ignoreDoorSteps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayerIgnoreDoors(myPlayer, position); }, playerPosition, *destination, ignoreDoorPath.data(), ignoreDoorPath.size()); + if (ignoreDoorSteps != 0) { + const std::optional block = FindFirstClosedDoorOnWalkPath(playerPosition, ignoreDoorPath.data(), ignoreDoorSteps); + if (block) { + if (playerPosition.WalkingDistance(block->doorPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("A door is blocking the path. Open it and try again."), true); + return; + } + + *destination = block->beforeDoor; + path.fill(WALK_NONE); + steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, *destination, path.data(), path.size()); + } + } + + if (steps == 0) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Can't find a path to the target."), true); + return; + } + } + + if (steps < static_cast(MaxPathLengthPlayer)) { + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, *destination); + return; + } + + const int segmentSteps = std::min(steps - 1, static_cast(MaxPathLengthPlayer - 1)); + const Point waypoint = PositionAfterWalkPathSteps(playerPosition, path.data(), segmentSteps); + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, waypoint); +} + +void ResetAutoWalkTracker() +{ + AutoWalkTrackerTargetId = -1; +} + +} // namespace devilution diff --git a/Source/controls/tracker.hpp b/Source/controls/tracker.hpp new file mode 100644 index 000000000..e73fa8166 --- /dev/null +++ b/Source/controls/tracker.hpp @@ -0,0 +1,31 @@ +/** + * @file controls/tracker.hpp + * + * Tracker system for accessibility: target cycling, pathfinding, and auto-walk. + */ +#pragma once + +#include + +namespace devilution { + +enum class TrackerTargetCategory : uint8_t { + Items, + Chests, + Doors, + Shrines, + Objects, + Breakables, + Monsters, + DeadBodies, +}; + +extern TrackerTargetCategory SelectedTrackerTargetCategory; + +void CycleTrackerTargetKeyPressed(); +void NavigateToTrackerTargetKeyPressed(); +void AutoWalkToTrackerTargetKeyPressed(); +void UpdateAutoWalkTracker(); +void ResetAutoWalkTracker(); + +} // namespace devilution diff --git a/Source/utils/accessibility_announcements.cpp b/Source/utils/accessibility_announcements.cpp new file mode 100644 index 000000000..c1275c6d1 --- /dev/null +++ b/Source/utils/accessibility_announcements.cpp @@ -0,0 +1,489 @@ +/** + * @file utils/accessibility_announcements.cpp + * + * Periodic accessibility announcements (low HP warning, durability, boss health, + * attackable monsters, interactable doors). + */ +#include "utils/accessibility_announcements.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include "controls/plrctrls.h" +#include "engine/sound.h" +#include "gamemenu.h" +#include "inv.h" +#include "items.h" +#include "levels/gendung.h" +#include "monster.h" +#include "objects.h" +#include "player.h" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" +#include "utils/string_or_view.hpp" + +namespace devilution { + +#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 + +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 UpdateAttackableMonsterAnnouncements() +{ + static std::optional LastAttackableMonsterId; + + if (MyPlayer == nullptr) { + LastAttackableMonsterId = std::nullopt; + return; + } + if (leveltype == DTYPE_TOWN) { + LastAttackableMonsterId = std::nullopt; + return; + } + if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) { + LastAttackableMonsterId = std::nullopt; + return; + } + if (InGameMenu() || invflag) { + LastAttackableMonsterId = std::nullopt; + return; + } + + const Player &player = *MyPlayer; + const Point playerPosition = player.position.tile; + + int bestRotations = 5; + std::optional bestId; + + 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 (monster.hitPoints <= 0) + continue; + if (monster.isPlayerMinion()) + continue; + if (!monster.isPossibleToHit()) + continue; + + const Point monsterPosition = monster.position.tile; + if (playerPosition.WalkingDistance(monsterPosition) > 1) + continue; + + const int d1 = static_cast(player._pdir); + const int d2 = static_cast(GetDirection(playerPosition, monsterPosition)); + + int rotations = std::abs(d1 - d2); + if (rotations > 4) + rotations = 4 - (rotations % 4); + + if (!bestId || rotations < bestRotations || (rotations == bestRotations && monsterId < *bestId)) { + bestRotations = rotations; + bestId = monsterId; + } + } + + if (!bestId) { + LastAttackableMonsterId = std::nullopt; + return; + } + + if (LastAttackableMonsterId && *LastAttackableMonsterId == *bestId) + return; + + LastAttackableMonsterId = *bestId; + + const std::string_view name = Monsters[*bestId].name(); + if (!name.empty()) + SpeakText(name, /*force=*/true); +} + +StringOrView DoorLabelForSpeech(const Object &door) +{ + if (!door.isDoor()) + return door.name(); + + // Catacombs doors are grates, so differentiate them for the screen reader / tracker. + if (IsAnyOf(door._otype, _object_id::OBJ_L2LDOOR, _object_id::OBJ_L2RDOOR)) { + if (door._oVar4 == DOOR_OPEN) + return _("Open Grate Door"); + if (door._oVar4 == DOOR_CLOSED) + return _("Closed Grate Door"); + if (door._oVar4 == DOOR_BLOCKED) + return _("Blocked Grate Door"); + return _("Grate Door"); + } + + return door.name(); +} + +void UpdateInteractableDoorAnnouncements() +{ + static std::optional LastInteractableDoorId; + static std::optional LastInteractableDoorState; + + if (MyPlayer == nullptr) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + if (leveltype == DTYPE_TOWN) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + if (InGameMenu() || invflag) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + + const Player &player = *MyPlayer; + const Point playerPosition = player.position.tile; + + std::optional bestId; + int bestRotations = 5; + int bestDistance = 0; + + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + if (dx == 0 && dy == 0) + continue; + + const Point pos = playerPosition + Displacement { dx, dy }; + if (!InDungeonBounds(pos)) + continue; + + const int objectId = std::abs(dObject[pos.x][pos.y]) - 1; + if (objectId < 0 || objectId >= MAXOBJECTS) + continue; + + const Object &door = Objects[objectId]; + if (!door.isDoor() || !door.canInteractWith()) + continue; + + const int distance = playerPosition.WalkingDistance(door.position); + if (distance > 1) + continue; + + const int d1 = static_cast(player._pdir); + const int d2 = static_cast(GetDirection(playerPosition, door.position)); + + int rotations = std::abs(d1 - d2); + if (rotations > 4) + rotations = 4 - (rotations % 4); + + if (!bestId || rotations < bestRotations || (rotations == bestRotations && distance < bestDistance) + || (rotations == bestRotations && distance == bestDistance && objectId < *bestId)) { + bestRotations = rotations; + bestDistance = distance; + bestId = objectId; + } + } + } + + if (!bestId) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + + const Object &door = Objects[*bestId]; + const int state = door._oVar4; + if (LastInteractableDoorId && LastInteractableDoorState && *LastInteractableDoorId == *bestId && *LastInteractableDoorState == state) + return; + + LastInteractableDoorId = *bestId; + LastInteractableDoorState = state; + + const StringOrView label = DoorLabelForSpeech(door); + if (!label.empty()) + SpeakText(label.str(), /*force=*/true); +} + +} // namespace devilution diff --git a/Source/utils/accessibility_announcements.hpp b/Source/utils/accessibility_announcements.hpp new file mode 100644 index 000000000..af8bdd75f --- /dev/null +++ b/Source/utils/accessibility_announcements.hpp @@ -0,0 +1,22 @@ +/** + * @file utils/accessibility_announcements.hpp + * + * Periodic accessibility announcements (low HP warning, durability, boss health, + * attackable monsters, interactable doors). + */ +#pragma once + +#include "utils/string_or_view.hpp" + +namespace devilution { + +struct Object; + +void UpdatePlayerLowHpWarningSound(); +void UpdateLowDurabilityWarnings(); +void UpdateBossHealthAnnouncements(); +void UpdateAttackableMonsterAnnouncements(); +StringOrView DoorLabelForSpeech(const Object &door); +void UpdateInteractableDoorAnnouncements(); + +} // namespace devilution diff --git a/Source/utils/navigation_speech.cpp b/Source/utils/navigation_speech.cpp new file mode 100644 index 000000000..15b20f2e9 --- /dev/null +++ b/Source/utils/navigation_speech.cpp @@ -0,0 +1,685 @@ +/** + * @file utils/navigation_speech.cpp + * + * Navigation speech: exit/stairs/portal/unexplored speech and keyboard walk keys. + */ +#include "utils/navigation_speech.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include "automap.h" +#include "control/control.hpp" +#include "controls/accessibility_keys.hpp" +#include "controls/plrctrls.h" +#include "diablo.h" +#include "help.h" +#include "inv.h" +#include "levels/gendung.h" +#include "levels/setmaps.h" +#include "levels/tile_properties.hpp" +#include "levels/trigs.h" +#include "minitext.h" +#include "missiles.h" +#include "multi.h" +#include "player.h" +#include "portal.h" +#include "qol/chatlog.h" +#include "qol/stash.h" +#include "quests.h" +#include "stores.h" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" +#include "utils/sdl_compat.h" +#include "utils/walk_path_speech.hpp" + +namespace devilution { + +namespace { + +std::optional FindNearestUnexploredTile(Point startPosition) +{ + if (!InDungeonBounds(startPosition)) + return std::nullopt; + + std::array visited {}; + std::queue queue; + + const auto enqueue = [&](Point position) { + if (!InDungeonBounds(position)) + return; + + const size_t index = static_cast(position.x) + static_cast(position.y) * MAXDUNX; + if (visited[index]) + return; + + if (!IsTileWalkable(position, /*ignoreDoors=*/true)) + return; + + visited[index] = true; + queue.push(position); + }; + + enqueue(startPosition); + + constexpr std::array Neighbors = { + Direction::NorthEast, + Direction::SouthWest, + Direction::SouthEast, + Direction::NorthWest, + }; + + while (!queue.empty()) { + const Point position = queue.front(); + queue.pop(); + + if (!HasAnyOf(dFlags[position.x][position.y], DungeonFlag::Explored)) + return position; + + for (const Direction dir : Neighbors) { + enqueue(position + dir); + } + } + + return std::nullopt; +} + +std::string TriggerLabelForSpeech(const TriggerStruct &trigger) +{ + switch (trigger._tmsg) { + case WM_DIABNEXTLVL: + if (leveltype == DTYPE_TOWN) + return std::string { _("Cathedral entrance") }; + return std::string { _("Stairs down") }; + case WM_DIABPREVLVL: + return std::string { _("Stairs up") }; + case WM_DIABTOWNWARP: + switch (trigger._tlvl) { + case 5: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Catacombs")); + case 9: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Caves")); + case 13: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Hell")); + case 17: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Nest")); + case 21: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Crypt")); + default: + return fmt::format(fmt::runtime(_("Town warp to level {:d}")), trigger._tlvl); + } + case WM_DIABTWARPUP: + return std::string { _("Warp up") }; + case WM_DIABRETOWN: + return std::string { _("Return to town") }; + case WM_DIABWARPLVL: + return std::string { _("Warp") }; + case WM_DIABSETLVL: + return std::string { _("Set level") }; + case WM_DIABRTNLVL: + return std::string { _("Return level") }; + default: + return std::string { _("Exit") }; + } +} + +std::optional LockedTownDungeonTriggerIndex; + +std::vector CollectTownDungeonTriggerIndices() +{ + std::vector result; + result.reserve(static_cast(std::max(0, numtrigs))); + + for (int i = 0; i < numtrigs; ++i) { + if (IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP)) + result.push_back(i); + } + + std::sort(result.begin(), result.end(), [](int a, int b) { + const TriggerStruct &ta = trigs[a]; + const TriggerStruct &tb = trigs[b]; + + const int kindA = ta._tmsg == WM_DIABNEXTLVL ? 0 : (ta._tmsg == WM_DIABTOWNWARP ? 1 : 2); + const int kindB = tb._tmsg == WM_DIABNEXTLVL ? 0 : (tb._tmsg == WM_DIABTOWNWARP ? 1 : 2); + if (kindA != kindB) + return kindA < kindB; + + if (ta._tmsg == WM_DIABTOWNWARP && tb._tmsg == WM_DIABTOWNWARP && ta._tlvl != tb._tlvl) + return ta._tlvl < tb._tlvl; + + return a < b; + }); + + return result; +} + +std::optional FindDefaultTownDungeonTriggerIndex(const std::vector &candidates) +{ + for (const int index : candidates) { + if (trigs[index]._tmsg == WM_DIABNEXTLVL) + return index; + } + if (!candidates.empty()) + return candidates.front(); + return std::nullopt; +} + +std::optional FindLockedTownDungeonTriggerIndex(const std::vector &candidates) +{ + if (!LockedTownDungeonTriggerIndex) + return std::nullopt; + if (std::find(candidates.begin(), candidates.end(), *LockedTownDungeonTriggerIndex) != candidates.end()) + return *LockedTownDungeonTriggerIndex; + return std::nullopt; +} + +std::optional FindNextTownDungeonTriggerIndex(const std::vector &candidates, int current) +{ + if (candidates.empty()) + return std::nullopt; + + const auto it = std::find(candidates.begin(), candidates.end(), current); + if (it == candidates.end()) + return candidates.front(); + if (std::next(it) == candidates.end()) + return candidates.front(); + return *std::next(it); +} + +std::optional FindNearestTriggerIndexWithMessage(int message) +{ + if (numtrigs <= 0 || MyPlayer == nullptr) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + std::optional bestIndex; + int bestDistance = 0; + + for (int i = 0; i < numtrigs; ++i) { + if (trigs[i]._tmsg != message) + continue; + + const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + if (!bestIndex || distance < bestDistance) { + bestIndex = i; + bestDistance = distance; + } + } + + return bestIndex; +} + +std::optional FindNearestTownPortalOnCurrentLevel() +{ + if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + const int currentLevel = setlevel ? static_cast(setlvlnum) : currlevel; + + std::optional bestPosition; + int bestDistance = 0; + + for (int i = 0; i < MAXPORTAL; ++i) { + const Portal &portal = Portals[i]; + if (!portal.open) + continue; + if (portal.setlvl != setlevel) + continue; + if (portal.level != currentLevel) + continue; + + const int distance = playerPosition.WalkingDistance(portal.position); + if (!bestPosition || distance < bestDistance) { + bestPosition = portal.position; + bestDistance = distance; + } + } + + return bestPosition; +} + +struct TownPortalInTown { + int portalIndex; + Point position; + int distance; +}; + +std::optional FindNearestTownPortalInTown() +{ + if (MyPlayer == nullptr || leveltype != DTYPE_TOWN) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + + std::optional best; + int bestDistance = 0; + + for (const Missile &missile : Missiles) { + if (missile._mitype != MissileID::TownPortal) + continue; + if (missile._misource < 0 || missile._misource >= MAXPORTAL) + continue; + if (!Portals[missile._misource].open) + continue; + + const Point portalPosition = missile.position.tile; + const int distance = playerPosition.WalkingDistance(portalPosition); + if (!best || distance < bestDistance) { + best = TownPortalInTown { + .portalIndex = missile._misource, + .position = portalPosition, + .distance = distance, + }; + bestDistance = distance; + } + } + + return best; +} + +[[nodiscard]] std::string TownPortalLabelForSpeech(const Portal &portal) +{ + if (portal.level <= 0) + return std::string { _("Town portal") }; + + if (portal.setlvl) { + const auto questLevel = static_cast<_setlevels>(portal.level); + const char *questLevelName = QuestLevelNames[questLevel]; + if (questLevelName == nullptr || questLevelName[0] == '\0') + return std::string { _("Town portal to set level") }; + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a set/quest level name. */ "Town portal to {:s}")), _(questLevelName)); + } + + constexpr std::array DungeonStrs = { + N_("Town"), + N_("Cathedral"), + N_("Catacombs"), + N_("Caves"), + N_("Hell"), + N_("Nest"), + N_("Crypt"), + }; + std::string dungeonStr; + if (portal.ltype >= DTYPE_TOWN && portal.ltype <= DTYPE_LAST) { + dungeonStr = _(DungeonStrs[static_cast(portal.ltype)]); + } else { + dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None"); + } + + int floor = portal.level; + if (portal.ltype == DTYPE_CATACOMBS) + floor -= 4; + else if (portal.ltype == DTYPE_CAVES) + floor -= 8; + else if (portal.ltype == DTYPE_HELL) + floor -= 12; + else if (portal.ltype == DTYPE_NEST) + floor -= 16; + else if (portal.ltype == DTYPE_CRYPT) + floor -= 20; + + if (floor > 0) + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name and {:d} is a floor number. */ "Town portal to {:s} {:d}")), dungeonStr, floor); + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name. */ "Town portal to {:s}")), dungeonStr); +} + +struct QuestSetLevelEntrance { + _setlevels questLevel; + Point entrancePosition; + int distance; +}; + +std::optional FindNearestQuestSetLevelEntranceOnCurrentLevel() +{ + if (MyPlayer == nullptr || setlevel) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + std::optional best; + int bestDistance = 0; + + for (const Quest &quest : Quests) { + if (quest._qslvl == SL_NONE) + continue; + if (quest._qactive == QUEST_NOTAVAIL) + continue; + if (quest._qlevel != currlevel) + continue; + if (!InDungeonBounds(quest.position)) + continue; + + const int distance = playerPosition.WalkingDistance(quest.position); + if (!best || distance < bestDistance) { + best = QuestSetLevelEntrance { + .questLevel = quest._qslvl, + .entrancePosition = quest.position, + .distance = distance, + }; + bestDistance = distance; + } + } + + return best; +} + +void SpeakNearestStairsKeyPressed(int triggerMessage) +{ + if (!CanPlayerTakeAction()) + return; + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (leveltype == DTYPE_TOWN) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(triggerMessage); + if (!triggerIndex) { + SpeakText(_("No exits found."), true); + return; + } + + const TriggerStruct &trigger = trigs[*triggerIndex]; + const Point startPosition = MyPlayer->position.future; + const Point targetPosition { trigger.position.x, trigger.position.y }; + + std::string message; + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + if (!path) { + AppendDirectionalFallback(message, targetPosition - startPosition); + } else { + AppendKeyboardWalkPathForSpeech(message, *path); + } + + SpeakText(message, true); +} + +void KeyboardWalkKeyPressed(Direction direction) +{ + CancelAutoWalk(); + if (!IsKeyboardWalkAllowed()) + return; + + if (MyPlayer == nullptr) + return; + + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, MyPlayer->position.future + direction); +} + +} // namespace + +void SpeakNearestExitKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const Point startPosition = MyPlayer->position.future; + + const SDL_Keymod modState = SDL_GetModState(); + const bool seekQuestEntrance = (modState & SDL_KMOD_SHIFT) != 0; + const bool cycleTownDungeon = (modState & SDL_KMOD_CTRL) != 0; + + if (seekQuestEntrance) { + if (setlevel) { + const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(WM_DIABRTNLVL); + if (!triggerIndex) { + SpeakText(_("No quest exits found."), true); + return; + } + + const TriggerStruct &trigger = trigs[*triggerIndex]; + const Point targetPosition { trigger.position.x, trigger.position.y }; + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + std::string message = TriggerLabelForSpeech(trigger); + if (!message.empty()) + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); + return; + } + + if (const std::optional entrance = FindNearestQuestSetLevelEntranceOnCurrentLevel(); entrance) { + const Point targetPosition = entrance->entrancePosition; + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + + std::string message { _(QuestLevelNames[entrance->questLevel]) }; + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); + return; + } + + SpeakText(_("No quest entrances found."), true); + return; + } + + if (leveltype == DTYPE_TOWN) { + const std::vector dungeonCandidates = CollectTownDungeonTriggerIndices(); + if (dungeonCandidates.empty()) { + SpeakText(_("No exits found."), true); + return; + } + + if (cycleTownDungeon) { + if (dungeonCandidates.size() <= 1) { + SpeakText(_("No other dungeon entrances found."), true); + return; + } + + const int current = LockedTownDungeonTriggerIndex.value_or(-1); + const std::optional next = FindNextTownDungeonTriggerIndex(dungeonCandidates, current); + if (!next) { + SpeakText(_("No other dungeon entrances found."), true); + return; + } + + LockedTownDungeonTriggerIndex = *next; + const std::string label = TriggerLabelForSpeech(trigs[*next]); + if (!label.empty()) + SpeakText(label, true); + return; + } + + const int triggerIndex = FindLockedTownDungeonTriggerIndex(dungeonCandidates) + .value_or(FindDefaultTownDungeonTriggerIndex(dungeonCandidates).value_or(dungeonCandidates.front())); + LockedTownDungeonTriggerIndex = triggerIndex; + + const TriggerStruct &trigger = trigs[triggerIndex]; + const Point targetPosition { trigger.position.x, trigger.position.y }; + + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + std::string message = TriggerLabelForSpeech(trigger); + if (!message.empty()) + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + + SpeakText(message, true); + return; + } + + if (const std::optional portalPosition = FindNearestTownPortalOnCurrentLevel(); portalPosition) { + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, *portalPosition); + std::string message { _("Return to town") }; + message.append(": "); + if (!path) + AppendDirectionalFallback(message, *portalPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); + return; + } + + const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(WM_DIABPREVLVL); + if (!triggerIndex) { + SpeakText(_("No exits found."), true); + return; + } + + const TriggerStruct &trigger = trigs[*triggerIndex]; + const Point targetPosition { trigger.position.x, trigger.position.y }; + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + std::string message = TriggerLabelForSpeech(trigger); + if (!message.empty()) + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); +} + +void SpeakNearestTownPortalInTownKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (leveltype != DTYPE_TOWN) { + SpeakText(_("Not in town."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const std::optional portal = FindNearestTownPortalInTown(); + if (!portal) { + SpeakText(_("No town portals found."), true); + return; + } + + const Point startPosition = MyPlayer->position.future; + const Point targetPosition = portal->position; + + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + + std::string message = TownPortalLabelForSpeech(Portals[portal->portalIndex]); + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + + SpeakText(message, true); +} + +void SpeakNearestStairsDownKeyPressed() +{ + SpeakNearestStairsKeyPressed(WM_DIABNEXTLVL); +} + +void SpeakNearestStairsUpKeyPressed() +{ + SpeakNearestStairsKeyPressed(WM_DIABPREVLVL); +} + +bool IsKeyboardWalkAllowed() +{ + return CanPlayerTakeAction() + && !InGameMenu() + && !IsPlayerInStore() + && !QuestLogIsOpen + && !HelpFlag + && !ChatLogFlag + && !ChatFlag + && !DropGoldFlag + && !IsStashOpen + && !IsWithdrawGoldOpen + && !AutomapActive + && !invflag + && !CharFlag + && !SpellbookFlag + && !SpellSelectFlag + && !qtextflag; +} + +void KeyboardWalkNorthKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::NorthEast); +} + +void KeyboardWalkSouthKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::SouthWest); +} + +void KeyboardWalkEastKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::SouthEast); +} + +void KeyboardWalkWestKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::NorthWest); +} + +void SpeakNearestUnexploredTileKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (leveltype == DTYPE_TOWN) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const Point startPosition = MyPlayer->position.future; + const std::optional target = FindNearestUnexploredTile(startPosition); + if (!target) { + SpeakText(_("No unexplored areas found."), true); + return; + } + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, *target); + std::string message; + if (!path) + AppendDirectionalFallback(message, *target - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + + SpeakText(message, true); +} + +} // namespace devilution diff --git a/Source/utils/navigation_speech.hpp b/Source/utils/navigation_speech.hpp new file mode 100644 index 000000000..147d0d404 --- /dev/null +++ b/Source/utils/navigation_speech.hpp @@ -0,0 +1,21 @@ +/** + * @file utils/navigation_speech.hpp + * + * Navigation speech: exit/stairs/portal/unexplored speech and keyboard walk keys. + */ +#pragma once + +namespace devilution { + +void SpeakNearestExitKeyPressed(); +void SpeakNearestTownPortalInTownKeyPressed(); +void SpeakNearestStairsDownKeyPressed(); +void SpeakNearestStairsUpKeyPressed(); +void KeyboardWalkNorthKeyPressed(); +void KeyboardWalkSouthKeyPressed(); +void KeyboardWalkEastKeyPressed(); +void KeyboardWalkWestKeyPressed(); +void SpeakNearestUnexploredTileKeyPressed(); +bool IsKeyboardWalkAllowed(); + +} // namespace devilution diff --git a/Source/utils/walk_path_speech.cpp b/Source/utils/walk_path_speech.cpp new file mode 100644 index 000000000..105c329d2 --- /dev/null +++ b/Source/utils/walk_path_speech.cpp @@ -0,0 +1,604 @@ +/** + * @file utils/walk_path_speech.cpp + * + * Walk-path helpers, PosOk variants, and BFS pathfinding for accessibility speech. + */ +#include "utils/walk_path_speech.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "engine/path.h" +#include "levels/gendung.h" +#include "levels/tile_properties.hpp" +#include "monster.h" +#include "objects.h" +#include "player.h" +#include "utils/language.h" +#include "utils/str_cat.hpp" + +namespace devilution { + +Point NextPositionForWalkDirection(Point position, int8_t walkDir) +{ + switch (walkDir) { + case WALK_NE: + return { position.x, position.y - 1 }; + case WALK_NW: + return { position.x - 1, position.y }; + case WALK_SE: + return { position.x + 1, position.y }; + case WALK_SW: + return { position.x, position.y + 1 }; + case WALK_N: + return { position.x - 1, position.y - 1 }; + case WALK_E: + return { position.x + 1, position.y - 1 }; + case WALK_S: + return { position.x + 1, position.y + 1 }; + case WALK_W: + return { position.x - 1, position.y + 1 }; + default: + return position; + } +} + +Point PositionAfterWalkPathSteps(Point start, const int8_t *path, int steps) +{ + Point position = start; + for (int i = 0; i < steps; ++i) { + position = NextPositionForWalkDirection(position, path[i]); + } + return position; +} + +int8_t OppositeWalkDirection(int8_t walkDir) +{ + switch (walkDir) { + case WALK_NE: + return WALK_SW; + case WALK_SW: + return WALK_NE; + case WALK_NW: + return WALK_SE; + case WALK_SE: + return WALK_NW; + case WALK_N: + return WALK_S; + case WALK_S: + return WALK_N; + case WALK_E: + return WALK_W; + case WALK_W: + return WALK_E; + default: + return WALK_NONE; + } +} + +bool PosOkPlayerIgnoreDoors(const Player &player, Point position) +{ + if (!InDungeonBounds(position)) + return false; + if (!IsTileWalkable(position, /*ignoreDoors=*/true)) + return false; + + Player *otherPlayer = PlayerAtPosition(position); + if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) + return false; + + if (dMonster[position.x][position.y] != 0) { + if (leveltype == DTYPE_TOWN) + return false; + if (dMonster[position.x][position.y] <= 0) + return false; + if (!Monsters[dMonster[position.x][position.y] - 1].hasNoLife()) + return false; + } + + return true; +} + +bool IsTileWalkableForTrackerPath(Point position, bool ignoreDoors, bool ignoreBreakables) +{ + Object *object = FindObjectAtPosition(position); + if (object != nullptr) { + if (ignoreDoors && object->isDoor()) { + return true; + } + if (ignoreBreakables && object->_oSolidFlag && object->IsBreakable()) { + return true; + } + if (object->_oSolidFlag) { + return false; + } + } + + return IsTileNotSolid(position); +} + +bool PosOkPlayerIgnoreMonsters(const Player &player, Point position) +{ + if (!InDungeonBounds(position)) + return false; + if (!IsTileWalkableForTrackerPath(position, /*ignoreDoors=*/false, /*ignoreBreakables=*/false)) + return false; + + Player *otherPlayer = PlayerAtPosition(position); + if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) + return false; + + return true; +} + +bool PosOkPlayerIgnoreDoorsAndMonsters(const Player &player, Point position) +{ + if (!InDungeonBounds(position)) + return false; + if (!IsTileWalkableForTrackerPath(position, /*ignoreDoors=*/true, /*ignoreBreakables=*/false)) + return false; + + Player *otherPlayer = PlayerAtPosition(position); + if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) + return false; + + return true; +} + +bool PosOkPlayerIgnoreDoorsMonstersAndBreakables(const Player &player, Point position) +{ + if (!InDungeonBounds(position)) + return false; + if (!IsTileWalkableForTrackerPath(position, /*ignoreDoors=*/true, /*ignoreBreakables=*/true)) + return false; + + Player *otherPlayer = PlayerAtPosition(position); + if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) + return false; + + return true; +} + +namespace { + +using PosOkForSpeechFn = bool (*)(const Player &, Point); + +template +std::optional> FindKeyboardWalkPathForSpeechBfs(const Player &player, Point startPosition, Point destinationPosition, PosOkForSpeechFn posOk, const std::array &walkDirections, bool allowDiagonalSteps, bool allowDestinationNonWalkable) +{ + if (!InDungeonBounds(startPosition) || !InDungeonBounds(destinationPosition)) + return std::nullopt; + + if (startPosition == destinationPosition) + return std::vector {}; + + std::array visited {}; + std::array parentDir {}; + parentDir.fill(WALK_NONE); + + std::queue queue; + + const auto indexOf = [](Point position) -> size_t { + return static_cast(position.x) + static_cast(position.y) * MAXDUNX; + }; + + const auto enqueue = [&](Point current, int8_t dir) { + const Point next = NextPositionForWalkDirection(current, dir); + if (!InDungeonBounds(next)) + return; + + const size_t idx = indexOf(next); + if (visited[idx]) + return; + + const bool ok = posOk(player, next); + if (ok) { + if (!CanStep(current, next)) + return; + } else { + if (!allowDestinationNonWalkable || next != destinationPosition) + return; + } + + visited[idx] = true; + parentDir[idx] = dir; + queue.push(next); + }; + + visited[indexOf(startPosition)] = true; + queue.push(startPosition); + + const auto hasReachedDestination = [&]() -> bool { + return visited[indexOf(destinationPosition)]; + }; + + while (!queue.empty() && !hasReachedDestination()) { + const Point current = queue.front(); + queue.pop(); + + const Displacement delta = destinationPosition - current; + const int deltaAbsX = delta.deltaX >= 0 ? delta.deltaX : -delta.deltaX; + const int deltaAbsY = delta.deltaY >= 0 ? delta.deltaY : -delta.deltaY; + + std::array prioritizedDirs; + size_t prioritizedCount = 0; + + const auto addUniqueDir = [&](int8_t dir) { + if (dir == WALK_NONE) + return; + for (size_t i = 0; i < prioritizedCount; ++i) { + if (prioritizedDirs[i] == dir) + return; + } + prioritizedDirs[prioritizedCount++] = dir; + }; + + const int8_t xDir = delta.deltaX > 0 ? WALK_SE : (delta.deltaX < 0 ? WALK_NW : WALK_NONE); + const int8_t yDir = delta.deltaY > 0 ? WALK_SW : (delta.deltaY < 0 ? WALK_NE : WALK_NONE); + + if (allowDiagonalSteps && delta.deltaX != 0 && delta.deltaY != 0) { + const int8_t diagDir = delta.deltaX > 0 ? (delta.deltaY > 0 ? WALK_S : WALK_E) : (delta.deltaY > 0 ? WALK_W : WALK_N); + addUniqueDir(diagDir); + } + + if (deltaAbsX >= deltaAbsY) { + addUniqueDir(xDir); + addUniqueDir(yDir); + } else { + addUniqueDir(yDir); + addUniqueDir(xDir); + } + for (const int8_t dir : walkDirections) { + addUniqueDir(dir); + } + + for (size_t i = 0; i < prioritizedCount; ++i) { + enqueue(current, prioritizedDirs[i]); + } + } + + if (!hasReachedDestination()) + return std::nullopt; + + std::vector path; + Point position = destinationPosition; + while (position != startPosition) { + const int8_t dir = parentDir[indexOf(position)]; + if (dir == WALK_NONE) + return std::nullopt; + + path.push_back(dir); + position = NextPositionForWalkDirection(position, OppositeWalkDirection(dir)); + } + + std::reverse(path.begin(), path.end()); + return path; +} + +std::optional> FindKeyboardWalkPathForSpeechWithPosOk(const Player &player, Point startPosition, Point destinationPosition, PosOkForSpeechFn posOk, bool allowDestinationNonWalkable) +{ + constexpr std::array AxisDirections = { + WALK_NE, + WALK_SW, + WALK_SE, + WALK_NW, + }; + + constexpr std::array AllDirections = { + WALK_NE, + WALK_SW, + WALK_SE, + WALK_NW, + WALK_N, + WALK_E, + WALK_S, + WALK_W, + }; + + if (const std::optional> axisPath = FindKeyboardWalkPathForSpeechBfs(player, startPosition, destinationPosition, posOk, AxisDirections, /*allowDiagonalSteps=*/false, allowDestinationNonWalkable); axisPath) { + return axisPath; + } + + return FindKeyboardWalkPathForSpeechBfs(player, startPosition, destinationPosition, posOk, AllDirections, /*allowDiagonalSteps=*/true, allowDestinationNonWalkable); +} + +template +std::optional> FindKeyboardWalkPathToClosestReachableForSpeechBfs(const Player &player, Point startPosition, Point destinationPosition, PosOkForSpeechFn posOk, const std::array &walkDirections, bool allowDiagonalSteps, Point &closestPosition) +{ + if (!InDungeonBounds(startPosition) || !InDungeonBounds(destinationPosition)) + return std::nullopt; + + if (startPosition == destinationPosition) { + closestPosition = destinationPosition; + return std::vector {}; + } + + std::array visited {}; + std::array parentDir {}; + std::array depth {}; + parentDir.fill(WALK_NONE); + depth.fill(0); + + std::queue queue; + + const auto indexOf = [](Point position) -> size_t { + return static_cast(position.x) + static_cast(position.y) * MAXDUNX; + }; + + const auto enqueue = [&](Point current, int8_t dir) { + const Point next = NextPositionForWalkDirection(current, dir); + if (!InDungeonBounds(next)) + return; + + const size_t nextIdx = indexOf(next); + if (visited[nextIdx]) + return; + + if (!posOk(player, next)) + return; + if (!CanStep(current, next)) + return; + + const size_t currentIdx = indexOf(current); + visited[nextIdx] = true; + parentDir[nextIdx] = dir; + depth[nextIdx] = static_cast(depth[currentIdx] + 1); + queue.push(next); + }; + + const size_t startIdx = indexOf(startPosition); + visited[startIdx] = true; + queue.push(startPosition); + + Point best = startPosition; + int bestDistance = startPosition.WalkingDistance(destinationPosition); + uint16_t bestDepth = 0; + + const auto considerBest = [&](Point position) { + const int distance = position.WalkingDistance(destinationPosition); + const uint16_t posDepth = depth[indexOf(position)]; + if (distance < bestDistance || (distance == bestDistance && posDepth < bestDepth)) { + best = position; + bestDistance = distance; + bestDepth = posDepth; + } + }; + + while (!queue.empty()) { + const Point current = queue.front(); + queue.pop(); + + considerBest(current); + + const Displacement delta = destinationPosition - current; + const int deltaAbsX = delta.deltaX >= 0 ? delta.deltaX : -delta.deltaX; + const int deltaAbsY = delta.deltaY >= 0 ? delta.deltaY : -delta.deltaY; + + std::array prioritizedDirs; + size_t prioritizedCount = 0; + + const auto addUniqueDir = [&](int8_t dir) { + if (dir == WALK_NONE) + return; + for (size_t i = 0; i < prioritizedCount; ++i) { + if (prioritizedDirs[i] == dir) + return; + } + prioritizedDirs[prioritizedCount++] = dir; + }; + + const int8_t xDir = delta.deltaX > 0 ? WALK_SE : (delta.deltaX < 0 ? WALK_NW : WALK_NONE); + const int8_t yDir = delta.deltaY > 0 ? WALK_SW : (delta.deltaY < 0 ? WALK_NE : WALK_NONE); + + if (allowDiagonalSteps && delta.deltaX != 0 && delta.deltaY != 0) { + const int8_t diagDir = delta.deltaX > 0 ? (delta.deltaY > 0 ? WALK_S : WALK_E) : (delta.deltaY > 0 ? WALK_W : WALK_N); + addUniqueDir(diagDir); + } + + if (deltaAbsX >= deltaAbsY) { + addUniqueDir(xDir); + addUniqueDir(yDir); + } else { + addUniqueDir(yDir); + addUniqueDir(xDir); + } + for (const int8_t dir : walkDirections) { + addUniqueDir(dir); + } + + for (size_t i = 0; i < prioritizedCount; ++i) { + enqueue(current, prioritizedDirs[i]); + } + } + + closestPosition = best; + if (best == startPosition) + return std::vector {}; + + std::vector path; + Point position = best; + while (position != startPosition) { + const int8_t dir = parentDir[indexOf(position)]; + if (dir == WALK_NONE) + return std::nullopt; + + path.push_back(dir); + position = NextPositionForWalkDirection(position, OppositeWalkDirection(dir)); + } + + std::reverse(path.begin(), path.end()); + return path; +} + +} // namespace + +std::optional> FindKeyboardWalkPathForSpeech(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +{ + return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, allowDestinationNonWalkable); +} + +std::optional> FindKeyboardWalkPathForSpeechRespectingDoors(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +{ + return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayer, allowDestinationNonWalkable); +} + +std::optional> FindKeyboardWalkPathForSpeechIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +{ + return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoorsAndMonsters, allowDestinationNonWalkable); +} + +std::optional> FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +{ + return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreMonsters, allowDestinationNonWalkable); +} + +std::optional> FindKeyboardWalkPathForSpeechLenient(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +{ + return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoorsMonstersAndBreakables, allowDestinationNonWalkable); +} + +std::optional> FindKeyboardWalkPathToClosestReachableForSpeech(const Player &player, Point startPosition, Point destinationPosition, Point &closestPosition) +{ + constexpr std::array AxisDirections = { + WALK_NE, + WALK_SW, + WALK_SE, + WALK_NW, + }; + + constexpr std::array AllDirections = { + WALK_NE, + WALK_SW, + WALK_SE, + WALK_NW, + WALK_N, + WALK_E, + WALK_S, + WALK_W, + }; + + Point axisClosest; + const std::optional> axisPath = FindKeyboardWalkPathToClosestReachableForSpeechBfs(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, AxisDirections, /*allowDiagonalSteps=*/false, axisClosest); + + Point diagClosest; + const std::optional> diagPath = FindKeyboardWalkPathToClosestReachableForSpeechBfs(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, AllDirections, /*allowDiagonalSteps=*/true, diagClosest); + + if (!axisPath && !diagPath) + return std::nullopt; + if (!axisPath) { + closestPosition = diagClosest; + return diagPath; + } + if (!diagPath) { + closestPosition = axisClosest; + return axisPath; + } + + const int axisDistance = axisClosest.WalkingDistance(destinationPosition); + const int diagDistance = diagClosest.WalkingDistance(destinationPosition); + if (diagDistance < axisDistance) { + closestPosition = diagClosest; + return diagPath; + } + + closestPosition = axisClosest; + return axisPath; +} + +void AppendKeyboardWalkPathForSpeech(std::string &message, const std::vector &path) +{ + if (path.empty()) { + message.append(_("here")); + return; + } + + bool any = false; + const auto appendPart = [&](std::string_view label, int distance) { + if (distance == 0) + return; + if (any) + message.append(", "); + StrAppend(message, label, " ", distance); + any = true; + }; + + const auto labelForWalkDirection = [](int8_t dir) -> std::string_view { + switch (dir) { + case WALK_NE: + return _("north"); + case WALK_SW: + return _("south"); + case WALK_SE: + return _("east"); + case WALK_NW: + return _("west"); + case WALK_N: + return _("northwest"); + case WALK_E: + return _("northeast"); + case WALK_S: + return _("southeast"); + case WALK_W: + return _("southwest"); + default: + return {}; + } + }; + + int8_t currentDir = path.front(); + int runLength = 1; + for (size_t i = 1; i < path.size(); ++i) { + if (path[i] == currentDir) { + ++runLength; + continue; + } + + const std::string_view label = labelForWalkDirection(currentDir); + if (!label.empty()) + appendPart(label, runLength); + + currentDir = path[i]; + runLength = 1; + } + + const std::string_view label = labelForWalkDirection(currentDir); + if (!label.empty()) + appendPart(label, runLength); + + if (!any) + message.append(_("here")); +} + +void AppendDirectionalFallback(std::string &message, const Displacement &delta) +{ + bool any = false; + const auto appendPart = [&](std::string_view label, int distance) { + if (distance == 0) + return; + if (any) + message.append(", "); + StrAppend(message, label, " ", distance); + any = true; + }; + + if (delta.deltaY < 0) + appendPart(_("north"), -delta.deltaY); + else if (delta.deltaY > 0) + appendPart(_("south"), delta.deltaY); + + if (delta.deltaX > 0) + appendPart(_("east"), delta.deltaX); + else if (delta.deltaX < 0) + appendPart(_("west"), -delta.deltaX); + + if (!any) + message.append(_("here")); +} + +} // namespace devilution diff --git a/Source/utils/walk_path_speech.hpp b/Source/utils/walk_path_speech.hpp new file mode 100644 index 000000000..1188c946c --- /dev/null +++ b/Source/utils/walk_path_speech.hpp @@ -0,0 +1,44 @@ +/** + * @file utils/walk_path_speech.hpp + * + * Walk-path helpers, PosOk variants, and BFS pathfinding for accessibility speech. + */ +#pragma once + +#include +#include +#include +#include + +#include "engine/displacement.hpp" +#include "engine/point.hpp" + +namespace devilution { + +struct Player; + +// Walk direction helpers +Point NextPositionForWalkDirection(Point position, int8_t walkDir); +Point PositionAfterWalkPathSteps(Point start, const int8_t *path, int steps); +int8_t OppositeWalkDirection(int8_t walkDir); + +// PosOk variants for pathfinding +bool PosOkPlayerIgnoreDoors(const Player &player, Point position); +bool IsTileWalkableForTrackerPath(Point position, bool ignoreDoors, bool ignoreBreakables); +bool PosOkPlayerIgnoreMonsters(const Player &player, Point position); +bool PosOkPlayerIgnoreDoorsAndMonsters(const Player &player, Point position); +bool PosOkPlayerIgnoreDoorsMonstersAndBreakables(const Player &player, Point position); + +// BFS pathfinding for speech +std::optional> FindKeyboardWalkPathForSpeech(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); +std::optional> FindKeyboardWalkPathForSpeechRespectingDoors(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); +std::optional> FindKeyboardWalkPathForSpeechIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); +std::optional> FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); +std::optional> FindKeyboardWalkPathForSpeechLenient(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); +std::optional> FindKeyboardWalkPathToClosestReachableForSpeech(const Player &player, Point startPosition, Point destinationPosition, Point &closestPosition); + +// Speech formatting +void AppendKeyboardWalkPathForSpeech(std::string &message, const std::vector &path); +void AppendDirectionalFallback(std::string &message, const Displacement &delta); + +} // namespace devilution From 0ea5705dbfd56df3c0bce5ca469529a3fbb5396f Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:17:39 -0500 Subject: [PATCH 02/13] access: wire extracted modules into diablo.cpp and build system Remove ~4,042 lines of extracted function bodies and forward declarations from diablo.cpp. Add includes for the 6 new module headers. Update CancelAutoWalkInternal() to delegate to ResetAutoWalkTracker() and ResetAutoWalkTownNpc(). Remove unused includes from diablo.cpp that were only needed by the extracted code (, format_int.hpp, tile_properties.hpp). Add the 6 new .cpp files to Source/CMakeLists.txt so they are compiled into libdevilutionx. Also includes post-review cleanups: - Remove unused automap.h include from accessibility_keys.cpp - Remove unused walk_path_speech.hpp include from diablo.cpp - Replace .size() == 0 with .empty() in accessibility_keys.cpp - Simplify CanAutomapBeToggledOff to direct return expression - Remove unreachable dead code and dead FindPreferredExitTriggerIndex function from navigation_speech.cpp Co-Authored-By: Claude Opus 4.5 --- Source/CMakeLists.txt | 54 +- Source/diablo.cpp | 11363 +++++++++++++--------------------------- 2 files changed, 3690 insertions(+), 7727 deletions(-) diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 93bd22a71..0646bd366 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -41,6 +41,7 @@ set(libdevilutionx_SRCS control/control_infobox.cpp control/control_panel.cpp + controls/accessibility_keys.cpp controls/axis_direction.cpp controls/controller_motion.cpp controls/controller.cpp @@ -51,6 +52,8 @@ set(libdevilutionx_SRCS controls/menu_controls.cpp controls/modifier_hints.cpp controls/plrctrls.cpp + controls/town_npc_nav.cpp + controls/tracker.cpp DiabloUI/button.cpp DiabloUI/credits.cpp @@ -160,13 +163,16 @@ set(libdevilutionx_SRCS tables/textdat.cpp tables/townerdat.cpp + utils/accessibility_announcements.cpp utils/display.cpp - utils/language.cpp - utils/proximity_audio.cpp - utils/sdl_bilinear_scale.cpp - utils/sdl_thread.cpp - utils/surface_to_clx.cpp - utils/timer.cpp) + utils/language.cpp + utils/navigation_speech.cpp + utils/proximity_audio.cpp + utils/sdl_bilinear_scale.cpp + utils/sdl_thread.cpp + utils/surface_to_clx.cpp + utils/timer.cpp + utils/walk_path_speech.cpp) # These files are responsible for most of the runtime in Debug mode. # Apply some optimizations to them even in Debug mode to get reasonable performance. @@ -739,15 +745,15 @@ target_link_dependencies(libdevilutionx_utf8 PRIVATE SheenBidi::SheenBidi ) -if(NOSOUND) - add_devilutionx_object_library(libdevilutionx_sound - effects_stubs.cpp - engine/sound_pool_stubs.cpp - engine/sound_stubs.cpp - ) - target_link_dependencies(libdevilutionx_sound PUBLIC - DevilutionX::SDL - fmt::fmt +if(NOSOUND) + add_devilutionx_object_library(libdevilutionx_sound + effects_stubs.cpp + engine/sound_pool_stubs.cpp + engine/sound_stubs.cpp + ) + target_link_dependencies(libdevilutionx_sound PUBLIC + DevilutionX::SDL + fmt::fmt magic_enum::magic_enum tl unordered_dense::unordered_dense @@ -755,15 +761,15 @@ if(NOSOUND) libdevilutionx_random libdevilutionx_sdl2_to_1_2_backports ) -else() - add_devilutionx_object_library(libdevilutionx_sound - effects.cpp - engine/sound_pool.cpp - engine/sound.cpp - utils/soundsample.cpp - ) - if(USE_SDL3) - target_link_dependencies(libdevilutionx_sound PUBLIC +else() + add_devilutionx_object_library(libdevilutionx_sound + effects.cpp + engine/sound_pool.cpp + engine/sound.cpp + utils/soundsample.cpp + ) + if(USE_SDL3) + target_link_dependencies(libdevilutionx_sound PUBLIC SDL3_mixer::SDL3_mixer ) else() diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 9dbece3d7..12dedc5e5 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -1,7703 +1,3660 @@ -/** - * @file diablo.cpp - * - * Implementation of the main game initialization functions. - */ -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef USE_SDL3 -#include -#include -#include -#else -#include - -#ifdef USE_SDL1 -#include "utils/sdl2_to_1_2_backports.h" -#endif -#endif - -#include - -#include - -#include "DiabloUI/selstart.h" -#include "appfat.h" -#include "automap.h" -#include "capture.h" -#include "control/control.hpp" -#include "cursor.h" -#include "dead.h" -#ifdef _DEBUG -#include "debug.h" -#endif -#include "DiabloUI/diabloui.h" -#include "controls/control_mode.hpp" -#include "controls/keymapper.hpp" -#include "controls/plrctrls.h" -#include "controls/remap_keyboard.h" -#include "diablo.h" -#include "diablo_msg.hpp" -#include "discord/discord.h" -#include "doom.h" -#include "encrypt.h" -#include "engine/backbuffer_state.hpp" -#include "engine/clx_sprite.hpp" -#include "engine/demomode.h" -#include "engine/dx.h" -#include "engine/events.hpp" -#include "engine/load_cel.hpp" -#include "engine/load_file.hpp" -#include "engine/path.h" -#include "engine/random.hpp" -#include "engine/render/clx_render.hpp" -#include "engine/sound.h" -#include "game_mode.hpp" -#include "gamemenu.h" -#include "gmenu.h" -#include "headless_mode.hpp" -#include "help.h" -#include "hwcursor.hpp" -#include "init.hpp" -#include "inv.h" -#include "levels/drlg_l1.h" -#include "levels/drlg_l2.h" -#include "levels/drlg_l3.h" -#include "levels/drlg_l4.h" -#include "levels/gendung.h" -#include "levels/setmaps.h" -#include "levels/themes.h" -#include "levels/tile_properties.hpp" -#include "levels/town.h" -#include "levels/trigs.h" -#include "lighting.h" -#include "loadsave.h" -#include "lua/lua_global.hpp" -#include "menu.h" -#include "minitext.h" -#include "missiles.h" -#include "movie.h" -#include "multi.h" -#include "nthread.h" -#include "objects.h" -#include "options.h" -#include "panels/charpanel.hpp" -#include "panels/console.hpp" -#include "panels/info_box.hpp" -#include "panels/partypanel.hpp" -#include "panels/spell_book.hpp" -#include "panels/spell_list.hpp" -#include "pfile.h" -#include "plrmsg.h" -#include "portal.h" -#include "qol/chatlog.h" -#include "qol/floatingnumbers.h" -#include "qol/itemlabels.h" -#include "qol/monhealthbar.h" -#include "qol/stash.h" -#include "qol/xpbar.h" -#include "quick_messages.hpp" -#include "restrict.h" -#include "stores.h" -#include "storm/storm_net.hpp" -#include "storm/storm_svid.h" -#include "tables/monstdat.h" -#include "tables/playerdat.hpp" -#include "towners.h" -#include "track.h" -#include "utils/console.h" -#include "utils/display.h" -#include "utils/format_int.hpp" -#include "utils/is_of.hpp" -#include "utils/language.h" -#include "utils/parse_int.hpp" -#include "utils/paths.h" -#include "utils/proximity_audio.hpp" -#include "utils/screen_reader.hpp" -#include "utils/sdl_compat.h" -#include "utils/sdl_thread.h" -#include "utils/status_macros.hpp" -#include "utils/str_cat.hpp" -#include "utils/utf8.hpp" - -#ifndef USE_SDL1 -#include "controls/touch/gamepad.h" -#include "controls/touch/renderers.h" -#endif - -#ifdef __vita__ -#include "platform/vita/touch.h" -#endif - -#ifdef GPERF_HEAP_FIRST_GAME_ITERATION -#include -#endif - -namespace devilution { - -uint32_t DungeonSeeds[NUMLEVELS]; -std::optional LevelSeeds[NUMLEVELS]; -Point MousePosition; -bool gbRunGameResult; -bool ReturnToMainMenu; -/** Enable updating of player character, set to false once Diablo dies */ -bool gbProcessPlayers; -bool gbLoadGame; -bool cineflag; -int PauseMode; -clicktype sgbMouseDown; -uint16_t gnTickDelay = 50; -char gszProductName[64] = "DevilutionX vUnknown"; - -#ifdef _DEBUG -bool DebugDisableNetworkTimeout = false; -std::vector DebugCmdsFromCommandLine; -#endif -GameLogicStep gGameLogicStep = GameLogicStep::None; - -/** This and the following mouse variables are for handling in-game click-and-hold actions */ -PlayerActionType LastPlayerAction = PlayerActionType::None; - -// Controller support: Actions to run after updating the cursor state. -// Defined in SourceX/controls/plctrls.cpp. -extern void plrctrls_after_check_curs_move(); -extern void plrctrls_every_frame(); -extern void plrctrls_after_game_logic(); - -namespace { - -char gszVersionNumber[64] = "internal version unknown"; - -void SelectNextTownNpcKeyPressed(); -void SelectPreviousTownNpcKeyPressed(); -void UpdateAutoWalkTownNpc(); -void UpdateAutoWalkTracker(); -void AutoWalkToTrackerTargetKeyPressed(); -void SpeakSelectedSpeedbookSpell(); -void SpellBookKeyPressed(); -std::optional> FindKeyboardWalkPathForSpeech(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); -std::optional> FindKeyboardWalkPathForSpeechRespectingDoors(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); -std::optional> FindKeyboardWalkPathForSpeechIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); -std::optional> FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); -std::optional> FindKeyboardWalkPathForSpeechLenient(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); -std::optional> FindKeyboardWalkPathToClosestReachableForSpeech(const Player &player, Point startPosition, Point destinationPosition, Point &closestPosition); -void AppendKeyboardWalkPathForSpeech(std::string &message, const std::vector &path); -void AppendDirectionalFallback(std::string &message, const Displacement &delta); - -bool gbGameLoopStartup; -bool forceSpawn; -bool forceDiablo; -int sgnTimeoutCurs; -bool gbShowIntro = true; -/** To know if these things have been done when we get to the diablo_deinit() function */ -bool was_archives_init = false; -/** To know if surfaces have been initialized or not */ -bool was_window_init = false; -bool was_ui_init = false; - -void StartGame(interface_mode uMsg) -{ - CalcViewportGeometry(); - cineflag = false; - InitCursor(); -#ifdef _DEBUG - LoadDebugGFX(); -#endif - assert(HeadlessMode || ghMainWnd); - music_stop(); - InitMonsterHealthBar(); - InitXPBar(); - ShowProgress(uMsg); - gmenu_init_menu(); - InitLevelCursor(); - sgnTimeoutCurs = CURSOR_NONE; - sgbMouseDown = CLICK_NONE; - LastPlayerAction = PlayerActionType::None; -} - -void FreeGame() -{ - FreeMonsterHealthBar(); - FreeXPBar(); - FreeControlPan(); - FreeInvGFX(); - FreeGMenu(); - FreeQuestText(); - FreeInfoBoxGfx(); - FreeStoreMem(); - - for (Player &player : Players) - ResetPlayerGFX(player); - - FreeCursor(); -#ifdef _DEBUG - FreeDebugGFX(); -#endif - FreeGameMem(); - stream_stop(); - music_stop(); -} - -bool ProcessInput() -{ - if (PauseMode == 2) { - return false; - } - - plrctrls_every_frame(); - - if (!gbIsMultiplayer && gmenu_is_active()) { - RedrawViewport(); - return false; - } - - if (!gmenu_is_active() && sgnTimeoutCurs == CURSOR_NONE) { -#ifdef __vita__ - FinishSimulatedMouseClicks(MousePosition); -#endif - CheckCursMove(); - plrctrls_after_check_curs_move(); - RepeatPlayerAction(); - } - - return true; -} - -void LeftMouseCmd(bool bShift) -{ - bool bNear; - - assert(!GetMainPanel().contains(MousePosition)); - - if (leveltype == DTYPE_TOWN) { - CloseGoldWithdraw(); - CloseStash(); - if (pcursitem != -1 && pcurs == CURSOR_HAND) - NetSendCmdLocParam1(true, invflag ? CMD_GOTOGETITEM : CMD_GOTOAGETITEM, cursPosition, pcursitem); - if (pcursmonst != -1) - NetSendCmdLocParam1(true, CMD_TALKXY, cursPosition, pcursmonst); - if (pcursitem == -1 && pcursmonst == -1 && PlayerUnderCursor == nullptr) { - LastPlayerAction = PlayerActionType::Walk; - NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, cursPosition); - } - return; - } - - const Player &myPlayer = *MyPlayer; - bNear = myPlayer.position.tile.WalkingDistance(cursPosition) < 2; - if (pcursitem != -1 && pcurs == CURSOR_HAND && !bShift) { - NetSendCmdLocParam1(true, invflag ? CMD_GOTOGETITEM : CMD_GOTOAGETITEM, cursPosition, pcursitem); - } else if (ObjectUnderCursor != nullptr && !ObjectUnderCursor->IsDisabled() && (!bShift || (bNear && ObjectUnderCursor->_oBreak == 1))) { - LastPlayerAction = PlayerActionType::OperateObject; - NetSendCmdLoc(MyPlayerId, true, pcurs == CURSOR_DISARM ? CMD_DISARMXY : CMD_OPOBJXY, cursPosition); - } else if (myPlayer.UsesRangedWeapon()) { - if (bShift) { - LastPlayerAction = PlayerActionType::Attack; - NetSendCmdLoc(MyPlayerId, true, CMD_RATTACKXY, cursPosition); - } else if (pcursmonst != -1) { - if (CanTalkToMonst(Monsters[pcursmonst])) { - NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); - } else { - LastPlayerAction = PlayerActionType::AttackMonsterTarget; - NetSendCmdParam1(true, CMD_RATTACKID, pcursmonst); - } - } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { - LastPlayerAction = PlayerActionType::AttackPlayerTarget; - NetSendCmdParam1(true, CMD_RATTACKPID, PlayerUnderCursor->getId()); - } - } else { - if (bShift) { - if (pcursmonst != -1) { - if (CanTalkToMonst(Monsters[pcursmonst])) { - NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); - } else { - LastPlayerAction = PlayerActionType::Attack; - NetSendCmdLoc(MyPlayerId, true, CMD_SATTACKXY, cursPosition); - } - } else { - LastPlayerAction = PlayerActionType::Attack; - NetSendCmdLoc(MyPlayerId, true, CMD_SATTACKXY, cursPosition); - } - } else if (pcursmonst != -1) { - LastPlayerAction = PlayerActionType::AttackMonsterTarget; - NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); - } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { - LastPlayerAction = PlayerActionType::AttackPlayerTarget; - NetSendCmdParam1(true, CMD_ATTACKPID, PlayerUnderCursor->getId()); - } - } - if (!bShift && pcursitem == -1 && ObjectUnderCursor == nullptr && pcursmonst == -1 && PlayerUnderCursor == nullptr) { - LastPlayerAction = PlayerActionType::Walk; - NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, cursPosition); - } -} - -bool TryOpenDungeonWithMouse() -{ - if (leveltype != DTYPE_TOWN) - return false; - - const Item &holdItem = MyPlayer->HoldItem; - if (holdItem.IDidx == IDI_RUNEBOMB && OpensHive(cursPosition)) - OpenHive(); - else if (holdItem.IDidx == IDI_MAPOFDOOM && OpensGrave(cursPosition)) - OpenGrave(); - else - return false; - - NewCursor(CURSOR_HAND); - return true; -} - -void LeftMouseDown(uint16_t modState) -{ - LastPlayerAction = PlayerActionType::None; - - if (gmenu_left_mouse(true)) - return; - - if (CheckMuteButton()) - return; - - if (sgnTimeoutCurs != CURSOR_NONE) - return; - - if (MyPlayerIsDead) { - CheckMainPanelButtonDead(); - return; - } - - if (PauseMode == 2) { - return; - } - if (DoomFlag) { - doom_close(); - return; - } - - if (SpellSelectFlag) { - SetSpell(); - return; - } - - if (IsPlayerInStore()) { - CheckStoreBtn(); - return; - } - - const bool isShiftHeld = (modState & SDL_KMOD_SHIFT) != 0; - const bool isCtrlHeld = (modState & SDL_KMOD_CTRL) != 0; - - if (!GetMainPanel().contains(MousePosition)) { - if (!gmenu_is_active() && !TryIconCurs()) { - if (QuestLogIsOpen && GetLeftPanel().contains(MousePosition)) { - QuestlogESC(); - } else if (qtextflag) { - qtextflag = false; - stream_stop(); - } else if (CharFlag && GetLeftPanel().contains(MousePosition)) { - CheckChrBtns(); - } else if (invflag && GetRightPanel().contains(MousePosition)) { - if (!DropGoldFlag) - CheckInvItem(isShiftHeld, isCtrlHeld); - } else if (IsStashOpen && GetLeftPanel().contains(MousePosition)) { - if (!IsWithdrawGoldOpen) - CheckStashItem(MousePosition, isShiftHeld, isCtrlHeld); - CheckStashButtonPress(MousePosition); - } else if (SpellbookFlag && GetRightPanel().contains(MousePosition)) { - CheckSBook(); - } else if (!MyPlayer->HoldItem.isEmpty()) { - if (!TryOpenDungeonWithMouse()) { - const Point currentPosition = MyPlayer->position.tile; - std::optional itemTile = FindAdjacentPositionForItem(currentPosition, GetDirection(currentPosition, cursPosition)); - if (itemTile) { - NetSendCmdPItem(true, CMD_PUTITEM, *itemTile, MyPlayer->HoldItem); - NewCursor(CURSOR_HAND); - } - } - } else { - CheckLevelButton(); - if (!LevelButtonDown) - LeftMouseCmd(isShiftHeld); - } - } - } else { - if (!ChatFlag && !DropGoldFlag && !IsWithdrawGoldOpen && !gmenu_is_active()) - CheckInvScrn(isShiftHeld, isCtrlHeld); - CheckMainPanelButton(); - CheckStashButtonPress(MousePosition); - if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) - NewCursor(CURSOR_HAND); - } -} - -void LeftMouseUp(uint16_t modState) -{ - gmenu_left_mouse(false); - CheckMuteButtonUp(); - if (MainPanelButtonDown) - CheckMainPanelButtonUp(); - CheckStashButtonRelease(MousePosition); - if (CharPanelButtonActive) { - const bool isShiftHeld = (modState & SDL_KMOD_SHIFT) != 0; - ReleaseChrBtns(isShiftHeld); - } - if (LevelButtonDown) - CheckLevelButtonUp(); - if (IsPlayerInStore()) - ReleaseStoreBtn(); -} - -void RightMouseDown(bool isShiftHeld) -{ - LastPlayerAction = PlayerActionType::None; - - if (gmenu_is_active() || sgnTimeoutCurs != CURSOR_NONE || PauseMode == 2 || MyPlayer->_pInvincible) { - return; - } - - if (qtextflag) { - qtextflag = false; - stream_stop(); - return; - } - - if (DoomFlag) { - doom_close(); - return; - } - if (IsPlayerInStore()) - return; - if (SpellSelectFlag) { - SetSpell(); - return; - } - if (SpellbookFlag && GetRightPanel().contains(MousePosition)) - return; - if (TryIconCurs()) - return; - if (pcursinvitem != -1 && UseInvItem(pcursinvitem)) - return; - if (pcursstashitem != StashStruct::EmptyCell && UseStashItem(pcursstashitem)) - return; - if (DidRightClickPartyPortrait()) - return; - if (pcurs == CURSOR_HAND) { - CheckPlrSpell(isShiftHeld); - } else if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { - NewCursor(CURSOR_HAND); - } -} - -void ReleaseKey(SDL_Keycode vkey) -{ - remap_keyboard_key(&vkey); - if (sgnTimeoutCurs != CURSOR_NONE) - return; - KeymapperRelease(vkey); -} - -void ClosePanels() -{ - if (CanPanelsCoverView()) { - if (!IsLeftPanelOpen() && IsRightPanelOpen() && MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); - } else if (!IsRightPanelOpen() && IsLeftPanelOpen() && MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition - Displacement { 160, 0 }); - } - } - CloseInventory(); - CloseCharPanel(); - SpellbookFlag = false; - QuestLogIsOpen = false; -} - -void PressKey(SDL_Keycode vkey, uint16_t modState) -{ - Options &options = GetOptions(); - remap_keyboard_key(&vkey); - - if (vkey == SDLK_UNKNOWN) - return; - - if (gmenu_presskeys(vkey) || CheckKeypress(vkey)) { - return; - } - - if (MyPlayerIsDead) { - if (vkey == SDLK_ESCAPE) { - if (!gbIsMultiplayer) { - if (gbValidSaveFile) - gamemenu_load_game(false); - else - gamemenu_exit_game(false); - } else { - NetSendCmd(true, CMD_RETOWN); - } - return; - } - if (sgnTimeoutCurs != CURSOR_NONE) { - return; - } - KeymapperPress(vkey); - if (vkey == SDLK_RETURN || vkey == SDLK_KP_ENTER) { - if ((modState & SDL_KMOD_ALT) != 0) { - options.Graphics.fullscreen.SetValue(!IsFullScreen()); - if (!demo::IsRunning()) SaveOptions(); - } else { - TypeChatMessage(); - } - } - if (vkey != SDLK_ESCAPE) { - return; - } - } - // Disallow player from accessing escape menu during the frames before the death message appears - if (vkey == SDLK_ESCAPE && MyPlayer->_pHitPoints > 0) { - if (!PressEscKey()) { - LastPlayerAction = PlayerActionType::None; - gamemenu_on(); - } - return; - } - - if (DropGoldFlag) { - control_drop_gold(vkey); - return; - } - if (IsWithdrawGoldOpen) { - WithdrawGoldKeyPress(vkey); - return; - } - - if (sgnTimeoutCurs != CURSOR_NONE) { - return; - } - - KeymapperPress(vkey); - - if (PauseMode == 2) { - if ((vkey == SDLK_RETURN || vkey == SDLK_KP_ENTER) && (modState & SDL_KMOD_ALT) != 0) { - options.Graphics.fullscreen.SetValue(!IsFullScreen()); - if (!demo::IsRunning()) SaveOptions(); - } - return; - } - - if (DoomFlag) { - doom_close(); - return; - } - - switch (vkey) { - case SDLK_PLUS: - case SDLK_KP_PLUS: - case SDLK_EQUALS: - case SDLK_KP_EQUALS: - if (AutomapActive) { - AutomapZoomIn(); - } - return; - case SDLK_MINUS: - case SDLK_KP_MINUS: - case SDLK_UNDERSCORE: - if (AutomapActive) { - AutomapZoomOut(); - } - return; -#ifdef _DEBUG - case SDLK_V: - if ((modState & SDL_KMOD_SHIFT) != 0) - NextDebugMonster(); - else - GetDebugMonster(); - return; -#endif - case SDLK_RETURN: - case SDLK_KP_ENTER: - if ((modState & SDL_KMOD_ALT) != 0) { - options.Graphics.fullscreen.SetValue(!IsFullScreen()); - if (!demo::IsRunning()) SaveOptions(); - } else if (CharFlag) { - CharacterScreenActivateSelection((modState & SDL_KMOD_SHIFT) != 0); - } else if (IsPlayerInStore()) { - StoreEnter(); - } else if (QuestLogIsOpen) { - QuestlogEnter(); - } else if (SpellSelectFlag) { - SetSpell(); - } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { - const Player &player = *MyPlayer; - if (IsValidSpell(player._pRSpell)) { - std::string msg; - StrAppend(msg, _("Selected: "), pgettext("spell", GetSpellData(player._pRSpell).sNameText)); - SpeakText(msg, /*force=*/true); - } else { - SpeakText(_("No spell selected."), /*force=*/true); - } - SpellBookKeyPressed(); - } else { - TypeChatMessage(); - } - return; - case SDLK_UP: - if (IsPlayerInStore()) { - StoreUp(); - } else if (QuestLogIsOpen) { - QuestlogUp(); - } else if (CharFlag) { - CharacterScreenMoveSelection(-1); - } else if (HelpFlag) { - HelpScrollUp(); - } else if (ChatLogFlag) { - ChatLogScrollUp(); - } else if (SpellSelectFlag) { - HotSpellMove({ AxisDirectionX_NONE, AxisDirectionY_UP }); - SpeakSelectedSpeedbookSpell(); - } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { - const std::optional next = GetSpellBookAdjacentAvailableSpell(SpellbookTab, *MyPlayer, MyPlayer->_pRSpell, -1); - if (next) { - MyPlayer->_pRSpell = *next; - MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*next)) != 0 ? SpellType::Skill - : (MyPlayer->_pISpells & GetSpellBitmask(*next)) != 0 ? SpellType::Charges - : SpellType::Spell; - UpdateSpellTarget(*next); - RedrawEverything(); - SpeakText(pgettext("spell", GetSpellData(*next).sNameText), /*force=*/true); - } - } else if (invflag) { - InventoryMoveFromKeyboard({ AxisDirectionX_NONE, AxisDirectionY_UP }); - } else if (AutomapActive) { - AutomapUp(); - } else if (IsStashOpen) { - Stash.PreviousPage(); - } - return; - case SDLK_DOWN: - if (IsPlayerInStore()) { - StoreDown(); - } else if (QuestLogIsOpen) { - QuestlogDown(); - } else if (CharFlag) { - CharacterScreenMoveSelection(+1); - } else if (HelpFlag) { - HelpScrollDown(); - } else if (ChatLogFlag) { - ChatLogScrollDown(); - } else if (SpellSelectFlag) { - HotSpellMove({ AxisDirectionX_NONE, AxisDirectionY_DOWN }); - SpeakSelectedSpeedbookSpell(); - } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { - const std::optional next = GetSpellBookAdjacentAvailableSpell(SpellbookTab, *MyPlayer, MyPlayer->_pRSpell, +1); - if (next) { - MyPlayer->_pRSpell = *next; - MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*next)) != 0 ? SpellType::Skill - : (MyPlayer->_pISpells & GetSpellBitmask(*next)) != 0 ? SpellType::Charges - : SpellType::Spell; - UpdateSpellTarget(*next); - RedrawEverything(); - SpeakText(pgettext("spell", GetSpellData(*next).sNameText), /*force=*/true); - } - } else if (invflag) { - InventoryMoveFromKeyboard({ AxisDirectionX_NONE, AxisDirectionY_DOWN }); - } else if (AutomapActive) { - AutomapDown(); - } else if (IsStashOpen) { - Stash.NextPage(); - } - return; - case SDLK_PAGEUP: - if (IsPlayerInStore()) { - StorePrior(); - } else if (ChatLogFlag) { - ChatLogScrollTop(); - } else { - const KeymapperOptions::Action *action = GetOptions().Keymapper.findAction(static_cast(vkey)); - if (action == nullptr || !action->isEnabled()) - SelectPreviousTownNpcKeyPressed(); - } - return; - case SDLK_PAGEDOWN: - if (IsPlayerInStore()) { - StoreNext(); - } else if (ChatLogFlag) { - ChatLogScrollBottom(); - } else { - const KeymapperOptions::Action *action = GetOptions().Keymapper.findAction(static_cast(vkey)); - if (action == nullptr || !action->isEnabled()) - SelectNextTownNpcKeyPressed(); - } - return; - case SDLK_LEFT: - if (CharFlag) { - CharacterScreenMoveSelection(-1); - } else if (SpellSelectFlag) { - HotSpellMove({ AxisDirectionX_LEFT, AxisDirectionY_NONE }); - SpeakSelectedSpeedbookSpell(); - } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { - if (SpellbookTab > 0) { - SpellbookTab--; - const std::optional first = GetSpellBookFirstAvailableSpell(SpellbookTab, *MyPlayer); - if (first) { - MyPlayer->_pRSpell = *first; - MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*first)) != 0 ? SpellType::Skill - : (MyPlayer->_pISpells & GetSpellBitmask(*first)) != 0 ? SpellType::Charges - : SpellType::Spell; - UpdateSpellTarget(*first); - RedrawEverything(); - SpeakText(pgettext("spell", GetSpellData(*first).sNameText), /*force=*/true); - } - } - } else if (invflag) { - InventoryMoveFromKeyboard({ AxisDirectionX_LEFT, AxisDirectionY_NONE }); - } else if (AutomapActive && !ChatFlag) { - AutomapLeft(); - } - return; - case SDLK_RIGHT: - if (CharFlag) { - CharacterScreenMoveSelection(+1); - } else if (SpellSelectFlag) { - HotSpellMove({ AxisDirectionX_RIGHT, AxisDirectionY_NONE }); - SpeakSelectedSpeedbookSpell(); - } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { - const int maxTab = gbIsHellfire ? 4 : 3; - if (SpellbookTab < maxTab) { - SpellbookTab++; - const std::optional first = GetSpellBookFirstAvailableSpell(SpellbookTab, *MyPlayer); - if (first) { - MyPlayer->_pRSpell = *first; - MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*first)) != 0 ? SpellType::Skill - : (MyPlayer->_pISpells & GetSpellBitmask(*first)) != 0 ? SpellType::Charges - : SpellType::Spell; - UpdateSpellTarget(*first); - RedrawEverything(); - SpeakText(pgettext("spell", GetSpellData(*first).sNameText), /*force=*/true); - } - } - } else if (invflag) { - InventoryMoveFromKeyboard({ AxisDirectionX_RIGHT, AxisDirectionY_NONE }); - } else if (AutomapActive && !ChatFlag) { - AutomapRight(); - } - return; - default: - break; - } -} - -void HandleMouseButtonDown(Uint8 button, uint16_t modState) -{ - if (IsPlayerInStore() && (button == SDL_BUTTON_X1 -#if !SDL_VERSION_ATLEAST(2, 0, 0) - || button == 8 -#endif - )) { - StoreESC(); - return; - } - - switch (button) { - case SDL_BUTTON_LEFT: - if (sgbMouseDown == CLICK_NONE) { - sgbMouseDown = CLICK_LEFT; - LeftMouseDown(modState); - } - break; - case SDL_BUTTON_RIGHT: - if (sgbMouseDown == CLICK_NONE) { - sgbMouseDown = CLICK_RIGHT; - RightMouseDown((modState & SDL_KMOD_SHIFT) != 0); - } - break; - default: - KeymapperPress(static_cast(button | KeymapperMouseButtonMask)); - break; - } -} - -void HandleMouseButtonUp(Uint8 button, uint16_t modState) -{ - if (sgbMouseDown == CLICK_LEFT && button == SDL_BUTTON_LEFT) { - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_NONE; - LeftMouseUp(modState); - } else if (sgbMouseDown == CLICK_RIGHT && button == SDL_BUTTON_RIGHT) { - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_NONE; - } else { - KeymapperRelease(static_cast(button | KeymapperMouseButtonMask)); - } -} - -[[maybe_unused]] void LogUnhandledEvent(const char *name, int value) -{ - LogVerbose("Unhandled SDL event: {} {}", name, value); -} - -void PrepareForFadeIn() -{ - if (HeadlessMode) return; - BlackPalette(); - - // Render the game to the buffer(s) with a fully black palette. - // Palette fade-in will gradually make it visible. - RedrawEverything(); - while (IsRedrawEverything()) { - DrawAndBlit(); - } -} - -void GameEventHandler(const SDL_Event &event, uint16_t modState) -{ - [[maybe_unused]] const Options &options = GetOptions(); - StaticVector ctrlEvents = ToControllerButtonEvents(event); - for (const ControllerButtonEvent ctrlEvent : ctrlEvents) { - GameAction action; - if (HandleControllerButtonEvent(event, ctrlEvent, action) && action.type == GameActionType_SEND_KEY) { - if ((action.send_key.vk_code & KeymapperMouseButtonMask) != 0) { - const unsigned button = action.send_key.vk_code & ~KeymapperMouseButtonMask; - if (!action.send_key.up) - HandleMouseButtonDown(static_cast(button), modState); - else - HandleMouseButtonUp(static_cast(button), modState); - } else { - if (!action.send_key.up) - PressKey(static_cast(action.send_key.vk_code), modState); - else - ReleaseKey(static_cast(action.send_key.vk_code)); - } - } - } - if (ctrlEvents.size() > 0 && ctrlEvents[0].button != ControllerButton_NONE) { - return; - } - -#ifdef _DEBUG - if (ConsoleHandleEvent(event)) { - return; - } -#endif - - if (IsChatActive() && HandleTalkTextInputEvent(event)) { - return; - } - if (DropGoldFlag && HandleGoldDropTextInputEvent(event)) { - return; - } - if (IsWithdrawGoldOpen && HandleGoldWithdrawTextInputEvent(event)) { - return; - } - - switch (event.type) { - case SDL_EVENT_KEY_DOWN: - PressKey(SDLC_EventKey(event), modState); - return; - case SDL_EVENT_KEY_UP: - ReleaseKey(SDLC_EventKey(event)); - return; - case SDL_EVENT_MOUSE_MOTION: - if (ControlMode == ControlTypes::KeyboardAndMouse && invflag) - InvalidateInventorySlot(); - MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; - gmenu_on_mouse_move(); - return; - case SDL_EVENT_MOUSE_BUTTON_DOWN: - MousePosition = { SDLC_EventButtonIntX(event), SDLC_EventButtonIntY(event) }; - HandleMouseButtonDown(event.button.button, modState); - return; - case SDL_EVENT_MOUSE_BUTTON_UP: - MousePosition = { SDLC_EventButtonIntX(event), SDLC_EventButtonIntY(event) }; - HandleMouseButtonUp(event.button.button, modState); - return; -#if SDL_VERSION_ATLEAST(2, 0, 0) - case SDL_EVENT_MOUSE_WHEEL: - if (SDLC_EventWheelIntY(event) > 0) { // Up - if (IsPlayerInStore()) { - StoreUp(); - } else if (QuestLogIsOpen) { - QuestlogUp(); - } else if (HelpFlag) { - HelpScrollUp(); - } else if (ChatLogFlag) { - ChatLogScrollUp(); - } else if (IsStashOpen) { - Stash.PreviousPage(); - } else if (SDL_GetModState() & SDL_KMOD_CTRL) { - if (AutomapActive) { - AutomapZoomIn(); - } - } else { - KeymapperPress(MouseScrollUpButton); - } - } else if (SDLC_EventWheelIntY(event) < 0) { // down - if (IsPlayerInStore()) { - StoreDown(); - } else if (QuestLogIsOpen) { - QuestlogDown(); - } else if (HelpFlag) { - HelpScrollDown(); - } else if (ChatLogFlag) { - ChatLogScrollDown(); - } else if (IsStashOpen) { - Stash.NextPage(); - } else if (SDL_GetModState() & SDL_KMOD_CTRL) { - if (AutomapActive) { - AutomapZoomOut(); - } - } else { - KeymapperPress(MouseScrollDownButton); - } - } else if (SDLC_EventWheelIntX(event) > 0) { // left - KeymapperPress(MouseScrollLeftButton); - } else if (SDLC_EventWheelIntX(event) < 0) { // right - KeymapperPress(MouseScrollRightButton); - } - break; -#endif - default: - if (IsCustomEvent(event.type)) { - if (gbIsMultiplayer) - pfile_write_hero(); - nthread_ignore_mutex(true); - PaletteFadeOut(8); - sound_stop(); - ShowProgress(GetCustomEvent(event)); - - PrepareForFadeIn(); - LoadPWaterPalette(); - if (gbRunGame) - PaletteFadeIn(8); - nthread_ignore_mutex(false); - gbGameLoopStartup = true; - return; - } - MainWndProc(event); - break; - } -} - -void RunGameLoop(interface_mode uMsg) -{ - demo::NotifyGameLoopStart(); - - nthread_ignore_mutex(true); - StartGame(uMsg); - assert(HeadlessMode || ghMainWnd); - EventHandler previousHandler = SetEventHandler(GameEventHandler); - run_delta_info(); - gbRunGame = true; - gbProcessPlayers = IsDiabloAlive(true); - gbRunGameResult = true; - - PrepareForFadeIn(); - LoadPWaterPalette(); - PaletteFadeIn(8); - InitBackbufferState(); - RedrawEverything(); - gbGameLoopStartup = true; - nthread_ignore_mutex(false); - - discord_manager::StartGame(); - LuaEvent("GameStart"); -#ifdef GPERF_HEAP_FIRST_GAME_ITERATION - unsigned run_game_iteration = 0; -#endif - - while (gbRunGame) { - -#ifdef _DEBUG - if (!gbGameLoopStartup && !DebugCmdsFromCommandLine.empty()) { - InitConsole(); - for (const std::string &cmd : DebugCmdsFromCommandLine) { - RunInConsole(cmd); - } - DebugCmdsFromCommandLine.clear(); - } -#endif - - SDL_Event event; - uint16_t modState; - while (FetchMessage(&event, &modState)) { - if (event.type == SDL_EVENT_QUIT) { - gbRunGameResult = false; - gbRunGame = false; - break; - } - HandleMessage(event, modState); - } - if (!gbRunGame) - break; - - bool drawGame = true; - bool processInput = true; - const bool runGameLoop = demo::IsRunning() ? demo::GetRunGameLoop(drawGame, processInput) : nthread_has_500ms_passed(&drawGame); - if (demo::IsRecording()) - demo::RecordGameLoopResult(runGameLoop); - - discord_manager::UpdateGame(); - - if (!runGameLoop) { - if (processInput) - ProcessInput(); - DvlNet_ProcessNetworkPackets(); - if (!drawGame) - continue; - RedrawViewport(); - DrawAndBlit(); - continue; - } - - ProcessGameMessagePackets(); - if (game_loop(gbGameLoopStartup)) - diablo_color_cyc_logic(); - gbGameLoopStartup = false; - if (drawGame) - DrawAndBlit(); -#ifdef GPERF_HEAP_FIRST_GAME_ITERATION - if (run_game_iteration++ == 0) - HeapProfilerDump("first_game_iteration"); -#endif - } - - demo::NotifyGameLoopEnd(); - - if (gbIsMultiplayer) { - pfile_write_hero(/*writeGameData=*/false); - sfile_write_stash(); - } - - PaletteFadeOut(8); - NewCursor(CURSOR_NONE); - ClearScreenBuffer(); - RedrawEverything(); - scrollrt_draw_game_screen(); - previousHandler = SetEventHandler(previousHandler); - assert(HeadlessMode || previousHandler == GameEventHandler); - FreeGame(); - - if (cineflag) { - cineflag = false; - DoEnding(); - } -} - -void PrintWithRightPadding(std::string_view str, size_t width) -{ - printInConsole(str); - if (str.size() >= width) - return; - printInConsole(std::string(width - str.size(), ' ')); -} - -void PrintHelpOption(std::string_view flags, std::string_view description) -{ - printInConsole(" "); - PrintWithRightPadding(flags, 20); - printInConsole(" "); - PrintWithRightPadding(description, 30); - printNewlineInConsole(); -} - -#if SDL_VERSION_ATLEAST(2, 0, 0) -FILE *SdlLogFile = nullptr; - -extern "C" void SdlLogToFile(void *userdata, int category, SDL_LogPriority priority, const char *message) -{ - FILE *file = reinterpret_cast(userdata); - static const char *const LogPriorityPrefixes[SDL_LOG_PRIORITY_COUNT] = { - "", - "VERBOSE", - "DEBUG", - "INFO", - "WARN", - "ERROR", - "CRITICAL" - }; - std::fprintf(file, "%s: %s\n", LogPriorityPrefixes[priority], message); - std::fflush(file); -} -#endif - -[[noreturn]] void PrintHelpAndExit() -{ - printInConsole((/* TRANSLATORS: Commandline Option */ "Options:")); - printNewlineInConsole(); - PrintHelpOption("-h, --help", _(/* TRANSLATORS: Commandline Option */ "Print this message and exit")); - PrintHelpOption("--version", _(/* TRANSLATORS: Commandline Option */ "Print the version and exit")); - PrintHelpOption("--data-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the folder of diabdat.mpq")); - PrintHelpOption("--save-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the folder of save files")); - PrintHelpOption("--config-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the location of diablo.ini")); - PrintHelpOption("--lang", _(/* TRANSLATORS: Commandline Option */ "Specify the language code (e.g. en or pt_BR)")); - PrintHelpOption("-n", _(/* TRANSLATORS: Commandline Option */ "Skip startup videos")); - PrintHelpOption("-f", _(/* TRANSLATORS: Commandline Option */ "Display frames per second")); - PrintHelpOption("--verbose", _(/* TRANSLATORS: Commandline Option */ "Enable verbose logging")); -#if SDL_VERSION_ATLEAST(2, 0, 0) - PrintHelpOption("--log-to-file ", _(/* TRANSLATORS: Commandline Option */ "Log to a file instead of stderr")); -#endif -#ifndef DISABLE_DEMOMODE - PrintHelpOption("--record <#>", _(/* TRANSLATORS: Commandline Option */ "Record a demo file")); - PrintHelpOption("--demo <#>", _(/* TRANSLATORS: Commandline Option */ "Play a demo file")); - PrintHelpOption("--timedemo", _(/* TRANSLATORS: Commandline Option */ "Disable all frame limiting during demo playback")); -#endif - printNewlineInConsole(); - printInConsole(_(/* TRANSLATORS: Commandline Option */ "Game selection:")); - printNewlineInConsole(); - PrintHelpOption("--spawn", _(/* TRANSLATORS: Commandline Option */ "Force Shareware mode")); - PrintHelpOption("--diablo", _(/* TRANSLATORS: Commandline Option */ "Force Diablo mode")); - PrintHelpOption("--hellfire", _(/* TRANSLATORS: Commandline Option */ "Force Hellfire mode")); - printInConsole(_(/* TRANSLATORS: Commandline Option */ "Hellfire options:")); - printNewlineInConsole(); -#ifdef _DEBUG - printNewlineInConsole(); - printInConsole("Debug options:"); - printNewlineInConsole(); - PrintHelpOption("-i", "Ignore network timeout"); - PrintHelpOption("+", "Pass commands to the engine"); -#endif - printNewlineInConsole(); - printInConsole(_("Report bugs at https://github.com/diasurgical/devilutionX/")); - printNewlineInConsole(); - diablo_quit(0); -} - -void PrintFlagMessage(std::string_view flag, std::string_view message) -{ - printInConsole(flag); - printInConsole(message); - printNewlineInConsole(); -} - -void PrintFlagRequiresArgument(std::string_view flag) -{ - PrintFlagMessage(flag, " requires an argument"); -} - -void DiabloParseFlags(int argc, char **argv) -{ -#ifdef _DEBUG - int argumentIndexOfLastCommandPart = -1; - std::string currentCommand; -#endif -#ifndef DISABLE_DEMOMODE - bool timedemo = false; - int demoNumber = -1; - int recordNumber = -1; - bool createDemoReference = false; -#endif - for (int i = 1; i < argc; i++) { - const std::string_view arg = argv[i]; - if (arg == "-h" || arg == "--help") { - PrintHelpAndExit(); - } else if (arg == "--version") { - printInConsole(PROJECT_NAME); - printInConsole(" v"); - printInConsole(PROJECT_VERSION); - printNewlineInConsole(); - diablo_quit(0); - } else if (arg == "--data-dir") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--data-dir"); - diablo_quit(64); - } - paths::SetBasePath(argv[++i]); - } else if (arg == "--save-dir") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--save-dir"); - diablo_quit(64); - } - paths::SetPrefPath(argv[++i]); - } else if (arg == "--config-dir") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--config-dir"); - diablo_quit(64); - } - paths::SetConfigPath(argv[++i]); - } else if (arg == "--lang") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--lang"); - diablo_quit(64); - } - forceLocale = argv[++i]; -#ifndef DISABLE_DEMOMODE - } else if (arg == "--demo") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--demo"); - diablo_quit(64); - } - ParseIntResult parsedParam = ParseInt(argv[++i]); - if (!parsedParam.has_value()) { - PrintFlagMessage("--demo", " must be a number"); - diablo_quit(64); - } - demoNumber = parsedParam.value(); - gbShowIntro = false; - } else if (arg == "--timedemo") { - timedemo = true; - } else if (arg == "--record") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--record"); - diablo_quit(64); - } - ParseIntResult parsedParam = ParseInt(argv[++i]); - if (!parsedParam.has_value()) { - PrintFlagMessage("--record", " must be a number"); - diablo_quit(64); - } - recordNumber = parsedParam.value(); - } else if (arg == "--create-reference") { - createDemoReference = true; -#else - } else if (arg == "--demo" || arg == "--timedemo" || arg == "--record" || arg == "--create-reference") { - printInConsole("Binary compiled without demo mode support."); - printNewlineInConsole(); - diablo_quit(1); -#endif - } else if (arg == "-n") { - gbShowIntro = false; - } else if (arg == "-f") { - EnableFrameCount(); - } else if (arg == "--spawn") { - forceSpawn = true; - } else if (arg == "--diablo") { - forceDiablo = true; - } else if (arg == "--hellfire") { - forceHellfire = true; - } else if (arg == "--vanilla") { - gbVanilla = true; - } else if (arg == "--verbose") { - SDL_SetLogPriorities(SDL_LOG_PRIORITY_VERBOSE); -#if SDL_VERSION_ATLEAST(2, 0, 0) - } else if (arg == "--log-to-file") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--log-to-file"); - diablo_quit(64); - } - SdlLogFile = OpenFile(argv[++i], "wb"); - if (SdlLogFile == nullptr) { - printInConsole("Failed to open log file for writing"); - diablo_quit(64); - } - SDL_SetLogOutputFunction(&SdlLogToFile, /*userdata=*/SdlLogFile); -#endif -#ifdef _DEBUG - } else if (arg == "-i") { - DebugDisableNetworkTimeout = true; - } else if (arg[0] == '+') { - if (!currentCommand.empty()) - DebugCmdsFromCommandLine.push_back(currentCommand); - argumentIndexOfLastCommandPart = i; - currentCommand = arg.substr(1); - } else if (arg[0] != '-' && (argumentIndexOfLastCommandPart + 1) == i) { - currentCommand.append(" "); - currentCommand.append(arg); - argumentIndexOfLastCommandPart = i; -#endif - } else { - printInConsole("unrecognized option '"); - printInConsole(argv[i]); - printInConsole("'"); - printNewlineInConsole(); - PrintHelpAndExit(); - } - } - -#ifdef _DEBUG - if (!currentCommand.empty()) - DebugCmdsFromCommandLine.push_back(currentCommand); -#endif - -#ifndef DISABLE_DEMOMODE - if (demoNumber != -1) - demo::InitPlayBack(demoNumber, timedemo); - if (recordNumber != -1) - demo::InitRecording(recordNumber, createDemoReference); -#endif -} - -void DiabloInitScreen() -{ - MousePosition = { gnScreenWidth / 2, gnScreenHeight / 2 }; - if (ControlMode == ControlTypes::KeyboardAndMouse) - SetCursorPos(MousePosition); - - ClrDiabloMsg(); -} - -void SetApplicationVersions() -{ - *BufCopy(gszProductName, PROJECT_NAME, " v", PROJECT_VERSION) = '\0'; - *BufCopy(gszVersionNumber, "version ", PROJECT_VERSION) = '\0'; -} - -void CheckArchivesUpToDate() -{ - const bool devilutionxMpqOutOfDate = IsDevilutionXMpqOutOfDate(); - const bool fontsMpqOutOfDate = AreExtraFontsOutOfDate(); - - if (devilutionxMpqOutOfDate && fontsMpqOutOfDate) { - app_fatal(_("Please update devilutionx.mpq and fonts.mpq to the latest version")); - } else if (devilutionxMpqOutOfDate) { - app_fatal(_("Failed to load UI resources.\n" - "\n" - "Make sure devilutionx.mpq is in the game folder and that it is up to date.")); - } else if (fontsMpqOutOfDate) { - app_fatal(_("Please update fonts.mpq to the latest version")); - } -} - -void ApplicationInit() -{ - if (*GetOptions().Graphics.showFPS) - EnableFrameCount(); - - init_create_window(); - was_window_init = true; - - InitializeScreenReader(); - LanguageInitialize(); - - SetApplicationVersions(); - - ReadOnlyTest(); -} - -void DiabloInit() -{ - if (forceSpawn || *GetOptions().GameMode.shareware) - gbIsSpawn = true; - - bool wasHellfireDiscovered = false; - if (!forceDiablo && !forceHellfire) - wasHellfireDiscovered = (HaveHellfire() && *GetOptions().GameMode.gameMode == StartUpGameMode::Ask); - bool enableHellfire = forceHellfire || wasHellfireDiscovered; - if (!forceDiablo && *GetOptions().GameMode.gameMode == StartUpGameMode::Hellfire) { // Migrate legacy options - GetOptions().GameMode.gameMode.SetValue(StartUpGameMode::Diablo); - enableHellfire = true; - } - if (forceDiablo || enableHellfire) { - GetOptions().Mods.SetHellfireEnabled(enableHellfire); - } - - gbIsHellfireSaveGame = gbIsHellfire; - - for (size_t i = 0; i < QuickMessages.size(); i++) { - auto &messages = GetOptions().Chat.szHotKeyMsgs[i]; - if (messages.empty()) { - messages.emplace_back(_(QuickMessages[i].message)); - } - } - -#ifndef USE_SDL1 - InitializeVirtualGamepad(); -#endif - - UiInitialize(); - was_ui_init = true; - - if (wasHellfireDiscovered) { - UiSelStartUpGameOption(); - if (!gbIsHellfire) { - // Reinitialize the UI Elements because we changed the game - UnloadUiGFX(); - UiInitialize(); - if (IsHardwareCursor()) - SetHardwareCursor(CursorInfo::UnknownCursor()); - } - } - - DiabloInitScreen(); - - snd_init(); - - ui_sound_init(); - - // Item graphics are loaded early, they already get touched during hero selection. - InitItemGFX(); - - // Always available. - LoadSmallSelectionSpinner(); - - CheckArchivesUpToDate(); -} - -void DiabloSplash() -{ - if (!gbShowIntro) - return; - - if (*GetOptions().StartUp.splash == StartUpSplash::LogoAndTitleDialog) - play_movie("gendata\\logo.smk", true); - - auto &intro = gbIsHellfire ? GetOptions().StartUp.hellfireIntro : GetOptions().StartUp.diabloIntro; - - if (*intro != StartUpIntro::Off) { - if (gbIsHellfire) - play_movie("gendata\\Hellfire.smk", true); - else - play_movie("gendata\\diablo1.smk", true); - if (*intro == StartUpIntro::Once) { - intro.SetValue(StartUpIntro::Off); - if (!demo::IsRunning()) SaveOptions(); - } - } - - if (IsAnyOf(*GetOptions().StartUp.splash, StartUpSplash::TitleDialog, StartUpSplash::LogoAndTitleDialog)) - UiTitleDialog(); -} - -void DiabloDeinit() -{ - FreeItemGFX(); - - LuaShutdown(); - ShutDownScreenReader(); - - if (gbSndInited) - effects_cleanup_sfx(); - snd_deinit(); - if (was_ui_init) - UiDestroy(); - if (was_archives_init) - init_cleanup(); - if (was_window_init) - dx_cleanup(); // Cleanup SDL surfaces stuff, so we have to do it before SDL_Quit(). - UnloadFonts(); - if (SDL_WasInit((~0U) & ~SDL_INIT_HAPTIC) != 0) - SDL_Quit(); -} - -tl::expected LoadLvlGFX() -{ - assert(pDungeonCels == nullptr); - constexpr int SpecialCelWidth = 64; - - const auto loadAll = [](const char *cel, const char *til, const char *special) -> tl::expected { - ASSIGN_OR_RETURN(pDungeonCels, LoadFileInMemWithStatus(cel)); - ASSIGN_OR_RETURN(pMegaTiles, LoadFileInMemWithStatus(til)); - ASSIGN_OR_RETURN(pSpecialCels, LoadCelWithStatus(special, SpecialCelWidth)); - return {}; - }; - - switch (leveltype) { - case DTYPE_TOWN: { - auto cel = LoadFileInMemWithStatus("nlevels\\towndata\\town.cel"); - if (!cel.has_value()) { - ASSIGN_OR_RETURN(pDungeonCels, LoadFileInMemWithStatus("levels\\towndata\\town.cel")); - } else { - pDungeonCels = std::move(*cel); - } - auto til = LoadFileInMemWithStatus("nlevels\\towndata\\town.til"); - if (!til.has_value()) { - ASSIGN_OR_RETURN(pMegaTiles, LoadFileInMemWithStatus("levels\\towndata\\town.til")); - } else { - pMegaTiles = std::move(*til); - } - ASSIGN_OR_RETURN(pSpecialCels, LoadCelWithStatus("levels\\towndata\\towns", SpecialCelWidth)); - return {}; - } - case DTYPE_CATHEDRAL: - return loadAll( - "levels\\l1data\\l1.cel", - "levels\\l1data\\l1.til", - "levels\\l1data\\l1s"); - case DTYPE_CATACOMBS: - return loadAll( - "levels\\l2data\\l2.cel", - "levels\\l2data\\l2.til", - "levels\\l2data\\l2s"); - case DTYPE_CAVES: - return loadAll( - "levels\\l3data\\l3.cel", - "levels\\l3data\\l3.til", - "levels\\l1data\\l1s"); - case DTYPE_HELL: - return loadAll( - "levels\\l4data\\l4.cel", - "levels\\l4data\\l4.til", - "levels\\l2data\\l2s"); - case DTYPE_NEST: - return loadAll( - "nlevels\\l6data\\l6.cel", - "nlevels\\l6data\\l6.til", - "levels\\l1data\\l1s"); - case DTYPE_CRYPT: - return loadAll( - "nlevels\\l5data\\l5.cel", - "nlevels\\l5data\\l5.til", - "nlevels\\l5data\\l5s"); - default: - return tl::make_unexpected("LoadLvlGFX"); - } -} - -tl::expected LoadAllGFX() -{ - IncProgress(); -#if !defined(USE_SDL1) && !defined(__vita__) - InitVirtualGamepadGFX(); -#endif - IncProgress(); - RETURN_IF_ERROR(InitObjectGFX()); - IncProgress(); - RETURN_IF_ERROR(InitMissileGFX()); - IncProgress(); - return {}; -} - -/** - * @param entry Where is the player entering from - */ -void CreateLevel(lvl_entry entry) -{ - CreateDungeon(DungeonSeeds[currlevel], entry); - - switch (leveltype) { - case DTYPE_TOWN: - InitTownTriggers(); - break; - case DTYPE_CATHEDRAL: - InitL1Triggers(); - break; - case DTYPE_CATACOMBS: - InitL2Triggers(); - break; - case DTYPE_CAVES: - InitL3Triggers(); - break; - case DTYPE_HELL: - InitL4Triggers(); - break; - case DTYPE_NEST: - InitHiveTriggers(); - break; - case DTYPE_CRYPT: - InitCryptTriggers(); - break; - default: - app_fatal("CreateLevel"); - } - - if (leveltype != DTYPE_TOWN) { - Freeupstairs(); - } - LoadRndLvlPal(leveltype); -} - -void UnstuckChargers() -{ - if (gbIsMultiplayer) { - for (Player &player : Players) { - if (!player.plractive) - continue; - if (player._pLvlChanging) - continue; - if (!player.isOnActiveLevel()) - continue; - if (&player == MyPlayer) - continue; - return; - } - } - for (size_t i = 0; i < ActiveMonsterCount; i++) { - Monster &monster = Monsters[ActiveMonsters[i]]; - if (monster.mode == MonsterMode::Charge) - monster.mode = MonsterMode::Stand; - } -} - -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); - } - - if (monster.lightId != NO_LIGHT) { - if (monster.lightId == MyPlayer->lightId) { // Fix old saves where some monsters had 0 instead of NO_LIGHT - monster.lightId = NO_LIGHT; - continue; - } - - const Light &light = Lights[monster.lightId]; - if (monster.position.tile != light.position.tile) { - ChangeLightXY(monster.lightId, monster.position.tile); - } - } - } -} - -#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 - -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 UpdateAttackableMonsterAnnouncements() -{ - static std::optional LastAttackableMonsterId; - - if (MyPlayer == nullptr) { - LastAttackableMonsterId = std::nullopt; - return; - } - if (leveltype == DTYPE_TOWN) { - LastAttackableMonsterId = std::nullopt; - return; - } - if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) { - LastAttackableMonsterId = std::nullopt; - return; - } - if (InGameMenu() || invflag) { - LastAttackableMonsterId = std::nullopt; - return; - } - - const Player &player = *MyPlayer; - const Point playerPosition = player.position.tile; - - int bestRotations = 5; - std::optional bestId; - - 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 (monster.hitPoints <= 0) - continue; - if (monster.isPlayerMinion()) - continue; - if (!monster.isPossibleToHit()) - continue; - - const Point monsterPosition = monster.position.tile; - if (playerPosition.WalkingDistance(monsterPosition) > 1) - continue; - - const int d1 = static_cast(player._pdir); - const int d2 = static_cast(GetDirection(playerPosition, monsterPosition)); - - int rotations = std::abs(d1 - d2); - if (rotations > 4) - rotations = 4 - (rotations % 4); - - if (!bestId || rotations < bestRotations || (rotations == bestRotations && monsterId < *bestId)) { - bestRotations = rotations; - bestId = monsterId; - } - } - - if (!bestId) { - LastAttackableMonsterId = std::nullopt; - return; - } - - if (LastAttackableMonsterId && *LastAttackableMonsterId == *bestId) - return; - - LastAttackableMonsterId = *bestId; - - const std::string_view name = Monsters[*bestId].name(); - if (!name.empty()) - SpeakText(name, /*force=*/true); -} - -[[nodiscard]] StringOrView DoorLabelForSpeech(const Object &door) -{ - if (!door.isDoor()) - return door.name(); - - // Catacombs doors are grates, so differentiate them for the screen reader / tracker. - if (IsAnyOf(door._otype, _object_id::OBJ_L2LDOOR, _object_id::OBJ_L2RDOOR)) { - if (door._oVar4 == DOOR_OPEN) - return _("Open Grate Door"); - if (door._oVar4 == DOOR_CLOSED) - return _("Closed Grate Door"); - if (door._oVar4 == DOOR_BLOCKED) - return _("Blocked Grate Door"); - return _("Grate Door"); - } - - return door.name(); -} - -void UpdateInteractableDoorAnnouncements() -{ - static std::optional LastInteractableDoorId; - static std::optional LastInteractableDoorState; - - if (MyPlayer == nullptr) { - LastInteractableDoorId = std::nullopt; - LastInteractableDoorState = std::nullopt; - return; - } - if (leveltype == DTYPE_TOWN) { - LastInteractableDoorId = std::nullopt; - LastInteractableDoorState = std::nullopt; - return; - } - if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) { - LastInteractableDoorId = std::nullopt; - LastInteractableDoorState = std::nullopt; - return; - } - if (InGameMenu() || invflag) { - LastInteractableDoorId = std::nullopt; - LastInteractableDoorState = std::nullopt; - return; - } - - const Player &player = *MyPlayer; - const Point playerPosition = player.position.tile; - - std::optional bestId; - int bestRotations = 5; - int bestDistance = 0; - - for (int dy = -1; dy <= 1; ++dy) { - for (int dx = -1; dx <= 1; ++dx) { - if (dx == 0 && dy == 0) - continue; - - const Point pos = playerPosition + Displacement { dx, dy }; - if (!InDungeonBounds(pos)) - continue; - - const int objectId = std::abs(dObject[pos.x][pos.y]) - 1; - if (objectId < 0 || objectId >= MAXOBJECTS) - continue; - - const Object &door = Objects[objectId]; - if (!door.isDoor() || !door.canInteractWith()) - continue; - - const int distance = playerPosition.WalkingDistance(door.position); - if (distance > 1) - continue; - - const int d1 = static_cast(player._pdir); - const int d2 = static_cast(GetDirection(playerPosition, door.position)); - - int rotations = std::abs(d1 - d2); - if (rotations > 4) - rotations = 4 - (rotations % 4); - - if (!bestId || rotations < bestRotations || (rotations == bestRotations && distance < bestDistance) - || (rotations == bestRotations && distance == bestDistance && objectId < *bestId)) { - bestRotations = rotations; - bestDistance = distance; - bestId = objectId; - } - } - } - - if (!bestId) { - LastInteractableDoorId = std::nullopt; - LastInteractableDoorState = std::nullopt; - return; - } - - const Object &door = Objects[*bestId]; - const int state = door._oVar4; - if (LastInteractableDoorId && LastInteractableDoorState && *LastInteractableDoorId == *bestId && *LastInteractableDoorState == state) - return; - - LastInteractableDoorId = *bestId; - LastInteractableDoorState = state; - - const StringOrView label = DoorLabelForSpeech(door); - if (!label.empty()) - SpeakText(label.str(), /*force=*/true); -} - -void GameLogic() -{ - if (!ProcessInput()) { - return; - } - if (gbProcessPlayers) { - gGameLogicStep = GameLogicStep::ProcessPlayers; - ProcessPlayers(); - UpdateAutoWalkTownNpc(); - UpdateAutoWalkTracker(); - UpdateLowDurabilityWarnings(); - } - if (leveltype != DTYPE_TOWN) { - gGameLogicStep = GameLogicStep::ProcessMonsters; -#ifdef _DEBUG - if (!DebugInvisible) -#endif - ProcessMonsters(); - gGameLogicStep = GameLogicStep::ProcessObjects; - ProcessObjects(); - gGameLogicStep = GameLogicStep::ProcessMissiles; - ProcessMissiles(); - gGameLogicStep = GameLogicStep::ProcessItems; - ProcessItems(); - ProcessLightList(); - ProcessVisionList(); - UpdateBossHealthAnnouncements(); - UpdateProximityAudioCues(); - UpdateAttackableMonsterAnnouncements(); - UpdateInteractableDoorAnnouncements(); - } else { - gGameLogicStep = GameLogicStep::ProcessTowners; - ProcessTowners(); - gGameLogicStep = GameLogicStep::ProcessItemsTown; - ProcessItems(); - gGameLogicStep = GameLogicStep::ProcessMissilesTown; - ProcessMissiles(); - UpdateProximityAudioCues(); - } - - UpdatePlayerLowHpWarningSound(); - - gGameLogicStep = GameLogicStep::None; - -#ifdef _DEBUG - if (DebugScrollViewEnabled && (SDL_GetModState() & SDL_KMOD_SHIFT) != 0) { - ScrollView(); - } -#endif - - sound_update(); - CheckTriggers(); - CheckQuests(); - RedrawViewport(); - pfile_update(false); - - plrctrls_after_game_logic(); -} - -void TimeoutCursor(bool bTimeout) -{ - if (bTimeout) { - if (sgnTimeoutCurs == CURSOR_NONE && sgbMouseDown == CLICK_NONE) { - sgnTimeoutCurs = pcurs; - multi_net_ping(); - InfoString = StringOrView {}; - AddInfoBoxString(_("-- Network timeout --")); - AddInfoBoxString(_("-- Waiting for players --")); - for (uint8_t i = 0; i < Players.size(); i++) { - bool isConnected = (player_state[i] & PS_CONNECTED) != 0; - bool isActive = (player_state[i] & PS_ACTIVE) != 0; - if (!(isConnected && !isActive)) continue; - - DvlNetLatencies latencies = DvlNet_GetLatencies(i); - - std::string ping = fmt::format( - fmt::runtime(_(/* TRANSLATORS: {:s} means: Character Name */ "Player {:s} is timing out!")), - Players[i].name()); - - StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Echo latency: {:d} ms")), latencies.echoLatency)); - - if (latencies.providerLatency) { - if (latencies.isRelayed && *latencies.isRelayed) { - StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Provider latency: {:d} ms (Relayed)")), *latencies.providerLatency)); - } else { - StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Provider latency: {:d} ms")), *latencies.providerLatency)); - } - } - EventPlrMsg(ping); - } - NewCursor(CURSOR_HOURGLASS); - RedrawEverything(); - } - scrollrt_draw_game_screen(); - } else if (sgnTimeoutCurs != CURSOR_NONE) { - // Timeout is gone, we should restore the previous cursor. - // But the timeout cursor could already be changed by the now processed messages (for example item cursor from CMD_GETITEM). - // Changing the item cursor back to the previous (hand) cursor could result in deleted items, because this resets Player.HoldItem (see NewCursor). - if (pcurs == CURSOR_HOURGLASS) - NewCursor(sgnTimeoutCurs); - sgnTimeoutCurs = CURSOR_NONE; - InfoString = StringOrView {}; - RedrawEverything(); - } -} - -void HelpKeyPressed() -{ - if (HelpFlag) { - HelpFlag = false; - } else if (IsPlayerInStore()) { - InfoString = StringOrView {}; - AddInfoBoxString(_("No help available")); /// BUGFIX: message isn't displayed - AddInfoBoxString(_("while in stores")); - LastPlayerAction = PlayerActionType::None; - } else { - CloseInventory(); - CloseCharPanel(); - SpellbookFlag = false; - SpellSelectFlag = false; - if (qtextflag && leveltype == DTYPE_TOWN) { - qtextflag = false; - stream_stop(); - } - QuestLogIsOpen = false; - CancelCurrentDiabloMsg(); - gamemenu_off(); - DisplayHelp(); - doom_close(); - } -} - -bool CanPlayerTakeAction(); - -std::vector TownNpcOrder; -int SelectedTownNpc = -1; -int AutoWalkTownNpcTarget = -1; - -enum class TrackerTargetCategory : uint8_t { - Items, - Chests, - Doors, - Shrines, - Objects, - Breakables, - Monsters, - DeadBodies, -}; - -TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; -TrackerTargetCategory AutoWalkTrackerTargetCategory = TrackerTargetCategory::Items; ///< Category of the active auto-walk target. -int AutoWalkTrackerTargetId = -1; ///< ID of the target being auto-walked to, or -1 if inactive. - -Point NextPositionForWalkDirection(Point position, int8_t walkDir) -{ - switch (walkDir) { - case WALK_NE: - return { position.x, position.y - 1 }; - case WALK_NW: - return { position.x - 1, position.y }; - case WALK_SE: - return { position.x + 1, position.y }; - case WALK_SW: - return { position.x, position.y + 1 }; - case WALK_N: - return { position.x - 1, position.y - 1 }; - case WALK_E: - return { position.x + 1, position.y - 1 }; - case WALK_S: - return { position.x + 1, position.y + 1 }; - case WALK_W: - return { position.x - 1, position.y + 1 }; - default: - return position; - } -} - -Point PositionAfterWalkPathSteps(Point start, const int8_t *path, int steps) -{ - Point position = start; - for (int i = 0; i < steps; ++i) { - position = NextPositionForWalkDirection(position, path[i]); - } - return position; -} - -int8_t OppositeWalkDirection(int8_t walkDir) -{ - switch (walkDir) { - case WALK_NE: - return WALK_SW; - case WALK_SW: - return WALK_NE; - case WALK_NW: - return WALK_SE; - case WALK_SE: - return WALK_NW; - case WALK_N: - return WALK_S; - case WALK_S: - return WALK_N; - case WALK_E: - return WALK_W; - case WALK_W: - return WALK_E; - default: - return WALK_NONE; - } -} - -bool IsTownNpcActionAllowed() -{ - return CanPlayerTakeAction() - && leveltype == DTYPE_TOWN - && !IsPlayerInStore() - && !ChatLogFlag - && !HelpFlag; -} - -void ResetTownNpcSelection() -{ - TownNpcOrder.clear(); - SelectedTownNpc = -1; -} - -void RefreshTownNpcOrder(bool selectFirst = false) -{ - TownNpcOrder.clear(); - if (leveltype != DTYPE_TOWN) - return; - - const Point playerPosition = MyPlayer->position.future; - - for (size_t i = 0; i < GetNumTowners(); ++i) { - const Towner &towner = Towners[i]; - if (!IsTownerPresent(towner._ttype)) - continue; - if (towner._ttype == TOWN_COW) - continue; - TownNpcOrder.push_back(static_cast(i)); - } - - if (TownNpcOrder.empty()) { - SelectedTownNpc = -1; - return; - } - - std::sort(TownNpcOrder.begin(), TownNpcOrder.end(), [&playerPosition](int a, int b) { - const Towner &townerA = Towners[a]; - const Towner &townerB = Towners[b]; - const int distanceA = playerPosition.WalkingDistance(townerA.position); - const int distanceB = playerPosition.WalkingDistance(townerB.position); - if (distanceA != distanceB) - return distanceA < distanceB; - return townerA.name < townerB.name; - }); - - if (selectFirst) { - SelectedTownNpc = TownNpcOrder.front(); - return; - } - - const auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); - if (it == TownNpcOrder.end()) - SelectedTownNpc = TownNpcOrder.front(); -} - -void EnsureTownNpcOrder() -{ - if (leveltype != DTYPE_TOWN) { - ResetTownNpcSelection(); - return; - } - if (TownNpcOrder.empty()) { - RefreshTownNpcOrder(true); - return; - } - if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { - RefreshTownNpcOrder(true); - return; - } - const auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); - if (it == TownNpcOrder.end()) - SelectedTownNpc = TownNpcOrder.front(); -} - -void SpeakSelectedTownNpc() -{ - EnsureTownNpcOrder(); - - if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { - SpeakText(_("No NPC selected."), true); - return; - } - - const Towner &towner = Towners[SelectedTownNpc]; - const Point playerPosition = MyPlayer->position.future; - const int distance = playerPosition.WalkingDistance(towner.position); - - std::string msg; - StrAppend(msg, towner.name); - StrAppend(msg, "\n", _("Distance: "), distance); - StrAppend(msg, "\n", _("Position: "), towner.position.x, ", ", towner.position.y); - SpeakText(msg, true); -} - -void SelectTownNpcRelative(int delta) -{ - if (!IsTownNpcActionAllowed()) - return; - - EnsureTownNpcOrder(); - if (TownNpcOrder.empty()) { - SpeakText(_("No town NPCs found."), true); - return; - } - - auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); - int currentIndex = (it != TownNpcOrder.end()) ? static_cast(it - TownNpcOrder.begin()) : 0; - - const int size = static_cast(TownNpcOrder.size()); - int newIndex = (currentIndex + delta) % size; - if (newIndex < 0) - newIndex += size; - SelectedTownNpc = TownNpcOrder[static_cast(newIndex)]; - SpeakSelectedTownNpc(); -} - -void SelectNextTownNpcKeyPressed() -{ - SelectTownNpcRelative(+1); -} - -void SelectPreviousTownNpcKeyPressed() -{ - SelectTownNpcRelative(-1); -} - -void GoToSelectedTownNpcKeyPressed() -{ - if (!IsTownNpcActionAllowed()) - return; - - EnsureTownNpcOrder(); - if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { - SpeakText(_("No NPC selected."), true); - return; - } - - const Towner &towner = Towners[SelectedTownNpc]; - - std::string msg; - StrAppend(msg, _("Going to: "), towner.name); - SpeakText(msg, true); - - AutoWalkTownNpcTarget = SelectedTownNpc; - UpdateAutoWalkTownNpc(); -} - -void UpdateAutoWalkTownNpc() -{ - if (AutoWalkTownNpcTarget < 0) - return; - if (leveltype != DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag) { - AutoWalkTownNpcTarget = -1; - return; - } - if (!CanPlayerTakeAction()) - return; - - if (MyPlayer->_pmode != PM_STAND) - return; - if (MyPlayer->walkpath[0] != WALK_NONE) - return; - if (MyPlayer->destAction != ACTION_NONE) - return; - - if (AutoWalkTownNpcTarget >= static_cast(GetNumTowners())) { - AutoWalkTownNpcTarget = -1; - SpeakText(_("No NPC selected."), true); - return; - } - - const Towner &towner = Towners[AutoWalkTownNpcTarget]; - if (!IsTownerPresent(towner._ttype) || towner._ttype == TOWN_COW) { - AutoWalkTownNpcTarget = -1; - SpeakText(_("No NPC selected."), true); - return; - } - - Player &myPlayer = *MyPlayer; - const Point playerPosition = myPlayer.position.future; - if (playerPosition.WalkingDistance(towner.position) < 2) { - const int townerIdx = AutoWalkTownNpcTarget; - AutoWalkTownNpcTarget = -1; - NetSendCmdLocParam1(true, CMD_TALKXY, towner.position, static_cast(townerIdx)); - return; - } - - constexpr size_t MaxAutoWalkPathLength = 512; - std::array path; - path.fill(WALK_NONE); - - const int steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, towner.position, path.data(), path.size()); - if (steps == 0) { - AutoWalkTownNpcTarget = -1; - std::string error; - StrAppend(error, _("Can't find a path to: "), towner.name); - SpeakText(error, true); - return; - } - - // FindPath returns 0 if the path length is equal to the maximum. - // The player walkpath buffer is MaxPathLengthPlayer, so keep segments strictly shorter. - if (steps < static_cast(MaxPathLengthPlayer)) { - const int townerIdx = AutoWalkTownNpcTarget; - AutoWalkTownNpcTarget = -1; - NetSendCmdLocParam1(true, CMD_TALKXY, towner.position, static_cast(townerIdx)); - return; - } - - const int segmentSteps = std::min(steps - 1, static_cast(MaxPathLengthPlayer - 1)); - const Point waypoint = PositionAfterWalkPathSteps(playerPosition, path.data(), segmentSteps); - NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, waypoint); -} - -namespace { - -/// Maximum Chebyshev distance (in tiles) at which the player is considered -/// close enough to interact with a tracker target. -constexpr int TrackerInteractDistanceTiles = 1; -constexpr int TrackerCycleDistanceTiles = 12; - -int LockedTrackerItemId = -1; -int LockedTrackerChestId = -1; -int LockedTrackerDoorId = -1; -int LockedTrackerShrineId = -1; -int LockedTrackerObjectId = -1; -int LockedTrackerBreakableId = -1; -int LockedTrackerMonsterId = -1; -int LockedTrackerDeadBodyId = -1; - -struct TrackerLevelKey { - dungeon_type levelType; - int currLevel; - bool isSetLevel; - int setLevelNum; -}; - -std::optional LockedTrackerLevelKey; - -void ClearTrackerLocks() -{ - LockedTrackerItemId = -1; - LockedTrackerChestId = -1; - LockedTrackerDoorId = -1; - LockedTrackerShrineId = -1; - LockedTrackerObjectId = -1; - LockedTrackerBreakableId = -1; - LockedTrackerMonsterId = -1; - LockedTrackerDeadBodyId = -1; -} - -void EnsureTrackerLocksMatchCurrentLevel() -{ - const TrackerLevelKey current { - .levelType = leveltype, - .currLevel = currlevel, - .isSetLevel = setlevel, - .setLevelNum = setlvlnum, - }; - - if (!LockedTrackerLevelKey || LockedTrackerLevelKey->levelType != current.levelType || LockedTrackerLevelKey->currLevel != current.currLevel - || LockedTrackerLevelKey->isSetLevel != current.isSetLevel || LockedTrackerLevelKey->setLevelNum != current.setLevelNum) { - ClearTrackerLocks(); - LockedTrackerLevelKey = current; - } -} - -int &LockedTrackerTargetId(TrackerTargetCategory category) -{ - switch (category) { - case TrackerTargetCategory::Items: - return LockedTrackerItemId; - case TrackerTargetCategory::Chests: - return LockedTrackerChestId; - case TrackerTargetCategory::Doors: - return LockedTrackerDoorId; - case TrackerTargetCategory::Shrines: - return LockedTrackerShrineId; - case TrackerTargetCategory::Objects: - return LockedTrackerObjectId; - case TrackerTargetCategory::Breakables: - return LockedTrackerBreakableId; - case TrackerTargetCategory::Monsters: - return LockedTrackerMonsterId; - case TrackerTargetCategory::DeadBodies: - return LockedTrackerDeadBodyId; - } - app_fatal("Invalid TrackerTargetCategory"); -} - -std::string_view TrackerTargetCategoryLabel(TrackerTargetCategory category) -{ - switch (category) { - case TrackerTargetCategory::Items: - return _("items"); - case TrackerTargetCategory::Chests: - return _("chests"); - case TrackerTargetCategory::Doors: - return _("doors"); - case TrackerTargetCategory::Shrines: - return _("shrines"); - case TrackerTargetCategory::Objects: - return _("objects"); - case TrackerTargetCategory::Breakables: - return _("breakables"); - case TrackerTargetCategory::Monsters: - return _("monsters"); - case TrackerTargetCategory::DeadBodies: - return _("dead bodies"); - default: - return _("items"); - } -} - -void SpeakTrackerTargetCategory() -{ - std::string message; - StrAppend(message, _("Tracker target: "), TrackerTargetCategoryLabel(SelectedTrackerTargetCategory)); - SpeakText(message, true); -} - -void CycleTrackerTargetKeyPressed() -{ - if (!CanPlayerTakeAction() || InGameMenu()) - return; - - AutoWalkTrackerTargetId = -1; - - const SDL_Keymod modState = SDL_GetModState(); - const bool cyclePrevious = (modState & SDL_KMOD_SHIFT) != 0; - - if (cyclePrevious) { - switch (SelectedTrackerTargetCategory) { - case TrackerTargetCategory::Items: - SelectedTrackerTargetCategory = TrackerTargetCategory::DeadBodies; - break; - case TrackerTargetCategory::Chests: - SelectedTrackerTargetCategory = TrackerTargetCategory::Items; - break; - case TrackerTargetCategory::Doors: - SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; - break; - case TrackerTargetCategory::Shrines: - SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; - break; - case TrackerTargetCategory::Objects: - SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; - break; - case TrackerTargetCategory::Breakables: - SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; - break; - case TrackerTargetCategory::Monsters: - SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; - break; - case TrackerTargetCategory::DeadBodies: - default: - SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; - break; - } - } else { - switch (SelectedTrackerTargetCategory) { - case TrackerTargetCategory::Items: - SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; - break; - case TrackerTargetCategory::Chests: - SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; - break; - case TrackerTargetCategory::Doors: - SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; - break; - case TrackerTargetCategory::Shrines: - SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; - break; - case TrackerTargetCategory::Objects: - SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; - break; - case TrackerTargetCategory::Breakables: - SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; - break; - case TrackerTargetCategory::Monsters: - SelectedTrackerTargetCategory = TrackerTargetCategory::DeadBodies; - break; - case TrackerTargetCategory::DeadBodies: - default: - SelectedTrackerTargetCategory = TrackerTargetCategory::Items; - break; - } - } - - SpeakTrackerTargetCategory(); -} - -std::optional FindNearestGroundItemId(Point playerPosition) -{ - std::optional bestId; - int bestDistance = 0; - - for (int y = 0; y < MAXDUNY; ++y) { - for (int x = 0; x < MAXDUNX; ++x) { - const int itemId = std::abs(dItem[x][y]) - 1; - if (itemId < 0 || itemId > MAXITEMS) - continue; - - const Item &item = Items[itemId]; - if (item.isEmpty() || item._iClass == ICLASS_NONE) - continue; - - const int distance = playerPosition.WalkingDistance(Point { x, y }); - if (!bestId || distance < bestDistance) { - bestId = itemId; - bestDistance = distance; - } - } - } - - return bestId; -} - -[[nodiscard]] constexpr int CorpseTrackerIdForPosition(Point position) -{ - return position.x + position.y * MAXDUNX; -} - -[[nodiscard]] constexpr Point CorpsePositionForTrackerId(int corpseId) -{ - return { corpseId % MAXDUNX, corpseId / MAXDUNX }; -} - -std::optional FindNearestCorpseId(Point playerPosition) -{ - std::optional bestId; - int bestDistance = 0; - - for (int y = 0; y < MAXDUNY; ++y) { - for (int x = 0; x < MAXDUNX; ++x) { - if (dCorpse[x][y] == 0) - continue; - - const Point position { x, y }; - const int distance = playerPosition.WalkingDistance(position); - if (!bestId || distance < bestDistance) { - bestId = CorpseTrackerIdForPosition(position); - bestDistance = distance; - } - } - } - - return bestId; -} - -struct TrackerCandidate { - int id; - int distance; - StringOrView name; -}; - -[[nodiscard]] bool IsBetterTrackerCandidate(const TrackerCandidate &a, const TrackerCandidate &b) -{ - if (a.distance != b.distance) - return a.distance < b.distance; - return a.id < b.id; -} - -[[nodiscard]] std::vector CollectNearbyItemTrackerCandidates(Point playerPosition, int maxDistance) -{ - std::vector result; - result.reserve(ActiveItemCount); - - const int minX = std::max(0, playerPosition.x - maxDistance); - const int minY = std::max(0, playerPosition.y - maxDistance); - const int maxX = std::min(MAXDUNX - 1, playerPosition.x + maxDistance); - const int maxY = std::min(MAXDUNY - 1, playerPosition.y + maxDistance); - - std::array seen {}; - - for (int y = minY; y <= maxY; ++y) { - for (int x = minX; x <= maxX; ++x) { - const int itemId = std::abs(dItem[x][y]) - 1; - if (itemId < 0 || itemId > MAXITEMS) - continue; - if (seen[itemId]) - continue; - seen[itemId] = true; - - const Item &item = Items[itemId]; - if (item.isEmpty() || item._iClass == ICLASS_NONE) - continue; - - const int distance = playerPosition.WalkingDistance(Point { x, y }); - if (distance > maxDistance) - continue; - - result.push_back(TrackerCandidate { - .id = itemId, - .distance = distance, - .name = item.getName(), - }); - } - } - - std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); - return result; -} - -[[nodiscard]] std::vector CollectNearbyCorpseTrackerCandidates(Point playerPosition, int maxDistance) -{ - std::vector result; - - const int minX = std::max(0, playerPosition.x - maxDistance); - const int minY = std::max(0, playerPosition.y - maxDistance); - const int maxX = std::min(MAXDUNX - 1, playerPosition.x + maxDistance); - const int maxY = std::min(MAXDUNY - 1, playerPosition.y + maxDistance); - - for (int y = minY; y <= maxY; ++y) { - for (int x = minX; x <= maxX; ++x) { - if (dCorpse[x][y] == 0) - continue; - - const Point position { x, y }; - const int distance = playerPosition.WalkingDistance(position); - if (distance > maxDistance) - continue; - - result.push_back(TrackerCandidate { - .id = CorpseTrackerIdForPosition(position), - .distance = distance, - .name = _("Dead body"), - }); - } - } - - std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); - return result; -} - -[[nodiscard]] constexpr bool IsTrackedChestObject(const Object &object) -{ - return object.canInteractWith() && (object.IsChest() || object._otype == _object_id::OBJ_SIGNCHEST); -} - -[[nodiscard]] constexpr bool IsTrackedDoorObject(const Object &object) -{ - // Track both closed and open doors (to match proximity audio cues). - return object.isDoor() && object.canInteractWith(); -} - -[[nodiscard]] constexpr bool IsShrineLikeObject(const Object &object) -{ - return object.canInteractWith() - && (object.IsShrine() - || IsAnyOf(object._otype, _object_id::OBJ_BLOODFTN, _object_id::OBJ_PURIFYINGFTN, _object_id::OBJ_GOATSHRINE, _object_id::OBJ_CAULDRON, - _object_id::OBJ_MURKYFTN, _object_id::OBJ_TEARFTN)); -} - -[[nodiscard]] constexpr bool IsTrackedBreakableObject(const Object &object) -{ - return object.IsBreakable(); -} - -[[nodiscard]] constexpr bool IsTrackedMiscInteractableObject(const Object &object) -{ - if (!object.canInteractWith()) - return false; - if (object.IsChest() || object._otype == _object_id::OBJ_SIGNCHEST) - return false; - if (object.isDoor()) - return false; - if (IsShrineLikeObject(object)) - return false; - if (object.IsBreakable()) - return false; - return true; -} - -[[nodiscard]] bool IsTrackedMonster(const Monster &monster) -{ - return !monster.isInvalid - && (monster.flags & MFLAG_HIDDEN) == 0 - && monster.hitPoints > 0; -} - -template -[[nodiscard]] std::vector CollectNearbyObjectTrackerCandidates(Point playerPosition, int maxDistance, Predicate predicate) -{ - std::vector result; - result.reserve(ActiveObjectCount); - - const int minX = std::max(0, playerPosition.x - maxDistance); - const int minY = std::max(0, playerPosition.y - maxDistance); - const int maxX = std::min(MAXDUNX - 1, playerPosition.x + maxDistance); - const int maxY = std::min(MAXDUNY - 1, playerPosition.y + maxDistance); - - std::array bestDistanceById {}; - bestDistanceById.fill(std::numeric_limits::max()); - - for (int y = minY; y <= maxY; ++y) { - for (int x = minX; x <= maxX; ++x) { - const int objectId = std::abs(dObject[x][y]) - 1; - if (objectId < 0 || objectId >= MAXOBJECTS) - continue; - - const Object &object = Objects[objectId]; - if (object._otype == OBJ_NULL) - continue; - if (!predicate(object)) - continue; - - const int distance = playerPosition.WalkingDistance(Point { x, y }); - if (distance > maxDistance) - continue; - - int &bestDistance = bestDistanceById[objectId]; - if (distance < bestDistance) - bestDistance = distance; - } - } - - for (int objectId = 0; objectId < MAXOBJECTS; ++objectId) { - const int distance = bestDistanceById[objectId]; - if (distance == std::numeric_limits::max()) - continue; - - const Object &object = Objects[objectId]; - result.push_back(TrackerCandidate { - .id = objectId, - .distance = distance, - .name = object.name(), - }); - } - - std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); - return result; -} - -template -[[nodiscard]] std::optional FindNearestObjectId(Point playerPosition, Predicate predicate) -{ - std::array bestDistanceById {}; - bestDistanceById.fill(std::numeric_limits::max()); - - for (int y = 0; y < MAXDUNY; ++y) { - for (int x = 0; x < MAXDUNX; ++x) { - const int objectId = std::abs(dObject[x][y]) - 1; - if (objectId < 0 || objectId >= MAXOBJECTS) - continue; - - const Object &object = Objects[objectId]; - if (object._otype == OBJ_NULL) - continue; - if (!predicate(object)) - continue; - - const int distance = playerPosition.WalkingDistance(Point { x, y }); - int &bestDistance = bestDistanceById[objectId]; - if (distance < bestDistance) - bestDistance = distance; - } - } - - std::optional bestId; - int bestDistance = 0; - for (int objectId = 0; objectId < MAXOBJECTS; ++objectId) { - const int distance = bestDistanceById[objectId]; - if (distance == std::numeric_limits::max()) - continue; - - if (!bestId || distance < bestDistance) { - bestId = objectId; - bestDistance = distance; - } - } - - return bestId; -} - -[[nodiscard]] std::vector CollectNearbyChestTrackerCandidates(Point playerPosition, int maxDistance) -{ - return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedChestObject); -} - -[[nodiscard]] std::vector CollectNearbyDoorTrackerCandidates(Point playerPosition, int maxDistance) -{ - return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedDoorObject); -} - -[[nodiscard]] std::vector CollectNearbyShrineTrackerCandidates(Point playerPosition, int maxDistance) -{ - return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsShrineLikeObject); -} - -[[nodiscard]] std::vector CollectNearbyBreakableTrackerCandidates(Point playerPosition, int maxDistance) -{ - return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedBreakableObject); -} - -[[nodiscard]] std::vector CollectNearbyObjectInteractableTrackerCandidates(Point playerPosition, int maxDistance) -{ - return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedMiscInteractableObject); -} - -[[nodiscard]] std::vector CollectNearbyMonsterTrackerCandidates(Point playerPosition, int maxDistance) -{ - std::vector result; - result.reserve(ActiveMonsterCount); - - 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 (monster.hitPoints <= 0) - continue; - - const Point monsterDistancePosition { monster.position.future }; - const int distance = playerPosition.ApproxDistance(monsterDistancePosition); - if (distance > maxDistance) - continue; - - result.push_back(TrackerCandidate { - .id = monsterId, - .distance = distance, - .name = monster.name(), - }); - } - - std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); - return result; -} - -[[nodiscard]] std::optional FindNextTrackerCandidateId(const std::vector &candidates, int currentId) -{ - if (candidates.empty()) - return std::nullopt; - if (currentId < 0) - return candidates.front().id; - - const auto it = std::find_if(candidates.begin(), candidates.end(), [currentId](const TrackerCandidate &c) { return c.id == currentId; }); - if (it == candidates.end()) - return candidates.front().id; - - if (candidates.size() <= 1) - return std::nullopt; - - const size_t idx = static_cast(it - candidates.begin()); - const size_t nextIdx = (idx + 1) % candidates.size(); - return candidates[nextIdx].id; -} - -void DecorateTrackerTargetNameWithOrdinalIfNeeded(int targetId, StringOrView &targetName, const std::vector &candidates) -{ - if (targetName.empty()) - return; - - const std::string_view baseName = targetName.str(); - int total = 0; - for (const TrackerCandidate &c : candidates) { - if (c.name.str() == baseName) - ++total; - } - if (total <= 1) - return; - - int ordinal = 0; - int seen = 0; - for (const TrackerCandidate &c : candidates) { - if (c.name.str() != baseName) - continue; - ++seen; - if (c.id == targetId) { - ordinal = seen; - break; - } - } - if (ordinal <= 0) - return; - - std::string decorated; - StrAppend(decorated, baseName, " ", ordinal); - targetName = std::move(decorated); -} - -[[nodiscard]] bool IsGroundItemPresent(int itemId) -{ - if (itemId < 0 || itemId > MAXITEMS) - return false; - - for (uint8_t i = 0; i < ActiveItemCount; ++i) { - if (ActiveItems[i] == itemId) - return true; - } - - return false; -} - -[[nodiscard]] bool IsCorpsePresent(int corpseId) -{ - if (corpseId < 0 || corpseId >= MAXDUNX * MAXDUNY) - return false; - - const Point position = CorpsePositionForTrackerId(corpseId); - return InDungeonBounds(position) && dCorpse[position.x][position.y] != 0; -} - -std::optional FindNearestUnopenedChestObjectId(Point playerPosition) -{ - return FindNearestObjectId(playerPosition, IsTrackedChestObject); -} - -std::optional FindNearestDoorObjectId(Point playerPosition) -{ - return FindNearestObjectId(playerPosition, IsTrackedDoorObject); -} - -std::optional FindNearestShrineObjectId(Point playerPosition) -{ - return FindNearestObjectId(playerPosition, IsShrineLikeObject); -} - -std::optional FindNearestBreakableObjectId(Point playerPosition) -{ - return FindNearestObjectId(playerPosition, IsTrackedBreakableObject); -} - -std::optional FindNearestMiscInteractableObjectId(Point playerPosition) -{ - return FindNearestObjectId(playerPosition, IsTrackedMiscInteractableObject); -} - -std::optional FindNearestMonsterId(Point playerPosition) -{ - std::optional bestId; - int bestDistance = 0; - - 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 (monster.hitPoints <= 0) - continue; - - const Point monsterDistancePosition { monster.position.future }; - const int distance = playerPosition.ApproxDistance(monsterDistancePosition); - if (!bestId || distance < bestDistance) { - bestId = monsterId; - bestDistance = distance; - } - } - - return bestId; -} - -std::optional FindBestAdjacentApproachTile(const Player &player, Point playerPosition, Point targetPosition) -{ - std::optional best; - size_t bestPathLength = 0; - int bestDistance = 0; - - std::optional bestFallback; - int bestFallbackDistance = 0; - - for (int dy = -1; dy <= 1; ++dy) { - for (int dx = -1; dx <= 1; ++dx) { - if (dx == 0 && dy == 0) - continue; - - const Point tile { targetPosition.x + dx, targetPosition.y + dy }; - if (!PosOkPlayer(player, tile)) - continue; - - const int distance = playerPosition.WalkingDistance(tile); - - if (!bestFallback || distance < bestFallbackDistance) { - bestFallback = tile; - bestFallbackDistance = distance; - } - - const std::optional> path = FindKeyboardWalkPathForSpeech(player, playerPosition, tile); - if (!path) - continue; - - const size_t pathLength = path->size(); - if (!best || pathLength < bestPathLength || (pathLength == bestPathLength && distance < bestDistance)) { - best = tile; - bestPathLength = pathLength; - bestDistance = distance; - } - } - } - - if (best) - return best; - - return bestFallback; -} - -bool PosOkPlayerIgnoreDoors(const Player &player, Point position) -{ - if (!InDungeonBounds(position)) - return false; - if (!IsTileWalkable(position, /*ignoreDoors=*/true)) - return false; - - Player *otherPlayer = PlayerAtPosition(position); - if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) - return false; - - if (dMonster[position.x][position.y] != 0) { - if (leveltype == DTYPE_TOWN) - return false; - if (dMonster[position.x][position.y] <= 0) - return false; - if (!Monsters[dMonster[position.x][position.y] - 1].hasNoLife()) - return false; - } - - return true; -} - -[[nodiscard]] bool IsTileWalkableForTrackerPath(Point position, bool ignoreDoors, bool ignoreBreakables) -{ - Object *object = FindObjectAtPosition(position); - if (object != nullptr) { - if (ignoreDoors && object->isDoor()) { - return true; - } - if (ignoreBreakables && object->_oSolidFlag && object->IsBreakable()) { - return true; - } - if (object->_oSolidFlag) { - return false; - } - } - - return IsTileNotSolid(position); -} - -bool PosOkPlayerIgnoreMonsters(const Player &player, Point position) -{ - if (!InDungeonBounds(position)) - return false; - if (!IsTileWalkableForTrackerPath(position, /*ignoreDoors=*/false, /*ignoreBreakables=*/false)) - return false; - - Player *otherPlayer = PlayerAtPosition(position); - if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) - return false; - - return true; -} - -bool PosOkPlayerIgnoreDoorsAndMonsters(const Player &player, Point position) -{ - if (!InDungeonBounds(position)) - return false; - if (!IsTileWalkableForTrackerPath(position, /*ignoreDoors=*/true, /*ignoreBreakables=*/false)) - return false; - - Player *otherPlayer = PlayerAtPosition(position); - if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) - return false; - - return true; -} - -bool PosOkPlayerIgnoreDoorsMonstersAndBreakables(const Player &player, Point position) -{ - if (!InDungeonBounds(position)) - return false; - if (!IsTileWalkableForTrackerPath(position, /*ignoreDoors=*/true, /*ignoreBreakables=*/true)) - return false; - - Player *otherPlayer = PlayerAtPosition(position); - if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) - return false; - - return true; -} - -std::optional FindBestApproachTileForObject(const Player &player, Point playerPosition, const Object &object) -{ - // Some interactable objects are placed on a walkable tile (e.g. floor switches). Prefer stepping on the tile in that case. - if (!object._oSolidFlag && PosOkPlayer(player, object.position)) - return object.position; - - std::optional best; - size_t bestPathLength = 0; - int bestDistance = 0; - - std::optional bestFallback; - int bestFallbackDistance = 0; - - const auto considerTile = [&](Point tile) { - if (!PosOkPlayerIgnoreDoors(player, tile)) - return; - - const int distance = playerPosition.WalkingDistance(tile); - if (!bestFallback || distance < bestFallbackDistance) { - bestFallback = tile; - bestFallbackDistance = distance; - } - - const std::optional> path = FindKeyboardWalkPathForSpeech(player, playerPosition, tile); - if (!path) - return; - - const size_t pathLength = path->size(); - if (!best || pathLength < bestPathLength || (pathLength == bestPathLength && distance < bestDistance)) { - best = tile; - bestPathLength = pathLength; - bestDistance = distance; - } - }; - - for (int dy = -1; dy <= 1; ++dy) { - for (int dx = -1; dx <= 1; ++dx) { - if (dx == 0 && dy == 0) - continue; - considerTile(object.position + Displacement { dx, dy }); - } - } - - if (FindObjectAtPosition(object.position + Direction::NorthEast) == &object) { - // Special case for large objects (e.g. sarcophagi): allow approaching from one tile further to the north. - for (int dx = -1; dx <= 1; ++dx) { - considerTile(object.position + Displacement { dx, -2 }); - } - } - - if (best) - return best; - - return bestFallback; -} - -struct DoorBlockInfo { - Point beforeDoor; - Point doorPosition; -}; - -std::optional FindFirstClosedDoorOnWalkPath(Point startPosition, const int8_t *path, int steps) -{ - Point position = startPosition; - for (int i = 0; i < steps; ++i) { - const Point next = NextPositionForWalkDirection(position, path[i]); - Object *object = FindObjectAtPosition(next); - // Only closed doors block the path; blocked doors (DOOR_BLOCKED) are physically passable. - if (object != nullptr && object->isDoor() && object->_oVar4 == DOOR_CLOSED) { - return DoorBlockInfo { .beforeDoor = position, .doorPosition = object->position }; - } - position = next; - } - return std::nullopt; -} - -enum class TrackerPathBlockType : uint8_t { - Door, - Monster, - Breakable, -}; - -struct TrackerPathBlockInfo { - TrackerPathBlockType type; - size_t stepIndex; - Point beforeBlock; - Point blockPosition; -}; - -[[nodiscard]] std::optional FindFirstTrackerPathBlock(Point startPosition, const int8_t *path, size_t steps, bool considerDoors, bool considerMonsters, bool considerBreakables, Point targetPosition) -{ - Point position = startPosition; - for (size_t i = 0; i < steps; ++i) { - const Point next = NextPositionForWalkDirection(position, path[i]); - if (next == targetPosition) { - position = next; - continue; - } - - Object *object = FindObjectAtPosition(next); - // Only closed doors block the path; blocked doors (DOOR_BLOCKED) are physically passable. - if (considerDoors && object != nullptr && object->isDoor() && object->_oVar4 == DOOR_CLOSED) { - return TrackerPathBlockInfo { - .type = TrackerPathBlockType::Door, - .stepIndex = i, - .beforeBlock = position, - .blockPosition = object->position, - }; - } - if (considerBreakables && object != nullptr && object->_oSolidFlag && object->IsBreakable()) { - return TrackerPathBlockInfo { - .type = TrackerPathBlockType::Breakable, - .stepIndex = i, - .beforeBlock = position, - .blockPosition = next, - }; - } - - if (considerMonsters && leveltype != DTYPE_TOWN && dMonster[next.x][next.y] != 0) { - const int monsterRef = dMonster[next.x][next.y]; - const int monsterId = std::abs(monsterRef) - 1; - const bool blocks = monsterRef <= 0 || (monsterId >= 0 && monsterId < static_cast(MaxMonsters) && !Monsters[monsterId].hasNoLife()); - if (blocks) { - return TrackerPathBlockInfo { - .type = TrackerPathBlockType::Monster, - .stepIndex = i, - .beforeBlock = position, - .blockPosition = next, - }; - } - } - - position = next; - } - - return std::nullopt; -} - -void NavigateToTrackerTargetKeyPressed() -{ - if (!CanPlayerTakeAction() || InGameMenu()) - return; - if (leveltype == DTYPE_TOWN && IsNoneOf(SelectedTrackerTargetCategory, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies)) { - SpeakText(_("Not in a dungeon."), true); - return; - } - if (AutomapActive) { - SpeakText(_("Close the map first."), true); - return; - } - if (MyPlayer == nullptr) - return; - - EnsureTrackerLocksMatchCurrentLevel(); - - const SDL_Keymod modState = SDL_GetModState(); - const bool cycleTarget = (modState & SDL_KMOD_SHIFT) != 0; - const bool clearTarget = (modState & SDL_KMOD_CTRL) != 0; - - const Point playerPosition = MyPlayer->position.future; - AutoWalkTrackerTargetId = -1; - - int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); - if (clearTarget) { - lockedTargetId = -1; - SpeakText(_("Tracker target cleared."), true); - return; - } - - std::optional targetId; - std::optional targetPosition; - std::optional alternateTargetPosition; - StringOrView targetName; - - switch (SelectedTrackerTargetCategory) { - case TrackerTargetCategory::Items: { - const std::vector nearbyCandidates = CollectNearbyItemTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - if (cycleTarget) { - targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); - if (!targetId) { - if (nearbyCandidates.empty()) - SpeakText(_("No items found."), true); - else - SpeakText(_("No next item."), true); - return; - } - } else if (IsGroundItemPresent(lockedTargetId)) { - targetId = lockedTargetId; - } else { - targetId = FindNearestGroundItemId(playerPosition); - } - if (!targetId) { - SpeakText(_("No items found."), true); - return; - } - - if (!IsGroundItemPresent(*targetId)) { - lockedTargetId = -1; - SpeakText(_("No items found."), true); - return; - } - - lockedTargetId = *targetId; - const Item &tracked = Items[*targetId]; - - targetName = tracked.getName(); - DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); - targetPosition = tracked.position; - break; - } - case TrackerTargetCategory::Chests: { - const std::vector nearbyCandidates = CollectNearbyChestTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - if (cycleTarget) { - targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); - if (!targetId) { - if (nearbyCandidates.empty()) - SpeakText(_("No chests found."), true); - else - SpeakText(_("No next chest."), true); - return; - } - } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = FindNearestUnopenedChestObjectId(playerPosition); - } - if (!targetId) { - SpeakText(_("No chests found."), true); - return; - } - - const Object &object = Objects[*targetId]; - if (!IsTrackedChestObject(object)) { - lockedTargetId = -1; - targetId = FindNearestUnopenedChestObjectId(playerPosition); - if (!targetId) { - SpeakText(_("No chests found."), true); - return; - } - } - - lockedTargetId = *targetId; - const Object &tracked = Objects[*targetId]; - - targetName = tracked.name(); - DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); - if (!cycleTarget) { - targetPosition = tracked.position; - if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) - alternateTargetPosition = tracked.position + Direction::NorthEast; - } - break; - } - case TrackerTargetCategory::Doors: { - std::vector nearbyCandidates = CollectNearbyDoorTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - for (TrackerCandidate &c : nearbyCandidates) { - if (c.id < 0 || c.id >= MAXOBJECTS) - continue; - c.name = DoorLabelForSpeech(Objects[c.id]); - } - if (cycleTarget) { - targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); - if (!targetId) { - if (nearbyCandidates.empty()) - SpeakText(_("No doors found."), true); - else - SpeakText(_("No next door."), true); - return; - } - } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = FindNearestDoorObjectId(playerPosition); - } - if (!targetId) { - SpeakText(_("No doors found."), true); - return; - } - - const Object &object = Objects[*targetId]; - if (!IsTrackedDoorObject(object)) { - lockedTargetId = -1; - targetId = FindNearestDoorObjectId(playerPosition); - if (!targetId) { - SpeakText(_("No doors found."), true); - return; - } - } - - lockedTargetId = *targetId; - const Object &tracked = Objects[*targetId]; - - targetName = DoorLabelForSpeech(tracked); - DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); - if (!cycleTarget) { - targetPosition = tracked.position; - if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) - alternateTargetPosition = tracked.position + Direction::NorthEast; - } - break; - } - case TrackerTargetCategory::Shrines: { - const std::vector nearbyCandidates = CollectNearbyShrineTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - if (cycleTarget) { - targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); - if (!targetId) { - if (nearbyCandidates.empty()) - SpeakText(_("No shrines found."), true); - else - SpeakText(_("No next shrine."), true); - return; - } - } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = FindNearestShrineObjectId(playerPosition); - } - if (!targetId) { - SpeakText(_("No shrines found."), true); - return; - } - - const Object &object = Objects[*targetId]; - if (!IsShrineLikeObject(object)) { - lockedTargetId = -1; - targetId = FindNearestShrineObjectId(playerPosition); - if (!targetId) { - SpeakText(_("No shrines found."), true); - return; - } - } - - lockedTargetId = *targetId; - const Object &tracked = Objects[*targetId]; - - targetName = tracked.name(); - DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); - if (!cycleTarget) { - targetPosition = tracked.position; - if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) - alternateTargetPosition = tracked.position + Direction::NorthEast; - } - break; - } - case TrackerTargetCategory::Objects: { - const std::vector nearbyCandidates = CollectNearbyObjectInteractableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - if (cycleTarget) { - targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); - if (!targetId) { - if (nearbyCandidates.empty()) - SpeakText(_("No objects found."), true); - else - SpeakText(_("No next object."), true); - return; - } - } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = FindNearestMiscInteractableObjectId(playerPosition); - } - if (!targetId) { - SpeakText(_("No objects found."), true); - return; - } - - const Object &object = Objects[*targetId]; - if (!IsTrackedMiscInteractableObject(object)) { - lockedTargetId = -1; - targetId = FindNearestMiscInteractableObjectId(playerPosition); - if (!targetId) { - SpeakText(_("No objects found."), true); - return; - } - } - - lockedTargetId = *targetId; - const Object &tracked = Objects[*targetId]; - - targetName = tracked.name(); - DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); - if (!cycleTarget) { - targetPosition = tracked.position; - if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) - alternateTargetPosition = tracked.position + Direction::NorthEast; - } - break; - } - case TrackerTargetCategory::Breakables: { - const std::vector nearbyCandidates = CollectNearbyBreakableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - if (cycleTarget) { - targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); - if (!targetId) { - if (nearbyCandidates.empty()) - SpeakText(_("No breakables found."), true); - else - SpeakText(_("No next breakable."), true); - return; - } - } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = FindNearestBreakableObjectId(playerPosition); - } - if (!targetId) { - SpeakText(_("No breakables found."), true); - return; - } - - const Object &object = Objects[*targetId]; - if (!IsTrackedBreakableObject(object)) { - lockedTargetId = -1; - targetId = FindNearestBreakableObjectId(playerPosition); - if (!targetId) { - SpeakText(_("No breakables found."), true); - return; - } - } - - lockedTargetId = *targetId; - const Object &tracked = Objects[*targetId]; - - targetName = tracked.name(); - DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); - if (!cycleTarget) { - targetPosition = tracked.position; - if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) - alternateTargetPosition = tracked.position + Direction::NorthEast; - } - break; - } - case TrackerTargetCategory::Monsters: { - const std::vector nearbyCandidates = CollectNearbyMonsterTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - if (cycleTarget) { - targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); - if (!targetId) { - if (nearbyCandidates.empty()) - SpeakText(_("No monsters found."), true); - else - SpeakText(_("No next monster."), true); - return; - } - } else if (lockedTargetId >= 0 && lockedTargetId < static_cast(MaxMonsters)) { - targetId = lockedTargetId; - } else { - targetId = FindNearestMonsterId(playerPosition); - } - if (!targetId) { - SpeakText(_("No monsters found."), true); - return; - } - - const Monster &monster = Monsters[*targetId]; - if (!IsTrackedMonster(monster)) { - lockedTargetId = -1; - targetId = FindNearestMonsterId(playerPosition); - if (!targetId) { - SpeakText(_("No monsters found."), true); - return; - } - } - - lockedTargetId = *targetId; - const Monster &tracked = Monsters[*targetId]; - - targetName = tracked.name(); - DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); - if (!cycleTarget) { - targetPosition = tracked.position.tile; - } - break; - } - case TrackerTargetCategory::DeadBodies: { - const std::vector nearbyCandidates = CollectNearbyCorpseTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - if (cycleTarget) { - targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); - if (!targetId) { - if (nearbyCandidates.empty()) - SpeakText(_("No dead bodies found."), true); - else - SpeakText(_("No next dead body."), true); - return; - } - } else if (IsCorpsePresent(lockedTargetId)) { - targetId = lockedTargetId; - } else { - targetId = FindNearestCorpseId(playerPosition); - } - if (!targetId) { - SpeakText(_("No dead bodies found."), true); - return; - } - - if (!IsCorpsePresent(*targetId)) { - lockedTargetId = -1; - SpeakText(_("No dead bodies found."), true); - return; - } - - lockedTargetId = *targetId; - targetName = _("Dead body"); - DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); - if (!cycleTarget) { - targetPosition = CorpsePositionForTrackerId(*targetId); - } - break; - } - } - - if (cycleTarget) { - SpeakText(targetName.str(), /*force=*/true); - return; - } - - if (!targetPosition) { - SpeakText(_("Can't find a nearby tile to walk to."), true); - return; - } - - Point chosenTargetPosition = *targetPosition; - enum class TrackerPathMode : uint8_t { - RespectDoorsAndMonsters, - IgnoreDoors, - IgnoreMonsters, - IgnoreDoorsAndMonsters, - Lenient, - }; - - auto findPathToTarget = [&](Point destination, TrackerPathMode mode) -> std::optional> { - const bool allowDestinationNonWalkable = !PosOkPlayer(*MyPlayer, destination); - switch (mode) { - case TrackerPathMode::RespectDoorsAndMonsters: - return FindKeyboardWalkPathForSpeechRespectingDoors(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); - case TrackerPathMode::IgnoreDoors: - return FindKeyboardWalkPathForSpeech(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); - case TrackerPathMode::IgnoreMonsters: - return FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); - case TrackerPathMode::IgnoreDoorsAndMonsters: - return FindKeyboardWalkPathForSpeechIgnoringMonsters(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); - case TrackerPathMode::Lenient: - return FindKeyboardWalkPathForSpeechLenient(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); - default: - return std::nullopt; - } - }; - - std::optional> spokenPath; - bool pathIgnoresDoors = false; - bool pathIgnoresMonsters = false; - bool pathIgnoresBreakables = false; - - const auto considerDestination = [&](Point destination, TrackerPathMode mode) { - const std::optional> candidate = findPathToTarget(destination, mode); - if (!candidate) - return; - if (!spokenPath || candidate->size() < spokenPath->size()) { - spokenPath = *candidate; - chosenTargetPosition = destination; - - pathIgnoresDoors = mode == TrackerPathMode::IgnoreDoors || mode == TrackerPathMode::IgnoreDoorsAndMonsters || mode == TrackerPathMode::Lenient; - pathIgnoresMonsters = mode == TrackerPathMode::IgnoreMonsters || mode == TrackerPathMode::IgnoreDoorsAndMonsters || mode == TrackerPathMode::Lenient; - pathIgnoresBreakables = mode == TrackerPathMode::Lenient; - } - }; - - considerDestination(*targetPosition, TrackerPathMode::RespectDoorsAndMonsters); - if (alternateTargetPosition) - considerDestination(*alternateTargetPosition, TrackerPathMode::RespectDoorsAndMonsters); - - if (!spokenPath) { - considerDestination(*targetPosition, TrackerPathMode::IgnoreDoors); - if (alternateTargetPosition) - considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreDoors); - } - - if (!spokenPath) { - considerDestination(*targetPosition, TrackerPathMode::IgnoreMonsters); - if (alternateTargetPosition) - considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreMonsters); - } - - if (!spokenPath) { - considerDestination(*targetPosition, TrackerPathMode::IgnoreDoorsAndMonsters); - if (alternateTargetPosition) - considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreDoorsAndMonsters); - } - - if (!spokenPath) { - considerDestination(*targetPosition, TrackerPathMode::Lenient); - if (alternateTargetPosition) - considerDestination(*alternateTargetPosition, TrackerPathMode::Lenient); - } - - bool showUnreachableWarning = false; - if (!spokenPath) { - showUnreachableWarning = true; - Point closestPosition; - spokenPath = FindKeyboardWalkPathToClosestReachableForSpeech(*MyPlayer, playerPosition, chosenTargetPosition, closestPosition); - pathIgnoresDoors = true; - pathIgnoresMonsters = false; - pathIgnoresBreakables = false; - } - - if (spokenPath && !showUnreachableWarning && !PosOkPlayer(*MyPlayer, chosenTargetPosition)) { - if (!spokenPath->empty()) - spokenPath->pop_back(); - } - - if (spokenPath && (pathIgnoresDoors || pathIgnoresMonsters || pathIgnoresBreakables)) { - const std::optional block = FindFirstTrackerPathBlock(playerPosition, spokenPath->data(), spokenPath->size(), pathIgnoresDoors, pathIgnoresMonsters, pathIgnoresBreakables, chosenTargetPosition); - if (block) { - if (playerPosition.WalkingDistance(block->blockPosition) <= TrackerInteractDistanceTiles) { - switch (block->type) { - case TrackerPathBlockType::Door: - SpeakText(_("A door is blocking the path. Open it and try again."), true); - return; - case TrackerPathBlockType::Monster: - SpeakText(_("A monster is blocking the path. Clear it and try again."), true); - return; - case TrackerPathBlockType::Breakable: - SpeakText(_("A breakable object is blocking the path. Destroy it and try again."), true); - return; - } - } - - spokenPath = std::vector(spokenPath->begin(), spokenPath->begin() + block->stepIndex); - } - } - - std::string message; - if (!targetName.empty()) - StrAppend(message, targetName, "\n"); - if (showUnreachableWarning) { - message.append(_("Can't find a path to the target.")); - if (spokenPath && !spokenPath->empty()) - message.append("\n"); - } - if (spokenPath) { - if (!showUnreachableWarning || !spokenPath->empty()) - AppendKeyboardWalkPathForSpeech(message, *spokenPath); - } - - SpeakText(message, true); -} - -} // namespace - -/** - * Validates an object-category auto-walk target and computes the walk destination. - * - * Checks bounds, applies the validity predicate, tests interaction distance, and - * computes the approach tile. On failure the walk is cancelled and a spoken - * message is emitted. - * - * @param[out] destination Set to the approach tile when the target is valid. - * @return true if the walk should continue (destination is set), false if cancelled. - */ -template -bool ValidateAutoWalkObjectTarget( - const Player &myPlayer, Point playerPosition, - Predicate isValid, const char *goneMessage, const char *inRangeMessage, - std::optional &destination) -{ - const int objectId = AutoWalkTrackerTargetId; - if (objectId < 0 || objectId >= MAXOBJECTS) { - AutoWalkTrackerTargetId = -1; - SpeakText(_(goneMessage), true); - return false; - } - const Object &object = Objects[objectId]; - if (!isValid(object)) { - AutoWalkTrackerTargetId = -1; - SpeakText(_(goneMessage), true); - return false; - } - if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_(inRangeMessage), true); - return false; - } - destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); - return true; -} - -/** - * Resolves which object to walk toward for the given tracker category. - * - * Uses the locked target if it is still valid, otherwise falls back to the - * nearest matching object. On success, updates lockedTargetId and targetName. - * - * @return The resolved object ID, or nullopt if nothing was found (a spoken - * message will already have been emitted). - */ -template -std::optional ResolveObjectTrackerTarget( - int &lockedTargetId, Point playerPosition, - Predicate isValid, FindNearest findNearest, GetName getName, - const char *notFoundMessage, StringOrView &targetName) -{ - std::optional targetId; - if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = findNearest(playerPosition); - } - if (!targetId) { - SpeakText(_(notFoundMessage), true); - return std::nullopt; - } - if (!isValid(Objects[*targetId])) { - lockedTargetId = -1; - targetId = findNearest(playerPosition); - if (!targetId) { - SpeakText(_(notFoundMessage), true); - return std::nullopt; - } - // findNearest guarantees the result passes isValid, but verify defensively. - if (!isValid(Objects[*targetId])) { - SpeakText(_(notFoundMessage), true); - return std::nullopt; - } - } - lockedTargetId = *targetId; - targetName = getName(*targetId); - return targetId; -} - -/** - * Called each game tick to advance auto-walk toward the current tracker target. - * Does nothing if no target is active (AutoWalkTrackerTargetId < 0) or if the - * player is not idle. Validates the target still exists and is reachable, then - * computes a path. If a closed door blocks the path, reroutes to the tile - * before the door. Long paths are sent in segments. - */ -void UpdateAutoWalkTracker() -{ - if (AutoWalkTrackerTargetId < 0) - return; - if (leveltype == DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag || InGameMenu()) { - AutoWalkTrackerTargetId = -1; - return; - } - if (!CanPlayerTakeAction()) - return; - - if (MyPlayer == nullptr) { - SpeakText(_("Cannot walk right now."), true); - return; - } - if (MyPlayer->_pmode != PM_STAND) - return; - if (MyPlayer->walkpath[0] != WALK_NONE) - return; - if (MyPlayer->destAction != ACTION_NONE) - return; - - Player &myPlayer = *MyPlayer; - const Point playerPosition = myPlayer.position.future; - - std::optional destination; - - switch (AutoWalkTrackerTargetCategory) { - case TrackerTargetCategory::Items: { - const int itemId = AutoWalkTrackerTargetId; - if (itemId < 0 || itemId > MAXITEMS) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target item is gone."), true); - return; - } - if (!IsGroundItemPresent(itemId)) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target item is gone."), true); - return; - } - const Item &item = Items[itemId]; - if (playerPosition.WalkingDistance(item.position) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Item in range."), true); - return; - } - destination = item.position; - break; - } - case TrackerTargetCategory::Chests: - if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, - IsTrackedChestObject, N_("Target chest is gone."), N_("Chest in range."), destination)) - return; - break; - case TrackerTargetCategory::Doors: - if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedDoorObject, N_("Target door is gone."), N_("Door in range."), destination)) - return; - break; - case TrackerTargetCategory::Shrines: - if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsShrineLikeObject, N_("Target shrine is gone."), N_("Shrine in range."), destination)) - return; - break; - case TrackerTargetCategory::Objects: - if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedMiscInteractableObject, N_("Target object is gone."), N_("Object in range."), destination)) - return; - break; - case TrackerTargetCategory::Breakables: - if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedBreakableObject, N_("Target breakable is gone."), N_("Breakable in range."), destination)) - return; - break; - case TrackerTargetCategory::Monsters: { - const int monsterId = AutoWalkTrackerTargetId; - if (monsterId < 0 || monsterId >= static_cast(MaxMonsters)) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target monster is gone."), true); - return; - } - const Monster &monster = Monsters[monsterId]; - if (!IsTrackedMonster(monster)) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target monster is gone."), true); - return; - } - const Point monsterPosition { monster.position.tile }; - if (playerPosition.WalkingDistance(monsterPosition) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Monster in range."), true); - return; - } - destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, monsterPosition); - break; - } - case TrackerTargetCategory::DeadBodies: { - const int corpseId = AutoWalkTrackerTargetId; - if (!IsCorpsePresent(corpseId)) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target dead body is gone."), true); - return; - } - - const Point corpsePosition = CorpsePositionForTrackerId(corpseId); - if (playerPosition.WalkingDistance(corpsePosition) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Dead body in range."), true); - return; - } - - destination = corpsePosition; - break; - } - } - - if (!destination) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Can't find a nearby tile to walk to."), true); - return; - } - - constexpr size_t MaxAutoWalkPathLength = 512; - std::array path; - path.fill(WALK_NONE); - - int steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, *destination, path.data(), path.size()); - // If no direct path exists, try pathfinding that treats closed doors as walkable. - // If that finds a path, identify the first closed door along it and re-route the - // player to the tile just before that door, so they can open it and retry. - if (steps == 0) { - std::array ignoreDoorPath; - ignoreDoorPath.fill(WALK_NONE); - - const int ignoreDoorSteps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayerIgnoreDoors(myPlayer, position); }, playerPosition, *destination, ignoreDoorPath.data(), ignoreDoorPath.size()); - if (ignoreDoorSteps != 0) { - const std::optional block = FindFirstClosedDoorOnWalkPath(playerPosition, ignoreDoorPath.data(), ignoreDoorSteps); - if (block) { - if (playerPosition.WalkingDistance(block->doorPosition) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("A door is blocking the path. Open it and try again."), true); - return; - } - - *destination = block->beforeDoor; - path.fill(WALK_NONE); - steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, *destination, path.data(), path.size()); - } - } - - if (steps == 0) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Can't find a path to the target."), true); - return; - } - } - - // FindPath returns 0 if the path length is equal to the maximum. - // The player walkpath buffer is MaxPathLengthPlayer, so keep segments strictly shorter. - if (steps < static_cast(MaxPathLengthPlayer)) { - NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, *destination); - return; - } - - const int segmentSteps = std::min(steps - 1, static_cast(MaxPathLengthPlayer - 1)); - const Point waypoint = PositionAfterWalkPathSteps(playerPosition, path.data(), segmentSteps); - NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, waypoint); -} - -/** - * Initiates auto-walk toward the currently selected tracker target (M key). - * Resolves the target from the locked tracker target or the nearest of the - * selected category. Sets AutoWalkTrackerTargetId/Category, then calls - * UpdateAutoWalkTracker() to begin the first walk segment. Subsequent segments - * are issued per-tick. Press M again to cancel. - */ -void AutoWalkToTrackerTargetKeyPressed() -{ - // Cancel in-progress auto-walk (must be checked before the action guard - // so that cancellation works even if the player is mid-walk). - if (AutoWalkTrackerTargetId >= 0) { - CancelAutoWalk(); - SpeakText(_("Walk cancelled."), true); - return; - } - - // Defense-in-depth: keymapper canTrigger also checks these, but guard here - // in case the function is called from another code path. - if (!CanPlayerTakeAction() || InGameMenu()) - return; - - if (leveltype == DTYPE_TOWN) { - SpeakText(_("Not in a dungeon."), true); - return; - } - if (AutomapActive) { - SpeakText(_("Close the map first."), true); - return; - } - if (MyPlayer == nullptr) { - SpeakText(_("Cannot walk right now."), true); - return; - } - - EnsureTrackerLocksMatchCurrentLevel(); - - const Point playerPosition = MyPlayer->position.future; - int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); - - std::optional targetId; - StringOrView targetName; - - switch (SelectedTrackerTargetCategory) { - case TrackerTargetCategory::Items: { - if (IsGroundItemPresent(lockedTargetId)) { - targetId = lockedTargetId; - } else { - targetId = FindNearestGroundItemId(playerPosition); - } - if (!targetId) { - SpeakText(_("No items found."), true); - return; - } - if (!IsGroundItemPresent(*targetId)) { - lockedTargetId = -1; - SpeakText(_("No items found."), true); - return; - } - lockedTargetId = *targetId; - targetName = Items[*targetId].getName(); - break; - } - case TrackerTargetCategory::Chests: - targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedChestObject, FindNearestUnopenedChestObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No chests found."), targetName); - if (!targetId) - return; - break; - case TrackerTargetCategory::Doors: - targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedDoorObject, FindNearestDoorObjectId, [](int id) -> StringOrView { return DoorLabelForSpeech(Objects[id]); }, N_("No doors found."), targetName); - if (!targetId) - return; - break; - case TrackerTargetCategory::Shrines: - targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsShrineLikeObject, FindNearestShrineObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No shrines found."), targetName); - if (!targetId) - return; - break; - case TrackerTargetCategory::Objects: - targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedMiscInteractableObject, FindNearestMiscInteractableObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No objects found."), targetName); - if (!targetId) - return; - break; - case TrackerTargetCategory::Breakables: - targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedBreakableObject, FindNearestBreakableObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No breakables found."), targetName); - if (!targetId) - return; - break; - case TrackerTargetCategory::Monsters: { - if (lockedTargetId >= 0 && lockedTargetId < static_cast(MaxMonsters)) { - targetId = lockedTargetId; - } else { - targetId = FindNearestMonsterId(playerPosition); - } - if (!targetId) { - SpeakText(_("No monsters found."), true); - return; - } - const Monster &monster = Monsters[*targetId]; - if (!IsTrackedMonster(monster)) { - lockedTargetId = -1; - targetId = FindNearestMonsterId(playerPosition); - if (!targetId) { - SpeakText(_("No monsters found."), true); - return; - } - } - lockedTargetId = *targetId; - targetName = Monsters[*targetId].name(); - break; - } - case TrackerTargetCategory::DeadBodies: { - if (IsCorpsePresent(lockedTargetId)) { - targetId = lockedTargetId; - } else { - targetId = FindNearestCorpseId(playerPosition); - } - if (!targetId) { - SpeakText(_("No dead bodies found."), true); - return; - } - if (!IsCorpsePresent(*targetId)) { - lockedTargetId = -1; - SpeakText(_("No dead bodies found."), true); - return; - } - lockedTargetId = *targetId; - targetName = _("Dead body"); - break; - } - } - - std::string msg; - StrAppend(msg, _("Going to: "), targetName); - SpeakText(msg, true); - - AutoWalkTrackerTargetId = *targetId; - AutoWalkTrackerTargetCategory = SelectedTrackerTargetCategory; - UpdateAutoWalkTracker(); -} - -void ListTownNpcsKeyPressed() -{ - if (leveltype != DTYPE_TOWN) { - ResetTownNpcSelection(); - SpeakText(_("Not in town."), true); - return; - } - if (IsPlayerInStore()) - return; - - std::vector townNpcs; - std::vector cows; - - townNpcs.reserve(Towners.size()); - cows.reserve(Towners.size()); - - const Point playerPosition = MyPlayer->position.future; - - for (const Towner &towner : Towners) { - if (!IsTownerPresent(towner._ttype)) - continue; - - if (towner._ttype == TOWN_COW) { - cows.push_back(&towner); - continue; - } - - townNpcs.push_back(&towner); - } - - if (townNpcs.empty() && cows.empty()) { - ResetTownNpcSelection(); - SpeakText(_("No town NPCs found."), true); - return; - } - - std::sort(townNpcs.begin(), townNpcs.end(), [&playerPosition](const Towner *a, const Towner *b) { - const int distanceA = playerPosition.WalkingDistance(a->position); - const int distanceB = playerPosition.WalkingDistance(b->position); - if (distanceA != distanceB) - return distanceA < distanceB; - return a->name < b->name; - }); - - std::string output; - StrAppend(output, _("Town NPCs:")); - for (size_t i = 0; i < townNpcs.size(); ++i) { - StrAppend(output, "\n", i + 1, ". ", townNpcs[i]->name); - } - if (!cows.empty()) { - StrAppend(output, "\n", _("Cows: "), static_cast(cows.size())); - } - - RefreshTownNpcOrder(true); - if (SelectedTownNpc >= 0 && SelectedTownNpc < static_cast(GetNumTowners())) { - const Towner &towner = Towners[SelectedTownNpc]; - StrAppend(output, "\n", _("Selected: "), towner.name); - StrAppend(output, "\n", _("PageUp/PageDown: select. Home: go. End: repeat.")); - } - const std::string_view exitKey = GetOptions().Keymapper.KeyNameForAction("SpeakNearestExit"); - if (!exitKey.empty()) { - StrAppend(output, "\n", fmt::format(fmt::runtime(_("Cathedral entrance: press {:s}.")), exitKey)); - } - - SpeakText(output, true); -} - -namespace { - -using PosOkForSpeechFn = bool (*)(const Player &, Point); - -template -std::optional> FindKeyboardWalkPathForSpeechBfs(const Player &player, Point startPosition, Point destinationPosition, PosOkForSpeechFn posOk, const std::array &walkDirections, bool allowDiagonalSteps, bool allowDestinationNonWalkable) -{ - if (!InDungeonBounds(startPosition) || !InDungeonBounds(destinationPosition)) - return std::nullopt; - - if (startPosition == destinationPosition) - return std::vector {}; - - std::array visited {}; - std::array parentDir {}; - parentDir.fill(WALK_NONE); - - std::queue queue; - - const auto indexOf = [](Point position) -> size_t { - return static_cast(position.x) + static_cast(position.y) * MAXDUNX; - }; - - const auto enqueue = [&](Point current, int8_t dir) { - const Point next = NextPositionForWalkDirection(current, dir); - if (!InDungeonBounds(next)) - return; - - const size_t idx = indexOf(next); - if (visited[idx]) - return; - - const bool ok = posOk(player, next); - if (ok) { - if (!CanStep(current, next)) - return; - } else { - if (!allowDestinationNonWalkable || next != destinationPosition) - return; - } - - visited[idx] = true; - parentDir[idx] = dir; - queue.push(next); - }; - - visited[indexOf(startPosition)] = true; - queue.push(startPosition); - - const auto hasReachedDestination = [&]() -> bool { - return visited[indexOf(destinationPosition)]; - }; - - while (!queue.empty() && !hasReachedDestination()) { - const Point current = queue.front(); - queue.pop(); - - const Displacement delta = destinationPosition - current; - const int deltaAbsX = delta.deltaX >= 0 ? delta.deltaX : -delta.deltaX; - const int deltaAbsY = delta.deltaY >= 0 ? delta.deltaY : -delta.deltaY; - - std::array prioritizedDirs; - size_t prioritizedCount = 0; - - const auto addUniqueDir = [&](int8_t dir) { - if (dir == WALK_NONE) - return; - for (size_t i = 0; i < prioritizedCount; ++i) { - if (prioritizedDirs[i] == dir) - return; - } - prioritizedDirs[prioritizedCount++] = dir; - }; - - const int8_t xDir = delta.deltaX > 0 ? WALK_SE : (delta.deltaX < 0 ? WALK_NW : WALK_NONE); - const int8_t yDir = delta.deltaY > 0 ? WALK_SW : (delta.deltaY < 0 ? WALK_NE : WALK_NONE); - - if (allowDiagonalSteps && delta.deltaX != 0 && delta.deltaY != 0) { - const int8_t diagDir = delta.deltaX > 0 ? (delta.deltaY > 0 ? WALK_S : WALK_E) : (delta.deltaY > 0 ? WALK_W : WALK_N); - addUniqueDir(diagDir); - } - - if (deltaAbsX >= deltaAbsY) { - addUniqueDir(xDir); - addUniqueDir(yDir); - } else { - addUniqueDir(yDir); - addUniqueDir(xDir); - } - for (const int8_t dir : walkDirections) { - addUniqueDir(dir); - } - - for (size_t i = 0; i < prioritizedCount; ++i) { - enqueue(current, prioritizedDirs[i]); - } - } - - if (!hasReachedDestination()) - return std::nullopt; - - std::vector path; - Point position = destinationPosition; - while (position != startPosition) { - const int8_t dir = parentDir[indexOf(position)]; - if (dir == WALK_NONE) - return std::nullopt; - - path.push_back(dir); - position = NextPositionForWalkDirection(position, OppositeWalkDirection(dir)); - } - - std::reverse(path.begin(), path.end()); - return path; -} - -std::optional> FindKeyboardWalkPathForSpeechWithPosOk(const Player &player, Point startPosition, Point destinationPosition, PosOkForSpeechFn posOk, bool allowDestinationNonWalkable) -{ - constexpr std::array AxisDirections = { - WALK_NE, - WALK_SW, - WALK_SE, - WALK_NW, - }; - - constexpr std::array AllDirections = { - WALK_NE, - WALK_SW, - WALK_SE, - WALK_NW, - WALK_N, - WALK_E, - WALK_S, - WALK_W, - }; - - if (const std::optional> axisPath = FindKeyboardWalkPathForSpeechBfs(player, startPosition, destinationPosition, posOk, AxisDirections, /*allowDiagonalSteps=*/false, allowDestinationNonWalkable); axisPath) { - return axisPath; - } - - return FindKeyboardWalkPathForSpeechBfs(player, startPosition, destinationPosition, posOk, AllDirections, /*allowDiagonalSteps=*/true, allowDestinationNonWalkable); -} - -} // namespace - -std::optional> FindKeyboardWalkPathForSpeech(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) -{ - return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, allowDestinationNonWalkable); -} - -std::optional> FindKeyboardWalkPathForSpeechRespectingDoors(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) -{ - return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayer, allowDestinationNonWalkable); -} - -std::optional> FindKeyboardWalkPathForSpeechIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) -{ - return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoorsAndMonsters, allowDestinationNonWalkable); -} - -std::optional> FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) -{ - return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreMonsters, allowDestinationNonWalkable); -} - -std::optional> FindKeyboardWalkPathForSpeechLenient(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) -{ - return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoorsMonstersAndBreakables, allowDestinationNonWalkable); -} - -namespace { - -template -std::optional> FindKeyboardWalkPathToClosestReachableForSpeechBfs(const Player &player, Point startPosition, Point destinationPosition, PosOkForSpeechFn posOk, const std::array &walkDirections, bool allowDiagonalSteps, Point &closestPosition) -{ - if (!InDungeonBounds(startPosition) || !InDungeonBounds(destinationPosition)) - return std::nullopt; - - if (startPosition == destinationPosition) { - closestPosition = destinationPosition; - return std::vector {}; - } - - std::array visited {}; - std::array parentDir {}; - std::array depth {}; - parentDir.fill(WALK_NONE); - depth.fill(0); - - std::queue queue; - - const auto indexOf = [](Point position) -> size_t { - return static_cast(position.x) + static_cast(position.y) * MAXDUNX; - }; - - const auto enqueue = [&](Point current, int8_t dir) { - const Point next = NextPositionForWalkDirection(current, dir); - if (!InDungeonBounds(next)) - return; - - const size_t nextIdx = indexOf(next); - if (visited[nextIdx]) - return; - - if (!posOk(player, next)) - return; - if (!CanStep(current, next)) - return; - - const size_t currentIdx = indexOf(current); - visited[nextIdx] = true; - parentDir[nextIdx] = dir; - depth[nextIdx] = static_cast(depth[currentIdx] + 1); - queue.push(next); - }; - - const size_t startIdx = indexOf(startPosition); - visited[startIdx] = true; - queue.push(startPosition); - - Point best = startPosition; - int bestDistance = startPosition.WalkingDistance(destinationPosition); - uint16_t bestDepth = 0; - - const auto considerBest = [&](Point position) { - const int distance = position.WalkingDistance(destinationPosition); - const uint16_t posDepth = depth[indexOf(position)]; - if (distance < bestDistance || (distance == bestDistance && posDepth < bestDepth)) { - best = position; - bestDistance = distance; - bestDepth = posDepth; - } - }; - - while (!queue.empty()) { - const Point current = queue.front(); - queue.pop(); - - considerBest(current); - - const Displacement delta = destinationPosition - current; - const int deltaAbsX = delta.deltaX >= 0 ? delta.deltaX : -delta.deltaX; - const int deltaAbsY = delta.deltaY >= 0 ? delta.deltaY : -delta.deltaY; - - std::array prioritizedDirs; - size_t prioritizedCount = 0; - - const auto addUniqueDir = [&](int8_t dir) { - if (dir == WALK_NONE) - return; - for (size_t i = 0; i < prioritizedCount; ++i) { - if (prioritizedDirs[i] == dir) - return; - } - prioritizedDirs[prioritizedCount++] = dir; - }; - - const int8_t xDir = delta.deltaX > 0 ? WALK_SE : (delta.deltaX < 0 ? WALK_NW : WALK_NONE); - const int8_t yDir = delta.deltaY > 0 ? WALK_SW : (delta.deltaY < 0 ? WALK_NE : WALK_NONE); - - if (allowDiagonalSteps && delta.deltaX != 0 && delta.deltaY != 0) { - const int8_t diagDir = delta.deltaX > 0 ? (delta.deltaY > 0 ? WALK_S : WALK_E) : (delta.deltaY > 0 ? WALK_W : WALK_N); - addUniqueDir(diagDir); - } - - if (deltaAbsX >= deltaAbsY) { - addUniqueDir(xDir); - addUniqueDir(yDir); - } else { - addUniqueDir(yDir); - addUniqueDir(xDir); - } - for (const int8_t dir : walkDirections) { - addUniqueDir(dir); - } - - for (size_t i = 0; i < prioritizedCount; ++i) { - enqueue(current, prioritizedDirs[i]); - } - } - - closestPosition = best; - if (best == startPosition) - return std::vector {}; - - std::vector path; - Point position = best; - while (position != startPosition) { - const int8_t dir = parentDir[indexOf(position)]; - if (dir == WALK_NONE) - return std::nullopt; - - path.push_back(dir); - position = NextPositionForWalkDirection(position, OppositeWalkDirection(dir)); - } - - std::reverse(path.begin(), path.end()); - return path; -} - -} // namespace - -std::optional> FindKeyboardWalkPathToClosestReachableForSpeech(const Player &player, Point startPosition, Point destinationPosition, Point &closestPosition) -{ - constexpr std::array AxisDirections = { - WALK_NE, - WALK_SW, - WALK_SE, - WALK_NW, - }; - - constexpr std::array AllDirections = { - WALK_NE, - WALK_SW, - WALK_SE, - WALK_NW, - WALK_N, - WALK_E, - WALK_S, - WALK_W, - }; - - Point axisClosest; - const std::optional> axisPath = FindKeyboardWalkPathToClosestReachableForSpeechBfs(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, AxisDirections, /*allowDiagonalSteps=*/false, axisClosest); - - Point diagClosest; - const std::optional> diagPath = FindKeyboardWalkPathToClosestReachableForSpeechBfs(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, AllDirections, /*allowDiagonalSteps=*/true, diagClosest); - - if (!axisPath && !diagPath) - return std::nullopt; - if (!axisPath) { - closestPosition = diagClosest; - return diagPath; - } - if (!diagPath) { - closestPosition = axisClosest; - return axisPath; - } - - const int axisDistance = axisClosest.WalkingDistance(destinationPosition); - const int diagDistance = diagClosest.WalkingDistance(destinationPosition); - if (diagDistance < axisDistance) { - closestPosition = diagClosest; - return diagPath; - } - - closestPosition = axisClosest; - return axisPath; -} - -void AppendKeyboardWalkPathForSpeech(std::string &message, const std::vector &path) -{ - if (path.empty()) { - message.append(_("here")); - return; - } - - bool any = false; - const auto appendPart = [&](std::string_view label, int distance) { - if (distance == 0) - return; - if (any) - message.append(", "); - StrAppend(message, label, " ", distance); - any = true; - }; - - const auto labelForWalkDirection = [](int8_t dir) -> std::string_view { - switch (dir) { - case WALK_NE: - return _("north"); - case WALK_SW: - return _("south"); - case WALK_SE: - return _("east"); - case WALK_NW: - return _("west"); - case WALK_N: - return _("northwest"); - case WALK_E: - return _("northeast"); - case WALK_S: - return _("southeast"); - case WALK_W: - return _("southwest"); - default: - return {}; - } - }; - - int8_t currentDir = path.front(); - int runLength = 1; - for (size_t i = 1; i < path.size(); ++i) { - if (path[i] == currentDir) { - ++runLength; - continue; - } - - const std::string_view label = labelForWalkDirection(currentDir); - if (!label.empty()) - appendPart(label, runLength); - - currentDir = path[i]; - runLength = 1; - } - - const std::string_view label = labelForWalkDirection(currentDir); - if (!label.empty()) - appendPart(label, runLength); - - if (!any) - message.append(_("here")); -} - -void AppendDirectionalFallback(std::string &message, const Displacement &delta) -{ - bool any = false; - const auto appendPart = [&](std::string_view label, int distance) { - if (distance == 0) - return; - if (any) - message.append(", "); - StrAppend(message, label, " ", distance); - any = true; - }; - - if (delta.deltaY < 0) - appendPart(_("north"), -delta.deltaY); - else if (delta.deltaY > 0) - appendPart(_("south"), delta.deltaY); - - if (delta.deltaX > 0) - appendPart(_("east"), delta.deltaX); - else if (delta.deltaX < 0) - appendPart(_("west"), -delta.deltaX); - - if (!any) - message.append(_("here")); -} - -std::optional FindNearestUnexploredTile(Point startPosition) -{ - if (!InDungeonBounds(startPosition)) - return std::nullopt; - - std::array visited {}; - std::queue queue; - - const auto enqueue = [&](Point position) { - if (!InDungeonBounds(position)) - return; - - const size_t index = static_cast(position.x) + static_cast(position.y) * MAXDUNX; - if (visited[index]) - return; - - if (!IsTileWalkable(position, /*ignoreDoors=*/true)) - return; - - visited[index] = true; - queue.push(position); - }; - - enqueue(startPosition); - - constexpr std::array Neighbors = { - Direction::NorthEast, - Direction::SouthWest, - Direction::SouthEast, - Direction::NorthWest, - }; - - while (!queue.empty()) { - const Point position = queue.front(); - queue.pop(); - - if (!HasAnyOf(dFlags[position.x][position.y], DungeonFlag::Explored)) - return position; - - for (const Direction dir : Neighbors) { - enqueue(position + dir); - } - } - - return std::nullopt; -} - -std::string TriggerLabelForSpeech(const TriggerStruct &trigger) -{ - switch (trigger._tmsg) { - case WM_DIABNEXTLVL: - if (leveltype == DTYPE_TOWN) - return std::string { _("Cathedral entrance") }; - return std::string { _("Stairs down") }; - case WM_DIABPREVLVL: - return std::string { _("Stairs up") }; - case WM_DIABTOWNWARP: - switch (trigger._tlvl) { - case 5: - return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Catacombs")); - case 9: - return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Caves")); - case 13: - return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Hell")); - case 17: - return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Nest")); - case 21: - return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Crypt")); - default: - return fmt::format(fmt::runtime(_("Town warp to level {:d}")), trigger._tlvl); - } - case WM_DIABTWARPUP: - return std::string { _("Warp up") }; - case WM_DIABRETOWN: - return std::string { _("Return to town") }; - case WM_DIABWARPLVL: - return std::string { _("Warp") }; - case WM_DIABSETLVL: - return std::string { _("Set level") }; - case WM_DIABRTNLVL: - return std::string { _("Return level") }; - default: - return std::string { _("Exit") }; - } -} - -std::optional LockedTownDungeonTriggerIndex; - -std::vector CollectTownDungeonTriggerIndices() -{ - std::vector result; - result.reserve(static_cast(std::max(0, numtrigs))); - - for (int i = 0; i < numtrigs; ++i) { - if (IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP)) - result.push_back(i); - } - - std::sort(result.begin(), result.end(), [](int a, int b) { - const TriggerStruct &ta = trigs[a]; - const TriggerStruct &tb = trigs[b]; - - const int kindA = ta._tmsg == WM_DIABNEXTLVL ? 0 : (ta._tmsg == WM_DIABTOWNWARP ? 1 : 2); - const int kindB = tb._tmsg == WM_DIABNEXTLVL ? 0 : (tb._tmsg == WM_DIABTOWNWARP ? 1 : 2); - if (kindA != kindB) - return kindA < kindB; - - if (ta._tmsg == WM_DIABTOWNWARP && tb._tmsg == WM_DIABTOWNWARP && ta._tlvl != tb._tlvl) - return ta._tlvl < tb._tlvl; - - return a < b; - }); - - return result; -} - -std::optional FindDefaultTownDungeonTriggerIndex(const std::vector &candidates) -{ - for (const int index : candidates) { - if (trigs[index]._tmsg == WM_DIABNEXTLVL) - return index; - } - if (!candidates.empty()) - return candidates.front(); - return std::nullopt; -} - -std::optional FindLockedTownDungeonTriggerIndex(const std::vector &candidates) -{ - if (!LockedTownDungeonTriggerIndex) - return std::nullopt; - if (std::find(candidates.begin(), candidates.end(), *LockedTownDungeonTriggerIndex) != candidates.end()) - return *LockedTownDungeonTriggerIndex; - return std::nullopt; -} - -std::optional FindNextTownDungeonTriggerIndex(const std::vector &candidates, int current) -{ - if (candidates.empty()) - return std::nullopt; - - const auto it = std::find(candidates.begin(), candidates.end(), current); - if (it == candidates.end()) - return candidates.front(); - if (std::next(it) == candidates.end()) - return candidates.front(); - return *std::next(it); -} - -std::optional FindPreferredExitTriggerIndex() -{ - if (numtrigs <= 0) - return std::nullopt; - - if (leveltype == DTYPE_TOWN && MyPlayer != nullptr) { - const Point playerPosition = MyPlayer->position.future; - std::optional bestIndex; - int bestDistance = 0; - - for (int i = 0; i < numtrigs; ++i) { - if (!IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP)) - continue; - - const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; - const int distance = playerPosition.WalkingDistance(triggerPosition); - if (!bestIndex || distance < bestDistance) { - bestIndex = i; - bestDistance = distance; - } - } - - if (bestIndex) - return bestIndex; - } - - const Point playerPosition = MyPlayer->position.future; - std::optional bestIndex; - int bestDistance = 0; - - for (int i = 0; i < numtrigs; ++i) { - const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; - const int distance = playerPosition.WalkingDistance(triggerPosition); - if (!bestIndex || distance < bestDistance) { - bestIndex = i; - bestDistance = distance; - } - } - - return bestIndex; -} - -std::optional FindNearestTriggerIndexWithMessage(int message) -{ - if (numtrigs <= 0 || MyPlayer == nullptr) - return std::nullopt; - - const Point playerPosition = MyPlayer->position.future; - std::optional bestIndex; - int bestDistance = 0; - - for (int i = 0; i < numtrigs; ++i) { - if (trigs[i]._tmsg != message) - continue; - - const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; - const int distance = playerPosition.WalkingDistance(triggerPosition); - if (!bestIndex || distance < bestDistance) { - bestIndex = i; - bestDistance = distance; - } - } - - return bestIndex; -} - -std::optional FindNearestTownPortalOnCurrentLevel() -{ - if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) - return std::nullopt; - - const Point playerPosition = MyPlayer->position.future; - const int currentLevel = setlevel ? static_cast(setlvlnum) : currlevel; - - std::optional bestPosition; - int bestDistance = 0; - - for (int i = 0; i < MAXPORTAL; ++i) { - const Portal &portal = Portals[i]; - if (!portal.open) - continue; - if (portal.setlvl != setlevel) - continue; - if (portal.level != currentLevel) - continue; - - const int distance = playerPosition.WalkingDistance(portal.position); - if (!bestPosition || distance < bestDistance) { - bestPosition = portal.position; - bestDistance = distance; - } - } - - return bestPosition; -} - -struct TownPortalInTown { - int portalIndex; - Point position; - int distance; -}; - -std::optional FindNearestTownPortalInTown() -{ - if (MyPlayer == nullptr || leveltype != DTYPE_TOWN) - return std::nullopt; - - const Point playerPosition = MyPlayer->position.future; - - std::optional best; - int bestDistance = 0; - - for (const Missile &missile : Missiles) { - if (missile._mitype != MissileID::TownPortal) - continue; - if (missile._misource < 0 || missile._misource >= MAXPORTAL) - continue; - if (!Portals[missile._misource].open) - continue; - - const Point portalPosition = missile.position.tile; - const int distance = playerPosition.WalkingDistance(portalPosition); - if (!best || distance < bestDistance) { - best = TownPortalInTown { - .portalIndex = missile._misource, - .position = portalPosition, - .distance = distance, - }; - bestDistance = distance; - } - } - - return best; -} - -[[nodiscard]] std::string TownPortalLabelForSpeech(const Portal &portal) -{ - if (portal.level <= 0) - return std::string { _("Town portal") }; - - if (portal.setlvl) { - const auto questLevel = static_cast<_setlevels>(portal.level); - const char *questLevelName = QuestLevelNames[questLevel]; - if (questLevelName == nullptr || questLevelName[0] == '\0') - return std::string { _("Town portal to set level") }; - - return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a set/quest level name. */ "Town portal to {:s}")), _(questLevelName)); - } - - constexpr std::array DungeonStrs = { - N_("Town"), - N_("Cathedral"), - N_("Catacombs"), - N_("Caves"), - N_("Hell"), - N_("Nest"), - N_("Crypt"), - }; - std::string dungeonStr; - if (portal.ltype >= DTYPE_TOWN && portal.ltype <= DTYPE_LAST) { - dungeonStr = _(DungeonStrs[static_cast(portal.ltype)]); - } else { - dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None"); - } - - int floor = portal.level; - if (portal.ltype == DTYPE_CATACOMBS) - floor -= 4; - else if (portal.ltype == DTYPE_CAVES) - floor -= 8; - else if (portal.ltype == DTYPE_HELL) - floor -= 12; - else if (portal.ltype == DTYPE_NEST) - floor -= 16; - else if (portal.ltype == DTYPE_CRYPT) - floor -= 20; - - if (floor > 0) - return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name and {:d} is a floor number. */ "Town portal to {:s} {:d}")), dungeonStr, floor); - - return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name. */ "Town portal to {:s}")), dungeonStr); -} - -struct QuestSetLevelEntrance { - _setlevels questLevel; - Point entrancePosition; - int distance; -}; - -std::optional FindNearestQuestSetLevelEntranceOnCurrentLevel() -{ - if (MyPlayer == nullptr || setlevel) - return std::nullopt; - - const Point playerPosition = MyPlayer->position.future; - std::optional best; - int bestDistance = 0; - - for (const Quest &quest : Quests) { - if (quest._qslvl == SL_NONE) - continue; - if (quest._qactive == QUEST_NOTAVAIL) - continue; - if (quest._qlevel != currlevel) - continue; - if (!InDungeonBounds(quest.position)) - continue; - - const int distance = playerPosition.WalkingDistance(quest.position); - if (!best || distance < bestDistance) { - best = QuestSetLevelEntrance { - .questLevel = quest._qslvl, - .entrancePosition = quest.position, - .distance = distance, - }; - bestDistance = distance; - } - } - - return best; -} - -void SpeakNearestExitKeyPressed() -{ - if (!CanPlayerTakeAction()) - return; - if (AutomapActive) { - SpeakText(_("Close the map first."), true); - return; - } - if (MyPlayer == nullptr) - return; - - const Point startPosition = MyPlayer->position.future; - - const SDL_Keymod modState = SDL_GetModState(); - const bool seekQuestEntrance = (modState & SDL_KMOD_SHIFT) != 0; - const bool cycleTownDungeon = (modState & SDL_KMOD_CTRL) != 0; - - if (seekQuestEntrance) { - if (setlevel) { - const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(WM_DIABRTNLVL); - if (!triggerIndex) { - SpeakText(_("No quest exits found."), true); - return; - } - - const TriggerStruct &trigger = trigs[*triggerIndex]; - const Point targetPosition { trigger.position.x, trigger.position.y }; - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); - std::string message = TriggerLabelForSpeech(trigger); - if (!message.empty()) - message.append(": "); - if (!path) - AppendDirectionalFallback(message, targetPosition - startPosition); - else - AppendKeyboardWalkPathForSpeech(message, *path); - SpeakText(message, true); - return; - } - - if (const std::optional entrance = FindNearestQuestSetLevelEntranceOnCurrentLevel(); entrance) { - const Point targetPosition = entrance->entrancePosition; - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); - - std::string message { _(QuestLevelNames[entrance->questLevel]) }; - message.append(": "); - if (!path) - AppendDirectionalFallback(message, targetPosition - startPosition); - else - AppendKeyboardWalkPathForSpeech(message, *path); - SpeakText(message, true); - return; - } - - SpeakText(_("No quest entrances found."), true); - return; - } - - if (leveltype == DTYPE_TOWN) { - const std::vector dungeonCandidates = CollectTownDungeonTriggerIndices(); - if (dungeonCandidates.empty()) { - SpeakText(_("No exits found."), true); - return; - } - - if (cycleTownDungeon) { - if (dungeonCandidates.size() <= 1) { - SpeakText(_("No other dungeon entrances found."), true); - return; - } - - const int current = LockedTownDungeonTriggerIndex.value_or(-1); - const std::optional next = FindNextTownDungeonTriggerIndex(dungeonCandidates, current); - if (!next) { - SpeakText(_("No other dungeon entrances found."), true); - return; - } - - LockedTownDungeonTriggerIndex = *next; - const std::string label = TriggerLabelForSpeech(trigs[*next]); - if (!label.empty()) - SpeakText(label, true); - return; - } - - const int triggerIndex = FindLockedTownDungeonTriggerIndex(dungeonCandidates) - .value_or(FindDefaultTownDungeonTriggerIndex(dungeonCandidates).value_or(dungeonCandidates.front())); - LockedTownDungeonTriggerIndex = triggerIndex; - - const TriggerStruct &trigger = trigs[triggerIndex]; - const Point targetPosition { trigger.position.x, trigger.position.y }; - - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); - std::string message = TriggerLabelForSpeech(trigger); - if (!message.empty()) - message.append(": "); - if (!path) - AppendDirectionalFallback(message, targetPosition - startPosition); - else - AppendKeyboardWalkPathForSpeech(message, *path); - - SpeakText(message, true); - return; - } - - if (leveltype != DTYPE_TOWN) { - if (const std::optional portalPosition = FindNearestTownPortalOnCurrentLevel(); portalPosition) { - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, *portalPosition); - std::string message { _("Return to town") }; - message.append(": "); - if (!path) - AppendDirectionalFallback(message, *portalPosition - startPosition); - else - AppendKeyboardWalkPathForSpeech(message, *path); - SpeakText(message, true); - return; - } - - const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(WM_DIABPREVLVL); - if (!triggerIndex) { - SpeakText(_("No exits found."), true); - return; - } - - const TriggerStruct &trigger = trigs[*triggerIndex]; - const Point targetPosition { trigger.position.x, trigger.position.y }; - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); - std::string message = TriggerLabelForSpeech(trigger); - if (!message.empty()) - message.append(": "); - if (!path) - AppendDirectionalFallback(message, targetPosition - startPosition); - else - AppendKeyboardWalkPathForSpeech(message, *path); - SpeakText(message, true); - return; - } - - const std::optional triggerIndex = FindPreferredExitTriggerIndex(); - if (!triggerIndex) { - SpeakText(_("No exits found."), true); - return; - } - - const TriggerStruct &trigger = trigs[*triggerIndex]; - const Point targetPosition { trigger.position.x, trigger.position.y }; - - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); - std::string message = TriggerLabelForSpeech(trigger); - if (!message.empty()) - message.append(": "); - if (!path) - AppendDirectionalFallback(message, targetPosition - startPosition); - else - AppendKeyboardWalkPathForSpeech(message, *path); - - SpeakText(message, true); -} - -void SpeakNearestTownPortalInTownKeyPressed() -{ - if (!CanPlayerTakeAction()) - return; - if (AutomapActive) { - SpeakText(_("Close the map first."), true); - return; - } - if (leveltype != DTYPE_TOWN) { - SpeakText(_("Not in town."), true); - return; - } - if (MyPlayer == nullptr) - return; - - const std::optional portal = FindNearestTownPortalInTown(); - if (!portal) { - SpeakText(_("No town portals found."), true); - return; - } - - const Point startPosition = MyPlayer->position.future; - const Point targetPosition = portal->position; - - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); - - std::string message = TownPortalLabelForSpeech(Portals[portal->portalIndex]); - message.append(": "); - if (!path) - AppendDirectionalFallback(message, targetPosition - startPosition); - else - AppendKeyboardWalkPathForSpeech(message, *path); - - SpeakText(message, true); -} - -void SpeakNearestStairsKeyPressed(int triggerMessage) -{ - if (!CanPlayerTakeAction()) - return; - if (AutomapActive) { - SpeakText(_("Close the map first."), true); - return; - } - if (leveltype == DTYPE_TOWN) { - SpeakText(_("Not in a dungeon."), true); - return; - } - if (MyPlayer == nullptr) - return; - - const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(triggerMessage); - if (!triggerIndex) { - SpeakText(_("No exits found."), true); - return; - } - - const TriggerStruct &trigger = trigs[*triggerIndex]; - const Point startPosition = MyPlayer->position.future; - const Point targetPosition { trigger.position.x, trigger.position.y }; - - std::string message; - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); - if (!path) { - AppendDirectionalFallback(message, targetPosition - startPosition); - } else { - AppendKeyboardWalkPathForSpeech(message, *path); - } - - SpeakText(message, true); -} - -void SpeakNearestStairsDownKeyPressed() -{ - SpeakNearestStairsKeyPressed(WM_DIABNEXTLVL); -} - -void SpeakNearestStairsUpKeyPressed() -{ - SpeakNearestStairsKeyPressed(WM_DIABPREVLVL); -} - -bool IsKeyboardWalkAllowed() -{ - return CanPlayerTakeAction() - && !InGameMenu() - && !IsPlayerInStore() - && !QuestLogIsOpen - && !HelpFlag - && !ChatLogFlag - && !ChatFlag - && !DropGoldFlag - && !IsStashOpen - && !IsWithdrawGoldOpen - && !AutomapActive - && !invflag - && !CharFlag - && !SpellbookFlag - && !SpellSelectFlag - && !qtextflag; -} - -void KeyboardWalkKeyPressed(Direction direction) -{ - CancelAutoWalk(); - if (!IsKeyboardWalkAllowed()) - return; - - if (MyPlayer == nullptr) - return; - - NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, MyPlayer->position.future + direction); -} - -void KeyboardWalkNorthKeyPressed() -{ - KeyboardWalkKeyPressed(Direction::NorthEast); -} - -void KeyboardWalkSouthKeyPressed() -{ - KeyboardWalkKeyPressed(Direction::SouthWest); -} - -void KeyboardWalkEastKeyPressed() -{ - KeyboardWalkKeyPressed(Direction::SouthEast); -} - -void KeyboardWalkWestKeyPressed() -{ - KeyboardWalkKeyPressed(Direction::NorthWest); -} - -void SpeakNearestUnexploredTileKeyPressed() -{ - if (!CanPlayerTakeAction()) - return; - if (leveltype == DTYPE_TOWN) { - SpeakText(_("Not in a dungeon."), true); - return; - } - if (AutomapActive) { - SpeakText(_("Close the map first."), true); - return; - } - if (MyPlayer == nullptr) - return; - - const Point startPosition = MyPlayer->position.future; - const std::optional target = FindNearestUnexploredTile(startPosition); - if (!target) { - SpeakText(_("No unexplored areas found."), true); - return; - } - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, *target); - std::string message; - if (!path) - AppendDirectionalFallback(message, *target - startPosition); - else - AppendKeyboardWalkPathForSpeech(message, *path); - - SpeakText(message, true); -} - -void SpeakPlayerHealthPercentageKeyPressed() -{ - if (!CanPlayerTakeAction()) - return; - if (MyPlayer == nullptr) - return; - - const int maxHp = MyPlayer->_pMaxHP; - if (maxHp <= 0) - return; - - const int currentHp = std::max(MyPlayer->_pHitPoints, 0); - int hpPercent = static_cast((static_cast(currentHp) * 100 + maxHp / 2) / maxHp); - hpPercent = std::clamp(hpPercent, 0, 100); - SpeakText(fmt::format("{:d}%", hpPercent), /*force=*/true); -} - -void SpeakExperienceToNextLevelKeyPressed() -{ - if (!CanPlayerTakeAction()) - return; - if (MyPlayer == nullptr) - return; - - const Player &myPlayer = *MyPlayer; - if (myPlayer.isMaxCharacterLevel()) { - SpeakText(_("Max level."), /*force=*/true); - return; - } - - const uint32_t nextExperienceThreshold = myPlayer.getNextExperienceThreshold(); - const uint32_t currentExperience = myPlayer._pExperience; - const uint32_t remainingExperience = currentExperience >= nextExperienceThreshold ? 0 : nextExperienceThreshold - currentExperience; - const int nextLevel = myPlayer.getCharacterLevel() + 1; - SpeakText( - fmt::format(fmt::runtime(_("{:s} to Level {:d}")), FormatInteger(remainingExperience), nextLevel), - /*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()) - return; - invflag = !invflag; - if (!IsLeftPanelOpen() && CanPanelsCoverView()) { - if (!invflag) { // We closed the inventory - if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); - } - } else if (!SpellbookFlag) { // We opened the inventory - if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition - Displacement { 160, 0 }); - } - } - } - SpellbookFlag = false; - CloseGoldWithdraw(); - CloseStash(); - if (invflag) - FocusOnInventory(); -} - -void CharacterSheetKeyPressed() -{ - if (IsPlayerInStore()) - return; - if (!IsRightPanelOpen() && CanPanelsCoverView()) { - if (CharFlag) { // We are closing the character sheet - if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition - Displacement { 160, 0 }); - } - } else if (!QuestLogIsOpen) { // We opened the character sheet - if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); - } - } - } - ToggleCharPanel(); -} - -void PartyPanelSideToggleKeyPressed() -{ - PartySidePanelOpen = !PartySidePanelOpen; -} - -void QuestLogKeyPressed() -{ - if (IsPlayerInStore()) - return; - if (!QuestLogIsOpen) { - StartQuestlog(); - } else { - QuestLogIsOpen = false; - } - if (!IsRightPanelOpen() && CanPanelsCoverView()) { - if (!QuestLogIsOpen) { // We closed the quest log - if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition - Displacement { 160, 0 }); - } - } else if (!CharFlag) { // We opened the character quest log - if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); - } - } - } - CloseCharPanel(); - CloseGoldWithdraw(); - CloseStash(); -} - -void SpeakSelectedSpeedbookSpell() -{ - for (const auto &spellListItem : GetSpellListItems()) { - if (spellListItem.isSelected) { - SpeakText(pgettext("spell", GetSpellData(spellListItem.id).sNameText), /*force=*/true); - return; - } - } - SpeakText(_("No spell selected."), /*force=*/true); -} - -void DisplaySpellsKeyPressed() -{ - if (IsPlayerInStore()) - return; - CloseCharPanel(); - QuestLogIsOpen = false; - CloseInventory(); - SpellbookFlag = false; - if (!SpellSelectFlag) { - DoSpeedBook(); - SpeakSelectedSpeedbookSpell(); - } else { - SpellSelectFlag = false; - } - LastPlayerAction = PlayerActionType::None; -} - -void SpellBookKeyPressed() -{ - if (IsPlayerInStore()) - return; - SpellbookFlag = !SpellbookFlag; - if (SpellbookFlag && MyPlayer != nullptr) { - const Player &player = *MyPlayer; - if (IsValidSpell(player._pRSpell)) { - SpeakText(pgettext("spell", GetSpellData(player._pRSpell).sNameText), /*force=*/true); - } else { - SpeakText(_("No spell selected."), /*force=*/true); - } - } - if (!IsLeftPanelOpen() && CanPanelsCoverView()) { - if (!SpellbookFlag) { // We closed the inventory - if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); - } - } else if (!invflag) { // We opened the inventory - if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition - Displacement { 160, 0 }); - } - } - } - CloseInventory(); -} - -void CycleSpellHotkeys(bool next) -{ - StaticVector validHotKeyIndexes; - std::optional currentIndex; - for (size_t slot = 0; slot < NumHotkeys; slot++) { - if (!IsValidSpeedSpell(slot)) - continue; - if (MyPlayer->_pRSpell == MyPlayer->_pSplHotKey[slot] && MyPlayer->_pRSplType == MyPlayer->_pSplTHotKey[slot]) { - // found current - currentIndex = validHotKeyIndexes.size(); - } - validHotKeyIndexes.emplace_back(slot); - } - if (validHotKeyIndexes.size() == 0) - return; - - size_t newIndex; - if (!currentIndex) { - newIndex = next ? 0 : (validHotKeyIndexes.size() - 1); - } else if (next) { - newIndex = (*currentIndex == validHotKeyIndexes.size() - 1) ? 0 : (*currentIndex + 1); - } else { - newIndex = *currentIndex == 0 ? (validHotKeyIndexes.size() - 1) : (*currentIndex - 1); - } - ToggleSpell(validHotKeyIndexes[newIndex]); -} - -bool IsPlayerDead() -{ - return MyPlayer->_pmode == PM_DEATH || MyPlayerIsDead; -} - -bool IsGameRunning() -{ - return PauseMode != 2; -} - -bool CanPlayerTakeAction() -{ - return !IsPlayerDead() && IsGameRunning(); -} - -bool CanAutomapBeToggledOff() -{ - // check if every window is closed - if yes, automap can be toggled off - if (!QuestLogIsOpen && !IsWithdrawGoldOpen && !IsStashOpen && !CharFlag - && !SpellbookFlag && !invflag && !isGameMenuOpen && !qtextflag && !SpellSelectFlag - && !ChatLogFlag && !HelpFlag) - return true; - - return false; -} - -void OptionLanguageCodeChanged() -{ - UnloadFonts(); - LanguageInitialize(); - LoadLanguageArchive(); - effects_cleanup_sfx(false); - if (gbRunGame) - sound_init(); - else - ui_sound_init(); -} - -const auto OptionChangeHandlerLanguage = (GetOptions().Language.code.SetValueChangedCallback(OptionLanguageCodeChanged), true); - -void CancelAutoWalkInternal() -{ - AutoWalkTrackerTargetId = -1; - AutoWalkTownNpcTarget = -1; -} - -} // namespace - -void CancelAutoWalk() -{ - CancelAutoWalkInternal(); - if (MyPlayer != nullptr) - NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, MyPlayer->position.future); -} - -void InitKeymapActions() -{ - Options &options = GetOptions(); - for (uint32_t i = 0; i < 8; ++i) { - options.Keymapper.AddAction( - "BeltItem{}", - N_("Belt item {}"), - N_("Use Belt item."), - '1' + i, - [i] { - const Player &myPlayer = *MyPlayer; - if (!myPlayer.SpdList[i].isEmpty() && myPlayer.SpdList[i]._itype != ItemType::Gold) { - UseInvItem(INVITEM_BELT_FIRST + i); - } - }, - nullptr, - CanPlayerTakeAction, - i + 1); - } - for (uint32_t i = 0; i < NumHotkeys; ++i) { - options.Keymapper.AddAction( - "QuickSpell{}", - N_("Quick spell {}"), - N_("Hotkey for skill or spell."), - i < 4 ? static_cast(SDLK_F5) + i : static_cast(SDLK_UNKNOWN), - [i]() { - if (SpellSelectFlag) { - SetSpeedSpell(i); - return; - } - if (!*GetOptions().Gameplay.quickCast) - ToggleSpell(i); - else - QuickCast(i); - }, - nullptr, - CanPlayerTakeAction, - i + 1); - } - options.Keymapper.AddAction( - "QuickSpellPrevious", - N_("Previous quick spell"), - N_("Selects the previous quick spell (cycles)."), - MouseScrollUpButton, - [] { CycleSpellHotkeys(false); }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "QuickSpellNext", - N_("Next quick spell"), - N_("Selects the next quick spell (cycles)."), - MouseScrollDownButton, - [] { CycleSpellHotkeys(true); }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "UseHealthPotion", - N_("Use health potion"), - N_("Use health potions from belt."), - SDLK_UNKNOWN, - [] { UseBeltItem(BeltItemType::Healing); }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "UseManaPotion", - N_("Use mana potion"), - N_("Use mana potions from belt."), - SDLK_UNKNOWN, - [] { UseBeltItem(BeltItemType::Mana); }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "DisplaySpells", - N_("Speedbook"), - N_("Open Speedbook."), - 'S', - DisplaySpellsKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "QuickSave", - N_("Quick save"), - N_("Saves the game."), - SDLK_F2, - [] { gamemenu_save_game(false); }, - nullptr, - [&]() { return !gbIsMultiplayer && CanPlayerTakeAction(); }); - options.Keymapper.AddAction( - "QuickLoad", - N_("Quick load"), - N_("Loads the game."), - SDLK_F3, - [] { gamemenu_load_game(false); }, - nullptr, - [&]() { return !gbIsMultiplayer && gbValidSaveFile && !IsPlayerInStore() && IsGameRunning(); }); -#ifndef NOEXIT - options.Keymapper.AddAction( - "QuitGame", - N_("Quit game"), - N_("Closes the game."), - SDLK_UNKNOWN, - [] { gamemenu_quit_game(false); }); -#endif - options.Keymapper.AddAction( - "StopHero", - N_("Stop hero"), - N_("Stops walking and cancel pending actions."), - SDLK_UNKNOWN, - [] { MyPlayer->Stop(); }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "ItemHighlighting", - N_("Item highlighting"), - N_("Show/hide items on ground."), - SDLK_LALT, - [] { HighlightKeyPressed(true); }, - [] { HighlightKeyPressed(false); }); - options.Keymapper.AddAction( - "ToggleItemHighlighting", - N_("Toggle item highlighting"), - N_("Permanent show/hide items on ground."), - SDLK_RCTRL, - nullptr, - [] { ToggleItemLabelHighlight(); }); - options.Keymapper.AddAction( - "ToggleAutomap", - N_("Toggle automap"), - N_("Toggles if automap is displayed."), - SDLK_TAB, - DoAutoMap, - nullptr, - IsGameRunning); - options.Keymapper.AddAction( - "CycleAutomapType", - N_("Cycle map type"), - N_("Opaque -> Transparent -> Minimap -> None"), - SDLK_M, - CycleAutomapType, - nullptr, - IsGameRunning); - - options.Keymapper.AddAction( - "ListTownNpcs", - N_("List town NPCs"), - N_("Speaks a list of town NPCs."), - SDLK_F4, - ListTownNpcsKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "PreviousTownNpc", - N_("Previous town NPC"), - N_("Select previous town NPC (speaks)."), - SDLK_PAGEUP, - SelectPreviousTownNpcKeyPressed, - nullptr, - IsTownNpcActionAllowed); - options.Keymapper.AddAction( - "NextTownNpc", - N_("Next town NPC"), - N_("Select next town NPC (speaks)."), - SDLK_PAGEDOWN, - SelectNextTownNpcKeyPressed, - nullptr, - IsTownNpcActionAllowed); - options.Keymapper.AddAction( - "SpeakSelectedTownNpc", - N_("Speak selected town NPC"), - N_("Speaks the currently selected town NPC."), - SDLK_END, - SpeakSelectedTownNpc, - nullptr, - IsTownNpcActionAllowed); - options.Keymapper.AddAction( - "GoToSelectedTownNpc", - N_("Go to selected town NPC"), - N_("Walks to the selected town NPC."), - SDLK_HOME, - GoToSelectedTownNpcKeyPressed, - nullptr, - IsTownNpcActionAllowed); - options.Keymapper.AddAction( - "SpeakNearestUnexploredSpace", - N_("Nearest unexplored space"), - N_("Speaks the nearest unexplored space."), - 'H', - SpeakNearestUnexploredTileKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpeakNearestExit", - N_("Nearest exit"), - N_("Speaks the nearest exit. Hold Shift for quest entrances (or to leave a quest level). In town, press Ctrl+E to cycle dungeon entrances."), - 'E', - SpeakNearestExitKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpeakNearestStairsDown", - N_("Nearest stairs down"), - N_("Speaks directions to the nearest stairs down."), - '.', - SpeakNearestStairsDownKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && leveltype != DTYPE_TOWN; }); - options.Keymapper.AddAction( - "SpeakNearestStairsUp", - N_("Nearest stairs up"), - N_("Speaks directions to the nearest stairs up."), - ',', - SpeakNearestStairsUpKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && leveltype != DTYPE_TOWN; }); - options.Keymapper.AddAction( - "CycleTrackerTarget", - N_("Cycle tracker target"), - N_("Cycles what the tracker looks for (items, chests, doors, shrines, objects, breakables, monsters, dead bodies). Hold Shift to cycle backwards."), - 'T', - CycleTrackerTargetKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Keymapper.AddAction( - "NavigateToTrackerTarget", - N_("Tracker directions"), - N_("Speaks directions to a tracked target of the selected tracker category. Shift+N: cycle targets (speaks name only). Ctrl+N: clear target."), - 'N', - NavigateToTrackerTargetKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Keymapper.AddAction( - "AutoWalkToTrackerTarget", - N_("Walk to tracker target"), - N_("Automatically walks to the currently selected tracker target. Press again to cancel."), - 'M', - AutoWalkToTrackerTargetKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Keymapper.AddAction( - "KeyboardWalkNorth", - N_("Walk north"), - N_("Walk north (one tile)."), - SDLK_UP, - KeyboardWalkNorthKeyPressed); - options.Keymapper.AddAction( - "KeyboardWalkSouth", - N_("Walk south"), - N_("Walk south (one tile)."), - SDLK_DOWN, - KeyboardWalkSouthKeyPressed); - options.Keymapper.AddAction( - "KeyboardWalkEast", - N_("Walk east"), - N_("Walk east (one tile)."), - SDLK_RIGHT, - KeyboardWalkEastKeyPressed); - options.Keymapper.AddAction( - "KeyboardWalkWest", - N_("Walk west"), - N_("Walk west (one tile)."), - SDLK_LEFT, - KeyboardWalkWestKeyPressed); - options.Keymapper.AddAction( - "PrimaryAction", - N_("Primary action"), - N_("Attack monsters, talk to towners, lift and place inventory items."), - 'A', - PerformPrimaryActionAutoTarget, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Keymapper.AddAction( - "SecondaryAction", - N_("Secondary action"), - N_("Open chests, interact with doors, pick up items."), - 'D', - PerformSecondaryActionAutoTarget, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Keymapper.AddAction( - "SpellAction", - N_("Spell action"), - N_("Cast the active spell."), - 'W', - PerformSpellActionAutoTarget, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - - options.Keymapper.AddAction( - "Inventory", - N_("Inventory"), - N_("Open Inventory screen."), - 'I', - InventoryKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "Character", - N_("Character"), - N_("Open Character screen."), - 'C', - CharacterSheetKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "Party", - N_("Party"), - N_("Open side Party panel."), - 'Y', - PartyPanelSideToggleKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "QuestLog", - N_("Quest log"), - N_("Open Quest log."), - 'Q', - QuestLogKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpellBook", - N_("Spellbook"), - N_("Open Spellbook."), - 'B', - SpellBookKeyPressed, - nullptr, - CanPlayerTakeAction); - for (uint32_t i = 0; i < QuickMessages.size(); ++i) { - options.Keymapper.AddAction( - "QuickMessage{}", - N_("Quick Message {}"), - N_("Use Quick Message in chat."), - (i < 4) ? static_cast(SDLK_F9) + i : static_cast(SDLK_UNKNOWN), - [i]() { DiabloHotkeyMsg(i); }, - nullptr, - nullptr, - i + 1); - } - options.Keymapper.AddAction( - "HideInfoScreens", - N_("Hide Info Screens"), - N_("Hide all info screens."), - SDLK_SPACE, - [] { - if (CanAutomapBeToggledOff()) - AutomapActive = false; - - ClosePanels(); - HelpFlag = false; - ChatLogFlag = false; - SpellSelectFlag = false; - if (qtextflag && leveltype == DTYPE_TOWN) { - qtextflag = false; - stream_stop(); - } - - CancelCurrentDiabloMsg(); - gamemenu_off(); - doom_close(); - }, - nullptr, - IsGameRunning); - options.Keymapper.AddAction( - "Zoom", - N_("Zoom"), - N_("Zoom Game Screen."), - SDLK_UNKNOWN, - [] { - GetOptions().Graphics.zoom.SetValue(!*GetOptions().Graphics.zoom); - CalcViewportGeometry(); - }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpeakPlayerHealthPercentage", - N_("Health percentage"), - N_("Speaks the player's health as a percentage."), - 'Z', - SpeakPlayerHealthPercentageKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpeakExperienceToNextLevel", - N_("Experience to level"), - N_("Speaks how much experience remains to reach the next level."), - 'X', - SpeakExperienceToNextLevelKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "PauseGame", - N_("Pause Game"), - N_("Pauses the game."), - SDLK_UNKNOWN, - diablo_pause_game); - options.Keymapper.AddAction( - "PauseGameAlternate", - N_("Pause Game (Alternate)"), - N_("Pauses the game."), - SDLK_PAUSE, - diablo_pause_game); - options.Keymapper.AddAction( - "DecreaseBrightness", - N_("Decrease Brightness"), - N_("Reduce screen brightness."), - 'F', - DecreaseBrightness, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "IncreaseBrightness", - N_("Increase Brightness"), - N_("Increase screen brightness."), - 'G', - IncreaseBrightness, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "Help", - N_("Help"), - N_("Open Help Screen."), - SDLK_F1, - HelpKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "Screenshot", - N_("Screenshot"), - N_("Takes a screenshot."), - SDLK_PRINTSCREEN, - nullptr, - CaptureScreen); - options.Keymapper.AddAction( - "GameInfo", - N_("Game info"), - N_("Displays game infos."), - 'V', - [] { - EventPlrMsg(fmt::format( - fmt::runtime(_(/* TRANSLATORS: {:s} means: Project Name, Game Version. */ "{:s} {:s}")), - PROJECT_NAME, - PROJECT_VERSION), - UiFlags::ColorWhite); - }, - nullptr, - CanPlayerTakeAction); - 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); - }); -#ifdef _DEBUG - options.Keymapper.AddAction( - "OpenConsole", - N_("Console"), - N_("Opens Lua console."), - SDLK_GRAVE, - OpenConsole); - options.Keymapper.AddAction( - "DebugToggle", - "Debug toggle", - "Programming is like magic.", - 'X', - [] { - DebugToggle = !DebugToggle; - }); -#endif - options.Keymapper.AddAction( - "SpeakNearestTownPortal", - N_("Nearest town portal"), - N_("Speaks directions to the nearest open town portal in town."), - 'P', - SpeakNearestTownPortalInTownKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && leveltype == DTYPE_TOWN; }); - options.Keymapper.CommitActions(); -} - -void InitPadmapActions() -{ - Options &options = GetOptions(); - for (int i = 0; i < 8; ++i) { - options.Padmapper.AddAction( - "BeltItem{}", - N_("Belt item {}"), - N_("Use Belt item."), - ControllerButton_NONE, - [i] { - const Player &myPlayer = *MyPlayer; - if (!myPlayer.SpdList[i].isEmpty() && myPlayer.SpdList[i]._itype != ItemType::Gold) { - UseInvItem(INVITEM_BELT_FIRST + i); - } - }, - nullptr, - CanPlayerTakeAction, - i + 1); - } - for (uint32_t i = 0; i < NumHotkeys; ++i) { - options.Padmapper.AddAction( - "QuickSpell{}", - N_("Quick spell {}"), - N_("Hotkey for skill or spell."), - ControllerButton_NONE, - [i]() { - if (SpellSelectFlag) { - SetSpeedSpell(i); - return; - } - if (!*GetOptions().Gameplay.quickCast) - ToggleSpell(i); - else - QuickCast(i); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }, - i + 1); - } - options.Padmapper.AddAction( - "PrimaryAction", - N_("Primary action"), - N_("Attack monsters, talk to towners, lift and place inventory items."), - ControllerButton_BUTTON_B, - [] { - ControllerActionHeld = GameActionType_PRIMARY_ACTION; - LastPlayerAction = PlayerActionType::None; - PerformPrimaryAction(); - }, - [] { - ControllerActionHeld = GameActionType_NONE; - LastPlayerAction = PlayerActionType::None; - }, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "SecondaryAction", - N_("Secondary action"), - N_("Open chests, interact with doors, pick up items."), - ControllerButton_BUTTON_Y, - [] { - ControllerActionHeld = GameActionType_SECONDARY_ACTION; - LastPlayerAction = PlayerActionType::None; - PerformSecondaryAction(); - }, - [] { - ControllerActionHeld = GameActionType_NONE; - LastPlayerAction = PlayerActionType::None; - }, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "SpellAction", - N_("Spell action"), - N_("Cast the active spell."), - ControllerButton_BUTTON_X, - [] { - ControllerActionHeld = GameActionType_CAST_SPELL; - LastPlayerAction = PlayerActionType::None; - PerformSpellAction(); - }, - [] { - ControllerActionHeld = GameActionType_NONE; - LastPlayerAction = PlayerActionType::None; - }, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "CancelAction", - N_("Cancel action"), - N_("Close menus."), - ControllerButton_BUTTON_A, - [] { - if (DoomFlag) { - doom_close(); - return; - } - - GameAction action; - if (SpellSelectFlag) - action = GameAction(GameActionType_TOGGLE_QUICK_SPELL_MENU); - else if (invflag) - action = GameAction(GameActionType_TOGGLE_INVENTORY); - else if (SpellbookFlag) - action = GameAction(GameActionType_TOGGLE_SPELL_BOOK); - else if (QuestLogIsOpen) - action = GameAction(GameActionType_TOGGLE_QUEST_LOG); - else if (CharFlag) - action = GameAction(GameActionType_TOGGLE_CHARACTER_INFO); - ProcessGameAction(action); - }, - nullptr, - [] { return DoomFlag || SpellSelectFlag || invflag || SpellbookFlag || QuestLogIsOpen || CharFlag; }); - options.Padmapper.AddAction( - "MoveUp", - N_("Move up"), - N_("Moves the player character up."), - ControllerButton_BUTTON_DPAD_UP, - [] {}); - options.Padmapper.AddAction( - "MoveDown", - N_("Move down"), - N_("Moves the player character down."), - ControllerButton_BUTTON_DPAD_DOWN, - [] {}); - options.Padmapper.AddAction( - "MoveLeft", - N_("Move left"), - N_("Moves the player character left."), - ControllerButton_BUTTON_DPAD_LEFT, - [] {}); - options.Padmapper.AddAction( - "MoveRight", - N_("Move right"), - N_("Moves the player character right."), - ControllerButton_BUTTON_DPAD_RIGHT, - [] {}); - options.Padmapper.AddAction( - "StandGround", - N_("Stand ground"), - N_("Hold to prevent the player from moving."), - ControllerButton_NONE, - [] {}); - options.Padmapper.AddAction( - "ToggleStandGround", - N_("Toggle stand ground"), - N_("Toggle whether the player moves."), - ControllerButton_NONE, - [] { StandToggle = !StandToggle; }, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "UseHealthPotion", - N_("Use health potion"), - N_("Use health potions from belt."), - ControllerButton_BUTTON_LEFTSHOULDER, - [] { UseBeltItem(BeltItemType::Healing); }, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "UseManaPotion", - N_("Use mana potion"), - N_("Use mana potions from belt."), - ControllerButton_BUTTON_RIGHTSHOULDER, - [] { UseBeltItem(BeltItemType::Mana); }, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "Character", - N_("Character"), - N_("Open Character screen."), - ControllerButton_AXIS_TRIGGERLEFT, - [] { - ProcessGameAction(GameAction { GameActionType_TOGGLE_CHARACTER_INFO }); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "Inventory", - N_("Inventory"), - N_("Open Inventory screen."), - ControllerButton_AXIS_TRIGGERRIGHT, - [] { - ProcessGameAction(GameAction { GameActionType_TOGGLE_INVENTORY }); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "QuestLog", - N_("Quest log"), - N_("Open Quest log."), - { ControllerButton_BUTTON_BACK, ControllerButton_AXIS_TRIGGERLEFT }, - [] { - ProcessGameAction(GameAction { GameActionType_TOGGLE_QUEST_LOG }); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "SpellBook", - N_("Spellbook"), - N_("Open Spellbook."), - { ControllerButton_BUTTON_BACK, ControllerButton_AXIS_TRIGGERRIGHT }, - [] { - ProcessGameAction(GameAction { GameActionType_TOGGLE_SPELL_BOOK }); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "DisplaySpells", - N_("Speedbook"), - N_("Open Speedbook."), - ControllerButton_BUTTON_A, - [] { - ProcessGameAction(GameAction { GameActionType_TOGGLE_QUICK_SPELL_MENU }); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "ToggleAutomap", - N_("Toggle automap"), - N_("Toggles if automap is displayed."), - ControllerButton_BUTTON_LEFTSTICK, - DoAutoMap); - options.Padmapper.AddAction( - "AutomapMoveUp", - N_("Automap Move Up"), - N_("Moves the automap up when active."), - ControllerButton_NONE, - [] {}); - options.Padmapper.AddAction( - "AutomapMoveDown", - N_("Automap Move Down"), - N_("Moves the automap down when active."), - ControllerButton_NONE, - [] {}); - options.Padmapper.AddAction( - "AutomapMoveLeft", - N_("Automap Move Left"), - N_("Moves the automap left when active."), - ControllerButton_NONE, - [] {}); - options.Padmapper.AddAction( - "AutomapMoveRight", - N_("Automap Move Right"), - N_("Moves the automap right when active."), - ControllerButton_NONE, - [] {}); - options.Padmapper.AddAction( - "MouseUp", - N_("Move mouse up"), - N_("Simulates upward mouse movement."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_UP }, - [] {}); - options.Padmapper.AddAction( - "MouseDown", - N_("Move mouse down"), - N_("Simulates downward mouse movement."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_DOWN }, - [] {}); - options.Padmapper.AddAction( - "MouseLeft", - N_("Move mouse left"), - N_("Simulates leftward mouse movement."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_LEFT }, - [] {}); - options.Padmapper.AddAction( - "MouseRight", - N_("Move mouse right"), - N_("Simulates rightward mouse movement."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_RIGHT }, - [] {}); - auto leftMouseDown = [] { - const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); - const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); - sgbMouseDown = CLICK_LEFT; - LeftMouseDown(standGround ? SDL_KMOD_SHIFT : SDL_KMOD_NONE); - }; - auto leftMouseUp = [] { - const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); - const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_NONE; - LeftMouseUp(standGround ? SDL_KMOD_SHIFT : SDL_KMOD_NONE); - }; - options.Padmapper.AddAction( - "LeftMouseClick1", - N_("Left mouse click"), - N_("Simulates the left mouse button."), - ControllerButton_BUTTON_RIGHTSTICK, - leftMouseDown, - leftMouseUp); - options.Padmapper.AddAction( - "LeftMouseClick2", - N_("Left mouse click"), - N_("Simulates the left mouse button."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_LEFTSHOULDER }, - leftMouseDown, - leftMouseUp); - auto rightMouseDown = [] { - const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); - const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_RIGHT; - RightMouseDown(standGround); - }; - auto rightMouseUp = [] { - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_NONE; - }; - options.Padmapper.AddAction( - "RightMouseClick1", - N_("Right mouse click"), - N_("Simulates the right mouse button."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_RIGHTSTICK }, - rightMouseDown, - rightMouseUp); - options.Padmapper.AddAction( - "RightMouseClick2", - N_("Right mouse click"), - N_("Simulates the right mouse button."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_RIGHTSHOULDER }, - rightMouseDown, - rightMouseUp); - options.Padmapper.AddAction( - "PadHotspellMenu", - N_("Gamepad hotspell menu"), - N_("Hold to set or use spell hotkeys."), - ControllerButton_BUTTON_BACK, - [] { PadHotspellMenuActive = true; }, - [] { PadHotspellMenuActive = false; }); - options.Padmapper.AddAction( - "PadMenuNavigator", - N_("Gamepad menu navigator"), - N_("Hold to access gamepad menu navigation."), - ControllerButton_BUTTON_START, - [] { PadMenuNavigatorActive = true; }, - [] { PadMenuNavigatorActive = false; }); - auto toggleGameMenu = [] { - const bool inMenu = gmenu_is_active(); - PressEscKey(); - LastPlayerAction = PlayerActionType::None; - PadHotspellMenuActive = false; - PadMenuNavigatorActive = false; - if (!inMenu) - gamemenu_on(); - }; - options.Padmapper.AddAction( - "ToggleGameMenu1", - N_("Toggle game menu"), - N_("Opens the game menu."), - { - ControllerButton_BUTTON_BACK, - ControllerButton_BUTTON_START, - }, - toggleGameMenu); - options.Padmapper.AddAction( - "ToggleGameMenu2", - N_("Toggle game menu"), - N_("Opens the game menu."), - { - ControllerButton_BUTTON_START, - ControllerButton_BUTTON_BACK, - }, - toggleGameMenu); - options.Padmapper.AddAction( - "QuickSave", - N_("Quick save"), - N_("Saves the game."), - ControllerButton_NONE, - [] { gamemenu_save_game(false); }, - nullptr, - [&]() { return !gbIsMultiplayer && CanPlayerTakeAction(); }); - options.Padmapper.AddAction( - "QuickLoad", - N_("Quick load"), - N_("Loads the game."), - ControllerButton_NONE, - [] { gamemenu_load_game(false); }, - nullptr, - [&]() { return !gbIsMultiplayer && gbValidSaveFile && !IsPlayerInStore() && IsGameRunning(); }); - options.Padmapper.AddAction( - "ItemHighlighting", - N_("Item highlighting"), - N_("Show/hide items on ground."), - ControllerButton_NONE, - [] { HighlightKeyPressed(true); }, - [] { HighlightKeyPressed(false); }); - options.Padmapper.AddAction( - "ToggleItemHighlighting", - N_("Toggle item highlighting"), - N_("Permanent show/hide items on ground."), - ControllerButton_NONE, - nullptr, - [] { ToggleItemLabelHighlight(); }); - options.Padmapper.AddAction( - "HideInfoScreens", - N_("Hide Info Screens"), - N_("Hide all info screens."), - ControllerButton_NONE, - [] { - if (CanAutomapBeToggledOff()) - AutomapActive = false; - - ClosePanels(); - HelpFlag = false; - ChatLogFlag = false; - SpellSelectFlag = false; - if (qtextflag && leveltype == DTYPE_TOWN) { - qtextflag = false; - stream_stop(); - } - - CancelCurrentDiabloMsg(); - gamemenu_off(); - doom_close(); - }, - nullptr, - IsGameRunning); - options.Padmapper.AddAction( - "Zoom", - N_("Zoom"), - N_("Zoom Game Screen."), - ControllerButton_NONE, - [] { - GetOptions().Graphics.zoom.SetValue(!*GetOptions().Graphics.zoom); - CalcViewportGeometry(); - }, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "PauseGame", - N_("Pause Game"), - N_("Pauses the game."), - ControllerButton_NONE, - diablo_pause_game); - options.Padmapper.AddAction( - "DecreaseBrightness", - N_("Decrease Brightness"), - N_("Reduce screen brightness."), - ControllerButton_NONE, - DecreaseBrightness, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "IncreaseBrightness", - N_("Increase Brightness"), - N_("Increase screen brightness."), - ControllerButton_NONE, - IncreaseBrightness, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "Help", - N_("Help"), - N_("Open Help Screen."), - ControllerButton_NONE, - HelpKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "Screenshot", - N_("Screenshot"), - N_("Takes a screenshot."), - ControllerButton_NONE, - nullptr, - CaptureScreen); - options.Padmapper.AddAction( - "GameInfo", - N_("Game info"), - N_("Displays game infos."), - ControllerButton_NONE, - [] { - EventPlrMsg(fmt::format( - fmt::runtime(_(/* TRANSLATORS: {:s} means: Project Name, Game Version. */ "{:s} {:s}")), - PROJECT_NAME, - PROJECT_VERSION), - UiFlags::ColorWhite); - }, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "SortInv", - N_("Sort Inventory"), - N_("Sorts the inventory."), - ControllerButton_NONE, - [] { - ReorganizeInventory(*MyPlayer); - }); - options.Padmapper.AddAction( - "ChatLog", - N_("Chat Log"), - N_("Displays chat log."), - ControllerButton_NONE, - [] { - ToggleChatLog(); - }); - options.Padmapper.CommitActions(); -} - -void SetCursorPos(Point position) -{ - MousePosition = position; - if (ControlDevice != ControlTypes::KeyboardAndMouse) { - return; - } - - LogicalToOutput(&position.x, &position.y); - if (!demo::IsRunning()) - SDL_WarpMouseInWindow(ghMainWnd, position.x, position.y); -} - -void FreeGameMem() -{ - pDungeonCels = nullptr; - pMegaTiles = nullptr; - pSpecialCels = std::nullopt; - - FreeMonsters(); - FreeMissileGFX(); - FreeObjectGFX(); - FreeTownerGFX(); - FreeStashGFX(); -#ifndef USE_SDL1 - DeactivateVirtualGamepad(); - FreeVirtualGamepadGFX(); -#endif -} - -bool StartGame(bool bNewGame, bool bSinglePlayer) -{ - gbSelectProvider = true; - ReturnToMainMenu = false; - - do { - gbLoadGame = false; - - if (!NetInit(bSinglePlayer)) { - gbRunGameResult = true; - break; - } - - // Save 2.8 MiB of RAM by freeing all main menu resources - // before starting the game. - UiDestroy(); - - gbSelectProvider = false; - - if (bNewGame || !gbValidSaveFile) { - InitLevels(); - InitQuests(); - InitPortals(); - InitDungMsgs(*MyPlayer); - DeltaSyncJunk(); - } - giNumberOfLevels = gbIsHellfire ? 25 : 17; - interface_mode uMsg = WM_DIABNEWGAME; - if (gbValidSaveFile && gbLoadGame) { - uMsg = WM_DIABLOADGAME; - } - RunGameLoop(uMsg); - NetClose(); - UnloadFonts(); - - // If the player left the game into the main menu, - // initialize main menu resources. - if (gbRunGameResult) - UiInitialize(); - if (ReturnToMainMenu) - return true; - } while (gbRunGameResult); - - SNetDestroy(); - return gbRunGameResult; -} - -void diablo_quit(int exitStatus) -{ - FreeGameMem(); - music_stop(); - DiabloDeinit(); - -#if SDL_VERSION_ATLEAST(2, 0, 0) - if (SdlLogFile != nullptr) std::fclose(SdlLogFile); -#endif - - exit(exitStatus); -} - -#ifdef __UWP__ -void (*onInitialized)() = NULL; - -void setOnInitialized(void (*callback)()) -{ - onInitialized = callback; -} -#endif - -int DiabloMain(int argc, char **argv) -{ -#ifdef _DEBUG - SDL_SetLogPriorities(SDL_LOG_PRIORITY_DEBUG); -#endif - - DiabloParseFlags(argc, argv); - InitKeymapActions(); - InitPadmapActions(); - - // Need to ensure devilutionx.mpq (and fonts.mpq if available) are loaded before attempting to read translation settings - LoadCoreArchives(); - was_archives_init = true; - - // Read settings including translation next. This will use the presence of fonts.mpq and look for assets in devilutionx.mpq - LoadOptions(); - if (demo::IsRunning()) demo::OverrideOptions(); - - // Then look for a voice pack file based on the selected translation - LoadLanguageArchive(); - - ApplicationInit(); - LuaInitialize(); - if (!demo::IsRunning()) SaveOptions(); - - // Finally load game data - LoadGameArchives(); - - LoadTextData(); - - // Load dynamic data before we go into the menu as we need to initialise player characters in memory pretty early. - LoadPlayerDataFiles(); - - // TODO: We can probably load this much later (when the game is starting). - LoadSpellData(); - LoadMissileData(); - LoadMonsterData(); - LoadItemData(); - LoadObjectData(); - LoadQuestData(); - - DiabloInit(); -#ifdef __UWP__ - onInitialized(); -#endif - if (!demo::IsRunning()) SaveOptions(); - - DiabloSplash(); - mainmenu_loop(); - DiabloDeinit(); - - return 0; -} - -bool TryIconCurs() -{ - if (pcurs == CURSOR_RESURRECT) { - if (PlayerUnderCursor != nullptr) { - NetSendCmdParam1(true, CMD_RESURRECT, PlayerUnderCursor->getId()); - NewCursor(CURSOR_HAND); - return true; - } - - return false; - } - - if (pcurs == CURSOR_HEALOTHER) { - if (PlayerUnderCursor != nullptr) { - NetSendCmdParam1(true, CMD_HEALOTHER, PlayerUnderCursor->getId()); - NewCursor(CURSOR_HAND); - return true; - } - - return false; - } - - if (pcurs == CURSOR_TELEKINESIS) { - DoTelekinesis(); - return true; - } - - Player &myPlayer = *MyPlayer; - - if (pcurs == CURSOR_IDENTIFY) { - if (pcursinvitem != -1 && !IsInspectingPlayer()) - CheckIdentify(myPlayer, pcursinvitem); - else if (pcursstashitem != StashStruct::EmptyCell) { - Item &item = Stash.stashList[pcursstashitem]; - item._iIdentified = true; - } - NewCursor(CURSOR_HAND); - return true; - } - - if (pcurs == CURSOR_REPAIR) { - if (pcursinvitem != -1 && !IsInspectingPlayer()) - DoRepair(myPlayer, pcursinvitem); - else if (pcursstashitem != StashStruct::EmptyCell) { - Item &item = Stash.stashList[pcursstashitem]; - RepairItem(item, myPlayer.getCharacterLevel()); - } - NewCursor(CURSOR_HAND); - return true; - } - - if (pcurs == CURSOR_RECHARGE) { - if (pcursinvitem != -1 && !IsInspectingPlayer()) - DoRecharge(myPlayer, pcursinvitem); - else if (pcursstashitem != StashStruct::EmptyCell) { - Item &item = Stash.stashList[pcursstashitem]; - RechargeItem(item, myPlayer); - } - NewCursor(CURSOR_HAND); - return true; - } - - if (pcurs == CURSOR_OIL) { - bool changeCursor = true; - if (pcursinvitem != -1 && !IsInspectingPlayer()) - changeCursor = DoOil(myPlayer, pcursinvitem); - else if (pcursstashitem != StashStruct::EmptyCell) { - Item &item = Stash.stashList[pcursstashitem]; - changeCursor = ApplyOilToItem(item, myPlayer); - } - if (changeCursor) - NewCursor(CURSOR_HAND); - return true; - } - - if (pcurs == CURSOR_TELEPORT) { - const SpellID spellID = myPlayer.inventorySpell; - const SpellType spellType = SpellType::Scroll; - const int spellFrom = myPlayer.spellFrom; - if (IsWallSpell(spellID)) { - const Direction sd = GetDirection(myPlayer.position.tile, cursPosition); - NetSendCmdLocParam4(true, CMD_SPELLXYD, cursPosition, static_cast(spellID), static_cast(spellType), static_cast(sd), spellFrom); - } else if (pcursmonst != -1 && leveltype != DTYPE_TOWN) { - NetSendCmdParam4(true, CMD_SPELLID, pcursmonst, static_cast(spellID), static_cast(spellType), spellFrom); - } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { - NetSendCmdParam4(true, CMD_SPELLPID, PlayerUnderCursor->getId(), static_cast(spellID), static_cast(spellType), spellFrom); - } else { - NetSendCmdLocParam3(true, CMD_SPELLXY, cursPosition, static_cast(spellID), static_cast(spellType), spellFrom); - } - NewCursor(CURSOR_HAND); - return true; - } - - if (pcurs == CURSOR_DISARM && ObjectUnderCursor == nullptr) { - NewCursor(CURSOR_HAND); - return true; - } - - return false; -} - -void diablo_pause_game() -{ - if (!gbIsMultiplayer) { - if (PauseMode != 0) { - PauseMode = 0; - } else { - PauseMode = 2; - sound_stop(); - qtextflag = false; - LastPlayerAction = PlayerActionType::None; - } - - RedrawEverything(); - } -} - -bool GameWasAlreadyPaused = false; -bool MinimizePaused = false; - -bool diablo_is_focused() -{ -#ifndef USE_SDL1 - return SDL_GetKeyboardFocus() == ghMainWnd; -#else - Uint8 appState = SDL_GetAppState(); - return (appState & SDL_APPINPUTFOCUS) != 0; -#endif -} - -void diablo_focus_pause() -{ - if (!movie_playing && (gbIsMultiplayer || MinimizePaused)) { - return; - } - - GameWasAlreadyPaused = PauseMode != 0; - - if (!GameWasAlreadyPaused) { - PauseMode = 2; - sound_stop(); - LastPlayerAction = PlayerActionType::None; - } - - SVidMute(); - music_mute(); - - MinimizePaused = true; -} - -void diablo_focus_unpause() -{ - if (!GameWasAlreadyPaused) { - PauseMode = 0; - } - - SVidUnmute(); - music_unmute(); - - MinimizePaused = false; -} - -bool PressEscKey() -{ - bool rv = false; - - if (DoomFlag) { - doom_close(); - rv = true; - } - - if (HelpFlag) { - HelpFlag = false; - rv = true; - } - - if (ChatLogFlag) { - ChatLogFlag = false; - rv = true; - } - - if (qtextflag) { - qtextflag = false; - stream_stop(); - rv = true; - } - - if (IsPlayerInStore()) { - StoreESC(); - rv = true; - } - - if (IsDiabloMsgAvailable()) { - CancelCurrentDiabloMsg(); - rv = true; - } - - if (ChatFlag) { - ResetChat(); - rv = true; - } - - if (DropGoldFlag) { - control_drop_gold(SDLK_ESCAPE); - rv = true; - } - - if (IsWithdrawGoldOpen) { - WithdrawGoldKeyPress(SDLK_ESCAPE); - rv = true; - } - - if (SpellSelectFlag) { - SpellSelectFlag = false; - rv = true; - } - - if (IsLeftPanelOpen() || IsRightPanelOpen()) { - ClosePanels(); - rv = true; - } - - return rv; -} - -void DisableInputEventHandler(const SDL_Event &event, uint16_t modState) -{ - switch (event.type) { - case SDL_EVENT_MOUSE_MOTION: - MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; - return; - case SDL_EVENT_MOUSE_BUTTON_DOWN: - if (sgbMouseDown != CLICK_NONE) - return; - switch (event.button.button) { - case SDL_BUTTON_LEFT: - sgbMouseDown = CLICK_LEFT; - return; - case SDL_BUTTON_RIGHT: - sgbMouseDown = CLICK_RIGHT; - return; - default: - return; - } - case SDL_EVENT_MOUSE_BUTTON_UP: - sgbMouseDown = CLICK_NONE; - return; - } - - MainWndProc(event); -} - -void LoadGameLevelStopMusic(_music_id neededTrack) -{ - if (neededTrack != sgnMusicTrack) - music_stop(); -} - -void LoadGameLevelStartMusic(_music_id neededTrack) -{ - if (sgnMusicTrack != neededTrack) - music_start(neededTrack); - - if (MinimizePaused) { - music_mute(); - } -} - -void LoadGameLevelResetCursor() -{ - if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { - NewCursor(CURSOR_HAND); - } -} - -void SetRndSeedForDungeonLevel() -{ - if (setlevel) { - // Maps are not randomly generated, but the monsters max hitpoints are. - // So we need to ensure that we have a stable seed when generating quest/set-maps. - // For this purpose we reuse the normal dungeon seeds. - SetRndSeed(DungeonSeeds[static_cast(setlvlnum)]); - } else { - SetRndSeed(DungeonSeeds[currlevel]); - } -} - -void LoadGameLevelFirstFlagEntry() -{ - CloseInventory(); - qtextflag = false; - if (!HeadlessMode) { - InitInv(); - ClearUniqueItemFlags(); - InitQuestText(); - InitInfoBoxGfx(); - InitHelp(); - } - InitStores(); - InitAutomapOnce(); -} - -void LoadGameLevelStores() -{ - if (leveltype == DTYPE_TOWN) { - SetupTownStores(); - } else { - FreeStoreMem(); - } -} - -void LoadGameLevelStash() -{ - const bool isHellfireSaveGame = gbIsHellfireSaveGame; - - gbIsHellfireSaveGame = gbIsHellfire; - LoadStash(); - gbIsHellfireSaveGame = isHellfireSaveGame; -} - -tl::expected LoadGameLevelDungeon(bool firstflag, lvl_entry lvldir, const Player &myPlayer) -{ - if (firstflag || lvldir == ENTRY_LOAD || !myPlayer._pLvlVisited[currlevel] || gbIsMultiplayer) { - HoldThemeRooms(); - [[maybe_unused]] const uint32_t mid1Seed = GetLCGEngineState(); - InitGolems(); - InitObjects(); - [[maybe_unused]] const uint32_t mid2Seed = GetLCGEngineState(); - - IncProgress(); - - RETURN_IF_ERROR(InitMonsters()); - InitItems(); - CreateThemeRooms(); - - IncProgress(); - - [[maybe_unused]] const uint32_t mid3Seed = GetLCGEngineState(); - InitMissiles(); - InitCorpses(); -#ifdef _DEBUG - SetDebugLevelSeedInfos(mid1Seed, mid2Seed, mid3Seed, GetLCGEngineState()); -#endif - SavePreLighting(); - - IncProgress(); - - if (gbIsMultiplayer) - DeltaLoadLevel(); - } else { - HoldThemeRooms(); - InitGolems(); - RETURN_IF_ERROR(InitMonsters()); - InitMissiles(); - InitCorpses(); - - IncProgress(); - - RETURN_IF_ERROR(LoadLevel()); - - IncProgress(); - } - return {}; -} - -void LoadGameLevelSyncPlayerEntry(lvl_entry lvldir) -{ - for (Player &player : Players) { - if (player.plractive && player.isOnActiveLevel() && (!player._pLvlChanging || &player == MyPlayer)) { - if (player._pHitPoints > 0) { - if (lvldir != ENTRY_LOAD) - SyncInitPlrPos(player); - } else { - dFlags[player.position.tile.x][player.position.tile.y] |= DungeonFlag::DeadPlayer; - } - } - } -} - -void LoadGameLevelLightVision() -{ - if (leveltype != DTYPE_TOWN) { - memcpy(dLight, dPreLight, sizeof(dLight)); // resets the light on entering a level to get rid of incorrect light - ChangeLightXY(Players[MyPlayerId].lightId, Players[MyPlayerId].position.tile); // forces player light refresh - ProcessLightList(); - ProcessVisionList(); - } -} - -void LoadGameLevelReturn() -{ - ViewPosition = GetMapReturnPosition(); - if (Quests[Q_BETRAYER]._qactive == QUEST_DONE) - Quests[Q_BETRAYER]._qvar2 = 2; -} - -void LoadGameLevelInitPlayers(bool firstflag, lvl_entry lvldir) -{ - for (Player &player : Players) { - if (player.plractive && player.isOnActiveLevel()) { - InitPlayerGFX(player); - if (lvldir != ENTRY_LOAD) - InitPlayer(player, firstflag); - } - } -} - -void LoadGameLevelSetVisited() -{ - bool visited = false; - for (const Player &player : Players) { - if (player.plractive) - visited = visited || player._pLvlVisited[currlevel]; - } -} - -tl::expected LoadGameLevelTown(bool firstflag, lvl_entry lvldir, const Player &myPlayer) -{ - for (int i = 0; i < MAXDUNX; i++) { // NOLINT(modernize-loop-convert) - for (int j = 0; j < MAXDUNY; j++) { - dFlags[i][j] |= DungeonFlag::Lit; - } - } - - InitTowners(); - InitStash(); - InitItems(); - InitMissiles(); - - IncProgress(); - - if (!firstflag && lvldir != ENTRY_LOAD && myPlayer._pLvlVisited[currlevel] && !gbIsMultiplayer) - RETURN_IF_ERROR(LoadLevel()); - if (gbIsMultiplayer) - DeltaLoadLevel(); - - IncProgress(); - - for (int x = 0; x < DMAXX; x++) - for (int y = 0; y < DMAXY; y++) - UpdateAutomapExplorer({ x, y }, MAP_EXP_SELF); - return {}; -} - -tl::expected LoadGameLevelSetLevel(bool firstflag, lvl_entry lvldir, const Player &myPlayer) -{ - LoadSetMap(); - IncProgress(); - RETURN_IF_ERROR(GetLevelMTypes()); - IncProgress(); - InitGolems(); - RETURN_IF_ERROR(InitMonsters()); - IncProgress(); - if (!HeadlessMode) { -#if !defined(USE_SDL1) && !defined(__vita__) - InitVirtualGamepadGFX(); -#endif - RETURN_IF_ERROR(InitMissileGFX()); - IncProgress(); - } - InitCorpses(); - IncProgress(); - - if (lvldir == ENTRY_WARPLVL) - GetPortalLvlPos(); - IncProgress(); - - for (Player &player : Players) { - if (player.plractive && player.isOnActiveLevel()) { - InitPlayerGFX(player); - if (lvldir != ENTRY_LOAD) - InitPlayer(player, firstflag); - } - } - IncProgress(); - InitMultiView(); - IncProgress(); - - if (firstflag || lvldir == ENTRY_LOAD || !myPlayer._pSLvlVisited[setlvlnum] || gbIsMultiplayer) { - InitItems(); - SavePreLighting(); - } else { - RETURN_IF_ERROR(LoadLevel()); - } - if (gbIsMultiplayer) { - DeltaLoadLevel(); - if (!UseMultiplayerQuests()) - ResyncQuests(); - } - - PlayDungMsgs(); - InitMissiles(); - IncProgress(); - return {}; -} - -tl::expected LoadGameLevelStandardLevel(bool firstflag, lvl_entry lvldir, const Player &myPlayer) -{ - CreateLevel(lvldir); - - IncProgress(); - - SetRndSeedForDungeonLevel(); - - if (leveltype != DTYPE_TOWN) { - RETURN_IF_ERROR(GetLevelMTypes()); - InitThemes(); - if (!HeadlessMode) - RETURN_IF_ERROR(LoadAllGFX()); - } else if (!HeadlessMode) { - IncProgress(); - -#if !defined(USE_SDL1) && !defined(__vita__) - InitVirtualGamepadGFX(); -#endif - - IncProgress(); - - RETURN_IF_ERROR(InitMissileGFX()); - - IncProgress(); - IncProgress(); - } - - IncProgress(); - - if (lvldir == ENTRY_RTNLVL) { - LoadGameLevelReturn(); - } - - if (lvldir == ENTRY_WARPLVL) - GetPortalLvlPos(); - - IncProgress(); - - LoadGameLevelInitPlayers(firstflag, lvldir); - InitMultiView(); - - IncProgress(); - - LoadGameLevelSetVisited(); - - SetRndSeedForDungeonLevel(); - - if (leveltype == DTYPE_TOWN) { - LoadGameLevelTown(firstflag, lvldir, myPlayer); - } else { - LoadGameLevelDungeon(firstflag, lvldir, myPlayer); - } - - PlayDungMsgs(); - - if (UseMultiplayerQuests()) - ResyncMPQuests(); - else - ResyncQuests(); - return {}; -} - -void LoadGameLevelCrypt() -{ - if (CornerStone.isAvailable()) { - CornerstoneLoad(CornerStone.position); - } - if (Quests[Q_NAKRUL]._qactive == QUEST_DONE && currlevel == 24) { - SyncNakrulRoom(); - } -} - -void LoadGameLevelCalculateCursor() -{ - // Recalculate mouse selection of entities after level change/load - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_NONE; - ResetItemlabelHighlighted(); // level changed => item changed - pcursmonst = -1; // ensure pcurstemp is set to a valid value - CheckCursMove(); -} - -tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir) -{ - const _music_id neededTrack = GetLevelMusic(leveltype); - - ClearFloatingNumbers(); - LoadGameLevelStopMusic(neededTrack); - LoadGameLevelResetCursor(); - SetRndSeedForDungeonLevel(); - NaKrulTomeSequence = 0; - - IncProgress(); - - RETURN_IF_ERROR(LoadTrns()); - MakeLightTable(); - RETURN_IF_ERROR(LoadLevelSOLData()); - - IncProgress(); - - RETURN_IF_ERROR(LoadLvlGFX()); - SetDungeonMicros(pDungeonCels, MicroTileLen); - ClearClxDrawCache(); - - IncProgress(); - - if (firstflag) { - LoadGameLevelFirstFlagEntry(); - } - - SetRndSeedForDungeonLevel(); - - LoadGameLevelStores(); - - if (firstflag || lvldir == ENTRY_LOAD) { - LoadGameLevelStash(); - } - - IncProgress(); - - InitAutomap(); - - if (leveltype != DTYPE_TOWN && lvldir != ENTRY_LOAD) { - InitLighting(); - } - - InitLevelMonsters(); - - IncProgress(); - - const Player &myPlayer = *MyPlayer; - - if (setlevel) { - RETURN_IF_ERROR(LoadGameLevelSetLevel(firstflag, lvldir, myPlayer)); - } else { - RETURN_IF_ERROR(LoadGameLevelStandardLevel(firstflag, lvldir, myPlayer)); - } - - SyncPortals(); - LoadGameLevelSyncPlayerEntry(lvldir); - - IncProgress(); - IncProgress(); - - if (firstflag) { - RETURN_IF_ERROR(InitMainPanel()); - } - - IncProgress(); - - UpdateMonsterLights(); - UnstuckChargers(); - - LoadGameLevelLightVision(); - - if (leveltype == DTYPE_CRYPT) { - LoadGameLevelCrypt(); - } - -#ifndef USE_SDL1 - ActivateVirtualGamepad(); -#endif - LoadGameLevelStartMusic(neededTrack); - - CompleteProgress(); - - LoadGameLevelCalculateCursor(); - if (leveltype != DTYPE_TOWN) - SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); - return {}; -} - -bool game_loop(bool bStartup) -{ - const uint16_t wait = bStartup ? sgGameInitInfo.nTickRate * 3 : 3; - - for (unsigned i = 0; i < wait; i++) { - if (!multi_handle_delta()) { - TimeoutCursor(true); - return false; - } - TimeoutCursor(false); - GameLogic(); - ClearLastSentPlayerCmd(); - - if (!gbRunGame || !gbIsMultiplayer || demo::IsRunning() || demo::IsRecording() || !nthread_has_500ms_passed()) - break; - } - return true; -} - -void diablo_color_cyc_logic() -{ - if (!*GetOptions().Graphics.colorCycling) - return; - - if (PauseMode != 0) - return; - - if (leveltype == DTYPE_CAVES) { - if (setlevel && setlvlnum == Quests[Q_PWATER]._qslvl) { - UpdatePWaterPalette(); - } else { - palette_update_caves(); - } - } else if (leveltype == DTYPE_HELL) { - lighting_color_cycling(); - } else if (leveltype == DTYPE_NEST) { - palette_update_hive(); - } else if (leveltype == DTYPE_CRYPT) { - palette_update_crypt(); - } -} - -bool IsDiabloAlive(bool playSFX) -{ - if (Quests[Q_DIABLO]._qactive == QUEST_DONE && !gbIsMultiplayer) { - if (playSFX) - PlaySFX(SfxID::DiabloDeath); - return false; - } - - return true; -} - -void PrintScreen(SDL_Keycode vkey) -{ - ReleaseKey(vkey); -} - -} // namespace devilution +/** + * @file diablo.cpp + * + * Implementation of the main game initialization functions. + */ +#include +#include +#include +#include +#include +#include +#include + +#ifdef USE_SDL3 +#include +#include +#include +#else +#include + +#ifdef USE_SDL1 +#include "utils/sdl2_to_1_2_backports.h" +#endif +#endif + +#include + +#include + +#include "DiabloUI/selstart.h" +#include "appfat.h" +#include "automap.h" +#include "capture.h" +#include "control/control.hpp" +#include "cursor.h" +#include "dead.h" +#ifdef _DEBUG +#include "debug.h" +#endif +#include "DiabloUI/diabloui.h" +#include "controls/accessibility_keys.hpp" +#include "controls/control_mode.hpp" +#include "controls/keymapper.hpp" +#include "controls/plrctrls.h" +#include "controls/remap_keyboard.h" +#include "controls/town_npc_nav.hpp" +#include "controls/tracker.hpp" +#include "diablo.h" +#include "diablo_msg.hpp" +#include "discord/discord.h" +#include "doom.h" +#include "encrypt.h" +#include "engine/backbuffer_state.hpp" +#include "engine/clx_sprite.hpp" +#include "engine/demomode.h" +#include "engine/dx.h" +#include "engine/events.hpp" +#include "engine/load_cel.hpp" +#include "engine/load_file.hpp" +#include "engine/path.h" +#include "engine/random.hpp" +#include "engine/render/clx_render.hpp" +#include "engine/sound.h" +#include "game_mode.hpp" +#include "gamemenu.h" +#include "gmenu.h" +#include "headless_mode.hpp" +#include "help.h" +#include "hwcursor.hpp" +#include "init.hpp" +#include "inv.h" +#include "levels/drlg_l1.h" +#include "levels/drlg_l2.h" +#include "levels/drlg_l3.h" +#include "levels/drlg_l4.h" +#include "levels/gendung.h" +#include "levels/setmaps.h" +#include "levels/themes.h" +#include "levels/town.h" +#include "levels/trigs.h" +#include "lighting.h" +#include "loadsave.h" +#include "lua/lua_global.hpp" +#include "menu.h" +#include "minitext.h" +#include "missiles.h" +#include "movie.h" +#include "multi.h" +#include "nthread.h" +#include "objects.h" +#include "options.h" +#include "panels/charpanel.hpp" +#include "panels/console.hpp" +#include "panels/info_box.hpp" +#include "panels/partypanel.hpp" +#include "panels/spell_book.hpp" +#include "panels/spell_list.hpp" +#include "pfile.h" +#include "plrmsg.h" +#include "portal.h" +#include "qol/chatlog.h" +#include "qol/floatingnumbers.h" +#include "qol/itemlabels.h" +#include "qol/monhealthbar.h" +#include "qol/stash.h" +#include "qol/xpbar.h" +#include "quick_messages.hpp" +#include "restrict.h" +#include "stores.h" +#include "storm/storm_net.hpp" +#include "storm/storm_svid.h" +#include "tables/monstdat.h" +#include "tables/playerdat.hpp" +#include "towners.h" +#include "track.h" +#include "utils/console.h" +#include "utils/display.h" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/parse_int.hpp" +#include "utils/paths.h" +#include "utils/accessibility_announcements.hpp" +#include "utils/navigation_speech.hpp" +#include "utils/proximity_audio.hpp" +#include "utils/screen_reader.hpp" +#include "utils/sdl_compat.h" +#include "utils/sdl_thread.h" +#include "utils/status_macros.hpp" +#include "utils/str_cat.hpp" +#include "utils/utf8.hpp" + +#ifndef USE_SDL1 +#include "controls/touch/gamepad.h" +#include "controls/touch/renderers.h" +#endif + +#ifdef __vita__ +#include "platform/vita/touch.h" +#endif + +#ifdef GPERF_HEAP_FIRST_GAME_ITERATION +#include +#endif + +namespace devilution { + +uint32_t DungeonSeeds[NUMLEVELS]; +std::optional LevelSeeds[NUMLEVELS]; +Point MousePosition; +bool gbRunGameResult; +bool ReturnToMainMenu; +/** Enable updating of player character, set to false once Diablo dies */ +bool gbProcessPlayers; +bool gbLoadGame; +bool cineflag; +int PauseMode; +clicktype sgbMouseDown; +uint16_t gnTickDelay = 50; +char gszProductName[64] = "DevilutionX vUnknown"; + +#ifdef _DEBUG +bool DebugDisableNetworkTimeout = false; +std::vector DebugCmdsFromCommandLine; +#endif +GameLogicStep gGameLogicStep = GameLogicStep::None; + +/** This and the following mouse variables are for handling in-game click-and-hold actions */ +PlayerActionType LastPlayerAction = PlayerActionType::None; + +// Controller support: Actions to run after updating the cursor state. +// Defined in SourceX/controls/plctrls.cpp. +extern void plrctrls_after_check_curs_move(); +extern void plrctrls_every_frame(); +extern void plrctrls_after_game_logic(); + +namespace { + +char gszVersionNumber[64] = "internal version unknown"; + +bool gbGameLoopStartup; +bool forceSpawn; +bool forceDiablo; +int sgnTimeoutCurs; +bool gbShowIntro = true; +/** To know if these things have been done when we get to the diablo_deinit() function */ +bool was_archives_init = false; +/** To know if surfaces have been initialized or not */ +bool was_window_init = false; +bool was_ui_init = false; + +void StartGame(interface_mode uMsg) +{ + CalcViewportGeometry(); + cineflag = false; + InitCursor(); +#ifdef _DEBUG + LoadDebugGFX(); +#endif + assert(HeadlessMode || ghMainWnd); + music_stop(); + InitMonsterHealthBar(); + InitXPBar(); + ShowProgress(uMsg); + gmenu_init_menu(); + InitLevelCursor(); + sgnTimeoutCurs = CURSOR_NONE; + sgbMouseDown = CLICK_NONE; + LastPlayerAction = PlayerActionType::None; +} + +void FreeGame() +{ + FreeMonsterHealthBar(); + FreeXPBar(); + FreeControlPan(); + FreeInvGFX(); + FreeGMenu(); + FreeQuestText(); + FreeInfoBoxGfx(); + FreeStoreMem(); + + for (Player &player : Players) + ResetPlayerGFX(player); + + FreeCursor(); +#ifdef _DEBUG + FreeDebugGFX(); +#endif + FreeGameMem(); + stream_stop(); + music_stop(); +} + +bool ProcessInput() +{ + if (PauseMode == 2) { + return false; + } + + plrctrls_every_frame(); + + if (!gbIsMultiplayer && gmenu_is_active()) { + RedrawViewport(); + return false; + } + + if (!gmenu_is_active() && sgnTimeoutCurs == CURSOR_NONE) { +#ifdef __vita__ + FinishSimulatedMouseClicks(MousePosition); +#endif + CheckCursMove(); + plrctrls_after_check_curs_move(); + RepeatPlayerAction(); + } + + return true; +} + +void LeftMouseCmd(bool bShift) +{ + bool bNear; + + assert(!GetMainPanel().contains(MousePosition)); + + if (leveltype == DTYPE_TOWN) { + CloseGoldWithdraw(); + CloseStash(); + if (pcursitem != -1 && pcurs == CURSOR_HAND) + NetSendCmdLocParam1(true, invflag ? CMD_GOTOGETITEM : CMD_GOTOAGETITEM, cursPosition, pcursitem); + if (pcursmonst != -1) + NetSendCmdLocParam1(true, CMD_TALKXY, cursPosition, pcursmonst); + if (pcursitem == -1 && pcursmonst == -1 && PlayerUnderCursor == nullptr) { + LastPlayerAction = PlayerActionType::Walk; + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, cursPosition); + } + return; + } + + const Player &myPlayer = *MyPlayer; + bNear = myPlayer.position.tile.WalkingDistance(cursPosition) < 2; + if (pcursitem != -1 && pcurs == CURSOR_HAND && !bShift) { + NetSendCmdLocParam1(true, invflag ? CMD_GOTOGETITEM : CMD_GOTOAGETITEM, cursPosition, pcursitem); + } else if (ObjectUnderCursor != nullptr && !ObjectUnderCursor->IsDisabled() && (!bShift || (bNear && ObjectUnderCursor->_oBreak == 1))) { + LastPlayerAction = PlayerActionType::OperateObject; + NetSendCmdLoc(MyPlayerId, true, pcurs == CURSOR_DISARM ? CMD_DISARMXY : CMD_OPOBJXY, cursPosition); + } else if (myPlayer.UsesRangedWeapon()) { + if (bShift) { + LastPlayerAction = PlayerActionType::Attack; + NetSendCmdLoc(MyPlayerId, true, CMD_RATTACKXY, cursPosition); + } else if (pcursmonst != -1) { + if (CanTalkToMonst(Monsters[pcursmonst])) { + NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); + } else { + LastPlayerAction = PlayerActionType::AttackMonsterTarget; + NetSendCmdParam1(true, CMD_RATTACKID, pcursmonst); + } + } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { + LastPlayerAction = PlayerActionType::AttackPlayerTarget; + NetSendCmdParam1(true, CMD_RATTACKPID, PlayerUnderCursor->getId()); + } + } else { + if (bShift) { + if (pcursmonst != -1) { + if (CanTalkToMonst(Monsters[pcursmonst])) { + NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); + } else { + LastPlayerAction = PlayerActionType::Attack; + NetSendCmdLoc(MyPlayerId, true, CMD_SATTACKXY, cursPosition); + } + } else { + LastPlayerAction = PlayerActionType::Attack; + NetSendCmdLoc(MyPlayerId, true, CMD_SATTACKXY, cursPosition); + } + } else if (pcursmonst != -1) { + LastPlayerAction = PlayerActionType::AttackMonsterTarget; + NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); + } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { + LastPlayerAction = PlayerActionType::AttackPlayerTarget; + NetSendCmdParam1(true, CMD_ATTACKPID, PlayerUnderCursor->getId()); + } + } + if (!bShift && pcursitem == -1 && ObjectUnderCursor == nullptr && pcursmonst == -1 && PlayerUnderCursor == nullptr) { + LastPlayerAction = PlayerActionType::Walk; + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, cursPosition); + } +} + +bool TryOpenDungeonWithMouse() +{ + if (leveltype != DTYPE_TOWN) + return false; + + const Item &holdItem = MyPlayer->HoldItem; + if (holdItem.IDidx == IDI_RUNEBOMB && OpensHive(cursPosition)) + OpenHive(); + else if (holdItem.IDidx == IDI_MAPOFDOOM && OpensGrave(cursPosition)) + OpenGrave(); + else + return false; + + NewCursor(CURSOR_HAND); + return true; +} + +void LeftMouseDown(uint16_t modState) +{ + LastPlayerAction = PlayerActionType::None; + + if (gmenu_left_mouse(true)) + return; + + if (CheckMuteButton()) + return; + + if (sgnTimeoutCurs != CURSOR_NONE) + return; + + if (MyPlayerIsDead) { + CheckMainPanelButtonDead(); + return; + } + + if (PauseMode == 2) { + return; + } + if (DoomFlag) { + doom_close(); + return; + } + + if (SpellSelectFlag) { + SetSpell(); + return; + } + + if (IsPlayerInStore()) { + CheckStoreBtn(); + return; + } + + const bool isShiftHeld = (modState & SDL_KMOD_SHIFT) != 0; + const bool isCtrlHeld = (modState & SDL_KMOD_CTRL) != 0; + + if (!GetMainPanel().contains(MousePosition)) { + if (!gmenu_is_active() && !TryIconCurs()) { + if (QuestLogIsOpen && GetLeftPanel().contains(MousePosition)) { + QuestlogESC(); + } else if (qtextflag) { + qtextflag = false; + stream_stop(); + } else if (CharFlag && GetLeftPanel().contains(MousePosition)) { + CheckChrBtns(); + } else if (invflag && GetRightPanel().contains(MousePosition)) { + if (!DropGoldFlag) + CheckInvItem(isShiftHeld, isCtrlHeld); + } else if (IsStashOpen && GetLeftPanel().contains(MousePosition)) { + if (!IsWithdrawGoldOpen) + CheckStashItem(MousePosition, isShiftHeld, isCtrlHeld); + CheckStashButtonPress(MousePosition); + } else if (SpellbookFlag && GetRightPanel().contains(MousePosition)) { + CheckSBook(); + } else if (!MyPlayer->HoldItem.isEmpty()) { + if (!TryOpenDungeonWithMouse()) { + const Point currentPosition = MyPlayer->position.tile; + std::optional itemTile = FindAdjacentPositionForItem(currentPosition, GetDirection(currentPosition, cursPosition)); + if (itemTile) { + NetSendCmdPItem(true, CMD_PUTITEM, *itemTile, MyPlayer->HoldItem); + NewCursor(CURSOR_HAND); + } + } + } else { + CheckLevelButton(); + if (!LevelButtonDown) + LeftMouseCmd(isShiftHeld); + } + } + } else { + if (!ChatFlag && !DropGoldFlag && !IsWithdrawGoldOpen && !gmenu_is_active()) + CheckInvScrn(isShiftHeld, isCtrlHeld); + CheckMainPanelButton(); + CheckStashButtonPress(MousePosition); + if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) + NewCursor(CURSOR_HAND); + } +} + +void LeftMouseUp(uint16_t modState) +{ + gmenu_left_mouse(false); + CheckMuteButtonUp(); + if (MainPanelButtonDown) + CheckMainPanelButtonUp(); + CheckStashButtonRelease(MousePosition); + if (CharPanelButtonActive) { + const bool isShiftHeld = (modState & SDL_KMOD_SHIFT) != 0; + ReleaseChrBtns(isShiftHeld); + } + if (LevelButtonDown) + CheckLevelButtonUp(); + if (IsPlayerInStore()) + ReleaseStoreBtn(); +} + +void RightMouseDown(bool isShiftHeld) +{ + LastPlayerAction = PlayerActionType::None; + + if (gmenu_is_active() || sgnTimeoutCurs != CURSOR_NONE || PauseMode == 2 || MyPlayer->_pInvincible) { + return; + } + + if (qtextflag) { + qtextflag = false; + stream_stop(); + return; + } + + if (DoomFlag) { + doom_close(); + return; + } + if (IsPlayerInStore()) + return; + if (SpellSelectFlag) { + SetSpell(); + return; + } + if (SpellbookFlag && GetRightPanel().contains(MousePosition)) + return; + if (TryIconCurs()) + return; + if (pcursinvitem != -1 && UseInvItem(pcursinvitem)) + return; + if (pcursstashitem != StashStruct::EmptyCell && UseStashItem(pcursstashitem)) + return; + if (DidRightClickPartyPortrait()) + return; + if (pcurs == CURSOR_HAND) { + CheckPlrSpell(isShiftHeld); + } else if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { + NewCursor(CURSOR_HAND); + } +} + +void ReleaseKey(SDL_Keycode vkey) +{ + remap_keyboard_key(&vkey); + if (sgnTimeoutCurs != CURSOR_NONE) + return; + KeymapperRelease(vkey); +} + +void ClosePanels() +{ + if (CanPanelsCoverView()) { + if (!IsLeftPanelOpen() && IsRightPanelOpen() && MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); + } else if (!IsRightPanelOpen() && IsLeftPanelOpen() && MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition - Displacement { 160, 0 }); + } + } + CloseInventory(); + CloseCharPanel(); + SpellbookFlag = false; + QuestLogIsOpen = false; +} + +void PressKey(SDL_Keycode vkey, uint16_t modState) +{ + Options &options = GetOptions(); + remap_keyboard_key(&vkey); + + if (vkey == SDLK_UNKNOWN) + return; + + if (gmenu_presskeys(vkey) || CheckKeypress(vkey)) { + return; + } + + if (MyPlayerIsDead) { + if (vkey == SDLK_ESCAPE) { + if (!gbIsMultiplayer) { + if (gbValidSaveFile) + gamemenu_load_game(false); + else + gamemenu_exit_game(false); + } else { + NetSendCmd(true, CMD_RETOWN); + } + return; + } + if (sgnTimeoutCurs != CURSOR_NONE) { + return; + } + KeymapperPress(vkey); + if (vkey == SDLK_RETURN || vkey == SDLK_KP_ENTER) { + if ((modState & SDL_KMOD_ALT) != 0) { + options.Graphics.fullscreen.SetValue(!IsFullScreen()); + if (!demo::IsRunning()) SaveOptions(); + } else { + TypeChatMessage(); + } + } + if (vkey != SDLK_ESCAPE) { + return; + } + } + // Disallow player from accessing escape menu during the frames before the death message appears + if (vkey == SDLK_ESCAPE && MyPlayer->_pHitPoints > 0) { + if (!PressEscKey()) { + LastPlayerAction = PlayerActionType::None; + gamemenu_on(); + } + return; + } + + if (DropGoldFlag) { + control_drop_gold(vkey); + return; + } + if (IsWithdrawGoldOpen) { + WithdrawGoldKeyPress(vkey); + return; + } + + if (sgnTimeoutCurs != CURSOR_NONE) { + return; + } + + KeymapperPress(vkey); + + if (PauseMode == 2) { + if ((vkey == SDLK_RETURN || vkey == SDLK_KP_ENTER) && (modState & SDL_KMOD_ALT) != 0) { + options.Graphics.fullscreen.SetValue(!IsFullScreen()); + if (!demo::IsRunning()) SaveOptions(); + } + return; + } + + if (DoomFlag) { + doom_close(); + return; + } + + switch (vkey) { + case SDLK_PLUS: + case SDLK_KP_PLUS: + case SDLK_EQUALS: + case SDLK_KP_EQUALS: + if (AutomapActive) { + AutomapZoomIn(); + } + return; + case SDLK_MINUS: + case SDLK_KP_MINUS: + case SDLK_UNDERSCORE: + if (AutomapActive) { + AutomapZoomOut(); + } + return; +#ifdef _DEBUG + case SDLK_V: + if ((modState & SDL_KMOD_SHIFT) != 0) + NextDebugMonster(); + else + GetDebugMonster(); + return; +#endif + case SDLK_RETURN: + case SDLK_KP_ENTER: + if ((modState & SDL_KMOD_ALT) != 0) { + options.Graphics.fullscreen.SetValue(!IsFullScreen()); + if (!demo::IsRunning()) SaveOptions(); + } else if (CharFlag) { + CharacterScreenActivateSelection((modState & SDL_KMOD_SHIFT) != 0); + } else if (IsPlayerInStore()) { + StoreEnter(); + } else if (QuestLogIsOpen) { + QuestlogEnter(); + } else if (SpellSelectFlag) { + SetSpell(); + } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { + const Player &player = *MyPlayer; + if (IsValidSpell(player._pRSpell)) { + std::string msg; + StrAppend(msg, _("Selected: "), pgettext("spell", GetSpellData(player._pRSpell).sNameText)); + SpeakText(msg, /*force=*/true); + } else { + SpeakText(_("No spell selected."), /*force=*/true); + } + SpellBookKeyPressed(); + } else { + TypeChatMessage(); + } + return; + case SDLK_UP: + if (IsPlayerInStore()) { + StoreUp(); + } else if (QuestLogIsOpen) { + QuestlogUp(); + } else if (CharFlag) { + CharacterScreenMoveSelection(-1); + } else if (HelpFlag) { + HelpScrollUp(); + } else if (ChatLogFlag) { + ChatLogScrollUp(); + } else if (SpellSelectFlag) { + HotSpellMove({ AxisDirectionX_NONE, AxisDirectionY_UP }); + SpeakSelectedSpeedbookSpell(); + } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { + const std::optional next = GetSpellBookAdjacentAvailableSpell(SpellbookTab, *MyPlayer, MyPlayer->_pRSpell, -1); + if (next) { + MyPlayer->_pRSpell = *next; + MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*next)) != 0 ? SpellType::Skill + : (MyPlayer->_pISpells & GetSpellBitmask(*next)) != 0 ? SpellType::Charges + : SpellType::Spell; + UpdateSpellTarget(*next); + RedrawEverything(); + SpeakText(pgettext("spell", GetSpellData(*next).sNameText), /*force=*/true); + } + } else if (invflag) { + InventoryMoveFromKeyboard({ AxisDirectionX_NONE, AxisDirectionY_UP }); + } else if (AutomapActive) { + AutomapUp(); + } else if (IsStashOpen) { + Stash.PreviousPage(); + } + return; + case SDLK_DOWN: + if (IsPlayerInStore()) { + StoreDown(); + } else if (QuestLogIsOpen) { + QuestlogDown(); + } else if (CharFlag) { + CharacterScreenMoveSelection(+1); + } else if (HelpFlag) { + HelpScrollDown(); + } else if (ChatLogFlag) { + ChatLogScrollDown(); + } else if (SpellSelectFlag) { + HotSpellMove({ AxisDirectionX_NONE, AxisDirectionY_DOWN }); + SpeakSelectedSpeedbookSpell(); + } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { + const std::optional next = GetSpellBookAdjacentAvailableSpell(SpellbookTab, *MyPlayer, MyPlayer->_pRSpell, +1); + if (next) { + MyPlayer->_pRSpell = *next; + MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*next)) != 0 ? SpellType::Skill + : (MyPlayer->_pISpells & GetSpellBitmask(*next)) != 0 ? SpellType::Charges + : SpellType::Spell; + UpdateSpellTarget(*next); + RedrawEverything(); + SpeakText(pgettext("spell", GetSpellData(*next).sNameText), /*force=*/true); + } + } else if (invflag) { + InventoryMoveFromKeyboard({ AxisDirectionX_NONE, AxisDirectionY_DOWN }); + } else if (AutomapActive) { + AutomapDown(); + } else if (IsStashOpen) { + Stash.NextPage(); + } + return; + case SDLK_PAGEUP: + if (IsPlayerInStore()) { + StorePrior(); + } else if (ChatLogFlag) { + ChatLogScrollTop(); + } else { + const KeymapperOptions::Action *action = GetOptions().Keymapper.findAction(static_cast(vkey)); + if (action == nullptr || !action->isEnabled()) + SelectPreviousTownNpcKeyPressed(); + } + return; + case SDLK_PAGEDOWN: + if (IsPlayerInStore()) { + StoreNext(); + } else if (ChatLogFlag) { + ChatLogScrollBottom(); + } else { + const KeymapperOptions::Action *action = GetOptions().Keymapper.findAction(static_cast(vkey)); + if (action == nullptr || !action->isEnabled()) + SelectNextTownNpcKeyPressed(); + } + return; + case SDLK_LEFT: + if (CharFlag) { + CharacterScreenMoveSelection(-1); + } else if (SpellSelectFlag) { + HotSpellMove({ AxisDirectionX_LEFT, AxisDirectionY_NONE }); + SpeakSelectedSpeedbookSpell(); + } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { + if (SpellbookTab > 0) { + SpellbookTab--; + const std::optional first = GetSpellBookFirstAvailableSpell(SpellbookTab, *MyPlayer); + if (first) { + MyPlayer->_pRSpell = *first; + MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*first)) != 0 ? SpellType::Skill + : (MyPlayer->_pISpells & GetSpellBitmask(*first)) != 0 ? SpellType::Charges + : SpellType::Spell; + UpdateSpellTarget(*first); + RedrawEverything(); + SpeakText(pgettext("spell", GetSpellData(*first).sNameText), /*force=*/true); + } + } + } else if (invflag) { + InventoryMoveFromKeyboard({ AxisDirectionX_LEFT, AxisDirectionY_NONE }); + } else if (AutomapActive && !ChatFlag) { + AutomapLeft(); + } + return; + case SDLK_RIGHT: + if (CharFlag) { + CharacterScreenMoveSelection(+1); + } else if (SpellSelectFlag) { + HotSpellMove({ AxisDirectionX_RIGHT, AxisDirectionY_NONE }); + SpeakSelectedSpeedbookSpell(); + } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { + const int maxTab = gbIsHellfire ? 4 : 3; + if (SpellbookTab < maxTab) { + SpellbookTab++; + const std::optional first = GetSpellBookFirstAvailableSpell(SpellbookTab, *MyPlayer); + if (first) { + MyPlayer->_pRSpell = *first; + MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*first)) != 0 ? SpellType::Skill + : (MyPlayer->_pISpells & GetSpellBitmask(*first)) != 0 ? SpellType::Charges + : SpellType::Spell; + UpdateSpellTarget(*first); + RedrawEverything(); + SpeakText(pgettext("spell", GetSpellData(*first).sNameText), /*force=*/true); + } + } + } else if (invflag) { + InventoryMoveFromKeyboard({ AxisDirectionX_RIGHT, AxisDirectionY_NONE }); + } else if (AutomapActive && !ChatFlag) { + AutomapRight(); + } + return; + default: + break; + } +} + +void HandleMouseButtonDown(Uint8 button, uint16_t modState) +{ + if (IsPlayerInStore() && (button == SDL_BUTTON_X1 +#if !SDL_VERSION_ATLEAST(2, 0, 0) + || button == 8 +#endif + )) { + StoreESC(); + return; + } + + switch (button) { + case SDL_BUTTON_LEFT: + if (sgbMouseDown == CLICK_NONE) { + sgbMouseDown = CLICK_LEFT; + LeftMouseDown(modState); + } + break; + case SDL_BUTTON_RIGHT: + if (sgbMouseDown == CLICK_NONE) { + sgbMouseDown = CLICK_RIGHT; + RightMouseDown((modState & SDL_KMOD_SHIFT) != 0); + } + break; + default: + KeymapperPress(static_cast(button | KeymapperMouseButtonMask)); + break; + } +} + +void HandleMouseButtonUp(Uint8 button, uint16_t modState) +{ + if (sgbMouseDown == CLICK_LEFT && button == SDL_BUTTON_LEFT) { + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_NONE; + LeftMouseUp(modState); + } else if (sgbMouseDown == CLICK_RIGHT && button == SDL_BUTTON_RIGHT) { + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_NONE; + } else { + KeymapperRelease(static_cast(button | KeymapperMouseButtonMask)); + } +} + +[[maybe_unused]] void LogUnhandledEvent(const char *name, int value) +{ + LogVerbose("Unhandled SDL event: {} {}", name, value); +} + +void PrepareForFadeIn() +{ + if (HeadlessMode) return; + BlackPalette(); + + // Render the game to the buffer(s) with a fully black palette. + // Palette fade-in will gradually make it visible. + RedrawEverything(); + while (IsRedrawEverything()) { + DrawAndBlit(); + } +} + +void GameEventHandler(const SDL_Event &event, uint16_t modState) +{ + [[maybe_unused]] const Options &options = GetOptions(); + StaticVector ctrlEvents = ToControllerButtonEvents(event); + for (const ControllerButtonEvent ctrlEvent : ctrlEvents) { + GameAction action; + if (HandleControllerButtonEvent(event, ctrlEvent, action) && action.type == GameActionType_SEND_KEY) { + if ((action.send_key.vk_code & KeymapperMouseButtonMask) != 0) { + const unsigned button = action.send_key.vk_code & ~KeymapperMouseButtonMask; + if (!action.send_key.up) + HandleMouseButtonDown(static_cast(button), modState); + else + HandleMouseButtonUp(static_cast(button), modState); + } else { + if (!action.send_key.up) + PressKey(static_cast(action.send_key.vk_code), modState); + else + ReleaseKey(static_cast(action.send_key.vk_code)); + } + } + } + if (ctrlEvents.size() > 0 && ctrlEvents[0].button != ControllerButton_NONE) { + return; + } + +#ifdef _DEBUG + if (ConsoleHandleEvent(event)) { + return; + } +#endif + + if (IsChatActive() && HandleTalkTextInputEvent(event)) { + return; + } + if (DropGoldFlag && HandleGoldDropTextInputEvent(event)) { + return; + } + if (IsWithdrawGoldOpen && HandleGoldWithdrawTextInputEvent(event)) { + return; + } + + switch (event.type) { + case SDL_EVENT_KEY_DOWN: + PressKey(SDLC_EventKey(event), modState); + return; + case SDL_EVENT_KEY_UP: + ReleaseKey(SDLC_EventKey(event)); + return; + case SDL_EVENT_MOUSE_MOTION: + if (ControlMode == ControlTypes::KeyboardAndMouse && invflag) + InvalidateInventorySlot(); + MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; + gmenu_on_mouse_move(); + return; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + MousePosition = { SDLC_EventButtonIntX(event), SDLC_EventButtonIntY(event) }; + HandleMouseButtonDown(event.button.button, modState); + return; + case SDL_EVENT_MOUSE_BUTTON_UP: + MousePosition = { SDLC_EventButtonIntX(event), SDLC_EventButtonIntY(event) }; + HandleMouseButtonUp(event.button.button, modState); + return; +#if SDL_VERSION_ATLEAST(2, 0, 0) + case SDL_EVENT_MOUSE_WHEEL: + if (SDLC_EventWheelIntY(event) > 0) { // Up + if (IsPlayerInStore()) { + StoreUp(); + } else if (QuestLogIsOpen) { + QuestlogUp(); + } else if (HelpFlag) { + HelpScrollUp(); + } else if (ChatLogFlag) { + ChatLogScrollUp(); + } else if (IsStashOpen) { + Stash.PreviousPage(); + } else if (SDL_GetModState() & SDL_KMOD_CTRL) { + if (AutomapActive) { + AutomapZoomIn(); + } + } else { + KeymapperPress(MouseScrollUpButton); + } + } else if (SDLC_EventWheelIntY(event) < 0) { // down + if (IsPlayerInStore()) { + StoreDown(); + } else if (QuestLogIsOpen) { + QuestlogDown(); + } else if (HelpFlag) { + HelpScrollDown(); + } else if (ChatLogFlag) { + ChatLogScrollDown(); + } else if (IsStashOpen) { + Stash.NextPage(); + } else if (SDL_GetModState() & SDL_KMOD_CTRL) { + if (AutomapActive) { + AutomapZoomOut(); + } + } else { + KeymapperPress(MouseScrollDownButton); + } + } else if (SDLC_EventWheelIntX(event) > 0) { // left + KeymapperPress(MouseScrollLeftButton); + } else if (SDLC_EventWheelIntX(event) < 0) { // right + KeymapperPress(MouseScrollRightButton); + } + break; +#endif + default: + if (IsCustomEvent(event.type)) { + if (gbIsMultiplayer) + pfile_write_hero(); + nthread_ignore_mutex(true); + PaletteFadeOut(8); + sound_stop(); + ShowProgress(GetCustomEvent(event)); + + PrepareForFadeIn(); + LoadPWaterPalette(); + if (gbRunGame) + PaletteFadeIn(8); + nthread_ignore_mutex(false); + gbGameLoopStartup = true; + return; + } + MainWndProc(event); + break; + } +} + +void RunGameLoop(interface_mode uMsg) +{ + demo::NotifyGameLoopStart(); + + nthread_ignore_mutex(true); + StartGame(uMsg); + assert(HeadlessMode || ghMainWnd); + EventHandler previousHandler = SetEventHandler(GameEventHandler); + run_delta_info(); + gbRunGame = true; + gbProcessPlayers = IsDiabloAlive(true); + gbRunGameResult = true; + + PrepareForFadeIn(); + LoadPWaterPalette(); + PaletteFadeIn(8); + InitBackbufferState(); + RedrawEverything(); + gbGameLoopStartup = true; + nthread_ignore_mutex(false); + + discord_manager::StartGame(); + LuaEvent("GameStart"); +#ifdef GPERF_HEAP_FIRST_GAME_ITERATION + unsigned run_game_iteration = 0; +#endif + + while (gbRunGame) { + +#ifdef _DEBUG + if (!gbGameLoopStartup && !DebugCmdsFromCommandLine.empty()) { + InitConsole(); + for (const std::string &cmd : DebugCmdsFromCommandLine) { + RunInConsole(cmd); + } + DebugCmdsFromCommandLine.clear(); + } +#endif + + SDL_Event event; + uint16_t modState; + while (FetchMessage(&event, &modState)) { + if (event.type == SDL_EVENT_QUIT) { + gbRunGameResult = false; + gbRunGame = false; + break; + } + HandleMessage(event, modState); + } + if (!gbRunGame) + break; + + bool drawGame = true; + bool processInput = true; + const bool runGameLoop = demo::IsRunning() ? demo::GetRunGameLoop(drawGame, processInput) : nthread_has_500ms_passed(&drawGame); + if (demo::IsRecording()) + demo::RecordGameLoopResult(runGameLoop); + + discord_manager::UpdateGame(); + + if (!runGameLoop) { + if (processInput) + ProcessInput(); + DvlNet_ProcessNetworkPackets(); + if (!drawGame) + continue; + RedrawViewport(); + DrawAndBlit(); + continue; + } + + ProcessGameMessagePackets(); + if (game_loop(gbGameLoopStartup)) + diablo_color_cyc_logic(); + gbGameLoopStartup = false; + if (drawGame) + DrawAndBlit(); +#ifdef GPERF_HEAP_FIRST_GAME_ITERATION + if (run_game_iteration++ == 0) + HeapProfilerDump("first_game_iteration"); +#endif + } + + demo::NotifyGameLoopEnd(); + + if (gbIsMultiplayer) { + pfile_write_hero(/*writeGameData=*/false); + sfile_write_stash(); + } + + PaletteFadeOut(8); + NewCursor(CURSOR_NONE); + ClearScreenBuffer(); + RedrawEverything(); + scrollrt_draw_game_screen(); + previousHandler = SetEventHandler(previousHandler); + assert(HeadlessMode || previousHandler == GameEventHandler); + FreeGame(); + + if (cineflag) { + cineflag = false; + DoEnding(); + } +} + +void PrintWithRightPadding(std::string_view str, size_t width) +{ + printInConsole(str); + if (str.size() >= width) + return; + printInConsole(std::string(width - str.size(), ' ')); +} + +void PrintHelpOption(std::string_view flags, std::string_view description) +{ + printInConsole(" "); + PrintWithRightPadding(flags, 20); + printInConsole(" "); + PrintWithRightPadding(description, 30); + printNewlineInConsole(); +} + +#if SDL_VERSION_ATLEAST(2, 0, 0) +FILE *SdlLogFile = nullptr; + +extern "C" void SdlLogToFile(void *userdata, int category, SDL_LogPriority priority, const char *message) +{ + FILE *file = reinterpret_cast(userdata); + static const char *const LogPriorityPrefixes[SDL_LOG_PRIORITY_COUNT] = { + "", + "VERBOSE", + "DEBUG", + "INFO", + "WARN", + "ERROR", + "CRITICAL" + }; + std::fprintf(file, "%s: %s\n", LogPriorityPrefixes[priority], message); + std::fflush(file); +} +#endif + +[[noreturn]] void PrintHelpAndExit() +{ + printInConsole((/* TRANSLATORS: Commandline Option */ "Options:")); + printNewlineInConsole(); + PrintHelpOption("-h, --help", _(/* TRANSLATORS: Commandline Option */ "Print this message and exit")); + PrintHelpOption("--version", _(/* TRANSLATORS: Commandline Option */ "Print the version and exit")); + PrintHelpOption("--data-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the folder of diabdat.mpq")); + PrintHelpOption("--save-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the folder of save files")); + PrintHelpOption("--config-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the location of diablo.ini")); + PrintHelpOption("--lang", _(/* TRANSLATORS: Commandline Option */ "Specify the language code (e.g. en or pt_BR)")); + PrintHelpOption("-n", _(/* TRANSLATORS: Commandline Option */ "Skip startup videos")); + PrintHelpOption("-f", _(/* TRANSLATORS: Commandline Option */ "Display frames per second")); + PrintHelpOption("--verbose", _(/* TRANSLATORS: Commandline Option */ "Enable verbose logging")); +#if SDL_VERSION_ATLEAST(2, 0, 0) + PrintHelpOption("--log-to-file ", _(/* TRANSLATORS: Commandline Option */ "Log to a file instead of stderr")); +#endif +#ifndef DISABLE_DEMOMODE + PrintHelpOption("--record <#>", _(/* TRANSLATORS: Commandline Option */ "Record a demo file")); + PrintHelpOption("--demo <#>", _(/* TRANSLATORS: Commandline Option */ "Play a demo file")); + PrintHelpOption("--timedemo", _(/* TRANSLATORS: Commandline Option */ "Disable all frame limiting during demo playback")); +#endif + printNewlineInConsole(); + printInConsole(_(/* TRANSLATORS: Commandline Option */ "Game selection:")); + printNewlineInConsole(); + PrintHelpOption("--spawn", _(/* TRANSLATORS: Commandline Option */ "Force Shareware mode")); + PrintHelpOption("--diablo", _(/* TRANSLATORS: Commandline Option */ "Force Diablo mode")); + PrintHelpOption("--hellfire", _(/* TRANSLATORS: Commandline Option */ "Force Hellfire mode")); + printInConsole(_(/* TRANSLATORS: Commandline Option */ "Hellfire options:")); + printNewlineInConsole(); +#ifdef _DEBUG + printNewlineInConsole(); + printInConsole("Debug options:"); + printNewlineInConsole(); + PrintHelpOption("-i", "Ignore network timeout"); + PrintHelpOption("+", "Pass commands to the engine"); +#endif + printNewlineInConsole(); + printInConsole(_("Report bugs at https://github.com/diasurgical/devilutionX/")); + printNewlineInConsole(); + diablo_quit(0); +} + +void PrintFlagMessage(std::string_view flag, std::string_view message) +{ + printInConsole(flag); + printInConsole(message); + printNewlineInConsole(); +} + +void PrintFlagRequiresArgument(std::string_view flag) +{ + PrintFlagMessage(flag, " requires an argument"); +} + +void DiabloParseFlags(int argc, char **argv) +{ +#ifdef _DEBUG + int argumentIndexOfLastCommandPart = -1; + std::string currentCommand; +#endif +#ifndef DISABLE_DEMOMODE + bool timedemo = false; + int demoNumber = -1; + int recordNumber = -1; + bool createDemoReference = false; +#endif + for (int i = 1; i < argc; i++) { + const std::string_view arg = argv[i]; + if (arg == "-h" || arg == "--help") { + PrintHelpAndExit(); + } else if (arg == "--version") { + printInConsole(PROJECT_NAME); + printInConsole(" v"); + printInConsole(PROJECT_VERSION); + printNewlineInConsole(); + diablo_quit(0); + } else if (arg == "--data-dir") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--data-dir"); + diablo_quit(64); + } + paths::SetBasePath(argv[++i]); + } else if (arg == "--save-dir") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--save-dir"); + diablo_quit(64); + } + paths::SetPrefPath(argv[++i]); + } else if (arg == "--config-dir") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--config-dir"); + diablo_quit(64); + } + paths::SetConfigPath(argv[++i]); + } else if (arg == "--lang") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--lang"); + diablo_quit(64); + } + forceLocale = argv[++i]; +#ifndef DISABLE_DEMOMODE + } else if (arg == "--demo") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--demo"); + diablo_quit(64); + } + ParseIntResult parsedParam = ParseInt(argv[++i]); + if (!parsedParam.has_value()) { + PrintFlagMessage("--demo", " must be a number"); + diablo_quit(64); + } + demoNumber = parsedParam.value(); + gbShowIntro = false; + } else if (arg == "--timedemo") { + timedemo = true; + } else if (arg == "--record") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--record"); + diablo_quit(64); + } + ParseIntResult parsedParam = ParseInt(argv[++i]); + if (!parsedParam.has_value()) { + PrintFlagMessage("--record", " must be a number"); + diablo_quit(64); + } + recordNumber = parsedParam.value(); + } else if (arg == "--create-reference") { + createDemoReference = true; +#else + } else if (arg == "--demo" || arg == "--timedemo" || arg == "--record" || arg == "--create-reference") { + printInConsole("Binary compiled without demo mode support."); + printNewlineInConsole(); + diablo_quit(1); +#endif + } else if (arg == "-n") { + gbShowIntro = false; + } else if (arg == "-f") { + EnableFrameCount(); + } else if (arg == "--spawn") { + forceSpawn = true; + } else if (arg == "--diablo") { + forceDiablo = true; + } else if (arg == "--hellfire") { + forceHellfire = true; + } else if (arg == "--vanilla") { + gbVanilla = true; + } else if (arg == "--verbose") { + SDL_SetLogPriorities(SDL_LOG_PRIORITY_VERBOSE); +#if SDL_VERSION_ATLEAST(2, 0, 0) + } else if (arg == "--log-to-file") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--log-to-file"); + diablo_quit(64); + } + SdlLogFile = OpenFile(argv[++i], "wb"); + if (SdlLogFile == nullptr) { + printInConsole("Failed to open log file for writing"); + diablo_quit(64); + } + SDL_SetLogOutputFunction(&SdlLogToFile, /*userdata=*/SdlLogFile); +#endif +#ifdef _DEBUG + } else if (arg == "-i") { + DebugDisableNetworkTimeout = true; + } else if (arg[0] == '+') { + if (!currentCommand.empty()) + DebugCmdsFromCommandLine.push_back(currentCommand); + argumentIndexOfLastCommandPart = i; + currentCommand = arg.substr(1); + } else if (arg[0] != '-' && (argumentIndexOfLastCommandPart + 1) == i) { + currentCommand.append(" "); + currentCommand.append(arg); + argumentIndexOfLastCommandPart = i; +#endif + } else { + printInConsole("unrecognized option '"); + printInConsole(argv[i]); + printInConsole("'"); + printNewlineInConsole(); + PrintHelpAndExit(); + } + } + +#ifdef _DEBUG + if (!currentCommand.empty()) + DebugCmdsFromCommandLine.push_back(currentCommand); +#endif + +#ifndef DISABLE_DEMOMODE + if (demoNumber != -1) + demo::InitPlayBack(demoNumber, timedemo); + if (recordNumber != -1) + demo::InitRecording(recordNumber, createDemoReference); +#endif +} + +void DiabloInitScreen() +{ + MousePosition = { gnScreenWidth / 2, gnScreenHeight / 2 }; + if (ControlMode == ControlTypes::KeyboardAndMouse) + SetCursorPos(MousePosition); + + ClrDiabloMsg(); +} + +void SetApplicationVersions() +{ + *BufCopy(gszProductName, PROJECT_NAME, " v", PROJECT_VERSION) = '\0'; + *BufCopy(gszVersionNumber, "version ", PROJECT_VERSION) = '\0'; +} + +void CheckArchivesUpToDate() +{ + const bool devilutionxMpqOutOfDate = IsDevilutionXMpqOutOfDate(); + const bool fontsMpqOutOfDate = AreExtraFontsOutOfDate(); + + if (devilutionxMpqOutOfDate && fontsMpqOutOfDate) { + app_fatal(_("Please update devilutionx.mpq and fonts.mpq to the latest version")); + } else if (devilutionxMpqOutOfDate) { + app_fatal(_("Failed to load UI resources.\n" + "\n" + "Make sure devilutionx.mpq is in the game folder and that it is up to date.")); + } else if (fontsMpqOutOfDate) { + app_fatal(_("Please update fonts.mpq to the latest version")); + } +} + +void ApplicationInit() +{ + if (*GetOptions().Graphics.showFPS) + EnableFrameCount(); + + init_create_window(); + was_window_init = true; + + InitializeScreenReader(); + LanguageInitialize(); + + SetApplicationVersions(); + + ReadOnlyTest(); +} + +void DiabloInit() +{ + if (forceSpawn || *GetOptions().GameMode.shareware) + gbIsSpawn = true; + + bool wasHellfireDiscovered = false; + if (!forceDiablo && !forceHellfire) + wasHellfireDiscovered = (HaveHellfire() && *GetOptions().GameMode.gameMode == StartUpGameMode::Ask); + bool enableHellfire = forceHellfire || wasHellfireDiscovered; + if (!forceDiablo && *GetOptions().GameMode.gameMode == StartUpGameMode::Hellfire) { // Migrate legacy options + GetOptions().GameMode.gameMode.SetValue(StartUpGameMode::Diablo); + enableHellfire = true; + } + if (forceDiablo || enableHellfire) { + GetOptions().Mods.SetHellfireEnabled(enableHellfire); + } + + gbIsHellfireSaveGame = gbIsHellfire; + + for (size_t i = 0; i < QuickMessages.size(); i++) { + auto &messages = GetOptions().Chat.szHotKeyMsgs[i]; + if (messages.empty()) { + messages.emplace_back(_(QuickMessages[i].message)); + } + } + +#ifndef USE_SDL1 + InitializeVirtualGamepad(); +#endif + + UiInitialize(); + was_ui_init = true; + + if (wasHellfireDiscovered) { + UiSelStartUpGameOption(); + if (!gbIsHellfire) { + // Reinitialize the UI Elements because we changed the game + UnloadUiGFX(); + UiInitialize(); + if (IsHardwareCursor()) + SetHardwareCursor(CursorInfo::UnknownCursor()); + } + } + + DiabloInitScreen(); + + snd_init(); + + ui_sound_init(); + + // Item graphics are loaded early, they already get touched during hero selection. + InitItemGFX(); + + // Always available. + LoadSmallSelectionSpinner(); + + CheckArchivesUpToDate(); +} + +void DiabloSplash() +{ + if (!gbShowIntro) + return; + + if (*GetOptions().StartUp.splash == StartUpSplash::LogoAndTitleDialog) + play_movie("gendata\\logo.smk", true); + + auto &intro = gbIsHellfire ? GetOptions().StartUp.hellfireIntro : GetOptions().StartUp.diabloIntro; + + if (*intro != StartUpIntro::Off) { + if (gbIsHellfire) + play_movie("gendata\\Hellfire.smk", true); + else + play_movie("gendata\\diablo1.smk", true); + if (*intro == StartUpIntro::Once) { + intro.SetValue(StartUpIntro::Off); + if (!demo::IsRunning()) SaveOptions(); + } + } + + if (IsAnyOf(*GetOptions().StartUp.splash, StartUpSplash::TitleDialog, StartUpSplash::LogoAndTitleDialog)) + UiTitleDialog(); +} + +void DiabloDeinit() +{ + FreeItemGFX(); + + LuaShutdown(); + ShutDownScreenReader(); + + if (gbSndInited) + effects_cleanup_sfx(); + snd_deinit(); + if (was_ui_init) + UiDestroy(); + if (was_archives_init) + init_cleanup(); + if (was_window_init) + dx_cleanup(); // Cleanup SDL surfaces stuff, so we have to do it before SDL_Quit(). + UnloadFonts(); + if (SDL_WasInit((~0U) & ~SDL_INIT_HAPTIC) != 0) + SDL_Quit(); +} + +tl::expected LoadLvlGFX() +{ + assert(pDungeonCels == nullptr); + constexpr int SpecialCelWidth = 64; + + const auto loadAll = [](const char *cel, const char *til, const char *special) -> tl::expected { + ASSIGN_OR_RETURN(pDungeonCels, LoadFileInMemWithStatus(cel)); + ASSIGN_OR_RETURN(pMegaTiles, LoadFileInMemWithStatus(til)); + ASSIGN_OR_RETURN(pSpecialCels, LoadCelWithStatus(special, SpecialCelWidth)); + return {}; + }; + + switch (leveltype) { + case DTYPE_TOWN: { + auto cel = LoadFileInMemWithStatus("nlevels\\towndata\\town.cel"); + if (!cel.has_value()) { + ASSIGN_OR_RETURN(pDungeonCels, LoadFileInMemWithStatus("levels\\towndata\\town.cel")); + } else { + pDungeonCels = std::move(*cel); + } + auto til = LoadFileInMemWithStatus("nlevels\\towndata\\town.til"); + if (!til.has_value()) { + ASSIGN_OR_RETURN(pMegaTiles, LoadFileInMemWithStatus("levels\\towndata\\town.til")); + } else { + pMegaTiles = std::move(*til); + } + ASSIGN_OR_RETURN(pSpecialCels, LoadCelWithStatus("levels\\towndata\\towns", SpecialCelWidth)); + return {}; + } + case DTYPE_CATHEDRAL: + return loadAll( + "levels\\l1data\\l1.cel", + "levels\\l1data\\l1.til", + "levels\\l1data\\l1s"); + case DTYPE_CATACOMBS: + return loadAll( + "levels\\l2data\\l2.cel", + "levels\\l2data\\l2.til", + "levels\\l2data\\l2s"); + case DTYPE_CAVES: + return loadAll( + "levels\\l3data\\l3.cel", + "levels\\l3data\\l3.til", + "levels\\l1data\\l1s"); + case DTYPE_HELL: + return loadAll( + "levels\\l4data\\l4.cel", + "levels\\l4data\\l4.til", + "levels\\l2data\\l2s"); + case DTYPE_NEST: + return loadAll( + "nlevels\\l6data\\l6.cel", + "nlevels\\l6data\\l6.til", + "levels\\l1data\\l1s"); + case DTYPE_CRYPT: + return loadAll( + "nlevels\\l5data\\l5.cel", + "nlevels\\l5data\\l5.til", + "nlevels\\l5data\\l5s"); + default: + return tl::make_unexpected("LoadLvlGFX"); + } +} + +tl::expected LoadAllGFX() +{ + IncProgress(); +#if !defined(USE_SDL1) && !defined(__vita__) + InitVirtualGamepadGFX(); +#endif + IncProgress(); + RETURN_IF_ERROR(InitObjectGFX()); + IncProgress(); + RETURN_IF_ERROR(InitMissileGFX()); + IncProgress(); + return {}; +} + +/** + * @param entry Where is the player entering from + */ +void CreateLevel(lvl_entry entry) +{ + CreateDungeon(DungeonSeeds[currlevel], entry); + + switch (leveltype) { + case DTYPE_TOWN: + InitTownTriggers(); + break; + case DTYPE_CATHEDRAL: + InitL1Triggers(); + break; + case DTYPE_CATACOMBS: + InitL2Triggers(); + break; + case DTYPE_CAVES: + InitL3Triggers(); + break; + case DTYPE_HELL: + InitL4Triggers(); + break; + case DTYPE_NEST: + InitHiveTriggers(); + break; + case DTYPE_CRYPT: + InitCryptTriggers(); + break; + default: + app_fatal("CreateLevel"); + } + + if (leveltype != DTYPE_TOWN) { + Freeupstairs(); + } + LoadRndLvlPal(leveltype); +} + +void UnstuckChargers() +{ + if (gbIsMultiplayer) { + for (Player &player : Players) { + if (!player.plractive) + continue; + if (player._pLvlChanging) + continue; + if (!player.isOnActiveLevel()) + continue; + if (&player == MyPlayer) + continue; + return; + } + } + for (size_t i = 0; i < ActiveMonsterCount; i++) { + Monster &monster = Monsters[ActiveMonsters[i]]; + if (monster.mode == MonsterMode::Charge) + monster.mode = MonsterMode::Stand; + } +} + +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); + } + + if (monster.lightId != NO_LIGHT) { + if (monster.lightId == MyPlayer->lightId) { // Fix old saves where some monsters had 0 instead of NO_LIGHT + monster.lightId = NO_LIGHT; + continue; + } + + const Light &light = Lights[monster.lightId]; + if (monster.position.tile != light.position.tile) { + ChangeLightXY(monster.lightId, monster.position.tile); + } + } + } +} + + +void GameLogic() +{ + if (!ProcessInput()) { + return; + } + if (gbProcessPlayers) { + gGameLogicStep = GameLogicStep::ProcessPlayers; + ProcessPlayers(); + UpdateAutoWalkTownNpc(); + UpdateAutoWalkTracker(); + UpdateLowDurabilityWarnings(); + } + if (leveltype != DTYPE_TOWN) { + gGameLogicStep = GameLogicStep::ProcessMonsters; +#ifdef _DEBUG + if (!DebugInvisible) +#endif + ProcessMonsters(); + gGameLogicStep = GameLogicStep::ProcessObjects; + ProcessObjects(); + gGameLogicStep = GameLogicStep::ProcessMissiles; + ProcessMissiles(); + gGameLogicStep = GameLogicStep::ProcessItems; + ProcessItems(); + ProcessLightList(); + ProcessVisionList(); + UpdateBossHealthAnnouncements(); + UpdateProximityAudioCues(); + UpdateAttackableMonsterAnnouncements(); + UpdateInteractableDoorAnnouncements(); + } else { + gGameLogicStep = GameLogicStep::ProcessTowners; + ProcessTowners(); + gGameLogicStep = GameLogicStep::ProcessItemsTown; + ProcessItems(); + gGameLogicStep = GameLogicStep::ProcessMissilesTown; + ProcessMissiles(); + UpdateProximityAudioCues(); + } + + UpdatePlayerLowHpWarningSound(); + + gGameLogicStep = GameLogicStep::None; + +#ifdef _DEBUG + if (DebugScrollViewEnabled && (SDL_GetModState() & SDL_KMOD_SHIFT) != 0) { + ScrollView(); + } +#endif + + sound_update(); + CheckTriggers(); + CheckQuests(); + RedrawViewport(); + pfile_update(false); + + plrctrls_after_game_logic(); +} + +void TimeoutCursor(bool bTimeout) +{ + if (bTimeout) { + if (sgnTimeoutCurs == CURSOR_NONE && sgbMouseDown == CLICK_NONE) { + sgnTimeoutCurs = pcurs; + multi_net_ping(); + InfoString = StringOrView {}; + AddInfoBoxString(_("-- Network timeout --")); + AddInfoBoxString(_("-- Waiting for players --")); + for (uint8_t i = 0; i < Players.size(); i++) { + bool isConnected = (player_state[i] & PS_CONNECTED) != 0; + bool isActive = (player_state[i] & PS_ACTIVE) != 0; + if (!(isConnected && !isActive)) continue; + + DvlNetLatencies latencies = DvlNet_GetLatencies(i); + + std::string ping = fmt::format( + fmt::runtime(_(/* TRANSLATORS: {:s} means: Character Name */ "Player {:s} is timing out!")), + Players[i].name()); + + StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Echo latency: {:d} ms")), latencies.echoLatency)); + + if (latencies.providerLatency) { + if (latencies.isRelayed && *latencies.isRelayed) { + StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Provider latency: {:d} ms (Relayed)")), *latencies.providerLatency)); + } else { + StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Provider latency: {:d} ms")), *latencies.providerLatency)); + } + } + EventPlrMsg(ping); + } + NewCursor(CURSOR_HOURGLASS); + RedrawEverything(); + } + scrollrt_draw_game_screen(); + } else if (sgnTimeoutCurs != CURSOR_NONE) { + // Timeout is gone, we should restore the previous cursor. + // But the timeout cursor could already be changed by the now processed messages (for example item cursor from CMD_GETITEM). + // Changing the item cursor back to the previous (hand) cursor could result in deleted items, because this resets Player.HoldItem (see NewCursor). + if (pcurs == CURSOR_HOURGLASS) + NewCursor(sgnTimeoutCurs); + sgnTimeoutCurs = CURSOR_NONE; + InfoString = StringOrView {}; + RedrawEverything(); + } +} + +void HelpKeyPressed() +{ + if (HelpFlag) { + HelpFlag = false; + } else if (IsPlayerInStore()) { + InfoString = StringOrView {}; + AddInfoBoxString(_("No help available")); /// BUGFIX: message isn't displayed + AddInfoBoxString(_("while in stores")); + LastPlayerAction = PlayerActionType::None; + } else { + CloseInventory(); + CloseCharPanel(); + SpellbookFlag = false; + SpellSelectFlag = false; + if (qtextflag && leveltype == DTYPE_TOWN) { + qtextflag = false; + stream_stop(); + } + QuestLogIsOpen = false; + CancelCurrentDiabloMsg(); + gamemenu_off(); + DisplayHelp(); + doom_close(); + } +} + +void OptionLanguageCodeChanged() +{ + UnloadFonts(); + LanguageInitialize(); + LoadLanguageArchive(); + effects_cleanup_sfx(false); + if (gbRunGame) + sound_init(); + else + ui_sound_init(); +} + +const auto OptionChangeHandlerLanguage = (GetOptions().Language.code.SetValueChangedCallback(OptionLanguageCodeChanged), true); + +void CancelAutoWalkInternal() +{ + ResetAutoWalkTracker(); + ResetAutoWalkTownNpc(); +} + +} // namespace + +void CancelAutoWalk() +{ + CancelAutoWalkInternal(); + if (MyPlayer != nullptr) + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, MyPlayer->position.future); +} + +void InitKeymapActions() +{ + Options &options = GetOptions(); + for (uint32_t i = 0; i < 8; ++i) { + options.Keymapper.AddAction( + "BeltItem{}", + N_("Belt item {}"), + N_("Use Belt item."), + '1' + i, + [i] { + const Player &myPlayer = *MyPlayer; + if (!myPlayer.SpdList[i].isEmpty() && myPlayer.SpdList[i]._itype != ItemType::Gold) { + UseInvItem(INVITEM_BELT_FIRST + i); + } + }, + nullptr, + CanPlayerTakeAction, + i + 1); + } + for (uint32_t i = 0; i < NumHotkeys; ++i) { + options.Keymapper.AddAction( + "QuickSpell{}", + N_("Quick spell {}"), + N_("Hotkey for skill or spell."), + i < 4 ? static_cast(SDLK_F5) + i : static_cast(SDLK_UNKNOWN), + [i]() { + if (SpellSelectFlag) { + SetSpeedSpell(i); + return; + } + if (!*GetOptions().Gameplay.quickCast) + ToggleSpell(i); + else + QuickCast(i); + }, + nullptr, + CanPlayerTakeAction, + i + 1); + } + options.Keymapper.AddAction( + "QuickSpellPrevious", + N_("Previous quick spell"), + N_("Selects the previous quick spell (cycles)."), + MouseScrollUpButton, + [] { CycleSpellHotkeys(false); }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "QuickSpellNext", + N_("Next quick spell"), + N_("Selects the next quick spell (cycles)."), + MouseScrollDownButton, + [] { CycleSpellHotkeys(true); }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "UseHealthPotion", + N_("Use health potion"), + N_("Use health potions from belt."), + SDLK_UNKNOWN, + [] { UseBeltItem(BeltItemType::Healing); }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "UseManaPotion", + N_("Use mana potion"), + N_("Use mana potions from belt."), + SDLK_UNKNOWN, + [] { UseBeltItem(BeltItemType::Mana); }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "DisplaySpells", + N_("Speedbook"), + N_("Open Speedbook."), + 'S', + DisplaySpellsKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "QuickSave", + N_("Quick save"), + N_("Saves the game."), + SDLK_F2, + [] { gamemenu_save_game(false); }, + nullptr, + [&]() { return !gbIsMultiplayer && CanPlayerTakeAction(); }); + options.Keymapper.AddAction( + "QuickLoad", + N_("Quick load"), + N_("Loads the game."), + SDLK_F3, + [] { gamemenu_load_game(false); }, + nullptr, + [&]() { return !gbIsMultiplayer && gbValidSaveFile && !IsPlayerInStore() && IsGameRunning(); }); +#ifndef NOEXIT + options.Keymapper.AddAction( + "QuitGame", + N_("Quit game"), + N_("Closes the game."), + SDLK_UNKNOWN, + [] { gamemenu_quit_game(false); }); +#endif + options.Keymapper.AddAction( + "StopHero", + N_("Stop hero"), + N_("Stops walking and cancel pending actions."), + SDLK_UNKNOWN, + [] { MyPlayer->Stop(); }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "ItemHighlighting", + N_("Item highlighting"), + N_("Show/hide items on ground."), + SDLK_LALT, + [] { HighlightKeyPressed(true); }, + [] { HighlightKeyPressed(false); }); + options.Keymapper.AddAction( + "ToggleItemHighlighting", + N_("Toggle item highlighting"), + N_("Permanent show/hide items on ground."), + SDLK_RCTRL, + nullptr, + [] { ToggleItemLabelHighlight(); }); + options.Keymapper.AddAction( + "ToggleAutomap", + N_("Toggle automap"), + N_("Toggles if automap is displayed."), + SDLK_TAB, + DoAutoMap, + nullptr, + IsGameRunning); + options.Keymapper.AddAction( + "CycleAutomapType", + N_("Cycle map type"), + N_("Opaque -> Transparent -> Minimap -> None"), + SDLK_M, + CycleAutomapType, + nullptr, + IsGameRunning); + + options.Keymapper.AddAction( + "ListTownNpcs", + N_("List town NPCs"), + N_("Speaks a list of town NPCs."), + SDLK_F4, + ListTownNpcsKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "PreviousTownNpc", + N_("Previous town NPC"), + N_("Select previous town NPC (speaks)."), + SDLK_PAGEUP, + SelectPreviousTownNpcKeyPressed, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "NextTownNpc", + N_("Next town NPC"), + N_("Select next town NPC (speaks)."), + SDLK_PAGEDOWN, + SelectNextTownNpcKeyPressed, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "SpeakSelectedTownNpc", + N_("Speak selected town NPC"), + N_("Speaks the currently selected town NPC."), + SDLK_END, + SpeakSelectedTownNpc, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "GoToSelectedTownNpc", + N_("Go to selected town NPC"), + N_("Walks to the selected town NPC."), + SDLK_HOME, + GoToSelectedTownNpcKeyPressed, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "SpeakNearestUnexploredSpace", + N_("Nearest unexplored space"), + N_("Speaks the nearest unexplored space."), + 'H', + SpeakNearestUnexploredTileKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SpeakNearestExit", + N_("Nearest exit"), + N_("Speaks the nearest exit. Hold Shift for quest entrances (or to leave a quest level). In town, press Ctrl+E to cycle dungeon entrances."), + 'E', + SpeakNearestExitKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SpeakNearestStairsDown", + N_("Nearest stairs down"), + N_("Speaks directions to the nearest stairs down."), + '.', + SpeakNearestStairsDownKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && leveltype != DTYPE_TOWN; }); + options.Keymapper.AddAction( + "SpeakNearestStairsUp", + N_("Nearest stairs up"), + N_("Speaks directions to the nearest stairs up."), + ',', + SpeakNearestStairsUpKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && leveltype != DTYPE_TOWN; }); + options.Keymapper.AddAction( + "CycleTrackerTarget", + N_("Cycle tracker target"), + N_("Cycles what the tracker looks for (items, chests, doors, shrines, objects, breakables, monsters, dead bodies). Hold Shift to cycle backwards."), + 'T', + CycleTrackerTargetKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "NavigateToTrackerTarget", + N_("Tracker directions"), + N_("Speaks directions to a tracked target of the selected tracker category. Shift+N: cycle targets (speaks name only). Ctrl+N: clear target."), + 'N', + NavigateToTrackerTargetKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "AutoWalkToTrackerTarget", + N_("Walk to tracker target"), + N_("Automatically walks to the currently selected tracker target. Press again to cancel."), + 'M', + AutoWalkToTrackerTargetKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "KeyboardWalkNorth", + N_("Walk north"), + N_("Walk north (one tile)."), + SDLK_UP, + KeyboardWalkNorthKeyPressed); + options.Keymapper.AddAction( + "KeyboardWalkSouth", + N_("Walk south"), + N_("Walk south (one tile)."), + SDLK_DOWN, + KeyboardWalkSouthKeyPressed); + options.Keymapper.AddAction( + "KeyboardWalkEast", + N_("Walk east"), + N_("Walk east (one tile)."), + SDLK_RIGHT, + KeyboardWalkEastKeyPressed); + options.Keymapper.AddAction( + "KeyboardWalkWest", + N_("Walk west"), + N_("Walk west (one tile)."), + SDLK_LEFT, + KeyboardWalkWestKeyPressed); + options.Keymapper.AddAction( + "PrimaryAction", + N_("Primary action"), + N_("Attack monsters, talk to towners, lift and place inventory items."), + 'A', + PerformPrimaryActionAutoTarget, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "SecondaryAction", + N_("Secondary action"), + N_("Open chests, interact with doors, pick up items."), + 'D', + PerformSecondaryActionAutoTarget, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "SpellAction", + N_("Spell action"), + N_("Cast the active spell."), + 'W', + PerformSpellActionAutoTarget, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + + options.Keymapper.AddAction( + "Inventory", + N_("Inventory"), + N_("Open Inventory screen."), + 'I', + InventoryKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "Character", + N_("Character"), + N_("Open Character screen."), + 'C', + CharacterSheetKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "Party", + N_("Party"), + N_("Open side Party panel."), + 'Y', + PartyPanelSideToggleKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "QuestLog", + N_("Quest log"), + N_("Open Quest log."), + 'Q', + QuestLogKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SpellBook", + N_("Spellbook"), + N_("Open Spellbook."), + 'B', + SpellBookKeyPressed, + nullptr, + CanPlayerTakeAction); + for (uint32_t i = 0; i < QuickMessages.size(); ++i) { + options.Keymapper.AddAction( + "QuickMessage{}", + N_("Quick Message {}"), + N_("Use Quick Message in chat."), + (i < 4) ? static_cast(SDLK_F9) + i : static_cast(SDLK_UNKNOWN), + [i]() { DiabloHotkeyMsg(i); }, + nullptr, + nullptr, + i + 1); + } + options.Keymapper.AddAction( + "HideInfoScreens", + N_("Hide Info Screens"), + N_("Hide all info screens."), + SDLK_SPACE, + [] { + if (CanAutomapBeToggledOff()) + AutomapActive = false; + + ClosePanels(); + HelpFlag = false; + ChatLogFlag = false; + SpellSelectFlag = false; + if (qtextflag && leveltype == DTYPE_TOWN) { + qtextflag = false; + stream_stop(); + } + + CancelCurrentDiabloMsg(); + gamemenu_off(); + doom_close(); + }, + nullptr, + IsGameRunning); + options.Keymapper.AddAction( + "Zoom", + N_("Zoom"), + N_("Zoom Game Screen."), + SDLK_UNKNOWN, + [] { + GetOptions().Graphics.zoom.SetValue(!*GetOptions().Graphics.zoom); + CalcViewportGeometry(); + }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SpeakPlayerHealthPercentage", + N_("Health percentage"), + N_("Speaks the player's health as a percentage."), + 'Z', + SpeakPlayerHealthPercentageKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SpeakExperienceToNextLevel", + N_("Experience to level"), + N_("Speaks how much experience remains to reach the next level."), + 'X', + SpeakExperienceToNextLevelKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "PauseGame", + N_("Pause Game"), + N_("Pauses the game."), + SDLK_UNKNOWN, + diablo_pause_game); + options.Keymapper.AddAction( + "PauseGameAlternate", + N_("Pause Game (Alternate)"), + N_("Pauses the game."), + SDLK_PAUSE, + diablo_pause_game); + options.Keymapper.AddAction( + "DecreaseBrightness", + N_("Decrease Brightness"), + N_("Reduce screen brightness."), + 'F', + DecreaseBrightness, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "IncreaseBrightness", + N_("Increase Brightness"), + N_("Increase screen brightness."), + 'G', + IncreaseBrightness, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "Help", + N_("Help"), + N_("Open Help Screen."), + SDLK_F1, + HelpKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "Screenshot", + N_("Screenshot"), + N_("Takes a screenshot."), + SDLK_PRINTSCREEN, + nullptr, + CaptureScreen); + options.Keymapper.AddAction( + "GameInfo", + N_("Game info"), + N_("Displays game infos."), + 'V', + [] { + EventPlrMsg(fmt::format( + fmt::runtime(_(/* TRANSLATORS: {:s} means: Project Name, Game Version. */ "{:s} {:s}")), + PROJECT_NAME, + PROJECT_VERSION), + UiFlags::ColorWhite); + }, + nullptr, + CanPlayerTakeAction); + 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); + }); +#ifdef _DEBUG + options.Keymapper.AddAction( + "OpenConsole", + N_("Console"), + N_("Opens Lua console."), + SDLK_GRAVE, + OpenConsole); + options.Keymapper.AddAction( + "DebugToggle", + "Debug toggle", + "Programming is like magic.", + 'X', + [] { + DebugToggle = !DebugToggle; + }); +#endif + options.Keymapper.AddAction( + "SpeakNearestTownPortal", + N_("Nearest town portal"), + N_("Speaks directions to the nearest open town portal in town."), + 'P', + SpeakNearestTownPortalInTownKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && leveltype == DTYPE_TOWN; }); + options.Keymapper.CommitActions(); +} + +void InitPadmapActions() +{ + Options &options = GetOptions(); + for (int i = 0; i < 8; ++i) { + options.Padmapper.AddAction( + "BeltItem{}", + N_("Belt item {}"), + N_("Use Belt item."), + ControllerButton_NONE, + [i] { + const Player &myPlayer = *MyPlayer; + if (!myPlayer.SpdList[i].isEmpty() && myPlayer.SpdList[i]._itype != ItemType::Gold) { + UseInvItem(INVITEM_BELT_FIRST + i); + } + }, + nullptr, + CanPlayerTakeAction, + i + 1); + } + for (uint32_t i = 0; i < NumHotkeys; ++i) { + options.Padmapper.AddAction( + "QuickSpell{}", + N_("Quick spell {}"), + N_("Hotkey for skill or spell."), + ControllerButton_NONE, + [i]() { + if (SpellSelectFlag) { + SetSpeedSpell(i); + return; + } + if (!*GetOptions().Gameplay.quickCast) + ToggleSpell(i); + else + QuickCast(i); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }, + i + 1); + } + options.Padmapper.AddAction( + "PrimaryAction", + N_("Primary action"), + N_("Attack monsters, talk to towners, lift and place inventory items."), + ControllerButton_BUTTON_B, + [] { + ControllerActionHeld = GameActionType_PRIMARY_ACTION; + LastPlayerAction = PlayerActionType::None; + PerformPrimaryAction(); + }, + [] { + ControllerActionHeld = GameActionType_NONE; + LastPlayerAction = PlayerActionType::None; + }, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "SecondaryAction", + N_("Secondary action"), + N_("Open chests, interact with doors, pick up items."), + ControllerButton_BUTTON_Y, + [] { + ControllerActionHeld = GameActionType_SECONDARY_ACTION; + LastPlayerAction = PlayerActionType::None; + PerformSecondaryAction(); + }, + [] { + ControllerActionHeld = GameActionType_NONE; + LastPlayerAction = PlayerActionType::None; + }, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "SpellAction", + N_("Spell action"), + N_("Cast the active spell."), + ControllerButton_BUTTON_X, + [] { + ControllerActionHeld = GameActionType_CAST_SPELL; + LastPlayerAction = PlayerActionType::None; + PerformSpellAction(); + }, + [] { + ControllerActionHeld = GameActionType_NONE; + LastPlayerAction = PlayerActionType::None; + }, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "CancelAction", + N_("Cancel action"), + N_("Close menus."), + ControllerButton_BUTTON_A, + [] { + if (DoomFlag) { + doom_close(); + return; + } + + GameAction action; + if (SpellSelectFlag) + action = GameAction(GameActionType_TOGGLE_QUICK_SPELL_MENU); + else if (invflag) + action = GameAction(GameActionType_TOGGLE_INVENTORY); + else if (SpellbookFlag) + action = GameAction(GameActionType_TOGGLE_SPELL_BOOK); + else if (QuestLogIsOpen) + action = GameAction(GameActionType_TOGGLE_QUEST_LOG); + else if (CharFlag) + action = GameAction(GameActionType_TOGGLE_CHARACTER_INFO); + ProcessGameAction(action); + }, + nullptr, + [] { return DoomFlag || SpellSelectFlag || invflag || SpellbookFlag || QuestLogIsOpen || CharFlag; }); + options.Padmapper.AddAction( + "MoveUp", + N_("Move up"), + N_("Moves the player character up."), + ControllerButton_BUTTON_DPAD_UP, + [] {}); + options.Padmapper.AddAction( + "MoveDown", + N_("Move down"), + N_("Moves the player character down."), + ControllerButton_BUTTON_DPAD_DOWN, + [] {}); + options.Padmapper.AddAction( + "MoveLeft", + N_("Move left"), + N_("Moves the player character left."), + ControllerButton_BUTTON_DPAD_LEFT, + [] {}); + options.Padmapper.AddAction( + "MoveRight", + N_("Move right"), + N_("Moves the player character right."), + ControllerButton_BUTTON_DPAD_RIGHT, + [] {}); + options.Padmapper.AddAction( + "StandGround", + N_("Stand ground"), + N_("Hold to prevent the player from moving."), + ControllerButton_NONE, + [] {}); + options.Padmapper.AddAction( + "ToggleStandGround", + N_("Toggle stand ground"), + N_("Toggle whether the player moves."), + ControllerButton_NONE, + [] { StandToggle = !StandToggle; }, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "UseHealthPotion", + N_("Use health potion"), + N_("Use health potions from belt."), + ControllerButton_BUTTON_LEFTSHOULDER, + [] { UseBeltItem(BeltItemType::Healing); }, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "UseManaPotion", + N_("Use mana potion"), + N_("Use mana potions from belt."), + ControllerButton_BUTTON_RIGHTSHOULDER, + [] { UseBeltItem(BeltItemType::Mana); }, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "Character", + N_("Character"), + N_("Open Character screen."), + ControllerButton_AXIS_TRIGGERLEFT, + [] { + ProcessGameAction(GameAction { GameActionType_TOGGLE_CHARACTER_INFO }); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "Inventory", + N_("Inventory"), + N_("Open Inventory screen."), + ControllerButton_AXIS_TRIGGERRIGHT, + [] { + ProcessGameAction(GameAction { GameActionType_TOGGLE_INVENTORY }); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "QuestLog", + N_("Quest log"), + N_("Open Quest log."), + { ControllerButton_BUTTON_BACK, ControllerButton_AXIS_TRIGGERLEFT }, + [] { + ProcessGameAction(GameAction { GameActionType_TOGGLE_QUEST_LOG }); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "SpellBook", + N_("Spellbook"), + N_("Open Spellbook."), + { ControllerButton_BUTTON_BACK, ControllerButton_AXIS_TRIGGERRIGHT }, + [] { + ProcessGameAction(GameAction { GameActionType_TOGGLE_SPELL_BOOK }); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "DisplaySpells", + N_("Speedbook"), + N_("Open Speedbook."), + ControllerButton_BUTTON_A, + [] { + ProcessGameAction(GameAction { GameActionType_TOGGLE_QUICK_SPELL_MENU }); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "ToggleAutomap", + N_("Toggle automap"), + N_("Toggles if automap is displayed."), + ControllerButton_BUTTON_LEFTSTICK, + DoAutoMap); + options.Padmapper.AddAction( + "AutomapMoveUp", + N_("Automap Move Up"), + N_("Moves the automap up when active."), + ControllerButton_NONE, + [] {}); + options.Padmapper.AddAction( + "AutomapMoveDown", + N_("Automap Move Down"), + N_("Moves the automap down when active."), + ControllerButton_NONE, + [] {}); + options.Padmapper.AddAction( + "AutomapMoveLeft", + N_("Automap Move Left"), + N_("Moves the automap left when active."), + ControllerButton_NONE, + [] {}); + options.Padmapper.AddAction( + "AutomapMoveRight", + N_("Automap Move Right"), + N_("Moves the automap right when active."), + ControllerButton_NONE, + [] {}); + options.Padmapper.AddAction( + "MouseUp", + N_("Move mouse up"), + N_("Simulates upward mouse movement."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_UP }, + [] {}); + options.Padmapper.AddAction( + "MouseDown", + N_("Move mouse down"), + N_("Simulates downward mouse movement."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_DOWN }, + [] {}); + options.Padmapper.AddAction( + "MouseLeft", + N_("Move mouse left"), + N_("Simulates leftward mouse movement."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_LEFT }, + [] {}); + options.Padmapper.AddAction( + "MouseRight", + N_("Move mouse right"), + N_("Simulates rightward mouse movement."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_RIGHT }, + [] {}); + auto leftMouseDown = [] { + const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); + const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); + sgbMouseDown = CLICK_LEFT; + LeftMouseDown(standGround ? SDL_KMOD_SHIFT : SDL_KMOD_NONE); + }; + auto leftMouseUp = [] { + const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); + const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_NONE; + LeftMouseUp(standGround ? SDL_KMOD_SHIFT : SDL_KMOD_NONE); + }; + options.Padmapper.AddAction( + "LeftMouseClick1", + N_("Left mouse click"), + N_("Simulates the left mouse button."), + ControllerButton_BUTTON_RIGHTSTICK, + leftMouseDown, + leftMouseUp); + options.Padmapper.AddAction( + "LeftMouseClick2", + N_("Left mouse click"), + N_("Simulates the left mouse button."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_LEFTSHOULDER }, + leftMouseDown, + leftMouseUp); + auto rightMouseDown = [] { + const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); + const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_RIGHT; + RightMouseDown(standGround); + }; + auto rightMouseUp = [] { + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_NONE; + }; + options.Padmapper.AddAction( + "RightMouseClick1", + N_("Right mouse click"), + N_("Simulates the right mouse button."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_RIGHTSTICK }, + rightMouseDown, + rightMouseUp); + options.Padmapper.AddAction( + "RightMouseClick2", + N_("Right mouse click"), + N_("Simulates the right mouse button."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_RIGHTSHOULDER }, + rightMouseDown, + rightMouseUp); + options.Padmapper.AddAction( + "PadHotspellMenu", + N_("Gamepad hotspell menu"), + N_("Hold to set or use spell hotkeys."), + ControllerButton_BUTTON_BACK, + [] { PadHotspellMenuActive = true; }, + [] { PadHotspellMenuActive = false; }); + options.Padmapper.AddAction( + "PadMenuNavigator", + N_("Gamepad menu navigator"), + N_("Hold to access gamepad menu navigation."), + ControllerButton_BUTTON_START, + [] { PadMenuNavigatorActive = true; }, + [] { PadMenuNavigatorActive = false; }); + auto toggleGameMenu = [] { + const bool inMenu = gmenu_is_active(); + PressEscKey(); + LastPlayerAction = PlayerActionType::None; + PadHotspellMenuActive = false; + PadMenuNavigatorActive = false; + if (!inMenu) + gamemenu_on(); + }; + options.Padmapper.AddAction( + "ToggleGameMenu1", + N_("Toggle game menu"), + N_("Opens the game menu."), + { + ControllerButton_BUTTON_BACK, + ControllerButton_BUTTON_START, + }, + toggleGameMenu); + options.Padmapper.AddAction( + "ToggleGameMenu2", + N_("Toggle game menu"), + N_("Opens the game menu."), + { + ControllerButton_BUTTON_START, + ControllerButton_BUTTON_BACK, + }, + toggleGameMenu); + options.Padmapper.AddAction( + "QuickSave", + N_("Quick save"), + N_("Saves the game."), + ControllerButton_NONE, + [] { gamemenu_save_game(false); }, + nullptr, + [&]() { return !gbIsMultiplayer && CanPlayerTakeAction(); }); + options.Padmapper.AddAction( + "QuickLoad", + N_("Quick load"), + N_("Loads the game."), + ControllerButton_NONE, + [] { gamemenu_load_game(false); }, + nullptr, + [&]() { return !gbIsMultiplayer && gbValidSaveFile && !IsPlayerInStore() && IsGameRunning(); }); + options.Padmapper.AddAction( + "ItemHighlighting", + N_("Item highlighting"), + N_("Show/hide items on ground."), + ControllerButton_NONE, + [] { HighlightKeyPressed(true); }, + [] { HighlightKeyPressed(false); }); + options.Padmapper.AddAction( + "ToggleItemHighlighting", + N_("Toggle item highlighting"), + N_("Permanent show/hide items on ground."), + ControllerButton_NONE, + nullptr, + [] { ToggleItemLabelHighlight(); }); + options.Padmapper.AddAction( + "HideInfoScreens", + N_("Hide Info Screens"), + N_("Hide all info screens."), + ControllerButton_NONE, + [] { + if (CanAutomapBeToggledOff()) + AutomapActive = false; + + ClosePanels(); + HelpFlag = false; + ChatLogFlag = false; + SpellSelectFlag = false; + if (qtextflag && leveltype == DTYPE_TOWN) { + qtextflag = false; + stream_stop(); + } + + CancelCurrentDiabloMsg(); + gamemenu_off(); + doom_close(); + }, + nullptr, + IsGameRunning); + options.Padmapper.AddAction( + "Zoom", + N_("Zoom"), + N_("Zoom Game Screen."), + ControllerButton_NONE, + [] { + GetOptions().Graphics.zoom.SetValue(!*GetOptions().Graphics.zoom); + CalcViewportGeometry(); + }, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "PauseGame", + N_("Pause Game"), + N_("Pauses the game."), + ControllerButton_NONE, + diablo_pause_game); + options.Padmapper.AddAction( + "DecreaseBrightness", + N_("Decrease Brightness"), + N_("Reduce screen brightness."), + ControllerButton_NONE, + DecreaseBrightness, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "IncreaseBrightness", + N_("Increase Brightness"), + N_("Increase screen brightness."), + ControllerButton_NONE, + IncreaseBrightness, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "Help", + N_("Help"), + N_("Open Help Screen."), + ControllerButton_NONE, + HelpKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "Screenshot", + N_("Screenshot"), + N_("Takes a screenshot."), + ControllerButton_NONE, + nullptr, + CaptureScreen); + options.Padmapper.AddAction( + "GameInfo", + N_("Game info"), + N_("Displays game infos."), + ControllerButton_NONE, + [] { + EventPlrMsg(fmt::format( + fmt::runtime(_(/* TRANSLATORS: {:s} means: Project Name, Game Version. */ "{:s} {:s}")), + PROJECT_NAME, + PROJECT_VERSION), + UiFlags::ColorWhite); + }, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "SortInv", + N_("Sort Inventory"), + N_("Sorts the inventory."), + ControllerButton_NONE, + [] { + ReorganizeInventory(*MyPlayer); + }); + options.Padmapper.AddAction( + "ChatLog", + N_("Chat Log"), + N_("Displays chat log."), + ControllerButton_NONE, + [] { + ToggleChatLog(); + }); + options.Padmapper.CommitActions(); +} + +void SetCursorPos(Point position) +{ + MousePosition = position; + if (ControlDevice != ControlTypes::KeyboardAndMouse) { + return; + } + + LogicalToOutput(&position.x, &position.y); + if (!demo::IsRunning()) + SDL_WarpMouseInWindow(ghMainWnd, position.x, position.y); +} + +void FreeGameMem() +{ + pDungeonCels = nullptr; + pMegaTiles = nullptr; + pSpecialCels = std::nullopt; + + FreeMonsters(); + FreeMissileGFX(); + FreeObjectGFX(); + FreeTownerGFX(); + FreeStashGFX(); +#ifndef USE_SDL1 + DeactivateVirtualGamepad(); + FreeVirtualGamepadGFX(); +#endif +} + +bool StartGame(bool bNewGame, bool bSinglePlayer) +{ + gbSelectProvider = true; + ReturnToMainMenu = false; + + do { + gbLoadGame = false; + + if (!NetInit(bSinglePlayer)) { + gbRunGameResult = true; + break; + } + + // Save 2.8 MiB of RAM by freeing all main menu resources + // before starting the game. + UiDestroy(); + + gbSelectProvider = false; + + if (bNewGame || !gbValidSaveFile) { + InitLevels(); + InitQuests(); + InitPortals(); + InitDungMsgs(*MyPlayer); + DeltaSyncJunk(); + } + giNumberOfLevels = gbIsHellfire ? 25 : 17; + interface_mode uMsg = WM_DIABNEWGAME; + if (gbValidSaveFile && gbLoadGame) { + uMsg = WM_DIABLOADGAME; + } + RunGameLoop(uMsg); + NetClose(); + UnloadFonts(); + + // If the player left the game into the main menu, + // initialize main menu resources. + if (gbRunGameResult) + UiInitialize(); + if (ReturnToMainMenu) + return true; + } while (gbRunGameResult); + + SNetDestroy(); + return gbRunGameResult; +} + +void diablo_quit(int exitStatus) +{ + FreeGameMem(); + music_stop(); + DiabloDeinit(); + +#if SDL_VERSION_ATLEAST(2, 0, 0) + if (SdlLogFile != nullptr) std::fclose(SdlLogFile); +#endif + + exit(exitStatus); +} + +#ifdef __UWP__ +void (*onInitialized)() = NULL; + +void setOnInitialized(void (*callback)()) +{ + onInitialized = callback; +} +#endif + +int DiabloMain(int argc, char **argv) +{ +#ifdef _DEBUG + SDL_SetLogPriorities(SDL_LOG_PRIORITY_DEBUG); +#endif + + DiabloParseFlags(argc, argv); + InitKeymapActions(); + InitPadmapActions(); + + // Need to ensure devilutionx.mpq (and fonts.mpq if available) are loaded before attempting to read translation settings + LoadCoreArchives(); + was_archives_init = true; + + // Read settings including translation next. This will use the presence of fonts.mpq and look for assets in devilutionx.mpq + LoadOptions(); + if (demo::IsRunning()) demo::OverrideOptions(); + + // Then look for a voice pack file based on the selected translation + LoadLanguageArchive(); + + ApplicationInit(); + LuaInitialize(); + if (!demo::IsRunning()) SaveOptions(); + + // Finally load game data + LoadGameArchives(); + + LoadTextData(); + + // Load dynamic data before we go into the menu as we need to initialise player characters in memory pretty early. + LoadPlayerDataFiles(); + + // TODO: We can probably load this much later (when the game is starting). + LoadSpellData(); + LoadMissileData(); + LoadMonsterData(); + LoadItemData(); + LoadObjectData(); + LoadQuestData(); + + DiabloInit(); +#ifdef __UWP__ + onInitialized(); +#endif + if (!demo::IsRunning()) SaveOptions(); + + DiabloSplash(); + mainmenu_loop(); + DiabloDeinit(); + + return 0; +} + +bool TryIconCurs() +{ + if (pcurs == CURSOR_RESURRECT) { + if (PlayerUnderCursor != nullptr) { + NetSendCmdParam1(true, CMD_RESURRECT, PlayerUnderCursor->getId()); + NewCursor(CURSOR_HAND); + return true; + } + + return false; + } + + if (pcurs == CURSOR_HEALOTHER) { + if (PlayerUnderCursor != nullptr) { + NetSendCmdParam1(true, CMD_HEALOTHER, PlayerUnderCursor->getId()); + NewCursor(CURSOR_HAND); + return true; + } + + return false; + } + + if (pcurs == CURSOR_TELEKINESIS) { + DoTelekinesis(); + return true; + } + + Player &myPlayer = *MyPlayer; + + if (pcurs == CURSOR_IDENTIFY) { + if (pcursinvitem != -1 && !IsInspectingPlayer()) + CheckIdentify(myPlayer, pcursinvitem); + else if (pcursstashitem != StashStruct::EmptyCell) { + Item &item = Stash.stashList[pcursstashitem]; + item._iIdentified = true; + } + NewCursor(CURSOR_HAND); + return true; + } + + if (pcurs == CURSOR_REPAIR) { + if (pcursinvitem != -1 && !IsInspectingPlayer()) + DoRepair(myPlayer, pcursinvitem); + else if (pcursstashitem != StashStruct::EmptyCell) { + Item &item = Stash.stashList[pcursstashitem]; + RepairItem(item, myPlayer.getCharacterLevel()); + } + NewCursor(CURSOR_HAND); + return true; + } + + if (pcurs == CURSOR_RECHARGE) { + if (pcursinvitem != -1 && !IsInspectingPlayer()) + DoRecharge(myPlayer, pcursinvitem); + else if (pcursstashitem != StashStruct::EmptyCell) { + Item &item = Stash.stashList[pcursstashitem]; + RechargeItem(item, myPlayer); + } + NewCursor(CURSOR_HAND); + return true; + } + + if (pcurs == CURSOR_OIL) { + bool changeCursor = true; + if (pcursinvitem != -1 && !IsInspectingPlayer()) + changeCursor = DoOil(myPlayer, pcursinvitem); + else if (pcursstashitem != StashStruct::EmptyCell) { + Item &item = Stash.stashList[pcursstashitem]; + changeCursor = ApplyOilToItem(item, myPlayer); + } + if (changeCursor) + NewCursor(CURSOR_HAND); + return true; + } + + if (pcurs == CURSOR_TELEPORT) { + const SpellID spellID = myPlayer.inventorySpell; + const SpellType spellType = SpellType::Scroll; + const int spellFrom = myPlayer.spellFrom; + if (IsWallSpell(spellID)) { + const Direction sd = GetDirection(myPlayer.position.tile, cursPosition); + NetSendCmdLocParam4(true, CMD_SPELLXYD, cursPosition, static_cast(spellID), static_cast(spellType), static_cast(sd), spellFrom); + } else if (pcursmonst != -1 && leveltype != DTYPE_TOWN) { + NetSendCmdParam4(true, CMD_SPELLID, pcursmonst, static_cast(spellID), static_cast(spellType), spellFrom); + } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { + NetSendCmdParam4(true, CMD_SPELLPID, PlayerUnderCursor->getId(), static_cast(spellID), static_cast(spellType), spellFrom); + } else { + NetSendCmdLocParam3(true, CMD_SPELLXY, cursPosition, static_cast(spellID), static_cast(spellType), spellFrom); + } + NewCursor(CURSOR_HAND); + return true; + } + + if (pcurs == CURSOR_DISARM && ObjectUnderCursor == nullptr) { + NewCursor(CURSOR_HAND); + return true; + } + + return false; +} + +void diablo_pause_game() +{ + if (!gbIsMultiplayer) { + if (PauseMode != 0) { + PauseMode = 0; + } else { + PauseMode = 2; + sound_stop(); + qtextflag = false; + LastPlayerAction = PlayerActionType::None; + } + + RedrawEverything(); + } +} + +bool GameWasAlreadyPaused = false; +bool MinimizePaused = false; + +bool diablo_is_focused() +{ +#ifndef USE_SDL1 + return SDL_GetKeyboardFocus() == ghMainWnd; +#else + Uint8 appState = SDL_GetAppState(); + return (appState & SDL_APPINPUTFOCUS) != 0; +#endif +} + +void diablo_focus_pause() +{ + if (!movie_playing && (gbIsMultiplayer || MinimizePaused)) { + return; + } + + GameWasAlreadyPaused = PauseMode != 0; + + if (!GameWasAlreadyPaused) { + PauseMode = 2; + sound_stop(); + LastPlayerAction = PlayerActionType::None; + } + + SVidMute(); + music_mute(); + + MinimizePaused = true; +} + +void diablo_focus_unpause() +{ + if (!GameWasAlreadyPaused) { + PauseMode = 0; + } + + SVidUnmute(); + music_unmute(); + + MinimizePaused = false; +} + +bool PressEscKey() +{ + bool rv = false; + + if (DoomFlag) { + doom_close(); + rv = true; + } + + if (HelpFlag) { + HelpFlag = false; + rv = true; + } + + if (ChatLogFlag) { + ChatLogFlag = false; + rv = true; + } + + if (qtextflag) { + qtextflag = false; + stream_stop(); + rv = true; + } + + if (IsPlayerInStore()) { + StoreESC(); + rv = true; + } + + if (IsDiabloMsgAvailable()) { + CancelCurrentDiabloMsg(); + rv = true; + } + + if (ChatFlag) { + ResetChat(); + rv = true; + } + + if (DropGoldFlag) { + control_drop_gold(SDLK_ESCAPE); + rv = true; + } + + if (IsWithdrawGoldOpen) { + WithdrawGoldKeyPress(SDLK_ESCAPE); + rv = true; + } + + if (SpellSelectFlag) { + SpellSelectFlag = false; + rv = true; + } + + if (IsLeftPanelOpen() || IsRightPanelOpen()) { + ClosePanels(); + rv = true; + } + + return rv; +} + +void DisableInputEventHandler(const SDL_Event &event, uint16_t modState) +{ + switch (event.type) { + case SDL_EVENT_MOUSE_MOTION: + MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; + return; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + if (sgbMouseDown != CLICK_NONE) + return; + switch (event.button.button) { + case SDL_BUTTON_LEFT: + sgbMouseDown = CLICK_LEFT; + return; + case SDL_BUTTON_RIGHT: + sgbMouseDown = CLICK_RIGHT; + return; + default: + return; + } + case SDL_EVENT_MOUSE_BUTTON_UP: + sgbMouseDown = CLICK_NONE; + return; + } + + MainWndProc(event); +} + +void LoadGameLevelStopMusic(_music_id neededTrack) +{ + if (neededTrack != sgnMusicTrack) + music_stop(); +} + +void LoadGameLevelStartMusic(_music_id neededTrack) +{ + if (sgnMusicTrack != neededTrack) + music_start(neededTrack); + + if (MinimizePaused) { + music_mute(); + } +} + +void LoadGameLevelResetCursor() +{ + if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { + NewCursor(CURSOR_HAND); + } +} + +void SetRndSeedForDungeonLevel() +{ + if (setlevel) { + // Maps are not randomly generated, but the monsters max hitpoints are. + // So we need to ensure that we have a stable seed when generating quest/set-maps. + // For this purpose we reuse the normal dungeon seeds. + SetRndSeed(DungeonSeeds[static_cast(setlvlnum)]); + } else { + SetRndSeed(DungeonSeeds[currlevel]); + } +} + +void LoadGameLevelFirstFlagEntry() +{ + CloseInventory(); + qtextflag = false; + if (!HeadlessMode) { + InitInv(); + ClearUniqueItemFlags(); + InitQuestText(); + InitInfoBoxGfx(); + InitHelp(); + } + InitStores(); + InitAutomapOnce(); +} + +void LoadGameLevelStores() +{ + if (leveltype == DTYPE_TOWN) { + SetupTownStores(); + } else { + FreeStoreMem(); + } +} + +void LoadGameLevelStash() +{ + const bool isHellfireSaveGame = gbIsHellfireSaveGame; + + gbIsHellfireSaveGame = gbIsHellfire; + LoadStash(); + gbIsHellfireSaveGame = isHellfireSaveGame; +} + +tl::expected LoadGameLevelDungeon(bool firstflag, lvl_entry lvldir, const Player &myPlayer) +{ + if (firstflag || lvldir == ENTRY_LOAD || !myPlayer._pLvlVisited[currlevel] || gbIsMultiplayer) { + HoldThemeRooms(); + [[maybe_unused]] const uint32_t mid1Seed = GetLCGEngineState(); + InitGolems(); + InitObjects(); + [[maybe_unused]] const uint32_t mid2Seed = GetLCGEngineState(); + + IncProgress(); + + RETURN_IF_ERROR(InitMonsters()); + InitItems(); + CreateThemeRooms(); + + IncProgress(); + + [[maybe_unused]] const uint32_t mid3Seed = GetLCGEngineState(); + InitMissiles(); + InitCorpses(); +#ifdef _DEBUG + SetDebugLevelSeedInfos(mid1Seed, mid2Seed, mid3Seed, GetLCGEngineState()); +#endif + SavePreLighting(); + + IncProgress(); + + if (gbIsMultiplayer) + DeltaLoadLevel(); + } else { + HoldThemeRooms(); + InitGolems(); + RETURN_IF_ERROR(InitMonsters()); + InitMissiles(); + InitCorpses(); + + IncProgress(); + + RETURN_IF_ERROR(LoadLevel()); + + IncProgress(); + } + return {}; +} + +void LoadGameLevelSyncPlayerEntry(lvl_entry lvldir) +{ + for (Player &player : Players) { + if (player.plractive && player.isOnActiveLevel() && (!player._pLvlChanging || &player == MyPlayer)) { + if (player._pHitPoints > 0) { + if (lvldir != ENTRY_LOAD) + SyncInitPlrPos(player); + } else { + dFlags[player.position.tile.x][player.position.tile.y] |= DungeonFlag::DeadPlayer; + } + } + } +} + +void LoadGameLevelLightVision() +{ + if (leveltype != DTYPE_TOWN) { + memcpy(dLight, dPreLight, sizeof(dLight)); // resets the light on entering a level to get rid of incorrect light + ChangeLightXY(Players[MyPlayerId].lightId, Players[MyPlayerId].position.tile); // forces player light refresh + ProcessLightList(); + ProcessVisionList(); + } +} + +void LoadGameLevelReturn() +{ + ViewPosition = GetMapReturnPosition(); + if (Quests[Q_BETRAYER]._qactive == QUEST_DONE) + Quests[Q_BETRAYER]._qvar2 = 2; +} + +void LoadGameLevelInitPlayers(bool firstflag, lvl_entry lvldir) +{ + for (Player &player : Players) { + if (player.plractive && player.isOnActiveLevel()) { + InitPlayerGFX(player); + if (lvldir != ENTRY_LOAD) + InitPlayer(player, firstflag); + } + } +} + +void LoadGameLevelSetVisited() +{ + bool visited = false; + for (const Player &player : Players) { + if (player.plractive) + visited = visited || player._pLvlVisited[currlevel]; + } +} + +tl::expected LoadGameLevelTown(bool firstflag, lvl_entry lvldir, const Player &myPlayer) +{ + for (int i = 0; i < MAXDUNX; i++) { // NOLINT(modernize-loop-convert) + for (int j = 0; j < MAXDUNY; j++) { + dFlags[i][j] |= DungeonFlag::Lit; + } + } + + InitTowners(); + InitStash(); + InitItems(); + InitMissiles(); + + IncProgress(); + + if (!firstflag && lvldir != ENTRY_LOAD && myPlayer._pLvlVisited[currlevel] && !gbIsMultiplayer) + RETURN_IF_ERROR(LoadLevel()); + if (gbIsMultiplayer) + DeltaLoadLevel(); + + IncProgress(); + + for (int x = 0; x < DMAXX; x++) + for (int y = 0; y < DMAXY; y++) + UpdateAutomapExplorer({ x, y }, MAP_EXP_SELF); + return {}; +} + +tl::expected LoadGameLevelSetLevel(bool firstflag, lvl_entry lvldir, const Player &myPlayer) +{ + LoadSetMap(); + IncProgress(); + RETURN_IF_ERROR(GetLevelMTypes()); + IncProgress(); + InitGolems(); + RETURN_IF_ERROR(InitMonsters()); + IncProgress(); + if (!HeadlessMode) { +#if !defined(USE_SDL1) && !defined(__vita__) + InitVirtualGamepadGFX(); +#endif + RETURN_IF_ERROR(InitMissileGFX()); + IncProgress(); + } + InitCorpses(); + IncProgress(); + + if (lvldir == ENTRY_WARPLVL) + GetPortalLvlPos(); + IncProgress(); + + for (Player &player : Players) { + if (player.plractive && player.isOnActiveLevel()) { + InitPlayerGFX(player); + if (lvldir != ENTRY_LOAD) + InitPlayer(player, firstflag); + } + } + IncProgress(); + InitMultiView(); + IncProgress(); + + if (firstflag || lvldir == ENTRY_LOAD || !myPlayer._pSLvlVisited[setlvlnum] || gbIsMultiplayer) { + InitItems(); + SavePreLighting(); + } else { + RETURN_IF_ERROR(LoadLevel()); + } + if (gbIsMultiplayer) { + DeltaLoadLevel(); + if (!UseMultiplayerQuests()) + ResyncQuests(); + } + + PlayDungMsgs(); + InitMissiles(); + IncProgress(); + return {}; +} + +tl::expected LoadGameLevelStandardLevel(bool firstflag, lvl_entry lvldir, const Player &myPlayer) +{ + CreateLevel(lvldir); + + IncProgress(); + + SetRndSeedForDungeonLevel(); + + if (leveltype != DTYPE_TOWN) { + RETURN_IF_ERROR(GetLevelMTypes()); + InitThemes(); + if (!HeadlessMode) + RETURN_IF_ERROR(LoadAllGFX()); + } else if (!HeadlessMode) { + IncProgress(); + +#if !defined(USE_SDL1) && !defined(__vita__) + InitVirtualGamepadGFX(); +#endif + + IncProgress(); + + RETURN_IF_ERROR(InitMissileGFX()); + + IncProgress(); + IncProgress(); + } + + IncProgress(); + + if (lvldir == ENTRY_RTNLVL) { + LoadGameLevelReturn(); + } + + if (lvldir == ENTRY_WARPLVL) + GetPortalLvlPos(); + + IncProgress(); + + LoadGameLevelInitPlayers(firstflag, lvldir); + InitMultiView(); + + IncProgress(); + + LoadGameLevelSetVisited(); + + SetRndSeedForDungeonLevel(); + + if (leveltype == DTYPE_TOWN) { + LoadGameLevelTown(firstflag, lvldir, myPlayer); + } else { + LoadGameLevelDungeon(firstflag, lvldir, myPlayer); + } + + PlayDungMsgs(); + + if (UseMultiplayerQuests()) + ResyncMPQuests(); + else + ResyncQuests(); + return {}; +} + +void LoadGameLevelCrypt() +{ + if (CornerStone.isAvailable()) { + CornerstoneLoad(CornerStone.position); + } + if (Quests[Q_NAKRUL]._qactive == QUEST_DONE && currlevel == 24) { + SyncNakrulRoom(); + } +} + +void LoadGameLevelCalculateCursor() +{ + // Recalculate mouse selection of entities after level change/load + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_NONE; + ResetItemlabelHighlighted(); // level changed => item changed + pcursmonst = -1; // ensure pcurstemp is set to a valid value + CheckCursMove(); +} + +tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir) +{ + const _music_id neededTrack = GetLevelMusic(leveltype); + + ClearFloatingNumbers(); + LoadGameLevelStopMusic(neededTrack); + LoadGameLevelResetCursor(); + SetRndSeedForDungeonLevel(); + NaKrulTomeSequence = 0; + + IncProgress(); + + RETURN_IF_ERROR(LoadTrns()); + MakeLightTable(); + RETURN_IF_ERROR(LoadLevelSOLData()); + + IncProgress(); + + RETURN_IF_ERROR(LoadLvlGFX()); + SetDungeonMicros(pDungeonCels, MicroTileLen); + ClearClxDrawCache(); + + IncProgress(); + + if (firstflag) { + LoadGameLevelFirstFlagEntry(); + } + + SetRndSeedForDungeonLevel(); + + LoadGameLevelStores(); + + if (firstflag || lvldir == ENTRY_LOAD) { + LoadGameLevelStash(); + } + + IncProgress(); + + InitAutomap(); + + if (leveltype != DTYPE_TOWN && lvldir != ENTRY_LOAD) { + InitLighting(); + } + + InitLevelMonsters(); + + IncProgress(); + + const Player &myPlayer = *MyPlayer; + + if (setlevel) { + RETURN_IF_ERROR(LoadGameLevelSetLevel(firstflag, lvldir, myPlayer)); + } else { + RETURN_IF_ERROR(LoadGameLevelStandardLevel(firstflag, lvldir, myPlayer)); + } + + SyncPortals(); + LoadGameLevelSyncPlayerEntry(lvldir); + + IncProgress(); + IncProgress(); + + if (firstflag) { + RETURN_IF_ERROR(InitMainPanel()); + } + + IncProgress(); + + UpdateMonsterLights(); + UnstuckChargers(); + + LoadGameLevelLightVision(); + + if (leveltype == DTYPE_CRYPT) { + LoadGameLevelCrypt(); + } + +#ifndef USE_SDL1 + ActivateVirtualGamepad(); +#endif + LoadGameLevelStartMusic(neededTrack); + + CompleteProgress(); + + LoadGameLevelCalculateCursor(); + if (leveltype != DTYPE_TOWN) + SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); + return {}; +} + +bool game_loop(bool bStartup) +{ + const uint16_t wait = bStartup ? sgGameInitInfo.nTickRate * 3 : 3; + + for (unsigned i = 0; i < wait; i++) { + if (!multi_handle_delta()) { + TimeoutCursor(true); + return false; + } + TimeoutCursor(false); + GameLogic(); + ClearLastSentPlayerCmd(); + + if (!gbRunGame || !gbIsMultiplayer || demo::IsRunning() || demo::IsRecording() || !nthread_has_500ms_passed()) + break; + } + return true; +} + +void diablo_color_cyc_logic() +{ + if (!*GetOptions().Graphics.colorCycling) + return; + + if (PauseMode != 0) + return; + + if (leveltype == DTYPE_CAVES) { + if (setlevel && setlvlnum == Quests[Q_PWATER]._qslvl) { + UpdatePWaterPalette(); + } else { + palette_update_caves(); + } + } else if (leveltype == DTYPE_HELL) { + lighting_color_cycling(); + } else if (leveltype == DTYPE_NEST) { + palette_update_hive(); + } else if (leveltype == DTYPE_CRYPT) { + palette_update_crypt(); + } +} + +bool IsDiabloAlive(bool playSFX) +{ + if (Quests[Q_DIABLO]._qactive == QUEST_DONE && !gbIsMultiplayer) { + if (playSFX) + PlaySFX(SfxID::DiabloDeath); + return false; + } + + return true; +} + +void PrintScreen(SDL_Keycode vkey) +{ + ReleaseKey(vkey); +} + +} // namespace devilution From e55ce908145e18e656a1961fd6f6ef4ae3ac2c37 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:42:11 -0500 Subject: [PATCH 03/13] access: fix incorrect and misleading comments Fix copy-paste comment errors in SpellBookKeyPressed() that referred to "inventory" instead of "spellbook", and QuestLogKeyPressed() that said "character quest log" instead of "quest log". Correct a misleading FindPath comment in town NPC auto-walk, and update a stale SourceX path reference in diablo.cpp. Co-Authored-By: Claude Opus 4.5 --- Source/controls/accessibility_keys.cpp | 6 +++--- Source/controls/town_npc_nav.cpp | 2 +- Source/diablo.cpp | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Source/controls/accessibility_keys.cpp b/Source/controls/accessibility_keys.cpp index 39dd716ef..a619e4c77 100644 --- a/Source/controls/accessibility_keys.cpp +++ b/Source/controls/accessibility_keys.cpp @@ -195,7 +195,7 @@ void QuestLogKeyPressed() if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { SetCursorPos(MousePosition - Displacement { 160, 0 }); } - } else if (!CharFlag) { // We opened the character quest log + } else if (!CharFlag) { // We opened the quest log if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { SetCursorPos(MousePosition + Displacement { 160, 0 }); } @@ -248,11 +248,11 @@ void SpellBookKeyPressed() } } if (!IsLeftPanelOpen() && CanPanelsCoverView()) { - if (!SpellbookFlag) { // We closed the inventory + if (!SpellbookFlag) { // We closed the spellbook if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { SetCursorPos(MousePosition + Displacement { 160, 0 }); } - } else if (!invflag) { // We opened the inventory + } else if (!invflag) { // We opened the spellbook if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { SetCursorPos(MousePosition - Displacement { 160, 0 }); } diff --git a/Source/controls/town_npc_nav.cpp b/Source/controls/town_npc_nav.cpp index c7b50d065..97b2bc717 100644 --- a/Source/controls/town_npc_nav.cpp +++ b/Source/controls/town_npc_nav.cpp @@ -243,7 +243,7 @@ void UpdateAutoWalkTownNpc() return; } - // FindPath returns 0 if the path length is equal to the maximum. + // FindPath returns 0 when it fails to find a usable path. // The player walkpath buffer is MaxPathLengthPlayer, so keep segments strictly shorter. if (steps < static_cast(MaxPathLengthPlayer)) { const int townerIdx = AutoWalkTownNpcTarget; diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 12dedc5e5..f936badea 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -168,7 +168,7 @@ GameLogicStep gGameLogicStep = GameLogicStep::None; PlayerActionType LastPlayerAction = PlayerActionType::None; // Controller support: Actions to run after updating the cursor state. -// Defined in SourceX/controls/plctrls.cpp. +// Defined in Source/controls/plrctrls.cpp. extern void plrctrls_after_check_curs_move(); extern void plrctrls_every_frame(); extern void plrctrls_after_game_logic(); From 9799f418a975e3106791591b6d528574333798f1 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:42:26 -0500 Subject: [PATCH 04/13] access: move SelectedTrackerTargetCategory to anonymous namespace The variable is only used within tracker.cpp, so remove the unnecessary extern declaration from the header and place it in the anonymous namespace alongside the other file-local tracker state. Co-Authored-By: Claude Opus 4.5 --- Source/controls/tracker.cpp | 4 ++-- Source/controls/tracker.hpp | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Source/controls/tracker.cpp b/Source/controls/tracker.cpp index c983107fd..33f22bb05 100644 --- a/Source/controls/tracker.cpp +++ b/Source/controls/tracker.cpp @@ -50,10 +50,10 @@ namespace devilution { -TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; - namespace { +TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; + TrackerTargetCategory AutoWalkTrackerTargetCategory = TrackerTargetCategory::Items; ///< Category of the active auto-walk target. int AutoWalkTrackerTargetId = -1; ///< ID of the target being auto-walked to, or -1 if inactive. diff --git a/Source/controls/tracker.hpp b/Source/controls/tracker.hpp index e73fa8166..6d0c6735a 100644 --- a/Source/controls/tracker.hpp +++ b/Source/controls/tracker.hpp @@ -20,8 +20,6 @@ enum class TrackerTargetCategory : uint8_t { DeadBodies, }; -extern TrackerTargetCategory SelectedTrackerTargetCategory; - void CycleTrackerTargetKeyPressed(); void NavigateToTrackerTargetKeyPressed(); void AutoWalkToTrackerTargetKeyPressed(); From 4db589cfc21811be638c4b51fb201f78135ca1eb Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:43:08 -0500 Subject: [PATCH 05/13] access: add null-safety checks for MyPlayer in accessibility code Guard against MyPlayer being nullptr in IsPlayerDead(), CycleSpellHotkeys(), RefreshTownNpcOrder(), SpeakSelectedTownNpc(), and ListTownNpcsKeyPressed() to prevent crashes during early init or after disconnect. Also reset AutoWalkTrackerTargetId when MyPlayer is null in the auto-walk tracker to prevent the walk loop from retrying endlessly. Co-Authored-By: Claude Opus 4.5 --- Source/controls/accessibility_keys.cpp | 4 ++++ Source/controls/town_npc_nav.cpp | 6 ++++++ Source/controls/tracker.cpp | 2 ++ 3 files changed, 12 insertions(+) diff --git a/Source/controls/accessibility_keys.cpp b/Source/controls/accessibility_keys.cpp index a619e4c77..ea8b1d802 100644 --- a/Source/controls/accessibility_keys.cpp +++ b/Source/controls/accessibility_keys.cpp @@ -263,6 +263,8 @@ void SpellBookKeyPressed() void CycleSpellHotkeys(bool next) { + if (MyPlayer == nullptr) + return; StaticVector validHotKeyIndexes; std::optional currentIndex; for (size_t slot = 0; slot < NumHotkeys; slot++) { @@ -290,6 +292,8 @@ void CycleSpellHotkeys(bool next) bool IsPlayerDead() { + if (MyPlayer == nullptr) + return true; return MyPlayer->_pmode == PM_DEATH || MyPlayerIsDead; } diff --git a/Source/controls/town_npc_nav.cpp b/Source/controls/town_npc_nav.cpp index 97b2bc717..df0f6f77c 100644 --- a/Source/controls/town_npc_nav.cpp +++ b/Source/controls/town_npc_nav.cpp @@ -50,6 +50,8 @@ void RefreshTownNpcOrder(bool selectFirst = false) TownNpcOrder.clear(); if (leveltype != DTYPE_TOWN) return; + if (MyPlayer == nullptr) + return; const Point playerPosition = MyPlayer->position.future; @@ -147,6 +149,8 @@ void SpeakSelectedTownNpc() SpeakText(_("No NPC selected."), true); return; } + if (MyPlayer == nullptr) + return; const Towner &towner = Towners[SelectedTownNpc]; const Point playerPosition = MyPlayer->position.future; @@ -277,6 +281,8 @@ void ListTownNpcsKeyPressed() townNpcs.reserve(Towners.size()); cows.reserve(Towners.size()); + if (MyPlayer == nullptr) + return; const Point playerPosition = MyPlayer->position.future; diff --git a/Source/controls/tracker.cpp b/Source/controls/tracker.cpp index 33f22bb05..39e568090 100644 --- a/Source/controls/tracker.cpp +++ b/Source/controls/tracker.cpp @@ -1470,6 +1470,7 @@ void AutoWalkToTrackerTargetKeyPressed() return; } if (MyPlayer == nullptr) { + AutoWalkTrackerTargetId = -1; SpeakText(_("Cannot walk right now."), true); return; } @@ -1592,6 +1593,7 @@ void UpdateAutoWalkTracker() return; if (MyPlayer == nullptr) { + AutoWalkTrackerTargetId = -1; SpeakText(_("Cannot walk right now."), true); return; } From 91e4ab4e098937fbfaf194dfe09d02680cbb4804 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:43:24 -0500 Subject: [PATCH 06/13] access: log warning when low HP sound fails to load Add a LogWarn call when none of the expected audio file paths for the low HP warning sound can be loaded, making it easier to diagnose missing or misplaced sound assets. Co-Authored-By: Claude Opus 4.5 --- Source/utils/accessibility_announcements.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Source/utils/accessibility_announcements.cpp b/Source/utils/accessibility_announcements.cpp index c1275c6d1..0bdf593c7 100644 --- a/Source/utils/accessibility_announcements.cpp +++ b/Source/utils/accessibility_announcements.cpp @@ -33,6 +33,7 @@ #include "player.h" #include "utils/is_of.hpp" #include "utils/language.h" +#include "utils/log.hpp" #include "utils/screen_reader.hpp" #include "utils/str_cat.hpp" #include "utils/string_or_view.hpp" @@ -74,6 +75,7 @@ TSnd *GetPlayerLowHpWarningSound() && 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) { + LogWarn("Failed to load low HP warning sound from any of the expected paths."); PlayerLowHpWarningSound = nullptr; } From 9d3ecd7aaf4ee5bc19b0d3d7480bf72b8ba61b03 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:44:03 -0500 Subject: [PATCH 07/13] access: add null-safety check for MyPlayer in UpdateAutoWalkTownNpc Guard against MyPlayer being nullptr in UpdateAutoWalkTownNpc() to match the pattern used in UpdateAutoWalkTracker(). Without this, MyPlayer could be dereferenced if CanPlayerTakeAction() changes its null-handling behavior in the future. Co-Authored-By: Claude Opus 4.5 --- Source/controls/town_npc_nav.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Source/controls/town_npc_nav.cpp b/Source/controls/town_npc_nav.cpp index df0f6f77c..4c2f76c2e 100644 --- a/Source/controls/town_npc_nav.cpp +++ b/Source/controls/town_npc_nav.cpp @@ -204,6 +204,10 @@ void UpdateAutoWalkTownNpc() } if (!CanPlayerTakeAction()) return; + if (MyPlayer == nullptr) { + AutoWalkTownNpcTarget = -1; + return; + } if (MyPlayer->_pmode != PM_STAND) return; From cd9b82879fb42f72aa11e9c63ee3985065cdf8cb Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:45:57 -0500 Subject: [PATCH 08/13] access: provide spoken feedback on null-player early returns in town NPC nav When MyPlayer is null, the town NPC navigation functions now provide spoken feedback instead of returning silently, matching the pattern used in the tracker auto-walk code. RefreshTownNpcOrder also resets SelectedTownNpc to avoid leaving stale state. Co-Authored-By: Claude Opus 4.5 --- Source/controls/town_npc_nav.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Source/controls/town_npc_nav.cpp b/Source/controls/town_npc_nav.cpp index 4c2f76c2e..a66d1eb97 100644 --- a/Source/controls/town_npc_nav.cpp +++ b/Source/controls/town_npc_nav.cpp @@ -50,8 +50,10 @@ void RefreshTownNpcOrder(bool selectFirst = false) TownNpcOrder.clear(); if (leveltype != DTYPE_TOWN) return; - if (MyPlayer == nullptr) + if (MyPlayer == nullptr) { + SelectedTownNpc = -1; return; + } const Point playerPosition = MyPlayer->position.future; @@ -149,8 +151,10 @@ void SpeakSelectedTownNpc() SpeakText(_("No NPC selected."), true); return; } - if (MyPlayer == nullptr) + if (MyPlayer == nullptr) { + SpeakText(_("No NPC selected."), true); return; + } const Towner &towner = Towners[SelectedTownNpc]; const Point playerPosition = MyPlayer->position.future; @@ -285,8 +289,10 @@ void ListTownNpcsKeyPressed() townNpcs.reserve(Towners.size()); cows.reserve(Towners.size()); - if (MyPlayer == nullptr) + if (MyPlayer == nullptr) { + SpeakText(_("No town NPCs found."), true); return; + } const Point playerPosition = MyPlayer->position.future; From d1aeb394ac27ba8656758af549fba98b012b3ae9 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:24:07 -0500 Subject: [PATCH 09/13] access: speak class + difficulty descriptions Cherry-pick commit ff82e243b from upstream. Co-Authored-By: Claude Opus 4.5 --- Source/DiabloUI/diabloui.cpp | 93 ++++++++++++++++++------------- Source/DiabloUI/diabloui.h | 39 +++++++------ Source/DiabloUI/hero/selhero.cpp | 85 ++++++++++++++++++---------- Source/DiabloUI/multi/selgame.cpp | 50 +++++++++++------ Translations/pl.po | 24 ++++++++ 5 files changed, 188 insertions(+), 103 deletions(-) diff --git a/Source/DiabloUI/diabloui.cpp b/Source/DiabloUI/diabloui.cpp index 4cf221436..b308bcf8b 100644 --- a/Source/DiabloUI/diabloui.cpp +++ b/Source/DiabloUI/diabloui.cpp @@ -109,14 +109,16 @@ void (*gfnFullscreen)(); bool (*gfnListYesNo)(); std::vector gUiItems; UiList *gUiList = nullptr; -bool UiItemsWraps; - -std::optional UiTextInputState; -bool allowEmptyTextInput = false; - -constexpr Uint32 ListDoubleClickTimeMs = 500; -std::size_t lastListClickIndex = static_cast(-1); -Uint32 lastListClickTicks = 0; +bool UiItemsWraps; + +std::optional UiTextInputState; +bool allowEmptyTextInput = false; + +std::optional UiSpokenTextOverride; + +constexpr Uint32 ListDoubleClickTimeMs = 500; +std::size_t lastListClickIndex = static_cast(-1); +Uint32 lastListClickTicks = 0; struct ScrollBarState { bool upArrowPressed; @@ -155,14 +157,20 @@ std::string FormatSpokenText(const StringOrView &format, const std::vector SelectedItemMax) - return; - - const UiListItem *pItem = gUiList->GetItem(index); - if (pItem == nullptr) - return; +void SpeakListItem(std::size_t index, bool force = false) +{ + if (gUiList == nullptr || index > SelectedItemMax) + return; + + if (UiSpokenTextOverride) { + SpeakText(*UiSpokenTextOverride, force); + UiSpokenTextOverride = std::nullopt; + return; + } + + const UiListItem *pItem = gUiList->GetItem(index); + if (pItem == nullptr) + return; std::string text = FormatSpokenText(pItem->m_text, pItem->args); @@ -180,10 +188,10 @@ void SpeakListItem(std::size_t index, bool force = false) if (!text.empty()) SpeakText(text, force); -} - -void AdjustListOffset(std::size_t itemIndex) -{ +} + +void AdjustListOffset(std::size_t itemIndex) +{ if (itemIndex >= listOffset + ListViewportSize) listOffset = itemIndex - (ListViewportSize - 1); if (itemIndex < listOffset) @@ -232,13 +240,18 @@ void UiUpdateFadePalette() SystemPaletteUpdated(); if (IsHardwareCursor()) ReinitializeHardwareCursor(); } - -} // namespace - -bool IsTextInputActive() -{ - return UiTextInputState.has_value(); -} + +} // namespace + +void UiSetSpokenTextOverride(std::string text) +{ + UiSpokenTextOverride = std::move(text); +} + +bool IsTextInputActive() +{ + return UiTextInputState.has_value(); +} void UiInitList(void (*fnFocus)(size_t value), void (*fnSelect)(size_t value), void (*fnEsc)(), const std::vector> &items, bool itemsWraps, void (*fnFullscreen)(), bool (*fnYesNo)(), size_t selectedItem /*= 0*/) { @@ -365,19 +378,19 @@ void UiFocus(std::size_t itemIndex, bool checkUp, bool ignoreItemsWraps = false) } pItem = gUiList->GetItem(itemIndex); } - SpeakListItem(itemIndex); - - if (HasAnyOf(pItem->uiFlags, UiFlags::NeedsNextElement)) - AdjustListOffset(itemIndex + 1); - AdjustListOffset(itemIndex); - - SelectedItem = itemIndex; - - UiPlayMoveSound(); - - if (gfnListFocus != nullptr) - gfnListFocus(itemIndex); -} + if (HasAnyOf(pItem->uiFlags, UiFlags::NeedsNextElement)) + AdjustListOffset(itemIndex + 1); + AdjustListOffset(itemIndex); + + SelectedItem = itemIndex; + + UiPlayMoveSound(); + + if (gfnListFocus != nullptr) + gfnListFocus(itemIndex); + + SpeakListItem(itemIndex); +} void UiFocusUp() { diff --git a/Source/DiabloUI/diabloui.h b/Source/DiabloUI/diabloui.h index 61c5cd7a8..addc45dc8 100644 --- a/Source/DiabloUI/diabloui.h +++ b/Source/DiabloUI/diabloui.h @@ -1,13 +1,15 @@ -#pragma once - -#include -#include -#include -#include - -#ifdef USE_SDL3 -#include -#include +#pragma once + +#include +#include +#include +#include +#include +#include + +#ifdef USE_SDL3 +#include +#include #else #include #endif @@ -109,13 +111,16 @@ bool UiLoadBlackBackground(); void LoadBackgroundArt(const char *pszFile, int frames = 1); void UiAddBackground(std::vector> *vecDialog); void UiAddLogo(std::vector> *vecDialog, int y = GetUIRectangle().position.y); -void UiFocusNavigationSelect(); -void UiFocusNavigationEsc(); -void UiFocusNavigationYesNo(); - -void UiInitList(void (*fnFocus)(size_t value), void (*fnSelect)(size_t value), void (*fnEsc)(), const std::vector> &items, bool wraps = false, void (*fnFullscreen)() = nullptr, bool (*fnYesNo)() = nullptr, size_t selectedItem = 0); -void UiRenderListItems(); -void UiInitList_clear(); +void UiFocusNavigationSelect(); +void UiFocusNavigationEsc(); +void UiFocusNavigationYesNo(); + +/** Overrides what the screen reader will speak for the next focused list item. */ +void UiSetSpokenTextOverride(std::string text); + +void UiInitList(void (*fnFocus)(size_t value), void (*fnSelect)(size_t value), void (*fnEsc)(), const std::vector> &items, bool wraps = false, void (*fnFullscreen)() = nullptr, bool (*fnYesNo)() = nullptr, size_t selectedItem = 0); +void UiRenderListItems(); +void UiInitList_clear(); void UiClearScreen(); void UiPollAndRender(std::optional> eventHandler = std::nullopt); diff --git a/Source/DiabloUI/hero/selhero.cpp b/Source/DiabloUI/hero/selhero.cpp index 883b4c1f4..8292d05f5 100644 --- a/Source/DiabloUI/hero/selhero.cpp +++ b/Source/DiabloUI/hero/selhero.cpp @@ -65,23 +65,43 @@ std::vector> vecSelHeroDialog; std::vector> vecSelHeroDlgItems; std::vector> vecSelDlgItems; -UiImageClx *SELHERO_DIALOG_HERO_IMG; - -void SelheroListFocus(size_t value); -void SelheroListSelect(size_t value); -void SelheroListEsc(); -void SelheroLoadFocus(size_t value); -void SelheroLoadSelect(size_t value); -void SelheroNameSelect(size_t value); -void SelheroNameEsc(); -void SelheroClassSelectorFocus(size_t value); -void SelheroClassSelectorSelect(size_t value); -void SelheroClassSelectorEsc(); -const char *SelheroGenerateName(HeroClass heroClass); - -void SelheroUiFocusNavigationYesNo() -{ - if (selhero_isSavegame) +UiImageClx *SELHERO_DIALOG_HERO_IMG; + +void SelheroListFocus(size_t value); +void SelheroListSelect(size_t value); +void SelheroListEsc(); +void SelheroLoadFocus(size_t value); +void SelheroLoadSelect(size_t value); +void SelheroNameSelect(size_t value); +void SelheroNameEsc(); +void SelheroClassSelectorFocus(size_t value); +void SelheroClassSelectorSelect(size_t value); +void SelheroClassSelectorEsc(); +const char *SelheroGenerateName(HeroClass heroClass); + +std::string_view HeroClassDescriptionForSpeech(HeroClass heroClass) +{ + switch (heroClass) { + case HeroClass::Warrior: + return _("A powerful fighter who excels in melee combat."); + case HeroClass::Rogue: + return _("A nimble archer who excels at ranged combat."); + case HeroClass::Sorcerer: + return _("A master of arcane magic who casts powerful spells."); + case HeroClass::Monk: + return _("A holy warrior skilled in martial arts and staves."); + case HeroClass::Bard: + return _("A versatile fighter who blends melee and archery."); + case HeroClass::Barbarian: + return _("A fierce warrior who relies on brute strength."); + default: + return {}; + } +} + +void SelheroUiFocusNavigationYesNo() +{ + if (selhero_isSavegame) UiFocusNavigationYesNo(); } @@ -248,22 +268,31 @@ void SelheroListEsc() selhero_result = SELHERO_PREVIOUS; } -void SelheroClassSelectorFocus(size_t value) -{ - const auto heroClass = static_cast(vecSelHeroDlgItems[value]->m_value); - - _uidefaultstats defaults; - gfnHeroStats(heroClass, &defaults); +void SelheroClassSelectorFocus(size_t value) +{ + const auto heroClass = static_cast(vecSelHeroDlgItems[value]->m_value); + + _uidefaultstats defaults; + gfnHeroStats(heroClass, &defaults); selhero_heroInfo.level = 1; selhero_heroInfo.heroclass = heroClass; selhero_heroInfo.strength = defaults.strength; selhero_heroInfo.magic = defaults.magic; - selhero_heroInfo.dexterity = defaults.dexterity; - selhero_heroInfo.vitality = defaults.vitality; - - SelheroSetStats(); -} + selhero_heroInfo.dexterity = defaults.dexterity; + selhero_heroInfo.vitality = defaults.vitality; + + SelheroSetStats(); + + const PlayerData &playerData = GetPlayerDataForClass(heroClass); + const std::string_view description = HeroClassDescriptionForSpeech(heroClass); + std::string spoken = std::string(_(playerData.className)); + if (!description.empty()) { + spoken.append("\n"); + spoken.append(description); + } + UiSetSpokenTextOverride(std::move(spoken)); +} bool ShouldPrefillHeroName() { diff --git a/Source/DiabloUI/multi/selgame.cpp b/Source/DiabloUI/multi/selgame.cpp index dc8af704d..eb556ad52 100644 --- a/Source/DiabloUI/multi/selgame.cpp +++ b/Source/DiabloUI/multi/selgame.cpp @@ -409,24 +409,38 @@ void selgame_GameSelection_Esc() selgame_endMenu = true; } -void selgame_Diff_Focus(size_t value) -{ - switch (vecSelGameDlgItems[value]->m_value) { - case DIFF_NORMAL: - CopyUtf8(selgame_Label, _("Normal"), sizeof(selgame_Label)); - CopyUtf8(selgame_Description, _("Normal Difficulty\nThis is where a starting character should begin the quest to defeat Diablo."), sizeof(selgame_Description)); - break; - case DIFF_NIGHTMARE: - CopyUtf8(selgame_Label, _("Nightmare"), sizeof(selgame_Label)); - CopyUtf8(selgame_Description, _("Nightmare Difficulty\nThe denizens of the Labyrinth have been bolstered and will prove to be a greater challenge. This is recommended for experienced characters only."), sizeof(selgame_Description)); - break; - case DIFF_HELL: - CopyUtf8(selgame_Label, _("Hell"), sizeof(selgame_Label)); - CopyUtf8(selgame_Description, _("Hell Difficulty\nThe most powerful of the underworld's creatures lurk at the gateway into Hell. Only the most experienced characters should venture in this realm."), sizeof(selgame_Description)); - break; - } - CopyUtf8(selgame_Description, WordWrapString(selgame_Description, DESCRIPTION_WIDTH), sizeof(selgame_Description)); -} +void selgame_Diff_Focus(size_t value) +{ + std::string_view tooltip; + switch (vecSelGameDlgItems[value]->m_value) { + case DIFF_NORMAL: + CopyUtf8(selgame_Label, _("Normal"), sizeof(selgame_Label)); + tooltip = _("Normal Difficulty\nThis is where a starting character should begin the quest to defeat Diablo."); + CopyUtf8(selgame_Description, tooltip, sizeof(selgame_Description)); + break; + case DIFF_NIGHTMARE: + CopyUtf8(selgame_Label, _("Nightmare"), sizeof(selgame_Label)); + tooltip = _("Nightmare Difficulty\nThe denizens of the Labyrinth have been bolstered and will prove to be a greater challenge. This is recommended for experienced characters only."); + CopyUtf8(selgame_Description, tooltip, sizeof(selgame_Description)); + break; + case DIFF_HELL: + CopyUtf8(selgame_Label, _("Hell"), sizeof(selgame_Label)); + tooltip = _("Hell Difficulty\nThe most powerful of the underworld's creatures lurk at the gateway into Hell. Only the most experienced characters should venture in this realm."); + CopyUtf8(selgame_Description, tooltip, sizeof(selgame_Description)); + break; + } + CopyUtf8(selgame_Description, WordWrapString(selgame_Description, DESCRIPTION_WIDTH), sizeof(selgame_Description)); + + std::string spoken = selgame_Label; + std::string_view spokenDescription = tooltip; + if (const size_t newlinePos = spokenDescription.find('\n'); newlinePos != std::string_view::npos) + spokenDescription = spokenDescription.substr(newlinePos + 1); + if (!spokenDescription.empty()) { + spoken.append("\n"); + spoken.append(spokenDescription); + } + UiSetSpokenTextOverride(std::move(spoken)); +} bool IsDifficultyAllowed(int value) { diff --git a/Translations/pl.po b/Translations/pl.po index 0ff7be6ff..1beb093c5 100644 --- a/Translations/pl.po +++ b/Translations/pl.po @@ -5409,6 +5409,30 @@ msgstr "Barda" msgid "Barbarian" msgstr "Barbarzyńca" +#: Source/DiabloUI/hero/selhero.cpp:86 +msgid "A powerful fighter who excels in melee combat." +msgstr "Potężny wojownik, który świetnie radzi sobie w walce wręcz." + +#: Source/DiabloUI/hero/selhero.cpp:88 +msgid "A nimble archer who excels at ranged combat." +msgstr "Zwinna łuczniczka, która świetnie radzi sobie w walce na dystans." + +#: Source/DiabloUI/hero/selhero.cpp:90 +msgid "A master of arcane magic who casts powerful spells." +msgstr "Mistrz magii tajemnej, który rzuca potężne zaklęcia." + +#: Source/DiabloUI/hero/selhero.cpp:92 +msgid "A holy warrior skilled in martial arts and staves." +msgstr "Święty wojownik biegły w sztukach walki i władaniu kosturami." + +#: Source/DiabloUI/hero/selhero.cpp:94 +msgid "A versatile fighter who blends melee and archery." +msgstr "Wszechstronna wojowniczka, która łączy walkę wręcz i łucznictwo." + +#: Source/DiabloUI/hero/selhero.cpp:96 +msgid "A fierce warrior who relies on brute strength." +msgstr "Zaciekły wojownik, który polega na brutalnej sile." + #: Source/translation_dummy.cpp:17 msgctxt "monster" msgid "Zombie" From f7da793dcd15ee19a78f7b1b9cade386827c4fc8 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:53:10 -0500 Subject: [PATCH 10/13] access: export navigation speech helpers for use by tracker Move TriggerLabelForSpeech, TownPortalLabelForSpeech, and CollectTownDungeonTriggerIndices out of the anonymous namespace and declare them in navigation_speech.hpp so the tracker module can reuse them for its new DungeonEntrances and Portals categories. Co-Authored-By: Claude Opus 4.5 --- Source/utils/navigation_speech.cpp | 230 ++++++++++++++--------------- Source/utils/navigation_speech.hpp | 10 ++ 2 files changed, 125 insertions(+), 115 deletions(-) diff --git a/Source/utils/navigation_speech.cpp b/Source/utils/navigation_speech.cpp index 15b20f2e9..1b26a7f70 100644 --- a/Source/utils/navigation_speech.cpp +++ b/Source/utils/navigation_speech.cpp @@ -98,75 +98,8 @@ std::optional FindNearestUnexploredTile(Point startPosition) return std::nullopt; } -std::string TriggerLabelForSpeech(const TriggerStruct &trigger) -{ - switch (trigger._tmsg) { - case WM_DIABNEXTLVL: - if (leveltype == DTYPE_TOWN) - return std::string { _("Cathedral entrance") }; - return std::string { _("Stairs down") }; - case WM_DIABPREVLVL: - return std::string { _("Stairs up") }; - case WM_DIABTOWNWARP: - switch (trigger._tlvl) { - case 5: - return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Catacombs")); - case 9: - return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Caves")); - case 13: - return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Hell")); - case 17: - return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Nest")); - case 21: - return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Crypt")); - default: - return fmt::format(fmt::runtime(_("Town warp to level {:d}")), trigger._tlvl); - } - case WM_DIABTWARPUP: - return std::string { _("Warp up") }; - case WM_DIABRETOWN: - return std::string { _("Return to town") }; - case WM_DIABWARPLVL: - return std::string { _("Warp") }; - case WM_DIABSETLVL: - return std::string { _("Set level") }; - case WM_DIABRTNLVL: - return std::string { _("Return level") }; - default: - return std::string { _("Exit") }; - } -} - std::optional LockedTownDungeonTriggerIndex; -std::vector CollectTownDungeonTriggerIndices() -{ - std::vector result; - result.reserve(static_cast(std::max(0, numtrigs))); - - for (int i = 0; i < numtrigs; ++i) { - if (IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP)) - result.push_back(i); - } - - std::sort(result.begin(), result.end(), [](int a, int b) { - const TriggerStruct &ta = trigs[a]; - const TriggerStruct &tb = trigs[b]; - - const int kindA = ta._tmsg == WM_DIABNEXTLVL ? 0 : (ta._tmsg == WM_DIABTOWNWARP ? 1 : 2); - const int kindB = tb._tmsg == WM_DIABNEXTLVL ? 0 : (tb._tmsg == WM_DIABTOWNWARP ? 1 : 2); - if (kindA != kindB) - return kindA < kindB; - - if (ta._tmsg == WM_DIABTOWNWARP && tb._tmsg == WM_DIABTOWNWARP && ta._tlvl != tb._tlvl) - return ta._tlvl < tb._tlvl; - - return a < b; - }); - - return result; -} - std::optional FindDefaultTownDungeonTriggerIndex(const std::vector &candidates) { for (const int index : candidates) { @@ -293,54 +226,6 @@ std::optional FindNearestTownPortalInTown() return best; } -[[nodiscard]] std::string TownPortalLabelForSpeech(const Portal &portal) -{ - if (portal.level <= 0) - return std::string { _("Town portal") }; - - if (portal.setlvl) { - const auto questLevel = static_cast<_setlevels>(portal.level); - const char *questLevelName = QuestLevelNames[questLevel]; - if (questLevelName == nullptr || questLevelName[0] == '\0') - return std::string { _("Town portal to set level") }; - - return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a set/quest level name. */ "Town portal to {:s}")), _(questLevelName)); - } - - constexpr std::array DungeonStrs = { - N_("Town"), - N_("Cathedral"), - N_("Catacombs"), - N_("Caves"), - N_("Hell"), - N_("Nest"), - N_("Crypt"), - }; - std::string dungeonStr; - if (portal.ltype >= DTYPE_TOWN && portal.ltype <= DTYPE_LAST) { - dungeonStr = _(DungeonStrs[static_cast(portal.ltype)]); - } else { - dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None"); - } - - int floor = portal.level; - if (portal.ltype == DTYPE_CATACOMBS) - floor -= 4; - else if (portal.ltype == DTYPE_CAVES) - floor -= 8; - else if (portal.ltype == DTYPE_HELL) - floor -= 12; - else if (portal.ltype == DTYPE_NEST) - floor -= 16; - else if (portal.ltype == DTYPE_CRYPT) - floor -= 20; - - if (floor > 0) - return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name and {:d} is a floor number. */ "Town portal to {:s} {:d}")), dungeonStr, floor); - - return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name. */ "Town portal to {:s}")), dungeonStr); -} - struct QuestSetLevelEntrance { _setlevels questLevel; Point entrancePosition; @@ -430,6 +315,121 @@ void KeyboardWalkKeyPressed(Direction direction) } // namespace +std::string TriggerLabelForSpeech(const TriggerStruct &trigger) +{ + switch (trigger._tmsg) { + case WM_DIABNEXTLVL: + if (leveltype == DTYPE_TOWN) + return std::string { _("Cathedral entrance") }; + return std::string { _("Stairs down") }; + case WM_DIABPREVLVL: + return std::string { _("Stairs up") }; + case WM_DIABTOWNWARP: + switch (trigger._tlvl) { + case 5: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Catacombs")); + case 9: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Caves")); + case 13: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Hell")); + case 17: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Nest")); + case 21: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Crypt")); + default: + return fmt::format(fmt::runtime(_("Town warp to level {:d}")), trigger._tlvl); + } + case WM_DIABTWARPUP: + return std::string { _("Warp up") }; + case WM_DIABRETOWN: + return std::string { _("Return to town") }; + case WM_DIABWARPLVL: + return std::string { _("Warp") }; + case WM_DIABSETLVL: + return std::string { _("Set level") }; + case WM_DIABRTNLVL: + return std::string { _("Return level") }; + default: + return std::string { _("Exit") }; + } +} + +std::string TownPortalLabelForSpeech(const Portal &portal) +{ + if (portal.level <= 0) + return std::string { _("Town portal") }; + + if (portal.setlvl) { + const auto questLevel = static_cast<_setlevels>(portal.level); + const char *questLevelName = QuestLevelNames[questLevel]; + if (questLevelName == nullptr || questLevelName[0] == '\0') + return std::string { _("Town portal to set level") }; + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a set/quest level name. */ "Town portal to {:s}")), _(questLevelName)); + } + + constexpr std::array DungeonStrs = { + N_("Town"), + N_("Cathedral"), + N_("Catacombs"), + N_("Caves"), + N_("Hell"), + N_("Nest"), + N_("Crypt"), + }; + std::string dungeonStr; + if (portal.ltype >= DTYPE_TOWN && portal.ltype <= DTYPE_LAST) { + dungeonStr = _(DungeonStrs[static_cast(portal.ltype)]); + } else { + dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None"); + } + + int floor = portal.level; + if (portal.ltype == DTYPE_CATACOMBS) + floor -= 4; + else if (portal.ltype == DTYPE_CAVES) + floor -= 8; + else if (portal.ltype == DTYPE_HELL) + floor -= 12; + else if (portal.ltype == DTYPE_NEST) + floor -= 16; + else if (portal.ltype == DTYPE_CRYPT) + floor -= 20; + + if (floor > 0) + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name and {:d} is a floor number. */ "Town portal to {:s} {:d}")), dungeonStr, floor); + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name. */ "Town portal to {:s}")), dungeonStr); +} + +std::vector CollectTownDungeonTriggerIndices() +{ + std::vector result; + result.reserve(static_cast(std::max(0, numtrigs))); + + for (int i = 0; i < numtrigs; ++i) { + if (IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP)) + result.push_back(i); + } + + std::sort(result.begin(), result.end(), [](int a, int b) { + const TriggerStruct &ta = trigs[a]; + const TriggerStruct &tb = trigs[b]; + + const int kindA = ta._tmsg == WM_DIABNEXTLVL ? 0 : (ta._tmsg == WM_DIABTOWNWARP ? 1 : 2); + const int kindB = tb._tmsg == WM_DIABNEXTLVL ? 0 : (tb._tmsg == WM_DIABTOWNWARP ? 1 : 2); + if (kindA != kindB) + return kindA < kindB; + + if (ta._tmsg == WM_DIABTOWNWARP && tb._tmsg == WM_DIABTOWNWARP && ta._tlvl != tb._tlvl) + return ta._tlvl < tb._tlvl; + + return a < b; + }); + + return result; +} + void SpeakNearestExitKeyPressed() { if (!CanPlayerTakeAction()) diff --git a/Source/utils/navigation_speech.hpp b/Source/utils/navigation_speech.hpp index 147d0d404..c833671a0 100644 --- a/Source/utils/navigation_speech.hpp +++ b/Source/utils/navigation_speech.hpp @@ -5,8 +5,18 @@ */ #pragma once +#include +#include + namespace devilution { +struct TriggerStruct; +struct Portal; + +std::string TriggerLabelForSpeech(const TriggerStruct &trigger); +std::string TownPortalLabelForSpeech(const Portal &portal); +std::vector CollectTownDungeonTriggerIndices(); + void SpeakNearestExitKeyPressed(); void SpeakNearestTownPortalInTownKeyPressed(); void SpeakNearestStairsDownKeyPressed(); From 4a2eef76c9ece9f479a6425e03afdf9029cf6160 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:53:19 -0500 Subject: [PATCH 11/13] access: add 6 new tracker categories and unified cycling infrastructure Port upstream tracker overhaul into the modular tracker module. Adds Npcs, Players, DungeonEntrances, Stairs, QuestLocations, and Portals categories with collector functions, navigation, and auto-walk support. Replaces CycleTrackerTargetKeyPressed with TrackerPageUp/PageDown/Home key handlers that support Ctrl for category cycling and Shift for auto-walk. Includes review fixes: nullopt guard in auto-walk, removal of default switch clauses in favor of app_fatal, internal linkage for non-public functions, multi-tile object approach, and distance-sorted town dungeon entrances. Co-Authored-By: Claude Opus 4.5 --- Source/controls/tracker.cpp | 1355 +++++++++++++++++++++++++++++++---- Source/controls/tracker.hpp | 12 +- 2 files changed, 1241 insertions(+), 126 deletions(-) diff --git a/Source/controls/tracker.cpp b/Source/controls/tracker.cpp index 39e568090..3d0ff5c94 100644 --- a/Source/controls/tracker.cpp +++ b/Source/controls/tracker.cpp @@ -32,16 +32,23 @@ #include "help.h" #include "items.h" #include "levels/gendung.h" +#include "levels/setmaps.h" #include "levels/tile_properties.hpp" +#include "levels/trigs.h" +#include "missiles.h" #include "monster.h" #include "multi.h" #include "objects.h" #include "player.h" +#include "portal.h" #include "qol/chatlog.h" +#include "quests.h" #include "stores.h" +#include "towners.h" #include "utils/accessibility_announcements.hpp" #include "utils/is_of.hpp" #include "utils/language.h" +#include "utils/navigation_speech.hpp" #include "utils/screen_reader.hpp" #include "utils/sdl_compat.h" #include "utils/str_cat.hpp" @@ -70,12 +77,28 @@ int LockedTrackerObjectId = -1; int LockedTrackerBreakableId = -1; int LockedTrackerMonsterId = -1; int LockedTrackerDeadBodyId = -1; +int LockedTrackerNpcId = -1; +int LockedTrackerPlayerId = -1; +int LockedTrackerDungeonEntranceId = -1; +int LockedTrackerStairsId = -1; +int LockedTrackerQuestLocationId = -1; +int LockedTrackerPortalId = -1; struct TrackerLevelKey { dungeon_type levelType; int currLevel; bool isSetLevel; int setLevelNum; + + friend bool operator==(const TrackerLevelKey &lhs, const TrackerLevelKey &rhs) + { + return lhs.levelType == rhs.levelType && lhs.currLevel == rhs.currLevel + && lhs.isSetLevel == rhs.isSetLevel && lhs.setLevelNum == rhs.setLevelNum; + } + friend bool operator!=(const TrackerLevelKey &lhs, const TrackerLevelKey &rhs) + { + return !(lhs == rhs); + } }; std::optional LockedTrackerLevelKey; @@ -90,6 +113,12 @@ void ClearTrackerLocks() LockedTrackerBreakableId = -1; LockedTrackerMonsterId = -1; LockedTrackerDeadBodyId = -1; + LockedTrackerNpcId = -1; + LockedTrackerPlayerId = -1; + LockedTrackerDungeonEntranceId = -1; + LockedTrackerStairsId = -1; + LockedTrackerQuestLocationId = -1; + LockedTrackerPortalId = -1; } void EnsureTrackerLocksMatchCurrentLevel() @@ -101,8 +130,7 @@ void EnsureTrackerLocksMatchCurrentLevel() .setLevelNum = setlvlnum, }; - if (!LockedTrackerLevelKey || LockedTrackerLevelKey->levelType != current.levelType || LockedTrackerLevelKey->currLevel != current.currLevel - || LockedTrackerLevelKey->isSetLevel != current.isSetLevel || LockedTrackerLevelKey->setLevelNum != current.setLevelNum) { + if (!LockedTrackerLevelKey || *LockedTrackerLevelKey != current) { ClearTrackerLocks(); LockedTrackerLevelKey = current; } @@ -127,6 +155,18 @@ int &LockedTrackerTargetId(TrackerTargetCategory category) return LockedTrackerMonsterId; case TrackerTargetCategory::DeadBodies: return LockedTrackerDeadBodyId; + case TrackerTargetCategory::Npcs: + return LockedTrackerNpcId; + case TrackerTargetCategory::Players: + return LockedTrackerPlayerId; + case TrackerTargetCategory::DungeonEntrances: + return LockedTrackerDungeonEntranceId; + case TrackerTargetCategory::Stairs: + return LockedTrackerStairsId; + case TrackerTargetCategory::QuestLocations: + return LockedTrackerQuestLocationId; + case TrackerTargetCategory::Portals: + return LockedTrackerPortalId; } app_fatal("Invalid TrackerTargetCategory"); } @@ -150,16 +190,27 @@ std::string_view TrackerTargetCategoryLabel(TrackerTargetCategory category) return _("monsters"); case TrackerTargetCategory::DeadBodies: return _("dead bodies"); - default: - return _("items"); + case TrackerTargetCategory::Npcs: + return _("NPCs"); + case TrackerTargetCategory::Players: + return _("players"); + case TrackerTargetCategory::DungeonEntrances: + if (leveltype != DTYPE_TOWN) + return _("exits"); + return _("dungeon entrances"); + case TrackerTargetCategory::Stairs: + return _("stairs"); + case TrackerTargetCategory::QuestLocations: + return _("quest locations"); + case TrackerTargetCategory::Portals: + return _("portals"); } + app_fatal("Invalid TrackerTargetCategory"); } void SpeakTrackerTargetCategory() { - std::string message; - StrAppend(message, _("Tracker target: "), TrackerTargetCategoryLabel(SelectedTrackerTargetCategory)); - SpeakText(message, true); + SpeakText(TrackerTargetCategoryLabel(SelectedTrackerTargetCategory), true); } std::optional FindNearestGroundItemId(Point playerPosition) @@ -270,7 +321,7 @@ struct TrackerCandidate { } } - std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); return result; } @@ -301,7 +352,7 @@ struct TrackerCandidate { } } - std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); return result; } @@ -399,7 +450,7 @@ template }); } - std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); return result; } @@ -477,16 +528,10 @@ template 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 (monster.hitPoints <= 0) + if (!IsTrackedMonster(monster)) continue; - const Point monsterDistancePosition { monster.position.future }; - const int distance = playerPosition.ApproxDistance(monsterDistancePosition); + const int distance = playerPosition.ApproxDistance(monster.position.future); if (distance > maxDistance) continue; @@ -497,7 +542,280 @@ template }); } - std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::vector CollectNpcTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (leveltype != DTYPE_TOWN) + return result; + + result.reserve(GetNumTowners()); + for (size_t i = 0; i < GetNumTowners(); ++i) { + const Towner &towner = Towners[i]; + if (!IsTownerPresent(towner._ttype)) + continue; + + const int distance = playerPosition.WalkingDistance(towner.position); + result.push_back(TrackerCandidate { + .id = static_cast(i), + .distance = distance, + .name = towner.name, + }); + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { + if (a.distance != b.distance) + return a.distance < b.distance; + return a.name.str() < b.name.str(); + }); + return result; +} + +[[nodiscard]] std::vector CollectPlayerTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (!gbIsMultiplayer || MyPlayer == nullptr) + return result; + + result.reserve(MAX_PLRS); + + const uint8_t currentLevel = MyPlayer->plrlevel; + const bool currentIsSetLevel = setlevel; + + for (int i = 0; i < MAX_PLRS; ++i) { + if (i == MyPlayerId) + continue; + const Player &player = Players[i]; + if (!player.plractive) + continue; + if (player._pLvlChanging) + continue; + if (player.plrlevel != currentLevel) + continue; + if (player.plrIsOnSetLevel != currentIsSetLevel) + continue; + + const Point otherPosition = player.position.future; + if (!InDungeonBounds(otherPosition)) + continue; + + const int distance = playerPosition.WalkingDistance(otherPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = player.name(), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::vector CollectDungeonEntranceTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (MyPlayer == nullptr) + return result; + + if (leveltype == DTYPE_TOWN) { + const std::vector candidates = CollectTownDungeonTriggerIndices(); + result.reserve(candidates.size()); + + for (const int triggerIndex : candidates) { + if (triggerIndex < 0 || triggerIndex >= numtrigs) + continue; + const TriggerStruct &trigger = trigs[triggerIndex]; + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = triggerIndex, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; + } + + for (int i = 0; i < numtrigs; ++i) { + const TriggerStruct &trigger = trigs[i]; + if (setlevel) { + if (trigger._tmsg != WM_DIABRTNLVL) + continue; + } else { + if (trigger._tmsg != WM_DIABPREVLVL) + continue; + } + + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::optional FindTownPortalPositionInTownByPortalIndex(int portalIndex) +{ + if (portalIndex < 0 || portalIndex >= MAXPORTAL) + return std::nullopt; + + for (const Missile &missile : Missiles) { + if (missile._mitype != MissileID::TownPortal) + continue; + if (missile._misource != portalIndex) + continue; + return missile.position.tile; + } + + return std::nullopt; +} + +[[nodiscard]] bool IsTownPortalOpenOnCurrentLevel(int portalIndex) +{ + if (portalIndex < 0 || portalIndex >= MAXPORTAL) + return false; + const Portal &portal = Portals[portalIndex]; + if (!portal.open) + return false; + if (portal.setlvl != setlevel) + return false; + if (portal.level != currlevel) + return false; + if (portal.ltype != leveltype) + return false; + return InDungeonBounds(portal.position); +} + +[[nodiscard]] std::vector CollectPortalTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (MyPlayer == nullptr) + return result; + + if (leveltype == DTYPE_TOWN) { + std::array seen {}; + for (const Missile &missile : Missiles) { + if (missile._mitype != MissileID::TownPortal) + continue; + const int portalIndex = missile._misource; + if (portalIndex < 0 || portalIndex >= MAXPORTAL) + continue; + if (seen[portalIndex]) + continue; + seen[portalIndex] = true; + + const Point portalPosition = missile.position.tile; + const int distance = playerPosition.WalkingDistance(portalPosition); + result.push_back(TrackerCandidate { + .id = portalIndex, + .distance = distance, + .name = TownPortalLabelForSpeech(Portals[portalIndex]), + }); + } + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; + } + + for (int i = 0; i < MAXPORTAL; ++i) { + if (!IsTownPortalOpenOnCurrentLevel(i)) + continue; + const Portal &portal = Portals[i]; + const int distance = playerPosition.WalkingDistance(portal.position); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TownPortalLabelForSpeech(portal), + }); + } + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::vector CollectStairsTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) + return result; + + for (int i = 0; i < numtrigs; ++i) { + const TriggerStruct &trigger = trigs[i]; + if (!IsAnyOf(trigger._tmsg, WM_DIABNEXTLVL, WM_DIABPREVLVL)) + continue; + + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::vector CollectQuestLocationTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) + return result; + + if (setlevel) { + for (int i = 0; i < numtrigs; ++i) { + const TriggerStruct &trigger = trigs[i]; + if (trigger._tmsg != WM_DIABRTNLVL) + continue; + + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; + } + + constexpr size_t NumQuests = sizeof(Quests) / sizeof(Quests[0]); + result.reserve(NumQuests); + for (size_t questIndex = 0; questIndex < NumQuests; ++questIndex) { + const Quest &quest = Quests[questIndex]; + if (quest._qslvl == SL_NONE) + continue; + if (quest._qactive == QUEST_NOTAVAIL) + continue; + if (quest._qlevel != currlevel) + continue; + if (!InDungeonBounds(quest.position)) + continue; + + const char *questLevelName = QuestLevelNames[quest._qslvl]; + if (questLevelName == nullptr || questLevelName[0] == '\0') + questLevelName = N_("Set level"); + + const int distance = playerPosition.WalkingDistance(quest.position); + result.push_back(TrackerCandidate { + .id = static_cast(questIndex), + .distance = distance, + .name = _(questLevelName), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); return result; } @@ -520,6 +838,25 @@ template return candidates[nextIdx].id; } +[[nodiscard]] std::optional FindPreviousTrackerCandidateId(const std::vector &candidates, int currentId) +{ + if (candidates.empty()) + return std::nullopt; + if (currentId < 0) + return candidates.back().id; + + const auto it = std::find_if(candidates.begin(), candidates.end(), [currentId](const TrackerCandidate &c) { return c.id == currentId; }); + if (it == candidates.end()) + return candidates.back().id; + + if (candidates.size() <= 1) + return std::nullopt; + + const size_t idx = static_cast(it - candidates.begin()); + const size_t prevIdx = (idx + candidates.size() - 1) % candidates.size(); + return candidates[prevIdx].id; +} + void DecorateTrackerTargetNameWithOrdinalIfNeeded(int targetId, StringOrView &targetName, const std::vector &candidates) { if (targetName.empty()) @@ -608,16 +945,10 @@ std::optional FindNearestMonsterId(Point playerPosition) 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 (monster.hitPoints <= 0) + if (!IsTrackedMonster(monster)) continue; - const Point monsterDistancePosition { monster.position.future }; - const int distance = playerPosition.ApproxDistance(monsterDistancePosition); + const int distance = playerPosition.ApproxDistance(monster.position.future); if (!bestId || distance < bestDistance) { bestId = monsterId; bestDistance = distance; @@ -831,7 +1162,7 @@ bool ValidateAutoWalkObjectTarget( SpeakText(_(inRangeMessage), true); return false; } - destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); + destination = FindBestApproachTileForObject(myPlayer, playerPosition, object); return true; } @@ -871,119 +1202,321 @@ std::optional ResolveObjectTrackerTarget( return targetId; } -} // namespace +[[nodiscard]] std::vector TrackerTargetCategoriesForCurrentLevel() +{ + if (leveltype == DTYPE_TOWN) { + return { + TrackerTargetCategory::Items, + TrackerTargetCategory::DeadBodies, + TrackerTargetCategory::Npcs, + TrackerTargetCategory::Players, + TrackerTargetCategory::DungeonEntrances, + TrackerTargetCategory::Portals, + }; + } + + return { + TrackerTargetCategory::Items, + TrackerTargetCategory::Chests, + TrackerTargetCategory::Doors, + TrackerTargetCategory::Shrines, + TrackerTargetCategory::Objects, + TrackerTargetCategory::Breakables, + TrackerTargetCategory::Monsters, + TrackerTargetCategory::DeadBodies, + TrackerTargetCategory::DungeonEntrances, + TrackerTargetCategory::Stairs, + TrackerTargetCategory::QuestLocations, + TrackerTargetCategory::Players, + TrackerTargetCategory::Portals, + }; +} -void CycleTrackerTargetKeyPressed() +void SelectTrackerTargetCategoryRelative(int delta) { if (!CanPlayerTakeAction() || InGameMenu()) return; AutoWalkTrackerTargetId = -1; - const SDL_Keymod modState = SDL_GetModState(); - const bool cyclePrevious = (modState & SDL_KMOD_SHIFT) != 0; + const std::vector categories = TrackerTargetCategoriesForCurrentLevel(); + if (categories.empty()) + return; - if (cyclePrevious) { - switch (SelectedTrackerTargetCategory) { - case TrackerTargetCategory::Items: - SelectedTrackerTargetCategory = TrackerTargetCategory::DeadBodies; - break; - case TrackerTargetCategory::Chests: - SelectedTrackerTargetCategory = TrackerTargetCategory::Items; - break; - case TrackerTargetCategory::Doors: - SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; - break; - case TrackerTargetCategory::Shrines: - SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; - break; - case TrackerTargetCategory::Objects: - SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; - break; - case TrackerTargetCategory::Breakables: - SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; - break; - case TrackerTargetCategory::Monsters: - SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; - break; - case TrackerTargetCategory::DeadBodies: - default: - SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; - break; - } + auto it = std::find(categories.begin(), categories.end(), SelectedTrackerTargetCategory); + int currentIndex = 0; + if (it == categories.end()) { + currentIndex = delta > 0 ? -1 : 0; } else { - switch (SelectedTrackerTargetCategory) { - case TrackerTargetCategory::Items: - SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; - break; - case TrackerTargetCategory::Chests: - SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; - break; - case TrackerTargetCategory::Doors: - SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; - break; - case TrackerTargetCategory::Shrines: - SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; - break; - case TrackerTargetCategory::Objects: - SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; - break; - case TrackerTargetCategory::Breakables: - SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; - break; - case TrackerTargetCategory::Monsters: - SelectedTrackerTargetCategory = TrackerTargetCategory::DeadBodies; - break; - case TrackerTargetCategory::DeadBodies: - default: - SelectedTrackerTargetCategory = TrackerTargetCategory::Items; - break; - } + currentIndex = static_cast(it - categories.begin()); } + const int count = static_cast(categories.size()); + int newIndex = (currentIndex + delta) % count; + if (newIndex < 0) + newIndex += count; + + SelectedTrackerTargetCategory = categories[static_cast(newIndex)]; SpeakTrackerTargetCategory(); } -void NavigateToTrackerTargetKeyPressed() +[[nodiscard]] std::vector CollectTrackerCandidatesForSelection(TrackerTargetCategory category, Point playerPosition) { - if (!CanPlayerTakeAction() || InGameMenu()) - return; - if (leveltype == DTYPE_TOWN && IsNoneOf(SelectedTrackerTargetCategory, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies)) { - SpeakText(_("Not in a dungeon."), true); - return; + switch (category) { + case TrackerTargetCategory::Items: + return CollectNearbyItemTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Chests: + return CollectNearbyChestTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Doors: { + std::vector candidates = CollectNearbyDoorTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + for (TrackerCandidate &c : candidates) { + if (c.id < 0 || c.id >= MAXOBJECTS) + continue; + c.name = DoorLabelForSpeech(Objects[c.id]); + } + return candidates; } - if (AutomapActive) { - SpeakText(_("Close the map first."), true); - return; + case TrackerTargetCategory::Shrines: + return CollectNearbyShrineTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Objects: + return CollectNearbyObjectInteractableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Breakables: + return CollectNearbyBreakableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Monsters: + return CollectNearbyMonsterTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::DeadBodies: + return CollectNearbyCorpseTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Npcs: + return CollectNpcTrackerCandidates(playerPosition); + case TrackerTargetCategory::Players: + return CollectPlayerTrackerCandidates(playerPosition); + case TrackerTargetCategory::DungeonEntrances: + return CollectDungeonEntranceTrackerCandidates(playerPosition); + case TrackerTargetCategory::Stairs: + return CollectStairsTrackerCandidates(playerPosition); + case TrackerTargetCategory::QuestLocations: + return CollectQuestLocationTrackerCandidates(playerPosition); + case TrackerTargetCategory::Portals: + return CollectPortalTrackerCandidates(playerPosition); } - if (MyPlayer == nullptr) - return; - - EnsureTrackerLocksMatchCurrentLevel(); - - const SDL_Keymod modState = SDL_GetModState(); - const bool cycleTarget = (modState & SDL_KMOD_SHIFT) != 0; - const bool clearTarget = (modState & SDL_KMOD_CTRL) != 0; - - const Point playerPosition = MyPlayer->position.future; - AutoWalkTrackerTargetId = -1; + app_fatal("Invalid TrackerTargetCategory"); +} - int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); - if (clearTarget) { - lockedTargetId = -1; - SpeakText(_("Tracker target cleared."), true); - return; +[[nodiscard]] std::string_view TrackerCategoryNoCandidatesFoundMessage(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("No items found."); + case TrackerTargetCategory::Chests: + return _("No chests found."); + case TrackerTargetCategory::Doors: + return _("No doors found."); + case TrackerTargetCategory::Shrines: + return _("No shrines found."); + case TrackerTargetCategory::Objects: + return _("No objects found."); + case TrackerTargetCategory::Breakables: + return _("No breakables found."); + case TrackerTargetCategory::Monsters: + return _("No monsters found."); + case TrackerTargetCategory::DeadBodies: + return _("No dead bodies found."); + case TrackerTargetCategory::Npcs: + return _("No NPCs found."); + case TrackerTargetCategory::Players: + return _("No players found."); + case TrackerTargetCategory::DungeonEntrances: + if (leveltype != DTYPE_TOWN) + return _("No exits found."); + return _("No dungeon entrances found."); + case TrackerTargetCategory::Stairs: + return _("No stairs found."); + case TrackerTargetCategory::QuestLocations: + return _("No quest locations found."); + case TrackerTargetCategory::Portals: + return _("No portals found."); } + app_fatal("Invalid TrackerTargetCategory"); +} - std::optional targetId; - std::optional targetPosition; - std::optional alternateTargetPosition; - StringOrView targetName; - - switch (SelectedTrackerTargetCategory) { - case TrackerTargetCategory::Items: { - const std::vector nearbyCandidates = CollectNearbyItemTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - if (cycleTarget) { +[[nodiscard]] std::string_view TrackerCategoryNoNextMessage(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("No next item."); + case TrackerTargetCategory::Chests: + return _("No next chest."); + case TrackerTargetCategory::Doors: + return _("No next door."); + case TrackerTargetCategory::Shrines: + return _("No next shrine."); + case TrackerTargetCategory::Objects: + return _("No next object."); + case TrackerTargetCategory::Breakables: + return _("No next breakable."); + case TrackerTargetCategory::Monsters: + return _("No next monster."); + case TrackerTargetCategory::DeadBodies: + return _("No next dead body."); + case TrackerTargetCategory::Npcs: + return _("No next NPC."); + case TrackerTargetCategory::Players: + return _("No next player."); + case TrackerTargetCategory::DungeonEntrances: + return _("No next dungeon entrance."); + case TrackerTargetCategory::Stairs: + return _("No next stairs."); + case TrackerTargetCategory::QuestLocations: + return _("No next quest location."); + case TrackerTargetCategory::Portals: + return _("No next portal."); + } + app_fatal("Invalid TrackerTargetCategory"); +} + +[[nodiscard]] std::string_view TrackerCategoryNoPreviousMessage(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("No previous item."); + case TrackerTargetCategory::Chests: + return _("No previous chest."); + case TrackerTargetCategory::Doors: + return _("No previous door."); + case TrackerTargetCategory::Shrines: + return _("No previous shrine."); + case TrackerTargetCategory::Objects: + return _("No previous object."); + case TrackerTargetCategory::Breakables: + return _("No previous breakable."); + case TrackerTargetCategory::Monsters: + return _("No previous monster."); + case TrackerTargetCategory::DeadBodies: + return _("No previous dead body."); + case TrackerTargetCategory::Npcs: + return _("No previous NPC."); + case TrackerTargetCategory::Players: + return _("No previous player."); + case TrackerTargetCategory::DungeonEntrances: + return _("No previous dungeon entrance."); + case TrackerTargetCategory::Stairs: + return _("No previous stairs."); + case TrackerTargetCategory::QuestLocations: + return _("No previous quest location."); + case TrackerTargetCategory::Portals: + return _("No previous portal."); + } + app_fatal("Invalid TrackerTargetCategory"); +} + +/** + * Returns true if the given tracker category requires a dungeon (i.e. is not + * available in town). + */ +[[nodiscard]] bool IsDungeonOnlyTrackerCategory(TrackerTargetCategory category) +{ + return IsNoneOf(category, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies, + TrackerTargetCategory::Npcs, TrackerTargetCategory::Players, + TrackerTargetCategory::DungeonEntrances, TrackerTargetCategory::Portals); +} + +void SelectTrackerTargetRelative(int delta) +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + if (MyPlayer == nullptr) + return; + + if (leveltype == DTYPE_TOWN && IsDungeonOnlyTrackerCategory(SelectedTrackerTargetCategory)) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + + EnsureTrackerLocksMatchCurrentLevel(); + + const Point playerPosition = MyPlayer->position.future; + AutoWalkTrackerTargetId = -1; + + const std::vector candidates = CollectTrackerCandidatesForSelection(SelectedTrackerTargetCategory, playerPosition); + if (candidates.empty()) { + LockedTrackerTargetId(SelectedTrackerTargetCategory) = -1; + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + return; + } + + int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); + if (candidates.size() == 1) { + lockedTargetId = candidates.front().id; + SpeakText(candidates.front().name.str(), /*force=*/true); + return; + } + const std::optional targetId = delta > 0 ? FindNextTrackerCandidateId(candidates, lockedTargetId) : FindPreviousTrackerCandidateId(candidates, lockedTargetId); + if (!targetId) { + SpeakText(delta > 0 ? TrackerCategoryNoNextMessage(SelectedTrackerTargetCategory) : TrackerCategoryNoPreviousMessage(SelectedTrackerTargetCategory), true); + return; + } + + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == candidates.end()) { + lockedTargetId = -1; + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + return; + } + + lockedTargetId = *targetId; + StringOrView targetName = it->name.str(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, candidates); + SpeakText(targetName.str(), /*force=*/true); +} + +} // namespace + +namespace { + +void NavigateToTrackerTargetKeyPressed() +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + if (leveltype == DTYPE_TOWN && IsDungeonOnlyTrackerCategory(SelectedTrackerTargetCategory)) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + EnsureTrackerLocksMatchCurrentLevel(); + + const SDL_Keymod modState = SDL_GetModState(); + const bool cycleTarget = (modState & SDL_KMOD_SHIFT) != 0; + const bool clearTarget = (modState & SDL_KMOD_CTRL) != 0; + + const Point playerPosition = MyPlayer->position.future; + AutoWalkTrackerTargetId = -1; + + int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); + if (clearTarget) { + lockedTargetId = -1; + SpeakText(_("Tracker target cleared."), true); + return; + } + + std::optional targetId; + std::optional targetPosition; + std::optional alternateTargetPosition; + StringOrView targetName; + + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + const std::vector nearbyCandidates = CollectNearbyItemTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); if (!targetId) { if (nearbyCandidates.empty()) @@ -1312,6 +1845,248 @@ void NavigateToTrackerTargetKeyPressed() } break; } + case TrackerTargetCategory::Npcs: { + const std::vector nearbyCandidates = CollectNpcTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No NPCs found."), true); + else + SpeakText(_("No next NPC."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < static_cast(GetNumTowners())) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No NPCs found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No NPCs found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = Towners[*targetId].name; + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = Towners[*targetId].position; + } + break; + } + case TrackerTargetCategory::Players: { + const std::vector nearbyCandidates = CollectPlayerTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No players found."), true); + else + SpeakText(_("No next player."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAX_PLRS) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No players found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No players found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = Players[*targetId].name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = Players[*targetId].position.future; + } + break; + } + case TrackerTargetCategory::DungeonEntrances: { + const std::vector nearbyCandidates = CollectDungeonEntranceTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No dungeon entrances found."), true); + else + SpeakText(_("No next dungeon entrance."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < numtrigs) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No dungeon entrances found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No dungeon entrances found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = TriggerLabelForSpeech(trigs[*targetId]); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + const TriggerStruct &trigger = trigs[*targetId]; + targetPosition = Point { trigger.position.x, trigger.position.y }; + } + break; + } + case TrackerTargetCategory::Stairs: { + const std::vector nearbyCandidates = CollectStairsTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No stairs found."), true); + else + SpeakText(_("No next stairs."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < numtrigs) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No stairs found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No stairs found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = std::string(it->name.str()); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + const TriggerStruct &trigger = trigs[*targetId]; + targetPosition = Point { trigger.position.x, trigger.position.y }; + } + break; + } + case TrackerTargetCategory::QuestLocations: { + const std::vector nearbyCandidates = CollectQuestLocationTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No quest locations found."), true); + else + SpeakText(_("No next quest location."), true); + return; + } + } else if ((setlevel && lockedTargetId >= 0 && lockedTargetId < numtrigs) || (!setlevel && lockedTargetId >= 0 && lockedTargetId < static_cast(sizeof(Quests) / sizeof(Quests[0])))) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No quest locations found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No quest locations found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = std::string(it->name.str()); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + if (setlevel) { + const TriggerStruct &trigger = trigs[*targetId]; + targetPosition = Point { trigger.position.x, trigger.position.y }; + } else { + const Quest &quest = Quests[static_cast(*targetId)]; + targetPosition = quest.position; + } + } + break; + } + case TrackerTargetCategory::Portals: { + const std::vector nearbyCandidates = CollectPortalTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No portals found."), true); + else + SpeakText(_("No next portal."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXPORTAL) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No portals found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No portals found."), true); + return; + } + + Point portalPosition; + if (leveltype == DTYPE_TOWN) { + const std::optional townPos = FindTownPortalPositionInTownByPortalIndex(*targetId); + if (!townPos) { + lockedTargetId = -1; + SpeakText(_("No portals found."), true); + return; + } + portalPosition = *townPos; + } else { + if (!IsTownPortalOpenOnCurrentLevel(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No portals found."), true); + return; + } + portalPosition = Portals[*targetId].position; + } + + lockedTargetId = *targetId; + targetName = TownPortalLabelForSpeech(Portals[*targetId]); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = portalPosition; + } + break; + } } if (cycleTarget) { @@ -1461,7 +2236,7 @@ void AutoWalkToTrackerTargetKeyPressed() if (!CanPlayerTakeAction() || InGameMenu()) return; - if (leveltype == DTYPE_TOWN) { + if (leveltype == DTYPE_TOWN && IsDungeonOnlyTrackerCategory(SelectedTrackerTargetCategory)) { SpeakText(_("Not in a dungeon."), true); return; } @@ -1570,7 +2345,126 @@ void AutoWalkToTrackerTargetKeyPressed() targetName = _("Dead body"); break; } + case TrackerTargetCategory::Npcs: { + const std::vector candidates = CollectNpcTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No NPCs found."), true); + return; + } + + if (lockedTargetId >= 0 && lockedTargetId < static_cast(GetNumTowners())) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = Towners[*targetId].name; + break; + } + case TrackerTargetCategory::Players: { + const std::vector candidates = CollectPlayerTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No players found."), true); + return; + } + + if (lockedTargetId >= 0 && lockedTargetId < MAX_PLRS) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = Players[*targetId].name(); + break; + } + case TrackerTargetCategory::DungeonEntrances: { + const std::vector candidates = CollectDungeonEntranceTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No dungeon entrances found."), true); + return; + } + + if (lockedTargetId >= 0 && lockedTargetId < numtrigs) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = TriggerLabelForSpeech(trigs[*targetId]); + break; + } + case TrackerTargetCategory::Stairs: { + const std::vector candidates = CollectStairsTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No stairs found."), true); + return; + } + + if (lockedTargetId >= 0 && lockedTargetId < numtrigs) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = TriggerLabelForSpeech(trigs[*targetId]); + break; + } + case TrackerTargetCategory::QuestLocations: { + const std::vector candidates = CollectQuestLocationTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No quest locations found."), true); + return; + } + + if ((setlevel && lockedTargetId >= 0 && lockedTargetId < numtrigs) || (!setlevel && lockedTargetId >= 0 && lockedTargetId < static_cast(sizeof(Quests) / sizeof(Quests[0])))) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = std::string(candidates.front().name.str()); + if (const auto it = std::find_if(candidates.begin(), candidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); it != candidates.end()) + targetName = std::string(it->name.str()); + break; } + case TrackerTargetCategory::Portals: { + const std::vector candidates = CollectPortalTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No portals found."), true); + return; + } + + if (lockedTargetId >= 0 && lockedTargetId < MAXPORTAL) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = TownPortalLabelForSpeech(Portals[*targetId]); + break; + } + } + + if (!targetId) + return; std::string msg; StrAppend(msg, _("Going to: "), targetName); @@ -1581,11 +2475,18 @@ void AutoWalkToTrackerTargetKeyPressed() UpdateAutoWalkTracker(); } +} // namespace + void UpdateAutoWalkTracker() { if (AutoWalkTrackerTargetId < 0) return; - if (leveltype == DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag || InGameMenu()) { + if (IsPlayerInStore() || ChatLogFlag || HelpFlag || InGameMenu()) { + AutoWalkTrackerTargetId = -1; + return; + } + if (leveltype == DTYPE_TOWN + && IsDungeonOnlyTrackerCategory(AutoWalkTrackerTargetCategory)) { AutoWalkTrackerTargetId = -1; return; } @@ -1692,6 +2593,167 @@ void UpdateAutoWalkTracker() destination = corpsePosition; break; } + case TrackerTargetCategory::Npcs: { + const int npcId = AutoWalkTrackerTargetId; + if (leveltype != DTYPE_TOWN || npcId < 0 || npcId >= static_cast(GetNumTowners())) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target NPC is gone."), true); + return; + } + const Towner &towner = Towners[npcId]; + if (!IsTownerPresent(towner._ttype)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target NPC is gone."), true); + return; + } + if (playerPosition.WalkingDistance(towner.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("NPC in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, towner.position); + break; + } + case TrackerTargetCategory::Players: { + const int playerId = AutoWalkTrackerTargetId; + if (playerId < 0 || playerId >= MAX_PLRS) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target player is gone."), true); + return; + } + const Player &player = Players[playerId]; + if (!player.plractive || player._pLvlChanging || player.plrIsOnSetLevel != setlevel || player.plrlevel != MyPlayer->plrlevel) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target player is gone."), true); + return; + } + const Point targetPosition = player.position.future; + if (!InDungeonBounds(targetPosition)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target player is gone."), true); + return; + } + if (playerPosition.WalkingDistance(targetPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Player in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, targetPosition); + break; + } + case TrackerTargetCategory::DungeonEntrances: { + const int triggerIndex = AutoWalkTrackerTargetId; + if (triggerIndex < 0 || triggerIndex >= numtrigs) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target entrance is gone."), true); + return; + } + const TriggerStruct &trigger = trigs[triggerIndex]; + const bool valid = leveltype == DTYPE_TOWN + ? IsAnyOf(trigger._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP) + : (setlevel ? trigger._tmsg == WM_DIABRTNLVL : trigger._tmsg == WM_DIABPREVLVL); + if (!valid) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target entrance is gone."), true); + return; + } + const Point triggerPosition { trigger.position.x, trigger.position.y }; + if (playerPosition.WalkingDistance(triggerPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Entrance in range."), true); + return; + } + destination = triggerPosition; + break; + } + case TrackerTargetCategory::Stairs: { + const int triggerIndex = AutoWalkTrackerTargetId; + if (leveltype == DTYPE_TOWN || triggerIndex < 0 || triggerIndex >= numtrigs) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target stairs are gone."), true); + return; + } + const TriggerStruct &trigger = trigs[triggerIndex]; + if (!IsAnyOf(trigger._tmsg, WM_DIABNEXTLVL, WM_DIABPREVLVL)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target stairs are gone."), true); + return; + } + const Point triggerPosition { trigger.position.x, trigger.position.y }; + if (playerPosition.WalkingDistance(triggerPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Stairs in range."), true); + return; + } + destination = triggerPosition; + break; + } + case TrackerTargetCategory::QuestLocations: { + if (setlevel) { + const int triggerIndex = AutoWalkTrackerTargetId; + if (leveltype == DTYPE_TOWN || triggerIndex < 0 || triggerIndex >= numtrigs) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + const TriggerStruct &trigger = trigs[triggerIndex]; + if (trigger._tmsg != WM_DIABRTNLVL) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + const Point triggerPosition { trigger.position.x, trigger.position.y }; + if (playerPosition.WalkingDistance(triggerPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Quest exit in range."), true); + return; + } + destination = triggerPosition; + break; + } + + const int questIndex = AutoWalkTrackerTargetId; + if (questIndex < 0 || questIndex >= static_cast(sizeof(Quests) / sizeof(Quests[0]))) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + const Quest &quest = Quests[static_cast(questIndex)]; + if (quest._qslvl == SL_NONE || quest._qactive == QUEST_NOTAVAIL || quest._qlevel != currlevel || !InDungeonBounds(quest.position)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + if (playerPosition.WalkingDistance(quest.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Quest entrance in range."), true); + return; + } + destination = quest.position; + break; + } + case TrackerTargetCategory::Portals: { + const int portalIndex = AutoWalkTrackerTargetId; + std::optional portalPosition; + if (leveltype == DTYPE_TOWN) { + portalPosition = FindTownPortalPositionInTownByPortalIndex(portalIndex); + } else if (IsTownPortalOpenOnCurrentLevel(portalIndex)) { + portalPosition = Portals[portalIndex].position; + } + + if (!portalPosition) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target portal is gone."), true); + return; + } + if (playerPosition.WalkingDistance(*portalPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Portal in range."), true); + return; + } + destination = *portalPosition; + break; + } } if (!destination) { @@ -1742,6 +2804,53 @@ void UpdateAutoWalkTracker() NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, waypoint); } +void TrackerPageUpKeyPressed() +{ + const SDL_Keymod modState = SDL_GetModState(); + const bool cycleCategory = (modState & SDL_KMOD_CTRL) != 0; + + if (cycleCategory) { + SelectTrackerTargetCategoryRelative(-1); + if (MyPlayer != nullptr) { + const Point playerPosition = MyPlayer->position.future; + if (CollectTrackerCandidatesForSelection(SelectedTrackerTargetCategory, playerPosition).empty()) + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + } + return; + } + + SelectTrackerTargetRelative(-1); +} + +void TrackerPageDownKeyPressed() +{ + const SDL_Keymod modState = SDL_GetModState(); + const bool cycleCategory = (modState & SDL_KMOD_CTRL) != 0; + + if (cycleCategory) { + SelectTrackerTargetCategoryRelative(+1); + if (MyPlayer != nullptr) { + const Point playerPosition = MyPlayer->position.future; + if (CollectTrackerCandidatesForSelection(SelectedTrackerTargetCategory, playerPosition).empty()) + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + } + return; + } + + SelectTrackerTargetRelative(+1); +} + +void TrackerHomeKeyPressed() +{ + const SDL_Keymod modState = SDL_GetModState(); + const bool autoWalk = (modState & SDL_KMOD_SHIFT) != 0; + + if (autoWalk) + AutoWalkToTrackerTargetKeyPressed(); + else + NavigateToTrackerTargetKeyPressed(); +} + void ResetAutoWalkTracker() { AutoWalkTrackerTargetId = -1; diff --git a/Source/controls/tracker.hpp b/Source/controls/tracker.hpp index 6d0c6735a..62063ab70 100644 --- a/Source/controls/tracker.hpp +++ b/Source/controls/tracker.hpp @@ -18,11 +18,17 @@ enum class TrackerTargetCategory : uint8_t { Breakables, Monsters, DeadBodies, + Npcs, + Players, + DungeonEntrances, + Stairs, + QuestLocations, + Portals, }; -void CycleTrackerTargetKeyPressed(); -void NavigateToTrackerTargetKeyPressed(); -void AutoWalkToTrackerTargetKeyPressed(); +void TrackerPageUpKeyPressed(); +void TrackerPageDownKeyPressed(); +void TrackerHomeKeyPressed(); void UpdateAutoWalkTracker(); void ResetAutoWalkTracker(); From 63b36db1879c3c272c2014d00328d227e9965299 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:53:24 -0500 Subject: [PATCH 12/13] access: speak mana percentage with Shift+health key Hold Shift when pressing the health percentage key to hear mana instead. Extract ComputePercentage helper to deduplicate the HP/mana calculation. Co-Authored-By: Claude Opus 4.5 --- Source/controls/accessibility_keys.cpp | 35 +++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/Source/controls/accessibility_keys.cpp b/Source/controls/accessibility_keys.cpp index ea8b1d802..3e076ce03 100644 --- a/Source/controls/accessibility_keys.cpp +++ b/Source/controls/accessibility_keys.cpp @@ -13,7 +13,14 @@ #include #include "control/control.hpp" +#ifdef USE_SDL3 +#include +#else +#include +#endif + #include "controls/plrctrls.h" +#include "utils/sdl_compat.h" #include "diablo.h" #include "gamemenu.h" #include "help.h" @@ -38,6 +45,18 @@ namespace devilution { +namespace { + +/** Computes a rounded percentage (0--100) from a current and maximum value. */ +int ComputePercentage(int current, int maximum) +{ + const int clamped = std::max(current, 0); + int percent = static_cast((static_cast(clamped) * 100 + maximum / 2) / maximum); + return std::clamp(percent, 0, 100); +} + +} // namespace + void SpeakPlayerHealthPercentageKeyPressed() { if (!CanPlayerTakeAction()) @@ -45,14 +64,18 @@ void SpeakPlayerHealthPercentageKeyPressed() if (MyPlayer == nullptr) return; - const int maxHp = MyPlayer->_pMaxHP; - if (maxHp <= 0) + const SDL_Keymod modState = SDL_GetModState(); + const bool speakMana = (modState & SDL_KMOD_SHIFT) != 0; + if (speakMana) { + if (MyPlayer->_pMaxMana <= 0) + return; + SpeakText(fmt::format("{:d}%", ComputePercentage(MyPlayer->_pMana, MyPlayer->_pMaxMana)), /*force=*/true); return; + } - const int currentHp = std::max(MyPlayer->_pHitPoints, 0); - int hpPercent = static_cast((static_cast(currentHp) * 100 + maxHp / 2) / maxHp); - hpPercent = std::clamp(hpPercent, 0, 100); - SpeakText(fmt::format("{:d}%", hpPercent), /*force=*/true); + if (MyPlayer->_pMaxHP <= 0) + return; + SpeakText(fmt::format("{:d}%", ComputePercentage(MyPlayer->_pHitPoints, MyPlayer->_pMaxHP)), /*force=*/true); } void SpeakExperienceToNextLevelKeyPressed() From 0750821c01846c897af975ac489388dfd905e721 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:53:31 -0500 Subject: [PATCH 13/13] access: overhaul tracker keybindings in InitKeymapActions Replace 7 individual navigation/tracker key registrations with 3 unified tracker keys: PageUp (previous), PageDown (next), Home (navigate/walk). Unbind legacy town NPC nav keys (now superseded by tracker Npcs category). Update health key description to mention Shift+mana. Co-Authored-By: Claude Opus 4.5 --- Source/diablo.cpp | 80 ++++++++++++++--------------------------------- 1 file changed, 24 insertions(+), 56 deletions(-) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index f936badea..6faa5cdde 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -1931,7 +1931,7 @@ void InitKeymapActions() "ListTownNpcs", N_("List town NPCs"), N_("Speaks a list of town NPCs."), - SDLK_F4, + SDLK_UNKNOWN, ListTownNpcsKeyPressed, nullptr, CanPlayerTakeAction); @@ -1939,7 +1939,7 @@ void InitKeymapActions() "PreviousTownNpc", N_("Previous town NPC"), N_("Select previous town NPC (speaks)."), - SDLK_PAGEUP, + SDLK_UNKNOWN, SelectPreviousTownNpcKeyPressed, nullptr, IsTownNpcActionAllowed); @@ -1947,7 +1947,7 @@ void InitKeymapActions() "NextTownNpc", N_("Next town NPC"), N_("Select next town NPC (speaks)."), - SDLK_PAGEDOWN, + SDLK_UNKNOWN, SelectNextTownNpcKeyPressed, nullptr, IsTownNpcActionAllowed); @@ -1955,7 +1955,7 @@ void InitKeymapActions() "SpeakSelectedTownNpc", N_("Speak selected town NPC"), N_("Speaks the currently selected town NPC."), - SDLK_END, + SDLK_UNKNOWN, SpeakSelectedTownNpc, nullptr, IsTownNpcActionAllowed); @@ -1963,7 +1963,7 @@ void InitKeymapActions() "GoToSelectedTownNpc", N_("Go to selected town NPC"), N_("Walks to the selected town NPC."), - SDLK_HOME, + SDLK_UNKNOWN, GoToSelectedTownNpcKeyPressed, nullptr, IsTownNpcActionAllowed); @@ -1976,53 +1976,29 @@ void InitKeymapActions() nullptr, CanPlayerTakeAction); options.Keymapper.AddAction( - "SpeakNearestExit", - N_("Nearest exit"), - N_("Speaks the nearest exit. Hold Shift for quest entrances (or to leave a quest level). In town, press Ctrl+E to cycle dungeon entrances."), - 'E', - SpeakNearestExitKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpeakNearestStairsDown", - N_("Nearest stairs down"), - N_("Speaks directions to the nearest stairs down."), - '.', - SpeakNearestStairsDownKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && leveltype != DTYPE_TOWN; }); - options.Keymapper.AddAction( - "SpeakNearestStairsUp", - N_("Nearest stairs up"), - N_("Speaks directions to the nearest stairs up."), - ',', - SpeakNearestStairsUpKeyPressed, + "TrackerPrevious", + N_("Tracker previous"), + N_("PageUp: previous target. Ctrl+PageUp: previous category."), + SDLK_PAGEUP, + TrackerPageUpKeyPressed, nullptr, - []() { return CanPlayerTakeAction() && leveltype != DTYPE_TOWN; }); + []() { return CanPlayerTakeAction() && !InGameMenu() && !IsPlayerInStore() && !ChatLogFlag; }); options.Keymapper.AddAction( - "CycleTrackerTarget", - N_("Cycle tracker target"), - N_("Cycles what the tracker looks for (items, chests, doors, shrines, objects, breakables, monsters, dead bodies). Hold Shift to cycle backwards."), - 'T', - CycleTrackerTargetKeyPressed, + "TrackerNext", + N_("Tracker next"), + N_("PageDown: next target. Ctrl+PageDown: next category."), + SDLK_PAGEDOWN, + TrackerPageDownKeyPressed, nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); + []() { return CanPlayerTakeAction() && !InGameMenu() && !IsPlayerInStore() && !ChatLogFlag; }); options.Keymapper.AddAction( - "NavigateToTrackerTarget", - N_("Tracker directions"), - N_("Speaks directions to a tracked target of the selected tracker category. Shift+N: cycle targets (speaks name only). Ctrl+N: clear target."), - 'N', - NavigateToTrackerTargetKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Keymapper.AddAction( - "AutoWalkToTrackerTarget", - N_("Walk to tracker target"), - N_("Automatically walks to the currently selected tracker target. Press again to cancel."), - 'M', - AutoWalkToTrackerTargetKeyPressed, + "TrackerGo", + N_("Tracker go"), + N_("Home: speak directions to the selected target. Shift+Home: auto-walk to the selected target (press again to cancel)."), + SDLK_HOME, + TrackerHomeKeyPressed, nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); + []() { return CanPlayerTakeAction() && !InGameMenu() && !IsPlayerInStore() && !ChatLogFlag; }); options.Keymapper.AddAction( "KeyboardWalkNorth", N_("Walk north"), @@ -2161,7 +2137,7 @@ void InitKeymapActions() options.Keymapper.AddAction( "SpeakPlayerHealthPercentage", N_("Health percentage"), - N_("Speaks the player's health as a percentage."), + N_("Speaks the player's health as a percentage. Hold Shift for mana."), 'Z', SpeakPlayerHealthPercentageKeyPressed, nullptr, @@ -2271,14 +2247,6 @@ void InitKeymapActions() DebugToggle = !DebugToggle; }); #endif - options.Keymapper.AddAction( - "SpeakNearestTownPortal", - N_("Nearest town portal"), - N_("Speaks directions to the nearest open town portal in town."), - 'P', - SpeakNearestTownPortalInTownKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && leveltype == DTYPE_TOWN; }); options.Keymapper.CommitActions(); }