Created
January 21, 2026 20:41
-
-
Save yuripourre/06073c6bde747a30646ac5a6d48ecba0 to your computer and use it in GitHub Desktop.
Store Tabs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * @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