1239 lines
47 KiB
TypeScript
1239 lines
47 KiB
TypeScript
/**
|
||
* 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'
|
||
|
||
export type { SpriteRect }
|
||
export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts'
|
||
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 setPieces?: readonly string[] | undefined
|
||
readonly setBonuses?: readonly { text: string; color?: D2ColorCode }[] | 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 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.
|
||
*/
|
||
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: pile size determined by amount
|
||
if ((item as any).isGold || item.code === 'gld') {
|
||
const amount = (item as any).amount ?? 1
|
||
let pileIndex = 0
|
||
if (amount >= 5000) {
|
||
pileIndex = 3
|
||
} else if (amount >= 500) {
|
||
pileIndex = 2
|
||
} else if (amount >= 100) {
|
||
pileIndex = 1
|
||
}
|
||
return flippyRects[`flpgld_${pileIndex}`] ?? flippyRects['flpgld'] ?? 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
|
||
|
||
// 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 体力 / +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% / 抗电 +40% / 抗火 +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% / 抗电 +35% / 抗毒 +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 力量 / +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: [],
|
||
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: [],
|
||
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' },
|
||
],
|
||
},
|
||
},
|
||
]
|
||
|
||
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. */
|
||
gridItems: GridPlacement[] = [...STARTER_BAG_ITEMS]
|
||
/** Item currently held on the mouse cursor (`cursorItem`). */
|
||
cursorItem: UiInventoryItem | 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 gold in inventory. */
|
||
gold = 850_000
|
||
|
||
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 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
|
||
}
|
||
|
||
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]!
|
||
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.).
|
||
*/
|
||
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 (!this.cursorItem.allowedSlots.includes(slotId)) {
|
||
return false
|
||
}
|
||
this.equipped[slotId] = this.cursorItem
|
||
this.cursorItem = existing
|
||
return true
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
handleClick(
|
||
logicalX: number,
|
||
logicalY: number,
|
||
callbacks: {
|
||
onOpenCube: () => void
|
||
onCastTownPortal: () => void
|
||
onDropGold?: (amount: number) => void
|
||
},
|
||
): boolean {
|
||
if (!this.visible) return false
|
||
if (logicalX < 400) return false
|
||
|
||
// 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
|
||
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 (!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
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|