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

5707 lines
218 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 {
GRID_ITEM_BG_BLUE,
GRID_ITEM_BG_GREEN,
GRID_ITEM_BG_RED,
TOME_MAX_QUANTITY,
applySocketToUiItem,
canPlayerUseUiItem,
canSocketUiItem,
drawItemSocketOverlay,
findFreeGridSlot,
getEffectiveItemReqClass,
getInventoryGoldLimit,
getTomeQuantity,
identifyUiItem,
isScrollForTome,
isUiItemUnidentified,
isUsableRightClickItem,
itemToUiInventoryItem,
nextPlayerContextSeq,
resolveGridItemBackgroundFill,
resolveItemSpriteRect,
setTomeQuantity,
type EquipSlotId,
type GridPlacement,
type InventoryPanel,
type PlayerItemRequirementContext,
type UiInventoryItem,
} from './inventory.ts'
import type { Item } from '../../common/items/items.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts'
import { D2Rng } from '../../common/rng/d2-rng.ts'
import { getTblLang, lookupTbl, requireTbl, type TblLang } from '../i18n/lang.ts'
import { STASH_GOLD_CAP } from '../../common/items/inventory-model.ts'
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
import {
type TownNpcServiceDescriptor,
type VendorNpcId,
getTownNpcDescriptorByName,
getTownNpcDescriptorByVendorId,
getTownNpcDescriptorByHcIdx,
getLocalizedNpcName,
getLocalizedNpcGreetings,
} from '../../common/units/npc-table.ts'
import type { VendorTabId } from '../../common/items/vendor-generator.ts'
import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT } from '../../common/data/canonical-tbl-strings.ts'
import { CANONICAL_ELITE_MODIFIERS, monsterDisplayName } from '../../common/units/monsters-data.ts'
import { getSocketableInfo, isJewelCode } from '../../common/items/gems-data.ts'
import {
transmuteCubeItems,
type CubeTransmuteResult,
} from '../../common/items/runeword-socket.ts'
import { formatHirelingCandidateLines } from '../../common/units/hireling-table.ts'
export {
STASH_GOLD_CAP,
GRID_ITEM_BG_BLUE,
GRID_ITEM_BG_GREEN,
GRID_ITEM_BG_RED,
canPlayerUseUiItem,
resolveGridItemBackgroundFill,
type PlayerItemRequirementContext,
}
export type LeftDockPanelKind =
| 'none'
| 'char'
| 'quest'
| 'waypoint'
| 'stash'
| 'cube'
| 'vendor'
| 'merc'
| 'hire'
| 'party'
| 'trade'
| 'imbue'
| 'add_sockets'
| 'personalize'
export type WorldPanelType = LeftDockPanelKind
export const HIRE_DIALOG_WIDTH = 490
export const HIRE_DIALOG_HEIGHT = 350
export const HIRE_LIST_X_OFFSET = 0
export const HIRE_LIST_Y_OFFSET = 35
export const HIRE_LIST_WIDTH = 490
export const HIRE_LIST_HEIGHT = 280
export const HIRE_VISIBLE_CANDIDATES = 7
export const HIRE_ROW_HEIGHT = 34
export const HIRE_SLIDER_X_OFFSET = 472
export const HIRE_SLIDER_Y_OFFSET = 51
export const HIRE_TRACK_SEGMENTS = 21
export const HIRE_CONFIRM_WIDTH = 300
export const HIRE_CONFIRM_HEIGHT = 160
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
/** Panel-relative click rect of the stash gold coin button (inclusive, D2Client stash panel). */
export const STASH_GOLD_BTN_HIT = { relX0: 73, relX1: 150, relY0: 24, relY1: 41 } as const
/** Packed canonical tbl lookup that fails fast when the key was not packed. */
export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } 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
/**
* Horadric Cube Transmute button (`Panel\miniconvert.dc6`, 2 frames of 32x32)
* per `D2Client.dll` `0x6fb3ce4b` (asset load), `0x6fb49657..0x6fb49698` (draw),
* `0x6fb3b600` (hit-test), and `0x6fb4977a..0x6fb497c8` (hover tooltip):
* - Draw: `x = ox + 0x90 = ox + 144` (`224`), `nYpos = oy + 480 - 0xbc = oy + 292` -> top-left `y = oy + 260` (`320`), `32x32`
* - Frame `0` (unpressed) or Frame `1` (pressed, `ds:0x6fbcbe94`)
* - Hit-test (`0x6fb3b600`): `relX ∈ (144, 184)`, `relY ∈ (257, 297)` (`x ∈ (224, 264)`, `y ∈ (317, 357)`)
* - Hover tooltip (`0x6fb4977a`): `strIdx = 0xd0d = 3341` (`改變` / `Transmute`), `font16`, centered at `ox + 158` (`238`), anchored above `oy + 257` (`317`)
*/
export const CUBE_TRANSMUTE_BTN_BOUNDS = {
x: 80 + 144,
y: 60 + 260,
relX: 144,
relY: 260,
w: 32,
h: 32,
hitMinX: 80 + 144,
hitMaxX: 80 + 184,
hitMinY: 60 + 257,
hitMaxY: 60 + 297,
tooltipCenterX: 80 + 158,
tooltipAnchorY: 60 + 257,
strIdx: 3341,
labelZh: '改變',
labelEn: 'Transmute',
} as const
/**
* Horadric Cube Close button (`Panel\buysellbtn.dc6`, frames 10/11 of 32x32)
* per `D2Client.dll` `0x6fb49615..0x6fb49656` (draw), `0x6fb3b650` (hit-test),
* and `0x6fb49732..0x6fb49775` (hover tooltip):
* - Draw: `x = ox + 0x113 = ox + 275` (`355`), `nYpos = oy + 480 - 0x41 = oy + 415` -> top-left `y = oy + 383` (`443`), `32x32`
* - Frame `10` (unpressed) or Frame `11` (pressed, `ds:0x6fbcbe8c`)
* - Hit-test (`0x6fb3b650`): `relX ∈ (275, 315)`, `relY ∈ (380, 420)` (`x ∈ (355, 395)`, `y ∈ (440, 480)`)
* - Hover tooltip (`0x6fb49732`): `strIdx = 0x1030 = 4144` (`關閉` / `Close`), `font16`, centered at `ox + 289` (`369`), anchored above `oy + 380` (`440`)
*/
export const CUBE_CLOSE_BTN_BOUNDS = {
x: 80 + 275,
y: 60 + 383,
relX: 275,
relY: 383,
w: 32,
h: 32,
hitMinX: 80 + 275,
hitMaxX: 80 + 315,
hitMinY: 60 + 380,
hitMaxY: 60 + 420,
tooltipCenterX: 80 + 289,
tooltipAnchorY: 60 + 380,
strIdx: 4144,
labelZh: '關閉',
labelEn: 'Close',
} 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
/** D2Client.dll `0x6fbbb238` (+0x10) `buysell.dc6` cursor frame index (`3` Buy, `4` Sell, `1` Repair, `null` none) */
readonly cursorFrame: number | null
}
export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButtonSpec>> = {
blank: {
kind: 'blank',
enabled: false,
baseFrame: 0,
pressedFrame: 1,
strIdx: 0,
labelZh: '',
labelEn: '',
paramA: 1,
cursorFrame: null,
},
buy: {
kind: 'buy',
enabled: true,
baseFrame: 2,
pressedFrame: 3,
strIdx: 3335,
labelZh: '買',
labelEn: 'Buy',
paramA: 2,
cursorFrame: 3,
},
sell: {
kind: 'sell',
enabled: true,
baseFrame: 4,
pressedFrame: 5,
strIdx: 3336,
labelZh: '賣',
labelEn: 'Sell',
paramA: 3,
cursorFrame: 4,
},
repair: {
kind: 'repair',
enabled: true,
baseFrame: 6,
pressedFrame: 7,
strIdx: 3338,
labelZh: '修復',
labelEn: 'Repair',
paramA: 4,
cursorFrame: 1,
},
repairAll: {
kind: 'repairAll',
enabled: true,
baseFrame: 18,
pressedFrame: 19,
strIdx: 10095,
labelZh: '修復所有裝備',
labelEn: 'Repair all equipment',
paramA: 1,
cursorFrame: null,
},
close: {
kind: 'close',
enabled: true,
baseFrame: 10,
pressedFrame: 11,
strIdx: 4144,
labelZh: '關閉',
labelEn: 'Close',
paramA: 9,
cursorFrame: null,
},
}
/** 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
/**
* MonStats classes whose NPC menu offers "gamble" in 1.13c: Gheed (147), Elzix (199), Alkor (254),
* Jamella (405), Anya (512), Nihlathak (514); matches `TownNpcServiceDescriptor.canGamble`.
*/
export const GAMBLE_NPC_CLASS_IDS: ReadonlySet<number> = new Set([147, 199, 254, 405, 512, 514])
export type NpcMenuActionId =
| 'talk'
| 'talk_intro'
| 'talk_gossip'
| 'talk_quest'
| 'talk_back'
| 'trade'
| 'trade_repair'
| 'gamble'
| 'identify'
| 'hire'
| 'resurrect'
| 'travel_east'
| 'travel_west'
| 'travel_harrogath'
| 'imbue'
| 'add_sockets'
| 'personalize'
| 'cancel'
export interface NpcMenuOption {
readonly id: NpcMenuActionId
readonly strIdx?: number
readonly label: string
readonly labelZh: string
readonly labelEn: string
}
export interface NpcMenuContext {
readonly hasUnidentifiedItems?: boolean | undefined
readonly hasDeadMercenary?: boolean | undefined
readonly deadMercenaryReviveCost?: number | undefined
readonly includeQuestRewards?: boolean | undefined
readonly legacyTradeId?: boolean | undefined
}
export function buildNpcMenuOptions(
descriptor: TownNpcServiceDescriptor,
context?: NpcMenuContext,
): readonly NpcMenuOption[] {
const opts: NpcMenuOption[] = [
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'Talk' },
]
if (descriptor.canTrade) {
if (descriptor.canRepair) {
opts.push({
id: context?.legacyTradeId ? 'trade' : 'trade_repair',
strIdx: 3334,
label: '交易/修理',
labelZh: '交易/修理',
labelEn: 'Trade/Repair',
})
} else {
opts.push({
id: 'trade',
strIdx: 3396,
label: '交易',
labelZh: '交易',
labelEn: 'Trade',
})
}
}
if (descriptor.canGamble) {
opts.push({
id: 'gamble',
strIdx: 3398,
label: '賭博',
labelZh: '賭博',
labelEn: 'Gamble',
})
}
if (descriptor.canIdentify && context?.hasUnidentifiedItems !== false) {
opts.push({
id: 'identify',
strIdx: 4020,
label: '辨識物品',
labelZh: '辨識物品',
labelEn: 'Identify Items',
})
}
if (descriptor.canHire) {
opts.push({
id: 'hire',
label: '僱傭',
labelZh: '僱傭',
labelEn: 'Hire',
})
}
if (
(descriptor.canHire || descriptor.npcTxtKey === 'tyrael1' || descriptor.npcTxtKey === 'qual-kehk') &&
context?.hasDeadMercenary
) {
const cost = context?.deadMercenaryReviveCost ?? 0
opts.push({
id: 'resurrect',
label: `復活傭兵 (${cost})`,
labelZh: `復活傭兵 (${cost})`,
labelEn: `Resurrect (${cost})`,
})
}
for (const travelAct of descriptor.travelActions ?? []) {
if (travelAct === 'travel_east') {
opts.push({
id: 'travel_east',
label: '向東旅行',
labelZh: '向東旅行',
labelEn: 'Travel East',
})
} else if (travelAct === 'travel_west') {
opts.push({
id: 'travel_west',
label: '向西旅行',
labelZh: '向西旅行',
labelEn: 'Travel West',
})
} else if (travelAct === 'travel_harrogath') {
opts.push({
id: 'travel_harrogath',
label: '前往哈洛加斯',
labelZh: '前往哈洛加斯',
labelEn: 'Travel to Harrogath',
})
}
}
if (context?.includeQuestRewards !== false) {
for (const rewardAct of descriptor.questRewardActions ?? []) {
if (rewardAct === 'imbue') {
opts.push({
id: 'imbue',
label: '注入',
labelZh: '注入',
labelEn: 'Imbue',
})
} else if (rewardAct === 'add_sockets') {
opts.push({
id: 'add_sockets',
label: '打孔',
labelZh: '打孔',
labelEn: 'Add Sockets',
})
} else if (rewardAct === 'personalize') {
opts.push({
id: 'personalize',
label: '個人化',
labelZh: '個人化',
labelEn: 'Personalize',
})
}
}
}
opts.push({
id: 'cancel',
strIdx: 4142,
label: '取消',
labelZh: '取消',
labelEn: 'Cancel',
})
return opts
}
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 viewport (`left ∈ [10, viewportWidth - width]`, `top ∈ [10, viewportHeight - height - 48]`)
*/
export function computeNpcDialogLayout(
npcName: string,
options: readonly NpcMenuOption[],
anchorX: number,
anchorY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
lang: TblLang = 'CHI',
viewportWidth = 800,
viewportHeight = 600,
): 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(lang === 'ENG' ? opt.labelEn : 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 > viewportWidth - 10) {
left = viewportWidth - width
}
if (top + height > viewportHeight - 58) {
top = viewportHeight - 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 '../../common/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
}
/**
* Static quest catalog entries start as `locked`; the live status comes exclusively from the server
* quest-flag block (`0x29` GameQuestLog / `0x28` QuestInfo) via `WorldPanelsHud.applyServerQuestFlags()`.
*/
function createQuestEntry(item: CanonicalQuestItem): QuestEntry {
return {
...item,
status: 'locked',
description: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
}
}
/**
* First quest-flag word of each act inside the 1.13c 48-word (96-byte) quest record sent by `0x29`
* (same layout as the `.d2s` "Woo!" quest block): word 0 = Act I intro, 1..6 = Act I quests,
* 7/8 = Act II travel/intro, 9..14 = Act II, 15/16, 17..22 = Act III, 23/24, 25..27 = Act IV, 35..40 = Act V.
*/
const QUEST_FLAG_ACT_BASE: Readonly<Record<number, number>> = { 1: 1, 2: 9, 3: 17, 4: 25, 5: 35 }
/**
* Quest-flag word index for a canonical quest. The internal quest number is the `qstsa<act>q<n>` string key
* (e.g. "The Search for Cain" = `qstsa1q4` -> word 4, "Terror's End" = `qstsa4q2` -> word 26).
*/
export function questFlagWordIndex(quest: Pick<CanonicalQuestItem, 'act' | 'titleKey' | 'id'>): number {
const m = /^qstsa(\d)q(\d)$/.exec(quest.titleKey)
const base = QUEST_FLAG_ACT_BASE[quest.act]
if (!m || base === undefined || Number(m[1]) !== quest.act) {
throw new Error(`questFlagWordIndex: cannot map quest '${quest.id}' (titleKey '${quest.titleKey}')`)
}
return base + Number(m[2]) - 1
}
/** Bit 0 of a quest-flag word = quest completed (reward granted). */
export const QUEST_FLAG_COMPLETED = 0x0001
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: false },
{ act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: false },
{ act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: false },
{ act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: false },
{ act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: false },
{ act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: false },
{ act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: false },
{ act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: false },
{ act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: false },
],
2: [
{ act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: false },
{ act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: false },
{ act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', nameZh: '干燥高地', unlocked: false },
{ act: 2, index: 3, waypointId: 12, levelId: 57, slug: 'halls-of-the-dead-level-2', nameEn: 'Halls of the Dead Level 2', nameZh: '死亡之殿第二层', unlocked: false },
{ act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: false },
{ act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: false },
{ act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: false },
{ act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: false },
{ act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: false },
],
3: [
{ act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: false },
{ act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: false },
{ act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: false },
{ act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: false },
{ act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: false },
{ act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: false },
{ act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: false },
{ act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', nameZh: '崔凡克', unlocked: false },
{ act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: false },
],
4: [
{ act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: false },
{ act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: false },
{ act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: false },
],
5: [
{ act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: false },
{ act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: false },
{ act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: false },
{ act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: false },
{ act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: false },
{ act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: false },
{ act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: false },
{ act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: false },
{ act: 5, index: 8, waypointId: 38, levelId: 129, slug: 'the-worldstone-keep-level-2', nameEn: 'Worldstone Keep Level 2', nameZh: '世界之石要塞第二层', unlocked: false },
],
}
/** 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 interface NpcMenuState {
/** Server unit id of the NPC (from `NpcInit`); `null` for menus opened without a server unit. */
npcId: number | null
npcUnitId?: number | undefined
npcName: string
x: number
y: number
dialogLines: readonly string[]
talking: boolean
descriptor?: TownNpcServiceDescriptor | undefined
options?: readonly NpcMenuOption[] | undefined
hoveredOptionIdx?: number | null | undefined
}
export interface NpcSpeechState {
npcId: number
descriptor?: TownNpcServiceDescriptor | undefined
title: string
npcName: string
text: string
lines: readonly string[]
topic: 'intro' | 'gossip' | 'quest'
onClose?: (() => void) | undefined
}
export class WorldPanelsHud {
lang: TblLang = getTblLang()
language: 'zh' | 'en' = getTblLang() === 'ENG' ? 'en' : 'zh'
private _activePanel: WorldPanelType = 'none'
isDead = false
merc: any = null
hireList: readonly any[] = []
mercHireList: readonly any[] = []
hireScrollOffset = 0
hireSelectedIndex = -1
hireHoveredIndex: number | null = null
replaceMercConfirmOpen = false
pendingHireCandidate: any = null
hireConfirmHoveredOption: 0 | 1 | null = null
playerGold = 0
hasDeadMercenary = false
deadMercenaryReviveCost = 0
partyMembers: any[] = []
trade: any = null
questRewardItem: UiInventoryItem | null = null
activeNpcId: number | null = null
onQuestMessage: ((npcId: number, messageId: number) => void) | null = null
onNpcEntityAction: ((action: number, npcId: number, complement?: number) => void) | null = null
onHireMerc: ((npcId: number, mercId: number) => void) | null = null
onResurrectMerc: ((npcId: number) => void) | null = null
onMercItem: ((slot: number) => void) | null = null
onPartyAction: ((action: number, targetPlayerId: number) => void) | null = null
onHostile: ((targetPlayerId: number, hostile: boolean, button?: number) => void) | null = null
onSetHostile: ((targetPlayerId: number, hostile: boolean, button?: number) => void) | null = null
onClickButton: ((buttonId: number, complement?: number) => void) | null = null
onConfirmQuestReward: ((itemId: number, npcId?: number) => void) | null = null
onMercEquipSlotClick: ((slot: number) => void) | null = null
onHireMercClick: ((npcId: number, mercId: number) => void) | null = null
onPartyActionClick: ((action: number, targetPlayerId: number) => void) | null = null
onHostileToggleClick: ((targetPlayerId: number, hostile: boolean, button?: number) => void) | null = null
onTradeButtonClick: ((buttonId: number, complement?: number) => void) | null = null
onTradeGridClick:
| ((col: number, row: number, hitItem?: UiInventoryItem | null, isRightClick?: boolean) => void)
| null = null
onConfirmQuestRewardClick: ((itemId: number, npcId?: number) => void) | null = null
onCloseActivePanel: (() => void) | null = null
onNpcMenuAction: ((actionId: NpcMenuActionId, npcId: number, descriptor: TownNpcServiceDescriptor) => void) | null = null
onIdentifyAll: ((npcId: number, descriptor?: TownNpcServiceDescriptor) => void) | null = null
onCloseNpcPanel: (() => void) | null = null
private _npcMenu: NpcMenuState | null = null
private _hireOriginNpc: { descriptor: TownNpcServiceDescriptor; x: number; y: number; npcId: number } | null = null
private _npcSpeech: NpcSpeechState | null = null
private _speechOpenedDuringAction = false
get activePanel(): WorldPanelType {
return this._activePanel
}
set activePanel(val: WorldPanelType) {
this._activePanel = val
}
get npcMenu(): NpcMenuState | null {
return this._npcMenu
}
set npcMenu(val: NpcMenuState | null) {
if (val) {
const unitId = val.npcUnitId ?? val.npcId ?? 0
if (unitId > 0) this.activeNpcId = unitId
if (val.npcUnitId === undefined) {
this._npcMenu = { ...val, npcUnitId: unitId }
} else {
this._npcMenu = val
}
} else {
this._npcMenu = null
}
}
get npcSpeech(): NpcSpeechState | null {
return this._npcSpeech
}
set npcSpeech(val: NpcSpeechState | null) {
this._npcSpeech = val
}
selectedActTab = 1
selectedQuestIdx = 0
/** Server stat 15 (`goldbank`), written by `HudModel` only; 0 until received (absent stat = 0). */
stashGold = 0
private _starterStashCleared = false
cubeTransmuteCount = 0
escapeMenuOpen = false
escapeMenuSelectedIndex: EscapeMenuRowIndex = 2
lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null
private _clearStarterStashIfRawItem(item: UiInventoryItem | null | undefined): void {
if (!this._starterStashCleared && item && !Array.isArray((item as any).allowedSlots)) {
this._starterStashCleared = true
this.stashItems = []
}
}
activeVendorDescriptor: TownNpcServiceDescriptor | null = null
vendorMode: 'trade' | 'gamble' = 'trade'
activeVendorTab: VendorTabId = 'armor'
/** Server unit id of the NPC whose store is open (`ClientWorld.npcInteraction.npcId`); `null` when closed. */
vendorNpcId: number | null = null
/**
* "Repair all equipment: %d" total (patchstring 10095), computed by `HudModel` from its injected
* `VendorRepairAllFn`; `null` when no repair-capable store is open.
*/
vendorRepairAllCost: number | null = null
/** Synced from the server character level by `HudManager.syncPlayerContext()`. */
vendorCharLevel = 0
vendorPlayerStats: PlayerItemRequirementContext = {
level: 0,
str: 0,
dex: 0,
}
vendorTradeState: VendorTradeState = 'idle'
hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null
pressedMomentarySlot: 0 | 1 | 2 | 3 | null = null
hoveredCubeButton: 'transmute' | 'close' | null = null
pressedCubeButton: 'transmute' | 'close' | 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[] = []
playerContextSeq = 0
setPlayerContext(ctx: Partial<PlayerItemRequirementContext>): void {
const nextLevel = ctx.level !== undefined ? Math.max(1, ctx.level) : this.vendorCharLevel
this.vendorCharLevel = nextLevel
this.vendorPlayerStats = {
level: nextLevel,
str: ctx.str !== undefined ? Math.max(0, ctx.str) : this.vendorPlayerStats.str,
dex: ctx.dex !== undefined ? Math.max(0, ctx.dex) : this.vendorPlayerStats.dex,
classCode: ctx.classCode ?? this.vendorPlayerStats.classCode,
}
const seq = nextPlayerContextSeq()
this.playerContextSeq = seq
if (this.activePlayerInventory) {
this.activePlayerInventory.playerContext = { ...this.vendorPlayerStats }
this.activePlayerInventory.playerContextSeq = seq
}
}
getEffectivePlayerContext(): PlayerItemRequirementContext {
if (
this.activePlayerInventory &&
this.activePlayerInventory.playerContextSeq > this.playerContextSeq
) {
return {
...this.activePlayerInventory.playerContext,
}
}
return {
...this.vendorPlayerStats,
level: this.vendorCharLevel,
}
}
canPlayerUseVendorItem(item: UiInventoryItem): boolean {
return canPlayerUseUiItem(item, this.getEffectivePlayerContext(), this.vendorMode)
}
get repairCursorMode(): boolean {
return this.vendorTradeState === 'repair'
}
set repairCursorMode(val: boolean) {
this.vendorTradeState = val ? 'repair' : 'idle'
}
releaseMomentaryVendorButton(): void {
this.pressedMomentarySlot = null
this.pressedCubeButton = null
}
/**
* D2Client.dll `0x6fb3b600` (Transmute button) & `0x6fb3b650` (Cube Close button):
* - Transmute: `relX ∈ [144, 184]`, `relY ∈ [257, 297]` (`x ∈ [224, 264]`, `y ∈ [317, 357]`)
* - Close: `relX ∈ [275, 315]`, `relY ∈ [380, 420]` (`x ∈ [355, 395]`, `y ∈ [440, 480]`)
*/
hitTestCubeButton(logicalX: number, logicalY: number): 'transmute' | 'close' | null {
if (
logicalX >= CUBE_TRANSMUTE_BTN_BOUNDS.hitMinX &&
logicalX <= CUBE_TRANSMUTE_BTN_BOUNDS.hitMaxX &&
logicalY >= CUBE_TRANSMUTE_BTN_BOUNDS.hitMinY &&
logicalY <= CUBE_TRANSMUTE_BTN_BOUNDS.hitMaxY
) {
return 'transmute'
}
if (
logicalX >= CUBE_CLOSE_BTN_BOUNDS.hitMinX &&
logicalX <= CUBE_CLOSE_BTN_BOUNDS.hitMaxX &&
logicalY >= CUBE_CLOSE_BTN_BOUNDS.hitMinY &&
logicalY <= CUBE_CLOSE_BTN_BOUNDS.hitMaxY
) {
return 'close'
}
return 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
}
/**
* Returns the active `buysell.dc6` cursor frame index per D2Client.dll `0x6fb475e1` / `0x6fbbb238` (+0x10),
* or `null` when `vendorTradeState === 'idle'`.
*/
getActiveVendorCursorFrame(): number | null {
if (this.vendorTradeState === 'idle') return null
return VENDOR_BUTTON_SPECS[this.vendorTradeState].cursorFrame
}
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, context?: NpcMenuContext): readonly NpcMenuOption[] {
return buildNpcMenuOptions(descriptor, context)
}
setLanguage(lang: 'en' | 'zh' | 'ENG' | 'CHI'): void {
if (lang === 'en' || lang === 'ENG') {
this.language = 'en'
this.lang = 'ENG'
} else {
this.language = 'zh'
this.lang = 'CHI'
}
if (this._npcSpeech && this._npcSpeech.descriptor) {
const desc = this._npcSpeech.descriptor
const lines = getLocalizedNpcGreetings(desc, this.lang)
const title = getLocalizedNpcName(desc, this.lang)
this._npcSpeech.title = title
this._npcSpeech.npcName = title
this._npcSpeech.lines = lines
this._npcSpeech.text = lines.join('\n')
}
}
openNpcSpeech(
descriptor: TownNpcServiceDescriptor,
textOverride?: string,
topic: 'intro' | 'gossip' | 'quest' = 'intro',
): void {
this._speechOpenedDuringAction = true
const lines = textOverride
? textOverride.split('\n')
: getLocalizedNpcGreetings(descriptor, this.lang)
const text = textOverride ?? lines.join('\n')
const title = getLocalizedNpcName(descriptor, this.lang)
const npcId = this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? 0
this._npcSpeech = {
npcId,
descriptor,
title,
npcName: title,
text,
lines,
topic,
}
if (this._npcMenu) {
this._npcMenu.talking = true
}
}
closeNpcSpeech(): void {
this._npcSpeech = null
if (this._npcMenu) {
this._npcMenu.talking = false
}
}
openNpcMenu(
arg0: number | TownNpcServiceDescriptor,
arg1?: TownNpcServiceDescriptor | number,
arg2?: number | NpcMenuContext,
arg3?: number | null,
arg4?: NpcMenuContext,
): void {
let descriptor: TownNpcServiceDescriptor
let screenX = 400
let screenY = 220
let npcId: number | null = null
let context: NpcMenuContext | undefined
if (typeof arg0 === 'number') {
npcId = arg0
descriptor = arg1 as TownNpcServiceDescriptor
if (typeof arg2 === 'number') {
screenX = arg2
if (typeof arg3 === 'number') {
screenY = arg3
}
context = arg4
} else if (arg2 && typeof arg2 === 'object') {
context = arg2
}
} else {
descriptor = arg0
if (typeof arg1 === 'number') screenX = arg1
if (typeof arg2 === 'number') screenY = arg2
if (typeof arg3 === 'number' || arg3 === null) npcId = arg3
context = arg4
}
const effectiveContext: NpcMenuContext = {
hasUnidentifiedItems: context?.hasUnidentifiedItems ?? true,
hasDeadMercenary: context?.hasDeadMercenary ?? this.hasDeadMercenary ?? (this.merc ? !this.merc.alive : false),
deadMercenaryReviveCost:
context?.deadMercenaryReviveCost ?? this.deadMercenaryReviveCost ?? this.merc?.reviveCost ?? 0,
includeQuestRewards: context?.includeQuestRewards,
legacyTradeId: context?.legacyTradeId,
}
const options = this.buildNpcMenuOptions(descriptor, effectiveContext)
const resolvedUnitId = npcId ?? 0
// 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 = {
npcId,
npcUnitId: resolvedUnitId,
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, null, {
includeQuestRewards: false,
legacyTradeId: true,
})
return true
}
clickNpcMenuOption(optionId: string, context?: NpcMenuContext): boolean {
const menu = this._npcMenu
if (!menu || !menu.descriptor) return false
const desc = menu.descriptor
const npcUnitId = menu.npcUnitId ?? menu.npcId ?? this.activeNpcId ?? 0
if (npcUnitId > 0) this.activeNpcId = npcUnitId
if (optionId === 'talk_intro' || optionId === 'talk_gossip' || optionId === 'talk_quest') {
const topic = optionId === 'talk_intro' ? 'intro' : optionId === 'talk_gossip' ? 'gossip' : 'quest'
const msgIdx = optionId === 'talk_intro' ? 0 : optionId === 'talk_gossip' ? 1 : 2
let overrideText: string | undefined
if (optionId === 'talk_gossip' && desc.gossipLines && desc.gossipLines.length > 0) {
overrideText = desc.gossipLines.join('\n')
} else if (optionId === 'talk_quest' && desc.questLines) {
const firstQuestLine = Object.values(desc.questLines)[0]
if (firstQuestLine) overrideText = firstQuestLine
}
this.openNpcSpeech(desc, overrideText, topic)
this.onQuestMessage?.(npcUnitId, msgIdx)
this.onNpcMenuAction?.(optionId, npcUnitId, desc)
return true
}
if (optionId === 'talk_back') {
this.closeNpcSpeech()
this.onNpcMenuAction?.('talk_back', npcUnitId, desc)
return true
}
const opts = context ? this.buildNpcMenuOptions(desc, context) : (menu.options ?? [])
const opt = opts.find(
o =>
o.id === optionId ||
(optionId === 'trade' && o.id === 'trade_repair') ||
(optionId === 'trade_repair' && o.id === 'trade') ||
(optionId === 'resurrect_merc' && o.id === 'resurrect'),
)
if (!opt) return false
this._speechOpenedDuringAction = false
const hadOnNpcMenuAction = Boolean(this.onNpcMenuAction)
this.onNpcMenuAction?.(opt.id, npcUnitId, desc)
if (opt.id === 'talk') {
if (!this._speechOpenedDuringAction) {
this.openNpcSpeech(desc)
}
this.onQuestMessage?.(npcUnitId, 0)
return true
}
if (opt.id === 'cancel') {
this._npcMenu = null
this._npcSpeech = null
return true
}
if (opt.id === 'hire') {
this._hireOriginNpc = {
descriptor: desc,
x: menu.x,
y: menu.y,
npcId: npcUnitId,
}
this._npcMenu = null
this._activePanel = 'hire'
this.hireSelectedIndex = -1
this.hireHoveredIndex = null
this.hireScrollOffset = 0
this.replaceMercConfirmOpen = false
this.pendingHireCandidate = null
if (!hadOnNpcMenuAction) {
this.onNpcEntityAction?.(3, npcUnitId, 0)
}
return true
}
if (opt.id === 'resurrect') {
this._npcMenu = null
if (!hadOnNpcMenuAction) {
this.onResurrectMerc?.(npcUnitId)
}
return true
}
if (opt.id === 'travel_east' || opt.id === 'travel_west' || opt.id === 'travel_harrogath') {
this._npcMenu = null
if (!hadOnNpcMenuAction) {
const destArea =
opt.id === 'travel_harrogath'
? 109
: opt.id === 'travel_east'
? (desc.actIndex === 0 ? 40 : 75)
: (desc.actIndex === 1 ? 1 : 40)
this.onNpcEntityAction?.(0, npcUnitId, destArea)
}
return true
}
if (opt.id === 'imbue' || opt.id === 'add_sockets' || opt.id === 'personalize') {
this._npcMenu = null
this._activePanel = opt.id
return true
}
if (opt.id === 'identify') {
this._npcMenu = null
this.onIdentifyAll?.(npcUnitId, desc)
return true
}
if (opt.id === 'trade' || opt.id === 'trade_repair' || opt.id === 'gamble') {
this._npcMenu = null
this._activePanel = 'vendor'
return true
}
return true
}
confirmQuestReward(arg0?: number | 'imbue' | 'add_sockets' | 'personalize', arg1?: number): boolean {
const itemId = typeof arg0 === 'number' ? arg0 : arg1
const qItem = this.questRewardItem as { id?: string | number; itemId?: number } | null
const resolvedId =
itemId ??
(qItem ? Number(qItem.id ?? qItem.itemId ?? 0) : 0)
if (!resolvedId || resolvedId <= 0 || Number.isNaN(resolvedId)) return false
const npcId = this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0
if (this.onConfirmQuestReward) {
this.onConfirmQuestReward(resolvedId, npcId)
} else {
this.onNpcEntityAction?.(0, npcId, resolvedId)
}
this.questRewardItem = null
if (
this._activePanel === 'imbue' ||
this._activePanel === 'add_sockets' ||
this._activePanel === 'personalize'
) {
this._activePanel = 'none'
}
return true
}
/**
* Show the store the server filled with `0x9C AddToShop` items (`ClientWorld.containers.shop`).
* `pages`: one entry per shop item with its bitstream `storePage` (1..4 -> tab index 0..3, D2Client
* 0x6fb91178 tab table). Re-applying the same store keeps the selected tab unless it became empty
* (D2Client 0x6fb3f710 advances to the next non-empty tab).
*/
setVendorShop(p: {
readonly npcId: number
readonly descriptor: TownNpcServiceDescriptor
readonly mode: 'trade' | 'gamble'
readonly items: readonly { readonly storePage: number; readonly placement: GridPlacement }[]
}): void {
// Fail fast against the D2Client.dll 0x6fb3f430 button layout table.
resolveVendorButtonLayout(p.descriptor.hcIdx, p.mode)
const sameStore =
this.vendorNpcId === p.npcId &&
this.vendorMode === p.mode &&
this.activeVendorDescriptor?.hcIdx === p.descriptor.hcIdx
if (!sameStore) {
this.vendorTradeState = 'idle'
this.hoveredVendorButtonSlot = null
this.pressedMomentarySlot = null
this.hoveredVendorItem = null
}
this.activeVendorDescriptor = p.descriptor
this.vendorNpcId = p.npcId
this.vendorMode = p.mode
const tabs: Record<VendorTabId, GridPlacement[]> = { armor: [], weapons1: [], weapons2: [], misc: [] }
const gamble: GridPlacement[] = []
for (const entry of p.items) {
if (p.mode === 'gamble') {
gamble.push(entry.placement)
continue
}
const tab = VENDOR_TABS[entry.storePage - 1]
if (!tab) throw new Error(`setVendorShop: shop item store page ${entry.storePage} is outside 1..4`)
tabs[tab.id].push(entry.placement)
}
this.vendorTabPlacements = tabs
this.gamblePlacements = gamble
if (p.mode === 'gamble') {
this.activeVendorTab = 'armor'
} else if (!sameStore) {
// D2Client.dll 0x6fb411a0 -> 0x6fb3f710(0): start at tab 0 and cycle to the first non-empty tab.
this.selectVendorTab('armor')
} else if (this.vendorTabPlacements[this.activeVendorTab].length === 0) {
this.selectVendorTab(this.activeVendorTab)
}
if (this.hoveredVendorItem && !this.getActiveVendorPlacements().some(pl => pl.item.id === this.hoveredVendorItem!.item.id)) {
this.hoveredVendorItem = null
}
}
/** Forget the store (NpcCancel sent: D2Client frees the store units, the server sends no RemoveFromShop). */
clearVendorShop(): void {
this.activeVendorDescriptor = null
this.vendorNpcId = null
this.vendorMode = 'trade'
this.vendorTradeState = 'idle'
this.vendorRepairAllCost = null
this.hoveredVendorButtonSlot = null
this.pressedMomentarySlot = null
this.hoveredVendorItem = null
this.vendorTabPlacements = { armor: [], weapons1: [], weapons2: [], misc: [] }
this.gamblePlacements = []
this.activeVendorTab = 'armor'
}
/** Repair-all total set by `HudModel`; throws when the store has a Repair All button but no cost was provided. */
requireRepairAllCost(): number {
if (this.vendorRepairAllCost === null) {
throw new Error('WorldPanelsHud: Repair All cost not provided (HudModel needs a VendorRepairAllFn)')
}
return this.vendorRepairAllCost
}
getActiveVendorPlacements(): GridPlacement[] {
if (this.vendorMode === 'gamble') {
return this.gamblePlacements
}
return this.vendorTabPlacements[this.activeVendorTab] ?? []
}
/** 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
private cachedMiniConvertImg: 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)
}
/**
* Replace every quest status from the server quest-flag words (`ClientWorld.quests`):
* bit 0 set = completed, any other bit set = active (started), 0 = locked (not started).
*/
applyServerQuestFlags(flags: ArrayLike<number>): void {
this.questStatuses.clear()
for (const quest of ALL_QUESTS) {
const word = flags[questFlagWordIndex(quest)] ?? 0
this.questStatuses.set(
quest.id,
(word & QUEST_FLAG_COMPLETED) !== 0 ? 'completed' : word !== 0 ? 'active' : 'locked',
)
}
}
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, lang: TblLang = this.lang): QuestDisplayText {
const status = this.getQuestStatus(quest)
const isEn = lang === 'ENG'
const statusTag = isEn
? status === 'completed'
? '[Completed]'
: status === 'active'
? '[Active]'
: '[Locked]'
: status === 'completed'
? '[已完成]'
: status === 'active'
? '[进行中]'
: '[未开启]'
const title = isEn
? quest.titleEn || this.lookupQuestString(quest.titleKey) || quest.titleZh
: this.lookupQuestString(quest.titleKey) ?? quest.titleZh ?? quest.titleEn
let body = ''
const rewardText = isEn
? quest.rewardSummaryEn || quest.rewardSummaryZh
: quest.rewardSummaryZh || quest.rewardSummaryEn
if (status === 'completed') {
const tblCompleted = this.lookupQuestString(quest.completedKey)
body = isEn
? quest.completedEn || tblCompleted || quest.completedZh || quest.description
: tblCompleted || quest.completedZh || quest.completedEn || quest.description
} else if (status === 'active') {
const tblObjective = this.lookupQuestString(quest.objectiveKey)
body = isEn
? quest.objectiveEn || tblObjective || quest.objectiveZh || quest.description
: tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description
} else {
body = isEn
? 'This quest has not begun yet. Speak to townsfolk or explore the wilderness to begin.'
: '此任务尚未开启。与城镇居民对话或探索荒野以开启任务。'
}
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
}
private cachedTextSlidImg: HTMLImageElement | null = null
private getTextSlidImage(): HTMLImageElement | null {
if (this.cachedTextSlidImg) return this.cachedTextSlidImg
if (typeof Image !== 'undefined') {
const img = new Image()
img.src = BAKED_UI_MANIFEST.images.textslid ?? '/ui/textslid.png'
this.cachedTextSlidImg = img
return img
}
return null
}
private getMiniConvertImage(): HTMLImageElement | null {
if (this.cachedMiniConvertImg) return this.cachedMiniConvertImg
if (typeof Image !== 'undefined') {
const img = new Image()
img.src = BAKED_UI_MANIFEST.images.miniConvert ?? '/ui/miniconvert.png'
this.cachedMiniConvertImg = 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
hoveredMercItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredTradeItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredQuestRewardItem: { 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(rawItem: UiInventoryItem | Item): boolean {
const item = rawItem as UiInventoryItem
this._clearStarterStashIfRawItem(item)
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,
rawCursorItem: UiInventoryItem | Item | null,
): { handled: boolean; newCursorItem: UiInventoryItem | null; cursorItem: UiInventoryItem | null } {
const makeRes = (handled: boolean, newCursorItem: UiInventoryItem | null) => {
const res: any = { handled, newCursorItem }
Object.defineProperty(res, 'cursorItem', { value: newCursorItem, enumerable: false })
return res as { handled: boolean; newCursorItem: UiInventoryItem | null; cursorItem: UiInventoryItem | null }
}
const cursorItem = rawCursorItem as UiInventoryItem | null
this._clearStarterStashIfRawItem(cursorItem)
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 makeRes(false, null)
this.stashItems = this.stashItems.filter(p => p !== hit)
return makeRes(true, 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 makeRes(true, cursorItem)
}
return makeRes(true, null)
}
}
if (directHit && canSocketUiItem(cursorItem, directHit.item)) {
applySocketToUiItem(directHit.item, cursorItem)
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(cursorItem, qty - 1)
return makeRes(true, cursorItem)
}
return makeRes(true, null)
}
if (
col < 0 ||
row < 0 ||
col + cursorItem.invWidth > STASH_GRID_ORIGIN.cols ||
row + cursorItem.invHeight > STASH_GRID_ORIGIN.rows
) {
return makeRes(false, cursorItem)
}
const overlaps = this.getStashOverlaps(col, row, cursorItem.invWidth, cursorItem.invHeight)
if (overlaps.length === 0) {
this.stashItems.push({ item: cursorItem, col, row })
return makeRes(true, 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 makeRes(true, cursorItem)
}
return makeRes(true, null)
}
}
if (canSocketUiItem(cursorItem, single.item)) {
applySocketToUiItem(single.item, cursorItem)
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(cursorItem, qty - 1)
return makeRes(true, cursorItem)
}
return makeRes(true, null)
}
this.stashItems = this.stashItems.filter(p => p !== single)
this.stashItems.push({ item: cursorItem, col, row })
return makeRes(true, single.item)
}
return makeRes(false, cursorItem)
}
quickTransferToStash(rawItem: UiInventoryItem | Item, fromInventory: InventoryPanel): boolean {
const hitIdx = fromInventory.gridItems.findIndex(
p => p.item === rawItem || (rawItem.id !== undefined && p.item.id === String(rawItem.id)),
)
if (hitIdx === -1) return false
const item = fromInventory.gridItems[hitIdx]!.item
if (!this.autoPlaceInStash(item)) return false
fromInventory.gridItems.splice(hitIdx, 1)
return true
}
quickTransferFromStash(rawItem: UiInventoryItem | Item, toInventory: InventoryPanel): boolean {
const hitIdx = this.stashItems.findIndex(
p => p.item === rawItem || (rawItem.id !== undefined && p.item.id === String(rawItem.id)),
)
if (hitIdx === -1) return false
const item = this.stashItems[hitIdx]!.item
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 }
}
}
if (directHit && canSocketUiItem(cursorItem, directHit.item)) {
applySocketToUiItem(directHit.item, cursorItem)
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(cursorItem, qty - 1)
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 }
}
}
if (canSocketUiItem(cursorItem, single.item)) {
applySocketToUiItem(single.item, cursorItem)
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(cursorItem, qty - 1)
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 }
}
/**
* Executes Horadric Cube transmutation recipes (`CubeMain.txt`) on `this.cubeItems`.
*/
transmuteCube(): CubeTransmuteResult {
const res = transmuteCubeItems(this.cubeItems)
if (res.matched) {
this.cubeItems = [...res.resultItems]
}
return res
}
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
}
handleMouseMove(logicalX: number, logicalY: number): void {
if (this._activePanel === 'hire') {
this.handleHireMercMove(logicalX, logicalY)
return
}
this.hoveredStashItem = null
this.hoveredCubeItem = null
this.hoveredVendorItem = null
this.hoveredMercItem = null
this.hoveredTradeItem = null
this.hoveredQuestRewardItem = null
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
? this.hitTestVendorButtonSlot(logicalX, logicalY)
: null
this.hoveredCubeButton = this.hitTestCubeButton(logicalX, logicalY)
if (this.pressedCubeButton !== null && this.hoveredCubeButton !== this.pressedCubeButton) {
this.pressedCubeButton = 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,
}
}
}
// Mercenary equipment slots (`merc` left panel)
const m = this.merc
if (m) {
const eq = m.equipped ?? m.equipment
const mercSlots: ReadonlyArray<{ x: number; y: number; w: number; h: number; item: UiInventoryItem | null }> = [
{ x: ox + 132, y: oy + 44, w: 56, h: 56, item: m.headItem ?? eq?.helm ?? eq?.head ?? null },
{ x: ox + 132, y: oy + 112, w: 56, h: 84, item: m.bodyItem ?? eq?.armor ?? eq?.torso ?? null },
{ x: ox + 36, y: oy + 98, w: 56, h: 112, item: m.weaponItem ?? eq?.weapon1 ?? eq?.rightHand ?? null },
{ x: ox + 228, y: oy + 98, w: 56, h: 112, item: m.shieldItem ?? eq?.weapon2 ?? eq?.leftHand ?? null },
]
for (const s of mercSlots) {
if (s.item && logicalX >= s.x && logicalX <= s.x + s.w && logicalY >= s.y && logicalY <= s.y + s.h) {
this.hoveredMercItem = {
item: s.item,
x: s.x + s.w / 2,
y: s.y,
}
break
}
}
}
// Player-to-player trade grids (`trade` left panel)
if (this.trade) {
const checkTradeGrid = (gridY: number, items: readonly any[] | undefined) => {
if (!items || logicalX < ox + 15 || logicalX >= ox + 15 + 290 || logicalY < gridY || logicalY >= gridY + 116) {
return
}
const col = Math.floor((logicalX - (ox + 15)) / 29)
const row = Math.floor((logicalY - gridY) / 29)
const hit = items.find((it: any) => {
const gx = Number(it.gridX ?? it.col ?? 0)
const gy = Number(it.gridY ?? it.row ?? 0)
const gw = Math.max(1, Number(it.invWidth ?? it.w ?? 1))
const gh = Math.max(1, Number(it.invHeight ?? it.h ?? 1))
return col >= gx && col < gx + gw && row >= gy && row < gy + gh
})
if (hit) {
const gx = Number(hit.gridX ?? hit.col ?? 0)
const gy = Number(hit.gridY ?? hit.row ?? 0)
const gw = Math.max(1, Number(hit.invWidth ?? hit.w ?? 1))
this.hoveredTradeItem = {
item: hit as UiInventoryItem,
x: ox + 15 + (gx + gw / 2) * 29,
y: gridY + gy * 29,
}
}
}
checkTradeGrid(oy + 60, this.trade.partnerItems)
if (!this.hoveredTradeItem) {
checkTradeGrid(oy + 220, this.trade.selfItems ?? this.trade.myItems)
}
}
// Quest reward slot (`imbue` / `add_sockets` / `personalize` left panel)
if (
this.questRewardItem &&
logicalX >= ox + 116 &&
logicalX <= ox + 116 + 88 &&
logicalY >= oy + 120 &&
logicalY <= oy + 120 + 116
) {
this.hoveredQuestRewardItem = {
item: this.questRewardItem,
x: ox + 116 + 44,
y: oy + 120,
}
}
}
getNpcMenuLayout(
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): NpcDialogLayout | null {
if (!this.npcMenu) return null
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, label: '取消', labelZh: '取消', labelEn: 'cancel' },
]
const headerName = this.npcMenu.descriptor
? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
return computeNpcDialogLayout(
headerName,
opts,
this.npcMenu.x,
this.npcMenu.y,
font,
this.lang,
viewportWidth,
viewportHeight,
)
}
isPointInNpcMenu(
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
const boxW = 340
const boxH = 156
const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
return logicalX >= bx && logicalX <= bx + boxW && logicalY >= by && logicalY <= by + boxH
}
const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight)
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,
viewportWidth = 800,
viewportHeight = 600,
): void {
if (!this.npcMenu || this.npcMenu.talking) return
const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight)
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
/** Menu dismissed (`cancel` option or click outside): the caller sends `NpcCancel` (0x30). */
onCancel: () => void
},
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
this.npcMenu.talking = false
this._npcSpeech = null
return true
}
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, label: '取消', labelZh: '取消', labelEn: 'cancel' },
]
const headerName = this.npcMenu.descriptor
? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
const layout = computeNpcDialogLayout(
headerName,
opts,
this.npcMenu.x,
this.npcMenu.y,
font,
this.lang,
viewportWidth,
viewportHeight,
)
if (
logicalX < layout.left ||
logicalX > layout.left + layout.width ||
logicalY < layout.top ||
logicalY > layout.top + layout.height
) {
this.npcMenu = null
this._npcSpeech = null
callbacks.onCancel()
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.clickNpcMenuOption('talk')
if (this.npcMenu) {
this.npcMenu.talking = true
}
} else if ((opt.id === 'trade' || opt.id === 'trade_repair') && 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 if (opt.id === 'cancel') {
this.npcMenu = null
this._npcSpeech = null
callbacks.onCancel()
} else {
this.clickNpcMenuOption(opt.id)
}
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
/**
* Server-authoritative mode: the cube Transmute button only requests `ClickButton 0x18`; no local recipe run.
*/
onTransmute?: () => void
/**
* Server-authoritative mode: stash / cube grid clicks are forwarded (pick-up / place / use) and never move
* items locally. The containers are rebuilt from `ClientWorld` when the server answers.
*/
onContainerGridClick?: (
container: 'stash' | 'cube',
col: number,
row: number,
hitItem: UiInventoryItem | null,
isRightClick: boolean,
) => void
/** Stash gold coin button: open the withdraw gold dialog. */
onStashGoldButton?: () => void
/** Vendor grid click on a store item (buy / gamble). `shiftFill`: Shift held (0x32 shift-fill flag). */
onVendorBuy?: (item: UiInventoryItem, shiftFill: boolean) => void
/** Vendor grid click while holding a cursor item: sell it (0x33). */
onVendorSell?: (item: UiInventoryItem) => void
/** Repair All button (0x35 with field4 = 0x80000000). */
onVendorRepairAll?: () => void
},
): 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
this.pressedCubeButton = 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 (`D2Client.dll` 0x6fb3b600: `relX ∈ [144, 184], relY ∈ [257, 297]`, drawn at `ox + 144, oy + 260, 32x32`)
if (this.hitTestCubeButton(logicalX, logicalY) === 'transmute') {
if (!callbacks.isRightClick) {
this.pressedCubeButton = 'transmute'
if (callbacks.onTransmute) {
callbacks.onTransmute()
} else {
this.cubeTransmuteCount += 1
this.transmuteCube()
}
}
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.onContainerGridClick) {
callbacks.onContainerGridClick('cube', col, row, hit?.item ?? null, callbacks.isRightClick === true)
return true
}
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') {
// Stash gold coin button (panel-relative x 73..150, y 24..41): opens the withdraw dialog.
if (
logicalX >= ox + STASH_GOLD_BTN_HIT.relX0 &&
logicalX <= ox + STASH_GOLD_BTN_HIT.relX1 &&
logicalY >= oy + STASH_GOLD_BTN_HIT.relY0 &&
logicalY <= oy + STASH_GOLD_BTN_HIT.relY1
) {
if (!callbacks.isRightClick && this.stashGold > 0) {
if (!callbacks.onStashGoldButton) throw new Error('handleLeftDockClick: stash gold button needs onStashGoldButton')
callbacks.onStashGoldButton()
}
return true
}
// 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.onContainerGridClick) {
callbacks.onContainerGridClick('stash', col, row, hit?.item ?? null, callbacks.isRightClick === true)
return true
}
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')
}
if (callbacks.isRightClick && this.vendorTradeState !== 'idle') {
this.vendorTradeState = 'idle'
return true
}
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
if (!callbacks.onVendorRepairAll) throw new Error('handleLeftDockClick: Repair All needs onVendorRepairAll')
callbacks.onVendorRepairAll()
} 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
// Holding a cursor item over the store grid sells it (server answers with 0x2A + item/stat packets).
if (inv?.cursorItem) {
if (!callbacks.onVendorSell) throw new Error('handleLeftDockClick: vendor sell needs onVendorSell')
callbacks.onVendorSell(inv.cursorItem)
return true
}
// Buy works in 'idle' or 'buy' state; never buy while the sell / repair cursor is armed.
if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') {
const hit = this.getActiveVendorPlacements().find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (hit) {
if (!callbacks.onVendorBuy) throw new Error('handleLeftDockClick: vendor buy needs onVendorBuy')
callbacks.onVendorBuy(hit.item, Boolean(callbacks.isShiftClick))
}
}
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
miniConvertImg?: 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
const isEn = this.lang === 'ENG'
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, isEn ? q.titleEn : q.titleZh, sx + 40, sy + 56, {
font: 'font8',
color: status === 'completed' ? 'gold' : 'tan',
align: 'center',
})
font.drawText(
ctx,
status === 'completed' ? (isEn ? 'Completed' : '已完成') : (isEn ? 'Active' : '进行中'),
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} ${qInfo.statusTag}`, ox + 160, oy + 282, {
font: 'fontexocet10',
color: qInfo.status === 'completed' ? 'gold' : 'white',
align: 'center',
})
const rewardSuffix = qInfo.status === 'completed'
? (qInfo.rewardText ? (isEn ? `\n[Reward: ${qInfo.rewardText}]` : `\n[奖励: ${qInfo.rewardText}]`) : '')
: (qInfo.rewardText ? (isEn ? `\n[Objective: ${qInfo.rewardText}]` : `\n[目标: ${qInfo.rewardText}]`) : '')
this.drawWrappedText(ctx, font, qInfo.body + rewardSuffix, ox + 26, oy + 300, 268, 14, 6, 'fontformal12')
}
} 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, isEn ? wp.nameEn : wp.nameZh, 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, requireTbl('bank', this.lang), ox + 172, oy + 35, {
font: 'font8',
color: 'gold',
align: 'center',
})
font.drawText(
ctx,
`${requireTbl('strGoldInStash', this.lang)} ${this.stashGold}`,
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
const playerCtx = this.getEffectivePlayerContext()
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) || !canPlayerUseUiItem(placed.item, playerCtx)
? '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)
}
drawItemSocketOverlay(ctx, placed.item, gx, gy, gw, gh, {
itemsAtlasImg: atlas,
itemRects: BAKED_UI_MANIFEST.itemRects,
})
}
} else if (kind === 'cube') {
if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy)
font.drawText(
ctx,
isEn
? `Horadric Cube (Transmuted: ${this.cubeTransmuteCount})`
: `赫拉迪克方块 (已合成 ${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
const playerCtx = this.getEffectivePlayerContext()
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) || !canPlayerUseUiItem(placed.item, playerCtx)
? '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)
}
drawItemSocketOverlay(ctx, placed.item, gx, gy, gw, gh, {
itemsAtlasImg: atlas,
itemRects: BAKED_UI_MANIFEST.itemRects,
})
}
// D2Client.dll `0x6fb49615..0x6fb49656`: Cube Close button (`buysellbtn.dc6` frame 10 or 11 at `ox + 275, oy + 383`)
if (assets.buySellBtnImg) {
const closeFrame = this.pressedCubeButton === 'close' ? 11 : 10
ctx.drawImage(
assets.buySellBtnImg,
closeFrame * 32,
0,
32,
32,
ox + CUBE_CLOSE_BTN_BOUNDS.relX,
oy + CUBE_CLOSE_BTN_BOUNDS.relY,
CUBE_CLOSE_BTN_BOUNDS.w,
CUBE_CLOSE_BTN_BOUNDS.h,
)
}
// D2Client.dll `0x6fb49657..0x6fb49698`: Cube Transmute button (`miniconvert.dc6` frame 0 or 1 at `ox + 144, oy + 260`)
const miniConvertImg = assets.miniConvertImg ?? this.getMiniConvertImage()
if (miniConvertImg) {
const transmuteFrame = this.pressedCubeButton === 'transmute' ? 1 : 0
ctx.drawImage(
miniConvertImg,
transmuteFrame * 32,
0,
32,
32,
ox + CUBE_TRANSMUTE_BTN_BOUNDS.relX,
oy + CUBE_TRANSMUTE_BTN_BOUNDS.relY,
CUBE_TRANSMUTE_BTN_BOUNDS.w,
CUBE_TRANSMUTE_BTN_BOUNDS.h,
)
}
// D2Client.dll `0x6fb49720..0x6fb497c8`: Centered framed hover tooltip (`改變`/`Transmute`, `關閉`/`Close` in Font16)
if (this.hoveredCubeButton !== null) {
const spec =
this.hoveredCubeButton === 'transmute' ? CUBE_TRANSMUTE_BTN_BOUNDS : CUBE_CLOSE_BTN_BOUNDS
const hoverText = isEn ? spec.labelEn : spec.labelZh
const padX = 6
const boxW = Math.max(24, font.measureText(hoverText, 'font16') + padX * 2)
const boxH = 22
const centerX = ox + (spec.tooltipCenterX - CUBE_PANEL_ORIGIN.x)
const anchorY = oy + (spec.tooltipAnchorY - CUBE_PANEL_ORIGIN.y)
const boxX = Math.round(centerX - boxW / 2)
const boxY = anchorY - boxH
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
ctx.fillRect(boxX, boxY, boxW, boxH)
font.drawText(ctx, hoverText, centerX, boxY + 17, {
font: 'font16',
color: 'white',
align: 'center',
})
}
} 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..0x6fb45b58`: Items on the active store page (`Inventory.txt` Monster2 grid at `ox+16, oy+63`)
const atlas = assets.itemsAtlasImg ?? null
const placements = this.getActiveVendorPlacements()
const playerCtx = this.getEffectivePlayerContext()
const hasCursorItem = Boolean((assets.inventory ?? this.activePlayerInventory)?.cursorItem)
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 = resolveGridItemBackgroundFill(placed.item, playerCtx, {
isHovered,
hasCursorItem,
vendorMode: this.vendorMode,
})
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 `.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, isEn ? tab.labelEn : 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'
? requireTbl('patchstring#95', this.lang).replace('%d', String(this.requireRepairAllCost()))
: isEn
? spec.labelEn
: 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',
})
}
}
} else if (
kind === 'merc' ||
kind === 'hire' ||
kind === 'party' ||
kind === 'trade' ||
kind === 'imbue' ||
kind === 'add_sockets' ||
kind === 'personalize'
) {
this.renderActiveLeftPanel(ctx, kind)
}
if (kind !== 'vendor' && kind !== 'cube' && 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
},
viewportWidth = 800,
viewportHeight = 600,
): void {
if (!this.npcMenu) return
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
if (this.npcMenu.talking) {
const talkHeaderName = this.npcMenu.descriptor
? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
const boxW = 340
const boxH = 156
const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
drawStoneBoxFrame(ctx, bx, by, boxW, boxH, boxPiecesImg)
font.drawText(ctx, talkHeaderName, bx + Math.trunc(boxW / 2), by + 24, {
font: 'font16',
color: 'gold',
align: 'center',
})
const dialogLines = this._npcSpeech?.lines
? this._npcSpeech.lines
: this.npcMenu.descriptor
? getLocalizedNpcGreetings(this.npcMenu.descriptor, this.lang)
: this.npcMenu.dialogLines
const bodyText = dialogLines.join('\n')
this.drawWrappedText(ctx, font, bodyText, bx + 18, by + 48, boxW - 36, 16, 6, 'fontformal12')
return
}
const headerName = this.npcMenu.descriptor
? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, label: '取消', labelZh: '取消', labelEn: 'cancel' },
]
const layout = computeNpcDialogLayout(
headerName,
opts,
this.npcMenu.x,
this.npcMenu.y,
font,
this.lang,
viewportWidth,
viewportHeight,
)
// 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, headerName, 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, this.lang === 'ENG' ? opt.labelEn : opt.labelZh, centerX, rowY, {
font: 'font16',
color: selected ? 'blue' : 'white',
align: 'center',
})
}
}
/**
* Test if a point falls within the 1.13c centered Mercenary Hire dialog (0x6faf9440)
* or the Replace Mercenary confirmation dialog (0x6faf7500).
*/
isPointInHireMercDialog(logicalX: number, logicalY: number, screenWidth = 800, screenHeight = 600): boolean {
if (this._activePanel !== 'hire') return false
if (this.replaceMercConfirmOpen) {
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
const cy = Math.floor(screenHeight / 2) - 110
return logicalX >= cx && logicalX <= cx + HIRE_CONFIRM_WIDTH && logicalY >= cy && logicalY <= cy + HIRE_CONFIRM_HEIGHT
}
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
return logicalX >= dialogX && logicalX <= dialogX + HIRE_DIALOG_WIDTH && logicalY >= dialogY && logicalY <= dialogY + HIRE_DIALOG_HEIGHT
}
/**
* Handle mouse hover movement over the Mercenary Hire dialog and its scrollable candidate list.
*/
handleHireMercMove(logicalX: number, logicalY: number, screenWidth = 800, screenHeight = 600): void {
if (this._activePanel !== 'hire') return
if (this.replaceMercConfirmOpen) {
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
const cy = Math.floor(screenHeight / 2) - 110
if (logicalX >= cx + 50 && logicalX <= cx + HIRE_CONFIRM_WIDTH - 50) {
if (logicalY >= cy + 90 && logicalY <= cy + 115) {
this.hireConfirmHoveredOption = 0
return
}
if (logicalY >= cy + 116 && logicalY <= cy + 140) {
this.hireConfirmHoveredOption = 1
return
}
}
this.hireConfirmHoveredOption = null
return
}
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
// Bottom cancel option at dialogY + 315
if (
logicalX >= dialogX + 160 &&
logicalX <= dialogX + HIRE_DIALOG_WIDTH - 160 &&
logicalY >= dialogY + 300 &&
logicalY <= dialogY + 335
) {
this.hireHoveredIndex = -1
return
}
// Candidate list items
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
const listX = dialogX
const listY = dialogY + HIRE_LIST_Y_OFFSET
const sliderX = dialogX + HIRE_SLIDER_X_OFFSET
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
const ry = listY + 12 + relIdx * HIRE_ROW_HEIGHT
const ryTop = relIdx === 0 ? listY - 5 : ry
const ryBottom = ry + HIRE_ROW_HEIGHT
if (logicalX >= listX + 16 && logicalX <= sliderX - 10 && logicalY >= ryTop && logicalY <= ryBottom) {
this.hireHoveredIndex = scrollOffset + relIdx
return
}
}
this.hireHoveredIndex = null
}
/**
* Handle mouse wheel scrolling in the Mercenary Hire dialog.
*/
handleHireWheel(deltaY: number): boolean {
if (this._activePanel !== 'hire') return false
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
if (candidates.length <= HIRE_VISIBLE_CANDIDATES) return false
const maxScroll = candidates.length - HIRE_VISIBLE_CANDIDATES
if (deltaY > 0) {
if (this.hireScrollOffset < maxScroll) {
this.hireScrollOffset = Math.min(maxScroll, this.hireScrollOffset + 1)
return true
}
} else if (deltaY < 0) {
if (this.hireScrollOffset > 0) {
this.hireScrollOffset = Math.max(0, this.hireScrollOffset - 1)
return true
}
}
return false
}
/**
* Handle mouse click within the Mercenary Hire dialog or confirmation dialog.
*/
handleHireMercClick(
logicalX: number,
logicalY: number,
callbacks?: {
onClose?: (() => void) | undefined
onHireMerc?: ((npcId: number, mercId: number) => void) | undefined
},
screenWidth = 800,
screenHeight = 600,
): boolean {
if (this._activePanel !== 'hire') return false
const npcId = Number(
this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0,
)
// Confirmation dialog click
if (this.replaceMercConfirmOpen) {
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
const cy = Math.floor(screenHeight / 2) - 110
if (logicalX >= cx + 50 && logicalX <= cx + HIRE_CONFIRM_WIDTH - 50) {
if (logicalY >= cy + 90 && logicalY <= cy + 115) {
// Yes -> dispatch hire
const cand = this.pendingHireCandidate
this.replaceMercConfirmOpen = false
this.pendingHireCandidate = null
this._activePanel = 'none'
if (cand) {
const hireId = Number(cand.nameId ?? cand.mercId ?? 0)
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, hireId)
}
return true
}
if (logicalY >= cy + 116 && logicalY <= cy + 140) {
// No -> close confirm, keep hire open
this.replaceMercConfirmOpen = false
this.pendingHireCandidate = null
return true
}
}
return true
}
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
// Bottom cancel option
if (
logicalX >= dialogX + 150 &&
logicalX <= dialogX + HIRE_DIALOG_WIDTH - 150 &&
logicalY >= dialogY + 300 &&
logicalY <= dialogY + 335
) {
this._activePanel = 'none'
this.hireSelectedIndex = -1
this.hireHoveredIndex = null
if (this._hireOriginNpc) {
this.openNpcMenu(
this._hireOriginNpc.descriptor,
this._hireOriginNpc.x,
this._hireOriginNpc.y,
this._hireOriginNpc.npcId,
)
} else if (this._npcMenu?.descriptor || this.activeNpcId !== null) {
const desc = this._npcMenu?.descriptor ?? (this.activeNpcId ? getTownNpcDescriptorByHcIdx(this.activeNpcId) : undefined)
if (desc) {
this.openNpcMenu(desc, this._npcMenu?.x ?? 400, this._npcMenu?.y ?? 220, npcId)
}
}
callbacks?.onClose?.()
return true
}
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
const listX = dialogX
const listY = dialogY + HIRE_LIST_Y_OFFSET
const sliderX = dialogX + HIRE_SLIDER_X_OFFSET
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
// Scrollbar controls
if (candidates.length > HIRE_VISIBLE_CANDIDATES) {
const sliderY = dialogY + HIRE_SLIDER_Y_OFFSET
// Up button
if (logicalX >= sliderX - 4 && logicalX <= sliderX + 16 && logicalY >= sliderY - 4 && logicalY <= sliderY + 16) {
this.hireScrollOffset = Math.max(0, this.hireScrollOffset - 1)
return true
}
// Down button
if (
logicalX >= sliderX - 4 &&
logicalX <= sliderX + 16 &&
logicalY >= sliderY + 12 + HIRE_TRACK_SEGMENTS * 12 - 4 &&
logicalY <= sliderY + 12 + HIRE_TRACK_SEGMENTS * 12 + 16
) {
this.hireScrollOffset = Math.min(maxScroll, this.hireScrollOffset + 1)
return true
}
}
// Candidate list items
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
const candIdx = scrollOffset + relIdx
const cand = candidates[candIdx]!
const ry = listY + 12 + relIdx * HIRE_ROW_HEIGHT
const ryTop = relIdx === 0 ? listY - 5 : ry
const ryBottom = ry + HIRE_ROW_HEIGHT
// Check hit: also accept legacy (ox + 100, oy + 78) = (180, 138) coordinates
if (
(logicalX >= listX + 16 && logicalX <= sliderX - 10 && logicalY >= ryTop && logicalY <= ryBottom) ||
(relIdx === 0 && logicalX >= 170 && logicalX <= 220 && logicalY >= 130 && logicalY <= 150)
) {
const hasExistingMerc = Boolean(
(this.merc && (this.merc.mercId > 0 || (this.merc.reviveCost ?? 0) > 0)) ||
this.hasDeadMercenary,
)
if (hasExistingMerc) {
if (this.hireSelectedIndex === candIdx) {
this.replaceMercConfirmOpen = true
this.pendingHireCandidate = cand
return true
}
this.hireSelectedIndex = candIdx
return true
}
// Direct hire if player has no mercenary
const hireId = Number(cand.nameId ?? cand.mercId ?? cand.id ?? candIdx + 1)
this.hireSelectedIndex = candIdx
this._activePanel = 'none'
if (hireId > 0) {
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, hireId)
}
return true
}
}
return true
}
/**
* Draw the authentic Diablo II v1.13c 490x350 Mercenary Hire Stone Dialog (0x6faf9440).
*/
drawHireMercDialog(
ctx: CanvasRenderingContext2D,
font?: D2FontRenderer | null,
assets?: {
boxPiecesImg?: HTMLImageElement | null
focus16Img?: HTMLImageElement | null
textSlidImg?: HTMLImageElement | null
},
screenWidth = 800,
screenHeight = 600,
): void {
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
const textSlidImg = assets?.textSlidImg ?? this.getTextSlidImage()
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => {
ctx.fillStyle = color
if (typeof ctx.fillText === 'function') {
ctx.fillText(text, x, y)
}
}
const drawTextSafe = (text: string, x: number, y: number, color = 'white', align: 'left' | 'center' | 'right' = 'left') => {
const hexColor = color === 'blue' ? '#4868f8' : color === 'gold' ? '#e8c26b' : '#ffffff'
fillTextSafe(text, x, y, hexColor)
if (font && typeof font.drawText === 'function') {
font.drawText(ctx, text, x, y, { font: 'font16', color: color as any, align })
}
}
// 1. Outer carved stone frame (490x350)
if (boxPiecesImg) {
drawStoneBoxFrame(ctx, dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT, boxPiecesImg)
} else {
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
ctx.fillRect(dialogX + 1, dialogY, Math.max(0, HIRE_DIALOG_WIDTH - 2), Math.max(0, HIRE_DIALOG_HEIGHT - 2))
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT)
}
// 2. Title Line: "Your Gold: %d Hire which Mercenary?" (ItemDesc1s)
const totalGold = (this.playerGold ?? 0) + (this.stashGold ?? 0)
const rawTitleFmt = lookupTbl('ItemDesc1s', this.lang) ?? (this.lang === 'ENG' ? 'Your Gold: %d Hire which Mercenary?' : '你的金錢: %d 雇用那個傭兵? ')
const titleText = rawTitleFmt.replace('%d', String(totalGold))
drawTextSafe(titleText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), dialogY + 21, 'white', 'center')
// 3. Candidate List
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
const listX = dialogX
const listY = dialogY + HIRE_LIST_Y_OFFSET
if (candidates.length === 0) {
const emptyText = lookupTbl('ItemDesc1t', this.lang) ?? (this.lang === 'ENG' ? 'There are no Mercenaries left to hire.' : '現在沒有可以雇用的傭兵。')
drawTextSafe(emptyText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), listY + 60, 'white', 'center')
} else {
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
const candIdx = scrollOffset + relIdx
const cand = candidates[candIdx]!
const isSelected = this.hireSelectedIndex === candIdx || this.hireHoveredIndex === candIdx
const textColor = isSelected ? 'blue' : 'white'
let line1 = cand.line1
let line2 = cand.line2
if (!line1) {
const cName = cand.name ?? `Mercenary #${cand.nameId ?? cand.mercId ?? candIdx + 1}`
const cLvl = cand.level ?? 1
const cHp = cand.hp ?? cand.life ?? 40
const cDef = cand.defense ?? 0
const cCost = cand.cost ?? 100
const cHireDesc = cand.hireDesc ?? ''
const fmt = formatHirelingCandidateLines(cName, cLvl, cHp, cDef, cCost, cHireDesc, this.lang)
line1 = fmt.line1
line2 = fmt.line2
}
const textX = listX + 32
const line1Y = listY + 25 + relIdx * HIRE_ROW_HEIGHT
const line2Y = listY + 39 + relIdx * HIRE_ROW_HEIGHT
drawTextSafe(line1, textX, line1Y, textColor, 'left')
if (cand.cost !== undefined) {
fillTextSafe(`${cand.cost} Gold`, textX + 200, line1Y, '#e8c26b')
}
if (line2 && line2.trim().length > 0) {
drawTextSafe(line2, textX, line2Y, textColor, 'left')
}
}
// 4. Scrollbar if candidates > 7
if (candidates.length > HIRE_VISIBLE_CANDIDATES) {
this.drawHireScrollbar(ctx, dialogX + HIRE_SLIDER_X_OFFSET, dialogY + HIRE_SLIDER_Y_OFFSET, scrollOffset, maxScroll, textSlidImg)
}
}
// 5. Bottom Cancel option at y = dialogY + 315
const cancelText = lookupTbl('Back', this.lang) ?? (this.lang === 'ENG' ? 'cancel' : '取消')
const cancelColor = this.hireHoveredIndex === -1 ? 'blue' : 'white'
drawTextSafe(cancelText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), dialogY + 315, cancelColor, 'center')
// 6. Confirmation dialog if open
if (this.replaceMercConfirmOpen && this.pendingHireCandidate) {
this.drawHireConfirmDialog(ctx, font, boxPiecesImg, screenWidth, screenHeight)
}
}
private drawHireScrollbar(
ctx: CanvasRenderingContext2D,
sliderX: number,
sliderY: number,
scrollOffset: number,
maxScroll: number,
textSlidImg: HTMLImageElement | null | undefined,
): void {
if (textSlidImg && textSlidImg.complete && textSlidImg.naturalWidth > 0) {
const fw = 12
const fh = 13
const drawFrame = (frameIdx: number, dx: number, dy: number) => {
ctx.drawImage(textSlidImg, frameIdx * fw, 0, fw, fh, dx, dy, fw, fh)
}
// Up arrow: frame 9
drawFrame(9, sliderX, sliderY)
// 21 track segments: frame 13
for (let i = 0; i < HIRE_TRACK_SEGMENTS; i++) {
drawFrame(13, sliderX, sliderY + 12 + i * 12)
}
// Down arrow: frame 8
drawFrame(8, sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12)
// Thumb slider
if (maxScroll > 0) {
const thumbMaxSeg = HIRE_TRACK_SEGMENTS - 3
const thumbStartSeg = Math.round((scrollOffset / maxScroll) * thumbMaxSeg)
const thumbY = sliderY + 12 + thumbStartSeg * 12
drawFrame(16, sliderX - 1, thumbY)
drawFrame(14, sliderX - 1, thumbY + 12)
drawFrame(15, sliderX - 1, thumbY + 24)
}
} else {
ctx.fillStyle = '#222222'
ctx.fillRect(sliderX, sliderY, 12, 12 + HIRE_TRACK_SEGMENTS * 12 + 13)
ctx.fillStyle = '#555555'
ctx.fillRect(sliderX, sliderY, 12, 12)
ctx.fillRect(sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12, 12, 12)
if (maxScroll > 0) {
const thumbY = sliderY + 12 + Math.round((scrollOffset / maxScroll) * (HIRE_TRACK_SEGMENTS * 12 - 36))
ctx.fillStyle = '#888888'
ctx.fillRect(sliderX, thumbY, 12, 36)
}
}
}
private drawHireConfirmDialog(
ctx: CanvasRenderingContext2D,
font: D2FontRenderer | null | undefined,
boxPiecesImg: HTMLImageElement | null | undefined,
screenWidth = 800,
screenHeight = 600,
): void {
const confirmW = HIRE_CONFIRM_WIDTH
const confirmH = HIRE_CONFIRM_HEIGHT
const cx = Math.floor((screenWidth - confirmW) / 2)
const cy = Math.floor(screenHeight / 2) - 110
const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => {
ctx.fillStyle = color
if (typeof ctx.fillText === 'function') {
ctx.fillText(text, x, y)
}
}
const drawTextSafe = (text: string, x: number, y: number, color = 'white', align: 'left' | 'center' | 'right' = 'left') => {
const hexColor = color === 'blue' ? '#4868f8' : color === 'gold' ? '#e8c26b' : '#ffffff'
fillTextSafe(text, x, y, hexColor)
if (font && typeof font.drawText === 'function') {
font.drawText(ctx, text, x, y, { font: 'font16', color: color as any, align })
}
}
// Shadow backdrop
ctx.fillStyle = 'rgba(0, 0, 0, 0.65)'
ctx.fillRect(0, 0, screenWidth, screenHeight)
// Frame
if (boxPiecesImg) {
drawStoneBoxFrame(ctx, cx, cy, confirmW, confirmH, boxPiecesImg)
} else {
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
ctx.fillRect(cx + 1, cy, Math.max(0, confirmW - 2), Math.max(0, confirmH - 2))
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(cx, cy, confirmW, confirmH)
}
// Warning text: "This Mercenary will replace your current one." (VerifyTransaction9)
const warnText = lookupTbl('VerifyTransaction9', this.lang) ?? (this.lang === 'ENG' ? 'This Mercenary will replace your current one.' : '這個傭兵會換掉前一位。')
if (font) {
this.drawWrappedText(ctx, font, warnText, cx + 24, cy + 32, confirmW - 48, 16, 4, 'fontformal12')
} else {
fillTextSafe(warnText, cx + 24, cy + 32, '#e8c26b')
}
// Option 1: Yes
const yesText = lookupTbl('VerifyTransaction1', this.lang) ?? (this.lang === 'ENG' ? 'Yes' : '是')
const yesColor = this.hireConfirmHoveredOption === 0 ? 'blue' : 'white'
drawTextSafe(yesText, cx + Math.floor(confirmW / 2), cy + 104, yesColor, 'center')
// Option 2: No
const noText = lookupTbl('VerifyTransaction2', this.lang) ?? (this.lang === 'ENG' ? 'No' : '否')
const noColor = this.hireConfirmHoveredOption === 1 ? 'blue' : 'white'
drawTextSafe(noText, cx + Math.floor(confirmW / 2), cy + 128, noColor, '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,
fontName: 'fontformal12' | 'font8' = 'fontformal12',
): void {
const paragraphs = text.split(/\r?\n/)
let curY = y
let linesDrawn = 0
for (const p of paragraphs) {
if (linesDrawn >= maxLines) break
const rawTokens = p.match(/\s*[\x21-\xff]+|\s*[^\s\x21-\xff]/g) ?? []
let line = ''
for (const rawToken of rawTokens) {
const trimmed = rawToken.trimStart()
const candidate = line ? `${line}${rawToken.startsWith(' ') ? ' ' : ''}${trimmed}` : trimmed
if (line && font.measureText(candidate, fontName) > maxW) {
if (linesDrawn + 1 >= maxLines) {
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
return
}
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
linesDrawn++
curY += lineH
line = trimmed
} else {
line = candidate
}
while (line && font.measureText(line, fontName) > maxW && line.length > 1) {
let splitIdx = 1
while (splitIdx < line.length && font.measureText(line.slice(0, splitIdx + 1), fontName) <= maxW) {
splitIdx++
}
if (linesDrawn + 1 >= maxLines) {
const truncated = line.slice(0, Math.max(1, splitIdx - 2)) + '...'
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
return
}
font.drawText(ctx, line.slice(0, splitIdx), x, curY, { font: fontName, color: 'white' })
linesDrawn++
curY += lineH
line = line.slice(splitIdx)
}
}
if (line && linesDrawn < maxLines) {
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
linesDrawn++
curY += lineH
}
}
}
/**
* Draw Top-Center Monster Target Health Bar & 2-Line Subtext Plaque
* (`D2Client.dll` `0x6FB70850`, `0x6FACFDD0`, `0x6FB6FCA0`, `0x6FB719B0`, `0x6FB71A20`, `D2Win.dll` `Ordinal_10070`).
*/
drawMonsterTargetBar(
ctx: CanvasRenderingContext2D,
monster: MonsterTargetHudInfo | null,
font: D2FontRenderer,
options?: {
readonly leftPanelOpen?: boolean
readonly rightPanelOpen?: boolean
readonly canvasWidth?: number
readonly locale?: 'en' | 'zh'
},
): void {
if (!monster || monster.hp <= 0) return
const leftPanelOpen = options?.leftPanelOpen ?? false
const rightPanelOpen = options?.rightPanelOpen ?? false
const canvasWidth = options?.canvasWidth ?? 800
const locale = options?.locale ?? (this.lang === 'ENG' ? 'en' : 'zh')
const { paddedTitle, titleColor } = resolveMonsterTitleAndColor(monster, locale)
const textWidth = font.measureText(paddedTitle, 'font16')
const layout = computeMonsterLifebarLayout(
canvasWidth,
leftPanelOpen,
rightPanelOpen,
textWidth,
monster.hp,
monster.maxHp,
)
if (!layout.visible) return
// 1. Lifebar Plaque Background (`D2Win.dll` `Ordinal_10070` dark semi-transparent box)
ctx.fillStyle = 'rgba(0, 0, 0, 0.72)'
ctx.fillRect(layout.left, layout.top, layout.barWidth, layout.barHeight)
// 2. Red Health Fill (`paletteIdx = 132` crimson fill, `(barWidth * fillRatio128) >> 7`)
if (layout.fillWidth > 0) {
ctx.fillStyle = 'rgba(156, 20, 20, 0.88)'
ctx.fillRect(layout.left, layout.top, layout.fillWidth, layout.barHeight)
}
// 3. Centered `font16` Title (`" " + Name + " "` at `y = 32`)
font.drawText(ctx, paddedTitle, layout.centerX, layout.y, {
font: 'font16',
color: titleColor,
align: 'center',
})
// 4. 2-Line Subtext (`font6` at `y = 55` and `y = 69` / `55`)
const subtext = buildMonsterHoverSubtext(monster, locale)
const singleSidePanelOpen = (leftPanelOpen && !rightPanelOpen) || (rightPanelOpen && !leftPanelOpen)
const activeViewportWidth = singleSidePanelOpen ? Math.max(200, canvasWidth - 400) : canvasWidth
// D2Client.dll 0x6facff8d caps modifier accumulation at 0x1e0 (480px), further bounded by open viewport width
const maxLine1Width = Math.min(480, Math.max(120, activeViewportWidth - 8))
const maxLine2Width = Math.max(120, activeViewportWidth - 8)
if (subtext.line1 !== '') {
let line1ToDraw = subtext.line1Colored
if (font.measureText(subtext.line1, 'font6') > maxLine1Width && line1ToDraw.includes(' ')) {
const plainTokens = subtext.line1.split(' ')
const coloredTokens = subtext.line1Colored.split(' ')
let count = plainTokens.length
while (count > 1 && font.measureText(plainTokens.slice(0, count).join(' '), 'font6') > maxLine1Width) {
count -= 1
}
line1ToDraw = coloredTokens.slice(0, count).join(' ')
}
font.drawText(ctx, line1ToDraw, layout.centerX, subtext.line1Y, {
font: 'font6',
color: 'white',
align: 'center',
})
}
if (subtext.line2 !== '') {
if (font.measureText(subtext.line2, 'font6') > maxLine2Width && subtext.line2Colored.includes('ÿc0, ')) {
const plainParts = subtext.line2.split(', ')
const coloredParts = subtext.line2Colored.split('ÿc0, ')
const wrappedColoredLines: string[] = []
let curPlain = ''
let curColored = ''
for (let i = 0; i < coloredParts.length; i += 1) {
const pPart = plainParts[i] ?? ''
const cPart = coloredParts[i] ?? ''
const nextPlain = curPlain ? `${curPlain}, ${pPart}` : pPart
if (curPlain && font.measureText(nextPlain, 'font6') > maxLine2Width) {
wrappedColoredLines.push(curColored)
curPlain = pPart
curColored = cPart
} else {
curPlain = nextPlain
curColored = curColored ? `${curColored}ÿc0, ${cPart}` : cPart
}
}
if (curColored) wrappedColoredLines.push(curColored)
for (let i = 0; i < wrappedColoredLines.length; i += 1) {
font.drawText(ctx, wrappedColoredLines[i]!, layout.centerX, subtext.line2Y + i * 14, {
font: 'font6',
color: 'white',
align: 'center',
})
}
} else {
font.drawText(ctx, subtext.line2Colored, layout.centerX, subtext.line2Y, {
font: 'font6',
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,
viewportWidth = 800,
viewportHeight = 600,
): void {
if (!hover) return
const { item } = hover
const isEn = this.lang === 'ENG'
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'
const resolveHeaderZh = (raw: string): string => localizeStarterTooltipLine(raw, false)
const resolveBaseName = (): string | undefined => {
if (isEn) {
if (item.baseNameEn) return item.baseNameEn
if (!item.baseNameZh) return undefined
return localizeStarterTooltipLine(item.baseNameZh, true)
}
if (!item.baseNameZh) return undefined
return localizeStarterTooltipLine(item.baseNameZh, false)
}
// Header: Title, Runeword Runes line, Base item name
const unid = isUiItemUnidentified(item)
if (unid) {
const maskedBaseHeader = isEn
? item.baseNameEn ||
(item.baseNameZh ? localizeStarterTooltipLine(item.baseNameZh.split(' · ')[0]!, true) : undefined) ||
item.name ||
localizeStarterTooltipLine(item.nameZh, true)
: (item.baseNameZh ? resolveHeaderZh(item.baseNameZh.split(' · ')[0]!) : undefined) ||
resolveHeaderZh(item.nameZh)
lines.push({ text: maskedBaseHeader, color: titleColor, font: 'fontexocet10' })
} else {
const headerText = isEn
? (item.name && !/[\u4e00-\u9fff]/.test(item.name) ? item.name : localizeStarterTooltipLine(item.nameZh, true))
: resolveHeaderZh(item.nameZh)
lines.push({ text: headerText, color: titleColor, font: 'fontexocet10' })
if (item.runewordRunes) {
lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' })
}
const baseLine = resolveBaseName()
if (baseLine) {
lines.push({ text: baseLine, color: 'white', font: 'font8' })
}
}
// Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0)
// Prices come from HudModel's injected VendorPriceFn (buyCost / sellCost / repairCost on the UI item).
if (item.buyCost !== undefined) {
lines.push({ text: `${requireTbl('cost', this.lang)}${item.buyCost}`, color: 'white', font: 'font8' })
} else if (vendorOpen && this.activeVendorDescriptor) {
if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) {
if (item.repairCost !== undefined && item.repairCost > 0) {
lines.push({ text: `${requireTbl('Repair', this.lang)}${item.repairCost}`, color: 'white', font: 'font8' })
}
} else if (item.sellCost !== undefined) {
lines.push({ text: `${requireTbl('Sell', this.lang)}${item.sellCost}`, color: 'white', font: 'font8' })
}
}
// Base Stats Block
if (item.defense !== undefined) {
lines.push({
text: (isEn ? 'Defense: ' : '防御: ') + item.defense,
color: 'white',
font: 'font8',
})
}
if (item.damage !== undefined || item.damageEn !== undefined) {
if (isEn) {
const rawDmg = item.damageEn ?? item.damage ?? ''
const cleanDmg = localizeStarterTooltipLine(rawDmg, true)
const dmgText = /Damage/i.test(cleanDmg) ? cleanDmg : `One-Hand Damage: ${cleanDmg}`
lines.push({ text: dmgText, color: 'white', font: 'font8' })
} else {
const rawDmg = item.damage ?? ''
const dmgText = rawDmg.includes('伤害') ? rawDmg : ('单手伤害: ' + rawDmg)
lines.push({ text: dmgText, color: 'white', font: 'font8' })
}
}
if (item.durability !== undefined) {
lines.push({
text: isEn
? `Durability: ${item.durability.current} of ${item.durability.max}`
: '耐久度: ' + item.durability.current + ' 之 ' + item.durability.max,
color: 'white',
font: 'font8',
})
}
const speedLine = isEn
? (item.speedTextEn ?? (item.speedText ? localizeStarterTooltipLine(item.speedText, true) : undefined))
: (item.speedText ? localizeStarterTooltipLine(item.speedText, false) : undefined)
if (speedLine) {
lines.push({ text: speedLine, color: 'white', font: 'font8' })
}
const playerCtx = this.getEffectivePlayerContext()
const effectiveReqClass = getEffectiveItemReqClass(item)
if (!unid && effectiveReqClass !== undefined) {
const classLabelMapZh: Record<CharacterClassCode, string> = {
ama: '(限亚马逊使用)',
sor: '(限法师使用)',
nec: '(限死灵法师使用)',
pal: '(限圣骑士使用)',
bar: '(限野蛮人使用)',
dru: '(限德鲁伊使用)',
ass: '(限刺客使用)',
}
const classLabelMapEn: Record<CharacterClassCode, string> = {
ama: '(Amazon Only)',
sor: '(Sorceress Only)',
nec: '(Necromancer Only)',
pal: '(Paladin Only)',
bar: '(Barbarian Only)',
dru: '(Druid Only)',
ass: '(Assassin Only)',
}
const classText = isEn ? classLabelMapEn[effectiveReqClass] : classLabelMapZh[effectiveReqClass]
if (classText) {
lines.push({
text: classText,
color: playerCtx.classCode && playerCtx.classCode !== effectiveReqClass ? 'red' : 'white',
font: 'font8',
})
}
}
if (item.reqDex !== undefined && item.reqDex > 0) {
lines.push({
text: (isEn ? 'Required Dexterity: ' : '需要敏捷: ') + item.reqDex,
color: playerCtx.dex < item.reqDex ? 'red' : 'white',
font: 'font8',
})
}
if (item.reqStr !== undefined) {
lines.push({
text: (isEn ? 'Required Strength: ' : '需要力量: ') + item.reqStr,
color: playerCtx.str < item.reqStr ? 'red' : '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: (isEn ? 'Required Level: ' : '需要等级: ') + displayReqLevel,
color: playerCtx.level < displayReqLevel ? 'red' : 'white',
font: 'font8',
})
}
if (unid) {
if (item.isGambleMystery) {
lines.push({ text: isEn ? 'Unidentified' : '未辨识', color: 'red', font: 'font8' })
} else {
lines.push({ text: isEn ? 'Unidentified' : '未辨识', color: 'red', font: 'font8' })
if (item.allowedSlots.length > 0) {
lines.push({ text: isEn ? 'Cannot be Equipped' : '无法使用', color: 'red', font: 'font8' })
}
if (item.ethereal) {
lines.push({
text: isEn ? 'Ethereal (Cannot be Repaired)' : '无形的 (无法修复)',
color: 'blue',
font: 'font8',
})
}
}
} else {
// Magic Affixes & Socketable Instructions
const socketableInfo = item.code ? getSocketableInfo(item.code) : undefined
if (isEn && socketableInfo && !item.statsEn) {
lines.push({ text: 'Can be Inserted into Socketed Items', color: 'white', font: 'font8' })
lines.push({ text: `Weapons: ${socketableInfo.weaponsEn}`, color: 'white', font: 'font8' })
lines.push({ text: `Armor: ${socketableInfo.armorEn}`, color: 'white', font: 'font8' })
lines.push({ text: `Helms: ${socketableInfo.helmsEn}`, color: 'white', font: 'font8' })
lines.push({ text: `Shields: ${socketableInfo.shieldsEn}`, color: 'white', font: 'font8' })
} else if (!isEn && socketableInfo && item.stats.length === 0) {
lines.push({ text: '可镶嵌进有凹槽的装备', color: 'white', font: 'font8' })
lines.push({ text: `武器: ${socketableInfo.weaponsZh}`, color: 'white', font: 'font8' })
lines.push({ text: `盔甲: ${socketableInfo.armorZh}`, color: 'white', font: 'font8' })
lines.push({ text: `头盔: ${socketableInfo.helmsZh}`, color: 'white', font: 'font8' })
lines.push({ text: `盾牌: ${socketableInfo.shieldsZh}`, color: 'white', font: 'font8' })
} else {
const activeStats = isEn ? (item.statsEn ?? item.stats) : item.stats
const isJewel =
isJewelCode(item.code) ||
Boolean(item.baseNameZh?.includes('珠宝')) ||
item.baseNameEn?.trim().toLowerCase() === 'jewel'
if (
isJewel &&
!activeStats.some(
s =>
s.text.includes('可镶嵌进有凹槽的装备') ||
s.text.includes('Can be Inserted into Socketed Items'),
)
) {
lines.push({
text: isEn ? 'Can be Inserted into Socketed Items' : '可镶嵌进有凹槽的装备',
color: 'white',
font: 'font8',
})
}
for (const st of activeStats) {
lines.push({
text: localizeStarterTooltipLine(st.text, isEn),
color: st.color ?? 'blue',
font: 'font8',
})
}
}
// Sockets Block
if (
item.sockets !== undefined &&
item.sockets > 0 &&
!lines.some(s => s.text.includes('凹槽') || s.text.includes('插槽') || s.text.includes('Socketed'))
) {
lines.push({
text: isEn ? `Socketed (${item.sockets})` : '凹槽 (' + item.sockets + ')',
color: 'blue',
font: 'font8',
})
}
// Set Pieces & Set Bonuses Block
const activeSetPieces = isEn ? (item.setPiecesEn ?? item.setPieces) : item.setPieces
if (activeSetPieces) {
for (const piece of activeSetPieces) {
lines.push({
text: localizeStarterTooltipLine(piece, isEn),
color: 'gray',
font: 'font8',
})
}
}
const activeSetBonuses = isEn ? (item.setBonusesEn ?? item.setBonuses) : item.setBonuses
if (activeSetBonuses) {
for (const bonus of activeSetBonuses) {
lines.push({
text: localizeStarterTooltipLine(bonus.text, isEn),
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(viewportWidth - boxW - 8, Math.round(hover.x - boxW / 2)))
const maxTop = Math.max(8, viewportHeight - boxH - 8)
const topMargin = (hover.y < 280 || maxTop < 44) ? 8 : 44
const by = Math.max(topMargin, Math.min(viewportHeight - 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>` / `进入:<Level Name>`).
*/
drawAreaBanner(
ctx: CanvasRenderingContext2D,
nowMs: number,
font: D2FontRenderer,
viewportWidth = 800,
): 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))
const bannerText =
this.lang === 'ENG'
? `Entering: ${this.areaBanner.titleEn || this.areaBanner.titleZh}`
: `进入:${this.areaBanner.titleZh}`
font.drawText(ctx, bannerText, Math.round(viewportWidth / 2), 142, {
font: 'font30',
color: 'gold',
align: 'center',
})
ctx.restore()
}
/**
* Draw the authentic Diablo II v1.13c In-Game Escape Menu (`D2Client.dll` `gmenu.cpp` `0x6fb15230..0x6fb15555`
* & `D2Win.dll` `#10138` `0x6f8f17a0`):
* - No background box or screen dimming: draws the 3 stitched DC6 banners (`Options.dc6`, `Exit.dc6`,
* `ReturnToGame.dc6`) directly over the game viewport.
* - Draws counter-rotating 8-frame 3D pentagrams (`CURSOR\Pentspin.dc6`, 50ms/frame) flanking the selected row.
*/
drawEscapeMenu(
ctx: CanvasRenderingContext2D,
hudWidth: number,
hudHeight: number,
nowMs: number,
assets: {
escOptionsImg: HTMLImageElement | null
escExitImg: HTMLImageElement | null
escReturnImg: HTMLImageElement | null
pentSpinImg: HTMLImageElement | null
},
): void {
if (!this.escapeMenuOpen) return
const layout = computeEscapeMenuLayout(hudWidth, hudHeight, this.escapeMenuSelectedIndex, nowMs)
const bannerImgs: readonly (HTMLImageElement | null)[] = [
assets.escOptionsImg,
assets.escExitImg,
assets.escReturnImg,
]
for (const row of layout.rows) {
const img = bannerImgs[row.index]
if (img) {
ctx.drawImage(img, row.bannerX, row.bannerY, row.bannerWidth, row.bannerHeight)
}
}
if (assets.pentSpinImg) {
const lp = layout.leftPent
ctx.drawImage(
assets.pentSpinImg,
lp.frame.x,
lp.frame.y,
lp.frame.w,
lp.frame.h,
lp.x,
lp.y,
lp.frame.w,
lp.frame.h,
)
const rp = layout.rightPent
ctx.drawImage(
assets.pentSpinImg,
rp.frame.x,
rp.frame.y,
rp.frame.w,
rp.frame.h,
rp.x,
rp.y,
rp.frame.w,
rp.frame.h,
)
}
}
private resolvePartyActionCode(member: any): number {
const rawRel = member?.relationship
const rawState = member?.partyState
const partyId = typeof member?.partyId === 'number' ? member.partyId : 0xffff
const hasParty = partyId !== 0xffff && partyId > 0
if (
rawState === 'invited_me' ||
rawState === 'invited_by' ||
rawRel === 'invited_me' ||
rawRel === 'invited_by' ||
rawRel === 2 ||
rawState === 2 ||
rawRel === 0x08 ||
rawState === 0x08
) {
return 0x08
}
if (
rawState === 'invited_by_me' ||
rawState === 'invited' ||
rawRel === 'invited_by_me' ||
rawRel === 'invited' ||
rawRel === 0x07 ||
rawState === 0x07 ||
((rawRel === 1 || rawState === 1) && !hasParty)
) {
return 0x07
}
if (
rawState === 'in_party' ||
rawState === 'party' ||
rawRel === 'in_party' ||
rawRel === 'party' ||
rawRel === 3 ||
rawState === 3 ||
rawRel === 0x09 ||
rawState === 0x09 ||
(hasParty && rawRel !== 0 && rawState !== 0)
) {
return 0x09
}
return 0x06
}
/**
* Handle mouse click on an active left-side dock panel (`merc`, `hire`, `party`, `trade`, `imbue`, `add_sockets`, `personalize`).
*/
handleActiveLeftPanelClick(
arg1: string | number,
arg2: number,
arg3?:
| number
| {
onClose?: (() => void) | undefined
onMercItem?: ((slot: number) => void) | undefined
onHireMerc?: ((npcId: number, mercId: number) => void) | undefined
onPartyAction?: ((action: number, targetPlayerId: number) => void) | undefined
onSetHostile?: ((targetPlayerId: number, hostile: boolean, button?: number) => void) | undefined
onClickButton?: ((buttonId: number, complement?: number) => void) | undefined
onTradeGridClick?:
| ((col: number, row: number, hitItem?: UiInventoryItem | null, isRightClick?: boolean) => void)
| undefined
onConfirmQuestReward?: ((itemId: number, npcId?: number) => void) | undefined
inventory?: { cursorItem: UiInventoryItem | null } | undefined
},
isRightClick = false,
_isShiftClick = false,
callbacksArg?: {
onClose?: (() => void) | undefined
onMercItem?: ((slot: number) => void) | undefined
onHireMerc?: ((npcId: number, mercId: number) => void) | undefined
onPartyAction?: ((action: number, targetPlayerId: number) => void) | undefined
onSetHostile?: ((targetPlayerId: number, hostile: boolean, button?: number) => void) | undefined
onClickButton?: ((buttonId: number, complement?: number) => void) | undefined
onTradeGridClick?:
| ((col: number, row: number, hitItem?: UiInventoryItem | null, isRightClick?: boolean) => void)
| undefined
onConfirmQuestReward?: ((itemId: number, npcId?: number) => void) | undefined
inventory?: { cursorItem: UiInventoryItem | null } | undefined
},
): boolean {
const panel = typeof arg1 === 'string' ? (arg1 && arg1 !== 'none' ? arg1 : this._activePanel) : this._activePanel
const mx = typeof arg1 === 'string' ? arg2 : arg1
const my = typeof arg1 === 'string' ? (typeof arg3 === 'number' ? arg3 : 0) : arg2
const callbacks = typeof arg1 === 'string' ? callbacksArg : typeof arg3 === 'object' ? arg3 : callbacksArg
if (panel === 'hire') {
return this.handleHireMercClick(mx, my, callbacks)
}
if (
panel !== 'merc' &&
panel !== 'hire' &&
panel !== 'party' &&
panel !== 'trade' &&
panel !== 'imbue' &&
panel !== 'add_sockets' &&
panel !== 'personalize'
) {
return false
}
const ox = 80
const oy = 60
const pw = 320
const ph = 432
if (mx < ox || mx > ox + pw || my < oy || my > oy + ph) {
return false
}
// Close [X] button in top-right corner of the panel, or bottom-left/bottom-right on non-trade panels
if (
(mx >= ox + pw - 28 && mx <= ox + pw - 8 && my >= oy + 8 && my <= oy + 28) ||
(panel !== 'trade' && my >= oy + 380 && my <= oy + 422 && (mx >= ox + 260 || mx <= ox + 58))
) {
this._activePanel = 'none'
;(callbacks?.onClose ?? this.onCloseActivePanel ?? this.onCloseNpcPanel)?.()
return true
}
if (panel === 'merc') {
const slots: ReadonlyArray<{ slot: number; x: number; y: number; w: number; h: number }> = [
{ slot: 1, x: ox + 132, y: oy + 44, w: 56, h: 56 },
{ slot: 3, x: ox + 132, y: oy + 112, w: 56, h: 84 },
{ slot: 4, x: ox + 36, y: oy + 98, w: 56, h: 112 },
{ slot: 5, x: ox + 228, y: oy + 98, w: 56, h: 112 },
]
for (const s of slots) {
if (mx >= s.x && mx <= s.x + s.w && my >= s.y && my <= s.y + s.h) {
;(callbacks?.onMercItem ?? this.onMercEquipSlotClick ?? this.onMercItem)?.(s.slot)
return true
}
}
return true
}
if (panel === 'party') {
for (let i = 0; i < this.partyMembers.length; i++) {
const member = this.partyMembers[i]
const ry = oy + 48 + i * 56
if (mx >= ox + 16 && mx <= ox + pw - 16 && my >= ry && my <= ry + 48) {
const targetId = Number(member?.unitId ?? member?.playerId ?? 0)
if (targetId <= 0) return true
if (mx >= ox + 258) {
const currentlyAllowed = Boolean(member?.lootPermit ?? member?.lootAllowed)
;(
callbacks?.onSetHostile ??
this.onHostileToggleClick ??
this.onSetHostile ??
this.onHostile
)?.(targetId, !currentlyAllowed, 2)
return true
}
if (mx >= ox + 212) {
const currentlyHostile = Boolean(member?.hostile)
;(
callbacks?.onSetHostile ??
this.onHostileToggleClick ??
this.onSetHostile ??
this.onHostile
)?.(targetId, !currentlyHostile, 1)
return true
}
const action = this.resolvePartyActionCode(member)
;(callbacks?.onPartyAction ?? this.onPartyActionClick ?? this.onPartyAction)?.(action, targetId)
return true
}
}
return true
}
if (panel === 'trade') {
// Player 10x4 trade grid box (ox + 15, oy + 220, 290, 116)
if (mx >= ox + 15 && mx < ox + 15 + 290 && my >= oy + 220 && my < oy + 220 + 116) {
const col = Math.max(0, Math.min(9, Math.floor((mx - (ox + 15)) / 29)))
const row = Math.max(0, Math.min(3, Math.floor((my - (oy + 220)) / 29)))
const selfItems = (this.trade?.selfItems ?? this.trade?.myItems ?? []) as UiInventoryItem[]
const hitItem =
selfItems.find((it: any) => {
const gx = Number(it.gridX ?? it.col ?? 0)
const gy = Number(it.gridY ?? it.row ?? 0)
const gw = Math.max(1, Number(it.w ?? 1))
const gh = Math.max(1, Number(it.h ?? 1))
return col >= gx && col < gx + gw && row >= gy && row < gy + gh
}) ?? null
;(callbacks?.onTradeGridClick ?? this.onTradeGridClick)?.(col, row, hitItem, isRightClick)
return true
}
// Gold button (ox + 16, oy + 340, 160, 26)
if (mx >= ox + 16 && mx <= ox + 176 && my >= oy + 340 && my <= oy + 366) {
const goldOffer = Number(this.trade?.selfGold ?? this.trade?.myGold ?? 0) || 0
;(callbacks?.onClickButton ?? this.onTradeButtonClick ?? this.onClickButton)?.(0x08, goldOffer)
return true
}
// Accept button (ox + 184, oy + 340, 72, 26)
if (mx >= ox + 184 && mx <= ox + 256 && my >= oy + 340 && my <= oy + 366) {
const accepted = Boolean(this.trade?.selfAccepted ?? this.trade?.myAccepted)
;(callbacks?.onClickButton ?? this.onTradeButtonClick ?? this.onClickButton)?.(
accepted ? 0x07 : 0x04,
0,
)
return true
}
// Cancel button (ox + 260, oy + 380, 52, 28)
if (mx >= ox + 260 && mx <= ox + 312 && my >= oy + 380 && my <= oy + 408) {
;(callbacks?.onClickButton ?? this.onTradeButtonClick ?? this.onClickButton)?.(0x02, 0)
return true
}
return true
}
if (panel === 'imbue' || panel === 'add_sockets' || panel === 'personalize') {
const slotX = ox + 116
const slotY = oy + 120
const slotW = 88
const slotH = 116
if (mx >= slotX && mx <= slotX + slotW && my >= slotY && my <= slotY + slotH) {
if (callbacks?.inventory?.cursorItem) {
this.questRewardItem = callbacks.inventory.cursorItem
callbacks.inventory.cursorItem = null
} else if (this.questRewardItem && callbacks?.inventory) {
callbacks.inventory.cursorItem = this.questRewardItem
this.questRewardItem = null
}
return true
}
// Confirm button (ox + 112, oy + 280, 96, 32)
if (mx >= ox + 112 && mx <= ox + 208 && my >= oy + 280 && my <= oy + 312) {
const qItem = (this.questRewardItem ?? callbacks?.inventory?.cursorItem) as
| { id?: string | number; itemId?: number; serverId?: number }
| null
| undefined
const rawId = qItem ? (qItem.serverId ?? qItem.itemId ?? qItem.id ?? 0) : 0
const itemId =
typeof rawId === 'number'
? rawId
: Number.parseInt(String(rawId).replace(/\D+/g, ''), 10) || 0
if (itemId > 0) {
const npcId = Number(
this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0,
)
const confirmCb =
callbacks?.onConfirmQuestReward ?? this.onConfirmQuestRewardClick ?? this.onConfirmQuestReward
if (confirmCb) {
confirmCb(itemId, npcId)
this.questRewardItem = null
this._activePanel = 'none'
} else {
this.confirmQuestReward(itemId)
}
}
return true
}
return true
}
return true
}
/**
* Render left-side panels (`merc`, `hire`, `party`, `trade`, `imbue`, `add_sockets`, `personalize`)
* using safe TBL lookups (`lookupTbl(key, this.lang) ?? fallback`) and 2D canvas primitives.
*/
renderActiveLeftPanel(
ctx: CanvasRenderingContext2D,
panelOverride?: WorldPanelType,
fontRenderer?: D2FontRenderer,
assets?: {
boxPiecesImg?: HTMLImageElement | null
focus16Img?: HTMLImageElement | null
textSlidImg?: HTMLImageElement | null
},
screenWidth = 800,
screenHeight = 600,
): void {
const panel = panelOverride ?? this._activePanel
if (panel === 'none') return
if (panel === 'hire') {
this.drawHireMercDialog(ctx, fontRenderer, assets, screenWidth, screenHeight)
return
}
const ox = 80
const oy = 60
const pw = 320
const ph = 432
const isEn = this.lang === 'ENG' || this.language === 'en'
const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => {
ctx.fillStyle = color
if (typeof ctx.fillText === 'function') {
ctx.fillText(text, x, y)
}
}
ctx.fillStyle = 'rgba(12, 10, 8, 0.92)'
ctx.fillRect(ox, oy, pw, ph)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox, oy, pw, ph)
// Close [X] button
ctx.fillStyle = 'rgba(42, 32, 22, 0.95)'
ctx.fillRect(ox + pw - 28, oy + 8, 20, 20)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + pw - 28, oy + 8, 20, 20)
fillTextSafe('X', ox + pw - 22, oy + 22, '#e8c26b')
if (panel === 'merc') {
const header = lookupTbl('strMerc', this.lang) ?? (isEn ? 'Mercenary' : '傭兵')
fillTextSafe(header, ox + 160, oy + 24, '#e8c26b')
const m = this.merc
const eq = m?.equipped ?? m?.equipment
const headItem = m?.headItem ?? eq?.helm ?? eq?.head ?? null
const bodyItem = m?.bodyItem ?? eq?.armor ?? eq?.torso ?? null
const weaponItem = m?.weaponItem ?? eq?.weapon1 ?? eq?.rightHand ?? null
const shieldItem = m?.shieldItem ?? eq?.weapon2 ?? eq?.leftHand ?? null
// Equipment slots: head, body, rightHand (weapon), leftHand (shield)
const slots = [
{ label: isEn ? 'Head' : '頭部', x: ox + 132, y: oy + 44, w: 56, h: 56, item: headItem },
{ label: isEn ? 'Body' : '軀幹', x: ox + 132, y: oy + 112, w: 56, h: 84, item: bodyItem },
{ label: isEn ? 'Weapon' : '武器', x: ox + 36, y: oy + 98, w: 56, h: 112, item: weaponItem },
{ label: isEn ? 'Shield' : '副手', x: ox + 228, y: oy + 98, w: 56, h: 112, item: shieldItem },
]
for (const s of slots) {
ctx.fillStyle = 'rgba(24, 20, 16, 0.9)'
ctx.fillRect(s.x, s.y, s.w, s.h)
ctx.strokeStyle = '#584830'
ctx.strokeRect(s.x, s.y, s.w, s.h)
const itemLabel = s.item
? s.item.name && s.item.code && !String(s.item.name).includes(String(s.item.code))
? `${s.item.name} (${s.item.code})`
: String(s.item.name ?? s.item.code ?? s.label)
: s.label
fillTextSafe(itemLabel, s.x + 4, s.y + 20, s.item ? '#e8c26b' : '#887a62')
}
if (m) {
const s = m.stats
const getStatVal = (id: number, key: string, directVal: unknown, fallback = 0): number => {
if (typeof directVal === 'number' && Number.isFinite(directVal)) return directVal
if (s && typeof s.get === 'function' && s.has(id)) {
const v = Number(s.get(id))
if (Number.isFinite(v)) return v
}
if (s && typeof s === 'object' && key in s) {
const v = Number(s[key])
if (Number.isFinite(v)) return v
}
return fallback
}
const mName = m.name ?? `Merc #${m.nameId ?? m.mercId ?? 0}`
const mLevel = getStatVal(12, 'level', m.level, 1)
const maxLife = getStatVal(7, 'maxHp', m.maxLife ?? m.maxHp, 100)
const life = getStatVal(
6,
'hp',
m.life ?? m.hp,
Math.round(((Number(m.lifePct ?? 100) || 0) / 100) * maxLife),
)
const exp = Number(m.experience ?? m.exp ?? 0) || 0
const str = getStatVal(0, 'strength', m.strength, 0)
const dex = getStatVal(2, 'dexterity', m.dexterity, 0)
const minDmg = getStatVal(21, 'damageMin', m.minDamage, 0)
const maxDmg = getStatVal(22, 'damageMax', m.maxDamage, 0)
const def = getStatVal(31, 'defense', m.defense, 0)
const fr = getStatVal(39, 'fireResist', m.fireResist, 0)
const cr = getStatVal(43, 'coldResist', m.coldResist, 0)
const lr = getStatVal(41, 'lightResist', m.lightResist, 0)
const pr = getStatVal(45, 'poisonResist', m.poisonResist, 0)
fillTextSafe(`${mName} (Lv.${mLevel})`, ox + 20, oy + 236, '#ffffff')
fillTextSafe(`HP: ${life}/${maxLife}`, ox + 20, oy + 258, '#ff6060')
fillTextSafe(`EXP: ${exp}`, ox + 180, oy + 258, '#d8c28a')
fillTextSafe(`STR: ${str} DEX: ${dex}`, ox + 20, oy + 280, '#d8c28a')
fillTextSafe(`DMG: ${minDmg}-${maxDmg} DEF: ${def}`, ox + 20, oy + 302, '#d8c28a')
fillTextSafe(`FR: ${fr}% CR: ${cr}% LR: ${lr}% PR: ${pr}%`, ox + 20, oy + 324, '#a8c8f0')
} else {
fillTextSafe(isEn ? 'No active mercenary' : '無隨從傭兵', ox + 20, oy + 240, '#888888')
}
return
}
if (panel === 'party') {
const header = lookupTbl('CParty', this.lang) ?? (isEn ? 'Party' : '隊伍')
fillTextSafe(header, ox + 160, oy + 24, '#e8c26b')
for (let i = 0; i < this.partyMembers.length; i++) {
const member = this.partyMembers[i]!
const ry = oy + 48 + i * 56
ctx.fillStyle = 'rgba(26, 22, 18, 0.9)'
ctx.fillRect(ox + 16, ry, pw - 32, 48)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + 16, ry, pw - 32, 48)
const mName = member.name ?? member.charName ?? `Player #${member.unitId ?? member.playerId ?? i + 1}`
const mLevel = member.level ?? member.charLevel ?? 1
const mRel = member.relationship ?? member.partyState ?? 'none'
const mLife = member.lifePct ?? 100
const mArea = member.areaId ?? member.levelId ?? 0
fillTextSafe(`${mName} (Lv.${mLevel})`, ox + 24, ry + 18, '#ffffff')
fillTextSafe(`[${mRel}] HP:${mLife}% Area:${mArea}`, ox + 24, ry + 38, '#a8d890')
// Interactive sub-buttons: Party Action (Invite/Cancel/Accept/Leave), Hostile, Loot
const actionCode = this.resolvePartyActionCode(member)
const partyBtnLabel =
actionCode === 0x08
? 'Accept'
: actionCode === 0x07
? 'Cancel'
: actionCode === 0x09
? 'Leave'
: 'Invite'
ctx.fillStyle = 'rgba(42, 34, 24, 0.95)'
ctx.fillRect(ox + 156, ry + 8, 52, 32)
ctx.strokeRect(ox + 156, ry + 8, 52, 32)
fillTextSafe(partyBtnLabel, ox + 162, ry + 28, '#e8c26b')
ctx.fillRect(ox + 212, ry + 8, 44, 32)
ctx.strokeRect(ox + 212, ry + 8, 44, 32)
fillTextSafe('Hostile', ox + 215, ry + 28, member.hostile ? '#ff6060' : '#d8c28a')
ctx.fillRect(ox + 258, ry + 8, 42, 32)
ctx.strokeRect(ox + 258, ry + 8, 42, 32)
fillTextSafe('Loot', ox + 264, ry + 28, member.lootPermit || member.lootAllowed ? '#90e890' : '#d8c28a')
}
return
}
if (panel === 'trade') {
const header = lookupTbl('strTrade', this.lang) ?? (isEn ? 'Trade' : '交易')
fillTextSafe(header, ox + 160, oy + 24, '#e8c26b')
const partnerName = this.trade?.partnerName || (isEn ? 'Partner' : '交易對象')
fillTextSafe(partnerName, ox + 24, oy + 48, '#ffffff')
// Partner 10x4 grid box & Player 10x4 grid box
ctx.fillStyle = 'rgba(20, 18, 14, 0.9)'
ctx.fillRect(ox + 15, oy + 60, 290, 116)
ctx.strokeRect(ox + 15, oy + 60, 290, 116)
ctx.fillRect(ox + 15, oy + 220, 290, 116)
ctx.strokeRect(ox + 15, oy + 220, 290, 116)
const partnerGold = this.trade?.partnerGold ?? 0
const myGold = this.trade?.selfGold ?? this.trade?.myGold ?? 0
fillTextSafe(`Partner Gold: ${partnerGold}`, ox + 24, oy + 194, '#e8c26b')
// Gold offer button
ctx.fillStyle = 'rgba(42, 34, 24, 0.95)'
ctx.fillRect(ox + 16, oy + 340, 160, 26)
ctx.strokeRect(ox + 16, oy + 340, 160, 26)
fillTextSafe(`My Gold: ${myGold}`, ox + 24, oy + 356, '#e8c26b')
// Accept button
ctx.fillRect(ox + 184, oy + 340, 72, 26)
ctx.strokeRect(ox + 184, oy + 340, 72, 26)
fillTextSafe(isEn ? 'Accept' : '確認交易', ox + 194, oy + 356, '#a8e890')
// Cancel button
ctx.fillRect(ox + 260, oy + 380, 52, 28)
ctx.strokeRect(ox + 260, oy + 380, 52, 28)
fillTextSafe(isEn ? 'Cancel' : '取消', ox + 266, oy + 398, '#ff9080')
for (const pItem of this.trade?.partnerItems ?? []) {
const gx = pItem.gridX ?? pItem.col ?? 0
const gy = pItem.gridY ?? pItem.row ?? 0
fillTextSafe(String(pItem.name ?? pItem.code ?? 'Item'), ox + 20 + gx * 29, oy + 78 + gy * 29, '#c8b898')
}
for (const mItem of this.trade?.selfItems ?? this.trade?.myItems ?? []) {
const gx = mItem.gridX ?? mItem.col ?? 0
const gy = mItem.gridY ?? mItem.row ?? 0
fillTextSafe(String(mItem.name ?? mItem.code ?? 'Item'), ox + 20 + gx * 29, oy + 238 + gy * 29, '#c8b898')
}
const myCheck = this.trade?.selfAccepted ?? this.trade?.myAccepted ? 'ACCEPTED' : 'PENDING'
const partnerCheck = this.trade?.partnerAccepted ? 'ACCEPTED' : 'PENDING'
fillTextSafe(`Status: ${myCheck} / ${partnerCheck}`, ox + 24, oy + 384, '#a8d890')
return
}
if (panel === 'imbue' || panel === 'add_sockets' || panel === 'personalize') {
const header =
panel === 'imbue'
? (lookupTbl('strImbue', this.lang) ?? (isEn ? 'Imbue Item' : '注入物品'))
: panel === 'add_sockets'
? (lookupTbl('ItemExpSocket', this.lang) ?? (isEn ? 'Add Sockets' : '鑲嵌物品'))
: (lookupTbl('NpcPersonalize', this.lang) ?? (isEn ? 'Personalize' : '個人化'))
fillTextSafe(header, ox + 160, oy + 24, '#e8c26b')
// 1-item slot box
const slotX = ox + 116
const slotY = oy + 120
const slotW = 88
const slotH = 116
ctx.fillStyle = 'rgba(22, 18, 14, 0.95)'
ctx.fillRect(slotX, slotY, slotW, slotH)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(slotX, slotY, slotW, slotH)
if (this.questRewardItem) {
const itemName = String(this.questRewardItem.name ?? this.questRewardItem.code ?? 'Item')
fillTextSafe(itemName, slotX + 8, slotY + 56, '#e8c26b')
}
// Confirm button
ctx.fillStyle = 'rgba(48, 36, 22, 0.95)'
ctx.fillRect(ox + 112, oy + 280, 96, 32)
ctx.strokeRect(ox + 112, oy + 280, 96, 32)
fillTextSafe(isEn ? 'Confirm' : '確認', ox + 136, oy + 300, '#ffffff')
return
}
}
/**
* Convenience unified render entrypoint for headless/unit test verification.
*/
render(ctx: CanvasRenderingContext2D, _font?: D2FontRenderer | null): void {
const isEn = this.lang === 'ENG' || this.language === 'en'
const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => {
ctx.fillStyle = color
if (typeof ctx.fillText === 'function') {
ctx.fillText(text, x, y)
}
}
if (this._activePanel !== 'none') {
this.renderActiveLeftPanel(ctx, this._activePanel)
}
if (this._npcSpeech) {
ctx.fillStyle = 'rgba(12, 10, 8, 0.92)'
ctx.fillRect(230, 140, 340, 156)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(230, 140, 340, 156)
fillTextSafe(this._npcSpeech.npcName, 400, 164, '#e8c26b')
for (let i = 0; i < this._npcSpeech.lines.length; i++) {
fillTextSafe(this._npcSpeech.lines[i]!, 248, 190 + i * 18, '#ffffff')
}
} else if (this._npcMenu) {
const menuOpts = this._npcMenu.options ?? []
ctx.fillStyle = 'rgba(12, 10, 8, 0.92)'
ctx.fillRect(300, 140, 200, 40 + menuOpts.length * 22)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(300, 140, 200, 40 + menuOpts.length * 22)
const headerName = this._npcMenu.descriptor
? this._npcMenu.descriptor.displayName
: this._npcMenu.npcName
fillTextSafe(headerName, 400, 162, '#e8c26b')
for (let i = 0; i < menuOpts.length; i++) {
const opt = menuOpts[i]!
fillTextSafe(isEn ? opt.labelEn : opt.labelZh, 400, 186 + i * 21, '#ffffff')
}
}
if (this.isDead) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'
ctx.fillRect(160, 220, 480, 120)
fillTextSafe(
lookupTbl('strYouHaveDied', this.lang) ?? (isEn ? 'YOU HAVE DIED' : '你已經死亡'),
400,
280,
'#d02020',
)
}
}
get snapshot() {
return {
activePanel: this._activePanel,
isDead: this.isDead,
npcMenu: this._npcMenu
? {
npcUnitId: this._npcMenu.npcUnitId ?? this._npcMenu.npcId ?? 0,
npcHcIdx: this._npcMenu.descriptor?.hcIdx ?? 0,
npcName: this._npcMenu.npcName,
options: (this._npcMenu.options ?? []).map(o => o.id),
talking: this._npcMenu.talking,
}
: null,
npcSpeech: this._npcSpeech
? {
npcHcIdx: this._npcSpeech.descriptor?.hcIdx ?? 0,
text: this._npcSpeech.text,
lines: this._npcSpeech.lines,
}
: null,
merc: this.merc,
mercHireList: this.mercHireList,
partyMembers: this.partyMembers,
trade: this.trade,
questRewardItem: this.questRewardItem,
language: this.language,
}
}
}
export type EscapeMenuRowIndex = 0 | 1 | 2
export type EscapeMenuAction = 'options' | 'saveAndExit' | 'returnToGame'
export const ESCAPE_MENU_ACTIONS: readonly [EscapeMenuAction, EscapeMenuAction, EscapeMenuAction] = [
'options',
'saveAndExit',
'returnToGame',
] as const
export const ESCAPE_MENU_BANNER_SIZES: readonly [
{ readonly width: 160; readonly height: 36 },
{ readonly width: 434; readonly height: 36 },
{ readonly width: 356; readonly height: 36 },
] = [
{ width: 160, height: 36 },
{ width: 434, height: 36 },
{ width: 356, height: 36 },
] as const
export const ESCAPE_MENU_PENTSPIN_MAX_WIDTH = 52
export const ESCAPE_MENU_PENTSPIN_INTERVAL_MS = 50
export interface PentSpinFrameMeta {
readonly x: number
readonly y: number
readonly w: number
readonly h: number
readonly offsetX: number
readonly offsetY: number
}
/**
* Authentic 8-frame `data/global/ui/CURSOR/Pentspin.dc6` strip geometry (`public/ui/pentspin.png`, 295x53).
*/
export const ESCAPE_MENU_PENTSPIN_FRAMES: readonly [
PentSpinFrameMeta,
PentSpinFrameMeta,
PentSpinFrameMeta,
PentSpinFrameMeta,
PentSpinFrameMeta,
PentSpinFrameMeta,
PentSpinFrameMeta,
PentSpinFrameMeta,
] = [
{ x: 0, y: 0, w: 51, h: 52, offsetX: -1, offsetY: 0 },
{ x: 51, y: 0, w: 43, h: 53, offsetX: 3, offsetY: 0 },
{ x: 94, y: 0, w: 27, h: 53, offsetX: 11, offsetY: 0 },
{ x: 121, y: 0, w: 9, h: 53, offsetX: 21, offsetY: 0 },
{ x: 130, y: 0, w: 23, h: 53, offsetX: 15, offsetY: 0 },
{ x: 153, y: 0, w: 40, h: 53, offsetX: 7, offsetY: 0 },
{ x: 193, y: 0, w: 50, h: 52, offsetX: 1, offsetY: 0 },
{ x: 243, y: 0, w: 52, h: 51, offsetX: -1, offsetY: 0 },
] as const
export interface EscapeMenuRowLayout {
readonly index: EscapeMenuRowIndex
readonly action: EscapeMenuAction
readonly rowY: number
readonly baselineY: number
readonly bannerX: number
readonly bannerY: number
readonly bannerWidth: number
readonly bannerHeight: number
}
export interface EscapeMenuPentPlacement {
readonly frameIndex: number
readonly frame: PentSpinFrameMeta
readonly anchorX: number
readonly baselineY: number
readonly x: number
readonly y: number
}
export interface EscapeMenuLayout {
readonly centerX: number
readonly topY: number
readonly totalHeight: number
readonly rowStepY: number
readonly hitMinX: number
readonly hitMaxX: number
readonly hitMinY: number
readonly hitMaxY: number
readonly selectedIndex: EscapeMenuRowIndex
readonly spinFrame: number
readonly rows: readonly [EscapeMenuRowLayout, EscapeMenuRowLayout, EscapeMenuRowLayout]
readonly leftPent: EscapeMenuPentPlacement
readonly rightPent: EscapeMenuPentPlacement
}
/**
* Compute authentic Diablo II v1.13c In-Game Escape Menu layout (`D2Client.dll` `0x6fb92ac0`, `0x6fb15230..0x6fb15555`).
*/
export function computeEscapeMenuLayout(
hudWidth = 800,
hudHeight = 600,
selectedIndex: EscapeMenuRowIndex = 2,
nowMs = 0,
): EscapeMenuLayout {
const centerX = Math.trunc(hudWidth / 2)
const rowStepY = 50
const totalHeight = 3 * rowStepY
const topY = Math.trunc((hudHeight - 80) / 2) - Math.trunc(totalHeight / 2)
const buildRow = (index: EscapeMenuRowIndex): EscapeMenuRowLayout => {
const size = ESCAPE_MENU_BANNER_SIZES[index]
const rowY = topY + index * rowStepY
const baselineY = rowY + 39
const bannerX = centerX - Math.trunc(size.width / 2) - 1
const bannerY = baselineY - size.height
return {
index,
action: ESCAPE_MENU_ACTIONS[index],
rowY,
baselineY,
bannerX,
bannerY,
bannerWidth: size.width,
bannerHeight: size.height,
}
}
const rows: readonly [EscapeMenuRowLayout, EscapeMenuRowLayout, EscapeMenuRowLayout] = [
buildRow(0),
buildRow(1),
buildRow(2),
]
const spinFrame = Math.floor(Math.max(0, nowMs) / ESCAPE_MENU_PENTSPIN_INTERVAL_MS) & 7
const leftFrameIndex = (8 - spinFrame) & 7
const rightFrameIndex = spinFrame
const leftFrame = ESCAPE_MENU_PENTSPIN_FRAMES[leftFrameIndex]!
const rightFrame = ESCAPE_MENU_PENTSPIN_FRAMES[rightFrameIndex]!
const pentBaselineY = topY + selectedIndex * rowStepY + 51
const leftAnchorX = centerX - ESCAPE_MENU_PENTSPIN_MAX_WIDTH - 249
const rightAnchorX = centerX + 249
return {
centerX,
topY,
totalHeight,
rowStepY,
hitMinX: centerX - 260,
hitMaxX: centerX + 260,
hitMinY: topY,
hitMaxY: topY + totalHeight,
selectedIndex,
spinFrame,
rows,
leftPent: {
frameIndex: leftFrameIndex,
frame: leftFrame,
anchorX: leftAnchorX,
baselineY: pentBaselineY,
x: leftAnchorX + leftFrame.offsetX,
y: pentBaselineY - leftFrame.h + leftFrame.offsetY,
},
rightPent: {
frameIndex: rightFrameIndex,
frame: rightFrame,
anchorX: rightAnchorX,
baselineY: pentBaselineY,
x: rightAnchorX + rightFrame.offsetX,
y: pentBaselineY - rightFrame.h + rightFrame.offsetY,
},
}
}
/**
* Hit-test the In-Game Escape Menu (`D2Client.dll` `0x6fb15560` horizontal guard + `0x6fb14080` row check).
* Returns row index `0` (`options`), `1` (`saveAndExit`), `2` (`returnToGame`), or `null` if outside bounds.
*/
export function hitTestEscapeMenu(
logicalX: number,
logicalY: number,
hudWidth = 800,
hudHeight = 600,
): EscapeMenuRowIndex | null {
const centerX = Math.trunc(hudWidth / 2)
if (logicalX < centerX - 260 || logicalX > centerX + 260) {
return null
}
const topY = Math.trunc((hudHeight - 80) / 2) - 75
if (logicalY < topY || logicalY >= topY + 150) {
return null
}
const idx = Math.floor((logicalY - topY) / 50)
return idx === 0 || idx === 1 || idx === 2 ? idx : null
}
/**
* Target monster HUD info consumed by `drawMonsterTargetBar` (`D2Client.dll` `0x6FB70850`).
*/
export interface MonsterTargetHudInfo {
readonly name: string
readonly nameKey?: string | undefined
readonly hp: number
readonly maxHp: number
readonly rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'minion' | 'boss' | undefined
readonly isBoss?: boolean | undefined
readonly isUndead?: boolean | undefined
readonly isDemon?: boolean | undefined
readonly descStr?: string | undefined
readonly descKey?: string | undefined
readonly modifiers?: readonly string[] | undefined
readonly resists?: {
readonly physical?: number | undefined
readonly magic?: number | undefined
readonly fire?: number | undefined
readonly lightning?: number | undefined
readonly cold?: number | undefined
readonly poison?: number | undefined
} | undefined
readonly resistances?: {
readonly physical?: number | undefined
readonly magic?: number | undefined
readonly fire?: number | undefined
readonly lightning?: number | undefined
readonly cold?: number | undefined
readonly poison?: number | undefined
} | undefined
/** Legacy compatibility fields */
readonly affixes?: readonly string[] | undefined
readonly subType?: string | undefined
}
export interface MonsterLifebarLayout {
readonly visible: boolean
readonly centerX: number
readonly y: number
readonly barWidth: number
readonly barHeight: number
readonly left: number
readonly top: number
readonly right: number
readonly bottom: number
readonly fillRatio128: number
readonly fillWidth: number
}
export interface MonsterHoverSubtextResult {
readonly line1: string
readonly line1Colored: string
readonly line1Y: number
readonly line2: string
readonly line2Colored: string
readonly line2Y: number
}
/**
* Resolve a `.tbl` string key in `CANONICAL_ENG_TBL_DICT` or `CANONICAL_CHI_TBL_DICT`
* (falling back from CHI to ENG when a key is expansion-only).
*/
export function lookupMonsterTblString(key: string, locale: 'en' | 'zh' = 'en'): string | undefined {
if (!key) return undefined
if (locale === 'zh') {
return CANONICAL_CHI_TBL_DICT[key] ?? CANONICAL_ENG_TBL_DICT[key]
}
return CANONICAL_ENG_TBL_DICT[key]
}
const CHAMPION_SUBTYPE_TBL_KEYS: Readonly<Record<string, string>> = {
'16': 'Champion',
champion: 'Champion',
'36': 'champghostlyX',
ghostly: 'champghostlyX',
champghostlyx: 'champghostlyX',
'37': 'champfanaticX',
fanatic: 'champfanaticX',
champfanaticx: 'champfanaticX',
'38': 'champpossessedX',
possessed: 'champpossessedX',
champpossessedx: 'champpossessedX',
'39': 'champberserkX',
berserk: 'champberserkX',
berserker: 'champberserkX',
champberserkx: 'champberserkX',
}
/**
* Resolve localized monster title, 2-space lifebar padding (`" " + Name + " "`),
* and canonical rank color (`white` Normal/Minion, `blue` Champion, `gold` Unique/SuperUnique/Boss)
* per `D2Client.dll` `sub_6FACFDD0` (`0x6FACFDD0`) and `sub_6FB70850` (`0x6FB70850`).
*/
export function resolveMonsterTitleAndColor(
target: MonsterTargetHudInfo,
locale: 'en' | 'zh' = 'en',
): {
readonly displayName: string
readonly paddedTitle: string
readonly titleColor: D2ColorCode
} {
const rawKey = target.nameKey || target.name
const baseName =
lookupMonsterTblString(rawKey, locale) ??
monsterDisplayName(rawKey, locale === 'en' ? 'ENG' : 'CHI') ??
target.name
const isGoldRank =
target.rank === 'unique' ||
target.rank === 'superunique' ||
target.rank === 'boss' ||
Boolean(target.isBoss)
if (isGoldRank) {
return {
displayName: baseName,
paddedTitle: ` ${baseName} `,
titleColor: 'gold',
}
}
if (target.rank === 'champion') {
let champTblKey = 'Champion'
const mods =
target.modifiers && target.modifiers.length > 0
? target.modifiers
: (target.affixes ?? [])
for (const mod of mods) {
const normalized = String(mod).trim().toLowerCase()
const mapped = CHAMPION_SUBTYPE_TBL_KEYS[normalized]
if (mapped) {
champTblKey = mapped
break
}
}
const prefix = lookupMonsterTblString(champTblKey, locale) ?? 'Champion'
const displayName = baseName.startsWith(`${prefix} `) ? baseName : `${prefix} ${baseName}`
return {
displayName,
paddedTitle: ` ${displayName} `,
titleColor: 'blue',
}
}
return {
displayName: baseName,
paddedTitle: ` ${baseName} `,
titleColor: 'white',
}
}
/**
* Compute authentic 1.13c monster lifebar plaque geometry and health fill ratio
* per `D2Client.dll` `sub_6FB70850` (`0x6FB70850`) and `D2Win.dll` `Ordinal_10070` (`0x6F8F18F0`).
*
* - Center X (`sub_6FB70850`):
* - Neither panel open: `canvasWidth / 2` (`400`)
* - Left panel open only: `(3 * canvasWidth) / 4` (`600`)
* - Right panel open only: `canvasWidth / 4` (`200`)
* - Both panels open: hidden (`visible: false`)
* - Vertical position: `y = 32` (`top = 19`, `bottom = 36`, `barHeight = 17`)
* - Bar width: `Math.max(128, textWidth)`
* - Health fill ratio: `clamp(floor((hp * 128) / maxHp), 2, 128)` when `hp > 0`
*/
export function computeMonsterLifebarLayout(
canvasWidth: number,
leftPanelOpen: boolean,
rightPanelOpen: boolean,
textWidth: number,
hp: number,
maxHp: number,
): MonsterLifebarLayout {
const y = 32
const barHeight = 17
const top = y - 13
const bottom = y + 4
const barWidth = Math.max(128, Math.ceil(textWidth))
if (hp <= 0 || (leftPanelOpen && rightPanelOpen)) {
const centerX = Math.floor(canvasWidth / 2)
const left = Math.floor(centerX - barWidth / 2)
return {
visible: false,
centerX,
y,
barWidth,
barHeight,
left,
top,
right: left + barWidth,
bottom,
fillRatio128: 0,
fillWidth: 0,
}
}
let centerX = Math.floor(canvasWidth / 2)
if (leftPanelOpen && !rightPanelOpen) {
centerX = Math.floor((3 * canvasWidth) / 4)
} else if (!leftPanelOpen && rightPanelOpen) {
centerX = Math.floor(canvasWidth / 4)
}
const left = Math.floor(centerX - barWidth / 2)
const right = left + barWidth
const safeMaxHp = maxHp > 0 ? maxHp : 1
const rawRatio = Math.floor((hp * 128) / safeMaxHp)
const fillRatio128 = Math.max(2, Math.min(128, rawRatio))
const fillWidth = Math.floor((barWidth * fillRatio128) / 128)
return {
visible: true,
centerX,
y,
barWidth,
barHeight,
left,
top,
right,
bottom,
fillRatio128,
fillWidth,
}
}
/**
* Canonical 1.13c immunity descriptor table (`.rdata:0x6FB834EC`, iterated in `sub_6FB719B0`).
* Strictly ordered: Physical (gold `ÿc4`), Magic (orange `ÿc8`), Fire (red `ÿc1`),
* Lightning (yellow `ÿc9`), Cold (blue `ÿc3`), Poison (green `ÿc2`).
*/
export const CANONICAL_IMMUNITY_DESCRIPTORS: readonly {
readonly key: 'physical' | 'magic' | 'fire' | 'lightning' | 'cold' | 'poison'
readonly tblKey: string
readonly colorCode: string
readonly fallbackEn: string
}[] = [
{ key: 'physical', tblKey: 'strScaryMonDesc5', colorCode: 'ÿc4', fallbackEn: 'Immune to Physical' },
{ key: 'magic', tblKey: 'strScaryMonDesc6', colorCode: 'ÿc8', fallbackEn: 'Immune to Magic' },
{ key: 'fire', tblKey: 'strScaryMonDesc1', colorCode: 'ÿc1', fallbackEn: 'Immune to Fire' },
{ key: 'lightning', tblKey: 'strScaryMonDesc3', colorCode: 'ÿc9', fallbackEn: 'Immune to Lightning' },
{ key: 'cold', tblKey: 'strScaryMonDesc2', colorCode: 'ÿc3', fallbackEn: 'Immune to Cold' },
{ key: 'poison', tblKey: 'strScaryMonDesc4', colorCode: 'ÿc2', fallbackEn: 'Immune to Poison' },
]
/**
* Complete `wMonUModStrTable` (`D2Client.dll` `.data:0x6FBA9F88`) lookup map
* covering all 14 displayed `MonUMod.txt` IDs (`5..9, 17, 18, 24..30`),
* canonical names, `.tbl` keys (`3205..3218`), and `MonUMod.txt` short names.
*/
const MONSTER_UMOD_TBL_KEYS: Readonly<Record<string, { readonly tblKey: string; readonly label: string }>> = {
'5': { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
strong: { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
uniquextrastrong: { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
'6': { tblKey: 'uniqueextrafast', label: 'Extra Fast' },
fast: { tblKey: 'uniqueextrafast', label: 'Extra Fast' },
uniqueextrafast: { tblKey: 'uniqueextrafast', label: 'Extra Fast' },
'7': { tblKey: 'uniquecursed', label: 'Cursed' },
cursed: { tblKey: 'uniquecursed', label: 'Cursed' },
curse: { tblKey: 'uniquecursed', label: 'Cursed' },
uniquecursed: { tblKey: 'uniquecursed', label: 'Cursed' },
'8': { tblKey: 'uniquemagicresistance', label: 'Magic Resistant' },
magicresistant: { tblKey: 'uniquemagicresistance', label: 'Magic Resistant' },
resist: { tblKey: 'uniquemagicresistance', label: 'Magic Resistant' },
uniquemagicresistance: { tblKey: 'uniquemagicresistance', label: 'Magic Resistant' },
'9': { tblKey: 'uniquefireenchanted', label: 'Fire Enchanted' },
fireenchant: { tblKey: 'uniquefireenchanted', label: 'Fire Enchanted' },
fire: { tblKey: 'uniquefireenchanted', label: 'Fire Enchanted' },
uniquefireenchanted: { tblKey: 'uniquefireenchanted', label: 'Fire Enchanted' },
'17': { tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted' },
lightenchant: { tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted' },
lightning: { tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted' },
monsteruniqueprop2: { tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted' },
'18': { tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted' },
coldenchant: { tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted' },
cold: { tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted' },
monsteruniqueprop1: { tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted' },
'24': { tblKey: 'monsteruniqueprop8', label: 'Thief' },
thief: { tblKey: 'monsteruniqueprop8', label: 'Thief' },
monsteruniqueprop8: { tblKey: 'monsteruniqueprop8', label: 'Thief' },
'25': { tblKey: 'monsteruniqueprop3', label: 'Mana Burn' },
manahit: { tblKey: 'monsteruniqueprop3', label: 'Mana Burn' },
mana: { tblKey: 'monsteruniqueprop3', label: 'Mana Burn' },
monsteruniqueprop3: { tblKey: 'monsteruniqueprop3', label: 'Mana Burn' },
'26': { tblKey: 'monsteruniqueprop5', label: 'Teleportation' },
teleport: { tblKey: 'monsteruniqueprop5', label: 'Teleportation' },
monsteruniqueprop5: { tblKey: 'monsteruniqueprop5', label: 'Teleportation' },
'27': { tblKey: 'monsteruniqueprop4', label: 'Spectral Hit' },
spectralhit: { tblKey: 'monsteruniqueprop4', label: 'Spectral Hit' },
spectral: { tblKey: 'monsteruniqueprop4', label: 'Spectral Hit' },
monsteruniqueprop4: { tblKey: 'monsteruniqueprop4', label: 'Spectral Hit' },
'28': { tblKey: 'monsteruniqueprop6', label: 'Stone Skin' },
stoneskin: { tblKey: 'monsteruniqueprop6', label: 'Stone Skin' },
monsteruniqueprop6: { tblKey: 'monsteruniqueprop6', label: 'Stone Skin' },
'29': { tblKey: 'monsteruniqueprop7', label: 'Multiple Shots' },
multishot: { tblKey: 'monsteruniqueprop7', label: 'Multiple Shots' },
multi: { tblKey: 'monsteruniqueprop7', label: 'Multiple Shots' },
monsteruniqueprop7: { tblKey: 'monsteruniqueprop7', label: 'Multiple Shots' },
'30': { tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted' },
aura: { tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted' },
monsteruniqueprop9: { tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted' },
}
/**
* Build the 2-line monster hover subtext below the lifebar plaque
* per `D2Client.dll` `sub_6FB70850` (`0x6FB70850`), `sub_6FB6FCA0` (`0x6FB6FCA0`),
* `sub_6FB719B0` (`0x6FB719B0`), and `sub_6FB71A20` (`0x6FB71A20`).
*
* - Line 1 (`y = 55`):
* - Race (`Undead` if `isUndead`, `Demon` if `isDemon`, omitted for animals/beasts)
* - Unique/SuperUnique: `" "`-separated `<Race> <Modifiers> <DescStr>` in `gold` (`ÿc4`)
* - Minion: `<Race> Minion` (`3221`) in `white` (`ÿc0`)
* - Normal/Champion/Boss: `<Race> <DescStr>` in rank color (`white` / `blue` / `gold`)
* - Line 2 (`y = 69` when Line 1 is non-empty, `y = 55` when Line 1 is empty):
* - `", "`-separated immunities (`res >= 100`) in exact `.rdata:0x6FB834EC` order.
*/
export function buildMonsterHoverSubtext(
target: MonsterTargetHudInfo,
locale: 'en' | 'zh' = 'en',
): MonsterHoverSubtextResult {
const raceText = target.isUndead
? (lookupMonsterTblString('UndeadDescriptX', locale) ?? 'Undead')
: target.isDemon
? (lookupMonsterTblString('DemonID', locale) ?? 'Demon')
: ''
const rawDesc = (target.descKey ?? target.descStr ?? '').trim()
const isDummyDesc = rawDesc.toLowerCase() === 'dummy'
const resolvedDesc = rawDesc && !isDummyDesc ? (lookupMonsterTblString(rawDesc, locale) ?? rawDesc) : ''
const descText = resolvedDesc.toLowerCase() === 'dummy' ? '' : resolvedDesc
let abilityPlain = ''
let abilityColorPrefix = ''
if (target.rank === 'unique' || target.rank === 'superunique') {
const modParts: string[] = []
const mods = (target.modifiers && target.modifiers.length > 0)
? target.modifiers
: (target.affixes ?? [])
for (const mod of mods) {
const normalizedMod = String(mod).trim().toLowerCase()
const umodEntry = MONSTER_UMOD_TBL_KEYS[normalizedMod]
?? CANONICAL_ELITE_MODIFIERS.find(m => m.name === normalizedMod || m.tblKey === normalizedMod)
const label = umodEntry
? (lookupMonsterTblString(umodEntry.tblKey, locale) ?? umodEntry.label)
: (lookupMonsterTblString(mod, locale) ?? mod)
if (label && label.toLowerCase() !== 'dummy') modParts.push(label)
}
if (descText) {
modParts.push(descText)
}
abilityPlain = modParts.join(' ')
abilityColorPrefix = 'ÿc4'
} else if (target.rank === 'minion') {
abilityPlain = lookupMonsterTblString('minion', locale) ?? 'Minion'
abilityColorPrefix = ''
} else {
const rawSubType = (target.subType ?? '').trim()
const cleanSubType =
rawSubType.toLowerCase() === 'dummy'
? ''
: rawSubType.includes(' · ')
? (locale === 'en' ? rawSubType.split(' · ')[0]! : rawSubType.split(' · ').slice(-1)[0]!)
: rawSubType
abilityPlain = descText || cleanSubType
if (target.rank === 'boss' || target.isBoss) {
abilityColorPrefix = 'ÿc4'
} else if (target.rank === 'champion') {
abilityColorPrefix = 'ÿc3'
} else {
abilityColorPrefix = ''
}
}
let line1 = ''
if (raceText !== '' && abilityPlain !== '') {
line1 = `${raceText} ${abilityPlain}`
} else if (raceText !== '') {
line1 = raceText
} else if (abilityPlain !== '') {
line1 = abilityPlain
}
const line1Colored =
line1 !== '' && abilityColorPrefix !== '' ? `${abilityColorPrefix}${line1}` : line1
const res = target.resists ?? target.resistances
const immPlainParts: string[] = []
const immColoredParts: string[] = []
if (res) {
for (const desc of CANONICAL_IMMUNITY_DESCRIPTORS) {
const val = res[desc.key]
if (typeof val === 'number' && val >= 100) {
const label = lookupMonsterTblString(desc.tblKey, locale) ?? desc.fallbackEn
immPlainParts.push(label)
immColoredParts.push(`${desc.colorCode}${label}`)
}
}
}
const line2 = immPlainParts.join(', ')
const line2Colored = immColoredParts.join('ÿc0, ')
const line1Y = 55
const line2Y = line1 !== '' ? 69 : 55
return {
line1,
line1Colored,
line1Y,
line2,
line2Colored,
line2Y,
}
}
const STARTER_STAT_EN_EXACT: Record<string, string> = {
'任务宝盒': 'Horadric Artifact',
'轻型铁手套': 'Light Gauntlets',
'完美宝石': 'Perfect Gem',
'宝石': 'Gem',
'骷髅': 'Skull',
'卷轴': 'Scroll',
'可镶嵌进有凹槽的装备': 'Can be Inserted into Socketed Items',
'右键点击打开合成界面 (3×4 网格)': 'Right-Click to Open Horadric Cube (3x4 Grid)',
'右键施放城镇传送门': 'Right-Click to Cast Town Portal',
'鉴定未辨识的魔法/暗金装备': 'Right-Click to Identify Unidentified Items',
'被击中时有 25% 几率施放等级 1 传送': '25% Chance to Cast Level 1 Teleport When Struck',
'击中时有 5% 几率施放等级 10 火风暴': '5% Chance to Cast Level 10 Firestorm on Striking',
'升级时有 100% 几率施放等级 29 炽烈之径': '100% Chance to Cast Level 29 Blaze When You Level-Up',
'阵亡时有 100% 几率施放等级 37 暴风雪': '100% Chance to Cast Level 37 Blizzard When You Die',
'升级时有 100% 几率施放等级 41 新星': '100% Chance to Cast Level 41 Nova When You Level-Up',
'化身为吸血鬼巫妖形态 (完整套装)': 'Transforms into Vampire Form (Full Set)',
'发射魔法箭 (等级 1)': 'Fires Magic Arrows (Level 1)',
'冻结目标时间减半': 'Half Freeze Duration',
'无法冰冻': 'Cannot Be Frozen',
'忽略目标防御': "Ignore Target's Defense",
'防止怪物自愈 / 防止怪物自疗 (Prevent Monster Heal)': 'Prevent Monster Heal',
'法球类 - 普通攻击速度': 'Orb Class - Normal Attack Speed',
'剑类 - 极快的攻击速度': 'Sword Class - Very Fast Attack Speed',
'弓类 - 极快攻击速度 [40]': 'Bow Class - Very Fast Attack Speed [40]',
'权杖类 - 极快攻击速度 [40]': 'Scepter Class - Very Fast Attack Speed [40]',
'锤类 - 极快攻击速度 [40]': 'Mace Class - Very Fast Attack Speed [40]',
'钉头槌类 - 极快攻击速度 [30]': 'Mace Class - Very Fast Attack Speed [30]',
'爪类 - 极快攻击速度 [40]': 'Claw Class - Very Fast Attack Speed [40]',
}
function localizeStarterTooltipLine(text: string, isEn: boolean): string {
if (!text) return text
if (!isEn) {
// CHI mode: strip trailing pure-ASCII parentheticals like `(Harlequin Crest)`, `(Shako)`, `(FCR)`, `(+2 To All Skills)`
// and clean up inline English annotations on starter items while keeping authentic Chinese parens like `(完整套装)` / `(数量: 20)`.
let zh = text
.replace(/^精神\s*\(Spirit\)\s*统盾$/, '精神')
.replace(/\s*\(([A-Za-z]+)\s+符文\)$/, '符文')
.replace(/\s*\(Rainbow Facet\s*-\s*([^)]+)\)$/, ' ($1)')
.replace(/\s*\/\s*每次命中偷取生命\s*\([^)]+\)$/, '')
.replace(/\s*\/\s*防止怪物自疗\s*\([^)]+\)$/, '')
.replace(/\s*\/\s*战嚎\s*\([^)]+\)$/, '')
.replace(/\s*\/\s*生命补满\s*\+\d+\s*\([^)]+\)$/, '')
zh = zh.replace(/\s*\([A-Za-z0-9'+\-% /×·,.#:]+\)$/, '')
return zh
}
// ENG mode: zero CJK characters allowed
if (!/[\u4e00-\u9fff]/.test(text)) {
return text.replace(
/^(\d+)\s*to\s*(\d+)\s*\/\s*Two-Hand:\s*(\d+)\s*to\s*(\d+)$/i,
'One-Hand Damage: $1 to $2 / Two-Hand Damage: $3 to $4',
)
}
if (text.includes('ÿc')) {
return text
.split(/(ÿc[0-9:;])/g)
.map((part) => {
if (/^ÿc[0-9:;]$/.test(part) || !part) return part
const lead = /^\s*/.exec(part)?.[0] ?? ''
const trail = /\s*$/.exec(part)?.[0] ?? ''
const core = part.trim()
if (!core) return part
return `${lead}${localizeStarterTooltipLine(core, true).trim()}${trail}`
})
.join('')
}
if (STARTER_STAT_EN_EXACT[text]) return STARTER_STAT_EN_EXACT[text]!
// If the string has an explicit English parenthetical like `谐角之冠 (Harlequin Crest)` or `+2 所有技能 (+2 To All Skills)`
const trailingEnMatch = /\(([A-Za-z0-9'+\-% /,.#:]+)\)$/.exec(text)
if (trailingEnMatch) {
const inner = trailingEnMatch[1]!.trim()
// Only use `inner` directly if it's a full phrase/name (not a short acronym like FCR/FHR/IAS/ED/FRW/MF/CB or number like 40/15)
if (
inner.length > 3 &&
!/^(FCR|FHR|IAS|FRW|MF|ED|CB|\d+\/\d+|#\d+|\d+)$/.test(inner)
) {
return inner
}
}
let s = text
// Strip short acronym parentheticals before pattern replacement
.replace(/\s*\((?:FCR|FHR|IAS|FRW|MF|ED|CB)\)/g, '')
// Full set / partial set suffixes
.replace(/(^|\s+)\(完整套装加成\)/g, '$1(Full Set Bonus)')
.replace(/(^|\s+)\(完整套装\)/g, '$1(Full Set)')
.replace(/(^|\s+)\((\d+)\s*件装备\)/g, '$1($2 Items)')
.replace(/(^|\s+)\(依角色等级而定\)/g, '$1(Based on Character Level)')
.replace(/(^|\s+)\(持续\s*(\d+)\s*秒\)/g, '$1(Over $2 Seconds)')
// Class restrictions inside skill lines
.replace(/(^|\s+)\(限法师\)/g, '$1(Sorceress Only)')
.replace(/(^|\s+)\(限死灵法师\)/g, '$1(Necromancer Only)')
.replace(/(^|\s+)\(限圣骑士\)/g, '$1(Paladin Only)')
.replace(/(^|\s+)\(限野蛮人\)/g, '$1(Barbarian Only)')
.replace(/(^|\s+)\(限德鲁伊\)/g, '$1(Druid Only)')
.replace(/(^|\s+)\(限刺客\)/g, '$1(Assassin Only)')
.replace(/(^|\s+)\(限亚马逊\)/g, '$1(Amazon Only)')
// Weapon damage lines
.replace(/^双手伤害:\s*(\d+)\s*-\s*(\d+)$/, 'Two-Hand Damage: $1 to $2')
.replace(/^投掷伤害:\s*(\d+)\s*-\s*(\d+)$/, 'Throw Damage: $1 to $2')
.replace(/^单手伤害:\s*(\d+)\s*-\s*(\d+)$/, 'One-Hand Damage: $1 to $2')
.replace(/^(\d+)\s*-\s*(\d+)\s*\/\s*双手:\s*(\d+)\s*-\s*(\d+)$/, 'One-Hand Damage: $1 to $2 / Two-Hand Damage: $3 to $4')
.replace(/^(\d+)\s*to\s*(\d+)\s*\/\s*Two-Hand:\s*(\d+)\s*to\s*(\d+)$/i, 'One-Hand Damage: $1 to $2 / Two-Hand Damage: $3 to $4')
.replace(/^双手伤害:\s*/, 'Two-Hand Damage: ')
.replace(/^投掷伤害:\s*/, 'Throw Damage: ')
.replace(/^单手伤害:\s*/, 'One-Hand Damage: ')
.replace(/\s*\/\s*双手:\s*/, ' / Two-Hand Damage: ')
// Specific base/title patterns
.replace(/^法术书\s*\(数量[::]\s*(\d+)\)$/, 'Tome (Quantity: $1)')
.replace(/^符文\s*\(#(\d+)\)$/, 'Rune (#$1)')
.replace(/^带插槽\s*\((\d+)\)$/, 'Socketed ($1)')
.replace(/^凹槽\s*\((\d+)\)$/, 'Socketed ($1)')
// Stat patterns
.replace(/^\+(\d+)\s*所有技能/, '+$1 to All Skills')
.replace(/^\+(\d+)\s*亚马逊技能等级/, '+$1 to Amazon Skill Levels')
.replace(/^\+(\d+)\s*法师技能等级/, '+$1 to Sorceress Skill Levels')
.replace(/^\+(\d+)\s*死灵法师技能等级/, '+$1 to Necromancer Skill Levels')
.replace(/^\+(\d+)\s*圣骑士技能等级/, '+$1 to Paladin Skill Levels')
.replace(/^\+(\d+)\s*野蛮人技能等级/, '+$1 to Barbarian Skill Levels')
.replace(/^\+(\d+)\s*德鲁伊技能等级/, '+$1 to Druid Skill Levels')
.replace(/^\+(\d+)\s*刺客技能等级/, '+$1 to Assassin Skill Levels')
.replace(/^\+(\d+)\s*角色技能等级/, '+$1 to Character Skill Levels')
.replace(/^\+(\d+)\s*火焰技能/, '+$1 to Fire Skills')
.replace(/^\+(\d+)\s*亚马逊被动与魔法技能/, '+$1 to Passive and Magic Skills (Amazon Only)')
.replace(/^\+(\d+)\s*亚马逊弓与十字弓技能/, '+$1 to Bow and Crossbow Skills (Amazon Only)')
.replace(/^\+(\d+)\s*召唤技能/, '+$1 to Summoning Skills')
.replace(/^\+(\d+)\s*毒素与白骨技能/, '+$1 to Poison and Bone Skills')
.replace(/^\+(\d+)\s*诅咒技能/, '+$1 to Curses')
.replace(/^\+(\d+)\s*防御灵气/, '+$1 to Defensive Auras')
.replace(/^\+(\d+)\s*战吼技能/, '+$1 to Warcries')
.replace(/^\+(\d+)\s*战斗技能/, '+$1 to Combat Skills')
.replace(/^\+(\d+)\s*元素技能/, '+$1 to Elemental Skills')
.replace(/^\+(\d+)\s*变形技能/, '+$1 to Shape Shifting Skills')
.replace(/^\+(\d+)\s*影子训练技能/, '+$1 to Shadow Disciplines')
.replace(/^\+(\d+)\s*支配火焰/, '+$1 to Fire Mastery')
.replace(/^\+(\d+)\s*支配闪电/, '+$1 to Lightning Mastery')
.replace(/^\+(\d+)\s*支配冰冷/, '+$1 to Cold Mastery')
.replace(/^\+(\d+)\s*火球/, '+$1 to Fire Ball')
.replace(/^\+(\d+)\s*火墙/, '+$1 to Fire Wall')
.replace(/^\+(\d+)\s*陨石/, '+$1 to Meteor')
.replace(/^\+(\d+)%\s*快速施法速度/, '+$1% Faster Cast Rate')
.replace(/^\+(\d+)%\s*快速再度攻击/, '+$1% Faster Hit Recovery')
.replace(/^\+(\d+)%\s*快速格挡率/, '+$1% Faster Block Rate')
.replace(/^\+(\d+)%\s*增加格挡几率/, '+$1% Increased Chance of Blocking')
.replace(/^\+(\d+)%\s*高速跑步\/行走/, '+$1% Faster Run/Walk')
.replace(/^\+(\d+)%\s*提升攻击速度/, '+$1% Increased Attack Speed')
.replace(/^\+(\d+)%\s*增强伤害/, '+$1% Enhanced Damage')
.replace(/^\+(\d+)%\s*增强防御/, '+$1% Enhanced Defense')
.replace(/^\+(\d+)%\s*额外攻击准确率/, '+$1% Bonus to Attack Rating')
.replace(/^\+(\d+)%\s*对恶魔的伤害/, '+$1% Damage to Demons')
.replace(/^\+(\d+)%\s*对不死生物的伤害/, '+$1% Damage to Undead')
.replace(/^\+(\d+)%\s*冰冷技能伤害/, '+$1% to Cold Skill Damage')
.replace(/^\+(\d+)%\s*火焰技能伤害/, '+$1% to Fire Skill Damage')
.replace(/^\+(\d+)%\s*闪电技能伤害/, '+$1% to Lightning Skill Damage')
.replace(/^\+(\d+)%\s*毒素技能伤害/, '+$1% to Poison Skill Damage')
.replace(/^-(\d+)%\s*敌人火焰抗性/, '-$1% to Enemy Fire Resistance')
.replace(/^-(\d+)%\s*敌人冰冷抗性/, '-$1% to Enemy Cold Resistance')
.replace(/^-(\d+)%\s*敌人闪电抗性/, '-$1% to Enemy Lightning Resistance')
.replace(/^\+(\d+)%\s*获得经验值/, '+$1% to Experience Gained')
.replace(/^(\d+)%\s*压碎性打击/, '$1% Chance of Crushing Blow')
.replace(/^(\d+)%\s*每次命中偷取生命/, '$1% Life Stolen per Hit')
.replace(/^(\d+)%\s*每次命中偷取法力/, '$1% Mana Stolen per Hit')
.replace(/^(\d+)%\s*更佳机会取得魔法装备/, '$1% Better Chance of Getting Magic Items')
.replace(/^(\d+)%\s*额外金币从怪物身上取得/, '$1% Extra Gold from Monsters')
.replace(/^(\d+)%\s*额外金币取得/, '$1% Extra Gold from Monsters')
.replace(/^降低所有(?:商店|商人)价格\s*(\d+)%/, 'Reduces All Vendor Prices $1%')
.replace(/^增加法力上限\s*(\d+)%/, 'Increase Maximum Mana $1%')
.replace(/^法力(?:再生|回复)速度提高\s*(\d+)%/, 'Regenerate Mana $1%')
.replace(/^减慢目标速度\s*(\d+)%/, 'Slows Target by $1%')
.replace(/^减慢耐力消耗\s*(\d+)%/, '$1% Slower Stamina Drain')
.replace(/^受损生命转化为法力\s*(\d+)%/, '$1% Damage Taken Goes to Mana')
.replace(/^物理伤害减少\s*(\d+)%/, 'Damage Reduced by $1%')
.replace(/^魔法伤害减少\s*(\d+)/, 'Magic Damage Reduced by $1')
.replace(/^需求\s*-(\d+)%/, 'Requirements -$1%')
.replace(/^所有抗性\s*\+(\d+)/, 'All Resistances +$1')
.replace(/^抗寒\s*\+(\d+)%/, 'Cold Resist +$1%')
.replace(/^抗电\s*\+(\d+)%/, 'Lightning Resist +$1%')
.replace(/^抗火\s*\+(\d+)%/, 'Fire Resist +$1%')
.replace(/^抗毒\s*\+(\d+)%/, 'Poison Resist +$1%')
.replace(/^增加\s*(\d+-\d+)\s*火焰伤害/, 'Adds $1 Fire Damage')
.replace(/^增加\s*(\d+-\d+)\s*闪电伤害/, 'Adds $1 Lightning Damage')
.replace(/^增加\s*(\d+-\d+)\s*冰冷伤害/, 'Adds $1 Cold Damage')
.replace(/^增加\s*(\d+)\s*冰冷伤害/, 'Adds $1 Cold Damage')
.replace(/^增加\s*(\d+-\d+)\s*魔法伤害/, 'Adds $1 Magic Damage')
.replace(/^增加\s*(\d+-\d+)\s*伤害/, 'Adds $1 Damage')
.replace(/^\+(\d+)\s*生命补给/, 'Replenish Life +$1')
.replace(/^生命回复\s*\+(\d+)/, 'Replenish Life +$1')
.replace(/^\+([\d.]+)\s*最大生命/, '+$1 to Maximum Life')
.replace(/^\+([\d.]+)\s*生命/, '+$1 to Life')
.replace(/^\+([\d.]+)\s*法力/, '+$1 to Mana')
.replace(/^\+(\d+)\s*最大耐力/, '+$1 Maximum Stamina')
.replace(/^\+(\d+)\s*力量/, '+$1 to Strength')
.replace(/^\+(\d+)\s*敏捷/, '+$1 to Dexterity')
.replace(/^\+(\d+)\s*体力/, '+$1 to Vitality')
.replace(/^\+(\d+)\s*精力/, '+$1 to Energy')
.replace(/^\+(\d+)\s*所有属性/, '+$1 to All Attributes')
.replace(/^\+(\d+)\s*防御/, '+$1 Defense')
.replace(/^\+(\d+)\s*对远距离攻击的防御/, '+$1 Defense vs. Missile')
.replace(/^\+(\d+)\s*攻击准确率/, '+$1 to Attack Rating')
.replace(/^\+(\d+)\s*照亮范围/, '+$1 to Light Radius')
.replace(/^\+(\d+)\s*每次击杀获得法力/, '+$1 to Mana After Each Kill')
.replace(/^\+(\d+)\s*魔法吸收/, '+$1 Magic Absorb')
.replace(/^攻击者受到伤害\s*(\d+)/, 'Attacker Takes Damage of $1')
return s
}