diff --git a/CMake/Assets.cmake b/CMake/Assets.cmake index e3ead57bc..b84e300bd 100644 --- a/CMake/Assets.cmake +++ b/CMake/Assets.cmake @@ -5,15 +5,15 @@ if(NOT DEFINED DEVILUTIONX_ASSETS_OUTPUT_DIRECTORY) set(DEVILUTIONX_ASSETS_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/assets") endif() -set(devilutionx_langs be bg cs da de el es et fi fr hr hu it ja ko pl pt_BR ro ru uk sv tr zh_CN zh_TW) -if(USE_GETTEXT_FROM_VCPKG) - # vcpkg doesn't add its own tools directory to the search path - list(APPEND Gettext_ROOT ${CMAKE_CURRENT_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/tools/gettext/bin) -endif() -find_package(Gettext) -if (Gettext_FOUND) - file(MAKE_DIRECTORY "${DEVILUTIONX_ASSETS_OUTPUT_DIRECTORY}") - foreach(lang ${devilutionx_langs}) +set(devilutionx_langs be bg cs da de el es et fi fr hr hu it ja ko pl pt_BR ro ru uk sv tr zh_CN zh_TW) +if(USE_GETTEXT_FROM_VCPKG) + # vcpkg doesn't add its own tools directory to the search path + list(APPEND Gettext_ROOT ${CMAKE_CURRENT_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/tools/gettext/bin) +endif() +find_package(Gettext) +if (Gettext_FOUND) + file(MAKE_DIRECTORY "${DEVILUTIONX_ASSETS_OUTPUT_DIRECTORY}") + foreach(lang ${devilutionx_langs}) set(_po_file "${CMAKE_CURRENT_SOURCE_DIR}/Translations/${lang}.po") set(_gmo_file "${DEVILUTIONX_ASSETS_OUTPUT_DIRECTORY}/${lang}.gmo") set(_lang_target devilutionx_lang_${lang}) @@ -37,11 +37,46 @@ if (Gettext_FOUND) target_sources(${BIN_TARGET} PRIVATE "${_gmo_file}") endif() - if(VITA) - list(APPEND VITA_TRANSLATIONS_LIST "FILE" "${_gmo_file}" "assets/${lang}.gmo") - endif() - endforeach() -endif() + if(VITA) + list(APPEND VITA_TRANSLATIONS_LIST "FILE" "${_gmo_file}" "assets/${lang}.gmo") + endif() + endforeach() +else() + # Fallback: compile translations using Python if gettext tools aren't available. + find_package(Python3 COMPONENTS Interpreter) + if(Python3_Interpreter_FOUND) + file(MAKE_DIRECTORY "${DEVILUTIONX_ASSETS_OUTPUT_DIRECTORY}") + foreach(lang ${devilutionx_langs}) + set(_po_file "${CMAKE_CURRENT_SOURCE_DIR}/Translations/${lang}.po") + set(_gmo_file "${DEVILUTIONX_ASSETS_OUTPUT_DIRECTORY}/${lang}.gmo") + set(_lang_target devilutionx_lang_${lang}) + add_custom_command( + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/msgfmt.py" -o "${_gmo_file}" "${_po_file}" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + OUTPUT "${_gmo_file}" + MAIN_DEPENDENCY "${_po_file}" + DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/tools/msgfmt.py" + VERBATIM + ) + add_custom_target("${_lang_target}" DEPENDS "${_gmo_file}") + list(APPEND devilutionx_lang_targets "${_lang_target}") + list(APPEND devilutionx_lang_files "${_gmo_file}") + + if(APPLE) + set_source_files_properties("${_gmo_file}" PROPERTIES + MACOSX_PACKAGE_LOCATION Resources + XCODE_EXPLICIT_FILE_TYPE compiled) + add_dependencies(libdevilutionx "${_lang_target}") + add_dependencies(${BIN_TARGET} "${_lang_target}") + target_sources(${BIN_TARGET} PRIVATE "${_gmo_file}") + endif() + + if(VITA) + list(APPEND VITA_TRANSLATIONS_LIST "FILE" "${_gmo_file}" "assets/${lang}.gmo") + endif() + endforeach() + endif() +endif() set(devilutionx_assets ASSETS_VERSION @@ -246,17 +281,17 @@ else() # Copy assets to the build assets subdirectory. This serves two purposes: # - If smpq is installed, devilutionx.mpq is built from these files. # - If smpq is not installed, the game will load the assets directly from this directory. - copy_files( - FILES ${devilutionx_assets} - SRC_PREFIX "assets/" - OUTPUT_DIR "${DEVILUTIONX_ASSETS_OUTPUT_DIRECTORY}" + copy_files( + FILES ${devilutionx_assets} + SRC_PREFIX "assets/" + OUTPUT_DIR "${DEVILUTIONX_ASSETS_OUTPUT_DIRECTORY}" OUTPUT_VARIABLE DEVILUTIONX_OUTPUT_ASSETS_FILES) - set(DEVILUTIONX_MPQ_FILES ${devilutionx_assets}) - if (Gettext_FOUND) - foreach(lang ${devilutionx_langs}) - list(APPEND DEVILUTIONX_MPQ_FILES "${lang}.gmo") - endforeach() - endif() + set(DEVILUTIONX_MPQ_FILES ${devilutionx_assets}) + if(devilutionx_lang_targets) + foreach(lang ${devilutionx_langs}) + list(APPEND DEVILUTIONX_MPQ_FILES "${lang}.gmo") + endforeach() + endif() add_trim_target(devilutionx_trim_assets ROOT_FOLDER "${DEVILUTIONX_ASSETS_OUTPUT_DIRECTORY}" diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 4501d6cc9..5ff6ad7c1 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -161,11 +161,12 @@ set(libdevilutionx_SRCS tables/townerdat.cpp utils/display.cpp - utils/language.cpp - utils/sdl_bilinear_scale.cpp - utils/sdl_thread.cpp - utils/surface_to_clx.cpp - utils/timer.cpp) + utils/language.cpp + utils/proximity_audio.cpp + utils/sdl_bilinear_scale.cpp + utils/sdl_thread.cpp + utils/surface_to_clx.cpp + utils/timer.cpp) # These files are responsible for most of the runtime in Debug mode. # Apply some optimizations to them even in Debug mode to get reasonable performance. diff --git a/Source/DiabloUI/diabloui.cpp b/Source/DiabloUI/diabloui.cpp index f85599af4..43f247d28 100644 --- a/Source/DiabloUI/diabloui.cpp +++ b/Source/DiabloUI/diabloui.cpp @@ -17,15 +17,18 @@ #include #include #include -#else -#include -#endif - -#include - -#include "DiabloUI/button.h" -#include "DiabloUI/scrollbar.h" -#include "DiabloUI/text_input.hpp" +#else +#include +#endif + +#include +#include + +#include + +#include "DiabloUI/button.h" +#include "DiabloUI/scrollbar.h" +#include "DiabloUI/text_input.hpp" #include "DiabloUI/ui_flags.hpp" #include "DiabloUI/ui_item.h" #include "appfat.h" @@ -123,13 +126,66 @@ struct ScrollBarState { { upArrowPressed = false; downArrowPressed = false; - } -} scrollBarState; - -void AdjustListOffset(std::size_t itemIndex) -{ - if (itemIndex >= listOffset + ListViewportSize) - listOffset = itemIndex - (ListViewportSize - 1); + } +} scrollBarState; + +std::string FormatSpokenText(const StringOrView &format, const std::vector &args) +{ + if (args.empty()) + return std::string(format.str()); + + std::string formatted; + FMT_TRY + { + fmt::dynamic_format_arg_store store; + for (const DrawStringFormatArg &arg : args) { + const DrawStringFormatArg::Value &value = arg.value(); + if (std::holds_alternative(value)) { + store.push_back(std::get(value)); + } else { + store.push_back(std::get(value)); + } + } + formatted = fmt::vformat(format.str(), store); + } + FMT_CATCH(const fmt::format_error &) + { + formatted = std::string(format.str()); + } + return formatted; +} + +void SpeakListItem(std::size_t index, bool force = false) +{ + if (gUiList == nullptr || index > SelectedItemMax) + return; + + const UiListItem *pItem = gUiList->GetItem(index); + if (pItem == nullptr) + return; + + std::string text = FormatSpokenText(pItem->m_text, pItem->args); + + if (HasAnyOf(pItem->uiFlags, UiFlags::NeedsNextElement) && index < SelectedItemMax) { + const UiListItem *pNextItem = gUiList->GetItem(index + 1); + if (pNextItem != nullptr && pNextItem->m_value == pItem->m_value) { + const std::string nextText = FormatSpokenText(pNextItem->m_text, pNextItem->args); + if (!nextText.empty()) { + if (!text.empty()) + text.append(" "); + text.append(nextText); + } + } + } + + if (!text.empty()) + SpeakText(text, force); +} + +void AdjustListOffset(std::size_t itemIndex) +{ + if (itemIndex >= listOffset + ListViewportSize) + listOffset = itemIndex - (ListViewportSize - 1); if (itemIndex < listOffset) listOffset = itemIndex; } @@ -229,14 +285,14 @@ void UiInitList(void (*fnFocus)(size_t value), void (*fnSelect)(size_t value), v auto *uiList = static_cast(item.get()); SelectedItemMax = std::max(uiList->m_vecItems.size() - 1, static_cast(0)); ListViewportSize = uiList->viewportSize; - gUiList = uiList; - if (selectedItem <= SelectedItemMax && HasAnyOf(uiList->GetItem(selectedItem)->uiFlags, UiFlags::NeedsNextElement)) - AdjustListOffset(selectedItem + 1); - SpeakText(uiList->GetItem(selectedItem)->m_text); - } else if (item->IsType(UiType::Scrollbar)) { - uiScrollbar = static_cast(item.get()); - } - } + gUiList = uiList; + if (selectedItem <= SelectedItemMax && HasAnyOf(uiList->GetItem(selectedItem)->uiFlags, UiFlags::NeedsNextElement)) + AdjustListOffset(selectedItem + 1); + SpeakListItem(selectedItem); + } else if (item->IsType(UiType::Scrollbar)) { + uiScrollbar = static_cast(item.get()); + } + } AdjustListOffset(selectedItem); @@ -303,15 +359,15 @@ void UiFocus(std::size_t itemIndex, bool checkUp, bool ignoreItemsWraps = false) else if (UiItemsWraps && !ignoreItemsWraps) itemIndex = 0; else - checkUp = true; - } - pItem = gUiList->GetItem(itemIndex); - } - SpeakText(pItem->m_text); - - if (HasAnyOf(pItem->uiFlags, UiFlags::NeedsNextElement)) - AdjustListOffset(itemIndex + 1); - AdjustListOffset(itemIndex); + checkUp = true; + } + pItem = gUiList->GetItem(itemIndex); + } + SpeakListItem(itemIndex); + + if (HasAnyOf(pItem->uiFlags, UiFlags::NeedsNextElement)) + AdjustListOffset(itemIndex + 1); + AdjustListOffset(itemIndex); SelectedItem = itemIndex; diff --git a/Source/control/control_infobox.cpp b/Source/control/control_infobox.cpp index 30469e973..20b4cf6a5 100644 --- a/Source/control/control_infobox.cpp +++ b/Source/control/control_infobox.cpp @@ -9,23 +9,47 @@ #include "qol/xpbar.h" #include "towners.h" #include "utils/algorithm/container.hpp" -#include "utils/format_int.hpp" -#include "utils/log.hpp" -#include "utils/screen_reader.hpp" -#include "utils/str_cat.hpp" -#include "utils/str_split.hpp" +#include "utils/format_int.hpp" +#include "utils/log.hpp" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" +#include "utils/str_split.hpp" namespace devilution { StringOrView InfoString; StringOrView FloatingInfoString; -namespace { - -void PrintInfo(const Surface &out) -{ - if (ChatFlag) - return; +namespace { + +std::string LastSpokenInfoString; +std::string LastSpokenFloatingInfoString; + +[[nodiscard]] bool ShouldSpeakInfoBox() +{ + // Suppress hover-based dungeon announcements; those are noisy for keyboard/screen-reader play. + return pcursitem == -1 && ObjectUnderCursor == nullptr && pcursmonst == -1 && PlayerUnderCursor == nullptr && PortraitIdUnderCursor == -1; +} + +void SpeakIfChanged(const StringOrView &text, std::string &lastSpoken) +{ + if (text.empty()) { + lastSpoken.clear(); + return; + } + + const std::string_view current = text.str(); + if (current == lastSpoken) + return; + + lastSpoken.assign(current); + SpeakText(current, /*force=*/true); +} + +void PrintInfo(const Surface &out) +{ + if (ChatFlag) + return; const int space[] = { 18, 12, 6, 3, 0 }; Rectangle infoBox = InfoBoxRect; @@ -37,16 +61,17 @@ void PrintInfo(const Surface &out) const int spacing = space[spaceIndex]; const int lineHeight = 12 + spacing; - // Adjusting the line height to add spacing between lines - // will also add additional space beneath the last line - // which throws off the vertical centering - infoBox.position.y += spacing / 2; - - SpeakText(InfoString); - - DrawString(out, InfoString, infoBox, - { - .flags = InfoColor | UiFlags::AlignCenter | UiFlags::VerticalCenter | UiFlags::KerningFitSpacing, + // Adjusting the line height to add spacing between lines + // will also add additional space beneath the last line + // which throws off the vertical centering + infoBox.position.y += spacing / 2; + + if (ShouldSpeakInfoBox()) + SpeakIfChanged(InfoString, LastSpokenInfoString); + + DrawString(out, InfoString, infoBox, + { + .flags = InfoColor | UiFlags::AlignCenter | UiFlags::VerticalCenter | UiFlags::KerningFitSpacing, .spacing = 2, .lineHeight = lineHeight, }); @@ -210,11 +235,11 @@ int ClampAboveOrBelow(int anchorY, int spriteH, int boxH, int pad, int linePad) const int yBelow = anchorY + linePad / 2 + pad; return (yAbove >= 0) ? yAbove : yBelow; } - -void PrintFloatingInfo(const Surface &out) -{ - if (ChatFlag) - return; + +void PrintFloatingInfo(const Surface &out) +{ + if (ChatFlag) + return; if (FloatingInfoString.empty()) return; @@ -235,11 +260,11 @@ void PrintFloatingInfo(const Surface &out) // Prevent the floating info box from going off-screen vertically floatingInfoBox.position.y = ClampAboveOrBelow(anchorY, spriteH, floatingInfoBox.size.height, vPadding, verticalSpacing); - SpeakText(FloatingInfoString); - - for (int i = 0; i < 3; i++) - DrawHalfTransparentRectTo(out, floatingInfoBox.position.x - hPadding, floatingInfoBox.position.y - vPadding, floatingInfoBox.size.width + hPadding * 2, floatingInfoBox.size.height + vPadding * 2); - DrawHalfTransparentVerticalLine(out, { floatingInfoBox.position.x - hPadding - 1, floatingInfoBox.position.y - vPadding - 1 }, floatingInfoBox.size.height + (vPadding * 2) + 2, PAL16_GRAY + 10); + SpeakIfChanged(FloatingInfoString, LastSpokenFloatingInfoString); + + for (int i = 0; i < 3; i++) + DrawHalfTransparentRectTo(out, floatingInfoBox.position.x - hPadding, floatingInfoBox.position.y - vPadding, floatingInfoBox.size.width + hPadding * 2, floatingInfoBox.size.height + vPadding * 2); + DrawHalfTransparentVerticalLine(out, { floatingInfoBox.position.x - hPadding - 1, floatingInfoBox.position.y - vPadding - 1 }, floatingInfoBox.size.height + (vPadding * 2) + 2, PAL16_GRAY + 10); DrawHalfTransparentVerticalLine(out, { floatingInfoBox.position.x + hPadding + floatingInfoBox.size.width, floatingInfoBox.position.y - vPadding - 1 }, floatingInfoBox.size.height + (vPadding * 2) + 2, PAL16_GRAY + 10); DrawHalfTransparentHorizontalLine(out, { floatingInfoBox.position.x - hPadding, floatingInfoBox.position.y - vPadding - 1 }, floatingInfoBox.size.width + (hPadding * 2), PAL16_GRAY + 10); DrawHalfTransparentHorizontalLine(out, { floatingInfoBox.position.x - hPadding, floatingInfoBox.position.y + vPadding + floatingInfoBox.size.height }, floatingInfoBox.size.width + (hPadding * 2), PAL16_GRAY + 10); @@ -353,17 +378,17 @@ void CheckPanelInfo() MainPanelFlag = true; } -void DrawInfoBox(const Surface &out) -{ - DrawPanelBox(out, MakeSdlRect(InfoBoxRect.position.x, InfoBoxRect.position.y + PanelPaddingHeight, InfoBoxRect.size.width, InfoBoxRect.size.height), GetMainPanel().position + Displacement { InfoBoxRect.position.x, InfoBoxRect.position.y }); - if (!MainPanelFlag && !trigflag && pcursinvitem == -1 && pcursstashitem == StashStruct::EmptyCell && !SpellSelectFlag && pcurs != CURSOR_HOURGLASS) { - InfoString = StringOrView {}; - InfoColor = UiFlags::ColorWhite; - } - const Player &myPlayer = *MyPlayer; - if (SpellSelectFlag || trigflag || pcurs == CURSOR_HOURGLASS) { - InfoColor = UiFlags::ColorWhite; - } else if (!myPlayer.HoldItem.isEmpty()) { +void DrawInfoBox(const Surface &out) +{ + DrawPanelBox(out, MakeSdlRect(InfoBoxRect.position.x, InfoBoxRect.position.y + PanelPaddingHeight, InfoBoxRect.size.width, InfoBoxRect.size.height), GetMainPanel().position + Displacement { InfoBoxRect.position.x, InfoBoxRect.position.y }); + if (!MainPanelFlag && !trigflag && pcursinvitem == -1 && pcursstashitem == StashStruct::EmptyCell && !SpellSelectFlag && pcurs != CURSOR_HOURGLASS) { + InfoString = StringOrView {}; + InfoColor = UiFlags::ColorWhite; + } + const Player &myPlayer = *MyPlayer; + if (SpellSelectFlag || trigflag || pcurs == CURSOR_HOURGLASS) { + InfoColor = UiFlags::ColorWhite; + } else if (!myPlayer.HoldItem.isEmpty()) { if (myPlayer.HoldItem._itype == ItemType::Gold) { const int nGold = myPlayer.HoldItem._ivalue; InfoString = fmt::format(fmt::runtime(ngettext("{:s} gold piece", "{:s} gold pieces", nGold)), FormatInteger(nGold)); @@ -406,20 +431,24 @@ void DrawInfoBox(const Surface &out) InfoString = std::string_view(target._pName); AddInfoBoxString(_("Right click to inspect")); } - } - if (!InfoString.empty()) - PrintInfo(out); -} - -void DrawFloatingInfoBox(const Surface &out) -{ - if (pcursinvitem == -1 && pcursstashitem == StashStruct::EmptyCell) { - FloatingInfoString = StringOrView {}; - InfoColor = UiFlags::ColorWhite; - } - - if (!FloatingInfoString.empty()) - PrintFloatingInfo(out); -} + } + if (InfoString.empty()) { + LastSpokenInfoString.clear(); + } else { + PrintInfo(out); + } +} + +void DrawFloatingInfoBox(const Surface &out) +{ + if (pcursinvitem == -1 && pcursstashitem == StashStruct::EmptyCell) { + FloatingInfoString = StringOrView {}; + InfoColor = UiFlags::ColorWhite; + LastSpokenFloatingInfoString.clear(); + } + + if (!FloatingInfoString.empty()) + PrintFloatingInfo(out); +} } // namespace devilution diff --git a/Source/control/control_panel.cpp b/Source/control/control_panel.cpp index dd33e6160..20d705954 100644 --- a/Source/control/control_panel.cpp +++ b/Source/control/control_panel.cpp @@ -295,13 +295,14 @@ void FocusOnCharInfo() SetCursorPos(CharPanelButtonRect[stat].Center()); } -void OpenCharPanel() -{ - QuestLogIsOpen = false; - CloseGoldWithdraw(); - CloseStash(); - CharFlag = true; -} +void OpenCharPanel() +{ + QuestLogIsOpen = false; + CloseGoldWithdraw(); + CloseStash(); + CharFlag = true; + InitCharacterScreenSpeech(); +} void CloseCharPanel() { diff --git a/Source/controls/plrctrls.cpp b/Source/controls/plrctrls.cpp index 7a54aba31..32b4b0ac3 100644 --- a/Source/controls/plrctrls.cpp +++ b/Source/controls/plrctrls.cpp @@ -1,13 +1,14 @@ #include "controls/plrctrls.h" -#include -#include -#include -#include - -#ifdef USE_SDL3 -#include -#include +#include +#include +#include +#include +#include + +#ifdef USE_SDL3 +#include +#include #include #else #include @@ -15,12 +16,14 @@ #ifdef USE_SDL1 #include "utils/sdl2_to_1_2_backports.h" #endif -#endif - -#include "automap.h" -#include "control/control.hpp" -#include "controls/controller_motion.h" -#ifndef USE_SDL1 +#endif + +#include + +#include "automap.h" +#include "control/control.hpp" +#include "controls/controller_motion.h" +#ifndef USE_SDL1 #include "controls/devices/game_controller.h" #endif #include "controls/control_mode.hpp" @@ -46,13 +49,16 @@ #include "panels/ui_panels.hpp" #include "qol/chatlog.h" #include "qol/stash.h" -#include "stores.h" -#include "towners.h" -#include "track.h" -#include "utils/is_of.hpp" -#include "utils/log.hpp" -#include "utils/sdl_compat.h" -#include "utils/str_cat.hpp" +#include "stores.h" +#include "towners.h" +#include "track.h" +#include "utils/format_int.hpp" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/log.hpp" +#include "utils/screen_reader.hpp" +#include "utils/sdl_compat.h" +#include "utils/str_cat.hpp" namespace devilution { @@ -669,18 +675,104 @@ Point GetSlotCoord(int slot) /** * Return the item id of the current slot */ -int GetItemIdOnSlot(int slot) -{ - if (slot >= SLOTXY_INV_FIRST && slot <= SLOTXY_INV_LAST) { - return std::abs(MyPlayer->InvGrid[slot - SLOTXY_INV_FIRST]); - } - - return 0; -} - -/** - * Get item size (grid size) on the slot specified. Returns 1x1 if none exists. - */ +int GetItemIdOnSlot(int slot) +{ + if (slot >= SLOTXY_INV_FIRST && slot <= SLOTXY_INV_LAST) { + return std::abs(MyPlayer->InvGrid[slot - SLOTXY_INV_FIRST]); + } + + return 0; +} + +StringOrView GetInventorySlotNameForSpeech(int slot) +{ + switch (slot) { + case SLOTXY_HEAD: + return _("Head"); + case SLOTXY_RING_LEFT: + return _("Left ring"); + case SLOTXY_RING_RIGHT: + return _("Right ring"); + case SLOTXY_AMULET: + return _("Amulet"); + case SLOTXY_HAND_LEFT: + return _("Left hand"); + case SLOTXY_HAND_RIGHT: + return _("Right hand"); + case SLOTXY_CHEST: + return _("Chest"); + default: + break; + } + + if (slot >= SLOTXY_BELT_FIRST && slot <= SLOTXY_BELT_LAST) + return StrCat(_("Belt"), " ", slot - SLOTXY_BELT_FIRST + 1); + + return _("Inventory"); +} + +void SpeakInventorySlotForAccessibility() +{ + if (MyPlayer == nullptr) + return; + + const Player &player = *MyPlayer; + const Item *item = nullptr; + + if (Slot >= SLOTXY_BELT_FIRST && Slot <= SLOTXY_BELT_LAST) { + item = &player.SpdList[Slot - SLOTXY_BELT_FIRST]; + } else if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_INV_LAST) { + const int invId = GetItemIdOnSlot(Slot); + if (invId != 0) + item = &player.InvList[invId - 1]; + } else { + switch (Slot) { + case SLOTXY_HEAD: + item = &player.InvBody[INVLOC_HEAD]; + break; + case SLOTXY_RING_LEFT: + item = &player.InvBody[INVLOC_RING_LEFT]; + break; + case SLOTXY_RING_RIGHT: + item = &player.InvBody[INVLOC_RING_RIGHT]; + break; + case SLOTXY_AMULET: + item = &player.InvBody[INVLOC_AMULET]; + break; + case SLOTXY_HAND_LEFT: + item = &player.InvBody[INVLOC_HAND_LEFT]; + break; + case SLOTXY_HAND_RIGHT: { + const Item &left = player.InvBody[INVLOC_HAND_LEFT]; + if (!left.isEmpty() && player.GetItemLocation(left) == ILOC_TWOHAND) + item = &left; + else + item = &player.InvBody[INVLOC_HAND_RIGHT]; + } break; + case SLOTXY_CHEST: + item = &player.InvBody[INVLOC_CHEST]; + break; + default: + break; + } + } + + if (item != nullptr && !item->isEmpty()) { + if (item->_itype == ItemType::Gold) { + const int nGold = item->_ivalue; + SpeakText(fmt::format(fmt::runtime(ngettext("{:s} gold piece", "{:s} gold pieces", nGold)), FormatInteger(nGold)), /*force=*/true); + } else { + SpeakText(item->getName(), /*force=*/true); + } + return; + } + + SpeakText(StrCat(GetInventorySlotNameForSpeech(Slot), ": ", _("empty")), /*force=*/true); +} + +/** + * Get item size (grid size) on the slot specified. Returns 1x1 if none exists. + */ Size GetItemSizeOnSlot(int slot) { if (slot >= SLOTXY_INV_FIRST && slot <= SLOTXY_INV_LAST) { @@ -1146,12 +1238,14 @@ void InventoryMove(AxisDirection dir) mousePos.y += ((itemSize.height - 1) * InventorySlotSizeInPixels.height) / 2; } - if (mousePos == MousePosition) { - return; // Avoid wobbling when scaled - } - - SetCursorPos(mousePos); -} + if (mousePos == MousePosition) { + SpeakInventorySlotForAccessibility(); + return; // Avoid wobbling when scaled + } + + SetCursorPos(mousePos); + SpeakInventorySlotForAccessibility(); +} /** * Move the cursor around in the inventory @@ -1349,12 +1443,12 @@ void StashMove(AxisDirection dir) FocusOnInventory(); } -void HotSpellMove(AxisDirection dir) -{ - static AxisDirectionRepeater repeater; - dir = repeater.Get(dir); - if (dir.x == AxisDirectionX_NONE && dir.y == AxisDirectionY_NONE) - return; +void HotSpellMoveInternal(AxisDirection dir) +{ + static AxisDirectionRepeater repeater; + dir = repeater.Get(dir); + if (dir.x == AxisDirectionX_NONE && dir.y == AxisDirectionY_NONE) + return; auto spellListItems = GetSpellListItems(); @@ -1497,14 +1591,14 @@ void StoreMove(AxisDirection moveDir) using HandleLeftStickOrDPadFn = void (*)(devilution::AxisDirection); -HandleLeftStickOrDPadFn GetLeftStickOrDPadGameUIHandler() -{ - if (SpellSelectFlag) { - return &HotSpellMove; - } - if (IsStashOpen) { - return &StashMove; - } +HandleLeftStickOrDPadFn GetLeftStickOrDPadGameUIHandler() +{ + if (SpellSelectFlag) { + return &HotSpellMoveInternal; + } + if (IsStashOpen) { + return &StashMove; + } if (invflag) { return &CheckInventoryMove; } @@ -1732,11 +1826,16 @@ void LogGamepadChange(GamepadLayout newGamepad) } #endif -} // namespace - -void DetectInputMethod(const SDL_Event &event, const ControllerButtonEvent &gamepadEvent) -{ - ControlTypes inputType = GetInputTypeFromEvent(event); +} // namespace + +void HotSpellMove(AxisDirection dir) +{ + HotSpellMoveInternal(dir); +} + +void DetectInputMethod(const SDL_Event &event, const ControllerButtonEvent &gamepadEvent) +{ + ControlTypes inputType = GetInputTypeFromEvent(event); if (inputType == ControlTypes::None) return; @@ -1906,11 +2005,20 @@ void InvalidateInventorySlot() /** * @brief Moves the mouse to the first inventory slot. */ -void FocusOnInventory() -{ - Slot = SLOTXY_INV_FIRST; - ResetInvCursorPosition(); -} +void FocusOnInventory() +{ + Slot = SLOTXY_INV_FIRST; + ResetInvCursorPosition(); + SpeakInventorySlotForAccessibility(); +} + +void InventoryMoveFromKeyboard(AxisDirection dir) +{ + if (!invflag) + return; + + CheckInventoryMove(dir); +} bool PointAndClickState = false; @@ -1982,10 +2090,10 @@ void plrctrls_after_game_logic() Movement(*MyPlayer); } -void UseBeltItem(BeltItemType type) -{ - for (int i = 0; i < MaxBeltItems; i++) { - const Item &item = MyPlayer->SpdList[i]; +void UseBeltItem(BeltItemType type) +{ + for (int i = 0; i < MaxBeltItems; i++) { + const Item &item = MyPlayer->SpdList[i]; if (item.isEmpty()) { continue; } @@ -1997,15 +2105,55 @@ void UseBeltItem(BeltItemType type) if ((type == BeltItemType::Healing && isHealing) || (type == BeltItemType::Mana && isMana)) { UseInvItem(INVITEM_BELT_FIRST + i); break; - } - } -} - -void PerformPrimaryAction() -{ - if (SpellSelectFlag) { - SetSpell(); - return; + } + } +} + +namespace { + +void UpdateTargetsForKeyboardAction() +{ + // Clear focus set by cursor. + PlayerUnderCursor = nullptr; + pcursmonst = -1; + pcursitem = -1; + ObjectUnderCursor = nullptr; + + pcursmissile = nullptr; + pcurstrig = -1; + pcursquest = Q_INVALID; + cursPosition = { -1, -1 }; + + if (MyPlayer == nullptr) + return; + if (MyPlayer->_pInvincible) + return; + if (DoomFlag) + return; + if (invflag) + return; + + InfoString = StringOrView {}; + FindActor(); + FindItemOrObject(); + FindTrigger(); +} + +} // namespace + +void PerformPrimaryActionAutoTarget() +{ + if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { + UpdateTargetsForKeyboardAction(); + } + PerformPrimaryAction(); +} + +void PerformPrimaryAction() +{ + if (SpellSelectFlag) { + SetSpell(); + return; } if (invflag) { // inventory is open @@ -2028,9 +2176,9 @@ void PerformPrimaryAction() ReleaseChrBtns(false); return; } - - Interact(); -} + + Interact(); +} bool SpellHasActorTarget() { @@ -2174,11 +2322,11 @@ void CtrlUseInvItem() } } -void CtrlUseStashItem() -{ - if (pcursstashitem == StashStruct::EmptyCell) { - return; - } +void CtrlUseStashItem() +{ + if (pcursstashitem == StashStruct::EmptyCell) { + return; + } const Item &item = Stash.stashList[pcursstashitem]; if (item.isScroll()) { @@ -2194,15 +2342,31 @@ void CtrlUseStashItem() CheckStashItem(MousePosition, true, false); // Auto-equip if it's equipment } else { UseStashItem(pcursstashitem); - } - // Todo reset cursor position if item is moved -} - -void PerformSecondaryAction() -{ - Player &myPlayer = *MyPlayer; - if (invflag) { - if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { + } + // Todo reset cursor position if item is moved +} + +void PerformSecondaryActionAutoTarget() +{ + if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { + UpdateTargetsForKeyboardAction(); + } + PerformSecondaryAction(); +} + +void PerformSpellActionAutoTarget() +{ + if (ControlMode == ControlTypes::KeyboardAndMouse && !IsPointAndClick()) { + UpdateTargetsForKeyboardAction(); + } + PerformSpellAction(); +} + +void PerformSecondaryAction() +{ + Player &myPlayer = *MyPlayer; + if (invflag) { + if (pcurs > CURSOR_HAND && pcurs < CURSOR_FIRSTITEM) { TryIconCurs(); NewCursor(CURSOR_HAND); } else if (IsStashOpen) { diff --git a/Source/controls/plrctrls.h b/Source/controls/plrctrls.h index 4b79c8fde..eeba6dd98 100644 --- a/Source/controls/plrctrls.h +++ b/Source/controls/plrctrls.h @@ -7,12 +7,13 @@ #ifdef USE_SDL3 #include #else -#include -#endif - -#include "controls/controller.h" -#include "controls/game_controls.h" -#include "player.h" +#include +#endif + +#include "controls/axis_direction.h" +#include "controls/controller.h" +#include "controls/game_controls.h" +#include "player.h" namespace devilution { @@ -50,19 +51,30 @@ bool IsMovementHandlerActive(); void DetectInputMethod(const SDL_Event &event, const ControllerButtonEvent &gamepadEvent); void ProcessGameAction(const GameAction &action); -void UseBeltItem(BeltItemType type); - -// Talk to towners, click on inv items, attack, etc. -void PerformPrimaryAction(); - -// Open chests, doors, pickup items. -void PerformSecondaryAction(); -void UpdateSpellTarget(SpellID spell); -bool TryDropItem(); -void InvalidateInventorySlot(); -void FocusOnInventory(); -void PerformSpellAction(); -void QuickCast(size_t slot); +void UseBeltItem(BeltItemType type); + +// Talk to towners, click on inv items, attack, etc. +void PerformPrimaryAction(); + +// Open chests, doors, pickup items. +void PerformSecondaryAction(); + +// Like PerformPrimaryAction but auto-selects a nearby target for keyboard-only play. +void PerformPrimaryActionAutoTarget(); + +// Like PerformSecondaryAction but auto-selects a nearby target for keyboard-only play. +void PerformSecondaryActionAutoTarget(); + +// Like PerformSpellAction but auto-selects a nearby target for keyboard-only play. +void PerformSpellActionAutoTarget(); +void UpdateSpellTarget(SpellID spell); +bool TryDropItem(); +void InvalidateInventorySlot(); +void FocusOnInventory(); +void InventoryMoveFromKeyboard(AxisDirection dir); +void HotSpellMove(AxisDirection dir); +void PerformSpellAction(); +void QuickCast(size_t slot); extern int speedspellcount; diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 0339a9043..c5b230480 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -1,11 +1,14 @@ -/** - * @file diablo.cpp - * - * Implementation of the main game initialization functions. - */ -#include -#include -#include +/** + * @file diablo.cpp + * + * Implementation of the main game initialization functions. + */ +#include +#include +#include +#include +#include +#include #ifdef USE_SDL3 #include @@ -47,12 +50,13 @@ #include "engine/clx_sprite.hpp" #include "engine/demomode.h" #include "engine/dx.h" -#include "engine/events.hpp" -#include "engine/load_cel.hpp" -#include "engine/load_file.hpp" -#include "engine/random.hpp" -#include "engine/render/clx_render.hpp" -#include "engine/sound.h" +#include "engine/events.hpp" +#include "engine/load_cel.hpp" +#include "engine/load_file.hpp" +#include "engine/path.h" +#include "engine/random.hpp" +#include "engine/render/clx_render.hpp" +#include "engine/sound.h" #include "game_mode.hpp" #include "gamemenu.h" #include "gmenu.h" @@ -67,12 +71,13 @@ #include "levels/drlg_l4.h" #include "levels/gendung.h" #include "levels/setmaps.h" -#include "levels/themes.h" -#include "levels/town.h" -#include "levels/trigs.h" -#include "lighting.h" -#include "loadsave.h" -#include "lua/lua_global.hpp" +#include "levels/themes.h" +#include "levels/town.h" +#include "levels/trigs.h" +#include "levels/tile_properties.hpp" +#include "lighting.h" +#include "loadsave.h" +#include "lua/lua_global.hpp" #include "menu.h" #include "minitext.h" #include "missiles.h" @@ -80,14 +85,16 @@ #include "multi.h" #include "nthread.h" #include "objects.h" -#include "options.h" -#include "panels/console.hpp" -#include "panels/info_box.hpp" -#include "panels/partypanel.hpp" -#include "panels/spell_book.hpp" -#include "panels/spell_list.hpp" -#include "pfile.h" -#include "plrmsg.h" +#include "options.h" +#include "panels/console.hpp" +#include "panels/info_box.hpp" +#include "panels/charpanel.hpp" +#include "panels/partypanel.hpp" +#include "panels/spell_book.hpp" +#include "panels/spell_list.hpp" +#include "pfile.h" +#include "portal.h" +#include "plrmsg.h" #include "qol/chatlog.h" #include "qol/floatingnumbers.h" #include "qol/itemlabels.h" @@ -107,12 +114,13 @@ #include "utils/display.h" #include "utils/is_of.hpp" #include "utils/language.h" -#include "utils/parse_int.hpp" -#include "utils/paths.h" -#include "utils/screen_reader.hpp" -#include "utils/sdl_compat.h" -#include "utils/sdl_thread.h" -#include "utils/status_macros.hpp" +#include "utils/parse_int.hpp" +#include "utils/paths.h" +#include "utils/proximity_audio.hpp" +#include "utils/screen_reader.hpp" +#include "utils/sdl_compat.h" +#include "utils/sdl_thread.h" +#include "utils/status_macros.hpp" #include "utils/str_cat.hpp" #include "utils/utf8.hpp" @@ -156,17 +164,27 @@ PlayerActionType LastPlayerAction = PlayerActionType::None; // Controller support: Actions to run after updating the cursor state. // Defined in SourceX/controls/plctrls.cpp. -extern void plrctrls_after_check_curs_move(); -extern void plrctrls_every_frame(); -extern void plrctrls_after_game_logic(); - -namespace { - -char gszVersionNumber[64] = "internal version unknown"; - -bool gbGameLoopStartup; -bool forceSpawn; -bool forceDiablo; +extern void plrctrls_after_check_curs_move(); +extern void plrctrls_every_frame(); +extern void plrctrls_after_game_logic(); + +namespace { + +char gszVersionNumber[64] = "internal version unknown"; + + void SelectNextTownNpcKeyPressed(); + void SelectPreviousTownNpcKeyPressed(); + void UpdateAutoWalkTownNpc(); + void UpdateAutoWalkTracker(); + void SpeakSelectedSpeedbookSpell(); + void SpellBookKeyPressed(); + std::optional> FindKeyboardWalkPathForSpeech(const Player &player, Point startPosition, Point destinationPosition); + void AppendKeyboardWalkPathForSpeech(std::string &message, const std::vector &path); + void AppendDirectionalFallback(std::string &message, const Displacement &delta); + +bool gbGameLoopStartup; +bool forceSpawn; +bool forceDiablo; int sgnTimeoutCurs; bool gbShowIntro = true; /** To know if these things have been done when we get to the diablo_deinit() function */ @@ -593,75 +611,178 @@ void PressKey(SDL_Keycode vkey, uint16_t modState) GetDebugMonster(); return; #endif - case SDLK_RETURN: - case SDLK_KP_ENTER: - if ((modState & SDL_KMOD_ALT) != 0) { - options.Graphics.fullscreen.SetValue(!IsFullScreen()); - if (!demo::IsRunning()) SaveOptions(); - } else if (IsPlayerInStore()) { - StoreEnter(); - } else if (QuestLogIsOpen) { - QuestlogEnter(); - } else { - TypeChatMessage(); - } - return; - case SDLK_UP: - if (IsPlayerInStore()) { - StoreUp(); - } else if (QuestLogIsOpen) { - QuestlogUp(); - } else if (HelpFlag) { - HelpScrollUp(); - } else if (ChatLogFlag) { - ChatLogScrollUp(); - } else if (AutomapActive) { - AutomapUp(); - } else if (IsStashOpen) { - Stash.PreviousPage(); - } - return; - case SDLK_DOWN: - if (IsPlayerInStore()) { - StoreDown(); - } else if (QuestLogIsOpen) { - QuestlogDown(); - } else if (HelpFlag) { - HelpScrollDown(); - } else if (ChatLogFlag) { - ChatLogScrollDown(); - } else if (AutomapActive) { - AutomapDown(); - } else if (IsStashOpen) { - Stash.NextPage(); - } - return; - case SDLK_PAGEUP: - if (IsPlayerInStore()) { - StorePrior(); - } else if (ChatLogFlag) { - ChatLogScrollTop(); - } - return; - case SDLK_PAGEDOWN: - if (IsPlayerInStore()) { - StoreNext(); - } else if (ChatLogFlag) { - ChatLogScrollBottom(); - } - return; - case SDLK_LEFT: - if (AutomapActive && !ChatFlag) - AutomapLeft(); - return; - case SDLK_RIGHT: - if (AutomapActive && !ChatFlag) - AutomapRight(); - return; - default: - break; - } -} + case SDLK_RETURN: + case SDLK_KP_ENTER: + if ((modState & SDL_KMOD_ALT) != 0) { + options.Graphics.fullscreen.SetValue(!IsFullScreen()); + if (!demo::IsRunning()) SaveOptions(); + } else if (CharFlag) { + CharacterScreenActivateSelection((modState & SDL_KMOD_SHIFT) != 0); + } else if (IsPlayerInStore()) { + StoreEnter(); + } else if (QuestLogIsOpen) { + QuestlogEnter(); + } else if (SpellSelectFlag) { + SetSpell(); + } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { + const Player &player = *MyPlayer; + if (IsValidSpell(player._pRSpell)) { + std::string msg; + StrAppend(msg, _("Selected: "), pgettext("spell", GetSpellData(player._pRSpell).sNameText)); + SpeakText(msg, /*force=*/true); + } else { + SpeakText(_("No spell selected."), /*force=*/true); + } + SpellBookKeyPressed(); + } else { + TypeChatMessage(); + } + return; + case SDLK_UP: + if (IsPlayerInStore()) { + StoreUp(); + } else if (QuestLogIsOpen) { + QuestlogUp(); + } else if (CharFlag) { + CharacterScreenMoveSelection(-1); + } else if (HelpFlag) { + HelpScrollUp(); + } else if (ChatLogFlag) { + ChatLogScrollUp(); + } else if (SpellSelectFlag) { + HotSpellMove({ AxisDirectionX_NONE, AxisDirectionY_UP }); + SpeakSelectedSpeedbookSpell(); + } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { + const std::optional next = GetSpellBookAdjacentAvailableSpell(SpellbookTab, *MyPlayer, MyPlayer->_pRSpell, -1); + if (next) { + MyPlayer->_pRSpell = *next; + MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*next)) != 0 ? SpellType::Skill + : (MyPlayer->_pISpells & GetSpellBitmask(*next)) != 0 ? SpellType::Charges + : SpellType::Spell; + UpdateSpellTarget(*next); + RedrawEverything(); + SpeakText(pgettext("spell", GetSpellData(*next).sNameText), /*force=*/true); + } + } else if (invflag) { + InventoryMoveFromKeyboard({ AxisDirectionX_NONE, AxisDirectionY_UP }); + } else if (AutomapActive) { + AutomapUp(); + } else if (IsStashOpen) { + Stash.PreviousPage(); + } + return; + case SDLK_DOWN: + if (IsPlayerInStore()) { + StoreDown(); + } else if (QuestLogIsOpen) { + QuestlogDown(); + } else if (CharFlag) { + CharacterScreenMoveSelection(+1); + } else if (HelpFlag) { + HelpScrollDown(); + } else if (ChatLogFlag) { + ChatLogScrollDown(); + } else if (SpellSelectFlag) { + HotSpellMove({ AxisDirectionX_NONE, AxisDirectionY_DOWN }); + SpeakSelectedSpeedbookSpell(); + } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { + const std::optional next = GetSpellBookAdjacentAvailableSpell(SpellbookTab, *MyPlayer, MyPlayer->_pRSpell, +1); + if (next) { + MyPlayer->_pRSpell = *next; + MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*next)) != 0 ? SpellType::Skill + : (MyPlayer->_pISpells & GetSpellBitmask(*next)) != 0 ? SpellType::Charges + : SpellType::Spell; + UpdateSpellTarget(*next); + RedrawEverything(); + SpeakText(pgettext("spell", GetSpellData(*next).sNameText), /*force=*/true); + } + } else if (invflag) { + InventoryMoveFromKeyboard({ AxisDirectionX_NONE, AxisDirectionY_DOWN }); + } else if (AutomapActive) { + AutomapDown(); + } else if (IsStashOpen) { + Stash.NextPage(); + } + return; + case SDLK_PAGEUP: + if (IsPlayerInStore()) { + StorePrior(); + } else if (ChatLogFlag) { + ChatLogScrollTop(); + } else { + const KeymapperOptions::Action *action = GetOptions().Keymapper.findAction(static_cast(vkey)); + if (action == nullptr || !action->isEnabled()) + SelectPreviousTownNpcKeyPressed(); + } + return; + case SDLK_PAGEDOWN: + if (IsPlayerInStore()) { + StoreNext(); + } else if (ChatLogFlag) { + ChatLogScrollBottom(); + } else { + const KeymapperOptions::Action *action = GetOptions().Keymapper.findAction(static_cast(vkey)); + if (action == nullptr || !action->isEnabled()) + SelectNextTownNpcKeyPressed(); + } + return; + case SDLK_LEFT: + if (CharFlag) { + CharacterScreenMoveSelection(-1); + } else if (SpellSelectFlag) { + HotSpellMove({ AxisDirectionX_LEFT, AxisDirectionY_NONE }); + SpeakSelectedSpeedbookSpell(); + } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { + if (SpellbookTab > 0) { + SpellbookTab--; + const std::optional first = GetSpellBookFirstAvailableSpell(SpellbookTab, *MyPlayer); + if (first) { + MyPlayer->_pRSpell = *first; + MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*first)) != 0 ? SpellType::Skill + : (MyPlayer->_pISpells & GetSpellBitmask(*first)) != 0 ? SpellType::Charges + : SpellType::Spell; + UpdateSpellTarget(*first); + RedrawEverything(); + SpeakText(pgettext("spell", GetSpellData(*first).sNameText), /*force=*/true); + } + } + } else if (invflag) { + InventoryMoveFromKeyboard({ AxisDirectionX_LEFT, AxisDirectionY_NONE }); + } else if (AutomapActive && !ChatFlag) { + AutomapLeft(); + } + return; + case SDLK_RIGHT: + if (CharFlag) { + CharacterScreenMoveSelection(+1); + } else if (SpellSelectFlag) { + HotSpellMove({ AxisDirectionX_RIGHT, AxisDirectionY_NONE }); + SpeakSelectedSpeedbookSpell(); + } else if (SpellbookFlag && MyPlayer != nullptr && !IsInspectingPlayer()) { + const int maxTab = gbIsHellfire ? 4 : 3; + if (SpellbookTab < maxTab) { + SpellbookTab++; + const std::optional first = GetSpellBookFirstAvailableSpell(SpellbookTab, *MyPlayer); + if (first) { + MyPlayer->_pRSpell = *first; + MyPlayer->_pRSplType = (MyPlayer->_pAblSpells & GetSpellBitmask(*first)) != 0 ? SpellType::Skill + : (MyPlayer->_pISpells & GetSpellBitmask(*first)) != 0 ? SpellType::Charges + : SpellType::Spell; + UpdateSpellTarget(*first); + RedrawEverything(); + SpeakText(pgettext("spell", GetSpellData(*first).sNameText), /*force=*/true); + } + } + } else if (invflag) { + InventoryMoveFromKeyboard({ AxisDirectionX_RIGHT, AxisDirectionY_NONE }); + } else if (AutomapActive && !ChatFlag) { + AutomapRight(); + } + return; + default: + break; + } +} void HandleMouseButtonDown(Uint8 button, uint16_t modState) { @@ -1512,11 +1633,13 @@ void GameLogic() if (!ProcessInput()) { return; } - if (gbProcessPlayers) { - gGameLogicStep = GameLogicStep::ProcessPlayers; - ProcessPlayers(); - } - if (leveltype != DTYPE_TOWN) { + if (gbProcessPlayers) { + gGameLogicStep = GameLogicStep::ProcessPlayers; + ProcessPlayers(); + UpdateAutoWalkTownNpc(); + UpdateAutoWalkTracker(); + } + if (leveltype != DTYPE_TOWN) { gGameLogicStep = GameLogicStep::ProcessMonsters; #ifdef _DEBUG if (!DebugInvisible) @@ -1526,14 +1649,15 @@ void GameLogic() ProcessObjects(); gGameLogicStep = GameLogicStep::ProcessMissiles; ProcessMissiles(); - gGameLogicStep = GameLogicStep::ProcessItems; - ProcessItems(); - ProcessLightList(); - ProcessVisionList(); - } else { - gGameLogicStep = GameLogicStep::ProcessTowners; - ProcessTowners(); - gGameLogicStep = GameLogicStep::ProcessItemsTown; + gGameLogicStep = GameLogicStep::ProcessItems; + ProcessItems(); + ProcessLightList(); + ProcessVisionList(); + UpdateProximityAudioCues(); + } else { + gGameLogicStep = GameLogicStep::ProcessTowners; + ProcessTowners(); + gGameLogicStep = GameLogicStep::ProcessItemsTown; ProcessItems(); gGameLogicStep = GameLogicStep::ProcessMissilesTown; ProcessMissiles(); @@ -1602,11 +1726,11 @@ void TimeoutCursor(bool bTimeout) } } -void HelpKeyPressed() -{ - if (HelpFlag) { - HelpFlag = false; - } else if (IsPlayerInStore()) { +void HelpKeyPressed() +{ + if (HelpFlag) { + HelpFlag = false; + } else if (IsPlayerInStore()) { InfoString = StringOrView {}; AddInfoBoxString(_("No help available")); /// BUGFIX: message isn't displayed AddInfoBoxString(_("while in stores")); @@ -1625,14 +1749,1503 @@ void HelpKeyPressed() gamemenu_off(); DisplayHelp(); doom_close(); - } -} - -void InventoryKeyPressed() -{ - if (IsPlayerInStore()) - return; - invflag = !invflag; + } +} + +bool CanPlayerTakeAction(); + +std::vector TownNpcOrder; +int SelectedTownNpc = -1; +int AutoWalkTownNpcTarget = -1; + +enum class TrackerTargetCategory : uint8_t { + Items, + Chests, + Monsters, +}; + +TrackerTargetCategory SelectedTrackerTargetCategory = TrackerTargetCategory::Items; +TrackerTargetCategory AutoWalkTrackerTargetCategory = TrackerTargetCategory::Items; +int AutoWalkTrackerTargetId = -1; + +Point NextPositionForWalkDirection(Point position, int8_t walkDir) +{ + switch (walkDir) { + case WALK_NE: + return { position.x, position.y - 1 }; + case WALK_NW: + return { position.x - 1, position.y }; + case WALK_SE: + return { position.x + 1, position.y }; + case WALK_SW: + return { position.x, position.y + 1 }; + case WALK_N: + return { position.x - 1, position.y - 1 }; + case WALK_E: + return { position.x + 1, position.y - 1 }; + case WALK_S: + return { position.x + 1, position.y + 1 }; + case WALK_W: + return { position.x - 1, position.y + 1 }; + default: + return position; + } +} + +Point PositionAfterWalkPathSteps(Point start, const int8_t *path, int steps) +{ + Point position = start; + for (int i = 0; i < steps; ++i) { + position = NextPositionForWalkDirection(position, path[i]); + } + return position; +} + +int8_t OppositeWalkDirection(int8_t walkDir) +{ + switch (walkDir) { + case WALK_NE: + return WALK_SW; + case WALK_SW: + return WALK_NE; + case WALK_NW: + return WALK_SE; + case WALK_SE: + return WALK_NW; + case WALK_N: + return WALK_S; + case WALK_S: + return WALK_N; + case WALK_E: + return WALK_W; + case WALK_W: + return WALK_E; + default: + return WALK_NONE; + } +} + +bool IsTownNpcActionAllowed() +{ + return CanPlayerTakeAction() + && leveltype == DTYPE_TOWN + && !IsPlayerInStore() + && !ChatLogFlag + && !HelpFlag; +} + +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 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, _("Selected: "), towner.name); + StrAppend(msg, "\n", _("Distance: "), distance); + StrAppend(msg, "\n", _("Position: "), towner.position.x, ", ", towner.position.y); + SpeakText(msg, true); +} + +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(); +} + +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); +} + +namespace { + +constexpr int TrackerInteractDistanceTiles = 1; + +int LockedTrackerItemId = -1; +int LockedTrackerChestId = -1; +int LockedTrackerMonsterId = -1; + +struct TrackerLevelKey { + dungeon_type levelType; + int currLevel; + bool isSetLevel; + int setLevelNum; +}; + +std::optional LockedTrackerLevelKey; + +void ClearTrackerLocks() +{ + LockedTrackerItemId = -1; + LockedTrackerChestId = -1; + LockedTrackerMonsterId = -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::Monsters: + default: + return LockedTrackerMonsterId; + } +} + +std::string_view TrackerTargetCategoryLabel(TrackerTargetCategory category) +{ + switch (category) { + case TrackerTargetCategory::Items: + return _("items"); + case TrackerTargetCategory::Chests: + return _("chests"); + case TrackerTargetCategory::Monsters: + return _("monsters"); + default: + return _("items"); + } +} + +void SpeakTrackerTargetCategory() +{ + std::string message; + StrAppend(message, _("Tracker target: "), TrackerTargetCategoryLabel(SelectedTrackerTargetCategory)); + SpeakText(message, true); +} + +void CycleTrackerTargetKeyPressed() +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + + AutoWalkTrackerTargetId = -1; + + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: + SelectedTrackerTargetCategory = TrackerTargetCategory::Chests; + break; + case TrackerTargetCategory::Chests: + SelectedTrackerTargetCategory = TrackerTargetCategory::Monsters; + break; + case TrackerTargetCategory::Monsters: + default: + SelectedTrackerTargetCategory = TrackerTargetCategory::Items; + break; + } + + SpeakTrackerTargetCategory(); +} + +std::optional FindNearestGroundItemId(Point playerPosition) +{ + if (ActiveItemCount == 0) + return std::nullopt; + + std::optional bestId; + int bestDistance = 0; + + for (uint8_t i = 0; i < ActiveItemCount; i++) { + const int itemId = ActiveItems[i]; + if (itemId < 0 || itemId > MAXITEMS) + continue; + + const Item &item = Items[itemId]; + if (item._iClass == ICLASS_NONE) + continue; + + const int distance = playerPosition.WalkingDistance(item.position); + if (!bestId || distance < bestDistance) { + bestId = itemId; + bestDistance = distance; + } + } + + return bestId; +} + +[[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; +} + +std::optional FindNearestUnopenedChestObjectId(Point playerPosition) +{ + if (ActiveObjectCount == 0) + return std::nullopt; + + std::optional bestId; + int bestDistance = 0; + + for (int i = 0; i < ActiveObjectCount; i++) { + const int objectId = ActiveObjects[i]; + if (objectId < 0 || objectId >= MAXOBJECTS) + continue; + + const Object &object = Objects[objectId]; + if (!object.canInteractWith()) + continue; + if (!object.IsChest()) + continue; + + const int distance = playerPosition.WalkingDistance(object.position); + if (!bestId || distance < bestDistance) { + bestId = objectId; + bestDistance = distance; + } + } + + return bestId; +} + +std::optional FindNearestMonsterId(Point playerPosition) +{ + if (ActiveMonsterCount == 0) + return std::nullopt; + + std::optional bestId; + int bestDistance = 0; + + for (size_t i = 0; i < ActiveMonsterCount; i++) { + const int monsterId = static_cast(ActiveMonsters[i]); + if (monsterId < 0 || monsterId >= static_cast(MaxMonsters)) + continue; + + const Monster &monster = Monsters[monsterId]; + if (monster.isInvalid) + continue; + if ((monster.flags & MFLAG_HIDDEN) != 0) + continue; + if (monster.hitPoints <= 0) + continue; + + const Point monsterPosition { monster.position.tile }; + const int distance = playerPosition.WalkingDistance(monsterPosition); + if (!bestId || distance < bestDistance) { + bestId = monsterId; + bestDistance = distance; + } + } + + return bestId; +} + +std::optional FindBestAdjacentApproachTile(const Player &player, Point playerPosition, Point targetPosition) +{ + std::optional best; + 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 tile { targetPosition.x + dx, targetPosition.y + dy }; + if (!PosOkPlayer(player, tile)) + continue; + + const int distance = playerPosition.WalkingDistance(tile); + if (!best || distance < bestDistance) { + best = tile; + bestDistance = distance; + } + } + } + + return best; +} + +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; +} + +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; +} + +void NavigateToTrackerTargetKeyPressed() +{ + if (!CanPlayerTakeAction() || InGameMenu()) + return; + if (leveltype == DTYPE_TOWN) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + EnsureTrackerLocksMatchCurrentLevel(); + + const SDL_Keymod modState = SDL_GetModState(); + const bool forceRetarget = (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; + + switch (SelectedTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + if (!forceRetarget && 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]; + + targetPosition = tracked.position; + break; + } + case TrackerTargetCategory::Chests: { + if (!forceRetarget && lockedTargetId >= 0 && lockedTargetId < MAXOBJECTS) { + targetId = lockedTargetId; + } else { + targetId = FindNearestUnopenedChestObjectId(playerPosition); + } + if (!targetId) { + SpeakText(_("No chests found."), true); + return; + } + + const Object &object = Objects[*targetId]; + if (!object.IsChest() || !object.canInteractWith()) { + lockedTargetId = -1; + targetId = FindNearestUnopenedChestObjectId(playerPosition); + if (!targetId) { + SpeakText(_("No chests found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Object &tracked = Objects[*targetId]; + + targetPosition = FindBestAdjacentApproachTile(*MyPlayer, playerPosition, tracked.position); + if (!targetPosition) { + SpeakText(_("Can't find a nearby tile to walk to."), true); + return; + } + break; + } + case TrackerTargetCategory::Monsters: + default: + if (!forceRetarget && lockedTargetId >= 0 && lockedTargetId < static_cast(MaxMonsters)) { + targetId = lockedTargetId; + } else { + targetId = FindNearestMonsterId(playerPosition); + } + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + + const Monster &monster = Monsters[*targetId]; + if (monster.isInvalid || (monster.flags & MFLAG_HIDDEN) != 0 || monster.hitPoints <= 0) { + lockedTargetId = -1; + targetId = FindNearestMonsterId(playerPosition); + if (!targetId) { + SpeakText(_("No monsters found."), true); + return; + } + } + + lockedTargetId = *targetId; + const Monster &tracked = Monsters[*targetId]; + + const Point monsterPosition { tracked.position.tile }; + targetPosition = FindBestAdjacentApproachTile(*MyPlayer, playerPosition, monsterPosition); + if (!targetPosition) { + SpeakText(_("Can't find a nearby tile to walk to."), true); + return; + } + break; + } + + if (!targetPosition) { + SpeakText(_("Can't find a nearby tile to walk to."), true); + return; + } + + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, playerPosition, *targetPosition); + std::string message; + if (!path) { + AppendDirectionalFallback(message, *targetPosition - playerPosition); + } else { + AppendKeyboardWalkPathForSpeech(message, *path); + } + + SpeakText(message, true); +} + +} // namespace + +void UpdateAutoWalkTracker() +{ + if (AutoWalkTrackerTargetId < 0) + return; + if (leveltype == DTYPE_TOWN || IsPlayerInStore() || ChatLogFlag || HelpFlag) { + AutoWalkTrackerTargetId = -1; + return; + } + if (!CanPlayerTakeAction()) + return; + + if (MyPlayer == nullptr) + return; + if (MyPlayer->_pmode != PM_STAND) + return; + if (MyPlayer->walkpath[0] != WALK_NONE) + return; + if (MyPlayer->destAction != ACTION_NONE) + return; + + Player &myPlayer = *MyPlayer; + const Point playerPosition = myPlayer.position.future; + + std::optional destination; + + switch (AutoWalkTrackerTargetCategory) { + case TrackerTargetCategory::Items: { + const int itemId = AutoWalkTrackerTargetId; + if (itemId < 0 || itemId > MAXITEMS) { + AutoWalkTrackerTargetId = -1; + return; + } + if (!IsGroundItemPresent(itemId)) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target item is gone."), true); + return; + } + const Item &item = Items[itemId]; + if (playerPosition.WalkingDistance(item.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Item in range."), true); + return; + } + destination = item.position; + break; + } + case TrackerTargetCategory::Chests: { + const int objectId = AutoWalkTrackerTargetId; + if (objectId < 0 || objectId >= MAXOBJECTS) { + AutoWalkTrackerTargetId = -1; + return; + } + const Object &object = Objects[objectId]; + if (!object.IsChest() || !object.canInteractWith()) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target chest is gone."), true); + return; + } + if (playerPosition.WalkingDistance(object.position) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Chest in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, object.position); + break; + } + case TrackerTargetCategory::Monsters: + default: { + const int monsterId = AutoWalkTrackerTargetId; + if (monsterId < 0 || monsterId >= static_cast(MaxMonsters)) { + AutoWalkTrackerTargetId = -1; + return; + } + const Monster &monster = Monsters[monsterId]; + if (monster.isInvalid || (monster.flags & MFLAG_HIDDEN) != 0 || monster.hitPoints <= 0) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Target monster is gone."), true); + return; + } + const Point monsterPosition { monster.position.tile }; + if (playerPosition.WalkingDistance(monsterPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Monster in range."), true); + return; + } + destination = FindBestAdjacentApproachTile(myPlayer, playerPosition, monsterPosition); + break; + } + } + + if (!destination) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Can't find a nearby tile to walk to."), true); + return; + } + + constexpr size_t MaxAutoWalkPathLength = 512; + std::array path; + path.fill(WALK_NONE); + + int steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, *destination, path.data(), path.size()); + if (steps == 0) { + std::array ignoreDoorPath; + ignoreDoorPath.fill(WALK_NONE); + + const int ignoreDoorSteps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayerIgnoreDoors(myPlayer, position); }, playerPosition, *destination, ignoreDoorPath.data(), ignoreDoorPath.size()); + if (ignoreDoorSteps != 0) { + const std::optional block = FindFirstClosedDoorOnWalkPath(playerPosition, ignoreDoorPath.data(), ignoreDoorSteps); + if (block) { + if (playerPosition.WalkingDistance(block->doorPosition) <= TrackerInteractDistanceTiles) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("A door is blocking the path. Open it and try again."), true); + return; + } + + *destination = block->beforeDoor; + path.fill(WALK_NONE); + steps = FindPath(CanStep, [&myPlayer](Point position) { return PosOkPlayer(myPlayer, position); }, playerPosition, *destination, path.data(), path.size()); + } + } + + if (steps == 0) { + AutoWalkTrackerTargetId = -1; + SpeakText(_("Can't find a path to the target."), true); + return; + } + } + + if (steps < static_cast(MaxPathLengthPlayer)) { + NetSendCmdLoc(MyPlayerId, true, CMD_WALKXY, *destination); + 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); +} + +bool IsTileNavigableForSpeech(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 (leveltype == DTYPE_TOWN) { + // In town, treat NPCs as blocking. + if (dMonster[position.x][position.y] != 0) + return false; + } + + return true; +} + +std::optional> FindKeyboardWalkPathForSpeech(const Player &player, Point startPosition, Point destinationPosition) +{ + 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; + + if (!IsTileNavigableForSpeech(player, next)) + return; + if (!CanStep(current, next)) + 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)]; + }; + + constexpr std::array WalkDirections = { + WALK_NE, + WALK_SW, + WALK_SE, + WALK_NW, + }; + + 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 (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; +} + +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"); + 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")); +} + +std::optional FindNearestUnexploredTile(Point startPosition) +{ + if (!InDungeonBounds(startPosition)) + return std::nullopt; + + std::array visited {}; + std::queue queue; + + const auto enqueue = [&](Point position) { + if (!InDungeonBounds(position)) + return; + + const size_t index = static_cast(position.x) + static_cast(position.y) * MAXDUNX; + if (visited[index]) + return; + + if (!IsTileWalkable(position, /*ignoreDoors=*/true)) + return; + + visited[index] = true; + queue.push(position); + }; + + enqueue(startPosition); + + constexpr std::array Neighbors = { + Direction::NorthEast, + Direction::SouthWest, + Direction::SouthEast, + Direction::NorthWest, + }; + + while (!queue.empty()) { + const Point position = queue.front(); + queue.pop(); + + if (!HasAnyOf(dFlags[position.x][position.y], DungeonFlag::Explored)) + return position; + + for (const Direction dir : Neighbors) { + enqueue(position + dir); + } + } + + return std::nullopt; +} + +std::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 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; +} + +void SpeakNearestExitKeyPressed() +{ + if (!CanPlayerTakeAction()) + return; + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const Point startPosition = MyPlayer->position.future; + + 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 SpeakNearestStairsKeyPressed(int triggerMessage) +{ + if (!CanPlayerTakeAction()) + return; + if (AutomapActive) { + SpeakText(_("Close the map first."), true); + return; + } + if (leveltype == DTYPE_TOWN) { + SpeakText(_("Not in a dungeon."), true); + return; + } + if (MyPlayer == nullptr) + return; + + const std::optional triggerIndex = FindNearestTriggerIndexWithMessage(triggerMessage); + if (!triggerIndex) { + SpeakText(_("No exits found."), true); + return; + } + + const TriggerStruct &trigger = trigs[*triggerIndex]; + const Point startPosition = MyPlayer->position.future; + const Point targetPosition { trigger.position.x, trigger.position.y }; + + std::string message; + const std::optional> path = FindKeyboardWalkPathForSpeech(*MyPlayer, startPosition, targetPosition); + if (!path) { + AppendDirectionalFallback(message, targetPosition - startPosition); + } else { + AppendKeyboardWalkPathForSpeech(message, *path); + } + + SpeakText(message, true); +} + +void 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) +{ + 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); +} + +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 InventoryKeyPressed() +{ + if (IsPlayerInStore()) + return; + invflag = !invflag; if (!IsLeftPanelOpen() && CanPanelsCoverView()) { if (!invflag) { // We closed the inventory if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { @@ -1643,11 +3256,13 @@ void InventoryKeyPressed() SetCursorPos(MousePosition - Displacement { 160, 0 }); } } - } - SpellbookFlag = false; - CloseGoldWithdraw(); - CloseStash(); -} + } + SpellbookFlag = false; + CloseGoldWithdraw(); + CloseStash(); + if (invflag) + FocusOnInventory(); +} void CharacterSheetKeyPressed() { @@ -1693,35 +3308,55 @@ void QuestLogKeyPressed() } } CloseCharPanel(); - CloseGoldWithdraw(); - CloseStash(); -} - -void DisplaySpellsKeyPressed() -{ - if (IsPlayerInStore()) - return; - CloseCharPanel(); + CloseGoldWithdraw(); + CloseStash(); +} + +void SpeakSelectedSpeedbookSpell() +{ + for (const auto &spellListItem : GetSpellListItems()) { + if (spellListItem.isSelected) { + SpeakText(pgettext("spell", GetSpellData(spellListItem.id).sNameText), /*force=*/true); + return; + } + } + SpeakText(_("No spell selected."), /*force=*/true); +} + +void DisplaySpellsKeyPressed() +{ + if (IsPlayerInStore()) + return; + CloseCharPanel(); QuestLogIsOpen = false; - CloseInventory(); - SpellbookFlag = false; - if (!SpellSelectFlag) { - DoSpeedBook(); - } else { - SpellSelectFlag = false; - } - LastPlayerAction = PlayerActionType::None; -} + CloseInventory(); + SpellbookFlag = false; + if (!SpellSelectFlag) { + DoSpeedBook(); + SpeakSelectedSpeedbookSpell(); + } else { + SpellSelectFlag = false; + } + LastPlayerAction = PlayerActionType::None; +} void SpellBookKeyPressed() { - if (IsPlayerInStore()) - return; - SpellbookFlag = !SpellbookFlag; - if (!IsLeftPanelOpen() && CanPanelsCoverView()) { - if (!SpellbookFlag) { // We closed the inventory - if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { - SetCursorPos(MousePosition + Displacement { 160, 0 }); + if (IsPlayerInStore()) + return; + SpellbookFlag = !SpellbookFlag; + if (SpellbookFlag && MyPlayer != nullptr) { + const Player &player = *MyPlayer; + if (IsValidSpell(player._pRSpell)) { + SpeakText(pgettext("spell", GetSpellData(player._pRSpell).sNameText), /*force=*/true); + } else { + SpeakText(_("No spell selected."), /*force=*/true); + } + } + if (!IsLeftPanelOpen() && CanPanelsCoverView()) { + if (!SpellbookFlag) { // We closed the inventory + if (MousePosition.x < 480 && MousePosition.y < GetMainPanel().position.y) { + SetCursorPos(MousePosition + Displacement { 160, 0 }); } } else if (!invflag) { // We opened the inventory if (MousePosition.x > 160 && MousePosition.y < GetMainPanel().position.y) { @@ -1934,20 +3569,157 @@ void InitKeymapActions() DoAutoMap, nullptr, IsGameRunning); - options.Keymapper.AddAction( - "CycleAutomapType", - N_("Cycle map type"), - N_("Opaque -> Transparent -> Minimap -> None"), - SDLK_M, - CycleAutomapType, - nullptr, - IsGameRunning); - - options.Keymapper.AddAction( - "Inventory", - N_("Inventory"), - N_("Open Inventory screen."), - 'I', + options.Keymapper.AddAction( + "CycleAutomapType", + N_("Cycle map type"), + N_("Opaque -> Transparent -> Minimap -> None"), + SDLK_M, + CycleAutomapType, + nullptr, + IsGameRunning); + + options.Keymapper.AddAction( + "ListTownNpcs", + N_("List town NPCs"), + N_("Speaks a list of town NPCs."), + SDLK_F4, + ListTownNpcsKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "PreviousTownNpc", + N_("Previous town NPC"), + N_("Select previous town NPC (speaks)."), + SDLK_PAGEUP, + SelectPreviousTownNpcKeyPressed, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "NextTownNpc", + N_("Next town NPC"), + N_("Select next town NPC (speaks)."), + SDLK_PAGEDOWN, + SelectNextTownNpcKeyPressed, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "SpeakSelectedTownNpc", + N_("Speak selected town NPC"), + N_("Speaks the currently selected town NPC."), + SDLK_END, + SpeakSelectedTownNpc, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "GoToSelectedTownNpc", + N_("Go to selected town NPC"), + N_("Walks to the selected town NPC."), + SDLK_HOME, + GoToSelectedTownNpcKeyPressed, + nullptr, + IsTownNpcActionAllowed); + options.Keymapper.AddAction( + "SpeakNearestUnexploredSpace", + N_("Nearest unexplored space"), + N_("Speaks the nearest unexplored space."), + 'H', + SpeakNearestUnexploredTileKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SpeakNearestExit", + N_("Nearest exit"), + N_("Speaks the nearest exit."), + 'E', + SpeakNearestExitKeyPressed, + nullptr, + CanPlayerTakeAction); + options.Keymapper.AddAction( + "SpeakNearestStairsDown", + N_("Nearest stairs down"), + N_("Speaks directions to the nearest stairs down."), + '.', + SpeakNearestStairsDownKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && leveltype != DTYPE_TOWN; }); + options.Keymapper.AddAction( + "SpeakNearestStairsUp", + N_("Nearest stairs up"), + N_("Speaks directions to the nearest stairs up."), + ',', + SpeakNearestStairsUpKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && leveltype != DTYPE_TOWN; }); + options.Keymapper.AddAction( + "CycleTrackerTarget", + N_("Cycle tracker target"), + N_("Cycles what the tracker looks for (items, chests, monsters)."), + 'T', + CycleTrackerTargetKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "NavigateToTrackerTarget", + N_("Tracker directions"), + N_("Speaks directions to a tracked target of the selected tracker category. Hold Shift to retarget; hold Ctrl to clear."), + 'N', + NavigateToTrackerTargetKeyPressed, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "KeyboardWalkNorth", + N_("Walk north"), + N_("Walk north (one tile)."), + SDLK_UP, + KeyboardWalkNorthKeyPressed); + options.Keymapper.AddAction( + "KeyboardWalkSouth", + N_("Walk south"), + N_("Walk south (one tile)."), + SDLK_DOWN, + KeyboardWalkSouthKeyPressed); + options.Keymapper.AddAction( + "KeyboardWalkEast", + N_("Walk east"), + N_("Walk east (one tile)."), + SDLK_RIGHT, + KeyboardWalkEastKeyPressed); + options.Keymapper.AddAction( + "KeyboardWalkWest", + N_("Walk west"), + N_("Walk west (one tile)."), + SDLK_LEFT, + KeyboardWalkWestKeyPressed); + options.Keymapper.AddAction( + "PrimaryAction", + N_("Primary action"), + N_("Attack monsters, talk to towners, lift and place inventory items."), + 'A', + PerformPrimaryActionAutoTarget, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "SecondaryAction", + N_("Secondary action"), + N_("Open chests, interact with doors, pick up items."), + 'D', + PerformSecondaryActionAutoTarget, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + options.Keymapper.AddAction( + "SpellAction", + N_("Spell action"), + N_("Cast the active spell."), + 'W', + PerformSpellActionAutoTarget, + nullptr, + []() { return CanPlayerTakeAction() && !InGameMenu(); }); + + options.Keymapper.AddAction( + "Inventory", + N_("Inventory"), + N_("Open Inventory screen."), + 'I', InventoryKeyPressed, nullptr, CanPlayerTakeAction); diff --git a/Source/gmenu.cpp b/Source/gmenu.cpp index 6e1c5230d..15dc27d87 100644 --- a/Source/gmenu.cpp +++ b/Source/gmenu.cpp @@ -30,15 +30,17 @@ #include "engine/render/primitive_render.hpp" #include "engine/render/text_render.hpp" #include "headless_mode.hpp" -#include "options.h" -#include "stores.h" -#include "utils/language.h" -#include "utils/sdl_compat.h" -#include "utils/ui_fwd.h" - -namespace devilution { - -namespace { +#include "options.h" +#include "stores.h" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/sdl_compat.h" +#include "utils/ui_fwd.h" +#include "utils/str_cat.hpp" + +namespace devilution { + +namespace { // Width of the slider menu item, including the label. constexpr int SliderItemWidth = 490; @@ -68,15 +70,32 @@ bool isDraggingSlider; TMenuItem *sgpCurrItem; int LogoAnim_tick; uint8_t LogoAnim_frame; -void (*gmenu_current_option)(); -int sgCurrentMenuIdx; - -void GmenuUpDown(bool isDown) -{ - if (sgpCurrItem == nullptr) { - return; - } - isDraggingSlider = false; +void (*gmenu_current_option)(); +int sgCurrentMenuIdx; + +void AnnounceCurrentMenuItem(bool force = false) +{ + if (sgpCurrItem == nullptr || sgpCurrItem->pszStr == nullptr) + return; + + const std::string_view label = _(sgpCurrItem->pszStr); + if (!sgpCurrItem->isSlider()) { + SpeakText(label, force); + return; + } + + const uint16_t steps = std::max(sgpCurrItem->sliderSteps(), 2); + const uint16_t step = std::min(sgpCurrItem->sliderStep(), steps); + const int percent = (step * 100 + steps / 2) / steps; + SpeakText(StrCat(label, ": ", percent, "%"), force); +} + +void GmenuUpDown(bool isDown, bool announce = true) +{ + if (sgpCurrItem == nullptr) { + return; + } + isDraggingSlider = false; int i = sgCurrentMenuIdx; if (sgCurrentMenuIdx != 0) { while (i != 0) { @@ -89,22 +108,24 @@ void GmenuUpDown(bool isDown) if (sgpCurrItem == sgpCurrentMenu) sgpCurrItem = &sgpCurrentMenu[sgCurrentMenuIdx]; sgpCurrItem--; - } - if (sgpCurrItem->enabled()) { - if (i != 0) - PlaySFX(SfxID::MenuMove); - return; - } - } - } -} - -void GmenuLeftRight(bool isRight) -{ - if (!sgpCurrItem->isSlider()) - return; - - uint16_t step = sgpCurrItem->sliderStep(); + } + if (sgpCurrItem->enabled()) { + if (i != 0) + PlaySFX(SfxID::MenuMove); + if (announce) + AnnounceCurrentMenuItem(); + return; + } + } + } +} + +void GmenuLeftRight(bool isRight, bool announce = true) +{ + if (!sgpCurrItem->isSlider()) + return; + + uint16_t step = sgpCurrItem->sliderStep(); if (isRight) { if (step == sgpCurrItem->sliderSteps()) return; @@ -113,14 +134,16 @@ void GmenuLeftRight(bool isRight) if (step == 0) return; step--; - } - sgpCurrItem->setSliderStep(step); - sgpCurrItem->fnMenu(false); -} - -int GmenuGetLineWidth(TMenuItem *pItem) -{ - if (pItem->isSlider()) + } + sgpCurrItem->setSliderStep(step); + sgpCurrItem->fnMenu(false); + if (announce) + AnnounceCurrentMenuItem(); +} + +int GmenuGetLineWidth(TMenuItem *pItem) +{ + if (pItem->isSlider()) return SliderItemWidth; return GetLineWidth(_(pItem->pszStr), GameFont46, 2); @@ -225,10 +248,10 @@ bool gmenu_is_active() return sgpCurrentMenu != nullptr; } -void gmenu_set_items(TMenuItem *pItem, void (*gmFunc)()) -{ - PauseMode = 0; - isDraggingSlider = false; +void gmenu_set_items(TMenuItem *pItem, void (*gmFunc)()) +{ + PauseMode = 0; + isDraggingSlider = false; sgpCurrentMenu = pItem; gmenu_current_option = gmFunc; if (gmenu_current_option != nullptr) { @@ -239,14 +262,16 @@ void gmenu_set_items(TMenuItem *pItem, void (*gmFunc)()) for (int i = 0; sgpCurrentMenu[i].fnMenu != nullptr; i++) { sgCurrentMenuIdx++; } - } - // BUGFIX: OOB access when sgCurrentMenuIdx is 0; should be set to NULL instead. (fixed) - sgpCurrItem = sgCurrentMenuIdx > 0 ? &sgpCurrentMenu[sgCurrentMenuIdx - 1] : nullptr; - GmenuUpDown(true); - if (sgpCurrentMenu == nullptr && !demo::IsRunning()) { - SaveOptions(); - } -} + } + // BUGFIX: OOB access when sgCurrentMenuIdx is 0; should be set to NULL instead. (fixed) + sgpCurrItem = sgCurrentMenuIdx > 0 ? &sgpCurrentMenu[sgCurrentMenuIdx - 1] : nullptr; + GmenuUpDown(true, /*announce=*/false); + if (sgpCurrentMenu != nullptr) + AnnounceCurrentMenuItem(/*force=*/true); + if (sgpCurrentMenu == nullptr && !demo::IsRunning()) { + SaveOptions(); + } +} void gmenu_draw(const Surface &out) { @@ -277,39 +302,40 @@ void gmenu_draw(const Surface &out) } } -bool gmenu_presskeys(SDL_Keycode vkey) -{ - if (sgpCurrentMenu == nullptr) - return false; - switch (vkey) { - case SDLK_KP_ENTER: - case SDLK_RETURN: - if (sgpCurrItem->enabled()) { - PlaySFX(SfxID::MenuMove); - sgpCurrItem->fnMenu(true); - } - break; - case SDLK_ESCAPE: - PlaySFX(SfxID::MenuMove); - gmenu_set_items(nullptr, nullptr); - break; - case SDLK_SPACE: - return false; - case SDLK_LEFT: - GmenuLeftRight(false); - break; - case SDLK_RIGHT: - GmenuLeftRight(true); - break; - case SDLK_UP: - GmenuUpDown(false); - break; - case SDLK_DOWN: - GmenuUpDown(true); +bool gmenu_presskeys(SDL_Keycode vkey) +{ + if (sgpCurrentMenu == nullptr) + return false; + switch (vkey) { + case SDLK_KP_ENTER: + case SDLK_RETURN: + if (sgpCurrItem->enabled()) { + PlaySFX(SfxID::MenuMove); + sgpCurrItem->fnMenu(true); + AnnounceCurrentMenuItem(); + } + break; + case SDLK_ESCAPE: + PlaySFX(SfxID::MenuMove); + gmenu_set_items(nullptr, nullptr); break; - default: - break; - } + case SDLK_SPACE: + return false; + case SDLK_LEFT: + GmenuLeftRight(false); + break; + case SDLK_RIGHT: + GmenuLeftRight(true); + break; + case SDLK_UP: + GmenuUpDown(false); + break; + case SDLK_DOWN: + GmenuUpDown(true); + break; + default: + break; + } return true; } @@ -325,15 +351,16 @@ bool gmenu_on_mouse_move() return true; } -bool gmenu_left_mouse(bool isDown) -{ - if (!isDown) { - if (isDraggingSlider) { - isDraggingSlider = false; - return true; - } - return false; - } +bool gmenu_left_mouse(bool isDown) +{ + if (!isDown) { + if (isDraggingSlider) { + isDraggingSlider = false; + AnnounceCurrentMenuItem(); + return true; + } + return false; + } if (sgpCurrentMenu == nullptr) { return false; @@ -362,15 +389,16 @@ bool gmenu_left_mouse(bool isDown) return true; } sgpCurrItem = pItem; - PlaySFX(SfxID::MenuMove); - if (pItem->isSlider()) { - isDraggingSlider = GmenuMouseIsOverSlider(); - gmenu_on_mouse_move(); - } else { - sgpCurrItem->fnMenu(true); - } - return true; -} + PlaySFX(SfxID::MenuMove); + if (pItem->isSlider()) { + isDraggingSlider = GmenuMouseIsOverSlider(); + gmenu_on_mouse_move(); + } else { + sgpCurrItem->fnMenu(true); + } + AnnounceCurrentMenuItem(); + return true; +} void gmenu_slider_set(TMenuItem *pItem, int min, int max, int value) { diff --git a/Source/inv.cpp b/Source/inv.cpp index fc97994c9..05be5b81f 100644 --- a/Source/inv.cpp +++ b/Source/inv.cpp @@ -26,14 +26,15 @@ #include "engine/clx_sprite.hpp" #include "engine/load_cel.hpp" #include "engine/palette.h" -#include "engine/render/clx_render.hpp" -#include "engine/render/text_render.hpp" -#include "engine/size.hpp" -#include "hwcursor.hpp" -#include "inv_iterators.hpp" -#include "levels/tile_properties.hpp" -#include "levels/town.h" -#include "minitext.h" +#include "engine/render/clx_render.hpp" +#include "engine/render/text_render.hpp" +#include "engine/size.hpp" +#include "engine/sound.h" +#include "hwcursor.hpp" +#include "inv_iterators.hpp" +#include "levels/tile_properties.hpp" +#include "levels/town.h" +#include "minitext.h" #include "options.h" #include "panels/ui_panels.hpp" #include "player.h" @@ -43,15 +44,63 @@ #include "towners.h" #include "utils/display.h" #include "utils/format_int.hpp" -#include "utils/is_of.hpp" -#include "utils/language.h" -#include "utils/sdl_geometry.h" -#include "utils/str_cat.hpp" -#include "utils/utf8.hpp" - -namespace devilution { - -bool invflag; +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/sdl_geometry.h" +#include "utils/str_cat.hpp" +#include "utils/utf8.hpp" + +namespace devilution { + +namespace { + +TSnd *GetAccessibilityPickupSound() +{ +#ifdef NOSOUND + return nullptr; +#else + static std::unique_ptr snd; + static bool attempted = false; + if (attempted) + return snd.get(); + attempted = true; + + snd = std::make_unique(); + snd->start_tc = SDL_GetTicks() - 80 - 1; + if (snd->DSB.SetChunkStream("audio\\player_pickedup_item.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && snd->DSB.SetChunkStream("..\\audio\\player_pickedup_item.ogg", /*isMp3=*/false, /*logErrors=*/false) != 0 + && snd->DSB.SetChunkStream("audio\\player_pickedup_item.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && snd->DSB.SetChunkStream("..\\audio\\player_pickedup_item.mp3", /*isMp3=*/true, /*logErrors=*/false) != 0 + && snd->DSB.SetChunkStream("audio\\player_pickedup_item.wav", /*isMp3=*/false, /*logErrors=*/false) != 0 + && snd->DSB.SetChunkStream("..\\audio\\player_pickedup_item.wav", /*isMp3=*/false, /*logErrors=*/false) != 0) { + snd = nullptr; + } + + return snd.get(); +#endif +} + +void PlayAccessibilityPickupFeedback() +{ + if (!gbSndInited || !gbSoundOn) + return; + + TSnd *snd = GetAccessibilityPickupSound(); + if (snd == nullptr) + return; + + snd_play_snd(snd, /*lVolume=*/0, /*lPan=*/0); +} + +void AnnouncePickedUpItem(const Item &item) +{ + SpeakText(item.getName(), /*force=*/true); +} + +} // namespace + +bool invflag; /** * Maps from inventory slot to screen position. The inventory slots are @@ -1669,27 +1718,33 @@ void InvGetItem(Player &player, int ii) if (dItem[item.position.x][item.position.y] == 0) return; - item._iCreateInfo &= ~CF_PREGEN; - CheckQuestItem(player, item); - item.updateRequiredStatsCacheForPlayer(player); - - if (item._itype == ItemType::Gold && GoldAutoPlace(player, item)) { - if (MyPlayer == &player) { - // Non-gold items (or gold when you have a full inventory) go to the hand then provide audible feedback on - // paste. To give the same feedback for auto-placed gold we play the sound effect now. - PlaySFX(SfxID::ItemGold); - } - } else { - // The item needs to go into the players hand - if (MyPlayer == &player && !player.HoldItem.isEmpty()) { - // drop whatever the player is currently holding - NetSendCmdPItem(true, CMD_SYNCPUTITEM, player.position.tile, player.HoldItem); - } - - // need to copy here instead of move so CleanupItems still has access to the position - player.HoldItem = item; - NewCursor(player.HoldItem); - } + item._iCreateInfo &= ~CF_PREGEN; + CheckQuestItem(player, item); + item.updateRequiredStatsCacheForPlayer(player); + + if (item._itype == ItemType::Gold && GoldAutoPlace(player, item)) { + if (MyPlayer == &player) { + // Non-gold items (or gold when you have a full inventory) go to the hand then provide audible feedback on + // paste. To give the same feedback for auto-placed gold we play the sound effect now. + PlaySFX(SfxID::ItemGold); + PlayAccessibilityPickupFeedback(); + AnnouncePickedUpItem(item); + } + } else { + // The item needs to go into the players hand + if (MyPlayer == &player && !player.HoldItem.isEmpty()) { + // drop whatever the player is currently holding + NetSendCmdPItem(true, CMD_SYNCPUTITEM, player.position.tile, player.HoldItem); + } + + // need to copy here instead of move so CleanupItems still has access to the position + player.HoldItem = item; + NewCursor(player.HoldItem); + if (MyPlayer == &player) { + PlayAccessibilityPickupFeedback(); + AnnouncePickedUpItem(item); + } + } // This potentially moves items in memory so must be done after we've made a copy CleanupItems(ii); @@ -1766,12 +1821,16 @@ void AutoGetItem(Player &player, Item *itemPointer, int ii) } } - if (done) { - if (!autoEquipped && *GetOptions().Audio.itemPickupSound && &player == MyPlayer) { - PlaySFX(SfxID::GrabItem); - } - - CleanupItems(ii); + if (done) { + if (&player == MyPlayer) { + PlayAccessibilityPickupFeedback(); + AnnouncePickedUpItem(item); + } + if (!autoEquipped && *GetOptions().Audio.itemPickupSound && &player == MyPlayer) { + PlaySFX(SfxID::GrabItem); + } + + CleanupItems(ii); return; } diff --git a/Source/minitext.cpp b/Source/minitext.cpp index 075279377..bbeccd06e 100644 --- a/Source/minitext.cpp +++ b/Source/minitext.cpp @@ -16,11 +16,12 @@ #include "engine/load_cel.hpp" #include "engine/render/clx_render.hpp" #include "engine/render/primitive_render.hpp" -#include "engine/render/text_render.hpp" -#include "tables/playerdat.hpp" -#include "tables/textdat.h" -#include "utils/language.h" -#include "utils/timer.hpp" +#include "engine/render/text_render.hpp" +#include "tables/playerdat.hpp" +#include "tables/textdat.h" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/timer.hpp" namespace devilution { @@ -161,13 +162,15 @@ void InitQTextMsg(_speech_id m) default: break; } - if (Speeches[m].scrlltxt) { - QuestLogIsOpen = false; - LoadText(_(Speeches[m].txtstr)); - qtextflag = true; - qtextSpd = CalculateTextSpeed(sfxnr); - ScrollStart = GetMillisecondsSinceStartup(); - } + if (Speeches[m].scrlltxt) { + QuestLogIsOpen = false; + const std::string_view text = _(Speeches[m].txtstr); + LoadText(text); + SpeakText(text, /*force=*/true); + qtextflag = true; + qtextSpd = CalculateTextSpeed(sfxnr); + ScrollStart = GetMillisecondsSinceStartup(); + } PlaySFX(sfxnr); } diff --git a/Source/options.cpp b/Source/options.cpp index 65ae9a892..2081209d4 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -1128,13 +1128,19 @@ KeymapperOptions::KeymapperOptions() keyIDToKeyName.emplace(SDLK_PERIOD, "."); keyIDToKeyName.emplace(SDLK_SLASH, "/"); - keyIDToKeyName.emplace(SDLK_BACKSPACE, "BACKSPACE"); - keyIDToKeyName.emplace(SDLK_CAPSLOCK, "CAPSLOCK"); - keyIDToKeyName.emplace(SDLK_SCROLLLOCK, "SCROLLLOCK"); - keyIDToKeyName.emplace(SDLK_INSERT, "INSERT"); - keyIDToKeyName.emplace(SDLK_DELETE, "DELETE"); - keyIDToKeyName.emplace(SDLK_HOME, "HOME"); - keyIDToKeyName.emplace(SDLK_END, "END"); + keyIDToKeyName.emplace(SDLK_BACKSPACE, "BACKSPACE"); + keyIDToKeyName.emplace(SDLK_CAPSLOCK, "CAPSLOCK"); + keyIDToKeyName.emplace(SDLK_SCROLLLOCK, "SCROLLLOCK"); + keyIDToKeyName.emplace(SDLK_INSERT, "INSERT"); + keyIDToKeyName.emplace(SDLK_DELETE, "DELETE"); + keyIDToKeyName.emplace(SDLK_HOME, "HOME"); + keyIDToKeyName.emplace(SDLK_END, "END"); + keyIDToKeyName.emplace(SDLK_PAGEUP, "PAGEUP"); + keyIDToKeyName.emplace(SDLK_PAGEDOWN, "PAGEDOWN"); + keyIDToKeyName.emplace(SDLK_UP, "UP"); + keyIDToKeyName.emplace(SDLK_DOWN, "DOWN"); + keyIDToKeyName.emplace(SDLK_LEFT, "LEFT"); + keyIDToKeyName.emplace(SDLK_RIGHT, "RIGHT"); keyIDToKeyName.emplace(SDLK_KP_DIVIDE, "KEYPAD /"); keyIDToKeyName.emplace(SDLK_KP_MULTIPLY, "KEYPAD *"); @@ -1178,19 +1184,27 @@ std::string_view KeymapperOptions::Action::GetName() const return dynamicName; } -void KeymapperOptions::Action::LoadFromIni(std::string_view category) -{ - const std::span iniValues = ini->get(category, key); - if (iniValues.empty()) { - SetValue(defaultKey); - return; // Use the default key if no key has been set. - } - - const std::string_view iniValue = iniValues.back().value; - if (iniValue.empty()) { - SetValue(SDLK_UNKNOWN); - return; - } +void KeymapperOptions::Action::LoadFromIni(std::string_view category) +{ + const std::span iniValues = ini->get(category, key); + if (iniValues.empty()) { + SetValue(defaultKey); + return; // Use the default key if no key has been set. + } + + const std::string_view iniValue = iniValues.back().value; + if (iniValue.empty()) { + // Migration: some actions were previously saved as unbound because their default + // keys were not supported by the keymapper. If we see an explicit empty mapping + // for these actions, treat it as "use default". + if (IsAnyOf(key, "PreviousTownNpc", "NextTownNpc", "KeyboardWalkNorth", "KeyboardWalkSouth", "KeyboardWalkEast", "KeyboardWalkWest")) { + SetValue(defaultKey); + return; + } + + SetValue(SDLK_UNKNOWN); + return; + } auto keyIt = GetOptions().Keymapper.keyNameToKeyID.find(iniValue); if (keyIt == GetOptions().Keymapper.keyNameToKeyID.end()) { @@ -1219,16 +1233,16 @@ void KeymapperOptions::Action::SaveToIni(std::string_view category) const ini->set(category, key, keyNameIt->second); } -std::string_view KeymapperOptions::Action::GetValueDescription() const -{ - if (boundKey == SDLK_UNKNOWN) - return ""; - auto keyNameIt = GetOptions().Keymapper.keyIDToKeyName.find(boundKey); - if (keyNameIt == GetOptions().Keymapper.keyIDToKeyName.end()) { - return ""; - } - return keyNameIt->second; -} +std::string_view KeymapperOptions::Action::GetValueDescription() const +{ + if (boundKey == SDLK_UNKNOWN) + return _("Unbound"); + auto keyNameIt = GetOptions().Keymapper.keyIDToKeyName.find(boundKey); + if (keyNameIt == GetOptions().Keymapper.keyIDToKeyName.end()) { + return ""; + } + return keyNameIt->second; +} bool KeymapperOptions::Action::SetValue(int value) { diff --git a/Source/panels/charpanel.cpp b/Source/panels/charpanel.cpp index d7d676746..dcf267eba 100644 --- a/Source/panels/charpanel.cpp +++ b/Source/panels/charpanel.cpp @@ -12,29 +12,75 @@ #include "control/control.hpp" #include "engine/load_clx.hpp" #include "engine/render/clx_render.hpp" -#include "engine/render/text_render.hpp" -#include "panels/ui_panels.hpp" -#include "player.h" -#include "tables/playerdat.hpp" -#include "utils/algorithm/container.hpp" -#include "utils/display.h" -#include "utils/enum_traits.h" +#include "engine/render/text_render.hpp" +#include "panels/ui_panels.hpp" +#include "msg.h" +#include "player.h" +#include "tables/playerdat.hpp" +#include "utils/algorithm/container.hpp" +#include "utils/display.h" +#include "utils/enum_traits.h" #include "utils/format_int.hpp" #include "utils/language.h" -#include "utils/status_macros.hpp" -#include "utils/str_cat.hpp" -#include "utils/surface_to_clx.hpp" - -namespace devilution { - -OptionalOwnedClxSpriteList pChrButtons; - -namespace { - -struct StyledText { - UiFlags style; - std::string text; - int spacing = 1; +#include "utils/status_macros.hpp" +#include "utils/str_cat.hpp" +#include "utils/screen_reader.hpp" +#include "utils/surface_to_clx.hpp" + +namespace devilution { + +OptionalOwnedClxSpriteList pChrButtons; + +namespace { + +enum class CharacterScreenField : uint8_t { + NameAndClass, + Level, + Experience, + NextLevel, + Strength, + Magic, + Dexterity, + Vitality, + PointsToDistribute, + Gold, + ArmorClass, + ChanceToHit, + Damage, + Life, + Mana, + ResistMagic, + ResistFire, + ResistLightning, +}; + +constexpr std::array CharacterScreenFieldOrder = { + CharacterScreenField::NameAndClass, + CharacterScreenField::Level, + CharacterScreenField::Experience, + CharacterScreenField::NextLevel, + CharacterScreenField::Strength, + CharacterScreenField::Magic, + CharacterScreenField::Dexterity, + CharacterScreenField::Vitality, + CharacterScreenField::PointsToDistribute, + CharacterScreenField::Gold, + CharacterScreenField::ArmorClass, + CharacterScreenField::ChanceToHit, + CharacterScreenField::Damage, + CharacterScreenField::Life, + CharacterScreenField::Mana, + CharacterScreenField::ResistMagic, + CharacterScreenField::ResistFire, + CharacterScreenField::ResistLightning, +}; + +size_t SelectedCharacterScreenFieldIndex = 0; + +struct StyledText { + UiFlags style; + std::string text; + int spacing = 1; }; struct PanelEntry { @@ -203,13 +249,138 @@ OptionalOwnedClxSpriteList Panel; constexpr int PanelFieldHeight = 24; constexpr int PanelFieldPaddingTop = 3; constexpr int PanelFieldPaddingBottom = 3; -constexpr int PanelFieldPaddingSide = 5; -constexpr int PanelFieldInnerHeight = PanelFieldHeight - PanelFieldPaddingTop - PanelFieldPaddingBottom; - -void DrawPanelField(const Surface &out, Point pos, int len, ClxSprite left, ClxSprite middle, ClxSprite right) -{ - RenderClxSprite(out, left, pos); - pos.x += left.width(); +constexpr int PanelFieldPaddingSide = 5; +constexpr int PanelFieldInnerHeight = PanelFieldHeight - PanelFieldPaddingTop - PanelFieldPaddingBottom; + +[[nodiscard]] std::string GetEntryValue(const PanelEntry &entry) +{ + if (!entry.statDisplayFunc) + return {}; + const StyledText tmp = (*entry.statDisplayFunc)(); + return tmp.text; +} + +[[nodiscard]] std::string_view GetBaseLabelForSpeech() +{ + return LanguageTranslate(panelEntries[AttributeHeaderEntryIndices[0]].label); +} + +[[nodiscard]] std::string_view GetNowLabelForSpeech() +{ + return LanguageTranslate(panelEntries[AttributeHeaderEntryIndices[1]].label); +} + +[[nodiscard]] std::optional AttributeForField(CharacterScreenField field) +{ + switch (field) { + case CharacterScreenField::Strength: + return CharacterAttribute::Strength; + case CharacterScreenField::Magic: + return CharacterAttribute::Magic; + case CharacterScreenField::Dexterity: + return CharacterAttribute::Dexterity; + case CharacterScreenField::Vitality: + return CharacterAttribute::Vitality; + default: + return std::nullopt; + } +} + +[[nodiscard]] std::string_view AttributeLabelForSpeech(CharacterAttribute attribute) +{ + switch (attribute) { + case CharacterAttribute::Strength: + return LanguageTranslate(panelEntries[7].label); + case CharacterAttribute::Magic: + return LanguageTranslate(panelEntries[9].label); + case CharacterAttribute::Dexterity: + return LanguageTranslate(panelEntries[11].label); + case CharacterAttribute::Vitality: + return LanguageTranslate(panelEntries[13].label); + default: + return {}; + } +} + +[[nodiscard]] int PointsToDistributeForSpeech() +{ + if (InspectPlayer == nullptr) + return 0; + return std::min(CalcStatDiff(*InspectPlayer), InspectPlayer->_pStatPts); +} + +[[nodiscard]] std::string GetCharacterScreenFieldText(CharacterScreenField field) +{ + if (InspectPlayer == nullptr) + return {}; + + switch (field) { + case CharacterScreenField::NameAndClass: { + const std::string name = GetEntryValue(panelEntries[0]); + const std::string className = GetEntryValue(panelEntries[1]); + if (name.empty()) + return className; + if (className.empty()) + return name; + return StrCat(name, ", ", className); + } + case CharacterScreenField::Level: + return StrCat(LanguageTranslate(panelEntries[2].label), ": ", GetEntryValue(panelEntries[2])); + case CharacterScreenField::Experience: + return StrCat(LanguageTranslate(panelEntries[3].label), ": ", GetEntryValue(panelEntries[3])); + case CharacterScreenField::NextLevel: + return StrCat(LanguageTranslate(panelEntries[4].label), ": ", GetEntryValue(panelEntries[4])); + case CharacterScreenField::Strength: + return StrCat(LanguageTranslate(panelEntries[7].label), ": ", GetBaseLabelForSpeech(), " ", GetEntryValue(panelEntries[7]), ", ", GetNowLabelForSpeech(), " ", GetEntryValue(panelEntries[8])); + case CharacterScreenField::Magic: + return StrCat(LanguageTranslate(panelEntries[9].label), ": ", GetBaseLabelForSpeech(), " ", GetEntryValue(panelEntries[9]), ", ", GetNowLabelForSpeech(), " ", GetEntryValue(panelEntries[10])); + case CharacterScreenField::Dexterity: + return StrCat(LanguageTranslate(panelEntries[11].label), ": ", GetBaseLabelForSpeech(), " ", GetEntryValue(panelEntries[11]), ", ", GetNowLabelForSpeech(), " ", GetEntryValue(panelEntries[12])); + case CharacterScreenField::Vitality: + return StrCat(LanguageTranslate(panelEntries[13].label), ": ", GetBaseLabelForSpeech(), " ", GetEntryValue(panelEntries[13]), ", ", GetNowLabelForSpeech(), " ", GetEntryValue(panelEntries[14])); + case CharacterScreenField::PointsToDistribute: + return StrCat(LanguageTranslate(panelEntries[15].label), ": ", PointsToDistributeForSpeech()); + case CharacterScreenField::Gold: + return StrCat(LanguageTranslate(panelEntries[16].label), ": ", GetEntryValue(panelEntries[17])); + case CharacterScreenField::ArmorClass: + return StrCat(LanguageTranslate(panelEntries[18].label), ": ", GetEntryValue(panelEntries[18])); + case CharacterScreenField::ChanceToHit: + return StrCat(LanguageTranslate(panelEntries[19].label), ": ", GetEntryValue(panelEntries[19])); + case CharacterScreenField::Damage: + return StrCat(LanguageTranslate(panelEntries[20].label), ": ", GetEntryValue(panelEntries[20])); + case CharacterScreenField::Life: { + const std::string maxValue = GetEntryValue(panelEntries[21]); + const std::string currentValue = GetEntryValue(panelEntries[22]); + return StrCat(LanguageTranslate(panelEntries[21].label), ": ", currentValue, "/", maxValue); + } + case CharacterScreenField::Mana: { + const std::string maxValue = GetEntryValue(panelEntries[23]); + const std::string currentValue = GetEntryValue(panelEntries[24]); + return StrCat(LanguageTranslate(panelEntries[23].label), ": ", currentValue, "/", maxValue); + } + case CharacterScreenField::ResistMagic: + return StrCat(LanguageTranslate(panelEntries[25].label), ": ", GetEntryValue(panelEntries[25])); + case CharacterScreenField::ResistFire: + return StrCat(LanguageTranslate(panelEntries[26].label), ": ", GetEntryValue(panelEntries[26])); + case CharacterScreenField::ResistLightning: + return StrCat(LanguageTranslate(panelEntries[27].label), ": ", GetEntryValue(panelEntries[27])); + default: + return {}; + } +} + +void SpeakCurrentCharacterScreenField() +{ + const CharacterScreenField field = CharacterScreenFieldOrder[SelectedCharacterScreenFieldIndex]; + const std::string text = GetCharacterScreenFieldText(field); + if (!text.empty()) + SpeakText(text, true); +} + +void DrawPanelField(const Surface &out, Point pos, int len, ClxSprite left, ClxSprite middle, ClxSprite right) +{ + RenderClxSprite(out, left, pos); + pos.x += left.width(); len -= left.width() + right.width(); RenderClxSprite(out.subregion(pos.x, pos.y, len, middle.height()), middle, Point { 0, 0 }); pos.x += len; @@ -306,10 +477,10 @@ void FreeCharPanel() Panel = std::nullopt; } -void DrawChr(const Surface &out) -{ - const Point pos = GetPanelPosition(UiPanels::Character, { 0, 0 }); - RenderClxSprite(out, (*Panel)[0], pos); +void DrawChr(const Surface &out) +{ + const Point pos = GetPanelPosition(UiPanels::Character, { 0, 0 }); + RenderClxSprite(out, (*Panel)[0], pos); for (auto &entry : panelEntries) { if (entry.statDisplayFunc) { const StyledText tmp = (*entry.statDisplayFunc)(); @@ -320,7 +491,123 @@ void DrawChr(const Surface &out) { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter | UiFlags::VerticalCenter | tmp.style }); } } - DrawStatButtons(out); -} - -} // namespace devilution + DrawStatButtons(out); +} + +void InitCharacterScreenSpeech() +{ + if (InspectPlayer == nullptr) { + SpeakText(_("No player."), true); + return; + } + + SelectedCharacterScreenFieldIndex = 0; + if (!IsInspectingPlayer() && PointsToDistributeForSpeech() > 0) { + for (auto attribute : enum_values()) { + if (InspectPlayer->GetBaseAttributeValue(attribute) >= InspectPlayer->GetMaximumAttributeValue(attribute)) + continue; + switch (attribute) { + case CharacterAttribute::Strength: + SelectedCharacterScreenFieldIndex = 4; + break; + case CharacterAttribute::Magic: + SelectedCharacterScreenFieldIndex = 5; + break; + case CharacterAttribute::Dexterity: + SelectedCharacterScreenFieldIndex = 6; + break; + case CharacterAttribute::Vitality: + SelectedCharacterScreenFieldIndex = 7; + break; + } + break; + } + } + + SpeakCurrentCharacterScreenField(); +} + +void CharacterScreenMoveSelection(int delta) +{ + if (CharFlag == false) + return; + + const int size = static_cast(CharacterScreenFieldOrder.size()); + int idx = static_cast(SelectedCharacterScreenFieldIndex); + idx = (idx + delta) % size; + if (idx < 0) + idx += size; + + SelectedCharacterScreenFieldIndex = static_cast(idx); + SpeakCurrentCharacterScreenField(); +} + +void CharacterScreenActivateSelection(bool addAllStatPoints) +{ + if (!CharFlag) + return; + + if (InspectPlayer == nullptr) + return; + + const CharacterScreenField field = CharacterScreenFieldOrder[SelectedCharacterScreenFieldIndex]; + const std::optional attribute = AttributeForField(field); + if (!attribute) { + SpeakCurrentCharacterScreenField(); + return; + } + + if (IsInspectingPlayer()) { + SpeakText(_("Can't distribute stat points while inspecting players."), true); + return; + } + + Player &player = *InspectPlayer; + const int pointsAvailable = PointsToDistributeForSpeech(); + if (pointsAvailable <= 0) { + SpeakText(_("No stat points to distribute."), true); + return; + } + + const int baseValue = player.GetBaseAttributeValue(*attribute); + const int maxValue = player.GetMaximumAttributeValue(*attribute); + if (baseValue >= maxValue) { + SpeakText(_("Stat is already at maximum."), true); + return; + } + + const int pointsToReachCap = maxValue - baseValue; + const int pointsToAdd = addAllStatPoints ? std::min(pointsAvailable, pointsToReachCap) : 1; + if (pointsToAdd <= 0) + return; + + switch (*attribute) { + case CharacterAttribute::Strength: + NetSendCmdParam1(true, CMD_ADDSTR, pointsToAdd); + player._pStatPts -= pointsToAdd; + break; + case CharacterAttribute::Magic: + NetSendCmdParam1(true, CMD_ADDMAG, pointsToAdd); + player._pStatPts -= pointsToAdd; + break; + case CharacterAttribute::Dexterity: + NetSendCmdParam1(true, CMD_ADDDEX, pointsToAdd); + player._pStatPts -= pointsToAdd; + break; + case CharacterAttribute::Vitality: + NetSendCmdParam1(true, CMD_ADDVIT, pointsToAdd); + player._pStatPts -= pointsToAdd; + break; + } + + const int currentValue = player.GetCurrentAttributeValue(*attribute); + const std::string_view attributeLabel = AttributeLabelForSpeech(*attribute); + const int remaining = PointsToDistributeForSpeech(); + + std::string message; + StrAppend(message, attributeLabel, ": ", GetBaseLabelForSpeech(), " ", baseValue + pointsToAdd, ", ", GetNowLabelForSpeech(), " ", currentValue + pointsToAdd); + StrAppend(message, ". ", LanguageTranslate(panelEntries[15].label), ": ", remaining); + SpeakText(message, true); +} + +} // namespace devilution diff --git a/Source/panels/charpanel.hpp b/Source/panels/charpanel.hpp index 947b6e1ef..4405a844b 100644 --- a/Source/panels/charpanel.hpp +++ b/Source/panels/charpanel.hpp @@ -9,10 +9,13 @@ namespace devilution { -extern OptionalOwnedClxSpriteList pChrButtons; - -void DrawChr(const Surface &); -tl::expected LoadCharPanel(); -void FreeCharPanel(); - -} // namespace devilution +extern OptionalOwnedClxSpriteList pChrButtons; + +void DrawChr(const Surface &); +void InitCharacterScreenSpeech(); +void CharacterScreenMoveSelection(int delta); +void CharacterScreenActivateSelection(bool addAllStatPoints); +tl::expected LoadCharPanel(); +void FreeCharPanel(); + +} // namespace devilution diff --git a/Source/panels/spell_book.cpp b/Source/panels/spell_book.cpp index 532fa7a9b..fe75d3235 100644 --- a/Source/panels/spell_book.cpp +++ b/Source/panels/spell_book.cpp @@ -1,8 +1,10 @@ -#include "panels/spell_book.hpp" - -#include -#include -#include +#include "panels/spell_book.hpp" + +#include +#include +#include +#include +#include #include #include @@ -52,13 +54,13 @@ const SpellID SpellPages[SpellBookPages][SpellBookPageEntries] = { { SpellID::Invalid, SpellID::Invalid, SpellID::Invalid, SpellID::Invalid, SpellID::Invalid, SpellID::Invalid, SpellID::Invalid } }; -SpellID GetSpellFromSpellPage(size_t page, size_t entry) -{ - assert(page <= SpellBookPages && entry <= SpellBookPageEntries); - if (page == 0 && entry == 0) - return GetPlayerStartingLoadoutForClass(InspectPlayer->_pClass).skill; - return SpellPages[page][entry]; -} +SpellID GetSpellFromSpellPage(const Player &player, size_t page, size_t entry) +{ + assert(page <= SpellBookPages && entry <= SpellBookPageEntries); + if (page == 0 && entry == 0) + return GetPlayerStartingLoadoutForClass(player._pClass).skill; + return SpellPages[page][entry]; +} constexpr Size SpellBookDescription { 250, 43 }; constexpr int SpellBookDescriptionPaddingHorizontal = 2; @@ -133,8 +135,8 @@ void FreeSpellBook() spellBookBackground = std::nullopt; } -void DrawSpellBook(const Surface &out) -{ +void DrawSpellBook(const Surface &out) +{ constexpr int SpellBookButtonX = 7; constexpr int SpellBookButtonY = 348; ClxDraw(out, GetPanelPosition(UiPanels::Spell, { 0, 351 }), (*spellBookBackground)[0]); @@ -152,11 +154,11 @@ void DrawSpellBook(const Surface &out) int yp = 12; const int textPaddingTop = 7; - for (size_t pageEntry = 0; pageEntry < SpellBookPageEntries; pageEntry++) { - const SpellID sn = GetSpellFromSpellPage(SpellbookTab, pageEntry); - if (IsValidSpell(sn) && (spl & GetSpellBitmask(sn)) != 0) { - const SpellType st = GetSBookTrans(sn, true); - SetSpellTrans(st); + for (size_t pageEntry = 0; pageEntry < SpellBookPageEntries; pageEntry++) { + const SpellID sn = GetSpellFromSpellPage(player, SpellbookTab, pageEntry); + if (IsValidSpell(sn) && (spl & GetSpellBitmask(sn)) != 0) { + const SpellType st = GetSBookTrans(sn, true); + SetSpellTrans(st); const Point spellCellPosition = GetPanelPosition(UiPanels::Spell, { 11, yp + SpellBookDescription.height }); DrawSmallSpellIcon(out, spellCellPosition, sn); if (sn == player._pRSpell && st == player._pRSplType && !IsInspectingPlayer()) { @@ -190,19 +192,19 @@ void DrawSpellBook(const Surface &out) } } -void CheckSBook() -{ +void CheckSBook() +{ // Icons are drawn in a column near the left side of the panel and aligned with the spell book description entries // Spell icons/buttons are 37x38 pixels, laid out from 11,18 with a 5 pixel margin between each icon. This is close // enough to the height of the space given to spell descriptions that we can reuse that value and subtract the // padding from the end of the area. - const Rectangle iconArea = { GetPanelPosition(UiPanels::Spell, { 11, 18 }), Size { 37, SpellBookDescription.height * 7 - 5 } }; - if (iconArea.contains(MousePosition) && !IsInspectingPlayer()) { - const SpellID sn = GetSpellFromSpellPage(SpellbookTab, (MousePosition.y - iconArea.position.y) / SpellBookDescription.height); - Player &player = *InspectPlayer; - const uint64_t spl = player._pMemSpells | player._pISpells | player._pAblSpells; - if (IsValidSpell(sn) && (spl & GetSpellBitmask(sn)) != 0) { - SpellType st = SpellType::Spell; + const Rectangle iconArea = { GetPanelPosition(UiPanels::Spell, { 11, 18 }), Size { 37, SpellBookDescription.height * 7 - 5 } }; + if (iconArea.contains(MousePosition) && !IsInspectingPlayer()) { + Player &player = *InspectPlayer; + const SpellID sn = GetSpellFromSpellPage(player, SpellbookTab, (MousePosition.y - iconArea.position.y) / SpellBookDescription.height); + const uint64_t spl = player._pMemSpells | player._pISpells | player._pAblSpells; + if (IsValidSpell(sn) && (spl & GetSpellBitmask(sn)) != 0) { + SpellType st = SpellType::Spell; if ((player._pISpells & GetSpellBitmask(sn)) != 0) { st = SpellType::Charges; } @@ -230,7 +232,54 @@ void CheckSBook() hitColumn--; } SpellbookTab = hitColumn / buttonWidth; - } -} - -} // namespace devilution + } +} + +std::vector GetSpellBookAvailableSpells(int tab, const Player &player) +{ + std::vector spells; + const uint64_t spl = player._pMemSpells | player._pISpells | player._pAblSpells; + for (size_t pageEntry = 0; pageEntry < SpellBookPageEntries; pageEntry++) { + const SpellID sn = GetSpellFromSpellPage(player, tab, pageEntry); + if (IsValidSpell(sn) && (spl & GetSpellBitmask(sn)) != 0) { + spells.push_back(sn); + } + } + return spells; +} + +std::optional GetSpellBookFirstAvailableSpell(int tab, const Player &player) +{ + std::vector spells = GetSpellBookAvailableSpells(tab, player); + if (spells.empty()) + return std::nullopt; + return spells.front(); +} + +std::optional GetSpellBookAdjacentAvailableSpell(int tab, const Player &player, SpellID currentSpell, int delta) +{ + std::vector spells = GetSpellBookAvailableSpells(tab, player); + if (spells.empty()) + return std::nullopt; + + const auto it = std::find(spells.begin(), spells.end(), currentSpell); + if (it == spells.end()) { + return delta < 0 ? spells.back() : spells.front(); + } + + const size_t index = static_cast(it - spells.begin()); + if (delta < 0) { + if (index == 0) + return std::nullopt; + return spells[index - 1]; + } + if (delta > 0) { + if (index + 1 >= spells.size()) + return std::nullopt; + return spells[index + 1]; + } + + return spells[index]; +} + +} // namespace devilution diff --git a/Source/panels/spell_book.hpp b/Source/panels/spell_book.hpp index 9d15381e4..932f6dc34 100644 --- a/Source/panels/spell_book.hpp +++ b/Source/panels/spell_book.hpp @@ -1,17 +1,26 @@ -#pragma once - -#include - -#include - -#include "engine/clx_sprite.hpp" -#include "engine/surface.hpp" - -namespace devilution { - -tl::expected InitSpellBook(); -void FreeSpellBook(); -void CheckSBook(); -void DrawSpellBook(const Surface &out); - -} // namespace devilution +#pragma once + +#include +#include +#include + +#include + +#include "engine/clx_sprite.hpp" +#include "engine/surface.hpp" +#include "tables/spelldat.h" + +namespace devilution { + +struct Player; + +tl::expected InitSpellBook(); +void FreeSpellBook(); +void CheckSBook(); +void DrawSpellBook(const Surface &out); + +std::vector GetSpellBookAvailableSpells(int tab, const Player &player); +std::optional GetSpellBookFirstAvailableSpell(int tab, const Player &player); +std::optional GetSpellBookAdjacentAvailableSpell(int tab, const Player &player, SpellID currentSpell, int delta); + +} // namespace devilution diff --git a/Source/player.cpp b/Source/player.cpp index b0e9a7222..363e82dc8 100644 --- a/Source/player.cpp +++ b/Source/player.cpp @@ -54,11 +54,12 @@ #include "spells.h" #include "stores.h" #include "towners.h" -#include "utils/is_of.hpp" -#include "utils/language.h" -#include "utils/log.hpp" -#include "utils/str_cat.hpp" -#include "utils/utf8.hpp" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/log.hpp" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" +#include "utils/utf8.hpp" namespace devilution { @@ -2379,9 +2380,9 @@ int CalcStatDiff(Player &player) return diff; } -void NextPlrLevel(Player &player) -{ - player.setCharacterLevel(player.getCharacterLevel() + 1); +void NextPlrLevel(Player &player) +{ + player.setCharacterLevel(player.getCharacterLevel() + 1); CalcPlrInv(player, true); @@ -2415,13 +2416,18 @@ void NextPlrLevel(Player &player) RedrawComponent(PanelDrawComponent::Mana); } - if (ControlMode != ControlTypes::KeyboardAndMouse) - FocusOnCharInfo(); - - CalcPlrInv(player, true); - PlaySFX(SfxID::ItemArmor); - PlaySFX(SfxID::ItemSign); -} + if (ControlMode != ControlTypes::KeyboardAndMouse) + FocusOnCharInfo(); + + CalcPlrInv(player, true); + PlaySFX(SfxID::QuestDone); + + std::string message; + StrAppend(message, _("Level Up"), ": ", player.getCharacterLevel()); + if (player._pStatPts > 0) + StrAppend(message, ". ", _("Points to distribute"), ": ", player._pStatPts); + SpeakText(message, true); +} void Player::_addExperience(uint32_t experience, int levelDelta) { diff --git a/Source/quests.cpp b/Source/quests.cpp index 32cf9cba5..ac79613fe 100644 --- a/Source/quests.cpp +++ b/Source/quests.cpp @@ -31,10 +31,12 @@ #include "stores.h" #include "tables/townerdat.hpp" #include "towners.h" -#include "utils/endian_swap.hpp" -#include "utils/is_of.hpp" -#include "utils/language.h" -#include "utils/utf8.hpp" +#include "utils/endian_swap.hpp" +#include "utils/is_of.hpp" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" +#include "utils/utf8.hpp" #ifdef _DEBUG #include "debug.h" @@ -69,10 +71,10 @@ int SelectedQuest; constexpr Rectangle InnerPanel { { 32, 26 }, { 280, 300 } }; constexpr int LineHeight = 12; constexpr int MaxSpacing = LineHeight * 2; -int ListYOffset; -int LineSpacing; -/** The number of pixels to move finished quest, to separate them from the active ones */ -int FinishedQuestOffset; +int ListYOffset; +int LineSpacing; +/** The number of pixels to move finished quest, to separate them from the active ones */ +int FinishedQuestOffset; const char *const QuestTriggerNames[5] = { N_(/* TRANSLATORS: Quest Map*/ "King Leoric's Tomb"), @@ -779,8 +781,8 @@ void DrawQuestLog(const Surface &out) } } -void StartQuestlog() -{ +void StartQuestlog() +{ auto sortQuestIdx = [](int a, int b) { return QuestsData[a].questBookOrder < QuestsData[b].questBookOrder; @@ -826,36 +828,65 @@ void StartQuestlog() const int overallHeight = EncounteredQuestCount * LineSpacing + FinishedQuestOffset; ListYOffset += (space - overallHeight) / 2; } - - SelectedQuest = FirstFinishedQuest == 0 ? -1 : 0; - QuestLogIsOpen = true; -} - -void QuestlogUp() -{ - if (FirstFinishedQuest == 0) { - SelectedQuest = -1; - } else { - SelectedQuest--; - if (SelectedQuest < 0) { - SelectedQuest = FirstFinishedQuest - 1; - } - PlaySFX(SfxID::MenuMove); - } -} - -void QuestlogDown() -{ - if (FirstFinishedQuest == 0) { - SelectedQuest = -1; - } else { - SelectedQuest++; - if (SelectedQuest == FirstFinishedQuest) { - SelectedQuest = 0; - } - PlaySFX(SfxID::MenuMove); - } -} + + SelectedQuest = FirstFinishedQuest == 0 ? -1 : 0; + QuestLogIsOpen = true; + + if (EncounteredQuestCount == 0) { + SpeakText(_("No quests found."), true); + return; + } + + std::string speech; + if (FirstFinishedQuest > 0) { + StrAppend(speech, _("Active quests:")); + for (int i = 0; i < FirstFinishedQuest; ++i) { + StrAppend(speech, "\n", i + 1, ". ", _(QuestsData[EncounteredQuests[i]]._qlstr)); + } + } + + if (EncounteredQuestCount > FirstFinishedQuest) { + if (!speech.empty()) + speech.append("\n"); + StrAppend(speech, _("Completed quests:")); + for (int i = FirstFinishedQuest; i < EncounteredQuestCount; ++i) { + StrAppend(speech, "\n", (i - FirstFinishedQuest) + 1, ". ", _(QuestsData[EncounteredQuests[i]]._qlstr)); + } + } + + if (!speech.empty()) + SpeakText(speech, true); +} + +void QuestlogUp() +{ + if (FirstFinishedQuest == 0) { + SelectedQuest = -1; + SpeakText(_("No active quests."), true); + } else { + SelectedQuest--; + if (SelectedQuest < 0) { + SelectedQuest = FirstFinishedQuest - 1; + } + PlaySFX(SfxID::MenuMove); + SpeakText(_(QuestsData[EncounteredQuests[SelectedQuest]]._qlstr), true); + } +} + +void QuestlogDown() +{ + if (FirstFinishedQuest == 0) { + SelectedQuest = -1; + SpeakText(_("No active quests."), true); + } else { + SelectedQuest++; + if (SelectedQuest == FirstFinishedQuest) { + SelectedQuest = 0; + } + PlaySFX(SfxID::MenuMove); + SpeakText(_(QuestsData[EncounteredQuests[SelectedQuest]]._qlstr), true); + } +} void QuestlogEnter() { diff --git a/Source/stores.cpp b/Source/stores.cpp index 70c468f7c..575858ae7 100644 --- a/Source/stores.cpp +++ b/Source/stores.cpp @@ -28,11 +28,12 @@ #include "panels/info_box.hpp" #include "qol/stash.h" #include "tables/townerdat.hpp" -#include "towners.h" -#include "utils/format_int.hpp" -#include "utils/language.h" -#include "utils/str_cat.hpp" -#include "utils/utf8.hpp" +#include "towners.h" +#include "utils/format_int.hpp" +#include "utils/language.h" +#include "utils/screen_reader.hpp" +#include "utils/str_cat.hpp" +#include "utils/utf8.hpp" namespace devilution { @@ -122,16 +123,21 @@ int8_t CountdownScrollUp; /** Countdown for the push state of the scroll down button */ int8_t CountdownScrollDown; -/** Remember current store while displaying a dialog */ -TalkID OldActiveStore; - -/** Temporary item used to hold the item being traded */ -Item TempItem; - -/** Maps from towner IDs to NPC names. */ -const char *const TownerNames[] = { - N_("Griswold"), - N_("Pepin"), +/** Remember current store while displaying a dialog */ +TalkID OldActiveStore; + +/** Temporary item used to hold the item being traded */ +Item TempItem; + +TalkID LastSpokenStore = TalkID::None; +int LastSpokenTextLine = -1; +int LastSpokenScrollPos = -1; +bool LastSpokenHadScrollbar = false; + +/** Maps from towner IDs to NPC names. */ +const char *const TownerNames[] = { + N_("Griswold"), + N_("Pepin"), "", N_("Ogden"), N_("Cain"), @@ -151,13 +157,58 @@ constexpr int SmallTextHeight = 12; // For larger small fonts (Chinese and Japanese), text lines are // taller and overflow. // We space out blank lines a bit more to give space to 3-line store items. -constexpr int LargeLineHeight = SmallLineHeight + 1; -constexpr int LargeTextHeight = 18; - -/** - * The line index with the Back / Leave button. - * This is a special button that is always the last line. - * +constexpr int LargeLineHeight = SmallLineHeight + 1; +constexpr int LargeTextHeight = 18; + +int BackButtonLine(); + +void SpeakCurrentStoreSelection() +{ + if (CurrentTextLine < 0 || CurrentTextLine >= NumStoreLines) + return; + + if (!TextLine[CurrentTextLine].hasText()) + return; + + if (!TextLine[CurrentTextLine].isSelectable() && CurrentTextLine != BackButtonLine()) + return; + + std::string speech = TextLine[CurrentTextLine].text; + + const int price = TextLine[CurrentTextLine]._sval; + if (price > 0) { + speech = fmt::format("{:s} - {:s}", speech, FormatInteger(price)); + } + + // Add details below the selected store item (if any). + int addedDetailLines = 0; + for (int i = CurrentTextLine + 1; i < NumStoreLines && addedDetailLines < 3; ++i) { + if (TextLine[i].isSelectable() || TextLine[i].isDivider()) + break; + if (!TextLine[i].hasText()) + continue; + speech.append(". "); + speech.append(TextLine[i].text); + addedDetailLines++; + } + + const bool selectionChanged = ActiveStore != LastSpokenStore + || CurrentTextLine != LastSpokenTextLine + || HasScrollbar != LastSpokenHadScrollbar + || (HasScrollbar && ScrollPos != LastSpokenScrollPos); + + SpeakText(speech, selectionChanged); + + LastSpokenStore = ActiveStore; + LastSpokenTextLine = CurrentTextLine; + LastSpokenHadScrollbar = HasScrollbar; + LastSpokenScrollPos = HasScrollbar ? ScrollPos : -1; +} + +/** + * The line index with the Back / Leave button. + * This is a special button that is always the last line. + * * For lists with a scrollbar, it is not selectable (mouse-only). */ int BackButtonLine() @@ -2327,10 +2378,10 @@ void StartStore(TalkID s) ActiveStore = s; } -void DrawSText(const Surface &out) -{ - if (!IsTextFullSize) - DrawSTextBack(out); +void DrawSText(const Surface &out) +{ + if (!IsTextFullSize) + DrawSTextBack(out); else DrawQTextBack(out); @@ -2373,9 +2424,11 @@ void DrawSText(const Surface &out) PrintSString(out, 28, 1, fmt::format(fmt::runtime(_("Your gold: {:s}")), FormatInteger(TotalPlayerGold())).c_str(), UiFlags::ColorWhitegold | UiFlags::AlignRight); } - if (HasScrollbar) - DrawSSlider(out, 4, 20); -} + if (HasScrollbar) + DrawSSlider(out, 4, 20); + + SpeakCurrentStoreSelection(); +} void StoreESC() { @@ -2735,9 +2788,16 @@ void ReleaseStoreBtn() CountdownScrollDown = -1; } -bool IsPlayerInStore() -{ - return ActiveStore != TalkID::None; -} - -} // namespace devilution +bool IsPlayerInStore() +{ + const bool inStore = ActiveStore != TalkID::None; + if (!inStore) { + LastSpokenStore = TalkID::None; + LastSpokenTextLine = -1; + LastSpokenScrollPos = -1; + LastSpokenHadScrollbar = false; + } + return inStore; +} + +} // namespace devilution diff --git a/Source/utils/proximity_audio.cpp b/Source/utils/proximity_audio.cpp new file mode 100644 index 000000000..c6fe9c283 --- /dev/null +++ b/Source/utils/proximity_audio.cpp @@ -0,0 +1,597 @@ +#include "utils/proximity_audio.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include "controls/plrctrls.h" +#include "engine/assets.hpp" +#include "engine/path.h" +#include "engine/sound.h" +#include "engine/sound_position.hpp" +#include "inv.h" +#include "items.h" +#include "levels/gendung.h" +#include "levels/tile_properties.hpp" +#include "monster.h" +#include "objects.h" +#include "player.h" +#include "utils/is_of.hpp" +#include "utils/math.h" +#include "utils/screen_reader.hpp" +#include "utils/stdcompat/shared_ptr_array.hpp" + +namespace devilution { + +#ifdef NOSOUND + +void UpdateProximityAudioCues() +{ +} + +#else + +namespace { + +constexpr int MaxCueDistanceTiles = 12; +constexpr int InteractDistanceTiles = 1; + +// Pitch shifting via resampling caused audible glitches on some setups; keep cues at normal pitch for stability. +constexpr size_t PitchLevels = 1; + +constexpr uint32_t MinIntervalMs = 250; +constexpr uint32_t MaxIntervalMs = 1000; + +// Extra attenuation applied on top of CalculateSoundPosition(). +// Kept at 0 because stronger attenuation makes distant proximity cues too quiet and feel "glitchy"/missing. +constexpr int ExtraAttenuationMax = 0; + +struct CueSound { + std::array, PitchLevels> variants; + + [[nodiscard]] bool IsLoaded() const + { + for (const auto &variant : variants) { + if (variant != nullptr && variant->DSB.IsLoaded()) + return true; + } + return false; + } + + [[nodiscard]] bool IsAnyPlaying() const + { + for (const auto &variant : variants) { + if (variant != nullptr && variant->DSB.IsLoaded() && variant->DSB.IsPlaying()) + return true; + } + return false; + } +}; + +std::optional WeaponItemCue; +std::optional ArmorItemCue; +std::optional GoldItemCue; +std::optional ChestCue; +std::optional DoorCue; +std::optional MonsterCue; +std::optional InteractCue; + +std::array LastObjectCueTimeMs {}; +uint32_t LastMonsterCueTimeMs = 0; +std::optional LastInteractableId; +uint32_t LastWeaponItemCueTimeMs = 0; +uint32_t LastArmorItemCueTimeMs = 0; +uint32_t LastGoldItemCueTimeMs = 0; + +enum class InteractTargetType : uint8_t { + Item, + Object, +}; + +struct InteractTarget { + InteractTargetType type; + int id; + Point position; +}; + +[[nodiscard]] bool EndsWithCaseInsensitive(std::string_view str, std::string_view suffix) +{ + if (str.size() < suffix.size()) + return false; + const std::string_view tail { str.data() + (str.size() - suffix.size()), suffix.size() }; + return std::equal(tail.begin(), tail.end(), suffix.begin(), suffix.end(), [](char a, char b) { + return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); + }); +} + +[[nodiscard]] bool IsMp3Path(std::string_view path) +{ + return EndsWithCaseInsensitive(path, ".mp3"); +} + +[[nodiscard]] float PlaybackRateForPitchLevel(size_t level) +{ + (void)level; + return 1.0F; +} + +[[nodiscard]] size_t PitchLevelForDistance(int distance, int maxDistance) +{ + (void)distance; + (void)maxDistance; + return 0; +} + +[[nodiscard]] uint32_t IntervalMsForDistance(int distance, int maxDistance) +{ + if (maxDistance <= 0) + return MinIntervalMs; + + const float t = std::clamp(static_cast(distance) / static_cast(maxDistance), 0.0F, 1.0F); + const float closeness = 1.0F - t; + const float interval = static_cast(MaxIntervalMs) - closeness * static_cast(MaxIntervalMs - MinIntervalMs); + return static_cast(std::lround(interval)); +} + +std::optional TryLoadCueSound(std::initializer_list candidatePaths) +{ + if (!gbSndInited) + return std::nullopt; + + for (std::string_view path : candidatePaths) { + AssetRef ref = FindAsset(path); + if (!ref.ok()) + continue; + + const size_t size = ref.size(); + if (size == 0) + continue; + + AssetHandle handle = OpenAsset(std::move(ref), /*threadsafe=*/true); + if (!handle.ok()) + continue; + + auto fileData = MakeArraySharedPtr(size); + if (!handle.read(fileData.get(), size)) + continue; + + CueSound cue {}; + bool ok = true; + + for (size_t i = 0; i < PitchLevels; ++i) { + auto snd = std::make_unique(); + snd->start_tc = SDL_GetTicks() - 80 - 1; +#ifndef NOSOUND + const bool isMp3 = IsMp3Path(path); + if (snd->DSB.SetChunk(fileData, size, isMp3, PlaybackRateForPitchLevel(i)) != 0) { + ok = false; + break; + } +#endif + cue.variants[i] = std::move(snd); + } + + if (ok) + return cue; + } + + return std::nullopt; +} + +void EnsureCuesLoaded() +{ + static bool loaded = false; + if (loaded) + return; + + WeaponItemCue = TryLoadCueSound({ "audio\\weapon.ogg", "..\\audio\\weapon.ogg", "audio\\weapon.wav", "..\\audio\\weapon.wav", "audio\\weapon.mp3", "..\\audio\\weapon.mp3" }); + ArmorItemCue = TryLoadCueSound({ "audio\\armor.ogg", "..\\audio\\armor.ogg", "audio\\armor.wav", "..\\audio\\armor.wav", "audio\\armor.mp3", "..\\audio\\armor.mp3" }); + GoldItemCue = TryLoadCueSound({ "audio\\coin.ogg", "..\\audio\\coin.ogg", "audio\\coin.wav", "..\\audio\\coin.wav", "audio\\coin.mp3", "..\\audio\\coin.mp3" }); + + ChestCue = TryLoadCueSound({ "audio\\chest.ogg", "..\\audio\\chest.ogg", "audio\\chest.wav", "..\\audio\\chest.wav", "audio\\chest.mp3", "..\\audio\\chest.mp3" }); + DoorCue = TryLoadCueSound({ "audio\\door.ogg", "..\\audio\\door.ogg", "audio\\door.wav", "..\\audio\\door.wav", "audio\\Door.wav", "..\\audio\\Door.wav", "audio\\door.mp3", "..\\audio\\door.mp3" }); + + MonsterCue = TryLoadCueSound({ "audio\\monster.ogg", "..\\audio\\monster.ogg", "audio\\monster.wav", "..\\audio\\monster.wav", "audio\\monster.mp3", "..\\audio\\monster.mp3" }); + + InteractCue = TryLoadCueSound({ "audio\\interactispossible.ogg", "..\\audio\\interactispossible.ogg", "audio\\interactispossible.wav", "..\\audio\\interactispossible.wav", "audio\\interactispossible.mp3", "..\\audio\\interactispossible.mp3" }); + + loaded = true; +} + +[[nodiscard]] bool IsAnyCuePlaying() +{ + const auto isAnyPlaying = [](const std::optional &cue) { + return cue && cue->IsAnyPlaying(); + }; + return isAnyPlaying(WeaponItemCue) || isAnyPlaying(ArmorItemCue) || isAnyPlaying(GoldItemCue) || isAnyPlaying(ChestCue) || isAnyPlaying(DoorCue) + || isAnyPlaying(MonsterCue) || isAnyPlaying(InteractCue); +} + +[[nodiscard]] bool PlayCueAt(const CueSound &cue, Point position, int distance, int maxDistance) +{ + if (!gbSndInited || !gbSoundOn) + return false; + + // Proximity cues are meant to guide the player; overlapping the same cue can create audio glitches/noise. + if (cue.IsAnyPlaying()) + return false; + + int logVolume = 0; + int logPan = 0; + if (!CalculateSoundPosition(position, &logVolume, &logPan)) + return false; + + const int extraAttenuation = static_cast(std::lround(math::Remap(0, maxDistance, 0, ExtraAttenuationMax, distance))); + logVolume = std::max(ATTENUATION_MIN, logVolume - extraAttenuation); + if (logVolume <= ATTENUATION_MIN) + return false; + + const size_t pitchLevel = std::min(PitchLevels - 1, PitchLevelForDistance(distance, maxDistance)); + TSnd *snd = cue.variants[pitchLevel].get(); + if (snd == nullptr || !snd->DSB.IsLoaded()) + return false; + + snd_play_snd(snd, logVolume, logPan); + return true; +} + +[[nodiscard]] bool UpdateItemCues(const Point playerPosition, uint32_t now) +{ + struct Candidate { + item_class itemClass; + int distance; + Point position; + }; + + std::optional nearest; + + for (uint8_t i = 0; i < ActiveItemCount; i++) { + const int itemId = ActiveItems[i]; + const Item &item = Items[itemId]; + + switch (item._iClass) { + case ICLASS_WEAPON: + break; + case ICLASS_ARMOR: + break; + case ICLASS_GOLD: + break; + default: + continue; + } + + const int distance = playerPosition.ApproxDistance(item.position); + if (distance > MaxCueDistanceTiles) + continue; + + if (!nearest || distance < nearest->distance) + nearest = Candidate { item._iClass, distance, item.position }; + } + + if (!nearest) + return false; + + const CueSound *cue = nullptr; + uint32_t *lastTimeMs = nullptr; + switch (nearest->itemClass) { + case ICLASS_WEAPON: + if (WeaponItemCue && WeaponItemCue->IsLoaded()) + cue = &*WeaponItemCue; + lastTimeMs = &LastWeaponItemCueTimeMs; + break; + case ICLASS_ARMOR: + if (ArmorItemCue && ArmorItemCue->IsLoaded()) + cue = &*ArmorItemCue; + lastTimeMs = &LastArmorItemCueTimeMs; + break; + case ICLASS_GOLD: + if (GoldItemCue && GoldItemCue->IsLoaded()) + cue = &*GoldItemCue; + lastTimeMs = &LastGoldItemCueTimeMs; + break; + default: + return false; + } + + if (cue == nullptr || lastTimeMs == nullptr) + return false; + + const int distance = nearest->distance; + const uint32_t intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles); + if (now - *lastTimeMs < intervalMs) + return false; + + if (PlayCueAt(*cue, nearest->position, distance, MaxCueDistanceTiles)) { + *lastTimeMs = now; + return true; + } + + return false; +} + +[[nodiscard]] int GetRotaryDistanceForInteractTarget(const Player &player, Point destination) +{ + if (player.position.future == destination) + return -1; + + const int d1 = static_cast(player._pdir); + const int d2 = static_cast(GetDirection(player.position.future, destination)); + + const int d = std::abs(d1 - d2); + if (d > 4) + return 4 - (d % 4); + + return d; +} + +[[nodiscard]] bool IsReachableWithinSteps(const Player &player, Point start, Point destination, size_t maxSteps) +{ + if (maxSteps == 0) + return start == destination; + + if (start == destination) + return true; + + if (start.WalkingDistance(destination) > static_cast(maxSteps)) + return false; + + std::array path; + path.fill(WALK_NONE); + + const int steps = FindPath(CanStep, [&player](Point position) { return PosOkPlayer(player, position); }, start, destination, path.data(), path.size()); + return steps != 0 && steps <= static_cast(maxSteps); +} + +std::optional FindInteractTargetInRange(const Player &player, Point playerPosition) +{ + int rotations = 5; + std::optional best; + + for (int dx = -1; dx <= 1; ++dx) { + for (int dy = -1; dy <= 1; ++dy) { + const Point targetPosition { playerPosition.x + dx, playerPosition.y + dy }; + if (!InDungeonBounds(targetPosition)) + continue; + + const int itemId = dItem[targetPosition.x][targetPosition.y] - 1; + if (itemId < 0) + continue; + + const Item &item = Items[itemId]; + if (item.isEmpty() || item.selectionRegion == SelectionRegion::None) + continue; + + const int newRotations = GetRotaryDistanceForInteractTarget(player, targetPosition); + if (rotations < newRotations) + continue; + if (targetPosition != playerPosition && !IsReachableWithinSteps(player, playerPosition, targetPosition, InteractDistanceTiles)) + continue; + + rotations = newRotations; + best = InteractTarget { .type = InteractTargetType::Item, .id = itemId, .position = targetPosition }; + } + } + + if (best) + return best; + + rotations = 5; + + for (int dx = -1; dx <= 1; ++dx) { + for (int dy = -1; dy <= 1; ++dy) { + const Point targetPosition { playerPosition.x + dx, playerPosition.y + dy }; + if (!InDungeonBounds(targetPosition)) + continue; + + Object *object = FindObjectAtPosition(targetPosition); + if (object == nullptr || !object->canInteractWith()) + continue; + if (!object->isDoor() && !object->IsChest()) + continue; + if (object->IsDisabled()) + continue; + if (targetPosition == playerPosition && object->_oDoorFlag) + continue; + + const int newRotations = GetRotaryDistanceForInteractTarget(player, targetPosition); + if (rotations < newRotations) + continue; + if (targetPosition != playerPosition && !IsReachableWithinSteps(player, playerPosition, targetPosition, InteractDistanceTiles)) + continue; + + const int objectId = static_cast(object - Objects); + rotations = newRotations; + best = InteractTarget { .type = InteractTargetType::Object, .id = objectId, .position = targetPosition }; + } + } + + return best; +} + +[[nodiscard]] bool UpdateObjectCues(const Point playerPosition, uint32_t now) +{ + struct Candidate { + int objectId; + int distance; + const CueSound *cue; + }; + + std::optional nearest; + + for (int i = 0; i < ActiveObjectCount; i++) { + const int objectId = ActiveObjects[i]; + const Object &object = Objects[objectId]; + if (!object.canInteractWith()) + continue; + if (!object.isDoor() && !object.IsChest()) + continue; + + const int distance = playerPosition.ApproxDistance(object.position); + if (distance > MaxCueDistanceTiles) + continue; + + const CueSound *cue = nullptr; + if (object.IsChest()) { + if (ChestCue && ChestCue->IsLoaded()) + cue = &*ChestCue; + } else if (object.isDoor()) { + if (DoorCue && DoorCue->IsLoaded()) + cue = &*DoorCue; + } + + if (cue == nullptr) + continue; + + if (!nearest || distance < nearest->distance) + nearest = Candidate { objectId, distance, cue }; + } + + if (!nearest) + return false; + + const int objectId = nearest->objectId; + const int distance = nearest->distance; + const uint32_t intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles); + if (now - LastObjectCueTimeMs[objectId] < intervalMs) + return false; + + if (PlayCueAt(*nearest->cue, Objects[objectId].position, distance, MaxCueDistanceTiles)) { + LastObjectCueTimeMs[objectId] = now; + return true; + } + + return false; +} + +[[nodiscard]] bool UpdateMonsterCue(const Point playerPosition, uint32_t now) +{ + if (!MonsterCue || !MonsterCue->IsLoaded()) + return false; + + std::optional> nearest; + + 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 monsterPosition { monster.position.tile }; + const int distance = playerPosition.ApproxDistance(monsterPosition); + if (distance > MaxCueDistanceTiles) + continue; + + if (!nearest || distance < nearest->first) { + nearest = { distance, monsterPosition }; + } + } + + if (!nearest) + return false; + + const int distance = nearest->first; + const uint32_t intervalMs = IntervalMsForDistance(distance, MaxCueDistanceTiles); + if (now - LastMonsterCueTimeMs < intervalMs) + return false; + + if (!PlayCueAt(*MonsterCue, nearest->second, distance, MaxCueDistanceTiles)) + return false; + + LastMonsterCueTimeMs = now; + return true; +} + +[[nodiscard]] bool UpdateInteractCue(const Point playerPosition, uint32_t now) +{ + if (!InteractCue || !InteractCue->IsLoaded()) + return false; + + if (MyPlayer == nullptr) + return false; + + const Player &player = *MyPlayer; + const std::optional target = FindInteractTargetInRange(player, playerPosition); + if (!target) { + LastInteractableId = std::nullopt; + return false; + } + + const uint32_t id = target->type == InteractTargetType::Item + ? (1U << 16) | static_cast(target->id) + : (2U << 16) | static_cast(target->id); + if (LastInteractableId && *LastInteractableId == id) + return false; + + LastInteractableId = id; + + if (!invflag) { + if (target->type == InteractTargetType::Item) { + const Item &item = Items[target->id]; + const StringOrView name = item.getName(); + if (!name.empty()) + SpeakText(name.str(), /*force=*/true); + } else { + const Object &object = Objects[target->id]; + const StringOrView name = object.name(); + if (!name.empty()) + SpeakText(name.str(), /*force=*/true); + } + } + + if (!PlayCueAt(*InteractCue, target->position, /*distance=*/0, /*maxDistance=*/1)) + return true; + (void)now; + return true; +} + +} // namespace + +void UpdateProximityAudioCues() +{ + if (!gbSndInited || !gbSoundOn) + return; + if (leveltype == DTYPE_TOWN) + return; + if (MyPlayer == nullptr || MyPlayerIsDead || MyPlayer->_pmode == PM_DEATH) + return; + if (InGameMenu()) + return; + + EnsureCuesLoaded(); + + const uint32_t now = SDL_GetTicks(); + const Point playerPosition { MyPlayer->position.future }; + + // Don't start another cue while one is playing (helps avoid overlap-related stutter/noise). + if (IsAnyCuePlaying()) + return; + + // Keep cues readable and reduce overlap/glitches by playing at most one per tick (priority order). + if (UpdateInteractCue(playerPosition, now)) + return; + if (UpdateMonsterCue(playerPosition, now)) + return; + if (UpdateItemCues(playerPosition, now)) + return; + (void)UpdateObjectCues(playerPosition, now); +} + +#endif // NOSOUND + +} // namespace devilution diff --git a/Source/utils/proximity_audio.hpp b/Source/utils/proximity_audio.hpp new file mode 100644 index 000000000..1a8c36cc5 --- /dev/null +++ b/Source/utils/proximity_audio.hpp @@ -0,0 +1,8 @@ +#pragma once + +namespace devilution { + +void UpdateProximityAudioCues(); + +} // namespace devilution + diff --git a/Source/utils/screen_reader.cpp b/Source/utils/screen_reader.cpp index 43e3ecdf9..7a7c497a6 100644 --- a/Source/utils/screen_reader.cpp +++ b/Source/utils/screen_reader.cpp @@ -25,30 +25,31 @@ void InitializeScreenReader() #endif } -void ShutDownScreenReader() -{ -#ifdef _WIN32 - Tolk_Unload(); -#else - spd_close(Speechd); -#endif -} - -void SpeakText(std::string_view text) -{ - static std::string SpokenText; - - if (SpokenText == text) - return; - - SpokenText = text; - -#ifdef _WIN32 - const auto textUtf16 = ToWideChar(SpokenText); - Tolk_Output(&textUtf16[0], true); -#else - spd_say(Speechd, SPD_TEXT, SpokenText.c_str()); -#endif -} +void ShutDownScreenReader() +{ +#ifdef _WIN32 + Tolk_Unload(); +#else + spd_close(Speechd); +#endif +} + +void SpeakText(std::string_view text, bool force) +{ + static std::string SpokenText; + + if (!force && SpokenText == text) + return; + + SpokenText = text; + +#ifdef _WIN32 + const auto textUtf16 = ToWideChar(SpokenText); + if (textUtf16 != nullptr) + Tolk_Output(textUtf16.get(), true); +#else + spd_say(Speechd, SPD_TEXT, SpokenText.c_str()); +#endif +} } // namespace devilution diff --git a/Source/utils/screen_reader.hpp b/Source/utils/screen_reader.hpp index 64a029c1c..14a5c6562 100644 --- a/Source/utils/screen_reader.hpp +++ b/Source/utils/screen_reader.hpp @@ -2,24 +2,24 @@ #include -namespace devilution { - -#ifdef SCREEN_READER_INTEGRATION -void InitializeScreenReader(); -void ShutDownScreenReader(); -void SpeakText(std::string_view text); -#else -constexpr void InitializeScreenReader() -{ -} - -constexpr void ShutDownScreenReader() -{ -} - -constexpr void SpeakText(std::string_view text) -{ -} -#endif - -} // namespace devilution +namespace devilution { + +#ifdef SCREEN_READER_INTEGRATION +void InitializeScreenReader(); +void ShutDownScreenReader(); +void SpeakText(std::string_view text, bool force = false); +#else +constexpr void InitializeScreenReader() +{ +} + +constexpr void ShutDownScreenReader() +{ +} + +constexpr void SpeakText(std::string_view text, bool force = false) +{ +} +#endif + +} // namespace devilution diff --git a/Source/utils/soundsample.cpp b/Source/utils/soundsample.cpp index 693906ffe..1f8be9692 100644 --- a/Source/utils/soundsample.cpp +++ b/Source/utils/soundsample.cpp @@ -8,12 +8,13 @@ #ifdef USE_SDL3 #include #include -#else -#include -#include -#include - -#include +#else +#include +#include +#include +#include + +#include #ifdef USE_SDL1 #include "utils/sdl2_to_1_2_backports.h" #else @@ -66,21 +67,112 @@ float PanLogToLinear(int logPan) return copysign(1.F - factor, static_cast(logPan)); } -std::unique_ptr CreateDecoder(bool isMp3) -{ - if (isMp3) - return std::make_unique(); - return std::make_unique(); -} - -std::unique_ptr CreateStream(SDL_IOStream *handle, bool isMp3) -{ - auto decoder = CreateDecoder(isMp3); - if (!decoder->open(handle)) // open for `getRate` - return nullptr; - auto resampler = CreateAulibResampler(decoder->getRate()); - return std::make_unique(handle, std::move(decoder), std::move(resampler), /*closeRw=*/true); -} +std::unique_ptr CreateDecoder(bool isMp3) +{ + if (isMp3) + return std::make_unique(); + return std::make_unique(); +} + +class PlaybackRateDecoder final : public Aulib::Decoder { +public: + PlaybackRateDecoder(std::unique_ptr inner, float playbackRate) + : inner_(std::move(inner)) + , playbackRate_(playbackRate) + { + } + + auto open(SDL_RWops *rwops) -> bool override + { + if (isOpen()) + return true; + if (inner_ == nullptr) + return false; + + if (!inner_->open(rwops)) + return false; + + setIsOpen(true); + return true; + } + + auto getChannels() const -> int override + { + return inner_ != nullptr ? inner_->getChannels() : 0; + } + + auto getRate() const -> int override + { + if (inner_ == nullptr) + return 0; + + const int baseRate = inner_->getRate(); + if (baseRate <= 0) + return baseRate; + + const int adjustedRate = static_cast(std::lround(static_cast(baseRate) * playbackRate_)); + return std::max(adjustedRate, 1); + } + + auto rewind() -> bool override + { + return inner_ != nullptr && inner_->rewind(); + } + + auto duration() const -> std::chrono::microseconds override + { + if (inner_ == nullptr) + return {}; + + const auto base = inner_->duration(); + if (playbackRate_ <= 0) + return base; + + return std::chrono::duration_cast(base / playbackRate_); + } + + auto seekToTime(std::chrono::microseconds pos) -> bool override + { + if (inner_ == nullptr) + return false; + if (playbackRate_ <= 0) + return inner_->seekToTime(pos); + + return inner_->seekToTime(std::chrono::duration_cast(pos * playbackRate_)); + } + +protected: + auto doDecoding(float buf[], int len, bool &callAgain) -> int override + { + return inner_ != nullptr ? inner_->decode(buf, len, callAgain) : 0; + } + +private: + std::unique_ptr inner_; + float playbackRate_; +}; + +std::unique_ptr CreateStream(SDL_IOStream *handle, bool isMp3, float playbackRate) +{ + std::unique_ptr decoder; + if (isMp3) { + decoder = std::make_unique(); + } else { + const auto rwPos = SDL_RWtell(handle); + decoder = Aulib::Decoder::decoderFor(handle); + SDL_RWseek(handle, rwPos, RW_SEEK_SET); + if (decoder == nullptr) + decoder = std::make_unique(); + } + + if (playbackRate != 1.0F) + decoder = std::make_unique(std::move(decoder), playbackRate); + + if (!decoder->open(handle)) // open for `getRate` + return nullptr; + auto resampler = CreateAulibResampler(decoder->getRate()); + return std::make_unique(handle, std::move(decoder), std::move(resampler), /*closeRw=*/true); +} /** * @brief Converts log volume passed in into linear volume. @@ -96,15 +188,20 @@ float VolumeLogToLinear(int logVolume, int logMin, int logMax) } #endif -} // namespace - -///// SoundSample ///// - -#ifndef USE_SDL3 -void SoundSample::SetFinishCallback(Aulib::Stream::Callback &&callback) -{ - stream_->setFinishCallback(std::forward(callback)); -} +} // namespace + +///// SoundSample ///// + +SoundSample::SoundSample() = default; +SoundSample::~SoundSample() = default; +SoundSample::SoundSample(SoundSample &&) noexcept = default; +SoundSample &SoundSample::operator=(SoundSample &&) noexcept = default; + +#ifndef USE_SDL3 +void SoundSample::SetFinishCallback(Aulib::Stream::Callback &&callback) +{ + stream_->setFinishCallback(std::forward(callback)); +} #endif void SoundSample::Stop() @@ -162,54 +259,67 @@ bool SoundSample::Play(int numIterations) #endif } -int SoundSample::SetChunkStream(std::string filePath, bool isMp3, bool logErrors) -{ -#ifdef USE_SDL3 - return 0; -#else - SDL_IOStream *handle = OpenAssetAsSdlRwOps(filePath.c_str(), /*threadsafe=*/true); - if (handle == nullptr) { - if (logErrors) - LogError(LogCategory::Audio, "OpenAsset failed (from SoundSample::SetChunkStream) for {}: {}", filePath, SDL_GetError()); - return -1; - } - file_path_ = std::move(filePath); - isMp3_ = isMp3; - stream_ = CreateStream(handle, isMp3); - if (!stream_->open()) { - stream_ = nullptr; - if (logErrors) - LogError(LogCategory::Audio, "Aulib::Stream::open (from SoundSample::SetChunkStream) for {}: {}", file_path_, SDL_GetError()); - return -1; - } - return 0; -#endif -} - -int SoundSample::SetChunk(ArraySharedPtr fileData, std::size_t dwBytes, bool isMp3) -{ -#ifdef USE_SDL3 - return 0; -#else - isMp3_ = isMp3; - file_data_ = std::move(fileData); - file_data_size_ = dwBytes; - SDL_IOStream *buf = SDL_IOFromConstMem(file_data_.get(), static_cast(dwBytes)); - if (buf == nullptr) { - return -1; - } - - stream_ = CreateStream(buf, isMp3_); - if (!stream_->open()) { - stream_ = nullptr; - file_data_ = nullptr; - LogError(LogCategory::Audio, "Aulib::Stream::open (from SoundSample::SetChunk): {}", SDL_GetError()); - return -1; - } - - return 0; -#endif -} +int SoundSample::SetChunkStream(std::string filePath, bool isMp3, bool logErrors, float playbackRate) +{ +#ifdef USE_SDL3 + return 0; +#else + SDL_IOStream *handle = OpenAssetAsSdlRwOps(filePath.c_str(), /*threadsafe=*/true); + if (handle == nullptr) { + if (logErrors) + LogError(LogCategory::Audio, "OpenAsset failed (from SoundSample::SetChunkStream) for {}: {}", filePath, SDL_GetError()); + return -1; + } + file_path_ = std::move(filePath); + isMp3_ = isMp3; + playbackRate_ = playbackRate; + stream_ = CreateStream(handle, isMp3, playbackRate_); + if (!stream_) { + SDL_RWclose(handle); + if (logErrors) + LogError(LogCategory::Audio, "CreateStream failed (from SoundSample::SetChunkStream) for {}: {}", file_path_, SDL_GetError()); + return -1; + } + if (!stream_->open()) { + stream_ = nullptr; + if (logErrors) + LogError(LogCategory::Audio, "Aulib::Stream::open (from SoundSample::SetChunkStream) for {}: {}", file_path_, SDL_GetError()); + return -1; + } + return 0; +#endif +} + +int SoundSample::SetChunk(ArraySharedPtr fileData, std::size_t dwBytes, bool isMp3, float playbackRate) +{ +#ifdef USE_SDL3 + return 0; +#else + isMp3_ = isMp3; + playbackRate_ = playbackRate; + file_data_ = std::move(fileData); + file_data_size_ = dwBytes; + SDL_IOStream *buf = SDL_IOFromConstMem(file_data_.get(), static_cast(dwBytes)); + if (buf == nullptr) { + return -1; + } + + stream_ = CreateStream(buf, isMp3_, playbackRate_); + if (!stream_) { + SDL_RWclose(buf); + file_data_ = nullptr; + return -1; + } + if (!stream_->open()) { + stream_ = nullptr; + file_data_ = nullptr; + LogError(LogCategory::Audio, "Aulib::Stream::open (from SoundSample::SetChunk): {}", SDL_GetError()); + return -1; + } + + return 0; +#endif +} void SoundSample::SetVolume(int logVolume, int logMin, int logMax) { diff --git a/Source/utils/soundsample.h b/Source/utils/soundsample.h index f0bb523d7..f063d64dd 100644 --- a/Source/utils/soundsample.h +++ b/Source/utils/soundsample.h @@ -19,11 +19,12 @@ class Stream; namespace devilution { -class SoundSample final { -public: - SoundSample() = default; - SoundSample(SoundSample &&) noexcept = default; - SoundSample &operator=(SoundSample &&) noexcept = default; +class SoundSample final { +public: + SoundSample(); + ~SoundSample(); + SoundSample(SoundSample &&) noexcept; + SoundSample &operator=(SoundSample &&) noexcept; [[nodiscard]] bool IsLoaded() const { @@ -37,8 +38,8 @@ public: void Release(); bool IsPlaying(); - // Returns 0 on success. - int SetChunkStream(std::string filePath, bool isMp3, bool logErrors = true); + // Returns 0 on success. + int SetChunkStream(std::string filePath, bool isMp3, bool logErrors = true, float playbackRate = 1.0F); #ifndef USE_SDL3 void SetFinishCallback(std::function &&callback); @@ -48,22 +49,23 @@ public: * @brief Sets the sample's WAV, FLAC, or Ogg/Vorbis data. * @param fileData Buffer containing the data * @param dwBytes Length of buffer - * @param isMp3 Whether the data is an MP3 - * @return 0 on success, -1 otherwise - */ - int SetChunk(ArraySharedPtr fileData, std::size_t dwBytes, bool isMp3); + * @param isMp3 Whether the data is an MP3 + * @param playbackRate Playback speed/pitch multiplier (1.0 = normal, >1.0 faster+higher, <1.0 slower+lower) + * @return 0 on success, -1 otherwise + */ + int SetChunk(ArraySharedPtr fileData, std::size_t dwBytes, bool isMp3, float playbackRate = 1.0F); [[nodiscard]] bool IsStreaming() const { return file_data_ == nullptr; } - int DuplicateFrom(const SoundSample &other) - { - if (other.IsStreaming()) - return SetChunkStream(other.file_path_, other.isMp3_); - return SetChunk(other.file_data_, other.file_data_size_, other.isMp3_); - } + int DuplicateFrom(const SoundSample &other) + { + if (other.IsStreaming()) + return SetChunkStream(other.file_path_, other.isMp3_, /*logErrors=*/true, other.playbackRate_); + return SetChunk(other.file_data_, other.file_data_size_, other.isMp3_, other.playbackRate_); + } /** * @brief Start playing the sound for a given number of iterations (0 means loop). @@ -104,7 +106,8 @@ private: // Set for streaming audio to allow for duplicating it: std::string file_path_; - bool isMp3_; + bool isMp3_; + float playbackRate_ = 1.0F; #ifndef USE_SDL3 std::unique_ptr stream_; diff --git a/Translations/pl.po b/Translations/pl.po index b8ddfb708..7a89b67d6 100644 --- a/Translations/pl.po +++ b/Translations/pl.po @@ -11961,6 +11961,274 @@ msgstr "Runa Kamienia" msgid "," msgstr "," +#: Source/diablo.cpp +msgid "Town NPCs:" +msgstr "NPC w mieście:" + +#: Source/diablo.cpp +msgid "Cows: " +msgstr "Krowy: " + +#: Source/diablo.cpp +msgid "Selected: " +msgstr "Wybrano: " + +#: Source/diablo.cpp +msgid "PageUp/PageDown: select. Home: go. End: repeat." +msgstr "PageUp/PageDown: wybór. Home: idź. End: powtórz." + +#: Source/diablo.cpp +msgid "Not in a dungeon." +msgstr "Nie jesteś w lochu." + +#: Source/diablo.cpp +msgid "Close the map first." +msgstr "Najpierw zamknij mapę." + +#: Source/diablo.cpp +msgid "No exits found." +msgstr "Nie znaleziono wyjść." + +#: Source/diablo.cpp +msgid "Not in town." +msgstr "Nie jesteś w mieście." + +#: Source/diablo.cpp +msgid "Nearest stairs down" +msgstr "Najbliższe schody w dół" + +#: Source/diablo.cpp +msgid "Speaks directions to the nearest stairs down." +msgstr "Podaje wskazówki dojścia do najbliższych schodów w dół." + +#: Source/diablo.cpp +msgid "Nearest stairs up" +msgstr "Najbliższe schody w górę" + +#: Source/diablo.cpp +msgid "Speaks directions to the nearest stairs up." +msgstr "Podaje wskazówki dojścia do najbliższych schodów w górę." + +#: Source/diablo.cpp +msgid "Nearest exit: " +msgstr "Najbliższe wyjście: " + +#: Source/diablo.cpp +msgid "Cathedral entrance" +msgstr "Wejście do Katedry" + +#: Source/diablo.cpp +msgid "Cathedral entrance: press {:s}." +msgstr "Wejście do Katedry: naciśnij {:s}." + +#: Source/diablo.cpp +msgid "Stairs down" +msgstr "Schody w dół" + +#: Source/diablo.cpp +msgid "Stairs up" +msgstr "Schody w górę" + +#: Source/diablo.cpp +msgid "Town warp to level {:d}" +msgstr "Portal miejski na poziom {:d}" + +#: Source/diablo.cpp +msgid "Town warp to {:s}" +msgstr "Portal miejski do {:s}" + +#: Source/diablo.cpp +msgid "Warp up" +msgstr "Portal w górę" + +#: Source/diablo.cpp +msgid "Return to town" +msgstr "Powrót do miasta" + +#: Source/diablo.cpp +msgid "Warp" +msgstr "Portal" + +#: Source/diablo.cpp +msgid "Set level" +msgstr "Poziom zadania" + +#: Source/diablo.cpp +msgid "Return level" +msgstr "Powrót do zadania" + +#: Source/diablo.cpp +msgid "Exit" +msgstr "Wyjście" + +#: Source/diablo.cpp +msgid "north" +msgstr "północ" + +#: Source/diablo.cpp +msgid "south" +msgstr "południe" + +#: Source/diablo.cpp +msgid "east" +msgstr "wschód" + +#: Source/diablo.cpp +msgid "west" +msgstr "zachód" + +#: Source/diablo.cpp +msgid "here" +msgstr "tutaj" + +#: Source/diablo.cpp +msgid "No unexplored areas found." +msgstr "Nie znaleziono nieodkrytych obszarów." + +#: Source/diablo.cpp +msgid "Nearest unexplored space: " +msgstr "Najbliższe nieodkryte miejsce: " + +#: Source/diablo.cpp +msgid "Cycle tracker target" +msgstr "Zmień cel trackera" + +#: Source/diablo.cpp +msgid "Cycles what the tracker looks for (items, chests, monsters)." +msgstr "Zmienia, czego szuka tracker (przedmioty, skrzynie, potwory)." + +#: Source/diablo.cpp +msgid "Navigate to tracker target" +msgstr "Nawiguj do celu trackera" + +#: Source/diablo.cpp +msgid "Walks to the nearest target of the selected tracker category." +msgstr "Prowadzi do najbliższego celu wybranej kategorii trackera." + +#: Source/diablo.cpp +msgid "Tracker target: " +msgstr "Śledzenie: " + +#: Source/diablo.cpp +msgid "items" +msgstr "przedmioty" + +#: Source/diablo.cpp +msgid "chests" +msgstr "skrzynie" + +#: Source/diablo.cpp +msgid "monsters" +msgstr "potwory" + +#: Source/diablo.cpp +msgid "No items found." +msgstr "Nie znaleziono żadnych przedmiotów." + +#: Source/diablo.cpp +msgid "No chests found." +msgstr "Nie znaleziono żadnych skrzyń." + +#: Source/diablo.cpp +msgid "No monsters found." +msgstr "Nie znaleziono żadnych potworów." + +#: Source/diablo.cpp +msgid "Navigating to nearest item." +msgstr "Nawiguję do najbliższego przedmiotu." + +#: Source/diablo.cpp +msgid "Navigating to nearest chest." +msgstr "Nawiguję do najbliższej skrzyni." + +#: Source/diablo.cpp +msgid "Navigating to nearest monster." +msgstr "Nawiguję do najbliższego potwora." + +#: Source/diablo.cpp +msgid "Target item is gone." +msgstr "Docelowy przedmiot zniknął." + +#: Source/diablo.cpp +msgid "Item in range." +msgstr "Przedmiot jest w zasięgu." + +#: Source/diablo.cpp +msgid "Target chest is gone." +msgstr "Docelowa skrzynia zniknęła." + +#: Source/diablo.cpp +msgid "Chest in range." +msgstr "Skrzynia jest w zasięgu." + +#: Source/diablo.cpp +msgid "Target monster is gone." +msgstr "Docelowy potwór zniknął." + +#: Source/diablo.cpp +msgid "Monster in range." +msgstr "Potwór jest w zasięgu." + +#: Source/diablo.cpp +msgid "Can't find a nearby tile to walk to." +msgstr "Nie mogę znaleźć pobliskiego pola, na które da się podejść." + +#: Source/diablo.cpp +msgid "Can't find a path to the target." +msgstr "Nie mogę znaleźć ścieżki do celu." + +#: Source/diablo.cpp +msgid "A door is blocking the path. Open it and try again." +msgstr "Drzwi blokują drogę. Otwórz je i spróbuj ponownie." + +#: Source/controls/plrctrls.cpp +msgid "Head" +msgstr "Głowa" + +#: Source/controls/plrctrls.cpp +msgid "Left ring" +msgstr "Lewy pierścień" + +#: Source/controls/plrctrls.cpp +msgid "Right ring" +msgstr "Prawy pierścień" + +#: Source/controls/plrctrls.cpp +msgid "Amulet" +msgstr "Amulet" + +#: Source/controls/plrctrls.cpp +msgid "Left hand" +msgstr "Lewa ręka" + +#: Source/controls/plrctrls.cpp +msgid "Right hand" +msgstr "Prawa ręka" + +#: Source/controls/plrctrls.cpp +msgid "Chest" +msgstr "Tułów" + +#: Source/controls/plrctrls.cpp +msgid "Belt" +msgstr "Pas" + +#: Source/controls/plrctrls.cpp +msgid "Inventory" +msgstr "Ekwipunek" + +#: Source/controls/plrctrls.cpp +msgid "empty" +msgstr "pusto" + +#: Source/options.cpp +msgid "Unbound" +msgstr "Brak przypisania" + +#: Source/diablo.cpp +msgid "No spell selected." +msgstr "Nie wybrano czaru." + #~ msgid "Decrease Gamma" #~ msgstr "Zmniejsz jasność" diff --git a/tools/msgfmt.py b/tools/msgfmt.py new file mode 100644 index 000000000..d2ff9fb59 --- /dev/null +++ b/tools/msgfmt.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import re +import struct +from pathlib import Path + + +_ESCAPE_RE = re.compile(r"\\(n|t|r|\\|\"|[0-7]{1,3}|x[0-9a-fA-F]{2})") + + +def _unescape(value: str) -> str: + def repl(match: re.Match[str]) -> str: + escape = match.group(1) + if escape == "n": + return "\n" + if escape == "t": + return "\t" + if escape == "r": + return "\r" + if escape == "\\": + return "\\" + if escape == '"': + return '"' + if escape.startswith("x"): + return chr(int(escape[1:], 16)) + return chr(int(escape, 8)) + + return _ESCAPE_RE.sub(repl, value) + + +def _parse_quoted(rest: str) -> str: + rest = rest.strip() + if not (rest.startswith('"') and rest.endswith('"')): + raise ValueError(f"Invalid PO string: {rest!r}") + return _unescape(rest[1:-1]) + + +def parse_po(path: Path) -> dict[str, str]: + messages: list[tuple[str | None, str, str | None, dict[int, str], set[str]]] = [] + + msgctxt: str | None = None + msgid: str | None = None + msgid_plural: str | None = None + msgstr: dict[int, str] = {} + flags: set[str] = set() + active: tuple[str, int | None] | None = None + + def flush() -> None: + nonlocal msgctxt, msgid, msgid_plural, msgstr, flags, active + if msgid is None: + msgctxt = None + msgid_plural = None + msgstr = {} + flags = set() + active = None + return + + messages.append((msgctxt, msgid, msgid_plural, dict(msgstr), set(flags))) + + msgctxt = None + msgid = None + msgid_plural = None + msgstr = {} + flags = set() + active = None + + with path.open("r", encoding="utf-8", errors="replace", newline="") as file: + for raw_line in file: + line = raw_line.rstrip("\n") + + if not line.strip(): + flush() + continue + + if line.startswith("#,"): + for flag in line[2:].split(","): + flag = flag.strip() + if flag: + flags.add(flag) + continue + + if line.startswith("#"): + continue + + if line.startswith("msgctxt"): + msgctxt = _parse_quoted(line[len("msgctxt") :]) + active = ("msgctxt", None) + continue + + if line.startswith("msgid_plural"): + msgid_plural = _parse_quoted(line[len("msgid_plural") :]) + active = ("msgid_plural", None) + continue + + if line.startswith("msgid"): + msgid = _parse_quoted(line[len("msgid") :]) + active = ("msgid", None) + continue + + if line.startswith("msgstr["): + close = line.find("]") + index = int(line[len("msgstr[") : close]) + msgstr[index] = _parse_quoted(line[close + 1 :]) + active = ("msgstr", index) + continue + + if line.startswith("msgstr"): + msgstr[0] = _parse_quoted(line[len("msgstr") :]) + active = ("msgstr", 0) + continue + + if line.lstrip().startswith('"'): + value = _parse_quoted(line) + if active is None: + continue + kind, index = active + if kind == "msgctxt": + msgctxt = (msgctxt or "") + value + elif kind == "msgid": + msgid = (msgid or "") + value + elif kind == "msgid_plural": + msgid_plural = (msgid_plural or "") + value + elif kind == "msgstr": + assert index is not None + msgstr[index] = msgstr.get(index, "") + value + continue + + flush() + + catalog: dict[str, str] = {} + for msgctxt, msgid, msgid_plural, msgstrs, flags in messages: + if "fuzzy" in flags: + continue + + if msgid_plural is not None: + key = msgid + "\x00" + msgid_plural + max_index = max(msgstrs.keys(), default=0) + value = "\x00".join(msgstrs.get(i, "") for i in range(max_index + 1)) + else: + key = msgid + value = msgstrs.get(0, "") + + if msgctxt: + key = msgctxt + "\x04" + key + + catalog[key] = value + + catalog.setdefault("", "") + return catalog + + +def write_mo(catalog: dict[str, str], out_file: Path) -> None: + entries = sorted(catalog.items(), key=lambda kv: kv[0]) + ids = [key.encode("utf-8") for key, _ in entries] + strs = [value.encode("utf-8") for _, value in entries] + + count = len(entries) + header_size = 7 * 4 + table_size = count * 8 + originals_offset = header_size + translations_offset = originals_offset + table_size + string_offset = translations_offset + table_size + + offsets_ids: list[tuple[int, int]] = [] + offsets_strs: list[tuple[int, int]] = [] + pool = bytearray() + + for value in ids: + offsets_ids.append((len(value), string_offset + len(pool))) + pool.extend(value) + pool.append(0) + + for value in strs: + offsets_strs.append((len(value), string_offset + len(pool))) + pool.extend(value) + pool.append(0) + + out_file.parent.mkdir(parents=True, exist_ok=True) + with out_file.open("wb") as file: + file.write( + struct.pack( + " int: + parser = argparse.ArgumentParser(description="Compile a .po file into a GNU .mo/.gmo file.") + parser.add_argument("input", type=Path, help="Input .po file") + parser.add_argument("-o", "--output", type=Path, required=True, help="Output .mo/.gmo file") + args = parser.parse_args() + + catalog = parse_po(args.input) + write_mo(catalog, args.output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +