From 0ecc3edaed3b82cd48c70259d36b93a328989375 Mon Sep 17 00:00:00 2001 From: Scott Richmond Date: Sun, 29 Mar 2026 23:23:43 +1000 Subject: [PATCH] [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) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CMake/Assets.cmake | 4 + Source/CMakeLists.txt | 1 + Source/control/control_infobox.cpp | 33 +- Source/control/control_panel.cpp | 10 +- Source/controls/game_controls.cpp | 11 + Source/controls/plrctrls.cpp | 426 +++++++++++++-- Source/controls/plrctrls.h | 4 + Source/cursor.cpp | 6 + Source/diablo.cpp | 26 +- Source/engine/render/scrollrt.cpp | 7 +- Source/inv.cpp | 22 +- Source/options.cpp | 2 + Source/options.h | 2 + Source/qol/visual_store.cpp | 826 +++++++++++++++++++++++++++++ Source/qol/visual_store.h | 188 +++++++ Source/stores.cpp | 215 +++++--- Source/stores.h | 24 + assets/data/repairAllBtn.clx | Bin 0 -> 1898 bytes assets/data/repairSingleBtn.clx | Bin 0 -> 1074 bytes assets/data/store.clx | Bin 0 -> 71513 bytes assets/data/tabBtnUp.clx | Bin 0 -> 2622 bytes 21 files changed, 1675 insertions(+), 132 deletions(-) create mode 100644 Source/qol/visual_store.cpp create mode 100644 Source/qol/visual_store.h create mode 100644 assets/data/repairAllBtn.clx create mode 100644 assets/data/repairSingleBtn.clx create mode 100644 assets/data/store.clx create mode 100644 assets/data/tabBtnUp.clx diff --git a/CMake/Assets.cmake b/CMake/Assets.cmake index e3ead57bc..6ae73c224 100644 --- a/CMake/Assets.cmake +++ b/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 diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 2179dadf2..06367ab69 100644 --- a/Source/CMakeLists.txt +++ b/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 diff --git a/Source/control/control_infobox.cpp b/Source/control/control_infobox.cpp index 30469e973..8dd06fca6 100644 --- a/Source/control/control_infobox.cpp +++ b/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 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 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; } diff --git a/Source/control/control_panel.cpp b/Source/control/control_panel.cpp index dd33e6160..dec1cb067 100644 --- a/Source/control/control_panel.cpp +++ b/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: diff --git a/Source/controls/game_controls.cpp b/Source/controls/game_controls.cpp index dee48bcaa..6de0128c8 100644 --- a/Source/controls/game_controls.cpp +++ b/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)) { diff --git a/Source/controls/plrctrls.cpp b/Source/controls/plrctrls.cpp index c4ec5092f..42beb0518 100644 --- a/Source/controls/plrctrls.cpp +++ b/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 &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((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 items = GetVisualStoreItems(); + if (itemIdx - 1 < static_cast(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 items = GetVisualStoreItems(); + if (itemIdx - 1 < static_cast(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(); } diff --git a/Source/controls/plrctrls.h b/Source/controls/plrctrls.h index 4b79c8fde..7a535eeea 100644 --- a/Source/controls/plrctrls.h +++ b/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); diff --git a/Source/cursor.cpp b/Source/cursor.cpp index 0fa280b6c..5569a78c2 100644 --- a/Source/cursor.cpp +++ b/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; } diff --git a/Source/diablo.cpp b/Source/diablo.cpp index b14da41cb..a2e7fa60b 100644 --- a/Source/diablo.cpp +++ b/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 LoadGameLevelTown(bool firstflag, lvl_entry lvld InitTowners(); InitStash(); + InitVisualStore(); InitItems(); InitMissiles(); diff --git a/Source/engine/render/scrollrt.cpp b/Source/engine/render/scrollrt.cpp index eab266304..7e9ef704b 100644 --- a/Source/engine/render/scrollrt.cpp +++ b/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); } diff --git a/Source/inv.cpp b/Source/inv.cpp index fc97994c9..a69f2d9fd 100644 --- a/Source/inv.cpp +++ b/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; } diff --git a/Source/options.cpp b/Source/options.cpp index 52e2730ed..f1d7c6ef9 100644 --- a/Source/options.cpp +++ b/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 GameplayOptions::GetEntries() &testBarbarian, &experienceBar, &showItemGraphicsInStores, + &visualStoreUI, &showHealthValues, &showManaValues, &showMultiplayerPartyInfo, diff --git a/Source/options.h b/Source/options.h index 58954f55f..2aa649556 100644 --- a/Source/options.h +++ b/Source/options.h @@ -635,6 +635,8 @@ struct GameplayOptions : OptionCategoryBase { OptionEntryInt numRejuPotionPickup; /** @brief Number of Full Rejuvenating potions to pick up automatically */ OptionEntryInt 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. diff --git a/Source/qol/visual_store.cpp b/Source/qol/visual_store.cpp new file mode 100644 index 000000000..1fd592bd5 --- /dev/null +++ b/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 +#include +#include + +#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 GetVendorItems(VisualStoreVendor vendor, VisualStoreTab tab) +{ + switch (vendor) { + case VisualStoreVendor::Smith: { + if (tab == VisualStoreTab::Premium) { + return { PremiumItems.data(), static_cast(PremiumItems.size()) }; + } + return { SmithItems.data(), static_cast(SmithItems.size()) }; + } + case VisualStoreVendor::Witch: { + return { WitchItems.data(), static_cast(WitchItems.size()) }; + } + case VisualStoreVendor::Healer: { + return { HealerItems.data(), static_cast(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 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(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(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 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 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 items = GetVisualStoreItems(); + int count = 0; + for (const Item &item : items) { + if (!item.isEmpty()) + count++; + } + return count; +} + +std::span GetVisualStoreItems() +{ + return GetVendorItems(VisualStore.vendor, VisualStore.activeTab); +} + +int GetVisualStorePageCount() +{ + return std::max(1, static_cast(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 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 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(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 items = GetVisualStoreItems(); + if (itemIndex >= static_cast(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(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 diff --git a/Source/qol/visual_store.h b/Source/qol/visual_store.h new file mode 100644 index 000000000..45889cec5 --- /dev/null +++ b/Source/qol/visual_store.h @@ -0,0 +1,188 @@ +/** + * @file qol/visual_store.h + * + * Interface of visual grid-based store UI. + */ +#pragma once + +#include +#include +#include + +#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 items; + uint16_t grid[VisualStoreGridWidth][VisualStoreGridHeight]; +}; + +struct VisualStoreState { + VisualStoreVendor vendor; + VisualStoreTab activeTab; // For Smith: Regular vs Premium + unsigned currentPage; + std::vector 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 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 diff --git a/Source/stores.cpp b/Source/stores.cpp index bd5cb919f..b23368803 100644 --- a/Source/stores.cpp +++ b/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 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(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(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 diff --git a/Source/stores.h b/Source/stores.h index fc38e00db..a783c3289 100644 --- a/Source/stores.h +++ b/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 diff --git a/assets/data/repairAllBtn.clx b/assets/data/repairAllBtn.clx new file mode 100644 index 0000000000000000000000000000000000000000..b76b869ca4fdb5496bcfaad45f9a4e12b405884a GIT binary patch literal 1898 zcmaJ>+pgR+6di#CuRQV@d>4Pi1FFPt6liDShunPKcETT26}+I9HkrhU6HgMBGp#^E zLMP7gIBTzcTiah;E|+hxnlCSx@4voWzPfyO`S!Aud0td?#qzxf22a>sso+kf-)m_m5ZVZuDnzS)D zy(`VsrnO=^63*0BM{u^)TD8E+Ozl!K6{S>Um|m6E0bmh1uj-m{>@48 z(w4@f>qeuQ%)|hC4jyra^IM{~N*OX>h6hb{2?;)6FQurfTGX{BqvUC@*Wmv{8Xp|+ z#>O!DX*jyR>xUUAB^V!T?t<560>xj?`p|GEBXpdGdv|0V>zA${XRQ>IBiD2v=m~#w z)<^2lO-}0755v9hI>giW-Es7zMiQg2Q#b2p2KSR+OB#AZ5~ZKR>=8|Wzu&r#_kfBS z(+@~5wJVPaZZoxX{sqF-nb{S3`5w84Wiht(9deLKE%{kXrWWn{8IChKX-LAlqzI0$_H>_|wB$qRehMdSxG=6AN!! z+cf4iEtc@nSr!jsaU(JrF>|HI2G}av7XEL9yM=ZeGQWHYh;87*x=E+&mEc4`% zRtgzUSQ$<5hY!%Ab~^ip#%1=P ztG%Q8>olRP1ipGrwcEhAxoEG@!C^D{)q14p(pAwoh$bl&u}lW{4Mq&>7lIg)&=}y@ zI*4mdiiY%@y}Ar3?A9jXGW^(+bQ7WDOPvaD?N1J>G1XQ!&5x-9<>y^nPIbwp5V*VR V8%CbVtN4KN`aEa2ltf+De*@BF4YB|L literal 0 HcmV?d00001 diff --git a/assets/data/repairSingleBtn.clx b/assets/data/repairSingleBtn.clx new file mode 100644 index 0000000000000000000000000000000000000000..995ef57af6d56b7e582462e2e0ed9baa1f648979 GIT binary patch literal 1074 zcmZ8gxsKZ~6ciRg>XiA0l=OQKGS8PdB55{S0xnRm=P z^pIuQCw#s~!8+A{OnajxcZ9EM4BorpyB zP6)Z(!nJq&op`COHd-js*%bOgi;j0G6bPszy|3GF?QgS^9Z$rVJk$jAdS!hWncAVz z7VvKUA?c-GH7J}{1J1X^iiDXD#!)BBi(7g#}YWH+*x)gJQHD(=d+f8_~VM!FYpBFxfneE^eLr8|k+; z$9}kBoHmRq5{(xz#YT(6vgKo#8O7AiD^DW#**PpjT117$`*uSLNX;p%Y<9~$J9O9h z_L@?AB-BJ$GMy9ip`GuF09~Hk?lgGR{#F5_u;laRrl=Fvjk^aS+TCT^ll%xz_nd^- z8Z~bq@G>F#1oa3*ESVM#$j`*^KI{tOO$5WwHUpBar|p zv$Cg|+2#zJ-RwtYWhD>~4}XMXe?K+%c;Jxk;clj;s-~u9_BS>2KmW5o z`?LR%zyIZb`tv{gAO7rr{`3F!&zo15=jZuFb~(D(TwYu}yBzLaAMag{xV|~hU-7qh z_uK0aBG$CD|4GSa>lJ6*_!rq;10#6onn_8Jl@7TVX zK=}b2!Mkal>C} z5+3C3Vwctgbv(!iRib6;(KheU+djxh=l?tic4?nQx?_spqqFJ?in8*-I^`d<&$mSP z%7Vv^!;w@hd>HI*+8N*4$*bmkTQ@6|Gee7$>1;S%QMdkXpQ^gqVf}{MNifT{`b5Y1 zxU?>yl20p4h0APuGaKz0MylwXyScV8>ny7)|CUWO*aW%z8q)-&F5P`&TUxa+gXv)r zd)1wfZ(tzV)b#xAtC@ZqB?hc{RV74dx5fwOTBSLB9IUVliKpgL<`EP}sa3 zps?%3YE=!&)jVJPcC~7Y#bVVi7Uf{E^5Z&XU9IxPzihJ(A}fsSvT~KOlB>-Z-;z}V z?E#g1^?HwiL7O@xC*YnjX zU9NgD2Gh{n#fyu{YOoqiTH5WRZApe=S65+d1APTmd(ExlDjIgZ8VtB;mhze_K3*&a zk$14Ft&_-0O-Ej2zm*r_6jh;h^QKEE1hJlswS7S5eipi1W___`VavWHFQNu+vtX$^ z_7_~Yi*^kAcgyYBM~J~SE)*>&V=bqWNxDOcWN%j!ZX%bwtO~4owSXkIt`(GA3o{pd z`0JBJdtGtG#|uo-yzxlo@8$LOVO}L$46dt0ATFg4#5yvva7gA}7P#57uzIm&!MqR% z_Nl__;OvlvL-tp;%GF@3SPzs7GX*V3N#Zg|D;ab*SWPZoT#{W&M1Qn_+9uedM#Ud2 z_)>bYf-!H-ZR54it{3R^%8zB#3HPhPJRiKcCvPISpSLX+L4eQgrL`mVGfGLNB^Cy4 zl~_0=gOj__Db?8IMyh7ULiNBR$aH)*rAO?kbR)0U4)+F9?Kl!sYz)B0jQsTg`@xt7Wk+igi`h>!QLk@_D_O z&#$k)rW0u9`0RYSSuB>v^BVuiw|Tp`&gXBrTrL;O)vC}@7DcvNFAJ`ht75piDe_g} zvvpb8U3@FSt1G-V>0I(<35gZ7atmCo%XPkLNVCY&<91c#RbDP2Rjt=h zT&>nwLG42&U%`4=^0&^5(dvfJxV0{;sx0$$Q�VD>r=4*U!tgD2MBt5^{VVOVniR z*?{h2e0hayqH)?m%wg~Hi;FKV{b4#mVXE+41S{ z>}&qMIvt*5{B2H8;(vP@H*U^`r=!Cge?#i${2iU%oaASl$)w3({)j?rcw3iQH7XlL z$VwFMO>URj`liUxS=lss-BwlJEH+hB)-_V1CguM!vhjojWt~+kIY}}0)wMMPOq=#*GhAPAm6~_^N_6499OGjZqjJx z^HCD>oB213o4AY2@ntz&47*(Ia+$~+ qxf&@h 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 literal 0 HcmV?d00001