diablo2-web/src/ui/world-panels.ts

2807 lines
107 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Diablo II v1.13c 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
/**
* `Panel\TradeStash.dc6` (`public/ui/stash-bg.png`, 320x432 anchored at `ox = 80, oy = 60`)
* header recessed dark boxes (Issue #493):
* - Top box (`relY = 24..38`, height 15px) is divided by a vertical bar at `relX = 94..96` into:
* - Left icon socket: `relX = 74..93, relY = 24..38` (20x15px)
* - Right title text recess: `relX = 97..247, relY = 24..38` (151x15px, center `relX = 172, relY = 31`, alphabetic baseline `oy + 35`)
* - Bottom gold text recess: `relX = 75..246, relY = 49..65` (172x17px, center `relX = 160, relY = 57`, alphabetic baseline `oy + 61`)
*/
export const STASH_HEADER_ICON_SOCKET = {
x: 80 + 74,
y: 60 + 24,
relX: 74,
relY: 24,
w: 20,
h: 15,
} as const
export const STASH_HEADER_TOP_RECESS = {
x: 80 + 97,
y: 60 + 24,
relX: 97,
relY: 24,
w: 151,
h: 15,
textX: 80 + 172,
textY: 60 + 35,
} as const
export const STASH_HEADER_BOTTOM_RECESS = {
x: 80 + 75,
y: 60 + 49,
relX: 75,
relY: 49,
w: 172,
h: 17,
textX: 80 + 160,
textY: 60 + 61,
} 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<Record<VendorButtonKind, VendorButtonSpec>> = {
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<number> = new Set([154, 178, 253, 257, 511])
export const TRADER_CLASS_IDS: ReadonlySet<number> = 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 strIdx?: number
readonly labelZh: string
readonly labelEn: string
}
export const BOXPIECES_FRAME_W = 14
export const BOXPIECES_FRAME_H = 15
export interface NpcDialogLayout {
readonly left: number
readonly top: number
readonly width: number
readonly height: number
readonly headerY: number
readonly optionYs: readonly number[]
}
/**
* D2Client.dll `0x6fb53580` (`DIALOG_Layout` in `dialog.cpp`):
* - Uses `font16` (`D2Win_SetFont(1)`)
* - Line 0 (NPC Name Header): `lineStepY = 21` (`0x15`), `selectable = false`
* - Lines 1..N (Menu Options): `lineStepY = 15` (`0x0f`), `selectable = true`
* - `width = maxLineWidth + 20` (`0x14`), `height = totalStepY + 15` (`0x0f`)
* - `left = anchorX - trunc(width / 2)`, `top = anchorY - 21`
* - Clamped to 800x600 viewport (`left ∈ [10, 800 - width]`, `top ∈ [10, 600 - height - 48]`)
*/
export function computeNpcDialogLayout(
npcName: string,
options: readonly NpcMenuOption[],
anchorX: number,
anchorY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
): NpcDialogLayout {
const measure = (text: string): number =>
font ? font.measureText(text, 'font16') : text.length * 10
let maxLineWidth = measure(npcName)
for (const opt of options) {
const w = measure(opt.labelZh)
if (w > maxLineWidth) maxLineWidth = w
}
const headerStepY = 21
const optionStepY = 15
const totalStepY = headerStepY + options.length * optionStepY
const width = maxLineWidth + 20
const height = totalStepY + 15
let left = Math.trunc(anchorX) - Math.trunc(width / 2)
let top = Math.trunc(anchorY) - headerStepY
if (left + width > 800 - 10) {
left = 800 - width
}
if (top + height > 600 - 58) {
top = 600 - height - 48
}
if (left < 10) left = 10
if (top < 10) top = 10
const headerY = top + headerStepY
const optionYs = options.map((_, idx) => top + headerStepY + (idx + 1) * optionStepY)
return {
left,
top,
width,
height,
headerY,
optionYs,
}
}
/**
* D2Client.dll `0x6fb534b0` (`DIALOG_GetLineAtPoint` in `dialog.cpp`):
* Open-interval hit-testing for selectable options:
* `mouseX > left + 15 && mouseX < left + width - 15 && mouseY > rowY - 11 && mouseY < rowY + 4`
*/
export function hitTestNpcDialogOption(
layout: NpcDialogLayout,
logicalX: number,
logicalY: number,
): number | null {
if (logicalX <= layout.left + 15 || logicalX >= layout.left + layout.width - 15) {
return null
}
for (let i = 0; i < layout.optionYs.length; i++) {
const rowY = layout.optionYs[i]!
if (logicalY > rowY - 11 && logicalY < rowY + 4) {
return i
}
}
return null
}
/**
* D2Client.dll `0x6fb53840` (`DIALOG_Draw`) + `0x6fb6e4c0` (`DATA\GLOBAL\UI\menu\boxpieces.dc6`):
* - Draws translucent black background fill at `(left + 1, top, width - 2, height - 2)`
* - Tiles the 22 `14x15` carved stone pieces from `boxpieces.dc6`:
* - Top edge: frames `2..7` (`(esi % 6) + 2`) at `x = left + 12 .. right - 14` step `12`, `y = top + 12`
* - Bottom edge: frames `16..21` (`(esi % 6) + 16`) at `x = left + 13 .. right - 14` step `12`, `y = bottom + 10`
* - Left edge: frames `10..12` (`(esi % 3) + 10`) at `x = left - 4`, `y = top + 22 .. bottom - 2` step `12`
* - Right edge: frames `13..15` (`(esi % 3) + 13`) at `x = right - 7`, `y = top + 24 .. bottom - 2` step `12`
* - Corners: frame `0` `(left, top + 12)`, frame `1` `(right - 12, top + 12)`,
* frame `8` `(left, bottom + 1)`, frame `9` `(right - 12, bottom + 1)`
* - Each `14x15` DC6 cell passed `(nXpos, nYpos)` to `D2GFX_DrawCellContext` is blitted with top-left `(nXpos, nYpos - 15)`.
*/
export function drawStoneBoxFrame(
ctx: CanvasRenderingContext2D,
left: number,
top: number,
width: number,
height: number,
boxPiecesImg: HTMLImageElement | null | undefined,
): void {
if (!boxPiecesImg) {
throw new Error('Missing required baked UI asset boxpieces.png (DATA\\GLOBAL\\UI\\menu\\boxpieces.dc6)')
}
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
ctx.fillRect(left + 1, top, Math.max(0, width - 2), Math.max(0, height - 2))
const right = left + width
const bottom = top + height
const drawPiece = (frameIdx: number, x: number, y: number): void => {
ctx.drawImage(
boxPiecesImg,
frameIdx * BOXPIECES_FRAME_W,
0,
BOXPIECES_FRAME_W,
BOXPIECES_FRAME_H,
x,
y - BOXPIECES_FRAME_H,
BOXPIECES_FRAME_W,
BOXPIECES_FRAME_H,
)
}
let esi = 0
for (let x = left + 12; x < right - 13; x += 12) {
esi++
drawPiece((esi % 6) + 2, x, top + 12)
}
for (let x = left + 13; x < right - 13; x += 12) {
esi++
drawPiece((esi % 6) + 16, x, bottom + 10)
}
for (let y = top + 22; y < bottom - 1; y += 12) {
esi++
drawPiece((esi % 3) + 10, left - 4, y)
}
for (let y = top + 24; y < bottom - 1; y += 12) {
esi++
drawPiece((esi % 3) + 13, right - 7, y)
}
drawPiece(0, left, top + 12)
drawPiece(1, right - 12, top + 12)
drawPiece(8, left, bottom + 1)
drawPiece(9, right - 12, bottom + 1)
}
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<Record<number, readonly QuestEntry[]>> = {
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<Record<number, readonly WaypointEntry[]>> = {
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<VendorTabId, GridPlacement[]> = {
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 }
}
/**
* D2Client.dll `0x6fba3ba0` (`gNpcMenuTable`) + `0x6faf8c50` (`NPCMENU_Open`):
* Uses official 1.13c `.tbl` string IDs:
* - `3381` (`talk`): `交談` (`talk`)
* - `4020` (`identify`): `辨視物品` (`Identify Items`)
* - `3334` (`trade` with `canRepair`): `交易/修理` (`trade/repair`)
* - `3396` (`trade` without `canRepair`): `交易` (`trade`)
* - `3398` (`gamble`): `賭博` (`gamble`)
* - `4142` (`cancel`): `取消` (`cancel`)
*/
buildNpcMenuOptions(descriptor: TownNpcServiceDescriptor): NpcMenuOption[] {
const opts: NpcMenuOption[] = [{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' }]
if (descriptor.canIdentify) {
opts.push({ id: 'identify', strIdx: 4020, labelZh: '辨視物品', labelEn: 'Identify Items' })
}
if (descriptor.canTrade) {
opts.push({
id: 'trade',
strIdx: descriptor.canRepair ? 3334 : 3396,
labelZh: descriptor.canRepair ? '交易/修理' : '交易',
labelEn: descriptor.canRepair ? 'trade/repair' : 'trade',
})
}
if (descriptor.canGamble) {
opts.push({ id: 'gamble', strIdx: 3398, labelZh: '賭博', labelEn: 'gamble' })
}
opts.push({ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' })
return opts
}
openNpcMenu(
descriptor: TownNpcServiceDescriptor,
screenX = 400,
screenY = 220,
): void {
const options = this.buildNpcMenuOptions(descriptor)
// D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open.
// Box position clamping is performed dynamically in computeNpcDialogLayout (0x6fb53580).
this.npcMenu = {
npcName: descriptor.displayName,
x: Math.round(screenX),
y: Math.round(screenY),
dialogLines: descriptor.greetingLines,
talking: false,
descriptor,
options,
hoveredOptionIdx: 0,
}
}
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<string, 'completed' | 'active' | 'locked'>()
private cachedQuestsAtlasImg: HTMLImageElement | null = null
private cachedBoxPiecesImg: 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
}
private getBoxPiecesImage(): HTMLImageElement | null {
if (this.cachedBoxPiecesImg) return this.cachedBoxPiecesImg
if (typeof Image !== 'undefined') {
const img = new Image()
img.src = BAKED_UI_MANIFEST.images.boxPieces ?? '/ui/boxpieces.png'
this.cachedBoxPiecesImg = img
return img
}
return null
}
/** Area entry banner state (`Entering: <Level Name>`). */
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<number, boolean>()
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<number>): 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,
}
}
}
}
getNpcMenuLayout(font?: Pick<D2FontRenderer, 'measureText'> | null): NpcDialogLayout | null {
if (!this.npcMenu) return null
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
return computeNpcDialogLayout(this.npcMenu.npcName, opts, this.npcMenu.x, this.npcMenu.y, font)
}
isPointInNpcMenu(
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
): boolean {
if (!this.npcMenu) return false
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)))
return logicalX >= bx && logicalX <= bx + boxW && logicalY >= by && logicalY <= by + boxH
}
const layout = this.getNpcMenuLayout(font)
if (!layout) return false
return (
logicalX >= layout.left &&
logicalX <= layout.left + layout.width &&
logicalY >= layout.top &&
logicalY <= layout.top + layout.height
)
}
handleNpcMenuMove(
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
): void {
if (!this.npcMenu || this.npcMenu.talking) return
const layout = this.getNpcMenuLayout(font)
if (!layout) return
// D2Client.dll 0x6fb533c0 -> 0x6fb534b0: update selected option when cursor hits a selectable line;
// when cursor is outside selectable lines (0x6fb533de: je 0x6fb53402), keep existing selection.
const hitIdx = hitTestNpcDialogOption(layout, logicalX, logicalY)
if (hitIdx !== null) {
this.npcMenu.hoveredOptionIdx = hitIdx
}
}
handleNpcMenuClick(
logicalX: number,
logicalY: number,
callbacks: {
onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void
onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void
},
font?: Pick<D2FontRenderer, 'measureText'> | null,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
this.npcMenu.talking = false
return true
}
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
const layout = computeNpcDialogLayout(this.npcMenu.npcName, opts, this.npcMenu.x, this.npcMenu.y, font)
if (
logicalX < layout.left ||
logicalX > layout.left + layout.width ||
logicalY < layout.top ||
logicalY > layout.top + layout.height
) {
this.npcMenu = null
return true
}
const hitIdx = hitTestNpcDialogOption(layout, logicalX, logicalY)
if (hitIdx !== null) {
this.npcMenu.hoveredOptionIdx = hitIdx
const opt = opts[hitIdx]!
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<LeftDockPanelKind, 'none' | 'char'>,
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<LeftDockPanelKind, 'none' | 'char'>,
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 + 172, oy + 35, {
font: 'font8',
color: 'gold',
align: 'center',
})
font.drawText(ctx, `储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 61, {
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 (`dialog.cpp` `0x6fb53840` + `npcmenu.cpp` `0x6faf8c50` + `boxpieces.dc6` `0x6fb6e4c0`).
*/
drawNpcMenu(
ctx: CanvasRenderingContext2D,
font: D2FontRenderer,
assets?: {
boxPiecesImg?: HTMLImageElement | null
focus16Img?: HTMLImageElement | null
},
): void {
if (!this.npcMenu) return
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
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)))
drawStoneBoxFrame(ctx, bx, by, boxW, boxH, boxPiecesImg)
font.drawText(ctx, this.npcMenu.npcName, bx + Math.trunc(boxW / 2), by + 24, {
font: 'font16',
color: 'gold',
align: 'center',
})
const bodyText = this.npcMenu.dialogLines.join('\n')
this.drawWrappedText(ctx, font, bodyText, bx + 18, by + 48, boxW - 36, 16, 6)
return
}
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
const layout = computeNpcDialogLayout(this.npcMenu.npcName, opts, this.npcMenu.x, this.npcMenu.y, font)
// 1. D2Client.dll 0x6fb53890 + 0x6fb6e4c0: dark translucent fill + carved stone boxpieces.dc6 border
drawStoneBoxFrame(ctx, layout.left, layout.top, layout.width, layout.height, boxPiecesImg)
const centerX = layout.left + Math.trunc(layout.width / 2)
// 2. D2Client.dll 0x6faf8c67 + 0x6fb53912: Line 0 NPC Name Header in Font16 Gold (color = 4, step = 21)
font.drawText(ctx, this.npcMenu.npcName, centerX, layout.headerY, {
font: 'font16',
color: 'gold',
align: 'center',
})
// 3. D2Client.dll 0x6faf8f81 + 0x6fb53988..0x6fb53a05: Options in Font16, Blue ('blue', 3) when selected else White ('white', 0)
const selectedIdx = this.npcMenu.hoveredOptionIdx ?? 0
for (let i = 0; i < opts.length; i++) {
const opt = opts[i]!
const rowY = layout.optionYs[i]!
const selected = selectedIdx === i
font.drawText(ctx, opt.labelZh, centerX, rowY, {
font: 'font16',
color: selected ? 'blue' : 'white',
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
const unid = isUiItemUnidentified(item)
if (unid) {
const maskedBaseHeader =
(item.baseNameZh ? item.baseNameZh.split(' · ')[0] : undefined) || item.nameZh
lines.push({ text: maskedBaseHeader, color: titleColor, font: 'fontexocet10' })
} else {
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' })
}
const displayReqLevel = unid
? item.rawItem?.base?.level && item.rawItem.base.level > 1
? item.rawItem.base.level
: undefined
: item.reqLevel
if (displayReqLevel !== undefined) {
lines.push({ text: '需要等级: ' + displayReqLevel, color: 'white', font: 'font8' })
}
if (unid) {
if (item.isGambleMystery) {
lines.push({ text: '未鉴定 (Unidentified Gamble Item)', color: 'red', font: 'font8' })
} else {
lines.push({ text: '未辨识 (Unidentified)', color: 'red', font: 'font8' })
if (item.allowedSlots.length > 0) {
lines.push({ text: '无法使用 (Cannot be Equipped)', color: 'red', font: 'font8' })
}
if (item.ethereal) {
lines.push({ text: '无形的 (无法修复) (Ethereal)', color: 'blue', font: 'font8' })
}
}
} else {
// 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: <Level Name>`).
*/
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()
}
}