You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
454 lines
16 KiB
454 lines
16 KiB
#include "control.hpp" |
|
#include "control_panel.hpp" |
|
|
|
#include "engine/render/primitive_render.hpp" |
|
#include "inv.h" |
|
#include "levels/trigs.h" |
|
#include "panels/partypanel.hpp" |
|
#include "qol/stash.h" |
|
#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" |
|
|
|
namespace devilution { |
|
|
|
StringOrView InfoString; |
|
StringOrView FloatingInfoString; |
|
|
|
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; |
|
|
|
SetPanelObjectPosition(UiPanels::Main, infoBox); |
|
|
|
const auto newLineCount = static_cast<int>(c_count(InfoString.str(), '\n')); |
|
const int spaceIndex = std::min(4, newLineCount); |
|
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; |
|
|
|
if (ShouldSpeakInfoBox()) |
|
SpeakIfChanged(InfoString, LastSpokenInfoString); |
|
|
|
DrawString(out, InfoString, infoBox, |
|
{ |
|
.flags = InfoColor | UiFlags::AlignCenter | UiFlags::VerticalCenter | UiFlags::KerningFitSpacing, |
|
.spacing = 2, |
|
.lineHeight = lineHeight, |
|
}); |
|
} |
|
|
|
Rectangle GetFloatingInfoRect(const int lineHeight, const int textSpacing) |
|
{ |
|
// Calculate the width and height of the floating info box |
|
const std::string txt = std::string(FloatingInfoString); |
|
|
|
auto lines = SplitByChar(txt, '\n'); |
|
const GameFontTables font = GameFont12; |
|
int maxW = 0; |
|
|
|
for (const auto &line : lines) { |
|
const int w = GetLineWidth(line, font, textSpacing, nullptr); |
|
maxW = std::max(maxW, w); |
|
} |
|
|
|
const auto lineCount = 1 + static_cast<int>(c_count(FloatingInfoString.str(), '\n')); |
|
const int totalH = lineCount * lineHeight; |
|
|
|
const Player &player = *InspectPlayer; |
|
|
|
// 1) Equipment (Rect position) |
|
if (pcursinvitem >= INVITEM_HEAD && pcursinvitem < INVITEM_INV_FIRST) { |
|
const int slot = pcursinvitem - INVITEM_HEAD; |
|
static constexpr Point equipLocal[] = { |
|
{ 133, 59 }, |
|
{ 48, 205 }, |
|
{ 249, 205 }, |
|
{ 205, 60 }, |
|
{ 17, 160 }, |
|
{ 248, 160 }, |
|
{ 133, 160 }, |
|
}; |
|
|
|
Point itemPosition = equipLocal[slot]; |
|
auto &item = player.InvBody[slot]; |
|
const Size frame = GetInvItemSize(item._iCurs + CURSOR_FIRSTITEM); |
|
|
|
if (slot == INVLOC_HAND_LEFT) { |
|
itemPosition.x += frame.width == InventorySlotSizeInPixels.width |
|
? InventorySlotSizeInPixels.width |
|
: 0; |
|
itemPosition.y += frame.height == 3 * InventorySlotSizeInPixels.height |
|
? 0 |
|
: -InventorySlotSizeInPixels.height; |
|
} else if (slot == INVLOC_HAND_RIGHT) { |
|
itemPosition.x += frame.width == InventorySlotSizeInPixels.width |
|
? (InventorySlotSizeInPixels.width - 1) |
|
: 1; |
|
itemPosition.y += frame.height == 3 * InventorySlotSizeInPixels.height |
|
? 0 |
|
: -InventorySlotSizeInPixels.height; |
|
} |
|
|
|
itemPosition.y++; // Align position to bottom left of the item graphic |
|
itemPosition.x += frame.width / 2; // Align position to center of the item graphic |
|
itemPosition.x -= maxW / 2; // Align position to the center of the floating item info box |
|
|
|
const Point screen = GetPanelPosition(UiPanels::Inventory, itemPosition); |
|
|
|
return { { screen.x, screen.y }, { maxW, totalH } }; |
|
} |
|
|
|
// 2) Inventory grid (Rect position) |
|
if (pcursinvitem >= INVITEM_INV_FIRST && pcursinvitem < INVITEM_INV_FIRST + InventoryGridCells) { |
|
const int itemIdx = pcursinvitem - INVITEM_INV_FIRST; |
|
|
|
for (int j = 0; j < InventoryGridCells; ++j) { |
|
if (player.InvGrid[j] > 0 && player.InvGrid[j] - 1 == itemIdx) { |
|
const Item &it = player.InvList[itemIdx]; |
|
Point itemPosition = InvRect[j + SLOTXY_INV_FIRST].position; |
|
|
|
itemPosition.x += GetInventorySize(it).width * InventorySlotSizeInPixels.width / 2; // Align position to center of the item graphic |
|
itemPosition.x -= maxW / 2; // Align position to the center of the floating item info box |
|
|
|
const Point screen = GetPanelPosition(UiPanels::Inventory, itemPosition); |
|
|
|
return { { screen.x, screen.y }, { maxW, totalH } }; |
|
} |
|
} |
|
} |
|
|
|
// 3) Belt (Rect position) |
|
if (pcursinvitem >= INVITEM_BELT_FIRST && pcursinvitem < INVITEM_BELT_FIRST + MaxBeltItems) { |
|
const int itemIdx = pcursinvitem - INVITEM_BELT_FIRST; |
|
for (int i = 0; i < MaxBeltItems; ++i) { |
|
if (player.SpdList[i].isEmpty()) |
|
continue; |
|
if (i != itemIdx) |
|
continue; |
|
|
|
const Item &item = player.SpdList[i]; |
|
Point itemPosition = InvRect[i + SLOTXY_BELT_FIRST].position; |
|
|
|
itemPosition.x += GetInventorySize(item).width * InventorySlotSizeInPixels.width / 2; // Align position to center of the item graphic |
|
itemPosition.x -= maxW / 2; // Align position to the center of the floating item info box |
|
|
|
const Point screen = GetMainPanel().position + Displacement { itemPosition.x, itemPosition.y }; |
|
|
|
return { { screen.x, screen.y }, { maxW, totalH } }; |
|
} |
|
} |
|
|
|
// 4) Stash (Rect position) |
|
if (pcursstashitem != StashStruct::EmptyCell) { |
|
for (auto slot : StashGridRange) { |
|
auto itemId = Stash.GetItemIdAtPosition(slot); |
|
if (itemId == StashStruct::EmptyCell) |
|
continue; |
|
if (itemId != pcursstashitem) |
|
continue; |
|
|
|
const Item &item = Stash.stashList[itemId]; |
|
Point itemPosition = GetStashSlotCoord(slot); |
|
const Size itemGridSize = GetInventorySize(item); |
|
|
|
itemPosition.y += itemGridSize.height * (InventorySlotSizeInPixels.height + 1) - 1; // Align position to bottom left of the item graphic |
|
itemPosition.x += itemGridSize.width * InventorySlotSizeInPixels.width / 2; // Align position to center of the item graphic |
|
itemPosition.x -= maxW / 2; // Align position to the center of the floating item info box |
|
|
|
return { { itemPosition.x, itemPosition.y }, { maxW, totalH } }; |
|
} |
|
} |
|
|
|
return { { 0, 0 }, { 0, 0 } }; |
|
} |
|
|
|
int GetHoverSpriteHeight() |
|
{ |
|
if (pcursinvitem >= INVITEM_HEAD && pcursinvitem < INVITEM_INV_FIRST) { |
|
auto &it = (*InspectPlayer).InvBody[pcursinvitem - INVITEM_HEAD]; |
|
return GetInvItemSize(it._iCurs + CURSOR_FIRSTITEM).height + 1; |
|
} |
|
if (pcursinvitem >= INVITEM_INV_FIRST |
|
&& pcursinvitem < INVITEM_INV_FIRST + InventoryGridCells) { |
|
const int idx = pcursinvitem - INVITEM_INV_FIRST; |
|
auto &it = (*InspectPlayer).InvList[idx]; |
|
return GetInventorySize(it).height * (InventorySlotSizeInPixels.height + 1) |
|
- InventorySlotSizeInPixels.height; |
|
} |
|
if (pcursinvitem >= INVITEM_BELT_FIRST |
|
&& pcursinvitem < INVITEM_BELT_FIRST + MaxBeltItems) { |
|
const int idx = pcursinvitem - INVITEM_BELT_FIRST; |
|
auto &it = (*InspectPlayer).SpdList[idx]; |
|
return GetInventorySize(it).height * (InventorySlotSizeInPixels.height + 1) |
|
- InventorySlotSizeInPixels.height - 1; |
|
} |
|
if (pcursstashitem != StashStruct::EmptyCell) { |
|
auto &it = Stash.stashList[pcursstashitem]; |
|
return GetInventorySize(it).height * (InventorySlotSizeInPixels.height + 1); |
|
} |
|
return InventorySlotSizeInPixels.height; |
|
} |
|
|
|
int ClampAboveOrBelow(int anchorY, int spriteH, int boxH, int pad, int linePad) |
|
{ |
|
const int yAbove = anchorY - spriteH - boxH - pad; |
|
const int yBelow = anchorY + linePad / 2 + pad; |
|
return (yAbove >= 0) ? yAbove : yBelow; |
|
} |
|
|
|
void PrintFloatingInfo(const Surface &out) |
|
{ |
|
if (ChatFlag) |
|
return; |
|
if (FloatingInfoString.empty()) |
|
return; |
|
|
|
const int verticalSpacing = 3; |
|
const int lineHeight = 12 + verticalSpacing; |
|
const int textSpacing = 2; |
|
const int hPadding = 5; |
|
const int vPadding = 4; |
|
|
|
Rectangle floatingInfoBox = GetFloatingInfoRect(lineHeight, textSpacing); |
|
|
|
// Prevent the floating info box from going off-screen horizontally |
|
floatingInfoBox.position.x = std::clamp(floatingInfoBox.position.x, hPadding, GetScreenWidth() - (floatingInfoBox.size.width + hPadding)); |
|
|
|
const int spriteH = GetHoverSpriteHeight(); |
|
const int anchorY = floatingInfoBox.position.y; |
|
|
|
// Prevent the floating info box from going off-screen vertically |
|
floatingInfoBox.position.y = ClampAboveOrBelow(anchorY, spriteH, floatingInfoBox.size.height, vPadding, verticalSpacing); |
|
|
|
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); |
|
|
|
DrawString(out, FloatingInfoString, floatingInfoBox, |
|
{ |
|
.flags = InfoColor | UiFlags::AlignCenter | UiFlags::VerticalCenter, |
|
.spacing = textSpacing, |
|
.lineHeight = lineHeight, |
|
}); |
|
} |
|
|
|
} // namespace |
|
|
|
void AddInfoBoxString(std::string_view str, bool floatingBox /*= false*/) |
|
{ |
|
StringOrView &infoString = floatingBox ? FloatingInfoString : InfoString; |
|
|
|
if (infoString.empty()) |
|
infoString = str; |
|
else |
|
infoString = StrCat(infoString, "\n", str); |
|
} |
|
|
|
void AddInfoBoxString(std::string &&str, bool floatingBox /*= false*/) |
|
{ |
|
StringOrView &infoString = floatingBox ? FloatingInfoString : InfoString; |
|
|
|
if (infoString.empty()) |
|
infoString = std::move(str); |
|
else |
|
infoString = StrCat(infoString, "\n", str); |
|
} |
|
|
|
void CheckPanelInfo() |
|
{ |
|
MainPanelFlag = false; |
|
InfoString = StringOrView {}; |
|
FloatingInfoString = StringOrView {}; |
|
|
|
const int totalButtons = IsChatAvailable() ? TotalMpMainPanelButtons : TotalSpMainPanelButtons; |
|
|
|
for (int i = 0; i < totalButtons; i++) { |
|
Rectangle button = MainPanelButtonRect[i]; |
|
|
|
SetPanelObjectPosition(UiPanels::Main, button); |
|
|
|
if (button.contains(MousePosition)) { |
|
if (i != 7) { |
|
InfoString = _(PanBtnStr[i]); |
|
} else { |
|
if (MyPlayer->friendlyMode) |
|
InfoString = _("Player friendly"); |
|
else |
|
InfoString = _("Player attack"); |
|
} |
|
if (PanBtnHotKey[i] != nullptr) { |
|
AddInfoBoxString(fmt::format(fmt::runtime(_("Hotkey: {:s}")), _(PanBtnHotKey[i]))); |
|
} |
|
InfoColor = UiFlags::ColorWhite; |
|
MainPanelFlag = true; |
|
} |
|
} |
|
|
|
Rectangle spellSelectButton = SpellButtonRect; |
|
|
|
SetPanelObjectPosition(UiPanels::Main, spellSelectButton); |
|
|
|
if (!SpellSelectFlag && spellSelectButton.contains(MousePosition)) { |
|
InfoString = _("Select current spell button"); |
|
InfoColor = UiFlags::ColorWhite; |
|
MainPanelFlag = true; |
|
AddInfoBoxString(_("Hotkey: 's'")); |
|
const Player &myPlayer = *MyPlayer; |
|
const SpellID spellId = myPlayer._pRSpell; |
|
if (IsValidSpell(spellId)) { |
|
switch (myPlayer._pRSplType) { |
|
case SpellType::Skill: |
|
AddInfoBoxString(fmt::format(fmt::runtime(_("{:s} Skill")), pgettext("spell", GetSpellData(spellId).sNameText))); |
|
break; |
|
case SpellType::Spell: { |
|
AddInfoBoxString(fmt::format(fmt::runtime(_("{:s} Spell")), pgettext("spell", GetSpellData(spellId).sNameText))); |
|
const int spellLevel = myPlayer.GetSpellLevel(spellId); |
|
AddInfoBoxString(spellLevel == 0 ? _("Spell Level 0 - Unusable") : fmt::format(fmt::runtime(_("Spell Level {:d}")), spellLevel)); |
|
} break; |
|
case SpellType::Scroll: { |
|
AddInfoBoxString(fmt::format(fmt::runtime(_("Scroll of {:s}")), pgettext("spell", GetSpellData(spellId).sNameText))); |
|
const int scrollCount = c_count_if(InventoryAndBeltPlayerItemsRange { myPlayer }, [spellId](const Item &item) { |
|
return item.isScrollOf(spellId); |
|
}); |
|
AddInfoBoxString(fmt::format(fmt::runtime(ngettext("{:d} Scroll", "{:d} Scrolls", scrollCount)), scrollCount)); |
|
} break; |
|
case SpellType::Charges: |
|
AddInfoBoxString(fmt::format(fmt::runtime(_("Staff of {:s}")), pgettext("spell", GetSpellData(spellId).sNameText))); |
|
AddInfoBoxString(fmt::format(fmt::runtime(ngettext("{:d} Charge", "{:d} Charges", myPlayer.InvBody[INVLOC_HAND_LEFT]._iCharges)), myPlayer.InvBody[INVLOC_HAND_LEFT]._iCharges)); |
|
break; |
|
case SpellType::Invalid: |
|
break; |
|
} |
|
} |
|
} |
|
|
|
Rectangle belt = BeltRect; |
|
|
|
SetPanelObjectPosition(UiPanels::Main, belt); |
|
|
|
if (belt.contains(MousePosition)) |
|
pcursinvitem = CheckInvHLight(); |
|
|
|
if (CheckXPBarInfo()) |
|
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()) { |
|
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)); |
|
} else if (!myPlayer.CanUseItem(myPlayer.HoldItem)) { |
|
InfoString = _("Requirements not met"); |
|
} else { |
|
InfoString = myPlayer.HoldItem.getName(); |
|
InfoColor = myPlayer.HoldItem.getTextColor(); |
|
} |
|
} else { |
|
if (pcursitem != -1) |
|
GetItemStr(Items[pcursitem]); |
|
else if (ObjectUnderCursor != nullptr) |
|
GetObjectStr(*ObjectUnderCursor); |
|
if (pcursmonst != -1) { |
|
if (leveltype != DTYPE_TOWN) { |
|
const Monster &monster = Monsters[pcursmonst]; |
|
InfoColor = UiFlags::ColorWhite; |
|
InfoString = monster.name(); |
|
if (monster.isUnique()) { |
|
InfoColor = UiFlags::ColorWhitegold; |
|
PrintUniqueHistory(); |
|
} else { |
|
PrintMonstHistory(monster.type().type); |
|
} |
|
} else if (pcursitem == -1) { |
|
InfoString = std::string_view(Towners[pcursmonst].name); |
|
} |
|
} |
|
if (PlayerUnderCursor != nullptr) { |
|
InfoColor = UiFlags::ColorWhitegold; |
|
const auto &target = *PlayerUnderCursor; |
|
InfoString = std::string_view(target._pName); |
|
AddInfoBoxString(fmt::format(fmt::runtime(_("{:s}, Level: {:d}")), target.getClassName(), target.getCharacterLevel())); |
|
AddInfoBoxString(fmt::format(fmt::runtime(_("Hit Points {:d} of {:d}")), target._pHitPoints >> 6, target._pMaxHP >> 6)); |
|
} |
|
if (PortraitIdUnderCursor != -1) { |
|
InfoColor = UiFlags::ColorWhitegold; |
|
auto &target = Players[PortraitIdUnderCursor]; |
|
InfoString = std::string_view(target._pName); |
|
AddInfoBoxString(_("Right click to inspect")); |
|
} |
|
} |
|
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
|
|
|