diablo2-web/src/ui/inventory.ts

1704 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo II v1.13c Inventory & Equipment Paperdoll Panel (`Panel\invchar6.dc6` frames 4..7).
*
* Implements:
* - Right-Half 800x600 Gothic Border Frame (`800BorderFrame.dc6` frames 5..9 at x=400, y=0)
* - 320x432 Inventory Panel anchored at (400, 60)
* - 10x4 Inventory Grid (29x29 px per cell at local x=18, y=256)
* - 10 Equipment Slots (`helm`, `amulet`, `weapon1`, `armor`, `weapon2`, `gloves`, `ring1`, `belt`, `ring2`, `boots`)
* - Weapon Swap `I / II` Tabs (`invchar6Tab.dc6`, hotkey `W`)
* - Full Cursor Pick-up (`cursorItem`), Grid Collision, Slot Type Validation, Swap-on-Place,
* Right-Click Quick Equip / Potion Consume, and Drop-to-Ground (`engine.ground`)
*/
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 }
export { PLAYER_GOLD_CAP } from './world-panels.ts'
export type EquipSlotId =
| 'helm'
| 'amulet'
| 'weapon1'
| 'armor'
| 'weapon2'
| 'gloves'
| 'ring1'
| 'belt'
| 'ring2'
| 'boots'
export type UiItemQuality = 'normal' | 'magic' | 'rare' | 'set' | 'unique' | 'craft' | 'rune'
export interface UiInventoryItem {
readonly id: string
readonly code: string
readonly invFile: string
readonly name: string
readonly nameZh: string
readonly baseNameZh: string
readonly quality: UiItemQuality
readonly invWidth: number
readonly invHeight: number
readonly allowedSlots: readonly EquipSlotId[]
readonly defense?: number | undefined
readonly damage?: string | undefined
readonly reqLevel?: number | undefined
readonly reqStr?: number | undefined
readonly stats: readonly { text: string; color?: D2ColorCode }[]
readonly invtransform?: string | undefined
readonly chrtransform?: string | undefined
readonly durability?: { readonly current: number; readonly max: number } | undefined
readonly speedText?: string | undefined
readonly runewordRunes?: string | undefined
readonly sockets?: number | undefined
readonly ethereal?: boolean | undefined
readonly setPieces?: readonly string[] | undefined
readonly setBonuses?: readonly { text: string; color?: D2ColorCode }[] | undefined
readonly flippyFile?: string | undefined
readonly dropSound?: string | undefined
readonly dropSfxFrame?: number | undefined
readonly rawItem?: Item | undefined
readonly buyCost?: number | undefined
readonly sellCost?: number | undefined
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 {
readonly item: UiInventoryItem
readonly col: number
readonly row: number
}
/**
* Find the first available (col, row) slot for an item in a grid without overlaps.
*/
export function findFreeGridSlot(
gridItems: readonly GridPlacement[],
invWidth: number,
invHeight: number,
totalCols = 10,
totalRows = 4,
): { col: number; row: number } | null {
for (let r = 0; r <= totalRows - invHeight; r += 1) {
for (let c = 0; c <= totalCols - invWidth; c += 1) {
const overlaps = gridItems.some(p => {
const ox = c < p.col + p.item.invWidth && c + invWidth > p.col
const oy = r < p.row + p.item.invHeight && r + invHeight > p.row
return ox && oy
})
if (!overlaps) {
return { col: c, row: r }
}
}
}
return null
}
export const INV_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const
export const INV_GRID_ORIGIN = { x: 400 + 18, y: 60 + 256, cols: 10, rows: 4, cellPx: 29 } as const
export const EQUIP_SLOTS_LAYOUT: Readonly<
Record<EquipSlotId, { readonly x: number; readonly y: number; readonly w: number; readonly h: number; readonly label: string }>
> = {
helm: { x: 400 + 132, y: 60 + 4, w: 58, h: 58, label: '头盔' },
amulet: { x: 400 + 204, y: 60 + 33, w: 29, h: 29, label: '项链' },
weapon1: { x: 400 + 18, y: 60 + 47, w: 58, h: 116, label: '主手武器' },
armor: { x: 400 + 132, y: 60 + 76, w: 58, h: 87, label: '护甲' },
weapon2: { x: 400 + 248, y: 60 + 47, w: 58, h: 116, label: '副手/盾牌' },
gloves: { x: 400 + 18, y: 60 + 179, w: 58, h: 58, label: '手套' },
ring1: { x: 400 + 92, y: 60 + 179, w: 29, h: 29, label: '左戒指' },
belt: { x: 400 + 132, y: 60 + 179, w: 58, h: 29, label: '腰带' },
ring2: { x: 400 + 204, y: 60 + 179, w: 29, h: 29, label: '右戒指' },
boots: { x: 400 + 248, y: 60 + 179, w: 58, h: 58, label: '靴子' },
}
export const WEAPON_SWAP_TABS_BOUNDS = {
left: { x: 400 + 16, y: 60 + 24, w: 60, h: 21 },
right: { x: 400 + 246, y: 60 + 24, w: 60, h: 21 },
} as const
export const INV_CLOSE_BTN_BOUNDS = { x: 400 + 18, y: 60 + 385, w: 32, h: 32 } as const
/**
* Resolves the authentic DC6 sprite rectangle from the items atlas for a given item.
*
* Implements a 5-tier fallback resolution chain guaranteeing 0 empty fallback boxes:
* 1. Direct `item.invFile` lookup in `itemRects`
* 2. Item unique/display name lookup in `BAKED_UI_MANIFEST.codeToInvFile`
* 3. Base item code lookup in `BAKED_UI_MANIFEST.codeToInvFile`
* 4. Authoritative Diablo II 1.13c specific item code/name mappings
* 5. Robust bounding-box dimension fallbacks based on (invWidth, invHeight)
*/
export interface ResolvableItem {
readonly code?: string | undefined
readonly name?: string | undefined
readonly nameZh?: string | undefined
readonly baseNameZh?: string | undefined
readonly invFile?: string | undefined
readonly flippyFile?: string | undefined
readonly isGold?: boolean | undefined
readonly amount?: number | undefined
readonly gold?: number | undefined
readonly invtransform?: string | undefined
readonly chrtransform?: string | undefined
readonly variant?: number | undefined
readonly id?: string | undefined
readonly invWidth?: number | undefined
readonly invHeight?: number | undefined
readonly width?: number | undefined
readonly height?: number | undefined
}
/**
* Returns the canonical, unpadded item code for sprite lookups.
*
* Simulation items carry `code` as the fixed 4-byte .d2s field, space padded
* (`'hp1 '`, `'dr3 '`); UI items carry it unpadded. Both are normalized here,
* falling back to the ItemBase code (`base.id`) when `code` is absent.
*/
export function normalizeItemCode(item: ResolvableItem): string | undefined {
const raw = item.code ?? (item as { base?: { id?: unknown } }).base?.id
if (typeof raw !== 'string') return undefined
const trimmed = raw.trim()
return trimmed.length > 0 ? trimmed : undefined
}
export function resolveItemSpriteRect(
rawItem: ResolvableItem,
itemRects: Record<string, SpriteRect> = BAKED_UI_MANIFEST.itemRects,
): SpriteRect | null {
const normalizedCode = normalizeItemCode(rawItem)
const item: ResolvableItem = normalizedCode === rawItem.code ? rawItem : { ...rawItem, code: normalizedCode }
// Tier 0: Transformed dyed item lookup: `${invFile}_${invtransform}`
const targetInv =
item.invFile ||
(item.name ? BAKED_UI_MANIFEST.codeToInvFile[item.name] : undefined) ||
(item.code ? BAKED_UI_MANIFEST.codeToInvFile[item.code] : undefined)
if (targetInv && item.invtransform) {
const targetLower = targetInv.toLowerCase()
const dyedKey = `${targetLower}_${item.invtransform}`.toLowerCase()
if (itemRects[dyedKey]) return itemRects[dyedKey]
if (itemRects[`${targetInv}_${item.invtransform}`]) {
return itemRects[`${targetInv}_${item.invtransform}`]
}
if (targetLower.endsWith(`_${item.invtransform.toLowerCase()}`) && itemRects[targetLower]) {
return itemRects[targetLower]
}
}
// Tier 0.5: Authentic Diablo II 1.13c Charm Resolution (cm1, cm2, cm3, Annihilus, Torch, Gheed's)
if (item.code === 'cm1' || item.code === 'anni' || item.name?.includes('Annihilus')) {
const validSmall = ['invch1', 'invch4', 'invch7', 'invmss']
if (item.variant !== undefined && !item.name?.includes('Annihilus') && item.code !== 'anni') {
const v = ['invch1', 'invch4', 'invch7'][Math.abs(Math.floor(item.variant)) % 3]
if (itemRects[v]) return itemRects[v]
}
const file = item.invFile?.toLowerCase()
const direct = file && validSmall.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
if (item.code === 'anni' || item.name?.includes('Annihilus')) {
return direct ?? itemRects['invmss'] ?? itemRects['invch1'] ?? null
}
return direct ?? itemRects['invch1'] ?? itemRects['invmss'] ?? null
}
if (item.code === 'cm2' || item.code === 'torch' || item.name?.includes('Torch')) {
const validLarge = ['invch2', 'invch5', 'invch8', 'invtrch']
if (item.variant !== undefined && !item.name?.includes('Torch') && item.code !== 'torch') {
const v = ['invch2', 'invch5', 'invch8'][Math.abs(Math.floor(item.variant)) % 3]
if (itemRects[v]) return itemRects[v]
}
const file = item.invFile?.toLowerCase()
const direct = file && validLarge.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
if (item.code === 'torch' || item.name?.includes('Torch')) {
return direct ?? itemRects['invtrch'] ?? itemRects['invch2'] ?? null
}
return direct ?? itemRects['invch2'] ?? itemRects['invtrch'] ?? null
}
if (item.code === 'cm3' || item.code === 'gheeds' || item.name?.includes('Gheed')) {
const validGrand = ['invch3', 'invch6', 'invch9']
if (item.variant !== undefined) {
const v = validGrand[Math.abs(Math.floor(item.variant)) % validGrand.length]
if (itemRects[v]) return itemRects[v]
}
const file = item.invFile?.toLowerCase()
const direct = file && validGrand.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
return direct ?? itemRects['invch3'] ?? itemRects['invch6'] ?? itemRects['invch9'] ?? null
}
// Tier 0.6: Authentic Diablo II 1.13c ItemTypes.txt VarInvGfx (Ring: 5, Amulet: 3, Jewel: 6)
if (item.code === 'rin' || item.code === 'ring' || (item.invFile && /^invrin\d?$/i.test(item.invFile))) {
const ringVariants = ['invrin1', 'invrin2', 'invrin3', 'invrin4', 'invrin5']
if (item.variant !== undefined) {
const idx = Math.abs(Math.floor(item.variant)) % ringVariants.length
const key = ringVariants[idx]
if (itemRects[key]) return itemRects[key]
}
if (item.invFile && item.invFile.toLowerCase() !== 'invrin' && itemRects[item.invFile]) {
return itemRects[item.invFile]
}
if (item.id) {
let hash = 0
for (let i = 0; i < item.id.length; i++) {
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0
}
const idx = Math.abs(hash) % ringVariants.length
const key = ringVariants[idx]
if (itemRects[key]) return itemRects[key]
}
return itemRects['invrin1'] ?? itemRects['invrin'] ?? null
}
if (item.code === 'amu' || item.code === 'amul' || (item.invFile && /^invamu\d?$/i.test(item.invFile))) {
const amuVariants = ['invamu1', 'invamu2', 'invamu3']
if (item.variant !== undefined) {
const idx = Math.abs(Math.floor(item.variant)) % amuVariants.length
const key = amuVariants[idx]
if (itemRects[key]) return itemRects[key]
}
if (item.invFile && item.invFile.toLowerCase() !== 'invamu' && itemRects[item.invFile]) {
return itemRects[item.invFile]
}
if (item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name] && itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]) {
return itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]
}
if (item.id) {
let hash = 0
for (let i = 0; i < item.id.length; i++) {
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0
}
const idx = Math.abs(hash) % amuVariants.length
const key = amuVariants[idx]
if (itemRects[key]) return itemRects[key]
}
return itemRects['invamu1'] ?? itemRects['invamu'] ?? null
}
if (item.code === 'jew' || item.code === 'jewl' || (item.invFile && /^(invjw|invjew)\d?$/i.test(item.invFile))) {
const jewelVariants = ['invjw1', 'invjw2', 'invjw3', 'invjw4', 'invjw5', 'invjw6']
if (item.variant !== undefined) {
const idx = Math.abs(Math.floor(item.variant)) % jewelVariants.length
const key = jewelVariants[idx]
if (itemRects[key]) return itemRects[key]
}
if (item.invFile && itemRects[item.invFile]) {
return itemRects[item.invFile]
}
if (item.id) {
let hash = 0
for (let i = 0; i < item.id.length; i++) {
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0
}
const idx = Math.abs(hash) % jewelVariants.length
const key = jewelVariants[idx]
if (itemRects[key]) return itemRects[key]
}
return itemRects['invjw1'] ?? null
}
// Tier 1: Direct invFile (ignore misc.txt placeholder invfiles for charms)
const isCharmPlaceholder =
(item.code === 'cm3' && item.invFile?.toLowerCase() === 'invsst') ||
(item.code === 'cm2' && item.invFile?.toLowerCase() === 'invwnd') ||
(item.code === 'cm1' && item.invFile?.toLowerCase() === 'invchm')
if (item.invFile && !isCharmPlaceholder) {
if (itemRects[item.invFile]) return itemRects[item.invFile]
const lower = item.invFile.toLowerCase()
if (itemRects[lower]) return itemRects[lower]
}
// Tier 2: Unique / set / display name in codeToInvFile
if (item.name) {
const fromName = BAKED_UI_MANIFEST.codeToInvFile[item.name]
if (fromName && itemRects[fromName]) return itemRects[fromName]
}
// Tier 3: Base item code in codeToInvFile
if (item.code) {
const fromCode = BAKED_UI_MANIFEST.codeToInvFile[item.code]
if (fromCode && itemRects[fromCode]) return itemRects[fromCode]
}
// Tier 4: Canonical Diablo II 1.13c specific code overrides
if (item.code === 'tbk') return itemRects['invbbk'] ?? null
if (item.code === 'ibk') return itemRects['invrbk'] ?? null
if (item.code === 'box' || item.code === 'cube') return itemRects['invbox'] ?? null
if (item.code === 'crs' || item.code === 'cta' || item.name?.includes('Call to Arms')) {
return itemRects['invcrs'] ?? null
}
// Tier 4.5: Name and semantic keyword match for common weapons, armors, and potions
const nameStr = (
(item.name || '') + ' ' +
(item.id || '') + ' ' +
((item as any).nameZh || '') + ' ' +
((item as any).base?.name || '') + ' ' +
((item as any).base?.nameZh || '')
).toLowerCase()
if (nameStr.includes('sword') || nameStr.includes('剑')) return itemRects['invssd'] ?? itemRects['invbsd'] ?? itemRects['invcrs'] ?? null
if (nameStr.includes('axe') || nameStr.includes('斧')) return itemRects['invhax'] ?? itemRects['invbax'] ?? null
if (nameStr.includes('bow') || nameStr.includes('弓')) return itemRects['invsbw'] ?? itemRects['invhbw'] ?? null
if (nameStr.includes('shield') || nameStr.includes('盾')) return itemRects['invsml'] ?? itemRects['invlrg'] ?? itemRects['invkit'] ?? null
if (nameStr.includes('helm') || nameStr.includes('cap') || nameStr.includes('盔') || nameStr.includes('帽')) return itemRects['invcap'] ?? itemRects['invhlm'] ?? null
if (nameStr.includes('armor') || nameStr.includes('plate') || nameStr.includes('甲')) return itemRects['invlea'] ?? itemRects['invqui'] ?? itemRects['invfld'] ?? null
if (nameStr.includes('potion') || nameStr.includes('药')) {
if (nameStr.includes('mana') || nameStr.includes('法力') || nameStr.includes('蓝')) return itemRects['invmp1'] ?? itemRects['invhp1'] ?? null
if (nameStr.includes('rejuv') || nameStr.includes('活力') || nameStr.includes('紫')) return itemRects['invrvs'] ?? itemRects['invhp1'] ?? null
return itemRects['invhp1'] ?? null
}
if (nameStr.includes('ring') || nameStr.includes('戒')) return itemRects['invrin1'] ?? null
if (nameStr.includes('amulet') || nameStr.includes('项链')) return itemRects['invamu1'] ?? null
if (nameStr.includes('boot') || nameStr.includes('靴') || nameStr.includes('鞋')) return itemRects['invlbt'] ?? itemRects['invhbt'] ?? null
if (nameStr.includes('glove') || nameStr.includes('手套')) return itemRects['invlgl'] ?? itemRects['invhgl'] ?? null
if (nameStr.includes('belt') || nameStr.includes('腰带') || nameStr.includes('带')) return itemRects['invlbl'] ?? itemRects['invhbl'] ?? null
if (nameStr.includes('rune') || nameStr.includes('符文')) return itemRects['invr01'] ?? null
if (nameStr.includes('scroll') || nameStr.includes('卷轴')) return itemRects['invtsc'] ?? itemRects['invisc'] ?? null
if (nameStr.includes('gold') || nameStr.includes('金币')) return itemRects['invgld'] ?? null
// Tier 5: Dimension-based fallback guarantee (w x h in cells)
const w = item.invWidth ?? (item as any).width ?? 1
const h = item.invHeight ?? (item as any).height ?? 1
if (w === 1 && h === 1) return itemRects['invch1'] ?? itemRects['invmss'] ?? itemRects['invchm'] ?? itemRects['invrin'] ?? itemRects['invcap'] ?? null
if (w === 1 && h === 2) return itemRects['invch2'] ?? itemRects['invtrch'] ?? itemRects['invbbk'] ?? itemRects['invglv'] ?? null
if (w === 1 && h === 3) {
const isCharm = item.code?.startsWith('cm') || item.name?.toLowerCase().includes('charm')
if (isCharm) {
return itemRects['invch3'] ?? itemRects['invch6'] ?? itemRects['invch9'] ?? null
}
return itemRects['invsst'] ?? itemRects['invclb'] ?? itemRects['invwnd'] ?? itemRects['invch3'] ?? null
}
if (w === 1 && h === 4) return itemRects['invbst'] ?? itemRects['invstf'] ?? null
if (w === 2 && h === 1) return itemRects['invvbl'] ?? itemRects['invlbl'] ?? null
if (w === 2 && h === 2) return itemRects['invbox'] ?? itemRects['invcap'] ?? null
if (w === 2 && h === 3) return itemRects['invcrs'] ?? itemRects['invgth'] ?? null
if (w === 2 && h === 4) return itemRects['invhxb'] ?? itemRects['invpst'] ?? null
const firstKey = Object.keys(itemRects)[0]
return firstKey ? itemRects[firstKey] ?? null : null
}
/**
* Resolves the authentic Diablo II 1.13c ground item sprite rect from the items atlas.
*
* In Diablo II, ground items do NOT use the 2D inventory icon (\`invfile\`).
* Instead, they use \`flippyfile\` (\`flp*.dc6\`), where the animation's last frame
* represents the resting, hand-drawn/3D-rendered isometric ground item.
*/
/**
* Authentic Diablo II 1.13c Inventory Gold Limit (D2Common/src/Units/Units.cpp:3103).
* Character inventory gold is strictly capped at 10,000 * player.level.
*/
export function getInventoryGoldLimit(level: number): number {
const safeLevel = Math.max(1, Math.min(99, Math.floor(level || 1)))
return safeLevel * 10_000
}
/**
* Authentic Diablo II 1.13c Gold Flippy Sprite Rect Resolution.
* - Small pile (flpgld_0 / tier 0): 1–49 gold (24×7 px).
* - Medium pile (flpgld_1 / tier 1): 50–499 gold (26×13 px).
* - Large pile (flpgld_2 / tier 2): 500+ gold (32×20 px).
*/
export function getGoldFlippyRect(
amount: number,
flippyRects: Record<string, SpriteRect> = BAKED_UI_MANIFEST.flippyRects ?? {},
): SpriteRect | null {
const safeAmount = Math.max(1, Math.floor(amount || 1))
let pileIndex = 0
if (safeAmount >= 500) {
pileIndex = 2
} else if (safeAmount >= 50) {
pileIndex = 1
} else {
pileIndex = 0
}
return flippyRects[`flpgld_${pileIndex}`] ?? flippyRects['flpgld'] ?? null
}
export function resolveGroundItemSpriteRect(
item: ResolvableItem,
flippyRects: Record<string, SpriteRect> = BAKED_UI_MANIFEST.flippyRects ?? {},
codeToFlippyFile: Record<string, string> = BAKED_UI_MANIFEST.codeToFlippyFile ?? {},
): SpriteRect | null {
// 1. Gold rendering: 3 authentic visual graphic tiers (1-49, 50-499, 500+)
if ((item as any).isGold || item.code === 'gld') {
const amount = (item as any).amount ?? 1
return getGoldFlippyRect(amount, flippyRects)
}
// 1.5 Authentic Charm Ground Flippy Resolution (cm1 -> flpchm1, cm2 -> flpchm2, cm3 -> flpchm3)
const normCode = normalizeItemCode(item)
const isAnni = normCode === 'anni' || (typeof item.name === 'string' && item.name.includes('Annihilus'))
const isTorch = normCode === 'torch' || (typeof item.name === 'string' && item.name.includes('Torch'))
if (normCode === 'cm3' || normCode === 'gheeds' || (typeof item.name === 'string' && item.name.includes('Gheed'))) {
return flippyRects['flpchm3'] ?? null
}
if (normCode === 'cm2' || isTorch) {
return isTorch ? (flippyRects['flptrch'] ?? flippyRects['flpchm2'] ?? null) : (flippyRects['flpchm2'] ?? null)
}
if (normCode === 'cm1' || isAnni) {
return isAnni ? (flippyRects['flpmss'] ?? flippyRects['flpchm1'] ?? null) : (flippyRects['flpchm1'] ?? null)
}
// 2. Direct flippyFile property
if ((item as any).flippyFile) {
const flpFile = String((item as any).flippyFile).toLowerCase()
if (item.invtransform) {
const dyed = `${flpFile}_${item.invtransform}`.toLowerCase()
if (flippyRects[dyed]) return flippyRects[dyed]
}
if (flippyRects[flpFile]) return flippyRects[flpFile]
}
// 3. Unique / Set item name lookup in codeToFlippyFile
if (item.name) {
const fromName = codeToFlippyFile[item.name]
if (fromName) {
if (item.invtransform) {
const dyed = `${fromName}_${item.invtransform}`.toLowerCase()
if (flippyRects[dyed]) return flippyRects[dyed]
}
if (flippyRects[fromName]) return flippyRects[fromName]
}
}
// 4. Base item code lookup in codeToFlippyFile.
// Simulation `Item.code` is the 4-char space-padded .d2s code (e.g. 'dr3 ',
// D2Common!6FD77180), so it must be trimmed; `base.id` is the unpadded
// ItemBase code and is authoritative when `code` is absent.
const itemCode = normalizeItemCode(item)
if (itemCode) {
const fromCode = codeToFlippyFile[itemCode] ?? codeToFlippyFile[itemCode.toLowerCase()]
if (fromCode) {
if (item.invtransform) {
const dyed = `${fromCode}_${item.invtransform}`.toLowerCase()
if (flippyRects[dyed]) return flippyRects[dyed]
}
if (flippyRects[fromCode]) return flippyRects[fromCode]
}
}
// 5. invFile -> flp substitution (e.g. invhax -> flphax, invcap -> flpcap)
if (item.invFile) {
const flpCandidate = item.invFile.toLowerCase().replace(/^inv/, 'flp')
if (item.invtransform) {
const dyed = `${flpCandidate}_${item.invtransform}`.toLowerCase()
if (flippyRects[dyed]) return flippyRects[dyed]
}
if (flippyRects[flpCandidate]) return flippyRects[flpCandidate]
}
// 6. Name-based semantic fallback for flippy items
const nameStr = (
(item.name || '') + ' ' +
(item.id || '') + ' ' +
((item as any).nameZh || '') + ' ' +
((item as any).base?.name || '') + ' ' +
((item as any).base?.nameZh || '')
).toLowerCase()
if (nameStr.includes('sword') || nameStr.includes('剑')) return flippyRects['flpssd'] ?? flippyRects['flpbsd'] ?? flippyRects['flpcrs'] ?? null
if (nameStr.includes('axe') || nameStr.includes('斧')) return flippyRects['flphax'] ?? flippyRects['flpbax'] ?? null
if (nameStr.includes('bow') || nameStr.includes('弓')) return flippyRects['flpsbw'] ?? flippyRects['flphbw'] ?? null
if (nameStr.includes('shield') || nameStr.includes('盾')) return flippyRects['flpsml'] ?? flippyRects['flplrg'] ?? flippyRects['flpkit'] ?? null
if (nameStr.includes('helm') || nameStr.includes('cap') || nameStr.includes('盔') || nameStr.includes('帽')) return flippyRects['flpcap'] ?? flippyRects['flphlm'] ?? null
if (nameStr.includes('armor') || nameStr.includes('plate') || nameStr.includes('甲')) return flippyRects['flplea'] ?? flippyRects['flpqui'] ?? flippyRects['flpfld'] ?? null
if (nameStr.includes('potion') || nameStr.includes('药')) {
if (nameStr.includes('mana') || nameStr.includes('法力') || nameStr.includes('蓝')) return flippyRects['flpbps'] ?? flippyRects['flprps'] ?? null
if (nameStr.includes('rejuv') || nameStr.includes('活力') || nameStr.includes('紫')) return flippyRects['flprvs'] ?? flippyRects['flprps'] ?? null
return flippyRects['flprps'] ?? null
}
if (nameStr.includes('ring') || nameStr.includes('戒')) return flippyRects['flprin'] ?? null
if (nameStr.includes('amulet') || nameStr.includes('项链')) return flippyRects['flpamu'] ?? null
if (nameStr.includes('boot') || nameStr.includes('靴') || nameStr.includes('鞋')) return flippyRects['flplbt'] ?? flippyRects['flphbt'] ?? null
if (nameStr.includes('glove') || nameStr.includes('手套')) return flippyRects['flplgl'] ?? flippyRects['flphgl'] ?? null
if (nameStr.includes('belt') || nameStr.includes('腰带') || nameStr.includes('带')) return flippyRects['flplbl'] ?? flippyRects['flphbl'] ?? null
if (nameStr.includes('rune') || nameStr.includes('符文')) return flippyRects['flprun'] ?? null
if (nameStr.includes('scroll') || nameStr.includes('卷轴')) return flippyRects['flptsc'] ?? flippyRects['flprsc'] ?? null
if (nameStr.includes('grand charm') || nameStr.includes('超大护身符') || nameStr.includes('超大型护身符')) return flippyRects['flpchm3'] ?? null
if (nameStr.includes('large charm') || nameStr.includes('大型护身符')) return flippyRects['flpchm2'] ?? null
if (nameStr.includes('small charm') || nameStr.includes('小型护身符') || nameStr.includes('小护身符')) return flippyRects['flpchm1'] ?? null
if (nameStr.includes('charm') || nameStr.includes('护身符')) return flippyRects['flpchm1'] ?? null
// 7. Fallback to 2D inventory item sprite rect if no flippy found
return resolveItemSpriteRect(item, BAKED_UI_MANIFEST.itemRects)
}
export const STARTER_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInventoryItem>>> = {
helm: {
id: 'eq-shako',
code: 'uap',
invFile: 'invcap',
invtransform: 'cgrn',
name: 'Harlequin Crest',
nameZh: '谐角之冠 (Harlequin Crest)',
baseNameZh: '军帽 (Shako)',
quality: 'unique',
invWidth: 2,
invHeight: 2,
allowedSlots: ['helm'],
defense: 141,
durability: { current: 12, max: 12 },
reqLevel: 62,
reqStr: 50,
stats: [
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
{ text: '+1.5 生命 (依角色等级而定)', color: 'blue' },
{ text: '+1.5 法力 (依角色等级而定)', color: 'blue' },
{ text: '物理伤害减少 10%', color: 'blue' },
{ text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' },
{ text: '+2 所有属性', color: 'blue' },
],
},
amulet: {
id: 'eq-maras',
code: 'amu',
invFile: 'invamu',
name: "Mara's Kaleidoscope",
nameZh: "马拉的万花筒 (Mara's Kaleidoscope)",
baseNameZh: '项链 (Amulet)',
quality: 'unique',
invWidth: 1,
invHeight: 1,
allowedSlots: ['amulet'],
reqLevel: 67,
stats: [
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
{ text: '+5 所有属性', color: 'blue' },
{ text: '所有抗性 +30 (All Resistances +30)', color: 'blue' },
],
},
weapon1: {
id: 'eq-oculus',
code: 'oba',
invFile: 'invob5',
name: 'The Oculus',
nameZh: '眼球 (The Oculus)',
baseNameZh: '涡流水晶 (Swirling Crystal)',
quality: 'unique',
invWidth: 1,
invHeight: 3,
allowedSlots: ['weapon1'],
damage: '18 - 42',
durability: { current: 50, max: 50 },
speedText: '法球类 - 普通攻击速度',
reqLevel: 42,
stats: [
{ text: '被击中时有 25% 几率施放等级 1 传送', color: 'blue' },
{ text: '+3 法师技能等级', color: 'blue' },
{ text: '+30% 快速施法速度 (FCR)', color: 'blue' },
{ text: '+20% 增强防御', color: 'blue' },
{ text: '+20 体力', color: 'blue' },
{ text: '+20 精力', color: 'blue' },
{ text: '所有抗性 +20', color: 'blue' },
{ text: '+5 每次击杀获得法力', color: 'blue' },
{ text: '50% 更佳机会取得魔法装备', color: 'blue' },
],
},
armor: {
id: 'eq-tal-armor',
code: 'uth',
invFile: 'invgth',
invtransform: 'dpur',
name: "Tal Rasha's Guardianship",
nameZh: '塔·拉夏的守护',
baseNameZh: '漆甲 (Lacquered Plate)',
quality: 'set',
invWidth: 2,
invHeight: 3,
allowedSlots: ['armor'],
defense: 941,
durability: { current: 55, max: 55 },
reqLevel: 71,
reqStr: 84,
stats: [
{ text: '+400 防御', color: 'blue' },
{ text: '抗寒 +40%', color: 'blue' },
{ text: '抗电 +40%', color: 'blue' },
{ text: '抗火 +40%', color: 'blue' },
{ text: '魔法伤害减少 15', color: 'blue' },
{ text: '88% 更佳机会取得魔法装备', color: 'blue' },
],
setPieces: [
'塔·拉夏的守护 (Tal Rasha\'s Guardianship)',
'塔·拉夏的赫拉迪克纹章 (Tal Rasha\'s Horadric Crest)',
'塔·拉夏的判决 (Tal Rasha\'s Adjudication)',
'塔·拉夏的无睑之眼 (Tal Rasha\'s Lidless Eye)',
'塔·拉夏的织细衣服 (Tal Rasha\'s Fine-Spun Cloth)',
],
setBonuses: [
{ text: '+10% 快速再度攻击 (2 件装备)', color: 'green' },
{ text: '+3 法师技能等级 (完整套装)', color: 'green' },
{ text: '所有抗性 +50 (完整套装)', color: 'green' },
{ text: '+150 生命 (完整套装)', color: 'green' },
],
},
weapon2: {
id: 'eq-spirit',
code: 'uit',
invFile: 'invtow',
name: 'Spirit Monarch',
nameZh: '精神 (Spirit) 统盾',
baseNameZh: '统治者大盾 (Monarch)',
quality: 'rune',
runewordRunes: "'TalThulOrtAmn'",
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon2'],
defense: 148,
durability: { current: 148, max: 148 },
reqLevel: 54,
reqStr: 156,
sockets: 4,
stats: [
{ text: '+2 所有技能 (+2 To All Skills)', color: 'blue' },
{ text: '+35% 快速施法速度 (FCR)', color: 'blue' },
{ text: '+55% 快速再度攻击 (FHR)', color: 'blue' },
{ text: '+250 对远距离攻击的防御', color: 'blue' },
{ text: '+22 体力', color: 'blue' },
{ text: '+112 法力', color: 'blue' },
{ text: '抗寒 +35%', color: 'blue' },
{ text: '抗电 +35%', color: 'blue' },
{ text: '抗毒 +35%', color: 'blue' },
{ text: '+8 魔法吸收', color: 'blue' },
],
},
gloves: {
id: 'eq-magefist',
code: 'tgl',
invFile: 'invtgl',
invtransform: 'lgry',
name: 'Magefist',
nameZh: '法师之拳 (Magefist)',
baseNameZh: '轻型铁手套',
quality: 'unique',
invWidth: 2,
invHeight: 2,
allowedSlots: ['gloves'],
defense: 25,
durability: { current: 14, max: 14 },
reqLevel: 23,
reqStr: 45,
stats: [
{ text: '+1 火焰技能', color: 'blue' },
{ text: '+20% 快速施法速度 (FCR)', color: 'blue' },
{ text: '法力再生速度提高 25%', color: 'blue' },
{ text: '增加 1-6 火焰伤害', color: 'blue' },
{ text: '+30% 增强防御', color: 'blue' },
],
},
ring1: {
id: 'eq-soj-1',
code: 'rin',
invFile: 'invrin',
name: 'The Stone of Jordan',
nameZh: '乔丹之石 (Stone of Jordan)',
baseNameZh: '戒指 (Ring)',
quality: 'unique',
invWidth: 1,
invHeight: 1,
allowedSlots: ['ring1', 'ring2'],
reqLevel: 29,
stats: [
{ text: '+1 所有技能', color: 'blue' },
{ text: '增加法力上限 25%', color: 'blue' },
{ text: '+20 法力', color: 'blue' },
{ text: '增加 1-12 闪电伤害', color: 'blue' },
],
},
belt: {
id: 'eq-arach',
code: 'ulc',
invFile: 'invvbl',
invtransform: 'blac',
name: 'Arachnid Mesh',
nameZh: '蜘蛛之网 (Arachnid Mesh)',
baseNameZh: '蛛网腰带 (Spiderweb Sash)',
quality: 'unique',
invWidth: 2,
invHeight: 1,
allowedSlots: ['belt'],
defense: 138,
durability: { current: 12, max: 12 },
reqLevel: 80,
reqStr: 50,
stats: [
{ text: '+1 所有技能', color: 'blue' },
{ text: '+20% 快速施法速度 (FCR)', color: 'blue' },
{ text: '减慢目标速度 10%', color: 'blue' },
{ text: '增加法力上限 5%', color: 'blue' },
{ text: '+120% 增强防御', color: 'blue' },
],
},
ring2: {
id: 'eq-bk-2',
code: 'rin',
invFile: 'invrin',
name: "Bul-Kathos' Wedding Band",
nameZh: '布尔凯索之婚戒',
baseNameZh: '戒指 (Ring)',
quality: 'unique',
invWidth: 1,
invHeight: 1,
allowedSlots: ['ring1', 'ring2'],
reqLevel: 58,
stats: [
{ text: '+1 所有技能', color: 'blue' },
{ text: '+45 生命 (依角色等级而定)', color: 'blue' },
{ text: '5% 每次命中偷取生命', color: 'blue' },
{ text: '+50 最大耐力', color: 'blue' },
],
},
boots: {
id: 'eq-wartrav',
code: 'xtb',
invFile: 'invtbt',
name: 'War Traveler',
nameZh: '战争旅者 (War Traveler)',
baseNameZh: '战场之靴 (Battle Boots)',
quality: 'unique',
invWidth: 2,
invHeight: 2,
allowedSlots: ['boots'],
defense: 139,
durability: { current: 48, max: 48 },
reqLevel: 42,
reqStr: 95,
stats: [
{ text: '+25% 高速跑步/行走', color: 'blue' },
{ text: '+10 力量', color: 'blue' },
{ text: '+10 体力', color: 'blue' },
{ text: '增加 15-25 伤害', color: 'blue' },
{ text: '+190% 增强防御', color: 'blue' },
{ text: '减慢耐力消耗 40%', color: 'blue' },
{ text: '攻击者受到伤害 10', color: 'blue' },
{ text: '50% 更佳机会取得魔法装备 (MF)', color: 'blue' },
],
},
}
export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [
{
col: 0,
row: 0,
item: {
id: 'bag-cube',
code: 'box',
invFile: 'invbox',
name: 'Horadric Cube',
nameZh: '赫拉迪克方块 (Horadric Cube)',
baseNameZh: '任务宝盒',
quality: 'unique',
invWidth: 2,
invHeight: 2,
allowedSlots: [],
stats: [{ text: '右键点击打开合成界面 (3×4 网格)', color: 'gold' }],
},
},
{
col: 2,
row: 0,
item: {
id: 'bag-tbk',
code: 'tbk',
invFile: 'invbbk',
name: 'Tome of Town Portal',
nameZh: '回城之书 (Tome of Town Portal)',
baseNameZh: '法术书 (数量: 20)',
quality: 'normal',
invWidth: 1,
invHeight: 2,
allowedSlots: [],
quantity: 20,
maxQuantity: 20,
stats: [{ text: '右键施放城镇传送门', color: 'white' }],
},
},
{
col: 2,
row: 2,
item: {
id: 'bag-ibk',
code: 'ibk',
invFile: 'invrbk',
name: 'Tome of Identify',
nameZh: '鉴定之书 (Tome of Identify)',
baseNameZh: '法术书 (数量: 20)',
quality: 'normal',
invWidth: 1,
invHeight: 2,
allowedSlots: [],
quantity: 20,
maxQuantity: 20,
stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }],
},
},
{
col: 3,
row: 0,
item: {
id: 'bag-torch',
code: 'cm2',
invFile: 'invtrch',
name: 'Hellfire Torch',
nameZh: '地狱火炬 (Hellfire Torch)',
baseNameZh: '大型护身符 (Large Charm)',
quality: 'unique',
invWidth: 1,
invHeight: 2,
allowedSlots: [],
reqLevel: 75,
stats: [
{ text: '击中时有 5% 几率施放等级 10 火风暴', color: 'blue' },
{ text: '+3 法师技能等级', color: 'blue' },
{ text: '+20 所有属性', color: 'blue' },
{ text: '所有抗性 +20', color: 'blue' },
{ text: '+8 照亮范围', color: 'blue' },
],
},
},
{
col: 3,
row: 2,
item: {
id: 'bag-anni',
code: 'cm1',
invFile: 'invmss',
name: 'Annihilus',
nameZh: '毁灭 (Annihilus)',
baseNameZh: '小型护身符 (Small Charm)',
quality: 'unique',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
reqLevel: 70,
stats: [
{ text: '+1 所有技能 (+1 To All Skills)', color: 'blue' },
{ text: '+20 所有属性', color: 'blue' },
{ text: '所有抗性 +20', color: 'blue' },
{ text: '+10% 获得经验值', color: 'blue' },
],
},
},
{
col: 4,
row: 0,
item: {
id: 'bag-gheeds',
code: 'cm3',
invFile: 'invch3',
name: "Gheed's Fortune",
nameZh: '基德的运气 (Gheed\'s Fortune)',
baseNameZh: '超大型护身符 (Grand Charm)',
quality: 'unique',
invWidth: 1,
invHeight: 3,
allowedSlots: [],
reqLevel: 62,
stats: [
{ text: '160% 额外金币从怪物身上取得', color: 'blue' },
{ text: '40% 更佳机会取得魔法装备 (MF)', color: 'blue' },
{ text: '降低所有商店价格 15%', color: 'blue' },
],
},
},
{
col: 8,
row: 0,
item: {
id: 'bag-cta',
code: 'crs',
invFile: 'invcrs',
name: 'Call to Arms',
nameZh: '战争召唤 (Call to Arms)',
baseNameZh: '水晶剑 (Crystal Sword)',
quality: 'rune',
runewordRunes: "'AmnRalMalIstOhm'",
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon1'],
damage: '17 - 52',
durability: { current: 20, max: 20 },
reqStr: 43,
reqLevel: 57,
sockets: 5,
speedText: '剑类 - 极快的攻击速度',
stats: [
{ text: '+1 所有技能 (+1 To All Skills)', color: 'blue' },
{ text: '+40% 增加攻击速度 (+40% Increased Attack Speed)', color: 'blue' },
{ text: '+255% 增强伤害 (+255% Enhanced Damage)', color: 'blue' },
{ text: '增加 5-30 火焰伤害 (Adds 5-30 Fire Damage)', color: 'blue' },
{ text: '7% 生命偷取 / 每次命中偷取生命 (7% Life Stolen)', color: 'blue' },
{ text: '防止怪物自愈 / 防止怪物自疗 (Prevent Monster Heal)', color: 'blue' },
{ text: '+6 战斗体制 (Battle Orders)', color: 'blue' },
{ text: '+6 战斗指挥 (Battle Command)', color: 'blue' },
{ text: '+4 战斗狂嗥 / 战嚎 (Battle Cry)', color: 'blue' },
{ text: '生命回复 +12 / 生命补满 +12 (Replenish Life +12)', color: 'blue' },
{ text: '30% 更佳机会取得魔法装备 (30% Better Chance of Getting Magic Items)', color: 'blue' },
],
},
},
]
/** 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 withTomeQuantity(item: UiInventoryItem, quantity: number): UiInventoryItem {
const cloned: UiInventoryItem = { ...item, stats: [...item.stats] }
return setTomeQuantity(cloned, quantity)
}
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. */
weaponSwapSet: 0 | 1 = 0
/** Equipped gear slots. */
readonly equipped: Partial<Record<EquipSlotId, UiInventoryItem>> = { ...STARTER_EQUIPPED_GEAR }
/** Secondary weapon swap slots (`Weapon Set II`). */
readonly swapWeapons: { weapon1: UiInventoryItem | null; weapon2: UiInventoryItem | null } = {
weapon1: null,
weapon2: null,
}
/** 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. */
playerLevel = 1
/** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */
get maxGold(): number {
return getInventoryGoldLimit(this.playerLevel)
}
/** 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
if (this.swapWeapons.weapon1) this.equipped.weapon1 = this.swapWeapons.weapon1
else delete this.equipped.weapon1
if (this.swapWeapons.weapon2) this.equipped.weapon2 = this.swapWeapons.weapon2
else delete this.equipped.weapon2
this.swapWeapons.weapon1 = curW1
this.swapWeapons.weapon2 = curW2
this.weaponSwapSet = this.weaponSwapSet === 0 ? 1 : 0
}
/**
* Check whether an item of `(w, h)` can be placed at `(col, row)` in the 10x4 grid.
* Returns the list of overlapping `GridPlacement`s.
*/
getGridOverlaps(col: number, row: number, w: number, h: number): GridPlacement[] {
if (col < 0 || row < 0 || col + w > INV_GRID_ORIGIN.cols || row + h > INV_GRID_ORIGIN.rows) {
throw new RangeError('Out of 10x4 grid bounds')
}
return this.gridItems.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
})
}
/**
* Attempt to place an item into the first free `(col, row)` in the 10x4 grid.
*/
autoPlaceInGrid(item: UiInventoryItem): boolean {
for (let col = 0; col <= INV_GRID_ORIGIN.cols - item.invWidth; col++) {
for (let row = 0; row <= INV_GRID_ORIGIN.rows - item.invHeight; row++) {
if (this.getGridOverlaps(col, row, item.invWidth, item.invHeight).length === 0) {
this.gridItems.push({ item, col, row })
return true
}
}
}
return false
}
/**
* Deduct gold from inventory to drop. Returns actual amount deducted.
*/
dropGold(amount: number): number {
const toDrop = Math.max(0, Math.min(amount, this.gold))
this.gold -= toDrop
return toDrop
}
/**
* 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 {
if (!this.cursorItem) {
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 false
this.gridItems = this.gridItems.filter(p => p !== hit)
this.cursorItem = hit.item
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)
if (overlaps.length === 0) {
this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow })
this.cursorItem = null
return true
}
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
return true
}
return false
}
/**
* 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
if (!this.cursorItem) {
if (!existing) return false
delete this.equipped[slotId]
this.cursorItem = existing
return true
}
if (isUiItemUnidentified(this.cursorItem)) {
return false
}
if (!this.cursorItem.allowedSlots.includes(slotId)) {
return false
}
this.equipped[slotId] = this.cursorItem
this.cursorItem = existing
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?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | 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
const castOk = callbacks?.onCastTownPortal?.('tbk', placed.item)
if (castOk !== false) {
setTomeQuantity(placed.item, qty - 1)
}
return true
}
if (code === 'tsc') {
const castOk = callbacks?.onCastTownPortal?.('tsc', placed.item)
if (castOk !== false) {
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)
if (this.hoveredItem?.item.id === placed.item.id) {
this.hoveredItem = null
}
}
}
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
// Check equipment slots
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) {
this.hoveredItem = { item: eq, x: rect.x + rect.w / 2, y: rect.y }
}
return
}
}
// Check 10x4 grid
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) {
this.hoveredItem = {
item: hit.item,
x: INV_GRID_ORIGIN.x + (hit.col + hit.item.invWidth / 2) * INV_GRID_ORIGIN.cellPx,
y: INV_GRID_ORIGIN.y + hit.row * INV_GRID_ORIGIN.cellPx,
}
}
}
}
/**
* 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?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | 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 &&
logicalX <= INV_CLOSE_BTN_BOUNDS.x + INV_CLOSE_BTN_BOUNDS.w &&
logicalY >= INV_CLOSE_BTN_BOUNDS.y &&
logicalY <= INV_CLOSE_BTN_BOUNDS.y + INV_CLOSE_BTN_BOUNDS.h
) {
this.visible = false
return true
}
// 2. Weapon Swap `I / II` Tabs
for (const tab of [WEAPON_SWAP_TABS_BOUNDS.left, WEAPON_SWAP_TABS_BOUNDS.right]) {
if (logicalX >= tab.x && logicalX <= tab.x + tab.w && logicalY >= tab.y && logicalY <= tab.y + tab.h) {
this.toggleWeaponSwap()
return true
}
}
// 3. Equipment slots
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) {
this.clickEquipSlot(slotId)
return true
}
}
// 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 &&
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)
this.clickGridCell(col, row)
return true
}
// 5. Gold Button / Readout Area (x: 500..625, y: 450..485)
if (logicalX >= 500 && logicalX <= 625 && logicalY >= 450 && logicalY <= 485) {
if (this.gold > 0) {
const dropAmt = Math.min(10_000, this.gold)
callbacks?.onDropGold?.(dropAmt)
}
return true
}
return true
}
draw(
ctx: CanvasRenderingContext2D,
assets: {
borderRightImg: HTMLImageElement | null
invSheetImg: HTMLImageElement | null
invTab0Img: HTMLImageElement | null
invTab1Img: HTMLImageElement | null
buySellBtnImg: HTMLImageElement | null
itemsAtlasImg: HTMLImageElement | null
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
},
font: D2FontRenderer,
): void {
if (!this.visible) return
// 1. Draw 800x600 Right Gothic Stone Border (`800BorderFrame.dc6` frames 5..9) at (400, 0)
if (assets.borderRightImg) {
ctx.drawImage(assets.borderRightImg, 400, 0)
}
// 2. Draw 320x432 Inventory Panel (`invchar6.dc6` frames 4..7) at (400, 60)
if (assets.invSheetImg) {
ctx.drawImage(assets.invSheetImg, INV_PANEL_ORIGIN.x, INV_PANEL_ORIGIN.y)
} else {
ctx.fillStyle = '#181410'
ctx.fillRect(INV_PANEL_ORIGIN.x, INV_PANEL_ORIGIN.y, INV_PANEL_ORIGIN.width, INV_PANEL_ORIGIN.height)
}
// 3. Draw Weapon Swap `I / II` Tabs (`invchar6Tab.dc6`)
const tabImg = this.weaponSwapSet === 0 ? assets.invTab0Img : assets.invTab1Img
if (tabImg) {
ctx.drawImage(tabImg, 400 + 15, 60 + 23)
ctx.drawImage(tabImg, 400 + 245, 60 + 23)
}
font.drawText(ctx, this.weaponSwapSet === 0 ? 'I' : 'II', 400 + 46, 60 + 38, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
font.drawText(ctx, this.weaponSwapSet === 0 ? 'I' : 'II', 400 + 276, 60 + 38, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
// 4. Draw Equipped Items in Paperdoll Slots
for (const [slotId, rect] of Object.entries(EQUIP_SLOTS_LAYOUT) as [EquipSlotId, typeof EQUIP_SLOTS_LAYOUT[EquipSlotId]][]) {
const eq = this.equipped[slotId]
if (!eq) continue
ctx.fillStyle = 'rgba(18, 42, 24, 0.52)'
ctx.fillRect(rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2)
this.drawItemSprite(ctx, eq, rect.x, rect.y, rect.w, rect.h, assets)
}
// 5. Draw 10x4 Bag Grid Items
for (const placed of this.gridItems) {
const gx = INV_GRID_ORIGIN.x + placed.col * INV_GRID_ORIGIN.cellPx
const gy = INV_GRID_ORIGIN.y + placed.row * INV_GRID_ORIGIN.cellPx
const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx
const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx
ctx.fillStyle =
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)
this.drawItemSprite(ctx, placed.item, gx, gy, gw, gh, assets)
}
// 6. Gold Amount Readout
font.drawText(ctx, this.gold.toLocaleString(), 400 + 162, 60 + 406, {
font: 'font8',
color: 'white',
align: 'center',
})
// 7. Close Button (`buysellbtn.dc6` frame 10)
if (assets.buySellBtnImg) {
ctx.drawImage(
assets.buySellBtnImg,
10 * 32,
0,
32,
32,
INV_CLOSE_BTN_BOUNDS.x,
INV_CLOSE_BTN_BOUNDS.y,
32,
32,
)
}
}
private drawItemSprite(
ctx: CanvasRenderingContext2D,
item: UiInventoryItem,
x: number,
y: number,
w: number,
h: number,
assets: {
itemsAtlasImg: HTMLImageElement | null
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
},
): void {
const sr = resolveItemSpriteRect(item, assets.itemRects)
if (assets.itemsAtlasImg && sr) {
const dx = x + Math.floor((w - sr.w) / 2)
const dy = y + Math.floor((h - sr.h) / 2)
ctx.drawImage(assets.itemsAtlasImg, sr.x, sr.y, sr.w, sr.h, dx, dy, sr.w, sr.h)
} else {
ctx.strokeStyle = '#c8a15a'
ctx.strokeRect(x + 2, y + 2, w - 4, h - 4)
}
}
}