/** * 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 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 > = { 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 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 } export function resolveItemSpriteRect( item: ResolvableItem, itemRects: Record = BAKED_UI_MANIFEST.itemRects, ): SpriteRect | null { // Tier 0: Transformed dyed item lookup: `${invFile}_${invtransform}` if (item.invFile && item.invtransform) { const dyedKey = `${item.invFile}_${item.invtransform}`.toLowerCase() if (itemRects[dyedKey]) return itemRects[dyedKey] if (itemRects[`${item.invFile}_${item.invtransform}`]) { return itemRects[`${item.invFile}_${item.invtransform}`] } } // 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 } export const STARTER_EQUIPPED_GEAR: Readonly>> = { 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: 'invbst', name: 'The Oculus', nameZh: '眼球 (The Oculus)', baseNameZh: '涡流水晶 (Swirling Crystal)', quality: 'unique', invWidth: 2, 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> = { ...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 }, 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 }, ): 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) } } }