/** * Diablo II v1.13c World & Extended UI Panels: * 1. Quest Log Panel (`Menu\questbackground.dc6`, hotkey `Q`) * 2. Waypoint Teleport Panel (`Menu\waygatebackground.dc6`, hotkey `V` or clicking scene Waypoint) * 3. Private Stash (`Panel\TradeStash.dc6`, 6x8 grid) & Horadric Cube (`Panel\supertransmogrifier.dc6`, 3x4 grid) * 4. Town NPC Stone Interaction Menu (`npcmenu.cpp`) & Vendor Panel (`Panel\buysell.dc6`, `Panel\buyselltabs.dc6`, `Panel\buysellbtn.dc6`) * 5. Monster Target Top Plaque (`text.cpp`), Item Hover Tooltip (`showitems.cpp`), * Ground `Alt` Item Labels, Area Entry Gothic Banner (`Font30`), and Escape Menu (`menupanel.dc6`). */ import type { D2ColorCode, D2FontRenderer } from './font.ts' import { TOME_MAX_QUANTITY, findFreeGridSlot, getTomeQuantity, identifyUiItem, isScrollForTome, isUiItemUnidentified, isUsableRightClickItem, itemToUiInventoryItem, resolveItemSpriteRect, setTomeQuantity, type EquipSlotId, type GridPlacement, type InventoryPanel, type UiInventoryItem, } from './inventory.ts' import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts' import { type TownNpcServiceDescriptor, type VendorNpcId, getTownNpcDescriptorByName, getTownNpcDescriptorByVendorId, } from '../game/npc-table.ts' import type { VendorTabId } from '../game/vendor-generator.ts' import { VendorSessionManager } from '../game/vendor-service.ts' import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts' import { isItemRepairable } from '../game/item-cost.ts' export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' | 'cube' | 'vendor' export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const export const STASH_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const export const STASH_GOLD_CAP = 2_500_000 export const PLAYER_GOLD_CAP = 2_500_000 export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } as const export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const /** `Inventory.txt` `Transmogrify Box Page 1` / `Transmogrify Box2` (`supertransmogrifier.dc6` 3x4 grid) */ export const CUBE_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const export const CUBE_GRID_ORIGIN = { x: 80 + 117, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 } as const export const VENDOR_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const /** `Inventory.txt` row `Monster2`: gridLeft=96 (`80 + 16`), gridTop=123 (`60 + 63`), 10x10 cells of 29x29px */ export const VENDOR_GRID_ORIGIN = { x: 80 + 16, y: 60 + 63, cols: 10, rows: 10, cellPx: 29 } as const /** * D2Client.dll vendor button geometry (`0x6fb45df1`, `0x6fb4c27a`, `0x6fb3f260`): * - Mode-3 button table X offsets (relative to panel X0=80): `{116, 169, 221, 273}` * - Sprite draw position: `x = X0 + tableX - 1` (`115, 168, 220, 272` relative), `y = panelTop + 385` (`445` absolute) * - Hit-test open intervals: `relX ∈ (tableX, tableX + 45)`, `relY ∈ (371, 415)` */ export const VENDOR_BUTTON_TABLE_X = [116, 169, 221, 273] as const export const VENDOR_BUTTON_DRAW_Y = 385 export const VENDOR_BUTTON_HIT_Y_MIN = 371 export const VENDOR_BUTTON_HIT_Y_MAX = 415 export const VENDOR_BUTTON_HIT_W = 45 export type VendorButtonKind = 'blank' | 'buy' | 'sell' | 'repair' | 'repairAll' | 'close' export type VendorTradeState = 'idle' | 'buy' | 'sell' | 'repair' export interface VendorButtonSpec { readonly kind: VendorButtonKind readonly enabled: boolean readonly baseFrame: number readonly pressedFrame: number readonly strIdx: number readonly labelZh: string readonly labelEn: string readonly paramA: number } export const VENDOR_BUTTON_SPECS: Readonly> = { blank: { kind: 'blank', enabled: false, baseFrame: 0, pressedFrame: 1, strIdx: 0, labelZh: '', labelEn: '', paramA: 1, }, buy: { kind: 'buy', enabled: true, baseFrame: 2, pressedFrame: 3, strIdx: 3335, labelZh: '買', labelEn: 'Buy', paramA: 2, }, sell: { kind: 'sell', enabled: true, baseFrame: 4, pressedFrame: 5, strIdx: 3336, labelZh: '賣', labelEn: 'Sell', paramA: 3, }, repair: { kind: 'repair', enabled: true, baseFrame: 6, pressedFrame: 7, strIdx: 3338, labelZh: '修復', labelEn: 'Repair', paramA: 4, }, repairAll: { kind: 'repairAll', enabled: true, baseFrame: 18, pressedFrame: 19, strIdx: 10095, labelZh: '修復所有裝備', labelEn: 'Repair all equipment', paramA: 1, }, close: { kind: 'close', enabled: true, baseFrame: 10, pressedFrame: 11, strIdx: 4144, labelZh: '關閉', labelEn: 'Close', paramA: 9, }, } /** D2Client.dll `0x6fb3f430` jump tables (`0x6fb3f5d4`, `0x6fb3f5e0`, `0x6fb3f618`) */ export const BLACKSMITH_CLASS_IDS: ReadonlySet = new Set([154, 178, 253, 257, 511]) export const TRADER_CLASS_IDS: ReadonlySet = new Set([147, 148, 177, 199, 202, 252, 254, 255, 405, 512, 513]) export function resolveVendorButtonLayout( hcIdx: number, mode: 'trade' | 'gamble', ): readonly [VendorButtonKind, VendorButtonKind, VendorButtonKind, VendorButtonKind] { if (mode === 'gamble') return ['buy', 'sell', 'blank', 'close'] if (BLACKSMITH_CLASS_IDS.has(hcIdx)) return ['buy', 'sell', 'repair', 'repairAll'] if (TRADER_CLASS_IDS.has(hcIdx)) return ['buy', 'sell', 'blank', 'close'] throw new Error(`D2Client 0x6fb3f430 assigns no trade buttons to MonStats class ${hcIdx}`) } /** D2Client.dll tab table at `0x6fb91178` (stride 18) & `buyselltabs.dc6` (`0x6fb45e5e–0x6fb45f5d`) */ export const VENDOR_TABS: readonly { readonly id: VendorTabId readonly index: 0 | 1 | 2 | 3 readonly xOffset: number readonly width: number readonly height: number readonly centerX: number readonly textY: number readonly strIdx: number readonly labelZh: string readonly labelEn: string }[] = [ { id: 'armor', index: 0, xOffset: 0, width: 79, height: 31, centerX: 42, textY: 20, strIdx: 4036, labelZh: '裝甲', labelEn: 'Armor' }, { id: 'weapons1', index: 1, xOffset: 80, width: 79, height: 31, centerX: 121, textY: 20, strIdx: 4037, labelZh: '武器', labelEn: 'Weapons' }, { id: 'weapons2', index: 2, xOffset: 160, width: 79, height: 31, centerX: 201, textY: 20, strIdx: 4037, labelZh: '武器', labelEn: 'Weapons' }, { id: 'misc', index: 3, xOffset: 240, width: 79, height: 31, centerX: 281, textY: 20, strIdx: 4039, labelZh: '其他', labelEn: 'Misc' }, ] as const export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel' export interface NpcMenuOption { readonly id: NpcMenuActionId readonly labelZh: string readonly labelEn: string } import { CANONICAL_QUESTS_BY_ACT, type CanonicalQuestItem, type CanonicalQuestStage, } from '../data/canonical-quest-data.ts' export interface QuestEntry { readonly id: string readonly act: number readonly index: number readonly titleEn: string readonly titleZh: string readonly titleKey: string readonly status: 'completed' | 'active' | 'locked' readonly description: string readonly objectiveKey: string readonly objectiveEn: string readonly objectiveZh: string readonly completedKey: string readonly completedEn: string readonly completedZh: string readonly rewardSummaryEn: string readonly rewardSummaryZh: string readonly stages: readonly CanonicalQuestStage[] } export type QuestTblLookup = (key: string) => string | undefined export interface QuestDisplayText { readonly title: string readonly titleKey: string readonly body: string readonly status: 'completed' | 'active' | 'locked' readonly statusTag: string readonly rewardText: string } export interface WaypointEntry { readonly act: number readonly index: number readonly waypointId: number readonly levelId: number readonly slug: string readonly nameEn: string readonly nameZh: string readonly unlocked: boolean } const DEFAULT_COMPLETED_QUEST_IDS = new Set(['a1q1', 'a1q2', 'a1q3']) function createQuestEntry(item: CanonicalQuestItem): QuestEntry { const isCompleted = DEFAULT_COMPLETED_QUEST_IDS.has(item.id) return { ...item, status: isCompleted ? 'completed' : 'active', description: isCompleted ? `${item.completedZh} (奖励: ${item.rewardSummaryZh})` : `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`, } } export const ACT_QUESTS: Readonly> = { 1: CANONICAL_QUESTS_BY_ACT[1]!.map(createQuestEntry), 2: CANONICAL_QUESTS_BY_ACT[2]!.map(createQuestEntry), 3: CANONICAL_QUESTS_BY_ACT[3]!.map(createQuestEntry), 4: CANONICAL_QUESTS_BY_ACT[4]!.map(createQuestEntry), 5: CANONICAL_QUESTS_BY_ACT[5]!.map(createQuestEntry), } /** Flat list of all 27 canonical Diablo II quests across Acts 1..5. */ export const ALL_QUESTS: readonly QuestEntry[] = [ ...ACT_QUESTS[1]!, ...ACT_QUESTS[2]!, ...ACT_QUESTS[3]!, ...ACT_QUESTS[4]!, ...ACT_QUESTS[5]!, ] export const ACT_WAYPOINTS: Readonly> = { 1: [ { act: 1, index: 0, waypointId: 0, levelId: 1, slug: 'rogue-encampment', nameEn: 'Rogue Encampment', nameZh: '罗格营地', unlocked: true }, { act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: true }, { act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: true }, { act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: true }, { act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: true }, { act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: true }, { act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: true }, { act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: true }, { act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: true }, ], 2: [ { act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: true }, { act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: true }, { act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', nameZh: '干燥高地', unlocked: true }, { act: 2, index: 3, waypointId: 12, levelId: 57, slug: 'halls-of-the-dead-level-2', nameEn: 'Halls of the Dead Level 2', nameZh: '死亡之殿第二层', unlocked: true }, { act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: true }, { act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: true }, { act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: true }, { act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: true }, { act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: true }, ], 3: [ { act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: true }, { act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: true }, { act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: true }, { act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: true }, { act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: true }, { act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: true }, { act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: true }, { act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', nameZh: '崔凡克', unlocked: true }, { act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: true }, ], 4: [ { act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: true }, { act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: true }, { act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: true }, ], 5: [ { act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: true }, { act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: true }, { act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: true }, { act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: true }, { act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: true }, { act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: true }, { act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: true }, { act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: true }, { act: 5, index: 8, waypointId: 38, levelId: 129, slug: 'the-worldstone-keep-level-2', nameEn: 'Worldstone Keep Level 2', nameZh: '世界之石要塞第二层', unlocked: true }, ], } /** Authentic D2Client.dll 1.13c waypoint row Y offsets table at VA 0x6fb8e6d8. */ export const WAYPOINT_ROW_OFFSETS_Y: readonly number[] = [60, 96, 132, 168, 205, 241, 277, 313, 349] as const export function findWaypointBySlug(slug: string): WaypointEntry | undefined { for (const list of Object.values(ACT_WAYPOINTS)) { const found = list.find(w => w.slug === slug) if (found) return found } return undefined } export class WorldPanelsHud { selectedActTab = 1 selectedQuestIdx = 0 stashGold = 2_500_000 cubeTransmuteCount = 0 escapeMenuOpen = false lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null npcMenu: { npcName: string x: number y: number dialogLines: readonly string[] talking: boolean descriptor?: TownNpcServiceDescriptor | undefined options?: readonly NpcMenuOption[] | undefined hoveredOptionIdx?: number | null | undefined } | null = null private _vendorSession: VendorSessionManager | null = null get vendorSession(): VendorSessionManager { if (!this._vendorSession) { this._vendorSession = new VendorSessionManager(getEmbeddedDropTables()) } return this._vendorSession } activeVendorDescriptor: TownNpcServiceDescriptor | null = null vendorMode: 'trade' | 'gamble' = 'trade' activeVendorTab: VendorTabId = 'armor' vendorDifficulty: 0 | 1 | 2 = 0 vendorCharLevel = 85 vendorTradeState: VendorTradeState = 'idle' hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null pressedMomentarySlot: 0 | 1 | 2 | 3 | null = null activePlayerInventory: InventoryPanel | null = null vendorStatusMsg: { text: string; color: D2ColorCode; untilMs: number } | null = null hoveredVendorItem: { item: UiInventoryItem; x: number; y: number; col: number; row: number } | null = null vendorTabPlacements: Record = { armor: [], weapons1: [], weapons2: [], misc: [], } gamblePlacements: GridPlacement[] = [] get repairCursorMode(): boolean { return this.vendorTradeState === 'repair' } set repairCursorMode(val: boolean) { this.vendorTradeState = val ? 'repair' : 'idle' } releaseMomentaryVendorButton(): void { this.pressedMomentarySlot = null } isVendorTabPresent(tabId: VendorTabId): boolean { if (this.vendorMode === 'gamble') { return tabId === 'armor' } return this.vendorTabPlacements[tabId].length > 0 } /** * D2Client.dll `0x6fb3f710` (`0x6fb3f7a1–0x6fb3f873`): * If requested tab is empty, cycle forward `(idx + 1) % 4` up to 4 tries to find a non-empty tab. */ selectVendorTab(requestedTab: VendorTabId): void { if (this.vendorMode === 'gamble') { this.activeVendorTab = 'armor' return } const startIdx = VENDOR_TABS.findIndex(t => t.id === requestedTab) if (startIdx === -1) { throw new Error(`Invalid vendor tab: ${requestedTab}`) } for (let step = 0; step < 4; step++) { const idx = (startIdx + step) % 4 const candidate = VENDOR_TABS[idx]! if (this.vendorTabPlacements[candidate.id].length > 0) { this.activeVendorTab = candidate.id return } } this.activeVendorTab = VENDOR_TABS[startIdx]!.id } /** * D2Client.dll `0x6fb4c27a` (mousedown) & `0x6fb3f260` (hover): * Open intervals `relX ∈ (tableX, tableX + 45)`, `relY ∈ (371, 415)`, enabled slots only. */ hitTestVendorButtonSlot(logicalX: number, logicalY: number): 0 | 1 | 2 | 3 | null { if (!this.activeVendorDescriptor) return null const relX = logicalX - VENDOR_PANEL_ORIGIN.x const relY = logicalY - VENDOR_PANEL_ORIGIN.y if (relY <= VENDOR_BUTTON_HIT_Y_MIN || relY >= VENDOR_BUTTON_HIT_Y_MAX) return null const layout = resolveVendorButtonLayout(this.activeVendorDescriptor.hcIdx, this.vendorMode) for (let slot = 0 as 0 | 1 | 2 | 3; slot < 4; slot = (slot + 1) as 0 | 1 | 2 | 3) { const kind = layout[slot]! const spec = VENDOR_BUTTON_SPECS[kind] if (!spec.enabled) continue const tableX = VENDOR_BUTTON_TABLE_X[slot]! if (relX > tableX && relX < tableX + VENDOR_BUTTON_HIT_W) { return slot } } return null } getActiveVendorButtonLayout(): readonly [VendorButtonKind, VendorButtonKind, VendorButtonKind, VendorButtonKind] { if (!this.activeVendorDescriptor) { throw new Error('Cannot resolve vendor button layout without active vendor descriptor') } return resolveVendorButtonLayout(this.activeVendorDescriptor.hcIdx, this.vendorMode) } getVendorButtonFrame( slot: 0 | 1 | 2 | 3, layout?: readonly [VendorButtonKind, VendorButtonKind, VendorButtonKind, VendorButtonKind], ): number { if (!layout && !this.activeVendorDescriptor) { throw new Error('Cannot resolve vendor button frame without active vendor descriptor') } const resolvedLayout = layout ?? resolveVendorButtonLayout(this.activeVendorDescriptor!.hcIdx, this.vendorMode) const kind = resolvedLayout[slot]! const spec = VENDOR_BUTTON_SPECS[kind] if (!spec.enabled) return spec.baseFrame const isPressed = kind === 'buy' || kind === 'sell' || kind === 'repair' ? this.vendorTradeState === kind : this.pressedMomentarySlot === slot return isPressed ? spec.pressedFrame : spec.baseFrame } showVendorStatus(text: string, color: D2ColorCode = 'gold', nowMs = performance.now()): void { this.vendorStatusMsg = { text, color, untilMs: nowMs + 2800 } } buildNpcMenuOptions(descriptor: TownNpcServiceDescriptor): NpcMenuOption[] { const opts: NpcMenuOption[] = [{ id: 'talk', labelZh: '交谈', labelEn: 'Talk' }] if (descriptor.canIdentify) { opts.push({ id: 'identify', labelZh: '鉴定物品', labelEn: 'Identify Items' }) } if (descriptor.canTrade) { opts.push({ id: 'trade', labelZh: descriptor.canRepair ? '交易 / 修理' : '交易', labelEn: descriptor.canRepair ? 'Trade / Repair' : 'Trade', }) } if (descriptor.canGamble) { opts.push({ id: 'gamble', labelZh: '赌博', labelEn: 'Gamble' }) } opts.push({ id: 'cancel', labelZh: '取消', labelEn: 'Cancel' }) return opts } openNpcMenu( descriptor: TownNpcServiceDescriptor, screenX = 400, screenY = 220, ): void { const options = this.buildNpcMenuOptions(descriptor) const clampedX = Math.max(130, Math.min(670, Math.round(screenX))) const clampedY = Math.max(70, Math.min(400, Math.round(screenY))) this.npcMenu = { npcName: descriptor.displayName, x: clampedX, y: clampedY, dialogLines: descriptor.greetingLines, talking: false, descriptor, options, hoveredOptionIdx: null, } } openNpcMenuByName(name: string, screenX = 400, screenY = 220): boolean { const desc = getTownNpcDescriptorByName(name) if (!desc) return false this.openNpcMenu(desc, screenX, screenY) return true } openVendorForNpc( descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string, mode: 'trade' | 'gamble' = 'trade', cLvl = 85, difficulty: 0 | 1 | 2 = 0, ): boolean { const descriptor = typeof descriptorOrName === 'object' ? descriptorOrName : typeof descriptorOrName === 'number' ? getTownNpcDescriptorByVendorId(descriptorOrName) : getTownNpcDescriptorByName(descriptorOrName) if (!descriptor || descriptor.vendorId === null) return false // Fail-fast validation against D2Client.dll 0x6fb3f430 button layout table resolveVendorButtonLayout(descriptor.hcIdx, mode) this.activeVendorDescriptor = descriptor this.vendorMode = mode this.vendorCharLevel = Math.max(1, cLvl) this.vendorDifficulty = difficulty this.vendorTradeState = 'idle' this.hoveredVendorButtonSlot = null this.pressedMomentarySlot = null this.hoveredVendorItem = null const tables = getEmbeddedDropTables() const vendorId = descriptor.vendorId if (mode === 'gamble') { const gambleInv = this.vendorSession.getOrCreateGambleStore(vendorId, this.vendorCharLevel) this.gamblePlacements = gambleInv.getAllItems().map(p => { const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, difficulty, this.vendorCharLevel, true) const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, isGambleMystery: true }, tables) return { item: uiItem, col: p.x, row: p.y } }) this.activeVendorTab = 'armor' return true } const store = this.vendorSession.getOrCreateVendorStore(vendorId, difficulty, this.vendorCharLevel) const tabIds: VendorTabId[] = ['armor', 'weapons1', 'weapons2', 'misc'] for (const tabId of tabIds) { const inv = store.tabs[tabId] this.vendorTabPlacements[tabId] = inv.getAllItems().map(p => { const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, difficulty, this.vendorCharLevel, false) const sellCost = this.vendorSession.getSellPrice(p.item, vendorId, difficulty, this.vendorCharLevel) const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, sellCost }, tables) return { item: uiItem, col: p.x, row: p.y } }) } // D2Client.dll 0x6fb411a0 -> 0x6fb3f710(0): start at tab 0 ('armor') and cycle to first non-empty tab this.selectVendorTab('armor') return true } getActiveVendorPlacements(): GridPlacement[] { if (this.vendorMode === 'gamble') { return this.gamblePlacements } return this.vendorTabPlacements[this.activeVendorTab] ?? [] } getPlayerItemSellPrice(item: UiInventoryItem): number { if (item.sellCost !== undefined) return item.sellCost const vendorId = this.activeVendorDescriptor?.vendorId ?? (0 as VendorNpcId) if (item.rawItem) { return this.vendorSession.getSellPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel) } return Math.max(1, Math.floor(150)) } getPlayerItemRepairPrice(item: UiInventoryItem): number { if (!item.durability || item.durability.current >= item.durability.max) return 0 if (item.ethereal) return 0 const vendorId = this.activeVendorDescriptor?.vendorId ?? (2 as VendorNpcId) if (item.rawItem) { const syncedRaw = { ...item.rawItem, durability: item.durability.current, maxDurability: item.durability.max, } if (!isItemRepairable(syncedRaw)) return 0 return this.vendorSession.getRepairPrice(syncedRaw, vendorId, this.vendorDifficulty, this.vendorCharLevel) } const lost = Math.max(0, item.durability.max - item.durability.current) return Math.max(1, lost * 12) } buyFromActiveVendor( col: number, row: number, playerInventory: InventoryPanel, isShiftClick = false, ): { ok: boolean; reason?: string; boughtCount: number; totalCost: number } { if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { return { ok: false, reason: 'no_vendor', boughtCount: 0, totalCost: 0 } } const vendorId = this.activeVendorDescriptor.vendorId const placements = this.getActiveVendorPlacements() const hitIdx = placements.findIndex( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (hitIdx === -1) { return { ok: false, reason: 'empty_cell', boughtCount: 0, totalCost: 0 } } const hit = placements[hitIdx]! const unitCost = hit.item.buyCost ?? 1 if (playerInventory.gold < unitCost) { this.showVendorStatus('金币不足 (Not enough gold)', 'red') return { ok: false, reason: 'not_enough_gold', boughtCount: 0, totalCost: 0 } } const tables = getEmbeddedDropTables() // Gamble Mode: resolve mystery item into concrete rolled item! if (this.vendorMode === 'gamble') { const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight) if (!freeSlot) { this.showVendorStatus('背包空间不足 (Inventory full)', 'red') return { ok: false, reason: 'inventory_full', boughtCount: 0, totalCost: 0 } } const gambleRes = this.vendorSession.buyGambleItem( vendorId, hit.col, hit.row, playerInventory.gold, this.vendorCharLevel, ) if (!gambleRes.success || !gambleRes.item) { this.showVendorStatus('无法赌博该物品', 'red') return { ok: false, reason: gambleRes.error ?? 'gamble_failed', boughtCount: 0, totalCost: 0 } } playerInventory.gold = gambleRes.remainingGold const sellCost = this.vendorSession.getSellPrice(gambleRes.item, vendorId, this.vendorDifficulty, this.vendorCharLevel) const resolvedUi = itemToUiInventoryItem({ ...(gambleRes.item as any), sellCost }, tables) playerInventory.gridItems.push({ item: resolvedUi, col: freeSlot.col, row: freeSlot.row }) this.gamblePlacements.splice(hitIdx, 1) this.showVendorStatus(`赌博获得: ${resolvedUi.nameZh}`, 'gold') return { ok: true, boughtCount: 1, totalCost: gambleRes.goldSpent } } // Trade Mode: Shift-Click bulk buy for Scrolls/Potions/Keys const isScrollOrPotionOrKey = hit.item.permStore && (hit.item.code === 'tsc' || hit.item.code === 'isc' || hit.item.code === 'key' || hit.item.code === 'yps' || hit.item.code === 'vps' || hit.item.code === 'wms' || hit.item.code.startsWith('hp') || hit.item.code.startsWith('mp')) const maxCopies = isShiftClick && isScrollOrPotionOrKey ? 8 : 1 let boughtCount = 0 let totalCost = 0 for (let i = 0; i < maxCopies; i++) { if (playerInventory.gold < unitCost) break const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight) if (!freeSlot) { if (boughtCount === 0) { this.showVendorStatus('背包空间不足 (Inventory full)', 'red') return { ok: false, reason: 'inventory_full', boughtCount: 0, totalCost: 0 } } break } playerInventory.gold -= unitCost totalCost += unitCost boughtCount++ const rawClone = hit.item.rawItem ? { ...hit.item.rawItem, uniqueId: Math.floor(Math.random() * 0x7fffffff), permStore: false } : undefined const sellCost = rawClone ? this.vendorSession.getSellPrice(rawClone, vendorId, this.vendorDifficulty, this.vendorCharLevel) : Math.max(1, Math.floor(unitCost / 2)) const clonedUiItem: UiInventoryItem = { ...hit.item, id: `item-${hit.item.code}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, ...(rawClone ? { rawItem: rawClone } : {}), sellCost, } delete (clonedUiItem as any).buyCost delete (clonedUiItem as any).permStore playerInventory.gridItems.push({ item: clonedUiItem, col: freeSlot.col, row: freeSlot.row }) if (!hit.item.permStore) { placements.splice(hitIdx, 1) if (this.vendorMode === 'trade' && placements.length === 0) { this.selectVendorTab(this.activeVendorTab) } break } } if (boughtCount > 0) { this.showVendorStatus( `已购买 ${hit.item.nameZh}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} 金币)`, 'gold', ) return { ok: true, boughtCount, totalCost } } return { ok: false, reason: 'purchase_failed', boughtCount: 0, totalCost: 0 } } sellToActiveVendor( item: UiInventoryItem, playerInventory: InventoryPanel, ): { ok: boolean; goldReceived: number } { if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { return { ok: false, goldReceived: 0 } } const vendorId = this.activeVendorDescriptor.vendorId // Quest items cannot be sold if (item.rawItem?.base?.quest && item.rawItem.base.quest > 0) { this.showVendorStatus('任务物品无法出售 (Cannot sell quest item)', 'red') return { ok: false, goldReceived: 0 } } const sellPrice = this.getPlayerItemSellPrice(item) playerInventory.gold = Math.min(PLAYER_GOLD_CAP, playerInventory.gold + sellPrice) // Place into vendor store tab for buyback (unless in gamble mode or item is permStore) if (this.vendorMode === 'trade' && !item.permStore) { const kind = item.rawItem?.base?.kind ?? (item.allowedSlots.includes('weapon1') ? 'weapon' : item.allowedSlots.length > 0 ? 'armor' : 'misc') let targetTab: VendorTabId = kind === 'armor' ? 'armor' : kind === 'weapon' ? 'weapons1' : 'misc' let tabPlacements = this.vendorTabPlacements[targetTab] let freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) if (!freeSlot && targetTab === 'weapons1') { targetTab = 'weapons2' tabPlacements = this.vendorTabPlacements[targetTab] freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) } while (!freeSlot) { const evictIdx = tabPlacements.findIndex(p => !p.item.permStore) if (evictIdx === -1) break tabPlacements.splice(evictIdx, 1) freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) } if (freeSlot) { const buybackPrice = item.rawItem ? this.vendorSession.getBuyPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel, false) : Math.max(sellPrice * 2, 2) const buybackUi: UiInventoryItem = { ...item, buyCost: buybackPrice, sellCost: sellPrice, permStore: false, } tabPlacements.push({ item: buybackUi, col: freeSlot.col, row: freeSlot.row }) if (this.vendorTabPlacements[this.activeVendorTab].length === 0) { this.selectVendorTab(targetTab) } } } this.showVendorStatus(`已出售 ${item.nameZh} (+${sellPrice} 金币)`, 'gold') return { ok: true, goldReceived: sellPrice } } repairSingleUiItem( item: UiInventoryItem, playerInventory: InventoryPanel, ): { ok: boolean; cost: number } { if (!this.activeVendorDescriptor?.canRepair) { return { ok: false, cost: 0 } } if (!item.durability || item.durability.current >= item.durability.max) { this.showVendorStatus('该物品无需修理', 'tan') return { ok: false, cost: 0 } } if (item.ethereal) { this.showVendorStatus('无形物品无法修理 (Cannot repair ethereal item)', 'red') return { ok: false, cost: 0 } } const cost = this.getPlayerItemRepairPrice(item) if (cost <= 0) return { ok: false, cost: 0 } if (playerInventory.gold < cost) { this.showVendorStatus('金币不足,无法修理', 'red') return { ok: false, cost: 0 } } playerInventory.gold -= cost ;(item as { durability: { current: number; max: number } }).durability = { current: item.durability.max, max: item.durability.max, } if (item.rawItem) { item.rawItem.durability = item.durability.max } this.showVendorStatus(`已修理 ${item.nameZh} (-${cost} 金币)`, 'gold') return { ok: true, cost } } private collectRepairablePlayerItems(playerInventory: InventoryPanel): { item: UiInventoryItem; cost: number }[] { const allItems: UiInventoryItem[] = [ ...(Object.values(playerInventory.equipped).filter(Boolean) as UiInventoryItem[]), ...playerInventory.gridItems.map(p => p.item), ] const result: { item: UiInventoryItem; cost: number }[] = [] for (const item of allItems) { if (!item.durability || item.durability.current >= item.durability.max || item.ethereal) continue const cost = this.getPlayerItemRepairPrice(item) if (cost <= 0) continue result.push({ item, cost }) } return result } /** * D2Client.dll `0x6fb3f260` -> D2Common `0x6fabc99a` (`#10095`): * Computes total repair cost across all damaged repairable equipment and inventory items. */ getRepairAllCost(playerInventory?: InventoryPanel | null): number { if (!this.activeVendorDescriptor?.canRepair) return 0 const inv = playerInventory ?? this.activePlayerInventory if (!inv) return 0 let total = 0 for (const entry of this.collectRepairablePlayerItems(inv)) { total += entry.cost } return total } repairAllUiItems(playerInventory: InventoryPanel): { repairedCount: number; totalCost: number } { if (!this.activeVendorDescriptor?.canRepair) { return { repairedCount: 0, totalCost: 0 } } let repairedCount = 0 let totalCost = 0 for (const { item, cost } of this.collectRepairablePlayerItems(playerInventory)) { if (!item.durability) continue if (playerInventory.gold < cost) break const maxDur = item.durability.max playerInventory.gold -= cost totalCost += cost repairedCount++ ;(item as { durability: { current: number; max: number } }).durability = { current: maxDur, max: maxDur, } if (item.rawItem) { item.rawItem.durability = maxDur } } if (repairedCount > 0) { this.showVendorStatus(`已修理 ${repairedCount} 件装备 (-${totalCost} 金币)`, 'gold') } else { this.showVendorStatus('所有装备完好,无需修理', 'tan') } return { repairedCount, totalCost } } identifyAllUiItems(playerInventory: InventoryPanel): number { let count = 0 for (const placed of playerInventory.gridItems) { if (isUiItemUnidentified(placed.item)) { identifyUiItem(placed.item) count++ } } for (const placed of this.stashItems) { if (isUiItemUnidentified(placed.item)) { identifyUiItem(placed.item) count++ } } for (const placed of this.cubeItems) { if (isUiItemUnidentified(placed.item)) { identifyUiItem(placed.item) count++ } } return count } /** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */ private questStatuses = new Map() private cachedQuestsAtlasImg: HTMLImageElement | null = null getQuestStatus(quest: QuestEntry): 'completed' | 'active' | 'locked' { return this.questStatuses.get(quest.id) ?? quest.status } setQuestStatus(questId: string, status: 'completed' | 'active' | 'locked'): void { this.questStatuses.set(questId, status) } private questTblLookup?: QuestTblLookup | undefined private questStringTableChain?: { get(key: string): string | undefined } | null | undefined setQuestTblLookup(lookup: QuestTblLookup | null | undefined): void { this.questTblLookup = lookup ?? undefined } setQuestStringTableChain(chain: { get(key: string): string | undefined } | null | undefined): void { this.questStringTableChain = chain ?? undefined } lookupQuestString(key: string): string | undefined { if (this.questTblLookup) { const val = this.questTblLookup(key) if (val !== undefined && val.trim().length > 0) return val.trim() } if (this.questStringTableChain) { const val = this.questStringTableChain.get(key) if (val !== undefined && val.trim().length > 0) return val.trim() } return undefined } getQuestDisplayText(quest: QuestEntry): QuestDisplayText { const status = this.getQuestStatus(quest) const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]' const title = this.lookupQuestString(quest.titleKey) ?? quest.titleZh ?? quest.titleEn let body = '' const rewardText = quest.rewardSummaryZh || quest.rewardSummaryEn if (status === 'completed') { const tblCompleted = this.lookupQuestString(quest.completedKey) body = tblCompleted || quest.completedZh || quest.completedEn || quest.description } else if (status === 'active') { const tblObjective = this.lookupQuestString(quest.objectiveKey) body = tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description } else { body = '此任务尚未开启。与城镇居民对话或探索荒野以开启任务。' } return { title, titleKey: quest.titleKey, body, status, statusTag, rewardText, } } private getQuestsAtlasImage(): HTMLImageElement | null { if (this.cachedQuestsAtlasImg) return this.cachedQuestsAtlasImg if (typeof Image !== 'undefined') { const img = new Image() img.src = BAKED_UI_MANIFEST.images.questsAtlas ?? '/ui/quests-atlas.png' this.cachedQuestsAtlasImg = img return img } return null } /** Area entry banner state (`Entering: `). */ areaBanner: { titleZh: string; titleEn: string; untilMs: number } | null = null showAreaBanner(titleZh: string, titleEn: string, nowMs = performance.now()): void { this.areaBanner = { titleZh, titleEn, untilMs: nowMs + 3600 } } stashItems: GridPlacement[] = [] hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null cubeItems: GridPlacement[] = [] hoveredCubeItem: { item: UiInventoryItem; x: number; y: number } | null = null hoveredWaypointIdx: number | null = null /** * The waypoint that opened this menu or where the player currently stands. * Ground Truth (D2Client.dll 1.13c): * - 0x6fbacdd0: Current level ID * - 0x6fbacdd4: Current waypoint ID */ currentWaypointId: number | null = null currentLevelId: number | null = null /** Runtime waypoint unlocked overrides: waypointId -> boolean */ private waypointUnlockedOverrides = new Map() isWaypointUnlocked(wpOrId: WaypointEntry | number): boolean { const wpId = typeof wpOrId === 'number' ? wpOrId : wpOrId.waypointId const override = this.waypointUnlockedOverrides.get(wpId) if (override !== undefined) return override if (typeof wpOrId === 'object' && wpOrId !== null) return wpOrId.unlocked for (const list of Object.values(ACT_WAYPOINTS)) { const found = list.find(w => w.waypointId === wpId) if (found) return found.unlocked } return false } setWaypointUnlocked(waypointId: number, unlocked: boolean): void { this.waypointUnlockedOverrides.set(waypointId, unlocked) } setUnlockedWaypoints(unlockedWaypointIds: Iterable): void { this.waypointUnlockedOverrides.clear() const set = new Set(unlockedWaypointIds) for (const list of Object.values(ACT_WAYPOINTS)) { for (const wp of list) { this.waypointUnlockedOverrides.set(wp.waypointId, set.has(wp.waypointId)) } } } clearWaypointUnlockedOverrides(): void { this.waypointUnlockedOverrides.clear() } setCurrentWaypoint(waypointId: number | null, levelId?: number | null): void { this.currentWaypointId = waypointId if (levelId !== undefined) { this.currentLevelId = levelId } } setCurrentLevel(levelId: number | null): void { this.currentLevelId = levelId } isCurrentWaypoint(wp: WaypointEntry): boolean { if (this.currentWaypointId !== null && this.currentWaypointId !== undefined) { if (wp.waypointId === this.currentWaypointId) return true } if (this.currentLevelId !== null && this.currentLevelId !== undefined) { if (wp.levelId === this.currentLevelId) return true } return false } /** * Check whether an item of (w, h) can be placed at (col, row) in the 6x8 stash grid. * Throws RangeError if placed out of bounds. Returns all overlapping GridPlacements. */ getStashOverlaps(col: number, row: number, w: number, h: number): GridPlacement[] { if (col < 0 || row < 0 || col + w > STASH_GRID_ORIGIN.cols || row + h > STASH_GRID_ORIGIN.rows) { throw new RangeError('Out of 6x8 stash grid bounds') } return this.stashItems.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 }) } canPlaceInStash(col: number, row: number, w: number, h: number): boolean { if (col < 0 || row < 0 || col + w > STASH_GRID_ORIGIN.cols || row + h > STASH_GRID_ORIGIN.rows) { return false } return this.getStashOverlaps(col, row, w, h).length === 0 } autoPlaceInStash(item: UiInventoryItem): boolean { for (let col = 0; col <= STASH_GRID_ORIGIN.cols - item.invWidth; col++) { for (let row = 0; row <= STASH_GRID_ORIGIN.rows - item.invHeight; row++) { if (this.canPlaceInStash(col, row, item.invWidth, item.invHeight)) { this.stashItems.push({ item, col, row }) return true } } } return false } clickStashCell( col: number, row: number, cursorItem: UiInventoryItem | null, ): { handled: boolean; newCursorItem: UiInventoryItem | null } { if (!cursorItem) { const hit = this.stashItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (!hit) return { handled: false, newCursorItem: null } this.stashItems = this.stashItems.filter(p => p !== hit) return { handled: true, newCursorItem: hit.item } } const directHit = this.stashItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (directHit && isScrollForTome(cursorItem.code, directHit.item.code)) { const curQty = getTomeQuantity(directHit.item) const maxQty = directHit.item.maxQuantity ?? TOME_MAX_QUANTITY if (curQty < maxQty) { const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1 const addQty = Math.min(maxQty - curQty, scrollQty) setTomeQuantity(directHit.item, curQty + addQty) if (scrollQty > addQty) { setTomeQuantity(cursorItem, scrollQty - addQty) return { handled: true, newCursorItem: cursorItem } } return { handled: true, newCursorItem: null } } } if ( col < 0 || row < 0 || col + cursorItem.invWidth > STASH_GRID_ORIGIN.cols || row + cursorItem.invHeight > STASH_GRID_ORIGIN.rows ) { return { handled: false, newCursorItem: cursorItem } } const overlaps = this.getStashOverlaps(col, row, cursorItem.invWidth, cursorItem.invHeight) if (overlaps.length === 0) { this.stashItems.push({ item: cursorItem, col, row }) return { handled: true, newCursorItem: null } } if (overlaps.length === 1) { const single = overlaps[0]! if (isScrollForTome(cursorItem.code, single.item.code)) { const curQty = getTomeQuantity(single.item) const maxQty = single.item.maxQuantity ?? TOME_MAX_QUANTITY if (curQty < maxQty) { const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1 const addQty = Math.min(maxQty - curQty, scrollQty) setTomeQuantity(single.item, curQty + addQty) if (scrollQty > addQty) { setTomeQuantity(cursorItem, scrollQty - addQty) return { handled: true, newCursorItem: cursorItem } } return { handled: true, newCursorItem: null } } } this.stashItems = this.stashItems.filter(p => p !== single) this.stashItems.push({ item: cursorItem, col, row }) return { handled: true, newCursorItem: single.item } } return { handled: false, newCursorItem: cursorItem } } quickTransferToStash(item: UiInventoryItem, fromInventory: InventoryPanel): boolean { const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id) if (hitIdx === -1) return false if (!this.autoPlaceInStash(item)) return false fromInventory.gridItems.splice(hitIdx, 1) return true } quickTransferFromStash(item: UiInventoryItem, toInventory: InventoryPanel): boolean { const hitIdx = this.stashItems.findIndex(p => p.item.id === item.id) if (hitIdx === -1) return false if (!toInventory.autoPlaceInGrid(item)) return false this.stashItems.splice(hitIdx, 1) return true } /** * Check whether an item of (w, h) can be placed at (col, row) in the 3x4 Horadric Cube grid. */ getCubeOverlaps(col: number, row: number, w: number, h: number): GridPlacement[] { if (col < 0 || row < 0 || col + w > CUBE_GRID_ORIGIN.cols || row + h > CUBE_GRID_ORIGIN.rows) { throw new RangeError('Out of 3x4 Horadric Cube grid bounds') } return this.cubeItems.filter(p => { const ox = col < p.col + p.item.invWidth && col + w > p.col const oy = row < p.row + p.item.invHeight && row + h > p.row return ox && oy }) } canPlaceInCube(col: number, row: number, w: number, h: number): boolean { if (col < 0 || row < 0 || col + w > CUBE_GRID_ORIGIN.cols || row + h > CUBE_GRID_ORIGIN.rows) { return false } return this.getCubeOverlaps(col, row, w, h).length === 0 } autoPlaceInCube(item: UiInventoryItem): boolean { const code = (item.code ?? '').trim().toLowerCase() if (code === 'box' || code === 'cube') return false for (let col = 0; col <= CUBE_GRID_ORIGIN.cols - item.invWidth; col++) { for (let row = 0; row <= CUBE_GRID_ORIGIN.rows - item.invHeight; row++) { if (this.canPlaceInCube(col, row, item.invWidth, item.invHeight)) { this.cubeItems.push({ item, col, row }) return true } } } return false } clickCubeCell( col: number, row: number, cursorItem: UiInventoryItem | null, ): { handled: boolean; newCursorItem: UiInventoryItem | null } { if (!cursorItem) { const hit = this.cubeItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (!hit) return { handled: false, newCursorItem: null } this.cubeItems = this.cubeItems.filter(p => p !== hit) return { handled: true, newCursorItem: hit.item } } const code = (cursorItem.code ?? '').trim().toLowerCase() if (code === 'box' || code === 'cube') { return { handled: false, newCursorItem: cursorItem } } const directHit = this.cubeItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (directHit && isScrollForTome(cursorItem.code, directHit.item.code)) { const curQty = getTomeQuantity(directHit.item) const maxQty = directHit.item.maxQuantity ?? TOME_MAX_QUANTITY if (curQty < maxQty) { const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1 const addQty = Math.min(maxQty - curQty, scrollQty) setTomeQuantity(directHit.item, curQty + addQty) if (scrollQty > addQty) { setTomeQuantity(cursorItem, scrollQty - addQty) return { handled: true, newCursorItem: cursorItem } } return { handled: true, newCursorItem: null } } } const targetCol = Math.min(CUBE_GRID_ORIGIN.cols - cursorItem.invWidth, Math.max(0, col)) const targetRow = Math.min(CUBE_GRID_ORIGIN.rows - cursorItem.invHeight, Math.max(0, row)) if ( targetCol < 0 || targetRow < 0 || targetCol + cursorItem.invWidth > CUBE_GRID_ORIGIN.cols || targetRow + cursorItem.invHeight > CUBE_GRID_ORIGIN.rows ) { return { handled: false, newCursorItem: cursorItem } } const overlaps = this.getCubeOverlaps(targetCol, targetRow, cursorItem.invWidth, cursorItem.invHeight) if (overlaps.length === 0) { this.cubeItems.push({ item: cursorItem, col: targetCol, row: targetRow }) return { handled: true, newCursorItem: null } } if (overlaps.length === 1) { const single = overlaps[0]! if (isScrollForTome(cursorItem.code, single.item.code)) { const curQty = getTomeQuantity(single.item) const maxQty = single.item.maxQuantity ?? TOME_MAX_QUANTITY if (curQty < maxQty) { const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1 const addQty = Math.min(maxQty - curQty, scrollQty) setTomeQuantity(single.item, curQty + addQty) if (scrollQty > addQty) { setTomeQuantity(cursorItem, scrollQty - addQty) return { handled: true, newCursorItem: cursorItem } } return { handled: true, newCursorItem: null } } } this.cubeItems = this.cubeItems.filter(p => p !== single) this.cubeItems.push({ item: cursorItem, col: targetCol, row: targetRow }) return { handled: true, newCursorItem: single.item } } return { handled: false, newCursorItem: cursorItem } } quickTransferToCube(item: UiInventoryItem, fromInventory: InventoryPanel): boolean { const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id) if (hitIdx === -1) return false if (!this.autoPlaceInCube(item)) return false fromInventory.gridItems.splice(hitIdx, 1) return true } quickTransferFromCube(item: UiInventoryItem, toInventory: InventoryPanel): boolean { const hitIdx = this.cubeItems.findIndex(p => p.item.id === item.id) if (hitIdx === -1) return false if (!toInventory.autoPlaceInGrid(item)) return false this.cubeItems.splice(hitIdx, 1) return true } depositGold(amount: number, playerInventory: { gold: number }): number { if (amount <= 0) return 0 const available = Math.max(0, Math.min(amount, playerInventory.gold, STASH_GOLD_CAP - this.stashGold)) if (available <= 0) return 0 playerInventory.gold -= available this.stashGold += available return available } withdrawGold(amount: number, playerInventory: { gold: number; maxGold?: number }): number { if (amount <= 0) return 0 const maxInvGold = playerInventory.maxGold ?? PLAYER_GOLD_CAP const available = Math.max(0, Math.min(amount, this.stashGold, maxInvGold - playerInventory.gold)) if (available <= 0) return 0 this.stashGold -= available playerInventory.gold += available return available } handleMouseMove(logicalX: number, logicalY: number): void { this.hoveredStashItem = null this.hoveredCubeItem = null this.hoveredVendorItem = null this.hoveredVendorButtonSlot = this.activeVendorDescriptor ? this.hitTestVendorButtonSlot(logicalX, logicalY) : null this.hoveredWaypointIdx = null const ox = 80 const oy = 60 if (logicalX >= ox + 17 && logicalX <= ox + 298) { const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]! for (let i = 0; i < wps.length; i++) { const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36) const wy = oy + rowY if (logicalY >= wy && logicalY <= wy + 31) { this.hoveredWaypointIdx = i break } } } if ( logicalX >= STASH_GRID_ORIGIN.x && logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx && logicalY >= STASH_GRID_ORIGIN.y && logicalY < STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx ) { const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx) const hit = this.stashItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (hit) { this.hoveredStashItem = { item: hit.item, x: STASH_GRID_ORIGIN.x + (hit.col + hit.item.invWidth / 2) * STASH_GRID_ORIGIN.cellPx, y: STASH_GRID_ORIGIN.y + hit.row * STASH_GRID_ORIGIN.cellPx, } } } if ( logicalX >= CUBE_GRID_ORIGIN.x && logicalX < CUBE_GRID_ORIGIN.x + CUBE_GRID_ORIGIN.cols * CUBE_GRID_ORIGIN.cellPx && logicalY >= CUBE_GRID_ORIGIN.y && logicalY < CUBE_GRID_ORIGIN.y + CUBE_GRID_ORIGIN.rows * CUBE_GRID_ORIGIN.cellPx ) { const col = Math.floor((logicalX - CUBE_GRID_ORIGIN.x) / CUBE_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - CUBE_GRID_ORIGIN.y) / CUBE_GRID_ORIGIN.cellPx) const hit = this.cubeItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (hit) { this.hoveredCubeItem = { item: hit.item, x: CUBE_GRID_ORIGIN.x + (hit.col + hit.item.invWidth / 2) * CUBE_GRID_ORIGIN.cellPx, y: CUBE_GRID_ORIGIN.y + hit.row * CUBE_GRID_ORIGIN.cellPx, } } } if ( logicalX >= VENDOR_GRID_ORIGIN.x && logicalX < VENDOR_GRID_ORIGIN.x + VENDOR_GRID_ORIGIN.cols * VENDOR_GRID_ORIGIN.cellPx && logicalY >= VENDOR_GRID_ORIGIN.y && logicalY < VENDOR_GRID_ORIGIN.y + VENDOR_GRID_ORIGIN.rows * VENDOR_GRID_ORIGIN.cellPx ) { const col = Math.floor((logicalX - VENDOR_GRID_ORIGIN.x) / VENDOR_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx) const placements = this.getActiveVendorPlacements() const hit = placements.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (hit) { this.hoveredVendorItem = { item: hit.item, x: VENDOR_GRID_ORIGIN.x + (hit.col + hit.item.invWidth / 2) * VENDOR_GRID_ORIGIN.cellPx, y: VENDOR_GRID_ORIGIN.y + hit.row * VENDOR_GRID_ORIGIN.cellPx, col: hit.col, row: hit.row, } } } } handleNpcMenuMove(logicalX: number, logicalY: number): void { if (!this.npcMenu || this.npcMenu.talking) return const opts = this.npcMenu.options ?? [] const boxW = 216 const rowH = 26 const headerH = 32 const boxH = headerH + opts.length * rowH + 10 const bx = Math.round(this.npcMenu.x - boxW / 2) const by = Math.round(this.npcMenu.y - boxH / 2) this.npcMenu.hoveredOptionIdx = null if (logicalX >= bx + 8 && logicalX <= bx + boxW - 8) { for (let i = 0; i < opts.length; i++) { const ry = by + headerH + i * rowH if (logicalY >= ry && logicalY < ry + rowH) { this.npcMenu.hoveredOptionIdx = i break } } } } handleNpcMenuClick( logicalX: number, logicalY: number, callbacks: { onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void }, ): boolean { if (!this.npcMenu) return false if (this.npcMenu.talking) { this.npcMenu.talking = false return true } const opts = this.npcMenu.options ?? [] const boxW = 216 const rowH = 26 const headerH = 32 const boxH = headerH + opts.length * rowH + 10 const bx = Math.round(this.npcMenu.x - boxW / 2) const by = Math.round(this.npcMenu.y - boxH / 2) if (logicalX < bx || logicalX > bx + boxW || logicalY < by || logicalY > by + boxH) { this.npcMenu = null return true } for (let i = 0; i < opts.length; i++) { const ry = by + headerH + i * rowH if (logicalY >= ry && logicalY < ry + rowH) { const opt = opts[i]! const desc = this.npcMenu.descriptor if (opt.id === 'talk') { this.npcMenu.talking = true } else if (opt.id === 'trade' && desc) { this.npcMenu = null callbacks.onOpenVendor(desc, 'trade') } else if (opt.id === 'gamble' && desc) { this.npcMenu = null callbacks.onOpenVendor(desc, 'gamble') } else if (opt.id === 'identify' && desc) { this.npcMenu = null callbacks.onIdentifyAll(desc) } else { this.npcMenu = null } return true } } return true } handleLeftDockClick( kind: Exclude, logicalX: number, logicalY: number, callbacks: { onClose: () => void onWaypointTeleport: (act: number, slug: string, levelId: number) => void onOpenCube?: () => void onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void inventory?: InventoryPanel isShiftClick?: boolean isRightClick?: boolean }, ): boolean { if (logicalX > 400) return false const ox = 80 const oy = 60 // Generic Close button (`x = ox + 272, y = oy + 388` or `ox + 18, oy + 385`) for non-vendor panels only. // Vendor panels manage their 4 button slots via D2Client.dll 0x6fb3f430 / 0x6fb4c27a (where slot 3 is Repair All on blacksmiths). if ( kind !== 'vendor' && logicalY >= oy + 380 && logicalY <= oy + 422 && (logicalX >= ox + 260 || logicalX <= ox + 58) ) { this.vendorTradeState = 'idle' this.pressedMomentarySlot = null callbacks.onClose() return true } // Act Tabs (`Act I..V` at top of Quest / Waypoint panels: `oy + 8 .. oy + 40`) if ((kind === 'quest' || kind === 'waypoint') && logicalY >= oy + 6 && logicalY <= oy + 42) { const tabIdx = Math.min(5, Math.max(1, Math.floor((logicalX - (ox + 8)) / 61) + 1)) this.selectedActTab = tabIdx if (kind === 'quest') { const actQuests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]! if (this.selectedQuestIdx >= actQuests.length) { this.selectedQuestIdx = 0 } } return true } if (kind === 'quest') { // 6 Quest Sockets (`2 rows × 3 cols`) const actQuests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]! for (let i = 0; i < actQuests.length; i++) { const col = i % 3 const row = Math.floor(i / 3) const sx = ox + 28 + col * 92 const sy = oy + 64 + row * 100 if (logicalX >= sx && logicalX <= sx + 80 && logicalY >= sy && logicalY <= sy + 92) { this.selectedQuestIdx = i return true } } } else if (kind === 'waypoint') { const list = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]! for (let i = 0; i < list.length; i++) { const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36) const wy = oy + rowY if (logicalX >= ox + 17 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) { const wp = list[i]! if (!this.isWaypointUnlocked(wp)) return true if (this.isCurrentWaypoint(wp)) return true callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId) callbacks.onClose() return true } } } else if (kind === 'cube') { // 1. Transmute button at bottom center of Horadric Cube (`ox + 132, oy + 336, 56x36`) if (logicalX >= ox + 120 && logicalX <= ox + 200 && logicalY >= oy + 330 && logicalY <= oy + 380) { if (!callbacks.isRightClick) { this.cubeTransmuteCount += 1 } return true } // 2. Horadric Cube 3x4 Grid (`CUBE_GRID_ORIGIN`) if ( logicalX >= CUBE_GRID_ORIGIN.x && logicalX < CUBE_GRID_ORIGIN.x + CUBE_GRID_ORIGIN.cols * CUBE_GRID_ORIGIN.cellPx && logicalY >= CUBE_GRID_ORIGIN.y && logicalY < CUBE_GRID_ORIGIN.y + CUBE_GRID_ORIGIN.rows * CUBE_GRID_ORIGIN.cellPx ) { const col = Math.floor((logicalX - CUBE_GRID_ORIGIN.x) / CUBE_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - CUBE_GRID_ORIGIN.y) / CUBE_GRID_ORIGIN.cellPx) const hit = this.cubeItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (callbacks.inventory?.isIdentifyMode()) { if (callbacks.isRightClick) { callbacks.inventory.cancelIdentifyMode() } else if (hit && isUiItemUnidentified(hit.item)) { callbacks.inventory.applyIdentifyToItem(hit.item, { stashItems: this.stashItems, cubeItems: this.cubeItems, }) } else { callbacks.inventory.cancelIdentifyMode() } return true } if (callbacks.isRightClick && callbacks.inventory) { if (hit && isUsableRightClickItem(hit.item.code)) { callbacks.inventory.usePlacedItemByRightClick(hit, 'cube', this.cubeItems, { onOpenCube: () => callbacks.onOpenCube?.(), onCastTownPortal: (source, item) => callbacks.onCastTownPortal?.(source, item), }) return true } if (hit) { this.quickTransferFromCube(hit.item, callbacks.inventory) } return true } if (callbacks.isShiftClick && callbacks.inventory) { if (hit) { this.quickTransferFromCube(hit.item, callbacks.inventory) } return true } if (callbacks.inventory) { const result = this.clickCubeCell(col, row, callbacks.inventory.cursorItem) if (result.handled) { callbacks.inventory.cursorItem = result.newCursorItem return true } } return true } } else if (kind === 'stash') { // 1. Gold deposit button if ( logicalX >= STASH_DEPOSIT_BTN_BOUNDS.x && logicalX <= STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w && logicalY >= STASH_DEPOSIT_BTN_BOUNDS.y && logicalY <= STASH_DEPOSIT_BTN_BOUNDS.y + STASH_DEPOSIT_BTN_BOUNDS.h ) { if (!callbacks.isRightClick && callbacks.inventory) { const depositAmt = Math.min(50_000, callbacks.inventory.gold, STASH_GOLD_CAP - this.stashGold) if (depositAmt > 0) { this.depositGold(depositAmt, callbacks.inventory) } } return true } // 2. Gold withdraw button if ( logicalX >= STASH_WITHDRAW_BTN_BOUNDS.x && logicalX <= STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w && logicalY >= STASH_WITHDRAW_BTN_BOUNDS.y && logicalY <= STASH_WITHDRAW_BTN_BOUNDS.y + STASH_WITHDRAW_BTN_BOUNDS.h ) { if (!callbacks.isRightClick && callbacks.inventory) { const withdrawAmt = Math.min(50_000, this.stashGold, PLAYER_GOLD_CAP - callbacks.inventory.gold) if (withdrawAmt > 0) { this.withdrawGold(withdrawAmt, callbacks.inventory) } } return true } // 3. Stash 6x8 Grid if ( logicalX >= STASH_GRID_ORIGIN.x && logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx && logicalY >= STASH_GRID_ORIGIN.y && logicalY < STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx ) { const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx) const hit = this.stashItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) if (callbacks.inventory?.isIdentifyMode()) { if (callbacks.isRightClick) { callbacks.inventory.cancelIdentifyMode() } else if (hit && isUiItemUnidentified(hit.item)) { callbacks.inventory.applyIdentifyToItem(hit.item, { stashItems: this.stashItems, cubeItems: this.cubeItems, }) } else { callbacks.inventory.cancelIdentifyMode() } return true } if (callbacks.isRightClick && callbacks.inventory) { if (hit && isUsableRightClickItem(hit.item.code)) { callbacks.inventory.usePlacedItemByRightClick(hit, 'stash', this.stashItems, { onOpenCube: () => callbacks.onOpenCube?.(), onCastTownPortal: (source, item) => callbacks.onCastTownPortal?.(source, item), }) return true } if (hit) { this.quickTransferFromStash(hit.item, callbacks.inventory) } return true } if (callbacks.isShiftClick && callbacks.inventory) { if (hit) { this.quickTransferFromStash(hit.item, callbacks.inventory) } return true } if (callbacks.inventory) { const result = this.clickStashCell(col, row, callbacks.inventory.cursorItem) if (result.handled) { callbacks.inventory.cursorItem = result.newCursorItem return true } } return true } } else if (kind === 'vendor') { if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { throw new Error('Cannot handle vendor click without an active vendor descriptor') } const relX = logicalX - ox const relY = logicalY - oy // 1. D2Client.dll `0x6fb4c0e5–0x6fb4c233`: Tab click (`relY ∈ [1, 31]`, 80px bands `<=80, <=160, <=240, <=320`) if (this.vendorMode === 'trade' && relY >= 1 && relY <= 31 && relX >= 0 && relX <= 320) { const tabIdx = relX <= 80 ? 0 : relX <= 160 ? 1 : relX <= 240 ? 2 : 3 const chosen = VENDOR_TABS[tabIdx]! if (this.isVendorTabPresent(chosen.id) && chosen.id !== this.activeVendorTab) { this.activeVendorTab = chosen.id this.hoveredVendorItem = null } return true } // 2. D2Client.dll `0x6fb4c27a–0x6fb4c3ee`: Button mousedown hit-test & mutual-exclusion state machine const hitSlot = this.hitTestVendorButtonSlot(logicalX, logicalY) if (hitSlot !== null) { const layout = resolveVendorButtonLayout(this.activeVendorDescriptor.hcIdx, this.vendorMode) const btnKind = layout[hitSlot]! if (btnKind === 'buy' || btnKind === 'sell' || btnKind === 'repair') { this.pressedMomentarySlot = null this.vendorTradeState = this.vendorTradeState === btnKind ? 'idle' : btnKind } else if (btnKind === 'repairAll') { this.vendorTradeState = 'idle' this.pressedMomentarySlot = hitSlot const inv = callbacks.inventory ?? this.activePlayerInventory if (inv) { this.repairAllUiItems(inv) } } else if (btnKind === 'close') { this.vendorTradeState = 'idle' this.pressedMomentarySlot = hitSlot callbacks.onClose() } return true } if (relY > VENDOR_BUTTON_HIT_Y_MIN && relY < VENDOR_BUTTON_HIT_Y_MAX && relX > VENDOR_BUTTON_TABLE_X[0] && relX < VENDOR_BUTTON_TABLE_X[3] + VENDOR_BUTTON_HIT_W) { // Clicked disabled blank slot (`0x6fb3d300` no-op) return true } // 3. Vendor 10x10 Grid (`VENDOR_GRID_ORIGIN`) if ( logicalX >= VENDOR_GRID_ORIGIN.x && logicalX < VENDOR_GRID_ORIGIN.x + VENDOR_GRID_ORIGIN.cols * VENDOR_GRID_ORIGIN.cellPx && logicalY >= VENDOR_GRID_ORIGIN.y && logicalY < VENDOR_GRID_ORIGIN.y + VENDOR_GRID_ORIGIN.rows * VENDOR_GRID_ORIGIN.cellPx ) { const col = Math.floor((logicalX - VENDOR_GRID_ORIGIN.x) / VENDOR_GRID_ORIGIN.cellPx) const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx) const inv = callbacks.inventory ?? this.activePlayerInventory if (inv) { // If player is holding an item on cursor and clicks inside Vendor grid -> Sell it! if (inv.cursorItem) { const held = inv.cursorItem const res = this.sellToActiveVendor(held, inv) if (res.ok) { inv.cursorItem = null } return true } // Left-click buy works in 'idle' or 'buy' state; do not accidentally buy when in 'sell' or 'repair' state if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') { this.buyFromActiveVendor(col, row, inv, Boolean(callbacks.isShiftClick)) this.handleMouseMove(logicalX, logicalY) } } return true } } return true } drawLeftDockPanel( ctx: CanvasRenderingContext2D, kind: Exclude, assets: { borderLeftImg: HTMLImageElement | null questBgImg: HTMLImageElement | null waypointBgImg: HTMLImageElement | null stashBgImg: HTMLImageElement | null cubeBgImg: HTMLImageElement | null vendorBgImg: HTMLImageElement | null vendorTabsImg?: HTMLImageElement | null buySellBtnImg: HTMLImageElement | null questsAtlasImg?: HTMLImageElement | null itemsAtlasImg?: HTMLImageElement | null waypointTabsImg?: HTMLImageElement | null waypointIconsImg?: HTMLImageElement | null questTabsImg?: HTMLImageElement | null inventory?: InventoryPanel | null }, font: D2FontRenderer, ): void { // 1. Left 800x600 Gothic Stone Border (`800BorderFrame.dc6` frames 0..4) if (assets.borderLeftImg) { ctx.drawImage(assets.borderLeftImg, 0, 0) } const ox = 80 const oy = 60 if (kind === 'quest') { if (assets.questBgImg) ctx.drawImage(assets.questBgImg, ox, oy) this.drawActTabs(ctx, ox, oy, font, assets.questTabsImg) const quests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]! if (this.selectedQuestIdx >= quests.length) { this.selectedQuestIdx = 0 } const atlas = assets.questsAtlasImg ?? this.getQuestsAtlasImage() for (let i = 0; i < 6; i++) { // In Act 4, sockets 3, 4, 5 (indices 3, 4, 5) are unassigned if (i >= quests.length) { continue } const q = quests[i]! const col = i % 3 const row = Math.floor(i / 3) const sx = ox + 28 + col * 92 const sy = oy + 64 + row * 100 const selected = this.selectedQuestIdx === i const status = this.getQuestStatus(q) const rectKey = status === 'completed' ? `questdone_${q.id}` : q.id const rect = BAKED_UI_MANIFEST.questRects[rectKey] const ix = sx + 4 const iy = sy + 1 if (atlas && rect) { ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, ix, iy, rect.w, rect.h) } else { ctx.fillStyle = status === 'completed' ? 'rgba(42, 36, 22, 0.85)' : 'rgba(18, 16, 14, 0.88)' ctx.fillRect(sx, sy, 80, 88) font.drawText(ctx, `Q${i + 1}`, sx + 40, sy + 32, { font: 'font16', color: status === 'completed' ? 'gold' : 'white', align: 'center', }) font.drawText(ctx, q.titleZh, sx + 40, sy + 56, { font: 'font8', color: status === 'completed' ? 'gold' : 'tan', align: 'center', }) font.drawText(ctx, status === 'completed' ? '已完成' : '进行中', sx + 40, sy + 76, { font: 'font8', color: status === 'completed' ? 'green' : 'blue', align: 'center', }) } ctx.strokeStyle = selected ? '#e8c26b' : status === 'completed' ? '#6c5838' : '#3a3224' ctx.lineWidth = selected ? 2 : 1 ctx.strokeRect(sx + 0.5, sy + 0.5, 79, 87) } const activeQ = quests[this.selectedQuestIdx] ?? quests[0]! if (activeQ) { const qInfo = this.getQuestDisplayText(activeQ) ctx.fillStyle = 'rgba(8, 7, 6, 0.92)' ctx.fillRect(ox + 18, oy + 264, 284, 116) ctx.strokeStyle = '#6c5838' ctx.strokeRect(ox + 18.5, oy + 264.5, 283, 115) font.drawText(ctx, `${qInfo.title} (${activeQ.titleEn}) ${qInfo.statusTag}`, ox + 160, oy + 282, { font: 'fontexocet10', color: qInfo.status === 'completed' ? 'gold' : 'white', align: 'center', }) const rewardSuffix = qInfo.status === 'completed' ? (qInfo.rewardText ? `\n[奖励: ${qInfo.rewardText}]` : '') : (qInfo.rewardText ? `\n[目标: ${qInfo.rewardText}]` : '') this.drawWrappedText(ctx, font, qInfo.body + rewardSuffix, ox + 26, oy + 300, 268, 14, 5) } } else if (kind === 'waypoint') { if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy) this.drawActTabs(ctx, ox, oy, font, assets.waypointTabsImg) const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]! for (let i = 0; i < wps.length; i++) { const wp = wps[i]! const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36) const wy = oy + rowY const isHovered = this.hoveredWaypointIdx === i const isUnlocked = this.isWaypointUnlocked(wp) const isCurrent = isUnlocked && this.isCurrentWaypoint(wp) ctx.fillStyle = isHovered ? 'rgba(42, 34, 22, 0.88)' : 'rgba(18, 16, 13, 0.85)' ctx.fillRect(ox + 17, wy, 281, 31) ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32' ctx.strokeRect(ox + 17.5, wy + 0.5, 280, 30) if (isUnlocked) { const iconFrameIdx = isCurrent ? (isHovered ? 1 : 0) : (isHovered ? 4 : 3) const iconSx = iconFrameIdx * 30 if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) { ctx.drawImage(assets.waypointIconsImg, iconSx, 0, 30, 30, ox + 17, wy + 1, 30, 30) } else { ctx.fillStyle = isCurrent ? (isHovered ? '#78b4ff' : '#4888ff') : (isHovered ? '#f0d898' : '#c8aa6e') ctx.fillRect(ox + 26, wy + 9, 12, 12) } } font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 80, wy + 21, { font: 'fontexocet10', color: isUnlocked ? (isHovered ? 'white' : 'gold') : 'gray', }) } } else if (kind === 'stash') { if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy) font.drawText(ctx, '私人储物箱 (Private Stash)', ox + 160, oy + 26, { font: 'fontexocet10', color: 'gold', align: 'center', }) font.drawText(ctx, `储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 48, { font: 'font8', color: 'white', align: 'center', }) // 6x8 grid cell borders for (let r = 0; r < STASH_GRID_ORIGIN.rows; r++) { for (let c = 0; c < STASH_GRID_ORIGIN.cols; c++) { const cx = STASH_GRID_ORIGIN.x + c * STASH_GRID_ORIGIN.cellPx const cy = STASH_GRID_ORIGIN.y + r * STASH_GRID_ORIGIN.cellPx ctx.strokeStyle = 'rgba(64, 52, 38, 0.4)' ctx.strokeRect(cx + 0.5, cy + 0.5, STASH_GRID_ORIGIN.cellPx - 1, STASH_GRID_ORIGIN.cellPx - 1) } } // Draw items stored in stash const atlas = assets.itemsAtlasImg ?? null for (const placed of this.stashItems) { const gx = STASH_GRID_ORIGIN.x + placed.col * STASH_GRID_ORIGIN.cellPx const gy = STASH_GRID_ORIGIN.y + placed.row * STASH_GRID_ORIGIN.cellPx const gw = placed.item.invWidth * STASH_GRID_ORIGIN.cellPx const gh = placed.item.invHeight * STASH_GRID_ORIGIN.cellPx ctx.fillStyle = isUiItemUnidentified(placed.item) ? 'rgba(72, 18, 18, 0.50)' : placed.item.quality === 'unique' ? 'rgba(58, 42, 16, 0.45)' : placed.item.quality === 'set' ? 'rgba(16, 52, 22, 0.45)' : 'rgba(20, 32, 54, 0.45)' ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2) const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects) if (atlas && rect) { const scale = Math.min(gw / rect.w, gh / rect.h, 1) const dw = Math.round(rect.w * scale) const dh = Math.round(rect.h * scale) const dx = gx + Math.round((gw - dw) / 2) const dy = gy + Math.round((gh - dh) / 2) ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) } else { ctx.fillStyle = placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8' ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4) } } // Deposit button ctx.fillStyle = 'rgba(20, 16, 12, 0.9)' ctx.fillRect( STASH_DEPOSIT_BTN_BOUNDS.x, STASH_DEPOSIT_BTN_BOUNDS.y, STASH_DEPOSIT_BTN_BOUNDS.w, STASH_DEPOSIT_BTN_BOUNDS.h, ) ctx.strokeStyle = '#8a7248' ctx.strokeRect( STASH_DEPOSIT_BTN_BOUNDS.x + 0.5, STASH_DEPOSIT_BTN_BOUNDS.y + 0.5, STASH_DEPOSIT_BTN_BOUNDS.w - 1, STASH_DEPOSIT_BTN_BOUNDS.h - 1, ) font.drawText( ctx, '存入', STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w / 2, STASH_DEPOSIT_BTN_BOUNDS.y + 16, { font: 'font8', color: 'gold', align: 'center', }, ) // Withdraw button ctx.fillStyle = 'rgba(20, 16, 12, 0.9)' ctx.fillRect( STASH_WITHDRAW_BTN_BOUNDS.x, STASH_WITHDRAW_BTN_BOUNDS.y, STASH_WITHDRAW_BTN_BOUNDS.w, STASH_WITHDRAW_BTN_BOUNDS.h, ) ctx.strokeStyle = '#8a7248' ctx.strokeRect( STASH_WITHDRAW_BTN_BOUNDS.x + 0.5, STASH_WITHDRAW_BTN_BOUNDS.y + 0.5, STASH_WITHDRAW_BTN_BOUNDS.w - 1, STASH_WITHDRAW_BTN_BOUNDS.h - 1, ) font.drawText( ctx, '取出', STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w / 2, STASH_WITHDRAW_BTN_BOUNDS.y + 16, { font: 'font8', color: 'gold', align: 'center', }, ) } else if (kind === 'cube') { if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy) font.drawText(ctx, `赫拉迪克方块 (已合成 ${this.cubeTransmuteCount} 次)`, ox + 160, oy + 32, { font: 'fontexocet10', color: 'gold', align: 'center', }) // 3x4 Horadric Cube grid cell borders (`Inventory.txt` Transmogrify Box1) for (let r = 0; r < CUBE_GRID_ORIGIN.rows; r++) { for (let c = 0; c < CUBE_GRID_ORIGIN.cols; c++) { const cx = CUBE_GRID_ORIGIN.x + c * CUBE_GRID_ORIGIN.cellPx const cy = CUBE_GRID_ORIGIN.y + r * CUBE_GRID_ORIGIN.cellPx ctx.strokeStyle = 'rgba(64, 52, 38, 0.4)' ctx.strokeRect(cx + 0.5, cy + 0.5, CUBE_GRID_ORIGIN.cellPx - 1, CUBE_GRID_ORIGIN.cellPx - 1) } } // Draw items stored in Horadric Cube const atlas = assets.itemsAtlasImg ?? null for (const placed of this.cubeItems) { const gx = CUBE_GRID_ORIGIN.x + placed.col * CUBE_GRID_ORIGIN.cellPx const gy = CUBE_GRID_ORIGIN.y + placed.row * CUBE_GRID_ORIGIN.cellPx const gw = placed.item.invWidth * CUBE_GRID_ORIGIN.cellPx const gh = placed.item.invHeight * CUBE_GRID_ORIGIN.cellPx ctx.fillStyle = isUiItemUnidentified(placed.item) ? 'rgba(72, 18, 18, 0.50)' : placed.item.quality === 'unique' ? 'rgba(58, 42, 16, 0.45)' : placed.item.quality === 'set' ? 'rgba(16, 52, 22, 0.45)' : 'rgba(20, 32, 54, 0.45)' ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2) const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects) if (atlas && rect) { const scale = Math.min(gw / rect.w, gh / rect.h, 1) const dw = Math.round(rect.w * scale) const dh = Math.round(rect.h * scale) const dx = gx + Math.round((gw - dw) / 2) const dy = gy + Math.round((gh - dh) / 2) ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) } else { ctx.fillStyle = placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8' ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4) } } } else if (kind === 'vendor') { if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { throw new Error('Cannot draw vendor panel without an active vendor descriptor') } if (!assets.vendorBgImg || !assets.vendorTabsImg || !assets.buySellBtnImg) { throw new Error('Missing required baked UI assets for vendor panel (vendorBgImg, vendorTabsImg, buySellBtnImg)') } // 1. D2Client.dll `0x6fb45c8a–0x6fb45d63`: `buysell.dc6` 320x432 stitched background ctx.drawImage(assets.vendorBgImg, ox, oy) // 2. D2Client.dll `0x6fb458a0`: Items on the active store page (`Inventory.txt` Monster2 grid at `ox+16, oy+63`) const atlas = assets.itemsAtlasImg ?? null const placements = this.getActiveVendorPlacements() for (const placed of placements) { const gx = VENDOR_GRID_ORIGIN.x + placed.col * VENDOR_GRID_ORIGIN.cellPx const gy = VENDOR_GRID_ORIGIN.y + placed.row * VENDOR_GRID_ORIGIN.cellPx const gw = placed.item.invWidth * VENDOR_GRID_ORIGIN.cellPx const gh = placed.item.invHeight * VENDOR_GRID_ORIGIN.cellPx const isHovered = this.hoveredVendorItem?.item.id === placed.item.id ctx.fillStyle = isHovered ? 'rgba(46, 68, 36, 0.55)' : placed.item.quality === 'magic' ? 'rgba(20, 34, 62, 0.42)' : 'rgba(28, 24, 18, 0.42)' ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2) const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects) if (atlas && rect) { const scale = Math.min(gw / rect.w, gh / rect.h, 1) const dw = Math.round(rect.w * scale) const dh = Math.round(rect.h * scale) const dx = gx + Math.round((gw - dw) / 2) const dy = gy + Math.round((gh - dh) / 2) ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) } else { ctx.fillStyle = placed.item.quality === 'magic' ? '#4850b8' : '#786850' ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4) } } // 3. D2Client.dll `0x6fb45df1–0x6fb45e51`: 4 button slots (`buysellbtn.dc6` at `ox + tableX - 1`, `oy + 385`) const btnLayout = resolveVendorButtonLayout(this.activeVendorDescriptor.hcIdx, this.vendorMode) for (let slot = 0 as 0 | 1 | 2 | 3; slot < 4; slot = (slot + 1) as 0 | 1 | 2 | 3) { const frameIdx = this.getVendorButtonFrame(slot, btnLayout) const drawX = ox + VENDOR_BUTTON_TABLE_X[slot]! - 1 const drawY = oy + VENDOR_BUTTON_DRAW_Y ctx.drawImage(assets.buySellBtnImg, frameIdx * 32, 0, 32, 32, drawX, drawY, 32, 32) } // 4. D2Client.dll `0x6fb45e5e–0x6fb45f5d`: `buyselltabs.dc6` present tabs + Font16 CHI `.tbl` labels (skipped in gamble mode) for (const tab of VENDOR_TABS) { if (!this.isVendorTabPresent(tab.id)) continue const selected = this.activeVendorTab === tab.id const frameIdx = selected ? tab.index : tab.index + 4 ctx.drawImage(assets.vendorTabsImg, frameIdx * 79, 0, 79, 31, ox + tab.xOffset, oy, 79, 31) if (this.vendorMode === 'trade') { font.drawText(ctx, tab.labelZh, ox + tab.centerX, oy + tab.textY, { font: 'font16', color: selected ? 'gold' : 'white', align: 'center', }) } } // 5. Status toast text inside `buysell.dc6` bottom-left long box (`relX 17..200, relY 359..375`) if (this.vendorStatusMsg && performance.now() < this.vendorStatusMsg.untilMs) { font.drawText(ctx, this.vendorStatusMsg.text, ox + 109, oy + 371, { font: 'font8', color: this.vendorStatusMsg.color, align: 'center', }) } // 6. D2Client.dll `0x6fb3f260` -> D2Win #10085 (`0x6f8f33a0`): Left-anchored framed button hover text if (this.hoveredVendorButtonSlot !== null) { const slot = this.hoveredVendorButtonSlot const btnKind = btnLayout[slot]! const spec = VENDOR_BUTTON_SPECS[btnKind] if (spec.enabled) { const hoverText = btnKind === 'repairAll' ? `${spec.labelZh}:${this.getRepairAllCost(assets.inventory)}` : spec.labelZh const padX = 6 const boxW = Math.max(24, font.measureText(hoverText, 'font16') + padX * 2) const boxH = 22 const boxX = ox + VENDOR_BUTTON_TABLE_X[slot]! const boxY = oy + 380 - boxH ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' ctx.fillRect(boxX, boxY, boxW, boxH) font.drawText(ctx, hoverText, boxX + padX, boxY + 17, { font: 'font16', color: 'white', align: 'left', }) } } } if (kind !== 'vendor' && assets.buySellBtnImg) { ctx.drawImage(assets.buySellBtnImg, 10 * 32, 0, 32, 32, ox + 272, oy + 388, 32, 32) } } /** * Draw Town NPC Stone Interaction Menu (`npcmenu.cpp`) and Greeting Dialog Box. */ drawNpcMenu(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void { if (!this.npcMenu) return if (this.npcMenu.talking) { const boxW = 340 const boxH = 156 const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2))) const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2))) ctx.fillStyle = 'rgba(10, 8, 6, 0.94)' ctx.fillRect(bx, by, boxW, boxH) ctx.strokeStyle = '#a88c54' ctx.lineWidth = 2 ctx.strokeRect(bx + 1, by + 1, boxW - 2, boxH - 2) ctx.lineWidth = 1 font.drawText(ctx, this.npcMenu.npcName, bx + boxW / 2, by + 24, { font: 'fontexocet10', color: 'gold', align: 'center', }) const bodyText = this.npcMenu.dialogLines.join('\n') this.drawWrappedText(ctx, font, bodyText, bx + 18, by + 48, boxW - 36, 16, 5) font.drawText(ctx, '[点击继续 / Click to continue]', bx + boxW / 2, by + boxH - 12, { font: 'font8', color: 'tan', align: 'center', }) return } const opts = this.npcMenu.options ?? [ { id: 'talk', labelZh: '交谈', labelEn: 'Talk' }, { id: 'cancel', labelZh: '取消', labelEn: 'Cancel' }, ] const boxW = 216 const rowH = 26 const headerH = 32 const boxH = headerH + opts.length * rowH + 10 const bx = Math.round(this.npcMenu.x - boxW / 2) const by = Math.round(this.npcMenu.y - boxH / 2) ctx.fillStyle = 'rgba(12, 10, 8, 0.94)' ctx.fillRect(bx, by, boxW, boxH) ctx.strokeStyle = '#9c824c' ctx.lineWidth = 2 ctx.strokeRect(bx + 1, by + 1, boxW - 2, boxH - 2) ctx.lineWidth = 1 // Header divider ctx.strokeStyle = '#5a4a30' ctx.beginPath() ctx.moveTo(bx + 10, by + headerH - 4) ctx.lineTo(bx + boxW - 10, by + headerH - 4) ctx.stroke() font.drawText(ctx, this.npcMenu.npcName, bx + boxW / 2, by + 21, { font: 'fontexocet10', color: 'gold', align: 'center', }) for (let i = 0; i < opts.length; i++) { const opt = opts[i]! const ry = by + headerH + i * rowH const hovered = this.npcMenu.hoveredOptionIdx === i if (hovered) { ctx.fillStyle = 'rgba(64, 50, 28, 0.75)' ctx.fillRect(bx + 6, ry + 2, boxW - 12, rowH - 4) } font.drawText(ctx, `${opt.labelZh} (${opt.labelEn})`, bx + boxW / 2, ry + 18, { font: 'fontexocet10', color: hovered ? 'white' : 'gold', align: 'center', }) } } private drawActTabs( ctx: CanvasRenderingContext2D, ox: number, oy: number, font: D2FontRenderer, tabsImg?: HTMLImageElement | null, ): void { if (tabsImg && tabsImg.complete && tabsImg.naturalWidth > 0) { for (let i = 0; i < 5; i++) { const act = i + 1 const active = this.selectedActTab === act const frameIdx = active ? i * 2 : i * 2 + 1 const sx = frameIdx * 63 const tx = ox + 4 + i * 62 ctx.drawImage(tabsImg, sx, 0, 63, 31, tx, oy + 8, 63, 31) } return } const numerals = ['I', 'II', 'III', 'IV', 'V'] as const for (let i = 0; i < 5; i++) { const act = i + 1 const active = this.selectedActTab === act const tx = ox + 8 + i * 61 ctx.fillStyle = active ? '#32281c' : '#181410' ctx.fillRect(tx, oy + 8, 58, 28) ctx.strokeStyle = active ? '#e8c26b' : '#584a34' ctx.strokeRect(tx + 0.5, oy + 8.5, 57, 27) font.drawText(ctx, `Act ${numerals[i]!}`, tx + 29, oy + 27, { font: 'fontexocet10', color: active ? 'gold' : 'tan', align: 'center', }) } } private drawWrappedText( ctx: CanvasRenderingContext2D, font: D2FontRenderer, text: string, x: number, y: number, maxW: number, lineH: number, maxLines = 10, ): void { const paragraphs = text.split(/\r?\n/) let curY = y let linesDrawn = 0 for (const p of paragraphs) { if (linesDrawn >= maxLines) break let line = '' for (let i = 0; i < p.length; i++) { const ch = p[i]! if (font.measureText(line + ch, 'font8') > maxW) { if (linesDrawn + 1 >= maxLines) { const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...' font.drawText(ctx, truncated, x, curY, { font: 'font8', color: 'white' }) return } font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' }) linesDrawn++ line = ch curY += lineH } else { line += ch } } if (line && linesDrawn < maxLines) { font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' }) linesDrawn++ curY += lineH } } } /** * Draw Top-Center Monster Target Health Bar & Affix Plaque (`text.cpp` / `panel.cpp`). */ drawMonsterTargetBar( ctx: CanvasRenderingContext2D, monster: { name: string hp: number maxHp: number rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'boss' affixes?: readonly string[] subType?: string } | null, font: D2FontRenderer, ): void { if (!monster || monster.hp <= 0) return const barW = 220 const barH = 22 const x = 400 - barW / 2 const y = 42 const ratio = Math.max(0, Math.min(1, monster.hp / Math.max(1, monster.maxHp))) ctx.fillStyle = 'rgba(28, 8, 8, 0.88)' ctx.fillRect(x, y, barW, barH) ctx.fillStyle = 'rgba(156, 20, 20, 0.92)' ctx.fillRect(x + 1, y + 1, Math.round((barW - 2) * ratio), barH - 2) ctx.strokeStyle = '#6c5838' ctx.strokeRect(x + 0.5, y + 0.5, barW - 1, barH - 1) const titleColor: D2ColorCode = monster.rank === 'unique' || monster.rank === 'superunique' || monster.rank === 'boss' ? 'gold' : monster.rank === 'champion' ? 'blue' : 'white' font.drawText(ctx, monster.name, 400, y + 16, { font: 'fontexocet10', color: titleColor, align: 'center', }) if (monster.affixes && monster.affixes.length > 0) { font.drawText(ctx, monster.affixes.join(' · '), 400, y + 36, { font: 'font8', color: 'tan', align: 'center', }) } else if (monster.subType) { font.drawText(ctx, monster.subType, 400, y + 36, { font: 'font8', color: 'white', align: 'center', }) } } /** * Draw Canonical Diablo II Item Hover Tooltip (`showitems.cpp`). */ drawItemTooltip( ctx: CanvasRenderingContext2D, hover: { item: UiInventoryItem; x: number; y: number } | null, font: D2FontRenderer, vendorOpen = false, ): void { if (!hover) return const { item } = hover const lines: { text: string; color: D2ColorCode; font: 'fontexocet10' | 'font8' }[] = [] const titleColor: D2ColorCode = item.quality === 'unique' || item.quality === 'rune' ? 'gold' : item.quality === 'set' ? 'green' : item.quality === 'rare' ? 'yellow' : item.quality === 'magic' ? 'blue' : item.quality === 'craft' ? 'orange' : 'white' // Header: Title, Runeword Runes line, Base item name lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' }) if (item.runewordRunes) { lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' }) } if (item.baseNameZh) { lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' }) } // Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0) if (item.buyCost !== undefined) { const label = item.isGambleMystery ? '赌博价格 (Gamble Cost)' : '购买价格 (Buy Cost)' lines.push({ text: `${label}: ${item.buyCost.toLocaleString()} 金币`, color: 'gold', font: 'font8' }) } else if (vendorOpen && this.activeVendorDescriptor) { if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) { const repPrice = this.getPlayerItemRepairPrice(item) if (repPrice > 0) { lines.push({ text: `修理费用 (Repair Cost): ${repPrice.toLocaleString()} 金币`, color: 'gold', font: 'font8' }) } } else { const sellPrice = this.getPlayerItemSellPrice(item) if (sellPrice > 0) { lines.push({ text: `出售价格 (Sell Value): ${sellPrice.toLocaleString()} 金币`, color: 'gold', font: 'font8' }) } } } // Base Stats Block if (item.defense !== undefined) { lines.push({ text: '防御: ' + item.defense, color: 'white', font: 'font8' }) } if (item.damage !== undefined) { const dmgText = item.damage.includes('伤害') ? item.damage : ('单手伤害: ' + item.damage) lines.push({ text: dmgText, color: 'white', font: 'font8' }) } if (item.durability !== undefined) { lines.push({ text: '耐久度: ' + item.durability.current + ' 之 ' + item.durability.max, color: 'white', font: 'font8', }) } if (item.speedText) { lines.push({ text: item.speedText, color: 'white', font: 'font8' }) } if (item.reqStr !== undefined) { lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' }) } if (item.reqLevel !== undefined) { lines.push({ text: '需要等级: ' + item.reqLevel, color: 'white', font: 'font8' }) } // Magic Affixes for (const st of item.stats) { lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' }) } // Sockets Block if ( item.sockets !== undefined && item.sockets > 0 && !item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed')) ) { lines.push({ text: '凹槽 (' + item.sockets + ')', color: 'blue', font: 'font8' }) } // Set Pieces & Set Bonuses Block if (item.setPieces) { for (const piece of item.setPieces) { lines.push({ text: piece, color: 'gray', font: 'font8' }) } } if (item.setBonuses) { for (const bonus of item.setBonuses) { lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' }) } } // Viewport Dimensions & Dynamic Vertical Flip / Clamping const lineH = 17, padX = 16, padY = 12 const maxW = Math.max(...lines.map(l => font.measureText(l.text, l.font))) const boxW = Math.max(190, maxW + padX * 2) const boxH = lines.length * lineH + padY * 2 const rawBy = hover.y < 280 ? hover.y + 24 : hover.y - boxH - 8 const bx = Math.max(8, Math.min(800 - boxW - 8, Math.round(hover.x - boxW / 2))) const maxTop = Math.max(8, 600 - boxH - 8) const topMargin = (hover.y < 280 || maxTop < 44) ? 8 : 44 const by = Math.max(topMargin, Math.min(600 - boxH - 8, rawBy)) this.lastTooltipRect = { bx, by, boxW, boxH } // Rendering ctx.fillStyle = 'rgba(4, 4, 4, 0.94)' ctx.fillRect(bx, by, boxW, boxH) ctx.strokeStyle = '#8c7244' ctx.strokeRect(bx + 0.5, by + 0.5, boxW - 1, boxH - 1) lines.forEach((l, idx) => { font.drawText(ctx, l.text, bx + Math.round(boxW / 2), by + padY + 12 + idx * lineH, { font: l.font, color: l.color, align: 'center', }) }) } /** * Draw Area Entry Gothic Banner (`Entering: `). */ drawAreaBanner(ctx: CanvasRenderingContext2D, nowMs: number, font: D2FontRenderer): void { if (!this.areaBanner || nowMs >= this.areaBanner.untilMs) return const remain = this.areaBanner.untilMs - nowMs const alpha = remain < 800 ? remain / 800 : 1 ctx.save() ctx.globalAlpha = Math.max(0, Math.min(1, alpha)) font.drawText(ctx, `进入:${this.areaBanner.titleZh}`, 400, 142, { font: 'font30', color: 'gold', align: 'center', }) font.drawText(ctx, `Entering ${this.areaBanner.titleEn}`, 400, 166, { font: 'fontexocet10', color: 'tan', align: 'center', }) ctx.restore() } }