From 43599963e4dc5178b7b3dbae86be8d5f36dbb360 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 26 Sep 2026 14:17:57 +0000 Subject: [PATCH] fix(ui): separate left-click move/stack/identify-target from right-click use for tomes, scrolls, and cube (Fixes #487) --- src/game/class-starter-profiles.ts | 4 + src/ui/hud-manager.ts | 203 +++++++++----- src/ui/inventory.ts | 423 +++++++++++++++++++++++++++-- src/ui/item-bridge.ts | 32 ++- src/ui/world-panels.ts | 377 +++++++++++++++++++++++-- tests/ui-hud.test.ts | 265 +++++++++++++++++- 6 files changed, 1202 insertions(+), 102 deletions(-) diff --git a/src/game/class-starter-profiles.ts b/src/game/class-starter-profiles.ts index e01684b..5200827 100644 --- a/src/game/class-starter-profiles.ts +++ b/src/game/class-starter-profiles.ts @@ -1376,6 +1376,8 @@ export function createStarterBagForClass(classCode: CharacterClassCode): GridPla invWidth: 1, invHeight: 2, allowedSlots: [], + quantity: 20, + maxQuantity: 20, stats: [{ text: '右键施放城镇传送门', color: 'white' }], }, }, @@ -1393,6 +1395,8 @@ export function createStarterBagForClass(classCode: CharacterClassCode): GridPla invWidth: 1, invHeight: 2, allowedSlots: [], + quantity: 20, + maxQuantity: 20, stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }], }, }, diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index d123a8c..93004bb 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -21,7 +21,14 @@ import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from import { ControlBarHud, type MiniPanelAction } from './control-bar.ts' import { SKILLS_BY_ID } from '../data/skills-catalog.ts' import { calculateManaCost } from '../game/skill-calc-engine.ts' -import { InventoryPanel, type EquipSlotId, type UiInventoryItem, resolveItemSpriteRect, INV_GRID_ORIGIN } from './inventory.ts' +import { + InventoryPanel, + type EquipSlotId, + type UiInventoryItem, + resolveItemSpriteRect, + INV_GRID_ORIGIN, + isUsableRightClickItem, +} from './inventory.ts' import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts' import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts' import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts' @@ -545,11 +552,13 @@ export class HudManager { this.rightPanel !== 'none' || this.belt.lockedOpen || this.hotkeys.openPopup !== null || - this.worldPanels.npcMenu !== null + this.worldPanels.npcMenu !== null || + this.inventory.isIdentifyMode() this.leftPanel = 'none' this.rightPanel = 'none' this.charSheet.visible = false this.inventory.visible = false + this.inventory.cancelIdentifyMode() this.skillTree.visible = false this.belt.lockedOpen = false this.hotkeys.openPopup = null @@ -712,13 +721,23 @@ export class HudManager { this.inventory.hoveredItem.x += deltaRight } this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y) - if (this.leftPanel === 'stash' || this.leftPanel === 'waypoint' || this.leftPanel === 'vendor') { + if ( + this.leftPanel === 'stash' || + this.leftPanel === 'cube' || + this.leftPanel === 'waypoint' || + this.leftPanel === 'vendor' + ) { this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y) if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) { this.inventory.hoveredItem = { ...this.worldPanels.hoveredStashItem, x: this.worldPanels.hoveredStashItem.x + deltaLeft, } + } else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) { + this.inventory.hoveredItem = { + ...this.worldPanels.hoveredCubeItem, + x: this.worldPanels.hoveredCubeItem.x + deltaLeft, + } } else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) { this.inventory.hoveredItem = { item: this.worldPanels.hoveredVendorItem.item, @@ -746,6 +765,13 @@ export class HudManager { if (e.button !== 0 && e.button !== 2) return const pt = this.clientToLogical(e.clientX, e.clientY) if (!this.isPointInterceptedByHud(pt.x, pt.y)) { + if (this.inventory.isIdentifyMode()) { + e.preventDefault() + e.stopPropagation() + this.inventory.cancelIdentifyMode() + this.syncPublishedState() + return + } // If holding cursorItem and clicking outside panels onto the ground, drop it if (this.inventory.cursorItem !== null) { e.preventDefault() @@ -846,6 +872,8 @@ export class HudManager { onWaypointTeleport: (act, slug, levelId) => { this.callbacks.onWaypointTeleport(act, slug, levelId) }, + onOpenCube: () => this.toggleLeftPanel('cube'), + onCastTownPortal: () => this.callbacks.onCastTownPortal?.(), inventory: this.inventory, isShiftClick: e.shiftKey, isRightClick: e.button === 2, @@ -859,74 +887,106 @@ export class HudManager { if (pt.x >= 400 + deltaRight) { const rightX = pt.x - deltaRight if (this.rightPanel === 'inv') { - // Stash quick transfer - if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) { - if ( - rightX >= INV_GRID_ORIGIN.x && - rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && - pt.y >= INV_GRID_ORIGIN.y && - pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx - ) { - const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) - const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) - const hit = this.inventory.gridItems.find( - p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, - ) - if (hit) { - this.worldPanels.quickTransferToStash(hit.item, this.inventory) + if (!this.inventory.isIdentifyMode()) { + // Stash quick transfer (Shift+Click, or Right-Click on non-usable items) + if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) { + if ( + rightX >= INV_GRID_ORIGIN.x && + rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + pt.y >= INV_GRID_ORIGIN.y && + pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + const hit = this.inventory.gridItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hit && (e.shiftKey || !isUsableRightClickItem(hit.item.code))) { + this.worldPanels.quickTransferToStash(hit.item, this.inventory) + this.syncPublishedState() + return + } + } + } + + // Horadric Cube quick transfer (Shift+Click, or Right-Click on non-usable items) + if (this.leftPanel === 'cube' && (e.shiftKey || e.button === 2)) { + if ( + rightX >= INV_GRID_ORIGIN.x && + rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + pt.y >= INV_GRID_ORIGIN.y && + pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + const hit = this.inventory.gridItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hit && (e.shiftKey || !isUsableRightClickItem(hit.item.code))) { + this.worldPanels.quickTransferToCube(hit.item, this.inventory) + this.syncPublishedState() + return + } + } + } + + // Vendor Repair or Sell/Quick-Sell from Player Inventory + if (this.leftPanel === 'vendor') { + if ( + rightX >= INV_GRID_ORIGIN.x && + rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + pt.y >= INV_GRID_ORIGIN.y && + pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + const hitIdx = this.inventory.gridItems.findIndex( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hitIdx !== -1) { + const hit = this.inventory.gridItems[hitIdx]! + if (this.worldPanels.vendorTradeState === 'repair') { + this.worldPanels.repairSingleUiItem(hit.item, this.inventory) + this.syncPublishedState() + return + } + if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) { + const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory) + if (res.ok) { + this.inventory.gridItems.splice(hitIdx, 1) + this.inventory.hoveredItem = null + } + this.syncPublishedState() + return + } + } + } else if (this.worldPanels.vendorTradeState === 'repair' && this.inventory.hoveredItem) { + this.worldPanels.repairSingleUiItem(this.inventory.hoveredItem.item, this.inventory) this.syncPublishedState() return } } } - // Vendor Repair or Sell/Quick-Sell from Player Inventory - if (this.leftPanel === 'vendor') { - if ( - rightX >= INV_GRID_ORIGIN.x && - rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && - pt.y >= INV_GRID_ORIGIN.y && - pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx - ) { - const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) - const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) - const hitIdx = this.inventory.gridItems.findIndex( - p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, - ) - if (hitIdx !== -1) { - const hit = this.inventory.gridItems[hitIdx]! - if (this.worldPanels.vendorTradeState === 'repair') { - this.worldPanels.repairSingleUiItem(hit.item, this.inventory) - this.syncPublishedState() - return + this.inventory.handleClick( + rightX, + pt.y, + { + onOpenCube: () => this.toggleLeftPanel('cube'), + onCastTownPortal: () => this.callbacks.onCastTownPortal?.(), + onDropGold: (amt) => { + const dropped = this.inventory.dropGold(amt) + if (dropped > 0) { + this.callbacks.onDropGoldToGround?.(dropped, pt) } - if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) { - const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory) - if (res.ok) { - this.inventory.gridItems.splice(hitIdx, 1) - this.inventory.hoveredItem = null - } - this.syncPublishedState() - return - } - } - } else if (this.worldPanels.vendorTradeState === 'repair' && this.inventory.hoveredItem) { - this.worldPanels.repairSingleUiItem(this.inventory.hoveredItem.item, this.inventory) - this.syncPublishedState() - return - } - } - - this.inventory.handleClick(rightX, pt.y, { - onOpenCube: () => this.toggleLeftPanel('cube'), - onCastTownPortal: () => this.callbacks.onCastTownPortal?.(), - onDropGold: (amt) => { - const dropped = this.inventory.dropGold(amt) - if (dropped > 0) { - this.callbacks.onDropGoldToGround?.(dropped, pt) - } + }, }, - }) + e.button as 0 | 2, + { + stashItems: this.worldPanels.stashItems, + cubeItems: this.worldPanels.cubeItems, + }, + ) if (!this.inventory.visible) this.rightPanel = 'none' this.syncPublishedState() return @@ -1230,9 +1290,12 @@ export class HudManager { this.worldPanels.drawAreaBanner(ctx, nowMs, this.font) } - // 10. Hover Item Tooltip (Inventory / Equipment / Stash / Vendor) + // 10. Hover Item Tooltip (Inventory / Equipment / Stash / Cube / Vendor) if ( - (this.rightPanel === 'inv' || this.leftPanel === 'stash' || this.leftPanel === 'vendor') && + (this.rightPanel === 'inv' || + this.leftPanel === 'stash' || + this.leftPanel === 'cube' || + this.leftPanel === 'vendor') && this.inventory.hoveredItem && !this.inventory.cursorItem ) { @@ -1250,6 +1313,9 @@ export class HudManager { * Renders the authentic animated hand cursor or held cursorItem at cursor hotspot. */ renderCursor(ctx: CanvasRenderingContext2D, nowMs: number = performance.now()): void { + if (this.inventory.isIdentifyMode()) { + this.cursor.resetIdle(nowMs) + } const cursorAtlas = this.images.get('cursorAtlas') ?? this.images.get('cursorHand') ?? null const heldItem = this.inventory.cursorItem ? { @@ -1266,5 +1332,12 @@ export class HudManager { ? { atlas: heldItem.atlas, rect: heldItem.rect } : null, ) + + if (this.inventory.isIdentifyMode() && !this.inventory.cursorItem) { + this.font.drawText(ctx, '?', this.mouseX + 12, this.mouseY - 4, { + font: 'fontexocet10', + color: 'red', + }) + } } } diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index dae28b2..cd0daf1 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -15,8 +15,10 @@ import type { D2ColorCode, D2FontRenderer } from './font.ts' import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts' import type { Item } from '../game/items.ts' +import { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts' + export type { SpriteRect } -export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts' +export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } export { PLAYER_GOLD_CAP } from './world-panels.ts' export type EquipSlotId = @@ -67,6 +69,9 @@ export interface UiInventoryItem { readonly repairCost?: number | undefined readonly permStore?: boolean | undefined readonly isGambleMystery?: boolean | undefined + readonly identified?: boolean | undefined + readonly quantity?: number | undefined + readonly maxQuantity?: number | undefined } export interface GridPlacement { @@ -816,6 +821,8 @@ export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [ invWidth: 1, invHeight: 2, allowedSlots: [], + quantity: 20, + maxQuantity: 20, stats: [{ text: '右键施放城镇传送门', color: 'white' }], }, }, @@ -833,6 +840,8 @@ export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [ invWidth: 1, invHeight: 2, allowedSlots: [], + quantity: 20, + maxQuantity: 20, stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }], }, }, @@ -943,6 +952,141 @@ export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [ }, ] +/** Maximum scrolls held in a Tome of Town Portal (`tbk`) or Tome of Identify (`ibk`) per `Books.txt`. */ +export const TOME_MAX_QUANTITY = 20 + +export function isTomeCode(code: string | undefined): code is 'tbk' | 'ibk' { + const c = (code ?? '').trim().toLowerCase() + return c === 'tbk' || c === 'ibk' +} + +export function isScrollForTome(scrollCode: string | undefined, tomeCode: string | undefined): boolean { + const sc = (scrollCode ?? '').trim().toLowerCase() + const tc = (tomeCode ?? '').trim().toLowerCase() + return (sc === 'tsc' && tc === 'tbk') || (sc === 'isc' && tc === 'ibk') +} + +export function isUsableRightClickItem(code: string | undefined): boolean { + const c = (code ?? '').trim().toLowerCase() + return c === 'box' || c === 'cube' || c === 'tbk' || c === 'tsc' || c === 'ibk' || c === 'isc' +} + +export function getTomeQuantity(item: UiInventoryItem): number { + if (typeof item.quantity === 'number' && Number.isFinite(item.quantity)) { + return Math.max(0, Math.trunc(item.quantity)) + } + if (typeof item.rawItem?.quantity === 'number' && Number.isFinite(item.rawItem.quantity)) { + return Math.max(0, Math.trunc(item.rawItem.quantity)) + } + if (isTomeCode(item.code) && typeof item.rawItem?.stack === 'number' && Number.isFinite(item.rawItem.stack)) { + return Math.max(0, Math.trunc(item.rawItem.stack)) + } + const baseMatch = /数量\s*[::]\s*(\d+)|Quantity\s*[::]\s*(\d+)/i.exec(item.baseNameZh ?? '') + if (baseMatch) { + return Math.max(0, Number(baseMatch[1] ?? baseMatch[2])) + } + for (const s of item.stats ?? []) { + const statMatch = /数量\s*[::]\s*(\d+)|Quantity\s*[::]\s*(\d+)/i.exec(s.text) + if (statMatch) { + return Math.max(0, Number(statMatch[1] ?? statMatch[2])) + } + } + return isTomeCode(item.code) ? TOME_MAX_QUANTITY : 1 +} + +export function setTomeQuantity(item: UiInventoryItem, newQty: number): UiInventoryItem { + const maxQty = item.maxQuantity ?? (isTomeCode(item.code) ? TOME_MAX_QUANTITY : 99) + const qty = Math.max(0, Math.min(maxQty, Math.trunc(newQty))) + ;(item as { quantity?: number }).quantity = qty + + if (item.baseNameZh && /数量\s*[::]\s*\d+/i.test(item.baseNameZh)) { + ;(item as { baseNameZh: string }).baseNameZh = item.baseNameZh.replace(/数量\s*[::]\s*\d+/gi, `数量: ${qty}`) + } else if (isTomeCode(item.code) && item.baseNameZh && !item.stats?.some(s => /数量\s*[::]\s*\d+/i.test(s.text))) { + ;(item as { baseNameZh: string }).baseNameZh = `${item.baseNameZh} (数量: ${qty})` + } + + if (Array.isArray(item.stats)) { + const hasQtyLine = item.stats.some(s => /^(数量|Quantity)\s*[::]\s*\d+/i.test(s.text)) + if (hasQtyLine) { + ;(item as { stats: readonly { text: string; color?: D2ColorCode }[] }).stats = item.stats.map(s => + /^(数量|Quantity)\s*[::]\s*\d+/i.test(s.text) ? { ...s, text: `数量: ${qty}` } : s, + ) + } + } + + if (item.rawItem) { + ;(item.rawItem as { quantity?: number; stack?: number }).quantity = qty + ;(item.rawItem as { quantity?: number; stack?: number }).stack = qty + } + + return item +} + +export function isUiItemUnidentified(item: UiInventoryItem): boolean { + return Boolean( + item.identified === false || + item.isGambleMystery === true || + item.rawItem?.identified === false || + item.rawItem?.isGambleMystery === true || + (item.rawItem?.flags !== undefined && item.rawItem.flags.identified === false), + ) +} + +export function identifyUiItem(item: UiInventoryItem): UiInventoryItem { + ;(item as { identified?: boolean; isGambleMystery?: boolean }).identified = true + ;(item as { identified?: boolean; isGambleMystery?: boolean }).isGambleMystery = false + + if (item.rawItem) { + item.rawItem.identified = true + ;(item.rawItem as { isGambleMystery?: boolean }).isGambleMystery = false + if (item.rawItem.flags) { + ;(item.rawItem as { flags: typeof item.rawItem.flags }).flags = { + ...item.rawItem.flags, + identified: true, + gamble: false, + } + } + if (typeof item.rawItem.rawFlags === 'number') { + ;(item.rawItem as { rawFlags: number }).rawFlags = (item.rawItem.rawFlags | 1) & ~0x8000 + } + const rebuilt = itemToUiInventoryItem(item.rawItem) + Object.assign(item, { + name: rebuilt.name, + nameZh: rebuilt.nameZh, + baseNameZh: rebuilt.baseNameZh, + quality: rebuilt.quality, + stats: rebuilt.stats.filter( + (s: { text: string; color?: D2ColorCode }) => + !s.text.includes('未辨识') && !s.text.includes('未鉴定') && !s.text.includes('Unidentified'), + ), + ...(rebuilt.defense !== undefined ? { defense: rebuilt.defense } : {}), + ...(rebuilt.damage !== undefined ? { damage: rebuilt.damage } : {}), + ...(rebuilt.reqLevel !== undefined ? { reqLevel: rebuilt.reqLevel } : {}), + ...(rebuilt.reqStr !== undefined ? { reqStr: rebuilt.reqStr } : {}), + ...(rebuilt.durability !== undefined ? { durability: rebuilt.durability } : {}), + ...(rebuilt.sockets !== undefined ? { sockets: rebuilt.sockets } : {}), + ...(rebuilt.invtransform !== undefined ? { invtransform: rebuilt.invtransform } : {}), + ...(rebuilt.chrtransform !== undefined ? { chrtransform: rebuilt.chrtransform } : {}), + ...(rebuilt.setPieces !== undefined ? { setPieces: rebuilt.setPieces } : {}), + ...(rebuilt.setBonuses !== undefined ? { setBonuses: rebuilt.setBonuses } : {}), + identified: true, + isGambleMystery: false, + }) + } else if (Array.isArray(item.stats)) { + ;(item as { stats: readonly { text: string; color?: D2ColorCode }[] }).stats = item.stats.filter( + s => !s.text.includes('未辨识') && !s.text.includes('未鉴定') && !s.text.includes('Unidentified'), + ) + } + + return item +} + +export interface IdentifyCursorSource { + readonly container: 'inventory' | 'stash' | 'cube' + readonly itemId: string + readonly code: 'ibk' | 'isc' +} + export class InventoryPanel { visible = false /** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */ @@ -954,10 +1098,15 @@ export class InventoryPanel { weapon1: null, weapon2: null, } - /** 10x4 grid items. */ - gridItems: GridPlacement[] = [...STARTER_BAG_ITEMS] + /** 10x4 grid items (deep-cloned per instance so quantity/stat mutations do not leak across instances). */ + gridItems: GridPlacement[] = STARTER_BAG_ITEMS.map(p => ({ + ...p, + item: { ...p.item, stats: [...p.item.stats] }, + })) /** Item currently held on the mouse cursor (`cursorItem`). */ cursorItem: UiInventoryItem | null = null + /** Active Identify targeting mode source (`ibk` or `isc`) initiated via Right-Click. */ + identifyCursorSource: IdentifyCursorSource | null = null /** Currently hovered item (either in a slot or in the 10x4 grid) for Tooltip rendering. */ hoveredItem: { item: UiInventoryItem; x: number; y: number } | null = null /** Player character level for authentic inventory gold capacity calculation. */ @@ -971,6 +1120,89 @@ export class InventoryPanel { /** Player gold in inventory. */ gold = 850_000 + isIdentifyMode(): boolean { + return this.identifyCursorSource !== null + } + + cancelIdentifyMode(): void { + this.identifyCursorSource = null + } + + /** + * Enter Identify Cursor Mode from a Tome of Identify (`ibk`) or Scroll of Identify (`isc`). + */ + startIdentifyMode(item: UiInventoryItem, container: 'inventory' | 'stash' | 'cube' = 'inventory'): boolean { + const code = (item.code ?? '').trim().toLowerCase() + if (code === 'ibk') { + if (getTomeQuantity(item) <= 0) return false + this.identifyCursorSource = { container, itemId: item.id, code: 'ibk' } + return true + } + if (code === 'isc') { + this.identifyCursorSource = { container, itemId: item.id, code: 'isc' } + return true + } + return false + } + + /** + * Consume 1 charge from the active `identifyCursorSource` (`ibk` decrements quantity, `isc` is removed) + * and identify `targetItem` if it is currently unidentified. + */ + applyIdentifyToItem( + targetItem: UiInventoryItem, + externalContainers?: { + stashItems?: GridPlacement[] + cubeItems?: GridPlacement[] + }, + ): boolean { + if (!this.identifyCursorSource) return false + if (!isUiItemUnidentified(targetItem)) { + this.identifyCursorSource = null + return false + } + + const src = this.identifyCursorSource + const containerList = + src.container === 'stash' + ? externalContainers?.stashItems + : src.container === 'cube' + ? externalContainers?.cubeItems + : this.gridItems + + if (!containerList) { + this.identifyCursorSource = null + return false + } + + const srcIdx = containerList.findIndex(p => p.item.id === src.itemId) + if (srcIdx === -1) { + this.identifyCursorSource = null + return false + } + + const srcPlaced = containerList[srcIdx]! + if (src.code === 'ibk') { + const qty = getTomeQuantity(srcPlaced.item) + if (qty <= 0) { + this.identifyCursorSource = null + return false + } + setTomeQuantity(srcPlaced.item, qty - 1) + } else { + const qty = srcPlaced.item.quantity ?? srcPlaced.item.rawItem?.quantity ?? 1 + if (qty > 1) { + setTomeQuantity(srcPlaced.item, qty - 1) + } else { + containerList.splice(srcIdx, 1) + } + } + + identifyUiItem(targetItem) + this.identifyCursorSource = null + return true + } + toggleWeaponSwap(): void { const curW1 = this.equipped.weapon1 ?? null const curW2 = this.equipped.weapon2 ?? null @@ -1025,6 +1257,8 @@ export class InventoryPanel { /** * Handle left-click on a grid cell `(col, row)`: * - If `cursorItem` is null: pick up the item at `(col, row)` into `cursorItem`. + * - If `cursorItem` is a Scroll (`tsc`/`isc`) and hits a matching Tome (`tbk`/`ibk`) with `quantity < 20`: + * stack the scroll into the tome (`quantity + 1`) and clear `cursorItem` (`Books.txt`). * - If `cursorItem` is held: place it if 0 overlaps, or swap if exactly 1 overlap! */ clickGridCell(col: number, row: number): boolean { @@ -1038,6 +1272,26 @@ export class InventoryPanel { return true } + // Check direct hit for Scroll-into-Tome stacking (`tsc` -> `tbk`, `isc` -> `ibk`) + const directHit = this.gridItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (directHit && isScrollForTome(this.cursorItem.code, directHit.item.code)) { + const curQty = getTomeQuantity(directHit.item) + const maxQty = directHit.item.maxQuantity ?? TOME_MAX_QUANTITY + if (curQty < maxQty) { + const scrollQty = this.cursorItem.quantity ?? this.cursorItem.rawItem?.quantity ?? 1 + const addQty = Math.min(maxQty - curQty, scrollQty) + setTomeQuantity(directHit.item, curQty + addQty) + if (scrollQty > addQty) { + setTomeQuantity(this.cursorItem, scrollQty - addQty) + } else { + this.cursorItem = null + } + return true + } + } + const targetCol = Math.min(INV_GRID_ORIGIN.cols - this.cursorItem.invWidth, Math.max(0, col)) const targetRow = Math.min(INV_GRID_ORIGIN.rows - this.cursorItem.invHeight, Math.max(0, row)) const overlaps = this.getGridOverlaps(targetCol, targetRow, this.cursorItem.invWidth, this.cursorItem.invHeight) @@ -1048,6 +1302,21 @@ export class InventoryPanel { } if (overlaps.length === 1) { const single = overlaps[0]! + if (isScrollForTome(this.cursorItem.code, single.item.code)) { + const curQty = getTomeQuantity(single.item) + const maxQty = single.item.maxQuantity ?? TOME_MAX_QUANTITY + if (curQty < maxQty) { + const scrollQty = this.cursorItem.quantity ?? this.cursorItem.rawItem?.quantity ?? 1 + const addQty = Math.min(maxQty - curQty, scrollQty) + setTomeQuantity(single.item, curQty + addQty) + if (scrollQty > addQty) { + setTomeQuantity(this.cursorItem, scrollQty - addQty) + } else { + this.cursorItem = null + } + return true + } + } this.gridItems = this.gridItems.filter(p => p !== single) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.cursorItem = single.item @@ -1058,6 +1327,7 @@ export class InventoryPanel { /** * Handle left-click on an equipment slot (`helm`, `weapon1`, `armor`, etc.). + * Unidentified items cannot be equipped in Diablo II v1.13c. */ clickEquipSlot(slotId: EquipSlotId): boolean { const existing = this.equipped[slotId] ?? null @@ -1067,6 +1337,9 @@ export class InventoryPanel { this.cursorItem = existing return true } + if (isUiItemUnidentified(this.cursorItem)) { + return false + } if (!this.cursorItem.allowedSlots.includes(slotId)) { return false } @@ -1075,6 +1348,48 @@ export class InventoryPanel { return true } + /** + * Right-click use handler for usable items (`box`, `tbk`, `tsc`, `ibk`, `isc`) inside a container. + */ + usePlacedItemByRightClick( + placed: GridPlacement, + container: 'inventory' | 'stash' | 'cube', + containerList: GridPlacement[], + callbacks?: { + onOpenCube?: () => void + onCastTownPortal?: () => void + }, + ): boolean { + if (this.cursorItem !== null) return false + const code = (placed.item.code ?? '').trim().toLowerCase() + if (code === 'box' || code === 'cube') { + callbacks?.onOpenCube?.() + return true + } + if (code === 'tbk') { + const qty = getTomeQuantity(placed.item) + if (qty <= 0) return false + setTomeQuantity(placed.item, qty - 1) + callbacks?.onCastTownPortal?.() + return true + } + if (code === 'tsc') { + const qty = placed.item.quantity ?? placed.item.rawItem?.quantity ?? 1 + if (qty > 1) { + setTomeQuantity(placed.item, qty - 1) + } else { + const idx = containerList.indexOf(placed) + if (idx !== -1) containerList.splice(idx, 1) + } + callbacks?.onCastTownPortal?.() + return true + } + if (code === 'ibk' || code === 'isc') { + return this.startIdentifyMode(placed.item, container) + } + return false + } + handleMouseMove(logicalX: number, logicalY: number): void { this.hoveredItem = null if (!this.visible) return @@ -1112,18 +1427,99 @@ export class InventoryPanel { } } + /** + * Handle mouse click on the Inventory Panel (`400..720, 60..492`). + * + * Ground Truth (Diablo II v1.13c): + * - `button === 0` (Left-Click): + * - In Identify Cursor Mode: identifies an unidentified item under the cursor (consuming 1 tome charge or scroll), or cancels Identify mode if clicking elsewhere. + * - In normal mode: picks up, places, swaps items in the 10x4 grid or equipment slots; stacks scrolls (`tsc`/`isc`) into matching tomes (`tbk`/`ibk`). Never uses `box` or `tbk`! + * - `button === 2` (Right-Click): + * - In Identify Cursor Mode: cancels Identify mode. + * - In normal mode: uses usable items (`box` opens Horadric Cube, `tbk`/`tsc` casts Town Portal and consumes 1 charge/scroll, `ibk`/`isc` enters Identify Cursor Mode). Never picks up or moves items onto the cursor! + */ handleClick( logicalX: number, logicalY: number, - callbacks: { - onOpenCube: () => void - onCastTownPortal: () => void + callbacks?: { + onOpenCube?: () => void + onCastTownPortal?: () => void onDropGold?: (amount: number) => void }, + button: 0 | 2 = 0, + externalContainers?: { + stashItems?: GridPlacement[] + cubeItems?: GridPlacement[] + }, ): boolean { if (!this.visible) return false if (logicalX < 400) return false + // Active Identify Cursor Mode handling (`ibk` / `isc`) + if (this.identifyCursorSource !== null) { + if (button === 2) { + this.cancelIdentifyMode() + return true + } + + // Left-Click on equipment slot in Identify Mode + for (const [slotId, rect] of Object.entries(EQUIP_SLOTS_LAYOUT) as [EquipSlotId, typeof EQUIP_SLOTS_LAYOUT[EquipSlotId]][]) { + if (logicalX >= rect.x && logicalX <= rect.x + rect.w && logicalY >= rect.y && logicalY <= rect.y + rect.h) { + const eq = this.equipped[slotId] + if (eq && isUiItemUnidentified(eq)) { + this.applyIdentifyToItem(eq, externalContainers) + } else { + this.cancelIdentifyMode() + } + return true + } + } + + // Left-Click on 10x4 grid in Identify Mode + if ( + logicalX >= INV_GRID_ORIGIN.x && + logicalX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + logicalY >= INV_GRID_ORIGIN.y && + logicalY < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((logicalX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((logicalY - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + const hit = this.gridItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hit && isUiItemUnidentified(hit.item)) { + this.applyIdentifyToItem(hit.item, externalContainers) + } else { + this.cancelIdentifyMode() + } + return true + } + + this.cancelIdentifyMode() + return true + } + + // Right-Click (`button === 2`): Use items only; NEVER pick up or move items onto cursor! + if (button === 2) { + if ( + logicalX >= INV_GRID_ORIGIN.x && + logicalX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + logicalY >= INV_GRID_ORIGIN.y && + logicalY < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((logicalX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((logicalY - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + const hit = this.gridItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hit) { + return this.usePlacedItemByRightClick(hit, 'inventory', this.gridItems, callbacks) + } + } + return false + } + + // Left-Click (`button === 0`): UI Buttons & Item Move / Pick Up / Place / Swap / Stack // 1. Close button if ( logicalX >= INV_CLOSE_BTN_BOUNDS.x && @@ -1151,7 +1547,7 @@ export class InventoryPanel { } } - // 4. 10x4 Bag Grid + // 4. 10x4 Bag Grid (Left-Click always moves/picks up/places/stacks; never opens cube or casts TP) if ( logicalX >= INV_GRID_ORIGIN.x && logicalX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && @@ -1160,17 +1556,6 @@ export class InventoryPanel { ) { const col = Math.floor((logicalX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) - const hit = this.gridItems.find( - p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, - ) - if (!this.cursorItem && hit?.item.code === 'box') { - callbacks.onOpenCube() - return true - } - if (!this.cursorItem && hit?.item.code === 'tbk') { - callbacks.onCastTownPortal() - return true - } this.clickGridCell(col, row) return true } @@ -1179,7 +1564,7 @@ export class InventoryPanel { if (logicalX >= 500 && logicalX <= 625 && logicalY >= 450 && logicalY <= 485) { if (this.gold > 0) { const dropAmt = Math.min(10_000, this.gold) - callbacks.onDropGold?.(dropAmt) + callbacks?.onDropGold?.(dropAmt) } return true } diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index 8f672d7..ec9ae89 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -454,6 +454,33 @@ export function itemToUiInventoryItem( (rolledSockProp > 0 ? rolledSockProp : undefined) const ethereal = !isGambleMystery && Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal) + const isUnidentified = Boolean( + isGambleMystery || + rawItem.identified === false || + (rawItem.flags !== undefined && rawItem.flags.identified === false), + ) + if ( + isUnidentified && + !isGambleMystery && + !stats.some(s => s.text.includes('未鉴定') || s.text.includes('未辨识') || s.text.includes('Unidentified')) + ) { + stats = [{ text: '未辨识 (Unidentified)', color: 'red' }, ...stats] + } + + const isTome = code === 'tbk' || code === 'ibk' + const rawQty = + typeof rawItem.quantity === 'number' + ? rawItem.quantity + : isTome && typeof rawItem.stack === 'number' + ? rawItem.stack + : isTome + ? 20 + : undefined + const maxQuantity = isTome ? 20 : base.maxStack > 1 ? base.maxStack : undefined + if (isTome && rawQty !== undefined && !baseNameZh.includes('数量:') && !stats.some(s => s.text.includes('数量:'))) { + stats = [...stats, { text: `数量: ${rawQty}`, color: 'white' }] + } + const id = rawItem.uniqueId ? `item-${code}-${rawItem.uniqueId}` : (rawItem as any).id ?? `item-${code}-${Math.random().toString(36).slice(2, 9)}` @@ -497,7 +524,7 @@ export function itemToUiInventoryItem( ...(sockets !== undefined && sockets > 0 ? { sockets } : {}), ...(ethereal ? { ethereal: true } : {}), ...(!isGambleMystery && ((rawItem as any).speedText || tt.speedText) ? { speedText: (rawItem as any).speedText || tt.speedText } : {}), - ...(!isGambleMystery && ((rawItem as any).runewordRunes || tt.runewordRunes) ? { runewordRunes: (rawItem as any).runewordRunes || tt.runewordRunes } : {}), + ...(!isGambleMystery && ((rawItem as any).runewordRunes || tt.runewordRunes) ? { runewordRunes: (rawItem as any).runewordRunes } : {}), ...(!isGambleMystery && tt.setPieces ? { setPieces: tt.setPieces } : {}), ...(!isGambleMystery && tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}), ...(typeof flippyFile === 'string' && flippyFile.length > 0 ? { flippyFile } : {}), @@ -506,6 +533,9 @@ export function itemToUiInventoryItem( rawItem: { ...rawItem, base } as Item, ...(permStore ? { permStore: true } : {}), ...(isGambleMystery ? { isGambleMystery: true } : {}), + ...(isUnidentified ? { identified: false } : { identified: true }), + ...(rawQty !== undefined ? { quantity: rawQty } : {}), + ...(maxQuantity !== undefined ? { maxQuantity } : {}), ...((rawItem as any).buyCost !== undefined ? { buyCost: (rawItem as any).buyCost } : {}), ...((rawItem as any).sellCost !== undefined ? { sellCost: (rawItem as any).sellCost } : {}), ...((rawItem as any).repairCost !== undefined ? { repairCost: (rawItem as any).repairCost } : {}), diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 6e9bd7d..676d32a 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -9,8 +9,22 @@ */ import type { D2ColorCode, D2FontRenderer } from './font.ts' -import { findFreeGridSlot, type EquipSlotId, type GridPlacement, type InventoryPanel, type UiInventoryItem } from './inventory.ts' -import { resolveItemSpriteRect, itemToUiInventoryItem } from './inventory.ts' +import { + TOME_MAX_QUANTITY, + findFreeGridSlot, + getTomeQuantity, + identifyUiItem, + isScrollForTome, + isUiItemUnidentified, + isUsableRightClickItem, + itemToUiInventoryItem, + resolveItemSpriteRect, + setTomeQuantity, + type EquipSlotId, + type GridPlacement, + type InventoryPanel, + type UiInventoryItem, +} from './inventory.ts' import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts' import { type TownNpcServiceDescriptor, @@ -33,6 +47,10 @@ export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const +/** `Inventory.txt` `Transmogrify Box Page 1` / `Transmogrify Box2` (`supertransmogrifier.dc6` 3x4 grid) */ +export const CUBE_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const +export const CUBE_GRID_ORIGIN = { x: 80 + 117, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 } as const + export const VENDOR_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const /** `Inventory.txt` row `Monster2`: gridLeft=96 (`80 + 16`), gridTop=123 (`60 + 63`), 10x10 cells of 29x29px */ export const VENDOR_GRID_ORIGIN = { x: 80 + 16, y: 60 + 63, cols: 10, rows: 10, cellPx: 29 } as const @@ -855,8 +873,20 @@ export class WorldPanelsHud { identifyAllUiItems(playerInventory: InventoryPanel): number { let count = 0 for (const placed of playerInventory.gridItems) { - if (placed.item.rawItem && placed.item.rawItem.identified === false) { - placed.item.rawItem.identified = true + if (isUiItemUnidentified(placed.item)) { + identifyUiItem(placed.item) + count++ + } + } + for (const placed of this.stashItems) { + if (isUiItemUnidentified(placed.item)) { + identifyUiItem(placed.item) + count++ + } + } + for (const placed of this.cubeItems) { + if (isUiItemUnidentified(placed.item)) { + identifyUiItem(placed.item) count++ } } @@ -946,6 +976,8 @@ export class WorldPanelsHud { stashItems: GridPlacement[] = [] hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null + cubeItems: GridPlacement[] = [] + hoveredCubeItem: { item: UiInventoryItem; x: number; y: number } | null = null hoveredWaypointIdx: number | null = null /** @@ -1059,6 +1091,24 @@ export class WorldPanelsHud { return { handled: true, newCursorItem: hit.item } } + const directHit = this.stashItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (directHit && isScrollForTome(cursorItem.code, directHit.item.code)) { + const curQty = getTomeQuantity(directHit.item) + const maxQty = directHit.item.maxQuantity ?? TOME_MAX_QUANTITY + if (curQty < maxQty) { + const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1 + const addQty = Math.min(maxQty - curQty, scrollQty) + setTomeQuantity(directHit.item, curQty + addQty) + if (scrollQty > addQty) { + setTomeQuantity(cursorItem, scrollQty - addQty) + return { handled: true, newCursorItem: cursorItem } + } + return { handled: true, newCursorItem: null } + } + } + if ( col < 0 || row < 0 || @@ -1075,6 +1125,20 @@ export class WorldPanelsHud { } if (overlaps.length === 1) { const single = overlaps[0]! + if (isScrollForTome(cursorItem.code, single.item.code)) { + const curQty = getTomeQuantity(single.item) + const maxQty = single.item.maxQuantity ?? TOME_MAX_QUANTITY + if (curQty < maxQty) { + const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1 + const addQty = Math.min(maxQty - curQty, scrollQty) + setTomeQuantity(single.item, curQty + addQty) + if (scrollQty > addQty) { + setTomeQuantity(cursorItem, scrollQty - addQty) + return { handled: true, newCursorItem: cursorItem } + } + return { handled: true, newCursorItem: null } + } + } this.stashItems = this.stashItems.filter(p => p !== single) this.stashItems.push({ item: cursorItem, col, row }) return { handled: true, newCursorItem: single.item } @@ -1098,6 +1162,133 @@ export class WorldPanelsHud { return true } + /** + * Check whether an item of (w, h) can be placed at (col, row) in the 3x4 Horadric Cube grid. + */ + getCubeOverlaps(col: number, row: number, w: number, h: number): GridPlacement[] { + if (col < 0 || row < 0 || col + w > CUBE_GRID_ORIGIN.cols || row + h > CUBE_GRID_ORIGIN.rows) { + throw new RangeError('Out of 3x4 Horadric Cube grid bounds') + } + return this.cubeItems.filter(p => { + const ox = col < p.col + p.item.invWidth && col + w > p.col + const oy = row < p.row + p.item.invHeight && row + h > p.row + return ox && oy + }) + } + + canPlaceInCube(col: number, row: number, w: number, h: number): boolean { + if (col < 0 || row < 0 || col + w > CUBE_GRID_ORIGIN.cols || row + h > CUBE_GRID_ORIGIN.rows) { + return false + } + return this.getCubeOverlaps(col, row, w, h).length === 0 + } + + autoPlaceInCube(item: UiInventoryItem): boolean { + const code = (item.code ?? '').trim().toLowerCase() + if (code === 'box' || code === 'cube') return false + for (let col = 0; col <= CUBE_GRID_ORIGIN.cols - item.invWidth; col++) { + for (let row = 0; row <= CUBE_GRID_ORIGIN.rows - item.invHeight; row++) { + if (this.canPlaceInCube(col, row, item.invWidth, item.invHeight)) { + this.cubeItems.push({ item, col, row }) + return true + } + } + } + return false + } + + clickCubeCell( + col: number, + row: number, + cursorItem: UiInventoryItem | null, + ): { handled: boolean; newCursorItem: UiInventoryItem | null } { + if (!cursorItem) { + const hit = this.cubeItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (!hit) return { handled: false, newCursorItem: null } + this.cubeItems = this.cubeItems.filter(p => p !== hit) + return { handled: true, newCursorItem: hit.item } + } + + const code = (cursorItem.code ?? '').trim().toLowerCase() + if (code === 'box' || code === 'cube') { + return { handled: false, newCursorItem: cursorItem } + } + + const directHit = this.cubeItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (directHit && isScrollForTome(cursorItem.code, directHit.item.code)) { + const curQty = getTomeQuantity(directHit.item) + const maxQty = directHit.item.maxQuantity ?? TOME_MAX_QUANTITY + if (curQty < maxQty) { + const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1 + const addQty = Math.min(maxQty - curQty, scrollQty) + setTomeQuantity(directHit.item, curQty + addQty) + if (scrollQty > addQty) { + setTomeQuantity(cursorItem, scrollQty - addQty) + return { handled: true, newCursorItem: cursorItem } + } + return { handled: true, newCursorItem: null } + } + } + + const targetCol = Math.min(CUBE_GRID_ORIGIN.cols - cursorItem.invWidth, Math.max(0, col)) + const targetRow = Math.min(CUBE_GRID_ORIGIN.rows - cursorItem.invHeight, Math.max(0, row)) + if ( + targetCol < 0 || + targetRow < 0 || + targetCol + cursorItem.invWidth > CUBE_GRID_ORIGIN.cols || + targetRow + cursorItem.invHeight > CUBE_GRID_ORIGIN.rows + ) { + return { handled: false, newCursorItem: cursorItem } + } + + const overlaps = this.getCubeOverlaps(targetCol, targetRow, cursorItem.invWidth, cursorItem.invHeight) + if (overlaps.length === 0) { + this.cubeItems.push({ item: cursorItem, col: targetCol, row: targetRow }) + return { handled: true, newCursorItem: null } + } + if (overlaps.length === 1) { + const single = overlaps[0]! + if (isScrollForTome(cursorItem.code, single.item.code)) { + const curQty = getTomeQuantity(single.item) + const maxQty = single.item.maxQuantity ?? TOME_MAX_QUANTITY + if (curQty < maxQty) { + const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1 + const addQty = Math.min(maxQty - curQty, scrollQty) + setTomeQuantity(single.item, curQty + addQty) + if (scrollQty > addQty) { + setTomeQuantity(cursorItem, scrollQty - addQty) + return { handled: true, newCursorItem: cursorItem } + } + return { handled: true, newCursorItem: null } + } + } + this.cubeItems = this.cubeItems.filter(p => p !== single) + this.cubeItems.push({ item: cursorItem, col: targetCol, row: targetRow }) + return { handled: true, newCursorItem: single.item } + } + return { handled: false, newCursorItem: cursorItem } + } + + quickTransferToCube(item: UiInventoryItem, fromInventory: InventoryPanel): boolean { + const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id) + if (hitIdx === -1) return false + if (!this.autoPlaceInCube(item)) return false + fromInventory.gridItems.splice(hitIdx, 1) + return true + } + + quickTransferFromCube(item: UiInventoryItem, toInventory: InventoryPanel): boolean { + const hitIdx = this.cubeItems.findIndex(p => p.item.id === item.id) + if (hitIdx === -1) return false + if (!toInventory.autoPlaceInGrid(item)) return false + this.cubeItems.splice(hitIdx, 1) + return true + } + depositGold(amount: number, playerInventory: { gold: number }): number { if (amount <= 0) return 0 const available = Math.max(0, Math.min(amount, playerInventory.gold, STASH_GOLD_CAP - this.stashGold)) @@ -1119,6 +1310,7 @@ export class WorldPanelsHud { handleMouseMove(logicalX: number, logicalY: number): void { this.hoveredStashItem = null + this.hoveredCubeItem = null this.hoveredVendorItem = null this.hoveredVendorButtonSlot = this.activeVendorDescriptor ? this.hitTestVendorButtonSlot(logicalX, logicalY) @@ -1159,6 +1351,26 @@ export class WorldPanelsHud { } } + if ( + logicalX >= CUBE_GRID_ORIGIN.x && + logicalX < CUBE_GRID_ORIGIN.x + CUBE_GRID_ORIGIN.cols * CUBE_GRID_ORIGIN.cellPx && + logicalY >= CUBE_GRID_ORIGIN.y && + logicalY < CUBE_GRID_ORIGIN.y + CUBE_GRID_ORIGIN.rows * CUBE_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((logicalX - CUBE_GRID_ORIGIN.x) / CUBE_GRID_ORIGIN.cellPx) + const row = Math.floor((logicalY - CUBE_GRID_ORIGIN.y) / CUBE_GRID_ORIGIN.cellPx) + const hit = this.cubeItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hit) { + this.hoveredCubeItem = { + item: hit.item, + x: CUBE_GRID_ORIGIN.x + (hit.col + hit.item.invWidth / 2) * CUBE_GRID_ORIGIN.cellPx, + y: CUBE_GRID_ORIGIN.y + hit.row * CUBE_GRID_ORIGIN.cellPx, + } + } + } + if ( logicalX >= VENDOR_GRID_ORIGIN.x && logicalX < VENDOR_GRID_ORIGIN.x + VENDOR_GRID_ORIGIN.cols * VENDOR_GRID_ORIGIN.cellPx && @@ -1264,6 +1476,8 @@ export class WorldPanelsHud { callbacks: { onClose: () => void onWaypointTeleport: (act: number, slug: string, levelId: number) => void + onOpenCube?: () => void + onCastTownPortal?: () => void inventory?: InventoryPanel isShiftClick?: boolean isRightClick?: boolean @@ -1328,9 +1542,69 @@ export class WorldPanelsHud { } } } else if (kind === 'cube') { - // Transmute button at bottom center of Horadric Cube (`ox + 132, oy + 336, 56x36`) + // 1. Transmute button at bottom center of Horadric Cube (`ox + 132, oy + 336, 56x36`) if (logicalX >= ox + 120 && logicalX <= ox + 200 && logicalY >= oy + 330 && logicalY <= oy + 380) { - this.cubeTransmuteCount += 1 + if (!callbacks.isRightClick) { + this.cubeTransmuteCount += 1 + } + return true + } + + // 2. Horadric Cube 3x4 Grid (`CUBE_GRID_ORIGIN`) + if ( + logicalX >= CUBE_GRID_ORIGIN.x && + logicalX < CUBE_GRID_ORIGIN.x + CUBE_GRID_ORIGIN.cols * CUBE_GRID_ORIGIN.cellPx && + logicalY >= CUBE_GRID_ORIGIN.y && + logicalY < CUBE_GRID_ORIGIN.y + CUBE_GRID_ORIGIN.rows * CUBE_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((logicalX - CUBE_GRID_ORIGIN.x) / CUBE_GRID_ORIGIN.cellPx) + const row = Math.floor((logicalY - CUBE_GRID_ORIGIN.y) / CUBE_GRID_ORIGIN.cellPx) + const hit = this.cubeItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + + if (callbacks.inventory?.isIdentifyMode()) { + if (callbacks.isRightClick) { + callbacks.inventory.cancelIdentifyMode() + } else if (hit && isUiItemUnidentified(hit.item)) { + callbacks.inventory.applyIdentifyToItem(hit.item, { + stashItems: this.stashItems, + cubeItems: this.cubeItems, + }) + } else { + callbacks.inventory.cancelIdentifyMode() + } + return true + } + + if (callbacks.isRightClick && callbacks.inventory) { + if (hit && isUsableRightClickItem(hit.item.code)) { + callbacks.inventory.usePlacedItemByRightClick(hit, 'cube', this.cubeItems, { + onOpenCube: () => callbacks.onOpenCube?.(), + onCastTownPortal: () => callbacks.onCastTownPortal?.(), + }) + return true + } + if (hit) { + this.quickTransferFromCube(hit.item, callbacks.inventory) + } + return true + } + + if (callbacks.isShiftClick && callbacks.inventory) { + if (hit) { + this.quickTransferFromCube(hit.item, callbacks.inventory) + } + return true + } + + if (callbacks.inventory) { + const result = this.clickCubeCell(col, row, callbacks.inventory.cursorItem) + if (result.handled) { + callbacks.inventory.cursorItem = result.newCursorItem + return true + } + } return true } } else if (kind === 'stash') { @@ -1341,7 +1615,7 @@ export class WorldPanelsHud { logicalY >= STASH_DEPOSIT_BTN_BOUNDS.y && logicalY <= STASH_DEPOSIT_BTN_BOUNDS.y + STASH_DEPOSIT_BTN_BOUNDS.h ) { - if (callbacks.inventory) { + if (!callbacks.isRightClick && callbacks.inventory) { const depositAmt = Math.min(50_000, callbacks.inventory.gold, STASH_GOLD_CAP - this.stashGold) if (depositAmt > 0) { this.depositGold(depositAmt, callbacks.inventory) @@ -1357,7 +1631,7 @@ export class WorldPanelsHud { logicalY >= STASH_WITHDRAW_BTN_BOUNDS.y && logicalY <= STASH_WITHDRAW_BTN_BOUNDS.y + STASH_WITHDRAW_BTN_BOUNDS.h ) { - if (callbacks.inventory) { + if (!callbacks.isRightClick && callbacks.inventory) { const withdrawAmt = Math.min(50_000, this.stashGold, PLAYER_GOLD_CAP - callbacks.inventory.gold) if (withdrawAmt > 0) { this.withdrawGold(withdrawAmt, callbacks.inventory) @@ -1375,15 +1649,43 @@ export class WorldPanelsHud { ) { const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx) + const hit = this.stashItems.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) - if ((callbacks.isShiftClick || callbacks.isRightClick) && callbacks.inventory) { - const hit = this.stashItems.find( - p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, - ) - if (hit) { - this.quickTransferFromStash(hit.item, callbacks.inventory) + if (callbacks.inventory?.isIdentifyMode()) { + if (callbacks.isRightClick) { + callbacks.inventory.cancelIdentifyMode() + } else if (hit && isUiItemUnidentified(hit.item)) { + callbacks.inventory.applyIdentifyToItem(hit.item, { + stashItems: this.stashItems, + cubeItems: this.cubeItems, + }) + } else { + callbacks.inventory.cancelIdentifyMode() + } + return true + } + + if (callbacks.isRightClick && callbacks.inventory) { + if (hit && isUsableRightClickItem(hit.item.code)) { + callbacks.inventory.usePlacedItemByRightClick(hit, 'stash', this.stashItems, { + onOpenCube: () => callbacks.onOpenCube?.(), + onCastTownPortal: () => callbacks.onCastTownPortal?.(), + }) return true } + if (hit) { + this.quickTransferFromStash(hit.item, callbacks.inventory) + } + return true + } + + if (callbacks.isShiftClick && callbacks.inventory) { + if (hit) { + this.quickTransferFromStash(hit.item, callbacks.inventory) + } + return true } if (callbacks.inventory) { @@ -1643,8 +1945,9 @@ export class WorldPanelsHud { const gw = placed.item.invWidth * STASH_GRID_ORIGIN.cellPx const gh = placed.item.invHeight * STASH_GRID_ORIGIN.cellPx - ctx.fillStyle = - placed.item.quality === 'unique' + ctx.fillStyle = isUiItemUnidentified(placed.item) + ? 'rgba(72, 18, 18, 0.50)' + : placed.item.quality === 'unique' ? 'rgba(58, 42, 16, 0.45)' : placed.item.quality === 'set' ? 'rgba(16, 52, 22, 0.45)' @@ -1726,6 +2029,48 @@ export class WorldPanelsHud { color: 'gold', align: 'center', }) + + // 3x4 Horadric Cube grid cell borders (`Inventory.txt` Transmogrify Box1) + for (let r = 0; r < CUBE_GRID_ORIGIN.rows; r++) { + for (let c = 0; c < CUBE_GRID_ORIGIN.cols; c++) { + const cx = CUBE_GRID_ORIGIN.x + c * CUBE_GRID_ORIGIN.cellPx + const cy = CUBE_GRID_ORIGIN.y + r * CUBE_GRID_ORIGIN.cellPx + ctx.strokeStyle = 'rgba(64, 52, 38, 0.4)' + ctx.strokeRect(cx + 0.5, cy + 0.5, CUBE_GRID_ORIGIN.cellPx - 1, CUBE_GRID_ORIGIN.cellPx - 1) + } + } + + // Draw items stored in Horadric Cube + const atlas = assets.itemsAtlasImg ?? null + for (const placed of this.cubeItems) { + const gx = CUBE_GRID_ORIGIN.x + placed.col * CUBE_GRID_ORIGIN.cellPx + const gy = CUBE_GRID_ORIGIN.y + placed.row * CUBE_GRID_ORIGIN.cellPx + const gw = placed.item.invWidth * CUBE_GRID_ORIGIN.cellPx + const gh = placed.item.invHeight * CUBE_GRID_ORIGIN.cellPx + + ctx.fillStyle = isUiItemUnidentified(placed.item) + ? 'rgba(72, 18, 18, 0.50)' + : placed.item.quality === 'unique' + ? 'rgba(58, 42, 16, 0.45)' + : placed.item.quality === 'set' + ? 'rgba(16, 52, 22, 0.45)' + : 'rgba(20, 32, 54, 0.45)' + ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2) + + const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects) + if (atlas && rect) { + const scale = Math.min(gw / rect.w, gh / rect.h, 1) + const dw = Math.round(rect.w * scale) + const dh = Math.round(rect.h * scale) + const dx = gx + Math.round((gw - dw) / 2) + const dy = gy + Math.round((gh - dh) / 2) + ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) + } else { + ctx.fillStyle = + placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8' + ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4) + } + } } else if (kind === 'vendor') { if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { throw new Error('Cannot draw vendor panel without an active vendor descriptor') diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index 2b5f713..d7d91ab 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -26,7 +26,15 @@ import { } from '../src/ui/inventory.ts' import { CharacterSheetPanel, CHAR_PANEL_ORIGIN, CHAR_CLOSE_BTN_BOUNDS, STAT_ALLOC_BUTTONS } from '../src/ui/character-sheet.ts' import { SkillTreePanel, SKILL_PANEL_ORIGIN } from '../src/ui/skill-tree-panel.ts' -import { WorldPanelsHud, ACT_WAYPOINTS, ALL_QUESTS, ACT_QUESTS, findWaypointBySlug } from '../src/ui/world-panels.ts' +import { + WorldPanelsHud, + ACT_WAYPOINTS, + ALL_QUESTS, + ACT_QUESTS, + findWaypointBySlug, + CUBE_GRID_ORIGIN, + STASH_GRID_ORIGIN, +} from '../src/ui/world-panels.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import { HudManager, computeHudLayout } from '../src/ui/hud-manager.ts' import { projectNpcLabel } from '../src/scene/act-scene.ts' @@ -779,5 +787,260 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(placement.h).toBe(dummyLines.length * 17 + 24) } }) + + it('separates Left-Click (Move/Stack/Identify-Target) from Right-Click (Use) for Tomes, Scrolls, and Horadric Cube (Issue #487)', () => { + const inv = new InventoryPanel() + inv.visible = true + const worldPanels = new WorldPanelsHud() + let cubeOpened = 0 + let tpCastCount = 0 + + const cellCenter = (col: number, row: number) => ({ + x: INV_GRID_ORIGIN.x + col * INV_GRID_ORIGIN.cellPx + 10, + y: INV_GRID_ORIGIN.y + row * INV_GRID_ORIGIN.cellPx + 10, + }) + + // 1. Left-Click (button = 0) on Horadric Cube ('box' at 0,0) picks it up instead of opening Cube + const boxPt = cellCenter(0, 0) + expect( + inv.handleClick( + boxPt.x, + boxPt.y, + { + onOpenCube: () => { + cubeOpened++ + }, + onCastTownPortal: () => { + tpCastCount++ + }, + }, + 0, + ), + ).toBe(true) + expect(cubeOpened).toBe(0) + expect(inv.cursorItem?.code).toBe('box') + + // Put Horadric Cube back at (0, 0) via Left-Click + expect(inv.handleClick(boxPt.x, boxPt.y, undefined, 0)).toBe(true) + expect(inv.cursorItem).toBeNull() + + // 2. Right-Click (button = 2) on Horadric Cube ('box' at 0,0) opens Cube and leaves 'box' in inventory + expect( + inv.handleClick( + boxPt.x, + boxPt.y, + { + onOpenCube: () => { + cubeOpened++ + }, + }, + 2, + ), + ).toBe(true) + expect(cubeOpened).toBe(1) + expect(inv.cursorItem).toBeNull() + expect(inv.gridItems.some(p => p.item.code === 'box' && p.col === 0 && p.row === 0)).toBe(true) + + // 3. Left-Click (button = 0) on Tome of Town Portal ('tbk' at 2,0) picks it up without casting TP + const tbkPt = cellCenter(2, 0) + expect( + inv.handleClick( + tbkPt.x, + tbkPt.y, + { + onCastTownPortal: () => { + tpCastCount++ + }, + }, + 0, + ), + ).toBe(true) + expect(tpCastCount).toBe(0) + expect(inv.cursorItem?.code).toBe('tbk') + expect(inv.handleClick(tbkPt.x, tbkPt.y, undefined, 0)).toBe(true) + expect(inv.cursorItem).toBeNull() + + // 4. Right-Click (button = 2) on Tome of Town Portal ('tbk' at 2,0) decrements quantity (20 -> 19) and casts TP + expect( + inv.handleClick( + tbkPt.x, + tbkPt.y, + { + onCastTownPortal: () => { + tpCastCount++ + }, + }, + 2, + ), + ).toBe(true) + expect(tpCastCount).toBe(1) + expect(inv.cursorItem).toBeNull() + const tbkPlaced = inv.gridItems.find(p => p.item.code === 'tbk')! + expect(tbkPlaced.item.quantity).toBe(19) + expect(tbkPlaced.item.baseNameZh.includes('数量: 19')).toBe(true) + + // 5. Left-Click Scroll of Town Portal ('tsc') onto Tome of Town Portal ('tbk') stacks quantity (19 -> 20) + inv.cursorItem = { + id: 'scroll_tp_1', + code: 'tsc', + name: 'Scroll of Town Portal', + nameZh: '回城卷轴', + baseNameZh: '回城卷轴', + quality: 'normal', + invFile: 'invscrol', + invWidth: 1, + invHeight: 1, + allowedSlots: [], + stats: [{ text: '右键点击开启回城传送门', color: 'white' }], + } + expect(inv.handleClick(tbkPt.x, tbkPt.y, undefined, 0)).toBe(true) + expect(inv.cursorItem).toBeNull() + expect(tbkPlaced.item.quantity).toBe(20) + + // Place another 'tsc' in empty slot (5, 0) and Right-Click to consume scroll and cast TP + const tscSlotPt = cellCenter(5, 0) + inv.cursorItem = { + id: 'scroll_tp_2', + code: 'tsc', + name: 'Scroll of Town Portal', + nameZh: '回城卷轴', + baseNameZh: '回城卷轴', + quality: 'normal', + invFile: 'invscrol', + invWidth: 1, + invHeight: 1, + allowedSlots: [], + stats: [{ text: '右键点击开启回城传送门', color: 'white' }], + } + expect(inv.handleClick(tscSlotPt.x, tscSlotPt.y, undefined, 0)).toBe(true) + expect(inv.cursorItem).toBeNull() + expect( + inv.handleClick( + tscSlotPt.x, + tscSlotPt.y, + { + onCastTownPortal: () => { + tpCastCount++ + }, + }, + 2, + ), + ).toBe(true) + expect(tpCastCount).toBe(2) + expect(inv.gridItems.some(p => p.item.id === 'scroll_tp_2')).toBe(false) + + // 6. Right-Click Tome of Identify ('ibk' at 2,2) enters Identify Mode, Left-Click unidentified item identifies it + const unidRingPt = cellCenter(5, 1) + inv.gridItems.push({ + col: 5, + row: 1, + item: { + id: 'unid_ring_1', + code: 'rin', + name: 'The Stone of Jordan', + nameZh: '乔丹之石', + baseNameZh: '戒指', + quality: 'unique', + invFile: 'invrin', + invWidth: 1, + invHeight: 1, + allowedSlots: ['ring1', 'ring2'], + identified: false, + stats: [ + { text: '未辨识 (Unidentified)', color: 'red' }, + { text: '+1 所有技能', color: 'blue' }, + ], + }, + }) + // Unidentified item cannot be equipped + inv.cursorItem = inv.gridItems.find(p => p.item.id === 'unid_ring_1')!.item + expect(inv.clickEquipSlot('ring1')).toBe(false) + inv.cursorItem = null + + const ibkPt = cellCenter(2, 2) + expect(inv.isIdentifyMode()).toBe(false) + // Left-Click 'ibk' picks it up (does NOT enter Identify Mode) + expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 0)).toBe(true) + expect(inv.isIdentifyMode()).toBe(false) + expect((inv.cursorItem as { code?: string } | null)?.code).toBe('ibk') + expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 0)).toBe(true) + + // Right-Click 'ibk' enters Identify Mode + expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 2)).toBe(true) + expect(inv.isIdentifyMode()).toBe(true) + + // Left-Click unidentified ring in Inventory identifies it and decrements 'ibk' quantity (20 -> 19) + expect(inv.handleClick(unidRingPt.x, unidRingPt.y, undefined, 0)).toBe(true) + expect(inv.isIdentifyMode()).toBe(false) + const idRing = inv.gridItems.find(p => p.item.id === 'unid_ring_1')!.item + expect(idRing.identified).toBe(true) + expect(idRing.stats.some(s => s.text.includes('未辨识'))).toBe(false) + const ibkPlaced = inv.gridItems.find(p => p.item.code === 'ibk')! + expect(ibkPlaced.item.quantity).toBe(19) + + // Left-Click Scroll of Identify ('isc') onto 'ibk' stacks quantity (19 -> 20) + inv.cursorItem = { + id: 'scroll_id_1', + code: 'isc', + name: 'Scroll of Identify', + nameZh: '辨识卷轴', + baseNameZh: '辨识卷轴', + quality: 'normal', + invFile: 'invscrol', + invWidth: 1, + invHeight: 1, + allowedSlots: [], + stats: [{ text: '右键点击辨识未鉴定物品', color: 'white' }], + } + expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 0)).toBe(true) + expect(inv.cursorItem).toBeNull() + expect(ibkPlaced.item.quantity).toBe(20) + + // 7. Right-Click on normal equipment ('crs' at 8,0) does NOT pick it up onto cursorItem + const crsPt = cellCenter(8, 0) + expect(inv.handleClick(crsPt.x, crsPt.y, undefined, 2)).toBe(false) + expect(inv.cursorItem).toBeNull() + + // 8. Horadric Cube 3x4 grid placement and pickup (`CUBE_GRID_ORIGIN`) + expect(CUBE_GRID_ORIGIN).toEqual({ x: 80 + 117, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 }) + // Cannot place Horadric Cube ('box') inside Horadric Cube + const boxItem = inv.gridItems.find(p => p.item.code === 'box')!.item + expect(worldPanels.clickCubeCell(0, 0, boxItem).handled).toBe(false) + + // Can place identified ring into Horadric Cube (0, 0) and pick it back up via Left-Click + inv.cursorItem = idRing + const cubeCell00 = { + x: CUBE_GRID_ORIGIN.x + 10, + y: CUBE_GRID_ORIGIN.y + 10, + } + expect( + worldPanels.handleLeftDockClick('cube', cubeCell00.x, cubeCell00.y, { + onClose: () => {}, + onWaypointTeleport: () => {}, + inventory: inv, + isRightClick: false, + }), + ).toBe(true) + expect(inv.cursorItem).toBeNull() + expect(worldPanels.cubeItems).toHaveLength(1) + expect(worldPanels.cubeItems[0]!.item.id).toBe('unid_ring_1') + + // Left-Click again picks it back up from Cube + expect( + worldPanels.handleLeftDockClick('cube', cubeCell00.x, cubeCell00.y, { + onClose: () => {}, + onWaypointTeleport: () => {}, + inventory: inv, + isRightClick: false, + }), + ).toBe(true) + expect((inv.cursorItem as { id?: string } | null)?.id).toBe('unid_ring_1') + expect(worldPanels.cubeItems).toHaveLength(0) + + // Stash grid dimensions check + expect(STASH_GRID_ORIGIN.cols).toBe(6) + expect(STASH_GRID_ORIGIN.rows).toBe(8) + }) }) +