fix(ui): separate left-click move/stack/identify-target from right-click use for tomes, scrolls, and cube (Fixes #487)
This commit is contained in:
parent
cccc6ff527
commit
43599963e4
|
|
@ -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' }],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue