Skip to content

Instantly share code, notes, and snippets.

@yuripourre
Created January 21, 2026 20:41
Show Gist options
  • Select an option

  • Save yuripourre/06073c6bde747a30646ac5a6d48ecba0 to your computer and use it in GitHub Desktop.

Select an option

Save yuripourre/06073c6bde747a30646ac5a6d48ecba0 to your computer and use it in GitHub Desktop.
Store Tabs
/**
* @file qol/visual_store.cpp
*
* Implementation of visual grid-based store UI.
*/
#include "qol/visual_store.h"
#include <algorithm>
#include <cstdint>
#include <span>
#include "control/control.hpp"
#include "cursor.h"
#include "engine/clx_sprite.hpp"
#include "engine/load_clx.hpp"
#include "engine/load_pcx.hpp"
#include "engine/palette.h"
#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 "engine/surface.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/clx_encode.hpp"
#include "utils/endian_read.hpp"
#include "utils/endian_write.hpp"
#include "utils/format_int.hpp"
#include "utils/language.h"
#include "utils/sdl_bilinear_scale.hpp"
#include "utils/sdl_compat.h"
#include "utils/str_cat.hpp"
#include "utils/surface_to_clx.hpp"
namespace devilution {
bool IsVisualStoreOpen;
VisualStoreState VisualStore;
int16_t pcursstoreitem = -1;
int16_t pcursstorebtn = -1;
namespace {
OptionalOwnedClxSpriteList VisualStorePanelArt;
OptionalOwnedClxSpriteList VisualStoreNavButtonArt;
OptionalOwnedClxSpriteList VisualStoreNavButtonArtScaled;
OptionalOwnedClxSpriteList VisualStoreRepairAllButtonArt;
OptionalOwnedClxSpriteList VisualStoreRepairButtonArt;
int VisualStoreButtonPressed = -1;
constexpr int TabButtonCropWidth = 85;
constexpr int TabButtonCropHeight = 35;
constexpr int TabButtonScaledWidth = 78; // Scaled width in pixels (from top-left)
constexpr int TabButtonScaledHeight = 24; // Scaled height in pixels (from top-left)
constexpr Size ButtonSize { 27, 16 };
/** Contains mappings for the buttons in the visual store (page nav, tabs, leave) */
constexpr Rectangle VisualStoreButtonRect[] = {
// Page navigation buttons
//{ { 19, 19 }, ButtonSize }, // 10 left
//{ { 56, 19 }, ButtonSize }, // 1 left
//{ { 242, 19 }, ButtonSize }, // 1 right
//{ { 279, 19 }, ButtonSize }, // 10 right
// Tab buttons (Smith only) - positioned below title
{ { 13, 10 }, { TabButtonCropWidth, TabButtonCropHeight } }, // Regular tab
{ { 91, 10 }, { TabButtonCropWidth, TabButtonCropHeight } }, // Premium tab
{ { 280, 313 }, { 32, 32 } }, // Repair All Btn
{ { 245, 313 }, { 32, 32 } }, // Repair Btn
};
//constexpr int NavButton10Left = 0;
//constexpr int NavButton1Left = 1;
//constexpr int NavButton1Right = 2;
//constexpr int NavButton10Right = 3;
constexpr int TabButtonRegular = 0;
constexpr int TabButtonPremium = 1;
constexpr int RepairAllBtn = 2;
constexpr int RepairBtn = 3;
/** @brief Get the items array for a specific vendor/tab combination. */
std::span<Item> GetVendorItems(VisualStoreVendor vendor, VisualStoreTab tab)
{
switch (vendor) {
case VisualStoreVendor::Smith:
if (tab == VisualStoreTab::Premium) {
return { PremiumItems.data(), static_cast<size_t>(PremiumItems.size()) };
}
return { SmithItems.data(), static_cast<size_t>(SmithItems.size()) };
case VisualStoreVendor::Witch:
return { WitchItems.data(), static_cast<size_t>(WitchItems.size()) };
case VisualStoreVendor::Healer:
return { HealerItems.data(), static_cast<size_t>(HealerItems.size()) };
case VisualStoreVendor::Boy:
if (BoyItem.isEmpty()) {
return {};
}
return { &BoyItem, 1 };
}
return {};
}
/** @brief Check if the current vendor has tabs (Smith only). */
bool VendorHasTabs()
{
return VisualStore.vendor == VisualStoreVendor::Smith;
}
/** @brief Check if the current vendor accepts items for sale. */
bool VendorAcceptsSale()
{
switch (VisualStore.vendor) {
case VisualStoreVendor::Smith:
case VisualStoreVendor::Witch:
return true;
case VisualStoreVendor::Healer:
case VisualStoreVendor::Boy:
return false;
}
return false;
}
/** @brief Get the vendor name for display. */
const std::string_view GetVendorName()
{
switch (VisualStore.vendor) {
case VisualStoreVendor::Smith:
return _("Griswold's Shop");
case VisualStoreVendor::Witch:
return _("Adria's Shop");
case VisualStoreVendor::Healer:
return _("Pepin's Shop");
case VisualStoreVendor::Boy:
return _("Wirt's Shop");
}
return "";
}
/** @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 Check if Griswold will buy this item. */
bool SmithWillBuy(const Item &item)
{
if (item.isEmpty())
return false;
// Oils are accepted
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;
}
/** @brief Check if Adria will buy this item. */
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;
}
/** @brief Rebuild the grid layout for the current vendor/tab. */
void RefreshVisualStoreLayout()
{
VisualStore.pages.clear();
std::span<Item> items = GetVisualStoreItems();
if (items.empty()) {
VisualStore.pages.emplace_back();
VisualStorePage &page = VisualStore.pages.back();
memset(page.grid, 0, sizeof(page.grid));
return;
}
auto createNewPage = [&]() -> VisualStorePage & {
VisualStore.pages.emplace_back();
VisualStorePage &page = VisualStore.pages.back();
memset(page.grid, 0, sizeof(page.grid));
return page;
};
VisualStorePage *currentPage = &createNewPage();
for (uint16_t i = 0; i < static_cast<uint16_t>(items.size()); i++) {
const Item &item = items[i];
if (item.isEmpty())
continue;
const Size itemSize = GetInventorySize(item);
bool placed = false;
// Try to place in current page
for (auto stashPosition : PointsInRectangle(Rectangle { { 0, 0 }, Size { VisualStoreGridWidth - (itemSize.width - 1), VisualStoreGridHeight - (itemSize.height - 1) } })) {
bool isSpaceFree = true;
for (auto itemPoint : PointsInRectangle(Rectangle { stashPosition, itemSize })) {
if (currentPage->grid[itemPoint.x][itemPoint.y] != 0) {
isSpaceFree = false;
break;
}
}
if (isSpaceFree) {
for (auto itemPoint : PointsInRectangle(Rectangle { stashPosition, itemSize })) {
currentPage->grid[itemPoint.x][itemPoint.y] = i + 1;
}
currentPage->items.push_back({ i, stashPosition + Displacement { 0, itemSize.height - 1 } });
placed = true;
break;
}
}
if (!placed) {
// Start new page
currentPage = &createNewPage();
// Try placing again in new page
for (auto stashPosition : PointsInRectangle(Rectangle { { 0, 0 }, Size { VisualStoreGridWidth - (itemSize.width - 1), VisualStoreGridHeight - (itemSize.height - 1) } })) {
bool isSpaceFree = true;
for (auto itemPoint : PointsInRectangle(Rectangle { stashPosition, itemSize })) {
if (currentPage->grid[itemPoint.x][itemPoint.y] != 0) {
isSpaceFree = false;
break;
}
}
if (isSpaceFree) {
for (auto itemPoint : PointsInRectangle(Rectangle { stashPosition, itemSize })) {
currentPage->grid[itemPoint.x][itemPoint.y] = i + 1;
}
currentPage->items.push_back({ i, stashPosition + Displacement { 0, itemSize.height - 1 } });
placed = true;
break;
}
}
}
}
if (VisualStore.currentPage >= VisualStore.pages.size())
VisualStore.currentPage = VisualStore.pages.empty() ? 0 : static_cast<unsigned>(VisualStore.pages.size() - 1);
}
} // namespace
void InitVisualStore()
{
if (HeadlessMode)
return;
// For now, reuse the stash panel art as a placeholder
// In the future, create dedicated visual store assets
VisualStorePanelArt = LoadClx("data\\store.clx");
// Load button art from PCX and capture its palette
std::array<SDL_Color, 256> pcxPalette;
VisualStoreNavButtonArt = LoadPcxSpriteList("ui_art\\but_xsm", 2, 1, pcxPalette.data());
// Create a color mapping table from PCX palette to game palette
std::array<uint8_t, 256> colorMap;
for (int i = 0; i < 256; i++) {
// Find the closest matching color in the game's logical palette
uint8_t bestMatch = 0;
int bestDistance = INT_MAX;
const SDL_Color &pcxColor = pcxPalette[i];
for (int j = 0; j < 256; j++) {
const SDL_Color &gameColor = logical_palette[j];
// Calculate color distance (simple RGB distance)
const int dr = static_cast<int>(pcxColor.r) - static_cast<int>(gameColor.r);
const int dg = static_cast<int>(pcxColor.g) - static_cast<int>(gameColor.g);
const int db = static_cast<int>(pcxColor.b) - static_cast<int>(gameColor.b);
const int distance = dr * dr + dg * dg + db * db;
if (distance < bestDistance) {
bestDistance = distance;
bestMatch = static_cast<uint8_t>(j);
if (distance == 0) break; // Perfect match found
}
}
colorMap[i] = bestMatch;
}
VisualStoreRepairAllButtonArt = LoadClx("data\\repairAllBtn.clx");
VisualStoreRepairButtonArt = LoadClx("data\\repairSingleBtn.clx");
// Crop button sprites - we need 2 frames (active and inactive)
if (VisualStoreNavButtonArt) {
const int numFrames = 2; // Frame 0 = active, Frame 1 = inactive
std::vector<OwnedClxSpriteList> croppedFrames;
for (int frameIdx = 0; frameIdx < numFrames; frameIdx++) {
// Frame 0: crop from y=TabButtonCropHeight+1 (active button, skip first line)
// Frame 1: crop from y=1 (inactive button, skip first line)
const int cropStartY = (frameIdx == 0) ? TabButtonCropHeight + 1 : 1;
const int i = 0; // Always use first sprite from PCX (which contains both button states vertically)
const ClxSprite originalSprite = (*VisualStoreNavButtonArt)[i];
const int originalWidth = originalSprite.width();
const int originalHeight = originalSprite.height();
// Use exact crop dimensions
const int cropWidth = std::min(TabButtonCropWidth, originalWidth);
const int cropHeight = std::min(TabButtonCropHeight, originalHeight);
if (cropWidth <= 0 || cropHeight <= 0)
continue;
// Use fixed pixel dimensions for scaling
const int scaledWidth = TabButtonScaledWidth;
const int scaledHeight = TabButtonScaledHeight;
if (scaledWidth <= 0 || scaledHeight <= 0)
continue;
// Create surfaces for cropping and scaling
OwnedSurface originalSurface { originalWidth, originalHeight };
OwnedSurface croppedSurface { cropWidth, cropHeight };
OwnedSurface scaledSurface { scaledWidth, scaledHeight };
// Render original sprite to surface
const Surface origSurf = originalSurface.subregion(0, 0, originalWidth, originalHeight);
SDL_FillSurfaceRect(origSurf.surface, nullptr, 1);
ClxDraw(origSurf, { 0, originalHeight }, originalSprite);
// Copy cropped region starting from cropStartY (top-left crop from specific Y offset)
const Surface croppedSurf = croppedSurface.subregion(0, 0, cropWidth, cropHeight);
SDL_FillSurfaceRect(croppedSurf.surface, nullptr, 1);
uint8_t *srcPixels = static_cast<uint8_t *>(originalSurface.surface->pixels);
uint8_t *croppedPixels = static_cast<uint8_t *>(croppedSurface.surface->pixels);
const int srcPitch = originalSurface.surface->pitch;
const int croppedPitch = croppedSurface.surface->pitch;
// Copy pixels from source to cropped surface, starting from cropStartY
// Apply color mapping from PCX palette to game palette
for (int y = 0; y < cropHeight; y++) {
const int srcY = cropStartY + y;
if (srcY >= originalHeight) break;
for (int x = 0; x < cropWidth; x++) {
const uint8_t srcPixel = srcPixels[srcY * srcPitch + x];
croppedPixels[y * croppedPitch + x] = colorMap[srcPixel];
}
}
// Now scale the cropped surface
const Surface scaledSurf = scaledSurface.subregion(0, 0, scaledWidth, scaledHeight);
SDL_FillSurfaceRect(scaledSurf.surface, nullptr, 1);
uint8_t *scaledPixels = static_cast<uint8_t *>(scaledSurface.surface->pixels);
const int scaledPitch = scaledSurface.surface->pitch;
// Nearest-neighbor scaling from cropped to scaled surface (top-left origin)
const float scaleX = static_cast<float>(cropWidth) / scaledWidth;
const float scaleY = static_cast<float>(cropHeight) / scaledHeight;
for (int y = 0; y < scaledHeight; y++) {
const int srcY = static_cast<int>(y * scaleY);
for (int x = 0; x < scaledWidth; x++) {
const int srcX = static_cast<int>(x * scaleX);
scaledPixels[y * scaledPitch + x] = croppedPixels[srcY * croppedPitch + srcX];
}
}
// Convert scaled surface back to CLX format
croppedFrames.push_back(SurfaceToClx(scaledSurf, 1, 1));
}
// Combine all frames into a single sprite list with proper CLX format
if (!croppedFrames.empty()) {
const uint32_t numFrames = static_cast<uint32_t>(croppedFrames.size());
std::vector<uint8_t> clxData;
// CLX header: frame count, frame offset for each frame, file size
clxData.resize(4 * (2 + numFrames));
WriteLE32(clxData.data(), numFrames);
// Extract frame data from each single-frame CLX list and combine
for (uint32_t frame = 0; frame < numFrames; frame++) {
const ClxSpriteList frameList = croppedFrames[frame];
const ClxSprite frameSprite = frameList[0];
// Write offset to this frame's data
WriteLE32(&clxData[4 * (frame + 1)], static_cast<uint32_t>(clxData.size()));
// Get the frame data pointer (points to frame header)
const uint8_t *frameDataStart = frameList.data() + frameList.spriteOffset(0);
const uint16_t headerSize = LoadLE16(frameDataStart);
const size_t frameDataSize = headerSize + frameSprite.pixelDataSize();
// Copy frame data (header + pixel data)
const size_t oldSize = clxData.size();
clxData.resize(oldSize + frameDataSize);
std::memcpy(&clxData[oldSize], frameDataStart, frameDataSize);
}
// Write total file size
WriteLE32(&clxData[4 * (1 + numFrames)], static_cast<uint32_t>(clxData.size()));
// Create owned sprite list
std::unique_ptr<uint8_t[]> data(new uint8_t[clxData.size()]);
std::memcpy(data.get(), clxData.data(), clxData.size());
VisualStoreNavButtonArtScaled.emplace(OwnedClxSpriteList { std::move(data) });
}
}
}
void FreeVisualStoreGFX()
{
VisualStoreNavButtonArt = std::nullopt;
VisualStoreNavButtonArtScaled = std::nullopt;
VisualStorePanelArt = std::nullopt;
}
void OpenVisualStore(VisualStoreVendor vendor)
{
IsVisualStoreOpen = true;
invflag = true; // Open inventory panel alongside
VisualStore.vendor = vendor;
VisualStore.activeTab = VisualStoreTab::Regular;
VisualStore.currentPage = 0;
pcursstoreitem = -1;
pcursstorebtn = -1;
// Refresh item stat flags for current player
std::span<Item> items = GetVisualStoreItems();
for (Item &item : items) {
item._iStatFlag = MyPlayer->CanUseItem(item);
}
RefreshVisualStoreLayout();
}
void CloseVisualStore()
{
IsVisualStoreOpen = false;
invflag = false;
pcursstoreitem = -1;
pcursstorebtn = -1;
VisualStoreButtonPressed = -1;
VisualStore.pages.clear();
}
void SetVisualStoreTab(VisualStoreTab tab)
{
if (!VendorHasTabs())
return;
VisualStore.activeTab = tab;
VisualStore.currentPage = 0;
pcursstoreitem = -1;
pcursstorebtn = -1;
// Refresh item stat flags
std::span<Item> items = GetVisualStoreItems();
for (Item &item : items) {
item._iStatFlag = MyPlayer->CanUseItem(item);
}
RefreshVisualStoreLayout();
}
void VisualStoreNextPage()
{
if (VisualStore.currentPage + 1 < VisualStore.pages.size()) {
VisualStore.currentPage++;
pcursstoreitem = -1;
pcursstorebtn = -1;
}
}
void VisualStorePreviousPage()
{
if (VisualStore.currentPage > 0) {
VisualStore.currentPage--;
pcursstoreitem = -1;
pcursstorebtn = -1;
}
}
int GetRepairCost(const Item &item)
{
if (item.isEmpty() || item._iDurability == item._iMaxDur || item._iMaxDur == DUR_INDESTRUCTIBLE)
return 0;
const int due = item._iMaxDur - item._iDurability;
if (item._iMagical != ITEM_QUALITY_NORMAL && item._iIdentified) {
return 30 * item._iIvalue * due / (item._iMaxDur * 100 * 2);
} else {
return std::max(item._ivalue * due / (item._iMaxDur * 2), 1);
}
}
void VisualStoreRepairAll()
{
Player &myPlayer = *MyPlayer;
int totalCost = 0;
// Check body items
for (auto &item : myPlayer.InvBody) {
totalCost += GetRepairCost(item);
}
// Check inventory items
for (int i = 0; i < myPlayer._pNumInv; i++) {
totalCost += GetRepairCost(myPlayer.InvList[i]);
}
if (totalCost == 0)
return;
if (!PlayerCanAfford(totalCost)) {
// Optional: add a message that player can't afford
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)) {
//PlaySFX(HeroSpeech::ICantUseThisYet); // Or some other error sound
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
//constexpr Displacement GridOffset { 17, 60 + (INV_SLOT_SIZE_PX/2) };
return GetPanelPosition(UiPanels::Stash, slot * SlotSpacing + Displacement { 17, 44 });
}
Rectangle GetVisualBtnCoord(int btnId)
{
const Point panelPos = GetPanelPosition(UiPanels::Stash);
const Rectangle regBtnPos = { panelPos + (VisualStoreButtonRect[btnId].position - Point { 0, 0 }), VisualStoreButtonRect[btnId].size };
return regBtnPos;
}
int GetVisualStoreItemCount()
{
std::span<Item> items = GetVisualStoreItems();
int count = 0;
for (const Item &item : items) {
if (!item.isEmpty())
count++;
}
return count;
}
std::span<Item> GetVisualStoreItems()
{
return GetVendorItems(VisualStore.vendor, VisualStore.activeTab);
}
int GetVisualStorePageCount()
{
return std::max(1, static_cast<int>(VisualStore.pages.size()));
}
void DrawVisualStore(const Surface &out)
{
if (!VisualStorePanelArt)
return;
// Draw panel background (reusing stash art for now)
RenderClxSprite(out, (*VisualStorePanelArt)[0], GetPanelPosition(UiPanels::Stash));
const Point panelPos = GetPanelPosition(UiPanels::Stash);
const UiFlags styleWhite = UiFlags::VerticalCenter | UiFlags::ColorWhite;
constexpr int TextHeight = 13;
// Draw store title
/*DrawString(out, GetVendorName(), { panelPos + Displacement { 0, 2 }, { 320, TextHeight } },
{ .flags = UiFlags::AlignCenter | styleGold });*/
// Draw tab buttons (Smith only)
if (VendorHasTabs()) {
const Rectangle regBtnPos = { panelPos + (VisualStoreButtonRect[TabButtonRegular].position - Point { 0, 0 }), VisualStoreButtonRect[TabButtonRegular].size };
const bool regIsActive = VisualStore.activeTab == VisualStoreTab::Regular;
const bool regIsPressed = VisualStoreButtonPressed == TabButtonRegular;
if (VisualStoreNavButtonArtScaled) {
// Frame 0 = active or clicked, Frame 1 = inactive
const int frame = (regIsActive || regIsPressed) ? 0 : 1;
const ClxSprite scaledSprite = (*VisualStoreNavButtonArtScaled)[frame];
// Draw from top-left corner of button rectangle
RenderClxSprite(out, scaledSprite, regBtnPos.position);
}
// Center text in the scaled button size, not the crop size
// Move text 1px down when active or clicked
const int regTextOffsetY = (regIsActive || regIsPressed) ? 1 : 0;
const Rectangle regBtnTextPos = { regBtnPos.position + Displacement { 0, regTextOffsetY }, { TabButtonScaledWidth, TabButtonScaledHeight } };
DrawString(out, _("Basic"), regBtnTextPos,
{ .flags = UiFlags::AlignCenter | styleWhite });
const Rectangle premBtnPos = { panelPos + (VisualStoreButtonRect[TabButtonPremium].position - Point { 0, 0 }), VisualStoreButtonRect[TabButtonPremium].size };
const bool premIsActive = VisualStore.activeTab == VisualStoreTab::Premium;
const bool premIsPressed = VisualStoreButtonPressed == TabButtonPremium;
if (VisualStoreNavButtonArtScaled) {
// Frame 0 = active or clicked, Frame 1 = inactive
const int frame = (premIsActive || premIsPressed) ? 0 : 1;
const ClxSprite scaledSprite = (*VisualStoreNavButtonArtScaled)[frame];
// Draw from top-left corner of button rectangle
RenderClxSprite(out, scaledSprite, premBtnPos.position);
}
// Center text in the scaled button size, not the crop size
// Move text 1px down when active or clicked
const int premTextOffsetY = (premIsActive || premIsPressed) ? 1 : 0;
const Rectangle premBtnTextPos = { premBtnPos.position + Displacement { 0, premTextOffsetY }, { TabButtonScaledWidth, TabButtonScaledHeight } };
DrawString(out, _("Premium"), premBtnTextPos,
{ .flags = UiFlags::AlignCenter | styleWhite });
}
// Draw page number
/*int pageCount = GetVisualStorePageCount();
std::string pageText = StrCat(VisualStore.currentPage + 1, "/", pageCount);
DrawString(out, pageText, { panelPos + Displacement { 132, 40 }, { 57, TextHeight } },
{ .flags = UiFlags::AlignCenter | styleWhite });*/
if (VisualStore.currentPage >= VisualStore.pages.size())
return;
const VisualStorePage &page = VisualStore.pages[VisualStore.currentPage];
std::span<Item> allItems = GetVisualStoreItems();
constexpr Displacement offset { 0, INV_SLOT_SIZE_PX - 1 };
// First pass: draw item slot backgrounds
for (int y = 0; y < VisualStoreGridHeight; y++) {
for (int x = 0; x < VisualStoreGridWidth; x++) {
const uint16_t itemPlusOne = page.grid[x][y];
if (itemPlusOne == 0)
continue;
const Item &item = allItems[itemPlusOne - 1];
Point position = GetVisualStoreSlotCoord({ x, y }) + offset;
InvDrawSlotBack(out, position, InventorySlotSizeInPixels, item._iMagical);
}
}
// Second pass: draw item sprites
for (const auto &vsItem : page.items) {
const Item &item = allItems[vsItem.index];
Point position = GetVisualStoreSlotCoord(vsItem.position) + offset;
const int frame = item._iCurs + CURSOR_FIRSTITEM;
const ClxSprite sprite = GetInvItemSprite(frame);
// Draw highlight outline if this item is hovered
if (pcursstoreitem == vsItem.index) {
const uint8_t color = GetOutlineColor(item, true);
ClxDrawOutline(out, color, position, sprite);
}
DrawItem(item, out, position, sprite);
}
// Draw player gold at bottom
uint32_t totalGold = MyPlayer->_pGold + Stash.gold;
DrawString(out, StrCat(_("Gold: "), FormatInteger(totalGold)),
{ panelPos + Displacement { 20, 320 }, { 280, TextHeight } },
{ .flags = styleWhite });
// Draw Repair All
if (VisualStore.vendor == VisualStoreVendor::Smith) {
const Rectangle repairAllBtnPos = { panelPos + (VisualStoreButtonRect[RepairAllBtn].position - Point { 0, 0 }), VisualStoreButtonRect[RepairAllBtn].size };
RenderClxSprite(out, (*VisualStoreRepairAllButtonArt)[VisualStoreButtonPressed == RepairAllBtn], repairAllBtnPos.position);
// DrawString(out, _("Repair All"), repairAllBtnPos, { .flags = UiFlags::AlignCenter | styleWhite
// Draw Repair
const Rectangle repairBtnPos = { panelPos + (VisualStoreButtonRect[RepairBtn].position - Point { 0, 0 }), VisualStoreButtonRect[RepairBtn].size };
RenderClxSprite(out, (*VisualStoreRepairButtonArt)[VisualStoreButtonPressed == RepairBtn], repairBtnPos.position);
// DrawString(out, _("Repair"), repairBtnPos, { .flags = UiFlags::AlignCenter | styleWhite });
}
}
int16_t CheckVisualStoreHLight(Point mousePosition)
{
// Check buttons first
const Point panelPos = GetPanelPosition(UiPanels::Stash);
for (int i = 0; i < 4; i++) {
// Skip tab buttons if vendor doesn't have tabs
/*if (!VendorHasTabs() && (i == TabButtonRegular || i == TabButtonPremium))
continue;*/
Rectangle button = VisualStoreButtonRect[i];
button.position = panelPos + (button.position - Point { 0, 0 });
if (button.contains(mousePosition)) {
if (i == RepairAllBtn) {
int totalCost = 0;
Player &myPlayer = *MyPlayer;
for (auto &item : myPlayer.InvBody)
totalCost += GetRepairCost(item);
for (int j = 0; j < myPlayer._pNumInv; j++)
totalCost += GetRepairCost(myPlayer.InvList[j]);
InfoString = _("Repair All");
FloatingInfoString = _("Repair All");
if (totalCost > 0) {
AddInfoBoxString(StrCat(FormatInteger(totalCost), " Gold"));
AddInfoBoxString(StrCat(FormatInteger(totalCost), " Gold"), true);
} else {
AddInfoBoxString(_("Nothing to repair"));
AddInfoBoxString(_("Nothing to repair"), true);
}
InfoColor = UiFlags::ColorWhite;
pcursstorebtn = RepairAllBtn;
return -1;
} else if (i == RepairBtn) {
InfoString = _("Repair");
FloatingInfoString = _("Repair");
AddInfoBoxString(_("Repair a single item"));
AddInfoBoxString(_("Repair a single item"), true);
InfoColor = UiFlags::ColorWhite;
pcursstorebtn = RepairBtn;
return -1;
}
}
}
if (VisualStore.currentPage >= VisualStore.pages.size())
return -1;
const VisualStorePage &page = VisualStore.pages[VisualStore.currentPage];
std::span<Item> allItems = GetVisualStoreItems();
for (int y = 0; y < VisualStoreGridHeight; y++) {
for (int x = 0; x < VisualStoreGridWidth; x++) {
const uint16_t itemPlusOne = page.grid[x][y];
if (itemPlusOne == 0)
continue;
const int itemIndex = itemPlusOne - 1;
const Item &item = allItems[itemIndex];
const Rectangle cell {
GetVisualStoreSlotCoord({ x, y }),
InventorySlotSizeInPixels + 1
};
if (cell.contains(mousePosition)) {
// Set up info display
InfoColor = item.getTextColor();
InfoString = item.getName();
const int price = item._iIvalue;
const bool canAfford = PlayerCanAfford(price);
InfoString = item.getName();
FloatingInfoString = item.getName();
InfoColor = canAfford ? item.getTextColor() : UiFlags::ColorRed;
if (item._iIdentified) {
PrintItemDetails(item);
} else {
PrintItemDur(item);
}
AddInfoBoxString(StrCat("", FormatInteger(price), " Gold"));
return static_cast<int16_t>(itemIndex);
}
}
}
return -1;
}
void CheckVisualStoreItem(Point mousePosition)
{
// Check if clicking on an item to buy
int16_t itemIndex = CheckVisualStoreHLight(mousePosition);
if (itemIndex < 0)
return;
std::span<Item> items = GetVisualStoreItems();
if (itemIndex >= static_cast<int16_t>(items.size()))
return;
Item &item = items[itemIndex];
if (item.isEmpty())
return;
// Check if player can afford the item
int price = item._iIvalue;
uint32_t totalGold = MyPlayer->_pGold + Stash.gold;
if (totalGold < static_cast<uint32_t>(price)) {
//InitDiabloMsg(EMSG_NOT_ENOUGH_GOLD);
return;
}
// Check if player has room for the item
if (!StoreAutoPlace(item, false)) {
//InitDiabloMsg(EMSG_INVENTORY_FULL);
return;
}
// Execute the purchase
TakePlrsMoney(price);
StoreAutoPlace(item, true);
PlaySFX(ItemInvSnds[ItemCAnimTbl[item._iCurs]]);
// Remove item from store (vendor-specific handling)
switch (VisualStore.vendor) {
case VisualStoreVendor::Smith:
if (VisualStore.activeTab == VisualStoreTab::Premium) {
// Premium items get replaced
PremiumItems[itemIndex].clear();
SpawnPremium(*MyPlayer);
} else {
// Basic items are removed
SmithItems.erase(SmithItems.begin() + itemIndex);
}
break;
case VisualStoreVendor::Witch:
// First 3 items are pinned, don't remove them
if (itemIndex >= 3) {
WitchItems.erase(WitchItems.begin() + itemIndex);
}
break;
case VisualStoreVendor::Healer:
// First 2-3 items are pinned
if (itemIndex >= (gbIsMultiplayer ? 3 : 2)) {
HealerItems.erase(HealerItems.begin() + itemIndex);
}
break;
case VisualStoreVendor::Boy:
BoyItem.clear();
break;
}
pcursstoreitem = -1;
RefreshVisualStoreLayout();
}
void CheckVisualStorePaste(Point mousePosition)
{
if (!VendorAcceptsSale())
return;
Player &player = *MyPlayer;
if (player.HoldItem.isEmpty())
return;
// Check if the item can be sold to this vendor
if (!CanSellToCurrentVendor(player.HoldItem)) {
player.SaySpecific(HeroSpeech::ICantDoThat);
return;
}
// Calculate sell price
int sellPrice = GetSellPrice(player.HoldItem);
// Add gold to player
AddGoldToInventory(player, sellPrice);
PlaySFX(SfxID::ItemGold);
// Clear the held item
player.HoldItem.clear();
NewCursor(CURSOR_HAND);
}
bool CanSellToCurrentVendor(const Item &item)
{
if (item.isEmpty())
return false;
switch (VisualStore.vendor) {
case VisualStoreVendor::Smith:
return SmithWillBuy(item);
case VisualStoreVendor::Witch:
return WitchWillBuy(item);
case VisualStoreVendor::Healer:
case VisualStoreVendor::Boy:
return false;
}
return false;
}
void SellItemToVisualStore(int invIndex)
{
if (!VendorAcceptsSale())
return;
Player &player = *MyPlayer;
Item &item = player.InvList[invIndex];
if (!CanSellToCurrentVendor(item)) {
player.SaySpecific(HeroSpeech::ICantDoThat);
return;
}
// Calculate sell price
int sellPrice = GetSellPrice(item);
// Add gold to player
AddGoldToInventory(player, sellPrice);
PlaySFX(SfxID::ItemGold);
// Remove item from inventory
player.RemoveInvItem(invIndex);
}
void CheckVisualStoreButtonPress(Point mousePosition)
{
for (int i = 0; i < 4; i++) {
Rectangle button = VisualStoreButtonRect[i];
button.position = GetPanelPosition(UiPanels::Stash, button.position);
// Skip tab buttons if vendor doesn't have tabs
/*if (!VendorHasTabs() && (i == TabButtonRegular || i == TabButtonPremium))
continue;*/
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 NavButton10Left:
// for (int i = 0; i < 10 && VisualStore.currentPage > 0; i++)
// VisualStorePreviousPage();
// break;
//case NavButton1Left:
// VisualStorePreviousPage();
// break;
//case NavButton1Right:
// VisualStoreNextPage();
// break;
//case NavButton10Right:
// for (int i = 0; i < 10; i++)
// VisualStoreNextPage();
// break;
case TabButtonRegular:
SetVisualStoreTab(VisualStoreTab::Regular);
break;
case TabButtonPremium:
SetVisualStoreTab(VisualStoreTab::Premium);
break;
case RepairAllBtn:
VisualStoreRepairAll();
break;
case RepairBtn:
VisualStoreRepair();
break;
}
}
VisualStoreButtonPressed = -1;
}
} // namespace devilution
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment