You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

685 lines
19 KiB

/**
* @file utils/navigation_speech.cpp
*
* Navigation speech: exit/stairs/portal/unexplored speech and keyboard walk keys.
*/
#include "utils/navigation_speech.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <optional>
#include <queue>
#include <string>
#include <vector>
#include <fmt/format.h>
#ifdef USE_SDL3
#include <SDL3/SDL_keycode.h>
#else
#include <SDL.h>
#endif
#include "automap.h"
#include "control/control.hpp"
#include "controls/accessibility_keys.hpp"
#include "controls/plrctrls.h"
#include "diablo.h"
#include "help.h"
#include "inv.h"
#include "levels/gendung.h"
#include "levels/setmaps.h"
#include "levels/tile_properties.hpp"
#include "levels/trigs.h"
#include "minitext.h"
#include "missiles.h"
#include "multi.h"
#include "player.h"
#include "portal.h"
#include "qol/chatlog.h"
#include "qol/stash.h"
#include "quests.h"
#include "stores.h"
#include "utils/language.h"
#include "utils/screen_reader.hpp"
#include "utils/str_cat.hpp"
#include "utils/sdl_compat.h"
#include "utils/walk_path_speech.hpp"
namespace devilution {
namespace {
std::optional<Point> FindNearestUnexploredTile(Point startPosition)
{
if (!InDungeonBounds(startPosition))
return std::nullopt;
std::array<bool, MAXDUNX * MAXDUNY> visited {};
std::queue<Point> queue;
const auto enqueue = [&](Point position) {
if (!InDungeonBounds(position))
return;
const size_t index = static_cast<size_t>(position.x) + static_cast<size_t>(position.y) * MAXDUNX;
if (visited[index])
return;
if (!IsTileWalkable(position, /*ignoreDoors=*/true))
return;
visited[index] = true;
queue.push(position);
};
enqueue(startPosition);
constexpr std::array<Direction, 4> Neighbors = {
Direction::NorthEast,
Direction::SouthWest,
Direction::SouthEast,
Direction::NorthWest,
};
while (!queue.empty()) {
const Point position = queue.front();
queue.pop();
if (!HasAnyOf(dFlags[position.x][position.y], DungeonFlag::Explored))
return position;
for (const Direction dir : Neighbors) {
enqueue(position + dir);
}
}
return std::nullopt;
}
std::optional<int> LockedTownDungeonTriggerIndex;
std::optional<int> FindDefaultTownDungeonTriggerIndex(const std::vector<int> &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<int> FindLockedTownDungeonTriggerIndex(const std::vector<int> &candidates)
{
if (!LockedTownDungeonTriggerIndex)
return std::nullopt;
if (std::find(candidates.begin(), candidates.end(), *LockedTownDungeonTriggerIndex) != candidates.end())
return *LockedTownDungeonTriggerIndex;
return std::nullopt;
}
std::optional<int> FindNextTownDungeonTriggerIndex(const std::vector<int> &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<int> FindNearestTriggerIndexWithMessage(int message)
{
if (numtrigs <= 0 || MyPlayer == nullptr)
return std::nullopt;
const Point playerPosition = MyPlayer->position.future;
std::optional<int> 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<Point> FindNearestTownPortalOnCurrentLevel()
{
if (MyPlayer == nullptr || leveltype == DTYPE_TOWN)
return std::nullopt;
const Point playerPosition = MyPlayer->position.future;
const int currentLevel = setlevel ? static_cast<int>(setlvlnum) : currlevel;
std::optional<Point> 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<TownPortalInTown> FindNearestTownPortalInTown()
{
if (MyPlayer == nullptr || leveltype != DTYPE_TOWN)
return std::nullopt;
const Point playerPosition = MyPlayer->position.future;
std::optional<TownPortalInTown> best;
int bestDistance = 0;
for (const Missile &missile : Missiles) {
if (missile._mitype != MissileID::TownPortal)
continue;
if (missile._misource < 0 || missile._misource >= MAXPORTAL)
continue;
if (!Portals[missile._misource].open)
continue;
const Point portalPosition = missile.position.tile;
const int distance = playerPosition.WalkingDistance(portalPosition);
if (!best || distance < bestDistance) {
best = TownPortalInTown {
.portalIndex = missile._misource,
.position = portalPosition,
.distance = distance,
};
bestDistance = distance;
}
}
return best;
}
struct QuestSetLevelEntrance {
_setlevels questLevel;
Point entrancePosition;
int distance;
};
std::optional<QuestSetLevelEntrance> FindNearestQuestSetLevelEntranceOnCurrentLevel()
{
if (MyPlayer == nullptr || setlevel)
return std::nullopt;
const Point playerPosition = MyPlayer->position.future;
std::optional<QuestSetLevelEntrance> best;
int bestDistance = 0;
for (const Quest &quest : Quests) {
if (quest._qslvl == SL_NONE)
continue;
if (quest._qactive == QUEST_NOTAVAIL)
continue;
if (quest._qlevel != currlevel)
continue;
if (!InDungeonBounds(quest.position))
continue;
const int distance = playerPosition.WalkingDistance(quest.position);
if (!best || distance < bestDistance) {
best = QuestSetLevelEntrance {
.questLevel = quest._qslvl,
.entrancePosition = quest.position,
.distance = distance,
};
bestDistance = distance;
}
}
return best;
}
void SpeakNearestStairsKeyPressed(int triggerMessage)
{
if (!CanPlayerTakeAction())
return;
if (AutomapActive) {
SpeakText(_("Close the map first."), true);
return;
}
if (leveltype == DTYPE_TOWN) {
SpeakText(_("Not in a dungeon."), true);
return;
}
if (MyPlayer == nullptr)
return;
const std::optional<int> 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<std::vector<int8_t>> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition);
if (!path) {
AppendDirectionalFallback(message, targetPosition - startPosition);
} else {
AppendKeyboardWalkPathForSpeech(message, *path);
}
SpeakText(message, true);
}
void KeyboardWalkKeyPressed(Direction direction)
{
CancelAutoWalk();
if (!IsKeyboardWalkAllowed())
return;
if (MyPlayer == nullptr)
return;
NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, MyPlayer->position.future + direction);
}
} // namespace
std::string TriggerLabelForSpeech(const TriggerStruct &trigger)
{
switch (trigger._tmsg) {
case WM_DIABNEXTLVL:
if (leveltype == DTYPE_TOWN)
return std::string { _("Cathedral entrance") };
return std::string { _("Stairs down") };
case WM_DIABPREVLVL:
return std::string { _("Stairs up") };
case WM_DIABTOWNWARP:
switch (trigger._tlvl) {
case 5:
return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Catacombs"));
case 9:
return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Caves"));
case 13:
return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Hell"));
case 17:
return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Nest"));
case 21:
return fmt::format(fmt::runtime(_("Town warp to {:s}")), _("Crypt"));
default:
return fmt::format(fmt::runtime(_("Town warp to level {:d}")), trigger._tlvl);
}
case WM_DIABTWARPUP:
return std::string { _("Warp up") };
case WM_DIABRETOWN:
return std::string { _("Return to town") };
case WM_DIABWARPLVL:
return std::string { _("Warp") };
case WM_DIABSETLVL:
return std::string { _("Set level") };
case WM_DIABRTNLVL:
return std::string { _("Return level") };
default:
return std::string { _("Exit") };
}
}
std::string TownPortalLabelForSpeech(const Portal &portal)
{
if (portal.level <= 0)
return std::string { _("Town portal") };
if (portal.setlvl) {
const auto questLevel = static_cast<_setlevels>(portal.level);
const char *questLevelName = QuestLevelNames[questLevel];
if (questLevelName == nullptr || questLevelName[0] == '\0')
return std::string { _("Town portal to set level") };
return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a set/quest level name. */ "Town portal to {:s}")), _(questLevelName));
}
constexpr std::array<const char *, DTYPE_LAST + 1> 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<size_t>(portal.ltype)]);
} else {
dungeonStr = _(/* TRANSLATORS: type of dungeon (i.e. Cathedral, Caves)*/ "None");
}
int floor = portal.level;
if (portal.ltype == DTYPE_CATACOMBS)
floor -= 4;
else if (portal.ltype == DTYPE_CAVES)
floor -= 8;
else if (portal.ltype == DTYPE_HELL)
floor -= 12;
else if (portal.ltype == DTYPE_NEST)
floor -= 16;
else if (portal.ltype == DTYPE_CRYPT)
floor -= 20;
if (floor > 0)
return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name and {:d} is a floor number. */ "Town portal to {:s} {:d}")), dungeonStr, floor);
return fmt::format(fmt::runtime(_(/* TRANSLATORS: {:s} is a dungeon name. */ "Town portal to {:s}")), dungeonStr);
}
std::vector<int> CollectTownDungeonTriggerIndices()
{
std::vector<int> result;
result.reserve(static_cast<size_t>(std::max(0, numtrigs)));
for (int i = 0; i < numtrigs; ++i) {
if (IsAnyOf(trigs[i]._tmsg, WM_DIABNEXTLVL, WM_DIABTOWNWARP))
result.push_back(i);
}
std::sort(result.begin(), result.end(), [](int a, int b) {
const TriggerStruct &ta = trigs[a];
const TriggerStruct &tb = trigs[b];
const int kindA = ta._tmsg == WM_DIABNEXTLVL ? 0 : (ta._tmsg == WM_DIABTOWNWARP ? 1 : 2);
const int kindB = tb._tmsg == WM_DIABNEXTLVL ? 0 : (tb._tmsg == WM_DIABTOWNWARP ? 1 : 2);
if (kindA != kindB)
return kindA < kindB;
if (ta._tmsg == WM_DIABTOWNWARP && tb._tmsg == WM_DIABTOWNWARP && ta._tlvl != tb._tlvl)
return ta._tlvl < tb._tlvl;
return a < b;
});
return result;
}
void SpeakNearestExitKeyPressed()
{
if (!CanPlayerTakeAction())
return;
if (AutomapActive) {
SpeakText(_("Close the map first."), true);
return;
}
if (MyPlayer == nullptr)
return;
const Point startPosition = MyPlayer->position.future;
const SDL_Keymod modState = SDL_GetModState();
const bool seekQuestEntrance = (modState & SDL_KMOD_SHIFT) != 0;
const bool cycleTownDungeon = (modState & SDL_KMOD_CTRL) != 0;
if (seekQuestEntrance) {
if (setlevel) {
const std::optional<int> 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<std::vector<int8_t>> 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<QuestSetLevelEntrance> entrance = FindNearestQuestSetLevelEntranceOnCurrentLevel(); entrance) {
const Point targetPosition = entrance->entrancePosition;
const std::optional<std::vector<int8_t>> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition);
std::string message { _(QuestLevelNames[entrance->questLevel]) };
message.append(": ");
if (!path)
AppendDirectionalFallback(message, targetPosition - startPosition);
else
AppendKeyboardWalkPathForSpeech(message, *path);
SpeakText(message, true);
return;
}
SpeakText(_("No quest entrances found."), true);
return;
}
if (leveltype == DTYPE_TOWN) {
const std::vector<int> 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<int> 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<std::vector<int8_t>> 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<Point> portalPosition = FindNearestTownPortalOnCurrentLevel(); portalPosition) {
const std::optional<std::vector<int8_t>> 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<int> 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<std::vector<int8_t>> 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<TownPortalInTown> 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<std::vector<int8_t>> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition);
std::string message = TownPortalLabelForSpeech(Portals[portal->portalIndex]);
message.append(": ");
if (!path)
AppendDirectionalFallback(message, targetPosition - startPosition);
else
AppendKeyboardWalkPathForSpeech(message, *path);
SpeakText(message, true);
}
void SpeakNearestStairsDownKeyPressed()
{
SpeakNearestStairsKeyPressed(WM_DIABNEXTLVL);
}
void SpeakNearestStairsUpKeyPressed()
{
SpeakNearestStairsKeyPressed(WM_DIABPREVLVL);
}
bool IsKeyboardWalkAllowed()
{
return CanPlayerTakeAction()
&& !InGameMenu()
&& !IsPlayerInStore()
&& !QuestLogIsOpen
&& !HelpFlag
&& !ChatLogFlag
&& !ChatFlag
&& !DropGoldFlag
&& !IsStashOpen
&& !IsWithdrawGoldOpen
&& !AutomapActive
&& !invflag
&& !CharFlag
&& !SpellbookFlag
&& !SpellSelectFlag
&& !qtextflag;
}
void KeyboardWalkNorthKeyPressed()
{
KeyboardWalkKeyPressed(Direction::NorthEast);
}
void KeyboardWalkSouthKeyPressed()
{
KeyboardWalkKeyPressed(Direction::SouthWest);
}
void KeyboardWalkEastKeyPressed()
{
KeyboardWalkKeyPressed(Direction::SouthEast);
}
void KeyboardWalkWestKeyPressed()
{
KeyboardWalkKeyPressed(Direction::NorthWest);
}
void SpeakNearestUnexploredTileKeyPressed()
{
if (!CanPlayerTakeAction())
return;
if (leveltype == DTYPE_TOWN) {
SpeakText(_("Not in a dungeon."), true);
return;
}
if (AutomapActive) {
SpeakText(_("Close the map first."), true);
return;
}
if (MyPlayer == nullptr)
return;
const Point startPosition = MyPlayer->position.future;
const std::optional<Point> target = FindNearestUnexploredTile(startPosition);
if (!target) {
SpeakText(_("No unexplored areas found."), true);
return;
}
const std::optional<std::vector<int8_t>> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, *target);
std::string message;
if (!path)
AppendDirectionalFallback(message, *target - startPosition);
else
AppendKeyboardWalkPathForSpeech(message, *path);
SpeakText(message, true);
}
} // namespace devilution