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