Browse Source

[QOL] The buy and sell functionality of NPC stores is now visually driven to align with the stash and Diablo 2 style of trading (#8434)

- Add repair buttons (including “Repair All”) with proper alignment, tooltips, and cursor hit areas
- Improve controller support:
- Fix grid snapping and cursor positioning across panels
- Allow pressing/releasing UI buttons
- Fix item selling and movement between panels (inventory, belt, store)
- Prevent invalid interactions:
- Disable tab/repair navigation while holding items
- Hide unavailable options for non-Smith vendors
- Fix UI issues:
- Item misalignment and snapping
- Floating gold cost display
- Level-up button overlapping store panel
- Fix vendor-specific issues:
- Correct tooltip for non-Smith vendors
- Clean up Adria dialog options
- Fix stability issues:
- Resolve segfault when selling items
- Refactor and cleanup:
- Unify store sell logic
- Remove unused and stale code
- Improve IsPlayerInStore() logic
- General bug fixes and UI interaction improvements
---------

Co-authored-by: Yuri Pourre <yuripourre@users.noreply.github.com>
pull/8373/merge
Scott Richmond 21 hours ago committed by GitHub
parent
commit
0ecc3edaed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CMake/Assets.cmake
  2. 1
      Source/CMakeLists.txt
  3. 33
      Source/control/control_infobox.cpp
  4. 10
      Source/control/control_panel.cpp
  5. 11
      Source/controls/game_controls.cpp
  6. 426
      Source/controls/plrctrls.cpp
  7. 4
      Source/controls/plrctrls.h
  8. 6
      Source/cursor.cpp
  9. 26
      Source/diablo.cpp
  10. 7
      Source/engine/render/scrollrt.cpp
  11. 22
      Source/inv.cpp
  12. 2
      Source/options.cpp
  13. 2
      Source/options.h
  14. 826
      Source/qol/visual_store.cpp
  15. 188
      Source/qol/visual_store.h
  16. 215
      Source/stores.cpp
  17. 24
      Source/stores.h
  18. BIN
      assets/data/repairAllBtn.clx
  19. BIN
      assets/data/repairSingleBtn.clx
  20. BIN
      assets/data/store.clx
  21. BIN
      assets/data/tabBtnUp.clx

4
CMake/Assets.cmake

@ -62,9 +62,13 @@ set(devilutionx_assets
data/monstertags.clx
data/panel8buc.clx
data/panel8bucp.clx
data/repairAllBtn.clx
data/repairSingleBtn.clx
data/resistance.clx
data/stash.clx
data/stashnavbtns.clx
data/store.clx
data/tabBtnUp.clx
data/talkbutton.clx
data/xpbar.clx
fonts/12-00.clx

1
Source/CMakeLists.txt

@ -150,6 +150,7 @@ set(libdevilutionx_SRCS
qol/itemlabels.cpp
qol/monhealthbar.cpp
qol/stash.cpp
qol/visual_store.cpp
qol/xpbar.cpp
quests/validation.cpp

33
Source/control/control_infobox.cpp

@ -6,6 +6,7 @@
#include "levels/trigs.h"
#include "panels/partypanel.hpp"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "qol/xpbar.h"
#include "towners.h"
#include "utils/algorithm/container.hpp"
@ -174,6 +175,29 @@ Rectangle GetFloatingInfoRect(const int lineHeight, const int textSpacing)
}
}
// 5) Visual Store (Rect position)
if (pcursstoreitem != -1) {
const VisualStorePage &page = VisualStore.pages[VisualStore.currentPage];
std::span<Item> allItems = GetVisualStoreItems();
for (const auto &vsItem : page.items) {
if (vsItem.index != pcursstoreitem)
continue;
const Item &item = allItems[vsItem.index];
Point itemPosition = GetVisualStoreSlotCoord(vsItem.position);
const Size itemGridSize = GetInventorySize(item);
itemPosition.y += itemGridSize.height * (VisualStoreGridHeight + 1) - 1; // Align position to bottom left of the item graphic
itemPosition.x += itemGridSize.width * VisualStoreGridWidth / 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 } };
}
}
if (pcursstorebtn != -1) {
return { GetVisualBtnCoord(pcursstorebtn).position, { maxW, totalH } };
}
return { { 0, 0 }, { 0, 0 } };
}
@ -201,6 +225,11 @@ int GetHoverSpriteHeight()
auto &it = Stash.stashList[pcursstashitem];
return GetInventorySize(it).height * (InventorySlotSizeInPixels.height + 1);
}
if (pcursstoreitem != -1) {
std::span<Item> allItems = GetVisualStoreItems();
auto &it = allItems[pcursstoreitem];
return GetInventorySize(it).height * (INV_SLOT_SIZE_PX + 1);
}
return InventorySlotSizeInPixels.height;
}
@ -356,7 +385,7 @@ void CheckPanelInfo()
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) {
if (!MainPanelFlag && !trigflag && pcursinvitem == -1 && pcursstashitem == StashStruct::EmptyCell && pcursstoreitem == -1 && pcursstorebtn == -1 && !SpellSelectFlag && pcurs != CURSOR_HOURGLASS) {
InfoString = StringOrView {};
InfoColor = UiFlags::ColorWhite;
}
@ -413,7 +442,7 @@ void DrawInfoBox(const Surface &out)
void DrawFloatingInfoBox(const Surface &out)
{
if (pcursinvitem == -1 && pcursstashitem == StashStruct::EmptyCell) {
if (pcursinvitem == -1 && pcursstashitem == StashStruct::EmptyCell && pcursstoreitem == -1 && pcursstorebtn == -1) {
FloatingInfoString = StringOrView {};
InfoColor = UiFlags::ColorWhite;
}

10
Source/control/control_panel.cpp

@ -23,6 +23,7 @@
#include "panels/spell_list.hpp"
#include "pfile.h"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "stores.h"
#include "utils/sdl_compat.h"
@ -59,7 +60,7 @@ const Rectangle &GetRightPanel()
}
bool IsLeftPanelOpen()
{
return CharFlag || QuestLogIsOpen || IsStashOpen;
return CharFlag || QuestLogIsOpen || IsStashOpen || IsVisualStoreOpen;
}
bool IsRightPanelOpen()
{
@ -223,7 +224,7 @@ bool IsLevelUpButtonVisible()
if (ControlMode == ControlTypes::VirtualGamepad) {
return false;
}
if (IsPlayerInStore() || IsStashOpen) {
if (IsPlayerInStore() || IsStashOpen || IsVisualStoreOpen) {
return false;
}
if (QuestLogIsOpen && GetLeftPanel().contains(GetMainPanel().position + Displacement { 0, -74 })) {
@ -300,6 +301,7 @@ void OpenCharPanel()
QuestLogIsOpen = false;
CloseGoldWithdraw();
CloseStash();
CloseVisualStore();
CharFlag = true;
}
@ -565,6 +567,7 @@ void CheckMainPanelButtonUp()
CloseCharPanel();
CloseGoldWithdraw();
CloseStash();
CloseVisualStore();
if (!QuestLogIsOpen)
StartQuestlog();
else
@ -593,9 +596,10 @@ void CheckMainPanelButtonUp()
break;
case PanelButtonInventory:
SpellbookFlag = false;
invflag = !invflag;
CloseGoldWithdraw();
CloseStash();
invflag = !invflag;
CloseVisualStore();
CloseGoldDrop();
break;
case PanelButtonSpellbook:

11
Source/controls/game_controls.cpp

@ -181,6 +181,10 @@ bool GetGameAction(const SDL_Event &event, ControllerButtonEvent ctrlEvent, Game
if ((!VirtualGamepadState.primaryActionButton.isHeld && ControllerActionHeld == GameActionType_PRIMARY_ACTION)
|| (!VirtualGamepadState.secondaryActionButton.isHeld && ControllerActionHeld == GameActionType_SECONDARY_ACTION)
|| (!VirtualGamepadState.spellActionButton.isHeld && ControllerActionHeld == GameActionType_CAST_SPELL)) {
// Handle button release for visual store buttons
if (ControllerActionHeld == GameActionType_PRIMARY_ACTION) {
PerformPrimaryActionRelease();
}
ControllerActionHeld = GameActionType_NONE;
LastPlayerAction = PlayerActionType::None;
}
@ -399,6 +403,13 @@ bool HandleControllerButtonEvent(const SDL_Event &event, const ControllerButtonE
if (ctrlEvent.up && !PadmapperActionNameTriggeredByButtonEvent(ctrlEvent).empty()) {
// Button press may have brought up a menu;
// don't confuse release of that button with intent to interact with the menu
// Handle visual store button release for physical gamepad
std::string_view actionName = PadmapperActionNameTriggeredByButtonEvent(ctrlEvent);
if (actionName == "PrimaryAction") {
PerformPrimaryActionRelease();
}
PadmapperRelease(ctrlEvent.button, /*invokeAction=*/true);
return true;
} else if (GetGameAction(event, ctrlEvent, &action)) {

426
Source/controls/plrctrls.cpp

@ -46,6 +46,7 @@
#include "panels/ui_panels.hpp"
#include "qol/chatlog.h"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "stores.h"
#include "towners.h"
#include "track.h"
@ -79,12 +80,20 @@ bool InGameMenu()
|| (MyPlayer != nullptr && MyPlayer->_pInvincible && MyPlayer->hasNoLife());
}
// Forward declaration for use in anonymous namespace
void FocusOnVisualStore();
namespace {
int Slot = SLOTXY_INV_FIRST;
Point ActiveStashSlot = InvalidStashPoint;
Point VisualStoreSlot = { 0, 0 };
int PreviousInventoryColumn = -1;
bool BeltReturnsToStash = false;
bool BeltReturnsToVisualStore = false;
// Forward declaration for use in VisualStoreMove
void InventoryMove(AxisDirection dir);
const Direction FaceDir[3][3] = {
// NONE UP DOWN
@ -906,6 +915,305 @@ void LiftStashItem()
SetCursorPos(mousePos);
}
/**
* @brief Logic for moving within a grid (Stash or Visual Store) with item skipping.
* @return true if the move was successful within the grid, false if it hit a boundary.
*/
static bool GridMove(Point &pos, AxisDirection dir, Size gridSize, Size movingItemSize, bool isHoldingItem, const std::function<int(Point)> &getCellId)
{
const int cellId = getCellId(pos);
if (dir.x == AxisDirectionX_LEFT) {
if (pos.x > 0) {
pos.x--;
if (!isHoldingItem && cellId != 0) {
while (pos.x > 0 && getCellId(pos) == cellId) {
pos.x--;
}
}
return true;
}
} else if (dir.x == AxisDirectionX_RIGHT) {
if (pos.x < gridSize.width - movingItemSize.width) {
pos.x++;
if (!isHoldingItem && cellId != 0) {
while (pos.x < gridSize.width - 1 && getCellId(pos) == cellId) {
pos.x++;
}
}
return true;
}
} else if (dir.y == AxisDirectionY_UP) {
if (pos.y > 0) {
pos.y--;
if (!isHoldingItem && cellId != 0) {
while (pos.y > 0 && getCellId(pos) == cellId) {
pos.y--;
}
}
return true;
}
} else if (dir.y == AxisDirectionY_DOWN) {
if (pos.y < gridSize.height - movingItemSize.height) {
pos.y++;
if (!isHoldingItem && cellId != 0) {
while (pos.y < gridSize.height - 1 && getCellId(pos) == cellId) {
pos.y++;
}
}
return true;
}
}
return false;
}
void VisualStoreMove(AxisDirection dir)
{
static AxisDirectionRepeater repeater(/*min_interval_ms=*/150);
dir = repeater.Get(dir);
if (dir.x == AxisDirectionX_NONE && dir.y == AxisDirectionY_NONE)
return;
// Check if we're currently in inventory mode (similar to StashMove)
if (Slot >= 0) {
// Check if we need to jump from belt to visual store (similar to stash)
if (dir.y == AxisDirectionY_UP) {
Size itemSize = MyPlayer->HoldItem.isEmpty() ? Size { 1, 1 } : GetInventorySize(MyPlayer->HoldItem);
if (BeltReturnsToVisualStore && Slot >= SLOTXY_BELT_FIRST && Slot <= SLOTXY_BELT_LAST) {
const int beltSlot = Slot - SLOTXY_BELT_FIRST;
InvalidateInventorySlot();
Point mousePos;
// Only go to repair buttons if belt slot is one of the first 2 (matching repair button positions)
if (beltSlot < 2 && VisualStore.vendor == VisualStoreVendor::Smith) {
VisualStoreSlot = { beltSlot, VisualStoreGridHeight }; // Repair buttons (x=0 or x=1)
// Use button coordinate function for repair buttons
int btnId = VisualStoreSlot.x == 0 ? 2 : 3; // 2=RepairAll, 3=Repair
mousePos = GetVisualBtnCoord(btnId).Center();
} else {
// Calculate visual store position based on belt slot
// Match stash behavior: position at bottom of grid minus item height
VisualStoreSlot = { 2 + beltSlot, VisualStoreGridHeight - itemSize.height };
const Point slotPos = GetVisualStoreSlotCoord(VisualStoreSlot);
mousePos = slotPos + Displacement { itemSize.width * INV_SLOT_HALF_SIZE_PX, itemSize.height * INV_SLOT_HALF_SIZE_PX };
}
SetCursorPos(mousePos);
BeltReturnsToVisualStore = false;
return;
}
}
// We're in inventory - check if we should transition back to visual store
if (dir.x == AxisDirectionX_LEFT) {
int firstSlot = Slot;
if (Slot >= SLOTXY_INV_FIRST && Slot <= SLOTXY_INV_LAST) {
if (MyPlayer->HoldItem.isEmpty()) {
const int8_t itemId = GetItemIdOnSlot(Slot);
if (itemId != 0) {
firstSlot = FindFirstSlotOnItem(itemId);
}
}
}
// If we're in the leftmost column or left side of body, move back to visual store
if (IsAnyOf(firstSlot, SLOTXY_HEAD, SLOTXY_HAND_LEFT, SLOTXY_RING_LEFT, SLOTXY_AMULET, SLOTXY_CHEST,
SLOTXY_INV_ROW1_FIRST, SLOTXY_INV_ROW2_FIRST, SLOTXY_INV_ROW3_FIRST, SLOTXY_INV_ROW4_FIRST)) {
InvalidateInventorySlot();
// Focus on rightmost column of visual store (closest to inventory)
// Preserve vertical position based on inventory slot (similar to stash behavior)
Size itemSize = MyPlayer->HoldItem.isEmpty() ? Size { 1, 1 } : GetInventorySize(MyPlayer->HoldItem);
const Point invSlotCoord = GetSlotCoord(Slot);
const Point vsSlotPos = GetVisualStoreSlotCoord({ VisualStoreGridWidth - itemSize.width, 0 });
// Calculate the Y offset from the inventory slot to preserve vertical position
int targetY = std::clamp(static_cast<int>((invSlotCoord.y - vsSlotPos.y) / InventorySlotSizeInPixels.height), 0, VisualStoreGridHeight - 1);
VisualStoreSlot = { VisualStoreGridWidth - 1, targetY };
const Point slotPos = GetVisualStoreSlotCoord(VisualStoreSlot);
// Account for held item size when positioning cursor
SetCursorPos(slotPos + Displacement { itemSize.width * INV_SLOT_HALF_SIZE_PX, itemSize.height * INV_SLOT_HALF_SIZE_PX });
return;
}
}
// Delegate all inventory movement to InventoryMove
InventoryMove(dir);
return;
}
// We're in visual store mode - handle visual store navigation
const Size gridSize { VisualStoreGridWidth, VisualStoreGridHeight };
const bool isHoldingItem = !MyPlayer->HoldItem.isEmpty();
Size movingItemSize = { 1, 1 };
if (isHoldingItem) {
movingItemSize = GetInventorySize(MyPlayer->HoldItem);
} else if (VisualStoreSlot.y != -1 && VisualStoreSlot.y != VisualStoreGridHeight) {
const int itemIdx = VisualStore.pages[VisualStore.currentPage].grid[VisualStoreSlot.x][VisualStoreSlot.y];
if (itemIdx > 0) {
std::span<Item> items = GetVisualStoreItems();
if (itemIdx - 1 < static_cast<int>(items.size())) {
movingItemSize = GetInventorySize(items[itemIdx - 1]);
}
}
}
auto getCellId = [&](Point p) -> int {
return VisualStore.pages[VisualStore.currentPage].grid[p.x][p.y];
};
if (dir.x == AxisDirectionX_RIGHT) {
if (VisualStoreSlot.y == -1) { // Tabs
if (VisualStoreSlot.x == 0 && VisualStore.vendor == VisualStoreVendor::Smith) {
VisualStoreSlot.x = 1;
} else {
// Transition to inventory
VisualStoreSlot = { -1, -1 }; // Invalidate visual store slot
FocusOnInventory();
return;
}
} else if (VisualStoreSlot.y == VisualStoreGridHeight) { // Repair buttons
if (VisualStoreSlot.x == 0) {
VisualStoreSlot.x = 1;
} else {
// Transition to inventory
VisualStoreSlot = { -1, -1 }; // Invalidate visual store slot
FocusOnInventory();
return;
}
} else {
// Grid
if (!GridMove(VisualStoreSlot, dir, gridSize, movingItemSize, isHoldingItem, getCellId)) {
// Transition to inventory with smart positioning (similar to stash)
const Point vsSlotCoord = GetVisualStoreSlotCoord(VisualStoreSlot);
const Point rightPanelCoord = { GetRightPanel().position.x, vsSlotCoord.y };
Slot = FindClosestInventorySlot(rightPanelCoord, MyPlayer->HoldItem, [](Point mousePos, int slot) {
const Point slotPos = GetSlotCoord(slot);
// Exaggerate the vertical difference so that moving from the top rows of the
// visual store is more likely to land on a body slot
return std::abs(mousePos.y - slotPos.y) * 3 + std::abs(mousePos.x - slotPos.x);
});
VisualStoreSlot = { -1, -1 }; // Invalidate visual store slot
BeltReturnsToVisualStore = false;
ResetInvCursorPosition();
return;
}
}
} else if (dir.x == AxisDirectionX_LEFT) {
if (VisualStoreSlot.y == -1 || VisualStoreSlot.y == VisualStoreGridHeight) {
if (VisualStoreSlot.x > 0)
VisualStoreSlot.x--;
} else {
GridMove(VisualStoreSlot, dir, gridSize, movingItemSize, isHoldingItem, getCellId);
}
}
if (dir.y == AxisDirectionY_UP) {
if (VisualStoreSlot.y == -1) {
// Already at tabs
} else if (VisualStoreSlot.y == VisualStoreGridHeight) {
// From repair buttons to grid
VisualStoreSlot.y = VisualStoreGridHeight - 1;
} else {
if (!GridMove(VisualStoreSlot, dir, gridSize, movingItemSize, isHoldingItem, getCellId)) {
// Move to tabs
if (!isHoldingItem) {
VisualStoreSlot.y = -1;
VisualStoreSlot.x = 0; // Default to first tab
}
}
}
} else if (dir.y == AxisDirectionY_DOWN) {
if (VisualStoreSlot.y == -1) {
// From tabs to grid
VisualStoreSlot.y = 0;
} else if (VisualStoreSlot.y == VisualStoreGridHeight) {
// From repair buttons to belt (only for repair buttons at x=0 and x=1)
if (VisualStore.vendor == VisualStoreVendor::Smith) {
const Item &heldItem = MyPlayer->HoldItem;
if (heldItem.isEmpty() || CanBePlacedOnBelt(*MyPlayer, heldItem)) {
// Map repair button x position to corresponding belt slot
Slot = SLOTXY_BELT_FIRST + VisualStoreSlot.x;
VisualStoreSlot = { -1, -1 }; // Invalidate visual store slot
BeltReturnsToVisualStore = true;
ResetInvCursorPosition();
return;
}
}
// Otherwise go to inventory
VisualStoreSlot = { -1, -1 }; // Invalidate visual store slot
BeltReturnsToVisualStore = false;
FocusOnInventory();
return;
} else {
if (!GridMove(VisualStoreSlot, dir, gridSize, movingItemSize, isHoldingItem, getCellId)) {
// Move to repair buttons (if Smith vendor) or belt
// Always go to repair buttons first if Smith vendor, regardless of holding item
if (MyPlayer->HoldItem.isEmpty() && VisualStore.vendor == VisualStoreVendor::Smith) {
VisualStoreSlot.y = VisualStoreGridHeight;
VisualStoreSlot.x = 0; // Default to Repair All
} else if ((MyPlayer->HoldItem.isEmpty() || CanBePlacedOnBelt(*MyPlayer, MyPlayer->HoldItem)) && VisualStoreSlot.x > 1) {
// Non-Smith vendors: go directly to belt if item can be placed
// Match stash behavior: only columns x > 1 can access belt
const int beltSlot = VisualStoreSlot.x - 2;
Slot = SLOTXY_BELT_FIRST + beltSlot;
VisualStoreSlot = { -1, -1 }; // Invalidate visual store slot
BeltReturnsToVisualStore = true;
ResetInvCursorPosition();
return;
}
}
}
}
// Calculate cursor position
Point mousePos;
if (VisualStoreSlot.y == -1) {
// Tabs
// 0: Basic, 1: Premium
// Map to button IDs: TabButtonBasic=0, TabButtonPremium=1
int btnId = VisualStoreSlot.x == 0 ? 0 : 1;
mousePos = GetVisualBtnCoord(btnId).Center();
} else if (VisualStoreSlot.y == VisualStoreGridHeight) {
// Repair buttons
// 0: Repair All, 1: Repair
// Map to button IDs: RepairAllBtn=2, RepairBtn=3
int btnId = VisualStoreSlot.x == 0 ? 2 : 3;
mousePos = GetVisualBtnCoord(btnId).Center();
} else {
// Grid
Size itemSize = isHoldingItem ? GetInventorySize(MyPlayer->HoldItem) : Size { 1 };
Point displayPos = VisualStoreSlot;
// If hovering over an item (and not holding one), center on that item
if (!isHoldingItem) {
const int itemIdx = VisualStore.pages[VisualStore.currentPage].grid[VisualStoreSlot.x][VisualStoreSlot.y];
if (itemIdx > 0) {
std::span<Item> items = GetVisualStoreItems();
if (itemIdx - 1 < static_cast<int>(items.size())) {
const Item &item = items[itemIdx - 1];
itemSize = GetInventorySize(item);
// Find the top-left of this item (which is stored in the VisualStorePage items list)
for (const auto &vsItem : VisualStore.pages[VisualStore.currentPage].items) {
if (vsItem.index == itemIdx - 1) {
// Item positions in VisualStorePage are stored as bottom-left
// Convert to top-left for display/cursor calculation
displayPos = vsItem.position - Displacement { 0, itemSize.height - 1 };
break;
}
}
// Sync the logical slot to the top-left of the item to ensure consistent navigation
VisualStoreSlot = displayPos;
mousePos = GetVisualStoreSlotCoord(displayPos) + Displacement { (itemSize.width * INV_SLOT_HALF_SIZE_PX), (itemSize.height * INV_SLOT_HALF_SIZE_PX) };
}
}
}
mousePos = GetVisualStoreSlotCoord(displayPos) + Displacement { (itemSize.width * INV_SLOT_HALF_SIZE_PX), (itemSize.height * INV_SLOT_HALF_SIZE_PX) };
}
SetCursorPos(mousePos);
}
/**
* @brief Figures out where on the body to move when on the first row
*/
@ -1243,10 +1551,16 @@ void StashMove(AxisDirection dir)
// If we're in the leftmost column (or hovering over an item on the left side of the inventory) or
// left side of the body and we're moving left we need to move into the closest stash column
if (IsAnyOf(firstSlot, SLOTXY_HEAD, SLOTXY_HAND_LEFT, SLOTXY_RING_LEFT, SLOTXY_AMULET, SLOTXY_CHEST, SLOTXY_INV_ROW1_FIRST, SLOTXY_INV_ROW2_FIRST, SLOTXY_INV_ROW3_FIRST, SLOTXY_INV_ROW4_FIRST)) {
const Point slotCoord = GetSlotCoord(Slot);
InvalidateInventorySlot();
ActiveStashSlot = FindClosestStashSlot(slotCoord) - Displacement { itemSize.width - 1, 0 };
dir.x = AxisDirectionX_NONE;
if (IsVisualStoreOpen) {
InvalidateInventorySlot();
FocusOnVisualStore();
dir.x = AxisDirectionX_NONE;
} else {
const Point slotCoord = GetSlotCoord(Slot);
InvalidateInventorySlot();
ActiveStashSlot = FindClosestStashSlot(slotCoord) - Displacement { itemSize.width - 1, 0 };
dir.x = AxisDirectionX_NONE;
}
}
}
@ -1255,30 +1569,25 @@ void StashMove(AxisDirection dir)
return;
}
if (dir.x == AxisDirectionX_LEFT) {
if (ActiveStashSlot.x > 0) {
const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot);
ActiveStashSlot.x--;
if (holdItem.isEmpty() && itemIdAtActiveStashSlot != StashStruct::EmptyCell) {
while (ActiveStashSlot.x > 0 && itemIdAtActiveStashSlot == Stash.GetItemIdAtPosition(ActiveStashSlot)) {
ActiveStashSlot.x--;
}
}
const Size gridSize { 10, 10 };
const bool isHoldingItem = !holdItem.isEmpty();
Size movingItemSize = isHoldingItem ? GetInventorySize(holdItem) : Size { 1, 1 };
if (!isHoldingItem && ActiveStashSlot != InvalidStashPoint) {
const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot);
if (itemIdAtActiveStashSlot != StashStruct::EmptyCell) {
movingItemSize = GetInventorySize(Stash.stashList[itemIdAtActiveStashSlot]);
}
}
auto getCellId = [&](Point p) -> int {
return Stash.GetItemIdAtPosition(p);
};
if (dir.x == AxisDirectionX_LEFT) {
GridMove(ActiveStashSlot, dir, gridSize, movingItemSize, isHoldingItem, getCellId);
} else if (dir.x == AxisDirectionX_RIGHT) {
// If we're empty-handed and trying to move right while hovering over an item we may not
// have a free stash column to move to. If the item we're hovering over occupies the last
// column then we want to jump to the inventory instead of just moving one column over.
const Size itemUnderCursorSize = holdItem.isEmpty() ? GetItemSizeOnSlot(ActiveStashSlot) : itemSize;
if (ActiveStashSlot.x < 10 - itemUnderCursorSize.width) {
const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot);
ActiveStashSlot.x++;
if (holdItem.isEmpty() && itemIdAtActiveStashSlot != StashStruct::EmptyCell) {
while (ActiveStashSlot.x < 10 - itemSize.width && itemIdAtActiveStashSlot == Stash.GetItemIdAtPosition(ActiveStashSlot)) {
ActiveStashSlot.x++;
}
}
} else {
if (!GridMove(ActiveStashSlot, dir, gridSize, movingItemSize, isHoldingItem, getCellId)) {
const Point stashSlotCoord = GetStashSlotCoord(ActiveStashSlot);
const Point rightPanelCoord = { GetRightPanel().position.x, stashSlotCoord.y };
Slot = FindClosestInventorySlot(rightPanelCoord, holdItem, [](Point mousePos, int slot) {
@ -1296,29 +1605,15 @@ void StashMove(AxisDirection dir)
}
}
if (dir.y == AxisDirectionY_UP) {
if (ActiveStashSlot.y > 0) {
const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot);
ActiveStashSlot.y--;
if (holdItem.isEmpty() && itemIdAtActiveStashSlot != StashStruct::EmptyCell) {
while (ActiveStashSlot.y > 0 && itemIdAtActiveStashSlot == Stash.GetItemIdAtPosition(ActiveStashSlot)) {
ActiveStashSlot.y--;
}
}
}
GridMove(ActiveStashSlot, dir, gridSize, movingItemSize, isHoldingItem, getCellId);
} else if (dir.y == AxisDirectionY_DOWN) {
if (ActiveStashSlot.y < 10 - itemSize.height) {
const StashStruct::StashCell itemIdAtActiveStashSlot = Stash.GetItemIdAtPosition(ActiveStashSlot);
ActiveStashSlot.y++;
if (holdItem.isEmpty() && itemIdAtActiveStashSlot != StashStruct::EmptyCell) {
while (ActiveStashSlot.y < 10 - itemSize.height && itemIdAtActiveStashSlot == Stash.GetItemIdAtPosition(ActiveStashSlot)) {
ActiveStashSlot.y++;
}
if (!GridMove(ActiveStashSlot, dir, gridSize, movingItemSize, isHoldingItem, getCellId)) {
if ((holdItem.isEmpty() || CanBePlacedOnBelt(*MyPlayer, holdItem)) && ActiveStashSlot.x > 1) {
const int beltSlot = ActiveStashSlot.x - 2;
Slot = SLOTXY_BELT_FIRST + beltSlot;
ActiveStashSlot = InvalidStashPoint;
BeltReturnsToStash = true;
}
} else if ((holdItem.isEmpty() || CanBePlacedOnBelt(*MyPlayer, holdItem)) && ActiveStashSlot.x > 1) {
const int beltSlot = ActiveStashSlot.x - 2;
Slot = SLOTXY_BELT_FIRST + beltSlot;
ActiveStashSlot = InvalidStashPoint;
BeltReturnsToStash = true;
}
}
@ -1505,6 +1800,9 @@ HandleLeftStickOrDPadFn GetLeftStickOrDPadGameUIHandler()
if (IsStashOpen) {
return &StashMove;
}
if (IsVisualStoreOpen) {
return &VisualStoreMove;
}
if (invflag) {
return &CheckInventoryMove;
}
@ -1737,6 +2035,17 @@ void LogGamepadChange(GamepadLayout newGamepad)
} // namespace
void FocusOnVisualStore()
{
InvalidateInventorySlot(); // Clear inventory focus
BeltReturnsToVisualStore = false; // Reset belt return state
VisualStoreSlot = { 0, 0 };
const Point slotPos = GetVisualStoreSlotCoord(VisualStoreSlot);
// Account for held item size when positioning cursor
Size itemSize = MyPlayer->HoldItem.isEmpty() ? Size { 1, 1 } : GetInventorySize(MyPlayer->HoldItem);
SetCursorPos(slotPos + Displacement { itemSize.width * INV_SLOT_HALF_SIZE_PX, itemSize.height * INV_SLOT_HALF_SIZE_PX });
}
void DetectInputMethod(const SDL_Event &event, const ControllerButtonEvent &gamepadEvent)
{
ControlTypes inputType = GetInputTypeFromEvent(event);
@ -2021,6 +2330,15 @@ void PerformPrimaryAction()
LiftInventoryItem();
} else if (IsStashOpen && GetLeftPanel().contains(MousePosition)) {
LiftStashItem();
} else if (IsVisualStoreOpen && GetLeftPanel().contains(MousePosition)) {
if (!MyPlayer->HoldItem.isEmpty()) {
CheckVisualStorePaste(MousePosition);
} else if (pcursstorebtn != -1) {
// Only press the button, release will be called when button is released
CheckVisualStoreButtonPress(MousePosition);
} else {
CheckVisualStoreItem(MousePosition, false, false);
}
}
return;
}
@ -2035,6 +2353,14 @@ void PerformPrimaryAction()
Interact();
}
void PerformPrimaryActionRelease()
{
// Handle button release events for visual store buttons
if (IsVisualStoreOpen && GetLeftPanel().contains(MousePosition)) {
CheckVisualStoreButtonRelease(MousePosition);
}
}
bool SpellHasActorTarget()
{
const SpellID spl = MyPlayer->_pRSpell;
@ -2214,6 +2540,12 @@ void PerformSecondaryAction()
} else if (pcursinvitem != -1) {
TransferItemToStash(myPlayer, pcursinvitem);
}
} else if (IsVisualStoreOpen) {
if (!myPlayer.HoldItem.isEmpty() && GetLeftPanel().contains(MousePosition)) {
CheckVisualStorePaste(MousePosition);
} else if (pcursinvitem >= INVITEM_INV_FIRST && pcursinvitem <= INVITEM_INV_LAST) {
SellItemToVisualStore(pcursinvitem - INVITEM_INV_FIRST);
}
} else {
CtrlUseInvItem();
}

4
Source/controls/plrctrls.h

@ -55,12 +55,16 @@ void UseBeltItem(BeltItemType type);
// Talk to towners, click on inv items, attack, etc.
void PerformPrimaryAction();
// Handle button releases for primary action (e.g., visual store buttons)
void PerformPrimaryActionRelease();
// Open chests, doors, pickup items.
void PerformSecondaryAction();
void UpdateSpellTarget(SpellID spell);
bool TryDropItem();
void InvalidateInventorySlot();
void FocusOnInventory();
void FocusOnVisualStore();
void PerformSpellAction();
void QuickCast(size_t slot);

6
Source/cursor.cpp

@ -40,6 +40,7 @@
#include "options.h"
#include "qol/itemlabels.h"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "towners.h"
#include "track.h"
#include "utils/attributes.h"
@ -802,6 +803,8 @@ void ResetCursorInfo()
}
pcursinvitem = -1;
pcursstashitem = StashStruct::EmptyCell;
pcursstoreitem = -1;
pcursstorebtn = -1;
PlayerUnderCursor = nullptr;
ShowUniqueItemInfoBox = false;
MainPanelFlag = false;
@ -836,6 +839,9 @@ bool CheckPanelsAndFlags(Rectangle mainPanel)
if (IsStashOpen && GetLeftPanel().contains(MousePosition)) {
pcursstashitem = CheckStashHLight(MousePosition);
}
if (IsVisualStoreOpen && GetLeftPanel().contains(MousePosition)) {
pcursstoreitem = CheckVisualStoreHLight(MousePosition);
}
if (SpellbookFlag && GetRightPanel().contains(MousePosition)) {
return true;
}

26
Source/diablo.cpp

@ -94,6 +94,7 @@
#include "qol/itemlabels.h"
#include "qol/monhealthbar.h"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "qol/xpbar.h"
#include "quick_messages.hpp"
#include "restrict.h"
@ -253,6 +254,7 @@ void LeftMouseCmd(bool bShift)
if (leveltype == DTYPE_TOWN) {
CloseGoldWithdraw();
CloseStash();
CloseVisualStore();
if (pcursitem != -1 && pcurs == CURSOR_HAND)
NetSendCmdLocParam1(true, invflag ? CMD_GOTOGETITEM : CMD_GOTOAGETITEM, cursPosition, pcursitem);
if (pcursmonst != -1)
@ -385,6 +387,13 @@ void LeftMouseDown(uint16_t modState)
if (!IsWithdrawGoldOpen)
CheckStashItem(MousePosition, isShiftHeld, isCtrlHeld);
CheckStashButtonPress(MousePosition);
} else if (IsVisualStoreOpen && GetLeftPanel().contains(MousePosition)) {
if (!MyPlayer->HoldItem.isEmpty()) {
CheckVisualStorePaste(MousePosition);
} else {
CheckVisualStoreItem(MousePosition, isCtrlHeld, isShiftHeld);
}
CheckVisualStoreButtonPress(MousePosition);
} else if (SpellbookFlag && GetRightPanel().contains(MousePosition)) {
CheckSBook();
} else if (!MyPlayer->HoldItem.isEmpty()) {
@ -419,6 +428,7 @@ void LeftMouseUp(uint16_t modState)
if (MainPanelButtonDown)
CheckMainPanelButtonUp();
CheckStashButtonRelease(MousePosition);
CheckVisualStoreButtonRelease(MousePosition);
if (CharPanelButtonActive) {
const bool isShiftHeld = (modState & SDL_KMOD_SHIFT) != 0;
ReleaseChrBtns(isShiftHeld);
@ -1648,6 +1658,7 @@ void InventoryKeyPressed()
SpellbookFlag = false;
CloseGoldWithdraw();
CloseStash();
CloseVisualStore();
}
void CharacterSheetKeyPressed()
@ -1696,6 +1707,7 @@ void QuestLogKeyPressed()
CloseCharPanel();
CloseGoldWithdraw();
CloseStash();
CloseVisualStore();
}
void DisplaySpellsKeyPressed()
@ -1731,6 +1743,7 @@ void SpellBookKeyPressed()
}
}
CloseInventory();
CloseVisualStore();
}
void CycleSpellHotkeys(bool next)
@ -1778,7 +1791,7 @@ bool CanPlayerTakeAction()
bool CanAutomapBeToggledOff()
{
// check if every window is closed - if yes, automap can be toggled off
if (!QuestLogIsOpen && !IsWithdrawGoldOpen && !IsStashOpen && !CharFlag
if (!QuestLogIsOpen && !IsWithdrawGoldOpen && !IsStashOpen && !IsVisualStoreOpen && !CharFlag
&& !SpellbookFlag && !invflag && !isGameMenuOpen && !qtextflag && !SpellSelectFlag
&& !ChatLogFlag && !HelpFlag)
return true;
@ -2641,6 +2654,7 @@ void FreeGameMem()
FreeObjectGFX();
FreeTownerGFX();
FreeStashGFX();
FreeVisualStoreGFX();
#ifndef USE_SDL1
DeactivateVirtualGamepad();
FreeVirtualGamepadGFX();
@ -2811,9 +2825,12 @@ bool TryIconCurs()
}
if (pcurs == CURSOR_REPAIR) {
if (pcursinvitem != -1 && !IsInspectingPlayer())
DoRepair(myPlayer, pcursinvitem);
else if (pcursstashitem != StashStruct::EmptyCell) {
if (pcursinvitem != -1 && !IsInspectingPlayer()) {
if (IsVisualStoreOpen)
VisualStoreRepairItem(pcursinvitem);
else
DoRepair(myPlayer, pcursinvitem);
} else if (pcursstashitem != StashStruct::EmptyCell) {
Item &item = Stash.stashList[pcursstashitem];
RepairItem(item, myPlayer.getCharacterLevel());
}
@ -3196,6 +3213,7 @@ tl::expected<void, std::string> LoadGameLevelTown(bool firstflag, lvl_entry lvld
InitTowners();
InitStash();
InitVisualStore();
InitItems();
InitMissiles();

7
Source/engine/render/scrollrt.cpp

@ -63,6 +63,7 @@
#include "qol/itemlabels.h"
#include "qol/monhealthbar.h"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "qol/xpbar.h"
#include "stores.h"
#include "towners.h"
@ -1400,14 +1401,18 @@ void DrawView(const Surface &out, Point startPosition)
DrawDurIcon(out);
DrawLevelButton(out);
if (CharFlag) {
DrawChr(out);
} else if (QuestLogIsOpen) {
DrawQuestLog(out);
} else if (IsStashOpen) {
DrawStash(out);
} else if (IsVisualStoreOpen) {
DrawVisualStore(out);
}
DrawLevelButton(out);
if (ShowUniqueItemInfoBox) {
DrawUniqueInfo(out);
}

22
Source/inv.cpp

@ -39,6 +39,7 @@
#include "player.h"
#include "plrmsg.h"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "stores.h"
#include "towners.h"
#include "utils/display.h"
@ -900,6 +901,10 @@ void CheckInvCut(Player &player, Point cursorPosition, bool automaticMove, bool
player.InvBody[invloc] = holdItem.pop();
}
}
} else if (IsVisualStoreOpen && CanSellToCurrentVendor(player.InvList[iv]) && dropItem) {
// If visual store is open, ctrl-click sells the item
SellItemToVisualStore(iv);
automaticallyMoved = true;
} else {
holdItem = player.InvList[iv];
player.RemoveInvItem(iv, false);
@ -1981,7 +1986,21 @@ int8_t CheckInvHLight()
if (pi->isEmpty())
return -1;
if (pi->_itype == ItemType::Gold) {
if (IsVisualStoreOpen && pcurs == CURSOR_REPAIR) {
InfoColor = pi->getTextColor();
InfoString = pi->getName();
FloatingInfoString = pi->getName();
if (pi->_iIdentified) {
PrintItemDetails(*pi);
} else {
PrintItemDur(*pi);
}
int cost = GetRepairCost(*pi);
if (cost > 0)
AddInfoBoxString(StrCat(FormatInteger(cost), " Gold"));
else
AddInfoBoxString(_("Fully Repaired"));
} else if (pi->_itype == ItemType::Gold) {
const int nGold = pi->_ivalue;
InfoString = fmt::format(fmt::runtime(ngettext("{:s} gold piece", "{:s} gold pieces", nGold)), FormatInteger(nGold));
FloatingInfoString = fmt::format(fmt::runtime(ngettext("{:s} gold piece", "{:s} gold pieces", nGold)), FormatInteger(nGold));
@ -2213,6 +2232,7 @@ void CloseInventory()
{
CloseGoldWithdraw();
CloseStash();
CloseVisualStore();
invflag = false;
}

2
Source/options.cpp

@ -871,6 +871,7 @@ GameplayOptions::GameplayOptions()
, numFullManaPotionPickup("Full Mana Potion Pickup", OptionEntryFlags::None, N_("Full Mana Potion Pickup"), N_("Number of Full Mana potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 })
, numRejuPotionPickup("Rejuvenation Potion Pickup", OptionEntryFlags::None, N_("Rejuvenation Potion Pickup"), N_("Number of Rejuvenation potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 })
, numFullRejuPotionPickup("Full Rejuvenation Potion Pickup", OptionEntryFlags::None, N_("Full Rejuvenation Potion Pickup"), N_("Number of Full Rejuvenation potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 })
, visualStoreUI("Visual Store UI", OptionEntryFlags::None, N_("Visual Store UI"), N_("Use visual grid-based store interface instead of text-based menus. Both store and inventory panels open together."), false)
, skipLoadingScreenThresholdMs("Skip loading screen threshold, ms", OptionEntryFlags::Invisible, "", "", 0)
{
}
@ -890,6 +891,7 @@ std::vector<OptionEntryBase *> GameplayOptions::GetEntries()
&testBarbarian,
&experienceBar,
&showItemGraphicsInStores,
&visualStoreUI,
&showHealthValues,
&showManaValues,
&showMultiplayerPartyInfo,

2
Source/options.h

@ -635,6 +635,8 @@ struct GameplayOptions : OptionCategoryBase {
OptionEntryInt<int> numRejuPotionPickup;
/** @brief Number of Full Rejuvenating potions to pick up automatically */
OptionEntryInt<int> numFullRejuPotionPickup;
/** @brief Use visual grid-based store UI instead of text-based menus. */
OptionEntryBoolean visualStoreUI;
/**
* @brief If loading takes less than this value, skips displaying the loading screen.

826
Source/qol/visual_store.cpp

@ -0,0 +1,826 @@
/**
* @file qol/visual_store.cpp
*
* Implementation of visual grid-based store UI.
*/
#include "qol/visual_store.h"
#include <algorithm>
#include <cstdint>
#include <span>
#include "control/control.hpp"
#include "controls/plrctrls.h"
#include "cursor.h"
#include "engine/clx_sprite.hpp"
#include "engine/load_clx.hpp"
#include "engine/points_in_rectangle_range.hpp"
#include "engine/rectangle.hpp"
#include "engine/render/clx_render.hpp"
#include "engine/render/text_render.hpp"
#include "engine/size.hpp"
#include "game_mode.hpp"
#include "headless_mode.hpp"
#include "inv.h"
#include "items.h"
#include "minitext.h"
#include "options.h"
#include "panels/info_box.hpp"
#include "panels/ui_panels.hpp"
#include "player.h"
#include "qol/stash.h"
#include "spells.h"
#include "stores.h"
#include "utils/format_int.hpp"
#include "utils/language.h"
#include "utils/str_cat.hpp"
namespace devilution {
bool IsVisualStoreOpen;
VisualStoreState VisualStore;
int16_t pcursstoreitem = -1;
int16_t pcursstorebtn = -1;
namespace {
OptionalOwnedClxSpriteList VisualStorePanelArt;
OptionalOwnedClxSpriteList VisualStoreNavButtonArt;
OptionalOwnedClxSpriteList VisualStoreRepairAllButtonArt;
OptionalOwnedClxSpriteList VisualStoreRepairButtonArt;
int VisualStoreButtonPressed = -1;
constexpr Size ButtonSize { 27, 16 };
/** Contains mappings for the buttons in the visual store (tabs, repair) */
constexpr Rectangle VisualStoreButtonRect[] = {
// Tab buttons (Smith only) - positioned below title
{ { 14, 21 }, { 72, 22 } }, // Basic tab
{ { 14 + 73, 21 }, { 72, 22 } }, // Premium tab
{ { 233, 315 }, { 48, 24 } }, // Repair All Btn
{ { 286, 315 }, { 24, 24 } }, // Repair Btn
};
constexpr int TabButtonBasic = 0;
constexpr int TabButtonPremium = 1;
constexpr int RepairAllBtn = 2;
constexpr int RepairBtn = 3;
/** @brief Get the items array for a specific vendor/tab combination. */
std::span<Item> GetVendorItems(VisualStoreVendor vendor, VisualStoreTab tab)
{
switch (vendor) {
case VisualStoreVendor::Smith: {
if (tab == VisualStoreTab::Premium) {
return { PremiumItems.data(), static_cast<size_t>(PremiumItems.size()) };
}
return { SmithItems.data(), static_cast<size_t>(SmithItems.size()) };
}
case VisualStoreVendor::Witch: {
return { WitchItems.data(), static_cast<size_t>(WitchItems.size()) };
}
case VisualStoreVendor::Healer: {
return { HealerItems.data(), static_cast<size_t>(HealerItems.size()) };
}
case VisualStoreVendor::Boy: {
if (BoyItem.isEmpty()) {
return {};
}
return { &BoyItem, 1 };
}
}
return {};
}
/** @brief Check if the current vendor has tabs (Smith only). */
bool VendorHasTabs()
{
return VisualStore.vendor == VisualStoreVendor::Smith;
}
/** @brief Check if the current vendor accepts items for sale. */
bool VendorAcceptsSale()
{
switch (VisualStore.vendor) {
case VisualStoreVendor::Smith:
case VisualStoreVendor::Witch: {
return true;
}
case VisualStoreVendor::Healer:
case VisualStoreVendor::Boy: {
return false;
}
}
return false;
}
/** @brief Calculate the sell price for an item (1/4 of value). */
int GetSellPrice(const Item &item)
{
int value = item._ivalue;
if (item._iMagical != ITEM_QUALITY_NORMAL && item._iIdentified)
value = item._iIvalue;
return std::max(value / 4, 1);
}
/** @brief Rebuild the grid layout for the current vendor/tab. */
void RefreshVisualStoreLayout()
{
VisualStore.pages.clear();
std::span<Item> items = GetVisualStoreItems();
if (items.empty()) {
VisualStore.pages.emplace_back();
VisualStorePage &page = VisualStore.pages.back();
memset(page.grid, 0, sizeof(page.grid));
return;
}
auto createNewPage = [&]() -> VisualStorePage & {
VisualStore.pages.emplace_back();
VisualStorePage &page = VisualStore.pages.back();
memset(page.grid, 0, sizeof(page.grid));
return page;
};
VisualStorePage *currentPage = &createNewPage();
for (uint16_t i = 0; i < static_cast<uint16_t>(items.size()); i++) {
const Item &item = items[i];
if (item.isEmpty())
continue;
const Size itemSize = GetInventorySize(item);
bool placed = false;
// Try to place in current page
for (auto stashPosition : PointsInRectangle(Rectangle { { 0, 0 }, Size { VisualStoreGridWidth - (itemSize.width - 1), VisualStoreGridHeight - (itemSize.height - 1) } })) {
bool isSpaceFree = true;
for (auto itemPoint : PointsInRectangle(Rectangle { stashPosition, itemSize })) {
if (currentPage->grid[itemPoint.x][itemPoint.y] != 0) {
isSpaceFree = false;
break;
}
}
if (isSpaceFree) {
for (auto itemPoint : PointsInRectangle(Rectangle { stashPosition, itemSize })) {
currentPage->grid[itemPoint.x][itemPoint.y] = i + 1;
}
currentPage->items.push_back({ i, stashPosition + Displacement { 0, itemSize.height - 1 } });
placed = true;
break;
}
}
if (!placed) {
// Start new page
currentPage = &createNewPage();
// Try placing again in new page
for (auto stashPosition : PointsInRectangle(Rectangle { { 0, 0 }, Size { VisualStoreGridWidth - (itemSize.width - 1), VisualStoreGridHeight - (itemSize.height - 1) } })) {
bool isSpaceFree = true;
for (auto itemPoint : PointsInRectangle(Rectangle { stashPosition, itemSize })) {
if (currentPage->grid[itemPoint.x][itemPoint.y] != 0) {
isSpaceFree = false;
break;
}
}
if (isSpaceFree) {
for (auto itemPoint : PointsInRectangle(Rectangle { stashPosition, itemSize })) {
currentPage->grid[itemPoint.x][itemPoint.y] = i + 1;
}
currentPage->items.push_back({ i, stashPosition + Displacement { 0, itemSize.height - 1 } });
placed = true;
break;
}
}
}
}
if (VisualStore.currentPage >= VisualStore.pages.size())
VisualStore.currentPage = VisualStore.pages.empty() ? 0 : static_cast<unsigned>(VisualStore.pages.size() - 1);
}
} // namespace
void InitVisualStore()
{
if (HeadlessMode)
return;
VisualStorePanelArt = LoadClx("data\\store.clx");
VisualStoreNavButtonArt = LoadClx("data\\tabBtnUp.clx");
VisualStoreRepairAllButtonArt = LoadClx("data\\repairAllBtn.clx");
VisualStoreRepairButtonArt = LoadClx("data\\repairSingleBtn.clx");
}
void FreeVisualStoreGFX()
{
VisualStoreNavButtonArt = std::nullopt;
VisualStorePanelArt = std::nullopt;
VisualStoreRepairAllButtonArt = std::nullopt;
VisualStoreRepairButtonArt = std::nullopt;
}
void OpenVisualStore(VisualStoreVendor vendor)
{
IsVisualStoreOpen = true;
invflag = true; // Open inventory panel alongside
VisualStore.vendor = vendor;
VisualStore.activeTab = VisualStoreTab::Basic;
VisualStore.currentPage = 0;
pcursstoreitem = -1;
pcursstorebtn = -1;
// Refresh item stat flags for current player
std::span<Item> items = GetVisualStoreItems();
for (Item &item : items) {
item._iStatFlag = MyPlayer->CanUseItem(item);
}
RefreshVisualStoreLayout();
// Initialize controller focus to the visual store grid
FocusOnVisualStore();
}
void CloseVisualStore()
{
if (IsVisualStoreOpen) {
IsVisualStoreOpen = false;
invflag = false;
pcursstoreitem = -1;
pcursstorebtn = -1;
VisualStoreButtonPressed = -1;
VisualStore.pages.clear();
}
}
void SetVisualStoreTab(VisualStoreTab tab)
{
if (!VendorHasTabs())
return;
VisualStore.activeTab = tab;
VisualStore.currentPage = 0;
pcursstoreitem = -1;
pcursstorebtn = -1;
// Refresh item stat flags
std::span<Item> items = GetVisualStoreItems();
for (Item &item : items) {
item._iStatFlag = MyPlayer->CanUseItem(item);
}
RefreshVisualStoreLayout();
}
void VisualStoreNextPage()
{
if (VisualStore.currentPage + 1 < VisualStore.pages.size()) {
VisualStore.currentPage++;
pcursstoreitem = -1;
pcursstorebtn = -1;
}
}
void VisualStorePreviousPage()
{
if (VisualStore.currentPage > 0) {
VisualStore.currentPage--;
pcursstoreitem = -1;
pcursstorebtn = -1;
}
}
int GetRepairCost(const Item &item)
{
if (item.isEmpty() || item._iDurability == item._iMaxDur || item._iMaxDur == DUR_INDESTRUCTIBLE)
return 0;
const int due = item._iMaxDur - item._iDurability;
if (item._iMagical != ITEM_QUALITY_NORMAL && item._iIdentified) {
return 30 * item._iIvalue * due / (item._iMaxDur * 100 * 2);
} else {
return std::max(item._ivalue * due / (item._iMaxDur * 2), 1);
}
}
void VisualStoreRepairAll()
{
Player &myPlayer = *MyPlayer;
int totalCost = 0;
// Check body items
for (auto &item : myPlayer.InvBody) {
totalCost += GetRepairCost(item);
}
// Check inventory items
for (int i = 0; i < myPlayer._pNumInv; i++) {
totalCost += GetRepairCost(myPlayer.InvList[i]);
}
if (totalCost == 0)
return;
if (!PlayerCanAfford(totalCost)) {
return;
}
// Execute repairs
TakePlrsMoney(totalCost);
for (auto &item : myPlayer.InvBody) {
if (!item.isEmpty() && item._iMaxDur != DUR_INDESTRUCTIBLE)
item._iDurability = item._iMaxDur;
}
for (int i = 0; i < myPlayer._pNumInv; i++) {
Item &item = myPlayer.InvList[i];
if (!item.isEmpty() && item._iMaxDur != DUR_INDESTRUCTIBLE)
item._iDurability = item._iMaxDur;
}
PlaySFX(SfxID::ItemGold);
CalcPlrInv(myPlayer, true);
}
void VisualStoreRepair()
{
NewCursor(CURSOR_REPAIR);
}
void VisualStoreRepairItem(int invIndex)
{
Player &myPlayer = *MyPlayer;
Item *item = nullptr;
if (invIndex < INVITEM_INV_FIRST) {
item = &myPlayer.InvBody[invIndex];
} else if (invIndex <= INVITEM_INV_LAST) {
item = &myPlayer.InvList[invIndex - INVITEM_INV_FIRST];
} else {
return; // Belt items don't have durability
}
if (item->isEmpty())
return;
int cost = GetRepairCost(*item);
if (cost <= 0)
return;
if (!PlayerCanAfford(cost)) {
return;
}
TakePlrsMoney(cost);
item->_iDurability = item->_iMaxDur;
PlaySFX(SfxID::ItemGold);
CalcPlrInv(myPlayer, true);
}
Point GetVisualStoreSlotCoord(Point slot)
{
constexpr int SlotSpacing = INV_SLOT_SIZE_PX + 1;
// Grid starts below the header area
return GetPanelPosition(UiPanels::Stash, slot * SlotSpacing + Displacement { 17, 44 });
}
Rectangle GetVisualBtnCoord(int btnId)
{
return { GetPanelPosition(UiPanels::Stash, VisualStoreButtonRect[btnId].position), VisualStoreButtonRect[btnId].size };
}
int GetVisualStoreItemCount()
{
std::span<Item> items = GetVisualStoreItems();
int count = 0;
for (const Item &item : items) {
if (!item.isEmpty())
count++;
}
return count;
}
std::span<Item> GetVisualStoreItems()
{
return GetVendorItems(VisualStore.vendor, VisualStore.activeTab);
}
int GetVisualStorePageCount()
{
return std::max(1, static_cast<int>(VisualStore.pages.size()));
}
void DrawVisualStore(const Surface &out)
{
if (!VisualStorePanelArt)
return;
RenderClxSprite(out, (*VisualStorePanelArt)[0], GetPanelPosition(UiPanels::Stash));
const Point panelPos = GetPanelPosition(UiPanels::Stash);
const UiFlags styleWhite = UiFlags::VerticalCenter | UiFlags::ColorWhite;
const UiFlags styleTabPushed = UiFlags::VerticalCenter | UiFlags::ColorButtonpushed;
constexpr int TextHeight = 13;
// Draw tab buttons
UiFlags basicStyle = VisualStore.activeTab == VisualStoreTab::Basic ? styleWhite : styleTabPushed;
UiFlags premiumStyle = VisualStore.activeTab == VisualStoreTab::Premium ? styleWhite : styleTabPushed;
switch (VisualStore.vendor) {
case VisualStoreVendor::Smith: {
const Rectangle regBtnPos = { GetPanelPosition(UiPanels::Stash, VisualStoreButtonRect[TabButtonBasic].position), VisualStoreButtonRect[TabButtonBasic].size };
RenderClxSprite(out, (*VisualStoreNavButtonArt)[VisualStore.activeTab != VisualStoreTab::Basic], regBtnPos.position);
DrawString(out, _("Basic"), regBtnPos, { .flags = UiFlags::AlignCenter | basicStyle });
const Rectangle premBtnPos = { GetPanelPosition(UiPanels::Stash, VisualStoreButtonRect[TabButtonPremium].position), VisualStoreButtonRect[TabButtonPremium].size };
RenderClxSprite(out, (*VisualStoreNavButtonArt)[VisualStore.activeTab != VisualStoreTab::Premium], premBtnPos.position);
DrawString(out, _("Premium"), premBtnPos, { .flags = UiFlags::AlignCenter | premiumStyle });
break;
}
case VisualStoreVendor::Witch:
case VisualStoreVendor::Boy:
case VisualStoreVendor::Healer: {
const Rectangle miscBtnPos = { GetPanelPosition(UiPanels::Stash, VisualStoreButtonRect[TabButtonBasic].position), VisualStoreButtonRect[TabButtonBasic].size };
RenderClxSprite(out, (*VisualStoreNavButtonArt)[VisualStoreButtonPressed == TabButtonBasic], miscBtnPos.position);
DrawString(out, _("Misc"), miscBtnPos, { .flags = UiFlags::AlignCenter | basicStyle });
break;
}
default: {
break;
}
}
if (VisualStore.currentPage >= VisualStore.pages.size())
return;
const VisualStorePage &page = VisualStore.pages[VisualStore.currentPage];
std::span<Item> allItems = GetVisualStoreItems();
constexpr Displacement offset { 0, INV_SLOT_SIZE_PX - 1 };
// First pass: draw item slot backgrounds
for (int y = 0; y < VisualStoreGridHeight; y++) {
for (int x = 0; x < VisualStoreGridWidth; x++) {
const uint16_t itemPlusOne = page.grid[x][y];
if (itemPlusOne == 0)
continue;
const Item &item = allItems[itemPlusOne - 1];
Point position = GetVisualStoreSlotCoord({ x, y }) + offset;
InvDrawSlotBack(out, position, InventorySlotSizeInPixels, item._iMagical);
}
}
// Second pass: draw item sprites
for (const auto &vsItem : page.items) {
const Item &item = allItems[vsItem.index];
Point position = GetVisualStoreSlotCoord(vsItem.position) + offset;
const int frame = item._iCurs + CURSOR_FIRSTITEM;
const ClxSprite sprite = GetInvItemSprite(frame);
// Draw highlight outline if this item is hovered
if (pcursstoreitem == vsItem.index) {
const uint8_t color = GetOutlineColor(item, true);
ClxDrawOutline(out, color, position, sprite);
}
DrawItem(item, out, position, sprite);
}
// Draw player gold at bottom
uint32_t totalGold = MyPlayer->_pGold + Stash.gold;
DrawString(out, StrCat(_("Gold: "), FormatInteger(totalGold)),
{ panelPos + Displacement { 20, 320 }, { 280, TextHeight } },
{ .flags = styleWhite });
// Draw Repair All
if (VisualStore.vendor == VisualStoreVendor::Smith) {
const Rectangle repairAllBtnPos = { GetPanelPosition(UiPanels::Stash, VisualStoreButtonRect[RepairAllBtn].position), VisualStoreButtonRect[RepairAllBtn].size };
RenderClxSprite(out, (*VisualStoreRepairAllButtonArt)[VisualStoreButtonPressed == RepairAllBtn], repairAllBtnPos.position);
const Rectangle repairBtnPos = { GetPanelPosition(UiPanels::Stash, VisualStoreButtonRect[RepairBtn].position), VisualStoreButtonRect[RepairBtn].size };
RenderClxSprite(out, (*VisualStoreRepairButtonArt)[VisualStoreButtonPressed == RepairBtn], repairBtnPos.position);
}
}
int16_t CheckVisualStoreHLight(Point mousePosition)
{
// Check buttons first
const Point panelPos = GetPanelPosition(UiPanels::Stash);
if (MyPlayer->HoldItem.isEmpty()) {
for (int i = 0; i < 4; i++) {
// Skip tab buttons if vendor doesn't have tabs
if (!VendorHasTabs() && i != TabButtonBasic)
continue;
Rectangle button = VisualStoreButtonRect[i];
button.position = GetPanelPosition(UiPanels::Stash, button.position);
if (button.contains(mousePosition)) {
if (i == TabButtonBasic) {
if (VendorHasTabs()) {
InfoString = _("Basic");
FloatingInfoString = _("Basic");
AddInfoBoxString(_("Basic items"));
AddInfoBoxString(_("Basic items"), true);
} else {
InfoString = _("Misc");
FloatingInfoString = _("Misc");
AddInfoBoxString(_("Miscellaneous items"));
AddInfoBoxString(_("Miscellaneous items"), true);
}
InfoColor = UiFlags::ColorWhite;
pcursstorebtn = TabButtonBasic;
return -1;
} else if (i == TabButtonPremium) {
InfoString = _("Premium");
FloatingInfoString = _("Premium");
AddInfoBoxString(_("Premium items"));
AddInfoBoxString(_("Premium items"), true);
InfoColor = UiFlags::ColorWhite;
pcursstorebtn = TabButtonPremium;
return -1;
} else if (i == RepairAllBtn) {
int totalCost = 0;
Player &myPlayer = *MyPlayer;
for (auto &item : myPlayer.InvBody)
totalCost += GetRepairCost(item);
for (int j = 0; j < myPlayer._pNumInv; j++)
totalCost += GetRepairCost(myPlayer.InvList[j]);
InfoString = _("Repair All");
FloatingInfoString = _("Repair All");
if (totalCost > 0) {
AddInfoBoxString(StrCat(FormatInteger(totalCost), " Gold"));
AddInfoBoxString(StrCat(FormatInteger(totalCost), " Gold"), true);
} else {
AddInfoBoxString(_("Nothing to repair"));
AddInfoBoxString(_("Nothing to repair"), true);
}
InfoColor = UiFlags::ColorWhite;
pcursstorebtn = RepairAllBtn;
return -1;
} else if (i == RepairBtn) {
InfoString = _("Repair");
FloatingInfoString = _("Repair");
AddInfoBoxString(_("Repair a single item"));
AddInfoBoxString(_("Repair a single item"), true);
InfoColor = UiFlags::ColorWhite;
pcursstorebtn = RepairBtn;
return -1;
}
}
}
}
if (VisualStore.currentPage >= VisualStore.pages.size())
return -1;
const VisualStorePage &page = VisualStore.pages[VisualStore.currentPage];
std::span<Item> allItems = GetVisualStoreItems();
for (int y = 0; y < VisualStoreGridHeight; y++) {
for (int x = 0; x < VisualStoreGridWidth; x++) {
const uint16_t itemPlusOne = page.grid[x][y];
if (itemPlusOne == 0)
continue;
const int itemIndex = itemPlusOne - 1;
const Item &item = allItems[itemIndex];
const Rectangle cell {
GetVisualStoreSlotCoord({ x, y }),
InventorySlotSizeInPixels + 1
};
if (cell.contains(mousePosition)) {
const int price = item._iIvalue;
const bool canAfford = PlayerCanAfford(price);
InfoString = item.getName();
FloatingInfoString = item.getName();
InfoColor = canAfford ? item.getTextColor() : UiFlags::ColorRed;
if (item._iIdentified) {
PrintItemDetails(item);
} else {
PrintItemDur(item);
}
AddInfoBoxString(StrCat(FormatInteger(price), " Gold"));
return static_cast<int16_t>(itemIndex);
}
}
}
return -1;
}
void CheckVisualStoreItem(Point mousePosition, bool isCtrlHeld, bool isShiftHeld)
{
// Check if clicking on an item to buy
int16_t itemIndex = CheckVisualStoreHLight(mousePosition);
if (itemIndex < 0)
return;
std::span<Item> items = GetVisualStoreItems();
if (itemIndex >= static_cast<int16_t>(items.size()))
return;
Item &item = items[itemIndex];
if (item.isEmpty())
return;
// Check if player can afford the item
int price = item._iIvalue;
uint32_t totalGold = MyPlayer->_pGold + Stash.gold;
if (totalGold < static_cast<uint32_t>(price)) {
// InitDiabloMsg(EMSG_NOT_ENOUGH_GOLD);
return;
}
// Check if player has room for the item
if (!StoreAutoPlace(item, false)) {
// InitDiabloMsg(EMSG_INVENTORY_FULL);
return;
}
// Execute the purchase
TakePlrsMoney(price);
StoreAutoPlace(item, true);
PlaySFX(ItemInvSnds[ItemCAnimTbl[item._iCurs]]);
// Remove item from store (vendor-specific handling)
switch (VisualStore.vendor) {
case VisualStoreVendor::Smith: {
if (VisualStore.activeTab == VisualStoreTab::Premium) {
// Premium items get replaced
PremiumItems[itemIndex].clear();
SpawnPremium(*MyPlayer);
} else {
// Basic items are removed
SmithItems.erase(SmithItems.begin() + itemIndex);
}
break;
}
case VisualStoreVendor::Witch: {
// First 3 items are pinned, don't remove them
if (itemIndex >= 3) {
WitchItems.erase(WitchItems.begin() + itemIndex);
}
break;
}
case VisualStoreVendor::Healer: {
// First 2-3 items are pinned
if (itemIndex >= (gbIsMultiplayer ? 3 : 2)) {
HealerItems.erase(HealerItems.begin() + itemIndex);
}
break;
}
case VisualStoreVendor::Boy: {
BoyItem.clear();
break;
}
}
pcursstoreitem = -1;
RefreshVisualStoreLayout();
}
void CheckVisualStorePaste(Point mousePosition)
{
if (!VendorAcceptsSale())
return;
Player &player = *MyPlayer;
if (player.HoldItem.isEmpty())
return;
// Check if the item can be sold to this vendor
if (!CanSellToCurrentVendor(player.HoldItem)) {
player.SaySpecific(HeroSpeech::ICantDoThat);
return;
}
// Calculate sell price
int sellPrice = GetSellPrice(player.HoldItem);
// Add gold to player
AddGoldToInventory(player, sellPrice);
PlaySFX(SfxID::ItemGold);
// Clear the held item
player.HoldItem.clear();
NewCursor(CURSOR_HAND);
}
bool CanSellToCurrentVendor(const Item &item)
{
if (item.isEmpty())
return false;
switch (VisualStore.vendor) {
case VisualStoreVendor::Smith: {
return SmithWillBuy(item);
}
case VisualStoreVendor::Witch: {
return WitchWillBuy(item);
}
case VisualStoreVendor::Healer:
case VisualStoreVendor::Boy: {
return false;
}
}
return false;
}
void SellItemToVisualStore(int invIndex)
{
if (!VendorAcceptsSale())
return;
Player &player = *MyPlayer;
Item &item = player.InvList[invIndex];
if (!CanSellToCurrentVendor(item)) {
player.SaySpecific(HeroSpeech::ICantDoThat);
return;
}
// Calculate sell price
int sellPrice = GetSellPrice(item);
// Add gold to player
AddGoldToInventory(player, sellPrice);
PlaySFX(SfxID::ItemGold);
// Remove item from inventory
player.RemoveInvItem(invIndex);
}
void CheckVisualStoreButtonPress(Point mousePosition)
{
if (!MyPlayer->HoldItem.isEmpty())
return;
for (int i = 0; i < 4; i++) {
// Skip tab buttons if vendor doesn't have tabs
if (!VendorHasTabs() && i != TabButtonBasic)
continue;
Rectangle button = VisualStoreButtonRect[i];
button.position = GetPanelPosition(UiPanels::Stash, button.position);
if (button.contains(mousePosition)) {
VisualStoreButtonPressed = i;
return;
}
}
VisualStoreButtonPressed = -1;
}
void CheckVisualStoreButtonRelease(Point mousePosition)
{
if (VisualStoreButtonPressed == -1)
return;
Rectangle button = VisualStoreButtonRect[VisualStoreButtonPressed];
button.position = GetPanelPosition(UiPanels::Stash, button.position);
if (button.contains(mousePosition)) {
switch (VisualStoreButtonPressed) {
case TabButtonBasic: {
SetVisualStoreTab(VisualStoreTab::Basic);
break;
}
case TabButtonPremium: {
SetVisualStoreTab(VisualStoreTab::Premium);
break;
}
case RepairAllBtn: {
VisualStoreRepairAll();
break;
}
case RepairBtn: {
VisualStoreRepair();
break;
}
}
}
VisualStoreButtonPressed = -1;
}
} // namespace devilution

188
Source/qol/visual_store.h

@ -0,0 +1,188 @@
/**
* @file qol/visual_store.h
*
* Interface of visual grid-based store UI.
*/
#pragma once
#include <cstdint>
#include <span>
#include <vector>
#include "engine/point.hpp"
#include "engine/surface.hpp"
#include "items.h"
namespace devilution {
enum class VisualStoreVendor : uint8_t {
Smith,
Witch,
Healer,
Boy
};
enum class VisualStoreTab : uint8_t {
Basic = 0,
Premium = 1
};
// Grid: 10x9 = 90 slots per page
inline constexpr int VisualStoreGridWidth = 10;
inline constexpr int VisualStoreGridHeight = 9;
struct VisualStoreItem {
uint16_t index; // Index in the vendor's item list
Point position; // Top-left position in the grid
};
struct VisualStorePage {
std::vector<VisualStoreItem> items;
uint16_t grid[VisualStoreGridWidth][VisualStoreGridHeight];
};
struct VisualStoreState {
VisualStoreVendor vendor;
VisualStoreTab activeTab; // For Smith: Regular vs Premium
unsigned currentPage;
std::vector<VisualStorePage> pages;
};
extern bool IsVisualStoreOpen;
extern VisualStoreState VisualStore;
extern int16_t pcursstoreitem; // Currently highlighted store item index (-1 if none)
extern int16_t pcursstorebtn;
/**
* @brief Load visual store graphics.
*/
void InitVisualStore();
/**
* @brief Free visual store graphics.
*/
void FreeVisualStoreGFX();
/**
* @brief Open the visual store for a vendor.
* Opens both the store panel (left) and inventory panel (right).
* @param vendor The vendor to open the store for.
*/
void OpenVisualStore(VisualStoreVendor vendor);
/**
* @brief Close the visual store and inventory panels.
*/
void CloseVisualStore();
/**
* @brief Set the active tab for Smith (Regular/Premium).
* @param tab The tab to switch to.
*/
void SetVisualStoreTab(VisualStoreTab tab);
/**
* @brief Navigate to the next page of store items.
*/
void VisualStoreNextPage();
/**
* @brief Navigate to the previous page of store items.
*/
void VisualStorePreviousPage();
/**
* @brief Render the visual store panel to the given buffer.
*/
void DrawVisualStore(const Surface &out);
/**
* @brief Handle a click on the visual store panel.
* @param mousePosition The mouse position.
*/
void CheckVisualStoreItem(Point mousePosition, bool isCtrlHeld, bool isShiftHeld);
/**
* @brief Handle dropping an item on the visual store to sell.
* @param mousePosition The mouse position.
*/
void CheckVisualStorePaste(Point mousePosition);
/**
* @brief Check for item highlight under the cursor.
* @param mousePosition The mouse position.
* @return The index of the highlighted item, or -1 if none.
*/
int16_t CheckVisualStoreHLight(Point mousePosition);
/**
* @brief Handle button press in the visual store.
* @param mousePosition The mouse position.
*/
void CheckVisualStoreButtonPress(Point mousePosition);
/**
* @brief Handle button release in the visual store.
* @param mousePosition The mouse position.
*/
void CheckVisualStoreButtonRelease(Point mousePosition);
/**
* @brief Check if an item can be sold to the current vendor.
* @param item The item to check.
* @return true if the item can be sold.
*/
bool CanSellToCurrentVendor(const Item &item);
/**
* @brief Sell an item from the player's inventory to the current vendor.
* @param invIndex The inventory index of the item.
*/
void SellItemToVisualStore(int invIndex);
/**
* @brief Get the number of items for the current vendor/tab.
* @return The item count.
*/
int GetVisualStoreItemCount();
/**
* @brief Get the items array for the current vendor/tab.
* @return A span of items.
*/
std::span<Item> GetVisualStoreItems();
/**
* @brief Get the total number of pages for the current vendor/tab.
* @return The page count.
*/
int GetVisualStorePageCount();
/**
* @brief Convert a grid slot position to screen coordinates.
* @param slot The grid slot position.
* @return The screen coordinates.
*/
Point GetVisualStoreSlotCoord(Point slot);
/**
* @brief Gets the point for a btn on the panel.
* @param slot Btn id.
* @return The screen coordinates.
*/
Rectangle GetVisualBtnCoord(int btnId);
/**
* @brief Calculate the cost to repair an item.
* @param item The item to repair.
* @return The cost in gold.
*/
int GetRepairCost(const Item &item);
/**
* @brief Repair a specific item from the player's inventory/body.
* @param invIndex The inventory index of the item.
*/
void VisualStoreRepairItem(int invIndex);
} // namespace devilution

215
Source/stores.cpp

@ -27,6 +27,7 @@
#include "options.h"
#include "panels/info_box.hpp"
#include "qol/stash.h"
#include "qol/visual_store.h"
#include "tables/townerdat.hpp"
#include "towners.h"
#include "utils/format_int.hpp"
@ -337,25 +338,6 @@ void PrintStoreItem(const Item &item, int l, UiFlags flags, bool cursIndent = fa
AddSText(40, l++, productLine, flags, false, -1, cursIndent);
}
bool StoreAutoPlace(Item &item, bool persistItem)
{
Player &player = *MyPlayer;
if (AutoEquipEnabled(player, item) && AutoEquip(player, item, persistItem, true)) {
return true;
}
if (AutoPlaceItemInBelt(player, item, persistItem, true)) {
return true;
}
if (persistItem) {
return AutoPlaceItemInInventory(player, item, true);
}
return CanFitItemInInventory(player, item);
}
void ScrollVendorStore(std::span<Item> itemData, int storeLimit, int idx, int selling = true)
{
ClearSText(5, 21);
@ -386,11 +368,16 @@ void StartSmith()
AddSText(0, 3, _("Blacksmith's shop"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 7, _("Would you like to:"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 10, _("Talk to Griswold"), UiFlags::ColorBlue | UiFlags::AlignCenter, true);
AddSText(0, 12, _("Buy basic items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 14, _("Buy premium items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 16, _("Sell items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 18, _("Repair items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 20, _("Leave the shop"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
if (*GetOptions().Gameplay.visualStoreUI) {
AddSText(0, 12, _("Trade / Repair"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 14, _("Leave the shop"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
} else {
AddSText(0, 12, _("Buy basic items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 14, _("Buy premium items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 16, _("Sell items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 18, _("Repair items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 20, _("Leave the shop"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
}
AddSLine(5);
CurrentItemIndex = 20;
}
@ -405,12 +392,6 @@ uint32_t TotalPlayerGold()
return MyPlayer->_pGold + Stash.gold;
}
// TODO: Change `_iIvalue` to be unsigned instead of passing `int` here.
bool PlayerCanAfford(int price)
{
return TotalPlayerGold() >= static_cast<uint32_t>(price);
}
void StartSmithBuy()
{
IsTextFullSize = true;
@ -474,7 +455,7 @@ bool StartSmithPremiumBuy()
bool SmithSellOk(int i)
{
Item *pI;
const Item *pI;
if (i >= 0) {
pI = &MyPlayer->InvList[i];
@ -482,24 +463,7 @@ bool SmithSellOk(int i)
pI = &MyPlayer->SpdList[-(i + 1)];
}
if (pI->isEmpty())
return false;
if (pI->_iMiscId > IMISC_OILFIRST && pI->_iMiscId < IMISC_OILLAST)
return true;
if (pI->_itype == ItemType::Misc)
return false;
if (pI->_itype == ItemType::Gold)
return false;
if (pI->_itype == ItemType::Staff && (!gbIsHellfire || IsValidSpell(pI->_iSpell)))
return false;
if (pI->_iClass == ICLASS_QUEST)
return false;
if (pI->IDidx == IDI_LAZSTAFF)
return false;
return true;
return SmithWillBuy(*pI);
}
void ScrollSmithSell(int idx)
@ -661,11 +625,19 @@ void StartWitch()
AddSText(0, 2, _("Witch's shack"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 9, _("Would you like to:"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 12, _("Talk to Adria"), UiFlags::ColorBlue | UiFlags::AlignCenter, true);
AddSText(0, 14, _("Buy items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 16, _("Sell items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 18, _("Recharge staves"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 20, _("Leave the shack"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSLine(5);
if (*GetOptions().Gameplay.visualStoreUI) {
AddSText(0, 14, _("Buy / Sell"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 16, _("Recharge staves"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 18, _("Leave the shack"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSLine(4);
} else {
AddSText(0, 14, _("Buy items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 16, _("Sell items"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 18, _("Recharge staves"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSText(0, 20, _("Leave the shack"), UiFlags::ColorWhite | UiFlags::AlignCenter, true);
AddSLine(5);
}
CurrentItemIndex = 20;
}
@ -714,28 +686,14 @@ void StartWitchBuy()
bool WitchSellOk(int i)
{
Item *pI;
bool rv = false;
const Item *pI;
if (i >= 0)
pI = &MyPlayer->InvList[i];
else
pI = &MyPlayer->SpdList[-(i + 1)];
if (pI->_itype == ItemType::Misc)
rv = true;
if (pI->_iMiscId > 29 && pI->_iMiscId < 41)
rv = false;
if (pI->_iClass == ICLASS_QUEST)
rv = false;
if (pI->_itype == ItemType::Staff && (!gbIsHellfire || IsValidSpell(pI->_iSpell)))
rv = true;
if (pI->IDidx >= IDI_FIRSTQUEST && pI->IDidx <= IDI_LASTQUEST)
rv = false;
if (pI->IDidx == IDI_LAZSTAFF)
rv = false;
return rv;
return WitchWillBuy(*pI);
}
void StartWitchSell()
@ -1257,6 +1215,25 @@ void StartDrunk()
void SmithEnter()
{
if (*GetOptions().Gameplay.visualStoreUI) {
switch (CurrentTextLine) {
case 10:
TownerId = TOWN_SMITH;
OldTextLine = 10;
OldActiveStore = TalkID::Smith;
StartStore(TalkID::Gossip);
break;
case 12:
ActiveStore = TalkID::None;
OpenVisualStore(VisualStoreVendor::Smith);
break;
case 14:
ActiveStore = TalkID::None;
break;
}
return;
}
switch (CurrentTextLine) {
case 10:
TownerId = TOWN_SMITH;
@ -1492,10 +1469,20 @@ void WitchEnter()
StartStore(TalkID::Gossip);
break;
case 14:
StartStore(TalkID::WitchBuy);
if (*GetOptions().Gameplay.visualStoreUI) {
ActiveStore = TalkID::None;
OpenVisualStore(VisualStoreVendor::Witch);
} else {
StartStore(TalkID::WitchBuy);
}
break;
case 16:
StartStore(TalkID::WitchSell);
if (*GetOptions().Gameplay.visualStoreUI) {
ActiveStore = TalkID::None;
OpenVisualStore(VisualStoreVendor::Witch);
} else {
StartStore(TalkID::WitchSell);
}
break;
case 18:
StartStore(TalkID::WitchRecharge);
@ -1633,7 +1620,12 @@ void BoyEnter()
StartStore(TalkID::NoMoney);
} else {
TakePlrsMoney(50);
StartStore(TalkID::BoyBuy);
if (*GetOptions().Gameplay.visualStoreUI) {
ActiveStore = TalkID::None;
OpenVisualStore(VisualStoreVendor::Boy);
} else {
StartStore(TalkID::BoyBuy);
}
}
return;
}
@ -1810,7 +1802,12 @@ void HealerEnter()
StartStore(TalkID::Gossip);
break;
case 14:
StartStore(TalkID::HealerBuy);
if (*GetOptions().Gameplay.visualStoreUI) {
ActiveStore = TalkID::None;
OpenVisualStore(VisualStoreVendor::Healer);
} else {
StartStore(TalkID::HealerBuy);
}
break;
case 18:
ActiveStore = TalkID::None;
@ -2021,6 +2018,25 @@ void DrawSelector(const Surface &out, const Rectangle &rect, std::string_view te
} // namespace
bool StoreAutoPlace(Item &item, bool persistItem)
{
Player &player = *MyPlayer;
if (AutoEquipEnabled(player, item) && AutoEquip(player, item, persistItem, true)) {
return true;
}
if (AutoPlaceItemInBelt(player, item, persistItem, true)) {
return true;
}
if (persistItem) {
return AutoPlaceItemInInventory(player, item, true);
}
return CanFitItemInInventory(player, item);
}
void AddStoreHoldRepair(Item *itm, int8_t i)
{
Item *item;
@ -2740,4 +2756,55 @@ bool IsPlayerInStore()
return ActiveStore != TalkID::None;
}
// TODO: Change `_iIvalue` to be unsigned instead of passing `int` here.
bool PlayerCanAfford(int price)
{
return TotalPlayerGold() >= static_cast<uint32_t>(price);
}
bool SmithWillBuy(const Item &item)
{
if (item.isEmpty())
return false;
if (item._iMiscId > IMISC_OILFIRST && item._iMiscId < IMISC_OILLAST)
return true;
if (item._itype == ItemType::Misc)
return false;
if (item._itype == ItemType::Gold)
return false;
if (item._itype == ItemType::Staff && (!gbIsHellfire || IsValidSpell(item._iSpell)))
return false;
if (item._iClass == ICLASS_QUEST)
return false;
if (item.IDidx == IDI_LAZSTAFF)
return false;
return true;
}
bool WitchWillBuy(const Item &item)
{
if (item.isEmpty())
return false;
bool rv = false;
if (item._itype == ItemType::Misc)
rv = true;
if (item._iMiscId > 29 && item._iMiscId < 41)
rv = false;
if (item._iClass == ICLASS_QUEST)
rv = false;
if (item._itype == ItemType::Staff && (!gbIsHellfire || IsValidSpell(item._iSpell)))
rv = true;
if (item.IDidx >= IDI_FIRSTQUEST && item.IDidx <= IDI_LASTQUEST)
rv = false;
if (item.IDidx == IDI_LAZSTAFF)
rv = false;
return rv;
}
} // namespace devilution

24
Source/stores.h

@ -13,6 +13,7 @@
#include "engine/clx_sprite.hpp"
#include "engine/surface.hpp"
#include "game_mode.hpp"
#include "items.h"
#include "utils/attributes.h"
#include "utils/static_vector.hpp"
@ -119,4 +120,27 @@ void CheckStoreBtn();
void ReleaseStoreBtn();
bool IsPlayerInStore();
/**
* @brief Places an item in the player's inventory, belt, or equipment.
* @param item The item to place.
* @param persistItem If true, actually place the item. If false, just check if it can be placed.
* @return true if the item can be/was placed.
*/
bool StoreAutoPlace(Item &item, bool persistItem);
bool PlayerCanAfford(int price);
/**
* @brief Check if Griswold will buy this item.
* @param item The item to check.
* @return true if the item can be sold to Griswold.
*/
bool SmithWillBuy(const Item &item);
/**
* @brief Check if Adria will buy this item.
* @param item The item to check.
* @return true if the item can be sold to Adria.
*/
bool WitchWillBuy(const Item &item);
} // namespace devilution

BIN
assets/data/repairAllBtn.clx

Binary file not shown.

BIN
assets/data/repairSingleBtn.clx

Binary file not shown.

BIN
assets/data/store.clx

Binary file not shown.

BIN
assets/data/tabBtnUp.clx

Binary file not shown.
Loading…
Cancel
Save