Browse Source

Diablo Access: accessibility improvements

access
mojsior 2 months ago
parent
commit
a5d43059e4
  1. 83
      CMake/Assets.cmake
  2. 11
      Source/CMakeLists.txt
  3. 122
      Source/DiabloUI/diabloui.cpp
  4. 143
      Source/control/control_infobox.cpp
  5. 15
      Source/control/control_panel.cpp
  6. 350
      Source/controls/plrctrls.cpp
  7. 50
      Source/controls/plrctrls.h
  8. 2140
      Source/diablo.cpp
  9. 236
      Source/gmenu.cpp
  10. 147
      Source/inv.cpp
  11. 27
      Source/minitext.cpp
  12. 74
      Source/options.cpp
  13. 359
      Source/panels/charpanel.cpp
  14. 17
      Source/panels/charpanel.hpp
  15. 113
      Source/panels/spell_book.cpp
  16. 43
      Source/panels/spell_book.hpp
  17. 36
      Source/player.cpp
  18. 111
      Source/quests.cpp
  19. 130
      Source/stores.cpp
  20. 597
      Source/utils/proximity_audio.cpp
  21. 8
      Source/utils/proximity_audio.hpp
  22. 51
      Source/utils/screen_reader.cpp
  23. 42
      Source/utils/screen_reader.hpp
  24. 266
      Source/utils/soundsample.cpp
  25. 39
      Source/utils/soundsample.h
  26. 268
      Translations/pl.po
  27. 215
      tools/msgfmt.py

83
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}"

11
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.

122
Source/DiabloUI/diabloui.cpp

