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

3982 lines
157 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,
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 { CharacterClassCode } from '../game/classes.ts'
import { getTblLang, type TblLang } from '../i18n/lang.ts'
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
import {
type TownNpcServiceDescriptor,
type VendorNpcId,
getTownNpcDescriptorByName,
getTownNpcDescriptorByVendorId,
getLocalizedNpcName,
getLocalizedNpcGreetings,
} from '../game/npc-table.ts'
import type { VendorTabId } from '../game/vendor-generator.ts'
import { VendorSessionManager } from '../game/vendor-service.ts'
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { isItemRepairable } from '../game/item-cost.ts'
import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT } from '../data/canonical-tbl-strings.ts'
import { CANONICAL_ELITE_MODIFIERS, monsterDisplayName } from '../game/monsters.ts'
import { getSocketableInfo, isJewelCode } from '../game/gems-data.ts'
import {
transmuteCubeItems,
type CubeTransmuteResult,
} from '../game/runeword-socket.ts'
export {
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'
export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
export const STASH_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const
export const STASH_GOLD_CAP = 2_500_000
export const PLAYER_GOLD_CAP = 2_500_000
export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } as const
/**
* `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
export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel'
export interface NpcMenuOption {
readonly id: NpcMenuActionId
readonly strIdx?: number
readonly labelZh: string
readonly labelEn: string
}
export const BOXPIECES_FRAME_W = 14
export const BOXPIECES_FRAME_H = 15
export interface NpcDialogLayout {
readonly left: number
readonly top: number
readonly width: number
readonly height: number
readonly headerY: number
readonly optionYs: readonly number[]
}
/**
* D2Client.dll `0x6fb53580` (`DIALOG_Layout` in `dialog.cpp`):
* - Uses `font16` (`D2Win_SetFont(1)`)
* - Line 0 (NPC Name Header): `lineStepY = 21` (`0x15`), `selectable = false`
* - Lines 1..N (Menu Options): `lineStepY = 15` (`0x0f`), `selectable = true`
* - `width = maxLineWidth + 20` (`0x14`), `height = totalStepY + 15` (`0x0f`)
* - `left = anchorX - trunc(width / 2)`, `top = anchorY - 21`
* - Clamped to 800x600 viewport (`left ∈ [10, 800 - width]`, `top ∈ [10, 600 - height - 48]`)
*/
export function computeNpcDialogLayout(
npcName: string,
options: readonly NpcMenuOption[],
anchorX: number,
anchorY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
lang: TblLang = 'CHI',
): 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 > 800 - 10) {
left = 800 - width
}
if (top + height > 600 - 58) {
top = 600 - height - 48
}
if (left < 10) left = 10
if (top < 10) top = 10
const headerY = top + headerStepY
const optionYs = options.map((_, idx) => top + headerStepY + (idx + 1) * optionStepY)
return {
left,
top,
width,
height,
headerY,
optionYs,
}
}
/**
* D2Client.dll `0x6fb534b0` (`DIALOG_GetLineAtPoint` in `dialog.cpp`):
* Open-interval hit-testing for selectable options:
* `mouseX > left + 15 && mouseX < left + width - 15 && mouseY > rowY - 11 && mouseY < rowY + 4`
*/
export function hitTestNpcDialogOption(
layout: NpcDialogLayout,
logicalX: number,
logicalY: number,
): number | null {
if (logicalX <= layout.left + 15 || logicalX >= layout.left + layout.width - 15) {
return null
}
for (let i = 0; i < layout.optionYs.length; i++) {
const rowY = layout.optionYs[i]!
if (logicalY > rowY - 11 && logicalY < rowY + 4) {
return i
}
}
return null
}
/**
* D2Client.dll `0x6fb53840` (`DIALOG_Draw`) + `0x6fb6e4c0` (`DATA\GLOBAL\UI\menu\boxpieces.dc6`):
* - Draws translucent black background fill at `(left + 1, top, width - 2, height - 2)`
* - Tiles the 22 `14x15` carved stone pieces from `boxpieces.dc6`:
* - Top edge: frames `2..7` (`(esi % 6) + 2`) at `x = left + 12 .. right - 14` step `12`, `y = top + 12`
* - Bottom edge: frames `16..21` (`(esi % 6) + 16`) at `x = left + 13 .. right - 14` step `12`, `y = bottom + 10`
* - Left edge: frames `10..12` (`(esi % 3) + 10`) at `x = left - 4`, `y = top + 22 .. bottom - 2` step `12`
* - Right edge: frames `13..15` (`(esi % 3) + 13`) at `x = right - 7`, `y = top + 24 .. bottom - 2` step `12`
* - Corners: frame `0` `(left, top + 12)`, frame `1` `(right - 12, top + 12)`,
* frame `8` `(left, bottom + 1)`, frame `9` `(right - 12, bottom + 1)`
* - Each `14x15` DC6 cell passed `(nXpos, nYpos)` to `D2GFX_DrawCellContext` is blitted with top-left `(nXpos, nYpos - 15)`.
*/
export function drawStoneBoxFrame(
ctx: CanvasRenderingContext2D,
left: number,
top: number,
width: number,
height: number,
boxPiecesImg: HTMLImageElement | null | undefined,
): void {
if (!boxPiecesImg) {
throw new Error('Missing required baked UI asset boxpieces.png (DATA\\GLOBAL\\UI\\menu\\boxpieces.dc6)')
}
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
ctx.fillRect(left + 1, top, Math.max(0, width - 2), Math.max(0, height - 2))
const right = left + width
const bottom = top + height
const drawPiece = (frameIdx: number, x: number, y: number): void => {
ctx.drawImage(
boxPiecesImg,
frameIdx * BOXPIECES_FRAME_W,
0,
BOXPIECES_FRAME_W,
BOXPIECES_FRAME_H,
x,
y - BOXPIECES_FRAME_H,
BOXPIECES_FRAME_W,
BOXPIECES_FRAME_H,
)
}
let esi = 0
for (let x = left + 12; x < right - 13; x += 12) {
esi++
drawPiece((esi % 6) + 2, x, top + 12)
}
for (let x = left + 13; x < right - 13; x += 12) {
esi++
drawPiece((esi % 6) + 16, x, bottom + 10)
}
for (let y = top + 22; y < bottom - 1; y += 12) {
esi++
drawPiece((esi % 3) + 10, left - 4, y)
}
for (let y = top + 24; y < bottom - 1; y += 12) {
esi++
drawPiece((esi % 3) + 13, right - 7, y)
}
drawPiece(0, left, top + 12)
drawPiece(1, right - 12, top + 12)
drawPiece(8, left, bottom + 1)
drawPiece(9, right - 12, bottom + 1)
}
import {
CANONICAL_QUESTS_BY_ACT,
type CanonicalQuestItem,
type CanonicalQuestStage,
} from '../data/canonical-quest-data.ts'
export interface QuestEntry {
readonly id: string
readonly act: number
readonly index: number
readonly titleEn: string
readonly titleZh: string
readonly titleKey: string
readonly status: 'completed' | 'active' | 'locked'
readonly description: string
readonly objectiveKey: string
readonly objectiveEn: string
readonly objectiveZh: string
readonly completedKey: string
readonly completedEn: string
readonly completedZh: string
readonly rewardSummaryEn: string
readonly rewardSummaryZh: string
readonly stages: readonly CanonicalQuestStage[]
}
export type QuestTblLookup = (key: string) => string | undefined
export interface QuestDisplayText {
readonly title: string
readonly titleKey: string
readonly body: string
readonly status: 'completed' | 'active' | 'locked'
readonly statusTag: string
readonly rewardText: string
}
export interface WaypointEntry {
readonly act: number
readonly index: number
readonly waypointId: number
readonly levelId: number
readonly slug: string
readonly nameEn: string
readonly nameZh: string
readonly unlocked: boolean
}
const DEFAULT_COMPLETED_QUEST_IDS = new Set(['a1q1', 'a1q2', 'a1q3'])
function createQuestEntry(item: CanonicalQuestItem): QuestEntry {
const isCompleted = DEFAULT_COMPLETED_QUEST_IDS.has(item.id)
return {
...item,
status: isCompleted ? 'completed' : 'active',
description: isCompleted
? `${item.completedZh} (奖励: ${item.rewardSummaryZh})`
: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
}
}
export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = {
1: CANONICAL_QUESTS_BY_ACT[1]!.map(createQuestEntry),
2: CANONICAL_QUESTS_BY_ACT[2]!.map(createQuestEntry),
3: CANONICAL_QUESTS_BY_ACT[3]!.map(createQuestEntry),
4: CANONICAL_QUESTS_BY_ACT[4]!.map(createQuestEntry),
5: CANONICAL_QUESTS_BY_ACT[5]!.map(createQuestEntry),
}
/** Flat list of all 27 canonical Diablo II quests across Acts 1..5. */
export const ALL_QUESTS: readonly QuestEntry[] = [
...ACT_QUESTS[1]!,
...ACT_QUESTS[2]!,
...ACT_QUESTS[3]!,
...ACT_QUESTS[4]!,
...ACT_QUESTS[5]!,
]
export const ACT_WAYPOINTS: Readonly<Record<number, readonly WaypointEntry[]>> = {
1: [
{ act: 1, index: 0, waypointId: 0, levelId: 1, slug: 'rogue-encampment', nameEn: 'Rogue Encampment', nameZh: '罗格营地', unlocked: true },
{ act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: true },
{ act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: true },
{ act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: true },
{ act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: true },
{ act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: true },
{ act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: true },
{ act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: true },
{ act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: true },
],
2: [
{ act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: true },
{ act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: true },
{ act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', nameZh: '干燥高地', unlocked: true },
{ act: 2, index: 3, waypointId: 12, levelId: 57, slug: 'halls-of-the-dead-level-2', nameEn: 'Halls of the Dead Level 2', nameZh: '死亡之殿第二层', unlocked: true },
{ act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: true },
{ act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: true },
{ act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: true },
{ act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: true },
{ act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: true },
],
3: [
{ act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: true },
{ act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: true },
{ act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: true },
{ act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: true },
{ act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: true },
{ act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: true },
{ act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: true },
{ act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', nameZh: '崔凡克', unlocked: true },
{ act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: true },
],
4: [
{ act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: true },
{ act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: true },
{ act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: true },
],
5: [
{ act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: true },
{ act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: true },
{ act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: true },
{ act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: true },
{ act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: true },
{ act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: true },
{ act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: true },
{ act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: true },
{ act: 5, index: 8, waypointId: 38, levelId: 129, slug: 'the-worldstone-keep-level-2', nameEn: 'Worldstone Keep Level 2', nameZh: '世界之石要塞第二层', unlocked: true },
],
}
/** Authentic D2Client.dll 1.13c waypoint row Y offsets table at VA 0x6fb8e6d8. */
export const WAYPOINT_ROW_OFFSETS_Y: readonly number[] = [60, 96, 132, 168, 205, 241, 277, 313, 349] as const
export function findWaypointBySlug(slug: string): WaypointEntry | undefined {
for (const list of Object.values(ACT_WAYPOINTS)) {
const found = list.find(w => w.slug === slug)
if (found) return found
}
return undefined
}
export class WorldPanelsHud {
lang: TblLang = getTblLang()
selectedActTab = 1
selectedQuestIdx = 0
stashGold = 2_500_000
cubeTransmuteCount = 0
escapeMenuOpen = false
lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null
npcMenu: {
npcName: string
x: number
y: number
dialogLines: readonly string[]
talking: boolean
descriptor?: TownNpcServiceDescriptor | undefined
options?: readonly NpcMenuOption[] | undefined
hoveredOptionIdx?: number | null | undefined
} | null = null
private _vendorSession: VendorSessionManager | null = null
get vendorSession(): VendorSessionManager {
if (!this._vendorSession) {
this._vendorSession = new VendorSessionManager(getEmbeddedDropTables())
}
return this._vendorSession
}
activeVendorDescriptor: TownNpcServiceDescriptor | null = null
vendorMode: 'trade' | 'gamble' = 'trade'
activeVendorTab: VendorTabId = 'armor'
vendorDifficulty: 0 | 1 | 2 = 0
vendorCharLevel = 85
vendorPlayerStats: PlayerItemRequirementContext = {
level: 85,
str: 156,
dex: 75,
classCode: 'sor',
}
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): NpcMenuOption[] {
const opts: NpcMenuOption[] = [{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' }]
if (descriptor.canIdentify) {
opts.push({ id: 'identify', strIdx: 4020, labelZh: '辨視物品', labelEn: 'Identify Items' })
}
if (descriptor.canTrade) {
opts.push({
id: 'trade',
strIdx: descriptor.canRepair ? 3334 : 3396,
labelZh: descriptor.canRepair ? '交易/修理' : '交易',
labelEn: descriptor.canRepair ? 'trade/repair' : 'trade',
})
}
if (descriptor.canGamble) {
opts.push({ id: 'gamble', strIdx: 3398, labelZh: '賭博', labelEn: 'gamble' })
}
opts.push({ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' })
return opts
}
openNpcMenu(
descriptor: TownNpcServiceDescriptor,
screenX = 400,
screenY = 220,
): void {
const options = this.buildNpcMenuOptions(descriptor)
// D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open.
// Box position clamping is performed dynamically in computeNpcDialogLayout (0x6fb53580).
this.npcMenu = {
npcName: descriptor.displayName,
x: Math.round(screenX),
y: Math.round(screenY),
dialogLines: descriptor.greetingLines,
talking: false,
descriptor,
options,
hoveredOptionIdx: 0,
}
}
openNpcMenuByName(name: string, screenX = 400, screenY = 220): boolean {
const desc = getTownNpcDescriptorByName(name)
if (!desc) return false
this.openNpcMenu(desc, screenX, screenY)
return true
}
openVendorForNpc(
descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string,
mode: 'trade' | 'gamble' = 'trade',
cLvl = 85,
difficulty: 0 | 1 | 2 = 0,
): boolean {
const descriptor =
typeof descriptorOrName === 'object'
? descriptorOrName
: typeof descriptorOrName === 'number'
? getTownNpcDescriptorByVendorId(descriptorOrName)
: getTownNpcDescriptorByName(descriptorOrName)
if (!descriptor || descriptor.vendorId === null) return false
// Fail-fast validation against D2Client.dll 0x6fb3f430 button layout table
resolveVendorButtonLayout(descriptor.hcIdx, mode)
this.activeVendorDescriptor = descriptor
this.vendorMode = mode
this.vendorCharLevel = Math.max(1, cLvl)
this.vendorDifficulty = difficulty
this.vendorTradeState = 'idle'
this.hoveredVendorButtonSlot = null
this.pressedMomentarySlot = null
this.hoveredVendorItem = null
const tables = getEmbeddedDropTables()
const vendorId = descriptor.vendorId
if (mode === 'gamble') {
const gambleInv = this.vendorSession.getOrCreateGambleStore(vendorId, this.vendorCharLevel)
this.gamblePlacements = gambleInv.getAllItems().map(p => {
const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, difficulty, this.vendorCharLevel, true)
const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, isGambleMystery: true }, tables)
return { item: uiItem, col: p.x, row: p.y }
})
this.activeVendorTab = 'armor'
return true
}
const store = this.vendorSession.getOrCreateVendorStore(vendorId, difficulty, this.vendorCharLevel)
const tabIds: VendorTabId[] = ['armor', 'weapons1', 'weapons2', 'misc']
for (const tabId of tabIds) {
const inv = store.tabs[tabId]
this.vendorTabPlacements[tabId] = inv.getAllItems().map(p => {
const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, difficulty, this.vendorCharLevel, false)
const sellCost = this.vendorSession.getSellPrice(p.item, vendorId, difficulty, this.vendorCharLevel)
const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, sellCost }, tables)
return { item: uiItem, col: p.x, row: p.y }
})
}
// D2Client.dll 0x6fb411a0 -> 0x6fb3f710(0): start at tab 0 ('armor') and cycle to first non-empty tab
this.selectVendorTab('armor')
return true
}
getActiveVendorPlacements(): GridPlacement[] {
if (this.vendorMode === 'gamble') {
return this.gamblePlacements
}
return this.vendorTabPlacements[this.activeVendorTab] ?? []
}
getPlayerItemSellPrice(item: UiInventoryItem): number {
if (item.sellCost !== undefined) return item.sellCost
const vendorId = this.activeVendorDescriptor?.vendorId ?? (0 as VendorNpcId)
if (item.rawItem) {
return this.vendorSession.getSellPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel)
}
return Math.max(1, Math.floor(150))
}
getPlayerItemRepairPrice(item: UiInventoryItem): number {
if (!item.durability || item.durability.current >= item.durability.max) return 0
if (item.ethereal) return 0
const vendorId = this.activeVendorDescriptor?.vendorId ?? (2 as VendorNpcId)
if (item.rawItem) {
const syncedRaw = {
...item.rawItem,
durability: item.durability.current,
maxDurability: item.durability.max,
}
if (!isItemRepairable(syncedRaw)) return 0
return this.vendorSession.getRepairPrice(syncedRaw, vendorId, this.vendorDifficulty, this.vendorCharLevel)
}
const lost = Math.max(0, item.durability.max - item.durability.current)
return Math.max(1, lost * 12)
}
buyFromActiveVendor(
col: number,
row: number,
playerInventory: InventoryPanel,
isShiftClick = false,
): { ok: boolean; reason?: string; boughtCount: number; totalCost: number } {
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
return { ok: false, reason: 'no_vendor', boughtCount: 0, totalCost: 0 }
}
const vendorId = this.activeVendorDescriptor.vendorId
const placements = this.getActiveVendorPlacements()
const hitIdx = placements.findIndex(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (hitIdx === -1) {
return { ok: false, reason: 'empty_cell', boughtCount: 0, totalCost: 0 }
}
const hit = placements[hitIdx]!
const unitCost = hit.item.buyCost ?? 1
if (playerInventory.gold < unitCost) {
this.showVendorStatus(this.lang === 'ENG' ? 'Not enough gold' : '金币不足', 'red')
return { ok: false, reason: 'not_enough_gold', boughtCount: 0, totalCost: 0 }
}
const tables = getEmbeddedDropTables()
// Gamble Mode: resolve mystery item into concrete rolled item!
if (this.vendorMode === 'gamble') {
const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight)
if (!freeSlot) {
this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red')
return { ok: false, reason: 'inventory_full', boughtCount: 0, totalCost: 0 }
}
const gambleRes = this.vendorSession.buyGambleItem(
vendorId,
hit.col,
hit.row,
playerInventory.gold,
this.vendorCharLevel,
)
if (!gambleRes.success || !gambleRes.item) {
this.showVendorStatus(this.lang === 'ENG' ? 'Cannot gamble this item' : '无法赌博该物品', 'red')
return { ok: false, reason: gambleRes.error ?? 'gamble_failed', boughtCount: 0, totalCost: 0 }
}
playerInventory.gold = gambleRes.remainingGold
const sellCost = this.vendorSession.getSellPrice(gambleRes.item, vendorId, this.vendorDifficulty, this.vendorCharLevel)
const resolvedUi = itemToUiInventoryItem({ ...(gambleRes.item as any), sellCost }, tables)
playerInventory.gridItems.push({ item: resolvedUi, col: freeSlot.col, row: freeSlot.row })
this.gamblePlacements.splice(hitIdx, 1)
this.showVendorStatus(
this.lang === 'ENG' ? `Gambled: ${resolvedUi.name}` : `赌博获得: ${resolvedUi.nameZh}`,
'gold',
)
return { ok: true, boughtCount: 1, totalCost: gambleRes.goldSpent }
}
// Trade Mode: Shift-Click bulk buy for Scrolls/Potions/Keys
const isScrollOrPotionOrKey =
hit.item.permStore &&
(hit.item.code === 'tsc' ||
hit.item.code === 'isc' ||
hit.item.code === 'key' ||
hit.item.code === 'yps' ||
hit.item.code === 'vps' ||
hit.item.code === 'wms' ||
hit.item.code.startsWith('hp') ||
hit.item.code.startsWith('mp'))
const maxCopies = isShiftClick && isScrollOrPotionOrKey ? 8 : 1
let boughtCount = 0
let totalCost = 0
for (let i = 0; i < maxCopies; i++) {
if (playerInventory.gold < unitCost) break
const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight)
if (!freeSlot) {
if (boughtCount === 0) {
this.showVendorStatus(this.lang === 'ENG' ? 'Inventory full' : '背包空间不足', 'red')
return { ok: false, reason: 'inventory_full', boughtCount: 0, totalCost: 0 }
}
break
}
playerInventory.gold -= unitCost
totalCost += unitCost
boughtCount++
const rawClone = hit.item.rawItem
? { ...hit.item.rawItem, uniqueId: Math.floor(Math.random() * 0x7fffffff), permStore: false }
: undefined
const sellCost = rawClone
? this.vendorSession.getSellPrice(rawClone, vendorId, this.vendorDifficulty, this.vendorCharLevel)
: Math.max(1, Math.floor(unitCost / 2))
const clonedUiItem: UiInventoryItem = {
...hit.item,
id: `item-${hit.item.code}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
...(rawClone ? { rawItem: rawClone } : {}),
sellCost,
}
delete (clonedUiItem as any).buyCost
delete (clonedUiItem as any).permStore
playerInventory.gridItems.push({ item: clonedUiItem, col: freeSlot.col, row: freeSlot.row })
if (!hit.item.permStore) {
placements.splice(hitIdx, 1)
if (this.vendorMode === 'trade' && placements.length === 0) {
this.selectVendorTab(this.activeVendorTab)
}
break
}
}
if (boughtCount > 0) {
this.showVendorStatus(
this.lang === 'ENG'
? `Bought ${hit.item.name}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} Gold)`
: `已购买 ${hit.item.nameZh}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} 金币)`,
'gold',
)
return { ok: true, boughtCount, totalCost }
}
return { ok: false, reason: 'purchase_failed', boughtCount: 0, totalCost: 0 }
}
sellToActiveVendor(
item: UiInventoryItem,
playerInventory: InventoryPanel,
): { ok: boolean; goldReceived: number } {
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
return { ok: false, goldReceived: 0 }
}
const vendorId = this.activeVendorDescriptor.vendorId
// Quest items cannot be sold
if (item.rawItem?.base?.quest && item.rawItem.base.quest > 0) {
this.showVendorStatus(
this.lang === 'ENG' ? 'Cannot sell quest item' : '任务物品无法出售',
'red',
)
return { ok: false, goldReceived: 0 }
}
const sellPrice = this.getPlayerItemSellPrice(item)
playerInventory.gold = Math.min(PLAYER_GOLD_CAP, playerInventory.gold + sellPrice)
// Place into vendor store tab for buyback (unless in gamble mode or item is permStore)
if (this.vendorMode === 'trade' && !item.permStore) {
const kind = item.rawItem?.base?.kind ?? (item.allowedSlots.includes('weapon1') ? 'weapon' : item.allowedSlots.length > 0 ? 'armor' : 'misc')
let targetTab: VendorTabId = kind === 'armor' ? 'armor' : kind === 'weapon' ? 'weapons1' : 'misc'
let tabPlacements = this.vendorTabPlacements[targetTab]
let freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10)
if (!freeSlot && targetTab === 'weapons1') {
targetTab = 'weapons2'
tabPlacements = this.vendorTabPlacements[targetTab]
freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10)
}
while (!freeSlot) {
const evictIdx = tabPlacements.findIndex(p => !p.item.permStore)
if (evictIdx === -1) break
tabPlacements.splice(evictIdx, 1)
freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10)
}
if (freeSlot) {
const buybackPrice = item.rawItem
? this.vendorSession.getBuyPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel, false)
: Math.max(sellPrice * 2, 2)
const buybackUi: UiInventoryItem = {
...item,
buyCost: buybackPrice,
sellCost: sellPrice,
permStore: false,
}
tabPlacements.push({ item: buybackUi, col: freeSlot.col, row: freeSlot.row })
if (this.vendorTabPlacements[this.activeVendorTab].length === 0) {
this.selectVendorTab(targetTab)
}
}
}
this.showVendorStatus(
this.lang === 'ENG'
? `Sold ${item.name} (+${sellPrice} Gold)`
: `已出售 ${item.nameZh} (+${sellPrice} 金币)`,
'gold',
)
return { ok: true, goldReceived: sellPrice }
}
repairSingleUiItem(
item: UiInventoryItem,
playerInventory: InventoryPanel,
): { ok: boolean; cost: number } {
if (!this.activeVendorDescriptor?.canRepair) {
return { ok: false, cost: 0 }
}
if (!item.durability || item.durability.current >= item.durability.max) {
this.showVendorStatus(
this.lang === 'ENG' ? 'Item does not need repair' : '该物品无需修理',
'tan',
)
return { ok: false, cost: 0 }
}
if (item.ethereal) {
this.showVendorStatus(
this.lang === 'ENG' ? 'Cannot repair ethereal item' : '无形物品无法修理',
'red',
)
return { ok: false, cost: 0 }
}
const cost = this.getPlayerItemRepairPrice(item)
if (cost <= 0) return { ok: false, cost: 0 }
if (playerInventory.gold < cost) {
this.showVendorStatus(
this.lang === 'ENG' ? 'Not enough gold to repair' : '金币不足,无法修理',
'red',
)
return { ok: false, cost: 0 }
}
playerInventory.gold -= cost
;(item as { durability: { current: number; max: number } }).durability = {
current: item.durability.max,
max: item.durability.max,
}
if (item.rawItem) {
item.rawItem.durability = item.durability.max
}
this.showVendorStatus(
this.lang === 'ENG'
? `Repaired ${item.name} (-${cost} Gold)`
: `已修理 ${item.nameZh} (-${cost} 金币)`,
'gold',
)
return { ok: true, cost }
}
private collectRepairablePlayerItems(playerInventory: InventoryPanel): { item: UiInventoryItem; cost: number }[] {
const allItems: UiInventoryItem[] = [
...(Object.values(playerInventory.equipped).filter(Boolean) as UiInventoryItem[]),
...playerInventory.gridItems.map(p => p.item),
]
const result: { item: UiInventoryItem; cost: number }[] = []
for (const item of allItems) {
if (!item.durability || item.durability.current >= item.durability.max || item.ethereal) continue
const cost = this.getPlayerItemRepairPrice(item)
if (cost <= 0) continue
result.push({ item, cost })
}
return result
}
/**
* D2Client.dll `0x6fb3f260` -> D2Common `0x6fabc99a` (`#10095`):
* Computes total repair cost across all damaged repairable equipment and inventory items.
*/
getRepairAllCost(playerInventory?: InventoryPanel | null): number {
if (!this.activeVendorDescriptor?.canRepair) return 0
const inv = playerInventory ?? this.activePlayerInventory
if (!inv) return 0
let total = 0
for (const entry of this.collectRepairablePlayerItems(inv)) {
total += entry.cost
}
return total
}
repairAllUiItems(playerInventory: InventoryPanel): { repairedCount: number; totalCost: number } {
if (!this.activeVendorDescriptor?.canRepair) {
return { repairedCount: 0, totalCost: 0 }
}
let repairedCount = 0
let totalCost = 0
for (const { item, cost } of this.collectRepairablePlayerItems(playerInventory)) {
if ( !item.durability) continue
if (playerInventory.gold < cost) break
const maxDur = item.durability.max
playerInventory.gold -= cost
totalCost += cost
repairedCount++
;(item as { durability: { current: number; max: number } }).durability = {
current: maxDur,
max: maxDur,
}
if (item.rawItem) {
item.rawItem.durability = maxDur
}
}
if (repairedCount > 0) {
this.showVendorStatus(
this.lang === 'ENG'
? `Repaired ${repairedCount} items (-${totalCost} Gold)`
: `已修理 ${repairedCount} 件装备 (-${totalCost} 金币)`,
'gold',
)
} else {
this.showVendorStatus(
this.lang === 'ENG' ? 'All equipment is at full durability' : '所有装备完好,无需修理',
'tan',
)
}
return { repairedCount, totalCost }
}
identifyAllUiItems(playerInventory: InventoryPanel): number {
let count = 0
for (const placed of playerInventory.gridItems) {
if (isUiItemUnidentified(placed.item)) {
identifyUiItem(placed.item)
count++
}
}
for (const placed of this.stashItems) {
if (isUiItemUnidentified(placed.item)) {
identifyUiItem(placed.item)
count++
}
}
for (const placed of this.cubeItems) {
if (isUiItemUnidentified(placed.item)) {
identifyUiItem(placed.item)
count++
}
}
return count
}
/** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
private cachedQuestsAtlasImg: HTMLImageElement | null = null
private cachedBoxPiecesImg: HTMLImageElement | null = null
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)
}
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 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
hoveredWaypointIdx: number | null = null
/**
* The waypoint that opened this menu or where the player currently stands.
* Ground Truth (D2Client.dll 1.13c):
* - 0x6fbacdd0: Current level ID
* - 0x6fbacdd4: Current waypoint ID
*/
currentWaypointId: number | null = null
currentLevelId: number | null = null
/** Runtime waypoint unlocked overrides: waypointId -> boolean */
private waypointUnlockedOverrides = new Map<number, boolean>()
isWaypointUnlocked(wpOrId: WaypointEntry | number): boolean {
const wpId = typeof wpOrId === 'number' ? wpOrId : wpOrId.waypointId
const override = this.waypointUnlockedOverrides.get(wpId)
if (override !== undefined) return override
if (typeof wpOrId === 'object' && wpOrId !== null) return wpOrId.unlocked
for (const list of Object.values(ACT_WAYPOINTS)) {
const found = list.find(w => w.waypointId === wpId)
if (found) return found.unlocked
}
return false
}
setWaypointUnlocked(waypointId: number, unlocked: boolean): void {
this.waypointUnlockedOverrides.set(waypointId, unlocked)
}
setUnlockedWaypoints(unlockedWaypointIds: Iterable<number>): void {
this.waypointUnlockedOverrides.clear()
const set = new Set(unlockedWaypointIds)
for (const list of Object.values(ACT_WAYPOINTS)) {
for (const wp of list) {
this.waypointUnlockedOverrides.set(wp.waypointId, set.has(wp.waypointId))
}
}
}
clearWaypointUnlockedOverrides(): void {
this.waypointUnlockedOverrides.clear()
}
setCurrentWaypoint(waypointId: number | null, levelId?: number | null): void {
this.currentWaypointId = waypointId
if (levelId !== undefined) {
this.currentLevelId = levelId
}
}
setCurrentLevel(levelId: number | null): void {
this.currentLevelId = levelId
}
isCurrentWaypoint(wp: WaypointEntry): boolean {
if (this.currentWaypointId !== null && this.currentWaypointId !== undefined) {
if (wp.waypointId === this.currentWaypointId) return true
}
if (this.currentLevelId !== null && this.currentLevelId !== undefined) {
if (wp.levelId === this.currentLevelId) return true
}
return false
}
/**
* Check whether an item of (w, h) can be placed at (col, row) in the 6x8 stash grid.
* Throws RangeError if placed out of bounds. Returns all overlapping GridPlacements.
*/
getStashOverlaps(col: number, row: number, w: number, h: number): GridPlacement[] {
if (col < 0 || row < 0 || col + w > STASH_GRID_ORIGIN.cols || row + h > STASH_GRID_ORIGIN.rows) {
throw new RangeError('Out of 6x8 stash grid bounds')
}
return this.stashItems.filter(p => {
const ox = col < p.col + p.item.invWidth && col + w > p.col
const oy = row < p.row + p.item.invHeight && row + h > p.row
return ox && oy
})
}
canPlaceInStash(col: number, row: number, w: number, h: number): boolean {
if (col < 0 || row < 0 || col + w > STASH_GRID_ORIGIN.cols || row + h > STASH_GRID_ORIGIN.rows) {
return false
}
return this.getStashOverlaps(col, row, w, h).length === 0
}
autoPlaceInStash(item: UiInventoryItem): boolean {
for (let col = 0; col <= STASH_GRID_ORIGIN.cols - item.invWidth; col++) {
for (let row = 0; row <= STASH_GRID_ORIGIN.rows - item.invHeight; row++) {
if (this.canPlaceInStash(col, row, item.invWidth, item.invHeight)) {
this.stashItems.push({ item, col, row })
return true
}
}
}
return false
}
clickStashCell(
col: number,
row: number,
cursorItem: UiInventoryItem | null,
): { handled: boolean; newCursorItem: UiInventoryItem | null } {
if (!cursorItem) {
const hit = this.stashItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (!hit) return { handled: false, newCursorItem: null }
this.stashItems = this.stashItems.filter(p => p !== hit)
return { handled: true, newCursorItem: hit.item }
}
const directHit = this.stashItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (directHit && isScrollForTome(cursorItem.code, directHit.item.code)) {
const curQty = getTomeQuantity(directHit.item)
const maxQty = directHit.item.maxQuantity ?? TOME_MAX_QUANTITY
if (curQty < maxQty) {
const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
const addQty = Math.min(maxQty - curQty, scrollQty)
setTomeQuantity(directHit.item, curQty + addQty)
if (scrollQty > addQty) {
setTomeQuantity(cursorItem, scrollQty - addQty)
return { handled: true, newCursorItem: cursorItem }
}
return { handled: true, newCursorItem: null }
}
}
if (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 }
}
if (
col < 0 ||
row < 0 ||
col + cursorItem.invWidth > STASH_GRID_ORIGIN.cols ||
row + cursorItem.invHeight > STASH_GRID_ORIGIN.rows
) {
return { handled: false, newCursorItem: cursorItem }
}
const overlaps = this.getStashOverlaps(col, row, cursorItem.invWidth, cursorItem.invHeight)
if (overlaps.length === 0) {
this.stashItems.push({ item: cursorItem, col, row })
return { handled: true, newCursorItem: null }
}
if (overlaps.length === 1) {
const single = overlaps[0]!
if (isScrollForTome(cursorItem.code, single.item.code)) {
const curQty = getTomeQuantity(single.item)
const maxQty = single.item.maxQuantity ?? TOME_MAX_QUANTITY
if (curQty < maxQty) {
const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
const addQty = Math.min(maxQty - curQty, scrollQty)
setTomeQuantity(single.item, curQty + addQty)
if (scrollQty > addQty) {
setTomeQuantity(cursorItem, scrollQty - addQty)
return { handled: true, newCursorItem: cursorItem }
}
return { handled: true, newCursorItem: null }
}
}
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.stashItems = this.stashItems.filter(p => p !== single)
this.stashItems.push({ item: cursorItem, col, row })
return { handled: true, newCursorItem: single.item }
}
return { handled: false, newCursorItem: cursorItem }
}
quickTransferToStash(item: UiInventoryItem, fromInventory: InventoryPanel): boolean {
const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id)
if (hitIdx === -1) return false
if (!this.autoPlaceInStash(item)) return false
fromInventory.gridItems.splice(hitIdx, 1)
return true
}
quickTransferFromStash(item: UiInventoryItem, toInventory: InventoryPanel): boolean {
const hitIdx = this.stashItems.findIndex(p => p.item.id === item.id)
if (hitIdx === -1) return false
if (!toInventory.autoPlaceInGrid(item)) return false
this.stashItems.splice(hitIdx, 1)
return true
}
/**
* Check whether an item of (w, h) can be placed at (col, row) in the 3x4 Horadric Cube grid.
*/
getCubeOverlaps(col: number, row: number, w: number, h: number): GridPlacement[] {
if (col < 0 || row < 0 || col + w > CUBE_GRID_ORIGIN.cols || row + h > CUBE_GRID_ORIGIN.rows) {
throw new RangeError('Out of 3x4 Horadric Cube grid bounds')
}
return this.cubeItems.filter(p => {
const ox = col < p.col + p.item.invWidth && col + w > p.col
const oy = row < p.row + p.item.invHeight && row + h > p.row
return ox && oy
})
}
canPlaceInCube(col: number, row: number, w: number, h: number): boolean {
if (col < 0 || row < 0 || col + w > CUBE_GRID_ORIGIN.cols || row + h > CUBE_GRID_ORIGIN.rows) {
return false
}
return this.getCubeOverlaps(col, row, w, h).length === 0
}
autoPlaceInCube(item: UiInventoryItem): boolean {
const code = (item.code ?? '').trim().toLowerCase()
if (code === 'box' || code === 'cube') return false
for (let col = 0; col <= CUBE_GRID_ORIGIN.cols - item.invWidth; col++) {
for (let row = 0; row <= CUBE_GRID_ORIGIN.rows - item.invHeight; row++) {
if (this.canPlaceInCube(col, row, item.invWidth, item.invHeight)) {
this.cubeItems.push({ item, col, row })
return true
}
}
}
return false
}
clickCubeCell(
col: number,
row: number,
cursorItem: UiInventoryItem | null,
): { handled: boolean; newCursorItem: UiInventoryItem | null } {
if (!cursorItem) {
const hit = this.cubeItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (!hit) return { handled: false, newCursorItem: null }
this.cubeItems = this.cubeItems.filter(p => p !== hit)
return { handled: true, newCursorItem: hit.item }
}
const code = (cursorItem.code ?? '').trim().toLowerCase()
if (code === 'box' || code === 'cube') {
return { handled: false, newCursorItem: cursorItem }
}
const directHit = this.cubeItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (directHit && isScrollForTome(cursorItem.code, directHit.item.code)) {
const curQty = getTomeQuantity(directHit.item)
const maxQty = directHit.item.maxQuantity ?? TOME_MAX_QUANTITY
if (curQty < maxQty) {
const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
const addQty = Math.min(maxQty - curQty, scrollQty)
setTomeQuantity(directHit.item, curQty + addQty)
if (scrollQty > addQty) {
setTomeQuantity(cursorItem, scrollQty - addQty)
return { handled: true, newCursorItem: cursorItem }
}
return { handled: true, newCursorItem: null }
}
}
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
}
depositGold(amount: number, playerInventory: { gold: number }): number {
if (amount <= 0) return 0
const available = Math.max(0, Math.min(amount, playerInventory.gold, STASH_GOLD_CAP - this.stashGold))
if (available <= 0) return 0
playerInventory.gold -= available
this.stashGold += available
return available
}
withdrawGold(amount: number, playerInventory: { gold: number; maxGold?: number }): number {
if (amount <= 0) return 0
const available = Math.max(0, Math.min(amount, this.stashGold, PLAYER_GOLD_CAP - playerInventory.gold))
if (available <= 0) return 0
this.stashGold -= available
playerInventory.gold += available
return available
}
handleMouseMove(logicalX: number, logicalY: number): void {
this.hoveredStashItem = null
this.hoveredCubeItem = null
this.hoveredVendorItem = null
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
? this.hitTestVendorButtonSlot(logicalX, logicalY)
: null
this.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,
}
}
}
}
getNpcMenuLayout(font?: Pick<D2FontRenderer, 'measureText'> | null): NpcDialogLayout | null {
if (!this.npcMenu) return null
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
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)
}
isPointInNpcMenu(
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
const boxW = 340
const boxH = 156
const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
return logicalX >= bx && logicalX <= bx + boxW && logicalY >= by && logicalY <= by + boxH
}
const layout = this.getNpcMenuLayout(font)
if (!layout) return false
return (
logicalX >= layout.left &&
logicalX <= layout.left + layout.width &&
logicalY >= layout.top &&
logicalY <= layout.top + layout.height
)
}
handleNpcMenuMove(
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
): void {
if (!this.npcMenu || this.npcMenu.talking) return
const layout = this.getNpcMenuLayout(font)
if (!layout) return
// D2Client.dll 0x6fb533c0 -> 0x6fb534b0: update selected option when cursor hits a selectable line;
// when cursor is outside selectable lines (0x6fb533de: je 0x6fb53402), keep existing selection.
const hitIdx = hitTestNpcDialogOption(layout, logicalX, logicalY)
if (hitIdx !== null) {
this.npcMenu.hoveredOptionIdx = hitIdx
}
}
handleNpcMenuClick(
logicalX: number,
logicalY: number,
callbacks: {
onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void
onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void
},
font?: Pick<D2FontRenderer, 'measureText'> | null,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
this.npcMenu.talking = false
return true
}
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
const 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)
if (
logicalX < layout.left ||
logicalX > layout.left + layout.width ||
logicalY < layout.top ||
logicalY > layout.top + layout.height
) {
this.npcMenu = null
return true
}
const hitIdx = hitTestNpcDialogOption(layout, logicalX, logicalY)
if (hitIdx !== null) {
this.npcMenu.hoveredOptionIdx = hitIdx
const opt = opts[hitIdx]!
const desc = this.npcMenu.descriptor
if (opt.id === 'talk') {
this.npcMenu.talking = true
} else if (opt.id === 'trade' && desc) {
this.npcMenu = null
callbacks.onOpenVendor(desc, 'trade')
} else if (opt.id === 'gamble' && desc) {
this.npcMenu = null
callbacks.onOpenVendor(desc, 'gamble')
} else if (opt.id === 'identify' && desc) {
this.npcMenu = null
callbacks.onIdentifyAll(desc)
} else {
this.npcMenu = null
}
return true
}
return true
}
handleLeftDockClick(
kind: Exclude<LeftDockPanelKind, 'none' | 'char'>,
logicalX: number,
logicalY: number,
callbacks: {
onClose: () => void
onWaypointTeleport: (act: number, slug: string, levelId: number) => void
onOpenCube?: () => void
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
inventory?: InventoryPanel
isShiftClick?: boolean
isRightClick?: boolean
},
): boolean {
if (logicalX > 400) return false
const ox = 80
const oy = 60
// Generic Close button (`x = ox + 272, y = oy + 388` or `ox + 18, oy + 385`) for non-vendor panels only.
// Vendor panels manage their 4 button slots via D2Client.dll 0x6fb3f430 / 0x6fb4c27a (where slot 3 is Repair All on blacksmiths).
if (
kind !== 'vendor' &&
logicalY >= oy + 380 &&
logicalY <= oy + 422 &&
(logicalX >= ox + 260 || logicalX <= ox + 58)
) {
this.vendorTradeState = 'idle'
this.pressedMomentarySlot = null
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'
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.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 6x8 Grid
if (
logicalX >= STASH_GRID_ORIGIN.x &&
logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx &&
logicalY >= STASH_GRID_ORIGIN.y &&
logicalY < STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx
) {
const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx)
const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx)
const hit = this.stashItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (callbacks.inventory?.isIdentifyMode()) {
if (callbacks.isRightClick) {
callbacks.inventory.cancelIdentifyMode()
} else if (hit && isUiItemUnidentified(hit.item)) {
callbacks.inventory.applyIdentifyToItem(hit.item, {
stashItems: this.stashItems,
cubeItems: this.cubeItems,
})
} else {
callbacks.inventory.cancelIdentifyMode()
}
return true
}
if (callbacks.isRightClick && callbacks.inventory) {
if (hit && isUsableRightClickItem(hit.item.code)) {
callbacks.inventory.usePlacedItemByRightClick(hit, 'stash', this.stashItems, {
onOpenCube: () => callbacks.onOpenCube?.(),
onCastTownPortal: (source, item) => callbacks.onCastTownPortal?.(source, item),
})
return true
}
if (hit) {
this.quickTransferFromStash(hit.item, callbacks.inventory)
}
return true
}
if (callbacks.isShiftClick && callbacks.inventory) {
if (hit) {
this.quickTransferFromStash(hit.item, callbacks.inventory)
}
return true
}
if (callbacks.inventory) {
const result = this.clickStashCell(col, row, callbacks.inventory.cursorItem)
if (result.handled) {
callbacks.inventory.cursorItem = result.newCursorItem
return true
}
}
return true
}
} else if (kind === 'vendor') {
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
throw new Error('Cannot handle vendor click without an active vendor descriptor')
}
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
const inv = callbacks.inventory ?? this.activePlayerInventory
if (inv) {
this.repairAllUiItems(inv)
}
} else if (btnKind === 'close') {
this.vendorTradeState = 'idle'
this.pressedMomentarySlot = hitSlot
callbacks.onClose()
}
return true
}
if (relY > VENDOR_BUTTON_HIT_Y_MIN && relY < VENDOR_BUTTON_HIT_Y_MAX && relX > VENDOR_BUTTON_TABLE_X[0] && relX < VENDOR_BUTTON_TABLE_X[3] + VENDOR_BUTTON_HIT_W) {
// Clicked disabled blank slot (`0x6fb3d300` no-op)
return true
}
// 3. Vendor 10x10 Grid (`VENDOR_GRID_ORIGIN`)
if (
logicalX >= VENDOR_GRID_ORIGIN.x &&
logicalX < VENDOR_GRID_ORIGIN.x + VENDOR_GRID_ORIGIN.cols * VENDOR_GRID_ORIGIN.cellPx &&
logicalY >= VENDOR_GRID_ORIGIN.y &&
logicalY < VENDOR_GRID_ORIGIN.y + VENDOR_GRID_ORIGIN.rows * VENDOR_GRID_ORIGIN.cellPx
) {
const col = Math.floor((logicalX - VENDOR_GRID_ORIGIN.x) / VENDOR_GRID_ORIGIN.cellPx)
const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx)
const inv = callbacks.inventory ?? this.activePlayerInventory
if (inv) {
// If player is holding an item on cursor and clicks inside Vendor grid -> Sell it!
if (inv.cursorItem) {
const held = inv.cursorItem
const res = this.sellToActiveVendor(held, inv)
if (res.ok) {
inv.cursorItem = null
}
return true
}
// Left-click buy works in 'idle' or 'buy' state; do not accidentally buy when in 'sell' or 'repair' state
if (this.vendorTradeState === 'idle' || this.vendorTradeState === 'buy') {
this.buyFromActiveVendor(col, row, inv, Boolean(callbacks.isShiftClick))
this.handleMouseMove(logicalX, logicalY)
}
}
return true
}
}
return true
}
drawLeftDockPanel(
ctx: CanvasRenderingContext2D,
kind: Exclude<LeftDockPanelKind, 'none' | 'char'>,
assets: {
borderLeftImg: HTMLImageElement | null
questBgImg: HTMLImageElement | null
waypointBgImg: HTMLImageElement | null
stashBgImg: HTMLImageElement | null
cubeBgImg: HTMLImageElement | null
vendorBgImg: HTMLImageElement | null
vendorTabsImg?: HTMLImageElement | null
buySellBtnImg: HTMLImageElement | null
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, isEn ? 'Private Stash' : '私人储物箱', ox + 172, oy + 35, {
font: 'font8',
color: 'gold',
align: 'center',
})
font.drawText(
ctx,
isEn ? `Stash Gold: ${this.stashGold.toLocaleString()}` : `储物箱金币: ${this.stashGold.toLocaleString()}`,
ox + 160,
oy + 61,
{
font: 'font8',
color: 'white',
align: 'center',
},
)
// 6x8 grid cell borders
for (let r = 0; r < STASH_GRID_ORIGIN.rows; r++) {
for (let c = 0; c < STASH_GRID_ORIGIN.cols; c++) {
const cx = STASH_GRID_ORIGIN.x + c * STASH_GRID_ORIGIN.cellPx
const cy = STASH_GRID_ORIGIN.y + r * STASH_GRID_ORIGIN.cellPx
ctx.strokeStyle = 'rgba(64, 52, 38, 0.4)'
ctx.strokeRect(cx + 0.5, cy + 0.5, STASH_GRID_ORIGIN.cellPx - 1, STASH_GRID_ORIGIN.cellPx - 1)
}
}
// Draw items stored in stash
const atlas = assets.itemsAtlasImg ?? null
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'
? isEn
? `${spec.labelEn}: ${this.getRepairAllCost(assets.inventory)}`
: `${spec.labelZh}:${this.getRepairAllCost(assets.inventory)}`
: 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',
})
}
}
}
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
},
): 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(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
drawStoneBoxFrame(ctx, bx, by, boxW, boxH, boxPiecesImg)
font.drawText(ctx, talkHeaderName, bx + Math.trunc(boxW / 2), by + 24, {
font: 'font16',
color: 'gold',
align: 'center',
})
const dialogLines = 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, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
// 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',
})
}
}
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,
): 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)
if (item.buyCost !== undefined) {
const label = item.isGambleMystery
? (isEn ? 'Gamble Cost' : '赌博价格')
: (isEn ? 'Buy Cost' : '购买价格')
const suffix = isEn ? ' Gold' : ' 金币'
lines.push({ text: `${label}: ${item.buyCost.toLocaleString()}${suffix}`, color: 'gold', font: 'font8' })
} else if (vendorOpen && this.activeVendorDescriptor) {
if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) {
const repPrice = this.getPlayerItemRepairPrice(item)
if (repPrice > 0) {
lines.push({
text: isEn
? `Repair Cost: ${repPrice.toLocaleString()} Gold`
: `修理费用: ${repPrice.toLocaleString()} 金币`,
color: 'gold',
font: 'font8',
})
}
} else {
const sellPrice = this.getPlayerItemSellPrice(item)
if (sellPrice > 0) {
lines.push({
text: isEn
? `Sell Value: ${sellPrice.toLocaleString()} Gold`
: `出售价格: ${sellPrice.toLocaleString()} 金币`,
color: 'gold',
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(800 - boxW - 8, Math.round(hover.x - boxW / 2)))
const maxTop = Math.max(8, 600 - boxH - 8)
const topMargin = (hover.y < 280 || maxTop < 44) ? 8 : 44
const by = Math.max(topMargin, Math.min(600 - boxH - 8, rawBy))
this.lastTooltipRect = { bx, by, boxW, boxH }
// Rendering
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
ctx.fillRect(bx, by, boxW, boxH)
ctx.strokeStyle = '#8c7244'
ctx.strokeRect(bx + 0.5, by + 0.5, boxW - 1, boxH - 1)
lines.forEach((l, idx) => {
font.drawText(ctx, l.text, bx + Math.round(boxW / 2), by + padY + 12 + idx * lineH, {
font: l.font,
color: l.color,
align: 'center',
})
})
}
/**
* Draw Area Entry Gothic Banner (`Entering: <Level Name>` / `进入:<Level Name>`).
*/
drawAreaBanner(ctx: CanvasRenderingContext2D, nowMs: number, font: D2FontRenderer): void {
if (!this.areaBanner || nowMs >= this.areaBanner.untilMs) return
const remain = this.areaBanner.untilMs - nowMs
const alpha = remain < 800 ? remain / 800 : 1
ctx.save()
ctx.globalAlpha = Math.max(0, Math.min(1, alpha))
const bannerText =
this.lang === 'ENG'
? `Entering: ${this.areaBanner.titleEn || this.areaBanner.titleZh}`
: `进入:${this.areaBanner.titleZh}`
font.drawText(ctx, bannerText, 400, 142, {
font: 'font30',
color: 'gold',
align: 'center',
})
ctx.restore()
}
}
/**
* 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
}