diff --git a/README.md b/README.md index 0ceaf5912..6f0cb8f97 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Keybinds are configurable, but these are the defaults most players will use: - `Shift`+`N` - cycle to the next target in the current tracker category (speaks name only; duplicates get ordinal numbers). - `Ctrl`+`N` - clear the tracker target. - `H` - speak nearest unexplored space. -- `E` - speak nearest exit (hold `Shift` for quest entrances). +- `E` - speak nearest exit (hold `Shift` for quest entrances; in town: `Ctrl`+`E` cycles dungeon entrances). - `,` - speak nearest stairs up. - `.` - speak nearest stairs down. - `L` - speak current dungeon + floor. diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 7642edc79..653be4a90 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -4383,6 +4383,69 @@ std::string TriggerLabelForSpeech(const TriggerStruct &trigger) } } +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) @@ -4533,6 +4596,7 @@ void SpeakNearestExitKeyPressed() 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 (const std::optional entrance = FindNearestQuestSetLevelEntranceOnCurrentLevel(); entrance) { @@ -4553,6 +4617,53 @@ void SpeakNearestExitKeyPressed() 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); @@ -5222,7 +5333,7 @@ void InitKeymapActions() options.Keymapper.AddAction( "SpeakNearestExit", N_("Nearest exit"), - N_("Speaks the nearest exit. Hold Shift for quest entrances."), + N_("Speaks the nearest exit. Hold Shift for quest entrances. In town, press Ctrl+E to cycle dungeon entrances."), 'E', SpeakNearestExitKeyPressed, nullptr,