From 3382c0e07cc2db08fe229df81b5ac857242a22d9 Mon Sep 17 00:00:00 2001 From: mojsior Date: Mon, 2 Feb 2026 15:08:18 +0100 Subject: [PATCH] access: tracker exits + stairs speech fix - Treat DungeonEntrances as exit targets inside dungeons/quest levels\n- Add Stairs/QuestLocations support and avoid blank TTS output\n- build_release.ps1 writes flat output to build\\releases and zip; ignore build/releases --- .gitignore | 1 + Source/diablo.cpp | 1915 +++++++++++++++++++++++++++++++++++---------- build_release.ps1 | 73 +- 3 files changed, 1556 insertions(+), 433 deletions(-) diff --git a/.gitignore b/.gitignore index 464043044..7b8f35c24 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/Source/diablo.cpp b/Source/diablo.cpp index 9dbece3d7..b2a1c5cb4 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -167,34 +167,37 @@ PlayerActionType LastPlayerAction = PlayerActionType::None; // Controller support: Actions to run after updating the cursor state. // Defined in SourceX/controls/plctrls.cpp. -extern void plrctrls_after_check_curs_move(); -extern void plrctrls_every_frame(); -extern void plrctrls_after_game_logic(); - -namespace { +extern void plrctrls_after_check_curs_move(); +extern void plrctrls_every_frame(); +extern void plrctrls_after_game_logic(); + +namespace { char gszVersionNumber[64] = "internal version unknown"; -void SelectNextTownNpcKeyPressed(); -void SelectPreviousTownNpcKeyPressed(); -void UpdateAutoWalkTownNpc(); -void UpdateAutoWalkTracker(); -void AutoWalkToTrackerTargetKeyPressed(); -void SpeakSelectedSpeedbookSpell(); -void SpellBookKeyPressed(); + void SelectNextTownNpcKeyPressed(); + void SelectPreviousTownNpcKeyPressed(); + void UpdateAutoWalkTownNpc(); + void UpdateAutoWalkTracker(); + void AutoWalkToTrackerTargetKeyPressed(); + void SpeakSelectedSpeedbookSpell(); + void SpellBookKeyPressed(); std::optional> FindKeyboardWalkPathForSpeech(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); std::optional> FindKeyboardWalkPathForSpeechRespectingDoors(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); std::optional> FindKeyboardWalkPathForSpeechIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); std::optional> FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); std::optional> FindKeyboardWalkPathForSpeechLenient(const Player &player, Point startPosition, Point destinationPosition, bool allowDestinationNonWalkable = false); std::optional> FindKeyboardWalkPathToClosestReachableForSpeech(const Player &player, Point startPosition, Point destinationPosition, Point &closestPosition); -void AppendKeyboardWalkPathForSpeech(std::string &message, const std::vector &path); -void AppendDirectionalFallback(std::string &message, const Displacement &delta); - -bool gbGameLoopStartup; -bool forceSpawn; -bool forceDiablo; -int sgnTimeoutCurs; + void AppendKeyboardWalkPathForSpeech(std::string &message, const std::vector &path); + void AppendDirectionalFallback(std::string &message, const Displacement &delta); + std::string TriggerLabelForSpeech(const TriggerStruct &trigger); + [[nodiscard]] std::string TownPortalLabelForSpeech(const Portal &portal); + std::vector CollectTownDungeonTriggerIndices(); + + 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; @@ -2222,16 +2225,22 @@ std::vector TownNpcOrder; int SelectedTownNpc = -1; int AutoWalkTownNpcTarget = -1; -enum class TrackerTargetCategory : uint8_t { - Items, - Chests, - Doors, - Shrines, - Objects, - Breakables, - Monsters, - DeadBodies, -}; +enum class TrackerTargetCategory : uint8_t { + Items, + Chests, + Doors, + Shrines, + Objects, + Breakables, + Monsters, + DeadBodies, + Npcs, + Players, + DungeonEntrances, + Stairs, + QuestLocations, + Portals, +}; TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; TrackerTargetCategory AutoWalkTrackerTargetCategory = TrackerTargetCategory::Items; ///< Category of the active auto-walk target. @@ -2517,14 +2526,20 @@ namespace { constexpr int TrackerInteractDistanceTiles = 1; constexpr int TrackerCycleDistanceTiles = 12; -int LockedTrackerItemId = -1; -int LockedTrackerChestId = -1; -int LockedTrackerDoorId = -1; -int LockedTrackerShrineId = -1; -int LockedTrackerObjectId = -1; -int LockedTrackerBreakableId = -1; -int LockedTrackerMonsterId = -1; -int LockedTrackerDeadBodyId = -1; +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; @@ -2535,17 +2550,23 @@ struct TrackerLevelKey { std::optional LockedTrackerLevelKey; -void ClearTrackerLocks() -{ - LockedTrackerItemId = -1; - LockedTrackerChestId = -1; - LockedTrackerDoorId = -1; - LockedTrackerShrineId = -1; - LockedTrackerObjectId = -1; - LockedTrackerBreakableId = -1; - LockedTrackerMonsterId = -1; - LockedTrackerDeadBodyId = -1; -} +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() { @@ -2563,135 +2584,144 @@ void EnsureTrackerLocksMatchCurrentLevel() } } -int &LockedTrackerTargetId(TrackerTargetCategory category) -{ - switch (category) { - case TrackerTargetCategory::Items: - return LockedTrackerItemId; - case TrackerTargetCategory::Chests: - return LockedTrackerChestId; - case TrackerTargetCategory::Doors: - return LockedTrackerDoorId; - case TrackerTargetCategory::Shrines: - return LockedTrackerShrineId; - case TrackerTargetCategory::Objects: - return LockedTrackerObjectId; - case TrackerTargetCategory::Breakables: - return LockedTrackerBreakableId; - case TrackerTargetCategory::Monsters: - return LockedTrackerMonsterId; - case TrackerTargetCategory::DeadBodies: - return LockedTrackerDeadBodyId; - } - app_fatal("Invalid TrackerTargetCategory"); -} - -std::string_view TrackerTargetCategoryLabel(TrackerTargetCategory category) -{ - switch (category) { - case TrackerTargetCategory::Items: - return _("items"); - case TrackerTargetCategory::Chests: - return _("chests"); - case TrackerTargetCategory::Doors: - return _("doors"); - case TrackerTargetCategory::Shrines: - return _("shrines"); - case TrackerTargetCategory::Objects: - return _("objects"); - case TrackerTargetCategory::Breakables: - return _("breakables"); - case TrackerTargetCategory::Monsters: - return _("monsters"); - case TrackerTargetCategory::DeadBodies: - return _("dead bodies"); - default: - return _("items"); - } -} - -void SpeakTrackerTargetCategory() -{ - std::string message; - StrAppend(message, _("Tracker target: "), TrackerTargetCategoryLabel(SelectedTrackerTargetCategory)); - SpeakText(message, true); -} - -void CycleTrackerTargetKeyPressed() -{ - if (!CanPlayerTakeAction() || InGameMenu()) - return; - - AutoWalkTrackerTargetId = -1; - - const SDL_Keymod modState = SDL_GetModState(); - const bool cyclePrevious = (modState & SDL_KMOD_SHIFT) != 0; - - if (cyclePrevious) { - switch (SelectedTrackerTargetCategory) { - case TrackerTargetCategory::Items: - SelectedTrackerTargetCategory = TrackerTargetCategory::DeadBodies; - break; - case TrackerTargetCategory::Chests: - SelectedTrackerTargetCategory = TrackerTargetCategory::Items; - break; - case TrackerTargetCategory::Doors: - SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; - break; - case TrackerTargetCategory::Shrines: - SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; - break; - case TrackerTargetCategory::Objects: - SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; - break; - case TrackerTargetCategory::Breakables: - SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; - break; - case TrackerTargetCategory::Monsters: - SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; - break; - case TrackerTargetCategory::DeadBodies: - default: - SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; - break; - } - } else { - switch (SelectedTrackerTargetCategory) { - case TrackerTargetCategory::Items: - SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; - break; - case TrackerTargetCategory::Chests: - SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; - break; - case TrackerTargetCategory::Doors: - SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; - break; - case TrackerTargetCategory::Shrines: - SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; - break; - case TrackerTargetCategory::Objects: - SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; - break; - case TrackerTargetCategory::Breakables: - SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; - break; - case TrackerTargetCategory::Monsters: - SelectedTrackerTargetCategory = TrackerTargetCategory::DeadBodies; - break; - case TrackerTargetCategory::DeadBodies: - default: - SelectedTrackerTargetCategory = TrackerTargetCategory::Items; - break; - } - } - - SpeakTrackerTargetCategory(); -} - -std::optional FindNearestGroundItemId(Point playerPosition) -{ - std::optional bestId; - int bestDistance = 0; +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: + return _("dungeon entrances"); + case TrackerTargetCategory::Stairs: + return _("stairs"); + case TrackerTargetCategory::QuestLocations: + return _("quest locations"); + case TrackerTargetCategory::Portals: + return _("portals"); + default: + return _("items"); + } +} + +void SpeakTrackerTargetCategory() +{ + SpeakText(TrackerTargetCategoryLabel(SelectedTrackerTargetCategory), true); +} + +[[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(); +} + +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) { @@ -2996,10 +3026,10 @@ template return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedMiscInteractableObject); } -[[nodiscard]] std::vector CollectNearbyMonsterTrackerCandidates(Point playerPosition, int maxDistance) -{ - std::vector result; - result.reserve(ActiveMonsterCount); +[[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]); @@ -3024,16 +3054,292 @@ template }); } - 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; + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + 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(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + 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), + }); + } + + // Preserve the trigger ordering from CollectTownDungeonTriggerIndices() for stable cycling. + return result; + } + + // In a dungeon, treat this category as the exit from the current area: + // - On quest levels: return trigger back to the main level. + // - On normal levels: stairs up to the previous level (or to town on level 1). + for (int i = 0; i < numtrigs; ++i) { + const TriggerStruct &trigger = trigs[i]; + if (setlevel) { + if (trigger._tmsg != WM_DIABRTNLVL) + continue; + } else { + if (trigger._tmsg != WM_DIABPREVLVL) + continue; + } + + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + 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(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + 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(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + return result; +} + +[[nodiscard]] std::vector CollectStairsTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) + return result; + + for (int i = 0; i < numtrigs; ++i) { + const TriggerStruct &trigger = trigs[i]; + if (!IsAnyOf(trigger._tmsg, WM_DIABNEXTLVL, WM_DIABPREVLVL)) + continue; + + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + return result; +} + +[[nodiscard]] std::vector CollectQuestLocationTrackerCandidates(Point playerPosition) +{ + std::vector result; + if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) + return result; + + if (setlevel) { + for (int i = 0; i < numtrigs; ++i) { + const TriggerStruct &trigger = trigs[i]; + if (trigger._tmsg != WM_DIABRTNLVL) + continue; + + const Point triggerPosition { trigger.position.x, trigger.position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + result.push_back(TrackerCandidate { + .id = i, + .distance = distance, + .name = TriggerLabelForSpeech(trigger), + }); + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + 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(), [](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()) @@ -3042,15 +3348,34 @@ template if (candidates.size() <= 1) return std::nullopt; - const size_t idx = static_cast(it - candidates.begin()); - const size_t nextIdx = (idx + 1) % candidates.size(); - return candidates[nextIdx].id; -} - -void DecorateTrackerTargetNameWithOrdinalIfNeeded(int targetId, StringOrView &targetName, const std::vector &candidates) -{ - if (targetName.empty()) - return; + const 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; @@ -3077,13 +3402,257 @@ void DecorateTrackerTargetNameWithOrdinalIfNeeded(int targetId, StringOrView &ta std::string decorated; StrAppend(decorated, baseName, " ", ordinal); - targetName = std::move(decorated); -} - -[[nodiscard]] bool IsGroundItemPresent(int itemId) -{ - if (itemId < 0 || itemId > MAXITEMS) - return false; + targetName = std::move(decorated); +} + +[[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); + default: + return {}; + } +} + +[[nodiscard]] std::string_view TrackerCategoryNoCandidatesFoundMessage(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("No items found."); + case TrackerTargetCategory::Chests: + return _("No chests found."); + case TrackerTargetCategory::Doors: + return _("No doors found."); + case TrackerTargetCategory::Shrines: + return _("No shrines found."); + case TrackerTargetCategory::Objects: + return _("No objects found."); + case TrackerTargetCategory::Breakables: + return _("No breakables found."); + case TrackerTargetCategory::Monsters: + return _("No monsters found."); + case TrackerTargetCategory::DeadBodies: + return _("No dead bodies found."); + case TrackerTargetCategory::Npcs: + return _("No NPCs found."); + case TrackerTargetCategory::Players: + return _("No players found."); + case TrackerTargetCategory::DungeonEntrances: + if (leveltype != DTYPE_TOWN) + return _("No exits found."); + return _("No dungeon entrances found."); + case TrackerTargetCategory::Stairs: + return _("No stairs found."); + case TrackerTargetCategory::QuestLocations: + return _("No quest locations found."); + case TrackerTargetCategory::Portals: + return _("No portals found."); + default: + return _("No targets found."); + } +} + +[[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."); + default: + return _("No next target."); + } +} + +[[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."); + default: + return _("No previous target."); + } +} + +void SelectTrackerTargetRelative(int delta) +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + if (MyPlayer == nullptr) + return; + + if (leveltype == DTYPE_TOWN + && IsNoneOf(SelectedTrackerTargetCategory, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies, TrackerTargetCategory::Npcs, + TrackerTargetCategory::Players, TrackerTargetCategory::DungeonEntrances, TrackerTargetCategory::Portals)) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + + EnsureTrackerLocksMatchCurrentLevel(); + + const Point playerPosition = MyPlayer->position.future; + AutoWalkTrackerTargetId = -1; + + const std::vector candidates = CollectTrackerCandidatesForSelection(SelectedTrackerTargetCategory, playerPosition); + if (candidates.empty()) { + LockedTrackerTargetId(SelectedTrackerTargetCategory) = -1; + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + return; + } + + int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); + if (candidates.size() == 1) { + lockedTargetId = candidates.front().id; + SpeakText(candidates.front().name.str(), /*force=*/true); + return; + } + const std::optional targetId = delta > 0 ? FindNextTrackerCandidateId(candidates, lockedTargetId) : FindPreviousTrackerCandidateId(candidates, lockedTargetId); + if (!targetId) { + SpeakText(delta > 0 ? TrackerCategoryNoNextMessage(SelectedTrackerTargetCategory) : TrackerCategoryNoPreviousMessage(SelectedTrackerTargetCategory), true); + return; + } + + const auto it = std::find_if(candidates.begin(), candidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == candidates.end()) { + lockedTargetId = -1; + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + return; + } + + lockedTargetId = *targetId; + StringOrView targetName = it->name.str(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, candidates); + SpeakText(targetName.str(), /*force=*/true); +} + +void TrackerPageUpKeyPressed() +{ + const SDL_Keymod modState = SDL_GetModState(); + const bool cycleCategory = (modState & SDL_KMOD_CTRL) != 0; + + if (cycleCategory) { + SelectTrackerTargetCategoryRelative(-1); + if (MyPlayer != nullptr) { + const Point playerPosition = MyPlayer->position.future; + if (CollectTrackerCandidatesForSelection(SelectedTrackerTargetCategory, playerPosition).empty()) + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + } + return; + } + + SelectTrackerTargetRelative(-1); +} + +void TrackerPageDownKeyPressed() +{ + const SDL_Keymod modState = SDL_GetModState(); + const bool cycleCategory = (modState & SDL_KMOD_CTRL) != 0; + + if (cycleCategory) { + SelectTrackerTargetCategoryRelative(+1); + if (MyPlayer != nullptr) { + const Point playerPosition = MyPlayer->position.future; + if (CollectTrackerCandidatesForSelection(SelectedTrackerTargetCategory, playerPosition).empty()) + SpeakText(TrackerCategoryNoCandidatesFoundMessage(SelectedTrackerTargetCategory), true); + } + return; + } + + SelectTrackerTargetRelative(+1); +} + +[[nodiscard]] bool IsGroundItemPresent(int itemId) +{ + if (itemId < 0 || itemId > MAXITEMS) + return false; for (uint8_t i = 0; i < ActiveItemCount; ++i) { if (ActiveItems[i] == itemId) @@ -3423,10 +3992,12 @@ void NavigateToTrackerTargetKeyPressed() { if (!CanPlayerTakeAction() || InGameMenu()) return; - if (leveltype == DTYPE_TOWN && IsNoneOf(SelectedTrackerTargetCategory, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies)) { - SpeakText(_("Not in a dungeon."), true); - return; - } + if (leveltype == DTYPE_TOWN + && IsNoneOf(SelectedTrackerTargetCategory, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies, TrackerTargetCategory::Npcs, + TrackerTargetCategory::Players, TrackerTargetCategory::DungeonEntrances, TrackerTargetCategory::Portals)) { + SpeakText(_("Not in a dungeon."), true); + return; + } if (AutomapActive) { SpeakText(_("Close the map first."), true); return; @@ -3752,10 +4323,10 @@ void NavigateToTrackerTargetKeyPressed() } break; } - case TrackerTargetCategory::DeadBodies: { - const std::vector nearbyCandidates = CollectNearbyCorpseTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); - if (cycleTarget) { - targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + 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); @@ -3784,10 +4355,252 @@ void NavigateToTrackerTargetKeyPressed() DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); if (!cycleTarget) { targetPosition = CorpsePositionForTrackerId(*targetId); - } - break; - } - } + } + break; + } + case TrackerTargetCategory::Npcs: { + const std::vector nearbyCandidates = CollectNpcTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No NPCs found."), true); + else + SpeakText(_("No next NPC."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < static_cast(GetNumTowners())) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No NPCs found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No NPCs found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = Towners[*targetId].name; + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = Towners[*targetId].position; + } + break; + } + case TrackerTargetCategory::Players: { + const std::vector nearbyCandidates = CollectPlayerTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No players found."), true); + else + SpeakText(_("No next player."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAX_PLRS) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No players found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No players found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = Players[*targetId].name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = Players[*targetId].position.future; + } + break; + } + case TrackerTargetCategory::DungeonEntrances: { + const std::vector nearbyCandidates = CollectDungeonEntranceTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No dungeon entrances found."), true); + else + SpeakText(_("No next dungeon entrance."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < numtrigs) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No dungeon entrances found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No dungeon entrances found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = TriggerLabelForSpeech(trigs[*targetId]); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + const TriggerStruct &trigger = trigs[*targetId]; + targetPosition = Point { trigger.position.x, trigger.position.y }; + } + break; + } + case TrackerTargetCategory::Stairs: { + const std::vector nearbyCandidates = CollectStairsTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No stairs found."), true); + else + SpeakText(_("No next stairs."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < numtrigs) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No stairs found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No stairs found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = std::string(it->name.str()); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + const TriggerStruct &trigger = trigs[*targetId]; + targetPosition = Point { trigger.position.x, trigger.position.y }; + } + break; + } + case TrackerTargetCategory::QuestLocations: { + const std::vector nearbyCandidates = CollectQuestLocationTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No quest locations found."), true); + else + SpeakText(_("No next quest location."), true); + return; + } + } else if ((setlevel && lockedTargetId >= 0 && lockedTargetId < numtrigs) || (!setlevel && lockedTargetId >= 0 && lockedTargetId < static_cast(sizeof(Quests) / sizeof(Quests[0])))) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No quest locations found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No quest locations found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = std::string(it->name.str()); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + if (setlevel) { + const TriggerStruct &trigger = trigs[*targetId]; + targetPosition = Point { trigger.position.x, trigger.position.y }; + } else { + const Quest &quest = Quests[static_cast(*targetId)]; + targetPosition = quest.position; + } + } + break; + } + case TrackerTargetCategory::Portals: { + const std::vector nearbyCandidates = CollectPortalTrackerCandidates(playerPosition); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No portals found."), true); + else + SpeakText(_("No next portal."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXPORTAL) { + targetId = lockedTargetId; + } else if (!nearbyCandidates.empty()) { + targetId = nearbyCandidates.front().id; + } + if (!targetId) { + SpeakText(_("No portals found."), true); + return; + } + + const auto it = std::find_if(nearbyCandidates.begin(), nearbyCandidates.end(), [id = *targetId](const TrackerCandidate &c) { return c.id == id; }); + if (it == nearbyCandidates.end()) { + lockedTargetId = -1; + SpeakText(_("No portals found."), true); + return; + } + + Point portalPosition; + if (leveltype == DTYPE_TOWN) { + const std::optional townPos = FindTownPortalPositionInTownByPortalIndex(*targetId); + if (!townPos) { + lockedTargetId = -1; + SpeakText(_("No portals found."), true); + return; + } + portalPosition = *townPos; + } else { + if (!IsTownPortalOpenOnCurrentLevel(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No portals found."), true); + return; + } + portalPosition = Portals[*targetId].position; + } + + lockedTargetId = *targetId; + targetName = TownPortalLabelForSpeech(Portals[*targetId]); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = portalPosition; + } + break; + } + } if (cycleTarget) { SpeakText(targetName.str(), /*force=*/true); @@ -3909,21 +4722,25 @@ void NavigateToTrackerTargetKeyPressed() } } - 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); -} + std::string message; + const bool speakPath = spokenPath && (!showUnreachableWarning || !spokenPath->empty()); + const bool suppressTrivialPath = spokenPath && spokenPath->empty() && !showUnreachableWarning && !targetName.empty(); + if (!targetName.empty()) { + if (showUnreachableWarning || (speakPath && !suppressTrivialPath)) + StrAppend(message, targetName, "\n"); + else + StrAppend(message, targetName); + } + if (showUnreachableWarning) { + message.append(_("Can't find a path to the target.")); + if (spokenPath && !spokenPath->empty()) + message.append("\n"); + } + if (speakPath && !suppressTrivialPath) + AppendKeyboardWalkPathForSpeech(message, *spokenPath); + + SpeakText(message, true); +} } // namespace @@ -3973,11 +4790,11 @@ bool ValidateAutoWalkObjectTarget( * @return The resolved object ID, or nullopt if nothing was found (a spoken * message will already have been emitted). */ -template -std::optional ResolveObjectTrackerTarget( - int &lockedTargetId, Point playerPosition, - Predicate isValid, FindNearest findNearest, GetName getName, - const char *notFoundMessage, StringOrView &targetName) +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) { @@ -4004,26 +4821,43 @@ std::optional ResolveObjectTrackerTarget( } lockedTargetId = *targetId; targetName = getName(*targetId); - return targetId; -} - -/** - * Called each game tick to advance auto-walk toward the current tracker target. - * Does nothing if no target is active (AutoWalkTrackerTargetId < 0) or if the - * player is not idle. Validates the target still exists and is reachable, then + return targetId; +} + +void TrackerHomeKeyPressed() +{ + const SDL_Keymod modState = SDL_GetModState(); + const bool autoWalk = (modState & SDL_KMOD_SHIFT) != 0; + + if (autoWalk) + AutoWalkToTrackerTargetKeyPressed(); + else + NavigateToTrackerTargetKeyPressed(); +} + +/** + * Called each game tick to advance auto-walk toward the current tracker target. + * Does nothing if no target is active (AutoWalkTrackerTargetId < 0) or if the + * player is not idle. Validates the target still exists and is reachable, then * computes a path. If a closed door blocks the path, reroutes to the tile * before the door. Long paths are sent in segments. */ -void UpdateAutoWalkTracker() -{ - if (AutoWalkTrackerTargetId < 0) - return; - if (leveltype == DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag || InGameMenu()) { - AutoWalkTrackerTargetId = -1; - return; - } - if (!CanPlayerTakeAction()) - return; +void UpdateAutoWalkTracker() +{ + if (AutoWalkTrackerTargetId < 0) + return; + if (IsPlayerInStore() || ChatLogFlag || HelpFlag || InGameMenu()) { + AutoWalkTrackerTargetId = -1; + return; + } + if (leveltype == DTYPE_TOWN + && IsNoneOf(AutoWalkTrackerTargetCategory, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies, TrackerTargetCategory::Npcs, + TrackerTargetCategory::Players, TrackerTargetCategory::DungeonEntrances, TrackerTargetCategory::Portals)) { + AutoWalkTrackerTargetId = -1; + return; + } + if (!CanPlayerTakeAction()) + return; if (MyPlayer == nullptr) { SpeakText(_("Cannot walk right now."), true); @@ -4106,10 +4940,10 @@ void UpdateAutoWalkTracker() destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, monsterPosition); break; } - case TrackerTargetCategory::DeadBodies: { - const int corpseId = AutoWalkTrackerTargetId; - if (!IsCorpsePresent(corpseId)) { - AutoWalkTrackerTargetId = -1; + case TrackerTargetCategory::DeadBodies: { + const int corpseId = AutoWalkTrackerTargetId; + if (!IsCorpsePresent(corpseId)) { + AutoWalkTrackerTargetId = -1; SpeakText(_("Target dead body is gone."), true); return; } @@ -4120,11 +4954,172 @@ void UpdateAutoWalkTracker() SpeakText(_("Dead body in range."), true); return; } - - destination = corpsePosition; - break; - } - } + + destination = corpsePosition; + break; + } + case TrackerTargetCategory::Npcs: { + const int npcId = AutoWalkTrackerTargetId; + if (leveltype != DTYPE_TOWN || npcId < 0 || npcId >= static_cast(GetNumTowners())) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target NPC is gone."), true); + return; + } + const Towner &towner = Towners[npcId]; + if (!IsTownerPresent(towner._ttype)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target NPC is gone."), true); + return; + } + if (playerPosition.WalkingDistance(towner.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("NPC in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, towner.position); + break; + } + case TrackerTargetCategory::Players: { + const int playerId = AutoWalkTrackerTargetId; + if (playerId < 0 || playerId >= MAX_PLRS) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target player is gone."), true); + return; + } + const Player &player = Players[playerId]; + if (!player.plractive || player._pLvlChanging || player.plrIsOnSetLevel != setlevel || player.plrlevel != MyPlayer->plrlevel) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target player is gone."), true); + return; + } + const Point targetPosition = player.position.future; + if (!InDungeonBounds(targetPosition)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target player is gone."), true); + return; + } + if (playerPosition.WalkingDistance(targetPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Player in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, targetPosition); + break; + } + case TrackerTargetCategory::DungeonEntrances: { + const int triggerIndex = AutoWalkTrackerTargetId; + if (triggerIndex < 0 || triggerIndex >= numtrigs) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target entrance is gone."), true); + return; + } + const TriggerStruct &trigger = trigs[triggerIndex]; + const bool valid = leveltype == DTYPE_TOWN + ? IsAnyOf(trigger._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP) + : (setlevel ? trigger._tmsg == WM_DIABRTNLVL : trigger._tmsg == WM_DIABPREVLVL); + if (!valid) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target entrance is gone."), true); + return; + } + const Point triggerPosition { trigger.position.x, trigger.position.y }; + if (playerPosition.WalkingDistance(triggerPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Entrance in range."), true); + return; + } + destination = triggerPosition; + break; + } + case TrackerTargetCategory::Stairs: { + const int triggerIndex = AutoWalkTrackerTargetId; + if (leveltype == DTYPE_TOWN || triggerIndex < 0 || triggerIndex >= numtrigs) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target stairs are gone."), true); + return; + } + const TriggerStruct &trigger = trigs[triggerIndex]; + if (!IsAnyOf(trigger._tmsg, WM_DIABNEXTLVL, WM_DIABPREVLVL)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target stairs are gone."), true); + return; + } + const Point triggerPosition { trigger.position.x, trigger.position.y }; + if (playerPosition.WalkingDistance(triggerPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Stairs in range."), true); + return; + } + destination = triggerPosition; + break; + } + case TrackerTargetCategory::QuestLocations: { + if (setlevel) { + const int triggerIndex = AutoWalkTrackerTargetId; + if (leveltype == DTYPE_TOWN || triggerIndex < 0 || triggerIndex >= numtrigs) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + const TriggerStruct &trigger = trigs[triggerIndex]; + if (trigger._tmsg != WM_DIABRTNLVL) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + const Point triggerPosition { trigger.position.x, trigger.position.y }; + if (playerPosition.WalkingDistance(triggerPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Quest exit in range."), true); + return; + } + destination = triggerPosition; + break; + } + + const int questIndex = AutoWalkTrackerTargetId; + if (questIndex < 0 || questIndex >= static_cast(sizeof(Quests) / sizeof(Quests[0]))) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + const Quest &quest = Quests[static_cast(questIndex)]; + if (quest._qslvl == SL_NONE || quest._qactive == QUEST_NOTAVAIL || quest._qlevel != currlevel || !InDungeonBounds(quest.position)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target quest location is gone."), true); + return; + } + if (playerPosition.WalkingDistance(quest.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Quest entrance in range."), true); + return; + } + destination = quest.position; + break; + } + case TrackerTargetCategory::Portals: { + const int portalIndex = AutoWalkTrackerTargetId; + std::optional portalPosition; + if (leveltype == DTYPE_TOWN) { + portalPosition = FindTownPortalPositionInTownByPortalIndex(portalIndex); + } else if (IsTownPortalOpenOnCurrentLevel(portalIndex)) { + portalPosition = Portals[portalIndex].position; + } + + if (!portalPosition) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target portal is gone."), true); + return; + } + if (playerPosition.WalkingDistance(*portalPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Portal in range."), true); + return; + } + destination = *portalPosition; + break; + } + } if (!destination) { AutoWalkTrackerTargetId = -1; @@ -4179,14 +5174,14 @@ void UpdateAutoWalkTracker() NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, waypoint); } -/** - * Initiates auto-walk toward the currently selected tracker target (M key). - * Resolves the target from the locked tracker target or the nearest of the - * selected category. Sets AutoWalkTrackerTargetId/Category, then calls - * UpdateAutoWalkTracker() to begin the first walk segment. Subsequent segments - * are issued per-tick. Press M again to cancel. - */ -void AutoWalkToTrackerTargetKeyPressed() +/** + * Initiates auto-walk toward the currently selected tracker target (Shift+Home). + * Resolves the target from the locked tracker target or the nearest of the + * selected category. Sets AutoWalkTrackerTargetId/Category, then calls + * UpdateAutoWalkTracker() to begin the first walk segment. Subsequent segments + * are issued per-tick. Press Shift+Home again to cancel. + */ +void AutoWalkToTrackerTargetKeyPressed() { // Cancel in-progress auto-walk (must be checked before the action guard // so that cancellation works even if the player is mid-walk). @@ -4201,10 +5196,12 @@ void AutoWalkToTrackerTargetKeyPressed() if (!CanPlayerTakeAction() || InGameMenu()) return; - if (leveltype == DTYPE_TOWN) { - SpeakText(_("Not in a dungeon."), true); - return; - } + if (leveltype == DTYPE_TOWN + && IsNoneOf(SelectedTrackerTargetCategory, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies, TrackerTargetCategory::Npcs, + TrackerTargetCategory::Players, TrackerTargetCategory::DungeonEntrances, TrackerTargetCategory::Portals)) { + SpeakText(_("Not in a dungeon."), true); + return; + } if (AutomapActive) { SpeakText(_("Close the map first."), true); return; @@ -4290,11 +5287,11 @@ void AutoWalkToTrackerTargetKeyPressed() targetName = Monsters[*targetId].name(); break; } - case TrackerTargetCategory::DeadBodies: { - if (IsCorpsePresent(lockedTargetId)) { - targetId = lockedTargetId; - } else { - targetId = FindNearestCorpseId(playerPosition); + case TrackerTargetCategory::DeadBodies: { + if (IsCorpsePresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestCorpseId(playerPosition); } if (!targetId) { SpeakText(_("No dead bodies found."), true); @@ -4306,10 +5303,126 @@ void AutoWalkToTrackerTargetKeyPressed() return; } lockedTargetId = *targetId; - targetName = _("Dead body"); - break; - } - } + 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; + } + } std::string msg; StrAppend(msg, _("Going to: "), targetName); @@ -5521,17 +6634,31 @@ void SpeakNearestUnexploredTileKeyPressed() SpeakText(message, true); } -void SpeakPlayerHealthPercentageKeyPressed() -{ - if (!CanPlayerTakeAction()) - return; - if (MyPlayer == nullptr) - return; - - const int maxHp = MyPlayer->_pMaxHP; - if (maxHp <= 0) - return; - +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) { + const int maxMana = MyPlayer->_pMaxMana; + if (maxMana <= 0) + return; + + const int currentMana = std::max(MyPlayer->_pMana, 0); + int manaPercent = static_cast((static_cast(currentMana) * 100 + maxMana / 2) / maxMana); + manaPercent = std::clamp(manaPercent, 0, 100); + SpeakText(fmt::format("{:d}%", manaPercent), /*force=*/true); + return; + } + + const int maxHp = MyPlayer->_pMaxHP; + if (maxHp <= 0) + return; + const int currentHp = std::max(MyPlayer->_pHitPoints, 0); int hpPercent = static_cast((static_cast(currentHp) * 100 + maxHp / 2) / maxHp); hpPercent = std::clamp(hpPercent, 0, 100); @@ -5970,102 +7097,78 @@ void InitKeymapActions() 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."), + options.Keymapper.AddAction( + "ListTownNpcs", + N_("List town NPCs"), + N_("Speaks a list of town NPCs."), + SDLK_UNKNOWN, + ListTownNpcsKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "PreviousTownNpc", + N_("Previous town NPC"), + N_("Select previous town NPC (speaks)."), + SDLK_UNKNOWN, + SelectPreviousTownNpcKeyPressed, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "NextTownNpc", + N_("Next town NPC"), + N_("Select next town NPC (speaks)."), + SDLK_UNKNOWN, + SelectNextTownNpcKeyPressed, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "SpeakSelectedTownNpc", + N_("Speak selected town NPC"), + N_("Speaks the currently selected town NPC."), + SDLK_UNKNOWN, + SpeakSelectedTownNpc, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "GoToSelectedTownNpc", + N_("Go to selected town NPC"), + N_("Walks to the selected town NPC."), + SDLK_UNKNOWN, + GoToSelectedTownNpcKeyPressed, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "SpeakNearestUnexploredSpace", + N_("Nearest unexplored space"), + N_("Speaks the nearest unexplored space."), 'H', SpeakNearestUnexploredTileKeyPressed, nullptr, CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpeakNearestExit", - N_("Nearest exit"), - N_("Speaks the nearest exit. Hold Shift for quest entrances (or to leave a quest level). In town, press Ctrl+E to cycle dungeon entrances."), - 'E', - SpeakNearestExitKeyPressed, - nullptr, - CanPlayerTakeAction); - options.Keymapper.AddAction( - "SpeakNearestStairsDown", - N_("Nearest stairs down"), - N_("Speaks directions to the nearest stairs down."), - '.', - SpeakNearestStairsDownKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && leveltype != DTYPE_TOWN; }); - options.Keymapper.AddAction( - "SpeakNearestStairsUp", - N_("Nearest stairs up"), - N_("Speaks directions to the nearest stairs up."), - ',', - SpeakNearestStairsUpKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && leveltype != DTYPE_TOWN; }); - options.Keymapper.AddAction( - "CycleTrackerTarget", - N_("Cycle tracker target"), - N_("Cycles what the tracker looks for (items, chests, doors, shrines, objects, breakables, monsters, dead bodies). Hold Shift to cycle backwards."), - 'T', - CycleTrackerTargetKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Keymapper.AddAction( - "NavigateToTrackerTarget", - N_("Tracker directions"), - N_("Speaks directions to a tracked target of the selected tracker category. Shift+N: cycle targets (speaks name only). Ctrl+N: clear target."), - 'N', - NavigateToTrackerTargetKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); - options.Keymapper.AddAction( - "AutoWalkToTrackerTarget", - N_("Walk to tracker target"), - N_("Automatically walks to the currently selected tracker target. Press again to cancel."), - 'M', - AutoWalkToTrackerTargetKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "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"), @@ -6201,14 +7304,14 @@ void InitKeymapActions() }, 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( + "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"), @@ -6311,19 +7414,11 @@ void InitKeymapActions() "Programming is like magic.", 'X', [] { - DebugToggle = !DebugToggle; - }); -#endif - options.Keymapper.AddAction( - "SpeakNearestTownPortal", - N_("Nearest town portal"), - N_("Speaks directions to the nearest open town portal in town."), - 'P', - SpeakNearestTownPortalInTownKeyPressed, - nullptr, - []() { return CanPlayerTakeAction() && leveltype == DTYPE_TOWN; }); - options.Keymapper.CommitActions(); -} + DebugToggle = !DebugToggle; + }); +#endif + options.Keymapper.CommitActions(); +} void InitPadmapActions() { diff --git a/build_release.ps1 b/build_release.ps1 index e664f1070..407447a47 100644 --- a/build_release.ps1 +++ b/build_release.ps1 @@ -1,12 +1,12 @@ <# .SYNOPSIS - Builds a Windows Release package and writes it to build\Release. + Builds a Windows Release package and writes it to build\releases. .DESCRIPTION - Runs CMake configure (if needed) and builds the project in Release mode. - - Creates a portable "release" folder and a zip with the runtime files - (exe + required DLLs + assets/audio/mods + README.md). - - Output location (by default): build\Release + - 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 @@ -24,7 +24,13 @@ param( # Where to write the packaged release (folder + zip). # Default matches the typical CMake multi-config output dir on Windows. - [string]$OutputDir = '' + [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 @@ -50,10 +56,6 @@ function Resolve-Version { $repoRoot = (Resolve-Path $PSScriptRoot).Path $buildDirPath = Join-Path $repoRoot $BuildDir - -if ([string]::IsNullOrWhiteSpace($OutputDir)) { - $OutputDir = Join-Path $BuildDir $Config -} $outputDirPath = Join-Path $repoRoot $OutputDir $cmakeCache = Join-Path $buildDirPath 'CMakeCache.txt' @@ -72,17 +74,23 @@ if (-not (Test-Path $buildOutputDir)) { $version = Resolve-Version -RepoRoot $repoRoot $packageName = "diabloaccess-$version-windows-x64" -$packageDir = Join-Path $outputDirPath $packageName $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 $packageDir) { - Remove-Item -Recurse -Force $packageDir +if (Test-Path $stagingDir) { + Remove-Item -Recurse -Force $stagingDir } -New-Item -ItemType Directory -Force $packageDir | Out-Null +New-Item -ItemType Directory -Force $stagingDir | Out-Null foreach ($d in @('assets', 'audio', 'mods')) { - New-Item -ItemType Directory -Force (Join-Path $packageDir $d) | Out-Null + New-Item -ItemType Directory -Force (Join-Path $stagingDir $d) | Out-Null } $runtimeFiles = @( @@ -101,12 +109,12 @@ foreach ($file in $runtimeFiles) { if (-not (Test-Path $src)) { throw "Missing runtime file in build output: $src" } - Copy-Item $src $packageDir -Force + Copy-Item $src $stagingDir -Force } $readmeSrc = Join-Path $repoRoot 'README.md' if (Test-Path $readmeSrc) { - Copy-Item $readmeSrc (Join-Path $packageDir 'README.md') -Force + Copy-Item $readmeSrc (Join-Path $stagingDir 'README.md') -Force } else { Write-Warning "README.md not found at repo root; skipping." } @@ -119,21 +127,40 @@ if (-not (Test-Path $assetsSrc)) { if (-not (Test-Path $assetsSrc)) { throw "Assets directory not found (expected build\\assets or build\\$Config\\assets)." } -Copy-Item (Join-Path $assetsSrc '*') (Join-Path $packageDir 'assets') -Recurse -Force +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 $packageDir 'audio') -Recurse -Force -Copy-Item (Join-Path $modsSrc '*') (Join-Path $packageDir 'mods') -Recurse -Force +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 } -Compress-Archive -Path (Join-Path $packageDir '*') -DestinationPath $zipPath -Force - -Write-Host "Release folder: $packageDir" -Write-Host "Release zip: $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"