diff --git a/.gitignore b/.gitignore index 434309e31dc..f1e7d0933bb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ comparer-config.toml #ignore cmake cache /build-*/ +/build/releases/ .vscode/tasks.json # Extra files in the source distribution (see make_src_dist.py) diff --git a/CMake/Dependencies.cmake b/CMake/Dependencies.cmake index a574210b2b0..d6c638b3dbb 100644 --- a/CMake/Dependencies.cmake +++ b/CMake/Dependencies.cmake @@ -51,6 +51,8 @@ add_subdirectory(3rdParty/sol2) if(SCREEN_READER_INTEGRATION) if(WIN32) add_subdirectory(3rdParty/tolk) + elseif(ANDROID) + # Android uses native accessibility API, no external dependency needed else() find_package(Speechd REQUIRED) endif() diff --git a/README.md b/README.md index da9c2f090e1..7c84f54ce28 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,14 @@ Keybinds are configurable, but these are the defaults most players will use: ## Navigation / tracking -- `T` - cycle tracker target (items / chests / doors / shrines / objects / breakables / monsters). -- `N` - tracker directions to the nearest target (speaks target name + directions). -- `Shift`+`N` - cycle to the next target in the current tracker category (speaks name only; duplicates get ordinal numbers). -- `Ctrl`+`N` - clear the tracker target. +- `Ctrl`+`PageUp` / `Ctrl`+`PageDown` - previous / next tracker category. +- `PageUp` / `PageDown` - select previous / next target in the current category (speaks name; duplicates get ordinal numbers). +- `Home` - tracker directions to the selected target (speaks target name + directions). +- `Ctrl`+`Home` - clear the selected tracker target. +- `Shift`+`Home` - auto-walk to the selected target (press again to cancel). - `H` - speak nearest unexplored space. -- `E` - speak nearest exit (hold `Shift` for quest entrances). -- `,` - speak nearest stairs up. -- `.` - speak nearest stairs down. - `L` - speak current dungeon + floor. -- `Z` - speak player health percentage. +- `Z` - speak player health percentage (hold `Shift` for mana). - `X` - speak experience remaining to next level. ## Keyboard controls @@ -41,10 +39,7 @@ Keybinds are configurable, but these are the defaults most players will use: ## Town NPCs -- `F4` - list town NPCs. -- `PageUp` / `PageDown` - select previous / next town NPC. -- `End` - speak selected town NPC. -- `Home` - walk to selected town NPC (town only). +Town NPCs are available via the tracker category list. # How to Install diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 93bd22a71be..6f0a2eb930d 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -41,6 +41,7 @@ set(libdevilutionx_SRCS control/control_infobox.cpp control/control_panel.cpp + controls/accessibility_keys.cpp controls/axis_direction.cpp controls/controller_motion.cpp controls/controller.cpp @@ -51,6 +52,7 @@ set(libdevilutionx_SRCS controls/menu_controls.cpp controls/modifier_hints.cpp controls/plrctrls.cpp + controls/tracker.cpp DiabloUI/button.cpp DiabloUI/credits.cpp @@ -160,13 +162,16 @@ set(libdevilutionx_SRCS tables/textdat.cpp tables/townerdat.cpp + utils/accessibility_announcements.cpp utils/display.cpp - utils/language.cpp - utils/proximity_audio.cpp - utils/sdl_bilinear_scale.cpp - utils/sdl_thread.cpp - utils/surface_to_clx.cpp - utils/timer.cpp) + utils/language.cpp + utils/navigation_speech.cpp + utils/proximity_audio.cpp + utils/sdl_bilinear_scale.cpp + utils/sdl_thread.cpp + utils/surface_to_clx.cpp + utils/timer.cpp + utils/walk_path_speech.cpp) # These files are responsible for most of the runtime in Debug mode. # Apply some optimizations to them even in Debug mode to get reasonable performance. @@ -739,15 +744,15 @@ target_link_dependencies(libdevilutionx_utf8 PRIVATE SheenBidi::SheenBidi ) -if(NOSOUND) - add_devilutionx_object_library(libdevilutionx_sound - effects_stubs.cpp - engine/sound_pool_stubs.cpp - engine/sound_stubs.cpp - ) - target_link_dependencies(libdevilutionx_sound PUBLIC - DevilutionX::SDL - fmt::fmt +if(NOSOUND) + add_devilutionx_object_library(libdevilutionx_sound + effects_stubs.cpp + engine/sound_pool_stubs.cpp + engine/sound_stubs.cpp + ) + target_link_dependencies(libdevilutionx_sound PUBLIC + DevilutionX::SDL + fmt::fmt magic_enum::magic_enum tl unordered_dense::unordered_dense @@ -755,15 +760,15 @@ if(NOSOUND) libdevilutionx_random libdevilutionx_sdl2_to_1_2_backports ) -else() - add_devilutionx_object_library(libdevilutionx_sound - effects.cpp - engine/sound_pool.cpp - engine/sound.cpp - utils/soundsample.cpp - ) - if(USE_SDL3) - target_link_dependencies(libdevilutionx_sound PUBLIC +else() + add_devilutionx_object_library(libdevilutionx_sound + effects.cpp + engine/sound_pool.cpp + engine/sound.cpp + utils/soundsample.cpp + ) + if(USE_SDL3) + target_link_dependencies(libdevilutionx_sound PUBLIC SDL3_mixer::SDL3_mixer ) else() @@ -996,6 +1001,8 @@ if(SCREEN_READER_INTEGRATION) if(WIN32) target_compile_definitions(libdevilutionx PRIVATE Tolk) target_link_libraries(libdevilutionx PUBLIC Tolk) + elseif(ANDROID) + # Android uses native accessibility API, no external library needed else() target_include_directories(libdevilutionx PUBLIC ${Speechd_INCLUDE_DIRS}) target_link_libraries(libdevilutionx PUBLIC speechd) diff --git a/Source/DiabloUI/diabloui.cpp b/Source/DiabloUI/diabloui.cpp index 4cf22143606..b308bcf8b26 100644 --- a/Source/DiabloUI/diabloui.cpp +++ b/Source/DiabloUI/diabloui.cpp @@ -109,14 +109,16 @@ void (*gfnFullscreen)(); bool (*gfnListYesNo)(); std::vector gUiItems; UiList *gUiList = nullptr; -bool UiItemsWraps; - -std::optional UiTextInputState; -bool allowEmptyTextInput = false; - -constexpr Uint32 ListDoubleClickTimeMs = 500; -std::size_t lastListClickIndex = static_cast(-1); -Uint32 lastListClickTicks = 0; +bool UiItemsWraps; + +std::optional UiTextInputState; +bool allowEmptyTextInput = false; + +std::optional UiSpokenTextOverride; + +constexpr Uint32 ListDoubleClickTimeMs = 500; +std::size_t lastListClickIndex = static_cast(-1); +Uint32 lastListClickTicks = 0; struct ScrollBarState { bool upArrowPressed; @@ -155,14 +157,20 @@ std::string FormatSpokenText(const StringOrView &format, const std::vector SelectedItemMax) - return; - - const UiListItem *pItem = gUiList->GetItem(index); - if (pItem == nullptr) - return; +void SpeakListItem(std::size_t index, bool force = false) +{ + if (gUiList == nullptr || index > SelectedItemMax) + return; + + if (UiSpokenTextOverride) { + SpeakText(*UiSpokenTextOverride, force); + UiSpokenTextOverride = std::nullopt; + return; + } + + const UiListItem *pItem = gUiList->GetItem(index); + if (pItem == nullptr) + return; std::string text = FormatSpokenText(pItem->m_text, pItem->args); @@ -180,10 +188,10 @@ void SpeakListItem(std::size_t index, bool force = false) if (!text.empty()) SpeakText(text, force); -} - -void AdjustListOffset(std::size_t itemIndex) -{ +} + +void AdjustListOffset(std::size_t itemIndex) +{ if (itemIndex >= listOffset + ListViewportSize) listOffset = itemIndex - (ListViewportSize - 1); if (itemIndex < listOffset) @@ -232,13 +240,18 @@ void UiUpdateFadePalette() SystemPaletteUpdated(); if (IsHardwareCursor()) ReinitializeHardwareCursor(); } - -} // namespace - -bool IsTextInputActive() -{ - return UiTextInputState.has_value(); -} + +} // namespace + +void UiSetSpokenTextOverride(std::string text) +{ + UiSpokenTextOverride = std::move(text); +} + +bool IsTextInputActive() +{ + return UiTextInputState.has_value(); +} void UiInitList(void (*fnFocus)(size_t value), void (*fnSelect)(size_t value), void (*fnEsc)(), const std::vector> &items, bool itemsWraps, void (*fnFullscreen)(), bool (*fnYesNo)(), size_t selectedItem /*= 0*/) { @@ -365,19 +378,19 @@ void UiFocus(std::size_t itemIndex, bool checkUp, bool ignoreItemsWraps = false) } pItem = gUiList->GetItem(itemIndex); } - SpeakListItem(itemIndex); - - if (HasAnyOf(pItem->uiFlags, UiFlags::NeedsNextElement)) - AdjustListOffset(itemIndex + 1); - AdjustListOffset(itemIndex); - - SelectedItem = itemIndex; - - UiPlayMoveSound(); - - if (gfnListFocus != nullptr) - gfnListFocus(itemIndex); -} + if (HasAnyOf(pItem->uiFlags, UiFlags::NeedsNextElement)) + AdjustListOffset(itemIndex + 1); + AdjustListOffset(itemIndex); + + SelectedItem = itemIndex; + + UiPlayMoveSound(); + + if (gfnListFocus != nullptr) + gfnListFocus(itemIndex); + + SpeakListItem(itemIndex); +} void UiFocusUp() { diff --git a/Source/DiabloUI/diabloui.h b/Source/DiabloUI/diabloui.h index 61c5cd7a802..addc45dc8fe 100644 --- a/Source/DiabloUI/diabloui.h +++ b/Source/DiabloUI/diabloui.h @@ -1,13 +1,15 @@ -#pragma once - -#include -#include -#include -#include - -#ifdef USE_SDL3 -#include -#include +#pragma once + +#include +#include +#include +#include +#include +#include + +#ifdef USE_SDL3 +#include +#include #else #include #endif @@ -109,13 +111,16 @@ bool UiLoadBlackBackground(); void LoadBackgroundArt(const char *pszFile, int frames = 1); void UiAddBackground(std::vector> *vecDialog); void UiAddLogo(std::vector> *vecDialog, int y = GetUIRectangle().position.y); -void UiFocusNavigationSelect(); -void UiFocusNavigationEsc(); -void UiFocusNavigationYesNo(); - -void UiInitList(void (*fnFocus)(size_t value), void (*fnSelect)(size_t value), void (*fnEsc)(), const std::vector> &items, bool wraps = false, void (*fnFullscreen)() = nullptr, bool (*fnYesNo)() = nullptr, size_t selectedItem = 0); -void UiRenderListItems(); -void UiInitList_clear(); +void UiFocusNavigationSelect(); +void UiFocusNavigationEsc(); +void UiFocusNavigationYesNo(); + +/** Overrides what the screen reader will speak for the next focused list item. */ +void UiSetSpokenTextOverride(std::string text); + +void UiInitList(void (*fnFocus)(size_t value), void (*fnSelect)(size_t value), void (*fnEsc)(), const std::vector> &items, bool wraps = false, void (*fnFullscreen)() = nullptr, bool (*fnYesNo)() = nullptr, size_t selectedItem = 0); +void UiRenderListItems(); +void UiInitList_clear(); void UiClearScreen(); void UiPollAndRender(std::optional> eventHandler = std::nullopt); diff --git a/Source/DiabloUI/hero/selhero.cpp b/Source/DiabloUI/hero/selhero.cpp index 883b4c1f49f..8292d05f588 100644 --- a/Source/DiabloUI/hero/selhero.cpp +++ b/Source/DiabloUI/hero/selhero.cpp @@ -65,23 +65,43 @@ std::vector> vecSelHeroDialog; std::vector> vecSelHeroDlgItems; std::vector> vecSelDlgItems; -UiImageClx *SELHERO_DIALOG_HERO_IMG; - -void SelheroListFocus(size_t value); -void SelheroListSelect(size_t value); -void SelheroListEsc(); -void SelheroLoadFocus(size_t value); -void SelheroLoadSelect(size_t value); -void SelheroNameSelect(size_t value); -void SelheroNameEsc(); -void SelheroClassSelectorFocus(size_t value); -void SelheroClassSelectorSelect(size_t value); -void SelheroClassSelectorEsc(); -const char *SelheroGenerateName(HeroClass heroClass); - -void SelheroUiFocusNavigationYesNo() -{ - if (selhero_isSavegame) +UiImageClx *SELHERO_DIALOG_HERO_IMG; + +void SelheroListFocus(size_t value); +void SelheroListSelect(size_t value); +void SelheroListEsc(); +void SelheroLoadFocus(size_t value); +void SelheroLoadSelect(size_t value); +void SelheroNameSelect(size_t value); +void SelheroNameEsc(); +void SelheroClassSelectorFocus(size_t value); +void SelheroClassSelectorSelect(size_t value); +void SelheroClassSelectorEsc(); +const char *SelheroGenerateName(HeroClass heroClass); + +std::string_view HeroClassDescriptionForSpeech(HeroClass heroClass) +{ + switch (heroClass) { + case HeroClass::Warrior: + return _("A powerful fighter who excels in melee combat."); + case HeroClass::Rogue: + return _("A nimble archer who excels at ranged combat."); + case HeroClass::Sorcerer: + return _("A master of arcane magic who casts powerful spells."); + case HeroClass::Monk: + return _("A holy warrior skilled in martial arts and staves."); + case HeroClass::Bard: + return _("A versatile fighter who blends melee and archery."); + case HeroClass::Barbarian: + return _("A fierce warrior who relies on brute strength."); + default: + return {}; + } +} + +void SelheroUiFocusNavigationYesNo() +{ + if (selhero_isSavegame) UiFocusNavigationYesNo(); } @@ -248,22 +268,31 @@ void SelheroListEsc() selhero_result = SELHERO_PREVIOUS; } -void SelheroClassSelectorFocus(size_t value) -{ - const auto heroClass = static_cast(vecSelHeroDlgItems[value]->m_value); - - _uidefaultstats defaults; - gfnHeroStats(heroClass, &defaults); +void SelheroClassSelectorFocus(size_t value) +{ + const auto heroClass = static_cast(vecSelHeroDlgItems[value]->m_value); + + _uidefaultstats defaults; + gfnHeroStats(heroClass, &defaults); selhero_heroInfo.level = 1; selhero_heroInfo.heroclass = heroClass; selhero_heroInfo.strength = defaults.strength; selhero_heroInfo.magic = defaults.magic; - selhero_heroInfo.dexterity = defaults.dexterity; - selhero_heroInfo.vitality = defaults.vitality; - - SelheroSetStats(); -} + selhero_heroInfo.dexterity = defaults.dexterity; + selhero_heroInfo.vitality = defaults.vitality; + + SelheroSetStats(); + + const PlayerData &playerData = GetPlayerDataForClass(heroClass); + const std::string_view description = HeroClassDescriptionForSpeech(heroClass); + std::string spoken = std::string(_(playerData.className)); + if (!description.empty()) { + spoken.append("\n"); + spoken.append(description); + } + UiSetSpokenTextOverride(std::move(spoken)); +} bool ShouldPrefillHeroName() { diff --git a/Source/DiabloUI/multi/selgame.cpp b/Source/DiabloUI/multi/selgame.cpp index dc8af704d0a..eb556ad52fd 100644 --- a/Source/DiabloUI/multi/selgame.cpp +++ b/Source/DiabloUI/multi/selgame.cpp @@ -409,24 +409,38 @@ void selgame_GameSelection_Esc() selgame_endMenu = true; } -void selgame_Diff_Focus(size_t value) -{ - switch (vecSelGameDlgItems[value]->m_value) { - case DIFF_NORMAL: - CopyUtf8(selgame_Label, _("Normal"), sizeof(selgame_Label)); - CopyUtf8(selgame_Description, _("Normal Difficulty\nThis is where a starting character should begin the quest to defeat Diablo."), sizeof(selgame_Description)); - break; - case DIFF_NIGHTMARE: - CopyUtf8(selgame_Label, _("Nightmare"), sizeof(selgame_Label)); - CopyUtf8(selgame_Description, _("Nightmare Difficulty\nThe denizens of the Labyrinth have been bolstered and will prove to be a greater challenge. This is recommended for experienced characters only."), sizeof(selgame_Description)); - break; - case DIFF_HELL: - CopyUtf8(selgame_Label, _("Hell"), sizeof(selgame_Label)); - CopyUtf8(selgame_Description, _("Hell Difficulty\nThe most powerful of the underworld's creatures lurk at the gateway into Hell. Only the most experienced characters should venture in this realm."), sizeof(selgame_Description)); - break; - } - CopyUtf8(selgame_Description, WordWrapString(selgame_Description, DESCRIPTION_WIDTH), sizeof(selgame_Description)); -} +void selgame_Diff_Focus(size_t value) +{ + std::string_view tooltip; + switch (vecSelGameDlgItems[value]->m_value) { + case DIFF_NORMAL: + CopyUtf8(selgame_Label, _("Normal"), sizeof(selgame_Label)); + tooltip = _("Normal Difficulty\nThis is where a starting character should begin the quest to defeat Diablo."); + CopyUtf8(selgame_Description, tooltip, sizeof(selgame_Description)); + break; + case DIFF_NIGHTMARE: + CopyUtf8(selgame_Label, _("Nightmare"), sizeof(selgame_Label)); + tooltip = _("Nightmare Difficulty\nThe denizens of the Labyrinth have been bolstered and will prove to be a greater challenge. This is recommended for experienced characters only."); + CopyUtf8(selgame_Description, tooltip, sizeof(selgame_Description)); + break; + case DIFF_HELL: + CopyUtf8(selgame_Label, _("Hell"), sizeof(selgame_Label)); + tooltip = _("Hell Difficulty\nThe most powerful of the underworld's creatures lurk at the gateway into Hell. Only the most experienced characters should venture in this realm."); + CopyUtf8(selgame_Description, tooltip, sizeof(selgame_Description)); + break; + } + CopyUtf8(selgame_Description, WordWrapString(selgame_Description, DESCRIPTION_WIDTH), sizeof(selgame_Description)); + + std::string spoken = selgame_Label; + std::string_view spokenDescription = tooltip; + if (const size_t newlinePos = spokenDescription.find('\n'); newlinePos != std::string_view::npos) + spokenDescription = spokenDescription.substr(newlinePos + 1); + if (!spokenDescription.empty()) { + spoken.append("\n"); + spoken.append(spokenDescription); + } + UiSetSpokenTextOverride(std::move(spoken)); +} bool IsDifficultyAllowed(int value) { diff --git a/Source/controls/accessibility_keys.cpp b/Source/controls/accessibility_keys.cpp new file mode 100644 index 00000000000..2e2290cd9da --- /dev/null +++ b/Source/controls/accessibility_keys.cpp @@ -0,0 +1,373 @@ +/** + * @file controls/accessibility_keys.cpp + * + * UI accessibility key handlers and action-guard helpers. + */ +#include "controls/accessibility_keys.hpp" + +#include +#include +#include +#include + +#include + +#include "control/control.hpp" +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include "controls/plrctrls.h" +#include "utils/sdl_compat.h" +#include "diablo.h" +#include "engine/sound.h" +#include "engine/sound_defs.hpp" +#include "gamemenu.h" +#include "help.h" +#include "inv.h" +#include "levels/gendung.h" +#include "levels/setmaps.h" +#include "minitext.h" +#include "options.h" +#include "panels/charpanel.hpp" +#include "panels/partypanel.hpp" +#include "panels/spell_book.hpp" +#include "panels/spell_list.hpp" +#include "player.h" +#include "qol/chatlog.h" +#include "qol/stash.h" +#include "quests.h" +#include "stores.h" +#include "utils/format_int.hpp" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" + +namespace devilution { + +namespace { + +/** Computes a rounded percentage (0--100) from a current and maximum value. */ +int ComputePercentage(int current, int maximum) +{ + const int clamped = std::max(current, 0); + int percent = static_cast((static_cast(clamped) * 100 + maximum / 2) / maximum); + return std::clamp(percent, 0, 100); +} + +int PreviousAudioCuesVolume = VOLUME_MAX; + +} // namespace + +void SpeakPlayerHealthPercentageKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (MyPlayer == nullptr) + return; + + const SDL_Keymod modState = SDL_GetModState(); + const bool speakMana = (modState & SDL_KMOD_SHIFT) != 0; + if (speakMana) { + if (MyPlayer->_pMaxMana <= 0) + return; + SpeakText(fmt::format("{:d}%", ComputePercentage(MyPlayer->_pMana, MyPlayer->_pMaxMana)), /*force=*/true); + return; + } + + if (MyPlayer->_pMaxHP <= 0) + return; + SpeakText(fmt::format("{:d}%", ComputePercentage(MyPlayer->_pHitPoints, MyPlayer->_pMaxHP)), /*force=*/true); +} + +void SpeakExperienceToNextLevelKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (MyPlayer == nullptr) + return; + + const Player &myPlayer = *MyPlayer; + if (myPlayer.isMaxCharacterLevel()) { + SpeakText(_("Max level."), /*force=*/true); + return; + } + + const uint32_t nextExperienceThreshold = myPlayer.getNextExperienceThreshold(); + const uint32_t currentExperience = myPlayer._pExperience; + const uint32_t remainingExperience = currentExperience >= nextExperienceThreshold ? 0 : nextExperienceThreshold - currentExperience; + const int nextLevel = myPlayer.getCharacterLevel() + 1; + SpeakText( + fmt::format(fmt::runtime(_("{:s} to Level {:d}")), FormatInteger(remainingExperience), nextLevel), + /*force=*/true); +} + +std::string BuildCurrentLocationForSpeech() +{ + // Quest Level Name + if (setlevel) { + const char *const questLevelName = QuestLevelNames[setlvlnum]; + if (questLevelName == nullptr || questLevelName[0] == '\0') + return std::string { _("Set level") }; + + return fmt::format("{:s}: {:s}", _("Set level"), _(questLevelName)); + } + + // Dungeon Name + constexpr std::array DungeonStrs = { + N_("Town"), + N_("Cathedral"), + N_("Catacombs"), + N_("Caves"), + N_("Hell"), + N_("Nest"), + N_("Crypt"), + }; + std::string dungeonStr; + if (leveltype >= DTYPE_TOWN && leveltype <= DTYPE_LAST) { + dungeonStr = _(DungeonStrs[static_cast(leveltype)]); + } else { + dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None"); + } + + if (leveltype == DTYPE_TOWN || currlevel <= 0) + return dungeonStr; + + // Dungeon Level + int level = currlevel; + if (leveltype == DTYPE_CATACOMBS) + level -= 4; + else if (leveltype == DTYPE_CAVES) + level -= 8; + else if (leveltype == DTYPE_HELL) + level -= 12; + else if (leveltype == DTYPE_NEST) + level -= 16; + else if (leveltype == DTYPE_CRYPT) + level -= 20; + + if (level <= 0) + return dungeonStr; + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: dungeon type and floor number i.e. "Cathedral 3"*/ "{} {}")), dungeonStr, level); +} + +void SpeakCurrentLocationKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + + SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); +} + +void ToggleAudioCuesKeyPressed() +{ + const int currentVolume = sound_get_or_set_audio_cues_volume(1); + if (currentVolume == VOLUME_MIN) { + int restoredVolume = PreviousAudioCuesVolume; + if (restoredVolume <= VOLUME_MIN || restoredVolume > VOLUME_MAX) + restoredVolume = VOLUME_MAX; + sound_get_or_set_audio_cues_volume(restoredVolume); + SpeakText(_("Audio cues enabled."), /*force=*/true); + return; + } + + PreviousAudioCuesVolume = currentVolume; + sound_get_or_set_audio_cues_volume(VOLUME_MIN); + SpeakText(_("Audio cues disabled."), /*force=*/true); +} + +void ToggleNpcDialogTextReadingKeyPressed() +{ + auto &speakNpcDialogText = GetOptions().Gameplay.speakNpcDialogText; + const bool enabled = !*speakNpcDialogText; + speakNpcDialogText.SetValue(enabled); + SpeakText(enabled ? _("NPC subtitle reading enabled.") : _("NPC subtitle reading disabled."), /*force=*/true); +} + +void InventoryKeyPressed() +{ + if (IsPlayerInStore()) + return; + invflag = !invflag; + if (!IsLeftPanelOpen() && CanPanelsCoverView()) { + if (!invflag) { // We closed the inventory + if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); + } + } else if (!SpellbookFlag) { // We opened the inventory + if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition - Displacement { 160, 0 }); + } + } + } + SpellbookFlag = false; + CloseGoldWithdraw(); + CloseStash(); + if (invflag) + FocusOnInventory(); +} + +void CharacterSheetKeyPressed() +{ + if (IsPlayerInStore()) + return; + if (!IsRightPanelOpen() && CanPanelsCoverView()) { + if (CharFlag) { // We are closing the character sheet + if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition - Displacement { 160, 0 }); + } + } else if (!QuestLogIsOpen) { // We opened the character sheet + if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); + } + } + } + ToggleCharPanel(); +} + +void PartyPanelSideToggleKeyPressed() +{ + PartySidePanelOpen = !PartySidePanelOpen; +} + +void QuestLogKeyPressed() +{ + if (IsPlayerInStore()) + return; + if (!QuestLogIsOpen) { + StartQuestlog(); + } else { + QuestLogIsOpen = false; + } + if (!IsRightPanelOpen() && CanPanelsCoverView()) { + if (!QuestLogIsOpen) { // We closed the quest log + if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition - Displacement { 160, 0 }); + } + } else if (!CharFlag) { // We opened the quest log + if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); + } + } + } + CloseCharPanel(); + CloseGoldWithdraw(); + CloseStash(); +} + +void SpeakSelectedSpeedbookSpell() +{ + if (MyPlayer == nullptr) + return; + + const Player &player = *MyPlayer; + for (const auto &spellListItem : GetSpellListItems()) { + if (spellListItem.isSelected) { + SpeakText(BuildSpellDetailsForSpeech(player, spellListItem.id, spellListItem.type), /*force=*/true); + return; + } + } + SpeakText(_("No spell selected."), /*force=*/true); +} + +void DisplaySpellsKeyPressed() +{ + if (IsPlayerInStore()) + return; + CloseCharPanel(); + QuestLogIsOpen = false; + CloseInventory(); + SpellbookFlag = false; + if (!SpellSelectFlag) { + DoSpeedBook(); + SpeakSelectedSpeedbookSpell(); + } else { + SpellSelectFlag = false; + } + LastPlayerAction = PlayerActionType::None; +} + +void SpellBookKeyPressed() +{ + if (IsPlayerInStore()) + return; + SpellbookFlag = !SpellbookFlag; + if (SpellbookFlag && MyPlayer != nullptr) { + const Player &player = *MyPlayer; + if (IsValidSpell(player._pRSpell)) { + SpeakText(BuildSpellDetailsForSpeech(player, player._pRSpell, player._pRSplType), /*force=*/true); + } else { + SpeakText(_("No spell selected."), /*force=*/true); + } + } + if (!IsLeftPanelOpen() && CanPanelsCoverView()) { + if (!SpellbookFlag) { // We closed the spellbook + if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); + } + } else if (!invflag) { // We opened the spellbook + if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition - Displacement { 160, 0 }); + } + } + } + CloseInventory(); +} + +void CycleSpellHotkeys(bool next) +{ + if (MyPlayer == nullptr) + return; + StaticVector validHotKeyIndexes; + std::optional currentIndex; + for (size_t slot = 0; slot < NumHotkeys; slot++) { + if (!IsValidSpeedSpell(slot)) + continue; + if (MyPlayer->_pRSpell == MyPlayer->_pSplHotKey[slot] && MyPlayer->_pRSplType == MyPlayer->_pSplTHotKey[slot]) { + // found current + currentIndex = validHotKeyIndexes.size(); + } + validHotKeyIndexes.emplace_back(slot); + } + if (validHotKeyIndexes.empty()) + return; + + size_t newIndex; + if (!currentIndex) { + newIndex = next ? 0 : (validHotKeyIndexes.size() - 1); + } else if (next) { + newIndex = (*currentIndex == validHotKeyIndexes.size() - 1) ? 0 : (*currentIndex + 1); + } else { + newIndex = *currentIndex == 0 ? (validHotKeyIndexes.size() - 1) : (*currentIndex - 1); + } + ToggleSpell(validHotKeyIndexes[newIndex]); +} + +bool IsPlayerDead() +{ + if (MyPlayer == nullptr) + return true; + return MyPlayer->_pmode == PM_DEATH || MyPlayerIsDead; +} + +bool IsGameRunning() +{ + return PauseMode != 2; +} + +bool CanPlayerTakeAction() +{ + return !IsPlayerDead() && IsGameRunning(); +} + +bool CanAutomapBeToggledOff() +{ + return !QuestLogIsOpen && !IsWithdrawGoldOpen && !IsStashOpen && !CharFlag + && !SpellbookFlag && !invflag && !isGameMenuOpen && !qtextflag && !SpellSelectFlag + && !ChatLogFlag && !HelpFlag; +} + +} // namespace devilution diff --git a/Source/controls/accessibility_keys.hpp b/Source/controls/accessibility_keys.hpp new file mode 100644 index 00000000000..9ad90d15fed --- /dev/null +++ b/Source/controls/accessibility_keys.hpp @@ -0,0 +1,32 @@ +/** + * @file controls/accessibility_keys.hpp + * + * UI accessibility key handlers and action-guard helpers. + */ +#pragma once + +#include + +namespace devilution { + +void SpeakPlayerHealthPercentageKeyPressed(); +void SpeakExperienceToNextLevelKeyPressed(); +std::string BuildCurrentLocationForSpeech(); +void SpeakCurrentLocationKeyPressed(); +void ToggleAudioCuesKeyPressed(); +void ToggleNpcDialogTextReadingKeyPressed(); +void InventoryKeyPressed(); +void CharacterSheetKeyPressed(); +void PartyPanelSideToggleKeyPressed(); +void QuestLogKeyPressed(); +void SpeakSelectedSpeedbookSpell(); +void DisplaySpellsKeyPressed(); +void SpellBookKeyPressed(); +void CycleSpellHotkeys(bool next); + +bool IsPlayerDead(); +bool IsGameRunning(); +bool CanPlayerTakeAction(); +bool CanAutomapBeToggledOff(); + +} // namespace devilution diff --git a/Source/controls/plrctrls.cpp b/Source/controls/plrctrls.cpp index 32b4b0ac31a..f05f1e2a6d5 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,20 +16,21 @@ #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" #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" @@ -49,22 +50,23 @@ #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 { -GameActionType ControllerActionHeld = GameActionType_NONE; - -bool StandToggle = false; +GameActionType ControllerActionHeld = GameActionType_NONE; + +bool StandToggle = false; +bool StandGroundHeld = false; int pcurstrig = -1; Missile *pcursmissile = nullptr; @@ -90,8 +92,32 @@ namespace { int Slot = SLOTXY_INV_FIRST; Point ActiveStashSlot = InvalidStashPoint; int PreviousInventoryColumn = -1; +int PreviousBeltColumn = -1; bool BeltReturnsToStash = false; + +/** + * Tracks the row offset within a multi-tile item when navigating horizontally. + * This ensures that when navigating left into a 2x3 item and then right again, + * we exit from the same row we entered from. + * -1 means no entry point is tracked (single-tile item or not on an item). + */ +int CurrentItemEntryRow = -1; + +/** + * Tracks the column offset within a multi-tile item when navigating vertically. + * This ensures that when navigating up into a 3x2 item and then down again, + * we exit from the same column we entered from. + * -1 means no entry point is tracked. + */ +int CurrentItemEntryColumn = -1; + +/** + * The item ID we're currently tracking entry points for. + * Used to detect when we've moved to a different item. + */ +int8_t CurrentItemId = 0; + const Direction FaceDir[3][3] = { // NONE UP DOWN { Direction::South, Direction::North, Direction::South }, // NONE @@ -494,19 +520,26 @@ void FindTrigger() CheckRportal(); } -bool IsStandingGround() -{ - if (ControlMode == ControlTypes::Gamepad) { - const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); - return StandToggle || IsControllerButtonComboPressed(standGroundCombo); - } -#ifndef USE_SDL1 - if (ControlMode == ControlTypes::VirtualGamepad) { - return VirtualGamepadState.standButton.isHeld; - } -#endif - return false; -} +bool IsStandingGround() +{ + if (StandToggle || StandGroundHeld) + return true; + + if (ControlMode == ControlTypes::Gamepad) { + const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); + return IsControllerButtonComboPressed(standGroundCombo); + } +#ifndef USE_SDL1 + if (ControlMode == ControlTypes::VirtualGamepad) { + return VirtualGamepadState.standButton.isHeld; + } +#endif + if (ControlMode == ControlTypes::KeyboardAndMouse) { + // Match classic Diablo behavior: hold Shift to attack in place. + return (SDL_GetModState() & SDL_KMOD_SHIFT) != 0; + } + return false; +} void Interact() { @@ -675,106 +708,175 @@ 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) +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"); +} + +/** + * Get the row of a slot in the inventory grid (0-indexed). + */ +int GetSlotRow(int slot) +{ + if (slot < SLOTXY_INV_FIRST || slot > SLOTXY_INV_LAST) + return -1; + return (slot - SLOTXY_INV_FIRST) / INV_ROW_SLOT_SIZE; +} + +/** + * Get the column of a slot in the inventory grid (0-indexed). + */ +int GetSlotColumn(int slot) +{ + if (slot < SLOTXY_INV_FIRST || slot > SLOTXY_INV_LAST) + return -1; + return (slot - SLOTXY_INV_FIRST) % INV_ROW_SLOT_SIZE; +} + +void SpeakInventorySlotForAccessibility() { - 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 (MyPlayer == nullptr) + return; + + const Player &player = *MyPlayer; + const Item *item = nullptr; + std::string positionInfo; + + 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]; + + // Calculate row and column for inventory position (1-indexed for speech) + int row = GetSlotRow(Slot) + 1; + int column = GetSlotColumn(Slot) + 1; + positionInfo = fmt::format("Row {}, Column {}: ", row, column); + } 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()) { + std::string itemName; + if (item->_itype == ItemType::Gold) { + const int nGold = item->_ivalue; + itemName = fmt::format(fmt::runtime(ngettext("{:s} gold piece", "{:s} gold pieces", nGold)), FormatInteger(nGold)); + } else { + itemName = std::string(item->getName()); + } + + if (!positionInfo.empty()) { + SpeakText(StrCat(positionInfo, itemName), /*force=*/true); + } else { + SpeakText(itemName, /*force=*/true); + } + return; + } + + if (!positionInfo.empty()) { + SpeakText(StrCat(positionInfo, _("empty")), /*force=*/true); + } else { + SpeakText(StrCat(GetInventorySlotNameForSpeech(Slot), ": ", _("empty")), /*force=*/true); } - - if (slot >= SLOTXY_BELT_FIRST && slot <= SLOTXY_BELT_LAST) - return StrCat(_("Belt"), " ", slot - SLOTXY_BELT_FIRST + 1); - - return _("Inventory"); } -void SpeakInventorySlotForAccessibility() +void SpeakStashSlotForAccessibility() { 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 (ActiveStashSlot == InvalidStashPoint) { + SpeakText(_("empty"), /*force=*/true); + return; } - 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); + const int row = ActiveStashSlot.y + 1; + const int column = ActiveStashSlot.x + 1; + const int cell = ActiveStashSlot.y * StashGridSize.width + ActiveStashSlot.x + 1; + const std::string positionInfo = fmt::format("Row {}, Column {}, Cell {}: ", row, column, cell); + + const StashStruct::StashCell itemId = Stash.GetItemIdAtPosition(ActiveStashSlot); + if (itemId != StashStruct::EmptyCell) { + const Item &item = Stash.stashList[itemId]; + if (!item.isEmpty()) { + if (item._itype == ItemType::Gold) { + const int nGold = item._ivalue; + SpeakText(StrCat(positionInfo, fmt::format(fmt::runtime(ngettext("{:s} gold piece", "{:s} gold pieces", nGold)), FormatInteger(nGold))), /*force=*/true); + } else { + SpeakText(StrCat(positionInfo, item.getName()), /*force=*/true); + } + return; } - return; } - SpeakText(StrCat(GetInventorySlotNameForSpeech(Slot), ": ", _("empty")), /*force=*/true); + SpeakText(StrCat(positionInfo, _("empty")), /*force=*/true); } /** * Get item size (grid size) on the slot specified. Returns 1x1 if none exists. */ -Size GetItemSizeOnSlot(int slot) -{ +Size GetItemSizeOnSlot(int slot) +{ if (slot >= SLOTXY_INV_FIRST && slot <= SLOTXY_INV_LAST) { const int8_t ii = GetItemIdOnSlot(slot); if (ii != 0) { @@ -820,6 +922,151 @@ int FindFirstSlotOnItem(int8_t itemInvId) return -1; } +/** + * Get a slot from row and column coordinates. + */ +int GetSlotFromRowColumn(int row, int column) +{ + if (row < 0 || row >= 4 || column < 0 || column >= INV_ROW_SLOT_SIZE) + return -1; + return SLOTXY_INV_FIRST + row * INV_ROW_SLOT_SIZE + column; +} + +/** + * Update the entry point tracking for the current item. + * Call this when navigating to a new slot to track which row/column we entered from. + */ +void UpdateItemEntryPoint(int newSlot, AxisDirection dir) +{ + if (newSlot < SLOTXY_INV_FIRST || newSlot > SLOTXY_INV_LAST) { + // Not in inventory grid, clear tracking + CurrentItemEntryRow = -1; + CurrentItemEntryColumn = -1; + CurrentItemId = 0; + return; + } + + const int8_t newItemId = GetItemIdOnSlot(newSlot); + if (newItemId == 0) { + // Empty slot, clear tracking + CurrentItemEntryRow = -1; + CurrentItemEntryColumn = -1; + CurrentItemId = 0; + return; + } + + // Check if we're on the same item + if (newItemId == CurrentItemId) { + // Same item, keep existing entry point + return; + } + + // New item - record entry point based on navigation direction + CurrentItemId = newItemId; + int firstSlot = FindFirstSlotOnItem(newItemId); + if (firstSlot < 0) { + CurrentItemEntryRow = -1; + CurrentItemEntryColumn = -1; + return; + } + + int itemTopRow = GetSlotRow(firstSlot); + int itemLeftColumn = GetSlotColumn(firstSlot); + int slotRow = GetSlotRow(newSlot); + int slotColumn = GetSlotColumn(newSlot); + + // Record the row/column offset within the item + CurrentItemEntryRow = slotRow - itemTopRow; + CurrentItemEntryColumn = slotColumn - itemLeftColumn; +} + +/** + * Get the slot to exit to when leaving a multi-tile item horizontally. + * Uses the tracked entry row to maintain consistent navigation. + */ +int GetHorizontalExitSlot(int currentSlot, bool movingRight) +{ + const int8_t itemId = GetItemIdOnSlot(currentSlot); + if (itemId == 0) + return currentSlot + (movingRight ? 1 : -1); + + int firstSlot = FindFirstSlotOnItem(itemId); + if (firstSlot < 0) + return currentSlot + (movingRight ? 1 : -1); + + Size itemSize = GetItemSizeOnSlot(firstSlot); + int itemTopRow = GetSlotRow(firstSlot); + int itemLeftColumn = GetSlotColumn(firstSlot); + + // Determine which row to exit from + int exitRow = itemTopRow; + if (CurrentItemEntryRow >= 0 && CurrentItemEntryRow < itemSize.height) { + exitRow = itemTopRow + CurrentItemEntryRow; + } + + // Calculate the exit column + int exitColumn; + if (movingRight) { + exitColumn = itemLeftColumn + itemSize.width; // One past the right edge + } else { + exitColumn = itemLeftColumn - 1; // One before the left edge + } + + // Check bounds + if (exitColumn < 0 || exitColumn >= INV_ROW_SLOT_SIZE) + return -1; + + return GetSlotFromRowColumn(exitRow, exitColumn); +} + +/** + * Get the slot to exit to when leaving a multi-tile item vertically. + * Uses the tracked entry column to maintain consistent navigation. + */ +int GetVerticalExitSlot(int currentSlot, bool movingDown) +{ + const int8_t itemId = GetItemIdOnSlot(currentSlot); + if (itemId == 0) + return currentSlot + (movingDown ? INV_ROW_SLOT_SIZE : -INV_ROW_SLOT_SIZE); + + int firstSlot = FindFirstSlotOnItem(itemId); + if (firstSlot < 0) + return currentSlot + (movingDown ? INV_ROW_SLOT_SIZE : -INV_ROW_SLOT_SIZE); + + Size itemSize = GetItemSizeOnSlot(firstSlot); + int itemTopRow = GetSlotRow(firstSlot); + int itemLeftColumn = GetSlotColumn(firstSlot); + + // Determine which column to exit from + int exitColumn = itemLeftColumn; + if (CurrentItemEntryColumn >= 0 && CurrentItemEntryColumn < itemSize.width) { + exitColumn = itemLeftColumn + CurrentItemEntryColumn; + } + + // Calculate the exit row + int exitRow; + if (movingDown) { + exitRow = itemTopRow + itemSize.height; // One past the bottom edge + } else { + exitRow = itemTopRow - 1; // One before the top edge + } + + // Check bounds + if (exitRow < 0) + return -1; + + // If exiting downward past row 4, try to go to belt + if (exitRow >= 4) { + if (movingDown && exitColumn >= 0 && exitColumn <= 7) { + // Belt only has 8 slots (columns 0-7) + return SLOTXY_BELT_FIRST + exitColumn; + } + return -1; + } + + return GetSlotFromRowColumn(exitRow, exitColumn); +} + Point FindFirstStashSlotOnItem(StashStruct::StashCell itemInvId) { if (itemInvId == StashStruct::EmptyCell) @@ -905,6 +1152,11 @@ int FindClosestInventorySlot( checkCandidateSlot(i); } + // Also check belt slots + for (int i = SLOTXY_BELT_FIRST; i <= SLOTXY_BELT_LAST; i++) { + checkCandidateSlot(i); + } + return bestSlot; } @@ -1051,24 +1303,35 @@ void InventoryMove(AxisDirection dir) Slot = SLOTXY_HEAD; } else if (Slot == SLOTXY_RING_RIGHT) { Slot = SLOTXY_RING_LEFT; - } else if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_BELT_LAST) { + } else if (Slot >= SLOTXY_BELT_FIRST && Slot <= SLOTXY_BELT_LAST) { + // Belt navigation - move left within belt only + if (Slot > SLOTXY_BELT_FIRST) { + Slot -= 1; + } + // At belt slot 1, don't move + } else if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_INV_LAST) { const int8_t itemId = GetItemIdOnSlot(Slot); if (itemId != 0) { - for (int i = 1; i < INV_ROW_SLOT_SIZE && !IsAnyOf(Slot - i + 1, SLOTXY_INV_ROW1_FIRST, SLOTXY_INV_ROW2_FIRST, SLOTXY_INV_ROW3_FIRST, SLOTXY_INV_ROW4_FIRST, SLOTXY_BELT_FIRST); i++) { - if (itemId != GetItemIdOnSlot(Slot - i)) { - Slot -= i; - break; - } + // Use entry-point-aware exit to maintain the row we're on + int exitSlot = GetHorizontalExitSlot(Slot, false); + if (exitSlot >= SLOTXY_INV_FIRST && exitSlot <= SLOTXY_INV_LAST) { + Slot = exitSlot; } - } else if (IsNoneOf(Slot, SLOTXY_INV_ROW1_FIRST, SLOTXY_INV_ROW2_FIRST, SLOTXY_INV_ROW3_FIRST, SLOTXY_INV_ROW4_FIRST, SLOTXY_BELT_FIRST)) { + // If exitSlot is invalid (at left edge), don't move + } else if (IsNoneOf(Slot, SLOTXY_INV_ROW1_FIRST, SLOTXY_INV_ROW2_FIRST, SLOTXY_INV_ROW3_FIRST, SLOTXY_INV_ROW4_FIRST)) { Slot -= 1; } } } } else if (dir.x == AxisDirectionX_RIGHT) { if (isHoldingItem) { - if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_BELT_LAST) { - if (IsNoneOf(Slot + itemSize.width - 1, SLOTXY_INV_ROW1_LAST, SLOTXY_INV_ROW2_LAST, SLOTXY_INV_ROW3_LAST, SLOTXY_INV_ROW4_LAST, SLOTXY_BELT_LAST)) { + if (Slot >= SLOTXY_BELT_FIRST && Slot <= SLOTXY_BELT_LAST) { + // Belt navigation while holding item + if (Slot < SLOTXY_BELT_LAST) { + Slot += 1; + } + } else if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_INV_LAST) { + if (IsNoneOf(Slot + itemSize.width - 1, SLOTXY_INV_ROW1_LAST, SLOTXY_INV_ROW2_LAST, SLOTXY_INV_ROW3_LAST, SLOTXY_INV_ROW4_LAST)) { Slot += 1; } } else if (heldItem._itype == ItemType::Ring) { @@ -1085,16 +1348,22 @@ void InventoryMove(AxisDirection dir) Slot = SLOTXY_HAND_RIGHT; } else if (Slot == SLOTXY_HEAD) { Slot = SLOTXY_AMULET; - } else if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_BELT_LAST) { + } else if (Slot >= SLOTXY_BELT_FIRST && Slot <= SLOTXY_BELT_LAST) { + // Belt navigation - move right within belt only + if (Slot < SLOTXY_BELT_LAST) { + Slot += 1; + } + // At belt slot 8, don't move + } else if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_INV_LAST) { const int8_t itemId = GetItemIdOnSlot(Slot); if (itemId != 0) { - for (int i = 1; i < INV_ROW_SLOT_SIZE && !IsAnyOf(Slot + i - 1, SLOTXY_INV_ROW1_LAST, SLOTXY_INV_ROW2_LAST, SLOTXY_INV_ROW3_LAST, SLOTXY_INV_ROW4_LAST, SLOTXY_BELT_LAST); i++) { - if (itemId != GetItemIdOnSlot(Slot + i)) { - Slot += i; - break; - } + // Use entry-point-aware exit to maintain the row we're on + int exitSlot = GetHorizontalExitSlot(Slot, true); + if (exitSlot >= SLOTXY_INV_FIRST && exitSlot <= SLOTXY_INV_LAST) { + Slot = exitSlot; } - } else if (IsNoneOf(Slot, SLOTXY_INV_ROW1_LAST, SLOTXY_INV_ROW2_LAST, SLOTXY_INV_ROW3_LAST, SLOTXY_INV_ROW4_LAST, SLOTXY_BELT_LAST)) { + // If exitSlot is invalid (at right edge), don't move + } else if (IsNoneOf(Slot, SLOTXY_INV_ROW1_LAST, SLOTXY_INV_ROW2_LAST, SLOTXY_INV_ROW3_LAST, SLOTXY_INV_ROW4_LAST)) { Slot += 1; } } @@ -1102,7 +1371,10 @@ void InventoryMove(AxisDirection dir) } if (dir.y == AxisDirectionY_UP) { if (isHoldingItem) { - if (Slot >= SLOTXY_INV_ROW2_FIRST) { // general inventory + if (Slot >= SLOTXY_BELT_FIRST && Slot <= SLOTXY_BELT_LAST) { + // Going from belt back to inventory - go to row 4, column 1 + Slot = SLOTXY_INV_ROW4_FIRST; + } else if (Slot >= SLOTXY_INV_ROW2_FIRST) { // general inventory Slot -= INV_ROW_SLOT_SIZE; } else if (Slot >= SLOTXY_INV_FIRST) { if (heldItem._itype == ItemType::Ring) { @@ -1134,18 +1406,24 @@ void InventoryMove(AxisDirection dir) Slot = SLOTXY_HAND_RIGHT; } else if (Slot == SLOTXY_HAND_RIGHT) { Slot = SLOTXY_AMULET; + } else if (Slot >= SLOTXY_BELT_FIRST && Slot <= SLOTXY_BELT_LAST) { + // Going from belt back to inventory - go to row 4, column 1 + Slot = SLOTXY_INV_ROW4_FIRST; } else if (Slot >= SLOTXY_INV_ROW2_FIRST) { const int8_t itemId = GetItemIdOnSlot(Slot); if (itemId != 0) { - for (int i = 1; i < 5; i++) { - if (Slot - i * INV_ROW_SLOT_SIZE < SLOTXY_INV_ROW1_FIRST) { - Slot = InventoryMoveToBody(Slot - (i - 1) * INV_ROW_SLOT_SIZE); - break; - } - if (itemId != GetItemIdOnSlot(Slot - i * INV_ROW_SLOT_SIZE)) { - Slot -= i * INV_ROW_SLOT_SIZE; - break; + // Use entry-point-aware exit to maintain the column we're on + int exitSlot = GetVerticalExitSlot(Slot, false); + if (exitSlot >= SLOTXY_INV_FIRST && exitSlot <= SLOTXY_INV_LAST) { + Slot = exitSlot; + } else if (exitSlot < SLOTXY_INV_FIRST) { + // Would go above inventory, move to body based on current column + int firstSlot = FindFirstSlotOnItem(itemId); + int col = GetSlotColumn(firstSlot); + if (CurrentItemEntryColumn >= 0) { + col += CurrentItemEntryColumn; } + Slot = InventoryMoveToBody(SLOTXY_INV_ROW1_FIRST + col); } } else { Slot -= INV_ROW_SLOT_SIZE; @@ -1162,9 +1440,9 @@ void InventoryMove(AxisDirection dir) Slot = SLOTXY_INV_ROW1_LAST - 1; } else if (Slot <= (SLOTXY_INV_ROW4_LAST - (itemSize.height * INV_ROW_SLOT_SIZE))) { Slot += INV_ROW_SLOT_SIZE; - } else if (Slot <= SLOTXY_INV_LAST && heldItem._itype == ItemType::Misc && itemSize == Size { 1, 1 }) { // forcing only 1x1 misc items - if (Slot + INV_ROW_SLOT_SIZE <= SLOTXY_BELT_LAST) - Slot += INV_ROW_SLOT_SIZE; + } else if (Slot >= SLOTXY_INV_ROW4_FIRST && Slot <= SLOTXY_INV_ROW4_LAST && heldItem._itype == ItemType::Misc && itemSize == Size { 1, 1 }) { // forcing only 1x1 misc items + // Go to belt slot 1 + Slot = SLOTXY_BELT_FIRST; } } else { if (Slot == SLOTXY_HEAD) { @@ -1193,13 +1471,21 @@ void InventoryMove(AxisDirection dir) } else if (Slot <= SLOTXY_INV_LAST) { const int8_t itemId = GetItemIdOnSlot(Slot); if (itemId != 0) { - for (int i = 1; i < 5 && Slot + i * INV_ROW_SLOT_SIZE <= SLOTXY_BELT_LAST; i++) { - if (itemId != GetItemIdOnSlot(Slot + i * INV_ROW_SLOT_SIZE)) { - Slot += i * INV_ROW_SLOT_SIZE; - break; - } + // Check if this item extends to row 4 (can exit to belt) + int exitSlot = GetVerticalExitSlot(Slot, true); + if (exitSlot >= SLOTXY_BELT_FIRST && exitSlot <= SLOTXY_BELT_LAST) { + // Go to belt slot 1 for accessibility + Slot = SLOTXY_BELT_FIRST; + } else if (exitSlot >= SLOTXY_INV_FIRST && exitSlot <= SLOTXY_INV_LAST) { + // Moving within inventory (not to belt) + Slot = exitSlot; } - } else if (Slot + INV_ROW_SLOT_SIZE <= SLOTXY_BELT_LAST) { + // If exitSlot is invalid (at bottom edge), don't move + } else if (Slot >= SLOTXY_INV_ROW4_FIRST && Slot <= SLOTXY_INV_ROW4_LAST) { + // Empty slot in row 4 - go to belt slot 1 + Slot = SLOTXY_BELT_FIRST; + } else if (Slot >= SLOTXY_INV_FIRST && Slot < SLOTXY_INV_ROW4_FIRST) { + // Empty slot in rows 1-3 - move down one row Slot += INV_ROW_SLOT_SIZE; } } @@ -1210,6 +1496,9 @@ void InventoryMove(AxisDirection dir) if (Slot == initialSlot) return; + // Update entry point tracking for the new slot + UpdateItemEntryPoint(Slot, dir); + if (Slot < SLOTXY_INV_FIRST) { mousePos = InvGetEquipSlotCoordFromInvSlot(static_cast(Slot)); } else { @@ -1238,14 +1527,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 @@ -1284,111 +1573,54 @@ bool BlurInventory() return true; } -void StashMove(AxisDirection dir) -{ - static AxisDirectionRepeater repeater(/*min_interval_ms=*/150); - dir = repeater.Get(dir); - if (dir.x == AxisDirectionX_NONE && dir.y == AxisDirectionY_NONE) - return; - - const Item &holdItem = MyPlayer->HoldItem; - if (Slot < 0 && ActiveStashSlot == InvalidStashPoint) { - const int invSlot = FindClosestInventorySlot(MousePosition, holdItem); - const Point invSlotCoord = GetSlotCoord(invSlot); - const int invDistance = MousePosition.ManhattanDistance(invSlotCoord); - - const Point stashSlot = FindClosestStashSlot(MousePosition); - const Point stashSlotCoord = GetStashSlotCoord(stashSlot); - const int stashDistance = MousePosition.ManhattanDistance(stashSlotCoord); - - if (invDistance < stashDistance) { - BeltReturnsToStash = false; - InventoryMove(dir); - return; - } - - ActiveStashSlot = stashSlot; - } - - Size itemSize = holdItem.isEmpty() ? Size { 1, 1 } : GetInventorySize(holdItem); - - if (dir.y == AxisDirectionY_UP) { - // Check if we need to jump from belt to stash - if (BeltReturnsToStash && Slot >= SLOTXY_BELT_FIRST && Slot <= SLOTXY_BELT_LAST) { - const int beltSlot = Slot - SLOTXY_BELT_FIRST; - InvalidateInventorySlot(); - ActiveStashSlot = { 2 + beltSlot, 10 - itemSize.height }; - dir.y = AxisDirectionY_NONE; - } - } - - if (dir.x == AxisDirectionX_LEFT) { - // Check if we need to jump from general inventory to stash - int firstSlot = Slot; - if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_INV_LAST) { - if (MyPlayer->HoldItem.isEmpty()) { - const int8_t itemId = GetItemIdOnSlot(Slot); - if (itemId != 0) { - firstSlot = FindFirstSlotOnItem(itemId); - } - } - } - - // If we're in the leftmost column (or hovering over an item on the left side of the inventory) or - // left side of the body and we're moving left we need to move into the closest stash column - if (IsAnyOf(firstSlot, SLOTXY_HEAD, SLOTXY_HAND_LEFT, SLOTXY_RING_LEFT, SLOTXY_AMULET, SLOTXY_CHEST, SLOTXY_INV_ROW1_FIRST, SLOTXY_INV_ROW2_FIRST, SLOTXY_INV_ROW3_FIRST, SLOTXY_INV_ROW4_FIRST)) { - const Point slotCoord = GetSlotCoord(Slot); - InvalidateInventorySlot(); - ActiveStashSlot = FindClosestStashSlot(slotCoord) - Displacement { itemSize.width - 1, 0 }; - dir.x = AxisDirectionX_NONE; - } - } - - if (Slot >= 0) { - InventoryMove(dir); - return; - } - - if (dir.x == AxisDirectionX_LEFT) { - if (ActiveStashSlot.x > 0) { - const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot); +void StashMove(AxisDirection dir) +{ + static AxisDirectionRepeater repeater(/*min_interval_ms=*/150); + dir = repeater.Get(dir); + if (dir.x == AxisDirectionX_NONE && dir.y == AxisDirectionY_NONE) + return; + + const Item &holdItem = MyPlayer->HoldItem; + const bool cursorOnStash = GetLeftPanel().contains(MousePosition); + BeltReturnsToStash = false; + + if (!cursorOnStash) { + ActiveStashSlot = InvalidStashPoint; + InventoryMove(dir); + return; + } + + Slot = -1; + if (ActiveStashSlot == InvalidStashPoint) + ActiveStashSlot = FindClosestStashSlot(MousePosition); + + Size itemSize = holdItem.isEmpty() ? Size { 1, 1 } : GetInventorySize(holdItem); + + if (dir.x == AxisDirectionX_LEFT) { + if (ActiveStashSlot.x > 0) { + const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot); ActiveStashSlot.x--; if (holdItem.isEmpty() && itemIdAtActiveStashSlot != StashStruct::EmptyCell) { while (ActiveStashSlot.x > 0 && itemIdAtActiveStashSlot == Stash.GetItemIdAtPosition(ActiveStashSlot)) { ActiveStashSlot.x--; } - } - } - } else if (dir.x == AxisDirectionX_RIGHT) { - // If we're empty-handed and trying to move right while hovering over an item we may not - // have a free stash column to move to. If the item we're hovering over occupies the last - // column then we want to jump to the inventory instead of just moving one column over. - const Size itemUnderCursorSize = holdItem.isEmpty() ? GetItemSizeOnSlot(ActiveStashSlot) : itemSize; - if (ActiveStashSlot.x < 10 - itemUnderCursorSize.width) { - const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot); - ActiveStashSlot.x++; - if (holdItem.isEmpty() && itemIdAtActiveStashSlot != StashStruct::EmptyCell) { - while (ActiveStashSlot.x < 10 - itemSize.width && itemIdAtActiveStashSlot == Stash.GetItemIdAtPosition(ActiveStashSlot)) { - ActiveStashSlot.x++; - } - } - } else { - const Point stashSlotCoord = GetStashSlotCoord(ActiveStashSlot); - const Point rightPanelCoord = { GetRightPanel().position.x, stashSlotCoord.y }; - Slot = FindClosestInventorySlot(rightPanelCoord, holdItem, [](Point mousePos, int slot) { - const Point slotPos = GetSlotCoord(slot); - // Exaggerate the vertical difference so that moving from the top 6 rows of the - // stash is more likely to land on a body slot. The value 3 was found by trial and - // error, this allows moving from the top row of the stash to the head while - // empty-handed while 4 causes the amulet to be preferenced (due to less vertical - // distance) and 2 causes the left hand to be preferenced (due to less horizontal - // distance). - return std::abs(mousePos.y - slotPos.y) * 3 + std::abs(mousePos.x - slotPos.x); - }); - ActiveStashSlot = InvalidStashPoint; - BeltReturnsToStash = false; - } - } + } + } + } else if (dir.x == AxisDirectionX_RIGHT) { + // If we're empty-handed and trying to move right while hovering over an item we may not + // have a free stash column to move to. If the item we're hovering over occupies the last + // column then we want to stop instead of jumping to the inventory. + const Size itemUnderCursorSize = holdItem.isEmpty() ? GetItemSizeOnSlot(ActiveStashSlot) : itemSize; + if (ActiveStashSlot.x < 10 - itemUnderCursorSize.width) { + const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot); + ActiveStashSlot.x++; + if (holdItem.isEmpty() && itemIdAtActiveStashSlot != StashStruct::EmptyCell) { + while (ActiveStashSlot.x < 10 - itemSize.width && itemIdAtActiveStashSlot == Stash.GetItemIdAtPosition(ActiveStashSlot)) { + ActiveStashSlot.x++; + } + } + } + } if (dir.y == AxisDirectionY_UP) { if (ActiveStashSlot.y > 0) { const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot); @@ -1399,56 +1631,44 @@ void StashMove(AxisDirection dir) } } } - } else if (dir.y == AxisDirectionY_DOWN) { - if (ActiveStashSlot.y < 10 - itemSize.height) { - const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot); - ActiveStashSlot.y++; - if (holdItem.isEmpty() && itemIdAtActiveStashSlot != StashStruct::EmptyCell) { - while (ActiveStashSlot.y < 10 - itemSize.height && itemIdAtActiveStashSlot == Stash.GetItemIdAtPosition(ActiveStashSlot)) { - ActiveStashSlot.y++; - } - } - } else if ((holdItem.isEmpty() || CanBePlacedOnBelt(*MyPlayer, holdItem)) && ActiveStashSlot.x > 1) { - const int beltSlot = ActiveStashSlot.x - 2; - Slot = SLOTXY_BELT_FIRST + beltSlot; - ActiveStashSlot = InvalidStashPoint; - BeltReturnsToStash = true; - } - } - - if (Slot >= 0) { - ResetInvCursorPosition(); - return; - } - - if (ActiveStashSlot != InvalidStashPoint) { - Point mousePos = GetStashSlotCoord(ActiveStashSlot); - // At this point itemSize is the size of the item we're currently holding. - // We need to offset the mouse position to account for items (we're holding or hovering over) with a dimension larger than a single cell. - if (holdItem.isEmpty()) { - const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot); - if (itemIdAtActiveStashSlot != StashStruct::EmptyCell) { - const Item stashItem = Stash.stashList[itemIdAtActiveStashSlot]; - const Point firstSlotOnItem = FindFirstStashSlotOnItem(itemIdAtActiveStashSlot); - itemSize = GetInventorySize(stashItem); - mousePos = GetStashSlotCoord(firstSlotOnItem); - } - } + } else if (dir.y == AxisDirectionY_DOWN) { + if (ActiveStashSlot.y < 10 - itemSize.height) { + const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot); + ActiveStashSlot.y++; + if (holdItem.isEmpty() && itemIdAtActiveStashSlot != StashStruct::EmptyCell) { + while (ActiveStashSlot.y < 10 - itemSize.height && itemIdAtActiveStashSlot == Stash.GetItemIdAtPosition(ActiveStashSlot)) { + ActiveStashSlot.y++; + } + } + } + } + + Point mousePos = GetStashSlotCoord(ActiveStashSlot); + // At this point itemSize is the size of the item we're currently holding. + // We need to offset the mouse position to account for items (we're holding or hovering over) with a dimension larger than a single cell. + if (holdItem.isEmpty()) { + const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot); + if (itemIdAtActiveStashSlot != StashStruct::EmptyCell) { + const Point firstSlotOnItem = FindFirstStashSlotOnItem(itemIdAtActiveStashSlot); + const Item &stashItem = Stash.stashList[itemIdAtActiveStashSlot]; + ActiveStashSlot = firstSlotOnItem; + itemSize = GetInventorySize(stashItem); + mousePos = GetStashSlotCoord(firstSlotOnItem); + } + } + + mousePos += Displacement { itemSize.width * INV_SLOT_HALF_SIZE_PX, itemSize.height * INV_SLOT_HALF_SIZE_PX }; + if (mousePos != MousePosition) + SetCursorPos(mousePos); + SpeakStashSlotForAccessibility(); +} - mousePos += Displacement { itemSize.width * INV_SLOT_HALF_SIZE_PX, itemSize.height * INV_SLOT_HALF_SIZE_PX }; - SetCursorPos(mousePos); +void HotSpellMoveInternal(AxisDirection dir) +{ + static AxisDirectionRepeater repeater; + dir = repeater.Get(dir); + if (dir.x == AxisDirectionX_NONE && dir.y == AxisDirectionY_NONE) return; - } - - FocusOnInventory(); -} - -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 +1811,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 +2046,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; @@ -2012,8 +2232,49 @@ void FocusOnInventory() SpeakInventorySlotForAccessibility(); } +void ToggleStashFocus() +{ + if (!IsStashOpen || MyPlayer == nullptr) + return; + + const Item &holdItem = MyPlayer->HoldItem; + + // Toggle based on cursor location rather than `Slot`, as `Slot` gets invalidated on mouse move in + // keyboard+mouse mode (which would otherwise prevent reaching the stash). + const bool cursorOnStash = GetLeftPanel().contains(MousePosition); + + // If currently on inventory/belt (or elsewhere), jump to stash. Otherwise jump back to inventory. + if (!cursorOnStash) { + BeltReturnsToStash = false; + Slot = -1; + ActiveStashSlot = FindClosestStashSlot(MousePosition); + if (ActiveStashSlot == InvalidStashPoint) + ActiveStashSlot = { 0, 0 }; + + Point mousePos = GetStashSlotCoord(ActiveStashSlot); + Size itemSize = holdItem.isEmpty() ? Size { 1, 1 } : GetInventorySize(holdItem); + mousePos += Displacement { itemSize.width * INV_SLOT_HALF_SIZE_PX, itemSize.height * INV_SLOT_HALF_SIZE_PX }; + SetCursorPos(mousePos); + SpeakText(_("Stash"), /*force=*/true); + return; + } + + BeltReturnsToStash = false; + ActiveStashSlot = InvalidStashPoint; + Slot = FindClosestInventorySlot(MousePosition, holdItem); + ResetInvCursorPosition(); + SpeakInventorySlotForAccessibility(); +} + void InventoryMoveFromKeyboard(AxisDirection dir) { + // When the stash is open, arrow-key navigation should move within the stash/inventory + // combined UI. `StashMove` handles jumping between areas based on the current focus. + if (IsStashOpen) { + StashMove(dir); + return; + } + if (!invflag) return; @@ -2090,10 +2351,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 +2366,56 @@ 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() +{ + CancelAutoWalk(); + if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { + UpdateTargetsForKeyboardAction(); + } + PerformPrimaryAction(); +} + +void PerformPrimaryAction() +{ + if (SpellSelectFlag) { + SetSpell(); + return; } if (invflag) { // inventory is open @@ -2176,9 +2438,9 @@ void PerformPrimaryAction() ReleaseChrBtns(false); return; } - - Interact(); -} + + Interact(); +} bool SpellHasActorTarget() { @@ -2322,11 +2584,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 +2604,33 @@ 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() +{ + CancelAutoWalk(); + if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { + UpdateTargetsForKeyboardAction(); + } + PerformSecondaryAction(); +} + +void PerformSpellActionAutoTarget() +{ + CancelAutoWalk(); + 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/controls/plrctrls.h b/Source/controls/plrctrls.h index eeba6dd98a2..4c42ef6d3f3 100644 --- a/Source/controls/plrctrls.h +++ b/Source/controls/plrctrls.h @@ -22,12 +22,13 @@ enum class BeltItemType : uint8_t { Mana, }; -extern GameActionType ControllerActionHeld; -extern bool StandToggle; - -// Runs every frame. -// Handles menu movement. -void plrctrls_every_frame(); +extern GameActionType ControllerActionHeld; +extern bool StandToggle; +extern bool StandGroundHeld; + +// Runs every frame. +// Handles menu movement. +void plrctrls_every_frame(); // Run after every game logic iteration. // Handles player movement. @@ -71,6 +72,7 @@ void UpdateSpellTarget(SpellID spell); bool TryDropItem(); void InvalidateInventorySlot(); void FocusOnInventory(); +void ToggleStashFocus(); void InventoryMoveFromKeyboard(AxisDirection dir); void HotSpellMove(AxisDirection dir); void PerformSpellAction(); diff --git a/Source/controls/tracker.cpp b/Source/controls/tracker.cpp new file mode 100644 index 00000000000..38cb8e52cfa --- /dev/null +++ b/Source/controls/tracker.cpp @@ -0,0 +1,3024 @@ +/** + * @file controls/tracker.cpp + * + * Tracker system for accessibility: target cycling, pathfinding, and auto-walk. + */ +#include "controls/tracker.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include "appfat.h" +#include "automap.h" +#include "controls/accessibility_keys.hpp" +#include "controls/plrctrls.h" +#include "diablo.h" +#include "engine/path.h" +#include "gamemenu.h" +#include "help.h" +#include "items.h" +#include "levels/gendung.h" +#include "levels/setmaps.h" +#include "levels/tile_properties.hpp" +#include "levels/trigs.h" +#include "missiles.h" +#include "monster.h" +#include "multi.h" +#include "objects.h" +#include "player.h" +#include "portal.h" +#include "qol/chatlog.h" +#include "quests.h" +#include "stores.h" +#include "towners.h" +#include "utils/accessibility_announcements.hpp" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/navigation_speech.hpp" +#include "utils/screen_reader.hpp" +#include "utils/sdl_compat.h" +#include "utils/str_cat.hpp" +#include "utils/string_or_view.hpp" +#include "utils/walk_path_speech.hpp" + +namespace devilution { + +namespace { + +TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; + +TrackerTargetCategory AutoWalkTrackerTargetCategory = TrackerTargetCategory::Items; ///< Category of the active auto-walk target. +int AutoWalkTrackerTargetId = -1; ///< ID of the target being auto-walked to, or -1 if inactive. + +/// Maximum Chebyshev distance (in tiles) at which the player is considered +/// close enough to interact with a tracker target. +constexpr int TrackerInteractDistanceTiles = 1; +// Selection list range for PageUp/PageDown. Use a value larger than the maximum +// possible distance across the 112x112 dungeon grid so the list includes all +// eligible targets on the current level. +constexpr int TrackerCycleDistanceTiles = MAXDUNX + MAXDUNY; + +int LockedTrackerItemId = -1; +int LockedTrackerChestId = -1; +int LockedTrackerDoorId = -1; +int LockedTrackerShrineId = -1; +int LockedTrackerObjectId = -1; +int LockedTrackerBreakableId = -1; +int LockedTrackerMonsterId = -1; +int LockedTrackerDeadBodyId = -1; +int LockedTrackerNpcId = -1; +int LockedTrackerPlayerId = -1; +int LockedTrackerDungeonEntranceId = -1; +int LockedTrackerStairsId = -1; +int LockedTrackerQuestLocationId = -1; +int LockedTrackerPortalId = -1; + +struct TrackerLevelKey { + dungeon_type levelType; + int currLevel; + bool isSetLevel; + int setLevelNum; + + friend bool operator==(const TrackerLevelKey &lhs, const TrackerLevelKey &rhs) + { + return lhs.levelType == rhs.levelType && lhs.currLevel == rhs.currLevel + && lhs.isSetLevel == rhs.isSetLevel && lhs.setLevelNum == rhs.setLevelNum; + } + friend bool operator!=(const TrackerLevelKey &lhs, const TrackerLevelKey &rhs) + { + return !(lhs == rhs); + } +}; + +std::optional LockedTrackerLevelKey; + +void ClearTrackerLocks() +{ + LockedTrackerItemId = -1; + LockedTrackerChestId = -1; + LockedTrackerDoorId = -1; + LockedTrackerShrineId = -1; + LockedTrackerObjectId = -1; + LockedTrackerBreakableId = -1; + LockedTrackerMonsterId = -1; + LockedTrackerDeadBodyId = -1; + LockedTrackerNpcId = -1; + LockedTrackerPlayerId = -1; + LockedTrackerDungeonEntranceId = -1; + LockedTrackerStairsId = -1; + LockedTrackerQuestLocationId = -1; + LockedTrackerPortalId = -1; +} + +void EnsureTrackerLocksMatchCurrentLevel() +{ + const TrackerLevelKey current { + .levelType = leveltype, + .currLevel = currlevel, + .isSetLevel = setlevel, + .setLevelNum = setlvlnum, + }; + + if (!LockedTrackerLevelKey || *LockedTrackerLevelKey != current) { + ClearTrackerLocks(); + LockedTrackerLevelKey = current; + } +} + +int &LockedTrackerTargetId(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return LockedTrackerItemId; + case TrackerTargetCategory::Chests: + return LockedTrackerChestId; + case TrackerTargetCategory::Doors: + return LockedTrackerDoorId; + case TrackerTargetCategory::Shrines: + return LockedTrackerShrineId; + case TrackerTargetCategory::Objects: + return LockedTrackerObjectId; + case TrackerTargetCategory::Breakables: + return LockedTrackerBreakableId; + case TrackerTargetCategory::Monsters: + return LockedTrackerMonsterId; + case TrackerTargetCategory::DeadBodies: + return LockedTrackerDeadBodyId; + case TrackerTargetCategory::Npcs: + return LockedTrackerNpcId; + case TrackerTargetCategory::Players: + return LockedTrackerPlayerId; + case TrackerTargetCategory::DungeonEntrances: + return LockedTrackerDungeonEntranceId; + case TrackerTargetCategory::Stairs: + return LockedTrackerStairsId; + case TrackerTargetCategory::QuestLocations: + return LockedTrackerQuestLocationId; + case TrackerTargetCategory::Portals: + return LockedTrackerPortalId; + } + app_fatal("Invalid TrackerTargetCategory"); +} + +std::string_view TrackerTargetCategoryLabel(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("items"); + case TrackerTargetCategory::Chests: + return _("chests"); + case TrackerTargetCategory::Doors: + return _("doors"); + case TrackerTargetCategory::Shrines: + return _("shrines"); + case TrackerTargetCategory::Objects: + return _("objects"); + case TrackerTargetCategory::Breakables: + return _("breakables"); + case TrackerTargetCategory::Monsters: + return _("monsters"); + case TrackerTargetCategory::DeadBodies: + return _("dead bodies"); + case TrackerTargetCategory::Npcs: + return _("NPCs"); + case TrackerTargetCategory::Players: + return _("players"); + case TrackerTargetCategory::DungeonEntrances: + if (leveltype != DTYPE_TOWN) + return _("exits"); + return _("dungeon entrances"); + case TrackerTargetCategory::Stairs: + return _("stairs"); + case TrackerTargetCategory::QuestLocations: + return _("quest locations"); + case TrackerTargetCategory::Portals: + return _("portals"); + } + app_fatal("Invalid TrackerTargetCategory"); +} + +void SpeakTrackerTargetCategory() +{ + SpeakText(TrackerTargetCategoryLabel(SelectedTrackerTargetCategory), true); +} + +std::optional FindNearestGroundItemId(Point playerPosition) +{ + std::optional bestId; + int bestDistance = 0; + + for (int y = 0; y < MAXDUNY; ++y) { + for (int x = 0; x < MAXDUNX; ++x) { + const int itemId = std::abs(dItem[x][y]) - 1; + if (itemId < 0 || itemId > MAXITEMS) + continue; + + const Item &item = Items[itemId]; + if (item.isEmpty() || item._iClass == ICLASS_NONE) + continue; + + const int distance = playerPosition.WalkingDistance(Point { x, y }); + if (!bestId || distance < bestDistance) { + bestId = itemId; + bestDistance = distance; + } + } + } + + return bestId; +} + +[[nodiscard]] constexpr int CorpseTrackerIdForPosition(Point position) +{ + return position.x + position.y * MAXDUNX; +} + +[[nodiscard]] constexpr Point CorpsePositionForTrackerId(int corpseId) +{ + return { corpseId % MAXDUNX, corpseId / MAXDUNX }; +} + +std::optional FindNearestCorpseId(Point playerPosition) +{ + std::optional bestId; + int bestDistance = 0; + + for (int y = 0; y < MAXDUNY; ++y) { + for (int x = 0; x < MAXDUNX; ++x) { + if (dCorpse[x][y] == 0) + continue; + + const Point position { x, y }; + const int distance = playerPosition.WalkingDistance(position); + if (!bestId || distance < bestDistance) { + bestId = CorpseTrackerIdForPosition(position); + bestDistance = distance; + } + } + } + + return bestId; +} + +struct TrackerCandidate { + int id; + int distance; + StringOrView name; +}; + +[[nodiscard]] bool IsBetterTrackerCandidate(const TrackerCandidate &a, const TrackerCandidate &b) +{ + if (a.distance != b.distance) + return a.distance < b.distance; + return a.id < b.id; +} + +[[nodiscard]] constexpr int RedPortalTrackerIdForPosition(Point position) +{ + // Encode tile position into a stable negative id. + // MAXDUNX/MAXDUNY are 112, so this easily fits in int. + return -((position.y * MAXDUNX) + position.x + 1); +} + +[[nodiscard]] constexpr bool IsRedPortalTrackerId(int id) +{ + return id < 0; +} + +[[nodiscard]] constexpr Point RedPortalPositionForTrackerId(int id) +{ + const int encoded = -id - 1; + return { encoded % MAXDUNX, encoded / MAXDUNX }; +} + +[[nodiscard]] StringOrView ItemLabelForSpeech(const Item &item) +{ + const StringOrView name = item.getName(); + if (name.empty()) + return name; + + switch (item._iMagical) { + case ITEM_QUALITY_MAGIC: + return StrCat(name, ", ", _("magic item")); + case ITEM_QUALITY_UNIQUE: + return StrCat(name, ", ", _("unique item")); + default: + return name; + } +} + +[[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 = ItemLabelForSpeech(item), + }); + } + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::vector CollectNearbyCorpseTrackerCandidates(Point playerPosition, int maxDistance) +{ + std::vector result; + + const int minX = std::max(0, playerPosition.x - maxDistance); + const int minY = std::max(0, playerPosition.y - maxDistance); + const int maxX = std::min(MAXDUNX - 1, playerPosition.x + maxDistance); + const int maxY = std::min(MAXDUNY - 1, playerPosition.y + maxDistance); + + for (int y = minY; y <= maxY; ++y) { + for (int x = minX; x <= maxX; ++x) { + if (dCorpse[x][y] == 0) + continue; + + const Point position { x, y }; + const int distance = playerPosition.WalkingDistance(position); + if (distance > maxDistance) + continue; + + result.push_back(TrackerCandidate { + .id = CorpseTrackerIdForPosition(position), + .distance = distance, + .name = _("Dead body"), + }); + } + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] constexpr bool IsTrackedChestObject(const Object &object) +{ + return object.canInteractWith() && (object.IsChest() || object._otype == _object_id::OBJ_SIGNCHEST); +} + +[[nodiscard]] constexpr bool IsTrackedDoorObject(const Object &object) +{ + return object.isDoor() && object.canInteractWith(); +} + +[[nodiscard]] constexpr bool IsShrineLikeObject(const Object &object) +{ + return object.canInteractWith() + && (object.IsShrine() + || IsAnyOf(object._otype, _object_id::OBJ_BLOODFTN, _object_id::OBJ_PURIFYINGFTN, _object_id::OBJ_GOATSHRINE, _object_id::OBJ_CAULDRON, + _object_id::OBJ_MURKYFTN, _object_id::OBJ_TEARFTN)); +} + +[[nodiscard]] constexpr bool IsTrackedBreakableObject(const Object &object) +{ + return object.IsBreakable(); +} + +[[nodiscard]] bool IsLazarusMagicCircleObject(const Object &object) +{ + return setlevel && setlvlnum == SL_VILEBETRAYER && IsAnyOf(object._otype, _object_id::OBJ_MCIRCLE1, _object_id::OBJ_MCIRCLE2); +} + +[[nodiscard]] int TrackerObjectInteractDistance(const Object &object) +{ + return IsLazarusMagicCircleObject(object) ? 0 : TrackerInteractDistanceTiles; +} + +[[nodiscard]] StringOrView TrackerObjectLabelForSpeech(const Object &object) +{ + if (IsLazarusMagicCircleObject(object)) { + if (object._otype == _object_id::OBJ_MCIRCLE1) + return _("Central magic circle"); + return _("Magic circle"); + } + + return object.name(); +} + +[[nodiscard]] bool IsTrackedMiscInteractableObject(const Object &object) +{ + if (IsLazarusMagicCircleObject(object)) + return true; + if (!object.canInteractWith()) + return false; + if (object.IsChest() || object._otype == _object_id::OBJ_SIGNCHEST) + return false; + if (object.isDoor()) + return false; + if (IsShrineLikeObject(object)) + return false; + if (object.IsBreakable()) + return false; + return true; +} + +[[nodiscard]] bool IsTrackedMonster(const Monster &monster) +{ + return !monster.isInvalid + && (monster.flags & MFLAG_HIDDEN) == 0 + && monster.hitPoints > 0 + && !(monster.type().type == MT_GOLEM && monster.position.tile == GolemHoldingCell); +} + +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 = TrackerObjectLabelForSpeech(object), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + 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 (!IsTrackedMonster(monster)) + continue; + + const int distance = playerPosition.ApproxDistance(monster.position.future); + if (distance > maxDistance) + continue; + + result.push_back(TrackerCandidate { + .id = monsterId, + .distance = distance, + .name = MonsterLabelForSpeech(monster), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::vector CollectNpcTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (leveltype != DTYPE_TOWN) + return result; + + result.reserve(GetNumTowners()); + for (size_t i = 0; i < GetNumTowners(); ++i) { + const Towner &towner = Towners[i]; + if (!IsTownerPresent(towner._ttype)) + continue; + + const int distance = playerPosition.WalkingDistance(towner.position); + result.push_back(TrackerCandidate { + .id = static_cast(i), + .distance = distance, + .name = towner.name, + }); + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { + if (a.distance != b.distance) + return a.distance < b.distance; + return a.name.str() < b.name.str(); + }); + return result; +} + +[[nodiscard]] std::vector CollectPlayerTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (!gbIsMultiplayer || MyPlayer == nullptr) + return result; + + result.reserve(MAX_PLRS); + + const uint8_t currentLevel = MyPlayer->plrlevel; + const bool currentIsSetLevel = setlevel; + + for (int i = 0; i < MAX_PLRS; ++i) { + if (i == MyPlayerId) + continue; + const Player &player = Players[i]; + if (!player.plractive) + continue; + if (player._pLvlChanging) + continue; + if (player.plrlevel != currentLevel) + continue; + if (player.plrIsOnSetLevel != currentIsSetLevel) + continue; + + const Point otherPosition = player.position.future; + if (!InDungeonBounds(otherPosition)) + continue; + + const int distance = playerPosition.WalkingDistance(otherPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = player.name(), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::vector CollectDungeonEntranceTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (MyPlayer == nullptr) + return result; + + if (leveltype == DTYPE_TOWN) { + const std::vector candidates = CollectTownDungeonTriggerIndices(); + result.reserve(candidates.size()); + + for (const int triggerIndex : candidates) { + if (triggerIndex < 0 || triggerIndex >= numtrigs) + continue; + const TriggerStruct &trigger = trigs[triggerIndex]; + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = triggerIndex, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; + } + + for (int i = 0; i < numtrigs; ++i) { + const TriggerStruct &trigger = trigs[i]; + if (setlevel) { + if (trigger._tmsg != WM_DIABRTNLVL) + continue; + } else { + if (!IsAnyOf(trigger._tmsg, WM_DIABPREVLVL, WM_DIABTWARPUP)) + continue; + } + + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + // Lazarus' set level (SL_VILEBETRAYER) uses a RedPortal missile instead of a return trigger. + // Include it so the player can navigate out like other quest levels. + if (setlevel) { + for (const Missile &missile : Missiles) { + if (missile._mitype != MissileID::RedPortal) + continue; + const Point portalPosition = missile.position.tile; + if (!InDungeonBounds(portalPosition)) + continue; + const int distance = playerPosition.WalkingDistance(portalPosition); + result.push_back(TrackerCandidate { + .id = RedPortalTrackerIdForPosition(portalPosition), + .distance = distance, + .name = _("Red portal"), + }); + } + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::optional FindTownPortalPositionInTownByPortalIndex(int portalIndex) +{ + if (portalIndex < 0 || portalIndex >= MAXPORTAL) + return std::nullopt; + + for (const Missile &missile : Missiles) { + if (missile._mitype != MissileID::TownPortal) + continue; + if (missile._misource != portalIndex) + continue; + return missile.position.tile; + } + + return std::nullopt; +} + +[[nodiscard]] bool IsTownPortalOpenOnCurrentLevel(int portalIndex) +{ + if (portalIndex < 0 || portalIndex >= MAXPORTAL) + return false; + const Portal &portal = Portals[portalIndex]; + if (!portal.open) + return false; + if (portal.setlvl != setlevel) + return false; + if (portal.level != currlevel) + return false; + if (portal.ltype != leveltype) + return false; + return InDungeonBounds(portal.position); +} + +[[nodiscard]] std::vector CollectPortalTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (MyPlayer == nullptr) + return result; + + if (leveltype == DTYPE_TOWN) { + std::array seen {}; + for (const Missile &missile : Missiles) { + if (missile._mitype != MissileID::TownPortal) + continue; + const int portalIndex = missile._misource; + if (portalIndex < 0 || portalIndex >= MAXPORTAL) + continue; + if (seen[portalIndex]) + continue; + seen[portalIndex] = true; + + const Point portalPosition = missile.position.tile; + const int distance = playerPosition.WalkingDistance(portalPosition); + result.push_back(TrackerCandidate { + .id = portalIndex, + .distance = distance, + .name = TownPortalLabelForSpeech(Portals[portalIndex]), + }); + } + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; + } + + for (int i = 0; i < MAXPORTAL; ++i) { + if (!IsTownPortalOpenOnCurrentLevel(i)) + continue; + const Portal &portal = Portals[i]; + const int distance = playerPosition.WalkingDistance(portal.position); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TownPortalLabelForSpeech(portal), + }); + } + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::vector CollectStairsTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) + return result; + + for (int i = 0; i < numtrigs; ++i) { + const TriggerStruct &trigger = trigs[i]; + if (!IsAnyOf(trigger._tmsg, WM_DIABNEXTLVL, WM_DIABPREVLVL, WM_DIABTWARPUP)) + continue; + + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[nodiscard]] std::vector CollectQuestLocationTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) + return result; + + if (setlevel) { + for (int i = 0; i < numtrigs; ++i) { + const TriggerStruct &trigger = trigs[i]; + if (trigger._tmsg != WM_DIABRTNLVL) + continue; + + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + // Lazarus' set level (SL_VILEBETRAYER) uses a RedPortal missile instead of a return trigger. + for (const Missile &missile : Missiles) { + if (missile._mitype != MissileID::RedPortal) + continue; + const Point portalPosition = missile.position.tile; + if (!InDungeonBounds(portalPosition)) + continue; + const int distance = playerPosition.WalkingDistance(portalPosition); + result.push_back(TrackerCandidate { + .id = RedPortalTrackerIdForPosition(portalPosition), + .distance = distance, + .name = _("Red portal"), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; + } + + constexpr size_t NumQuests = sizeof(Quests) / sizeof(Quests[0]); + result.reserve(NumQuests); + for (size_t questIndex = 0; questIndex < NumQuests; ++questIndex) { + const Quest &quest = Quests[questIndex]; + if (quest._qslvl == SL_NONE) + continue; + if (quest._qactive == QUEST_NOTAVAIL) + continue; + if (quest._qlevel != currlevel) + continue; + if (!InDungeonBounds(quest.position)) + continue; + + const char *questLevelName = QuestLevelNames[quest._qslvl]; + if (questLevelName == nullptr || questLevelName[0] == '\0') + questLevelName = N_("Set level"); + + const int distance = playerPosition.WalkingDistance(quest.position); + result.push_back(TrackerCandidate { + .id = static_cast(questIndex), + .distance = distance, + .name = _(questLevelName), + }); + } + + std::sort(result.begin(), result.end(), IsBetterTrackerCandidate); + return result; +} + +[[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; +} + +[[nodiscard]] std::optional FindPreviousTrackerCandidateId(const std::vector &candidates, int currentId) +{ + if (candidates.empty()) + return std::nullopt; + if (currentId < 0) + return candidates.back().id; + + const auto it = std::find_if(candidates.begin(), candidates.end(), [currentId](const TrackerCandidate &c) { return c.id == currentId; }); + if (it == candidates.end()) + return candidates.back().id; + + if (candidates.size() <= 1) + return std::nullopt; + + const size_t idx = static_cast(it - candidates.begin()); + const size_t prevIdx = (idx + candidates.size() - 1) % candidates.size(); + return candidates[prevIdx].id; +} + +void DecorateTrackerTargetNameWithOrdinalIfNeeded(int targetId, StringOrView &targetName, const std::vector &candidates) +{ + if (targetName.empty()) + return; + + const std::string_view baseName = targetName.str(); + int total = 0; + for (const TrackerCandidate &c : candidates) { + if (c.name.str() == baseName) + ++total; + } + if (total <= 1) + return; + + int ordinal = 0; + int seen = 0; + for (const TrackerCandidate &c : candidates) { + if (c.name.str() != baseName) + continue; + ++seen; + if (c.id == targetId) { + ordinal = seen; + break; + } + } + if (ordinal <= 0) + return; + + std::string decorated; + StrAppend(decorated, baseName, " ", ordinal); + targetName = std::move(decorated); +} + +[[nodiscard]] bool IsGroundItemPresent(int itemId) +{ + if (itemId < 0 || itemId > MAXITEMS) + return false; + + for (uint8_t i = 0; i < ActiveItemCount; ++i) { + if (ActiveItems[i] == itemId) + return true; + } + + return false; +} + +[[nodiscard]] bool IsCorpsePresent(int corpseId) +{ + if (corpseId < 0 || corpseId >= MAXDUNX * MAXDUNY) + return false; + + const Point position = CorpsePositionForTrackerId(corpseId); + return InDungeonBounds(position) && dCorpse[position.x][position.y] != 0; +} + +std::optional FindNearestUnopenedChestObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedChestObject); +} + +std::optional FindNearestDoorObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedDoorObject); +} + +std::optional FindNearestShrineObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsShrineLikeObject); +} + +std::optional FindNearestBreakableObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedBreakableObject); +} + +std::optional FindNearestMiscInteractableObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedMiscInteractableObject); +} + +std::optional FindNearestMonsterId(Point playerPosition) +{ + std::optional bestId; + int bestDistance = 0; + + for (size_t i = 0; i < ActiveMonsterCount; ++i) { + const int monsterId = static_cast(ActiveMonsters[i]); + const Monster &monster = Monsters[monsterId]; + if (!IsTrackedMonster(monster)) + continue; + + const int distance = playerPosition.ApproxDistance(monster.position.future); + if (!bestId || distance < bestDistance) { + bestId = monsterId; + bestDistance = distance; + } + } + + return bestId; +} + +std::optional FindBestAdjacentApproachTile(const Player &player, Point playerPosition, Point targetPosition) +{ + std::optional best; + size_t bestPathLength = 0; + int bestDistance = 0; + + std::optional bestFallback; + int bestFallbackDistance = 0; + + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + if (dx == 0 && dy == 0) + continue; + + const Point tile { targetPosition.x + dx, targetPosition.y + dy }; + if (!PosOkPlayer(player, tile)) + continue; + + const int distance = playerPosition.WalkingDistance(tile); + + if (!bestFallback || distance < bestFallbackDistance) { + bestFallback = tile; + bestFallbackDistance = distance; + } + + const std::optional> path = FindKeyboardWalkPathForSpeech(player, playerPosition, tile); + if (!path) + continue; + + const size_t pathLength = path->size(); + if (!best || pathLength < bestPathLength || (pathLength == bestPathLength && distance < bestDistance)) { + best = tile; + bestPathLength = pathLength; + bestDistance = distance; + } + } + } + + if (best) + return best; + + return bestFallback; +} + +std::optional FindBestApproachTileForObject(const Player &player, Point playerPosition, const Object &object) +{ + if (!object._oSolidFlag && PosOkPlayer(player, object.position)) + return object.position; + + std::optional best; + size_t bestPathLength = 0; + int bestDistance = 0; + + std::optional bestFallback; + int bestFallbackDistance = 0; + + const auto considerTile = [&](Point tile) { + if (!PosOkPlayerIgnoreDoors(player, tile)) + return; + + const int distance = playerPosition.WalkingDistance(tile); + if (!bestFallback || distance < bestFallbackDistance) { + bestFallback = tile; + bestFallbackDistance = distance; + } + + const std::optional> path = FindKeyboardWalkPathForSpeech(player, playerPosition, tile); + if (!path) + return; + + const size_t pathLength = path->size(); + if (!best || pathLength < bestPathLength || (pathLength == bestPathLength && distance < bestDistance)) { + best = tile; + bestPathLength = pathLength; + bestDistance = distance; + } + }; + + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + if (dx == 0 && dy == 0) + continue; + considerTile(object.position + Displacement { dx, dy }); + } + } + + if (FindObjectAtPosition(object.position + Direction::NorthEast) == &object) { + for (int dx = -1; dx <= 1; ++dx) { + considerTile(object.position + Displacement { dx, -2 }); + } + } + + if (best) + return best; + + return bestFallback; +} + +struct DoorBlockInfo { + Point beforeDoor; + Point doorPosition; +}; + +std::optional FindFirstClosedDoorOnWalkPath(Point startPosition, const int8_t *path, int steps) +{ + Point position = startPosition; + for (int i = 0; i < steps; ++i) { + const Point next = NextPositionForWalkDirection(position, path[i]); + Object *object = FindObjectAtPosition(next); + if (object != nullptr && object->isDoor() && object->_oVar4 == DOOR_CLOSED) { + return DoorBlockInfo { .beforeDoor = position, .doorPosition = object->position }; + } + position = next; + } + return std::nullopt; +} + +enum class TrackerPathBlockType : uint8_t { + Door, + Monster, + Breakable, +}; + +struct TrackerPathBlockInfo { + TrackerPathBlockType type; + size_t stepIndex; + Point beforeBlock; + Point blockPosition; +}; + +[[nodiscard]] std::optional FindFirstTrackerPathBlock(Point startPosition, const int8_t *path, size_t steps, bool considerDoors, bool considerMonsters, bool considerBreakables, Point targetPosition) +{ + Point position = startPosition; + for (size_t i = 0; i < steps; ++i) { + const Point next = NextPositionForWalkDirection(position, path[i]); + if (next == targetPosition) { + position = next; + continue; + } + + Object *object = FindObjectAtPosition(next); + if (considerDoors && object != nullptr && object->isDoor() && object->_oVar4 == DOOR_CLOSED) { + return TrackerPathBlockInfo { + .type = TrackerPathBlockType::Door, + .stepIndex = i, + .beforeBlock = position, + .blockPosition = object->position, + }; + } + if (considerBreakables && object != nullptr && object->_oSolidFlag && object->IsBreakable()) { + return TrackerPathBlockInfo { + .type = TrackerPathBlockType::Breakable, + .stepIndex = i, + .beforeBlock = position, + .blockPosition = next, + }; + } + + if (considerMonsters && leveltype != DTYPE_TOWN && dMonster[next.x][next.y] != 0) { + const int monsterRef = dMonster[next.x][next.y]; + const int monsterId = std::abs(monsterRef) - 1; + const bool blocks = monsterRef <= 0 || (monsterId >= 0 && monsterId < static_cast(MaxMonsters) && !Monsters[monsterId].hasNoLife()); + if (blocks) { + return TrackerPathBlockInfo { + .type = TrackerPathBlockType::Monster, + .stepIndex = i, + .beforeBlock = position, + .blockPosition = next, + }; + } + } + + position = next; + } + + return std::nullopt; +} + +/** + * Validates an object-category auto-walk target and computes the walk destination. + */ +template +bool ValidateAutoWalkObjectTarget( + const Player &myPlayer, Point playerPosition, + Predicate isValid, const char *goneMessage, const char *inRangeMessage, + std::optional &destination) +{ + const int objectId = AutoWalkTrackerTargetId; + if (objectId < 0 || objectId >= MAXOBJECTS) { + AutoWalkTrackerTargetId = -1; + SpeakText(_(goneMessage), true); + return false; + } + const Object &object = Objects[objectId]; + if (!isValid(object)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_(goneMessage), true); + return false; + } + if (playerPosition.WalkingDistance(object.position) <= TrackerObjectInteractDistance(object)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_(inRangeMessage), true); + return false; + } + destination = FindBestApproachTileForObject(myPlayer, playerPosition, object); + return true; +} + +/** + * Resolves which object to walk toward for the given tracker category. + */ +template +std::optional ResolveObjectTrackerTarget( + int &lockedTargetId, Point playerPosition, + Predicate isValid, FindNearest findNearest, GetName getName, + const char *notFoundMessage, StringOrView &targetName) +{ + std::optional targetId; + if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = findNearest(playerPosition); + } + if (!targetId) { + SpeakText(_(notFoundMessage), true); + return std::nullopt; + } + if (!isValid(Objects[*targetId])) { + lockedTargetId = -1; + targetId = findNearest(playerPosition); + if (!targetId) { + SpeakText(_(notFoundMessage), true); + return std::nullopt; + } + if (!isValid(Objects[*targetId])) { + SpeakText(_(notFoundMessage), true); + return std::nullopt; + } + } + lockedTargetId = *targetId; + targetName = getName(*targetId); + return targetId; +} + +[[nodiscard]] std::vector TrackerTargetCategoriesForCurrentLevel() +{ + if (leveltype == DTYPE_TOWN) { + return { + TrackerTargetCategory::Items, + TrackerTargetCategory::DeadBodies, + TrackerTargetCategory::Npcs, + TrackerTargetCategory::Players, + TrackerTargetCategory::DungeonEntrances, + TrackerTargetCategory::Portals, + }; + } + + return { + TrackerTargetCategory::Items, + TrackerTargetCategory::Chests, + TrackerTargetCategory::Doors, + TrackerTargetCategory::Shrines, + TrackerTargetCategory::Objects, + TrackerTargetCategory::Breakables, + TrackerTargetCategory::Monsters, + TrackerTargetCategory::DeadBodies, + TrackerTargetCategory::DungeonEntrances, + TrackerTargetCategory::Stairs, + TrackerTargetCategory::QuestLocations, + TrackerTargetCategory::Players, + TrackerTargetCategory::Portals, + }; +} + +void SelectTrackerTargetCategoryRelative(int delta) +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + + AutoWalkTrackerTargetId = -1; + + const std::vector categories = TrackerTargetCategoriesForCurrentLevel(); + if (categories.empty()) + return; + + auto it = std::find(categories.begin(), categories.end(), SelectedTrackerTargetCategory); + int currentIndex = 0; + if (it == categories.end()) { + currentIndex = delta > 0 ? -1 : 0; + } else { + currentIndex = static_cast(it - categories.begin()); + } + + const int count = static_cast(categories.size()); + int newIndex = (currentIndex + delta) % count; + if (newIndex < 0) + newIndex += count; + + SelectedTrackerTargetCategory = categories[static_cast(newIndex)]; + SpeakTrackerTargetCategory(); +} + +[[nodiscard]] std::vector CollectTrackerCandidatesForSelection(TrackerTargetCategory category, Point playerPosition) +{ + switch (category) { + case TrackerTargetCategory::Items: + return CollectNearbyItemTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Chests: + return CollectNearbyChestTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Doors: { + std::vector candidates = CollectNearbyDoorTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + for (TrackerCandidate &c : candidates) { + if (c.id < 0 || c.id >= MAXOBJECTS) + continue; + c.name = DoorLabelForSpeech(Objects[c.id]); + } + return candidates; + } + case TrackerTargetCategory::Shrines: + return CollectNearbyShrineTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Objects: + return CollectNearbyObjectInteractableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Breakables: + return CollectNearbyBreakableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Monsters: + return CollectNearbyMonsterTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::DeadBodies: + return CollectNearbyCorpseTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + case TrackerTargetCategory::Npcs: + return CollectNpcTrackerCandidates(playerPosition); + case TrackerTargetCategory::Players: + return CollectPlayerTrackerCandidates(playerPosition); + case TrackerTargetCategory::DungeonEntrances: + return CollectDungeonEntranceTrackerCandidates(playerPosition); + case TrackerTargetCategory::Stairs: + return CollectStairsTrackerCandidates(playerPosition); + case TrackerTargetCategory::QuestLocations: + return CollectQuestLocationTrackerCandidates(playerPosition); + case TrackerTargetCategory::Portals: + return CollectPortalTrackerCandidates(playerPosition); + } + app_fatal("Invalid TrackerTargetCategory"); +} + +[[nodiscard]] std::string_view TrackerCategoryNoCandidatesFoundMessage(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("No items found."); + case TrackerTargetCategory::Chests: + return _("No chests found."); + case TrackerTargetCategory::Doors: + return _("No doors found."); + case TrackerTargetCategory::Shrines: + return _("No shrines found."); + case TrackerTargetCategory::Objects: + return _("No objects found."); + case TrackerTargetCategory::Breakables: + return _("No breakables found."); + case TrackerTargetCategory::Monsters: + return _("No monsters found."); + case TrackerTargetCategory::DeadBodies: + return _("No dead bodies found."); + case TrackerTargetCategory::Npcs: + return _("No NPCs found."); + case TrackerTargetCategory::Players: + return _("No players found."); + case TrackerTargetCategory::DungeonEntrances: + if (leveltype != DTYPE_TOWN) + return _("No exits found."); + return _("No dungeon entrances found."); + case TrackerTargetCategory::Stairs: + return _("No stairs found."); + case TrackerTargetCategory::QuestLocations: + return _("No quest locations found."); + case TrackerTargetCategory::Portals: + return _("No portals found."); + } + app_fatal("Invalid TrackerTargetCategory"); +} + +[[nodiscard]] constexpr bool TrackerCategorySelectionIsProximityLimited(TrackerTargetCategory category) +{ + return IsAnyOf(category, TrackerTargetCategory::Items, TrackerTargetCategory::Chests, TrackerTargetCategory::Doors, TrackerTargetCategory::Shrines, TrackerTargetCategory::Objects, + TrackerTargetCategory::Breakables, TrackerTargetCategory::Monsters, TrackerTargetCategory::DeadBodies); +} + +[[nodiscard]] bool TrackerCategoryHasAnyTargets(TrackerTargetCategory category, Point playerPosition) +{ + switch (category) { + case TrackerTargetCategory::Items: + return FindNearestGroundItemId(playerPosition).has_value(); + case TrackerTargetCategory::Chests: + return FindNearestUnopenedChestObjectId(playerPosition).has_value(); + case TrackerTargetCategory::Doors: + return FindNearestDoorObjectId(playerPosition).has_value(); + case TrackerTargetCategory::Shrines: + return FindNearestShrineObjectId(playerPosition).has_value(); + case TrackerTargetCategory::Objects: + return FindNearestMiscInteractableObjectId(playerPosition).has_value(); + case TrackerTargetCategory::Breakables: + return FindNearestBreakableObjectId(playerPosition).has_value(); + case TrackerTargetCategory::Monsters: + return FindNearestMonsterId(playerPosition).has_value(); + case TrackerTargetCategory::DeadBodies: + return FindNearestCorpseId(playerPosition).has_value(); + default: + return false; + } +} + +[[nodiscard]] std::string_view TrackerCategoryNoNearbyCandidatesFoundMessage(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("No nearby items found."); + case TrackerTargetCategory::Chests: + return _("No nearby chests found."); + case TrackerTargetCategory::Doors: + return _("No nearby doors found."); + case TrackerTargetCategory::Shrines: + return _("No nearby shrines found."); + case TrackerTargetCategory::Objects: + return _("No nearby objects found."); + case TrackerTargetCategory::Breakables: + return _("No nearby breakables found."); + case TrackerTargetCategory::Monsters: + return _("No nearby monsters found."); + case TrackerTargetCategory::DeadBodies: + return _("No nearby dead bodies found."); + default: + return TrackerCategoryNoCandidatesFoundMessage(category); + } +} + +[[nodiscard]] std::string_view TrackerCategoryNoNextMessage(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("No next item."); + case TrackerTargetCategory::Chests: + return _("No next chest."); + case TrackerTargetCategory::Doors: + return _("No next door."); + case TrackerTargetCategory::Shrines: + return _("No next shrine."); + case TrackerTargetCategory::Objects: + return _("No next object."); + case TrackerTargetCategory::Breakables: + return _("No next breakable."); + case TrackerTargetCategory::Monsters: + return _("No next monster."); + case TrackerTargetCategory::DeadBodies: + return _("No next dead body."); + case TrackerTargetCategory::Npcs: + return _("No next NPC."); + case TrackerTargetCategory::Players: + return _("No next player."); + case TrackerTargetCategory::DungeonEntrances: + return _("No next dungeon entrance."); + case TrackerTargetCategory::Stairs: + return _("No next stairs."); + case TrackerTargetCategory::QuestLocations: + return _("No next quest location."); + case TrackerTargetCategory::Portals: + return _("No next portal."); + } + app_fatal("Invalid TrackerTargetCategory"); +} + +[[nodiscard]] std::string_view TrackerCategoryNoPreviousMessage(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("No previous item."); + case TrackerTargetCategory::Chests: + return _("No previous chest."); + case TrackerTargetCategory::Doors: + return _("No previous door."); + case TrackerTargetCategory::Shrines: + return _("No previous shrine."); + case TrackerTargetCategory::Objects: + return _("No previous object."); + case TrackerTargetCategory::Breakables: + return _("No previous breakable."); + case TrackerTargetCategory::Monsters: + return _("No previous monster."); + case TrackerTargetCategory::DeadBodies: + return _("No previous dead body."); + case TrackerTargetCategory::Npcs: + return _("No previous NPC."); + case TrackerTargetCategory::Players: + return _("No previous player."); + case TrackerTargetCategory::DungeonEntrances: + return _("No previous dungeon entrance."); + case TrackerTargetCategory::Stairs: + return _("No previous stairs."); + case TrackerTargetCategory::QuestLocations: + return _("No previous quest location."); + case TrackerTargetCategory::Portals: + return _("No previous portal."); + } + app_fatal("Invalid TrackerTargetCategory"); +} + +/** + * Returns true if the given tracker category requires a dungeon (i.e. is not + * available in town). + */ +[[nodiscard]] bool IsDungeonOnlyTrackerCategory(TrackerTargetCategory category) +{ + return IsNoneOf(category, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies, + TrackerTargetCategory::Npcs, TrackerTargetCategory::Players, + TrackerTargetCategory::DungeonEntrances, TrackerTargetCategory::Portals); +} + +void SelectTrackerTargetRelative(int delta) +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + if (MyPlayer == nullptr) + return; + + if (leveltype == DTYPE_TOWN && IsDungeonOnlyTrackerCategory(SelectedTrackerTargetCategory)) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + + EnsureTrackerLocksMatchCurrentLevel(); + + const Point playerPosition = MyPlayer->position.future; + AutoWalkTrackerTargetId = -1; + + const std::vector candidates = CollectTrackerCandidatesForSelection(SelectedTrackerTargetCategory, playerPosition); + if (candidates.empty()) { + LockedTrackerTargetId(SelectedTrackerTargetCategory) = -1; + if (TrackerCategorySelectionIsProximityLimited(SelectedTrackerTargetCategory) && TrackerCategoryHasAnyTargets(SelectedTrackerTargetCategory, playerPosition)) + SpeakText(TrackerCategoryNoNearbyCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + else + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + return; + } + + int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); + if (candidates.size() == 1) { + lockedTargetId = candidates.front().id; + SpeakText(candidates.front().name.str(), /*force=*/true); + return; + } + const std::optional targetId = delta > 0 ? FindNextTrackerCandidateId(candidates, lockedTargetId) : FindPreviousTrackerCandidateId(candidates, lockedTargetId); + if (!targetId) { + SpeakText(delta > 0 ? TrackerCategoryNoNextMessage(SelectedTrackerTargetCategory) : TrackerCategoryNoPreviousMessage(SelectedTrackerTargetCategory), true); + return; + } + + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == candidates.end()) { + lockedTargetId = -1; + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + return; + } + + lockedTargetId = *targetId; + StringOrView targetName = it->name.str(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, candidates); + SpeakText(targetName.str(), /*force=*/true); +} + +} // namespace + +namespace { + +void NavigateToTrackerTargetKeyPressed() +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + if (leveltype == DTYPE_TOWN && IsDungeonOnlyTrackerCategory(SelectedTrackerTargetCategory)) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + EnsureTrackerLocksMatchCurrentLevel(); + + const SDL_Keymod modState = SDL_GetModState(); + const bool cycleTarget = (modState & SDL_KMOD_SHIFT) != 0; + const bool clearTarget = (modState & SDL_KMOD_CTRL) != 0; + + const Point playerPosition = MyPlayer->position.future; + AutoWalkTrackerTargetId = -1; + + int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); + if (clearTarget) { + lockedTargetId = -1; + SpeakText(_("Tracker target cleared."), true); + return; + } + + std::optional targetId; + std::optional targetPosition; + std::optional alternateTargetPosition; + StringOrView targetName; + + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + const std::vector nearbyCandidates = CollectNearbyItemTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + 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 = TrackerObjectLabelForSpeech(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::Breakables: { + const std::vector nearbyCandidates = CollectNearbyBreakableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No breakables found."), true); + else + SpeakText(_("No next breakable."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestBreakableObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No breakables found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsTrackedBreakableObject(object)) { + lockedTargetId = -1; + targetId = FindNearestBreakableObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No breakables found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Monsters: { + const std::vector nearbyCandidates = CollectNearbyMonsterTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No monsters found."), true); + else + SpeakText(_("No next monster."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < static_cast(MaxMonsters)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestMonsterId(playerPosition); + } + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + + const Monster &monster = Monsters[*targetId]; + if (!IsTrackedMonster(monster)) { + lockedTargetId = -1; + targetId = FindNearestMonsterId(playerPosition); + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Monster &tracked = Monsters[*targetId]; + + targetName = MonsterLabelForSpeech(tracked); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position.tile; + } + break; + } + case TrackerTargetCategory::DeadBodies: { + const std::vector nearbyCandidates = CollectNearbyCorpseTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No dead bodies found."), true); + else + SpeakText(_("No next dead body."), true); + return; + } + } else if (IsCorpsePresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestCorpseId(playerPosition); + } + if (!targetId) { + SpeakText(_("No dead bodies found."), true); + return; + } + + if (!IsCorpsePresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No dead bodies found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = _("Dead body"); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = CorpsePositionForTrackerId(*targetId); + } + break; + } + case TrackerTargetCategory::Npcs: { + const std::vector nearbyCandidates = CollectNpcTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No NPCs found."), true); + else + SpeakText(_("No next NPC."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < static_cast(GetNumTowners())) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No NPCs found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No NPCs found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = Towners[*targetId].name; + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = Towners[*targetId].position; + } + break; + } + case TrackerTargetCategory::Players: { + const std::vector nearbyCandidates = CollectPlayerTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No players found."), true); + else + SpeakText(_("No next player."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAX_PLRS) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No players found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No players found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = Players[*targetId].name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = Players[*targetId].position.future; + } + break; + } + case TrackerTargetCategory::DungeonEntrances: { + const std::vector nearbyCandidates = CollectDungeonEntranceTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No dungeon entrances found."), true); + else + SpeakText(_("No next dungeon entrance."), true); + return; + } + } else if (!nearbyCandidates.empty()) { + const auto lockedIt = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + targetId = lockedIt != nearbyCandidates.end() ? lockedTargetId : nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No dungeon entrances found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No dungeon entrances found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = std::string(it->name.str()); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + if (IsRedPortalTrackerId(*targetId)) { + targetPosition = RedPortalPositionForTrackerId(*targetId); + } else { + const TriggerStruct &trigger = trigs[*targetId]; + targetPosition = Point { trigger.position.x, trigger.position.y }; + } + } + break; + } + case TrackerTargetCategory::Stairs: { + const std::vector nearbyCandidates = CollectStairsTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No stairs found."), true); + else + SpeakText(_("No next stairs."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < numtrigs) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No stairs found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No stairs found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = std::string(it->name.str()); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + const TriggerStruct &trigger = trigs[*targetId]; + targetPosition = Point { trigger.position.x, trigger.position.y }; + } + break; + } + case TrackerTargetCategory::QuestLocations: { + const std::vector nearbyCandidates = CollectQuestLocationTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No quest locations found."), true); + else + SpeakText(_("No next quest location."), true); + return; + } + } else if (!nearbyCandidates.empty()) { + const auto lockedIt = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + targetId = lockedIt != nearbyCandidates.end() ? lockedTargetId : nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No quest locations found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No quest locations found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = std::string(it->name.str()); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + if (setlevel) { + if (IsRedPortalTrackerId(*targetId)) { + targetPosition = RedPortalPositionForTrackerId(*targetId); + } else { + const TriggerStruct &trigger = trigs[*targetId]; + targetPosition = Point { trigger.position.x, trigger.position.y }; + } + } else { + const Quest &quest = Quests[static_cast(*targetId)]; + targetPosition = quest.position; + } + } + break; + } + case TrackerTargetCategory::Portals: { + const std::vector nearbyCandidates = CollectPortalTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No portals found."), true); + else + SpeakText(_("No next portal."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXPORTAL) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No portals found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No portals found."), true); + return; + } + + Point portalPosition; + if (leveltype == DTYPE_TOWN) { + const std::optional townPos = FindTownPortalPositionInTownByPortalIndex(*targetId); + if (!townPos) { + lockedTargetId = -1; + SpeakText(_("No portals found."), true); + return; + } + portalPosition = *townPos; + } else { + if (!IsTownPortalOpenOnCurrentLevel(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No portals found."), true); + return; + } + portalPosition = Portals[*targetId].position; + } + + lockedTargetId = *targetId; + targetName = TownPortalLabelForSpeech(Portals[*targetId]); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = portalPosition; + } + break; + } + } + + if (cycleTarget) { + SpeakText(targetName.str(), /*force=*/true); + return; + } + + if (!targetPosition) { + SpeakText(_("Can't find a nearby tile to walk to."), true); + return; + } + + Point chosenTargetPosition = *targetPosition; + enum class TrackerPathMode : uint8_t { + RespectDoorsAndMonsters, + IgnoreDoors, + IgnoreMonsters, + IgnoreDoorsAndMonsters, + Lenient, + }; + + auto findPathToTarget = [&](Point destination, TrackerPathMode mode) -> std::optional> { + const bool allowDestinationNonWalkable = !PosOkPlayer(*MyPlayer, destination); + switch (mode) { + case TrackerPathMode::RespectDoorsAndMonsters: + return FindKeyboardWalkPathForSpeechRespectingDoors(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::IgnoreDoors: + return FindKeyboardWalkPathForSpeech(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::IgnoreMonsters: + return FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::IgnoreDoorsAndMonsters: + return FindKeyboardWalkPathForSpeechIgnoringMonsters(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::Lenient: + return FindKeyboardWalkPathForSpeechLenient(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + default: + return std::nullopt; + } + }; + + std::optional> spokenPath; + bool pathIgnoresDoors = false; + bool pathIgnoresMonsters = false; + bool pathIgnoresBreakables = false; + + const auto considerDestination = [&](Point destination, TrackerPathMode mode) { + const std::optional> candidate = findPathToTarget(destination, mode); + if (!candidate) + return; + if (!spokenPath || candidate->size() < spokenPath->size()) { + spokenPath = *candidate; + chosenTargetPosition = destination; + + pathIgnoresDoors = mode == TrackerPathMode::IgnoreDoors || mode == TrackerPathMode::IgnoreDoorsAndMonsters || mode == TrackerPathMode::Lenient; + pathIgnoresMonsters = mode == TrackerPathMode::IgnoreMonsters || mode == TrackerPathMode::IgnoreDoorsAndMonsters || mode == TrackerPathMode::Lenient; + pathIgnoresBreakables = mode == TrackerPathMode::Lenient; + } + }; + + considerDestination(*targetPosition, TrackerPathMode::RespectDoorsAndMonsters); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::RespectDoorsAndMonsters); + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::IgnoreDoors); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreDoors); + } + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::IgnoreMonsters); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreMonsters); + } + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::IgnoreDoorsAndMonsters); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreDoorsAndMonsters); + } + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::Lenient); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::Lenient); + } + + bool showUnreachableWarning = false; + if (!spokenPath) { + showUnreachableWarning = true; + Point closestPosition; + spokenPath = FindKeyboardWalkPathToClosestReachableForSpeech(*MyPlayer, playerPosition, chosenTargetPosition, closestPosition); + pathIgnoresDoors = true; + pathIgnoresMonsters = false; + pathIgnoresBreakables = false; + } + + if (spokenPath && !showUnreachableWarning && !PosOkPlayer(*MyPlayer, chosenTargetPosition)) { + if (!spokenPath->empty()) + spokenPath->pop_back(); + } + + if (spokenPath && (pathIgnoresDoors || pathIgnoresMonsters || pathIgnoresBreakables)) { + const std::optional block = FindFirstTrackerPathBlock(playerPosition, spokenPath->data(), spokenPath->size(), pathIgnoresDoors, pathIgnoresMonsters, pathIgnoresBreakables, chosenTargetPosition); + if (block) { + if (playerPosition.WalkingDistance(block->blockPosition) <= TrackerInteractDistanceTiles) { + switch (block->type) { + case TrackerPathBlockType::Door: + SpeakText(_("A door is blocking the path. Open it and try again."), true); + return; + case TrackerPathBlockType::Monster: + SpeakText(_("A monster is blocking the path. Clear it and try again."), true); + return; + case TrackerPathBlockType::Breakable: + SpeakText(_("A breakable object is blocking the path. Destroy it and try again."), true); + return; + } + } + + spokenPath = std::vector(spokenPath->begin(), spokenPath->begin() + block->stepIndex); + } + } + + std::string message; + if (!targetName.empty()) + StrAppend(message, targetName, "\n"); + if (showUnreachableWarning) { + message.append(_("Can't find a path to the target.")); + if (spokenPath && !spokenPath->empty()) + message.append("\n"); + } + if (spokenPath) { + if (!showUnreachableWarning || !spokenPath->empty()) + AppendKeyboardWalkPathForSpeech(message, *spokenPath); + } + + SpeakText(message, true); +} + +void AutoWalkToTrackerTargetKeyPressed() +{ + if (AutoWalkTrackerTargetId >= 0) { + CancelAutoWalk(); + SpeakText(_("Walk cancelled."), true); + return; + } + + if (!CanPlayerTakeAction() || InGameMenu()) + return; + + if (leveltype == DTYPE_TOWN && IsDungeonOnlyTrackerCategory(SelectedTrackerTargetCategory)) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Cannot walk right now."), true); + return; + } + + EnsureTrackerLocksMatchCurrentLevel(); + + const Point playerPosition = MyPlayer->position.future; + int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); + + std::optional targetId; + StringOrView targetName; + + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + if (IsGroundItemPresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestGroundItemId(playerPosition); + } + if (!targetId) { + SpeakText(_("No items found."), true); + return; + } + if (!IsGroundItemPresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No items found."), true); + return; + } + lockedTargetId = *targetId; + targetName = Items[*targetId].getName(); + break; + } + case TrackerTargetCategory::Chests: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedChestObject, FindNearestUnopenedChestObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No chests found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Doors: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedDoorObject, FindNearestDoorObjectId, [](int id) -> StringOrView { return DoorLabelForSpeech(Objects[id]); }, N_("No doors found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Shrines: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsShrineLikeObject, FindNearestShrineObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No shrines found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Objects: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedMiscInteractableObject, FindNearestMiscInteractableObjectId, [](int id) -> StringOrView { return TrackerObjectLabelForSpeech(Objects[id]); }, N_("No objects found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Breakables: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedBreakableObject, FindNearestBreakableObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No breakables found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Monsters: { + if (lockedTargetId >= 0 && lockedTargetId < static_cast(MaxMonsters)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestMonsterId(playerPosition); + } + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + const Monster &monster = Monsters[*targetId]; + if (!IsTrackedMonster(monster)) { + lockedTargetId = -1; + targetId = FindNearestMonsterId(playerPosition); + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + } + lockedTargetId = *targetId; + targetName = Monsters[*targetId].name(); + break; + } + case TrackerTargetCategory::DeadBodies: { + if (IsCorpsePresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestCorpseId(playerPosition); + } + if (!targetId) { + SpeakText(_("No dead bodies found."), true); + return; + } + if (!IsCorpsePresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No dead bodies found."), true); + return; + } + lockedTargetId = *targetId; + targetName = _("Dead body"); + break; + } + case TrackerTargetCategory::Npcs: { + const std::vector candidates = CollectNpcTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No NPCs found."), true); + return; + } + + if (lockedTargetId >= 0 && lockedTargetId < static_cast(GetNumTowners())) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = Towners[*targetId].name; + break; + } + case TrackerTargetCategory::Players: { + const std::vector candidates = CollectPlayerTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No players found."), true); + return; + } + + if (lockedTargetId >= 0 && lockedTargetId < MAX_PLRS) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = Players[*targetId].name(); + break; + } + case TrackerTargetCategory::DungeonEntrances: { + const std::vector candidates = CollectDungeonEntranceTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No dungeon entrances found."), true); + return; + } + + if (lockedTargetId >= 0 && lockedTargetId < numtrigs) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = TriggerLabelForSpeech(trigs[*targetId]); + break; + } + case TrackerTargetCategory::Stairs: { + const std::vector candidates = CollectStairsTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No stairs found."), true); + return; + } + + if (lockedTargetId >= 0 && lockedTargetId < numtrigs) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = TriggerLabelForSpeech(trigs[*targetId]); + break; + } + case TrackerTargetCategory::QuestLocations: { + const std::vector candidates = CollectQuestLocationTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No quest locations found."), true); + return; + } + + if ((setlevel && lockedTargetId >= 0 && lockedTargetId < numtrigs) || (!setlevel && lockedTargetId >= 0 && lockedTargetId < static_cast(sizeof(Quests) / sizeof(Quests[0])))) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = std::string(candidates.front().name.str()); + if (const auto it = std::find_if(candidates.begin(), candidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); it != candidates.end()) + targetName = std::string(it->name.str()); + break; + } + case TrackerTargetCategory::Portals: { + const std::vector candidates = CollectPortalTrackerCandidates(playerPosition); + if (candidates.empty()) { + SpeakText(_("No portals found."), true); + return; + } + + if (lockedTargetId >= 0 && lockedTargetId < MAXPORTAL) { + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = lockedTargetId](const TrackerCandidate &c) { return c.id == id; }); + if (it != candidates.end()) + targetId = lockedTargetId; + } + if (!targetId) + targetId = candidates.front().id; + + lockedTargetId = *targetId; + targetName = TownPortalLabelForSpeech(Portals[*targetId]); + break; + } + } + + if (!targetId) + return; + + std::string msg; + StrAppend(msg, _("Going to: "), targetName); + SpeakText(msg, true); + + AutoWalkTrackerTargetId = *targetId; + AutoWalkTrackerTargetCategory = SelectedTrackerTargetCategory; + UpdateAutoWalkTracker(); +} + +} // namespace + +void UpdateAutoWalkTracker() +{ + if (AutoWalkTrackerTargetId < 0) + return; + if (IsPlayerInStore() || ChatLogFlag || HelpFlag || InGameMenu()) { + AutoWalkTrackerTargetId = -1; + return; + } + if (leveltype == DTYPE_TOWN + && IsDungeonOnlyTrackerCategory(AutoWalkTrackerTargetCategory)) { + AutoWalkTrackerTargetId = -1; + return; + } + if (!CanPlayerTakeAction()) + return; + + if (MyPlayer == nullptr) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Cannot walk right now."), true); + return; + } + if (MyPlayer->_pmode != PM_STAND) + return; + if (MyPlayer->walkpath[0] != WALK_NONE) + return; + if (MyPlayer->destAction != ACTION_NONE) + return; + + Player &myPlayer = *MyPlayer; + const Point playerPosition = myPlayer.position.future; + + std::optional destination; + + switch (AutoWalkTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + const int itemId = AutoWalkTrackerTargetId; + if (itemId < 0 || itemId > MAXITEMS) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target item is gone."), true); + return; + } + if (!IsGroundItemPresent(itemId)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target item is gone."), true); + return; + } + const Item &item = Items[itemId]; + if (playerPosition.WalkingDistance(item.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Item in range."), true); + return; + } + destination = item.position; + break; + } + case TrackerTargetCategory::Chests: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, + IsTrackedChestObject, N_("Target chest is gone."), N_("Chest in range."), destination)) + return; + break; + case TrackerTargetCategory::Doors: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedDoorObject, N_("Target door is gone."), N_("Door in range."), destination)) + return; + break; + case TrackerTargetCategory::Shrines: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsShrineLikeObject, N_("Target shrine is gone."), N_("Shrine in range."), destination)) + return; + break; + case TrackerTargetCategory::Objects: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedMiscInteractableObject, N_("Target object is gone."), N_("Object in range."), destination)) + return; + break; + case TrackerTargetCategory::Breakables: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedBreakableObject, N_("Target breakable is gone."), N_("Breakable in range."), destination)) + return; + break; + case TrackerTargetCategory::Monsters: { + const int monsterId = AutoWalkTrackerTargetId; + if (monsterId < 0 || monsterId >= static_cast(MaxMonsters)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target monster is gone."), true); + return; + } + const Monster &monster = Monsters[monsterId]; + if (!IsTrackedMonster(monster)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target monster is gone."), true); + return; + } + const Point monsterPosition { monster.position.tile }; + if (playerPosition.WalkingDistance(monsterPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Monster in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, monsterPosition); + break; + } + case TrackerTargetCategory::DeadBodies: { + const int corpseId = AutoWalkTrackerTargetId; + if (!IsCorpsePresent(corpseId)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target dead body is gone."), true); + return; + } + + const Point corpsePosition = CorpsePositionForTrackerId(corpseId); + if (playerPosition.WalkingDistance(corpsePosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Dead body in range."), true); + return; + } + + destination = corpsePosition; + break; + } + case TrackerTargetCategory::Npcs: { + const int npcId = AutoWalkTrackerTargetId; + if (leveltype != DTYPE_TOWN || npcId < 0 || npcId >= static_cast(GetNumTowners())) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target NPC is gone."), true); + return; + } + const Towner &towner = Towners[npcId]; + if (!IsTownerPresent(towner._ttype)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target NPC is gone."), true); + return; + } + if (playerPosition.WalkingDistance(towner.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("NPC in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, towner.position); + break; + } + case TrackerTargetCategory::Players: { + const int playerId = AutoWalkTrackerTargetId; + if (playerId < 0 || playerId >= MAX_PLRS) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target player is gone."), true); + return; + } + const Player &player = Players[playerId]; + if (!player.plractive || player._pLvlChanging || player.plrIsOnSetLevel != setlevel || player.plrlevel != MyPlayer->plrlevel) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target player is gone."), true); + return; + } + const Point targetPosition = player.position.future; + if (!InDungeonBounds(targetPosition)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target player is gone."), true); + return; + } + if (playerPosition.WalkingDistance(targetPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Player in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, targetPosition); + break; + } + case TrackerTargetCategory::DungeonEntrances: { + const int triggerIndex = AutoWalkTrackerTargetId; + if (triggerIndex < 0 || triggerIndex >= numtrigs) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target entrance is gone."), true); + return; + } + const TriggerStruct &trigger = trigs[triggerIndex]; + const bool valid = leveltype == DTYPE_TOWN + ? IsAnyOf(trigger._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP) + : (setlevel ? trigger._tmsg == WM_DIABRTNLVL : IsAnyOf(trigger._tmsg, WM_DIABPREVLVL, WM_DIABTWARPUP)); + if (!valid) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target entrance is gone."), true); + return; + } + const Point triggerPosition { trigger.position.x, trigger.position.y }; + if (playerPosition.WalkingDistance(triggerPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Entrance in range."), true); + return; + } + destination = triggerPosition; + break; + } + case TrackerTargetCategory::Stairs: { + const int triggerIndex = AutoWalkTrackerTargetId; + if (leveltype == DTYPE_TOWN || triggerIndex < 0 || triggerIndex >= numtrigs) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target stairs are gone."), true); + return; + } + const TriggerStruct &trigger = trigs[triggerIndex]; + if (!IsAnyOf(trigger._tmsg, WM_DIABNEXTLVL, WM_DIABPREVLVL, WM_DIABTWARPUP)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target stairs are gone."), true); + return; + } + const Point triggerPosition { trigger.position.x, trigger.position.y }; + if (playerPosition.WalkingDistance(triggerPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Stairs in range."), true); + return; + } + destination = triggerPosition; + break; + } + case TrackerTargetCategory::QuestLocations: { + if (setlevel) { + const int triggerIndex = AutoWalkTrackerTargetId; + if (leveltype == DTYPE_TOWN || triggerIndex < 0 || triggerIndex >= numtrigs) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + const TriggerStruct &trigger = trigs[triggerIndex]; + if (trigger._tmsg != WM_DIABRTNLVL) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + const Point triggerPosition { trigger.position.x, trigger.position.y }; + if (playerPosition.WalkingDistance(triggerPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Quest exit in range."), true); + return; + } + destination = triggerPosition; + break; + } + + const int questIndex = AutoWalkTrackerTargetId; + if (questIndex < 0 || questIndex >= static_cast(sizeof(Quests) / sizeof(Quests[0]))) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + const Quest &quest = Quests[static_cast(questIndex)]; + if (quest._qslvl == SL_NONE || quest._qactive == QUEST_NOTAVAIL || quest._qlevel != currlevel || !InDungeonBounds(quest.position)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + if (playerPosition.WalkingDistance(quest.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Quest entrance in range."), true); + return; + } + destination = quest.position; + break; + } + case TrackerTargetCategory::Portals: { + const int portalIndex = AutoWalkTrackerTargetId; + std::optional portalPosition; + if (leveltype == DTYPE_TOWN) { + portalPosition = FindTownPortalPositionInTownByPortalIndex(portalIndex); + } else if (IsTownPortalOpenOnCurrentLevel(portalIndex)) { + portalPosition = Portals[portalIndex].position; + } + + if (!portalPosition) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target portal is gone."), true); + return; + } + if (playerPosition.WalkingDistance(*portalPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Portal in range."), true); + return; + } + destination = *portalPosition; + break; + } + } + + if (!destination) { + 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 TrackerPageUpKeyPressed() +{ + const SDL_Keymod modState = SDL_GetModState(); + const bool cycleCategory = (modState & SDL_KMOD_CTRL) != 0; + + if (cycleCategory) { + SelectTrackerTargetCategoryRelative(-1); + if (MyPlayer != nullptr) { + const Point playerPosition = MyPlayer->position.future; + if (CollectTrackerCandidatesForSelection(SelectedTrackerTargetCategory, playerPosition).empty()) { + if (TrackerCategorySelectionIsProximityLimited(SelectedTrackerTargetCategory) && TrackerCategoryHasAnyTargets(SelectedTrackerTargetCategory, playerPosition)) + SpeakText(TrackerCategoryNoNearbyCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + else + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + } + } + return; + } + + SelectTrackerTargetRelative(-1); +} + +void TrackerPageDownKeyPressed() +{ + const SDL_Keymod modState = SDL_GetModState(); + const bool cycleCategory = (modState & SDL_KMOD_CTRL) != 0; + + if (cycleCategory) { + SelectTrackerTargetCategoryRelative(+1); + if (MyPlayer != nullptr) { + const Point playerPosition = MyPlayer->position.future; + if (CollectTrackerCandidatesForSelection(SelectedTrackerTargetCategory, playerPosition).empty()) { + if (TrackerCategorySelectionIsProximityLimited(SelectedTrackerTargetCategory) && TrackerCategoryHasAnyTargets(SelectedTrackerTargetCategory, playerPosition)) + SpeakText(TrackerCategoryNoNearbyCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + else + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + } + } + return; + } + + SelectTrackerTargetRelative(+1); +} + +void TrackerHomeKeyPressed() +{ + const SDL_Keymod modState = SDL_GetModState(); + const bool autoWalk = (modState & SDL_KMOD_SHIFT) != 0; + + if (autoWalk) + AutoWalkToTrackerTargetKeyPressed(); + else + NavigateToTrackerTargetKeyPressed(); +} + +void ResetAutoWalkTracker() +{ + AutoWalkTrackerTargetId = -1; +} + +} // namespace devilution diff --git a/Source/controls/tracker.hpp b/Source/controls/tracker.hpp new file mode 100644 index 00000000000..62063ab7024 --- /dev/null +++ b/Source/controls/tracker.hpp @@ -0,0 +1,35 @@ +/** + * @file controls/tracker.hpp + * + * Tracker system for accessibility: target cycling, pathfinding, and auto-walk. + */ +#pragma once + +#include + +namespace devilution { + +enum class TrackerTargetCategory : uint8_t { + Items, + Chests, + Doors, + Shrines, + Objects, + Breakables, + Monsters, + DeadBodies, + Npcs, + Players, + DungeonEntrances, + Stairs, + QuestLocations, + Portals, +}; + +void TrackerPageUpKeyPressed(); +void TrackerPageDownKeyPressed(); +void TrackerHomeKeyPressed(); +void UpdateAutoWalkTracker(); +void ResetAutoWalkTracker(); + +} // namespace devilution diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 7642edc79a4..09bf4b0d73a 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -5,53 +5,54 @@ */ #include #include -#include #include +#include #include -#include #include #include - -#ifdef USE_SDL3 -#include -#include -#include -#else -#include - -#ifdef USE_SDL1 -#include "utils/sdl2_to_1_2_backports.h" -#endif -#endif - -#include - -#include - -#include "DiabloUI/selstart.h" -#include "appfat.h" -#include "automap.h" -#include "capture.h" -#include "control/control.hpp" -#include "cursor.h" -#include "dead.h" -#ifdef _DEBUG -#include "debug.h" -#endif -#include "DiabloUI/diabloui.h" -#include "controls/control_mode.hpp" -#include "controls/keymapper.hpp" -#include "controls/plrctrls.h" -#include "controls/remap_keyboard.h" -#include "diablo.h" -#include "diablo_msg.hpp" -#include "discord/discord.h" -#include "doom.h" -#include "encrypt.h" -#include "engine/backbuffer_state.hpp" -#include "engine/clx_sprite.hpp" -#include "engine/demomode.h" -#include "engine/dx.h" + +#ifdef USE_SDL3 +#include +#include +#include +#else +#include + +#ifdef USE_SDL1 +#include "utils/sdl2_to_1_2_backports.h" +#endif +#endif + +#include + +#include + +#include "DiabloUI/selstart.h" +#include "appfat.h" +#include "automap.h" +#include "capture.h" +#include "control/control.hpp" +#include "cursor.h" +#include "dead.h" +#ifdef _DEBUG +#include "debug.h" +#endif +#include "DiabloUI/diabloui.h" +#include "controls/accessibility_keys.hpp" +#include "controls/control_mode.hpp" +#include "controls/keymapper.hpp" +#include "controls/plrctrls.h" +#include "controls/remap_keyboard.h" +#include "controls/tracker.hpp" +#include "diablo.h" +#include "diablo_msg.hpp" +#include "discord/discord.h" +#include "doom.h" +#include "encrypt.h" +#include "engine/backbuffer_state.hpp" +#include "engine/clx_sprite.hpp" +#include "engine/demomode.h" +#include "engine/dx.h" #include "engine/events.hpp" #include "engine/load_cel.hpp" #include "engine/load_file.hpp" @@ -59,114 +60,114 @@ #include "engine/random.hpp" #include "engine/render/clx_render.hpp" #include "engine/sound.h" -#include "game_mode.hpp" -#include "gamemenu.h" -#include "gmenu.h" -#include "headless_mode.hpp" -#include "help.h" -#include "hwcursor.hpp" -#include "init.hpp" -#include "inv.h" -#include "levels/drlg_l1.h" -#include "levels/drlg_l2.h" -#include "levels/drlg_l3.h" -#include "levels/drlg_l4.h" -#include "levels/gendung.h" -#include "levels/setmaps.h" +#include "game_mode.hpp" +#include "gamemenu.h" +#include "gmenu.h" +#include "headless_mode.hpp" +#include "help.h" +#include "hwcursor.hpp" +#include "init.hpp" +#include "inv.h" +#include "levels/drlg_l1.h" +#include "levels/drlg_l2.h" +#include "levels/drlg_l3.h" +#include "levels/drlg_l4.h" +#include "levels/gendung.h" +#include "levels/setmaps.h" #include "levels/themes.h" #include "levels/town.h" #include "levels/trigs.h" -#include "levels/tile_properties.hpp" #include "lighting.h" #include "loadsave.h" #include "lua/lua_global.hpp" -#include "menu.h" -#include "minitext.h" -#include "missiles.h" -#include "movie.h" -#include "multi.h" -#include "nthread.h" -#include "objects.h" +#include "menu.h" +#include "minitext.h" +#include "missiles.h" +#include "movie.h" +#include "multi.h" +#include "nthread.h" +#include "objects.h" #include "options.h" +#include "panels/charpanel.hpp" #include "panels/console.hpp" #include "panels/info_box.hpp" -#include "panels/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" -#include "qol/monhealthbar.h" -#include "qol/stash.h" -#include "qol/xpbar.h" -#include "quick_messages.hpp" -#include "restrict.h" -#include "stores.h" -#include "storm/storm_net.hpp" -#include "storm/storm_svid.h" -#include "tables/monstdat.h" -#include "tables/playerdat.hpp" -#include "towners.h" -#include "track.h" +#include "portal.h" +#include "qol/chatlog.h" +#include "qol/floatingnumbers.h" +#include "qol/itemlabels.h" +#include "qol/monhealthbar.h" +#include "qol/stash.h" +#include "qol/xpbar.h" +#include "quick_messages.hpp" +#include "restrict.h" +#include "stores.h" +#include "storm/storm_net.hpp" +#include "storm/storm_svid.h" +#include "tables/monstdat.h" +#include "tables/playerdat.hpp" +#include "towners.h" +#include "track.h" #include "utils/console.h" #include "utils/display.h" -#include "utils/format_int.hpp" #include "utils/is_of.hpp" #include "utils/language.h" #include "utils/parse_int.hpp" #include "utils/paths.h" +#include "utils/accessibility_announcements.hpp" +#include "utils/navigation_speech.hpp" #include "utils/proximity_audio.hpp" #include "utils/screen_reader.hpp" #include "utils/sdl_compat.h" #include "utils/sdl_thread.h" #include "utils/status_macros.hpp" -#include "utils/str_cat.hpp" -#include "utils/utf8.hpp" - -#ifndef USE_SDL1 -#include "controls/touch/gamepad.h" -#include "controls/touch/renderers.h" -#endif - -#ifdef __vita__ -#include "platform/vita/touch.h" -#endif - -#ifdef GPERF_HEAP_FIRST_GAME_ITERATION -#include -#endif - -namespace devilution { - -uint32_t DungeonSeeds[NUMLEVELS]; -std::optional LevelSeeds[NUMLEVELS]; -Point MousePosition; -bool gbRunGameResult; -bool ReturnToMainMenu; -/** Enable updating of player character, set to false once Diablo dies */ -bool gbProcessPlayers; -bool gbLoadGame; -bool cineflag; -int PauseMode; -clicktype sgbMouseDown; -uint16_t gnTickDelay = 50; -char gszProductName[64] = "DevilutionX vUnknown"; - -#ifdef _DEBUG -bool DebugDisableNetworkTimeout = false; -std::vector DebugCmdsFromCommandLine; -#endif -GameLogicStep gGameLogicStep = GameLogicStep::None; - -/** This and the following mouse variables are for handling in-game click-and-hold actions */ -PlayerActionType LastPlayerAction = PlayerActionType::None; - -// Controller support: Actions to run after updating the cursor state. -// Defined in SourceX/controls/plctrls.cpp. +#include "utils/str_cat.hpp" +#include "utils/utf8.hpp" + +#ifndef USE_SDL1 +#include "controls/touch/gamepad.h" +#include "controls/touch/renderers.h" +#endif + +#ifdef __vita__ +#include "platform/vita/touch.h" +#endif + +#ifdef GPERF_HEAP_FIRST_GAME_ITERATION +#include +#endif + +namespace devilution { + +uint32_t DungeonSeeds[NUMLEVELS]; +std::optional LevelSeeds[NUMLEVELS]; +Point MousePosition; +bool gbRunGameResult; +bool ReturnToMainMenu; +/** Enable updating of player character, set to false once Diablo dies */ +bool gbProcessPlayers; +bool gbLoadGame; +bool cineflag; +int PauseMode; +clicktype sgbMouseDown; +uint16_t gnTickDelay = 50; +char gszProductName[64] = "DevilutionX vUnknown"; + +#ifdef _DEBUG +bool DebugDisableNetworkTimeout = false; +std::vector DebugCmdsFromCommandLine; +#endif +GameLogicStep gGameLogicStep = GameLogicStep::None; + +/** This and the following mouse variables are for handling in-game click-and-hold actions */ +PlayerActionType LastPlayerAction = PlayerActionType::None; + +// Controller support: Actions to run after updating the cursor state. +// Defined in Source/controls/plrctrls.cpp. extern void plrctrls_after_check_curs_move(); extern void plrctrls_every_frame(); extern void plrctrls_after_game_logic(); @@ -175,450 +176,435 @@ 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 */ -bool was_archives_init = false; -/** To know if surfaces have been initialized or not */ -bool was_window_init = false; -bool was_ui_init = false; - -void StartGame(interface_mode uMsg) -{ - CalcViewportGeometry(); - cineflag = false; - InitCursor(); -#ifdef _DEBUG - LoadDebugGFX(); -#endif - assert(HeadlessMode || ghMainWnd); - music_stop(); - InitMonsterHealthBar(); - InitXPBar(); - ShowProgress(uMsg); - gmenu_init_menu(); - InitLevelCursor(); - sgnTimeoutCurs = CURSOR_NONE; - sgbMouseDown = CLICK_NONE; - LastPlayerAction = PlayerActionType::None; -} - -void FreeGame() -{ - FreeMonsterHealthBar(); - FreeXPBar(); - FreeControlPan(); - FreeInvGFX(); - FreeGMenu(); - FreeQuestText(); - FreeInfoBoxGfx(); - FreeStoreMem(); - - for (Player &player : Players) - ResetPlayerGFX(player); - - FreeCursor(); -#ifdef _DEBUG - FreeDebugGFX(); -#endif - FreeGameMem(); - stream_stop(); - music_stop(); -} - -bool ProcessInput() -{ - if (PauseMode == 2) { - return false; - } - - plrctrls_every_frame(); - - if (!gbIsMultiplayer && gmenu_is_active()) { - RedrawViewport(); - return false; - } - - if (!gmenu_is_active() && sgnTimeoutCurs == CURSOR_NONE) { -#ifdef __vita__ - FinishSimulatedMouseClicks(MousePosition); -#endif - CheckCursMove(); - plrctrls_after_check_curs_move(); - RepeatPlayerAction(); - } - - return true; -} - -void LeftMouseCmd(bool bShift) -{ - bool bNear; - - assert(!GetMainPanel().contains(MousePosition)); - - if (leveltype == DTYPE_TOWN) { - CloseGoldWithdraw(); - CloseStash(); - if (pcursitem != -1 && pcurs == CURSOR_HAND) - NetSendCmdLocParam1(true, invflag ? CMD_GOTOGETITEM : CMD_GOTOAGETITEM, cursPosition, pcursitem); - if (pcursmonst != -1) - NetSendCmdLocParam1(true, CMD_TALKXY, cursPosition, pcursmonst); - if (pcursitem == -1 && pcursmonst == -1 && PlayerUnderCursor == nullptr) { - LastPlayerAction = PlayerActionType::Walk; - NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, cursPosition); - } - return; - } - - const Player &myPlayer = *MyPlayer; - bNear = myPlayer.position.tile.WalkingDistance(cursPosition) < 2; - if (pcursitem != -1 && pcurs == CURSOR_HAND && !bShift) { - NetSendCmdLocParam1(true, invflag ? CMD_GOTOGETITEM : CMD_GOTOAGETITEM, cursPosition, pcursitem); - } else if (ObjectUnderCursor != nullptr && !ObjectUnderCursor->IsDisabled() && (!bShift || (bNear && ObjectUnderCursor->_oBreak == 1))) { - LastPlayerAction = PlayerActionType::OperateObject; - NetSendCmdLoc(MyPlayerId, true, pcurs == CURSOR_DISARM ? CMD_DISARMXY : CMD_OPOBJXY, cursPosition); - } else if (myPlayer.UsesRangedWeapon()) { - if (bShift) { - LastPlayerAction = PlayerActionType::Attack; - NetSendCmdLoc(MyPlayerId, true, CMD_RATTACKXY, cursPosition); - } else if (pcursmonst != -1) { - if (CanTalkToMonst(Monsters[pcursmonst])) { - NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); - } else { - LastPlayerAction = PlayerActionType::AttackMonsterTarget; - NetSendCmdParam1(true, CMD_RATTACKID, pcursmonst); - } - } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { - LastPlayerAction = PlayerActionType::AttackPlayerTarget; - NetSendCmdParam1(true, CMD_RATTACKPID, PlayerUnderCursor->getId()); - } - } else { - if (bShift) { - if (pcursmonst != -1) { - if (CanTalkToMonst(Monsters[pcursmonst])) { - NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); - } else { - LastPlayerAction = PlayerActionType::Attack; - NetSendCmdLoc(MyPlayerId, true, CMD_SATTACKXY, cursPosition); - } - } else { - LastPlayerAction = PlayerActionType::Attack; - NetSendCmdLoc(MyPlayerId, true, CMD_SATTACKXY, cursPosition); - } - } else if (pcursmonst != -1) { - LastPlayerAction = PlayerActionType::AttackMonsterTarget; - NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); - } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { - LastPlayerAction = PlayerActionType::AttackPlayerTarget; - NetSendCmdParam1(true, CMD_ATTACKPID, PlayerUnderCursor->getId()); - } - } - if (!bShift && pcursitem == -1 && ObjectUnderCursor == nullptr && pcursmonst == -1 && PlayerUnderCursor == nullptr) { - LastPlayerAction = PlayerActionType::Walk; - NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, cursPosition); - } -} - -bool TryOpenDungeonWithMouse() -{ - if (leveltype != DTYPE_TOWN) - return false; - - const Item &holdItem = MyPlayer->HoldItem; - if (holdItem.IDidx == IDI_RUNEBOMB && OpensHive(cursPosition)) - OpenHive(); - else if (holdItem.IDidx == IDI_MAPOFDOOM && OpensGrave(cursPosition)) - OpenGrave(); - else - return false; - - NewCursor(CURSOR_HAND); - return true; -} - -void LeftMouseDown(uint16_t modState) -{ - LastPlayerAction = PlayerActionType::None; - - if (gmenu_left_mouse(true)) - return; - - if (CheckMuteButton()) - return; - - if (sgnTimeoutCurs != CURSOR_NONE) - return; - - if (MyPlayerIsDead) { - CheckMainPanelButtonDead(); - return; - } - - if (PauseMode == 2) { - return; - } - if (DoomFlag) { - doom_close(); - return; - } - - if (SpellSelectFlag) { - SetSpell(); - return; - } - - if (IsPlayerInStore()) { - CheckStoreBtn(); - return; - } - - const bool isShiftHeld = (modState & SDL_KMOD_SHIFT) != 0; - const bool isCtrlHeld = (modState & SDL_KMOD_CTRL) != 0; - - if (!GetMainPanel().contains(MousePosition)) { - if (!gmenu_is_active() && !TryIconCurs()) { - if (QuestLogIsOpen && GetLeftPanel().contains(MousePosition)) { - QuestlogESC(); - } else if (qtextflag) { - qtextflag = false; - stream_stop(); - } else if (CharFlag && GetLeftPanel().contains(MousePosition)) { - CheckChrBtns(); - } else if (invflag && GetRightPanel().contains(MousePosition)) { - if (!DropGoldFlag) - CheckInvItem(isShiftHeld, isCtrlHeld); - } else if (IsStashOpen && GetLeftPanel().contains(MousePosition)) { - if (!IsWithdrawGoldOpen) - CheckStashItem(MousePosition, isShiftHeld, isCtrlHeld); - CheckStashButtonPress(MousePosition); - } else if (SpellbookFlag && GetRightPanel().contains(MousePosition)) { - CheckSBook(); - } else if (!MyPlayer->HoldItem.isEmpty()) { - if (!TryOpenDungeonWithMouse()) { - const Point currentPosition = MyPlayer->position.tile; - std::optional itemTile = FindAdjacentPositionForItem(currentPosition, GetDirection(currentPosition, cursPosition)); - if (itemTile) { - NetSendCmdPItem(true, CMD_PUTITEM, *itemTile, MyPlayer->HoldItem); - NewCursor(CURSOR_HAND); - } - } - } else { - CheckLevelButton(); - if (!LevelButtonDown) - LeftMouseCmd(isShiftHeld); - } - } - } else { - if (!ChatFlag && !DropGoldFlag && !IsWithdrawGoldOpen && !gmenu_is_active()) - CheckInvScrn(isShiftHeld, isCtrlHeld); - CheckMainPanelButton(); - CheckStashButtonPress(MousePosition); - if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) - NewCursor(CURSOR_HAND); - } -} - -void LeftMouseUp(uint16_t modState) -{ - gmenu_left_mouse(false); - CheckMuteButtonUp(); - if (MainPanelButtonDown) - CheckMainPanelButtonUp(); - CheckStashButtonRelease(MousePosition); - if (CharPanelButtonActive) { - const bool isShiftHeld = (modState & SDL_KMOD_SHIFT) != 0; - ReleaseChrBtns(isShiftHeld); - } - if (LevelButtonDown) - CheckLevelButtonUp(); - if (IsPlayerInStore()) - ReleaseStoreBtn(); -} - -void RightMouseDown(bool isShiftHeld) -{ - LastPlayerAction = PlayerActionType::None; - - if (gmenu_is_active() || sgnTimeoutCurs != CURSOR_NONE || PauseMode == 2 || MyPlayer->_pInvincible) { - return; - } - - if (qtextflag) { - qtextflag = false; - stream_stop(); - return; - } - - if (DoomFlag) { - doom_close(); - return; - } - if (IsPlayerInStore()) - return; - if (SpellSelectFlag) { - SetSpell(); - return; - } - if (SpellbookFlag && GetRightPanel().contains(MousePosition)) - return; - if (TryIconCurs()) - return; - if (pcursinvitem != -1 && UseInvItem(pcursinvitem)) - return; - if (pcursstashitem != StashStruct::EmptyCell && UseStashItem(pcursstashitem)) - return; - if (DidRightClickPartyPortrait()) - return; - if (pcurs == CURSOR_HAND) { - CheckPlrSpell(isShiftHeld); - } else if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { - NewCursor(CURSOR_HAND); - } -} - -void ReleaseKey(SDL_Keycode vkey) -{ - remap_keyboard_key(&vkey); - if (sgnTimeoutCurs != CURSOR_NONE) - return; - KeymapperRelease(vkey); -} - -void ClosePanels() -{ - if (CanPanelsCoverView()) { - if (!IsLeftPanelOpen() && IsRightPanelOpen() && MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); - } else if (!IsRightPanelOpen() && IsLeftPanelOpen() && MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition - Displacement { 160, 0 }); - } - } - CloseInventory(); - CloseCharPanel(); - SpellbookFlag = false; - QuestLogIsOpen = false; -} - -void PressKey(SDL_Keycode vkey, uint16_t modState) -{ - Options &options = GetOptions(); - remap_keyboard_key(&vkey); - - if (vkey == SDLK_UNKNOWN) - return; - - if (gmenu_presskeys(vkey) || CheckKeypress(vkey)) { - return; - } - - if (MyPlayerIsDead) { - if (vkey == SDLK_ESCAPE) { - if (!gbIsMultiplayer) { - if (gbValidSaveFile) - gamemenu_load_game(false); - else - gamemenu_exit_game(false); - } else { - NetSendCmd(true, CMD_RETOWN); - } - return; - } - if (sgnTimeoutCurs != CURSOR_NONE) { - return; - } - KeymapperPress(vkey); - if (vkey == SDLK_RETURN || vkey == SDLK_KP_ENTER) { - if ((modState & SDL_KMOD_ALT) != 0) { - options.Graphics.fullscreen.SetValue(!IsFullScreen()); - if (!demo::IsRunning()) SaveOptions(); - } else { - TypeChatMessage(); - } - } - if (vkey != SDLK_ESCAPE) { - return; - } - } - // Disallow player from accessing escape menu during the frames before the death message appears - if (vkey == SDLK_ESCAPE && MyPlayer->_pHitPoints > 0) { - if (!PressEscKey()) { - LastPlayerAction = PlayerActionType::None; - gamemenu_on(); - } - return; - } - - if (DropGoldFlag) { - control_drop_gold(vkey); - return; - } - if (IsWithdrawGoldOpen) { - WithdrawGoldKeyPress(vkey); - return; - } - - if (sgnTimeoutCurs != CURSOR_NONE) { - return; - } - - KeymapperPress(vkey); - - if (PauseMode == 2) { - if ((vkey == SDLK_RETURN || vkey == SDLK_KP_ENTER) && (modState & SDL_KMOD_ALT) != 0) { - options.Graphics.fullscreen.SetValue(!IsFullScreen()); - if (!demo::IsRunning()) SaveOptions(); - } - return; - } - - if (DoomFlag) { - doom_close(); - return; - } - - switch (vkey) { - case SDLK_PLUS: - case SDLK_KP_PLUS: - case SDLK_EQUALS: - case SDLK_KP_EQUALS: - if (AutomapActive) { - AutomapZoomIn(); - } - return; - case SDLK_MINUS: - case SDLK_KP_MINUS: - case SDLK_UNDERSCORE: - if (AutomapActive) { - AutomapZoomOut(); - } - return; -#ifdef _DEBUG - case SDLK_V: - if ((modState & SDL_KMOD_SHIFT) != 0) - NextDebugMonster(); - else - GetDebugMonster(); - return; -#endif +int sgnTimeoutCurs; +bool gbShowIntro = true; +/** To know if these things have been done when we get to the diablo_deinit() function */ +bool was_archives_init = false; +/** To know if surfaces have been initialized or not */ +bool was_window_init = false; +bool was_ui_init = false; + +void StartGame(interface_mode uMsg) +{ + CalcViewportGeometry(); + cineflag = false; + InitCursor(); +#ifdef _DEBUG + LoadDebugGFX(); +#endif + assert(HeadlessMode || ghMainWnd); + music_stop(); + InitMonsterHealthBar(); + InitXPBar(); + ShowProgress(uMsg); + gmenu_init_menu(); + InitLevelCursor(); + sgnTimeoutCurs = CURSOR_NONE; + sgbMouseDown = CLICK_NONE; + LastPlayerAction = PlayerActionType::None; +} + +void FreeGame() +{ + FreeMonsterHealthBar(); + FreeXPBar(); + FreeControlPan(); + FreeInvGFX(); + FreeGMenu(); + FreeQuestText(); + FreeInfoBoxGfx(); + FreeStoreMem(); + + for (Player &player : Players) + ResetPlayerGFX(player); + + FreeCursor(); +#ifdef _DEBUG + FreeDebugGFX(); +#endif + FreeGameMem(); + stream_stop(); + music_stop(); +} + +bool ProcessInput() +{ + if (PauseMode == 2) { + return false; + } + + plrctrls_every_frame(); + + if (!gbIsMultiplayer && gmenu_is_active()) { + RedrawViewport(); + return false; + } + + if (!gmenu_is_active() && sgnTimeoutCurs == CURSOR_NONE) { +#ifdef __vita__ + FinishSimulatedMouseClicks(MousePosition); +#endif + CheckCursMove(); + plrctrls_after_check_curs_move(); + RepeatPlayerAction(); + } + + return true; +} + +void LeftMouseCmd(bool bShift) +{ + bool bNear; + + assert(!GetMainPanel().contains(MousePosition)); + + if (leveltype == DTYPE_TOWN) { + CloseGoldWithdraw(); + CloseStash(); + if (pcursitem != -1 && pcurs == CURSOR_HAND) + NetSendCmdLocParam1(true, invflag ? CMD_GOTOGETITEM : CMD_GOTOAGETITEM, cursPosition, pcursitem); + if (pcursmonst != -1) + NetSendCmdLocParam1(true, CMD_TALKXY, cursPosition, pcursmonst); + if (pcursitem == -1 && pcursmonst == -1 && PlayerUnderCursor == nullptr) { + LastPlayerAction = PlayerActionType::Walk; + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, cursPosition); + } + return; + } + + const Player &myPlayer = *MyPlayer; + bNear = myPlayer.position.tile.WalkingDistance(cursPosition) < 2; + if (pcursitem != -1 && pcurs == CURSOR_HAND && !bShift) { + NetSendCmdLocParam1(true, invflag ? CMD_GOTOGETITEM : CMD_GOTOAGETITEM, cursPosition, pcursitem); + } else if (ObjectUnderCursor != nullptr && !ObjectUnderCursor->IsDisabled() && (!bShift || (bNear && ObjectUnderCursor->_oBreak == 1))) { + LastPlayerAction = PlayerActionType::OperateObject; + NetSendCmdLoc(MyPlayerId, true, pcurs == CURSOR_DISARM ? CMD_DISARMXY : CMD_OPOBJXY, cursPosition); + } else if (myPlayer.UsesRangedWeapon()) { + if (bShift) { + LastPlayerAction = PlayerActionType::Attack; + NetSendCmdLoc(MyPlayerId, true, CMD_RATTACKXY, cursPosition); + } else if (pcursmonst != -1) { + if (CanTalkToMonst(Monsters[pcursmonst])) { + NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); + } else { + LastPlayerAction = PlayerActionType::AttackMonsterTarget; + NetSendCmdParam1(true, CMD_RATTACKID, pcursmonst); + } + } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { + LastPlayerAction = PlayerActionType::AttackPlayerTarget; + NetSendCmdParam1(true, CMD_RATTACKPID, PlayerUnderCursor->getId()); + } + } else { + if (bShift) { + if (pcursmonst != -1) { + if (CanTalkToMonst(Monsters[pcursmonst])) { + NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); + } else { + LastPlayerAction = PlayerActionType::Attack; + NetSendCmdLoc(MyPlayerId, true, CMD_SATTACKXY, cursPosition); + } + } else { + LastPlayerAction = PlayerActionType::Attack; + NetSendCmdLoc(MyPlayerId, true, CMD_SATTACKXY, cursPosition); + } + } else if (pcursmonst != -1) { + LastPlayerAction = PlayerActionType::AttackMonsterTarget; + NetSendCmdParam1(true, CMD_ATTACKID, pcursmonst); + } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { + LastPlayerAction = PlayerActionType::AttackPlayerTarget; + NetSendCmdParam1(true, CMD_ATTACKPID, PlayerUnderCursor->getId()); + } + } + if (!bShift && pcursitem == -1 && ObjectUnderCursor == nullptr && pcursmonst == -1 && PlayerUnderCursor == nullptr) { + LastPlayerAction = PlayerActionType::Walk; + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, cursPosition); + } +} + +bool TryOpenDungeonWithMouse() +{ + if (leveltype != DTYPE_TOWN) + return false; + + const Item &holdItem = MyPlayer->HoldItem; + if (holdItem.IDidx == IDI_RUNEBOMB && OpensHive(cursPosition)) + OpenHive(); + else if (holdItem.IDidx == IDI_MAPOFDOOM && OpensGrave(cursPosition)) + OpenGrave(); + else + return false; + + NewCursor(CURSOR_HAND); + return true; +} + +void LeftMouseDown(uint16_t modState) +{ + LastPlayerAction = PlayerActionType::None; + + if (gmenu_left_mouse(true)) + return; + + if (CheckMuteButton()) + return; + + if (sgnTimeoutCurs != CURSOR_NONE) + return; + + if (MyPlayerIsDead) { + CheckMainPanelButtonDead(); + return; + } + + if (PauseMode == 2) { + return; + } + if (DoomFlag) { + doom_close(); + return; + } + + if (SpellSelectFlag) { + SetSpell(); + return; + } + + if (IsPlayerInStore()) { + CheckStoreBtn(); + return; + } + + const bool isShiftHeld = (modState & SDL_KMOD_SHIFT) != 0; + const bool isCtrlHeld = (modState & SDL_KMOD_CTRL) != 0; + + if (!GetMainPanel().contains(MousePosition)) { + if (!gmenu_is_active() && !TryIconCurs()) { + if (QuestLogIsOpen && GetLeftPanel().contains(MousePosition)) { + QuestlogESC(); + } else if (qtextflag) { + qtextflag = false; + stream_stop(); + } else if (CharFlag && GetLeftPanel().contains(MousePosition)) { + CheckChrBtns(); + } else if (invflag && GetRightPanel().contains(MousePosition)) { + if (!DropGoldFlag) + CheckInvItem(isShiftHeld, isCtrlHeld); + } else if (IsStashOpen && GetLeftPanel().contains(MousePosition)) { + if (!IsWithdrawGoldOpen) + CheckStashItem(MousePosition, isShiftHeld, isCtrlHeld); + CheckStashButtonPress(MousePosition); + } else if (SpellbookFlag && GetRightPanel().contains(MousePosition)) { + CheckSBook(); + } else if (!MyPlayer->HoldItem.isEmpty()) { + if (!TryOpenDungeonWithMouse()) { + const Point currentPosition = MyPlayer->position.tile; + std::optional itemTile = FindAdjacentPositionForItem(currentPosition, GetDirection(currentPosition, cursPosition)); + if (itemTile) { + NetSendCmdPItem(true, CMD_PUTITEM, *itemTile, MyPlayer->HoldItem); + NewCursor(CURSOR_HAND); + } + } + } else { + CheckLevelButton(); + if (!LevelButtonDown) + LeftMouseCmd(isShiftHeld); + } + } + } else { + if (!ChatFlag && !DropGoldFlag && !IsWithdrawGoldOpen && !gmenu_is_active()) + CheckInvScrn(isShiftHeld, isCtrlHeld); + CheckMainPanelButton(); + CheckStashButtonPress(MousePosition); + if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) + NewCursor(CURSOR_HAND); + } +} + +void LeftMouseUp(uint16_t modState) +{ + gmenu_left_mouse(false); + CheckMuteButtonUp(); + if (MainPanelButtonDown) + CheckMainPanelButtonUp(); + CheckStashButtonRelease(MousePosition); + if (CharPanelButtonActive) { + const bool isShiftHeld = (modState & SDL_KMOD_SHIFT) != 0; + ReleaseChrBtns(isShiftHeld); + } + if (LevelButtonDown) + CheckLevelButtonUp(); + if (IsPlayerInStore()) + ReleaseStoreBtn(); +} + +void RightMouseDown(bool isShiftHeld) +{ + LastPlayerAction = PlayerActionType::None; + + if (gmenu_is_active() || sgnTimeoutCurs != CURSOR_NONE || PauseMode == 2 || MyPlayer->_pInvincible) { + return; + } + + if (qtextflag) { + qtextflag = false; + stream_stop(); + return; + } + + if (DoomFlag) { + doom_close(); + return; + } + if (IsPlayerInStore()) + return; + if (SpellSelectFlag) { + SetSpell(); + return; + } + if (SpellbookFlag && GetRightPanel().contains(MousePosition)) + return; + if (TryIconCurs()) + return; + if (pcursinvitem != -1 && UseInvItem(pcursinvitem)) + return; + if (pcursstashitem != StashStruct::EmptyCell && UseStashItem(pcursstashitem)) + return; + if (DidRightClickPartyPortrait()) + return; + if (pcurs == CURSOR_HAND) { + CheckPlrSpell(isShiftHeld); + } else if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { + NewCursor(CURSOR_HAND); + } +} + +void ReleaseKey(SDL_Keycode vkey) +{ + remap_keyboard_key(&vkey); + if (sgnTimeoutCurs != CURSOR_NONE) + return; + KeymapperRelease(vkey); +} + +void ClosePanels() +{ + if (CanPanelsCoverView()) { + if (!IsLeftPanelOpen() && IsRightPanelOpen() && MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); + } else if (!IsRightPanelOpen() && IsLeftPanelOpen() && MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition - Displacement { 160, 0 }); + } + } + CloseInventory(); + CloseCharPanel(); + SpellbookFlag = false; + QuestLogIsOpen = false; +} + +void PressKey(SDL_Keycode vkey, uint16_t modState) +{ + Options &options = GetOptions(); + remap_keyboard_key(&vkey); + + if (vkey == SDLK_UNKNOWN) + return; + + if (gmenu_presskeys(vkey) || CheckKeypress(vkey)) { + return; + } + + if (MyPlayerIsDead) { + if (vkey == SDLK_ESCAPE) { + if (!gbIsMultiplayer) { + if (gbValidSaveFile) + gamemenu_load_game(false); + else + gamemenu_exit_game(false); + } else { + NetSendCmd(true, CMD_RETOWN); + } + return; + } + if (sgnTimeoutCurs != CURSOR_NONE) { + return; + } + KeymapperPress(vkey); + if (vkey == SDLK_RETURN || vkey == SDLK_KP_ENTER) { + if ((modState & SDL_KMOD_ALT) != 0) { + options.Graphics.fullscreen.SetValue(!IsFullScreen()); + if (!demo::IsRunning()) SaveOptions(); + } else { + TypeChatMessage(); + } + } + if (vkey != SDLK_ESCAPE) { + return; + } + } + // Disallow player from accessing escape menu during the frames before the death message appears + if (vkey == SDLK_ESCAPE && MyPlayer->_pHitPoints > 0) { + if (!PressEscKey()) { + LastPlayerAction = PlayerActionType::None; + gamemenu_on(); + } + return; + } + + if (DropGoldFlag) { + control_drop_gold(vkey); + return; + } + if (IsWithdrawGoldOpen) { + WithdrawGoldKeyPress(vkey); + return; + } + + if (sgnTimeoutCurs != CURSOR_NONE) { + return; + } + + KeymapperPress(vkey); + + if (PauseMode == 2) { + if ((vkey == SDLK_RETURN || vkey == SDLK_KP_ENTER) && (modState & SDL_KMOD_ALT) != 0) { + options.Graphics.fullscreen.SetValue(!IsFullScreen()); + if (!demo::IsRunning()) SaveOptions(); + } + return; + } + + if (DoomFlag) { + doom_close(); + return; + } + + switch (vkey) { + case SDLK_PLUS: + case SDLK_KP_PLUS: + case SDLK_EQUALS: + case SDLK_KP_EQUALS: + if (AutomapActive) { + AutomapZoomIn(); + } + return; + case SDLK_MINUS: + case SDLK_KP_MINUS: + case SDLK_UNDERSCORE: + if (AutomapActive) { + AutomapZoomOut(); + } + return; +#ifdef _DEBUG + case SDLK_V: + if ((modState & SDL_KMOD_SHIFT) != 0) + NextDebugMonster(); + else + GetDebugMonster(); + return; +#endif case SDLK_RETURN: case SDLK_KP_ENTER: if ((modState & SDL_KMOD_ALT) != 0) { @@ -635,9 +621,7 @@ void PressKey(SDL_Keycode vkey, uint16_t modState) } 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); + SpeakText(StrCat(_("Selected: "), BuildSpellDetailsForSpeech(player, player._pRSpell, player._pRSplType)), /*force=*/true); } else { SpeakText(_("No spell selected."), /*force=*/true); } @@ -665,11 +649,11 @@ void PressKey(SDL_Keycode vkey, uint16_t modState) if (next) { MyPlayer->_pRSpell = *next; MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*next)) != 0 ? SpellType::Skill - : (MyPlayer->_pISpells & GetSpellBitmask(*next)) != 0 ? SpellType::Charges - : SpellType::Spell; + : (MyPlayer->_pISpells & GetSpellBitmask(*next)) != 0 ? SpellType::Charges + : SpellType::Spell; UpdateSpellTarget(*next); RedrawEverything(); - SpeakText(pgettext("spell", GetSpellData(*next).sNameText), /*force=*/true); + SpeakText(BuildSpellDetailsForSpeech(*MyPlayer, *next, MyPlayer->_pRSplType), /*force=*/true); } } else if (invflag) { InventoryMoveFromKeyboard({ AxisDirectionX_NONE, AxisDirectionY_UP }); @@ -698,11 +682,11 @@ void PressKey(SDL_Keycode vkey, uint16_t modState) if (next) { MyPlayer->_pRSpell = *next; MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*next)) != 0 ? SpellType::Skill - : (MyPlayer->_pISpells & GetSpellBitmask(*next)) != 0 ? SpellType::Charges - : SpellType::Spell; + : (MyPlayer->_pISpells & GetSpellBitmask(*next)) != 0 ? SpellType::Charges + : SpellType::Spell; UpdateSpellTarget(*next); RedrawEverything(); - SpeakText(pgettext("spell", GetSpellData(*next).sNameText), /*force=*/true); + SpeakText(BuildSpellDetailsForSpeech(*MyPlayer, *next, MyPlayer->_pRSplType), /*force=*/true); } } else if (invflag) { InventoryMoveFromKeyboard({ AxisDirectionX_NONE, AxisDirectionY_DOWN }); @@ -717,10 +701,6 @@ void PressKey(SDL_Keycode vkey, uint16_t modState) 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: @@ -728,10 +708,6 @@ void PressKey(SDL_Keycode vkey, uint16_t modState) 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: @@ -747,11 +723,11 @@ void PressKey(SDL_Keycode vkey, uint16_t modState) if (first) { MyPlayer->_pRSpell = *first; MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*first)) != 0 ? SpellType::Skill - : (MyPlayer->_pISpells & GetSpellBitmask(*first)) != 0 ? SpellType::Charges - : SpellType::Spell; + : (MyPlayer->_pISpells & GetSpellBitmask(*first)) != 0 ? SpellType::Charges + : SpellType::Spell; UpdateSpellTarget(*first); RedrawEverything(); - SpeakText(pgettext("spell", GetSpellData(*first).sNameText), /*force=*/true); + SpeakText(BuildSpellDetailsForSpeech(*MyPlayer, *first, MyPlayer->_pRSplType), /*force=*/true); } } } else if (invflag) { @@ -774,11 +750,11 @@ void PressKey(SDL_Keycode vkey, uint16_t modState) if (first) { MyPlayer->_pRSpell = *first; MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*first)) != 0 ? SpellType::Skill - : (MyPlayer->_pISpells & GetSpellBitmask(*first)) != 0 ? SpellType::Charges - : SpellType::Spell; + : (MyPlayer->_pISpells & GetSpellBitmask(*first)) != 0 ? SpellType::Charges + : SpellType::Spell; UpdateSpellTarget(*first); RedrawEverything(); - SpeakText(pgettext("spell", GetSpellData(*first).sNameText), /*force=*/true); + SpeakText(BuildSpellDetailsForSpeech(*MyPlayer, *first, MyPlayer->_pRSplType), /*force=*/true); } } } else if (invflag) { @@ -791,6098 +767,2855 @@ void PressKey(SDL_Keycode vkey, uint16_t modState) break; } } - -void HandleMouseButtonDown(Uint8 button, uint16_t modState) -{ - if (IsPlayerInStore() && (button == SDL_BUTTON_X1 -#if !SDL_VERSION_ATLEAST(2, 0, 0) - || button == 8 -#endif - )) { - StoreESC(); - return; - } - - switch (button) { - case SDL_BUTTON_LEFT: - if (sgbMouseDown == CLICK_NONE) { - sgbMouseDown = CLICK_LEFT; - LeftMouseDown(modState); - } - break; - case SDL_BUTTON_RIGHT: - if (sgbMouseDown == CLICK_NONE) { - sgbMouseDown = CLICK_RIGHT; - RightMouseDown((modState & SDL_KMOD_SHIFT) != 0); - } - break; - default: - KeymapperPress(static_cast(button | KeymapperMouseButtonMask)); - break; - } -} - -void HandleMouseButtonUp(Uint8 button, uint16_t modState) -{ - if (sgbMouseDown == CLICK_LEFT && button == SDL_BUTTON_LEFT) { - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_NONE; - LeftMouseUp(modState); - } else if (sgbMouseDown == CLICK_RIGHT && button == SDL_BUTTON_RIGHT) { - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_NONE; - } else { - KeymapperRelease(static_cast(button | KeymapperMouseButtonMask)); - } -} - -[[maybe_unused]] void LogUnhandledEvent(const char *name, int value) -{ - LogVerbose("Unhandled SDL event: {} {}", name, value); -} - -void PrepareForFadeIn() -{ - if (HeadlessMode) return; - BlackPalette(); - - // Render the game to the buffer(s) with a fully black palette. - // Palette fade-in will gradually make it visible. - RedrawEverything(); - while (IsRedrawEverything()) { - DrawAndBlit(); - } -} - -void GameEventHandler(const SDL_Event &event, uint16_t modState) -{ - [[maybe_unused]] const Options &options = GetOptions(); - StaticVector ctrlEvents = ToControllerButtonEvents(event); - for (const ControllerButtonEvent ctrlEvent : ctrlEvents) { - GameAction action; - if (HandleControllerButtonEvent(event, ctrlEvent, action) && action.type == GameActionType_SEND_KEY) { - if ((action.send_key.vk_code & KeymapperMouseButtonMask) != 0) { - const unsigned button = action.send_key.vk_code & ~KeymapperMouseButtonMask; - if (!action.send_key.up) - HandleMouseButtonDown(static_cast(button), modState); - else - HandleMouseButtonUp(static_cast(button), modState); - } else { - if (!action.send_key.up) - PressKey(static_cast(action.send_key.vk_code), modState); - else - ReleaseKey(static_cast(action.send_key.vk_code)); - } - } - } - if (ctrlEvents.size() > 0 && ctrlEvents[0].button != ControllerButton_NONE) { - return; - } - -#ifdef _DEBUG - if (ConsoleHandleEvent(event)) { - return; - } -#endif - - if (IsChatActive() && HandleTalkTextInputEvent(event)) { - return; - } - if (DropGoldFlag && HandleGoldDropTextInputEvent(event)) { - return; - } - if (IsWithdrawGoldOpen && HandleGoldWithdrawTextInputEvent(event)) { - return; - } - - switch (event.type) { - case SDL_EVENT_KEY_DOWN: - PressKey(SDLC_EventKey(event), modState); - return; - case SDL_EVENT_KEY_UP: - ReleaseKey(SDLC_EventKey(event)); - return; - case SDL_EVENT_MOUSE_MOTION: - if (ControlMode == ControlTypes::KeyboardAndMouse && invflag) - InvalidateInventorySlot(); - MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; - gmenu_on_mouse_move(); - return; - case SDL_EVENT_MOUSE_BUTTON_DOWN: - MousePosition = { SDLC_EventButtonIntX(event), SDLC_EventButtonIntY(event) }; - HandleMouseButtonDown(event.button.button, modState); - return; - case SDL_EVENT_MOUSE_BUTTON_UP: - MousePosition = { SDLC_EventButtonIntX(event), SDLC_EventButtonIntY(event) }; - HandleMouseButtonUp(event.button.button, modState); - return; -#if SDL_VERSION_ATLEAST(2, 0, 0) - case SDL_EVENT_MOUSE_WHEEL: - if (SDLC_EventWheelIntY(event) > 0) { // Up - if (IsPlayerInStore()) { - StoreUp(); - } else if (QuestLogIsOpen) { - QuestlogUp(); - } else if (HelpFlag) { - HelpScrollUp(); - } else if (ChatLogFlag) { - ChatLogScrollUp(); - } else if (IsStashOpen) { - Stash.PreviousPage(); - } else if (SDL_GetModState() & SDL_KMOD_CTRL) { - if (AutomapActive) { - AutomapZoomIn(); - } - } else { - KeymapperPress(MouseScrollUpButton); - } - } else if (SDLC_EventWheelIntY(event) < 0) { // down - if (IsPlayerInStore()) { - StoreDown(); - } else if (QuestLogIsOpen) { - QuestlogDown(); - } else if (HelpFlag) { - HelpScrollDown(); - } else if (ChatLogFlag) { - ChatLogScrollDown(); - } else if (IsStashOpen) { - Stash.NextPage(); - } else if (SDL_GetModState() & SDL_KMOD_CTRL) { - if (AutomapActive) { - AutomapZoomOut(); - } - } else { - KeymapperPress(MouseScrollDownButton); - } - } else if (SDLC_EventWheelIntX(event) > 0) { // left - KeymapperPress(MouseScrollLeftButton); - } else if (SDLC_EventWheelIntX(event) < 0) { // right - KeymapperPress(MouseScrollRightButton); - } - break; -#endif - default: - if (IsCustomEvent(event.type)) { - if (gbIsMultiplayer) - pfile_write_hero(); - nthread_ignore_mutex(true); - PaletteFadeOut(8); - sound_stop(); - ShowProgress(GetCustomEvent(event)); - - PrepareForFadeIn(); - LoadPWaterPalette(); - if (gbRunGame) - PaletteFadeIn(8); - nthread_ignore_mutex(false); - gbGameLoopStartup = true; - return; - } - MainWndProc(event); - break; - } -} - -void RunGameLoop(interface_mode uMsg) -{ - demo::NotifyGameLoopStart(); - - nthread_ignore_mutex(true); - StartGame(uMsg); - assert(HeadlessMode || ghMainWnd); - EventHandler previousHandler = SetEventHandler(GameEventHandler); - run_delta_info(); - gbRunGame = true; - gbProcessPlayers = IsDiabloAlive(true); - gbRunGameResult = true; - - PrepareForFadeIn(); - LoadPWaterPalette(); - PaletteFadeIn(8); - InitBackbufferState(); - RedrawEverything(); - gbGameLoopStartup = true; - nthread_ignore_mutex(false); - - discord_manager::StartGame(); - LuaEvent("GameStart"); -#ifdef GPERF_HEAP_FIRST_GAME_ITERATION - unsigned run_game_iteration = 0; -#endif - - while (gbRunGame) { - -#ifdef _DEBUG - if (!gbGameLoopStartup && !DebugCmdsFromCommandLine.empty()) { - InitConsole(); - for (const std::string &cmd : DebugCmdsFromCommandLine) { - RunInConsole(cmd); - } - DebugCmdsFromCommandLine.clear(); - } -#endif - - SDL_Event event; - uint16_t modState; - while (FetchMessage(&event, &modState)) { - if (event.type == SDL_EVENT_QUIT) { - gbRunGameResult = false; - gbRunGame = false; - break; - } - HandleMessage(event, modState); - } - if (!gbRunGame) - break; - - bool drawGame = true; - bool processInput = true; - const bool runGameLoop = demo::IsRunning() ? demo::GetRunGameLoop(drawGame, processInput) : nthread_has_500ms_passed(&drawGame); - if (demo::IsRecording()) - demo::RecordGameLoopResult(runGameLoop); - - discord_manager::UpdateGame(); - - if (!runGameLoop) { - if (processInput) - ProcessInput(); - DvlNet_ProcessNetworkPackets(); - if (!drawGame) - continue; - RedrawViewport(); - DrawAndBlit(); - continue; - } - - ProcessGameMessagePackets(); - if (game_loop(gbGameLoopStartup)) - diablo_color_cyc_logic(); - gbGameLoopStartup = false; - if (drawGame) - DrawAndBlit(); -#ifdef GPERF_HEAP_FIRST_GAME_ITERATION - if (run_game_iteration++ == 0) - HeapProfilerDump("first_game_iteration"); -#endif - } - - demo::NotifyGameLoopEnd(); - - if (gbIsMultiplayer) { - pfile_write_hero(/*writeGameData=*/false); - sfile_write_stash(); - } - - PaletteFadeOut(8); - NewCursor(CURSOR_NONE); - ClearScreenBuffer(); - RedrawEverything(); - scrollrt_draw_game_screen(); - previousHandler = SetEventHandler(previousHandler); - assert(HeadlessMode || previousHandler == GameEventHandler); - FreeGame(); - - if (cineflag) { - cineflag = false; - DoEnding(); - } -} - -void PrintWithRightPadding(std::string_view str, size_t width) -{ - printInConsole(str); - if (str.size() >= width) - return; - printInConsole(std::string(width - str.size(), ' ')); -} - -void PrintHelpOption(std::string_view flags, std::string_view description) -{ - printInConsole(" "); - PrintWithRightPadding(flags, 20); - printInConsole(" "); - PrintWithRightPadding(description, 30); - printNewlineInConsole(); -} - -#if SDL_VERSION_ATLEAST(2, 0, 0) -FILE *SdlLogFile = nullptr; - -extern "C" void SdlLogToFile(void *userdata, int category, SDL_LogPriority priority, const char *message) -{ - FILE *file = reinterpret_cast(userdata); - static const char *const LogPriorityPrefixes[SDL_LOG_PRIORITY_COUNT] = { - "", - "VERBOSE", - "DEBUG", - "INFO", - "WARN", - "ERROR", - "CRITICAL" - }; - std::fprintf(file, "%s: %s\n", LogPriorityPrefixes[priority], message); - std::fflush(file); -} -#endif - -[[noreturn]] void PrintHelpAndExit() -{ - printInConsole((/* TRANSLATORS: Commandline Option */ "Options:")); - printNewlineInConsole(); - PrintHelpOption("-h, --help", _(/* TRANSLATORS: Commandline Option */ "Print this message and exit")); - PrintHelpOption("--version", _(/* TRANSLATORS: Commandline Option */ "Print the version and exit")); - PrintHelpOption("--data-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the folder of diabdat.mpq")); - PrintHelpOption("--save-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the folder of save files")); - PrintHelpOption("--config-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the location of diablo.ini")); - PrintHelpOption("--lang", _(/* TRANSLATORS: Commandline Option */ "Specify the language code (e.g. en or pt_BR)")); - PrintHelpOption("-n", _(/* TRANSLATORS: Commandline Option */ "Skip startup videos")); - PrintHelpOption("-f", _(/* TRANSLATORS: Commandline Option */ "Display frames per second")); - PrintHelpOption("--verbose", _(/* TRANSLATORS: Commandline Option */ "Enable verbose logging")); -#if SDL_VERSION_ATLEAST(2, 0, 0) - PrintHelpOption("--log-to-file ", _(/* TRANSLATORS: Commandline Option */ "Log to a file instead of stderr")); -#endif -#ifndef DISABLE_DEMOMODE - PrintHelpOption("--record <#>", _(/* TRANSLATORS: Commandline Option */ "Record a demo file")); - PrintHelpOption("--demo <#>", _(/* TRANSLATORS: Commandline Option */ "Play a demo file")); - PrintHelpOption("--timedemo", _(/* TRANSLATORS: Commandline Option */ "Disable all frame limiting during demo playback")); -#endif - printNewlineInConsole(); - printInConsole(_(/* TRANSLATORS: Commandline Option */ "Game selection:")); - printNewlineInConsole(); - PrintHelpOption("--spawn", _(/* TRANSLATORS: Commandline Option */ "Force Shareware mode")); - PrintHelpOption("--diablo", _(/* TRANSLATORS: Commandline Option */ "Force Diablo mode")); - PrintHelpOption("--hellfire", _(/* TRANSLATORS: Commandline Option */ "Force Hellfire mode")); - printInConsole(_(/* TRANSLATORS: Commandline Option */ "Hellfire options:")); - printNewlineInConsole(); -#ifdef _DEBUG - printNewlineInConsole(); - printInConsole("Debug options:"); - printNewlineInConsole(); - PrintHelpOption("-i", "Ignore network timeout"); - PrintHelpOption("+", "Pass commands to the engine"); -#endif - printNewlineInConsole(); - printInConsole(_("Report bugs at https://github.com/diasurgical/devilutionX/")); - printNewlineInConsole(); - diablo_quit(0); -} - -void PrintFlagMessage(std::string_view flag, std::string_view message) -{ - printInConsole(flag); - printInConsole(message); - printNewlineInConsole(); -} - -void PrintFlagRequiresArgument(std::string_view flag) -{ - PrintFlagMessage(flag, " requires an argument"); -} - -void DiabloParseFlags(int argc, char **argv) -{ -#ifdef _DEBUG - int argumentIndexOfLastCommandPart = -1; - std::string currentCommand; -#endif -#ifndef DISABLE_DEMOMODE - bool timedemo = false; - int demoNumber = -1; - int recordNumber = -1; - bool createDemoReference = false; -#endif - for (int i = 1; i < argc; i++) { - const std::string_view arg = argv[i]; - if (arg == "-h" || arg == "--help") { - PrintHelpAndExit(); - } else if (arg == "--version") { - printInConsole(PROJECT_NAME); - printInConsole(" v"); - printInConsole(PROJECT_VERSION); - printNewlineInConsole(); - diablo_quit(0); - } else if (arg == "--data-dir") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--data-dir"); - diablo_quit(64); - } - paths::SetBasePath(argv[++i]); - } else if (arg == "--save-dir") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--save-dir"); - diablo_quit(64); - } - paths::SetPrefPath(argv[++i]); - } else if (arg == "--config-dir") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--config-dir"); - diablo_quit(64); - } - paths::SetConfigPath(argv[++i]); - } else if (arg == "--lang") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--lang"); - diablo_quit(64); - } - forceLocale = argv[++i]; -#ifndef DISABLE_DEMOMODE - } else if (arg == "--demo") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--demo"); - diablo_quit(64); - } - ParseIntResult parsedParam = ParseInt(argv[++i]); - if (!parsedParam.has_value()) { - PrintFlagMessage("--demo", " must be a number"); - diablo_quit(64); - } - demoNumber = parsedParam.value(); - gbShowIntro = false; - } else if (arg == "--timedemo") { - timedemo = true; - } else if (arg == "--record") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--record"); - diablo_quit(64); - } - ParseIntResult parsedParam = ParseInt(argv[++i]); - if (!parsedParam.has_value()) { - PrintFlagMessage("--record", " must be a number"); - diablo_quit(64); - } - recordNumber = parsedParam.value(); - } else if (arg == "--create-reference") { - createDemoReference = true; -#else - } else if (arg == "--demo" || arg == "--timedemo" || arg == "--record" || arg == "--create-reference") { - printInConsole("Binary compiled without demo mode support."); - printNewlineInConsole(); - diablo_quit(1); -#endif - } else if (arg == "-n") { - gbShowIntro = false; - } else if (arg == "-f") { - EnableFrameCount(); - } else if (arg == "--spawn") { - forceSpawn = true; - } else if (arg == "--diablo") { - forceDiablo = true; - } else if (arg == "--hellfire") { - forceHellfire = true; - } else if (arg == "--vanilla") { - gbVanilla = true; - } else if (arg == "--verbose") { - SDL_SetLogPriorities(SDL_LOG_PRIORITY_VERBOSE); -#if SDL_VERSION_ATLEAST(2, 0, 0) - } else if (arg == "--log-to-file") { - if (i + 1 == argc) { - PrintFlagRequiresArgument("--log-to-file"); - diablo_quit(64); - } - SdlLogFile = OpenFile(argv[++i], "wb"); - if (SdlLogFile == nullptr) { - printInConsole("Failed to open log file for writing"); - diablo_quit(64); - } - SDL_SetLogOutputFunction(&SdlLogToFile, /*userdata=*/SdlLogFile); -#endif -#ifdef _DEBUG - } else if (arg == "-i") { - DebugDisableNetworkTimeout = true; - } else if (arg[0] == '+') { - if (!currentCommand.empty()) - DebugCmdsFromCommandLine.push_back(currentCommand); - argumentIndexOfLastCommandPart = i; - currentCommand = arg.substr(1); - } else if (arg[0] != '-' && (argumentIndexOfLastCommandPart + 1) == i) { - currentCommand.append(" "); - currentCommand.append(arg); - argumentIndexOfLastCommandPart = i; -#endif - } else { - printInConsole("unrecognized option '"); - printInConsole(argv[i]); - printInConsole("'"); - printNewlineInConsole(); - PrintHelpAndExit(); - } - } - -#ifdef _DEBUG - if (!currentCommand.empty()) - DebugCmdsFromCommandLine.push_back(currentCommand); -#endif - -#ifndef DISABLE_DEMOMODE - if (demoNumber != -1) - demo::InitPlayBack(demoNumber, timedemo); - if (recordNumber != -1) - demo::InitRecording(recordNumber, createDemoReference); -#endif -} - -void DiabloInitScreen() -{ - MousePosition = { gnScreenWidth / 2, gnScreenHeight / 2 }; - if (ControlMode == ControlTypes::KeyboardAndMouse) - SetCursorPos(MousePosition); - - ClrDiabloMsg(); -} - -void SetApplicationVersions() -{ - *BufCopy(gszProductName, PROJECT_NAME, " v", PROJECT_VERSION) = '\0'; - *BufCopy(gszVersionNumber, "version ", PROJECT_VERSION) = '\0'; -} - -void CheckArchivesUpToDate() -{ - const bool devilutionxMpqOutOfDate = IsDevilutionXMpqOutOfDate(); - const bool fontsMpqOutOfDate = AreExtraFontsOutOfDate(); - - if (devilutionxMpqOutOfDate && fontsMpqOutOfDate) { - app_fatal(_("Please update devilutionx.mpq and fonts.mpq to the latest version")); - } else if (devilutionxMpqOutOfDate) { - app_fatal(_("Failed to load UI resources.\n" - "\n" - "Make sure devilutionx.mpq is in the game folder and that it is up to date.")); - } else if (fontsMpqOutOfDate) { - app_fatal(_("Please update fonts.mpq to the latest version")); - } -} - -void ApplicationInit() -{ - if (*GetOptions().Graphics.showFPS) - EnableFrameCount(); - - init_create_window(); - was_window_init = true; - - InitializeScreenReader(); - LanguageInitialize(); - - SetApplicationVersions(); - - ReadOnlyTest(); -} - -void DiabloInit() -{ - if (forceSpawn || *GetOptions().GameMode.shareware) - gbIsSpawn = true; - - bool wasHellfireDiscovered = false; - if (!forceDiablo && !forceHellfire) - wasHellfireDiscovered = (HaveHellfire() && *GetOptions().GameMode.gameMode == StartUpGameMode::Ask); - bool enableHellfire = forceHellfire || wasHellfireDiscovered; - if (!forceDiablo && *GetOptions().GameMode.gameMode == StartUpGameMode::Hellfire) { // Migrate legacy options - GetOptions().GameMode.gameMode.SetValue(StartUpGameMode::Diablo); - enableHellfire = true; - } - if (forceDiablo || enableHellfire) { - GetOptions().Mods.SetHellfireEnabled(enableHellfire); - } - - gbIsHellfireSaveGame = gbIsHellfire; - - for (size_t i = 0; i < QuickMessages.size(); i++) { - auto &messages = GetOptions().Chat.szHotKeyMsgs[i]; - if (messages.empty()) { - messages.emplace_back(_(QuickMessages[i].message)); - } - } - -#ifndef USE_SDL1 - InitializeVirtualGamepad(); -#endif - - UiInitialize(); - was_ui_init = true; - - if (wasHellfireDiscovered) { - UiSelStartUpGameOption(); - if (!gbIsHellfire) { - // Reinitialize the UI Elements because we changed the game - UnloadUiGFX(); - UiInitialize(); - if (IsHardwareCursor()) - SetHardwareCursor(CursorInfo::UnknownCursor()); - } - } - - DiabloInitScreen(); - - snd_init(); - - ui_sound_init(); - - // Item graphics are loaded early, they already get touched during hero selection. - InitItemGFX(); - - // Always available. - LoadSmallSelectionSpinner(); - - CheckArchivesUpToDate(); -} - -void DiabloSplash() -{ - if (!gbShowIntro) - return; - - if (*GetOptions().StartUp.splash == StartUpSplash::LogoAndTitleDialog) - play_movie("gendata\\logo.smk", true); - - auto &intro = gbIsHellfire ? GetOptions().StartUp.hellfireIntro : GetOptions().StartUp.diabloIntro; - - if (*intro != StartUpIntro::Off) { - if (gbIsHellfire) - play_movie("gendata\\Hellfire.smk", true); - else - play_movie("gendata\\diablo1.smk", true); - if (*intro == StartUpIntro::Once) { - intro.SetValue(StartUpIntro::Off); - if (!demo::IsRunning()) SaveOptions(); - } - } - - if (IsAnyOf(*GetOptions().StartUp.splash, StartUpSplash::TitleDialog, StartUpSplash::LogoAndTitleDialog)) - UiTitleDialog(); -} - -void DiabloDeinit() -{ - FreeItemGFX(); - - LuaShutdown(); - ShutDownScreenReader(); - - if (gbSndInited) - effects_cleanup_sfx(); - snd_deinit(); - if (was_ui_init) - UiDestroy(); - if (was_archives_init) - init_cleanup(); - if (was_window_init) - dx_cleanup(); // Cleanup SDL surfaces stuff, so we have to do it before SDL_Quit(). - UnloadFonts(); - if (SDL_WasInit((~0U) & ~SDL_INIT_HAPTIC) != 0) - SDL_Quit(); -} - -tl::expected LoadLvlGFX() -{ - assert(pDungeonCels == nullptr); - constexpr int SpecialCelWidth = 64; - - const auto loadAll = [](const char *cel, const char *til, const char *special) -> tl::expected { - ASSIGN_OR_RETURN(pDungeonCels, LoadFileInMemWithStatus(cel)); - ASSIGN_OR_RETURN(pMegaTiles, LoadFileInMemWithStatus(til)); - ASSIGN_OR_RETURN(pSpecialCels, LoadCelWithStatus(special, SpecialCelWidth)); - return {}; - }; - - switch (leveltype) { - case DTYPE_TOWN: { - auto cel = LoadFileInMemWithStatus("nlevels\\towndata\\town.cel"); - if (!cel.has_value()) { - ASSIGN_OR_RETURN(pDungeonCels, LoadFileInMemWithStatus("levels\\towndata\\town.cel")); - } else { - pDungeonCels = std::move(*cel); - } - auto til = LoadFileInMemWithStatus("nlevels\\towndata\\town.til"); - if (!til.has_value()) { - ASSIGN_OR_RETURN(pMegaTiles, LoadFileInMemWithStatus("levels\\towndata\\town.til")); - } else { - pMegaTiles = std::move(*til); - } - ASSIGN_OR_RETURN(pSpecialCels, LoadCelWithStatus("levels\\towndata\\towns", SpecialCelWidth)); - return {}; - } - case DTYPE_CATHEDRAL: - return loadAll( - "levels\\l1data\\l1.cel", - "levels\\l1data\\l1.til", - "levels\\l1data\\l1s"); - case DTYPE_CATACOMBS: - return loadAll( - "levels\\l2data\\l2.cel", - "levels\\l2data\\l2.til", - "levels\\l2data\\l2s"); - case DTYPE_CAVES: - return loadAll( - "levels\\l3data\\l3.cel", - "levels\\l3data\\l3.til", - "levels\\l1data\\l1s"); - case DTYPE_HELL: - return loadAll( - "levels\\l4data\\l4.cel", - "levels\\l4data\\l4.til", - "levels\\l2data\\l2s"); - case DTYPE_NEST: - return loadAll( - "nlevels\\l6data\\l6.cel", - "nlevels\\l6data\\l6.til", - "levels\\l1data\\l1s"); - case DTYPE_CRYPT: - return loadAll( - "nlevels\\l5data\\l5.cel", - "nlevels\\l5data\\l5.til", - "nlevels\\l5data\\l5s"); - default: - return tl::make_unexpected("LoadLvlGFX"); - } -} - -tl::expected LoadAllGFX() -{ - IncProgress(); -#if !defined(USE_SDL1) && !defined(__vita__) - InitVirtualGamepadGFX(); -#endif - IncProgress(); - RETURN_IF_ERROR(InitObjectGFX()); - IncProgress(); - RETURN_IF_ERROR(InitMissileGFX()); - IncProgress(); - return {}; -} - -/** - * @param entry Where is the player entering from - */ -void CreateLevel(lvl_entry entry) -{ - CreateDungeon(DungeonSeeds[currlevel], entry); - - switch (leveltype) { - case DTYPE_TOWN: - InitTownTriggers(); - break; - case DTYPE_CATHEDRAL: - InitL1Triggers(); - break; - case DTYPE_CATACOMBS: - InitL2Triggers(); - break; - case DTYPE_CAVES: - InitL3Triggers(); - break; - case DTYPE_HELL: - InitL4Triggers(); - break; - case DTYPE_NEST: - InitHiveTriggers(); - break; - case DTYPE_CRYPT: - InitCryptTriggers(); - break; - default: - app_fatal("CreateLevel"); - } - - if (leveltype != DTYPE_TOWN) { - Freeupstairs(); - } - LoadRndLvlPal(leveltype); -} - -void UnstuckChargers() -{ - if (gbIsMultiplayer) { - for (Player &player : Players) { - if (!player.plractive) - continue; - if (player._pLvlChanging) - continue; - if (!player.isOnActiveLevel()) - continue; - if (&player == MyPlayer) - continue; - return; - } - } - for (size_t i = 0; i < ActiveMonsterCount; i++) { - Monster &monster = Monsters[ActiveMonsters[i]]; - if (monster.mode == MonsterMode::Charge) - monster.mode = MonsterMode::Stand; - } -} - -void UpdateMonsterLights() -{ - for (size_t i = 0; i < ActiveMonsterCount; i++) { - Monster &monster = Monsters[ActiveMonsters[i]]; - if ((monster.flags & MFLAG_BERSERK) != 0) { - const int lightRadius = leveltype == DTYPE_NEST ? 9 : 3; - monster.lightId = AddLight(monster.position.tile, lightRadius); - } - - if (monster.lightId != NO_LIGHT) { - if (monster.lightId == MyPlayer->lightId) { // Fix old saves where some monsters had 0 instead of NO_LIGHT - monster.lightId = NO_LIGHT; - continue; - } - - const Light &light = Lights[monster.lightId]; - if (monster.position.tile != light.position.tile) { - ChangeLightXY(monster.lightId, monster.position.tile); - } - } +void HandleMouseButtonDown(Uint8 button, uint16_t modState) +{ + if (IsPlayerInStore() && (button == SDL_BUTTON_X1 +#if !SDL_VERSION_ATLEAST(2, 0, 0) + || button == 8 +#endif + )) { + StoreESC(); + return; } -} -#ifdef NOSOUND -void UpdatePlayerLowHpWarningSound() -{ + switch (button) { + case SDL_BUTTON_LEFT: + if (sgbMouseDown == CLICK_NONE) { + sgbMouseDown = CLICK_LEFT; + LeftMouseDown(modState); + } + break; + case SDL_BUTTON_RIGHT: + if (sgbMouseDown == CLICK_NONE) { + sgbMouseDown = CLICK_RIGHT; + RightMouseDown((modState & SDL_KMOD_SHIFT) != 0); + } + break; + default: + KeymapperPress(static_cast(button | KeymapperMouseButtonMask)); + break; + } } -#else -namespace { -std::unique_ptr PlayerLowHpWarningSound; -bool TriedLoadingPlayerLowHpWarningSound = false; - -TSnd *GetPlayerLowHpWarningSound() +void HandleMouseButtonUp(Uint8 button, uint16_t modState) { - 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; + if (sgbMouseDown == CLICK_LEFT && button == SDL_BUTTON_LEFT) { + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_NONE; + LeftMouseUp(modState); + } else if (sgbMouseDown == CLICK_RIGHT && button == SDL_BUTTON_RIGHT) { + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_NONE; + } else { + KeymapperRelease(static_cast(button | KeymapperMouseButtonMask)); } - - return PlayerLowHpWarningSound.get(); } -void StopPlayerLowHpWarningSound() +[[maybe_unused]] void LogUnhandledEvent(const char *name, int value) { - if (PlayerLowHpWarningSound != nullptr) - PlayerLowHpWarningSound->DSB.Stop(); + LogVerbose("Unhandled SDL event: {} {}", name, value); } -[[nodiscard]] uint32_t LowHpIntervalMs(int hpPercent) +void PrepareForFadeIn() { - // 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; -} + if (HeadlessMode) return; + BlackPalette(); -} // namespace + // Render the game to the buffer(s) with a fully black palette. + // Palette fade-in will gradually make it visible. + RedrawEverything(); + while (IsRedrawEverything()) { + DrawAndBlit(); + } +} -void UpdatePlayerLowHpWarningSound() +void GameEventHandler(const SDL_Event &event, uint16_t modState) { - static uint32_t LastWarningStartMs = 0; - - if (!gbSndInited || !gbSoundOn || MyPlayer == nullptr || InGameMenu()) { - StopPlayerLowHpWarningSound(); - LastWarningStartMs = 0; + [[maybe_unused]] const Options &options = GetOptions(); + StaticVector ctrlEvents = ToControllerButtonEvents(event); + for (const ControllerButtonEvent ctrlEvent : ctrlEvents) { + GameAction action; + if (HandleControllerButtonEvent(event, ctrlEvent, action) && action.type == GameActionType_SEND_KEY) { + if ((action.send_key.vk_code & KeymapperMouseButtonMask) != 0) { + const unsigned button = action.send_key.vk_code & ~KeymapperMouseButtonMask; + if (!action.send_key.up) + HandleMouseButtonDown(static_cast(button), modState); + else + HandleMouseButtonUp(static_cast(button), modState); + } else { + if (!action.send_key.up) + PressKey(static_cast(action.send_key.vk_code), modState); + else + ReleaseKey(static_cast(action.send_key.vk_code)); + } + } + } + if (ctrlEvents.size() > 0 && ctrlEvents[0].button != ControllerButton_NONE) { return; } - // Stop immediately when dead. - if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) { - StopPlayerLowHpWarningSound(); - LastWarningStartMs = 0; +#ifdef _DEBUG + if (ConsoleHandleEvent(event)) { return; } +#endif - const int maxHp = MyPlayer->_pMaxHP; - if (maxHp <= 0) { - StopPlayerLowHpWarningSound(); - LastWarningStartMs = 0; + if (IsChatActive() && HandleTalkTextInputEvent(event)) { 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; + if (DropGoldFlag && HandleGoldDropTextInputEvent(event)) { return; } - - TSnd *snd = GetPlayerLowHpWarningSound(); - if (snd == nullptr || !snd->DSB.IsLoaded()) + if (IsWithdrawGoldOpen && HandleGoldWithdrawTextInputEvent(event)) { return; + } - const uint32_t now = SDL_GetTicks(); - const uint32_t intervalMs = LowHpIntervalMs(hpPercent); - if (LastWarningStartMs == 0) - LastWarningStartMs = now - intervalMs; - if (now - LastWarningStartMs < intervalMs) + switch (event.type) { + case SDL_EVENT_KEY_DOWN: + PressKey(SDLC_EventKey(event), modState); 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; + case SDL_EVENT_KEY_UP: + ReleaseKey(SDLC_EventKey(event)); + return; + case SDL_EVENT_MOUSE_MOTION: + if (ControlMode == ControlTypes::KeyboardAndMouse && invflag) + InvalidateInventorySlot(); + MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; + gmenu_on_mouse_move(); + return; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + MousePosition = { SDLC_EventButtonIntX(event), SDLC_EventButtonIntY(event) }; + HandleMouseButtonDown(event.button.button, modState); + return; + case SDL_EVENT_MOUSE_BUTTON_UP: + MousePosition = { SDLC_EventButtonIntX(event), SDLC_EventButtonIntY(event) }; + HandleMouseButtonUp(event.button.button, modState); + return; +#if SDL_VERSION_ATLEAST(2, 0, 0) + case SDL_EVENT_MOUSE_WHEEL: + if (SDLC_EventWheelIntY(event) > 0) { // Up + if (IsPlayerInStore()) { + StoreUp(); + } else if (QuestLogIsOpen) { + QuestlogUp(); + } else if (HelpFlag) { + HelpScrollUp(); + } else if (ChatLogFlag) { + ChatLogScrollUp(); + } else if (IsStashOpen) { + Stash.PreviousPage(); + } else if (SDL_GetModState() & SDL_KMOD_CTRL) { + if (AutomapActive) { + AutomapZoomIn(); + } + } else { + KeymapperPress(MouseScrollUpButton); + } + } else if (SDLC_EventWheelIntY(event) < 0) { // down + if (IsPlayerInStore()) { + StoreDown(); + } else if (QuestLogIsOpen) { + QuestlogDown(); + } else if (HelpFlag) { + HelpScrollDown(); + } else if (ChatLogFlag) { + ChatLogScrollDown(); + } else if (IsStashOpen) { + Stash.NextPage(); + } else if (SDL_GetModState() & SDL_KMOD_CTRL) { + if (AutomapActive) { + AutomapZoomOut(); + } + } else { + KeymapperPress(MouseScrollDownButton); + } + } else if (SDLC_EventWheelIntX(event) > 0) { // left + KeymapperPress(MouseScrollLeftButton); + } else if (SDLC_EventWheelIntX(event) < 0) { // right + KeymapperPress(MouseScrollRightButton); + } + break; +#endif + default: + if (IsCustomEvent(event.type)) { + if (gbIsMultiplayer) + pfile_write_hero(); + nthread_ignore_mutex(true); + PaletteFadeOut(8); + sound_stop(); + ShowProgress(GetCustomEvent(event)); + + PrepareForFadeIn(); + LoadPWaterPalette(); + if (gbRunGame) + PaletteFadeIn(8); + nthread_ignore_mutex(false); + gbGameLoopStartup = true; + return; + } + MainWndProc(event); + break; + } } -#endif // NOSOUND - -namespace { -[[nodiscard]] bool IsBossMonsterForHpAnnouncement(const Monster &monster) +void RunGameLoop(interface_mode uMsg) { - return monster.isUnique() || monster.ai == MonsterAIID::Diablo; -} + demo::NotifyGameLoopStart(); -} // namespace + nthread_ignore_mutex(true); + StartGame(uMsg); + assert(HeadlessMode || ghMainWnd); + EventHandler previousHandler = SetEventHandler(GameEventHandler); + run_delta_info(); + gbRunGame = true; + gbProcessPlayers = IsDiabloAlive(true); + gbRunGameResult = true; -void UpdateLowDurabilityWarnings() -{ - static std::array WarnedSeeds {}; - static std::array HasWarned {}; + PrepareForFadeIn(); + LoadPWaterPalette(); + PaletteFadeIn(8); + InitBackbufferState(); + RedrawEverything(); + gbGameLoopStartup = true; + nthread_ignore_mutex(false); - if (MyPlayer == nullptr) - return; - if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) - return; + discord_manager::StartGame(); + LuaEvent("GameStart"); +#ifdef GPERF_HEAP_FIRST_GAME_ITERATION + unsigned run_game_iteration = 0; +#endif - std::vector newlyLow; - newlyLow.reserve(NUM_INVLOC); + while (gbRunGame) { - 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; +#ifdef _DEBUG + if (!gbGameLoopStartup && !DebugCmdsFromCommandLine.empty()) { + InitConsole(); + for (const std::string &cmd : DebugCmdsFromCommandLine) { + RunInConsole(cmd); + } + DebugCmdsFromCommandLine.clear(); } +#endif - int threshold = std::max(2, maxDur / 10); - threshold = std::clamp(threshold, 1, maxDur); - - const bool isLow = durability <= threshold; - if (!isLow) { - HasWarned[slot] = false; - continue; + SDL_Event event; + uint16_t modState; + while (FetchMessage(&event, &modState)) { + if (event.type == SDL_EVENT_QUIT) { + gbRunGameResult = false; + gbRunGame = false; + break; + } + HandleMessage(event, modState); } + if (!gbRunGame) + break; - 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()); - } + bool drawGame = true; + bool processInput = true; + const bool runGameLoop = demo::IsRunning() ? demo::GetRunGameLoop(drawGame, processInput) : nthread_has_500ms_passed(&drawGame); + if (demo::IsRecording()) + demo::RecordGameLoopResult(runGameLoop); - if (newlyLow.empty()) - return; + discord_manager::UpdateGame(); - // 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) + if (!runGameLoop) { + if (processInput) + ProcessInput(); + DvlNet_ProcessNetworkPackets(); + if (!drawGame) + continue; + RedrawViewport(); + DrawAndBlit(); 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; + ProcessGameMessagePackets(); + if (game_loop(gbGameLoopStartup)) + diablo_color_cyc_logic(); + gbGameLoopStartup = false; + if (drawGame) + DrawAndBlit(); +#ifdef GPERF_HEAP_FIRST_GAME_ITERATION + if (run_game_iteration++ == 0) + HeapProfilerDump("first_game_iteration"); +#endif } - for (size_t monsterId = 0; monsterId < MaxMonsters; ++monsterId) { - if (LastAnnouncedBucket[monsterId] < 0) - continue; + demo::NotifyGameLoopEnd(); - const Monster &monster = Monsters[monsterId]; - if (monster.isInvalid || monster.hitPoints <= 0 || !IsBossMonsterForHpAnnouncement(monster)) - LastAnnouncedBucket[monsterId] = -1; + if (gbIsMultiplayer) { + pfile_write_hero(/*writeGameData=*/false); + sfile_write_stash(); } - 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; + PaletteFadeOut(8); + NewCursor(CURSOR_NONE); + ClearScreenBuffer(); + RedrawEverything(); + scrollrt_draw_game_screen(); + previousHandler = SetEventHandler(previousHandler); + assert(HeadlessMode || previousHandler == GameEventHandler); + FreeGame(); - lastBucket = static_cast(bucket); - SpeakText(fmt::format(fmt::runtime(_("{:s} health: {:d}%")), monster.name(), bucket), /*force=*/false); + if (cineflag) { + cineflag = false; + DoEnding(); } } -void UpdateAttackableMonsterAnnouncements() +void PrintWithRightPadding(std::string_view str, size_t width) { - 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) + printInConsole(str); + if (str.size() >= width) return; - - LastAttackableMonsterId = *bestId; - - const std::string_view name = Monsters[*bestId].name(); - if (!name.empty()) - SpeakText(name, /*force=*/true); + printInConsole(std::string(width - str.size(), ' ')); } -void GameLogic() +void PrintHelpOption(std::string_view flags, std::string_view description) { - 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(); - } else { - gGameLogicStep = GameLogicStep::ProcessTowners; - ProcessTowners(); - gGameLogicStep = GameLogicStep::ProcessItemsTown; - ProcessItems(); - gGameLogicStep = GameLogicStep::ProcessMissilesTown; - ProcessMissiles(); - } - - UpdatePlayerLowHpWarningSound(); + printInConsole(" "); + PrintWithRightPadding(flags, 20); + printInConsole(" "); + PrintWithRightPadding(description, 30); + printNewlineInConsole(); +} - gGameLogicStep = GameLogicStep::None; +#if SDL_VERSION_ATLEAST(2, 0, 0) +FILE *SdlLogFile = nullptr; -#ifdef _DEBUG - if (DebugScrollViewEnabled && (SDL_GetModState() & SDL_KMOD_SHIFT) != 0) { - ScrollView(); - } -#endif - - sound_update(); - CheckTriggers(); - CheckQuests(); - RedrawViewport(); - pfile_update(false); - - plrctrls_after_game_logic(); -} - -void TimeoutCursor(bool bTimeout) -{ - if (bTimeout) { - if (sgnTimeoutCurs == CURSOR_NONE && sgbMouseDown == CLICK_NONE) { - sgnTimeoutCurs = pcurs; - multi_net_ping(); - InfoString = StringOrView {}; - AddInfoBoxString(_("-- Network timeout --")); - AddInfoBoxString(_("-- Waiting for players --")); - for (uint8_t i = 0; i < Players.size(); i++) { - bool isConnected = (player_state[i] & PS_CONNECTED) != 0; - bool isActive = (player_state[i] & PS_ACTIVE) != 0; - if (!(isConnected && !isActive)) continue; - - DvlNetLatencies latencies = DvlNet_GetLatencies(i); - - std::string ping = fmt::format( - fmt::runtime(_(/* TRANSLATORS: {:s} means: Character Name */ "Player {:s} is timing out!")), - Players[i].name()); - - StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Echo latency: {:d} ms")), latencies.echoLatency)); - - if (latencies.providerLatency) { - if (latencies.isRelayed && *latencies.isRelayed) { - StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Provider latency: {:d} ms (Relayed)")), *latencies.providerLatency)); - } else { - StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Provider latency: {:d} ms")), *latencies.providerLatency)); - } - } - EventPlrMsg(ping); - } - NewCursor(CURSOR_HOURGLASS); - RedrawEverything(); - } - scrollrt_draw_game_screen(); - } else if (sgnTimeoutCurs != CURSOR_NONE) { - // Timeout is gone, we should restore the previous cursor. - // But the timeout cursor could already be changed by the now processed messages (for example item cursor from CMD_GETITEM). - // Changing the item cursor back to the previous (hand) cursor could result in deleted items, because this resets Player.HoldItem (see NewCursor). - if (pcurs == CURSOR_HOURGLASS) - NewCursor(sgnTimeoutCurs); - sgnTimeoutCurs = CURSOR_NONE; - InfoString = StringOrView {}; - RedrawEverything(); - } -} - -void HelpKeyPressed() +extern "C" void SdlLogToFile(void *userdata, int category, SDL_LogPriority priority, const char *message) { - 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(); - } + FILE *file = reinterpret_cast(userdata); + static const char *const LogPriorityPrefixes[SDL_LOG_PRIORITY_COUNT] = { + "", + "VERBOSE", + "DEBUG", + "INFO", + "WARN", + "ERROR", + "CRITICAL" + }; + std::fprintf(file, "%s: %s\n", LogPriorityPrefixes[priority], message); + std::fflush(file); +} +#endif + +[[noreturn]] void PrintHelpAndExit() +{ + printInConsole((/* TRANSLATORS: Commandline Option */ "Options:")); + printNewlineInConsole(); + PrintHelpOption("-h, --help", _(/* TRANSLATORS: Commandline Option */ "Print this message and exit")); + PrintHelpOption("--version", _(/* TRANSLATORS: Commandline Option */ "Print the version and exit")); + PrintHelpOption("--data-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the folder of diabdat.mpq")); + PrintHelpOption("--save-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the folder of save files")); + PrintHelpOption("--config-dir", _(/* TRANSLATORS: Commandline Option */ "Specify the location of diablo.ini")); + PrintHelpOption("--lang", _(/* TRANSLATORS: Commandline Option */ "Specify the language code (e.g. en or pt_BR)")); + PrintHelpOption("-n", _(/* TRANSLATORS: Commandline Option */ "Skip startup videos")); + PrintHelpOption("-f", _(/* TRANSLATORS: Commandline Option */ "Display frames per second")); + PrintHelpOption("--verbose", _(/* TRANSLATORS: Commandline Option */ "Enable verbose logging")); +#if SDL_VERSION_ATLEAST(2, 0, 0) + PrintHelpOption("--log-to-file ", _(/* TRANSLATORS: Commandline Option */ "Log to a file instead of stderr")); +#endif +#ifndef DISABLE_DEMOMODE + PrintHelpOption("--record <#>", _(/* TRANSLATORS: Commandline Option */ "Record a demo file")); + PrintHelpOption("--demo <#>", _(/* TRANSLATORS: Commandline Option */ "Play a demo file")); + PrintHelpOption("--timedemo", _(/* TRANSLATORS: Commandline Option */ "Disable all frame limiting during demo playback")); +#endif + printNewlineInConsole(); + printInConsole(_(/* TRANSLATORS: Commandline Option */ "Game selection:")); + printNewlineInConsole(); + PrintHelpOption("--spawn", _(/* TRANSLATORS: Commandline Option */ "Force Shareware mode")); + PrintHelpOption("--diablo", _(/* TRANSLATORS: Commandline Option */ "Force Diablo mode")); + PrintHelpOption("--hellfire", _(/* TRANSLATORS: Commandline Option */ "Force Hellfire mode")); + printInConsole(_(/* TRANSLATORS: Commandline Option */ "Hellfire options:")); + printNewlineInConsole(); +#ifdef _DEBUG + printNewlineInConsole(); + printInConsole("Debug options:"); + printNewlineInConsole(); + PrintHelpOption("-i", "Ignore network timeout"); + PrintHelpOption("+", "Pass commands to the engine"); +#endif + printNewlineInConsole(); + printInConsole(_("Report bugs at https://github.com/diasurgical/devilutionX/")); + printNewlineInConsole(); + diablo_quit(0); } -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) +void PrintFlagMessage(std::string_view flag, std::string_view message) { - 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; - } + printInConsole(flag); + printInConsole(message); + printNewlineInConsole(); } -Point PositionAfterWalkPathSteps(Point start, const int8_t *path, int steps) +void PrintFlagRequiresArgument(std::string_view flag) { - Point position = start; - for (int i = 0; i < steps; ++i) { - position = NextPositionForWalkDirection(position, path[i]); - } - return position; + PrintFlagMessage(flag, " requires an argument"); } -int8_t OppositeWalkDirection(int8_t walkDir) +void DiabloParseFlags(int argc, char **argv) { - 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; +#ifdef _DEBUG + int argumentIndexOfLastCommandPart = -1; + std::string currentCommand; +#endif +#ifndef DISABLE_DEMOMODE + bool timedemo = false; + int demoNumber = -1; + int recordNumber = -1; + bool createDemoReference = false; +#endif + for (int i = 1; i < argc; i++) { + const std::string_view arg = argv[i]; + if (arg == "-h" || arg == "--help") { + PrintHelpAndExit(); + } else if (arg == "--version") { + printInConsole(PROJECT_NAME); + printInConsole(" v"); + printInConsole(PROJECT_VERSION); + printNewlineInConsole(); + diablo_quit(0); + } else if (arg == "--data-dir") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--data-dir"); + diablo_quit(64); + } + paths::SetBasePath(argv[++i]); + } else if (arg == "--save-dir") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--save-dir"); + diablo_quit(64); + } + paths::SetPrefPath(argv[++i]); + } else if (arg == "--config-dir") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--config-dir"); + diablo_quit(64); + } + paths::SetConfigPath(argv[++i]); + } else if (arg == "--lang") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--lang"); + diablo_quit(64); + } + forceLocale = argv[++i]; +#ifndef DISABLE_DEMOMODE + } else if (arg == "--demo") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--demo"); + diablo_quit(64); + } + ParseIntResult parsedParam = ParseInt(argv[++i]); + if (!parsedParam.has_value()) { + PrintFlagMessage("--demo", " must be a number"); + diablo_quit(64); + } + demoNumber = parsedParam.value(); + gbShowIntro = false; + } else if (arg == "--timedemo") { + timedemo = true; + } else if (arg == "--record") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--record"); + diablo_quit(64); + } + ParseIntResult parsedParam = ParseInt(argv[++i]); + if (!parsedParam.has_value()) { + PrintFlagMessage("--record", " must be a number"); + diablo_quit(64); + } + recordNumber = parsedParam.value(); + } else if (arg == "--create-reference") { + createDemoReference = true; +#else + } else if (arg == "--demo" || arg == "--timedemo" || arg == "--record" || arg == "--create-reference") { + printInConsole("Binary compiled without demo mode support."); + printNewlineInConsole(); + diablo_quit(1); +#endif + } else if (arg == "-n") { + gbShowIntro = false; + } else if (arg == "-f") { + EnableFrameCount(); + } else if (arg == "--spawn") { + forceSpawn = true; + } else if (arg == "--diablo") { + forceDiablo = true; + } else if (arg == "--hellfire") { + forceHellfire = true; + } else if (arg == "--vanilla") { + gbVanilla = true; + } else if (arg == "--verbose") { + SDL_SetLogPriorities(SDL_LOG_PRIORITY_VERBOSE); +#if SDL_VERSION_ATLEAST(2, 0, 0) + } else if (arg == "--log-to-file") { + if (i + 1 == argc) { + PrintFlagRequiresArgument("--log-to-file"); + diablo_quit(64); + } + SdlLogFile = OpenFile(argv[++i], "wb"); + if (SdlLogFile == nullptr) { + printInConsole("Failed to open log file for writing"); + diablo_quit(64); + } + SDL_SetLogOutputFunction(&SdlLogToFile, /*userdata=*/SdlLogFile); +#endif +#ifdef _DEBUG + } else if (arg == "-i") { + DebugDisableNetworkTimeout = true; + } else if (arg[0] == '+') { + if (!currentCommand.empty()) + DebugCmdsFromCommandLine.push_back(currentCommand); + argumentIndexOfLastCommandPart = i; + currentCommand = arg.substr(1); + } else if (arg[0] != '-' && (argumentIndexOfLastCommandPart + 1) == i) { + currentCommand.append(" "); + currentCommand.append(arg); + argumentIndexOfLastCommandPart = i; +#endif + } else { + printInConsole("unrecognized option '"); + printInConsole(argv[i]); + printInConsole("'"); + printNewlineInConsole(); + PrintHelpAndExit(); + } } -} -bool IsTownNpcActionAllowed() -{ - return CanPlayerTakeAction() - && leveltype == DTYPE_TOWN - && !IsPlayerInStore() - && !ChatLogFlag - && !HelpFlag; -} +#ifdef _DEBUG + if (!currentCommand.empty()) + DebugCmdsFromCommandLine.push_back(currentCommand); +#endif -void ResetTownNpcSelection() -{ - TownNpcOrder.clear(); - SelectedTownNpc = -1; +#ifndef DISABLE_DEMOMODE + if (demoNumber != -1) + demo::InitPlayBack(demoNumber, timedemo); + if (recordNumber != -1) + demo::InitRecording(recordNumber, createDemoReference); +#endif } -void RefreshTownNpcOrder(bool selectFirst = false) +void DiabloInitScreen() { - 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; - } + MousePosition = { gnScreenWidth / 2, gnScreenHeight / 2 }; + if (ControlMode == ControlTypes::KeyboardAndMouse) + SetCursorPos(MousePosition); - const auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); - if (it == TownNpcOrder.end()) - SelectedTownNpc = TownNpcOrder.front(); + ClrDiabloMsg(); } -void EnsureTownNpcOrder() +void SetApplicationVersions() { - 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(); + *BufCopy(gszProductName, PROJECT_NAME, " v", PROJECT_VERSION) = '\0'; + *BufCopy(gszVersionNumber, "version ", PROJECT_VERSION) = '\0'; } -void SpeakSelectedTownNpc() +void CheckArchivesUpToDate() { - EnsureTownNpcOrder(); + const bool devilutionxMpqOutOfDate = IsDevilutionXMpqOutOfDate(); + const bool fontsMpqOutOfDate = AreExtraFontsOutOfDate(); - if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { - SpeakText(_("No NPC selected."), true); - return; + if (devilutionxMpqOutOfDate && fontsMpqOutOfDate) { + app_fatal(_("Please update devilutionx.mpq and fonts.mpq to the latest version")); + } else if (devilutionxMpqOutOfDate) { + app_fatal(_("Failed to load UI resources.\n" + "\n" + "Make sure devilutionx.mpq is in the game folder and that it is up to date.")); + } else if (fontsMpqOutOfDate) { + app_fatal(_("Please update fonts.mpq to the latest version")); } - - 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) +void ApplicationInit() { - if (!IsTownNpcActionAllowed()) - return; - - EnsureTownNpcOrder(); - if (TownNpcOrder.empty()) { - SpeakText(_("No town NPCs found."), true); - return; - } + if (*GetOptions().Graphics.showFPS) + EnableFrameCount(); - auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); - int currentIndex = (it != TownNpcOrder.end()) ? static_cast(it - TownNpcOrder.begin()) : 0; + init_create_window(); + was_window_init = true; - const int size = static_cast(TownNpcOrder.size()); - int newIndex = (currentIndex + delta) % size; - if (newIndex < 0) - newIndex += size; - SelectedTownNpc = TownNpcOrder[static_cast(newIndex)]; - SpeakSelectedTownNpc(); -} + InitializeScreenReader(); + LanguageInitialize(); -void SelectNextTownNpcKeyPressed() -{ - SelectTownNpcRelative(+1); -} + SetApplicationVersions(); -void SelectPreviousTownNpcKeyPressed() -{ - SelectTownNpcRelative(-1); + ReadOnlyTest(); } -void GoToSelectedTownNpcKeyPressed() +void DiabloInit() { - if (!IsTownNpcActionAllowed()) - return; + if (forceSpawn || *GetOptions().GameMode.shareware) + gbIsSpawn = true; - EnsureTownNpcOrder(); - if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { - SpeakText(_("No NPC selected."), true); - return; + bool wasHellfireDiscovered = false; + if (!forceDiablo && !forceHellfire) + wasHellfireDiscovered = (HaveHellfire() && *GetOptions().GameMode.gameMode == StartUpGameMode::Ask); + bool enableHellfire = forceHellfire || wasHellfireDiscovered; + if (!forceDiablo && *GetOptions().GameMode.gameMode == StartUpGameMode::Hellfire) { // Migrate legacy options + GetOptions().GameMode.gameMode.SetValue(StartUpGameMode::Diablo); + enableHellfire = true; + } + if (forceDiablo || enableHellfire) { + GetOptions().Mods.SetHellfireEnabled(enableHellfire); } - const Towner &towner = Towners[SelectedTownNpc]; - - std::string msg; - StrAppend(msg, _("Going to: "), towner.name); - SpeakText(msg, true); - - AutoWalkTownNpcTarget = SelectedTownNpc; - UpdateAutoWalkTownNpc(); -} + gbIsHellfireSaveGame = gbIsHellfire; -void UpdateAutoWalkTownNpc() -{ - if (AutoWalkTownNpcTarget < 0) - return; - if (leveltype != DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag) { - AutoWalkTownNpcTarget = -1; - return; + for (size_t i = 0; i < QuickMessages.size(); i++) { + auto &messages = GetOptions().Chat.szHotKeyMsgs[i]; + if (messages.empty()) { + messages.emplace_back(_(QuickMessages[i].message)); + } } - if (!CanPlayerTakeAction()) - return; - if (MyPlayer->_pmode != PM_STAND) - return; - if (MyPlayer->walkpath[0] != WALK_NONE) - return; - if (MyPlayer->destAction != ACTION_NONE) - return; +#ifndef USE_SDL1 + InitializeVirtualGamepad(); +#endif - if (AutoWalkTownNpcTarget >= static_cast(GetNumTowners())) { - AutoWalkTownNpcTarget = -1; - SpeakText(_("No NPC selected."), true); - return; - } + UiInitialize(); + was_ui_init = true; - const Towner &towner = Towners[AutoWalkTownNpcTarget]; - if (!IsTownerPresent(towner._ttype) || towner._ttype == TOWN_COW) { - AutoWalkTownNpcTarget = -1; - SpeakText(_("No NPC selected."), true); - return; + if (wasHellfireDiscovered) { + UiSelStartUpGameOption(); + if (!gbIsHellfire) { + // Reinitialize the UI Elements because we changed the game + UnloadUiGFX(); + UiInitialize(); + if (IsHardwareCursor()) + SetHardwareCursor(CursorInfo::UnknownCursor()); + } } - 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; - } + DiabloInitScreen(); - constexpr size_t MaxAutoWalkPathLength = 512; - std::array path; - path.fill(WALK_NONE); + snd_init(); - 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; - } + ui_sound_init(); - // 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; - } + // Item graphics are loaded early, they already get touched during hero selection. + InitItemGFX(); - 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); -} + // Always available. + LoadSmallSelectionSpinner(); -namespace { + CheckArchivesUpToDate(); +} -constexpr int TrackerInteractDistanceTiles = 1; -constexpr int TrackerCycleDistanceTiles = 12; +void DiabloSplash() +{ + if (!gbShowIntro) + return; -int LockedTrackerItemId = -1; -int LockedTrackerChestId = -1; -int LockedTrackerDoorId = -1; -int LockedTrackerShrineId = -1; -int LockedTrackerObjectId = -1; -int LockedTrackerBreakableId = -1; -int LockedTrackerMonsterId = -1; + if (*GetOptions().StartUp.splash == StartUpSplash::LogoAndTitleDialog) + play_movie("gendata\\logo.smk", true); -struct TrackerLevelKey { - dungeon_type levelType; - int currLevel; - bool isSetLevel; - int setLevelNum; -}; + auto &intro = gbIsHellfire ? GetOptions().StartUp.hellfireIntro : GetOptions().StartUp.diabloIntro; -std::optional LockedTrackerLevelKey; + if (*intro != StartUpIntro::Off) { + if (gbIsHellfire) + play_movie("gendata\\Hellfire.smk", true); + else + play_movie("gendata\\diablo1.smk", true); + if (*intro == StartUpIntro::Once) { + intro.SetValue(StartUpIntro::Off); + if (!demo::IsRunning()) SaveOptions(); + } + } -void ClearTrackerLocks() -{ - LockedTrackerItemId = -1; - LockedTrackerChestId = -1; - LockedTrackerDoorId = -1; - LockedTrackerShrineId = -1; - LockedTrackerObjectId = -1; - LockedTrackerBreakableId = -1; - LockedTrackerMonsterId = -1; + if (IsAnyOf(*GetOptions().StartUp.splash, StartUpSplash::TitleDialog, StartUpSplash::LogoAndTitleDialog)) + UiTitleDialog(); } -void EnsureTrackerLocksMatchCurrentLevel() +void DiabloDeinit() { - const TrackerLevelKey current { - .levelType = leveltype, - .currLevel = currlevel, - .isSetLevel = setlevel, - .setLevelNum = setlvlnum, - }; + FreeItemGFX(); - if (!LockedTrackerLevelKey || LockedTrackerLevelKey->levelType != current.levelType || LockedTrackerLevelKey->currLevel != current.currLevel - || LockedTrackerLevelKey->isSetLevel != current.isSetLevel || LockedTrackerLevelKey->setLevelNum != current.setLevelNum) { - ClearTrackerLocks(); - LockedTrackerLevelKey = current; - } -} + LuaShutdown(); + ShutDownScreenReader(); -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; - } + if (gbSndInited) + effects_cleanup_sfx(); + snd_deinit(); + if (was_ui_init) + UiDestroy(); + if (was_archives_init) + init_cleanup(); + if (was_window_init) + dx_cleanup(); // Cleanup SDL surfaces stuff, so we have to do it before SDL_Quit(). + UnloadFonts(); + if (SDL_WasInit((~0U) & ~SDL_INIT_HAPTIC) != 0) + SDL_Quit(); } -std::string_view TrackerTargetCategoryLabel(TrackerTargetCategory category) +tl::expected LoadLvlGFX() { - 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"); + assert(pDungeonCels == nullptr); + constexpr int SpecialCelWidth = 64; + + const auto loadAll = [](const char *cel, const char *til, const char *special) -> tl::expected { + ASSIGN_OR_RETURN(pDungeonCels, LoadFileInMemWithStatus(cel)); + ASSIGN_OR_RETURN(pMegaTiles, LoadFileInMemWithStatus(til)); + ASSIGN_OR_RETURN(pSpecialCels, LoadCelWithStatus(special, SpecialCelWidth)); + return {}; + }; + + switch (leveltype) { + case DTYPE_TOWN: { + auto cel = LoadFileInMemWithStatus("nlevels\\towndata\\town.cel"); + if (!cel.has_value()) { + ASSIGN_OR_RETURN(pDungeonCels, LoadFileInMemWithStatus("levels\\towndata\\town.cel")); + } else { + pDungeonCels = std::move(*cel); + } + auto til = LoadFileInMemWithStatus("nlevels\\towndata\\town.til"); + if (!til.has_value()) { + ASSIGN_OR_RETURN(pMegaTiles, LoadFileInMemWithStatus("levels\\towndata\\town.til")); + } else { + pMegaTiles = std::move(*til); + } + ASSIGN_OR_RETURN(pSpecialCels, LoadCelWithStatus("levels\\towndata\\towns", SpecialCelWidth)); + return {}; + } + case DTYPE_CATHEDRAL: + return loadAll( + "levels\\l1data\\l1.cel", + "levels\\l1data\\l1.til", + "levels\\l1data\\l1s"); + case DTYPE_CATACOMBS: + return loadAll( + "levels\\l2data\\l2.cel", + "levels\\l2data\\l2.til", + "levels\\l2data\\l2s"); + case DTYPE_CAVES: + return loadAll( + "levels\\l3data\\l3.cel", + "levels\\l3data\\l3.til", + "levels\\l1data\\l1s"); + case DTYPE_HELL: + return loadAll( + "levels\\l4data\\l4.cel", + "levels\\l4data\\l4.til", + "levels\\l2data\\l2s"); + case DTYPE_NEST: + return loadAll( + "nlevels\\l6data\\l6.cel", + "nlevels\\l6data\\l6.til", + "levels\\l1data\\l1s"); + case DTYPE_CRYPT: + return loadAll( + "nlevels\\l5data\\l5.cel", + "nlevels\\l5data\\l5.til", + "nlevels\\l5data\\l5s"); default: - return _("items"); + return tl::make_unexpected("LoadLvlGFX"); } } -void SpeakTrackerTargetCategory() +tl::expected LoadAllGFX() { - std::string message; - StrAppend(message, _("Tracker target: "), TrackerTargetCategoryLabel(SelectedTrackerTargetCategory)); - SpeakText(message, true); + IncProgress(); +#if !defined(USE_SDL1) && !defined(__vita__) + InitVirtualGamepadGFX(); +#endif + IncProgress(); + RETURN_IF_ERROR(InitObjectGFX()); + IncProgress(); + RETURN_IF_ERROR(InitMissileGFX()); + IncProgress(); + return {}; } -void CycleTrackerTargetKeyPressed() +/** + * @param entry Where is the player entering from + */ +void CreateLevel(lvl_entry entry) { - if (!CanPlayerTakeAction() || InGameMenu()) - return; - - AutoWalkTrackerTargetId = -1; + CreateDungeon(DungeonSeeds[currlevel], entry); - switch (SelectedTrackerTargetCategory) { - case TrackerTargetCategory::Items: - SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; + switch (leveltype) { + case DTYPE_TOWN: + InitTownTriggers(); break; - case TrackerTargetCategory::Chests: - SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; + case DTYPE_CATHEDRAL: + InitL1Triggers(); break; - case TrackerTargetCategory::Doors: - SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; + case DTYPE_CATACOMBS: + InitL2Triggers(); break; - case TrackerTargetCategory::Shrines: - SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; + case DTYPE_CAVES: + InitL3Triggers(); break; - case TrackerTargetCategory::Objects: - SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; + case DTYPE_HELL: + InitL4Triggers(); break; - case TrackerTargetCategory::Breakables: - SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; + case DTYPE_NEST: + InitHiveTriggers(); break; - case TrackerTargetCategory::Monsters: - default: - SelectedTrackerTargetCategory = TrackerTargetCategory::Items; + case DTYPE_CRYPT: + InitCryptTriggers(); break; + default: + app_fatal("CreateLevel"); } - SpeakTrackerTargetCategory(); + if (leveltype != DTYPE_TOWN) { + Freeupstairs(); + } + LoadRndLvlPal(leveltype); } -std::optional FindNearestGroundItemId(Point playerPosition) +void UnstuckChargers() { - 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) + if (gbIsMultiplayer) { + for (Player &player : Players) { + if (!player.plractive) continue; - - const Item &item = Items[itemId]; - if (item.isEmpty() || item._iClass == ICLASS_NONE) + if (player._pLvlChanging) continue; - - const int distance = playerPosition.WalkingDistance(Point { x, y }); - if (!bestId || distance < bestDistance) { - bestId = itemId; - bestDistance = distance; - } + if (!player.isOnActiveLevel()) + continue; + if (&player == MyPlayer) + continue; + return; } } - - return bestId; + for (size_t i = 0; i < ActiveMonsterCount; i++) { + Monster &monster = Monsters[ActiveMonsters[i]]; + if (monster.mode == MonsterMode::Charge) + monster.mode = MonsterMode::Stand; + } } -struct TrackerCandidate { - int id; - int distance; - StringOrView name; -}; - -[[nodiscard]] bool IsBetterTrackerCandidate(const TrackerCandidate &a, const TrackerCandidate &b) +void UpdateMonsterLights() { - if (a.distance != b.distance) - return a.distance < b.distance; - return a.id < b.id; -} + for (size_t i = 0; i < ActiveMonsterCount; i++) { + Monster &monster = Monsters[ActiveMonsters[i]]; -[[nodiscard]] std::vector CollectNearbyItemTrackerCandidates(Point playerPosition, int maxDistance) -{ - std::vector result; - result.reserve(ActiveItemCount); + if ((monster.flags & MFLAG_BERSERK) != 0) { + const int lightRadius = leveltype == DTYPE_NEST ? 9 : 3; + monster.lightId = AddLight(monster.position.tile, lightRadius); + } - 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); + if (monster.lightId != NO_LIGHT) { + if (monster.lightId == MyPlayer->lightId) { // Fix old saves where some monsters had 0 instead of NO_LIGHT + monster.lightId = NO_LIGHT; + continue; + } - std::array seen {}; + const Light &light = Lights[monster.lightId]; + if (monster.position.tile != light.position.tile) { + ChangeLightXY(monster.lightId, monster.position.tile); + } + } + } +} - 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) +void GameLogic() { - if (targetName.empty()) + if (!ProcessInput()) { 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 (gbProcessPlayers) { + gGameLogicStep = GameLogicStep::ProcessPlayers; + ProcessPlayers(); + UpdateAutoWalkTracker(); + UpdateLowDurabilityWarnings(); } - 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; + if (leveltype != DTYPE_TOWN) { + gGameLogicStep = GameLogicStep::ProcessMonsters; +#ifdef _DEBUG + if (!DebugInvisible) +#endif + ProcessMonsters(); + gGameLogicStep = GameLogicStep::ProcessObjects; + ProcessObjects(); + gGameLogicStep = GameLogicStep::ProcessMissiles; + ProcessMissiles(); + gGameLogicStep = GameLogicStep::ProcessItems; + ProcessItems(); + ProcessLightList(); + ProcessVisionList(); + UpdateBossHealthAnnouncements(); + UpdateProximityAudioCues(); + UpdateAttackableMonsterAnnouncements(); + UpdateInteractableDoorAnnouncements(); + } else { + gGameLogicStep = GameLogicStep::ProcessTowners; + ProcessTowners(); + gGameLogicStep = GameLogicStep::ProcessItemsTown; + ProcessItems(); + gGameLogicStep = GameLogicStep::ProcessMissilesTown; + ProcessMissiles(); + UpdateProximityAudioCues(); } - 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]; + UpdatePlayerLowHpWarningSound(); - if (monster.isInvalid) - continue; - if ((monster.flags & MFLAG_HIDDEN) != 0) - continue; - if (monster.hitPoints <= 0) - continue; + gGameLogicStep = GameLogicStep::None; - const Point monsterDistancePosition { monster.position.future }; - const int distance = playerPosition.ApproxDistance(monsterDistancePosition); - if (!bestId || distance < bestDistance) { - bestId = monsterId; - bestDistance = distance; - } +#ifdef _DEBUG + if (DebugScrollViewEnabled && (SDL_GetModState() & SDL_KMOD_SHIFT) != 0) { + ScrollView(); } +#endif - return bestId; + sound_update(); + CheckTriggers(); + CheckQuests(); + RedrawViewport(); + pfile_update(false); + + plrctrls_after_game_logic(); } -std::optional FindBestAdjacentApproachTile(const Player &player, Point playerPosition, Point targetPosition) +void TimeoutCursor(bool bTimeout) { - 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; + 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; - const int distance = playerPosition.WalkingDistance(tile); + DvlNetLatencies latencies = DvlNet_GetLatencies(i); - if (!bestFallback || distance < bestFallbackDistance) { - bestFallback = tile; - bestFallbackDistance = distance; - } + std::string ping = fmt::format( + fmt::runtime(_(/* TRANSLATORS: {:s} means: Character Name */ "Player {:s} is timing out!")), + Players[i].name()); - const std::optional> path = FindKeyboardWalkPathForSpeech(player, playerPosition, tile); - if (!path) - continue; + StrAppend(ping, "\n ", fmt::format(fmt::runtime(_(/* TRANSLATORS: Network connectivity statistics */ "Echo latency: {:d} ms")), latencies.echoLatency)); - const size_t pathLength = path->size(); - if (!best || pathLength < bestPathLength || (pathLength == bestPathLength && distance < bestDistance)) { - best = tile; - bestPathLength = pathLength; - bestDistance = distance; + 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(); } - - 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) +void HelpKeyPressed() { - 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; + 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(); } - - 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) +void OptionLanguageCodeChanged() { - 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; + UnloadFonts(); + LanguageInitialize(); + LoadLanguageArchive(); + effects_cleanup_sfx(false); + if (gbRunGame) + sound_init(); + else + ui_sound_init(); } -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; -} +const auto OptionChangeHandlerLanguage = (GetOptions().Language.code.SetValueChangedCallback(OptionLanguageCodeChanged), true); -std::optional FindBestApproachTileForObject(const Player &player, Point playerPosition, const Object &object) +void CancelAutoWalkInternal() { - // 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; + ResetAutoWalkTracker(); } -struct DoorBlockInfo { - Point beforeDoor; - Point doorPosition; -}; +} // namespace -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: { - const std::vector nearbyCandidates = CollectNearbyDoorTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - 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 = 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::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; +void CancelAutoWalk() +{ + CancelAutoWalkInternal(); + if (MyPlayer != nullptr) + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, MyPlayer->position.future); +} + +void InitKeymapActions() +{ + Options &options = GetOptions(); + for (uint32_t i = 0; i < 8; ++i) { + options.Keymapper.AddAction( + "BeltItem{}", + N_("Belt item {}"), + N_("Use Belt item."), + '1' + i, + [i] { + const Player &myPlayer = *MyPlayer; + if (!myPlayer.SpdList[i].isEmpty() && myPlayer.SpdList[i]._itype != ItemType::Gold) { + UseInvItem(INVITEM_BELT_FIRST + i); + } + }, + nullptr, + CanPlayerTakeAction, + i + 1); + } + for (uint32_t i = 0; i < NumHotkeys; ++i) { + options.Keymapper.AddAction( + "QuickSpell{}", + N_("Quick spell {}"), + N_("Hotkey for skill or spell."), + i < 4 ? static_cast(SDLK_F5) + i : static_cast(SDLK_UNKNOWN), + [i]() { + if (SpellSelectFlag) { + SetSpeedSpell(i); + return; + } + if (!*GetOptions().Gameplay.quickCast) + ToggleSpell(i); + else + QuickCast(i); + }, + nullptr, + CanPlayerTakeAction, + i + 1); } + options.Keymapper.AddAction( + "QuickSpellPrevious", + N_("Previous quick spell"), + N_("Selects the previous quick spell (cycles)."), + MouseScrollUpButton, + [] { CycleSpellHotkeys(false); }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "QuickSpellNext", + N_("Next quick spell"), + N_("Selects the next quick spell (cycles)."), + MouseScrollDownButton, + [] { CycleSpellHotkeys(true); }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "UseHealthPotion", + N_("Use health potion"), + N_("Use health potions from belt."), + SDLK_UNKNOWN, + [] { UseBeltItem(BeltItemType::Healing); }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "UseManaPotion", + N_("Use mana potion"), + N_("Use mana potions from belt."), + SDLK_UNKNOWN, + [] { UseBeltItem(BeltItemType::Mana); }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "DisplaySpells", + N_("Speedbook"), + N_("Open Speedbook."), + 'S', + DisplaySpellsKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "QuickSave", + N_("Quick save"), + N_("Saves the game."), + SDLK_F2, + [] { gamemenu_save_game(false); }, + nullptr, + [&]() { return !gbIsMultiplayer && CanPlayerTakeAction(); }); + options.Keymapper.AddAction( + "QuickLoad", + N_("Quick load"), + N_("Loads the game."), + SDLK_F3, + [] { gamemenu_load_game(false); }, + nullptr, + [&]() { return !gbIsMultiplayer && gbValidSaveFile && !IsPlayerInStore() && IsGameRunning(); }); +#ifndef NOEXIT + options.Keymapper.AddAction( + "QuitGame", + N_("Quit game"), + N_("Closes the game."), + SDLK_UNKNOWN, + [] { gamemenu_quit_game(false); }); +#endif + options.Keymapper.AddAction( + "StopHero", + N_("Stop hero"), + N_("Stops walking and cancel pending actions."), + SDLK_UNKNOWN, + [] { MyPlayer->Stop(); }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "ItemHighlighting", + N_("Item highlighting"), + N_("Show/hide items on ground."), + SDLK_LALT, + [] { HighlightKeyPressed(true); }, + [] { HighlightKeyPressed(false); }); + options.Keymapper.AddAction( + "ToggleItemHighlighting", + N_("Toggle item highlighting"), + N_("Permanent show/hide items on ground."), + SDLK_RCTRL, + nullptr, + [] { ToggleItemLabelHighlight(); }); + options.Keymapper.AddAction( + "ToggleAutomap", + N_("Toggle automap"), + N_("Toggles if automap is displayed. While the stash is open, switches between inventory and stash."), + SDLK_TAB, + [] { + if (IsStashOpen) { + ToggleStashFocus(); + return; + } + DoAutoMap(); + }, + nullptr, + IsGameRunning); + options.Keymapper.AddAction( + "ToggleStashFocus", + N_("Toggle stash focus"), + N_("Tab: switches between inventory and stash."), + SDLK_UNKNOWN, + ToggleStashFocus, + nullptr, + []() { return IsStashOpen && !InGameMenu() && !ChatLogFlag; }); + options.Keymapper.AddAction( + "CycleAutomapType", + N_("Cycle map type"), + N_("Opaque -> Transparent -> Minimap -> None"), + SDLK_M, + CycleAutomapType, + nullptr, + IsGameRunning); - if (cycleTarget) { - SpeakText(targetName.str(), /*force=*/true); - return; - } + options.Keymapper.AddAction( + "SpeakNearestUnexploredSpace", + N_("Nearest unexplored space"), + N_("Speaks the nearest unexplored space."), + 'H', + SpeakNearestUnexploredTileKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "TrackerPrevious", + N_("Tracker previous"), + N_("PageUp: previous target. Ctrl+PageUp: previous category."), + SDLK_PAGEUP, + TrackerPageUpKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu() && !IsPlayerInStore() && !ChatLogFlag; }); + options.Keymapper.AddAction( + "TrackerNext", + N_("Tracker next"), + N_("PageDown: next target. Ctrl+PageDown: next category."), + SDLK_PAGEDOWN, + TrackerPageDownKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu() && !IsPlayerInStore() && !ChatLogFlag; }); + options.Keymapper.AddAction( + "TrackerGo", + N_("Tracker go"), + N_("Home: speak directions to the selected target. Shift+Home: auto-walk to the selected target (press again to cancel)."), + SDLK_HOME, + TrackerHomeKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu() && !IsPlayerInStore() && !ChatLogFlag; }); + 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( + "StandGround", + N_("Stand ground"), + N_("Hold to prevent the player from moving."), + SDLK_LSHIFT, + [] { StandGroundHeld = true; }, + [] { StandGroundHeld = false; }, + IsGameRunning); + options.Keymapper.AddAction( + "ToggleStandGround", + N_("Toggle stand ground"), + N_("Toggle whether the player moves."), + SDLK_UNKNOWN, + [] { StandToggle = !StandToggle; }, + nullptr, + CanPlayerTakeAction); + 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(); }); - if (!targetPosition) { - SpeakText(_("Can't find a nearby tile to walk to."), true); - return; + options.Keymapper.AddAction( + "Inventory", + N_("Inventory"), + N_("Open Inventory screen."), + 'I', + InventoryKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "Character", + N_("Character"), + N_("Open Character screen."), + 'C', + CharacterSheetKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "Party", + N_("Party"), + N_("Open side Party panel."), + 'Y', + PartyPanelSideToggleKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "QuestLog", + N_("Quest log"), + N_("Open Quest log."), + 'Q', + QuestLogKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SpellBook", + N_("Spellbook"), + N_("Open Spellbook."), + 'B', + SpellBookKeyPressed, + nullptr, + CanPlayerTakeAction); + for (uint32_t i = 0; i < QuickMessages.size(); ++i) { + options.Keymapper.AddAction( + "QuickMessage{}", + N_("Quick Message {}"), + N_("Use Quick Message in chat."), + (i < 4) ? static_cast(SDLK_F9) + i : static_cast(SDLK_UNKNOWN), + [i]() { DiabloHotkeyMsg(i); }, + nullptr, + nullptr, + i + 1); } - - Point chosenTargetPosition = *targetPosition; - enum class TrackerPathMode : uint8_t { - RespectDoorsAndMonsters, - IgnoreDoors, - IgnoreMonsters, - IgnoreDoorsAndMonsters, - Lenient, + options.Keymapper.AddAction( + "HideInfoScreens", + N_("Hide Info Screens"), + N_("Hide all info screens."), + SDLK_SPACE, + [] { + if (CanAutomapBeToggledOff()) + AutomapActive = false; + + ClosePanels(); + HelpFlag = false; + ChatLogFlag = false; + SpellSelectFlag = false; + if (qtextflag && leveltype == DTYPE_TOWN) { + qtextflag = false; + stream_stop(); + } + + CancelCurrentDiabloMsg(); + gamemenu_off(); + doom_close(); + }, + nullptr, + IsGameRunning); + options.Keymapper.AddAction( + "Zoom", + N_("Zoom"), + N_("Zoom Game Screen."), + SDLK_UNKNOWN, + [] { + GetOptions().Graphics.zoom.SetValue(!*GetOptions().Graphics.zoom); + CalcViewportGeometry(); + }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SpeakPlayerHealthPercentage", + N_("Health percentage"), + N_("Speaks the player's health as a percentage. Hold Shift for mana."), + 'Z', + SpeakPlayerHealthPercentageKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SpeakExperienceToNextLevel", + N_("Experience to level"), + N_("Speaks how much experience remains to reach the next level."), + 'X', + SpeakExperienceToNextLevelKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "PauseGame", + N_("Pause Game"), + N_("Pauses the game."), + SDLK_UNKNOWN, + diablo_pause_game); + options.Keymapper.AddAction( + "PauseGameAlternate", + N_("Pause Game (Alternate)"), + N_("Pauses the game."), + SDLK_PAUSE, + diablo_pause_game); + options.Keymapper.AddAction( + "DecreaseBrightness", + N_("Decrease Brightness"), + N_("Reduce screen brightness."), + 'F', + DecreaseBrightness, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "IncreaseBrightness", + N_("Increase Brightness"), + N_("Increase screen brightness."), + 'G', + IncreaseBrightness, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "Help", + N_("Help"), + N_("Open Help Screen."), + SDLK_F1, + HelpKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "Screenshot", + N_("Screenshot"), + N_("Takes a screenshot."), + SDLK_PRINTSCREEN, + nullptr, + CaptureScreen); + options.Keymapper.AddAction( + "GameInfo", + N_("Game info"), + N_("Displays game infos."), + 'V', + [] { + EventPlrMsg(fmt::format( + fmt::runtime(_(/* TRANSLATORS: {:s} means: Project Name, Game Version. */ "{:s} {:s}")), + PROJECT_NAME, + PROJECT_VERSION), + UiFlags::ColorWhite); + }, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "ChatLog", + N_("Chat Log"), + N_("Displays chat log."), + SDLK_INSERT, + [] { + ToggleChatLog(); + }); + options.Keymapper.AddAction( + "SpeakCurrentLocation", + N_("Location"), + N_("Speaks the current dungeon and floor."), + 'L', + SpeakCurrentLocationKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "ToggleAudioCues", + N_("Toggle Audio Cues"), + N_("Enable/disable all navigation audio cues."), + SDLK_SLASH, + ToggleAudioCuesKeyPressed, + nullptr, + IsGameRunning); + options.Keymapper.AddAction( + "ToggleNpcDialogTextReading", + N_("Toggle NPC Subtitle Reading"), + N_("Enable/disable reading subtitle text when NPCs speak."), + SDLK_BACKSLASH, + ToggleNpcDialogTextReadingKeyPressed, + nullptr, + IsGameRunning); + options.Keymapper.AddAction( + "SortInv", + N_("Sort Inventory"), + N_("Sorts the inventory."), + 'R', + [] { + ReorganizeInventory(*MyPlayer); + }); +#ifdef _DEBUG + options.Keymapper.AddAction( + "OpenConsole", + N_("Console"), + N_("Opens Lua console."), + SDLK_GRAVE, + OpenConsole); + options.Keymapper.AddAction( + "DebugToggle", + "Debug toggle", + "Programming is like magic.", + 'X', + [] { + DebugToggle = !DebugToggle; + }); +#endif + options.Keymapper.CommitActions(); +} + +void InitPadmapActions() +{ + Options &options = GetOptions(); + for (int i = 0; i < 8; ++i) { + options.Padmapper.AddAction( + "BeltItem{}", + N_("Belt item {}"), + N_("Use Belt item."), + ControllerButton_NONE, + [i] { + const Player &myPlayer = *MyPlayer; + if (!myPlayer.SpdList[i].isEmpty() && myPlayer.SpdList[i]._itype != ItemType::Gold) { + UseInvItem(INVITEM_BELT_FIRST + i); + } + }, + nullptr, + CanPlayerTakeAction, + i + 1); + } + for (uint32_t i = 0; i < NumHotkeys; ++i) { + options.Padmapper.AddAction( + "QuickSpell{}", + N_("Quick spell {}"), + N_("Hotkey for skill or spell."), + ControllerButton_NONE, + [i]() { + if (SpellSelectFlag) { + SetSpeedSpell(i); + return; + } + if (!*GetOptions().Gameplay.quickCast) + ToggleSpell(i); + else + QuickCast(i); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }, + i + 1); + } + options.Padmapper.AddAction( + "PrimaryAction", + N_("Primary action"), + N_("Attack monsters, talk to towners, lift and place inventory items."), + ControllerButton_BUTTON_B, + [] { + ControllerActionHeld = GameActionType_PRIMARY_ACTION; + LastPlayerAction = PlayerActionType::None; + PerformPrimaryAction(); + }, + [] { + ControllerActionHeld = GameActionType_NONE; + LastPlayerAction = PlayerActionType::None; + }, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "SecondaryAction", + N_("Secondary action"), + N_("Open chests, interact with doors, pick up items."), + ControllerButton_BUTTON_Y, + [] { + ControllerActionHeld = GameActionType_SECONDARY_ACTION; + LastPlayerAction = PlayerActionType::None; + PerformSecondaryAction(); + }, + [] { + ControllerActionHeld = GameActionType_NONE; + LastPlayerAction = PlayerActionType::None; + }, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "SpellAction", + N_("Spell action"), + N_("Cast the active spell."), + ControllerButton_BUTTON_X, + [] { + ControllerActionHeld = GameActionType_CAST_SPELL; + LastPlayerAction = PlayerActionType::None; + PerformSpellAction(); + }, + [] { + ControllerActionHeld = GameActionType_NONE; + LastPlayerAction = PlayerActionType::None; + }, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "CancelAction", + N_("Cancel action"), + N_("Close menus."), + ControllerButton_BUTTON_A, + [] { + if (DoomFlag) { + doom_close(); + return; + } + + GameAction action; + if (SpellSelectFlag) + action = GameAction(GameActionType_TOGGLE_QUICK_SPELL_MENU); + else if (invflag) + action = GameAction(GameActionType_TOGGLE_INVENTORY); + else if (SpellbookFlag) + action = GameAction(GameActionType_TOGGLE_SPELL_BOOK); + else if (QuestLogIsOpen) + action = GameAction(GameActionType_TOGGLE_QUEST_LOG); + else if (CharFlag) + action = GameAction(GameActionType_TOGGLE_CHARACTER_INFO); + ProcessGameAction(action); + }, + nullptr, + [] { return DoomFlag || SpellSelectFlag || invflag || SpellbookFlag || QuestLogIsOpen || CharFlag; }); + options.Padmapper.AddAction( + "MoveUp", + N_("Move up"), + N_("Moves the player character up."), + ControllerButton_BUTTON_DPAD_UP, + [] {}); + options.Padmapper.AddAction( + "MoveDown", + N_("Move down"), + N_("Moves the player character down."), + ControllerButton_BUTTON_DPAD_DOWN, + [] {}); + options.Padmapper.AddAction( + "MoveLeft", + N_("Move left"), + N_("Moves the player character left."), + ControllerButton_BUTTON_DPAD_LEFT, + [] {}); + options.Padmapper.AddAction( + "MoveRight", + N_("Move right"), + N_("Moves the player character right."), + ControllerButton_BUTTON_DPAD_RIGHT, + [] {}); + options.Padmapper.AddAction( + "StandGround", + N_("Stand ground"), + N_("Hold to prevent the player from moving."), + ControllerButton_NONE, + [] {}); + options.Padmapper.AddAction( + "ToggleStandGround", + N_("Toggle stand ground"), + N_("Toggle whether the player moves."), + ControllerButton_NONE, + [] { StandToggle = !StandToggle; }, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "UseHealthPotion", + N_("Use health potion"), + N_("Use health potions from belt."), + ControllerButton_BUTTON_LEFTSHOULDER, + [] { UseBeltItem(BeltItemType::Healing); }, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "UseManaPotion", + N_("Use mana potion"), + N_("Use mana potions from belt."), + ControllerButton_BUTTON_RIGHTSHOULDER, + [] { UseBeltItem(BeltItemType::Mana); }, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "Character", + N_("Character"), + N_("Open Character screen."), + ControllerButton_AXIS_TRIGGERLEFT, + [] { + ProcessGameAction(GameAction { GameActionType_TOGGLE_CHARACTER_INFO }); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "Inventory", + N_("Inventory"), + N_("Open Inventory screen."), + ControllerButton_AXIS_TRIGGERRIGHT, + [] { + ProcessGameAction(GameAction { GameActionType_TOGGLE_INVENTORY }); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "QuestLog", + N_("Quest log"), + N_("Open Quest log."), + { ControllerButton_BUTTON_BACK, ControllerButton_AXIS_TRIGGERLEFT }, + [] { + ProcessGameAction(GameAction { GameActionType_TOGGLE_QUEST_LOG }); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "SpellBook", + N_("Spellbook"), + N_("Open Spellbook."), + { ControllerButton_BUTTON_BACK, ControllerButton_AXIS_TRIGGERRIGHT }, + [] { + ProcessGameAction(GameAction { GameActionType_TOGGLE_SPELL_BOOK }); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "DisplaySpells", + N_("Speedbook"), + N_("Open Speedbook."), + ControllerButton_BUTTON_A, + [] { + ProcessGameAction(GameAction { GameActionType_TOGGLE_QUICK_SPELL_MENU }); + }, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Padmapper.AddAction( + "ToggleAutomap", + N_("Toggle automap"), + N_("Toggles if automap is displayed."), + ControllerButton_BUTTON_LEFTSTICK, + DoAutoMap); + options.Padmapper.AddAction( + "AutomapMoveUp", + N_("Automap Move Up"), + N_("Moves the automap up when active."), + ControllerButton_NONE, + [] {}); + options.Padmapper.AddAction( + "AutomapMoveDown", + N_("Automap Move Down"), + N_("Moves the automap down when active."), + ControllerButton_NONE, + [] {}); + options.Padmapper.AddAction( + "AutomapMoveLeft", + N_("Automap Move Left"), + N_("Moves the automap left when active."), + ControllerButton_NONE, + [] {}); + options.Padmapper.AddAction( + "AutomapMoveRight", + N_("Automap Move Right"), + N_("Moves the automap right when active."), + ControllerButton_NONE, + [] {}); + options.Padmapper.AddAction( + "MouseUp", + N_("Move mouse up"), + N_("Simulates upward mouse movement."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_UP }, + [] {}); + options.Padmapper.AddAction( + "MouseDown", + N_("Move mouse down"), + N_("Simulates downward mouse movement."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_DOWN }, + [] {}); + options.Padmapper.AddAction( + "MouseLeft", + N_("Move mouse left"), + N_("Simulates leftward mouse movement."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_LEFT }, + [] {}); + options.Padmapper.AddAction( + "MouseRight", + N_("Move mouse right"), + N_("Simulates rightward mouse movement."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_RIGHT }, + [] {}); + auto leftMouseDown = [] { + const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); + const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); + sgbMouseDown = CLICK_LEFT; + LeftMouseDown(standGround ? SDL_KMOD_SHIFT : SDL_KMOD_NONE); }; - - auto 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; - } + auto leftMouseUp = [] { + const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); + const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_NONE; + LeftMouseUp(standGround ? SDL_KMOD_SHIFT : SDL_KMOD_NONE); }; - - 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; - } + options.Padmapper.AddAction( + "LeftMouseClick1", + N_("Left mouse click"), + N_("Simulates the left mouse button."), + ControllerButton_BUTTON_RIGHTSTICK, + leftMouseDown, + leftMouseUp); + options.Padmapper.AddAction( + "LeftMouseClick2", + N_("Left mouse click"), + N_("Simulates the left mouse button."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_LEFTSHOULDER }, + leftMouseDown, + leftMouseUp); + auto rightMouseDown = [] { + const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); + const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_RIGHT; + RightMouseDown(standGround); }; - - 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); + auto rightMouseUp = [] { + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_NONE; + }; + options.Padmapper.AddAction( + "RightMouseClick1", + N_("Right mouse click"), + N_("Simulates the right mouse button."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_RIGHTSTICK }, + rightMouseDown, + rightMouseUp); + options.Padmapper.AddAction( + "RightMouseClick2", + N_("Right mouse click"), + N_("Simulates the right mouse button."), + { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_RIGHTSHOULDER }, + rightMouseDown, + rightMouseUp); + options.Padmapper.AddAction( + "PadHotspellMenu", + N_("Gamepad hotspell menu"), + N_("Hold to set or use spell hotkeys."), + ControllerButton_BUTTON_BACK, + [] { PadHotspellMenuActive = true; }, + [] { PadHotspellMenuActive = false; }); + options.Padmapper.AddAction( + "PadMenuNavigator", + N_("Gamepad menu navigator"), + N_("Hold to access gamepad menu navigation."), + ControllerButton_BUTTON_START, + [] { PadMenuNavigatorActive = true; }, + [] { PadMenuNavigatorActive = false; }); + auto toggleGameMenu = [] { + const bool inMenu = gmenu_is_active(); + PressEscKey(); + LastPlayerAction = PlayerActionType::None; + PadHotspellMenuActive = false; + PadMenuNavigatorActive = false; + if (!inMenu) + gamemenu_on(); + }; + options.Padmapper.AddAction( + "ToggleGameMenu1", + N_("Toggle game menu"), + N_("Opens the game menu."), + { + ControllerButton_BUTTON_BACK, + ControllerButton_BUTTON_START, + }, + toggleGameMenu); + options.Padmapper.AddAction( + "ToggleGameMenu2", + N_("Toggle game menu"), + N_("Opens the game menu."), + { + ControllerButton_BUTTON_START, + ControllerButton_BUTTON_BACK, + }, + toggleGameMenu); + options.Padmapper.AddAction( + "QuickSave", + N_("Quick save"), + N_("Saves the game."), + ControllerButton_NONE, + [] { gamemenu_save_game(false); }, + nullptr, + [&]() { return !gbIsMultiplayer && CanPlayerTakeAction(); }); + options.Padmapper.AddAction( + "QuickLoad", + N_("Quick load"), + N_("Loads the game."), + ControllerButton_NONE, + [] { gamemenu_load_game(false); }, + nullptr, + [&]() { return !gbIsMultiplayer && gbValidSaveFile && !IsPlayerInStore() && IsGameRunning(); }); + options.Padmapper.AddAction( + "ItemHighlighting", + N_("Item highlighting"), + N_("Show/hide items on ground."), + ControllerButton_NONE, + [] { HighlightKeyPressed(true); }, + [] { HighlightKeyPressed(false); }); + options.Padmapper.AddAction( + "ToggleItemHighlighting", + N_("Toggle item highlighting"), + N_("Permanent show/hide items on ground."), + ControllerButton_NONE, + nullptr, + [] { ToggleItemLabelHighlight(); }); + options.Padmapper.AddAction( + "HideInfoScreens", + N_("Hide Info Screens"), + N_("Hide all info screens."), + ControllerButton_NONE, + [] { + if (CanAutomapBeToggledOff()) + AutomapActive = false; + + ClosePanels(); + HelpFlag = false; + ChatLogFlag = false; + SpellSelectFlag = false; + if (qtextflag && leveltype == DTYPE_TOWN) { + qtextflag = false; + stream_stop(); + } + + CancelCurrentDiabloMsg(); + gamemenu_off(); + doom_close(); + }, + nullptr, + IsGameRunning); + options.Padmapper.AddAction( + "Zoom", + N_("Zoom"), + N_("Zoom Game Screen."), + ControllerButton_NONE, + [] { + GetOptions().Graphics.zoom.SetValue(!*GetOptions().Graphics.zoom); + CalcViewportGeometry(); + }, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "PauseGame", + N_("Pause Game"), + N_("Pauses the game."), + ControllerButton_NONE, + diablo_pause_game); + options.Padmapper.AddAction( + "DecreaseBrightness", + N_("Decrease Brightness"), + N_("Reduce screen brightness."), + ControllerButton_NONE, + DecreaseBrightness, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "IncreaseBrightness", + N_("Increase Brightness"), + N_("Increase screen brightness."), + ControllerButton_NONE, + IncreaseBrightness, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "Help", + N_("Help"), + N_("Open Help Screen."), + ControllerButton_NONE, + HelpKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "Screenshot", + N_("Screenshot"), + N_("Takes a screenshot."), + ControllerButton_NONE, + nullptr, + CaptureScreen); + options.Padmapper.AddAction( + "GameInfo", + N_("Game info"), + N_("Displays game infos."), + ControllerButton_NONE, + [] { + EventPlrMsg(fmt::format( + fmt::runtime(_(/* TRANSLATORS: {:s} means: Project Name, Game Version. */ "{:s} {:s}")), + PROJECT_NAME, + PROJECT_VERSION), + UiFlags::ColorWhite); + }, + nullptr, + CanPlayerTakeAction); + options.Padmapper.AddAction( + "SortInv", + N_("Sort Inventory"), + N_("Sorts the inventory."), + ControllerButton_NONE, + [] { + ReorganizeInventory(*MyPlayer); + }); + options.Padmapper.AddAction( + "ChatLog", + N_("Chat Log"), + N_("Displays chat log."), + ControllerButton_NONE, + [] { + ToggleChatLog(); + }); + options.Padmapper.CommitActions(); } -} // namespace - -void UpdateAutoWalkTracker() +void SetCursorPos(Point position) { - 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); + MousePosition = position; + if (ControlDevice != ControlTypes::KeyboardAndMouse) { 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); + LogicalToOutput(&position.x, &position.y); + if (!demo::IsRunning()) + SDL_WarpMouseInWindow(ghMainWnd, position.x, position.y); } -void ListTownNpcsKeyPressed() +void FreeGameMem() { - if (leveltype != DTYPE_TOWN) { - ResetTownNpcSelection(); - SpeakText(_("Not in town."), true); - return; - } - if (IsPlayerInStore()) - return; - - std::vector townNpcs; - std::vector cows; + pDungeonCels = nullptr; + pMegaTiles = nullptr; + pSpecialCels = std::nullopt; - townNpcs.reserve(Towners.size()); - cows.reserve(Towners.size()); + FreeMonsters(); + FreeMissileGFX(); + FreeObjectGFX(); + FreeTownerGFX(); + FreeStashGFX(); +#ifndef USE_SDL1 + DeactivateVirtualGamepad(); + FreeVirtualGamepadGFX(); +#endif +} - const Point playerPosition = MyPlayer->position.future; +bool StartGame(bool bNewGame, bool bSinglePlayer) +{ + gbSelectProvider = true; + ReturnToMainMenu = false; - for (const Towner &towner : Towners) { - if (!IsTownerPresent(towner._ttype)) - continue; + do { + gbLoadGame = false; - if (towner._ttype == TOWN_COW) { - cows.push_back(&towner); - continue; + if (!NetInit(bSinglePlayer)) { + gbRunGameResult = true; + break; } - townNpcs.push_back(&towner); - } + // Save 2.8 MiB of RAM by freeing all main menu resources + // before starting the game. + UiDestroy(); - if (townNpcs.empty() && cows.empty()) { - ResetTownNpcSelection(); - SpeakText(_("No town NPCs found."), true); - return; - } + gbSelectProvider = false; - 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())); - } + if (bNewGame || !gbValidSaveFile) { + InitLevels(); + InitQuests(); + InitPortals(); + InitDungMsgs(*MyPlayer); + DeltaSyncJunk(); + } + giNumberOfLevels = gbIsHellfire ? 25 : 17; + interface_mode uMsg = WM_DIABNEWGAME; + if (gbValidSaveFile && gbLoadGame) { + uMsg = WM_DIABLOADGAME; + } + RunGameLoop(uMsg); + NetClose(); + UnloadFonts(); - 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)); - } + // If the player left the game into the main menu, + // initialize main menu resources. + if (gbRunGameResult) + UiInitialize(); + if (ReturnToMainMenu) + return true; + } while (gbRunGameResult); - SpeakText(output, true); + SNetDestroy(); + return gbRunGameResult; } -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) +void diablo_quit(int exitStatus) { - if (!InDungeonBounds(startPosition) || !InDungeonBounds(destinationPosition)) - return std::nullopt; + FreeGameMem(); + music_stop(); + DiabloDeinit(); - if (startPosition == destinationPosition) - return std::vector {}; +#if SDL_VERSION_ATLEAST(2, 0, 0) + if (SdlLogFile != nullptr) std::fclose(SdlLogFile); +#endif - std::array visited {}; - std::array parentDir {}; - parentDir.fill(WALK_NONE); + exit(exitStatus); +} - std::queue queue; +#ifdef __UWP__ +void (*onInitialized)() = NULL; - const auto indexOf = [](Point position) -> size_t { - return static_cast(position.x) + static_cast(position.y) * MAXDUNX; - }; +void setOnInitialized(void (*callback)()) +{ + onInitialized = callback; +} +#endif - const auto enqueue = [&](Point current, int8_t dir) { - const Point next = NextPositionForWalkDirection(current, dir); - if (!InDungeonBounds(next)) - return; +int DiabloMain(int argc, char **argv) +{ +#ifdef _DEBUG + SDL_SetLogPriorities(SDL_LOG_PRIORITY_DEBUG); +#endif - const size_t idx = indexOf(next); - if (visited[idx]) - return; + DiabloParseFlags(argc, argv); + InitKeymapActions(); + InitPadmapActions(); - const bool ok = posOk(player, next); - if (ok) { - if (!CanStep(current, next)) - return; - } else { - if (!allowDestinationNonWalkable || next != destinationPosition) - return; - } + // Need to ensure devilutionx.mpq (and fonts.mpq if available) are loaded before attempting to read translation settings + LoadCoreArchives(); + was_archives_init = true; - visited[idx] = true; - parentDir[idx] = dir; - queue.push(next); - }; + // Read settings including translation next. This will use the presence of fonts.mpq and look for assets in devilutionx.mpq + LoadOptions(); + if (demo::IsRunning()) demo::OverrideOptions(); - visited[indexOf(startPosition)] = true; - queue.push(startPosition); + // Then look for a voice pack file based on the selected translation + LoadLanguageArchive(); - const auto hasReachedDestination = [&]() -> bool { - return visited[indexOf(destinationPosition)]; - }; + ApplicationInit(); + LuaInitialize(); + if (!demo::IsRunning()) SaveOptions(); - while (!queue.empty() && !hasReachedDestination()) { - const Point current = queue.front(); - queue.pop(); + // Finally load game data + LoadGameArchives(); - 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; + LoadTextData(); - std::array prioritizedDirs; - size_t prioritizedCount = 0; + // Load dynamic data before we go into the menu as we need to initialise player characters in memory pretty early. + LoadPlayerDataFiles(); - 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; - }; + // TODO: We can probably load this much later (when the game is starting). + LoadSpellData(); + LoadMissileData(); + LoadMonsterData(); + LoadItemData(); + LoadObjectData(); + LoadQuestData(); - 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); + DiabloInit(); +#ifdef __UWP__ + onInitialized(); +#endif + if (!demo::IsRunning()) SaveOptions(); - 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); - } + DiabloSplash(); + mainmenu_loop(); + DiabloDeinit(); - if (deltaAbsX >= deltaAbsY) { - addUniqueDir(xDir); - addUniqueDir(yDir); - } else { - addUniqueDir(yDir); - addUniqueDir(xDir); - } - for (const int8_t dir : walkDirections) { - addUniqueDir(dir); - } + return 0; +} - for (size_t i = 0; i < prioritizedCount; ++i) { - enqueue(current, prioritizedDirs[i]); +bool TryIconCurs() +{ + if (pcurs == CURSOR_RESURRECT) { + if (PlayerUnderCursor != nullptr) { + NetSendCmdParam1(true, CMD_RESURRECT, PlayerUnderCursor->getId()); + NewCursor(CURSOR_HAND); + return true; } - } - if (!hasReachedDestination()) - return std::nullopt; + return false; + } - std::vector path; - Point position = destinationPosition; - while (position != startPosition) { - const int8_t dir = parentDir[indexOf(position)]; - if (dir == WALK_NONE) - return std::nullopt; + if (pcurs == CURSOR_HEALOTHER) { + if (PlayerUnderCursor != nullptr) { + NetSendCmdParam1(true, CMD_HEALOTHER, PlayerUnderCursor->getId()); + NewCursor(CURSOR_HAND); + return true; + } - path.push_back(dir); - position = NextPositionForWalkDirection(position, OppositeWalkDirection(dir)); + return false; } - 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, - }; + if (pcurs == CURSOR_TELEKINESIS) { + DoTelekinesis(); + return true; + } - constexpr std::array AllDirections = { - WALK_NE, - WALK_SW, - WALK_SE, - WALK_NW, - WALK_N, - WALK_E, - WALK_S, - WALK_W, - }; + Player &myPlayer = *MyPlayer; - if (const std::optional> axisPath = FindKeyboardWalkPathForSpeechBfs(player, startPosition, destinationPosition, posOk, AxisDirections, /*allowDiagonalSteps=*/false, allowDestinationNonWalkable); axisPath) { - return axisPath; + if (pcurs == CURSOR_IDENTIFY) { + if (pcursinvitem != -1 && !IsInspectingPlayer()) + CheckIdentify(myPlayer, pcursinvitem); + else if (pcursstashitem != StashStruct::EmptyCell) { + Item &item = Stash.stashList[pcursstashitem]; + item._iIdentified = true; + } + NewCursor(CURSOR_HAND); + return true; + } + + if (pcurs == CURSOR_REPAIR) { + if (pcursinvitem != -1 && !IsInspectingPlayer()) + DoRepair(myPlayer, pcursinvitem); + else if (pcursstashitem != StashStruct::EmptyCell) { + Item &item = Stash.stashList[pcursstashitem]; + RepairItem(item, myPlayer.getCharacterLevel()); + } + NewCursor(CURSOR_HAND); + return true; + } + + if (pcurs == CURSOR_RECHARGE) { + if (pcursinvitem != -1 && !IsInspectingPlayer()) + DoRecharge(myPlayer, pcursinvitem); + else if (pcursstashitem != StashStruct::EmptyCell) { + Item &item = Stash.stashList[pcursstashitem]; + RechargeItem(item, myPlayer); + } + NewCursor(CURSOR_HAND); + return true; + } + + if (pcurs == CURSOR_OIL) { + bool changeCursor = true; + if (pcursinvitem != -1 && !IsInspectingPlayer()) + changeCursor = DoOil(myPlayer, pcursinvitem); + else if (pcursstashitem != StashStruct::EmptyCell) { + Item &item = Stash.stashList[pcursstashitem]; + changeCursor = ApplyOilToItem(item, myPlayer); + } + if (changeCursor) + NewCursor(CURSOR_HAND); + return true; + } + + if (pcurs == CURSOR_TELEPORT) { + const SpellID spellID = myPlayer.inventorySpell; + const SpellType spellType = SpellType::Scroll; + const int spellFrom = myPlayer.spellFrom; + if (IsWallSpell(spellID)) { + const Direction sd = GetDirection(myPlayer.position.tile, cursPosition); + NetSendCmdLocParam4(true, CMD_SPELLXYD, cursPosition, static_cast(spellID), static_cast(spellType), static_cast(sd), spellFrom); + } else if (pcursmonst != -1 && leveltype != DTYPE_TOWN) { + NetSendCmdParam4(true, CMD_SPELLID, pcursmonst, static_cast(spellID), static_cast(spellType), spellFrom); + } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { + NetSendCmdParam4(true, CMD_SPELLPID, PlayerUnderCursor->getId(), static_cast(spellID), static_cast(spellType), spellFrom); + } else { + NetSendCmdLocParam3(true, CMD_SPELLXY, cursPosition, static_cast(spellID), static_cast(spellType), spellFrom); + } + NewCursor(CURSOR_HAND); + return true; } - return FindKeyboardWalkPathForSpeechBfs(player, startPosition, destinationPosition, posOk, AllDirections, /*allowDiagonalSteps=*/true, allowDestinationNonWalkable); -} - -} // namespace + if (pcurs == CURSOR_DISARM && ObjectUnderCursor == nullptr) { + NewCursor(CURSOR_HAND); + return true; + } -std::optional> FindKeyboardWalkPathForSpeech(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) -{ - return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, allowDestinationNonWalkable); + return false; } -std::optional> FindKeyboardWalkPathForSpeechRespectingDoors(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +void diablo_pause_game() { - return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayer, allowDestinationNonWalkable); -} + if (!gbIsMultiplayer) { + if (PauseMode != 0) { + PauseMode = 0; + } else { + PauseMode = 2; + sound_stop(); + qtextflag = false; + LastPlayerAction = PlayerActionType::None; + } -std::optional> FindKeyboardWalkPathForSpeechIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) -{ - return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoorsAndMonsters, allowDestinationNonWalkable); + RedrawEverything(); + } } -std::optional> FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) -{ - return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreMonsters, allowDestinationNonWalkable); -} +bool GameWasAlreadyPaused = false; +bool MinimizePaused = false; -std::optional> FindKeyboardWalkPathForSpeechLenient(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +bool diablo_is_focused() { - return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoorsMonstersAndBreakables, allowDestinationNonWalkable); +#ifndef USE_SDL1 + return SDL_GetKeyboardFocus() == ghMainWnd; +#else + Uint8 appState = SDL_GetAppState(); + return (appState & SDL_APPINPUTFOCUS) != 0; +#endif } -namespace { - -template -std::optional> FindKeyboardWalkPathToClosestReachableForSpeechBfs(const Player &player, Point startPosition, Point destinationPosition, PosOkForSpeechFn posOk, const std::array &walkDirections, bool allowDiagonalSteps, Point &closestPosition) +void diablo_focus_pause() { - if (!InDungeonBounds(startPosition) || !InDungeonBounds(destinationPosition)) - return std::nullopt; - - if (startPosition == destinationPosition) { - closestPosition = destinationPosition; - return std::vector {}; + if (!movie_playing && (gbIsMultiplayer || MinimizePaused)) { + return; } - 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(); + GameWasAlreadyPaused = PauseMode != 0; - considerBest(current); + if (!GameWasAlreadyPaused) { + PauseMode = 2; + sound_stop(); + LastPlayerAction = PlayerActionType::None; + } - 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; + SVidMute(); + music_mute(); - std::array prioritizedDirs; - size_t prioritizedCount = 0; + MinimizePaused = true; +} - 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; - }; +void diablo_focus_unpause() +{ + if (!GameWasAlreadyPaused) { + PauseMode = 0; + } - 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); + SVidUnmute(); + music_unmute(); - 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); - } + MinimizePaused = false; +} - if (deltaAbsX >= deltaAbsY) { - addUniqueDir(xDir); - addUniqueDir(yDir); - } else { - addUniqueDir(yDir); - addUniqueDir(xDir); - } - for (const int8_t dir : walkDirections) { - addUniqueDir(dir); - } +bool PressEscKey() +{ + bool rv = false; - for (size_t i = 0; i < prioritizedCount; ++i) { - enqueue(current, prioritizedDirs[i]); - } + if (DoomFlag) { + doom_close(); + rv = true; } - 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)); + if (HelpFlag) { + HelpFlag = false; + rv = true; } - std::reverse(path.begin(), path.end()); - return path; -} + if (ChatLogFlag) { + ChatLogFlag = false; + rv = true; + } -} // namespace + if (qtextflag) { + qtextflag = false; + stream_stop(); + rv = true; + } -std::optional> FindKeyboardWalkPathToClosestReachableForSpeech(const Player &player, Point startPosition, Point destinationPosition, Point &closestPosition) -{ - constexpr std::array AxisDirections = { - WALK_NE, - WALK_SW, - WALK_SE, - WALK_NW, - }; + if (IsPlayerInStore()) { + StoreESC(); + rv = true; + } - constexpr std::array AllDirections = { - WALK_NE, - WALK_SW, - WALK_SE, - WALK_NW, - WALK_N, - WALK_E, - WALK_S, - WALK_W, - }; + if (IsDiabloMsgAvailable()) { + CancelCurrentDiabloMsg(); + rv = true; + } - Point axisClosest; - const std::optional> axisPath = FindKeyboardWalkPathToClosestReachableForSpeechBfs(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, AxisDirections, /*allowDiagonalSteps=*/false, axisClosest); + if (ChatFlag) { + ResetChat(); + rv = true; + } - Point diagClosest; - const std::optional> diagPath = FindKeyboardWalkPathToClosestReachableForSpeechBfs(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, AllDirections, /*allowDiagonalSteps=*/true, diagClosest); + if (DropGoldFlag) { + control_drop_gold(SDLK_ESCAPE); + rv = true; + } - if (!axisPath && !diagPath) - return std::nullopt; - if (!axisPath) { - closestPosition = diagClosest; - return diagPath; + if (IsWithdrawGoldOpen) { + WithdrawGoldKeyPress(SDLK_ESCAPE); + rv = true; } - if (!diagPath) { - closestPosition = axisClosest; - return axisPath; + + if (SpellSelectFlag) { + SpellSelectFlag = false; + rv = true; } - const int axisDistance = axisClosest.WalkingDistance(destinationPosition); - const int diagDistance = diagClosest.WalkingDistance(destinationPosition); - if (diagDistance < axisDistance) { - closestPosition = diagClosest; - return diagPath; + if (IsLeftPanelOpen() || IsRightPanelOpen()) { + ClosePanels(); + rv = true; } - closestPosition = axisClosest; - return axisPath; + return rv; } -void AppendKeyboardWalkPathForSpeech(std::string &message, const std::vector &path) +void DisableInputEventHandler(const SDL_Event &event, uint16_t modState) { - if (path.empty()) { - message.append(_("here")); + switch (event.type) { + case SDL_EVENT_MOUSE_MOTION: + MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; return; - } - - bool any = false; - const auto appendPart = [&](std::string_view label, int distance) { - if (distance == 0) + case SDL_EVENT_MOUSE_BUTTON_DOWN: + if (sgbMouseDown != CLICK_NONE) + return; + switch (event.button.button) { + case SDL_BUTTON_LEFT: + sgbMouseDown = CLICK_LEFT; + return; + case SDL_BUTTON_RIGHT: + sgbMouseDown = CLICK_RIGHT; return; - 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 {}; + return; } - }; + case SDL_EVENT_MOUSE_BUTTON_UP: + sgbMouseDown = CLICK_NONE; + return; + } - int8_t currentDir = path.front(); - int runLength = 1; - for (size_t i = 1; i < path.size(); ++i) { - if (path[i] == currentDir) { - ++runLength; - continue; - } + MainWndProc(event); +} + +void LoadGameLevelStopMusic(_music_id neededTrack) +{ + if (neededTrack != sgnMusicTrack) + music_stop(); +} - const std::string_view label = labelForWalkDirection(currentDir); - if (!label.empty()) - appendPart(label, runLength); +void LoadGameLevelStartMusic(_music_id neededTrack) +{ + if (sgnMusicTrack != neededTrack) + music_start(neededTrack); - currentDir = path[i]; - runLength = 1; + if (MinimizePaused) { + music_mute(); } +} - const std::string_view label = labelForWalkDirection(currentDir); - if (!label.empty()) - appendPart(label, runLength); +void LoadGameLevelResetCursor() +{ + if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { + NewCursor(CURSOR_HAND); + } +} - if (!any) - message.append(_("here")); +void SetRndSeedForDungeonLevel() +{ + if (setlevel) { + // Maps are not randomly generated, but the monsters max hitpoints are. + // So we need to ensure that we have a stable seed when generating quest/set-maps. + // For this purpose we reuse the normal dungeon seeds. + SetRndSeed(DungeonSeeds[static_cast(setlvlnum)]); + } else { + SetRndSeed(DungeonSeeds[currlevel]); + } } -void AppendDirectionalFallback(std::string &message, const Displacement &delta) +void LoadGameLevelFirstFlagEntry() { - 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; - }; + CloseInventory(); + qtextflag = false; + if (!HeadlessMode) { + InitInv(); + ClearUniqueItemFlags(); + InitQuestText(); + InitInfoBoxGfx(); + InitHelp(); + } + InitStores(); + InitAutomapOnce(); +} - if (delta.deltaY < 0) - appendPart(_("north"), -delta.deltaY); - else if (delta.deltaY > 0) - appendPart(_("south"), delta.deltaY); +void LoadGameLevelStores() +{ + if (leveltype == DTYPE_TOWN) { + SetupTownStores(); + } else { + FreeStoreMem(); + } +} - if (delta.deltaX > 0) - appendPart(_("east"), delta.deltaX); - else if (delta.deltaX < 0) - appendPart(_("west"), -delta.deltaX); +void LoadGameLevelStash() +{ + const bool isHellfireSaveGame = gbIsHellfireSaveGame; - if (!any) - message.append(_("here")); + gbIsHellfireSaveGame = gbIsHellfire; + LoadStash(); + gbIsHellfireSaveGame = isHellfireSaveGame; } -std::optional FindNearestUnexploredTile(Point startPosition) +tl::expected LoadGameLevelDungeon(bool firstflag, lvl_entry lvldir, const Player &myPlayer) { - if (!InDungeonBounds(startPosition)) - return std::nullopt; + if (firstflag || lvldir == ENTRY_LOAD || !myPlayer._pLvlVisited[currlevel] || gbIsMultiplayer) { + HoldThemeRooms(); + [[maybe_unused]] const uint32_t mid1Seed = GetLCGEngineState(); + InitGolems(); + InitObjects(); + [[maybe_unused]] const uint32_t mid2Seed = GetLCGEngineState(); - std::array visited {}; - std::queue queue; - - const auto enqueue = [&](Point position) { - if (!InDungeonBounds(position)) - return; + IncProgress(); - const size_t index = static_cast(position.x) + static_cast(position.y) * MAXDUNX; - if (visited[index]) - return; + RETURN_IF_ERROR(InitMonsters()); + InitItems(); + CreateThemeRooms(); - if (!IsTileWalkable(position, /*ignoreDoors=*/true)) - return; + IncProgress(); - visited[index] = true; - queue.push(position); - }; + [[maybe_unused]] const uint32_t mid3Seed = GetLCGEngineState(); + InitMissiles(); + InitCorpses(); +#ifdef _DEBUG + SetDebugLevelSeedInfos(mid1Seed, mid2Seed, mid3Seed, GetLCGEngineState()); +#endif + SavePreLighting(); - enqueue(startPosition); + IncProgress(); - constexpr std::array Neighbors = { - Direction::NorthEast, - Direction::SouthWest, - Direction::SouthEast, - Direction::NorthWest, - }; + if (gbIsMultiplayer) + DeltaLoadLevel(); + } else { + HoldThemeRooms(); + InitGolems(); + RETURN_IF_ERROR(InitMonsters()); + InitMissiles(); + InitCorpses(); - while (!queue.empty()) { - const Point position = queue.front(); - queue.pop(); + IncProgress(); - if (!HasAnyOf(dFlags[position.x][position.y], DungeonFlag::Explored)) - return position; + RETURN_IF_ERROR(LoadLevel()); - for (const Direction dir : Neighbors) { - enqueue(position + dir); - } + IncProgress(); } - - return std::nullopt; + return {}; } -std::string TriggerLabelForSpeech(const TriggerStruct &trigger) +void LoadGameLevelSyncPlayerEntry(lvl_entry lvldir) { - 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); + for (Player &player : Players) { + if (player.plractive && player.isOnActiveLevel() && (!player._pLvlChanging || &player == MyPlayer)) { + if (player._pHitPoints > 0) { + if (lvldir != ENTRY_LOAD) + SyncInitPlrPos(player); + } else { + dFlags[player.position.tile.x][player.position.tile.y] |= DungeonFlag::DeadPlayer; + } } - 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 FindPreferredExitTriggerIndex() +void LoadGameLevelLightVision() { - 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; + if (leveltype != DTYPE_TOWN) { + memcpy(dLight, dPreLight, sizeof(dLight)); // resets the light on entering a level to get rid of incorrect light + ChangeLightXY(Players[MyPlayerId].lightId, Players[MyPlayerId].position.tile); // forces player light refresh + ProcessLightList(); + ProcessVisionList(); } +} - const Point playerPosition = MyPlayer->position.future; - std::optional bestIndex; - int bestDistance = 0; +void LoadGameLevelReturn() +{ + ViewPosition = GetMapReturnPosition(); + if (Quests[Q_BETRAYER]._qactive == QUEST_DONE) + Quests[Q_BETRAYER]._qvar2 = 2; +} - 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; +void LoadGameLevelInitPlayers(bool firstflag, lvl_entry lvldir) +{ + for (Player &player : Players) { + if (player.plractive && player.isOnActiveLevel()) { + InitPlayerGFX(player); + if (lvldir != ENTRY_LOAD) + InitPlayer(player, firstflag); } } - - return bestIndex; } -std::optional FindNearestTriggerIndexWithMessage(int message) +void LoadGameLevelSetVisited() { - 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; - } + bool visited = false; + for (const Player &player : Players) { + if (player.plractive) + visited = visited || player._pLvlVisited[currlevel]; } - - return bestIndex; } -std::optional FindNearestTownPortalOnCurrentLevel() +tl::expected LoadGameLevelTown(bool firstflag, lvl_entry lvldir, const Player &myPlayer) { - if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) - return std::nullopt; + for (int i = 0; i < MAXDUNX; i++) { // NOLINT(modernize-loop-convert) + for (int j = 0; j < MAXDUNY; j++) { + dFlags[i][j] |= DungeonFlag::Lit; + } + } - const Point playerPosition = MyPlayer->position.future; - const int currentLevel = setlevel ? static_cast(setlvlnum) : currlevel; + InitTowners(); + InitStash(); + InitItems(); + InitMissiles(); - std::optional bestPosition; - int bestDistance = 0; + IncProgress(); - 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; + if (!firstflag && lvldir != ENTRY_LOAD && myPlayer._pLvlVisited[currlevel] && !gbIsMultiplayer) + RETURN_IF_ERROR(LoadLevel()); + if (gbIsMultiplayer) + DeltaLoadLevel(); - const int distance = playerPosition.WalkingDistance(portal.position); - if (!bestPosition || distance < bestDistance) { - bestPosition = portal.position; - bestDistance = distance; - } - } + IncProgress(); - return bestPosition; + for (int x = 0; x < DMAXX; x++) + for (int y = 0; y < DMAXY; y++) + UpdateAutomapExplorer({ x, y }, MAP_EXP_SELF); + return {}; } -struct QuestSetLevelEntrance { - _setlevels questLevel; - Point entrancePosition; - int distance; -}; - -std::optional FindNearestQuestSetLevelEntranceOnCurrentLevel() +tl::expected LoadGameLevelSetLevel(bool firstflag, lvl_entry lvldir, const Player &myPlayer) { - if (MyPlayer == nullptr || setlevel) - return std::nullopt; - - const Point playerPosition = MyPlayer->position.future; - std::optional best; - int bestDistance = 0; + LoadSetMap(); + IncProgress(); + RETURN_IF_ERROR(GetLevelMTypes()); + IncProgress(); + InitGolems(); + RETURN_IF_ERROR(InitMonsters()); + IncProgress(); + if (!HeadlessMode) { +#if !defined(USE_SDL1) && !defined(__vita__) + InitVirtualGamepadGFX(); +#endif + RETURN_IF_ERROR(InitMissileGFX()); + IncProgress(); + } + InitCorpses(); + IncProgress(); - 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; + if (lvldir == ENTRY_WARPLVL) + GetPortalLvlPos(); + IncProgress(); - const int distance = playerPosition.WalkingDistance(quest.position); - if (!best || distance < bestDistance) { - best = QuestSetLevelEntrance { - .questLevel = quest._qslvl, - .entrancePosition = quest.position, - .distance = distance, - }; - bestDistance = distance; + for (Player &player : Players) { + if (player.plractive && player.isOnActiveLevel()) { + InitPlayerGFX(player); + if (lvldir != ENTRY_LOAD) + InitPlayer(player, firstflag); } } + IncProgress(); + InitMultiView(); + IncProgress(); + + if (firstflag || lvldir == ENTRY_LOAD || !myPlayer._pSLvlVisited[setlvlnum] || gbIsMultiplayer) { + InitItems(); + SavePreLighting(); + } else { + RETURN_IF_ERROR(LoadLevel()); + } + if (gbIsMultiplayer) { + DeltaLoadLevel(); + if (!UseMultiplayerQuests()) + ResyncQuests(); + } - return best; + PlayDungMsgs(); + InitMissiles(); + IncProgress(); + return {}; } -void SpeakNearestExitKeyPressed() +tl::expected LoadGameLevelStandardLevel(bool firstflag, lvl_entry lvldir, const Player &myPlayer) { - if (!CanPlayerTakeAction()) - return; - if (AutomapActive) { - SpeakText(_("Close the map first."), true); - return; - } - if (MyPlayer == nullptr) - return; + CreateLevel(lvldir); - const Point startPosition = MyPlayer->position.future; + IncProgress(); - const SDL_Keymod modState = SDL_GetModState(); - const bool seekQuestEntrance = (modState & SDL_KMOD_SHIFT) != 0; + SetRndSeedForDungeonLevel(); - if (seekQuestEntrance) { - 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; - } + if (leveltype != DTYPE_TOWN) { + RETURN_IF_ERROR(GetLevelMTypes()); + InitThemes(); + if (!HeadlessMode) + RETURN_IF_ERROR(LoadAllGFX()); + } else if (!HeadlessMode) { + IncProgress(); - SpeakText(_("No quest entrances found."), true); - return; - } +#if !defined(USE_SDL1) && !defined(__vita__) + InitVirtualGamepadGFX(); +#endif - 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; - } + IncProgress(); - const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(WM_DIABPREVLVL); - if (!triggerIndex) { - SpeakText(_("No exits found."), true); - return; - } + RETURN_IF_ERROR(InitMissileGFX()); - 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; + IncProgress(); + IncProgress(); } - const std::optional triggerIndex = FindPreferredExitTriggerIndex(); - if (!triggerIndex) { - SpeakText(_("No exits found."), true); - return; + IncProgress(); + + if (lvldir == ENTRY_RTNLVL) { + LoadGameLevelReturn(); } - const TriggerStruct &trigger = trigs[*triggerIndex]; - const Point targetPosition { trigger.position.x, trigger.position.y }; + if (lvldir == ENTRY_WARPLVL) + GetPortalLvlPos(); - 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); + IncProgress(); - SpeakText(message, true); -} + LoadGameLevelInitPlayers(firstflag, lvldir); + InitMultiView(); -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; + IncProgress(); - const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(triggerMessage); - if (!triggerIndex) { - SpeakText(_("No exits found."), true); - return; - } + LoadGameLevelSetVisited(); - const TriggerStruct &trigger = trigs[*triggerIndex]; - const Point startPosition = MyPlayer->position.future; - const Point targetPosition { trigger.position.x, trigger.position.y }; + SetRndSeedForDungeonLevel(); - std::string message; - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); - if (!path) { - AppendDirectionalFallback(message, targetPosition - startPosition); + if (leveltype == DTYPE_TOWN) { + LoadGameLevelTown(firstflag, lvldir, myPlayer); } else { - AppendKeyboardWalkPathForSpeech(message, *path); + LoadGameLevelDungeon(firstflag, lvldir, myPlayer); } - SpeakText(message, true); -} + PlayDungMsgs(); -void SpeakNearestStairsDownKeyPressed() -{ - SpeakNearestStairsKeyPressed(WM_DIABNEXTLVL); + if (UseMultiplayerQuests()) + ResyncMPQuests(); + else + ResyncQuests(); + return {}; } -void SpeakNearestStairsUpKeyPressed() +void LoadGameLevelCrypt() { - SpeakNearestStairsKeyPressed(WM_DIABPREVLVL); + if (CornerStone.isAvailable()) { + CornerstoneLoad(CornerStone.position); + } + if (Quests[Q_NAKRUL]._qactive == QUEST_DONE && currlevel == 24) { + SyncNakrulRoom(); + } } -bool IsKeyboardWalkAllowed() +void LoadGameLevelCalculateCursor() { - return CanPlayerTakeAction() - && !InGameMenu() - && !IsPlayerInStore() - && !QuestLogIsOpen - && !HelpFlag - && !ChatLogFlag - && !ChatFlag - && !DropGoldFlag - && !IsStashOpen - && !IsWithdrawGoldOpen - && !AutomapActive - && !invflag - && !CharFlag - && !SpellbookFlag - && !SpellSelectFlag - && !qtextflag; + // Recalculate mouse selection of entities after level change/load + LastPlayerAction = PlayerActionType::None; + sgbMouseDown = CLICK_NONE; + ResetItemlabelHighlighted(); // level changed => item changed + pcursmonst = -1; // ensure pcurstemp is set to a valid value + CheckCursMove(); } -void KeyboardWalkKeyPressed(Direction direction) +tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir) { - if (!IsKeyboardWalkAllowed()) - return; + const _music_id neededTrack = GetLevelMusic(leveltype); - if (MyPlayer == nullptr) - return; + ClearFloatingNumbers(); + LoadGameLevelStopMusic(neededTrack); + LoadGameLevelResetCursor(); + SetRndSeedForDungeonLevel(); + NaKrulTomeSequence = 0; - NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, MyPlayer->position.future + direction); -} + IncProgress(); -void KeyboardWalkNorthKeyPressed() -{ - KeyboardWalkKeyPressed(Direction::NorthEast); -} + RETURN_IF_ERROR(LoadTrns()); + MakeLightTable(); + RETURN_IF_ERROR(LoadLevelSOLData()); -void KeyboardWalkSouthKeyPressed() -{ - KeyboardWalkKeyPressed(Direction::SouthWest); -} + IncProgress(); -void KeyboardWalkEastKeyPressed() -{ - KeyboardWalkKeyPressed(Direction::SouthEast); -} + RETURN_IF_ERROR(LoadLvlGFX()); + SetDungeonMicros(pDungeonCels, MicroTileLen); + ClearClxDrawCache(); -void KeyboardWalkWestKeyPressed() -{ - KeyboardWalkKeyPressed(Direction::NorthWest); -} + IncProgress(); -void SpeakNearestUnexploredTileKeyPressed() -{ - if (!CanPlayerTakeAction()) - return; - if (leveltype == DTYPE_TOWN) { - SpeakText(_("Not in a dungeon."), true); - return; + if (firstflag) { + LoadGameLevelFirstFlagEntry(); } - 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; + SetRndSeedForDungeonLevel(); + + LoadGameLevelStores(); + + if (firstflag || lvldir == ENTRY_LOAD) { + LoadGameLevelStash(); } - const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, *target); - std::string message; - if (!path) - AppendDirectionalFallback(message, *target - startPosition); - else - AppendKeyboardWalkPathForSpeech(message, *path); - SpeakText(message, true); -} + IncProgress(); -void SpeakPlayerHealthPercentageKeyPressed() -{ - if (!CanPlayerTakeAction()) - return; - if (MyPlayer == nullptr) - return; + InitAutomap(); - const int maxHp = MyPlayer->_pMaxHP; - if (maxHp <= 0) - return; + if (leveltype != DTYPE_TOWN && lvldir != ENTRY_LOAD) { + InitLighting(); + } - 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); -} + InitLevelMonsters(); -void SpeakExperienceToNextLevelKeyPressed() -{ - if (!CanPlayerTakeAction()) - return; - if (MyPlayer == nullptr) - return; + IncProgress(); const Player &myPlayer = *MyPlayer; - if (myPlayer.isMaxCharacterLevel()) { - SpeakText(_("Max level."), /*force=*/true); - return; + + if (setlevel) { + RETURN_IF_ERROR(LoadGameLevelSetLevel(firstflag, lvldir, myPlayer)); + } else { + RETURN_IF_ERROR(LoadGameLevelStandardLevel(firstflag, lvldir, myPlayer)); } - 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); -} + SyncPortals(); + LoadGameLevelSyncPlayerEntry(lvldir); -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") }; + IncProgress(); + IncProgress(); - return fmt::format("{:s}: {:s}", _("Set level"), _(questLevelName)); + if (firstflag) { + RETURN_IF_ERROR(InitMainPanel()); } - // 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"); + IncProgress(); + + UpdateMonsterLights(); + UnstuckChargers(); + + LoadGameLevelLightVision(); + + if (leveltype == DTYPE_CRYPT) { + LoadGameLevelCrypt(); } - 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 +#ifndef USE_SDL1 + ActivateVirtualGamepad(); +#endif + LoadGameLevelStartMusic(neededTrack); -void SpeakCurrentLocationKeyPressed() -{ - if (!CanPlayerTakeAction()) - return; + CompleteProgress(); - SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); + LoadGameLevelCalculateCursor(); + if (leveltype != DTYPE_TOWN) + SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); + return {}; } -void InventoryKeyPressed() +bool game_loop(bool bStartup) { - if (IsPlayerInStore()) - return; - invflag = !invflag; - if (!IsLeftPanelOpen() && CanPanelsCoverView()) { - if (!invflag) { // We closed the inventory - if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); - } - } else if (!SpellbookFlag) { // We opened the inventory - if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition - Displacement { 160, 0 }); - } - } - } - SpellbookFlag = false; - CloseGoldWithdraw(); - CloseStash(); - if (invflag) - FocusOnInventory(); -} - -void CharacterSheetKeyPressed() -{ - if (IsPlayerInStore()) - return; - if (!IsRightPanelOpen() && CanPanelsCoverView()) { - if (CharFlag) { // We are closing the character sheet - if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition - Displacement { 160, 0 }); - } - } else if (!QuestLogIsOpen) { // We opened the character sheet - if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); - } - } - } - ToggleCharPanel(); -} - -void PartyPanelSideToggleKeyPressed() -{ - PartySidePanelOpen = !PartySidePanelOpen; -} - -void QuestLogKeyPressed() -{ - if (IsPlayerInStore()) - return; - if (!QuestLogIsOpen) { - StartQuestlog(); - } else { - QuestLogIsOpen = false; - } - if (!IsRightPanelOpen() && CanPanelsCoverView()) { - if (!QuestLogIsOpen) { // We closed the quest log - if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition - Displacement { 160, 0 }); - } - } else if (!CharFlag) { // We opened the character quest log - if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); - } - } - } - CloseCharPanel(); - CloseGoldWithdraw(); - CloseStash(); -} + const uint16_t wait = bStartup ? sgGameInitInfo.nTickRate * 3 : 3; -void SpeakSelectedSpeedbookSpell() -{ - for (const auto &spellListItem : GetSpellListItems()) { - if (spellListItem.isSelected) { - SpeakText(pgettext("spell", GetSpellData(spellListItem.id).sNameText), /*force=*/true); - return; + for (unsigned i = 0; i < wait; i++) { + if (!multi_handle_delta()) { + TimeoutCursor(true); + return false; } + TimeoutCursor(false); + GameLogic(); + ClearLastSentPlayerCmd(); + + if (!gbRunGame || !gbIsMultiplayer || demo::IsRunning() || demo::IsRecording() || !nthread_has_500ms_passed()) + break; } - SpeakText(_("No spell selected."), /*force=*/true); + return true; } -void DisplaySpellsKeyPressed() +void diablo_color_cyc_logic() { - if (IsPlayerInStore()) + if (!*GetOptions().Graphics.colorCycling) return; - CloseCharPanel(); - QuestLogIsOpen = false; - CloseInventory(); - SpellbookFlag = false; - if (!SpellSelectFlag) { - DoSpeedBook(); - SpeakSelectedSpeedbookSpell(); - } else { - SpellSelectFlag = false; - } - LastPlayerAction = PlayerActionType::None; -} - -void SpellBookKeyPressed() -{ - if (IsPlayerInStore()) + + if (PauseMode != 0) return; - SpellbookFlag = !SpellbookFlag; - if (SpellbookFlag && MyPlayer != nullptr) { - const Player &player = *MyPlayer; - if (IsValidSpell(player._pRSpell)) { - SpeakText(pgettext("spell", GetSpellData(player._pRSpell).sNameText), /*force=*/true); + + if (leveltype == DTYPE_CAVES) { + if (setlevel && setlvlnum == Quests[Q_PWATER]._qslvl) { + UpdatePWaterPalette(); } else { - SpeakText(_("No spell selected."), /*force=*/true); + palette_update_caves(); } + } else if (leveltype == DTYPE_HELL) { + lighting_color_cycling(); + } else if (leveltype == DTYPE_NEST) { + palette_update_hive(); + } else if (leveltype == DTYPE_CRYPT) { + palette_update_crypt(); } - if (!IsLeftPanelOpen() && CanPanelsCoverView()) { - if (!SpellbookFlag) { // We closed the inventory - if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); - } - } else if (!invflag) { // We opened the inventory - if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition - Displacement { 160, 0 }); - } - } - } - CloseInventory(); -} - -void CycleSpellHotkeys(bool next) -{ - StaticVector validHotKeyIndexes; - std::optional currentIndex; - for (size_t slot = 0; slot < NumHotkeys; slot++) { - if (!IsValidSpeedSpell(slot)) - continue; - if (MyPlayer->_pRSpell == MyPlayer->_pSplHotKey[slot] && MyPlayer->_pRSplType == MyPlayer->_pSplTHotKey[slot]) { - // found current - currentIndex = validHotKeyIndexes.size(); - } - validHotKeyIndexes.emplace_back(slot); - } - if (validHotKeyIndexes.size() == 0) - return; - - size_t newIndex; - if (!currentIndex) { - newIndex = next ? 0 : (validHotKeyIndexes.size() - 1); - } else if (next) { - newIndex = (*currentIndex == validHotKeyIndexes.size() - 1) ? 0 : (*currentIndex + 1); - } else { - newIndex = *currentIndex == 0 ? (validHotKeyIndexes.size() - 1) : (*currentIndex - 1); - } - ToggleSpell(validHotKeyIndexes[newIndex]); -} - -bool IsPlayerDead() -{ - return MyPlayer->_pmode == PM_DEATH || MyPlayerIsDead; -} - -bool IsGameRunning() -{ - return PauseMode != 2; -} - -bool CanPlayerTakeAction() -{ - return !IsPlayerDead() && IsGameRunning(); -} - -bool CanAutomapBeToggledOff() -{ - // check if every window is closed - if yes, automap can be toggled off - if (!QuestLogIsOpen && !IsWithdrawGoldOpen && !IsStashOpen && !CharFlag - && !SpellbookFlag && !invflag && !isGameMenuOpen && !qtextflag && !SpellSelectFlag - && !ChatLogFlag && !HelpFlag) - return true; - - return false; -} - -void OptionLanguageCodeChanged() -{ - UnloadFonts(); - LanguageInitialize(); - LoadLanguageArchive(); - effects_cleanup_sfx(false); - if (gbRunGame) - sound_init(); - else - ui_sound_init(); -} - -const auto OptionChangeHandlerLanguage = (GetOptions().Language.code.SetValueChangedCallback(OptionLanguageCodeChanged), true); - -} // namespace - -void InitKeymapActions() -{ - Options &options = GetOptions(); - for (uint32_t i = 0; i < 8; ++i) { - options.Keymapper.AddAction( - "BeltItem{}", - N_("Belt item {}"), - N_("Use Belt item."), - '1' + i, - [i] { - const Player &myPlayer = *MyPlayer; - if (!myPlayer.SpdList[i].isEmpty() && myPlayer.SpdList[i]._itype != ItemType::Gold) { - UseInvItem(INVITEM_BELT_FIRST + i); - } - }, - nullptr, - CanPlayerTakeAction, - i + 1); - } - for (uint32_t i = 0; i < NumHotkeys; ++i) { - options.Keymapper.AddAction( - "QuickSpell{}", - N_("Quick spell {}"), - N_("Hotkey for skill or spell."), - i < 4 ? static_cast(SDLK_F5) + i : static_cast(SDLK_UNKNOWN), - [i]() { - if (SpellSelectFlag) { - SetSpeedSpell(i); - return; - } - if (!*GetOptions().Gameplay.quickCast) - ToggleSpell(i); - else - QuickCast(i); - }, - nullptr, - CanPlayerTakeAction, - i + 1); - } - options.Keymapper.AddAction( - "QuickSpellPrevious", - N_("Previous quick spell"), - N_("Selects the previous quick spell (cycles)."), - MouseScrollUpButton, - [] { CycleSpellHotkeys(false); }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "QuickSpellNext", - N_("Next quick spell"), - N_("Selects the next quick spell (cycles)."), - MouseScrollDownButton, - [] { CycleSpellHotkeys(true); }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "UseHealthPotion", - N_("Use health potion"), - N_("Use health potions from belt."), - SDLK_UNKNOWN, - [] { UseBeltItem(BeltItemType::Healing); }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "UseManaPotion", - N_("Use mana potion"), - N_("Use mana potions from belt."), - SDLK_UNKNOWN, - [] { UseBeltItem(BeltItemType::Mana); }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "DisplaySpells", - N_("Speedbook"), - N_("Open Speedbook."), - 'S', - DisplaySpellsKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "QuickSave", - N_("Quick save"), - N_("Saves the game."), - SDLK_F2, - [] { gamemenu_save_game(false); }, - nullptr, - [&]() { return !gbIsMultiplayer && CanPlayerTakeAction(); }); - options.Keymapper.AddAction( - "QuickLoad", - N_("Quick load"), - N_("Loads the game."), - SDLK_F3, - [] { gamemenu_load_game(false); }, - nullptr, - [&]() { return !gbIsMultiplayer && gbValidSaveFile && !IsPlayerInStore() && IsGameRunning(); }); -#ifndef NOEXIT - options.Keymapper.AddAction( - "QuitGame", - N_("Quit game"), - N_("Closes the game."), - SDLK_UNKNOWN, - [] { gamemenu_quit_game(false); }); -#endif - options.Keymapper.AddAction( - "StopHero", - N_("Stop hero"), - N_("Stops walking and cancel pending actions."), - SDLK_UNKNOWN, - [] { MyPlayer->Stop(); }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "ItemHighlighting", - N_("Item highlighting"), - N_("Show/hide items on ground."), - SDLK_LALT, - [] { HighlightKeyPressed(true); }, - [] { HighlightKeyPressed(false); }); - options.Keymapper.AddAction( - "ToggleItemHighlighting", - N_("Toggle item highlighting"), - N_("Permanent show/hide items on ground."), - SDLK_RCTRL, - nullptr, - [] { ToggleItemLabelHighlight(); }); - options.Keymapper.AddAction( - "ToggleAutomap", - N_("Toggle automap"), - N_("Toggles if automap is displayed."), - SDLK_TAB, - DoAutoMap, - nullptr, - IsGameRunning); - options.Keymapper.AddAction( - "CycleAutomapType", - N_("Cycle map type"), - N_("Opaque -> Transparent -> Minimap -> None"), - SDLK_M, - CycleAutomapType, - nullptr, - IsGameRunning); - - options.Keymapper.AddAction( - "ListTownNpcs", - N_("List town NPCs"), - N_("Speaks a list of town NPCs."), - SDLK_F4, - ListTownNpcsKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "PreviousTownNpc", - N_("Previous town NPC"), - N_("Select previous town NPC (speaks)."), - SDLK_PAGEUP, - SelectPreviousTownNpcKeyPressed, - nullptr, - IsTownNpcActionAllowed); - options.Keymapper.AddAction( - "NextTownNpc", - N_("Next town NPC"), - N_("Select next town NPC (speaks)."), - SDLK_PAGEDOWN, - SelectNextTownNpcKeyPressed, - nullptr, - IsTownNpcActionAllowed); - options.Keymapper.AddAction( - "SpeakSelectedTownNpc", - N_("Speak selected town NPC"), - N_("Speaks the currently selected town NPC."), - SDLK_END, - SpeakSelectedTownNpc, - nullptr, - IsTownNpcActionAllowed); - options.Keymapper.AddAction( - "GoToSelectedTownNpc", - N_("Go to selected town NPC"), - N_("Walks to the selected town NPC."), - SDLK_HOME, - GoToSelectedTownNpcKeyPressed, - nullptr, - IsTownNpcActionAllowed); - options.Keymapper.AddAction( - "SpeakNearestUnexploredSpace", - N_("Nearest unexplored space"), - N_("Speaks the nearest unexplored space."), - 'H', - SpeakNearestUnexploredTileKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpeakNearestExit", - N_("Nearest exit"), - N_("Speaks the nearest exit. Hold Shift for quest entrances."), - '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)."), - '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', - PartyPanelSideToggleKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "QuestLog", - N_("Quest log"), - N_("Open Quest log."), - 'Q', - QuestLogKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpellBook", - N_("Spellbook"), - N_("Open Spellbook."), - 'B', - SpellBookKeyPressed, - nullptr, - CanPlayerTakeAction); - for (uint32_t i = 0; i < QuickMessages.size(); ++i) { - options.Keymapper.AddAction( - "QuickMessage{}", - N_("Quick Message {}"), - N_("Use Quick Message in chat."), - (i < 4) ? static_cast(SDLK_F9) + i : static_cast(SDLK_UNKNOWN), - [i]() { DiabloHotkeyMsg(i); }, - nullptr, - nullptr, - i + 1); - } - options.Keymapper.AddAction( - "HideInfoScreens", - N_("Hide Info Screens"), - N_("Hide all info screens."), - SDLK_SPACE, - [] { - if (CanAutomapBeToggledOff()) - AutomapActive = false; - - ClosePanels(); - HelpFlag = false; - ChatLogFlag = false; - SpellSelectFlag = false; - if (qtextflag && leveltype == DTYPE_TOWN) { - qtextflag = false; - stream_stop(); - } - - CancelCurrentDiabloMsg(); - gamemenu_off(); - doom_close(); - }, - nullptr, - IsGameRunning); - options.Keymapper.AddAction( - "Zoom", - N_("Zoom"), - N_("Zoom Game Screen."), - SDLK_UNKNOWN, - [] { - GetOptions().Graphics.zoom.SetValue(!*GetOptions().Graphics.zoom); - CalcViewportGeometry(); - }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpeakPlayerHealthPercentage", - N_("Health percentage"), - N_("Speaks the player's health as a percentage."), - 'Z', - SpeakPlayerHealthPercentageKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpeakExperienceToNextLevel", - N_("Experience to level"), - N_("Speaks how much experience remains to reach the next level."), - 'X', - SpeakExperienceToNextLevelKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "PauseGame", - N_("Pause Game"), - N_("Pauses the game."), - 'P', - diablo_pause_game); - options.Keymapper.AddAction( - "PauseGameAlternate", - N_("Pause Game (Alternate)"), - N_("Pauses the game."), - SDLK_PAUSE, - diablo_pause_game); - options.Keymapper.AddAction( - "DecreaseBrightness", - N_("Decrease Brightness"), - N_("Reduce screen brightness."), - 'F', - DecreaseBrightness, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "IncreaseBrightness", - N_("Increase Brightness"), - N_("Increase screen brightness."), - 'G', - IncreaseBrightness, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "Help", - N_("Help"), - N_("Open Help Screen."), - SDLK_F1, - HelpKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "Screenshot", - N_("Screenshot"), - N_("Takes a screenshot."), - SDLK_PRINTSCREEN, - nullptr, - CaptureScreen); - options.Keymapper.AddAction( - "GameInfo", - N_("Game info"), - N_("Displays game infos."), - 'V', - [] { - EventPlrMsg(fmt::format( - fmt::runtime(_(/* TRANSLATORS: {:s} means: Project Name, Game Version. */ "{:s} {:s}")), - PROJECT_NAME, - PROJECT_VERSION), - UiFlags::ColorWhite); - }, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "ChatLog", - N_("Chat Log"), - N_("Displays chat log."), - SDLK_INSERT, - [] { - ToggleChatLog(); - }); - options.Keymapper.AddAction( - "SpeakCurrentLocation", - N_("Location"), - N_("Speaks the current dungeon and floor."), - 'L', - SpeakCurrentLocationKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SortInv", - N_("Sort Inventory"), - N_("Sorts the inventory."), - 'R', - [] { - ReorganizeInventory(*MyPlayer); - }); -#ifdef _DEBUG - options.Keymapper.AddAction( - "OpenConsole", - N_("Console"), - N_("Opens Lua console."), - SDLK_GRAVE, - OpenConsole); - options.Keymapper.AddAction( - "DebugToggle", - "Debug toggle", - "Programming is like magic.", - 'X', - [] { - DebugToggle = !DebugToggle; - }); -#endif - options.Keymapper.CommitActions(); -} - -void InitPadmapActions() -{ - Options &options = GetOptions(); - for (int i = 0; i < 8; ++i) { - options.Padmapper.AddAction( - "BeltItem{}", - N_("Belt item {}"), - N_("Use Belt item."), - ControllerButton_NONE, - [i] { - const Player &myPlayer = *MyPlayer; - if (!myPlayer.SpdList[i].isEmpty() && myPlayer.SpdList[i]._itype != ItemType::Gold) { - UseInvItem(INVITEM_BELT_FIRST + i); - } - }, - nullptr, - CanPlayerTakeAction, - i + 1); - } - for (uint32_t i = 0; i < NumHotkeys; ++i) { - options.Padmapper.AddAction( - "QuickSpell{}", - N_("Quick spell {}"), - N_("Hotkey for skill or spell."), - ControllerButton_NONE, - [i]() { - if (SpellSelectFlag) { - SetSpeedSpell(i); - return; - } - if (!*GetOptions().Gameplay.quickCast) - ToggleSpell(i); - else - QuickCast(i); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }, - i + 1); - } - options.Padmapper.AddAction( - "PrimaryAction", - N_("Primary action"), - N_("Attack monsters, talk to towners, lift and place inventory items."), - ControllerButton_BUTTON_B, - [] { - ControllerActionHeld = GameActionType_PRIMARY_ACTION; - LastPlayerAction = PlayerActionType::None; - PerformPrimaryAction(); - }, - [] { - ControllerActionHeld = GameActionType_NONE; - LastPlayerAction = PlayerActionType::None; - }, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "SecondaryAction", - N_("Secondary action"), - N_("Open chests, interact with doors, pick up items."), - ControllerButton_BUTTON_Y, - [] { - ControllerActionHeld = GameActionType_SECONDARY_ACTION; - LastPlayerAction = PlayerActionType::None; - PerformSecondaryAction(); - }, - [] { - ControllerActionHeld = GameActionType_NONE; - LastPlayerAction = PlayerActionType::None; - }, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "SpellAction", - N_("Spell action"), - N_("Cast the active spell."), - ControllerButton_BUTTON_X, - [] { - ControllerActionHeld = GameActionType_CAST_SPELL; - LastPlayerAction = PlayerActionType::None; - PerformSpellAction(); - }, - [] { - ControllerActionHeld = GameActionType_NONE; - LastPlayerAction = PlayerActionType::None; - }, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "CancelAction", - N_("Cancel action"), - N_("Close menus."), - ControllerButton_BUTTON_A, - [] { - if (DoomFlag) { - doom_close(); - return; - } - - GameAction action; - if (SpellSelectFlag) - action = GameAction(GameActionType_TOGGLE_QUICK_SPELL_MENU); - else if (invflag) - action = GameAction(GameActionType_TOGGLE_INVENTORY); - else if (SpellbookFlag) - action = GameAction(GameActionType_TOGGLE_SPELL_BOOK); - else if (QuestLogIsOpen) - action = GameAction(GameActionType_TOGGLE_QUEST_LOG); - else if (CharFlag) - action = GameAction(GameActionType_TOGGLE_CHARACTER_INFO); - ProcessGameAction(action); - }, - nullptr, - [] { return DoomFlag || SpellSelectFlag || invflag || SpellbookFlag || QuestLogIsOpen || CharFlag; }); - options.Padmapper.AddAction( - "MoveUp", - N_("Move up"), - N_("Moves the player character up."), - ControllerButton_BUTTON_DPAD_UP, - [] {}); - options.Padmapper.AddAction( - "MoveDown", - N_("Move down"), - N_("Moves the player character down."), - ControllerButton_BUTTON_DPAD_DOWN, - [] {}); - options.Padmapper.AddAction( - "MoveLeft", - N_("Move left"), - N_("Moves the player character left."), - ControllerButton_BUTTON_DPAD_LEFT, - [] {}); - options.Padmapper.AddAction( - "MoveRight", - N_("Move right"), - N_("Moves the player character right."), - ControllerButton_BUTTON_DPAD_RIGHT, - [] {}); - options.Padmapper.AddAction( - "StandGround", - N_("Stand ground"), - N_("Hold to prevent the player from moving."), - ControllerButton_NONE, - [] {}); - options.Padmapper.AddAction( - "ToggleStandGround", - N_("Toggle stand ground"), - N_("Toggle whether the player moves."), - ControllerButton_NONE, - [] { StandToggle = !StandToggle; }, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "UseHealthPotion", - N_("Use health potion"), - N_("Use health potions from belt."), - ControllerButton_BUTTON_LEFTSHOULDER, - [] { UseBeltItem(BeltItemType::Healing); }, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "UseManaPotion", - N_("Use mana potion"), - N_("Use mana potions from belt."), - ControllerButton_BUTTON_RIGHTSHOULDER, - [] { UseBeltItem(BeltItemType::Mana); }, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "Character", - N_("Character"), - N_("Open Character screen."), - ControllerButton_AXIS_TRIGGERLEFT, - [] { - ProcessGameAction(GameAction { GameActionType_TOGGLE_CHARACTER_INFO }); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "Inventory", - N_("Inventory"), - N_("Open Inventory screen."), - ControllerButton_AXIS_TRIGGERRIGHT, - [] { - ProcessGameAction(GameAction { GameActionType_TOGGLE_INVENTORY }); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "QuestLog", - N_("Quest log"), - N_("Open Quest log."), - { ControllerButton_BUTTON_BACK, ControllerButton_AXIS_TRIGGERLEFT }, - [] { - ProcessGameAction(GameAction { GameActionType_TOGGLE_QUEST_LOG }); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "SpellBook", - N_("Spellbook"), - N_("Open Spellbook."), - { ControllerButton_BUTTON_BACK, ControllerButton_AXIS_TRIGGERRIGHT }, - [] { - ProcessGameAction(GameAction { GameActionType_TOGGLE_SPELL_BOOK }); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "DisplaySpells", - N_("Speedbook"), - N_("Open Speedbook."), - ControllerButton_BUTTON_A, - [] { - ProcessGameAction(GameAction { GameActionType_TOGGLE_QUICK_SPELL_MENU }); - }, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Padmapper.AddAction( - "ToggleAutomap", - N_("Toggle automap"), - N_("Toggles if automap is displayed."), - ControllerButton_BUTTON_LEFTSTICK, - DoAutoMap); - options.Padmapper.AddAction( - "AutomapMoveUp", - N_("Automap Move Up"), - N_("Moves the automap up when active."), - ControllerButton_NONE, - [] {}); - options.Padmapper.AddAction( - "AutomapMoveDown", - N_("Automap Move Down"), - N_("Moves the automap down when active."), - ControllerButton_NONE, - [] {}); - options.Padmapper.AddAction( - "AutomapMoveLeft", - N_("Automap Move Left"), - N_("Moves the automap left when active."), - ControllerButton_NONE, - [] {}); - options.Padmapper.AddAction( - "AutomapMoveRight", - N_("Automap Move Right"), - N_("Moves the automap right when active."), - ControllerButton_NONE, - [] {}); - options.Padmapper.AddAction( - "MouseUp", - N_("Move mouse up"), - N_("Simulates upward mouse movement."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_UP }, - [] {}); - options.Padmapper.AddAction( - "MouseDown", - N_("Move mouse down"), - N_("Simulates downward mouse movement."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_DOWN }, - [] {}); - options.Padmapper.AddAction( - "MouseLeft", - N_("Move mouse left"), - N_("Simulates leftward mouse movement."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_LEFT }, - [] {}); - options.Padmapper.AddAction( - "MouseRight", - N_("Move mouse right"), - N_("Simulates rightward mouse movement."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_DPAD_RIGHT }, - [] {}); - auto leftMouseDown = [] { - const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); - const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); - sgbMouseDown = CLICK_LEFT; - LeftMouseDown(standGround ? SDL_KMOD_SHIFT : SDL_KMOD_NONE); - }; - auto leftMouseUp = [] { - const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); - const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_NONE; - LeftMouseUp(standGround ? SDL_KMOD_SHIFT : SDL_KMOD_NONE); - }; - options.Padmapper.AddAction( - "LeftMouseClick1", - N_("Left mouse click"), - N_("Simulates the left mouse button."), - ControllerButton_BUTTON_RIGHTSTICK, - leftMouseDown, - leftMouseUp); - options.Padmapper.AddAction( - "LeftMouseClick2", - N_("Left mouse click"), - N_("Simulates the left mouse button."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_LEFTSHOULDER }, - leftMouseDown, - leftMouseUp); - auto rightMouseDown = [] { - const ControllerButtonCombo standGroundCombo = GetOptions().Padmapper.ButtonComboForAction("StandGround"); - const bool standGround = StandToggle || IsControllerButtonComboPressed(standGroundCombo); - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_RIGHT; - RightMouseDown(standGround); - }; - auto rightMouseUp = [] { - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_NONE; - }; - options.Padmapper.AddAction( - "RightMouseClick1", - N_("Right mouse click"), - N_("Simulates the right mouse button."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_RIGHTSTICK }, - rightMouseDown, - rightMouseUp); - options.Padmapper.AddAction( - "RightMouseClick2", - N_("Right mouse click"), - N_("Simulates the right mouse button."), - { ControllerButton_BUTTON_BACK, ControllerButton_BUTTON_RIGHTSHOULDER }, - rightMouseDown, - rightMouseUp); - options.Padmapper.AddAction( - "PadHotspellMenu", - N_("Gamepad hotspell menu"), - N_("Hold to set or use spell hotkeys."), - ControllerButton_BUTTON_BACK, - [] { PadHotspellMenuActive = true; }, - [] { PadHotspellMenuActive = false; }); - options.Padmapper.AddAction( - "PadMenuNavigator", - N_("Gamepad menu navigator"), - N_("Hold to access gamepad menu navigation."), - ControllerButton_BUTTON_START, - [] { PadMenuNavigatorActive = true; }, - [] { PadMenuNavigatorActive = false; }); - auto toggleGameMenu = [] { - const bool inMenu = gmenu_is_active(); - PressEscKey(); - LastPlayerAction = PlayerActionType::None; - PadHotspellMenuActive = false; - PadMenuNavigatorActive = false; - if (!inMenu) - gamemenu_on(); - }; - options.Padmapper.AddAction( - "ToggleGameMenu1", - N_("Toggle game menu"), - N_("Opens the game menu."), - { - ControllerButton_BUTTON_BACK, - ControllerButton_BUTTON_START, - }, - toggleGameMenu); - options.Padmapper.AddAction( - "ToggleGameMenu2", - N_("Toggle game menu"), - N_("Opens the game menu."), - { - ControllerButton_BUTTON_START, - ControllerButton_BUTTON_BACK, - }, - toggleGameMenu); - options.Padmapper.AddAction( - "QuickSave", - N_("Quick save"), - N_("Saves the game."), - ControllerButton_NONE, - [] { gamemenu_save_game(false); }, - nullptr, - [&]() { return !gbIsMultiplayer && CanPlayerTakeAction(); }); - options.Padmapper.AddAction( - "QuickLoad", - N_("Quick load"), - N_("Loads the game."), - ControllerButton_NONE, - [] { gamemenu_load_game(false); }, - nullptr, - [&]() { return !gbIsMultiplayer && gbValidSaveFile && !IsPlayerInStore() && IsGameRunning(); }); - options.Padmapper.AddAction( - "ItemHighlighting", - N_("Item highlighting"), - N_("Show/hide items on ground."), - ControllerButton_NONE, - [] { HighlightKeyPressed(true); }, - [] { HighlightKeyPressed(false); }); - options.Padmapper.AddAction( - "ToggleItemHighlighting", - N_("Toggle item highlighting"), - N_("Permanent show/hide items on ground."), - ControllerButton_NONE, - nullptr, - [] { ToggleItemLabelHighlight(); }); - options.Padmapper.AddAction( - "HideInfoScreens", - N_("Hide Info Screens"), - N_("Hide all info screens."), - ControllerButton_NONE, - [] { - if (CanAutomapBeToggledOff()) - AutomapActive = false; - - ClosePanels(); - HelpFlag = false; - ChatLogFlag = false; - SpellSelectFlag = false; - if (qtextflag && leveltype == DTYPE_TOWN) { - qtextflag = false; - stream_stop(); - } - - CancelCurrentDiabloMsg(); - gamemenu_off(); - doom_close(); - }, - nullptr, - IsGameRunning); - options.Padmapper.AddAction( - "Zoom", - N_("Zoom"), - N_("Zoom Game Screen."), - ControllerButton_NONE, - [] { - GetOptions().Graphics.zoom.SetValue(!*GetOptions().Graphics.zoom); - CalcViewportGeometry(); - }, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "PauseGame", - N_("Pause Game"), - N_("Pauses the game."), - ControllerButton_NONE, - diablo_pause_game); - options.Padmapper.AddAction( - "DecreaseBrightness", - N_("Decrease Brightness"), - N_("Reduce screen brightness."), - ControllerButton_NONE, - DecreaseBrightness, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "IncreaseBrightness", - N_("Increase Brightness"), - N_("Increase screen brightness."), - ControllerButton_NONE, - IncreaseBrightness, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "Help", - N_("Help"), - N_("Open Help Screen."), - ControllerButton_NONE, - HelpKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "Screenshot", - N_("Screenshot"), - N_("Takes a screenshot."), - ControllerButton_NONE, - nullptr, - CaptureScreen); - options.Padmapper.AddAction( - "GameInfo", - N_("Game info"), - N_("Displays game infos."), - ControllerButton_NONE, - [] { - EventPlrMsg(fmt::format( - fmt::runtime(_(/* TRANSLATORS: {:s} means: Project Name, Game Version. */ "{:s} {:s}")), - PROJECT_NAME, - PROJECT_VERSION), - UiFlags::ColorWhite); - }, - nullptr, - CanPlayerTakeAction); - options.Padmapper.AddAction( - "SortInv", - N_("Sort Inventory"), - N_("Sorts the inventory."), - ControllerButton_NONE, - [] { - ReorganizeInventory(*MyPlayer); - }); - options.Padmapper.AddAction( - "ChatLog", - N_("Chat Log"), - N_("Displays chat log."), - ControllerButton_NONE, - [] { - ToggleChatLog(); - }); - options.Padmapper.CommitActions(); -} - -void SetCursorPos(Point position) +bool IsDiabloAlive(bool playSFX) { - MousePosition = position; - if (ControlDevice != ControlTypes::KeyboardAndMouse) { - return; + if (Quests[Q_DIABLO]._qactive == QUEST_DONE && !gbIsMultiplayer) { + if (playSFX) + PlaySFX(SfxID::DiabloDeath); + return false; } - LogicalToOutput(&position.x, &position.y); - if (!demo::IsRunning()) - SDL_WarpMouseInWindow(ghMainWnd, position.x, position.y); + return true; } - -void FreeGameMem() -{ - pDungeonCels = nullptr; - pMegaTiles = nullptr; - pSpecialCels = std::nullopt; - - FreeMonsters(); - FreeMissileGFX(); - FreeObjectGFX(); - FreeTownerGFX(); - FreeStashGFX(); -#ifndef USE_SDL1 - DeactivateVirtualGamepad(); - FreeVirtualGamepadGFX(); -#endif -} - -bool StartGame(bool bNewGame, bool bSinglePlayer) -{ - gbSelectProvider = true; - ReturnToMainMenu = false; - - do { - gbLoadGame = false; - - if (!NetInit(bSinglePlayer)) { - gbRunGameResult = true; - break; - } - - // Save 2.8 MiB of RAM by freeing all main menu resources - // before starting the game. - UiDestroy(); - - gbSelectProvider = false; - - if (bNewGame || !gbValidSaveFile) { - InitLevels(); - InitQuests(); - InitPortals(); - InitDungMsgs(*MyPlayer); - DeltaSyncJunk(); - } - giNumberOfLevels = gbIsHellfire ? 25 : 17; - interface_mode uMsg = WM_DIABNEWGAME; - if (gbValidSaveFile && gbLoadGame) { - uMsg = WM_DIABLOADGAME; - } - RunGameLoop(uMsg); - NetClose(); - UnloadFonts(); - - // If the player left the game into the main menu, - // initialize main menu resources. - if (gbRunGameResult) - UiInitialize(); - if (ReturnToMainMenu) - return true; - } while (gbRunGameResult); - - SNetDestroy(); - return gbRunGameResult; -} - -void diablo_quit(int exitStatus) -{ - FreeGameMem(); - music_stop(); - DiabloDeinit(); - -#if SDL_VERSION_ATLEAST(2, 0, 0) - if (SdlLogFile != nullptr) std::fclose(SdlLogFile); -#endif - - exit(exitStatus); -} - -#ifdef __UWP__ -void (*onInitialized)() = NULL; - -void setOnInitialized(void (*callback)()) -{ - onInitialized = callback; -} -#endif - -int DiabloMain(int argc, char **argv) -{ -#ifdef _DEBUG - SDL_SetLogPriorities(SDL_LOG_PRIORITY_DEBUG); -#endif - - DiabloParseFlags(argc, argv); - InitKeymapActions(); - InitPadmapActions(); - - // Need to ensure devilutionx.mpq (and fonts.mpq if available) are loaded before attempting to read translation settings - LoadCoreArchives(); - was_archives_init = true; - - // Read settings including translation next. This will use the presence of fonts.mpq and look for assets in devilutionx.mpq - LoadOptions(); - if (demo::IsRunning()) demo::OverrideOptions(); - - // Then look for a voice pack file based on the selected translation - LoadLanguageArchive(); - - ApplicationInit(); - LuaInitialize(); - if (!demo::IsRunning()) SaveOptions(); - - // Finally load game data - LoadGameArchives(); - - LoadTextData(); - - // Load dynamic data before we go into the menu as we need to initialise player characters in memory pretty early. - LoadPlayerDataFiles(); - - // TODO: We can probably load this much later (when the game is starting). - LoadSpellData(); - LoadMissileData(); - LoadMonsterData(); - LoadItemData(); - LoadObjectData(); - LoadQuestData(); - - DiabloInit(); -#ifdef __UWP__ - onInitialized(); -#endif - if (!demo::IsRunning()) SaveOptions(); - - DiabloSplash(); - mainmenu_loop(); - DiabloDeinit(); - - return 0; -} - -bool TryIconCurs() -{ - if (pcurs == CURSOR_RESURRECT) { - if (PlayerUnderCursor != nullptr) { - NetSendCmdParam1(true, CMD_RESURRECT, PlayerUnderCursor->getId()); - NewCursor(CURSOR_HAND); - return true; - } - - return false; - } - - if (pcurs == CURSOR_HEALOTHER) { - if (PlayerUnderCursor != nullptr) { - NetSendCmdParam1(true, CMD_HEALOTHER, PlayerUnderCursor->getId()); - NewCursor(CURSOR_HAND); - return true; - } - - return false; - } - - if (pcurs == CURSOR_TELEKINESIS) { - DoTelekinesis(); - return true; - } - - Player &myPlayer = *MyPlayer; - - if (pcurs == CURSOR_IDENTIFY) { - if (pcursinvitem != -1 && !IsInspectingPlayer()) - CheckIdentify(myPlayer, pcursinvitem); - else if (pcursstashitem != StashStruct::EmptyCell) { - Item &item = Stash.stashList[pcursstashitem]; - item._iIdentified = true; - } - NewCursor(CURSOR_HAND); - return true; - } - - if (pcurs == CURSOR_REPAIR) { - if (pcursinvitem != -1 && !IsInspectingPlayer()) - DoRepair(myPlayer, pcursinvitem); - else if (pcursstashitem != StashStruct::EmptyCell) { - Item &item = Stash.stashList[pcursstashitem]; - RepairItem(item, myPlayer.getCharacterLevel()); - } - NewCursor(CURSOR_HAND); - return true; - } - - if (pcurs == CURSOR_RECHARGE) { - if (pcursinvitem != -1 && !IsInspectingPlayer()) - DoRecharge(myPlayer, pcursinvitem); - else if (pcursstashitem != StashStruct::EmptyCell) { - Item &item = Stash.stashList[pcursstashitem]; - RechargeItem(item, myPlayer); - } - NewCursor(CURSOR_HAND); - return true; - } - - if (pcurs == CURSOR_OIL) { - bool changeCursor = true; - if (pcursinvitem != -1 && !IsInspectingPlayer()) - changeCursor = DoOil(myPlayer, pcursinvitem); - else if (pcursstashitem != StashStruct::EmptyCell) { - Item &item = Stash.stashList[pcursstashitem]; - changeCursor = ApplyOilToItem(item, myPlayer); - } - if (changeCursor) - NewCursor(CURSOR_HAND); - return true; - } - - if (pcurs == CURSOR_TELEPORT) { - const SpellID spellID = myPlayer.inventorySpell; - const SpellType spellType = SpellType::Scroll; - const int spellFrom = myPlayer.spellFrom; - if (IsWallSpell(spellID)) { - const Direction sd = GetDirection(myPlayer.position.tile, cursPosition); - NetSendCmdLocParam4(true, CMD_SPELLXYD, cursPosition, static_cast(spellID), static_cast(spellType), static_cast(sd), spellFrom); - } else if (pcursmonst != -1 && leveltype != DTYPE_TOWN) { - NetSendCmdParam4(true, CMD_SPELLID, pcursmonst, static_cast(spellID), static_cast(spellType), spellFrom); - } else if (PlayerUnderCursor != nullptr && !PlayerUnderCursor->hasNoLife() && !myPlayer.friendlyMode) { - NetSendCmdParam4(true, CMD_SPELLPID, PlayerUnderCursor->getId(), static_cast(spellID), static_cast(spellType), spellFrom); - } else { - NetSendCmdLocParam3(true, CMD_SPELLXY, cursPosition, static_cast(spellID), static_cast(spellType), spellFrom); - } - NewCursor(CURSOR_HAND); - return true; - } - - if (pcurs == CURSOR_DISARM && ObjectUnderCursor == nullptr) { - NewCursor(CURSOR_HAND); - return true; - } - - return false; -} - -void diablo_pause_game() -{ - if (!gbIsMultiplayer) { - if (PauseMode != 0) { - PauseMode = 0; - } else { - PauseMode = 2; - sound_stop(); - qtextflag = false; - LastPlayerAction = PlayerActionType::None; - } - - RedrawEverything(); - } -} - -bool GameWasAlreadyPaused = false; -bool MinimizePaused = false; - -bool diablo_is_focused() -{ -#ifndef USE_SDL1 - return SDL_GetKeyboardFocus() == ghMainWnd; -#else - Uint8 appState = SDL_GetAppState(); - return (appState & SDL_APPINPUTFOCUS) != 0; -#endif -} - -void diablo_focus_pause() -{ - if (!movie_playing && (gbIsMultiplayer || MinimizePaused)) { - return; - } - - GameWasAlreadyPaused = PauseMode != 0; - - if (!GameWasAlreadyPaused) { - PauseMode = 2; - sound_stop(); - LastPlayerAction = PlayerActionType::None; - } - - SVidMute(); - music_mute(); - - MinimizePaused = true; -} - -void diablo_focus_unpause() -{ - if (!GameWasAlreadyPaused) { - PauseMode = 0; - } - - SVidUnmute(); - music_unmute(); - - MinimizePaused = false; -} - -bool PressEscKey() -{ - bool rv = false; - - if (DoomFlag) { - doom_close(); - rv = true; - } - - if (HelpFlag) { - HelpFlag = false; - rv = true; - } - - if (ChatLogFlag) { - ChatLogFlag = false; - rv = true; - } - - if (qtextflag) { - qtextflag = false; - stream_stop(); - rv = true; - } - - if (IsPlayerInStore()) { - StoreESC(); - rv = true; - } - - if (IsDiabloMsgAvailable()) { - CancelCurrentDiabloMsg(); - rv = true; - } - - if (ChatFlag) { - ResetChat(); - rv = true; - } - - if (DropGoldFlag) { - control_drop_gold(SDLK_ESCAPE); - rv = true; - } - - if (IsWithdrawGoldOpen) { - WithdrawGoldKeyPress(SDLK_ESCAPE); - rv = true; - } - - if (SpellSelectFlag) { - SpellSelectFlag = false; - rv = true; - } - - if (IsLeftPanelOpen() || IsRightPanelOpen()) { - ClosePanels(); - rv = true; - } - - return rv; -} - -void DisableInputEventHandler(const SDL_Event &event, uint16_t modState) -{ - switch (event.type) { - case SDL_EVENT_MOUSE_MOTION: - MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; - return; - case SDL_EVENT_MOUSE_BUTTON_DOWN: - if (sgbMouseDown != CLICK_NONE) - return; - switch (event.button.button) { - case SDL_BUTTON_LEFT: - sgbMouseDown = CLICK_LEFT; - return; - case SDL_BUTTON_RIGHT: - sgbMouseDown = CLICK_RIGHT; - return; - default: - return; - } - case SDL_EVENT_MOUSE_BUTTON_UP: - sgbMouseDown = CLICK_NONE; - return; - } - - MainWndProc(event); -} - -void LoadGameLevelStopMusic(_music_id neededTrack) -{ - if (neededTrack != sgnMusicTrack) - music_stop(); -} - -void LoadGameLevelStartMusic(_music_id neededTrack) -{ - if (sgnMusicTrack != neededTrack) - music_start(neededTrack); - - if (MinimizePaused) { - music_mute(); - } -} - -void LoadGameLevelResetCursor() -{ - if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { - NewCursor(CURSOR_HAND); - } -} - -void SetRndSeedForDungeonLevel() -{ - if (setlevel) { - // Maps are not randomly generated, but the monsters max hitpoints are. - // So we need to ensure that we have a stable seed when generating quest/set-maps. - // For this purpose we reuse the normal dungeon seeds. - SetRndSeed(DungeonSeeds[static_cast(setlvlnum)]); - } else { - SetRndSeed(DungeonSeeds[currlevel]); - } -} - -void LoadGameLevelFirstFlagEntry() -{ - CloseInventory(); - qtextflag = false; - if (!HeadlessMode) { - InitInv(); - ClearUniqueItemFlags(); - InitQuestText(); - InitInfoBoxGfx(); - InitHelp(); - } - InitStores(); - InitAutomapOnce(); -} - -void LoadGameLevelStores() -{ - if (leveltype == DTYPE_TOWN) { - SetupTownStores(); - } else { - FreeStoreMem(); - } -} - -void LoadGameLevelStash() -{ - const bool isHellfireSaveGame = gbIsHellfireSaveGame; - - gbIsHellfireSaveGame = gbIsHellfire; - LoadStash(); - gbIsHellfireSaveGame = isHellfireSaveGame; -} - -tl::expected LoadGameLevelDungeon(bool firstflag, lvl_entry lvldir, const Player &myPlayer) -{ - if (firstflag || lvldir == ENTRY_LOAD || !myPlayer._pLvlVisited[currlevel] || gbIsMultiplayer) { - HoldThemeRooms(); - [[maybe_unused]] const uint32_t mid1Seed = GetLCGEngineState(); - InitGolems(); - InitObjects(); - [[maybe_unused]] const uint32_t mid2Seed = GetLCGEngineState(); - - IncProgress(); - - RETURN_IF_ERROR(InitMonsters()); - InitItems(); - CreateThemeRooms(); - - IncProgress(); - - [[maybe_unused]] const uint32_t mid3Seed = GetLCGEngineState(); - InitMissiles(); - InitCorpses(); -#ifdef _DEBUG - SetDebugLevelSeedInfos(mid1Seed, mid2Seed, mid3Seed, GetLCGEngineState()); -#endif - SavePreLighting(); - - IncProgress(); - - if (gbIsMultiplayer) - DeltaLoadLevel(); - } else { - HoldThemeRooms(); - InitGolems(); - RETURN_IF_ERROR(InitMonsters()); - InitMissiles(); - InitCorpses(); - - IncProgress(); - - RETURN_IF_ERROR(LoadLevel()); - - IncProgress(); - } - return {}; -} - -void LoadGameLevelSyncPlayerEntry(lvl_entry lvldir) -{ - for (Player &player : Players) { - if (player.plractive && player.isOnActiveLevel() && (!player._pLvlChanging || &player == MyPlayer)) { - if (player._pHitPoints > 0) { - if (lvldir != ENTRY_LOAD) - SyncInitPlrPos(player); - } else { - dFlags[player.position.tile.x][player.position.tile.y] |= DungeonFlag::DeadPlayer; - } - } - } -} - -void LoadGameLevelLightVision() -{ - if (leveltype != DTYPE_TOWN) { - memcpy(dLight, dPreLight, sizeof(dLight)); // resets the light on entering a level to get rid of incorrect light - ChangeLightXY(Players[MyPlayerId].lightId, Players[MyPlayerId].position.tile); // forces player light refresh - ProcessLightList(); - ProcessVisionList(); - } -} - -void LoadGameLevelReturn() -{ - ViewPosition = GetMapReturnPosition(); - if (Quests[Q_BETRAYER]._qactive == QUEST_DONE) - Quests[Q_BETRAYER]._qvar2 = 2; -} - -void LoadGameLevelInitPlayers(bool firstflag, lvl_entry lvldir) -{ - for (Player &player : Players) { - if (player.plractive && player.isOnActiveLevel()) { - InitPlayerGFX(player); - if (lvldir != ENTRY_LOAD) - InitPlayer(player, firstflag); - } - } -} - -void LoadGameLevelSetVisited() -{ - bool visited = false; - for (const Player &player : Players) { - if (player.plractive) - visited = visited || player._pLvlVisited[currlevel]; - } -} - -tl::expected LoadGameLevelTown(bool firstflag, lvl_entry lvldir, const Player &myPlayer) -{ - for (int i = 0; i < MAXDUNX; i++) { // NOLINT(modernize-loop-convert) - for (int j = 0; j < MAXDUNY; j++) { - dFlags[i][j] |= DungeonFlag::Lit; - } - } - - InitTowners(); - InitStash(); - InitItems(); - InitMissiles(); - - IncProgress(); - - if (!firstflag && lvldir != ENTRY_LOAD && myPlayer._pLvlVisited[currlevel] && !gbIsMultiplayer) - RETURN_IF_ERROR(LoadLevel()); - if (gbIsMultiplayer) - DeltaLoadLevel(); - - IncProgress(); - - for (int x = 0; x < DMAXX; x++) - for (int y = 0; y < DMAXY; y++) - UpdateAutomapExplorer({ x, y }, MAP_EXP_SELF); - return {}; -} - -tl::expected LoadGameLevelSetLevel(bool firstflag, lvl_entry lvldir, const Player &myPlayer) -{ - LoadSetMap(); - IncProgress(); - RETURN_IF_ERROR(GetLevelMTypes()); - IncProgress(); - InitGolems(); - RETURN_IF_ERROR(InitMonsters()); - IncProgress(); - if (!HeadlessMode) { -#if !defined(USE_SDL1) && !defined(__vita__) - InitVirtualGamepadGFX(); -#endif - RETURN_IF_ERROR(InitMissileGFX()); - IncProgress(); - } - InitCorpses(); - IncProgress(); - - if (lvldir == ENTRY_WARPLVL) - GetPortalLvlPos(); - IncProgress(); - - for (Player &player : Players) { - if (player.plractive && player.isOnActiveLevel()) { - InitPlayerGFX(player); - if (lvldir != ENTRY_LOAD) - InitPlayer(player, firstflag); - } - } - IncProgress(); - InitMultiView(); - IncProgress(); - - if (firstflag || lvldir == ENTRY_LOAD || !myPlayer._pSLvlVisited[setlvlnum] || gbIsMultiplayer) { - InitItems(); - SavePreLighting(); - } else { - RETURN_IF_ERROR(LoadLevel()); - } - if (gbIsMultiplayer) { - DeltaLoadLevel(); - if (!UseMultiplayerQuests()) - ResyncQuests(); - } - - PlayDungMsgs(); - InitMissiles(); - IncProgress(); - return {}; -} - -tl::expected LoadGameLevelStandardLevel(bool firstflag, lvl_entry lvldir, const Player &myPlayer) -{ - CreateLevel(lvldir); - - IncProgress(); - - SetRndSeedForDungeonLevel(); - - if (leveltype != DTYPE_TOWN) { - RETURN_IF_ERROR(GetLevelMTypes()); - InitThemes(); - if (!HeadlessMode) - RETURN_IF_ERROR(LoadAllGFX()); - } else if (!HeadlessMode) { - IncProgress(); - -#if !defined(USE_SDL1) && !defined(__vita__) - InitVirtualGamepadGFX(); -#endif - - IncProgress(); - - RETURN_IF_ERROR(InitMissileGFX()); - - IncProgress(); - IncProgress(); - } - - IncProgress(); - - if (lvldir == ENTRY_RTNLVL) { - LoadGameLevelReturn(); - } - - if (lvldir == ENTRY_WARPLVL) - GetPortalLvlPos(); - - IncProgress(); - - LoadGameLevelInitPlayers(firstflag, lvldir); - InitMultiView(); - - IncProgress(); - - LoadGameLevelSetVisited(); - - SetRndSeedForDungeonLevel(); - - if (leveltype == DTYPE_TOWN) { - LoadGameLevelTown(firstflag, lvldir, myPlayer); - } else { - LoadGameLevelDungeon(firstflag, lvldir, myPlayer); - } - - PlayDungMsgs(); - - if (UseMultiplayerQuests()) - ResyncMPQuests(); - else - ResyncQuests(); - return {}; -} - -void LoadGameLevelCrypt() -{ - if (CornerStone.isAvailable()) { - CornerstoneLoad(CornerStone.position); - } - if (Quests[Q_NAKRUL]._qactive == QUEST_DONE && currlevel == 24) { - SyncNakrulRoom(); - } -} - -void LoadGameLevelCalculateCursor() -{ - // Recalculate mouse selection of entities after level change/load - LastPlayerAction = PlayerActionType::None; - sgbMouseDown = CLICK_NONE; - ResetItemlabelHighlighted(); // level changed => item changed - pcursmonst = -1; // ensure pcurstemp is set to a valid value - CheckCursMove(); -} - -tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir) -{ - const _music_id neededTrack = GetLevelMusic(leveltype); - - ClearFloatingNumbers(); - LoadGameLevelStopMusic(neededTrack); - LoadGameLevelResetCursor(); - SetRndSeedForDungeonLevel(); - NaKrulTomeSequence = 0; - - IncProgress(); - - RETURN_IF_ERROR(LoadTrns()); - MakeLightTable(); - RETURN_IF_ERROR(LoadLevelSOLData()); - - IncProgress(); - - RETURN_IF_ERROR(LoadLvlGFX()); - SetDungeonMicros(pDungeonCels, MicroTileLen); - ClearClxDrawCache(); - - IncProgress(); - - if (firstflag) { - LoadGameLevelFirstFlagEntry(); - } - - SetRndSeedForDungeonLevel(); - - LoadGameLevelStores(); - - if (firstflag || lvldir == ENTRY_LOAD) { - LoadGameLevelStash(); - } - - IncProgress(); - - InitAutomap(); - - if (leveltype != DTYPE_TOWN && lvldir != ENTRY_LOAD) { - InitLighting(); - } - - InitLevelMonsters(); - - IncProgress(); - - const Player &myPlayer = *MyPlayer; - - if (setlevel) { - RETURN_IF_ERROR(LoadGameLevelSetLevel(firstflag, lvldir, myPlayer)); - } else { - RETURN_IF_ERROR(LoadGameLevelStandardLevel(firstflag, lvldir, myPlayer)); - } - - SyncPortals(); - LoadGameLevelSyncPlayerEntry(lvldir); - - IncProgress(); - IncProgress(); - - if (firstflag) { - RETURN_IF_ERROR(InitMainPanel()); - } - - IncProgress(); - - UpdateMonsterLights(); - UnstuckChargers(); - - LoadGameLevelLightVision(); - - if (leveltype == DTYPE_CRYPT) { - LoadGameLevelCrypt(); - } - -#ifndef USE_SDL1 - ActivateVirtualGamepad(); -#endif - LoadGameLevelStartMusic(neededTrack); - - CompleteProgress(); - LoadGameLevelCalculateCursor(); - if (leveltype != DTYPE_TOWN) - SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); - return {}; +void PrintScreen(SDL_Keycode vkey) +{ + ReleaseKey(vkey); } - -bool game_loop(bool bStartup) -{ - const uint16_t wait = bStartup ? sgGameInitInfo.nTickRate * 3 : 3; - - for (unsigned i = 0; i < wait; i++) { - if (!multi_handle_delta()) { - TimeoutCursor(true); - return false; - } - TimeoutCursor(false); - GameLogic(); - ClearLastSentPlayerCmd(); - - if (!gbRunGame || !gbIsMultiplayer || demo::IsRunning() || demo::IsRecording() || !nthread_has_500ms_passed()) - break; - } - return true; -} - -void diablo_color_cyc_logic() -{ - if (!*GetOptions().Graphics.colorCycling) - return; - - if (PauseMode != 0) - return; - - if (leveltype == DTYPE_CAVES) { - if (setlevel && setlvlnum == Quests[Q_PWATER]._qslvl) { - UpdatePWaterPalette(); - } else { - palette_update_caves(); - } - } else if (leveltype == DTYPE_HELL) { - lighting_color_cycling(); - } else if (leveltype == DTYPE_NEST) { - palette_update_hive(); - } else if (leveltype == DTYPE_CRYPT) { - palette_update_crypt(); - } -} - -bool IsDiabloAlive(bool playSFX) -{ - if (Quests[Q_DIABLO]._qactive == QUEST_DONE && !gbIsMultiplayer) { - if (playSFX) - PlaySFX(SfxID::DiabloDeath); - return false; - } - - return true; -} - -void PrintScreen(SDL_Keycode vkey) -{ - ReleaseKey(vkey); -} - -} // namespace devilution + +} // namespace devilution diff --git a/Source/diablo.h b/Source/diablo.h index ad28ebb8216..a487302d1fa 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 diff --git a/Source/items.cpp b/Source/items.cpp index 2f6ce462c0f..a5a8d449efb 100644 --- a/Source/items.cpp +++ b/Source/items.cpp @@ -1850,13 +1850,18 @@ void PrintItemMisc(const Item &item) } } -void PrintItemInfo(const Item &item) -{ - PrintItemMisc(item); - uint8_t str = item._iMinStr; - uint8_t dex = item._iMinDex; - uint8_t mag = item._iMinMag; - if (str != 0 || mag != 0 || dex != 0) { +void PrintItemInfo(const Item &item) +{ + PrintItemMisc(item); + + const Player *player = InspectPlayer != nullptr ? InspectPlayer : MyPlayer; + if (player != nullptr && !player->CanUseItem(item)) + AddItemInfoBoxString(_("Requirements not met")); + + uint8_t str = item._iMinStr; + uint8_t dex = item._iMinDex; + uint8_t mag = item._iMinMag; + if (str != 0 || mag != 0 || dex != 0) { std::string text = std::string(_("Required:")); if (str != 0) text.append(fmt::format(fmt::runtime(_(" {:d} Str")), str)); @@ -1865,13 +1870,79 @@ void PrintItemInfo(const Item &item) if (dex != 0) text.append(fmt::format(fmt::runtime(_(" {:d} Dex")), dex)); AddItemInfoBoxString(text); - } -} - -bool SmithItemOk(const Player & /*player*/, const ItemData &item) -{ - if (item.itype == ItemType::Misc) - return false; + } +} + +namespace { + +[[nodiscard]] item_equip_type WeaponHandednessForSpeech(const Item &item) +{ + const Player *player = InspectPlayer != nullptr ? InspectPlayer : MyPlayer; + if (player != nullptr) + return player->GetItemLocation(item); + + return item._iLoc; +} + +[[nodiscard]] std::optional WeaponBaseAttackFramesForSpeech(const Item &item) +{ + const Player *player = InspectPlayer != nullptr ? InspectPlayer : MyPlayer; + if (player == nullptr) + return std::nullopt; + if (item._iClass != ICLASS_WEAPON) + return std::nullopt; + + const PlayerAnimData &animData = GetPlayerAnimDataForClass(player->_pClass); + switch (item._itype) { + case ItemType::Sword: + return animData.swordFrames; + case ItemType::Axe: + return animData.axeFrames; + case ItemType::Bow: + return animData.bowFrames; + case ItemType::Mace: + return animData.maceFrames; + case ItemType::Staff: + return animData.staffFrames; + default: + return std::nullopt; + } +} + +[[nodiscard]] std::string_view AttackSpeedLabelForFrames(int frames) +{ + if (frames <= 13) + return _("Very fast"); + if (frames <= 16) + return _("Fast"); + if (frames <= 18) + return _("Normal"); + if (frames <= 20) + return _("Slow"); + return _("Very slow"); +} + +void PrintWeaponHandednessAndAttackSpeed(const Item &item) +{ + const item_equip_type handedness = WeaponHandednessForSpeech(item); + if (handedness == ILOC_ONEHAND) + AddItemInfoBoxString(_("One-handed weapon")); + else if (handedness == ILOC_TWOHAND) + AddItemInfoBoxString(_("Two-handed weapon")); + + const std::optional frames = WeaponBaseAttackFramesForSpeech(item); + if (!frames) + return; + + AddItemInfoBoxString(fmt::format(fmt::runtime(_("Attack speed: {:s} ({:d} frames)")), AttackSpeedLabelForFrames(*frames), *frames)); +} + +} // namespace + +bool SmithItemOk(const Player & /*player*/, const ItemData &item) +{ + if (item.itype == ItemType::Misc) + return false; if (item.itype == ItemType::Gold) return false; if (item.itype == ItemType::Staff && (!gbIsHellfire || IsValidSpell(item.iSpell))) @@ -4109,12 +4180,12 @@ void DrawUniqueInfo(const Surface &out) } } -void PrintItemDetails(const Item &item) -{ - if (HeadlessMode) - return; - - if (item._iClass == ICLASS_WEAPON) { +void PrintItemDetails(const Item &item) +{ + if (HeadlessMode) + return; + + if (item._iClass == ICLASS_WEAPON) { if (item._iMinDam == item._iMaxDam) { if (item._iMaxDur == DUR_INDESTRUCTIBLE) AddItemInfoBoxString(fmt::format(fmt::runtime(_("damage: {:d} Indestructible")), item._iMinDam)); @@ -4123,14 +4194,15 @@ void PrintItemDetails(const Item &item) } else { if (item._iMaxDur == DUR_INDESTRUCTIBLE) AddItemInfoBoxString(fmt::format(fmt::runtime(_("damage: {:d}-{:d} Indestructible")), item._iMinDam, item._iMaxDam)); - else - AddItemInfoBoxString(fmt::format(fmt::runtime(_(/* TRANSLATORS: Dur: is durability */ "damage: {:d}-{:d} Dur: {:d}/{:d}")), item._iMinDam, item._iMaxDam, item._iDurability, item._iMaxDur)); - } - } - if (item._iClass == ICLASS_ARMOR) { - if (item._iMaxDur == DUR_INDESTRUCTIBLE) - AddItemInfoBoxString(fmt::format(fmt::runtime(_("armor: {:d} Indestructible")), item._iAC)); - else + else + AddItemInfoBoxString(fmt::format(fmt::runtime(_(/* TRANSLATORS: Dur: is durability */ "damage: {:d}-{:d} Dur: {:d}/{:d}")), item._iMinDam, item._iMaxDam, item._iDurability, item._iMaxDur)); + } + PrintWeaponHandednessAndAttackSpeed(item); + } + if (item._iClass == ICLASS_ARMOR) { + if (item._iMaxDur == DUR_INDESTRUCTIBLE) + AddItemInfoBoxString(fmt::format(fmt::runtime(_("armor: {:d} Indestructible")), item._iAC)); + else AddItemInfoBoxString(fmt::format(fmt::runtime(_(/* TRANSLATORS: Dur: is durability */ "armor: {:d} Dur: {:d}/{:d}")), item._iAC, item._iDurability, item._iMaxDur)); } if (item._iMiscId == IMISC_STAFF && item._iMaxCharges != 0) { @@ -4139,23 +4211,32 @@ void PrintItemDetails(const Item &item) if (item._iPrePower != -1) { AddItemInfoBoxString(PrintItemPower(item._iPrePower, item)); } - if (item._iSufPower != -1) { - AddItemInfoBoxString(PrintItemPower(item._iSufPower, item)); - } - if (item._iMagical == ITEM_QUALITY_UNIQUE) { - AddItemInfoBoxString(_("unique item")); - ShowUniqueItemInfoBox = true; - curruitem = item; - } - PrintItemInfo(item); -} - -void PrintItemDur(const Item &item) -{ - if (HeadlessMode) - return; - - if (item._iClass == ICLASS_WEAPON) { + if (item._iSufPower != -1) { + AddItemInfoBoxString(PrintItemPower(item._iSufPower, item)); + } + if (item._iMagical == ITEM_QUALITY_MAGIC) { + AddItemInfoBoxString(_("magic item")); + } + if (item._iMagical == ITEM_QUALITY_UNIQUE) { + AddItemInfoBoxString(_("unique item")); + const UniqueItem &uitem = UniqueItems[item._iUid]; + for (const auto &power : uitem.powers) { + if (power.type == IPL_INVALID) + break; + AddItemInfoBoxString(PrintItemPower(power.type, item)); + } + ShowUniqueItemInfoBox = true; + curruitem = item; + } + PrintItemInfo(item); +} + +void PrintItemDur(const Item &item) +{ + if (HeadlessMode) + return; + + if (item._iClass == ICLASS_WEAPON) { if (item._iMinDam == item._iMaxDam) { if (item._iMaxDur == DUR_INDESTRUCTIBLE) AddItemInfoBoxString(fmt::format(fmt::runtime(_("damage: {:d} Indestructible")), item._iMinDam)); @@ -4164,13 +4245,14 @@ void PrintItemDur(const Item &item) } else { if (item._iMaxDur == DUR_INDESTRUCTIBLE) AddItemInfoBoxString(fmt::format(fmt::runtime(_("damage: {:d}-{:d} Indestructible")), item._iMinDam, item._iMaxDam)); - else - AddItemInfoBoxString(fmt::format(fmt::runtime(_("damage: {:d}-{:d} Dur: {:d}/{:d}")), item._iMinDam, item._iMaxDam, item._iDurability, item._iMaxDur)); - } - if (item._iMiscId == IMISC_STAFF && item._iMaxCharges > 0) { - AddItemInfoBoxString(fmt::format(fmt::runtime(_("Charges: {:d}/{:d}")), item._iCharges, item._iMaxCharges)); - } - if (item._iMagical != ITEM_QUALITY_NORMAL) + else + AddItemInfoBoxString(fmt::format(fmt::runtime(_("damage: {:d}-{:d} Dur: {:d}/{:d}")), item._iMinDam, item._iMaxDam, item._iDurability, item._iMaxDur)); + } + PrintWeaponHandednessAndAttackSpeed(item); + if (item._iMiscId == IMISC_STAFF && item._iMaxCharges > 0) { + AddItemInfoBoxString(fmt::format(fmt::runtime(_("Charges: {:d}/{:d}")), item._iCharges, item._iMaxCharges)); + } + if (item._iMagical != ITEM_QUALITY_NORMAL) AddItemInfoBoxString(_("Not Identified")); } if (item._iClass == ICLASS_ARMOR) { diff --git a/Source/minitext.cpp b/Source/minitext.cpp index bbeccd06ec7..a91fa4573f8 100644 --- a/Source/minitext.cpp +++ b/Source/minitext.cpp @@ -14,9 +14,11 @@ #include "engine/clx_sprite.hpp" #include "engine/dx.h" #include "engine/load_cel.hpp" -#include "engine/render/clx_render.hpp" -#include "engine/render/primitive_render.hpp" +#include "engine/render/clx_render.hpp" +#include "engine/render/primitive_render.hpp" #include "engine/render/text_render.hpp" +#include "levels/gendung.h" +#include "options.h" #include "tables/playerdat.hpp" #include "tables/textdat.h" #include "utils/language.h" @@ -166,7 +168,8 @@ void InitQTextMsg(_speech_id m) QuestLogIsOpen = false; const std::string_view text = _(Speeches[m].txtstr); LoadText(text); - SpeakText(text, /*force=*/true); + if (*GetOptions().Gameplay.speakNpcDialogText) + SpeakText(text, /*force=*/true); qtextflag = true; qtextSpd = CalculateTextSpeed(sfxnr); ScrollStart = GetMillisecondsSinceStartup(); diff --git a/Source/objects.cpp b/Source/objects.cpp index b489fa3d784..9f0706beef4 100644 --- a/Source/objects.cpp +++ b/Source/objects.cpp @@ -3,6 +3,7 @@ * * Implementation of object functionality, interaction, spawning, loading, etc. */ +#include #include #include #include @@ -100,14 +101,6 @@ enum shrine_type : uint8_t { NumberOfShrineTypes }; -enum { - // clang-format off - DOOR_CLOSED = 0, - DOOR_OPEN = 1, - DOOR_BLOCKED = 2, - // clang-format on -}; - int trapid; int trapdir; OptionalOwnedClxSpriteList pObjCels[40]; @@ -1182,11 +1175,19 @@ void AddDoor(Object &door) case OBJ_L5LDOOR: door._oVar1 = dPiece[door.position.x][door.position.y] + 1; door._oVar2 = dPiece[door.position.x][door.position.y - 1] + 1; + // Register the archway tile so FindObjectAtPosition resolves it to this door, + // enabling auto-walk door detection and IsTileWalkable with ignoreDoors. + assert(door.position.y > 0); + dObject[door.position.x][door.position.y - 1] = -(static_cast(door.GetId()) + 1); break; case OBJ_L1RDOOR: case OBJ_L5RDOOR: door._oVar1 = dPiece[door.position.x][door.position.y] + 1; door._oVar2 = dPiece[door.position.x - 1][door.position.y] + 1; + // Register the archway tile so FindObjectAtPosition resolves it to this door, + // enabling auto-walk door detection and IsTileWalkable with ignoreDoors. + assert(door.position.x > 0); + dObject[door.position.x - 1][door.position.y] = -(static_cast(door.GetId()) + 1); break; default: break; @@ -4303,7 +4304,7 @@ void MonstCheckDoors(const Monster &monster) continue; Object &door = *object; - // Doors use _oVar4 to track open/closed state, non-zero values indicate an open door + // Doors use _oVar4 to track state (DOOR_CLOSED, DOOR_OPEN, or DOOR_BLOCKED); skip non-closed doors if (!door.isDoor() || door._oVar4 != DOOR_CLOSED) continue; diff --git a/Source/objects.h b/Source/objects.h index c9032454ddd..7712fdc67bb 100644 --- a/Source/objects.h +++ b/Source/objects.h @@ -29,6 +29,17 @@ namespace devilution { #define MAXOBJECTS 127 +static_assert(MAXOBJECTS <= 127, "MAXOBJECTS must fit in int8_t for the dObject encoding scheme"); + +/** Door state values stored in Object::_oVar4 for door-type objects. */ +enum { + // clang-format off + DOOR_CLOSED = 0, + DOOR_OPEN = 1, + DOOR_BLOCKED = 2, + // clang-format on +}; + struct Object { _object_id _otype = OBJ_NULL; bool applyLighting = false; diff --git a/Source/options.cpp b/Source/options.cpp index a055f81ba49..08a2e52ab67 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -508,30 +508,30 @@ std::vector HellfireOptions::GetEntries() }; } -AudioOptions::AudioOptions() - : OptionCategoryBase("Audio", N_("Audio"), N_("Audio Settings")) - , soundVolume("Sound Volume", OptionEntryFlags::Invisible, "Sound Volume", "Movie and SFX volume.", VOLUME_MAX) - , audioCuesVolume("Audio Cues Volume", OptionEntryFlags::Invisible, "Audio Cues Volume", "Navigation audio cues volume.", VOLUME_MAX) - , musicVolume("Music Volume", OptionEntryFlags::Invisible, "Music Volume", "Music Volume.", VOLUME_MAX) - , walkingSound("Walking Sound", OptionEntryFlags::None, N_("Walking Sound"), N_("Player emits sound when walking."), true) - , autoEquipSound("Auto Equip Sound", OptionEntryFlags::None, N_("Auto Equip Sound"), N_("Automatically equipping items on pickup emits the equipment sound."), false) - , itemPickupSound("Item Pickup Sound", OptionEntryFlags::None, N_("Item Pickup Sound"), N_("Picking up items emits the items pickup sound."), false) - , sampleRate("Sample Rate", OptionEntryFlags::CantChangeInGame, N_("Sample Rate"), N_("Output sample rate (Hz)."), DEFAULT_AUDIO_SAMPLE_RATE, { 22050, 44100, 48000 }) +AudioOptions::AudioOptions() + : OptionCategoryBase("Audio", N_("Audio"), N_("Audio Settings")) + , soundVolume("Sound Volume", OptionEntryFlags::Invisible, "Sound Volume", "Movie and SFX volume.", VOLUME_MAX) + , audioCuesVolume("Audio Cues Volume", OptionEntryFlags::Invisible, "Audio Cues Volume", "Navigation audio cues volume.", VOLUME_MAX) + , musicVolume("Music Volume", OptionEntryFlags::Invisible, "Music Volume", "Music Volume.", VOLUME_MAX) + , walkingSound("Walking Sound", OptionEntryFlags::None, N_("Walking Sound"), N_("Player emits sound when walking."), true) + , autoEquipSound("Auto Equip Sound", OptionEntryFlags::None, N_("Auto Equip Sound"), N_("Automatically equipping items on pickup emits the equipment sound."), false) + , itemPickupSound("Item Pickup Sound", OptionEntryFlags::None, N_("Item Pickup Sound"), N_("Picking up items emits the items pickup sound."), false) + , sampleRate("Sample Rate", OptionEntryFlags::CantChangeInGame, N_("Sample Rate"), N_("Output sample rate (Hz)."), DEFAULT_AUDIO_SAMPLE_RATE, { 22050, 44100, 48000 }) , channels("Channels", OptionEntryFlags::CantChangeInGame, N_("Channels"), N_("Number of output channels."), DEFAULT_AUDIO_CHANNELS, { 1, 2 }) , bufferSize("Buffer Size", OptionEntryFlags::CantChangeInGame, N_("Buffer Size"), N_("Buffer size (number of frames per channel)."), DEFAULT_AUDIO_BUFFER_SIZE, { 1024, 2048, 5120 }) , resamplingQuality("Resampling Quality", OptionEntryFlags::CantChangeInGame, N_("Resampling Quality"), N_("Quality of the resampler, from 0 (lowest) to 5 (highest)."), DEFAULT_AUDIO_RESAMPLING_QUALITY, { 0, 1, 2, 3, 4, 5 }) { } -std::vector AudioOptions::GetEntries() -{ - // clang-format off - return { - &soundVolume, - &audioCuesVolume, - &musicVolume, - &walkingSound, - &autoEquipSound, - &itemPickupSound, +std::vector AudioOptions::GetEntries() +{ + // clang-format off + return { + &soundVolume, + &audioCuesVolume, + &musicVolume, + &walkingSound, + &autoEquipSound, + &itemPickupSound, &sampleRate, &channels, &bufferSize, @@ -861,11 +861,12 @@ GameplayOptions::GameplayOptions() , autoEquipJewelry("Auto Equip Jewelry", OptionEntryFlags::None, N_("Auto Equip Jewelry"), N_("Jewelry will be automatically equipped on pickup or purchase if enabled."), false) , randomizeQuests("Randomize Quests", OptionEntryFlags::CantChangeInGame, N_("Randomize Quests"), N_("Randomly selecting available quests for new games."), true) , showMonsterType("Show Monster Type", OptionEntryFlags::None, N_("Show Monster Type"), N_("Hovering over a monster will display the type of monster in the description box in the UI."), false) - , showItemLabels("Show Item Labels", OptionEntryFlags::None, N_("Show Item Labels"), N_("Show labels for items on the ground when enabled."), false) - , autoRefillBelt("Auto Refill Belt", OptionEntryFlags::None, N_("Auto Refill Belt"), N_("Refill belt from inventory when belt item is consumed."), false) - , disableCripplingShrines("Disable Crippling Shrines", OptionEntryFlags::None, N_("Disable Crippling Shrines"), N_("When enabled Cauldrons, Fascinating Shrines, Goat Shrines, Ornate Shrines, Sacred Shrines and Murphy's Shrines are not able to be clicked on and labeled as disabled."), false) - , quickCast("Quick Cast", OptionEntryFlags::None, N_("Quick Cast"), N_("Spell hotkeys instantly cast the spell, rather than switching the readied spell."), false) - , numHealPotionPickup("Heal Potion Pickup", OptionEntryFlags::None, N_("Heal Potion Pickup"), N_("Number of Healing potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) + , showItemLabels("Show Item Labels", OptionEntryFlags::None, N_("Show Item Labels"), N_("Show labels for items on the ground when enabled."), false) + , autoRefillBelt("Auto Refill Belt", OptionEntryFlags::None, N_("Auto Refill Belt"), N_("Refill belt from inventory when belt item is consumed."), false) + , disableCripplingShrines("Disable Crippling Shrines", OptionEntryFlags::None, N_("Disable Crippling Shrines"), N_("When enabled Cauldrons, Fascinating Shrines, Goat Shrines, Ornate Shrines, Sacred Shrines and Murphy's Shrines are not able to be clicked on and labeled as disabled."), false) + , quickCast("Quick Cast", OptionEntryFlags::None, N_("Quick Cast"), N_("Spell hotkeys instantly cast the spell, rather than switching the readied spell."), false) + , speakNpcDialogText("Read NPC Dialog Text", OptionEntryFlags::None, N_("Read NPC Dialog Text"), N_("Read subtitle text aloud when NPC dialog is shown."), true) + , numHealPotionPickup("Heal Potion Pickup", OptionEntryFlags::None, N_("Heal Potion Pickup"), N_("Number of Healing potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , numFullHealPotionPickup("Full Heal Potion Pickup", OptionEntryFlags::None, N_("Full Heal Potion Pickup"), N_("Number of Full Healing potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , numManaPotionPickup("Mana Potion Pickup", OptionEntryFlags::None, N_("Mana Potion Pickup"), N_("Number of Mana potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , numFullManaPotionPickup("Full Mana Potion Pickup", OptionEntryFlags::None, N_("Full Mana Potion Pickup"), N_("Number of Full Mana potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) @@ -896,15 +897,16 @@ std::vector GameplayOptions::GetEntries() &enemyHealthBar, &floatingInfoBox, &showMonsterType, - &showItemLabels, - &autoRefillBelt, - &autoEquipWeapons, - &autoEquipArmor, - &autoEquipHelms, - &autoEquipShields, - &autoEquipJewelry, - &autoGoldPickup, - &autoElixirPickup, + &showItemLabels, + &autoRefillBelt, + &autoEquipWeapons, + &autoEquipArmor, + &autoEquipHelms, + &autoEquipShields, + &autoEquipJewelry, + &speakNpcDialogText, + &autoGoldPickup, + &autoElixirPickup, &autoOilPickup, &numHealPotionPickup, &numFullHealPotionPickup, @@ -1130,19 +1132,19 @@ KeymapperOptions::KeymapperOptions() keyIDToKeyName.emplace(SDLK_PERIOD, "."); keyIDToKeyName.emplace(SDLK_SLASH, "/"); - keyIDToKeyName.emplace(SDLK_BACKSPACE, "BACKSPACE"); - keyIDToKeyName.emplace(SDLK_CAPSLOCK, "CAPSLOCK"); - keyIDToKeyName.emplace(SDLK_SCROLLLOCK, "SCROLLLOCK"); - keyIDToKeyName.emplace(SDLK_INSERT, "INSERT"); - keyIDToKeyName.emplace(SDLK_DELETE, "DELETE"); - keyIDToKeyName.emplace(SDLK_HOME, "HOME"); - keyIDToKeyName.emplace(SDLK_END, "END"); - keyIDToKeyName.emplace(SDLK_PAGEUP, "PAGEUP"); - keyIDToKeyName.emplace(SDLK_PAGEDOWN, "PAGEDOWN"); - keyIDToKeyName.emplace(SDLK_UP, "UP"); - keyIDToKeyName.emplace(SDLK_DOWN, "DOWN"); - keyIDToKeyName.emplace(SDLK_LEFT, "LEFT"); - keyIDToKeyName.emplace(SDLK_RIGHT, "RIGHT"); + keyIDToKeyName.emplace(SDLK_BACKSPACE, "BACKSPACE"); + keyIDToKeyName.emplace(SDLK_CAPSLOCK, "CAPSLOCK"); + keyIDToKeyName.emplace(SDLK_SCROLLLOCK, "SCROLLLOCK"); + keyIDToKeyName.emplace(SDLK_INSERT, "INSERT"); + keyIDToKeyName.emplace(SDLK_DELETE, "DELETE"); + keyIDToKeyName.emplace(SDLK_HOME, "HOME"); + keyIDToKeyName.emplace(SDLK_END, "END"); + keyIDToKeyName.emplace(SDLK_PAGEUP, "PAGEUP"); + keyIDToKeyName.emplace(SDLK_PAGEDOWN, "PAGEDOWN"); + keyIDToKeyName.emplace(SDLK_UP, "UP"); + keyIDToKeyName.emplace(SDLK_DOWN, "DOWN"); + keyIDToKeyName.emplace(SDLK_LEFT, "LEFT"); + keyIDToKeyName.emplace(SDLK_RIGHT, "RIGHT"); keyIDToKeyName.emplace(SDLK_KP_DIVIDE, "KEYPAD /"); keyIDToKeyName.emplace(SDLK_KP_MULTIPLY, "KEYPAD *"); @@ -1186,53 +1188,80 @@ std::string_view KeymapperOptions::Action::GetName() const return dynamicName; } -void KeymapperOptions::Action::LoadFromIni(std::string_view category) -{ - const std::span iniValues = ini->get(category, key); - if (iniValues.empty()) { - SetValue(defaultKey); - return; // Use the default key if no key has been set. - } - +void KeymapperOptions::Action::LoadFromIni(std::string_view category) +{ + const std::span iniValues = ini->get(category, key); + if (iniValues.empty()) { + SetValue(defaultKey); + return; // Use the default key if no key has been set. + } + const std::string_view iniValue = iniValues.back().value; if (iniValue.empty()) { - if (key == "SpeakCurrentLocation") { - const std::span chatLogValues = ini->get(category, "ChatLog"); - if (!chatLogValues.empty() && chatLogValues.back().value == "L") { + // Migration: `ToggleAutomap` could have been saved as unbound due to a key + // conflict when `ToggleStashFocus` was also bound to TAB. Restore the default + // key in that case so TAB works consistently. + if (key == "ToggleAutomap") { + const std::span stashFocusValues = ini->get(category, "ToggleStashFocus"); + if (!stashFocusValues.empty() && stashFocusValues.back().value == "TAB") { SetValue(defaultKey); return; } } - // Migration: some actions were previously saved as unbound because their default - // keys were not supported by the keymapper. If we see an explicit empty mapping - // for these actions, treat it as "use default". - if (IsAnyOf(key, "PreviousTownNpc", "NextTownNpc", "KeyboardWalkNorth", "KeyboardWalkSouth", "KeyboardWalkEast", "KeyboardWalkWest")) { - SetValue(defaultKey); - return; - } + if (key == "SpeakCurrentLocation") { + const std::span chatLogValues = ini->get(category, "ChatLog"); + if (!chatLogValues.empty() && chatLogValues.back().value == "L") { + SetValue(defaultKey); + return; + } + } + + // Migration: `SpeakNearestTownPortal` originally defaulted to `P` but could be + // saved as unbound due to key conflicts in earlier versions. Treat an explicit + // empty mapping as "use default" so existing configs start working again. + if (key == "SpeakNearestTownPortal") { + SetValue(defaultKey); + return; + } + + // Migration: some actions were previously saved as unbound because their default + // keys were not supported by the keymapper. If we see an explicit empty mapping + // for these actions, treat it as "use default". + if (IsAnyOf(key, "KeyboardWalkNorth", "KeyboardWalkSouth", "KeyboardWalkEast", "KeyboardWalkWest")) { + SetValue(defaultKey); + return; + } SetValue(SDLK_UNKNOWN); return; } - - auto keyIt = GetOptions().Keymapper.keyNameToKeyID.find(iniValue); - if (keyIt == GetOptions().Keymapper.keyNameToKeyID.end()) { - // Use the default key if the key is unknown. - Log("Keymapper: unknown key '{}'", iniValue); - SetValue(defaultKey); - return; - } - if (key == "ChatLog" && iniValue == "L") { + // Migration: `ToggleStashFocus` previously defaulted to `TAB`, which conflicts + // with `ToggleAutomap` and can result in TAB doing nothing (e.g. when the action + // is disabled by a menu state). Unbind it when configured as TAB. + if (key == "ToggleStashFocus" && iniValue == "TAB") { SetValue(defaultKey); return; } - // Store the key in action.key and in the map so we can save() the - // actions while keeping the same order as they have been added. - SetValue(keyIt->second); -} + auto keyIt = GetOptions().Keymapper.keyNameToKeyID.find(iniValue); + if (keyIt == GetOptions().Keymapper.keyNameToKeyID.end()) { + // Use the default key if the key is unknown. + Log("Keymapper: unknown key '{}'", iniValue); + SetValue(defaultKey); + return; + } + + if (key == "ChatLog" && iniValue == "L") { + SetValue(defaultKey); + return; + } + + // Store the key in action.key and in the map so we can save() the + // actions while keeping the same order as they have been added. + SetValue(keyIt->second); +} void KeymapperOptions::Action::SaveToIni(std::string_view category) const { if (boundKey == SDLK_UNKNOWN) { @@ -1248,16 +1277,16 @@ void KeymapperOptions::Action::SaveToIni(std::string_view category) const ini->set(category, key, keyNameIt->second); } -std::string_view KeymapperOptions::Action::GetValueDescription() const -{ - if (boundKey == SDLK_UNKNOWN) - return _("Unbound"); - auto keyNameIt = GetOptions().Keymapper.keyIDToKeyName.find(boundKey); - if (keyNameIt == GetOptions().Keymapper.keyIDToKeyName.end()) { - return ""; - } - return keyNameIt->second; -} +std::string_view KeymapperOptions::Action::GetValueDescription() const +{ + if (boundKey == SDLK_UNKNOWN) + return _("Unbound"); + auto keyNameIt = GetOptions().Keymapper.keyIDToKeyName.find(boundKey); + if (keyNameIt == GetOptions().Keymapper.keyIDToKeyName.end()) { + return ""; + } + return keyNameIt->second; +} bool KeymapperOptions::Action::SetValue(int value) { diff --git a/Source/options.h b/Source/options.h index fa2ee0199ee..3bfe4566445 100644 --- a/Source/options.h +++ b/Source/options.h @@ -619,12 +619,14 @@ struct GameplayOptions : OptionCategoryBase { OptionEntryBoolean showItemLabels; /** @brief Refill belt from inventory, or rather, use potions/scrolls from inventory first when belt item is consumed. */ OptionEntryBoolean autoRefillBelt; - /** @brief Locally disable clicking on shrines which permanently cripple character. */ - OptionEntryBoolean disableCripplingShrines; - /** @brief Spell hotkeys instantly cast the spell. */ - OptionEntryBoolean quickCast; - /** @brief Number of Healing potions to pick up automatically */ - OptionEntryInt numHealPotionPickup; + /** @brief Locally disable clicking on shrines which permanently cripple character. */ + OptionEntryBoolean disableCripplingShrines; + /** @brief Spell hotkeys instantly cast the spell. */ + OptionEntryBoolean quickCast; + /** @brief Read subtitle text aloud when NPC dialog is shown. */ + OptionEntryBoolean speakNpcDialogText; + /** @brief Number of Healing potions to pick up automatically */ + OptionEntryInt numHealPotionPickup; /** @brief Number of Full Healing potions to pick up automatically */ OptionEntryInt numFullHealPotionPickup; /** @brief Number of Mana potions to pick up automatically */ diff --git a/Source/panels/spell_list.cpp b/Source/panels/spell_list.cpp index cf3c18b0088..75667985f36 100644 --- a/Source/panels/spell_list.cpp +++ b/Source/panels/spell_list.cpp @@ -1,8 +1,9 @@ -#include "panels/spell_list.hpp" - -#include - -#include +#include "panels/spell_list.hpp" + +#include +#include + +#include #include "control/control.hpp" #include "controls/control_mode.hpp" @@ -11,8 +12,9 @@ #include "engine/palette.h" #include "engine/render/primitive_render.hpp" #include "engine/render/text_render.hpp" -#include "inv_iterators.hpp" -#include "options.h" +#include "inv_iterators.hpp" +#include "missiles.h" +#include "options.h" #include "panels/spell_icons.hpp" #include "player.h" #include "spells.h" @@ -67,7 +69,7 @@ bool GetSpellListSelection(SpellID &pSpell, SpellType &pSplType) return false; } -std::optional GetHotkeyName(SpellID spellId, SpellType spellType, bool useShortName = false) +std::optional GetHotkeyName(SpellID spellId, SpellType spellType, bool useShortName = false) { const Player &myPlayer = *MyPlayer; for (size_t t = 0; t < NumHotkeys; t++) { @@ -78,13 +80,99 @@ std::optional GetHotkeyName(SpellID spellId, SpellType spellTy return GetOptions().Padmapper.InputNameForAction(quickSpellActionKey, useShortName); return GetOptions().Keymapper.KeyNameForAction(quickSpellActionKey); } - return {}; -} - -} // namespace - -void DrawSpell(const Surface &out) -{ + return {}; +} + +std::string GetSpellPowerTextForSpeech(SpellID spell, int spellLevel) +{ + if (spellLevel == 0) { + return {}; + } + if (spell == SpellID::BoneSpirit) { + return std::string(_(/* TRANSLATORS: UI constraints, keep short please.*/ "Dmg: 1/3 target hp")); + } + const auto [min, max] = GetDamageAmt(spell, spellLevel); + if (min == -1) { + return {}; + } + if (spell == SpellID::Healing || spell == SpellID::HealOther) { + return fmt::format(fmt::runtime(_(/* TRANSLATORS: UI constraints, keep short please.*/ "Heals: {:d} - {:d}")), min, max); + } + return fmt::format(fmt::runtime(_(/* TRANSLATORS: UI constraints, keep short please.*/ "Damage: {:d} - {:d}")), min, max); +} + +void AppendSpellSpeechSegment(std::string &speech, std::string_view segment) +{ + if (segment.empty()) { + return; + } + if (!speech.empty()) { + StrAppend(speech, ", "); + } + StrAppend(speech, segment); +} + +} // namespace + +std::string BuildSpellDetailsForSpeech(const Player &player, SpellID spellId, SpellType spellType) +{ + if (!IsValidSpell(spellId)) { + return std::string(_("No spell selected.")); + } + + if (spellId == GetPlayerStartingLoadoutForClass(player._pClass).skill) { + spellType = SpellType::Skill; + } + + const std::string_view spellName = pgettext("spell", GetSpellData(spellId).sNameText); + std::string speech; + + switch (spellType) { + case SpellType::Skill: + AppendSpellSpeechSegment(speech, fmt::format(fmt::runtime(_("{:s} Skill")), spellName)); + break; + case SpellType::Spell: { + AppendSpellSpeechSegment(speech, fmt::format(fmt::runtime(_("{:s} Spell")), spellName)); + if (spellId == SpellID::HolyBolt) { + AppendSpellSpeechSegment(speech, _("Damages undead only")); + } + const int spellLevel = player.GetSpellLevel(spellId); + AppendSpellSpeechSegment(speech, + spellLevel == 0 ? std::string(_("Spell Level 0 - Unusable")) : fmt::format(fmt::runtime(_("Spell Level {:d}")), spellLevel)); + if (spellLevel > 0) { + const std::string power = GetSpellPowerTextForSpeech(spellId, spellLevel); + if (!power.empty()) { + AppendSpellSpeechSegment(speech, power); + } + const int mana = GetManaAmount(player, spellId) >> 6; + AppendSpellSpeechSegment(speech, fmt::format(fmt::runtime(pgettext("spellbook", "Mana: {:d}")), mana)); + } + } break; + case SpellType::Scroll: { + AppendSpellSpeechSegment(speech, fmt::format(fmt::runtime(_("Scroll of {:s}")), spellName)); + const int scrollCount = c_count_if(InventoryAndBeltPlayerItemsRange { player }, [spellId](const Item &item) { + return item.isScrollOf(spellId); + }); + AppendSpellSpeechSegment(speech, fmt::format(fmt::runtime(ngettext("{:d} Scroll", "{:d} Scrolls", scrollCount)), scrollCount)); + } break; + case SpellType::Charges: { + AppendSpellSpeechSegment(speech, fmt::format(fmt::runtime(_("Staff of {:s}")), spellName)); + const int charges = player.InvBody[INVLOC_HAND_LEFT]._iCharges; + AppendSpellSpeechSegment(speech, fmt::format(fmt::runtime(ngettext("{:d} Charge", "{:d} Charges", charges)), charges)); + } break; + case SpellType::Invalid: + AppendSpellSpeechSegment(speech, spellName); + break; + } + + if (speech.empty()) { + speech = spellName; + } + return speech; +} + +void DrawSpell(const Surface &out) +{ const Player &myPlayer = *MyPlayer; SpellID spl = myPlayer._pRSpell; SpellType st = myPlayer._pRSplType; @@ -329,7 +417,7 @@ void ToggleSpell(size_t slot) myPlayer._pRSpell = myPlayer._pSplHotKey[slot]; myPlayer._pRSplType = myPlayer._pSplTHotKey[slot]; RedrawEverything(); - SpeakText(pgettext("spell", GetSpellData(myPlayer._pRSpell).sNameText), /*force=*/true); + SpeakText(BuildSpellDetailsForSpeech(myPlayer, myPlayer._pRSpell, myPlayer._pRSplType), /*force=*/true); } } diff --git a/Source/panels/spell_list.hpp b/Source/panels/spell_list.hpp index 0b706f96aaf..0bec141aaf7 100644 --- a/Source/panels/spell_list.hpp +++ b/Source/panels/spell_list.hpp @@ -1,7 +1,8 @@ -#pragma once - -#include -#include +#pragma once + +#include +#include +#include #include "engine/point.hpp" #include "engine/surface.hpp" @@ -9,12 +10,14 @@ namespace devilution { -struct SpellListItem { - Point location; - SpellType type; - SpellID id; - bool isSelected; -}; +struct SpellListItem { + Point location; + SpellType type; + SpellID id; + bool isSelected; +}; + +struct Player; /** * @brief draws the current right mouse button spell. @@ -25,8 +28,9 @@ void DrawSpellList(const Surface &out); std::vector GetSpellListItems(); void SetSpell(); void SetSpeedSpell(size_t slot); -bool IsValidSpeedSpell(size_t slot); -void ToggleSpell(size_t slot); +bool IsValidSpeedSpell(size_t slot); +void ToggleSpell(size_t slot); +std::string BuildSpellDetailsForSpeech(const Player &player, SpellID spellId, SpellType spellType); /** * Draws the "Speed Book": the rows of known spells for quick-setting a spell that diff --git a/Source/platform/android/CLAUDE.md b/Source/platform/android/CLAUDE.md new file mode 100644 index 00000000000..e8b7cf08a4c --- /dev/null +++ b/Source/platform/android/CLAUDE.md @@ -0,0 +1,12 @@ + +# Recent Activity + + + +### Feb 5, 2026 + +| ID | Time | T | Title | Read | +|----|------|---|-------|------| +| #4 | 12:26 PM | 🔵 | Android accessibility function interface declared | ~183 | +| #3 | " | 🔵 | Android accessibility JNI bridge implementation found | ~229 | + \ No newline at end of file diff --git a/Source/platform/android/android.cpp b/Source/platform/android/android.cpp index fddb850defe..d1fbfdac935 100644 --- a/Source/platform/android/android.cpp +++ b/Source/platform/android/android.cpp @@ -2,21 +2,184 @@ #include "mpq/mpq_reader.hpp" #include +#include namespace devilution { -namespace { -bool AreExtraFontsOutOfDateForMpqPath(const char *mpqPath) +// Global Java VM pointer - set during JNI initialization +static JavaVM *g_jvm = nullptr; +static jobject g_activity = nullptr; + +// JNI method cache for accessibility functions +struct AndroidJNIMethods { + jmethodID isScreenReaderEnabled; + jmethodID accessibilitySpeak; + bool initialized; +} g_jniMethods = { nullptr, nullptr, false }; + +// Thread-local storage for JNIEnv +static pthread_key_t g_jniEnvKey; + +// Initialize JNI environment key +static void JNIKeyDestructor(void *env) +{ + // Don't detach - let SDL handle it +} + +static void InitializeJNIKey() +{ + static bool initialized = false; + if (initialized) + return; + + pthread_key_create(&g_jniEnvKey, JNIKeyDestructor); + initialized = true; +} + +// Get JNI environment for current thread +static JNIEnv* GetJNI() +{ + InitializeJNIKey(); + + JNIEnv *env = (JNIEnv *)pthread_getspecific(g_jniEnvKey); + if (env) + return env; + + if (g_jvm == nullptr) + return nullptr; + + // Get or attach the current thread + int status = g_jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + if (status == JNI_EDETACHED) { + // Thread not attached, attach it + status = g_jvm->AttachCurrentThread(&env, nullptr); + if (status < 0) + return nullptr; + pthread_setspecific(g_jniEnvKey, env); + } else if (status != JNI_OK) { + return nullptr; + } + + return env; +} + +static bool AreExtraFontsOutOfDateForMpqPath(const char *mpqPath) { int32_t error = 0; std::optional archive = MpqArchive::Open(mpqPath, error); return error == 0 && archive && AreExtraFontsOutOfDate(*archive); } -} // namespace +// Initialize JNI method IDs for accessibility +// This should be called once during initialization +static void InitializeAccessibilityJNI(JNIEnv *env) +{ + if (g_jniMethods.initialized) + return; + + // Get the DevilutionXSDLActivity class + jclass activityClass = env->FindClass("org/diasurgical/devilutionx/DevilutionXSDLActivity"); + if (activityClass == nullptr) { + return; + } + + // Cache method IDs for accessibility functions + g_jniMethods.isScreenReaderEnabled = env->GetMethodID(activityClass, "isScreenReaderEnabled", "()Z"); + g_jniMethods.accessibilitySpeak = env->GetMethodID(activityClass, "accessibilitySpeak", "(Ljava/lang/String;)V"); + + if (g_jniMethods.isScreenReaderEnabled && g_jniMethods.accessibilitySpeak) { + g_jniMethods.initialized = true; + } + + env->DeleteLocalRef(activityClass); +} + +// Public accessibility functions for Android +namespace accessibility { + +bool InitializeScreenReaderAndroid() +{ + // JNI is initialized when nativeInitAccessibility is called from Java + // This function is kept for compatibility but the actual initialization + // happens in Java_org_diasurgical_devilutionx_DevilutionXSDLActivity_nativeInitAccessibility + return g_jniMethods.initialized; +} + +void ShutDownScreenReaderAndroid() +{ + // Clean up the activity reference + if (g_activity != nullptr) { + JNIEnv *env = GetJNI(); + if (env != nullptr) { + env->DeleteGlobalRef(g_activity); + } + g_activity = nullptr; + } + + g_jniMethods.initialized = false; + g_jniMethods.isScreenReaderEnabled = nullptr; + g_jniMethods.accessibilitySpeak = nullptr; +} + +void SpeakTextAndroid(const char *text) +{ + if (!g_jniMethods.initialized) + return; + + JNIEnv *env = GetJNI(); + if (env == nullptr || g_activity == nullptr) + return; + + // Create a Java string from the text + jstring jText = env->NewStringUTF(text); + if (jText == nullptr) + return; + + // Call the accessibilitySpeak method + env->CallVoidMethod(g_activity, g_jniMethods.accessibilitySpeak, jText); + + // Clean up + env->DeleteLocalRef(jText); +} + +bool IsScreenReaderEnabledAndroid() +{ + if (!g_jniMethods.initialized) + return false; + + JNIEnv *env = GetJNI(); + if (env == nullptr || g_activity == nullptr) + return false; + + // Call the isScreenReaderEnabled method + jboolean result = env->CallBooleanMethod(g_activity, g_jniMethods.isScreenReaderEnabled); + + return result == JNI_TRUE; +} + +} // namespace accessibility } // namespace devilution +// JNI initialization function called from Java during Activity initialization extern "C" { +JNIEXPORT void JNICALL Java_org_diasurgical_devilutionx_DevilutionXSDLActivity_nativeInitAccessibility( + JNIEnv *env, jobject thiz) +{ + // Store the Java VM pointer + if (devilution::g_jvm == nullptr) { + env->GetJavaVM(&devilution::g_jvm); + } + + // Store a global reference to the activity + if (devilution::g_activity != nullptr) { + env->DeleteGlobalRef(devilution::g_activity); + } + devilution::g_activity = env->NewGlobalRef(thiz); + + // Initialize the JNI method cache + devilution::InitializeAccessibilityJNI(env); +} + JNIEXPORT jboolean JNICALL Java_org_diasurgical_devilutionx_DevilutionXSDLActivity_areFontsOutOfDate(JNIEnv *env, jclass cls, jstring fonts_mpq) { const char *mpqPath = env->GetStringUTFChars(fonts_mpq, nullptr); diff --git a/Source/platform/android/android.hpp b/Source/platform/android/android.hpp new file mode 100644 index 00000000000..e3bd4ed6444 --- /dev/null +++ b/Source/platform/android/android.hpp @@ -0,0 +1,14 @@ +#pragma once + +namespace devilution { +namespace accessibility { + +#ifdef __ANDROID__ +bool InitializeScreenReaderAndroid(); +void ShutDownScreenReaderAndroid(); +void SpeakTextAndroid(const char *text); +bool IsScreenReaderEnabledAndroid(); +#endif + +} // namespace accessibility +} // namespace devilution diff --git a/Source/utils/CLAUDE.md b/Source/utils/CLAUDE.md new file mode 100644 index 00000000000..6a7d38f7464 --- /dev/null +++ b/Source/utils/CLAUDE.md @@ -0,0 +1,11 @@ + +# Recent Activity + + + +### Feb 5, 2026 + +| ID | Time | T | Title | Read | +|----|------|---|-------|------| +| #2 | 12:26 PM | 🔵 | Screen reader platform abstraction architecture identified | ~245 | + \ No newline at end of file diff --git a/Source/utils/accessibility_announcements.cpp b/Source/utils/accessibility_announcements.cpp new file mode 100644 index 00000000000..d1e48c58215 --- /dev/null +++ b/Source/utils/accessibility_announcements.cpp @@ -0,0 +1,515 @@ +/** + * @file utils/accessibility_announcements.cpp + * + * Periodic accessibility announcements (low HP warning, durability, boss health, + * attackable monsters, interactable doors). + */ +#include "utils/accessibility_announcements.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include "controls/plrctrls.h" +#include "engine/sound.h" +#include "gamemenu.h" +#include "inv.h" +#include "items.h" +#include "levels/gendung.h" +#include "monster.h" +#include "objects.h" +#include "player.h" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/log.hpp" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" +#include "utils/string_or_view.hpp" + +namespace devilution { + +#ifdef NOSOUND +void UpdatePlayerLowHpWarningSound() +{ +} +#else +namespace { + +std::unique_ptr PlayerLowHpWarningSound; +bool TriedLoadingPlayerLowHpWarningSound = false; + +TSnd *GetPlayerLowHpWarningSound() +{ + if (TriedLoadingPlayerLowHpWarningSound) + return PlayerLowHpWarningSound.get(); + TriedLoadingPlayerLowHpWarningSound = true; + + if (!gbSndInited) + return nullptr; + + PlayerLowHpWarningSound = std::make_unique(); + PlayerLowHpWarningSound->start_tc = SDL_GetTicks() - 80 - 1; + + // Support both the new "playerhaslowhp" name and the older underscore version. + if (PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\playerhaslowhp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\playerhaslowhp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\player_has_low_hp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\player_has_low_hp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\playerhaslowhp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\playerhaslowhp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\player_has_low_hp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\player_has_low_hp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\playerhaslowhp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\playerhaslowhp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\player_has_low_hp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\player_has_low_hp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0) { + LogWarn("Failed to load low HP warning sound from any of the expected paths."); + 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 StringOrView label = MonsterLabelForSpeech(Monsters[*bestId]); + if (!label.empty()) + SpeakText(label.str(), /*force=*/true); +} + +StringOrView MonsterLabelForSpeech(const Monster &monster) +{ + const std::string_view name = monster.name(); + if (name.empty()) + return name; + + std::string_view type; + switch (monster.data().monsterClass) { + case MonsterClass::Animal: + type = _("Animal"); + break; + case MonsterClass::Demon: + type = _("Demon"); + break; + case MonsterClass::Undead: + type = _("Undead"); + break; + } + + if (type.empty()) + return name; + return StrCat(name, ", ", type); +} + +StringOrView DoorLabelForSpeech(const Object &door) +{ + if (!door.isDoor()) + return door.name(); + + // Catacombs doors are grates, so differentiate them for the screen reader / tracker. + if (IsAnyOf(door._otype, _object_id::OBJ_L2LDOOR, _object_id::OBJ_L2RDOOR)) { + if (door._oVar4 == DOOR_OPEN) + return _("Open Grate Door"); + if (door._oVar4 == DOOR_CLOSED) + return _("Closed Grate Door"); + if (door._oVar4 == DOOR_BLOCKED) + return _("Blocked Grate Door"); + return _("Grate Door"); + } + + return door.name(); +} + +void UpdateInteractableDoorAnnouncements() +{ + static std::optional LastInteractableDoorId; + static std::optional LastInteractableDoorState; + + if (MyPlayer == nullptr) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + if (leveltype == DTYPE_TOWN) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + if (InGameMenu() || invflag) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + + const Player &player = *MyPlayer; + const Point playerPosition = player.position.tile; + + std::optional bestId; + int bestRotations = 5; + int bestDistance = 0; + + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + if (dx == 0 && dy == 0) + continue; + + const Point pos = playerPosition + Displacement { dx, dy }; + if (!InDungeonBounds(pos)) + continue; + + const int objectId = std::abs(dObject[pos.x][pos.y]) - 1; + if (objectId < 0 || objectId >= MAXOBJECTS) + continue; + + const Object &door = Objects[objectId]; + if (!door.isDoor() || !door.canInteractWith()) + continue; + + const int distance = playerPosition.WalkingDistance(door.position); + if (distance > 1) + continue; + + const int d1 = static_cast(player._pdir); + const int d2 = static_cast(GetDirection(playerPosition, door.position)); + + int rotations = std::abs(d1 - d2); + if (rotations > 4) + rotations = 4 - (rotations % 4); + + if (!bestId || rotations < bestRotations || (rotations == bestRotations && distance < bestDistance) + || (rotations == bestRotations && distance == bestDistance && objectId < *bestId)) { + bestRotations = rotations; + bestDistance = distance; + bestId = objectId; + } + } + } + + if (!bestId) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + + const Object &door = Objects[*bestId]; + const int state = door._oVar4; + if (LastInteractableDoorId && LastInteractableDoorState && *LastInteractableDoorId == *bestId && *LastInteractableDoorState == state) + return; + + LastInteractableDoorId = *bestId; + LastInteractableDoorState = state; + + const StringOrView label = DoorLabelForSpeech(door); + if (!label.empty()) + SpeakText(label.str(), /*force=*/true); +} + +} // namespace devilution diff --git a/Source/utils/accessibility_announcements.hpp b/Source/utils/accessibility_announcements.hpp new file mode 100644 index 00000000000..d4d3d99f728 --- /dev/null +++ b/Source/utils/accessibility_announcements.hpp @@ -0,0 +1,24 @@ +/** + * @file utils/accessibility_announcements.hpp + * + * Periodic accessibility announcements (low HP warning, durability, boss health, + * attackable monsters, interactable doors). + */ +#pragma once + +#include "utils/string_or_view.hpp" + +namespace devilution { + +struct Monster; +struct Object; + +void UpdatePlayerLowHpWarningSound(); +void UpdateLowDurabilityWarnings(); +void UpdateBossHealthAnnouncements(); +void UpdateAttackableMonsterAnnouncements(); +StringOrView MonsterLabelForSpeech(const Monster &monster); +StringOrView DoorLabelForSpeech(const Object &door); +void UpdateInteractableDoorAnnouncements(); + +} // namespace devilution diff --git a/Source/utils/navigation_speech.cpp b/Source/utils/navigation_speech.cpp new file mode 100644 index 00000000000..1b26a7f70cb --- /dev/null +++ b/Source/utils/navigation_speech.cpp @@ -0,0 +1,685 @@ +/** + * @file utils/navigation_speech.cpp + * + * Navigation speech: exit/stairs/portal/unexplored speech and keyboard walk keys. + */ +#include "utils/navigation_speech.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include "automap.h" +#include "control/control.hpp" +#include "controls/accessibility_keys.hpp" +#include "controls/plrctrls.h" +#include "diablo.h" +#include "help.h" +#include "inv.h" +#include "levels/gendung.h" +#include "levels/setmaps.h" +#include "levels/tile_properties.hpp" +#include "levels/trigs.h" +#include "minitext.h" +#include "missiles.h" +#include "multi.h" +#include "player.h" +#include "portal.h" +#include "qol/chatlog.h" +#include "qol/stash.h" +#include "quests.h" +#include "stores.h" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" +#include "utils/sdl_compat.h" +#include "utils/walk_path_speech.hpp" + +namespace devilution { + +namespace { + +std::optional FindNearestUnexploredTile(Point startPosition) +{ + if (!InDungeonBounds(startPosition)) + return std::nullopt; + + std::array visited {}; + std::queue queue; + + const auto enqueue = [&](Point position) { + if (!InDungeonBounds(position)) + return; + + const size_t index = static_cast(position.x) + static_cast(position.y) * MAXDUNX; + if (visited[index]) + return; + + if (!IsTileWalkable(position, /*ignoreDoors=*/true)) + return; + + visited[index] = true; + queue.push(position); + }; + + enqueue(startPosition); + + constexpr std::array Neighbors = { + Direction::NorthEast, + Direction::SouthWest, + Direction::SouthEast, + Direction::NorthWest, + }; + + while (!queue.empty()) { + const Point position = queue.front(); + queue.pop(); + + if (!HasAnyOf(dFlags[position.x][position.y], DungeonFlag::Explored)) + return position; + + for (const Direction dir : Neighbors) { + enqueue(position + dir); + } + } + + return std::nullopt; +} + +std::optional LockedTownDungeonTriggerIndex; + +std::optional FindDefaultTownDungeonTriggerIndex(const std::vector &candidates) +{ + for (const int index : candidates) { + if (trigs[index]._tmsg == WM_DIABNEXTLVL) + return index; + } + if (!candidates.empty()) + return candidates.front(); + return std::nullopt; +} + +std::optional FindLockedTownDungeonTriggerIndex(const std::vector &candidates) +{ + if (!LockedTownDungeonTriggerIndex) + return std::nullopt; + if (std::find(candidates.begin(), candidates.end(), *LockedTownDungeonTriggerIndex) != candidates.end()) + return *LockedTownDungeonTriggerIndex; + return std::nullopt; +} + +std::optional FindNextTownDungeonTriggerIndex(const std::vector &candidates, int current) +{ + if (candidates.empty()) + return std::nullopt; + + const auto it = std::find(candidates.begin(), candidates.end(), current); + if (it == candidates.end()) + return candidates.front(); + if (std::next(it) == candidates.end()) + return candidates.front(); + return *std::next(it); +} + +std::optional FindNearestTriggerIndexWithMessage(int message) +{ + if (numtrigs <= 0 || MyPlayer == nullptr) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + std::optional bestIndex; + int bestDistance = 0; + + for (int i = 0; i < numtrigs; ++i) { + if (trigs[i]._tmsg != message) + continue; + + const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + if (!bestIndex || distance < bestDistance) { + bestIndex = i; + bestDistance = distance; + } + } + + return bestIndex; +} + +std::optional FindNearestTownPortalOnCurrentLevel() +{ + if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + const int currentLevel = setlevel ? static_cast(setlvlnum) : currlevel; + + std::optional bestPosition; + int bestDistance = 0; + + for (int i = 0; i < MAXPORTAL; ++i) { + const Portal &portal = Portals[i]; + if (!portal.open) + continue; + if (portal.setlvl != setlevel) + continue; + if (portal.level != currentLevel) + continue; + + const int distance = playerPosition.WalkingDistance(portal.position); + if (!bestPosition || distance < bestDistance) { + bestPosition = portal.position; + bestDistance = distance; + } + } + + return bestPosition; +} + +struct TownPortalInTown { + int portalIndex; + Point position; + int distance; +}; + +std::optional FindNearestTownPortalInTown() +{ + if (MyPlayer == nullptr || leveltype != DTYPE_TOWN) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + + std::optional best; + int bestDistance = 0; + + for (const Missile &missile : Missiles) { + if (missile._mitype != MissileID::TownPortal) + continue; + if (missile._misource < 0 || missile._misource >= MAXPORTAL) + continue; + if (!Portals[missile._misource].open) + continue; + + const Point portalPosition = missile.position.tile; + const int distance = playerPosition.WalkingDistance(portalPosition); + if (!best || distance < bestDistance) { + best = TownPortalInTown { + .portalIndex = missile._misource, + .position = portalPosition, + .distance = distance, + }; + bestDistance = distance; + } + } + + return best; +} + +struct QuestSetLevelEntrance { + _setlevels questLevel; + Point entrancePosition; + int distance; +}; + +std::optional FindNearestQuestSetLevelEntranceOnCurrentLevel() +{ + if (MyPlayer == nullptr || setlevel) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + std::optional best; + int bestDistance = 0; + + for (const Quest &quest : Quests) { + if (quest._qslvl == SL_NONE) + continue; + if (quest._qactive == QUEST_NOTAVAIL) + continue; + if (quest._qlevel != currlevel) + continue; + if (!InDungeonBounds(quest.position)) + continue; + + const int distance = playerPosition.WalkingDistance(quest.position); + if (!best || distance < bestDistance) { + best = QuestSetLevelEntrance { + .questLevel = quest._qslvl, + .entrancePosition = quest.position, + .distance = distance, + }; + bestDistance = distance; + } + } + + return best; +} + +void SpeakNearestStairsKeyPressed(int triggerMessage) +{ + if (!CanPlayerTakeAction()) + return; + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (leveltype == DTYPE_TOWN) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(triggerMessage); + if (!triggerIndex) { + SpeakText(_("No exits found."), true); + return; + } + + const TriggerStruct &trigger = trigs[*triggerIndex]; + const Point startPosition = MyPlayer->position.future; + const Point targetPosition { trigger.position.x, trigger.position.y }; + + std::string message; + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + if (!path) { + AppendDirectionalFallback(message, targetPosition - startPosition); + } else { + AppendKeyboardWalkPathForSpeech(message, *path); + } + + SpeakText(message, true); +} + +void KeyboardWalkKeyPressed(Direction direction) +{ + CancelAutoWalk(); + if (!IsKeyboardWalkAllowed()) + return; + + if (MyPlayer == nullptr) + return; + + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, MyPlayer->position.future + direction); +} + +} // namespace + +std::string TriggerLabelForSpeech(const TriggerStruct &trigger) +{ + switch (trigger._tmsg) { + case WM_DIABNEXTLVL: + if (leveltype == DTYPE_TOWN) + return std::string { _("Cathedral entrance") }; + return std::string { _("Stairs down") }; + case WM_DIABPREVLVL: + return std::string { _("Stairs up") }; + case WM_DIABTOWNWARP: + switch (trigger._tlvl) { + case 5: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Catacombs")); + case 9: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Caves")); + case 13: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Hell")); + case 17: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Nest")); + case 21: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Crypt")); + default: + return fmt::format(fmt::runtime(_("Town warp to level {:d}")), trigger._tlvl); + } + case WM_DIABTWARPUP: + return std::string { _("Warp up") }; + case WM_DIABRETOWN: + return std::string { _("Return to town") }; + case WM_DIABWARPLVL: + return std::string { _("Warp") }; + case WM_DIABSETLVL: + return std::string { _("Set level") }; + case WM_DIABRTNLVL: + return std::string { _("Return level") }; + default: + return std::string { _("Exit") }; + } +} + +std::string TownPortalLabelForSpeech(const Portal &portal) +{ + if (portal.level <= 0) + return std::string { _("Town portal") }; + + if (portal.setlvl) { + const auto questLevel = static_cast<_setlevels>(portal.level); + const char *questLevelName = QuestLevelNames[questLevel]; + if (questLevelName == nullptr || questLevelName[0] == '\0') + return std::string { _("Town portal to set level") }; + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a set/quest level name. */ "Town portal to {:s}")), _(questLevelName)); + } + + constexpr std::array DungeonStrs = { + N_("Town"), + N_("Cathedral"), + N_("Catacombs"), + N_("Caves"), + N_("Hell"), + N_("Nest"), + N_("Crypt"), + }; + std::string dungeonStr; + if (portal.ltype >= DTYPE_TOWN && portal.ltype <= DTYPE_LAST) { + dungeonStr = _(DungeonStrs[static_cast(portal.ltype)]); + } else { + dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None"); + } + + int floor = portal.level; + if (portal.ltype == DTYPE_CATACOMBS) + floor -= 4; + else if (portal.ltype == DTYPE_CAVES) + floor -= 8; + else if (portal.ltype == DTYPE_HELL) + floor -= 12; + else if (portal.ltype == DTYPE_NEST) + floor -= 16; + else if (portal.ltype == DTYPE_CRYPT) + floor -= 20; + + if (floor > 0) + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name and {:d} is a floor number. */ "Town portal to {:s} {:d}")), dungeonStr, floor); + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name. */ "Town portal to {:s}")), dungeonStr); +} + +std::vector CollectTownDungeonTriggerIndices() +{ + std::vector result; + result.reserve(static_cast(std::max(0, numtrigs))); + + for (int i = 0; i < numtrigs; ++i) { + if (IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP)) + result.push_back(i); + } + + std::sort(result.begin(), result.end(), [](int a, int b) { + const TriggerStruct &ta = trigs[a]; + const TriggerStruct &tb = trigs[b]; + + const int kindA = ta._tmsg == WM_DIABNEXTLVL ? 0 : (ta._tmsg == WM_DIABTOWNWARP ? 1 : 2); + const int kindB = tb._tmsg == WM_DIABNEXTLVL ? 0 : (tb._tmsg == WM_DIABTOWNWARP ? 1 : 2); + if (kindA != kindB) + return kindA < kindB; + + if (ta._tmsg == WM_DIABTOWNWARP && tb._tmsg == WM_DIABTOWNWARP && ta._tlvl != tb._tlvl) + return ta._tlvl < tb._tlvl; + + return a < b; + }); + + return result; +} + +void SpeakNearestExitKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const Point startPosition = MyPlayer->position.future; + + const SDL_Keymod modState = SDL_GetModState(); + const bool seekQuestEntrance = (modState & SDL_KMOD_SHIFT) != 0; + const bool cycleTownDungeon = (modState & SDL_KMOD_CTRL) != 0; + + if (seekQuestEntrance) { + if (setlevel) { + const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(WM_DIABRTNLVL); + if (!triggerIndex) { + SpeakText(_("No quest exits found."), true); + return; + } + + const TriggerStruct &trigger = trigs[*triggerIndex]; + const Point targetPosition { trigger.position.x, trigger.position.y }; + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + std::string message = TriggerLabelForSpeech(trigger); + if (!message.empty()) + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); + return; + } + + if (const std::optional entrance = FindNearestQuestSetLevelEntranceOnCurrentLevel(); entrance) { + const Point targetPosition = entrance->entrancePosition; + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + + std::string message { _(QuestLevelNames[entrance->questLevel]) }; + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); + return; + } + + SpeakText(_("No quest entrances found."), true); + return; + } + + if (leveltype == DTYPE_TOWN) { + const std::vector dungeonCandidates = CollectTownDungeonTriggerIndices(); + if (dungeonCandidates.empty()) { + SpeakText(_("No exits found."), true); + return; + } + + if (cycleTownDungeon) { + if (dungeonCandidates.size() <= 1) { + SpeakText(_("No other dungeon entrances found."), true); + return; + } + + const int current = LockedTownDungeonTriggerIndex.value_or(-1); + const std::optional next = FindNextTownDungeonTriggerIndex(dungeonCandidates, current); + if (!next) { + SpeakText(_("No other dungeon entrances found."), true); + return; + } + + LockedTownDungeonTriggerIndex = *next; + const std::string label = TriggerLabelForSpeech(trigs[*next]); + if (!label.empty()) + SpeakText(label, true); + return; + } + + const int triggerIndex = FindLockedTownDungeonTriggerIndex(dungeonCandidates) + .value_or(FindDefaultTownDungeonTriggerIndex(dungeonCandidates).value_or(dungeonCandidates.front())); + LockedTownDungeonTriggerIndex = triggerIndex; + + const TriggerStruct &trigger = trigs[triggerIndex]; + const Point targetPosition { trigger.position.x, trigger.position.y }; + + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + std::string message = TriggerLabelForSpeech(trigger); + if (!message.empty()) + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + + SpeakText(message, true); + return; + } + + if (const std::optional portalPosition = FindNearestTownPortalOnCurrentLevel(); portalPosition) { + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, *portalPosition); + std::string message { _("Return to town") }; + message.append(": "); + if (!path) + AppendDirectionalFallback(message, *portalPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); + return; + } + + const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(WM_DIABPREVLVL); + if (!triggerIndex) { + SpeakText(_("No exits found."), true); + return; + } + + const TriggerStruct &trigger = trigs[*triggerIndex]; + const Point targetPosition { trigger.position.x, trigger.position.y }; + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + std::string message = TriggerLabelForSpeech(trigger); + if (!message.empty()) + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); +} + +void SpeakNearestTownPortalInTownKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (leveltype != DTYPE_TOWN) { + SpeakText(_("Not in town."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const std::optional portal = FindNearestTownPortalInTown(); + if (!portal) { + SpeakText(_("No town portals found."), true); + return; + } + + const Point startPosition = MyPlayer->position.future; + const Point targetPosition = portal->position; + + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + + std::string message = TownPortalLabelForSpeech(Portals[portal->portalIndex]); + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + + SpeakText(message, true); +} + +void SpeakNearestStairsDownKeyPressed() +{ + SpeakNearestStairsKeyPressed(WM_DIABNEXTLVL); +} + +void SpeakNearestStairsUpKeyPressed() +{ + SpeakNearestStairsKeyPressed(WM_DIABPREVLVL); +} + +bool IsKeyboardWalkAllowed() +{ + return CanPlayerTakeAction() + && !InGameMenu() + && !IsPlayerInStore() + && !QuestLogIsOpen + && !HelpFlag + && !ChatLogFlag + && !ChatFlag + && !DropGoldFlag + && !IsStashOpen + && !IsWithdrawGoldOpen + && !AutomapActive + && !invflag + && !CharFlag + && !SpellbookFlag + && !SpellSelectFlag + && !qtextflag; +} + +void KeyboardWalkNorthKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::NorthEast); +} + +void KeyboardWalkSouthKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::SouthWest); +} + +void KeyboardWalkEastKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::SouthEast); +} + +void KeyboardWalkWestKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::NorthWest); +} + +void SpeakNearestUnexploredTileKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (leveltype == DTYPE_TOWN) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const Point startPosition = MyPlayer->position.future; + const std::optional target = FindNearestUnexploredTile(startPosition); + if (!target) { + SpeakText(_("No unexplored areas found."), true); + return; + } + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, *target); + std::string message; + if (!path) + AppendDirectionalFallback(message, *target - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + + SpeakText(message, true); +} + +} // namespace devilution diff --git a/Source/utils/navigation_speech.hpp b/Source/utils/navigation_speech.hpp new file mode 100644 index 00000000000..c833671a009 --- /dev/null +++ b/Source/utils/navigation_speech.hpp @@ -0,0 +1,31 @@ +/** + * @file utils/navigation_speech.hpp + * + * Navigation speech: exit/stairs/portal/unexplored speech and keyboard walk keys. + */ +#pragma once + +#include +#include + +namespace devilution { + +struct TriggerStruct; +struct Portal; + +std::string TriggerLabelForSpeech(const TriggerStruct &trigger); +std::string TownPortalLabelForSpeech(const Portal &portal); +std::vector CollectTownDungeonTriggerIndices(); + +void SpeakNearestExitKeyPressed(); +void SpeakNearestTownPortalInTownKeyPressed(); +void SpeakNearestStairsDownKeyPressed(); +void SpeakNearestStairsUpKeyPressed(); +void KeyboardWalkNorthKeyPressed(); +void KeyboardWalkSouthKeyPressed(); +void KeyboardWalkEastKeyPressed(); +void KeyboardWalkWestKeyPressed(); +void SpeakNearestUnexploredTileKeyPressed(); +bool IsKeyboardWalkAllowed(); + +} // namespace devilution diff --git a/Source/utils/proximity_audio.cpp b/Source/utils/proximity_audio.cpp index 4170b115e3b..e97c1b04605 100644 --- a/Source/utils/proximity_audio.cpp +++ b/Source/utils/proximity_audio.cpp @@ -316,8 +316,6 @@ void UpdateProximityAudioCues() { if (!gbSndInited || !gbSoundOn) return; - if (leveltype == DTYPE_TOWN) - return; if (MyPlayer == nullptr || MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH) return; if (InGameMenu()) @@ -384,86 +382,89 @@ void UpdateProximityAudioCues() }); } - for (int i = 0; i < ActiveObjectCount; i++) { - const int objectId = ActiveObjects[i]; - const Object &object = Objects[objectId]; - if (!object.canInteractWith()) - continue; - if (!object.isDoor() && !object.IsChest()) - continue; + // Only enable non-item emitters inside dungeons for now. + if (leveltype != DTYPE_TOWN) { + for (int i = 0; i < ActiveObjectCount; i++) { + const int objectId = ActiveObjects[i]; + const Object &object = Objects[objectId]; + if (!object.canInteractWith()) + continue; + if (!object.isDoor() && !object.IsChest()) + continue; - SoundPool::SoundId soundId; - if (object.IsChest()) { - soundId = SoundPool::SoundId::Chest; - } else { - soundId = SoundPool::SoundId::Door; - } + SoundPool::SoundId soundId; + if (object.IsChest()) { + soundId = SoundPool::SoundId::Chest; + } else { + soundId = SoundPool::SoundId::Door; + } - if (!pool.IsLoaded(soundId)) - continue; + if (!pool.IsLoaded(soundId)) + continue; - const int distance = playerPosition.ApproxDistance(object.position); - if (distance > MaxCueDistanceTiles) - continue; + const int distance = playerPosition.ApproxDistance(object.position); + if (distance > MaxCueDistanceTiles) + continue; - ConsiderCandidate(best, CandidateEmitter { - .emitterId = MakeEmitterId(EmitterType::Object, static_cast(objectId)), - .sound = soundId, - .position = object.position, - .distance = distance, - .intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinIntervalMs, MaxIntervalMs), - }); - } + ConsiderCandidate(best, CandidateEmitter { + .emitterId = MakeEmitterId(EmitterType::Object, static_cast(objectId)), + .sound = soundId, + .position = object.position, + .distance = distance, + .intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinIntervalMs, MaxIntervalMs), + }); + } - for (int i = 0; i < numtrigs; ++i) { - if (!IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABPREVLVL)) - continue; + for (int i = 0; i < numtrigs; ++i) { + if (!IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABPREVLVL)) + continue; - if (!pool.IsLoaded(SoundPool::SoundId::Stairs)) - continue; + if (!pool.IsLoaded(SoundPool::SoundId::Stairs)) + continue; - const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; - const int distance = playerPosition.ApproxDistance(triggerPosition); - if (distance > MaxCueDistanceTiles) - continue; + const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; + const int distance = playerPosition.ApproxDistance(triggerPosition); + if (distance > MaxCueDistanceTiles) + continue; - ConsiderCandidate(best, CandidateEmitter { - .emitterId = MakeEmitterId(EmitterType::Trigger, static_cast(i)), - .sound = SoundPool::SoundId::Stairs, - .position = triggerPosition, - .distance = distance, - .intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinIntervalMs, MaxIntervalMs), - }); - } + ConsiderCandidate(best, CandidateEmitter { + .emitterId = MakeEmitterId(EmitterType::Trigger, static_cast(i)), + .sound = SoundPool::SoundId::Stairs, + .position = triggerPosition, + .distance = distance, + .intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinIntervalMs, MaxIntervalMs), + }); + } - for (size_t i = 0; i < ActiveMonsterCount; i++) { - const int monsterId = static_cast(ActiveMonsters[i]); - const Monster &monster = Monsters[monsterId]; + 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.isInvalid) + continue; + if ((monster.flags & MFLAG_HIDDEN) != 0) + continue; + if (monster.hitPoints <= 0) + continue; - if (!pool.IsLoaded(SoundPool::SoundId::Monster)) - continue; + if (!pool.IsLoaded(SoundPool::SoundId::Monster)) + continue; - // Use the future position for distance/tempo so cues react immediately when a monster starts moving. - const Point monsterSoundPosition { monster.position.tile }; - const Point monsterDistancePosition { monster.position.future }; - const int distance = playerPosition.ApproxDistance(monsterDistancePosition); - if (distance > MaxCueDistanceTiles) - continue; + // Use the future position for distance/tempo so cues react immediately when a monster starts moving. + const Point monsterSoundPosition { monster.position.tile }; + const Point monsterDistancePosition { monster.position.future }; + const int distance = playerPosition.ApproxDistance(monsterDistancePosition); + if (distance > MaxCueDistanceTiles) + continue; - ConsiderCandidate(best, CandidateEmitter { - .emitterId = MakeEmitterId(EmitterType::Monster, static_cast(monsterId)), - .sound = SoundPool::SoundId::Monster, - .position = monsterSoundPosition, - .distance = distance, - .intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinMonsterIntervalMs, MaxMonsterIntervalMs), - }); + ConsiderCandidate(best, CandidateEmitter { + .emitterId = MakeEmitterId(EmitterType::Monster, static_cast(monsterId)), + .sound = SoundPool::SoundId::Monster, + .position = monsterSoundPosition, + .distance = distance, + .intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles, MinMonsterIntervalMs, MaxMonsterIntervalMs), + }); + } } std::array requests; diff --git a/Source/utils/screen_reader.cpp b/Source/utils/screen_reader.cpp index 7a7c497a6d4..7ecd623f3b6 100644 --- a/Source/utils/screen_reader.cpp +++ b/Source/utils/screen_reader.cpp @@ -6,13 +6,15 @@ #ifdef _WIN32 #include "utils/file_util.h" #include +#elif defined(__ANDROID__) +#include "platform/android/android.hpp" #else #include #endif namespace devilution { -#ifndef _WIN32 +#if !defined(_WIN32) && !defined(__ANDROID__) SPDConnection *Speechd; #endif @@ -20,36 +22,42 @@ void InitializeScreenReader() { #ifdef _WIN32 Tolk_Load(); +#elif defined(__ANDROID__) + devilution::accessibility::InitializeScreenReaderAndroid(); #else Speechd = spd_open("DevilutionX", "DevilutionX", NULL, SPD_MODE_SINGLE); #endif } -void ShutDownScreenReader() -{ -#ifdef _WIN32 - Tolk_Unload(); -#else - spd_close(Speechd); -#endif -} - -void SpeakText(std::string_view text, bool force) -{ - static std::string SpokenText; - - if (!force && SpokenText == text) - return; - - SpokenText = text; - -#ifdef _WIN32 - const auto textUtf16 = ToWideChar(SpokenText); - if (textUtf16 != nullptr) - Tolk_Output(textUtf16.get(), true); -#else - spd_say(Speechd, SPD_TEXT, SpokenText.c_str()); -#endif -} +void ShutDownScreenReader() +{ +#ifdef _WIN32 + Tolk_Unload(); +#elif defined(__ANDROID__) + devilution::accessibility::ShutDownScreenReaderAndroid(); +#else + spd_close(Speechd); +#endif +} + +void SpeakText(std::string_view text, bool force) +{ + static std::string SpokenText; + + if (!force && SpokenText == text) + return; + + SpokenText = text; + +#ifdef _WIN32 + const auto textUtf16 = ToWideChar(SpokenText); + if (textUtf16 != nullptr) + Tolk_Output(textUtf16.get(), true); +#elif defined(__ANDROID__) + devilution::accessibility::SpeakTextAndroid(SpokenText.c_str()); +#else + spd_say(Speechd, SPD_TEXT, SpokenText.c_str()); +#endif +} } // namespace devilution diff --git a/Source/utils/walk_path_speech.cpp b/Source/utils/walk_path_speech.cpp new file mode 100644 index 00000000000..105c329d2df --- /dev/null +++ b/Source/utils/walk_path_speech.cpp @@ -0,0 +1,604 @@ +/** + * @file utils/walk_path_speech.cpp + * + * Walk-path helpers, PosOk variants, and BFS pathfinding for accessibility speech. + */ +#include "utils/walk_path_speech.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "engine/path.h" +#include "levels/gendung.h" +#include "levels/tile_properties.hpp" +#include "monster.h" +#include "objects.h" +#include "player.h" +#include "utils/language.h" +#include "utils/str_cat.hpp" + +namespace devilution { + +Point NextPositionForWalkDirection(Point position, int8_t walkDir) +{ + switch (walkDir) { + case WALK_NE: + return { position.x, position.y - 1 }; + case WALK_NW: + return { position.x - 1, position.y }; + case WALK_SE: + return { position.x + 1, position.y }; + case WALK_SW: + return { position.x, position.y + 1 }; + case WALK_N: + return { position.x - 1, position.y - 1 }; + case WALK_E: + return { position.x + 1, position.y - 1 }; + case WALK_S: + return { position.x + 1, position.y + 1 }; + case WALK_W: + return { position.x - 1, position.y + 1 }; + default: + return position; + } +} + +Point PositionAfterWalkPathSteps(Point start, const int8_t *path, int steps) +{ + Point position = start; + for (int i = 0; i < steps; ++i) { + position = NextPositionForWalkDirection(position, path[i]); + } + return position; +} + +int8_t OppositeWalkDirection(int8_t walkDir) +{ + switch (walkDir) { + case WALK_NE: + return WALK_SW; + case WALK_SW: + return WALK_NE; + case WALK_NW: + return WALK_SE; + case WALK_SE: + return WALK_NW; + case WALK_N: + return WALK_S; + case WALK_S: + return WALK_N; + case WALK_E: + return WALK_W; + case WALK_W: + return WALK_E; + default: + return WALK_NONE; + } +} + +bool PosOkPlayerIgnoreDoors(const Player &player, Point position) +{ + if (!InDungeonBounds(position)) + return false; + if (!IsTileWalkable(position, /*ignoreDoors=*/true)) + return false; + + Player *otherPlayer = PlayerAtPosition(position); + if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) + return false; + + if (dMonster[position.x][position.y] != 0) { + if (leveltype == DTYPE_TOWN) + return false; + if (dMonster[position.x][position.y] <= 0) + return false; + if (!Monsters[dMonster[position.x][position.y] - 1].hasNoLife()) + return false; + } + + return true; +} + +bool IsTileWalkableForTrackerPath(Point position, bool ignoreDoors, bool ignoreBreakables) +{ + Object *object = FindObjectAtPosition(position); + if (object != nullptr) { + if (ignoreDoors && object->isDoor()) { + return true; + } + if (ignoreBreakables && object->_oSolidFlag && object->IsBreakable()) { + return true; + } + if (object->_oSolidFlag) { + return false; + } + } + + return IsTileNotSolid(position); +} + +bool PosOkPlayerIgnoreMonsters(const Player &player, Point position) +{ + if (!InDungeonBounds(position)) + return false; + if (!IsTileWalkableForTrackerPath(position, /*ignoreDoors=*/false, /*ignoreBreakables=*/false)) + return false; + + Player *otherPlayer = PlayerAtPosition(position); + if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) + return false; + + return true; +} + +bool PosOkPlayerIgnoreDoorsAndMonsters(const Player &player, Point position) +{ + if (!InDungeonBounds(position)) + return false; + if (!IsTileWalkableForTrackerPath(position, /*ignoreDoors=*/true, /*ignoreBreakables=*/false)) + return false; + + Player *otherPlayer = PlayerAtPosition(position); + if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) + return false; + + return true; +} + +bool PosOkPlayerIgnoreDoorsMonstersAndBreakables(const Player &player, Point position) +{ + if (!InDungeonBounds(position)) + return false; + if (!IsTileWalkableForTrackerPath(position, /*ignoreDoors=*/true, /*ignoreBreakables=*/true)) + return false; + + Player *otherPlayer = PlayerAtPosition(position); + if (otherPlayer != nullptr && otherPlayer != &player && !otherPlayer->hasNoLife()) + return false; + + return true; +} + +namespace { + +using PosOkForSpeechFn = bool (*)(const Player &, Point); + +template +std::optional> FindKeyboardWalkPathForSpeechBfs(const Player &player, Point startPosition, Point destinationPosition, PosOkForSpeechFn posOk, const std::array &walkDirections, bool allowDiagonalSteps, bool allowDestinationNonWalkable) +{ + if (!InDungeonBounds(startPosition) || !InDungeonBounds(destinationPosition)) + return std::nullopt; + + if (startPosition == destinationPosition) + return std::vector {}; + + std::array visited {}; + std::array parentDir {}; + parentDir.fill(WALK_NONE); + + std::queue queue; + + const auto indexOf = [](Point position) -> size_t { + return static_cast(position.x) + static_cast(position.y) * MAXDUNX; + }; + + const auto enqueue = [&](Point current, int8_t dir) { + const Point next = NextPositionForWalkDirection(current, dir); + if (!InDungeonBounds(next)) + return; + + const size_t idx = indexOf(next); + if (visited[idx]) + return; + + const bool ok = posOk(player, next); + if (ok) { + if (!CanStep(current, next)) + return; + } else { + if (!allowDestinationNonWalkable || next != destinationPosition) + return; + } + + visited[idx] = true; + parentDir[idx] = dir; + queue.push(next); + }; + + visited[indexOf(startPosition)] = true; + queue.push(startPosition); + + const auto hasReachedDestination = [&]() -> bool { + return visited[indexOf(destinationPosition)]; + }; + + while (!queue.empty() && !hasReachedDestination()) { + const Point current = queue.front(); + queue.pop(); + + const Displacement delta = destinationPosition - current; + const int deltaAbsX = delta.deltaX >= 0 ? delta.deltaX : -delta.deltaX; + const int deltaAbsY = delta.deltaY >= 0 ? delta.deltaY : -delta.deltaY; + + std::array prioritizedDirs; + size_t prioritizedCount = 0; + + const auto addUniqueDir = [&](int8_t dir) { + if (dir == WALK_NONE) + return; + for (size_t i = 0; i < prioritizedCount; ++i) { + if (prioritizedDirs[i] == dir) + return; + } + prioritizedDirs[prioritizedCount++] = dir; + }; + + const int8_t xDir = delta.deltaX > 0 ? WALK_SE : (delta.deltaX < 0 ? WALK_NW : WALK_NONE); + const int8_t yDir = delta.deltaY > 0 ? WALK_SW : (delta.deltaY < 0 ? WALK_NE : WALK_NONE); + + if (allowDiagonalSteps && delta.deltaX != 0 && delta.deltaY != 0) { + const int8_t diagDir = delta.deltaX > 0 ? (delta.deltaY > 0 ? WALK_S : WALK_E) : (delta.deltaY > 0 ? WALK_W : WALK_N); + addUniqueDir(diagDir); + } + + if (deltaAbsX >= deltaAbsY) { + addUniqueDir(xDir); + addUniqueDir(yDir); + } else { + addUniqueDir(yDir); + addUniqueDir(xDir); + } + for (const int8_t dir : walkDirections) { + addUniqueDir(dir); + } + + for (size_t i = 0; i < prioritizedCount; ++i) { + enqueue(current, prioritizedDirs[i]); + } + } + + if (!hasReachedDestination()) + return std::nullopt; + + std::vector path; + Point position = destinationPosition; + while (position != startPosition) { + const int8_t dir = parentDir[indexOf(position)]; + if (dir == WALK_NONE) + return std::nullopt; + + path.push_back(dir); + position = NextPositionForWalkDirection(position, OppositeWalkDirection(dir)); + } + + std::reverse(path.begin(), path.end()); + return path; +} + +std::optional> FindKeyboardWalkPathForSpeechWithPosOk(const Player &player, Point startPosition, Point destinationPosition, PosOkForSpeechFn posOk, bool allowDestinationNonWalkable) +{ + constexpr std::array AxisDirections = { + WALK_NE, + WALK_SW, + WALK_SE, + WALK_NW, + }; + + constexpr std::array AllDirections = { + WALK_NE, + WALK_SW, + WALK_SE, + WALK_NW, + WALK_N, + WALK_E, + WALK_S, + WALK_W, + }; + + if (const std::optional> axisPath = FindKeyboardWalkPathForSpeechBfs(player, startPosition, destinationPosition, posOk, AxisDirections, /*allowDiagonalSteps=*/false, allowDestinationNonWalkable); axisPath) { + return axisPath; + } + + return FindKeyboardWalkPathForSpeechBfs(player, startPosition, destinationPosition, posOk, AllDirections, /*allowDiagonalSteps=*/true, allowDestinationNonWalkable); +} + +template +std::optional> FindKeyboardWalkPathToClosestReachableForSpeechBfs(const Player &player, Point startPosition, Point destinationPosition, PosOkForSpeechFn posOk, const std::array &walkDirections, bool allowDiagonalSteps, Point &closestPosition) +{ + if (!InDungeonBounds(startPosition) || !InDungeonBounds(destinationPosition)) + return std::nullopt; + + if (startPosition == destinationPosition) { + closestPosition = destinationPosition; + return std::vector {}; + } + + std::array visited {}; + std::array parentDir {}; + std::array depth {}; + parentDir.fill(WALK_NONE); + depth.fill(0); + + std::queue queue; + + const auto indexOf = [](Point position) -> size_t { + return static_cast(position.x) + static_cast(position.y) * MAXDUNX; + }; + + const auto enqueue = [&](Point current, int8_t dir) { + const Point next = NextPositionForWalkDirection(current, dir); + if (!InDungeonBounds(next)) + return; + + const size_t nextIdx = indexOf(next); + if (visited[nextIdx]) + return; + + if (!posOk(player, next)) + return; + if (!CanStep(current, next)) + return; + + const size_t currentIdx = indexOf(current); + visited[nextIdx] = true; + parentDir[nextIdx] = dir; + depth[nextIdx] = static_cast(depth[currentIdx] + 1); + queue.push(next); + }; + + const size_t startIdx = indexOf(startPosition); + visited[startIdx] = true; + queue.push(startPosition); + + Point best = startPosition; + int bestDistance = startPosition.WalkingDistance(destinationPosition); + uint16_t bestDepth = 0; + + const auto considerBest = [&](Point position) { + const int distance = position.WalkingDistance(destinationPosition); + const uint16_t posDepth = depth[indexOf(position)]; + if (distance < bestDistance || (distance == bestDistance && posDepth < bestDepth)) { + best = position; + bestDistance = distance; + bestDepth = posDepth; + } + }; + + while (!queue.empty()) { + const Point current = queue.front(); + queue.pop(); + + considerBest(current); + + const Displacement delta = destinationPosition - current; + const int deltaAbsX = delta.deltaX >= 0 ? delta.deltaX : -delta.deltaX; + const int deltaAbsY = delta.deltaY >= 0 ? delta.deltaY : -delta.deltaY; + + std::array prioritizedDirs; + size_t prioritizedCount = 0; + + const auto addUniqueDir = [&](int8_t dir) { + if (dir == WALK_NONE) + return; + for (size_t i = 0; i < prioritizedCount; ++i) { + if (prioritizedDirs[i] == dir) + return; + } + prioritizedDirs[prioritizedCount++] = dir; + }; + + const int8_t xDir = delta.deltaX > 0 ? WALK_SE : (delta.deltaX < 0 ? WALK_NW : WALK_NONE); + const int8_t yDir = delta.deltaY > 0 ? WALK_SW : (delta.deltaY < 0 ? WALK_NE : WALK_NONE); + + if (allowDiagonalSteps && delta.deltaX != 0 && delta.deltaY != 0) { + const int8_t diagDir = delta.deltaX > 0 ? (delta.deltaY > 0 ? WALK_S : WALK_E) : (delta.deltaY > 0 ? WALK_W : WALK_N); + addUniqueDir(diagDir); + } + + if (deltaAbsX >= deltaAbsY) { + addUniqueDir(xDir); + addUniqueDir(yDir); + } else { + addUniqueDir(yDir); + addUniqueDir(xDir); + } + for (const int8_t dir : walkDirections) { + addUniqueDir(dir); + } + + for (size_t i = 0; i < prioritizedCount; ++i) { + enqueue(current, prioritizedDirs[i]); + } + } + + closestPosition = best; + if (best == startPosition) + return std::vector {}; + + std::vector path; + Point position = best; + while (position != startPosition) { + const int8_t dir = parentDir[indexOf(position)]; + if (dir == WALK_NONE) + return std::nullopt; + + path.push_back(dir); + position = NextPositionForWalkDirection(position, OppositeWalkDirection(dir)); + } + + std::reverse(path.begin(), path.end()); + return path; +} + +} // namespace + +std::optional> FindKeyboardWalkPathForSpeech(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +{ + return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, allowDestinationNonWalkable); +} + +std::optional> FindKeyboardWalkPathForSpeechRespectingDoors(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +{ + return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayer, allowDestinationNonWalkable); +} + +std::optional> FindKeyboardWalkPathForSpeechIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +{ + return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoorsAndMonsters, allowDestinationNonWalkable); +} + +std::optional> FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +{ + return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreMonsters, allowDestinationNonWalkable); +} + +std::optional> FindKeyboardWalkPathForSpeechLenient(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable) +{ + return FindKeyboardWalkPathForSpeechWithPosOk(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoorsMonstersAndBreakables, allowDestinationNonWalkable); +} + +std::optional> FindKeyboardWalkPathToClosestReachableForSpeech(const Player &player, Point startPosition, Point destinationPosition, Point &closestPosition) +{ + constexpr std::array AxisDirections = { + WALK_NE, + WALK_SW, + WALK_SE, + WALK_NW, + }; + + constexpr std::array AllDirections = { + WALK_NE, + WALK_SW, + WALK_SE, + WALK_NW, + WALK_N, + WALK_E, + WALK_S, + WALK_W, + }; + + Point axisClosest; + const std::optional> axisPath = FindKeyboardWalkPathToClosestReachableForSpeechBfs(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, AxisDirections, /*allowDiagonalSteps=*/false, axisClosest); + + Point diagClosest; + const std::optional> diagPath = FindKeyboardWalkPathToClosestReachableForSpeechBfs(player, startPosition, destinationPosition, PosOkPlayerIgnoreDoors, AllDirections, /*allowDiagonalSteps=*/true, diagClosest); + + if (!axisPath && !diagPath) + return std::nullopt; + if (!axisPath) { + closestPosition = diagClosest; + return diagPath; + } + if (!diagPath) { + closestPosition = axisClosest; + return axisPath; + } + + const int axisDistance = axisClosest.WalkingDistance(destinationPosition); + const int diagDistance = diagClosest.WalkingDistance(destinationPosition); + if (diagDistance < axisDistance) { + closestPosition = diagClosest; + return diagPath; + } + + closestPosition = axisClosest; + return axisPath; +} + +void AppendKeyboardWalkPathForSpeech(std::string &message, const std::vector &path) +{ + if (path.empty()) { + message.append(_("here")); + return; + } + + bool any = false; + const auto appendPart = [&](std::string_view label, int distance) { + if (distance == 0) + return; + if (any) + message.append(", "); + StrAppend(message, label, " ", distance); + any = true; + }; + + const auto labelForWalkDirection = [](int8_t dir) -> std::string_view { + switch (dir) { + case WALK_NE: + return _("north"); + case WALK_SW: + return _("south"); + case WALK_SE: + return _("east"); + case WALK_NW: + return _("west"); + case WALK_N: + return _("northwest"); + case WALK_E: + return _("northeast"); + case WALK_S: + return _("southeast"); + case WALK_W: + return _("southwest"); + default: + return {}; + } + }; + + int8_t currentDir = path.front(); + int runLength = 1; + for (size_t i = 1; i < path.size(); ++i) { + if (path[i] == currentDir) { + ++runLength; + continue; + } + + const std::string_view label = labelForWalkDirection(currentDir); + if (!label.empty()) + appendPart(label, runLength); + + currentDir = path[i]; + runLength = 1; + } + + const std::string_view label = labelForWalkDirection(currentDir); + if (!label.empty()) + appendPart(label, runLength); + + if (!any) + message.append(_("here")); +} + +void AppendDirectionalFallback(std::string &message, const Displacement &delta) +{ + bool any = false; + const auto appendPart = [&](std::string_view label, int distance) { + if (distance == 0) + return; + if (any) + message.append(", "); + StrAppend(message, label, " ", distance); + any = true; + }; + + if (delta.deltaY < 0) + appendPart(_("north"), -delta.deltaY); + else if (delta.deltaY > 0) + appendPart(_("south"), delta.deltaY); + + if (delta.deltaX > 0) + appendPart(_("east"), delta.deltaX); + else if (delta.deltaX < 0) + appendPart(_("west"), -delta.deltaX); + + if (!any) + message.append(_("here")); +} + +} // namespace devilution diff --git a/Source/utils/walk_path_speech.hpp b/Source/utils/walk_path_speech.hpp new file mode 100644 index 00000000000..1188c946c4b --- /dev/null +++ b/Source/utils/walk_path_speech.hpp @@ -0,0 +1,44 @@ +/** + * @file utils/walk_path_speech.hpp + * + * Walk-path helpers, PosOk variants, and BFS pathfinding for accessibility speech. + */ +#pragma once + +#include +#include +#include +#include + +#include "engine/displacement.hpp" +#include "engine/point.hpp" + +namespace devilution { + +struct Player; + +// Walk direction helpers +Point NextPositionForWalkDirection(Point position, int8_t walkDir); +Point PositionAfterWalkPathSteps(Point start, const int8_t *path, int steps); +int8_t OppositeWalkDirection(int8_t walkDir); + +// PosOk variants for pathfinding +bool PosOkPlayerIgnoreDoors(const Player &player, Point position); +bool IsTileWalkableForTrackerPath(Point position, bool ignoreDoors, bool ignoreBreakables); +bool PosOkPlayerIgnoreMonsters(const Player &player, Point position); +bool PosOkPlayerIgnoreDoorsAndMonsters(const Player &player, Point position); +bool PosOkPlayerIgnoreDoorsMonstersAndBreakables(const Player &player, Point position); + +// BFS pathfinding for speech +std::optional> FindKeyboardWalkPathForSpeech(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); +std::optional> FindKeyboardWalkPathForSpeechRespectingDoors(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); +std::optional> FindKeyboardWalkPathForSpeechIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); +std::optional> FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); +std::optional> FindKeyboardWalkPathForSpeechLenient(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); +std::optional> FindKeyboardWalkPathToClosestReachableForSpeech(const Player &player, Point startPosition, Point destinationPosition, Point &closestPosition); + +// Speech formatting +void AppendKeyboardWalkPathForSpeech(std::string &message, const std::vector &path); +void AppendDirectionalFallback(std::string &message, const Displacement &delta); + +} // namespace devilution diff --git a/Translations/pl.po b/Translations/pl.po index c4682443f30..6c704bb0ea9 100644 --- a/Translations/pl.po +++ b/Translations/pl.po @@ -1887,10 +1887,19 @@ msgid "Toggle automap" msgstr "Przełączanie automapy" #: Source/diablo.cpp:1896 Source/diablo.cpp:2305 -msgid "Toggles if automap is displayed." -msgstr "Przełącza, czy wyświetlana jest automapa." +msgid "Toggles if automap is displayed. While the stash is open, switches between inventory and stash." +msgstr "Przełącza wyświetlanie automapy. Gdy skrytka jest otwarta, przełącza między ekwipunkiem a skrytką." #: Source/diablo.cpp:1903 +msgid "Toggle stash focus" +msgstr "Przełącz fokus skrytki" + +msgid "Tab: switches between inventory and stash." +msgstr "Tab: przełącza między ekwipunkiem a skrytką." + +msgid "Stash" +msgstr "Skrytka" + msgid "Cycle map type" msgstr "Przełącz typ mapy" @@ -3768,6 +3777,43 @@ msgstr "Ładunki: {:d}/{:d}" msgid "unique item" msgstr "unikat" +#: Source/items.cpp:4146 Source/controls/tracker.cpp:316 +msgid "magic item" +msgstr "magiczny" + +#: Source/items.cpp +msgid "One-handed weapon" +msgstr "Broń jednoręczna" + +#: Source/items.cpp +msgid "Two-handed weapon" +msgstr "Broń dwuręczna" + +#: Source/items.cpp +#, c++-format +msgid "Attack speed: {:s} ({:d} frames)" +msgstr "Szybkość ataku: {:s} ({:d} klatek)" + +#: Source/items.cpp +msgid "Very fast" +msgstr "Bardzo szybka" + +#: Source/items.cpp +msgid "Fast" +msgstr "Szybka" + +#: Source/items.cpp +msgid "Normal" +msgstr "Normalna" + +#: Source/items.cpp +msgid "Slow" +msgstr "Wolna" + +#: Source/items.cpp +msgid "Very slow" +msgstr "Bardzo wolna" + #: Source/items.cpp:4167 Source/items.cpp:4175 Source/items.cpp:4181 msgid "Not Identified" msgstr "Nie zidentyfikowano" @@ -3878,6 +3924,10 @@ msgstr "Do Krypty - poziom {:d}" msgid "Back to Level {:d}" msgstr "Wróć na poziom {:d}" +#: Source/controls/tracker.cpp:713 Source/controls/tracker.cpp:855 +msgid "Red portal" +msgstr "czerwony portal" + #: Source/loadsave.cpp:2013 Source/loadsave.cpp:2470 msgid "Unable to open save file archive" msgstr "Nie można otworzyć pliku zapisu" @@ -3898,6 +3948,10 @@ msgstr "" "Nieprawidłowy rozmiar skrytki. Jeśli spróbujesz uzyskać dostęp do skrytki, " "dane zostaną nadpisane!!" +#: Source/controls/plrctrls.cpp:2283 +msgid "Stash" +msgstr "skrytka" + #: Source/loadsave.cpp:2474 msgid "Invalid save file" msgstr "Nieprawidłowy plik zapisu" @@ -4281,6 +4335,22 @@ msgstr "Zamknięte Drzwi" msgid "Blocked Door" msgstr "Zablokowane Drzwi" +#: Source/diablo.cpp:2754 +msgid "Open Grate Door" +msgstr "Otwarta Krata" + +#: Source/diablo.cpp:2756 +msgid "Closed Grate Door" +msgstr "Zamknięta Krata" + +#: Source/diablo.cpp:2758 +msgid "Blocked Grate Door" +msgstr "Zablokowana Krata" + +#: Source/diablo.cpp:2759 +msgid "Grate Door" +msgstr "Krata" + #: Source/objects.cpp:4818 msgid "Ancient Tome" msgstr "Starożytna Księga" @@ -5818,6 +5888,30 @@ msgstr "Barda" msgid "Barbarian" msgstr "Barbarzyńca" +#: Source/DiabloUI/hero/selhero.cpp:86 +msgid "A powerful fighter who excels in melee combat." +msgstr "Potężny wojownik, który świetnie radzi sobie w walce wręcz." + +#: Source/DiabloUI/hero/selhero.cpp:88 +msgid "A nimble archer who excels at ranged combat." +msgstr "Zwinna łuczniczka, która świetnie radzi sobie w walce na dystans." + +#: Source/DiabloUI/hero/selhero.cpp:90 +msgid "A master of arcane magic who casts powerful spells." +msgstr "Mistrz magii tajemnej, który rzuca potężne zaklęcia." + +#: Source/DiabloUI/hero/selhero.cpp:92 +msgid "A holy warrior skilled in martial arts and staves." +msgstr "Święty wojownik biegły w sztukach walki i władaniu kosturami." + +#: Source/DiabloUI/hero/selhero.cpp:94 +msgid "A versatile fighter who blends melee and archery." +msgstr "Wszechstronna wojowniczka, która łączy walkę wręcz i łucznictwo." + +#: Source/DiabloUI/hero/selhero.cpp:96 +msgid "A fierce warrior who relies on brute strength." +msgstr "Zaciekły wojownik, który polega na brutalnej sile." + #: Source/translation_dummy.cpp:17 msgctxt "monster" msgid "Zombie" @@ -6893,6 +6987,13 @@ msgstr "Atlas Grzybów" msgid "Spectral Elixir" msgstr "Widmowy Eliksir" +#: Source/diablo.cpp +msgid "No next dead body." +msgstr "Nie ma następnego martwego ciała." + +#: Source/diablo.cpp +msgid "Tracker directions" +msgstr "Kierunki do celu trackera" #: Source/translation_dummy.cpp:237 msgid "Blood Stone" msgstr "Kamień Krwi" @@ -12387,6 +12488,398 @@ msgstr "Runa Kamienia" msgid "," msgstr "," +#: Source/diablo.cpp +msgid "Town NPCs:" +msgstr "NPC w mieście:" + +#: Source/diablo.cpp +msgid "Cows: " +msgstr "Krowy: " + +#: Source/diablo.cpp +msgid "Selected: " +msgstr "Wybrano: " + +#: Source/diablo.cpp +msgid "PageUp/PageDown: select. Home: go. End: repeat." +msgstr "PageUp/PageDown: wybór. Home: idź. End: powtórz." + +#: Source/diablo.cpp +msgid "Not in a dungeon." +msgstr "Nie jesteś w lochu." + +#: Source/diablo.cpp +msgid "Close the map first." +msgstr "Najpierw zamknij mapę." + +#: Source/diablo.cpp +msgid "No exits found." +msgstr "Nie znaleziono wyjść." + +#: Source/diablo.cpp +msgid "No quest entrances found." +msgstr "Nie znaleziono wejść questowych." + +#: Source/diablo.cpp +msgid "No quest exits found." +msgstr "Nie znaleziono wyjść questowych." + +#: Source/diablo.cpp +msgid "Not in town." +msgstr "Nie jesteś w mieście." + +#: Source/diablo.cpp +msgid "Nearest stairs down" +msgstr "Najbliższe schody w dół" + +#: Source/diablo.cpp +msgid "Speaks directions to the nearest stairs down." +msgstr "Podaje wskazówki dojścia do najbliższych schodów w dół." + +#: Source/diablo.cpp +msgid "Nearest stairs up" +msgstr "Najbliższe schody w górę" + +#: Source/diablo.cpp +msgid "Speaks directions to the nearest stairs up." +msgstr "Podaje wskazówki dojścia do najbliższych schodów w górę." + +#: Source/diablo.cpp +msgid "Nearest exit" +msgstr "Najbliższe wyjście" + +#: Source/diablo.cpp +msgid "Speaks the nearest exit. Hold Shift for quest entrances." +msgstr "Podaje wskazówki dojścia do najbliższego wyjścia. Przytrzymaj Shift, aby namierzyć wejścia questowe." + +#: Source/diablo.cpp +msgid "Speaks the nearest exit. Hold Shift for quest entrances (or to leave a quest level). In town, press Ctrl+E to cycle dungeon entrances." +msgstr "Podaje wskazówki dojścia do najbliższego wyjścia. Przytrzymaj Shift, aby namierzyć wejścia questowe (lub opuścić lokację questową). W mieście naciśnij Ctrl+E, aby przełączać wejścia do lochów." + +#: Source/diablo.cpp +msgid "Nearest exit: " +msgstr "Najbliższe wyjście: " + +#: Source/diablo.cpp +msgid "Cathedral entrance" +msgstr "Wejście do Katedry" + +#: Source/diablo.cpp +msgid "Cathedral entrance: press {:s}." +msgstr "Wejście do Katedry: naciśnij {:s}." + +#: Source/diablo.cpp +msgid "Stairs down" +msgstr "Schody w dół" + +#: Source/diablo.cpp +msgid "Stairs up" +msgstr "Schody w górę" + +#: Source/diablo.cpp +msgid "Town warp to level {:d}" +msgstr "Portal miejski na poziom {:d}" + +#: Source/diablo.cpp +msgid "Town warp to {:s}" +msgstr "Portal miejski do {:s}" + +#: Source/diablo.cpp +msgid "Warp up" +msgstr "Portal w górę" + +#: Source/diablo.cpp +msgid "Return to town" +msgstr "Powrót do miasta" + +#: Source/diablo.cpp +msgid "Warp" +msgstr "Portal" + +#: Source/diablo.cpp +msgid "Set level" +msgstr "Poziom zadania" + +#: Source/diablo.cpp +msgid "Return level" +msgstr "Powrót do zadania" + +#: Source/diablo.cpp +msgid "Exit" +msgstr "Wyjście" + +#: Source/diablo.cpp +msgid "north" +msgstr "północ" + +#: Source/diablo.cpp +msgid "south" +msgstr "południe" + +#: Source/diablo.cpp +msgid "east" +msgstr "wschód" + +#: Source/diablo.cpp +msgid "west" +msgstr "zachód" + +#: Source/diablo.cpp +msgid "northwest" +msgstr "północny zachód" + +#: Source/diablo.cpp +msgid "northeast" +msgstr "północny wschód" + +#: Source/diablo.cpp +msgid "southeast" +msgstr "południowy wschód" + +#: Source/diablo.cpp +msgid "southwest" +msgstr "południowy zachód" + +#: Source/diablo.cpp +msgid "here" +msgstr "tutaj" + +#: Source/diablo.cpp +msgid "No unexplored areas found." +msgstr "Nie znaleziono nieodkrytych obszarów." + +#: Source/diablo.cpp +msgid "Nearest unexplored space: " +msgstr "Najbliższe nieodkryte miejsce: " + +#: Source/diablo.cpp +msgid "Cycle tracker target" +msgstr "Zmień cel trackera" + +#: Source/diablo.cpp +msgid "Cycles what the tracker looks for (items, chests, doors, shrines, objects, breakables, monsters)." +msgstr "Zmienia, czego szuka tracker (przedmioty, skrzynie, drzwi, kapliczki, obiekty, niszczalne, potwory)." + +#: Source/diablo.cpp +msgid "Cycles what the tracker looks for (items, chests, doors, shrines, objects, breakables, monsters). Hold Shift to cycle backwards." +msgstr "Zmienia, czego szuka tracker (przedmioty, skrzynie, drzwi, kapliczki, obiekty, niszczalne, potwory). Przytrzymaj Shift, aby przełączać wstecz." + +#: Source/diablo.cpp +msgid "Cycles what the tracker looks for (items, chests, doors, shrines, objects, breakables, monsters, dead bodies). Hold Shift to cycle backwards." +msgstr "Zmienia, czego szuka tracker (przedmioty, skrzynie, drzwi, kapliczki, obiekty, niszczalne, potwory, martwe ciała). Przytrzymaj Shift, aby przełączać wstecz." + +#: Source/diablo.cpp +msgid "Navigate to tracker target" +msgstr "Nawiguj do celu trackera" + +#: Source/diablo.cpp +msgid "Walks to the nearest target of the selected tracker category." +msgstr "Prowadzi do najbliższego celu wybranej kategorii trackera." + +#: Source/diablo.cpp +msgid "Tracker target: " +msgstr "Śledzenie: " + +#: Source/diablo.cpp +msgid "items" +msgstr "przedmioty" + +#: Source/diablo.cpp +msgid "chests" +msgstr "skrzynie" + +#: Source/diablo.cpp +msgid "doors" +msgstr "drzwi" + +#: Source/diablo.cpp +msgid "shrines" +msgstr "kapliczki" + +#: Source/diablo.cpp +msgid "objects" +msgstr "obiekty" + +#: Source/diablo.cpp +msgid "breakables" +msgstr "niszczalne" + +#: Source/diablo.cpp +msgid "monsters" +msgstr "potwory" + +#: Source/diablo.cpp +msgid "dead bodies" +msgstr "martwe ciała" + +#: Source/diablo.cpp +msgid "No items found." +msgstr "Nie znaleziono żadnych przedmiotów." + +#: Source/controls/tracker.cpp +msgid "No nearby items found." +msgstr "Nie znaleziono żadnych pobliskich przedmiotów." + +#: Source/diablo.cpp +msgid "No chests found." +msgstr "Nie znaleziono żadnych skrzyń." + +#: Source/controls/tracker.cpp +msgid "No nearby chests found." +msgstr "Nie znaleziono żadnych pobliskich skrzyń." + +#: Source/diablo.cpp +msgid "No doors found." +msgstr "Nie znaleziono żadnych drzwi." + +#: Source/controls/tracker.cpp +msgid "No nearby doors found." +msgstr "Nie znaleziono żadnych pobliskich drzwi." + +#: Source/diablo.cpp +msgid "No shrines found." +msgstr "Nie znaleziono żadnych kapliczek." + +#: Source/controls/tracker.cpp +msgid "No nearby shrines found." +msgstr "Nie znaleziono żadnych pobliskich kapliczek." + +#: Source/diablo.cpp +msgid "No objects found." +msgstr "Nie znaleziono żadnych obiektów." + +#: Source/controls/tracker.cpp +msgid "No nearby objects found." +msgstr "Nie znaleziono żadnych pobliskich obiektów." + +#: Source/diablo.cpp +msgid "No breakables found." +msgstr "Nie znaleziono żadnych niszczalnych obiektów." + +#: Source/controls/tracker.cpp +msgid "No nearby breakables found." +msgstr "Nie znaleziono żadnych pobliskich niszczalnych obiektów." + +#: Source/diablo.cpp +msgid "No monsters found." +msgstr "Nie znaleziono żadnych potworów." + +#: Source/controls/tracker.cpp +msgid "No nearby monsters found." +msgstr "Nie znaleziono żadnych pobliskich potworów." + +#: Source/diablo.cpp +msgid "No dead bodies found." +msgstr "Nie znaleziono żadnych martwych ciał." + +#: Source/controls/tracker.cpp +msgid "No nearby dead bodies found." +msgstr "Nie znaleziono żadnych pobliskich martwych ciał." + +#: Source/diablo.cpp +msgid "Navigating to nearest item." +msgstr "Nawiguję do najbliższego przedmiotu." + +#: Source/diablo.cpp +msgid "Navigating to nearest chest." +msgstr "Nawiguję do najbliższej skrzyni." + +#: Source/diablo.cpp +msgid "Navigating to nearest monster." +msgstr "Nawiguję do najbliższego potwora." + +#: Source/diablo.cpp +msgid "Target item is gone." +msgstr "Docelowy przedmiot zniknął." + +#: Source/diablo.cpp +msgid "Item in range." +msgstr "Przedmiot jest w zasięgu." + +#: Source/diablo.cpp +msgid "Target chest is gone." +msgstr "Docelowa skrzynia zniknęła." + +#: Source/diablo.cpp +msgid "Chest in range." +msgstr "Skrzynia jest w zasięgu." + +#: Source/diablo.cpp +msgid "Target monster is gone." +msgstr "Docelowy potwór zniknął." + +#: Source/diablo.cpp +msgid "Monster in range." +msgstr "Potwór jest w zasięgu." + +#: Source/diablo.cpp +msgid "Dead body" +msgstr "Martwe ciało" + +#: Source/diablo.cpp +msgid "Target dead body is gone." +msgstr "Docelowe martwe ciało zniknęło." + +#: Source/diablo.cpp +msgid "Dead body in range." +msgstr "Martwe ciało jest w zasięgu." + +#: Source/diablo.cpp +msgid "Can't find a nearby tile to walk to." +msgstr "Nie mogę znaleźć pobliskiego pola, na które da się podejść." + +#: Source/diablo.cpp +msgid "Can't find a path to the target." +msgstr "Nie mogę znaleźć ścieżki do celu." + +#: Source/diablo.cpp +msgid "A door is blocking the path. Open it and try again." +msgstr "Drzwi blokują drogę. Otwórz je i spróbuj ponownie." + +#: Source/diablo.cpp +msgid "A monster is blocking the path. Clear it and try again." +msgstr "Potwór blokuje drogę. Pokonaj go i spróbuj ponownie." + +#: Source/diablo.cpp +msgid "A breakable object is blocking the path. Destroy it and try again." +msgstr "Niszczalny obiekt blokuje drogę. Zniszcz go i spróbuj ponownie." + +#: Source/controls/plrctrls.cpp +msgid "Head" +msgstr "Głowa" + +#: Source/controls/plrctrls.cpp +msgid "Left ring" +msgstr "Lewy pierścień" + +#: Source/controls/plrctrls.cpp +msgid "Right ring" +msgstr "Prawy pierścień" + +#: Source/controls/plrctrls.cpp +msgid "Left hand" +msgstr "Lewa ręka" + +#: Source/controls/plrctrls.cpp +msgid "Right hand" +msgstr "Prawa ręka" + +#: Source/controls/plrctrls.cpp +msgid "Belt" +msgstr "Pas" + +#: Source/controls/plrctrls.cpp +msgid "empty" +msgstr "pusto" + +#: Source/options.cpp +msgid "Unbound" +msgstr "Brak przypisania" + +#: Source/diablo.cpp +msgid "No spell selected." +msgstr "Nie wybrano czaru." + #~ msgid "Decrease Gamma" #~ msgstr "Zmniejsz jasność" diff --git a/VERSION b/VERSION index 82ede4d5109..88c5fb891dc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0-dev +1.4.0 diff --git a/android-project/app/build.gradle b/android-project/app/build.gradle index 6bbbd34f435..585663cc810 100644 --- a/android-project/app/build.gradle +++ b/android-project/app/build.gradle @@ -19,7 +19,7 @@ android { versionName project.file('../../VERSION').text.trim() externalNativeBuild { cmake { - arguments "-DANDROID_STL=c++_static" + arguments "-DANDROID_STL=c++_static", "-DSCREEN_READER_INTEGRATION=ON" abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' } } diff --git a/android-project/app/src/main/java/org/diasurgical/devilutionx/DevilutionXSDLActivity.java b/android-project/app/src/main/java/org/diasurgical/devilutionx/DevilutionXSDLActivity.java index fef3a2de725..dcd7e4bac56 100644 --- a/android-project/app/src/main/java/org/diasurgical/devilutionx/DevilutionXSDLActivity.java +++ b/android-project/app/src/main/java/org/diasurgical/devilutionx/DevilutionXSDLActivity.java @@ -11,6 +11,7 @@ import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.view.WindowManager; +import android.view.accessibility.AccessibilityManager; import org.libsdl.app.SDLActivity; @@ -36,6 +37,10 @@ protected void onCreate(Bundle savedInstanceState) { migrateSaveGames(); super.onCreate(savedInstanceState); + + // Initialize accessibility JNI - must be after super.onCreate() + // so that the native library is loaded first + nativeInitAccessibility(); } /** @@ -178,4 +183,34 @@ protected String[] getLibraries() { } public static native boolean areFontsOutOfDate(String fonts_mpq); + + /** + * Native method to initialize accessibility JNI functions. + * This caches the method IDs needed for accessibility features. + */ + public native void nativeInitAccessibility(); + + /** + * Checks if the screen reader (TalkBack) is enabled on the device. + * This follows the same pattern as RetroArch's accessibility implementation. + * + * @return true if TalkBack is enabled and touch exploration is active + */ + public boolean isScreenReaderEnabled() { + AccessibilityManager accessibilityManager = (AccessibilityManager) getSystemService(ACCESSIBILITY_SERVICE); + boolean isAccessibilityEnabled = accessibilityManager.isEnabled(); + boolean isExploreByTouchEnabled = accessibilityManager.isTouchExplorationEnabled(); + return isAccessibilityEnabled && isExploreByTouchEnabled; + } + + /** + * Speaks the given message using Android's accessibility API. + * This integrates with TalkBack and other screen readers. + * This follows the same pattern as RetroArch's accessibility implementation. + * + * @param message The text to speak + */ + public void accessibilitySpeak(String message) { + getWindow().getDecorView().announceForAccessibility(message); + } } diff --git a/build_release.ps1 b/build_release.ps1 new file mode 100644 index 00000000000..407447a474d --- /dev/null +++ b/build_release.ps1 @@ -0,0 +1,166 @@ +<# +.SYNOPSIS + Builds a Windows Release package and writes it to build\releases. + +.DESCRIPTION + - Runs CMake configure (if needed) and builds the project in Release mode. + - Copies the built exe (and required runtime files) directly into build\releases + (no versioned subfolder). + - Also writes a versioned zip (diabloaccess--windows-x64.zip) into build\releases. + +.EXAMPLE + .\build_release.ps1 + +.EXAMPLE + .\build_release.ps1 -BuildDir build -Config Release +#> + +[CmdletBinding()] +param( + [ValidateSet('Release', 'RelWithDebInfo', 'Debug')] + [string]$Config = 'Release', + + [string]$BuildDir = 'build', + + # Where to write the packaged release (folder + zip). + # Default matches the typical CMake multi-config output dir on Windows. + [string]$OutputDir = 'build\\releases', + + # Layout: + # - Flat: write files directly into $OutputDir (exe is in $OutputDir\devilutionx.exe). + # - Versioned: create $OutputDir\\... and zip it. + [ValidateSet('Flat', 'Versioned')] + [string]$Layout = 'Flat' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-Version { + param([string]$RepoRoot) + + $version = $null + if (Get-Command git -ErrorAction SilentlyContinue) { + try { + $version = (git -C $RepoRoot describe --tags --abbrev=0 2>$null).Trim() + } catch { + $version = $null + } + } + if ([string]::IsNullOrWhiteSpace($version)) { + # Fallback: timestamp-based version + $version = (Get-Date -Format 'yyyyMMdd-HHmm') + } + return $version +} + +$repoRoot = (Resolve-Path $PSScriptRoot).Path +$buildDirPath = Join-Path $repoRoot $BuildDir +$outputDirPath = Join-Path $repoRoot $OutputDir + +$cmakeCache = Join-Path $buildDirPath 'CMakeCache.txt' +if (-not (Test-Path $cmakeCache)) { + Write-Host "Configuring CMake: $buildDirPath" + & cmake -S $repoRoot -B $buildDirPath | Out-Host +} + +Write-Host "Building: config=$Config" +& cmake --build $buildDirPath --config $Config | Out-Host + +$buildOutputDir = Join-Path $buildDirPath $Config +if (-not (Test-Path $buildOutputDir)) { + throw "Expected build output directory not found: $buildOutputDir" +} + +$version = Resolve-Version -RepoRoot $repoRoot +$packageName = "diabloaccess-$version-windows-x64" +$zipPath = Join-Path $outputDirPath "$packageName.zip" + +# Stage into a temp dir first, then (optionally) copy to output. +$stagingDir = if ($Layout -eq 'Versioned') { + Join-Path $outputDirPath $packageName +} else { + Join-Path $outputDirPath '_staging' +} + +New-Item -ItemType Directory -Force $outputDirPath | Out-Null +if (Test-Path $stagingDir) { + Remove-Item -Recurse -Force $stagingDir +} + +New-Item -ItemType Directory -Force $stagingDir | Out-Null +foreach ($d in @('assets', 'audio', 'mods')) { + New-Item -ItemType Directory -Force (Join-Path $stagingDir $d) | Out-Null +} + +$runtimeFiles = @( + 'devilutionx.exe', + 'SDL2.dll', + 'Tolk.dll', + 'zlib.dll', + 'nvdaControllerClient64.dll', + 'SAAPI64.dll', + 'ZDSRAPI.ini', + 'ZDSRAPI_x64.dll' +) + +foreach ($file in $runtimeFiles) { + $src = Join-Path $buildOutputDir $file + if (-not (Test-Path $src)) { + throw "Missing runtime file in build output: $src" + } + Copy-Item $src $stagingDir -Force +} + +$readmeSrc = Join-Path $repoRoot 'README.md' +if (Test-Path $readmeSrc) { + Copy-Item $readmeSrc (Join-Path $stagingDir 'README.md') -Force +} else { + Write-Warning "README.md not found at repo root; skipping." +} + +# Assets: prefer build\assets (generated + trimmed) and fall back to build\\assets if needed. +$assetsSrc = Join-Path $buildDirPath 'assets' +if (-not (Test-Path $assetsSrc)) { + $assetsSrc = Join-Path $buildOutputDir 'assets' +} +if (-not (Test-Path $assetsSrc)) { + throw "Assets directory not found (expected build\\assets or build\\$Config\\assets)." +} +Copy-Item (Join-Path $assetsSrc '*') (Join-Path $stagingDir 'assets') -Recurse -Force + +# Audio + mods are copied from build\\... +$audioSrc = Join-Path $buildOutputDir 'audio' +$modsSrc = Join-Path $buildOutputDir 'mods' +if (-not (Test-Path $audioSrc)) { throw "Audio directory not found: $audioSrc" } +if (-not (Test-Path $modsSrc)) { throw "Mods directory not found: $modsSrc" } +Copy-Item (Join-Path $audioSrc '*') (Join-Path $stagingDir 'audio') -Recurse -Force +Copy-Item (Join-Path $modsSrc '*') (Join-Path $stagingDir 'mods') -Recurse -Force + +if (Test-Path $zipPath) { + Remove-Item -Force $zipPath +} +$tmpZip = Join-Path ([System.IO.Path]::GetTempPath()) "$packageName-$([System.Guid]::NewGuid().ToString('N')).zip" +Compress-Archive -Path (Join-Path $stagingDir '*') -DestinationPath $tmpZip -Force +Move-Item -Force $tmpZip $zipPath + +if ($Layout -eq 'Flat') { + # Clean old release files (keep anything else the user may have in the folder). + $knownDirs = @('assets', 'audio', 'mods') + foreach ($d in $knownDirs) { + $dst = Join-Path $outputDirPath $d + if (Test-Path $dst) { Remove-Item -Recurse -Force $dst } + } + foreach ($file in $runtimeFiles + @('README.md')) { + $dst = Join-Path $outputDirPath $file + if (Test-Path $dst) { Remove-Item -Force $dst } + } + + # Copy staged content directly into output dir (exe ends up in build\releases\devilutionx.exe). + Copy-Item (Join-Path $stagingDir '*') $outputDirPath -Recurse -Force + Remove-Item -Recurse -Force $stagingDir + Write-Host "Release folder (flat): $outputDirPath" +} else { + Write-Host "Release folder: $stagingDir" +} +Write-Host "Release zip: $zipPath" diff --git a/docs/ANDROID_ACCESSIBILITY.md b/docs/ANDROID_ACCESSIBILITY.md new file mode 100644 index 00000000000..19f19109e28 --- /dev/null +++ b/docs/ANDROID_ACCESSIBILITY.md @@ -0,0 +1,254 @@ +# Android Accessibility Implementation + +## Overview + +This document describes the Android accessibility implementation for Diablo Access, which follows the same architecture pattern used by RetroArch to provide screen reader support for visually impaired players. + +## Architecture + +The implementation consists of three main components: + +### 1. Java Layer (DevilutionXSDLActivity.java) + +Located in `android-project/app/src/main/java/org/diasurgical/devilutionx/DevilutionXSDLActivity.java` + +```java +public boolean isScreenReaderEnabled() { + AccessibilityManager accessibilityManager = (AccessibilityManager) getSystemService(ACCESSIBILITY_SERVICE); + boolean isAccessibilityEnabled = accessibilityManager.isEnabled(); + boolean isExploreByTouchEnabled = accessibilityManager.isTouchExplorationEnabled(); + return isAccessibilityEnabled && isExploreByTouchEnabled; +} + +public void accessibilitySpeak(String message) { + getWindow().getDecorView().announceForAccessibility(message); +} +``` + +**Key Features:** +- `isScreenReaderEnabled()`: Checks if TalkBack is enabled and touch exploration is active +- `accessibilitySpeak()`: Uses Android's native `announceForAccessibility()` API to speak text +- These methods are called from native C++ code via JNI + +### 2. JNI Bridge (android.cpp) + +Located in `Source/platform/android/android.cpp` + +**Key Components:** + +1. **Global State:** + - `g_jvm`: Global JavaVM pointer + - `g_activity`: Global reference to the Activity + - `g_jniMethods`: Cached method IDs for performance + +2. **Thread Management:** + - Uses pthread thread-local storage to cache JNIEnv per thread + - Automatically attaches threads to JVM as needed + - Follows RetroArch's pattern for thread-safe JNI access + +3. **Public API (namespace `accessibility`):** + ```cpp + bool InitializeScreenReaderAndroid(); // Check if initialized + void ShutDownScreenReaderAndroid(); // Cleanup resources + void SpeakTextAndroid(const char *text); // Speak text + bool IsScreenReaderEnabledAndroid(); // Check TalkBack status + ``` + +4. **JNI Entry Point:** + ```cpp + JNIEXPORT void JNICALL Java_org_diasurgical_devilutionx_DevilutionXSDLActivity_nativeInitAccessibility( + JNIEnv *env, jobject thiz) + ``` + - Called from Java during Activity onCreate() + - Stores JVM pointer and global activity reference + - Caches method IDs for performance + +### 3. Platform Integration (screen_reader.cpp) + +Located in `Source/utils/screen_reader.cpp` + +Modified to support Android alongside Windows and Linux: + +```cpp +#ifdef _WIN32 + Tolk_Load(); +#elif defined(__ANDROID__) + devilution::accessibility::InitializeScreenReaderAndroid(); +#else + Speechd = spd_open("DevilutionX", "DevilutionX", NULL, SPD_MODE_SINGLE); +#endif +``` + +## How It Works + +### Initialization Flow + +1. App launches → `DevilutionXSDLActivity.onCreate()` is called +2. **IMPORTANT**: `super.onCreate()` must be called **before** `nativeInitAccessibility()` + - This ensures SDL loads the native library first + - Calling native methods before the library is loaded causes `UnsatisfiedLinkError` +3. `nativeInitAccessibility()` is called from Java (after `super.onCreate()`) +4. JNI function stores: + - JavaVM pointer + - Global reference to Activity + - Method IDs for accessibility functions +5. Game calls `InitializeScreenReader()` → checks if Android is ready + +**Critical Implementation Detail:** +```java +protected void onCreate(Bundle savedInstanceState) { + // ... setup code ... + + super.onCreate(savedInstanceState); // Must be FIRST - loads native library + + // Initialize accessibility JNI - must be after super.onCreate() + // so that the native library is loaded first + nativeInitAccessibility(); +} +``` + +### Speaking Text Flow + +1. Game code calls `SpeakText("Some text")` +2. `screen_reader.cpp` routes to `SpeakTextAndroid()` on Android +3. `SpeakTextAndroid()`: + - Gets JNIEnv for current thread (attaches if needed) + - Creates Java string from C string + - Calls `accessibilitySpeak()` method on Activity +4. Java method calls `announceForAccessibility()` +5. Android's accessibility framework forwards to TalkBack +6. TalkBack speaks the text + +### Thread Safety + +The implementation is thread-safe: + +- Each thread gets its own JNIEnv cached in thread-local storage +- The JavaVM pointer is global and constant +- The Activity reference is a global JNI reference (valid across threads) +- Method IDs are constant once initialized + +This follows the same pattern as RetroArch's `jni_thread_getenv()` function. + +## Comparison with Other Platforms + +### Windows (Tolk) +- Uses NVDA/JAWS screen readers via Tolk library +- Direct communication with screen readers +- Requires Windows-specific APIs + +### Linux (speech-dispatcher) +- Uses speech-dispatcher daemon +- Direct socket communication +- Requires speech-dispatcher to be running + +### Android (this implementation) +- Uses Android's accessibility framework +- Integrates with TalkBack and other screen readers +- Uses `announceForAccessibility()` API +- Requires TalkBack to be enabled + +## Advantages of This Approach + +1. **Native Integration**: Uses Android's built-in accessibility APIs +2. **Works with All Screen Readers**: TalkBack, Samsung TalkBack, BrailleBack, etc. +3. **No External Dependencies**: Uses only Android SDK and NDK +4. **Performance**: Method IDs cached, thread-local storage for JNIEnv +5. **Thread-Safe**: Can be called from any thread +6. **Follows Best Practices**: Same pattern as RetroArch (proven in production) + +## Differences from RetroArch + +### Similarities +- Both use `announceForAccessibility()` for speaking +- Both cache JNI method IDs +- Both use thread-local storage for JNIEnv +- Both check `isTouchExplorationEnabled()` + +### Minor Differences +- Diablo Access stores Activity as global reference; RetroArch uses android_app struct +- Diablo Access uses `pthread` directly; RetroArch wraps it in `jni_thread_getenv()` +- Diablo Access initialization happens in `onCreate()`; RetroArch uses native app glue + +Both approaches are valid and work correctly. + +## Testing + +### Prerequisites +1. Enable TalkBack on Android device/emulator: + - Settings → Accessibility → TalkBack → Enable + - Enable "Explore by touch" + +2. Build and install the app: + ```bash + cd android-project + ./gradlew assembleDebug + adb install app/build/outputs/apk/debug/app-debug.apk + ``` + +3. Test gameplay: + - Launch game + - Navigate through menus + - Listen for spoken feedback + - Verify all accessibility features work + +### Expected Behavior +- Menu items should be spoken +- Game state changes should be announced +- Tracker navigation should provide audio feedback +- Health/mana/status should be spoken + +## Troubleshooting + +### No Speech +- Verify TalkBack is enabled +- Check "Explore by touch" is enabled +- Verify device volume is up +- Check logcat for JNI errors + +### Crashes +- **UnsatisfiedLinkError**: Ensure `nativeInitAccessibility()` is called AFTER `super.onCreate()` + - The native library must be loaded by SDL before any native methods can be called + - `super.onCreate()` triggers SDL to load the devilutionx library +- Check that `nativeInitAccessibility()` is called before other functions +- Verify method IDs are cached successfully +- Check thread attachment in logcat + +### Common Issues and Solutions + +#### Issue: App crashes on startup with `UnsatisfiedLinkError` +**Error:** `No implementation found for void org.diasurgical.devilutionx.DevilutionXSDLActivity.nativeInitAccessibility()` + +**Cause:** Calling `nativeInitAccessibility()` before `super.onCreate()` in the Activity's `onCreate()` method. + +**Solution:** Move `nativeInitAccessibility()` to after `super.onCreate()`: +```java +@Override +protected void onCreate(Bundle savedInstanceState) { + // ... setup code ... + super.onCreate(savedInstanceState); // MUST be before nativeInitAccessibility() + nativeInitAccessibility(); // Call AFTER super.onCreate() +} +``` + +**Why:** SDL loads the native library (`libdevilutionx.so`) during `super.onCreate()`. Any native method calls before this will fail because the library isn't loaded yet. + +### Performance Issues +- Method ID caching should prevent repeated lookups +- Thread-local storage should minimize GetEnv calls +- Check for excessive JNI boundary crossings + +## Future Enhancements + +Possible improvements: +1. Add priority levels for announcements (like RetroArch) +2. Add speech speed control +3. Add haptic feedback integration +4. Support for accessibility gestures +5. Braille display integration + +## References + +- RetroArch Accessibility: https://github.com/libretro/RetroArch (accessibility.h, platform_unix.c) +- Android Accessibility: https://developer.android.com/guide/topics/ui/accessibility +- JNI Best Practices: https://developer.android.com/training/articles/perf-jni diff --git a/test/tile_properties_test.cpp b/test/tile_properties_test.cpp index 539a41ad76e..9dbb40dac2c 100644 --- a/test/tile_properties_test.cpp +++ b/test/tile_properties_test.cpp @@ -56,6 +56,29 @@ TEST(TilePropertiesTest, Walkable) EXPECT_TRUE(IsTileWalkable({ 5, 5 }, true)) << "Solid tiles occupied by an open door become walkable when ignoring doors"; } +TEST(TilePropertiesTest, DoorArchwaySolidTileBecomesWalkableWhenIgnoringDoors) +{ + dPiece[5][4] = 0; + SOLData[0] = TileProperties::Solid; + dObject[5][4] = 0; + EXPECT_FALSE(IsTileWalkable({ 5, 4 }, true)) + << "Solid tile with no object is unwalkable even when ignoring doors"; + + Objects[0]._otype = _object_id::OBJ_L1LDOOR; + Objects[0]._oSolidFlag = false; + dObject[5][5] = 1; + dObject[5][4] = -1; // Negative dObject value: extended area of Objects[0] (the door) + EXPECT_TRUE(IsTileWalkable({ 5, 4 }, true)) + << "Solid archway tile referencing a door becomes walkable when ignoring doors"; + EXPECT_FALSE(IsTileWalkable({ 5, 4 })) + << "Solid archway tile referencing a door is still unwalkable normally"; + + // Cleanup + dObject[5][5] = 0; + dObject[5][4] = 0; + Objects[0] = {}; +} + TEST(TilePropertiesTest, CanStepTest) { dPiece[0][0] = 0; diff --git a/uwp-project/.claude/settings.local.json b/uwp-project/.claude/settings.local.json new file mode 100644 index 00000000000..7a3b0043b96 --- /dev/null +++ b/uwp-project/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(git clone:*)", + "Bash(find:*)", + "Bash(bash:*)", + "Bash(dir:*)", + "Bash(gradlew.bat assembleDebug:*)", + "Bash(./gradlew.bat:*)" + ] + } +}