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 000000000..b76b869ca Binary files /dev/null and b/assets/data/repairAllBtn.clx differ diff --git a/assets/data/repairSingleBtn.clx b/assets/data/repairSingleBtn.clx new file mode 100644 index 000000000..995ef57af Binary files /dev/null and b/assets/data/repairSingleBtn.clx differ diff --git a/assets/data/store.clx b/assets/data/store.clx new file mode 100644 index 000000000..2a4063cc2 Binary files /dev/null and b/assets/data/store.clx differ diff --git a/assets/data/tabBtnUp.clx b/assets/data/tabBtnUp.clx new file mode 100644 index 000000000..6735f2066 Binary files /dev/null and b/assets/data/tabBtnUp.clx differ