From 4fab16d06663fed217ce1233938ec390732d6307 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:39:07 -0500 Subject: [PATCH 1/4] Normalize line endings to CRLF in diablo.cpp and plrctrls.cpp These files had mixed CRLF/LF line endings. Normalize to CRLF to match the .editorconfig convention for C++ source files. Co-Authored-By: Claude Opus 4.5 --- Source/controls/plrctrls.cpp | 514 +-- Source/diablo.cpp | 8282 +++++++++++++++++----------------- 2 files changed, 4398 insertions(+), 4398 deletions(-) diff --git a/Source/controls/plrctrls.cpp b/Source/controls/plrctrls.cpp index 32b4b0ac3..e27997574 100644 --- a/Source/controls/plrctrls.cpp +++ b/Source/controls/plrctrls.cpp @@ -1,14 +1,14 @@ #include "controls/plrctrls.h" -#include -#include -#include -#include -#include - -#ifdef USE_SDL3 -#include -#include +#include +#include +#include +#include +#include + +#ifdef USE_SDL3 +#include +#include #include #else #include @@ -16,14 +16,14 @@ #ifdef USE_SDL1 #include "utils/sdl2_to_1_2_backports.h" #endif -#endif - -#include - -#include "automap.h" -#include "control/control.hpp" -#include "controls/controller_motion.h" -#ifndef USE_SDL1 +#endif + +#include + +#include "automap.h" +#include "control/control.hpp" +#include "controls/controller_motion.h" +#ifndef USE_SDL1 #include "controls/devices/game_controller.h" #endif #include "controls/control_mode.hpp" @@ -49,16 +49,16 @@ #include "panels/ui_panels.hpp" #include "qol/chatlog.h" #include "qol/stash.h" -#include "stores.h" -#include "towners.h" -#include "track.h" -#include "utils/format_int.hpp" -#include "utils/is_of.hpp" -#include "utils/language.h" -#include "utils/log.hpp" -#include "utils/screen_reader.hpp" -#include "utils/sdl_compat.h" -#include "utils/str_cat.hpp" +#include "stores.h" +#include "towners.h" +#include "track.h" +#include "utils/format_int.hpp" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/log.hpp" +#include "utils/screen_reader.hpp" +#include "utils/sdl_compat.h" +#include "utils/str_cat.hpp" namespace devilution { @@ -675,104 +675,104 @@ Point GetSlotCoord(int slot) /** * Return the item id of the current slot */ -int GetItemIdOnSlot(int slot) -{ - if (slot >= SLOTXY_INV_FIRST && slot <= SLOTXY_INV_LAST) { - return std::abs(MyPlayer->InvGrid[slot - SLOTXY_INV_FIRST]); - } - - return 0; -} - -StringOrView GetInventorySlotNameForSpeech(int slot) -{ - switch (slot) { - case SLOTXY_HEAD: - return _("Head"); - case SLOTXY_RING_LEFT: - return _("Left ring"); - case SLOTXY_RING_RIGHT: - return _("Right ring"); - case SLOTXY_AMULET: - return _("Amulet"); - case SLOTXY_HAND_LEFT: - return _("Left hand"); - case SLOTXY_HAND_RIGHT: - return _("Right hand"); - case SLOTXY_CHEST: - return _("Chest"); - default: - break; - } - - if (slot >= SLOTXY_BELT_FIRST && slot <= SLOTXY_BELT_LAST) - return StrCat(_("Belt"), " ", slot - SLOTXY_BELT_FIRST + 1); - - return _("Inventory"); -} - -void SpeakInventorySlotForAccessibility() -{ - if (MyPlayer == nullptr) - return; - - const Player &player = *MyPlayer; - const Item *item = nullptr; - - if (Slot >= SLOTXY_BELT_FIRST && Slot <= SLOTXY_BELT_LAST) { - item = &player.SpdList[Slot - SLOTXY_BELT_FIRST]; - } else if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_INV_LAST) { - const int invId = GetItemIdOnSlot(Slot); - if (invId != 0) - item = &player.InvList[invId - 1]; - } else { - switch (Slot) { - case SLOTXY_HEAD: - item = &player.InvBody[INVLOC_HEAD]; - break; - case SLOTXY_RING_LEFT: - item = &player.InvBody[INVLOC_RING_LEFT]; - break; - case SLOTXY_RING_RIGHT: - item = &player.InvBody[INVLOC_RING_RIGHT]; - break; - case SLOTXY_AMULET: - item = &player.InvBody[INVLOC_AMULET]; - break; - case SLOTXY_HAND_LEFT: - item = &player.InvBody[INVLOC_HAND_LEFT]; - break; - case SLOTXY_HAND_RIGHT: { - const Item &left = player.InvBody[INVLOC_HAND_LEFT]; - if (!left.isEmpty() && player.GetItemLocation(left) == ILOC_TWOHAND) - item = &left; - else - item = &player.InvBody[INVLOC_HAND_RIGHT]; - } break; - case SLOTXY_CHEST: - item = &player.InvBody[INVLOC_CHEST]; - break; - default: - break; - } - } - - if (item != nullptr && !item->isEmpty()) { - if (item->_itype == ItemType::Gold) { - const int nGold = item->_ivalue; - SpeakText(fmt::format(fmt::runtime(ngettext("{:s} gold piece", "{:s} gold pieces", nGold)), FormatInteger(nGold)), /*force=*/true); - } else { - SpeakText(item->getName(), /*force=*/true); - } - return; - } - - SpeakText(StrCat(GetInventorySlotNameForSpeech(Slot), ": ", _("empty")), /*force=*/true); -} - -/** - * Get item size (grid size) on the slot specified. Returns 1x1 if none exists. - */ +int GetItemIdOnSlot(int slot) +{ + if (slot >= SLOTXY_INV_FIRST && slot <= SLOTXY_INV_LAST) { + return std::abs(MyPlayer->InvGrid[slot - SLOTXY_INV_FIRST]); + } + + return 0; +} + +StringOrView GetInventorySlotNameForSpeech(int slot) +{ + switch (slot) { + case SLOTXY_HEAD: + return _("Head"); + case SLOTXY_RING_LEFT: + return _("Left ring"); + case SLOTXY_RING_RIGHT: + return _("Right ring"); + case SLOTXY_AMULET: + return _("Amulet"); + case SLOTXY_HAND_LEFT: + return _("Left hand"); + case SLOTXY_HAND_RIGHT: + return _("Right hand"); + case SLOTXY_CHEST: + return _("Chest"); + default: + break; + } + + if (slot >= SLOTXY_BELT_FIRST && slot <= SLOTXY_BELT_LAST) + return StrCat(_("Belt"), " ", slot - SLOTXY_BELT_FIRST + 1); + + return _("Inventory"); +} + +void SpeakInventorySlotForAccessibility() +{ + if (MyPlayer == nullptr) + return; + + const Player &player = *MyPlayer; + const Item *item = nullptr; + + if (Slot >= SLOTXY_BELT_FIRST && Slot <= SLOTXY_BELT_LAST) { + item = &player.SpdList[Slot - SLOTXY_BELT_FIRST]; + } else if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_INV_LAST) { + const int invId = GetItemIdOnSlot(Slot); + if (invId != 0) + item = &player.InvList[invId - 1]; + } else { + switch (Slot) { + case SLOTXY_HEAD: + item = &player.InvBody[INVLOC_HEAD]; + break; + case SLOTXY_RING_LEFT: + item = &player.InvBody[INVLOC_RING_LEFT]; + break; + case SLOTXY_RING_RIGHT: + item = &player.InvBody[INVLOC_RING_RIGHT]; + break; + case SLOTXY_AMULET: + item = &player.InvBody[INVLOC_AMULET]; + break; + case SLOTXY_HAND_LEFT: + item = &player.InvBody[INVLOC_HAND_LEFT]; + break; + case SLOTXY_HAND_RIGHT: { + const Item &left = player.InvBody[INVLOC_HAND_LEFT]; + if (!left.isEmpty() && player.GetItemLocation(left) == ILOC_TWOHAND) + item = &left; + else + item = &player.InvBody[INVLOC_HAND_RIGHT]; + } break; + case SLOTXY_CHEST: + item = &player.InvBody[INVLOC_CHEST]; + break; + default: + break; + } + } + + if (item != nullptr && !item->isEmpty()) { + if (item->_itype == ItemType::Gold) { + const int nGold = item->_ivalue; + SpeakText(fmt::format(fmt::runtime(ngettext("{:s} gold piece", "{:s} gold pieces", nGold)), FormatInteger(nGold)), /*force=*/true); + } else { + SpeakText(item->getName(), /*force=*/true); + } + return; + } + + SpeakText(StrCat(GetInventorySlotNameForSpeech(Slot), ": ", _("empty")), /*force=*/true); +} + +/** + * Get item size (grid size) on the slot specified. Returns 1x1 if none exists. + */ Size GetItemSizeOnSlot(int slot) { if (slot >= SLOTXY_INV_FIRST && slot <= SLOTXY_INV_LAST) { @@ -1238,14 +1238,14 @@ void InventoryMove(AxisDirection dir) mousePos.y += ((itemSize.height - 1) * InventorySlotSizeInPixels.height) / 2; } - if (mousePos == MousePosition) { - SpeakInventorySlotForAccessibility(); - return; // Avoid wobbling when scaled - } - - SetCursorPos(mousePos); - SpeakInventorySlotForAccessibility(); -} + if (mousePos == MousePosition) { + SpeakInventorySlotForAccessibility(); + return; // Avoid wobbling when scaled + } + + SetCursorPos(mousePos); + SpeakInventorySlotForAccessibility(); +} /** * Move the cursor around in the inventory @@ -1443,12 +1443,12 @@ void StashMove(AxisDirection dir) FocusOnInventory(); } -void HotSpellMoveInternal(AxisDirection dir) -{ - static AxisDirectionRepeater repeater; - dir = repeater.Get(dir); - if (dir.x == AxisDirectionX_NONE && dir.y == AxisDirectionY_NONE) - return; +void HotSpellMoveInternal(AxisDirection dir) +{ + static AxisDirectionRepeater repeater; + dir = repeater.Get(dir); + if (dir.x == AxisDirectionX_NONE && dir.y == AxisDirectionY_NONE) + return; auto spellListItems = GetSpellListItems(); @@ -1591,14 +1591,14 @@ void StoreMove(AxisDirection moveDir) using HandleLeftStickOrDPadFn = void (*)(devilution::AxisDirection); -HandleLeftStickOrDPadFn GetLeftStickOrDPadGameUIHandler() -{ - if (SpellSelectFlag) { - return &HotSpellMoveInternal; - } - if (IsStashOpen) { - return &StashMove; - } +HandleLeftStickOrDPadFn GetLeftStickOrDPadGameUIHandler() +{ + if (SpellSelectFlag) { + return &HotSpellMoveInternal; + } + if (IsStashOpen) { + return &StashMove; + } if (invflag) { return &CheckInventoryMove; } @@ -1826,16 +1826,16 @@ void LogGamepadChange(GamepadLayout newGamepad) } #endif -} // namespace - -void HotSpellMove(AxisDirection dir) -{ - HotSpellMoveInternal(dir); -} - -void DetectInputMethod(const SDL_Event &event, const ControllerButtonEvent &gamepadEvent) -{ - ControlTypes inputType = GetInputTypeFromEvent(event); +} // namespace + +void HotSpellMove(AxisDirection dir) +{ + HotSpellMoveInternal(dir); +} + +void DetectInputMethod(const SDL_Event &event, const ControllerButtonEvent &gamepadEvent) +{ + ControlTypes inputType = GetInputTypeFromEvent(event); if (inputType == ControlTypes::None) return; @@ -2005,20 +2005,20 @@ void InvalidateInventorySlot() /** * @brief Moves the mouse to the first inventory slot. */ -void FocusOnInventory() -{ - Slot = SLOTXY_INV_FIRST; - ResetInvCursorPosition(); - SpeakInventorySlotForAccessibility(); -} - -void InventoryMoveFromKeyboard(AxisDirection dir) -{ - if (!invflag) - return; - - CheckInventoryMove(dir); -} +void FocusOnInventory() +{ + Slot = SLOTXY_INV_FIRST; + ResetInvCursorPosition(); + SpeakInventorySlotForAccessibility(); +} + +void InventoryMoveFromKeyboard(AxisDirection dir) +{ + if (!invflag) + return; + + CheckInventoryMove(dir); +} bool PointAndClickState = false; @@ -2090,10 +2090,10 @@ void plrctrls_after_game_logic() Movement(*MyPlayer); } -void UseBeltItem(BeltItemType type) -{ - for (int i = 0; i < MaxBeltItems; i++) { - const Item &item = MyPlayer->SpdList[i]; +void UseBeltItem(BeltItemType type) +{ + for (int i = 0; i < MaxBeltItems; i++) { + const Item &item = MyPlayer->SpdList[i]; if (item.isEmpty()) { continue; } @@ -2105,55 +2105,55 @@ void UseBeltItem(BeltItemType type) if ((type == BeltItemType::Healing && isHealing) || (type == BeltItemType::Mana && isMana)) { UseInvItem(INVITEM_BELT_FIRST + i); break; - } - } -} - -namespace { - -void UpdateTargetsForKeyboardAction() -{ - // Clear focus set by cursor. - PlayerUnderCursor = nullptr; - pcursmonst = -1; - pcursitem = -1; - ObjectUnderCursor = nullptr; - - pcursmissile = nullptr; - pcurstrig = -1; - pcursquest = Q_INVALID; - cursPosition = { -1, -1 }; - - if (MyPlayer == nullptr) - return; - if (MyPlayer->_pInvincible) - return; - if (DoomFlag) - return; - if (invflag) - return; - - InfoString = StringOrView {}; - FindActor(); - FindItemOrObject(); - FindTrigger(); -} - -} // namespace - -void PerformPrimaryActionAutoTarget() -{ - if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { - UpdateTargetsForKeyboardAction(); - } - PerformPrimaryAction(); -} - -void PerformPrimaryAction() -{ - if (SpellSelectFlag) { - SetSpell(); - return; + } + } +} + +namespace { + +void UpdateTargetsForKeyboardAction() +{ + // Clear focus set by cursor. + PlayerUnderCursor = nullptr; + pcursmonst = -1; + pcursitem = -1; + ObjectUnderCursor = nullptr; + + pcursmissile = nullptr; + pcurstrig = -1; + pcursquest = Q_INVALID; + cursPosition = { -1, -1 }; + + if (MyPlayer == nullptr) + return; + if (MyPlayer->_pInvincible) + return; + if (DoomFlag) + return; + if (invflag) + return; + + InfoString = StringOrView {}; + FindActor(); + FindItemOrObject(); + FindTrigger(); +} + +} // namespace + +void PerformPrimaryActionAutoTarget() +{ + if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { + UpdateTargetsForKeyboardAction(); + } + PerformPrimaryAction(); +} + +void PerformPrimaryAction() +{ + if (SpellSelectFlag) { + SetSpell(); + return; } if (invflag) { // inventory is open @@ -2176,9 +2176,9 @@ void PerformPrimaryAction() ReleaseChrBtns(false); return; } - - Interact(); -} + + Interact(); +} bool SpellHasActorTarget() { @@ -2322,11 +2322,11 @@ void CtrlUseInvItem() } } -void CtrlUseStashItem() -{ - if (pcursstashitem == StashStruct::EmptyCell) { - return; - } +void CtrlUseStashItem() +{ + if (pcursstashitem == StashStruct::EmptyCell) { + return; + } const Item &item = Stash.stashList[pcursstashitem]; if (item.isScroll()) { @@ -2342,31 +2342,31 @@ void CtrlUseStashItem() CheckStashItem(MousePosition, true, false); // Auto-equip if it's equipment } else { UseStashItem(pcursstashitem); - } - // Todo reset cursor position if item is moved -} - -void PerformSecondaryActionAutoTarget() -{ - if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { - UpdateTargetsForKeyboardAction(); - } - PerformSecondaryAction(); -} - -void PerformSpellActionAutoTarget() -{ - if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { - UpdateTargetsForKeyboardAction(); - } - PerformSpellAction(); -} - -void PerformSecondaryAction() -{ - Player &myPlayer = *MyPlayer; - if (invflag) { - if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { + } + // Todo reset cursor position if item is moved +} + +void PerformSecondaryActionAutoTarget() +{ + if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { + UpdateTargetsForKeyboardAction(); + } + PerformSecondaryAction(); +} + +void PerformSpellActionAutoTarget() +{ + if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { + UpdateTargetsForKeyboardAction(); + } + PerformSpellAction(); +} + +void PerformSecondaryAction() +{ + Player &myPlayer = *MyPlayer; + if (invflag) { + if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { TryIconCurs(); NewCursor(CURSOR_HAND); } else if (IsStashOpen) { diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 10190e4b4..4534cd92a 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -1,16 +1,16 @@ -/** - * @file diablo.cpp - * - * Implementation of the main game initialization functions. - */ -#include -#include -#include -#include -#include -#include -#include -#include +/** + * @file diablo.cpp + * + * Implementation of the main game initialization functions. + */ +#include +#include +#include +#include +#include +#include +#include +#include #ifdef USE_SDL3 #include @@ -52,13 +52,13 @@ #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 "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" @@ -73,13 +73,13 @@ #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 "levels/tile_properties.hpp" -#include "lighting.h" -#include "loadsave.h" -#include "lua/lua_global.hpp" +#include "levels/themes.h" +#include "levels/town.h" +#include "levels/trigs.h" +#include "levels/tile_properties.hpp" +#include "lighting.h" +#include "loadsave.h" +#include "lua/lua_global.hpp" #include "menu.h" #include "minitext.h" #include "missiles.h" @@ -87,16 +87,16 @@ #include "multi.h" #include "nthread.h" #include "objects.h" -#include "options.h" -#include "panels/console.hpp" -#include "panels/info_box.hpp" -#include "panels/charpanel.hpp" -#include "panels/partypanel.hpp" -#include "panels/spell_book.hpp" -#include "panels/spell_list.hpp" -#include "pfile.h" -#include "portal.h" -#include "plrmsg.h" +#include "options.h" +#include "panels/console.hpp" +#include "panels/info_box.hpp" +#include "panels/charpanel.hpp" +#include "panels/partypanel.hpp" +#include "panels/spell_book.hpp" +#include "panels/spell_list.hpp" +#include "pfile.h" +#include "portal.h" +#include "plrmsg.h" #include "qol/chatlog.h" #include "qol/floatingnumbers.h" #include "qol/itemlabels.h" @@ -112,18 +112,18 @@ #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/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" @@ -167,32 +167,32 @@ 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 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; +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 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 */ @@ -619,178 +619,178 @@ void PressKey(SDL_Keycode vkey, uint16_t modState) 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; - } -} + 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) { @@ -1612,11 +1612,11 @@ void UnstuckChargers() } } -void UpdateMonsterLights() -{ - for (size_t i = 0; i < ActiveMonsterCount; i++) { - Monster &monster = Monsters[ActiveMonsters[i]]; - +void UpdateMonsterLights() +{ + for (size_t i = 0; i < ActiveMonsterCount; i++) { + Monster &monster = Monsters[ActiveMonsters[i]]; + if ((monster.flags & MFLAG_BERSERK) != 0) { const int lightRadius = leveltype == DTYPE_NEST ? 9 : 3; monster.lightId = AddLight(monster.position.tile, lightRadius); @@ -1633,3616 +1633,3616 @@ void UpdateMonsterLights() 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(); - - // Door state values are defined in `Source/objects.cpp` (DOOR_CLOSED=0, DOOR_OPEN=1, DOOR_BLOCKED=2). - constexpr int DoorClosed = 0; - constexpr int DoorOpen = 1; - constexpr int DoorBlocked = 2; - - // 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 == DoorOpen) - return _("Open Grate Door"); - if (door._oVar4 == DoorClosed) - return _("Closed Grate Door"); - if (door._oVar4 == DoorBlocked) - 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) + } +} + +#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(); + + // Door state values are defined in `Source/objects.cpp` (DOOR_CLOSED=0, DOOR_OPEN=1, DOOR_BLOCKED=2). + constexpr int DoorClosed = 0; + constexpr int DoorOpen = 1; + constexpr int DoorBlocked = 2; + + // 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 == DoorOpen) + return _("Open Grate Door"); + if (door._oVar4 == DoorClosed) + return _("Closed Grate Door"); + if (door._oVar4 == DoorBlocked) + 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; + gGameLogicStep = GameLogicStep::ProcessItems; + ProcessItems(); + ProcessLightList(); + ProcessVisionList(); + UpdateBossHealthAnnouncements(); + UpdateProximityAudioCues(); + UpdateAttackableMonsterAnnouncements(); + UpdateInteractableDoorAnnouncements(); + } else { + gGameLogicStep = GameLogicStep::ProcessTowners; + ProcessTowners(); + gGameLogicStep = GameLogicStep::ProcessItemsTown; ProcessItems(); - gGameLogicStep = GameLogicStep::ProcessMissilesTown; - ProcessMissiles(); - } - - UpdatePlayerLowHpWarningSound(); - - gGameLogicStep = GameLogicStep::None; - -#ifdef _DEBUG - if (DebugScrollViewEnabled && (SDL_GetModState() & SDL_KMOD_SHIFT) != 0) { + gGameLogicStep = GameLogicStep::ProcessMissilesTown; + ProcessMissiles(); + } + + UpdatePlayerLowHpWarningSound(); + + gGameLogicStep = GameLogicStep::None; + +#ifdef _DEBUG + if (DebugScrollViewEnabled && (SDL_GetModState() & SDL_KMOD_SHIFT) != 0) { ScrollView(); } -#endif +#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, +}; + +TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; +TrackerTargetCategory AutoWalkTrackerTargetCategory = TrackerTargetCategory::Items; +int AutoWalkTrackerTargetId = -1; + +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 { + +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; + +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; +} + +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: + default: + return LockedTrackerMonsterId; + } +} + +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"); + 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::Monsters; + 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: + default: + SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; + 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: + 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; +} + +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]] 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; +} + +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; +} + +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); + if (object != nullptr && object->isDoor() && object->_oSolidFlag) { + return DoorBlockInfo { .beforeDoor = position, .doorPosition = next }; + } + 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->_oSolidFlag) { + return TrackerPathBlockInfo { + .type = TrackerPathBlockType::Door, + .stepIndex = i, + .beforeBlock = position, + .blockPosition = next, + }; + } + 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) { + 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: + default: + 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 (monster.isInvalid || (monster.flags & MFLAG_HIDDEN) != 0 || monster.hitPoints <= 0) { + 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; + } + + 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 + +void UpdateAutoWalkTracker() +{ + if (AutoWalkTrackerTargetId < 0) + return; + if (leveltype == DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag) { + AutoWalkTrackerTargetId = -1; + return; + } + if (!CanPlayerTakeAction()) + return; + + if (MyPlayer == nullptr) + 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; + 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: { + const int objectId = AutoWalkTrackerTargetId; + if (objectId < 0 || objectId >= MAXOBJECTS) { + AutoWalkTrackerTargetId = -1; + return; + } + const Object &object = Objects[objectId]; + if (!object.IsChest() || !object.canInteractWith()) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target chest is gone."), true); + return; + } + if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Chest in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); + break; + } + case TrackerTargetCategory::Monsters: + default: { + const int monsterId = AutoWalkTrackerTargetId; + if (monsterId < 0 || monsterId >= static_cast(MaxMonsters)) { + AutoWalkTrackerTargetId = -1; + return; + } + const Monster &monster = Monsters[monsterId]; + if (monster.isInvalid || (monster.flags & MFLAG_HIDDEN) != 0 || monster.hitPoints <= 0) { + 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; + } + } + + 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 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) +{ + 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; - sound_update(); - CheckTriggers(); - CheckQuests(); - RedrawViewport(); - pfile_update(false); + 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); - plrctrls_after_game_logic(); + SpeakText(message, true); } -void TimeoutCursor(bool bTimeout) +void SpeakPlayerHealthPercentageKeyPressed() { - 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; + if (!CanPlayerTakeAction()) + return; + if (MyPlayer == nullptr) + return; - DvlNetLatencies latencies = DvlNet_GetLatencies(i); + const int maxHp = MyPlayer->_pMaxHP; + if (maxHp <= 0) + return; - std::string ping = fmt::format( - fmt::runtime(_(/* TRANSLATORS: {:s} means: Character Name */ "Player {:s} is timing out!")), - Players[i].name()); + 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); +} - StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Echo latency: {:d} ms")), latencies.echoLatency)); +void SpeakExperienceToNextLevelKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (MyPlayer == nullptr) + return; - 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(); + 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); } -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; +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 { - 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, -}; - -TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; -TrackerTargetCategory AutoWalkTrackerTargetCategory = TrackerTargetCategory::Items; -int AutoWalkTrackerTargetId = -1; - -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 { - -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; - -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; -} - -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: - default: - return LockedTrackerMonsterId; - } -} - -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"); - 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::Monsters; - 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: - default: - SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; - 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: - 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; -} - -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]] 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; -} - -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; -} - -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); - if (object != nullptr && object->isDoor() && object->_oSolidFlag) { - return DoorBlockInfo { .beforeDoor = position, .doorPosition = next }; - } - 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->_oSolidFlag) { - return TrackerPathBlockInfo { - .type = TrackerPathBlockType::Door, - .stepIndex = i, - .beforeBlock = position, - .blockPosition = next, - }; - } - 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) { - 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: - default: - 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 (monster.isInvalid || (monster.flags & MFLAG_HIDDEN) != 0 || monster.hitPoints <= 0) { - 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; - } - - 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 - -void UpdateAutoWalkTracker() -{ - if (AutoWalkTrackerTargetId < 0) - return; - if (leveltype == DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag) { - AutoWalkTrackerTargetId = -1; - return; - } - if (!CanPlayerTakeAction()) - return; - - if (MyPlayer == nullptr) - 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; - 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: { - const int objectId = AutoWalkTrackerTargetId; - if (objectId < 0 || objectId >= MAXOBJECTS) { - AutoWalkTrackerTargetId = -1; - return; - } - const Object &object = Objects[objectId]; - if (!object.IsChest() || !object.canInteractWith()) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target chest is gone."), true); - return; - } - if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Chest in range."), true); - return; - } - destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); - break; - } - case TrackerTargetCategory::Monsters: - default: { - const int monsterId = AutoWalkTrackerTargetId; - if (monsterId < 0 || monsterId >= static_cast(MaxMonsters)) { - AutoWalkTrackerTargetId = -1; - return; - } - const Monster &monster = Monsters[monsterId]; - if (monster.isInvalid || (monster.flags & MFLAG_HIDDEN) != 0 || monster.hitPoints <= 0) { - 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; - } - } - - 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 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) -{ - 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; + 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) { @@ -5253,13 +5253,13 @@ void InventoryKeyPressed() SetCursorPos(MousePosition - Displacement { 160, 0 }); } } - } - SpellbookFlag = false; - CloseGoldWithdraw(); - CloseStash(); - if (invflag) - FocusOnInventory(); -} + } + SpellbookFlag = false; + CloseGoldWithdraw(); + CloseStash(); + if (invflag) + FocusOnInventory(); +} void CharacterSheetKeyPressed() { @@ -5305,55 +5305,55 @@ void QuestLogKeyPressed() } } 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(); + 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; -} + 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 }); + 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) { @@ -5566,173 +5566,173 @@ void InitKeymapActions() 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). 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( - "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', + 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). 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( + "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', + 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); @@ -5787,39 +5787,39 @@ void InitKeymapActions() }, 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( + "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)"), @@ -5871,27 +5871,27 @@ void InitKeymapActions() }, 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', + 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); }); @@ -5908,19 +5908,19 @@ void InitKeymapActions() "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(); -} + 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() { @@ -6418,17 +6418,17 @@ void InitPadmapActions() 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 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() { @@ -7231,13 +7231,13 @@ tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir) #endif LoadGameLevelStartMusic(neededTrack); - CompleteProgress(); - - LoadGameLevelCalculateCursor(); - if (leveltype != DTYPE_TOWN) - SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); - return {}; -} + CompleteProgress(); + + LoadGameLevelCalculateCursor(); + if (leveltype != DTYPE_TOWN) + SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); + return {}; +} bool game_loop(bool bStartup) { From e1606268b7f08e0daea6b69a7bb32f4b58d90cac Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:40:07 -0500 Subject: [PATCH 2/4] Add auto-walk to tracker target (M key) Allow the player to automatically walk toward the currently selected tracker target by pressing M. Supports all tracker categories: items, chests, doors, shrines, objects, breakables, and monsters. The walk stops when the target is within interaction range or becomes invalid. Co-Authored-By: Claude Opus 4.5 --- Source/diablo.cpp | 277 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 4534cd92a..b8eb822cd 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -179,6 +179,7 @@ char gszVersionNumber[64] = "internal version unknown"; 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); @@ -3865,6 +3866,86 @@ void UpdateAutoWalkTracker() destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); break; } + case TrackerTargetCategory::Doors: { + const int objectId = AutoWalkTrackerTargetId; + if (objectId < 0 || objectId >= MAXOBJECTS) { + AutoWalkTrackerTargetId = -1; + return; + } + const Object &object = Objects[objectId]; + if (!IsTrackedDoorObject(object)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target door is gone."), true); + return; + } + if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Door in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); + break; + } + case TrackerTargetCategory::Shrines: { + const int objectId = AutoWalkTrackerTargetId; + if (objectId < 0 || objectId >= MAXOBJECTS) { + AutoWalkTrackerTargetId = -1; + return; + } + const Object &object = Objects[objectId]; + if (!IsShrineLikeObject(object)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target shrine is gone."), true); + return; + } + if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Shrine in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); + break; + } + case TrackerTargetCategory::Objects: { + const int objectId = AutoWalkTrackerTargetId; + if (objectId < 0 || objectId >= MAXOBJECTS) { + AutoWalkTrackerTargetId = -1; + return; + } + const Object &object = Objects[objectId]; + if (!IsTrackedMiscInteractableObject(object)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target object is gone."), true); + return; + } + if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Object in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); + break; + } + case TrackerTargetCategory::Breakables: { + const int objectId = AutoWalkTrackerTargetId; + if (objectId < 0 || objectId >= MAXOBJECTS) { + AutoWalkTrackerTargetId = -1; + return; + } + const Object &object = Objects[objectId]; + if (!IsTrackedBreakableObject(object)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target breakable is gone."), true); + return; + } + if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Breakable in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); + break; + } case TrackerTargetCategory::Monsters: default: { const int monsterId = AutoWalkTrackerTargetId; @@ -3937,6 +4018,194 @@ void UpdateAutoWalkTracker() NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, waypoint); } +void AutoWalkToTrackerTargetKeyPressed() +{ + 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) + 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: { + if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestUnopenedChestObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No chests found."), true); + return; + } + if (!IsTrackedChestObject(Objects[*targetId])) { + lockedTargetId = -1; + targetId = FindNearestUnopenedChestObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No chests found."), true); + return; + } + } + lockedTargetId = *targetId; + targetName = Objects[*targetId].name(); + break; + } + case TrackerTargetCategory::Doors: { + if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestDoorObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No doors found."), true); + return; + } + if (!IsTrackedDoorObject(Objects[*targetId])) { + lockedTargetId = -1; + targetId = FindNearestDoorObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No doors found."), true); + return; + } + } + lockedTargetId = *targetId; + targetName = DoorLabelForSpeech(Objects[*targetId]); + break; + } + case TrackerTargetCategory::Shrines: { + if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestShrineObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No shrines found."), true); + return; + } + if (!IsShrineLikeObject(Objects[*targetId])) { + lockedTargetId = -1; + targetId = FindNearestShrineObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No shrines found."), true); + return; + } + } + lockedTargetId = *targetId; + targetName = Objects[*targetId].name(); + break; + } + case TrackerTargetCategory::Objects: { + if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestMiscInteractableObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No objects found."), true); + return; + } + if (!IsTrackedMiscInteractableObject(Objects[*targetId])) { + lockedTargetId = -1; + targetId = FindNearestMiscInteractableObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No objects found."), true); + return; + } + } + lockedTargetId = *targetId; + targetName = Objects[*targetId].name(); + break; + } + case TrackerTargetCategory::Breakables: { + if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestBreakableObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No breakables found."), true); + return; + } + if (!IsTrackedBreakableObject(Objects[*targetId])) { + lockedTargetId = -1; + targetId = FindNearestBreakableObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No breakables found."), true); + return; + } + } + lockedTargetId = *targetId; + targetName = Objects[*targetId].name(); + break; + } + case TrackerTargetCategory::Monsters: + default: { + 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 (monster.isInvalid || (monster.flags & MFLAG_HIDDEN) != 0 || monster.hitPoints <= 0) { + lockedTargetId = -1; + targetId = FindNearestMonsterId(playerPosition); + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + } + lockedTargetId = *targetId; + targetName = Monsters[*targetId].name(); + break; + } + } + + std::string msg; + StrAppend(msg, _("Going to: "), targetName); + SpeakText(msg, true); + + AutoWalkTrackerTargetId = *targetId; + AutoWalkTrackerTargetCategory = SelectedTrackerTargetCategory; + UpdateAutoWalkTracker(); +} + void ListTownNpcsKeyPressed() { if (leveltype != DTYPE_TOWN) { @@ -5663,6 +5932,14 @@ void InitKeymapActions() NavigateToTrackerTargetKeyPressed, nullptr, []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "AutoWalkToTrackerTarget", + N_("Walk to tracker target"), + N_("Automatically walks to the currently selected tracker target."), + 'M', + AutoWalkToTrackerTargetKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); options.Keymapper.AddAction( "KeyboardWalkNorth", N_("Walk north"), From 6b38cdb18ffd3f9c87986a668cdb6d3920c11e54 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:41:43 -0500 Subject: [PATCH 3/4] Fix auto-walk tracker bugs and improve UX - Fix OBJ_SIGNCHEST validation mismatch (Chests case now uses ValidateAutoWalkObjectTarget matching IsTrackedChestObject) - Add spoken feedback for all silent early returns (bounds checks, MyPlayer nullptr) - M key now toggles: press again to cancel in-progress auto-walk - A/Shift+A cancels auto-walk silently before attacking - Auto-walk cancels when in-game menu opens - Extract IsTrackedMonster() predicate (replaces 3 inline checks) - Remove default: from Monsters switch cases to enable -Wswitch exhaustiveness checking - Add defensive re-validation in ResolveObjectTrackerTarget - Add documentation comments Co-Authored-By: Claude Opus 4.5 --- Source/controls/plrctrls.cpp | 2 + Source/diablo.cpp | 398 +++++++++++++++++------------------ Source/diablo.h | 1 + 3 files changed, 193 insertions(+), 208 deletions(-) diff --git a/Source/controls/plrctrls.cpp b/Source/controls/plrctrls.cpp index e27997574..43fe6f878 100644 --- a/Source/controls/plrctrls.cpp +++ b/Source/controls/plrctrls.cpp @@ -30,6 +30,7 @@ #include "controls/game_controls.h" #include "controls/touch/gamepad.h" #include "cursor.h" +#include "diablo.h" #include "doom.h" #include "engine/point.hpp" #include "engine/points_in_rectangle_range.hpp" @@ -2143,6 +2144,7 @@ void UpdateTargetsForKeyboardAction() void PerformPrimaryActionAutoTarget() { + CancelAutoWalk(); if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { UpdateTargetsForKeyboardAction(); } diff --git a/Source/diablo.cpp b/Source/diablo.cpp index b8eb822cd..915d3dfea 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -2237,8 +2237,8 @@ enum class TrackerTargetCategory : uint8_t { }; TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; -TrackerTargetCategory AutoWalkTrackerTargetCategory = TrackerTargetCategory::Items; -int AutoWalkTrackerTargetId = -1; +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) { @@ -2515,6 +2515,8 @@ void UpdateAutoWalkTownNpc() 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; @@ -2578,9 +2580,9 @@ int &LockedTrackerTargetId(TrackerTargetCategory category) case TrackerTargetCategory::Breakables: return LockedTrackerBreakableId; case TrackerTargetCategory::Monsters: - default: return LockedTrackerMonsterId; } + app_fatal("Invalid TrackerTargetCategory"); } std::string_view TrackerTargetCategoryLabel(TrackerTargetCategory category) @@ -2643,7 +2645,6 @@ void CycleTrackerTargetKeyPressed() SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; break; case TrackerTargetCategory::Monsters: - default: SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; break; } @@ -2668,7 +2669,6 @@ void CycleTrackerTargetKeyPressed() SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; break; case TrackerTargetCategory::Monsters: - default: SelectedTrackerTargetCategory = TrackerTargetCategory::Items; break; } @@ -2796,6 +2796,13 @@ struct TrackerCandidate { 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) { @@ -3619,8 +3626,7 @@ void NavigateToTrackerTargetKeyPressed() } break; } - case TrackerTargetCategory::Monsters: - default: + case TrackerTargetCategory::Monsters: { const std::vector nearbyCandidates = CollectNearbyMonsterTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); if (cycleTarget) { targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); @@ -3642,7 +3648,7 @@ void NavigateToTrackerTargetKeyPressed() } const Monster &monster = Monsters[*targetId]; - if (monster.isInvalid || (monster.flags & MFLAG_HIDDEN) != 0 || monster.hitPoints <= 0) { + if (!IsTrackedMonster(monster)) { lockedTargetId = -1; targetId = FindNearestMonsterId(playerPosition); if (!targetId) { @@ -3661,6 +3667,7 @@ void NavigateToTrackerTargetKeyPressed() } break; } + } if (cycleTarget) { SpeakText(targetName.str(), /*force=*/true); @@ -3800,19 +3807,108 @@ void NavigateToTrackerTargetKeyPressed() } // 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) { + if (leveltype == DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag || InGameMenu()) { AutoWalkTrackerTargetId = -1; return; } if (!CanPlayerTakeAction()) return; - if (MyPlayer == nullptr) + if (MyPlayer == nullptr) { + SpeakText(_("Cannot walk right now."), true); return; + } if (MyPlayer->_pmode != PM_STAND) return; if (MyPlayer->walkpath[0] != WALK_NONE) @@ -3830,6 +3926,7 @@ void UpdateAutoWalkTracker() const int itemId = AutoWalkTrackerTargetId; if (itemId < 0 || itemId > MAXITEMS) { AutoWalkTrackerTargetId = -1; + SpeakText(_("Target item is gone."), true); return; } if (!IsGroundItemPresent(itemId)) { @@ -3846,115 +3943,36 @@ void UpdateAutoWalkTracker() destination = item.position; break; } - case TrackerTargetCategory::Chests: { - const int objectId = AutoWalkTrackerTargetId; - if (objectId < 0 || objectId >= MAXOBJECTS) { - AutoWalkTrackerTargetId = -1; - return; - } - const Object &object = Objects[objectId]; - if (!object.IsChest() || !object.canInteractWith()) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target chest is gone."), true); - return; - } - if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Chest in range."), true); + case TrackerTargetCategory::Chests: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, + IsTrackedChestObject, N_("Target chest is gone."), N_("Chest in range."), destination)) return; - } - destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); break; - } - case TrackerTargetCategory::Doors: { - const int objectId = AutoWalkTrackerTargetId; - if (objectId < 0 || objectId >= MAXOBJECTS) { - AutoWalkTrackerTargetId = -1; - return; - } - const Object &object = Objects[objectId]; - if (!IsTrackedDoorObject(object)) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target door is gone."), true); - return; - } - if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Door in range."), true); + case TrackerTargetCategory::Doors: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedDoorObject, N_("Target door is gone."), N_("Door in range."), destination)) return; - } - destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); break; - } - case TrackerTargetCategory::Shrines: { - const int objectId = AutoWalkTrackerTargetId; - if (objectId < 0 || objectId >= MAXOBJECTS) { - AutoWalkTrackerTargetId = -1; - return; - } - const Object &object = Objects[objectId]; - if (!IsShrineLikeObject(object)) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target shrine is gone."), true); - return; - } - if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Shrine in range."), true); + case TrackerTargetCategory::Shrines: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsShrineLikeObject, N_("Target shrine is gone."), N_("Shrine in range."), destination)) return; - } - destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); break; - } - case TrackerTargetCategory::Objects: { - const int objectId = AutoWalkTrackerTargetId; - if (objectId < 0 || objectId >= MAXOBJECTS) { - AutoWalkTrackerTargetId = -1; - return; - } - const Object &object = Objects[objectId]; - if (!IsTrackedMiscInteractableObject(object)) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target object is gone."), true); - return; - } - if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Object in range."), true); + case TrackerTargetCategory::Objects: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedMiscInteractableObject, N_("Target object is gone."), N_("Object in range."), destination)) return; - } - destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); break; - } - case TrackerTargetCategory::Breakables: { - const int objectId = AutoWalkTrackerTargetId; - if (objectId < 0 || objectId >= MAXOBJECTS) { - AutoWalkTrackerTargetId = -1; - return; - } - const Object &object = Objects[objectId]; - if (!IsTrackedBreakableObject(object)) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Target breakable is gone."), true); - return; - } - if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { - AutoWalkTrackerTargetId = -1; - SpeakText(_("Breakable in range."), true); + case TrackerTargetCategory::Breakables: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedBreakableObject, N_("Target breakable is gone."), N_("Breakable in range."), destination)) return; - } - destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); break; - } - case TrackerTargetCategory::Monsters: - default: { + 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 (monster.isInvalid || (monster.flags & MFLAG_HIDDEN) != 0 || monster.hitPoints <= 0) { + if (!IsTrackedMonster(monster)) { AutoWalkTrackerTargetId = -1; SpeakText(_("Target monster is gone."), true); return; @@ -3981,6 +3999,9 @@ void UpdateAutoWalkTracker() 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); @@ -4008,6 +4029,8 @@ void UpdateAutoWalkTracker() } } + // 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; @@ -4018,10 +4041,27 @@ void UpdateAutoWalkTracker() 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() { + // 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; + + // Cancel in-progress auto-walk + if (AutoWalkTrackerTargetId >= 0) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Walk cancelled."), true); + return; + } + if (leveltype == DTYPE_TOWN) { SpeakText(_("Not in a dungeon."), true); return; @@ -4030,8 +4070,10 @@ void AutoWalkToTrackerTargetKeyPressed() SpeakText(_("Close the map first."), true); return; } - if (MyPlayer == nullptr) + if (MyPlayer == nullptr) { + SpeakText(_("Cannot walk right now."), true); return; + } EnsureTrackerLocksMatchCurrentLevel(); @@ -4061,118 +4103,47 @@ void AutoWalkToTrackerTargetKeyPressed() targetName = Items[*targetId].getName(); break; } - case TrackerTargetCategory::Chests: { - if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = FindNearestUnopenedChestObjectId(playerPosition); - } - if (!targetId) { - SpeakText(_("No chests found."), true); + case TrackerTargetCategory::Chests: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, + IsTrackedChestObject, FindNearestUnopenedChestObjectId, + [](int id) -> StringOrView { return Objects[id].name(); }, + N_("No chests found."), targetName); + if (!targetId) return; - } - if (!IsTrackedChestObject(Objects[*targetId])) { - lockedTargetId = -1; - targetId = FindNearestUnopenedChestObjectId(playerPosition); - if (!targetId) { - SpeakText(_("No chests found."), true); - return; - } - } - lockedTargetId = *targetId; - targetName = Objects[*targetId].name(); break; - } - case TrackerTargetCategory::Doors: { - if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = FindNearestDoorObjectId(playerPosition); - } - if (!targetId) { - SpeakText(_("No doors found."), true); + case TrackerTargetCategory::Doors: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, + IsTrackedDoorObject, FindNearestDoorObjectId, + [](int id) -> StringOrView { return DoorLabelForSpeech(Objects[id]); }, + N_("No doors found."), targetName); + if (!targetId) return; - } - if (!IsTrackedDoorObject(Objects[*targetId])) { - lockedTargetId = -1; - targetId = FindNearestDoorObjectId(playerPosition); - if (!targetId) { - SpeakText(_("No doors found."), true); - return; - } - } - lockedTargetId = *targetId; - targetName = DoorLabelForSpeech(Objects[*targetId]); break; - } - case TrackerTargetCategory::Shrines: { - if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = FindNearestShrineObjectId(playerPosition); - } - if (!targetId) { - SpeakText(_("No shrines found."), true); + case TrackerTargetCategory::Shrines: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, + IsShrineLikeObject, FindNearestShrineObjectId, + [](int id) -> StringOrView { return Objects[id].name(); }, + N_("No shrines found."), targetName); + if (!targetId) return; - } - if (!IsShrineLikeObject(Objects[*targetId])) { - lockedTargetId = -1; - targetId = FindNearestShrineObjectId(playerPosition); - if (!targetId) { - SpeakText(_("No shrines found."), true); - return; - } - } - lockedTargetId = *targetId; - targetName = Objects[*targetId].name(); break; - } - case TrackerTargetCategory::Objects: { - if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = FindNearestMiscInteractableObjectId(playerPosition); - } - if (!targetId) { - SpeakText(_("No objects found."), true); + case TrackerTargetCategory::Objects: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, + IsTrackedMiscInteractableObject, FindNearestMiscInteractableObjectId, + [](int id) -> StringOrView { return Objects[id].name(); }, + N_("No objects found."), targetName); + if (!targetId) return; - } - if (!IsTrackedMiscInteractableObject(Objects[*targetId])) { - lockedTargetId = -1; - targetId = FindNearestMiscInteractableObjectId(playerPosition); - if (!targetId) { - SpeakText(_("No objects found."), true); - return; - } - } - lockedTargetId = *targetId; - targetName = Objects[*targetId].name(); break; - } - case TrackerTargetCategory::Breakables: { - if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { - targetId = lockedTargetId; - } else { - targetId = FindNearestBreakableObjectId(playerPosition); - } - if (!targetId) { - SpeakText(_("No breakables found."), true); + case TrackerTargetCategory::Breakables: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, + IsTrackedBreakableObject, FindNearestBreakableObjectId, + [](int id) -> StringOrView { return Objects[id].name(); }, + N_("No breakables found."), targetName); + if (!targetId) return; - } - if (!IsTrackedBreakableObject(Objects[*targetId])) { - lockedTargetId = -1; - targetId = FindNearestBreakableObjectId(playerPosition); - if (!targetId) { - SpeakText(_("No breakables found."), true); - return; - } - } - lockedTargetId = *targetId; - targetName = Objects[*targetId].name(); break; - } - case TrackerTargetCategory::Monsters: - default: { + case TrackerTargetCategory::Monsters: { if (lockedTargetId >= 0 && lockedTargetId < static_cast(MaxMonsters)) { targetId = lockedTargetId; } else { @@ -4183,7 +4154,7 @@ void AutoWalkToTrackerTargetKeyPressed() return; } const Monster &monster = Monsters[*targetId]; - if (monster.isInvalid || (monster.flags & MFLAG_HIDDEN) != 0 || monster.hitPoints <= 0) { + if (!IsTrackedMonster(monster)) { lockedTargetId = -1; targetId = FindNearestMonsterId(playerPosition); if (!targetId) { @@ -5700,8 +5671,19 @@ void OptionLanguageCodeChanged() const auto OptionChangeHandlerLanguage = (GetOptions().Language.code.SetValueChangedCallback(OptionLanguageCodeChanged), true); +void CancelAutoWalkInternal() +{ + AutoWalkTrackerTargetId = -1; + AutoWalkTownNpcTarget = -1; +} + } // namespace +void CancelAutoWalk() +{ + CancelAutoWalkInternal(); +} + void InitKeymapActions() { Options &options = GetOptions(); @@ -5935,7 +5917,7 @@ void InitKeymapActions() options.Keymapper.AddAction( "AutoWalkToTrackerTarget", N_("Walk to tracker target"), - N_("Automatically walks to the currently selected tracker target."), + N_("Automatically walks to the currently selected tracker target. Press again to cancel."), 'M', AutoWalkToTrackerTargetKeyPressed, nullptr, diff --git a/Source/diablo.h b/Source/diablo.h index ad28ebb82..a487302d1 100644 --- a/Source/diablo.h +++ b/Source/diablo.h @@ -102,6 +102,7 @@ void DisableInputEventHandler(const SDL_Event &event, uint16_t modState); tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir); bool IsDiabloAlive(bool playSFX); void PrintScreen(SDL_Keycode vkey); +void CancelAutoWalk(); /** * @param bStartup Process additional ticks before returning From 233386b9bcc1959d14a6ddb929903532f1ba7f26 Mon Sep 17 00:00:00 2001 From: hidwood <78058766+hidwood@users.noreply.github.com> Date: Wed, 28 Jan 2026 23:44:41 -0500 Subject: [PATCH 4/4] Fix auto-walk cancellation not stopping player movement CancelAutoWalk() now sends a walk-to-current-position command to immediately clear the walk path buffer, instead of only preventing new segments. Move the M key cancellation check above the CanPlayerTakeAction guard so it works mid-walk. Add CancelAutoWalk() calls to secondary action (D), spell action (W), and arrow-key walk so all player inputs properly cancel an in-progress auto-walk. Co-Authored-By: Claude Opus 4.5 --- Source/controls/plrctrls.cpp | 2 ++ Source/diablo.cpp | 18 +++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Source/controls/plrctrls.cpp b/Source/controls/plrctrls.cpp index 43fe6f878..01f2f7130 100644 --- a/Source/controls/plrctrls.cpp +++ b/Source/controls/plrctrls.cpp @@ -2350,6 +2350,7 @@ void CtrlUseStashItem() void PerformSecondaryActionAutoTarget() { + CancelAutoWalk(); if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { UpdateTargetsForKeyboardAction(); } @@ -2358,6 +2359,7 @@ void PerformSecondaryActionAutoTarget() void PerformSpellActionAutoTarget() { + CancelAutoWalk(); if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { UpdateTargetsForKeyboardAction(); } diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 915d3dfea..5e8760384 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -4050,18 +4050,19 @@ void UpdateAutoWalkTracker() */ void AutoWalkToTrackerTargetKeyPressed() { - // 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; - - // Cancel in-progress auto-walk + // 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) { - AutoWalkTrackerTargetId = -1; + 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; @@ -5319,6 +5320,7 @@ bool IsKeyboardWalkAllowed() void KeyboardWalkKeyPressed(Direction direction) { + CancelAutoWalk(); if (!IsKeyboardWalkAllowed()) return; @@ -5682,6 +5684,8 @@ void CancelAutoWalkInternal() void CancelAutoWalk() { CancelAutoWalkInternal(); + if (MyPlayer != nullptr) + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, MyPlayer->position.future); } void InitKeymapActions()