@ -17,15 +17,18 @@
#include <SDL3/SDL_rect.h>
#include <SDL3/SDL_surface.h>
#include <SDL3/SDL_timer.h>
#else
#include <SDL.h>
#endif
#include <function_ref.hpp>
#include "DiabloUI/button.h"
#include "DiabloUI/scrollbar.h"
#include "DiabloUI/text_input.hpp"
#else
#include <SDL.h>
#endif
#include <fmt/args.h>
#include <fmt/format.h>
#include <function_ref.hpp>
#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<DrawStringFormatArg> &args)
{
if (args.empty())
return std::string(format.str());
std::string formatted;
FMT_TRY
{
fmt::dynamic_format_arg_store<fmt::format_context> store;
for (const DrawStringFormatArg &arg : args) {
const DrawStringFormatArg::Value &value = arg.value();
if (std::holds_alternative<std::string_view>(value)) {
store.push_back(std::get<std::string_view>(value));
} else {
store.push_back(std::get<int>(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<UiList *>(item.get());
SelectedItemMax = std::max(uiList->m_vecItems.size() - 1, static_cast<size_t>(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<UiScrollbar *>(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<UiScrollbar *>(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;

143
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

15
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()
{

350
Source/controls/plrctrls.cpp

@ -1,13 +1,14 @@
#include "controls/plrctrls.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <list>
#ifdef USE_SDL3
#include <SDL3/SDL_events.h>
#include <SDL3/SDL_gamepad.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <list>
#include <string>
#ifdef USE_SDL3
#include <SDL3/SDL_events.h>
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_timer.h>
#else
#include <SDL.h>
@ -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 <fmt/format.h>
#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) {

50
Source/controls/plrctrls.h

@ -7,12 +7,13 @@
#ifdef USE_SDL3
#include <SDL3/SDL_events.h>
#else
#include <SDL.h>
#endif
#include "controls/controller.h"
#include "controls/game_controls.h"
#include "player.h"
#include <SDL.h>
#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;

2140
Source/diablo.cpp

File diff suppressed because it is too large Load Diff

236
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<uint16_t>(sgpCurrItem->sliderSteps(), 2);
const uint16_t step = std::min<uint16_t>(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)
{

147
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<TSnd> snd;
static bool attempted = false;
if (attempted)
return snd.get();
attempted = true;
snd = std::make_unique<TSnd>();
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;
}

27
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);
}

74
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<const Ini::Value> 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<const Ini::Value> 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)
{

359
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<CharacterScreenField, 18> 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<CharacterAttribute> 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<CharacterAttribute>()) {
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<int>(CharacterScreenFieldOrder.size());
int idx = static_cast<int>(SelectedCharacterScreenFieldIndex);
idx = (idx + delta) % size;
if (idx < 0)
idx += size;
SelectedCharacterScreenFieldIndex = static_cast<size_t>(idx);
SpeakCurrentCharacterScreenField();
}
void CharacterScreenActivateSelection(bool addAllStatPoints)
{
if (!CharFlag)
return;
if (InspectPlayer == nullptr)
return;
const CharacterScreenField field = CharacterScreenFieldOrder[SelectedCharacterScreenFieldIndex];
const std::optional<CharacterAttribute> 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

17
Source/panels/charpanel.hpp

@ -9,10 +9,13 @@
namespace devilution {
extern OptionalOwnedClxSpriteList pChrButtons;
void DrawChr(const Surface &);
tl::expected<void, std::string> LoadCharPanel();
void FreeCharPanel();
} // namespace devilution
extern OptionalOwnedClxSpriteList pChrButtons;
void DrawChr(const Surface &);
void InitCharacterScreenSpeech();
void CharacterScreenMoveSelection(int delta);
void CharacterScreenActivateSelection(bool addAllStatPoints);
tl::expected<void, std::string> LoadCharPanel();
void FreeCharPanel();
} // namespace devilution

113
Source/panels/spell_book.cpp

@ -1,8 +1,10 @@
#include "panels/spell_book.hpp"
#include <cstdint>
#include <optional>
#include <string>
#include "panels/spell_book.hpp"
#include <algorithm>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include <expected.hpp>
#include <fmt/format.h>
@ -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<SpellID> GetSpellBookAvailableSpells(int tab, const Player &player)
{
std::vector<SpellID> 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<SpellID> GetSpellBookFirstAvailableSpell(int tab, const Player &player)
{
std::vector<SpellID> spells = GetSpellBookAvailableSpells(tab, player);
if (spells.empty())
return std::nullopt;
return spells.front();
}
std::optional<SpellID> GetSpellBookAdjacentAvailableSpell(int tab, const Player &player, SpellID currentSpell, int delta)
{
std::vector<SpellID> 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<size_t>(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

43
Source/panels/spell_book.hpp

@ -1,17 +1,26 @@
#pragma once
#include <string>
#include <expected.hpp>
#include "engine/clx_sprite.hpp"
#include "engine/surface.hpp"
namespace devilution {
tl::expected<void, std::string> InitSpellBook();
void FreeSpellBook();
void CheckSBook();
void DrawSpellBook(const Surface &out);
} // namespace devilution
#pragma once
#include <optional>
#include <string>
#include <vector>
#include <expected.hpp>
#include "engine/clx_sprite.hpp"
#include "engine/surface.hpp"
#include "tables/spelldat.h"
namespace devilution {
struct Player;
tl::expected<void, std::string> InitSpellBook();
void FreeSpellBook();
void CheckSBook();
void DrawSpellBook(const Surface &out);
std::vector<SpellID> GetSpellBookAvailableSpells(int tab, const Player &player);
std::optional<SpellID> GetSpellBookFirstAvailableSpell(int tab, const Player &player);
std::optional<SpellID> GetSpellBookAdjacentAvailableSpell(int tab, const Player &player, SpellID currentSpell, int delta);
} // namespace devilution

36
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)
{

111
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()
{

130
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

597
Source/utils/proximity_audio.cpp

@ -0,0 +1,597 @@
#include "utils/proximity_audio.hpp"
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <memory>
#include <optional>
#include <string_view>
#ifdef USE_SDL3
#include <SDL3/SDL_timer.h>
#else
#include <SDL.h>
#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<std::unique_ptr<TSnd>, 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<CueSound> WeaponItemCue;
std::optional<CueSound> ArmorItemCue;
std::optional<CueSound> GoldItemCue;
std::optional<CueSound> ChestCue;
std::optional<CueSound> DoorCue;
std::optional<CueSound> MonsterCue;
std::optional<CueSound> InteractCue;
std::array<uint32_t, MAXOBJECTS> LastObjectCueTimeMs {};
uint32_t LastMonsterCueTimeMs = 0;
std::optional<uint32_t> 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<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(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<float>(distance) / static_cast<float>(maxDistance), 0.0F, 1.0F);
const float closeness = 1.0F - t;
const float interval = static_cast<float>(MaxIntervalMs) - closeness * static_cast<float>(MaxIntervalMs - MinIntervalMs);
return static_cast<uint32_t>(std::lround(interval));
}
std::optional<CueSound> TryLoadCueSound(std::initializer_list<std::string_view> 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<std::uint8_t>(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<TSnd>();
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<CueSound> &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<int>(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<Candidate> 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<int>(player._pdir);
const int d2 = static_cast<int>(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<int>(maxSteps))
return false;
std::array<int8_t, InteractDistanceTiles> 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<int>(maxSteps);
}
std::optional<InteractTarget> FindInteractTargetInRange(const Player &player, Point playerPosition)
{
int rotations = 5;
std::optional<InteractTarget> 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<int>(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<Candidate> 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<std::pair<int, Point>> nearest;
for (size_t i = 0; i < ActiveMonsterCount; i++) {
const int monsterId = static_cast<int>(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<InteractTarget> target = FindInteractTargetInRange(player, playerPosition);
if (!target) {
LastInteractableId = std::nullopt;
return false;
}
const uint32_t id = target->type == InteractTargetType::Item
? (1U << 16) | static_cast<uint32_t>(target->id)
: (2U << 16) | static_cast<uint32_t>(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

8
Source/utils/proximity_audio.hpp

@ -0,0 +1,8 @@
#pragma once
namespace devilution {
void UpdateProximityAudioCues();
} // namespace devilution

51
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

42
Source/utils/screen_reader.hpp

@ -2,24 +2,24 @@
#include <string_view>
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

266
Source/utils/soundsample.cpp

@ -8,12 +8,13 @@
#ifdef USE_SDL3
#include <SDL3/SDL_error.h>
#include <SDL3/SDL_iostream.h>
#else
#include <Aulib/DecoderDrmp3.h>
#include <Aulib/DecoderDrwav.h>
#include <Aulib/Stream.h>
#include <SDL.h>
#else
#include <Aulib/Decoder.h>
#include <Aulib/DecoderDrmp3.h>
#include <Aulib/DecoderDrwav.h>
#include <Aulib/Stream.h>
#include <SDL.h>
#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<float>(logPan));
}
std::unique_ptr<Aulib::Decoder> CreateDecoder(bool isMp3)
{
if (isMp3)
return std::make_unique<Aulib::DecoderDrmp3>();
return std::make_unique<Aulib::DecoderDrwav>();
}
std::unique_ptr<Aulib::Stream> 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<Aulib::Stream>(handle, std::move(decoder), std::move(resampler), /*closeRw=*/true);
}
std::unique_ptr<Aulib::Decoder> CreateDecoder(bool isMp3)
{
if (isMp3)
return std::make_unique<Aulib::DecoderDrmp3>();
return std::make_unique<Aulib::DecoderDrwav>();
}
class PlaybackRateDecoder final : public Aulib::Decoder {
public:
PlaybackRateDecoder(std::unique_ptr<Aulib::Decoder> 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<int>(std::lround(static_cast<float>(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<std::chrono::microseconds>(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<std::chrono::microseconds>(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<Aulib::Decoder> inner_;
float playbackRate_;
};
std::unique_ptr<Aulib::Stream> CreateStream(SDL_IOStream *handle, bool isMp3, float playbackRate)
{
std::unique_ptr<Aulib::Decoder> decoder;
if (isMp3) {
decoder = std::make_unique<Aulib::DecoderDrmp3>();
} 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<Aulib::DecoderDrwav>();
}
if (playbackRate != 1.0F)
decoder = std::make_unique<PlaybackRateDecoder>(std::move(decoder), playbackRate);
if (!decoder->open(handle)) // open for `getRate`
return nullptr;
auto resampler = CreateAulibResampler(decoder->getRate());
return std::make_unique<Aulib::Stream>(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<Aulib::Stream::Callback>(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<Aulib::Stream::Callback>(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<std::uint8_t> 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<int>(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<std::uint8_t> 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<int>(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)
{

39
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<void(Aulib::Stream &)> &&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<std::uint8_t> 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<std::uint8_t> 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<Aulib::Stream> stream_;

268
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ść"

215
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(
"<Iiiiiii",
0x950412DE, # magic
0, # version
count,
originals_offset,
translations_offset,
0, # hash table size
0, # hash table offset
)
)
for length, offset in offsets_ids:
file.write(struct.pack("<II", length, offset))
for length, offset in offsets_strs:
file.write(struct.pack("<II", length, offset))
file.write(pool)
def main() -> 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())
Loading…
Cancel
Save