diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 2179dadf2..334ccbd66 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -3,6 +3,10 @@ include(functions/devilutionx_library) include(functions/genex) set(libdevilutionx_SRCS + accessibility/location_speech.cpp + accessibility/speech.cpp + accessibility/town_navigation.cpp + accessibility/tracker.cpp appfat.cpp automap.cpp capture.cpp diff --git a/Source/accessibility/location_speech.cpp b/Source/accessibility/location_speech.cpp new file mode 100644 index 000000000..ed6a311fc --- /dev/null +++ b/Source/accessibility/location_speech.cpp @@ -0,0 +1,1327 @@ +#include "accessibility/location_speech.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include + +#include "accessibility/tracker.hpp" +#include "automap.h" +#include "control/control.hpp" +#include "controls/plrctrls.h" +#include "cursor.h" +#include "diablo.h" +#include "engine/path.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 "tables/playerdat.hpp" +#include "utils/format_int.hpp" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/sdl_compat.h" +#include "utils/str_cat.hpp" + +namespace devilution { + +namespace { + +// Walk direction helpers (duplicated locally to avoid exposing them as public API). +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; + } +} + +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; + } +} + +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; +} + +std::string TriggerLabelForSpeech(const TriggerStruct &trigger) +{ + switch (trigger._tmsg) { + case WM_DIABNEXTLVL: + if (leveltype == DTYPE_TOWN) + return std::string { _("Cathedral entrance") }; + return std::string { _("Stairs down") }; + case WM_DIABPREVLVL: + return std::string { _("Stairs up") }; + case WM_DIABTOWNWARP: + switch (trigger._tlvl) { + case 5: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Catacombs")); + case 9: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Caves")); + case 13: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Hell")); + case 17: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Nest")); + case 21: + return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Crypt")); + default: + return fmt::format(fmt::runtime(_("Town warp to level {:d}")), trigger._tlvl); + } + case WM_DIABTWARPUP: + return std::string { _("Warp up") }; + case WM_DIABRETOWN: + return std::string { _("Return to town") }; + case WM_DIABWARPLVL: + return std::string { _("Warp") }; + case WM_DIABSETLVL: + return std::string { _("Set level") }; + case WM_DIABRTNLVL: + return std::string { _("Return level") }; + default: + return std::string { _("Exit") }; + } +} + +std::optional LockedTownDungeonTriggerIndex; + +std::vector CollectTownDungeonTriggerIndices() +{ + std::vector result; + result.reserve(static_cast(std::max(0, numtrigs))); + + for (int i = 0; i < numtrigs; ++i) { + if (IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP)) + result.push_back(i); + } + + std::sort(result.begin(), result.end(), [](int a, int b) { + const TriggerStruct &ta = trigs[a]; + const TriggerStruct &tb = trigs[b]; + + const int kindA = ta._tmsg == WM_DIABNEXTLVL ? 0 : (ta._tmsg == WM_DIABTOWNWARP ? 1 : 2); + const int kindB = tb._tmsg == WM_DIABNEXTLVL ? 0 : (tb._tmsg == WM_DIABTOWNWARP ? 1 : 2); + if (kindA != kindB) + return kindA < kindB; + + if (ta._tmsg == WM_DIABTOWNWARP && tb._tmsg == WM_DIABTOWNWARP && ta._tlvl != tb._tlvl) + return ta._tlvl < tb._tlvl; + + return a < b; + }); + + return result; +} + +std::optional FindDefaultTownDungeonTriggerIndex(const std::vector &candidates) +{ + for (const int index : candidates) { + if (trigs[index]._tmsg == WM_DIABNEXTLVL) + return index; + } + if (!candidates.empty()) + return candidates.front(); + return std::nullopt; +} + +std::optional FindLockedTownDungeonTriggerIndex(const std::vector &candidates) +{ + if (!LockedTownDungeonTriggerIndex) + return std::nullopt; + if (std::find(candidates.begin(), candidates.end(), *LockedTownDungeonTriggerIndex) != candidates.end()) + return *LockedTownDungeonTriggerIndex; + return std::nullopt; +} + +std::optional FindNextTownDungeonTriggerIndex(const std::vector &candidates, int current) +{ + if (candidates.empty()) + return std::nullopt; + + const auto it = std::find(candidates.begin(), candidates.end(), current); + if (it == candidates.end()) + return candidates.front(); + if (std::next(it) == candidates.end()) + return candidates.front(); + return *std::next(it); +} + +std::optional FindPreferredExitTriggerIndex() +{ + if (numtrigs <= 0) + return std::nullopt; + + if (leveltype == DTYPE_TOWN && MyPlayer != nullptr) { + const Point playerPosition = MyPlayer->position.future; + std::optional bestIndex; + int bestDistance = 0; + + for (int i = 0; i < numtrigs; ++i) { + if (!IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP)) + continue; + + const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + if (!bestIndex || distance < bestDistance) { + bestIndex = i; + bestDistance = distance; + } + } + + if (bestIndex) + return bestIndex; + } + + const Point playerPosition = MyPlayer->position.future; + std::optional bestIndex; + int bestDistance = 0; + + for (int i = 0; i < numtrigs; ++i) { + const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + if (!bestIndex || distance < bestDistance) { + bestIndex = i; + bestDistance = distance; + } + } + + return bestIndex; +} + +std::optional FindNearestTriggerIndexWithMessage(int message) +{ + if (numtrigs <= 0 || MyPlayer == nullptr) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + std::optional bestIndex; + int bestDistance = 0; + + for (int i = 0; i < numtrigs; ++i) { + if (trigs[i]._tmsg != message) + continue; + + const Point triggerPosition { trigs[i].position.x, trigs[i].position.y }; + const int distance = playerPosition.WalkingDistance(triggerPosition); + if (!bestIndex || distance < bestDistance) { + bestIndex = i; + bestDistance = distance; + } + } + + return bestIndex; +} + +std::optional FindNearestTownPortalOnCurrentLevel() +{ + if (MyPlayer == nullptr || leveltype == DTYPE_TOWN) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + const int currentLevel = setlevel ? static_cast(setlvlnum) : currlevel; + + std::optional bestPosition; + int bestDistance = 0; + + for (int i = 0; i < MAXPORTAL; ++i) { + const Portal &portal = Portals[i]; + if (!portal.open) + continue; + if (portal.setlvl != setlevel) + continue; + if (portal.level != currentLevel) + continue; + + const int distance = playerPosition.WalkingDistance(portal.position); + if (!bestPosition || distance < bestDistance) { + bestPosition = portal.position; + bestDistance = distance; + } + } + + return bestPosition; +} + +struct TownPortalInTown { + int portalIndex; + Point position; + int distance; +}; + +std::optional FindNearestTownPortalInTown() +{ + if (MyPlayer == nullptr || leveltype != DTYPE_TOWN) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + + std::optional best; + int bestDistance = 0; + + for (const Missile &missile : Missiles) { + if (missile._mitype != MissileID::TownPortal) + continue; + if (missile._misource < 0 || missile._misource >= MAXPORTAL) + continue; + if (!Portals[missile._misource].open) + continue; + + const Point portalPosition = missile.position.tile; + const int distance = playerPosition.WalkingDistance(portalPosition); + if (!best || distance < bestDistance) { + best = TownPortalInTown { + .portalIndex = missile._misource, + .position = portalPosition, + .distance = distance, + }; + bestDistance = distance; + } + } + + return best; +} + +[[nodiscard]] std::string TownPortalLabelForSpeech(const Portal &portal) +{ + if (portal.level <= 0) + return std::string { _("Town portal") }; + + if (portal.setlvl) { + const auto questLevel = static_cast<_setlevels>(portal.level); + const char *questLevelName = QuestLevelNames[questLevel]; + if (questLevelName == nullptr || questLevelName[0] == '\0') + return std::string { _("Town portal to set level") }; + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a set/quest level name. */ "Town portal to {:s}")), _(questLevelName)); + } + + constexpr std::array DungeonStrs = { + N_("Town"), + N_("Cathedral"), + N_("Catacombs"), + N_("Caves"), + N_("Hell"), + N_("Nest"), + N_("Crypt"), + }; + std::string dungeonStr; + if (portal.ltype >= DTYPE_TOWN && portal.ltype <= DTYPE_LAST) { + dungeonStr = _(DungeonStrs[static_cast(portal.ltype)]); + } else { + dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None"); + } + + int floor = portal.level; + if (portal.ltype == DTYPE_CATACOMBS) + floor -= 4; + else if (portal.ltype == DTYPE_CAVES) + floor -= 8; + else if (portal.ltype == DTYPE_HELL) + floor -= 12; + else if (portal.ltype == DTYPE_NEST) + floor -= 16; + else if (portal.ltype == DTYPE_CRYPT) + floor -= 20; + + if (floor > 0) + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name and {:d} is a floor number. */ "Town portal to {:s} {:d}")), dungeonStr, floor); + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name. */ "Town portal to {:s}")), dungeonStr); +} + +struct QuestSetLevelEntrance { + _setlevels questLevel; + Point entrancePosition; + int distance; +}; + +std::optional FindNearestQuestSetLevelEntranceOnCurrentLevel() +{ + if (MyPlayer == nullptr || setlevel) + return std::nullopt; + + const Point playerPosition = MyPlayer->position.future; + std::optional best; + int bestDistance = 0; + + for (const Quest &quest : Quests) { + if (quest._qslvl == SL_NONE) + continue; + if (quest._qactive == QUEST_NOTAVAIL) + continue; + if (quest._qlevel != currlevel) + continue; + if (!InDungeonBounds(quest.position)) + continue; + + const int distance = playerPosition.WalkingDistance(quest.position); + if (!best || distance < bestDistance) { + best = QuestSetLevelEntrance { + .questLevel = quest._qslvl, + .entrancePosition = quest.position, + .distance = distance, + }; + bestDistance = distance; + } + } + + return best; +} + +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; +} + +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); +} + +} // namespace + +std::string BuildCurrentLocationForSpeech() +{ + // Quest Level Name + if (setlevel) { + const char *const questLevelName = QuestLevelNames[setlvlnum]; + if (questLevelName == nullptr || questLevelName[0] == '\0') + return std::string { _("Set level") }; + + return fmt::format("{:s}: {:s}", _("Set level"), _(questLevelName)); + } + + // Dungeon Name + constexpr std::array DungeonStrs = { + N_("Town"), + N_("Cathedral"), + N_("Catacombs"), + N_("Caves"), + N_("Hell"), + N_("Nest"), + N_("Crypt"), + }; + std::string dungeonStr; + if (leveltype >= DTYPE_TOWN && leveltype <= DTYPE_LAST) { + dungeonStr = _(DungeonStrs[static_cast(leveltype)]); + } else { + dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None"); + } + + if (leveltype == DTYPE_TOWN || currlevel <= 0) + return dungeonStr; + + // Dungeon Level + int level = currlevel; + if (leveltype == DTYPE_CATACOMBS) + level -= 4; + else if (leveltype == DTYPE_CAVES) + level -= 8; + else if (leveltype == DTYPE_HELL) + level -= 12; + else if (leveltype == DTYPE_NEST) + level -= 16; + else if (leveltype == DTYPE_CRYPT) + level -= 20; + + if (level <= 0) + return dungeonStr; + + return fmt::format(fmt::runtime(_(/* TRANSLATORS: dungeon type and floor number i.e. "Cathedral 3"*/ "{} {}")), dungeonStr, level); +} + +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")); +} + +void SpeakNearestUnexploredTileKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (leveltype == DTYPE_TOWN) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const Point startPosition = MyPlayer->position.future; + const std::optional target = FindNearestUnexploredTile(startPosition); + if (!target) { + SpeakText(_("No unexplored areas found."), true); + return; + } + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, *target); + std::string message; + if (!path) + AppendDirectionalFallback(message, *target - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + + SpeakText(message, true); +} + +void SpeakPlayerHealthPercentageKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (MyPlayer == nullptr) + return; + + const int maxHp = MyPlayer->_pMaxHP; + if (maxHp <= 0) + return; + + const int currentHp = std::max(MyPlayer->_pHitPoints, 0); + int hpPercent = static_cast((static_cast(currentHp) * 100 + maxHp / 2) / maxHp); + hpPercent = std::clamp(hpPercent, 0, 100); + SpeakText(fmt::format("{:d}%", hpPercent), /*force=*/true); +} + +void SpeakExperienceToNextLevelKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (MyPlayer == nullptr) + return; + + const Player &myPlayer = *MyPlayer; + if (myPlayer.isMaxCharacterLevel()) { + SpeakText(_("Max level."), /*force=*/true); + return; + } + + const uint32_t nextExperienceThreshold = myPlayer.getNextExperienceThreshold(); + const uint32_t currentExperience = myPlayer._pExperience; + const uint32_t remainingExperience = currentExperience >= nextExperienceThreshold ? 0 : nextExperienceThreshold - currentExperience; + const int nextLevel = myPlayer.getCharacterLevel() + 1; + SpeakText( + fmt::format(fmt::runtime(_("{:s} to Level {:d}")), FormatInteger(remainingExperience), nextLevel), + /*force=*/true); +} + +void SpeakCurrentLocationKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + + SpeakText(BuildCurrentLocationForSpeech(), /*force=*/true); +} + +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 = std::string(_(QuestLevelNames[entrance->questLevel])); + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); + return; + } + + SpeakText(_("No quest entrances found."), true); + return; + } + + if (leveltype == DTYPE_TOWN) { + const std::vector dungeonCandidates = CollectTownDungeonTriggerIndices(); + if (dungeonCandidates.empty()) { + SpeakText(_("No exits found."), true); + return; + } + + if (cycleTownDungeon) { + if (dungeonCandidates.size() <= 1) { + SpeakText(_("No other dungeon entrances found."), true); + return; + } + + const int current = LockedTownDungeonTriggerIndex.value_or(-1); + const std::optional next = FindNextTownDungeonTriggerIndex(dungeonCandidates, current); + if (!next) { + SpeakText(_("No other dungeon entrances found."), true); + return; + } + + LockedTownDungeonTriggerIndex = *next; + const std::string label = TriggerLabelForSpeech(trigs[*next]); + if (!label.empty()) + SpeakText(label, true); + return; + } + + const int triggerIndex = FindLockedTownDungeonTriggerIndex(dungeonCandidates) + .value_or(FindDefaultTownDungeonTriggerIndex(dungeonCandidates).value_or(dungeonCandidates.front())); + LockedTownDungeonTriggerIndex = triggerIndex; + + const TriggerStruct &trigger = trigs[triggerIndex]; + const Point targetPosition { trigger.position.x, trigger.position.y }; + + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + std::string message = TriggerLabelForSpeech(trigger); + if (!message.empty()) + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + + SpeakText(message, true); + return; + } + + if (leveltype != DTYPE_TOWN) { + if (const std::optional portalPosition = FindNearestTownPortalOnCurrentLevel(); portalPosition) { + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, *portalPosition); + std::string message { _("Return to town") }; + message.append(": "); + if (!path) + AppendDirectionalFallback(message, *portalPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); + return; + } + + const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(WM_DIABPREVLVL); + if (!triggerIndex) { + SpeakText(_("No exits found."), true); + return; + } + + const TriggerStruct &trigger = trigs[*triggerIndex]; + const Point targetPosition { trigger.position.x, trigger.position.y }; + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + std::string message = TriggerLabelForSpeech(trigger); + if (!message.empty()) + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + SpeakText(message, true); + return; + } + + const std::optional triggerIndex = FindPreferredExitTriggerIndex(); + if (!triggerIndex) { + SpeakText(_("No exits found."), true); + return; + } + + const TriggerStruct &trigger = trigs[*triggerIndex]; + const Point targetPosition { trigger.position.x, trigger.position.y }; + + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + std::string message = TriggerLabelForSpeech(trigger); + if (!message.empty()) + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + + SpeakText(message, true); +} + +void SpeakNearestTownPortalInTownKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (leveltype != DTYPE_TOWN) { + SpeakText(_("Not in town."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const std::optional portal = FindNearestTownPortalInTown(); + if (!portal) { + SpeakText(_("No town portals found."), true); + return; + } + + const Point startPosition = MyPlayer->position.future; + const Point targetPosition = portal->position; + + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + + std::string message = TownPortalLabelForSpeech(Portals[portal->portalIndex]); + message.append(": "); + if (!path) + AppendDirectionalFallback(message, targetPosition - startPosition); + else + AppendKeyboardWalkPathForSpeech(message, *path); + + SpeakText(message, true); +} + +void SpeakNearestStairsDownKeyPressed() +{ + SpeakNearestStairsKeyPressed(WM_DIABNEXTLVL); +} + +void SpeakNearestStairsUpKeyPressed() +{ + SpeakNearestStairsKeyPressed(WM_DIABPREVLVL); +} + +bool IsKeyboardWalkAllowed() +{ + return CanPlayerTakeAction() + && !InGameMenu() + && !IsPlayerInStore() + && !QuestLogIsOpen + && !HelpFlag + && !ChatLogFlag + && !ChatFlag + && !DropGoldFlag + && !IsStashOpen + && !IsWithdrawGoldOpen + && !AutomapActive + && !invflag + && !CharFlag + && !SpellbookFlag + && !SpellSelectFlag + && !qtextflag; +} + +void KeyboardWalkKeyPressed(Direction direction) +{ + CancelAutoWalk(); + if (!IsKeyboardWalkAllowed()) + return; + + if (MyPlayer == nullptr) + return; + + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, MyPlayer->position.future + direction); +} + +void KeyboardWalkNorthKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::NorthEast); +} + +void KeyboardWalkSouthKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::SouthWest); +} + +void KeyboardWalkEastKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::SouthEast); +} + +void KeyboardWalkWestKeyPressed() +{ + KeyboardWalkKeyPressed(Direction::NorthWest); +} + +} // namespace devilution diff --git a/Source/accessibility/location_speech.hpp b/Source/accessibility/location_speech.hpp new file mode 100644 index 000000000..c3e5ba1bf --- /dev/null +++ b/Source/accessibility/location_speech.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include + +#include "engine/displacement.hpp" +#include "engine/point.hpp" + +namespace devilution { + +struct Player; + +// Path-finding utilities for speech/navigation (BFS over the dungeon grid). +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); + +// Key handler that speaks the nearest unexplored tile direction. +void SpeakNearestUnexploredTileKeyPressed(); + +[[nodiscard]] std::string BuildCurrentLocationForSpeech(); + +// Key handlers for player status announcements. +void SpeakPlayerHealthPercentageKeyPressed(); +void SpeakExperienceToNextLevelKeyPressed(); +void SpeakCurrentLocationKeyPressed(); + +// Key handlers for navigation. +void SpeakNearestExitKeyPressed(); +void SpeakNearestTownPortalInTownKeyPressed(); +void SpeakNearestStairsDownKeyPressed(); +void SpeakNearestStairsUpKeyPressed(); + +// Keyboard directional walk. +bool IsKeyboardWalkAllowed(); +void KeyboardWalkNorthKeyPressed(); +void KeyboardWalkSouthKeyPressed(); +void KeyboardWalkEastKeyPressed(); +void KeyboardWalkWestKeyPressed(); + +} // namespace devilution diff --git a/Source/accessibility/speech.cpp b/Source/accessibility/speech.cpp new file mode 100644 index 000000000..9eeed9406 --- /dev/null +++ b/Source/accessibility/speech.cpp @@ -0,0 +1,490 @@ +#include "accessibility/speech.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#if !defined(USE_SDL3) && !defined(NOSOUND) +#include +#endif + +#include +#include "controls/plrctrls.h" +#include "engine/sound.h" +#include "inv.h" +#include "options.h" +#include "items.h" +#include "levels/gendung.h" +#include "monster.h" +#include "objects.h" +#include "player.h" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" + +namespace devilution { + +#ifdef NOSOUND +void UpdatePlayerLowHpWarningSound() +{ +} +#else +namespace { + +std::unique_ptr PlayerLowHpWarningSound; +bool TriedLoadingPlayerLowHpWarningSound = false; + +TSnd *GetPlayerLowHpWarningSound() +{ + if (TriedLoadingPlayerLowHpWarningSound) + return PlayerLowHpWarningSound.get(); + TriedLoadingPlayerLowHpWarningSound = true; + + if (!gbSndInited) + return nullptr; + + PlayerLowHpWarningSound = std::make_unique(); + PlayerLowHpWarningSound->start_tc = SDL_GetTicks() - 80 - 1; + + // Support both the new "playerhaslowhp" name and the older underscore version. + if (PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\playerhaslowhp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\playerhaslowhp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\player_has_low_hp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\player_has_low_hp.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\playerhaslowhp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\playerhaslowhp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\player_has_low_hp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\player_has_low_hp.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\playerhaslowhp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\playerhaslowhp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("audio\\player_has_low_hp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0 + && PlayerLowHpWarningSound->DSB.SetChunkStream("..\\audio\\player_has_low_hp.wav", /*isMp3=*/false, /*logErrors=*/false) != 0) { + PlayerLowHpWarningSound = nullptr; + } + + return PlayerLowHpWarningSound.get(); +} + +void StopPlayerLowHpWarningSound() +{ + if (PlayerLowHpWarningSound != nullptr) + PlayerLowHpWarningSound->DSB.Stop(); +} + +[[nodiscard]] uint32_t LowHpIntervalMs(int hpPercent) +{ + // The sound starts at 50% HP (slow) and speeds up every 10% down to 0%. + if (hpPercent > 40) + return 1500; + if (hpPercent > 30) + return 1200; + if (hpPercent > 20) + return 900; + if (hpPercent > 10) + return 600; + return 300; +} + +} // namespace + +void UpdatePlayerLowHpWarningSound() +{ + static uint32_t LastWarningStartMs = 0; + + if (!gbSndInited || !gbSoundOn || MyPlayer == nullptr || InGameMenu()) { + StopPlayerLowHpWarningSound(); + LastWarningStartMs = 0; + return; + } + + // Stop immediately when dead. + if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) { + StopPlayerLowHpWarningSound(); + LastWarningStartMs = 0; + return; + } + + const int maxHp = MyPlayer->_pMaxHP; + if (maxHp <= 0) { + StopPlayerLowHpWarningSound(); + LastWarningStartMs = 0; + return; + } + + const int hp = std::clamp(MyPlayer->_pHitPoints, 0, maxHp); + const int hpPercent = std::clamp(hp * 100 / maxHp, 0, 100); + + // Only play below (or equal to) 50% and above 0%. + if (hpPercent > 50 || hpPercent <= 0) { + StopPlayerLowHpWarningSound(); + LastWarningStartMs = 0; + return; + } + + TSnd *snd = GetPlayerLowHpWarningSound(); + if (snd == nullptr || !snd->DSB.IsLoaded()) + return; + + const uint32_t now = SDL_GetTicks(); + const uint32_t intervalMs = LowHpIntervalMs(hpPercent); + if (LastWarningStartMs == 0) + LastWarningStartMs = now - intervalMs; + if (now - LastWarningStartMs < intervalMs) + return; + + // Restart the cue even if it's already playing so the "tempo" is controlled by HP. + snd->DSB.Stop(); + snd_play_snd(snd, /*lVolume=*/0, /*lPan=*/0, *GetOptions().Audio.soundVolume); + LastWarningStartMs = now; +} +#endif // NOSOUND + +namespace { + +[[nodiscard]] bool IsBossMonsterForHpAnnouncement(const Monster &monster) +{ + return monster.isUnique() || monster.ai == MonsterAIID::Diablo; +} + +} // namespace + +void UpdateLowDurabilityWarnings() +{ + static std::array WarnedSeeds {}; + static std::array HasWarned {}; + + if (MyPlayer == nullptr) + return; + if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) + return; + + std::vector newlyLow; + newlyLow.reserve(NUM_INVLOC); + + for (int slot = 0; slot < NUM_INVLOC; ++slot) { + const Item &item = MyPlayer->InvBody[slot]; + if (item.isEmpty() || item._iMaxDur <= 0 || item._iMaxDur == DUR_INDESTRUCTIBLE || item._iDurability == DUR_INDESTRUCTIBLE) { + HasWarned[slot] = false; + continue; + } + + const int maxDur = item._iMaxDur; + const int durability = item._iDurability; + if (durability <= 0) { + HasWarned[slot] = false; + continue; + } + + int threshold = std::max(2, maxDur / 10); + threshold = std::clamp(threshold, 1, maxDur); + + const bool isLow = durability <= threshold; + if (!isLow) { + HasWarned[slot] = false; + continue; + } + + if (HasWarned[slot] && WarnedSeeds[slot] == item._iSeed) + continue; + + HasWarned[slot] = true; + WarnedSeeds[slot] = item._iSeed; + + const StringOrView name = item.getName(); + if (!name.empty()) + newlyLow.emplace_back(name.str().data(), name.str().size()); + } + + if (newlyLow.empty()) + return; + + // Add ordinal numbers for duplicates (e.g. two rings with the same name). + for (size_t i = 0; i < newlyLow.size(); ++i) { + int total = 0; + for (size_t j = 0; j < newlyLow.size(); ++j) { + if (newlyLow[j] == newlyLow[i]) + ++total; + } + if (total <= 1) + continue; + + int ordinal = 1; + for (size_t j = 0; j < i; ++j) { + if (newlyLow[j] == newlyLow[i]) + ++ordinal; + } + newlyLow[i] = fmt::format("{} {}", newlyLow[i], ordinal); + } + + std::string joined; + for (size_t i = 0; i < newlyLow.size(); ++i) { + if (i != 0) + joined += ", "; + joined += newlyLow[i]; + } + + SpeakText(fmt::format(fmt::runtime(_("Low durability: {:s}")), joined), /*force=*/true); +} + +void UpdateBossHealthAnnouncements() +{ + static dungeon_type LastLevelType = DTYPE_NONE; + static int LastCurrLevel = -1; + static bool LastSetLevel = false; + static _setlevels LastSetLevelNum = SL_NONE; + static std::array LastAnnouncedBucket {}; + + if (MyPlayer == nullptr) + return; + if (leveltype == DTYPE_TOWN) + return; + + const bool levelChanged = LastLevelType != leveltype || LastCurrLevel != currlevel || LastSetLevel != setlevel || LastSetLevelNum != setlvlnum; + if (levelChanged) { + LastAnnouncedBucket.fill(-1); + LastLevelType = leveltype; + LastCurrLevel = currlevel; + LastSetLevel = setlevel; + LastSetLevelNum = setlvlnum; + } + + for (size_t monsterId = 0; monsterId < MaxMonsters; ++monsterId) { + if (LastAnnouncedBucket[monsterId] < 0) + continue; + + const Monster &monster = Monsters[monsterId]; + if (monster.isInvalid || monster.hitPoints <= 0 || !IsBossMonsterForHpAnnouncement(monster)) + LastAnnouncedBucket[monsterId] = -1; + } + + for (size_t i = 0; i < ActiveMonsterCount; i++) { + const int monsterId = static_cast(ActiveMonsters[i]); + const Monster &monster = Monsters[monsterId]; + + if (monster.isInvalid) + continue; + if ((monster.flags & MFLAG_HIDDEN) != 0) + continue; + if (!IsBossMonsterForHpAnnouncement(monster)) + continue; + if (monster.hitPoints <= 0 || monster.maxHitPoints <= 0) + continue; + + const int64_t hp = std::clamp(monster.hitPoints, 0, monster.maxHitPoints); + const int64_t maxHp = monster.maxHitPoints; + const int hpPercent = static_cast(std::clamp(hp * 100 / maxHp, 0, 100)); + const int bucket = ((hpPercent + 9) / 10) * 10; + + int8_t &lastBucket = LastAnnouncedBucket[monsterId]; + if (lastBucket < 0) { + lastBucket = static_cast(((hpPercent + 9) / 10) * 10); + continue; + } + + if (bucket >= lastBucket) + continue; + + lastBucket = static_cast(bucket); + SpeakText(fmt::format(fmt::runtime(_("{:s} health: {:d}%")), monster.name(), bucket), /*force=*/false); + } +} + +void UpdateAttackableMonsterAnnouncements() +{ + static std::optional LastAttackableMonsterId; + + if (MyPlayer == nullptr) { + LastAttackableMonsterId = std::nullopt; + return; + } + if (leveltype == DTYPE_TOWN) { + LastAttackableMonsterId = std::nullopt; + return; + } + if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) { + LastAttackableMonsterId = std::nullopt; + return; + } + if (InGameMenu() || invflag) { + LastAttackableMonsterId = std::nullopt; + return; + } + + const Player &player = *MyPlayer; + const Point playerPosition = player.position.tile; + + int bestRotations = 5; + std::optional bestId; + + for (size_t i = 0; i < ActiveMonsterCount; i++) { + const int monsterId = static_cast(ActiveMonsters[i]); + const Monster &monster = Monsters[monsterId]; + + if (monster.isInvalid) + continue; + if ((monster.flags & MFLAG_HIDDEN) != 0) + continue; + if (monster.hitPoints <= 0) + continue; + if (monster.isPlayerMinion()) + continue; + if (!monster.isPossibleToHit()) + continue; + + const Point monsterPosition = monster.position.tile; + if (playerPosition.WalkingDistance(monsterPosition) > 1) + continue; + + const int d1 = static_cast(player._pdir); + const int d2 = static_cast(GetDirection(playerPosition, monsterPosition)); + + int rotations = std::abs(d1 - d2); + if (rotations > 4) + rotations = 4 - (rotations % 4); + + if (!bestId || rotations < bestRotations || (rotations == bestRotations && monsterId < *bestId)) { + bestRotations = rotations; + bestId = monsterId; + } + } + + if (!bestId) { + LastAttackableMonsterId = std::nullopt; + return; + } + + if (LastAttackableMonsterId && *LastAttackableMonsterId == *bestId) + return; + + LastAttackableMonsterId = *bestId; + + const std::string_view name = Monsters[*bestId].name(); + if (!name.empty()) + SpeakText(name, /*force=*/true); +} + +[[nodiscard]] StringOrView DoorLabelForSpeech(const Object &door) +{ + if (!door.isDoor()) + return door.name(); + + // Door state values are defined in `Source/objects.cpp` (DOOR_CLOSED=0, DOOR_OPEN=1, DOOR_BLOCKED=2). + constexpr int DoorClosed = 0; + constexpr int DoorOpen = 1; + constexpr int DoorBlocked = 2; + + // Catacombs doors are grates, so differentiate them for the screen reader / tracker. + if (IsAnyOf(door._otype, _object_id::OBJ_L2LDOOR, _object_id::OBJ_L2RDOOR)) { + if (door._oVar4 == DoorOpen) + return _("Open Grate Door"); + if (door._oVar4 == DoorClosed) + return _("Closed Grate Door"); + if (door._oVar4 == DoorBlocked) + return _("Blocked Grate Door"); + return _("Grate Door"); + } + + return door.name(); +} + +void UpdateInteractableDoorAnnouncements() +{ + static std::optional LastInteractableDoorId; + static std::optional LastInteractableDoorState; + + if (MyPlayer == nullptr) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + if (leveltype == DTYPE_TOWN) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + if (MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH || MyPlayer->hasNoLife()) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + if (InGameMenu() || invflag) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + + const Player &player = *MyPlayer; + const Point playerPosition = player.position.tile; + + std::optional bestId; + int bestRotations = 5; + int bestDistance = 0; + + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + if (dx == 0 && dy == 0) + continue; + + const Point pos = playerPosition + Displacement { dx, dy }; + if (!InDungeonBounds(pos)) + continue; + + const int objectId = std::abs(dObject[pos.x][pos.y]) - 1; + if (objectId < 0 || objectId >= MAXOBJECTS) + continue; + + const Object &door = Objects[objectId]; + if (!door.isDoor() || !door.canInteractWith()) + continue; + + const int distance = playerPosition.WalkingDistance(door.position); + if (distance > 1) + continue; + + const int d1 = static_cast(player._pdir); + const int d2 = static_cast(GetDirection(playerPosition, door.position)); + + int rotations = std::abs(d1 - d2); + if (rotations > 4) + rotations = 4 - (rotations % 4); + + if (!bestId || rotations < bestRotations || (rotations == bestRotations && distance < bestDistance) + || (rotations == bestRotations && distance == bestDistance && objectId < *bestId)) { + bestRotations = rotations; + bestDistance = distance; + bestId = objectId; + } + } + } + + if (!bestId) { + LastInteractableDoorId = std::nullopt; + LastInteractableDoorState = std::nullopt; + return; + } + + const Object &door = Objects[*bestId]; + const int state = door._oVar4; + if (LastInteractableDoorId && LastInteractableDoorState && *LastInteractableDoorId == *bestId && *LastInteractableDoorState == state) + return; + + LastInteractableDoorId = *bestId; + LastInteractableDoorState = state; + + const StringOrView label = DoorLabelForSpeech(door); + if (!label.empty()) + SpeakText(label.str(), /*force=*/true); +} + +} // namespace devilution diff --git a/Source/accessibility/speech.hpp b/Source/accessibility/speech.hpp new file mode 100644 index 000000000..4267c381d --- /dev/null +++ b/Source/accessibility/speech.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "utils/string_or_view.hpp" + +namespace devilution { + +struct Object; + +[[nodiscard]] StringOrView DoorLabelForSpeech(const Object &door); + +void UpdatePlayerLowHpWarningSound(); +void UpdateLowDurabilityWarnings(); +void UpdateBossHealthAnnouncements(); +void UpdateAttackableMonsterAnnouncements(); +void UpdateInteractableDoorAnnouncements(); + +} // namespace devilution diff --git a/Source/accessibility/town_navigation.cpp b/Source/accessibility/town_navigation.cpp new file mode 100644 index 000000000..61ccaff9c --- /dev/null +++ b/Source/accessibility/town_navigation.cpp @@ -0,0 +1,358 @@ +#include "accessibility/town_navigation.hpp" + +#include +#include +#include +#include +#include + +#include + +#include "controls/plrctrls.h" +#include "diablo.h" +#include "engine/path.h" +#include "help.h" +#include "levels/gendung.h" +#include "levels/tile_properties.hpp" +#include "multi.h" +#include "options.h" +#include "player.h" +#include "qol/chatlog.h" +#include "stores.h" +#include "towners.h" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" + +namespace devilution { + +namespace { + +std::vector TownNpcOrder; +int SelectedTownNpc = -1; +int AutoWalkTownNpcTarget = -1; + +void ResetTownNpcSelection() +{ + TownNpcOrder.clear(); + SelectedTownNpc = -1; +} + +void RefreshTownNpcOrder(bool selectFirst = false) +{ + TownNpcOrder.clear(); + if (leveltype != DTYPE_TOWN) + return; + + const Point playerPosition = MyPlayer->position.future; + + for (size_t i = 0; i < GetNumTowners(); ++i) { + const Towner &towner = Towners[i]; + if (!IsTownerPresent(towner._ttype)) + continue; + if (towner._ttype == TOWN_COW) + continue; + TownNpcOrder.push_back(static_cast(i)); + } + + if (TownNpcOrder.empty()) { + SelectedTownNpc = -1; + return; + } + + std::sort(TownNpcOrder.begin(), TownNpcOrder.end(), [&playerPosition](int a, int b) { + const Towner &townerA = Towners[a]; + const Towner &townerB = Towners[b]; + const int distanceA = playerPosition.WalkingDistance(townerA.position); + const int distanceB = playerPosition.WalkingDistance(townerB.position); + if (distanceA != distanceB) + return distanceA < distanceB; + return townerA.name < townerB.name; + }); + + if (selectFirst) { + SelectedTownNpc = TownNpcOrder.front(); + return; + } + + const auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); + if (it == TownNpcOrder.end()) + SelectedTownNpc = TownNpcOrder.front(); +} + +void EnsureTownNpcOrder() +{ + if (leveltype != DTYPE_TOWN) { + ResetTownNpcSelection(); + return; + } + if (TownNpcOrder.empty()) { + RefreshTownNpcOrder(true); + return; + } + if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { + RefreshTownNpcOrder(true); + return; + } + const auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); + if (it == TownNpcOrder.end()) + SelectedTownNpc = TownNpcOrder.front(); +} + +void SelectTownNpcRelative(int delta) +{ + if (!IsTownNpcActionAllowed()) + return; + + EnsureTownNpcOrder(); + if (TownNpcOrder.empty()) { + SpeakText(_("No town NPCs found."), true); + return; + } + + auto it = std::find(TownNpcOrder.begin(), TownNpcOrder.end(), SelectedTownNpc); + int currentIndex = (it != TownNpcOrder.end()) ? static_cast(it - TownNpcOrder.begin()) : 0; + + const int size = static_cast(TownNpcOrder.size()); + int newIndex = (currentIndex + delta) % size; + if (newIndex < 0) + newIndex += size; + SelectedTownNpc = TownNpcOrder[static_cast(newIndex)]; + SpeakSelectedTownNpc(); +} + +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; +} + +} // namespace + +bool IsTownNpcActionAllowed() +{ + return CanPlayerTakeAction() + && leveltype == DTYPE_TOWN + && !IsPlayerInStore() + && !ChatLogFlag + && !HelpFlag; +} + +void SpeakSelectedTownNpc() +{ + EnsureTownNpcOrder(); + + if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { + SpeakText(_("No NPC selected."), true); + return; + } + + const Towner &towner = Towners[SelectedTownNpc]; + const Point playerPosition = MyPlayer->position.future; + const int distance = playerPosition.WalkingDistance(towner.position); + + std::string msg; + StrAppend(msg, towner.name); + StrAppend(msg, "\n", _("Distance: "), distance); + StrAppend(msg, "\n", _("Position: "), towner.position.x, ", ", towner.position.y); + SpeakText(msg, true); +} + +void CancelTownNpcAutoWalk() +{ + AutoWalkTownNpcTarget = -1; +} + +void SelectNextTownNpcKeyPressed() +{ + SelectTownNpcRelative(+1); +} + +void SelectPreviousTownNpcKeyPressed() +{ + SelectTownNpcRelative(-1); +} + +void GoToSelectedTownNpcKeyPressed() +{ + if (!IsTownNpcActionAllowed()) + return; + + EnsureTownNpcOrder(); + if (SelectedTownNpc < 0 || SelectedTownNpc >= static_cast(GetNumTowners())) { + SpeakText(_("No NPC selected."), true); + return; + } + + const Towner &towner = Towners[SelectedTownNpc]; + + std::string msg; + StrAppend(msg, _("Going to: "), towner.name); + SpeakText(msg, true); + + AutoWalkTownNpcTarget = SelectedTownNpc; + UpdateAutoWalkTownNpc(); +} + +void UpdateAutoWalkTownNpc() +{ + if (AutoWalkTownNpcTarget < 0) + return; + if (leveltype != DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag) { + AutoWalkTownNpcTarget = -1; + return; + } + if (!CanPlayerTakeAction()) + return; + + if (MyPlayer->_pmode != PM_STAND) + return; + if (MyPlayer->walkpath[0] != WALK_NONE) + return; + if (MyPlayer->destAction != ACTION_NONE) + return; + + if (AutoWalkTownNpcTarget >= static_cast(GetNumTowners())) { + AutoWalkTownNpcTarget = -1; + SpeakText(_("No NPC selected."), true); + return; + } + + const Towner &towner = Towners[AutoWalkTownNpcTarget]; + if (!IsTownerPresent(towner._ttype) || towner._ttype == TOWN_COW) { + AutoWalkTownNpcTarget = -1; + SpeakText(_("No NPC selected."), true); + return; + } + + Player &myPlayer = *MyPlayer; + const Point playerPosition = myPlayer.position.future; + if (playerPosition.WalkingDistance(towner.position) < 2) { + const int townerIdx = AutoWalkTownNpcTarget; + AutoWalkTownNpcTarget = -1; + NetSendCmdLocParam1(true, CMD_TALKXY, towner.position, static_cast(townerIdx)); + return; + } + + constexpr size_t MaxAutoWalkPathLength = 512; + std::array path; + path.fill(WALK_NONE); + + const int steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, towner.position, path.data(), path.size()); + if (steps == 0) { + AutoWalkTownNpcTarget = -1; + std::string error; + StrAppend(error, _("Can't find a path to: "), towner.name); + SpeakText(error, true); + return; + } + + // FindPath returns 0 if the path length is equal to the maximum. + // The player walkpath buffer is MaxPathLengthPlayer, so keep segments strictly shorter. + if (steps < static_cast(MaxPathLengthPlayer)) { + const int townerIdx = AutoWalkTownNpcTarget; + AutoWalkTownNpcTarget = -1; + NetSendCmdLocParam1(true, CMD_TALKXY, towner.position, static_cast(townerIdx)); + return; + } + + const int segmentSteps = std::min(steps - 1, static_cast(MaxPathLengthPlayer - 1)); + const Point waypoint = PositionAfterWalkPathSteps(playerPosition, path.data(), segmentSteps); + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, waypoint); +} + +void ListTownNpcsKeyPressed() +{ + if (leveltype != DTYPE_TOWN) { + ResetTownNpcSelection(); + SpeakText(_("Not in town."), true); + return; + } + if (IsPlayerInStore()) + return; + + std::vector townNpcs; + std::vector cows; + + townNpcs.reserve(Towners.size()); + cows.reserve(Towners.size()); + + const Point playerPosition = MyPlayer->position.future; + + for (const Towner &towner : Towners) { + if (!IsTownerPresent(towner._ttype)) + continue; + + if (towner._ttype == TOWN_COW) { + cows.push_back(&towner); + continue; + } + + townNpcs.push_back(&towner); + } + + if (townNpcs.empty() && cows.empty()) { + ResetTownNpcSelection(); + SpeakText(_("No town NPCs found."), true); + return; + } + + std::sort(townNpcs.begin(), townNpcs.end(), [&playerPosition](const Towner *a, const Towner *b) { + const int distanceA = playerPosition.WalkingDistance(a->position); + const int distanceB = playerPosition.WalkingDistance(b->position); + if (distanceA != distanceB) + return distanceA < distanceB; + return a->name < b->name; + }); + + std::string output; + StrAppend(output, _("Town NPCs:")); + for (size_t i = 0; i < townNpcs.size(); ++i) { + StrAppend(output, "\n", i + 1, ". ", townNpcs[i]->name); + } + if (!cows.empty()) { + StrAppend(output, "\n", _("Cows: "), static_cast(cows.size())); + } + + RefreshTownNpcOrder(true); + if (SelectedTownNpc >= 0 && SelectedTownNpc < static_cast(GetNumTowners())) { + const Towner &towner = Towners[SelectedTownNpc]; + StrAppend(output, "\n", _("Selected: "), towner.name); + StrAppend(output, "\n", _("PageUp/PageDown: select. Home: go. End: repeat.")); + } + const std::string_view exitKey = GetOptions().Keymapper.KeyNameForAction("SpeakNearestExit"); + if (!exitKey.empty()) { + StrAppend(output, "\n", fmt::format(fmt::runtime(_("Cathedral entrance: press {:s}.")), exitKey)); + } + + SpeakText(output, true); +} + +} // namespace devilution diff --git a/Source/accessibility/town_navigation.hpp b/Source/accessibility/town_navigation.hpp new file mode 100644 index 000000000..70adacbed --- /dev/null +++ b/Source/accessibility/town_navigation.hpp @@ -0,0 +1,15 @@ +#pragma once + +namespace devilution { + +bool IsTownNpcActionAllowed(); +void SpeakSelectedTownNpc(); + +void SelectNextTownNpcKeyPressed(); +void SelectPreviousTownNpcKeyPressed(); +void GoToSelectedTownNpcKeyPressed(); +void ListTownNpcsKeyPressed(); +void UpdateAutoWalkTownNpc(); +void CancelTownNpcAutoWalk(); + +} // namespace devilution diff --git a/Source/accessibility/tracker.cpp b/Source/accessibility/tracker.cpp new file mode 100644 index 000000000..80e381d9f --- /dev/null +++ b/Source/accessibility/tracker.cpp @@ -0,0 +1,1830 @@ +#include "accessibility/tracker.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include + +#include "accessibility/location_speech.hpp" +#include "accessibility/speech.hpp" +#include "appfat.h" +#include "automap.h" +#include "controls/plrctrls.h" +#include "diablo.h" +#include "engine/path.h" +#include "help.h" +#include "items.h" +#include "levels/gendung.h" +#include "levels/tile_properties.hpp" +#include "monster.h" +#include "multi.h" +#include "objects.h" +#include "player.h" +#include "qol/chatlog.h" +#include "stores.h" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/sdl_compat.h" +#include "utils/str_cat.hpp" + +namespace devilution { + +TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; +TrackerTargetCategory AutoWalkTrackerTargetCategory = TrackerTargetCategory::Items; +int AutoWalkTrackerTargetId = -1; + +namespace { + +/// Maximum Chebyshev distance (in tiles) at which the player is considered +/// close enough to interact with a tracker target. +constexpr int TrackerInteractDistanceTiles = 1; +constexpr int TrackerCycleDistanceTiles = 12; + +int LockedTrackerItemId = -1; +int LockedTrackerChestId = -1; +int LockedTrackerDoorId = -1; +int LockedTrackerShrineId = -1; +int LockedTrackerObjectId = -1; +int LockedTrackerBreakableId = -1; +int LockedTrackerMonsterId = -1; +int LockedTrackerDeadBodyId = -1; + +struct TrackerLevelKey { + dungeon_type levelType; + int currLevel; + bool isSetLevel; + int setLevelNum; +}; + +std::optional LockedTrackerLevelKey; + +void ClearTrackerLocks() +{ + LockedTrackerItemId = -1; + LockedTrackerChestId = -1; + LockedTrackerDoorId = -1; + LockedTrackerShrineId = -1; + LockedTrackerObjectId = -1; + LockedTrackerBreakableId = -1; + LockedTrackerMonsterId = -1; + LockedTrackerDeadBodyId = -1; +} + +void EnsureTrackerLocksMatchCurrentLevel() +{ + const TrackerLevelKey current { + .levelType = leveltype, + .currLevel = currlevel, + .isSetLevel = setlevel, + .setLevelNum = setlvlnum, + }; + + if (!LockedTrackerLevelKey || LockedTrackerLevelKey->levelType != current.levelType || LockedTrackerLevelKey->currLevel != current.currLevel + || LockedTrackerLevelKey->isSetLevel != current.isSetLevel || LockedTrackerLevelKey->setLevelNum != current.setLevelNum) { + ClearTrackerLocks(); + LockedTrackerLevelKey = current; + } +} + +int &LockedTrackerTargetId(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return LockedTrackerItemId; + case TrackerTargetCategory::Chests: + return LockedTrackerChestId; + case TrackerTargetCategory::Doors: + return LockedTrackerDoorId; + case TrackerTargetCategory::Shrines: + return LockedTrackerShrineId; + case TrackerTargetCategory::Objects: + return LockedTrackerObjectId; + case TrackerTargetCategory::Breakables: + return LockedTrackerBreakableId; + case TrackerTargetCategory::Monsters: + return LockedTrackerMonsterId; + case TrackerTargetCategory::DeadBodies: + return LockedTrackerDeadBodyId; + } + app_fatal("Invalid TrackerTargetCategory"); +} + +std::string_view TrackerTargetCategoryLabel(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("items"); + case TrackerTargetCategory::Chests: + return _("chests"); + case TrackerTargetCategory::Doors: + return _("doors"); + case TrackerTargetCategory::Shrines: + return _("shrines"); + case TrackerTargetCategory::Objects: + return _("objects"); + case TrackerTargetCategory::Breakables: + return _("breakables"); + case TrackerTargetCategory::Monsters: + return _("monsters"); + case TrackerTargetCategory::DeadBodies: + return _("dead bodies"); + default: + return _("items"); + } +} + +void SpeakTrackerTargetCategory() +{ + std::string message; + StrAppend(message, _("Tracker target: "), TrackerTargetCategoryLabel(SelectedTrackerTargetCategory)); + SpeakText(message, true); +} + +[[nodiscard]] constexpr int CorpseTrackerIdForPosition(Point position) +{ + return position.x + position.y * MAXDUNX; +} + +[[nodiscard]] constexpr Point CorpsePositionForTrackerId(int corpseId) +{ + return { corpseId % MAXDUNX, corpseId / MAXDUNX }; +} + +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; +} + +[[nodiscard]] 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]] std::optional FindNearestCorpseId(Point playerPosition) +{ + std::optional bestId; + int bestDistance = 0; + + for (int y = 0; y < MAXDUNY; ++y) { + for (int x = 0; x < MAXDUNX; ++x) { + if (dCorpse[x][y] == 0) + continue; + + const Point position { x, y }; + const int distance = playerPosition.WalkingDistance(position); + if (!bestId || distance < bestDistance) { + bestId = CorpseTrackerIdForPosition(position); + bestDistance = distance; + } + } + } + + return bestId; +} + +struct TrackerCandidate { + int id; + int distance; + StringOrView name; +}; + +[[nodiscard]] bool IsBetterTrackerCandidate(const TrackerCandidate &a, const TrackerCandidate &b) +{ + if (a.distance != b.distance) + return a.distance < b.distance; + return a.id < b.id; +} + +[[nodiscard]] std::vector CollectNearbyItemTrackerCandidates(Point playerPosition, int maxDistance) +{ + std::vector result; + result.reserve(ActiveItemCount); + + const int minX = std::max(0, playerPosition.x - maxDistance); + const int minY = std::max(0, playerPosition.y - maxDistance); + const int maxX = std::min(MAXDUNX - 1, playerPosition.x + maxDistance); + const int maxY = std::min(MAXDUNY - 1, playerPosition.y + maxDistance); + + std::array seen {}; + + for (int y = minY; y <= maxY; ++y) { + for (int x = minX; x <= maxX; ++x) { + const int itemId = std::abs(dItem[x][y]) - 1; + if (itemId < 0 || itemId > MAXITEMS) + continue; + if (seen[itemId]) + continue; + seen[itemId] = true; + + const Item &item = Items[itemId]; + if (item.isEmpty() || item._iClass == ICLASS_NONE) + continue; + + const int distance = playerPosition.WalkingDistance(Point { x, y }); + if (distance > maxDistance) + continue; + + result.push_back(TrackerCandidate { + .id = itemId, + .distance = distance, + .name = item.getName(), + }); + } + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + return result; +} + +[[nodiscard]] std::vector CollectNearbyCorpseTrackerCandidates(Point playerPosition, int maxDistance) +{ + std::vector result; + + const int minX = std::max(0, playerPosition.x - maxDistance); + const int minY = std::max(0, playerPosition.y - maxDistance); + const int maxX = std::min(MAXDUNX - 1, playerPosition.x + maxDistance); + const int maxY = std::min(MAXDUNY - 1, playerPosition.y + maxDistance); + + for (int y = minY; y <= maxY; ++y) { + for (int x = minX; x <= maxX; ++x) { + if (dCorpse[x][y] == 0) + continue; + + const Point position { x, y }; + const int distance = playerPosition.WalkingDistance(position); + if (distance > maxDistance) + continue; + + result.push_back(TrackerCandidate { + .id = CorpseTrackerIdForPosition(position), + .distance = distance, + .name = _("Dead body"), + }); + } + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + return result; +} + +[[nodiscard]] constexpr bool IsTrackedChestObject(const Object &object) +{ + return object.canInteractWith() && (object.IsChest() || object._otype == _object_id::OBJ_SIGNCHEST); +} + +[[nodiscard]] constexpr bool IsTrackedDoorObject(const Object &object) +{ + // Track both closed and open doors (to match proximity audio cues). + return object.isDoor() && object.canInteractWith(); +} + +[[nodiscard]] constexpr bool IsShrineLikeObject(const Object &object) +{ + return object.canInteractWith() + && (object.IsShrine() + || IsAnyOf(object._otype, _object_id::OBJ_BLOODFTN, _object_id::OBJ_PURIFYINGFTN, _object_id::OBJ_GOATSHRINE, _object_id::OBJ_CAULDRON, + _object_id::OBJ_MURKYFTN, _object_id::OBJ_TEARFTN)); +} + +[[nodiscard]] constexpr bool IsTrackedBreakableObject(const Object &object) +{ + return object.IsBreakable(); +} + +[[nodiscard]] constexpr bool IsTrackedMiscInteractableObject(const Object &object) +{ + if (!object.canInteractWith()) + return false; + if (object.IsChest() || object._otype == _object_id::OBJ_SIGNCHEST) + return false; + if (object.isDoor()) + return false; + if (IsShrineLikeObject(object)) + return false; + if (object.IsBreakable()) + return false; + return true; +} + +[[nodiscard]] bool IsTrackedMonster(const Monster &monster) +{ + return !monster.isInvalid + && (monster.flags & MFLAG_HIDDEN) == 0 + && monster.hitPoints > 0; +} + +template +[[nodiscard]] std::vector CollectNearbyObjectTrackerCandidates(Point playerPosition, int maxDistance, Predicate predicate) +{ + std::vector result; + result.reserve(ActiveObjectCount); + + const int minX = std::max(0, playerPosition.x - maxDistance); + const int minY = std::max(0, playerPosition.y - maxDistance); + const int maxX = std::min(MAXDUNX - 1, playerPosition.x + maxDistance); + const int maxY = std::min(MAXDUNY - 1, playerPosition.y + maxDistance); + + std::array bestDistanceById {}; + bestDistanceById.fill(std::numeric_limits::max()); + + for (int y = minY; y <= maxY; ++y) { + for (int x = minX; x <= maxX; ++x) { + const int objectId = std::abs(dObject[x][y]) - 1; + if (objectId < 0 || objectId >= MAXOBJECTS) + continue; + + const Object &object = Objects[objectId]; + if (object._otype == OBJ_NULL) + continue; + if (!predicate(object)) + continue; + + const int distance = playerPosition.WalkingDistance(Point { x, y }); + if (distance > maxDistance) + continue; + + int &bestDistance = bestDistanceById[objectId]; + if (distance < bestDistance) + bestDistance = distance; + } + } + + for (int objectId = 0; objectId < MAXOBJECTS; ++objectId) { + const int distance = bestDistanceById[objectId]; + if (distance == std::numeric_limits::max()) + continue; + + const Object &object = Objects[objectId]; + result.push_back(TrackerCandidate { + .id = objectId, + .distance = distance, + .name = object.name(), + }); + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + return result; +} + +template +[[nodiscard]] std::optional FindNearestObjectId(Point playerPosition, Predicate predicate) +{ + std::array bestDistanceById {}; + bestDistanceById.fill(std::numeric_limits::max()); + + for (int y = 0; y < MAXDUNY; ++y) { + for (int x = 0; x < MAXDUNX; ++x) { + const int objectId = std::abs(dObject[x][y]) - 1; + if (objectId < 0 || objectId >= MAXOBJECTS) + continue; + + const Object &object = Objects[objectId]; + if (object._otype == OBJ_NULL) + continue; + if (!predicate(object)) + continue; + + const int distance = playerPosition.WalkingDistance(Point { x, y }); + int &bestDistance = bestDistanceById[objectId]; + if (distance < bestDistance) + bestDistance = distance; + } + } + + std::optional bestId; + int bestDistance = 0; + for (int objectId = 0; objectId < MAXOBJECTS; ++objectId) { + const int distance = bestDistanceById[objectId]; + if (distance == std::numeric_limits::max()) + continue; + + if (!bestId || distance < bestDistance) { + bestId = objectId; + bestDistance = distance; + } + } + + return bestId; +} + +[[nodiscard]] std::vector CollectNearbyChestTrackerCandidates(Point playerPosition, int maxDistance) +{ + return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedChestObject); +} + +[[nodiscard]] std::vector CollectNearbyDoorTrackerCandidates(Point playerPosition, int maxDistance) +{ + return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedDoorObject); +} + +[[nodiscard]] std::vector CollectNearbyShrineTrackerCandidates(Point playerPosition, int maxDistance) +{ + return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsShrineLikeObject); +} + +[[nodiscard]] std::vector CollectNearbyBreakableTrackerCandidates(Point playerPosition, int maxDistance) +{ + return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedBreakableObject); +} + +[[nodiscard]] std::vector CollectNearbyObjectInteractableTrackerCandidates(Point playerPosition, int maxDistance) +{ + return CollectNearbyObjectTrackerCandidates(playerPosition, maxDistance, IsTrackedMiscInteractableObject); +} + +[[nodiscard]] std::vector CollectNearbyMonsterTrackerCandidates(Point playerPosition, int maxDistance) +{ + std::vector result; + result.reserve(ActiveMonsterCount); + + for (size_t i = 0; i < ActiveMonsterCount; ++i) { + const int monsterId = static_cast(ActiveMonsters[i]); + const Monster &monster = Monsters[monsterId]; + + if (monster.isInvalid) + continue; + if ((monster.flags & MFLAG_HIDDEN) != 0) + continue; + if (monster.hitPoints <= 0) + continue; + + const Point monsterDistancePosition { monster.position.future }; + const int distance = playerPosition.ApproxDistance(monsterDistancePosition); + if (distance > maxDistance) + continue; + + result.push_back(TrackerCandidate { + .id = monsterId, + .distance = distance, + .name = monster.name(), + }); + } + + std::sort(result.begin(), result.end(), [](const TrackerCandidate &a, const TrackerCandidate &b) { return IsBetterTrackerCandidate(a, b); }); + return result; +} + +[[nodiscard]] std::optional FindNextTrackerCandidateId(const std::vector &candidates, int currentId) +{ + if (candidates.empty()) + return std::nullopt; + if (currentId < 0) + return candidates.front().id; + + const auto it = std::find_if(candidates.begin(), candidates.end(), [currentId](const TrackerCandidate &c) { return c.id == currentId; }); + if (it == candidates.end()) + return candidates.front().id; + + if (candidates.size() <= 1) + return std::nullopt; + + const size_t idx = static_cast(it - candidates.begin()); + const size_t nextIdx = (idx + 1) % candidates.size(); + return candidates[nextIdx].id; +} + +void DecorateTrackerTargetNameWithOrdinalIfNeeded(int targetId, StringOrView &targetName, const std::vector &candidates) +{ + if (targetName.empty()) + return; + + const std::string_view baseName = targetName.str(); + int total = 0; + for (const TrackerCandidate &c : candidates) { + if (c.name.str() == baseName) + ++total; + } + if (total <= 1) + return; + + int ordinal = 0; + int seen = 0; + for (const TrackerCandidate &c : candidates) { + if (c.name.str() != baseName) + continue; + ++seen; + if (c.id == targetId) { + ordinal = seen; + break; + } + } + if (ordinal <= 0) + return; + + std::string decorated; + StrAppend(decorated, baseName, " ", ordinal); + targetName = std::move(decorated); +} + +[[nodiscard]] bool IsGroundItemPresent(int itemId) +{ + if (itemId < 0 || itemId > MAXITEMS) + return false; + + for (uint8_t i = 0; i < ActiveItemCount; ++i) { + if (ActiveItems[i] == itemId) + return true; + } + + return false; +} + +[[nodiscard]] bool IsCorpsePresent(int corpseId) +{ + if (corpseId < 0 || corpseId >= MAXDUNX * MAXDUNY) + return false; + + const Point position = CorpsePositionForTrackerId(corpseId); + return InDungeonBounds(position) && dCorpse[position.x][position.y] != 0; +} + +[[nodiscard]] std::optional FindNearestUnopenedChestObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedChestObject); +} + +[[nodiscard]] std::optional FindNearestDoorObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedDoorObject); +} + +[[nodiscard]] std::optional FindNearestShrineObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsShrineLikeObject); +} + +[[nodiscard]] std::optional FindNearestBreakableObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedBreakableObject); +} + +[[nodiscard]] std::optional FindNearestMiscInteractableObjectId(Point playerPosition) +{ + return FindNearestObjectId(playerPosition, IsTrackedMiscInteractableObject); +} + +[[nodiscard]] std::optional FindNearestMonsterId(Point playerPosition) +{ + std::optional bestId; + int bestDistance = 0; + + for (size_t i = 0; i < ActiveMonsterCount; ++i) { + const int monsterId = static_cast(ActiveMonsters[i]); + const Monster &monster = Monsters[monsterId]; + + if (monster.isInvalid) + continue; + if ((monster.flags & MFLAG_HIDDEN) != 0) + continue; + if (monster.hitPoints <= 0) + continue; + + const Point monsterDistancePosition { monster.position.future }; + const int distance = playerPosition.ApproxDistance(monsterDistancePosition); + if (!bestId || distance < bestDistance) { + bestId = monsterId; + bestDistance = distance; + } + } + + return bestId; +} + +[[nodiscard]] 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; +} + +} // namespace + +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; +} + +namespace { + +[[nodiscard]] bool IsTileWalkableForTrackerPath(Point position, bool ignoreDoors, bool ignoreBreakables) +{ + Object *object = FindObjectAtPosition(position); + if (object != nullptr) { + if (ignoreDoors && object->isDoor()) { + return true; + } + if (ignoreBreakables && object->_oSolidFlag && object->IsBreakable()) { + return true; + } + if (object->_oSolidFlag) { + return false; + } + } + + return IsTileNotSolid(position); +} + +} // namespace + +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 { + +struct DoorBlockInfo { + Point beforeDoor; + Point doorPosition; +}; + +std::optional FindFirstClosedDoorOnWalkPath(Point startPosition, const int8_t *path, int steps) +{ + Point position = startPosition; + for (int i = 0; i < steps; ++i) { + const Point next = NextPositionForWalkDirection(position, path[i]); + Object *object = FindObjectAtPosition(next); + if (object != nullptr && object->isDoor() && object->_oSolidFlag) { + return DoorBlockInfo { .beforeDoor = position, .doorPosition = next }; + } + position = next; + } + return std::nullopt; +} + +enum class TrackerPathBlockType : uint8_t { + Door, + Monster, + Breakable, +}; + +struct TrackerPathBlockInfo { + TrackerPathBlockType type; + size_t stepIndex; + Point beforeBlock; + Point blockPosition; +}; + +[[nodiscard]] std::optional FindFirstTrackerPathBlock(Point startPosition, const int8_t *path, size_t steps, bool considerDoors, bool considerMonsters, bool considerBreakables, Point targetPosition) +{ + Point position = startPosition; + for (size_t i = 0; i < steps; ++i) { + const Point next = NextPositionForWalkDirection(position, path[i]); + if (next == targetPosition) { + position = next; + continue; + } + + Object *object = FindObjectAtPosition(next); + if (considerDoors && object != nullptr && object->isDoor() && object->_oSolidFlag) { + return TrackerPathBlockInfo { + .type = TrackerPathBlockType::Door, + .stepIndex = i, + .beforeBlock = position, + .blockPosition = next, + }; + } + if (considerBreakables && object != nullptr && object->_oSolidFlag && object->IsBreakable()) { + return TrackerPathBlockInfo { + .type = TrackerPathBlockType::Breakable, + .stepIndex = i, + .beforeBlock = position, + .blockPosition = next, + }; + } + + if (considerMonsters && leveltype != DTYPE_TOWN && dMonster[next.x][next.y] != 0) { + const int monsterRef = dMonster[next.x][next.y]; + const int monsterId = std::abs(monsterRef) - 1; + const bool blocks = monsterRef <= 0 || (monsterId >= 0 && monsterId < static_cast(MaxMonsters) && !Monsters[monsterId].hasNoLife()); + if (blocks) { + return TrackerPathBlockInfo { + .type = TrackerPathBlockType::Monster, + .stepIndex = i, + .beforeBlock = position, + .blockPosition = next, + }; + } + } + + position = next; + } + + return std::nullopt; +} + +static void NavigateToTrackerTargetKeyPressedImpl() +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + if (leveltype == DTYPE_TOWN && IsNoneOf(SelectedTrackerTargetCategory, TrackerTargetCategory::Items, TrackerTargetCategory::DeadBodies)) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + EnsureTrackerLocksMatchCurrentLevel(); + + const SDL_Keymod modState = SDL_GetModState(); + const bool cycleTarget = (modState & SDL_KMOD_SHIFT) != 0; + const bool clearTarget = (modState & SDL_KMOD_CTRL) != 0; + + const Point playerPosition = MyPlayer->position.future; + AutoWalkTrackerTargetId = -1; + + int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); + if (clearTarget) { + lockedTargetId = -1; + SpeakText(_("Tracker target cleared."), true); + return; + } + + std::optional targetId; + std::optional targetPosition; + std::optional alternateTargetPosition; + StringOrView targetName; + + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + const std::vector nearbyCandidates = CollectNearbyItemTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No items found."), true); + else + SpeakText(_("No next item."), true); + return; + } + } else if (IsGroundItemPresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestGroundItemId(playerPosition); + } + if (!targetId) { + SpeakText(_("No items found."), true); + return; + } + + if (!IsGroundItemPresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No items found."), true); + return; + } + + lockedTargetId = *targetId; + const Item &tracked = Items[*targetId]; + + targetName = tracked.getName(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + targetPosition = tracked.position; + break; + } + case TrackerTargetCategory::Chests: { + const std::vector nearbyCandidates = CollectNearbyChestTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No chests found."), true); + else + SpeakText(_("No next chest."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestUnopenedChestObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No chests found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsTrackedChestObject(object)) { + lockedTargetId = -1; + targetId = FindNearestUnopenedChestObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No chests found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Doors: { + std::vector nearbyCandidates = CollectNearbyDoorTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + for (TrackerCandidate &c : nearbyCandidates) { + if (c.id < 0 || c.id >= MAXOBJECTS) + continue; + c.name = DoorLabelForSpeech(Objects[c.id]); + } + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No doors found."), true); + else + SpeakText(_("No next door."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestDoorObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No doors found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsTrackedDoorObject(object)) { + lockedTargetId = -1; + targetId = FindNearestDoorObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No doors found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = DoorLabelForSpeech(tracked); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Shrines: { + const std::vector nearbyCandidates = CollectNearbyShrineTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No shrines found."), true); + else + SpeakText(_("No next shrine."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestShrineObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No shrines found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsShrineLikeObject(object)) { + lockedTargetId = -1; + targetId = FindNearestShrineObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No shrines found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Objects: { + const std::vector nearbyCandidates = CollectNearbyObjectInteractableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No objects found."), true); + else + SpeakText(_("No next object."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestMiscInteractableObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No objects found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsTrackedMiscInteractableObject(object)) { + lockedTargetId = -1; + targetId = FindNearestMiscInteractableObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No objects found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Breakables: { + const std::vector nearbyCandidates = CollectNearbyBreakableTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No breakables found."), true); + else + SpeakText(_("No next breakable."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestBreakableObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No breakables found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!IsTrackedBreakableObject(object)) { + lockedTargetId = -1; + targetId = FindNearestBreakableObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No breakables found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position; + if (FindObjectAtPosition(tracked.position + Direction::NorthEast) == &tracked) + alternateTargetPosition = tracked.position + Direction::NorthEast; + } + break; + } + case TrackerTargetCategory::Monsters: { + const std::vector nearbyCandidates = CollectNearbyMonsterTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No monsters found."), true); + else + SpeakText(_("No next monster."), true); + return; + } + } else if (lockedTargetId >= 0 && lockedTargetId < static_cast(MaxMonsters)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestMonsterId(playerPosition); + } + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + + const Monster &monster = Monsters[*targetId]; + if (!IsTrackedMonster(monster)) { + lockedTargetId = -1; + targetId = FindNearestMonsterId(playerPosition); + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Monster &tracked = Monsters[*targetId]; + + targetName = tracked.name(); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = tracked.position.tile; + } + break; + } + case TrackerTargetCategory::DeadBodies: { + const std::vector nearbyCandidates = CollectNearbyCorpseTrackerCandidates(playerPosition, TrackerCycleDistanceTiles); + if (cycleTarget) { + targetId = FindNextTrackerCandidateId(nearbyCandidates, lockedTargetId); + if (!targetId) { + if (nearbyCandidates.empty()) + SpeakText(_("No dead bodies found."), true); + else + SpeakText(_("No next dead body."), true); + return; + } + } else if (IsCorpsePresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestCorpseId(playerPosition); + } + if (!targetId) { + SpeakText(_("No dead bodies found."), true); + return; + } + + if (!IsCorpsePresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No dead bodies found."), true); + return; + } + + lockedTargetId = *targetId; + targetName = _("Dead body"); + DecorateTrackerTargetNameWithOrdinalIfNeeded(*targetId, targetName, nearbyCandidates); + if (!cycleTarget) { + targetPosition = CorpsePositionForTrackerId(*targetId); + } + break; + } + } + + if (cycleTarget) { + SpeakText(targetName.str(), /*force=*/true); + return; + } + + if (!targetPosition) { + SpeakText(_("Can't find a nearby tile to walk to."), true); + return; + } + + Point chosenTargetPosition = *targetPosition; + enum class TrackerPathMode : uint8_t { + RespectDoorsAndMonsters, + IgnoreDoors, + IgnoreMonsters, + IgnoreDoorsAndMonsters, + Lenient, + }; + + auto findPathToTarget = [&](Point destination, TrackerPathMode mode) -> std::optional> { + const bool allowDestinationNonWalkable = !PosOkPlayer(*MyPlayer, destination); + switch (mode) { + case TrackerPathMode::RespectDoorsAndMonsters: + return FindKeyboardWalkPathForSpeechRespectingDoors(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::IgnoreDoors: + return FindKeyboardWalkPathForSpeech(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::IgnoreMonsters: + return FindKeyboardWalkPathForSpeechRespectingDoorsIgnoringMonsters(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::IgnoreDoorsAndMonsters: + return FindKeyboardWalkPathForSpeechIgnoringMonsters(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + case TrackerPathMode::Lenient: + return FindKeyboardWalkPathForSpeechLenient(*MyPlayer, playerPosition, destination, allowDestinationNonWalkable); + default: + return std::nullopt; + } + }; + + std::optional> spokenPath; + bool pathIgnoresDoors = false; + bool pathIgnoresMonsters = false; + bool pathIgnoresBreakables = false; + + const auto considerDestination = [&](Point destination, TrackerPathMode mode) { + const std::optional> candidate = findPathToTarget(destination, mode); + if (!candidate) + return; + if (!spokenPath || candidate->size() < spokenPath->size()) { + spokenPath = *candidate; + chosenTargetPosition = destination; + + pathIgnoresDoors = mode == TrackerPathMode::IgnoreDoors || mode == TrackerPathMode::IgnoreDoorsAndMonsters || mode == TrackerPathMode::Lenient; + pathIgnoresMonsters = mode == TrackerPathMode::IgnoreMonsters || mode == TrackerPathMode::IgnoreDoorsAndMonsters || mode == TrackerPathMode::Lenient; + pathIgnoresBreakables = mode == TrackerPathMode::Lenient; + } + }; + + considerDestination(*targetPosition, TrackerPathMode::RespectDoorsAndMonsters); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::RespectDoorsAndMonsters); + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::IgnoreDoors); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreDoors); + } + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::IgnoreMonsters); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreMonsters); + } + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::IgnoreDoorsAndMonsters); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::IgnoreDoorsAndMonsters); + } + + if (!spokenPath) { + considerDestination(*targetPosition, TrackerPathMode::Lenient); + if (alternateTargetPosition) + considerDestination(*alternateTargetPosition, TrackerPathMode::Lenient); + } + + bool showUnreachableWarning = false; + if (!spokenPath) { + showUnreachableWarning = true; + Point closestPosition; + spokenPath = FindKeyboardWalkPathToClosestReachableForSpeech(*MyPlayer, playerPosition, chosenTargetPosition, closestPosition); + pathIgnoresDoors = true; + pathIgnoresMonsters = false; + pathIgnoresBreakables = false; + } + + if (spokenPath && !showUnreachableWarning && !PosOkPlayer(*MyPlayer, chosenTargetPosition)) { + if (!spokenPath->empty()) + spokenPath->pop_back(); + } + + if (spokenPath && (pathIgnoresDoors || pathIgnoresMonsters || pathIgnoresBreakables)) { + const std::optional block = FindFirstTrackerPathBlock(playerPosition, spokenPath->data(), spokenPath->size(), pathIgnoresDoors, pathIgnoresMonsters, pathIgnoresBreakables, chosenTargetPosition); + if (block) { + if (playerPosition.WalkingDistance(block->blockPosition) <= TrackerInteractDistanceTiles) { + switch (block->type) { + case TrackerPathBlockType::Door: + SpeakText(_("A door is blocking the path. Open it and try again."), true); + return; + case TrackerPathBlockType::Monster: + SpeakText(_("A monster is blocking the path. Clear it and try again."), true); + return; + case TrackerPathBlockType::Breakable: + SpeakText(_("A breakable object is blocking the path. Destroy it and try again."), true); + return; + } + } + + spokenPath = std::vector(spokenPath->begin(), spokenPath->begin() + block->stepIndex); + } + } + + std::string message; + if (!targetName.empty()) + StrAppend(message, targetName, "\n"); + if (showUnreachableWarning) { + message.append(_("Can't find a path to the target.")); + if (spokenPath && !spokenPath->empty()) + message.append("\n"); + } + if (spokenPath) { + if (!showUnreachableWarning || !spokenPath->empty()) + AppendKeyboardWalkPathForSpeech(message, *spokenPath); + } + + SpeakText(message, true); +} + +} // namespace + +void NavigateToTrackerTargetKeyPressed() +{ + NavigateToTrackerTargetKeyPressedImpl(); +} + +namespace { + +/** + * Validates an object-category auto-walk target and computes the walk destination. + */ +template +bool ValidateAutoWalkObjectTarget( + const Player &myPlayer, Point playerPosition, + Predicate isValid, const char *goneMessage, const char *inRangeMessage, + std::optional &destination) +{ + const int objectId = AutoWalkTrackerTargetId; + if (objectId < 0 || objectId >= MAXOBJECTS) { + AutoWalkTrackerTargetId = -1; + SpeakText(_(goneMessage), true); + return false; + } + const Object &object = Objects[objectId]; + if (!isValid(object)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_(goneMessage), true); + return false; + } + if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_(inRangeMessage), true); + return false; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); + return true; +} + +/** + * Resolves which object to walk toward for the given tracker category. + */ +template +std::optional ResolveObjectTrackerTarget( + int &lockedTargetId, Point playerPosition, + Predicate isValid, FindNearest findNearest, GetName getName, + const char *notFoundMessage, StringOrView &targetName) +{ + std::optional targetId; + if (lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = findNearest(playerPosition); + } + if (!targetId) { + SpeakText(_(notFoundMessage), true); + return std::nullopt; + } + if (!isValid(Objects[*targetId])) { + lockedTargetId = -1; + targetId = findNearest(playerPosition); + if (!targetId) { + SpeakText(_(notFoundMessage), true); + return std::nullopt; + } + // findNearest guarantees the result passes isValid, but verify defensively. + if (!isValid(Objects[*targetId])) { + SpeakText(_(notFoundMessage), true); + return std::nullopt; + } + } + lockedTargetId = *targetId; + targetName = getName(*targetId); + return targetId; +} + +} // namespace + +void CycleTrackerTargetKeyPressed() +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + + AutoWalkTrackerTargetId = -1; + + const SDL_Keymod modState = SDL_GetModState(); + const bool cyclePrevious = (modState & SDL_KMOD_SHIFT) != 0; + + if (cyclePrevious) { + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: + SelectedTrackerTargetCategory = TrackerTargetCategory::DeadBodies; + break; + case TrackerTargetCategory::Chests: + SelectedTrackerTargetCategory = TrackerTargetCategory::Items; + break; + case TrackerTargetCategory::Doors: + SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; + break; + case TrackerTargetCategory::Shrines: + SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; + break; + case TrackerTargetCategory::Objects: + SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; + break; + case TrackerTargetCategory::Breakables: + SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; + break; + case TrackerTargetCategory::Monsters: + SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; + break; + case TrackerTargetCategory::DeadBodies: + default: + SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; + break; + } + } else { + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: + SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; + break; + case TrackerTargetCategory::Chests: + SelectedTrackerTargetCategory = TrackerTargetCategory::Doors; + break; + case TrackerTargetCategory::Doors: + SelectedTrackerTargetCategory = TrackerTargetCategory::Shrines; + break; + case TrackerTargetCategory::Shrines: + SelectedTrackerTargetCategory = TrackerTargetCategory::Objects; + break; + case TrackerTargetCategory::Objects: + SelectedTrackerTargetCategory = TrackerTargetCategory::Breakables; + break; + case TrackerTargetCategory::Breakables: + SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; + break; + case TrackerTargetCategory::Monsters: + SelectedTrackerTargetCategory = TrackerTargetCategory::DeadBodies; + break; + case TrackerTargetCategory::DeadBodies: + default: + SelectedTrackerTargetCategory = TrackerTargetCategory::Items; + break; + } + } + + SpeakTrackerTargetCategory(); +} + +/** + * Called each game tick to advance auto-walk toward the current tracker target. + */ +void UpdateAutoWalkTracker() +{ + if (AutoWalkTrackerTargetId < 0) + return; + if (leveltype == DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag || InGameMenu()) { + AutoWalkTrackerTargetId = -1; + return; + } + if (!CanPlayerTakeAction()) + return; + + if (MyPlayer == nullptr) { + SpeakText(_("Cannot walk right now."), true); + return; + } + if (MyPlayer->_pmode != PM_STAND) + return; + if (MyPlayer->walkpath[0] != WALK_NONE) + return; + if (MyPlayer->destAction != ACTION_NONE) + return; + + Player &myPlayer = *MyPlayer; + const Point playerPosition = myPlayer.position.future; + + std::optional destination; + + switch (AutoWalkTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + const int itemId = AutoWalkTrackerTargetId; + if (itemId < 0 || itemId > MAXITEMS) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target item is gone."), true); + return; + } + if (!IsGroundItemPresent(itemId)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target item is gone."), true); + return; + } + const Item &item = Items[itemId]; + if (playerPosition.WalkingDistance(item.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Item in range."), true); + return; + } + destination = item.position; + break; + } + case TrackerTargetCategory::Chests: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, + IsTrackedChestObject, N_("Target chest is gone."), N_("Chest in range."), destination)) + return; + break; + case TrackerTargetCategory::Doors: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedDoorObject, N_("Target door is gone."), N_("Door in range."), destination)) + return; + break; + case TrackerTargetCategory::Shrines: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsShrineLikeObject, N_("Target shrine is gone."), N_("Shrine in range."), destination)) + return; + break; + case TrackerTargetCategory::Objects: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedMiscInteractableObject, N_("Target object is gone."), N_("Object in range."), destination)) + return; + break; + case TrackerTargetCategory::Breakables: + if (!ValidateAutoWalkObjectTarget(myPlayer, playerPosition, IsTrackedBreakableObject, N_("Target breakable is gone."), N_("Breakable in range."), destination)) + return; + break; + case TrackerTargetCategory::Monsters: { + const int monsterId = AutoWalkTrackerTargetId; + if (monsterId < 0 || monsterId >= static_cast(MaxMonsters)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target monster is gone."), true); + return; + } + const Monster &monster = Monsters[monsterId]; + if (!IsTrackedMonster(monster)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target monster is gone."), true); + return; + } + const Point monsterPosition { monster.position.tile }; + if (playerPosition.WalkingDistance(monsterPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Monster in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, monsterPosition); + break; + } + case TrackerTargetCategory::DeadBodies: { + const int corpseId = AutoWalkTrackerTargetId; + if (!IsCorpsePresent(corpseId)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target dead body is gone."), true); + return; + } + + const Point corpsePosition = CorpsePositionForTrackerId(corpseId); + if (playerPosition.WalkingDistance(corpsePosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Dead body in range."), true); + return; + } + + destination = corpsePosition; + break; + } + } + + if (!destination) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Can't find a nearby tile to walk to."), true); + return; + } + + constexpr size_t MaxAutoWalkPathLength = 512; + std::array path; + path.fill(WALK_NONE); + + int steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, *destination, path.data(), path.size()); + // If no direct path exists, try pathfinding that treats closed doors as walkable. + // If that finds a path, identify the first closed door along it and re-route the + // player to the tile just before that door, so they can open it and retry. + if (steps == 0) { + std::array ignoreDoorPath; + ignoreDoorPath.fill(WALK_NONE); + + const int ignoreDoorSteps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayerIgnoreDoors(myPlayer, position); }, playerPosition, *destination, ignoreDoorPath.data(), ignoreDoorPath.size()); + if (ignoreDoorSteps != 0) { + const std::optional block = FindFirstClosedDoorOnWalkPath(playerPosition, ignoreDoorPath.data(), ignoreDoorSteps); + if (block) { + if (playerPosition.WalkingDistance(block->doorPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("A door is blocking the path. Open it and try again."), true); + return; + } + + *destination = block->beforeDoor; + path.fill(WALK_NONE); + steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, *destination, path.data(), path.size()); + } + } + + if (steps == 0) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Can't find a path to the target."), true); + return; + } + } + + // FindPath returns 0 if the path length is equal to the maximum. + // The player walkpath buffer is MaxPathLengthPlayer, so keep segments strictly shorter. + if (steps < static_cast(MaxPathLengthPlayer)) { + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, *destination); + return; + } + + const int segmentSteps = std::min(steps - 1, static_cast(MaxPathLengthPlayer - 1)); + const Point waypoint = PositionAfterWalkPathSteps(playerPosition, path.data(), segmentSteps); + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, waypoint); +} + +/** + * Initiates auto-walk toward the currently selected tracker target. + */ +void AutoWalkToTrackerTargetKeyPressed() +{ + // Cancel in-progress auto-walk (must be checked before the action guard + // so that cancellation works even if the player is mid-walk). + if (AutoWalkTrackerTargetId >= 0) { + CancelAutoWalk(); + SpeakText(_("Walk cancelled."), true); + return; + } + + // Defense-in-depth: keymapper canTrigger also checks these, but guard here + // in case the function is called from another code path. + if (!CanPlayerTakeAction() || InGameMenu()) + return; + + if (leveltype == DTYPE_TOWN) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) { + SpeakText(_("Cannot walk right now."), true); + return; + } + + EnsureTrackerLocksMatchCurrentLevel(); + + const Point playerPosition = MyPlayer->position.future; + int &lockedTargetId = LockedTrackerTargetId(SelectedTrackerTargetCategory); + + std::optional targetId; + StringOrView targetName; + + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + if (IsGroundItemPresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestGroundItemId(playerPosition); + } + if (!targetId) { + SpeakText(_("No items found."), true); + return; + } + if (!IsGroundItemPresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No items found."), true); + return; + } + lockedTargetId = *targetId; + targetName = Items[*targetId].getName(); + break; + } + case TrackerTargetCategory::Chests: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedChestObject, FindNearestUnopenedChestObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No chests found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Doors: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedDoorObject, FindNearestDoorObjectId, [](int id) -> StringOrView { return DoorLabelForSpeech(Objects[id]); }, N_("No doors found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Shrines: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsShrineLikeObject, FindNearestShrineObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No shrines found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Objects: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedMiscInteractableObject, FindNearestMiscInteractableObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No objects found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Breakables: + targetId = ResolveObjectTrackerTarget(lockedTargetId, playerPosition, IsTrackedBreakableObject, FindNearestBreakableObjectId, [](int id) -> StringOrView { return Objects[id].name(); }, N_("No breakables found."), targetName); + if (!targetId) + return; + break; + case TrackerTargetCategory::Monsters: { + if (lockedTargetId >= 0 && lockedTargetId < static_cast(MaxMonsters)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestMonsterId(playerPosition); + } + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + const Monster &monster = Monsters[*targetId]; + if (!IsTrackedMonster(monster)) { + lockedTargetId = -1; + targetId = FindNearestMonsterId(playerPosition); + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + } + lockedTargetId = *targetId; + targetName = Monsters[*targetId].name(); + break; + } + case TrackerTargetCategory::DeadBodies: { + if (IsCorpsePresent(lockedTargetId)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestCorpseId(playerPosition); + } + if (!targetId) { + SpeakText(_("No dead bodies found."), true); + return; + } + if (!IsCorpsePresent(*targetId)) { + lockedTargetId = -1; + SpeakText(_("No dead bodies found."), true); + return; + } + lockedTargetId = *targetId; + targetName = _("Dead body"); + break; + } + } + + std::string msg; + StrAppend(msg, _("Going to: "), targetName); + SpeakText(msg, true); + + AutoWalkTrackerTargetId = *targetId; + AutoWalkTrackerTargetCategory = SelectedTrackerTargetCategory; + UpdateAutoWalkTracker(); +} + +} // namespace devilution diff --git a/Source/accessibility/tracker.hpp b/Source/accessibility/tracker.hpp new file mode 100644 index 000000000..0d22ebeac --- /dev/null +++ b/Source/accessibility/tracker.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +#include "engine/point.hpp" + +namespace devilution { + +struct Player; + +enum class TrackerTargetCategory : uint8_t { + Items, + Chests, + Doors, + Shrines, + Objects, + Breakables, + Monsters, + DeadBodies, +}; + +extern TrackerTargetCategory SelectedTrackerTargetCategory; +extern TrackerTargetCategory AutoWalkTrackerTargetCategory; +extern int AutoWalkTrackerTargetId; + +// Position-check predicates used by both tracker and location_speech path-finding. +bool PosOkPlayerIgnoreDoors(const Player &player, Point position); +bool PosOkPlayerIgnoreMonsters(const Player &player, Point position); +bool PosOkPlayerIgnoreDoorsAndMonsters(const Player &player, Point position); +bool PosOkPlayerIgnoreDoorsMonstersAndBreakables(const Player &player, Point position); + +void CycleTrackerTargetKeyPressed(); +void NavigateToTrackerTargetKeyPressed(); +void AutoWalkToTrackerTargetKeyPressed(); +void UpdateAutoWalkTracker(); + +} // namespace devilution diff --git a/Source/diablo.cpp b/Source/diablo.cpp index b14da41cb..5555adf17 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -24,6 +24,10 @@ #include #include "DiabloUI/selstart.h" +#include "accessibility/location_speech.hpp" +#include "accessibility/speech.hpp" +#include "accessibility/town_navigation.hpp" +#include "accessibility/tracker.hpp" #include "appfat.h" #include "automap.h" #include "capture.h" @@ -1770,7 +1774,7 @@ bool IsGameRunning() return PauseMode != 2; } -bool CanPlayerTakeAction() +bool CanPlayerTakeActionImpl() { return !IsPlayerDead() && IsGameRunning(); } @@ -1802,6 +1806,17 @@ const auto OptionChangeHandlerLanguage = (GetOptions().Language.code.SetValueCha } // namespace +bool CanPlayerTakeAction() +{ + return CanPlayerTakeActionImpl(); +} + +void CancelAutoWalk() +{ + CancelTownNpcAutoWalk(); + AutoWalkTrackerTargetId = -1; +} + void InitKeymapActions() { Options &options = GetOptions(); diff --git a/Source/diablo.h b/Source/diablo.h index ad28ebb82..f66a1186d 100644 --- a/Source/diablo.h +++ b/Source/diablo.h @@ -102,6 +102,8 @@ 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); +bool CanPlayerTakeAction(); +void CancelAutoWalk(); /** * @param bStartup Process additional ticks before returning diff --git a/Source/engine/sound_position.hpp b/Source/engine/sound_position.hpp index de062c59b..3602172f2 100644 --- a/Source/engine/sound_position.hpp +++ b/Source/engine/sound_position.hpp @@ -6,4 +6,4 @@ namespace devilution { bool CalculateSoundPosition(Point soundPosition, int *plVolume, int *plPan); -} // namespace devilution +} // namespace devilution \ No newline at end of file