diablo2-web/src/client/ui/hud-manager.ts

2684 lines
99 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Diablo II v1.13c Complete HUD & UI Orchestrator (`src/ui/hud-manager.ts`).
*
* Coordinates:
* - 800x600 Logical Viewport scaling onto the full-window `#d2-hud-canvas`
* - Bottom 800px Control Panel (`control-bar.ts`), Life/Mana Globes (`globes.ts`),
* 4x4 Belt (`belt.ts`), and Left/Right Skill Speedbar (`hotkeys.ts`)
* - Left-Half Dock (`0..400, 0..553` + `800BorderFrame` Left):
* Character Sheet (`A`/`C`), Quest Log (`Q`), Waypoint Menu (`V`), Stash, Horadric Cube, Vendor
* - Right-Half Dock (`400..800, 0..553` + `800BorderFrame` Right):
* Inventory & Equipment Paperdoll (`I`/`B`), Skill Tree (`T`)
* - Top Monster Health Bar, Item Hover Tooltips, Ground `Alt` Item Labels, Area Entry Banner,
* and Authentic Hand Cursor (`CURSOR\ohand.dc6`) / Held `cursorItem` Sprite.
*/
import { D2FontRenderer, type D2ColorCode } from './font.ts'
import type { GroundItemLabelBox } from '../../common/items/ground-items.ts'
import { GlobesHud } from './globes.ts'
import { BeltHud } from './belt.ts'
import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from './hotkeys.ts'
import { ControlBarHud, type MiniPanelAction } from './control-bar.ts'
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts'
import {
InventoryPanel,
type EquipSlotId,
type UiInventoryItem,
resolveItemSpriteRect,
INV_GRID_ORIGIN,
EQUIP_SLOTS_LAYOUT,
WEAPON_SWAP_TABS_BOUNDS,
isUsableRightClickItem,
isSocketableUiItem,
isScrollForTome,
isUiItemUnidentified,
} from './inventory.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
import { SkillTreePanel } from './skill-tree-panel.ts'
import {
ESCAPE_MENU_ACTIONS,
WorldPanelsHud,
computeEscapeMenuLayout,
hitTestEscapeMenu,
type EscapeMenuAction,
type EscapeMenuRowIndex,
type LeftDockPanelKind,
type MonsterTargetHudInfo,
type VendorTradeState,
} from './world-panels.ts'
import {
CursorController,
type BuySellCursorMode,
type CursorAnimation,
type CursorState,
} from './cursor.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts'
import {
detectInitialLang,
setCurrentLang,
toTblLang,
type GameLang,
type TblLang,
} from '../i18n/lang.ts'
import { setTooltipLocale } from '../../common/items/item-tooltip.ts'
import { requireTbl } from '../i18n/lang.ts'
import {
computeViewportAnchors,
getViewportProfile,
getViewportProfileBySize,
isExtendedViewport,
VIEWPORT_800x600,
type ViewportAnchorMetrics,
type ViewportProfile,
} from '../view/viewport-profile.ts'
import type { ViewportResolution } from '../settings/client-settings.ts'
export type LeftPanelType =
| LeftDockPanelKind
| 'merc'
| 'hire'
| 'party'
| 'trade'
| 'imbue'
| 'add_sockets'
| 'personalize'
export interface PublishedHudState {
ready: boolean
dc6DecodeFailures: number
decodedDc6Count: number
cursorState: CursorState
cursorTradeMode?: BuySellCursorMode | null
cursorAnimation?: CursorAnimation
cursorFrameIndex?: number
assetsLoaded: {
ctrlPnl: boolean
globes: boolean
borders: boolean
invSheet: boolean
charSheet: boolean
skillTree: boolean
font: boolean
itemsAtlas: boolean
questsAtlas: boolean
escapeMenu?: boolean
}
leftPanel: LeftPanelType
rightPanel: 'none' | 'inv' | 'skill'
beltExpanded: boolean
beltPotionsCount: number
miniPanelOpen: boolean
isRunning: boolean
showGroundLabels: boolean
cursorItem: string | null
equippedSlots: string[]
bagItemsCount: number
weaponSwapSet: 0 | 1
leftSkillId: number
rightSkillId: number
characterLevel: number
statPoints: number
skillPoints: number
hp: number
maxHp: number
mana: number
maxMana: number
stamina: number
maxStamina: number
docking: DockingLayout
viewportProfile?: ViewportProfile
activeVendor?: string | null
vendorMode?: 'trade' | 'gamble'
vendorTradeState?: VendorTradeState
vendorTab?: string
vendorItemsCount?: number
npcMenuOpen?: boolean
escapeMenuOpen?: boolean
escapeMenuSelectedIndex?: EscapeMenuRowIndex
}
interface UiManifest {
readonly version: string
readonly decodedDc6Count: number
readonly dc6DecodeFailures: number
readonly codeToInvFile: Record<string, string>
readonly itemRects: Record<string, { x: number; y: number; w: number; h: number }>
}
export interface HudLayout {
readonly uiScale: number
readonly offsetX: number
readonly offsetY: number
readonly dpr: number
readonly physicalScale: number
readonly physicalOffsetX: number
readonly physicalOffsetY: number
}
export interface DockingLayout extends HudLayout {
marginW: number
leftDockX: number
rightDockX: number
channelLeft: number
channelRight: number
channelWidthLogical: number
channelWidthCss: number
isWidescreen: boolean
barOffsetX?: number
barOffsetY?: number
panelOffsetY?: number
}
/**
* Computes 1:1 uniform aspect ratio scaling and horizontal centering margins
* for the active Diablo II HUD viewport (`ViewportProfile` size) onto an arbitrary window/canvas size.
*/
export function computeHudLayout(
cssW: number,
cssH: number,
dpr: number = 1,
profile: ViewportProfile = VIEWPORT_800x600,
): HudLayout {
const safeW = Number.isFinite(cssW) && cssW > 0 ? cssW : 1
const safeH = Number.isFinite(cssH) && cssH > 0 ? cssH : 1
const safeDpr = Number.isFinite(dpr) && dpr > 0 ? dpr : 1
const uiScale = Math.min(safeW / profile.width, safeH / profile.height)
const offsetX = Math.max(0, (safeW - profile.width * uiScale) / 2)
const offsetY = Math.max(0, safeH - profile.height * uiScale)
return {
uiScale,
offsetX,
offsetY,
dpr: safeDpr,
physicalScale: uiScale * safeDpr,
physicalOffsetX: Math.round(offsetX * safeDpr),
physicalOffsetY: Math.round(offsetY * safeDpr),
}
}
export { CHAR_PANEL_ORIGIN } from './character-sheet.ts'
export { INV_PANEL_ORIGIN, INV_GRID_ORIGIN } from './inventory.ts'
export { SKILL_PANEL_ORIGIN } from './skill-tree-panel.ts'
export {
STASH_PANEL_ORIGIN,
STASH_GRID_ORIGIN,
CUBE_PANEL_ORIGIN,
CUBE_GRID_ORIGIN,
CUBE_TRANSMUTE_BTN_BOUNDS,
CUBE_CLOSE_BTN_BOUNDS,
VENDOR_PANEL_ORIGIN,
VENDOR_GRID_ORIGIN,
ESCAPE_MENU_ACTIONS,
computeEscapeMenuLayout,
hitTestEscapeMenu,
type EscapeMenuAction,
type EscapeMenuRowIndex,
} from './world-panels.ts'
/**
* Computes widescreen panel docking layout metrics, margins, and central vision corridor.
*
* Extended profiles (larger than 800×600) pin the 400px dock panels to the logical viewport edges:
* left dock at `x = 0`, right dock at `x = width - 400`, corridor `400..width - 400`. The 800×600
* profile docks the panels against the browser window margins instead (`marginW`).
*/
export function computeDockingLayout(
cssW: number,
cssH: number,
dpr: number = 1,
profile: ViewportProfile = VIEWPORT_800x600,
): DockingLayout {
if (isExtendedViewport(profile)) {
const layout = computeHudLayout(cssW, cssH, dpr, profile)
const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0
const rightDockX = profile.width - 400
const channelWidthLogical = rightDockX - 400
return {
...layout,
marginW,
leftDockX: 0,
rightDockX,
channelLeft: 400,
channelRight: rightDockX,
channelWidthLogical,
channelWidthCss: channelWidthLogical * layout.uiScale,
isWidescreen: true,
barOffsetX: profile.barOffsetX,
barOffsetY: profile.barOffsetY,
panelOffsetY: profile.panelOffsetY,
}
}
const layout = computeHudLayout(cssW, cssH, dpr, VIEWPORT_800x600)
const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0
const leftDockX = -marginW || 0
const rightDockX = 400 + marginW
const channelLeft = 400 - marginW
const channelRight = 400 + marginW
const channelWidthLogical = 2 * marginW
const channelWidthCss = 2 * layout.offsetX
const isWidescreen = marginW > 0
return {
...layout,
marginW,
leftDockX,
rightDockX,
channelLeft,
channelRight,
channelWidthLogical,
channelWidthCss,
isWidescreen,
barOffsetX: 0,
barOffsetY: 0,
panelOffsetY: 0,
}
}
export function clientToLogical(
clientX: number,
clientY: number,
rect: { left: number; top: number; width: number; height: number },
dpr: number = 1,
profile: ViewportProfile = VIEWPORT_800x600,
): { x: number; y: number } {
const safeClientX = Number.isFinite(clientX) ? clientX : 0
const safeClientY = Number.isFinite(clientY) ? clientY : 0
const { uiScale, offsetX, offsetY } = computeHudLayout(rect.width, rect.height, dpr, profile)
const left = Number.isFinite(rect.left) ? rect.left : 0
const top = Number.isFinite(rect.top) ? rect.top : 0
return {
x: (safeClientX - left - offsetX) / uiScale,
y: (safeClientY - top - offsetY) / uiScale,
}
}
export function logicalToClient(
logicalX: number,
logicalY: number,
rect: { left: number; top: number; width: number; height: number },
dpr: number = 1,
profile: ViewportProfile = VIEWPORT_800x600,
): { x: number; y: number } {
const safeLogicalX = Number.isFinite(logicalX) ? logicalX : 0
const safeLogicalY = Number.isFinite(logicalY) ? logicalY : 0
const { uiScale, offsetX, offsetY } = computeHudLayout(rect.width, rect.height, dpr, profile)
const left = Number.isFinite(rect.left) ? rect.left : 0
const top = Number.isFinite(rect.top) ? rect.top : 0
return {
x: safeLogicalX * uiScale + left + offsetX,
y: safeLogicalY * uiScale + top + offsetY,
}
}
/**
* Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`;
* the HUD never mutates gameplay state locally while a sink is attached.
*/
export type GoldDialogMode = 0 | 3 | 4
export interface GoldDialogRect {
readonly x: number
readonly y: number
readonly w: number
readonly h: number
}
export interface HudCommandSink {
allocateStat(key: BaseStatKey): boolean
allocateSkill(skillId: number): boolean
selectSkill(hand: 'left' | 'right', skillId: number): void
useBeltSlot(col: number, shiftToMerc?: boolean): boolean
swapWeapons(): void
pickupItemToCursor(itemId: number): void
/** `buffer`: `0` = inventory, `3` = cube, `4` = stash. */
placeCursorItem(itemId: number, col: number, row: number, buffer: number): void
equipItem(itemId: number, slot: EquipSlotId): void
unequipItem(slot: EquipSlotId): void
swapEquipItem?(cursorItemId: number, slot: EquipSlotId): void
swapContainerItem?(cursorItemId: number, targetItemId: number, col: number, row: number, buffer: number): void
putBeltItem?(cursorItemId: number, slotIndex: number): void
removeBeltItem?(itemId: number): void
swapBeltItem?(cursorItemId: number, beltItemId: number): void
identifyItem?(targetItemId: number, scrollItemId: number): void
socketItem?(cursorItemId: number, targetItemId: number): void
scrollToBook?(scrollItemId: number, bookItemId: number): void
dropItem(itemId: number): void
dropGold(amount: number): void
useItem(itemId: number): void
transmuteCube(): void
teleportToLevel(destAreaId: number): void
/** NPC menu "trade" / "trade/repair": `0x38` action 1. */
openNpcTrade(npcId: number): void
/** NPC menu "gamble": `0x38` action 2 (Gheed, Elzix, Alkor, Jamella, Anya, Nihlathak). */
openNpcGamble(npcId: number): void
/** NPC menu "Identify Items" (Deckard Cain): `0x34`. */
identifyAllAtNpc(npcId: number): void
/** Close the NPC menu / store: `0x30 NpcCancel`. The server sends nothing back. */
closeNpc(npcId: number): void
/** Store grid click on a shop item: `0x32 NpcBuy` (gamble flag from the open window). */
vendorBuy(itemId: number, shiftFill: boolean): void
/** Sell a player item: `0x33 NpcSell`. */
vendorSell(itemId: number): void
/** Repair one item: `0x35 NpcRepair`. */
vendorRepairItem(itemId: number): void
/** Repair all equipped items: `0x35` with field4 = 0x80000000. */
vendorRepairAll(): void
/** Gold dialog mode 3: `0x4F` button 0x14. */
depositGold(amount: number): void
/** Gold dialog mode 4: `0x4F` button 0x13. */
withdrawGold(amount: number): void
/** Stash panel closed: `0x4F` button 0x12 (no reply). */
closeStash(): void
questMessage?(npcId: number, messageId: number): void
npcEntityAction?(actionType: number, entityId: number, complement: number): void
hireMerc?(npcId: number, mercListIndex: number): void
resurrectMerc?(npcId: number): void
mercItem?(bodyLoc: number): void
resurrect?(): void
partyAction?(action: number, playerUnitId: number): void
setHostile?(playerUnitId: number, hostile: boolean, button?: number): void
hostile?(playerUnitId: number, flag: number | boolean, button?: number): void
clickButton?(buttonId: number, complement?: number): void
confirmQuestReward?(itemId?: number): void
closeActiveNpcPanel?(): void
handleNpcMenuAction?(action: string, payload?: any): void
onResurrect?(): void
onSwapEquipItem?(cursorItemId: number, slot: EquipSlotId): void
onSwapContainerItem?(cursorItemId: number, targetItemId: number, col: number, row: number, buffer: number): void
onPutBeltItem?(cursorItemId: number, slotIndex: number): void
onRemoveBeltItem?(itemId: number): void
onSwapBeltItem?(cursorItemId: number, beltItemId: number): void
onUseBeltSlot?(slotIndex: number, shiftToMerc?: boolean): boolean | void
onIdentifyItem?(targetItemId: number, scrollItemId: number): void
onSocketItem?(cursorItemId: number, targetItemId: number): void
onScrollToBook?(scrollItemId: number, bookItemId: number): void
onUseInventoryItem?(itemId: number, x?: number, y?: number): void
}
/** Parse the numeric server item id carried by a `UiInventoryItem` synced from `ClientWorld`; throws otherwise. */
export function serverItemIdOf(item: UiInventoryItem): number {
const id = Number(item.id)
if (!Number.isInteger(id) || id < 0) throw new Error(`HUD item "${item.id}" has no server item id`)
return id
}
function groundLabelHexToD2Color(hex: string | undefined): D2ColorCode {
switch ((hex ?? '').toLowerCase()) {
case '#c4a060':
case '#d8b864':
case '#c8a15a':
return 'gold'
case '#00fc00':
return 'green'
case '#ffff64':
return 'yellow'
case '#ff8000':
case '#ffa500':
case '#ff9c18':
return 'orange'
case '#4880ff':
case '#6868ff':
return 'blue'
case '#a0a0a0':
case '#696969':
return 'gray'
default:
return 'white'
}
}
export class HudManager {
readonly font = new D2FontRenderer()
readonly globes = new GlobesHud()
readonly belt = new BeltHud()
readonly hotkeys = new SkillHotkeysHud()
readonly controlBar = new ControlBarHud()
readonly inventory = new InventoryPanel()
readonly charSheet = new CharacterSheetPanel()
readonly skillTree = new SkillTreePanel()
readonly worldPanels = new WorldPanelsHud()
readonly cursor = new CursorController()
leftPanel: LeftPanelType = 'none'
rightPanel: 'none' | 'inv' | 'skill' = 'none'
isRunning = true
showGroundLabels = false
/** Screen-space (800x600 viewport) ground item label boxes laid out for the current frame. */
groundItemLabelBoxes: readonly GroundItemLabelBox[] = []
/** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */
stamina = 0
maxStamina = 0
hp = 0
maxHp = 0
mana = 0
maxMana = 0
/** Server-authoritative death / mercenary / party / trade state synced by `HudModel`. */
isDead = false
merc: any = null
hireList: any[] = []
mercHireList: any[] = []
partyMembers: any[] = []
trade: any = null
pendingIdentifyScrollId: number | null = null
/** Optional 2D rendering context override (used by headless/unit tests). */
ctx: CanvasRenderingContext2D | null = null
/** Optional weapon switch callback. */
onWeaponSwitch?: (() => void) | undefined
onMercEquipSlotClick?: ((slot: number) => void) | undefined
onHireMercClick?: ((npcId: number, mercId: number) => void) | undefined
onPartyActionClick?: ((action: number, targetPlayerId: number) => void) | undefined
onHostileToggleClick?: ((targetPlayerId: number, hostile: boolean, button?: number) => void) | undefined
onTradeButtonClick?: ((buttonId: number, complement?: number) => void) | undefined
onConfirmQuestRewardClick?: ((itemId: number, npcId?: number) => void) | undefined
mouseX = 400
mouseY = 300
/** Optional target monster on top plaque. */
targetMonster: MonsterTargetHudInfo | null = null
private images = new Map<string, HTMLImageElement>()
private itemRects: Record<string, { x: number; y: number; w: number; h: number }> = {}
private decodedDc6Count = 415
private dc6DecodeFailures = 0
private eventsBound = false
readonly state: PublishedHudState = {
ready: false,
dc6DecodeFailures: 0,
decodedDc6Count: 415,
cursorState: 'POINTING',
cursorTradeMode: null,
cursorAnimation: 'protate',
cursorFrameIndex: 0,
assetsLoaded: {
ctrlPnl: false,
globes: false,
borders: false,
invSheet: false,
charSheet: false,
skillTree: false,
font: false,
itemsAtlas: false,
questsAtlas: false,
},
leftPanel: 'none',
rightPanel: 'none',
beltExpanded: false,
beltPotionsCount: 0,
miniPanelOpen: true,
isRunning: true,
showGroundLabels: false,
cursorItem: null,
equippedSlots: [],
bagItemsCount: 0,
weaponSwapSet: 0,
leftSkillId: 0,
rightSkillId: 0,
characterLevel: 0,
statPoints: 0,
skillPoints: 0,
hp: 0,
maxHp: 0,
mana: 0,
maxMana: 0,
stamina: 0,
maxStamina: 0,
docking: computeDockingLayout(800, 600, 1),
vendorTradeState: 'idle',
}
/** `null` until the server assigns the character class (see `setCharacterClass`). */
currentClass: CharacterClassCode | null = null
private _commandSink: HudCommandSink | null = null
/**
* D2Client gold dialog: mode `0` drop (string 4033), `3` deposit (4049), `4` withdraw (4050).
* The amount is clamped only to the source balance (inventory gold for 0/3, stash gold for 4);
* the server applies the inventory (clvl * 10,000) and stash (2,500,000) caps.
*/
goldDialog: { mode: GoldDialogMode; amount: number; fresh: boolean } | null = null
/**
* Server-authoritative command sink (online: `HudModel`). When attached, HUD interactions only emit
* `ClientCommand`s; all displayed state changes arrive back from the server through `HudModel.syncFromWorld()`.
*/
get commandSink(): HudCommandSink | null {
return this._commandSink
}
set commandSink(sink: HudCommandSink | null) {
this._commandSink = sink
this.hotkeys.onSelectSkill = sink ? (side, skillId) => sink.selectSkill(side, skillId) : undefined
}
/**
* Apply the server-assigned character class (MCP character list / `0x59` UnitAssign classId) to every
* class-dependent panel: skill tree layout, quickbar, character sheet and item requirement context.
* Hard points, bonus levels and quickbar skills are cleared; `HudModel` rebuilds them from the server skill list.
*/
setCharacterClass(classCode: CharacterClassCode): void {
if (this.currentClass === classCode && this.skillTree.characterClass === classCode) return
this.currentClass = classCode
this.charSheet.classCode = classCode
this.skillTree.setClass(classCode)
this.skillTree.hardPoints.clear()
this.skillTree.bonusLevels.clear()
this.hotkeys.setAvailableSkills([])
this.syncPublishedState()
}
private _lang: TblLang = 'CHI'
get lang(): TblLang {
return this._lang
}
set lang(val: TblLang) {
this.setLang(val)
}
setLang(lang: GameLang | TblLang): void {
const tbl = toTblLang(lang)
this._lang = tbl
setCurrentLang(tbl === 'ENG' ? 'en' : 'zh')
setTooltipLocale(tbl === 'ENG' ? 'en' : 'zh')
this.worldPanels.lang = tbl
this.charSheet.lang = tbl
this.skillTree.lang = tbl
this.hotkeys.lang = tbl
this.controlBar.lang = tbl
this.globes.lang = tbl
}
/** Optional callback invoked when an Escape menu row is activated ('options' | 'saveAndExit' | 'returnToGame'). */
onEscapeMenuAction?: ((action: EscapeMenuAction) => void) | undefined
/** Optional UI sound callback (e.g. soundId 1 = cursor/button.wav). */
onPlayUiSound?: ((soundId: number) => void) | undefined
private readonly hudCanvas: HTMLCanvasElement
get canvas(): HTMLCanvasElement {
return this.hudCanvas
}
private readonly callbacks: {
onToggleAutomap: () => void
onWaypointTeleport: (act: number, slug: string, levelId?: number) => void
onToggleAutomapMode?: () => void
onCycleAutomapFade?: () => void
onToggleAutomapNames?: () => void
onResetAutomapPan?: () => void
onPanAutomap?: (dx: number, dy: number) => boolean
onRenderAutomapOverlay?: (
ctx: CanvasRenderingContext2D,
font: D2FontRenderer,
logicalWidth: number,
logicalHeight: number,
) => void
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
onEscapeMenuAction?: (action: EscapeMenuAction) => void
onPlayUiSound?: (soundId: number) => void
}
constructor(
hudCanvas?: HTMLCanvasElement | null,
callbacks?: {
onToggleAutomap?: () => void
onWaypointTeleport?: (act: number, slug: string, levelId?: number) => void
onToggleAutomapMode?: () => void
onCycleAutomapFade?: () => void
onToggleAutomapNames?: () => void
onResetAutomapPan?: () => void
onPanAutomap?: (dx: number, dy: number) => boolean
onRenderAutomapOverlay?: (
ctx: CanvasRenderingContext2D,
font: D2FontRenderer,
logicalWidth: number,
logicalHeight: number,
) => void
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
onEscapeMenuAction?: (action: EscapeMenuAction) => void
onPlayUiSound?: (soundId: number) => void
},
) {
this.hudCanvas =
hudCanvas ??
({
width: 800,
height: 600,
clientWidth: 800,
clientHeight: 600,
style: { cursor: 'none', pointerEvents: 'none' },
getBoundingClientRect: () => ({
left: 0,
top: 0,
width: this.viewportProfile.width,
height: this.viewportProfile.height,
right: this.viewportProfile.width,
bottom: this.viewportProfile.height,
x: 0,
y: 0,
toJSON: () => ({}),
}),
getContext: () => null,
addEventListener: () => {},
} as unknown as HTMLCanvasElement)
this.callbacks = {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
...callbacks,
}
this.onEscapeMenuAction = callbacks?.onEscapeMenuAction
this.onPlayUiSound = callbacks?.onPlayUiSound
this.setLang(detectInitialLang())
this.worldPanels.activePlayerInventory = this.inventory
this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId)
if (this.hudCanvas && this.hudCanvas.style) {
this.hudCanvas.style.cursor = 'none'
}
this.bindEvents()
}
/** Server authority: only requests `AddStatPoint`; attributes/vitals change when the server answers. */
allocateStat(attr: 'strength' | 'dexterity' | 'vitality' | 'energy' | BaseStatKey): boolean {
const shortKey: BaseStatKey =
attr === 'strength'
? 'str'
: attr === 'dexterity'
? 'dex'
: attr === 'vitality'
? 'vit'
: attr === 'energy'
? 'ene'
: attr
if (this._commandSink) return this._commandSink.allocateStat(shortKey)
return false
}
useBeltSlot(col: number, shiftToMerc = false): boolean {
return this.drinkBeltSlot(col, shiftToMerc)
}
dropInventoryGold(amount: number, _mousePos?: { x: number; y: number }): number {
// Server authority: request `0x50 DropGold`; gold stat and ground pile arrive from the server.
this._commandSink?.dropGold(amount)
return 0
}
get screenWidth(): number {
return this.viewportProfile.width
}
get screenHeight(): number {
return this.viewportProfile.height
}
/** Switch to the registered profile of exactly this logical size; throws for an unregistered size. */
resize(width: number, height?: number): ViewportProfile {
return this.setViewportProfile(getViewportProfileBySize(width, height))
}
load(baseUrl = '/ui'): Promise<void> {
return this.init(baseUrl)
}
handleMouseMove(logicalX: number, logicalY: number): void {
this.mouseX = logicalX
this.mouseY = logicalY
this.cursor.handleMouseMove(logicalX, logicalY)
this.globes.handleMouseMove(logicalX, logicalY)
this.belt.handleMouseMove(logicalX, logicalY)
this.hotkeys.handleMouseMove(logicalX, logicalY)
this.controlBar.handleMouseMove(logicalX, logicalY)
if (this.worldPanels.npcMenu) {
this.worldPanels.handleNpcMenuMove(logicalX, logicalY, this.font)
}
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
this.inventory.handleMouseMove(logicalX - deltaRight, logicalY)
if (this.inventory.hoveredItem) {
this.inventory.hoveredItem.x += deltaRight
}
this.skillTree.handleMouseMove(logicalX - deltaRight, logicalY)
if (
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'waypoint' ||
this.leftPanel === 'vendor'
) {
this.worldPanels.handleMouseMove(logicalX - deltaLeft, logicalY)
}
}
handleClick(logicalX: number, logicalY: number, button = 0): void {
const clientPt = this.logicalToClient(logicalX, logicalY)
const fakeEvent = {
button,
clientX: clientPt.x,
clientY: clientPt.y,
shiftKey: false,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as MouseEvent
this.handleHudMouseDown(fakeEvent)
}
async init(baseUrl = '/ui'): Promise<void> {
this.hotkeys.preloadSkillIcons()
this.skillTree.preloadAssets()
await this.font.load(`${baseUrl}/fonts`)
try {
const mRes = await fetch(`${baseUrl}/manifest.json`)
if (mRes.ok) {
const manifest = (await mRes.json()) as UiManifest
this.itemRects = manifest.itemRects
this.decodedDc6Count = manifest.decodedDc6Count
this.dc6DecodeFailures = manifest.dc6DecodeFailures
}
} catch {
// Keep default manifest metadata
}
const imageFiles: Record<string, string> = {
ctrlPnl: `${baseUrl}/ctrlpnl-800.png`,
globeLife: `${baseUrl}/globe-life.png`,
globeMana: `${baseUrl}/globe-mana.png`,
globePoison: `${baseUrl}/globe-poison.png`,
overlapLeft: `${baseUrl}/overlap-left.png`,
overlapRight: `${baseUrl}/overlap-right.png`,
borderLeft: `${baseUrl}/border-left.png`,
borderRight: `${baseUrl}/border-right.png`,
charSheet: `${baseUrl}/char-sheet.png`,
invSheet: `${baseUrl}/inv-sheet.png`,
invTab0: `${baseUrl}/inv-tab-0.png`,
invTab1: `${baseUrl}/inv-tab-1.png`,
questBg: `${baseUrl}/quest-bg.png`,
waypointBg: `${baseUrl}/waypoint-bg.png`,
stashBg: `${baseUrl}/stash-bg.png`,
cubeBg: `${baseUrl}/cube-bg.png`,
vendorBg: `${baseUrl}/vendor-bg.png`,
vendorTabs: `${baseUrl}/vendor-tabs.png`,
popbelt: `${baseUrl}/popbelt.png`,
miniPanel: `${baseUrl}/minipanel.png`,
miniPanelBtns: `${baseUrl}/minipanel-btns.png`,
runBtn: `${baseUrl}/runbutton.png`,
menuBtn: `${baseUrl}/menubutton.png`,
buySellBtn: `${baseUrl}/buysellbtn.png`,
goldCoinBtn: `${baseUrl}/goldcoinbtn.png`,
miniConvert: `${baseUrl}/miniconvert.png`,
levelBtn: `${baseUrl}/level-btn.png`,
levelSocket: `${baseUrl}/level-socket.png`,
skillPoints: `${baseUrl}/skillpoints.png`,
skillTabs: `${baseUrl}/skill-tree-tabs.png`,
waypointTabs: `${baseUrl}/waypoint-tabs.png`,
waypointIcons: `${baseUrl}/waypoint-icons.png`,
questTabs: `${baseUrl}/quest-tabs.png`,
genericSkills: `${baseUrl}/generic-skills.png`,
cursorHand: `${baseUrl}/cursor-hand.png`,
cursorAtlas: `${baseUrl}/cursor.png`,
boxPieces: `${baseUrl}/boxpieces.png`,
focus16: `${baseUrl}/focus16.png`,
escOptions: `${baseUrl}/esc-options.png`,
escExit: `${baseUrl}/esc-exit.png`,
escReturn: `${baseUrl}/esc-return.png`,
pentSpin: `${baseUrl}/pentspin.png`,
itemsAtlas: `${baseUrl}/items-atlas.png`,
questsAtlas: `${baseUrl}/quests-atlas.png`,
}
await Promise.all(
Object.entries(imageFiles).map(
([key, src]) =>
new Promise<void>((resolve) => {
if (typeof Image === 'undefined') {
resolve()
return
}
const img = new Image()
img.onload = () => {
this.images.set(key, img)
resolve()
}
img.onerror = () => resolve()
img.src = src
}),
),
)
this.bindEvents()
this.syncPublishedState()
this.state.ready = true
}
openNpcMenuByName(name: string, screenX = 400, screenY = 220): boolean {
const ok = this.worldPanels.openNpcMenuByName(name, screenX, screenY)
if (ok) {
this.syncPublishedState()
}
return ok
}
private requireSink(action: string): HudCommandSink {
if (!this._commandSink) throw new Error(`HudManager.${action}: no server command sink attached`)
return this._commandSink
}
/** Server opened a store (`ClientWorld.npcInteraction.window`): show the vendor panel with the inventory. */
showVendorPanel(): void {
if (!this.worldPanels.activeVendorDescriptor) {
throw new Error('HudManager.showVendorPanel: no store set (WorldPanelsHud.setVendorShop)')
}
this.worldPanels.npcMenu = null
this.leftPanel = 'vendor'
this.charSheet.visible = false
if (this.rightPanel !== 'inv') this.toggleRightPanel('inv')
this.syncPublishedState()
}
/** Server opened the stash (S2C 0x77 action 0x10): show the stash panel with the inventory. */
showStashPanel(): void {
this.leftPanel = 'stash'
this.charSheet.visible = false
if (this.rightPanel !== 'inv') this.toggleRightPanel('inv')
this.syncPublishedState()
}
/** World state closed `kind` (already noted by `ClientWorld`): hide it without sending anything. */
hideLeftPanelFromWorld(kind: 'vendor' | 'stash'): void {
if (this.leftPanel !== kind) return
this.leftPanel = 'none'
if (kind === 'vendor') this.worldPanels.releaseMomentaryVendorButton()
if (this.goldDialog?.mode === 4 && kind === 'stash') this.goldDialog = null
this.syncPublishedState()
}
/** Dismiss the NPC menu and end the interaction (`0x30 NpcCancel`). */
dismissNpcMenu(): void {
const menu = this.worldPanels.npcMenu
if (!menu) return
this.worldPanels.npcMenu = null
if (menu.npcId !== null) this.requireSink('dismissNpcMenu').closeNpc(menu.npcId)
this.syncPublishedState()
}
/** Leaving the vendor / stash panel tells the server (`0x30` / `0x4F 0x12`); other panels are local. */
private leaveLeftPanel(prev: LeftPanelType): void {
if (prev === 'vendor') {
const npcId = this.worldPanels.vendorNpcId
this.worldPanels.clearVendorShop()
if (npcId !== null) this.requireSink('closeVendor').closeNpc(npcId)
} else if (prev === 'stash') {
if (this.goldDialog && this.goldDialog.mode !== 0) this.goldDialog = null
this._commandSink?.closeStash()
} else if (prev === 'hire' || prev === 'imbue' || prev === 'add_sockets' || prev === 'personalize') {
if (this._commandSink?.closeActiveNpcPanel) {
this._commandSink.closeActiveNpcPanel()
} else {
;(this.worldPanels as any).onCloseNpc?.()
}
} else if (prev === 'trade' && this.trade?.active) {
if (this._commandSink?.clickButton) {
this._commandSink.clickButton(2, 0)
} else {
;(this.worldPanels as any).onTradeAction?.(2)
}
}
}
/** Source balance the gold dialog clamps to: stash gold for withdraw, inventory gold otherwise. */
goldDialogBalance(mode: GoldDialogMode): number {
return mode === 4 ? this.worldPanels.stashGold : this.inventory.gold
}
/** Open the gold dialog pre-filled with the whole source balance. */
openGoldDialog(mode: GoldDialogMode): void {
if (mode === 3 && this.leftPanel !== 'stash') throw new Error('HudManager.openGoldDialog: deposit needs the stash open')
if (mode === 4 && this.leftPanel !== 'stash') throw new Error('HudManager.openGoldDialog: withdraw needs the stash open')
this.goldDialog = { mode, amount: this.goldDialogBalance(mode), fresh: true }
this.syncPublishedState()
}
/** Set the typed amount, clamped to `0 .. source balance`. */
setGoldDialogAmount(amount: number): void {
const dlg = this.goldDialog
if (!dlg) throw new Error('HudManager.setGoldDialogAmount: gold dialog is not open')
if (!Number.isFinite(amount)) throw new Error(`HudManager.setGoldDialogAmount: invalid amount ${amount}`)
dlg.amount = Math.max(0, Math.min(this.goldDialogBalance(dlg.mode), Math.floor(amount)))
dlg.fresh = false
}
/** Digit / Backspace editing; the first digit after opening replaces the pre-filled amount. */
typeGoldDialogKey(key: string): void {
const dlg = this.goldDialog
if (!dlg) throw new Error('HudManager.typeGoldDialogKey: gold dialog is not open')
if (key === 'Backspace') {
this.setGoldDialogAmount(dlg.fresh ? 0 : Math.floor(dlg.amount / 10))
} else if (/^[0-9]$/.test(key)) {
this.setGoldDialogAmount((dlg.fresh ? 0 : dlg.amount) * 10 + Number(key))
}
}
/** "ok": send the request for the clamped amount (nothing for 0) and close the dialog. */
confirmGoldDialog(): void {
const dlg = this.goldDialog
if (!dlg) throw new Error('HudManager.confirmGoldDialog: gold dialog is not open')
const amount = Math.max(0, Math.min(this.goldDialogBalance(dlg.mode), dlg.amount))
this.goldDialog = null
if (amount > 0) {
const sink = this.requireSink('confirmGoldDialog')
if (dlg.mode === 0) sink.dropGold(amount)
else if (dlg.mode === 3) sink.depositGold(amount)
else sink.withdrawGold(amount)
}
this.syncPublishedState()
}
cancelGoldDialog(): void {
this.goldDialog = null
this.syncPublishedState()
}
/** Gold dialog box (centered stone box: title, amount, ok / cancel). */
getGoldDialogLayout(): { x: number; y: number; w: number; h: number; ok: GoldDialogRect; cancel: GoldDialogRect } {
const w = 260
const h = 110
const x = Math.round(this.screenWidth / 2 - w / 2)
const y = Math.round((this.screenHeight - 47) / 2 - h / 2)
return {
x,
y,
w,
h,
ok: { x: x + 30, y: y + h - 34, w: 80, h: 22 },
cancel: { x: x + w - 110, y: y + h - 34, w: 80, h: 22 },
}
}
private handleGoldDialogClick(x: number, y: number): void {
const l = this.getGoldDialogLayout()
const inRect = (r: GoldDialogRect) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h
if (inRect(l.ok)) this.confirmGoldDialog()
else if (inRect(l.cancel)) this.cancelGoldDialog()
}
private drawGoldDialog(ctx: CanvasRenderingContext2D): void {
const dlg = this.goldDialog
if (!dlg) return
const l = this.getGoldDialogLayout()
const titleKey = dlg.mode === 0 ? 'strDropGoldHowMuch' : dlg.mode === 3 ? 'strBankGoldDeposit' : 'strBankGoldWithdraw'
ctx.fillStyle = 'rgba(0, 0, 0, 0.9)'
ctx.fillRect(l.x, l.y, l.w, l.h)
ctx.strokeStyle = '#6c5c40'
ctx.strokeRect(l.x + 0.5, l.y + 0.5, l.w - 1, l.h - 1)
this.font.drawText(ctx, requireTbl(titleKey, this.lang), l.x + l.w / 2, l.y + 22, {
font: 'font8',
color: 'white',
align: 'center',
})
this.font.drawText(ctx, String(dlg.amount), l.x + l.w / 2, l.y + 50, {
font: 'font16',
color: 'gold',
align: 'center',
})
for (const [rect, key] of [[l.ok, 'ok'], [l.cancel, 'Back']] as const) {
ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1)
this.font.drawText(ctx, requireTbl(key, this.lang), rect.x + rect.w / 2, rect.y + 16, {
font: 'font16',
color: 'white',
align: 'center',
})
}
}
/**
* Toggle a Left-Half Panel (`char`, `quest`, `waypoint`, `stash`, `cube`, `vendor`, `merc`, `hire`, `party`, `trade`, `imbue`, `add_sockets`, `personalize`).
* Strictly follows D2Client.dll mutual exclusion on the Left Dock (`0..400`).
*/
toggleLeftPanel(kind: Exclude<LeftPanelType, 'none'>): void {
const prev = this.leftPanel
if (prev === kind) {
this.leftPanel = 'none'
;(this.worldPanels as any).activePanel = 'none'
this.charSheet.visible = false
this.worldPanels.vendorTradeState = 'idle'
this.worldPanels.releaseMomentaryVendorButton()
this.leaveLeftPanel(prev)
} else {
if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) {
throw new Error('Cannot open vendor panel without an active vendor session')
}
this.leftPanel = kind
;(this.worldPanels as any).activePanel = kind === 'char' ? 'none' : kind
if (prev !== 'none') this.leaveLeftPanel(prev)
this.charSheet.visible = kind === 'char'
if (
(kind === 'stash' ||
kind === 'vendor' ||
kind === 'trade' ||
kind === 'imbue' ||
kind === 'add_sockets' ||
kind === 'personalize') &&
this.rightPanel === 'none'
) {
this.toggleRightPanel('inv')
}
}
this.syncPublishedState()
}
/**
* Toggle a Right-Half Panel (`inv` or `skill`).
* Strictly follows D2Client.dll mutual exclusion on the Right Dock (`400..800`).
*/
toggleRightPanel(kind: 'inv' | 'skill'): void {
if (this.rightPanel === kind) {
this.rightPanel = 'none'
this.inventory.visible = false
this.skillTree.visible = false
} else {
this.rightPanel = kind
this.inventory.visible = kind === 'inv'
this.skillTree.visible = kind === 'skill'
}
this.syncPublishedState()
}
get escapeMenuOpen(): boolean {
return this.worldPanels.escapeMenuOpen
}
set escapeMenuOpen(val: boolean) {
if (val && !this.worldPanels.escapeMenuOpen) {
this.worldPanels.escapeMenuSelectedIndex = 2
}
this.worldPanels.escapeMenuOpen = val
this.syncPublishedState()
}
get escapeMenuSelectedIndex(): EscapeMenuRowIndex {
return this.worldPanels.escapeMenuSelectedIndex
}
set escapeMenuSelectedIndex(idx: EscapeMenuRowIndex) {
this.worldPanels.escapeMenuSelectedIndex = idx
this.syncPublishedState()
}
/**
* Toggle the authentic 1.13c In-Game Escape Menu (`D2Client.dll` `0x6fb15760` & `0x6fb15bc0`).
* Opening the Escape menu closes any open panels and resets the default selection to row 2 (`Return to Game`).
*/
toggleEscapeMenu(): boolean {
if (this.worldPanels.escapeMenuOpen) {
this.worldPanels.escapeMenuOpen = false
this.syncPublishedState()
return false
}
this.closeAllPanels()
this.worldPanels.escapeMenuOpen = true
this.worldPanels.escapeMenuSelectedIndex = 2
this.syncPublishedState()
return true
}
/**
* Move Escape menu selection up (`delta = -1`) or down (`delta = 1`) with wrap-around (`0x6fb14ee0` / `0x6fb14f50`).
*/
moveEscapeMenuSelection(delta: -1 | 1): EscapeMenuRowIndex {
const next = ((this.worldPanels.escapeMenuSelectedIndex + delta + 3) % 3) as EscapeMenuRowIndex
this.worldPanels.escapeMenuSelectedIndex = next
this.onPlayUiSound?.(1)
this.syncPublishedState()
return next
}
/**
* Activate the Escape menu item at `index` (defaults to current `escapeMenuSelectedIndex`):
* - `0` (`options`): placeholder — selects row 0, plays click sound, keeps Escape menu open.
* - `1` (`saveAndExit`): closes Escape menu, plays click sound, invokes `onEscapeMenuAction('saveAndExit')`.
* - `2` (`returnToGame`): closes Escape menu, plays click sound, invokes `onEscapeMenuAction('returnToGame')`.
*/
activateEscapeMenuIndex(index: EscapeMenuRowIndex = this.worldPanels.escapeMenuSelectedIndex): EscapeMenuAction {
this.worldPanels.escapeMenuSelectedIndex = index
this.onPlayUiSound?.(1)
const action = ESCAPE_MENU_ACTIONS[index]
if (action === 'returnToGame' || action === 'saveAndExit') {
this.worldPanels.escapeMenuOpen = false
}
this.syncPublishedState()
this.onEscapeMenuAction?.(action)
return action
}
/**
* Hit-test and activate an Escape menu item at logical coordinates `(logicalX, logicalY)`.
*/
handleEscapeMenuClick(logicalX: number, logicalY: number): EscapeMenuAction | null {
const hit = hitTestEscapeMenu(logicalX, logicalY, this.viewportProfile.width, this.viewportProfile.height)
if (hit === null) return null
return this.activateEscapeMenuIndex(hit)
}
closeAllPanels(): boolean {
const anyOpen =
this.leftPanel !== 'none' ||
this.rightPanel !== 'none' ||
this.belt.lockedOpen ||
this.hotkeys.openPopup !== null ||
this.worldPanels.npcMenu !== null ||
this.worldPanels.escapeMenuOpen ||
this.goldDialog !== null ||
this.inventory.isIdentifyMode() ||
this.pendingIdentifyScrollId !== null
const prevLeft = this.leftPanel
this.goldDialog = null
this.pendingIdentifyScrollId = null
this.dismissNpcMenu()
this.leftPanel = 'none'
if (prevLeft !== 'none') this.leaveLeftPanel(prevLeft)
this.rightPanel = 'none'
this.charSheet.visible = false
this.inventory.visible = false
this.inventory.cancelIdentifyMode()
this.skillTree.visible = false
this.belt.lockedOpen = false
this.hotkeys.openPopup = null
this.worldPanels.npcMenu = null
this.worldPanels.escapeMenuOpen = false
this.worldPanels.vendorTradeState = 'idle'
this.worldPanels.releaseMomentaryVendorButton()
this.syncPublishedState()
return anyOpen
}
viewportProfile: ViewportProfile = VIEWPORT_800x600
getViewportProfile(): ViewportProfile {
return this.viewportProfile
}
setViewportProfile(profileOrId: ViewportProfile | ViewportResolution): ViewportProfile {
this.viewportProfile = getViewportProfile(profileOrId)
this.syncPublishedState()
return this.viewportProfile
}
getViewportAnchors(): ViewportAnchorMetrics {
return computeViewportAnchors(this.viewportProfile)
}
/**
* Convert client CSS pixel coordinates into the active Diablo II logical viewport (`ViewportProfile` size),
* inverting 1:1 uniform aspect ratio scaling and horizontal centering margins.
*/
clientToLogical(clientX: number, clientY: number): { x: number; y: number } {
return clientToLogical(clientX, clientY, this.hudCanvas.getBoundingClientRect(), 1, this.viewportProfile)
}
/**
* Convert Diablo II logical viewport coordinates into client CSS pixel coordinates.
*/
logicalToClient(logicalX: number, logicalY: number): { x: number; y: number } {
return logicalToClient(logicalX, logicalY, this.hudCanvas.getBoundingClientRect(), 1, this.viewportProfile)
}
/**
* Returns current widescreen panel docking layout metrics.
*/
getDockingLayout(): DockingLayout {
const hasWindow = typeof window !== 'undefined'
const dpr = Math.min(hasWindow && window.devicePixelRatio ? window.devicePixelRatio : 1, 2)
const rect = this.hudCanvas?.getBoundingClientRect?.()
const canvasW = this.hudCanvas?.clientWidth || rect?.width || (this.hudCanvas?.width ?? 0)
const canvasH = this.hudCanvas?.clientHeight || rect?.height || (this.hudCanvas?.height ?? 0)
const winW = hasWindow && window.innerWidth ? window.innerWidth : this.viewportProfile.width
const winH = hasWindow && window.innerHeight ? window.innerHeight : this.viewportProfile.height
const cssW = Math.max(320, canvasW || winW)
const cssH = Math.max(240, canvasH || winH)
return computeDockingLayout(cssW, cssH, dpr, this.viewportProfile)
}
/**
* Whether a logical `(x, y)` point hits an active HUD panel or control bar,
* so scene clicks/walks underneath are suppressed.
*
* Out-of-bounds coordinates (< -marginW or > width + marginW / height) fall outside the canvas
* and must return false so scene navigation is not blocked.
* Central vision corridor (e.g. `400 < logicalX < 624` in `1024x768`) remains open and returns false.
*/
isPointInterceptedByHud(logicalX: number, logicalY: number): boolean {
if (
this.isDead ||
Boolean((this.worldPanels as any).isDead) ||
this.goldDialog ||
this.worldPanels.escapeMenuOpen
) {
return true
}
const profile = this.viewportProfile
const docking = this.getDockingLayout()
const { marginW } = docking
if (isExtendedViewport(profile)) {
if (logicalX < -marginW || logicalX > profile.width + marginW || logicalY < 0 || logicalY > profile.height) {
return false
}
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
const barX = logicalX - profile.barOffsetX
const barY = logicalY - profile.barOffsetY
if (barY >= 540 && barY <= 600 && barX >= 0 && barX <= 800) return true
if (barX >= 0 && barX <= 117 && barY >= 496 && barY <= 600) return true
if (barX >= 683 && barX <= 800 && barY >= 496 && barY <= 600) return true
if (this.controlBar.miniPanelOpen && barX >= 312 && barX <= 488 && barY >= 522 && barY <= 600) return true
if (this.belt.isExpanded && barX >= 420 && barX <= 550 && barY >= 460 && barY <= 600) return true
if (this.hotkeys.openPopup !== null) return true
const panelY = logicalY - profile.panelOffsetY
if (this.leftPanel !== 'none' && logicalX >= 0 && logicalX <= 400 && panelY >= 0 && panelY <= 553) {
return true
}
const rightDockX = profile.width - 400
if (this.rightPanel !== 'none' && logicalX >= rightDockX && logicalX <= profile.width && panelY >= 0 && panelY <= 553) {
return true
}
return false
}
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true
if (this.controlBar.miniPanelOpen && logicalX >= 312 && logicalX <= 488 && logicalY >= 522) return true
if (this.belt.isExpanded && logicalX >= 420 && logicalX <= 550 && logicalY >= 460) return true
if (this.hotkeys.openPopup !== null) return true
if (this.leftPanel !== 'none' && logicalX >= -marginW && logicalX <= 400 - marginW && logicalY <= 553) return true
if (this.rightPanel !== 'none' && logicalX >= 400 + marginW && logicalX <= 800 + marginW && logicalY <= 553) return true
return false
}
drinkBeltSlot(col: number, shiftToMerc = false): boolean {
// Server authority: request `0x26 BeltUse`; healing and belt contents come back from the server.
if (this._commandSink) {
if (this._commandSink.onUseBeltSlot) {
return this._commandSink.onUseBeltSlot(col, shiftToMerc) !== false
}
return this._commandSink.useBeltSlot(col, shiftToMerc)
}
return false
}
private triggerResurrect(): void {
const sink = this._commandSink
if (sink?.onResurrect) {
sink.onResurrect()
} else if (sink?.resurrect) {
sink.resurrect()
} else {
;(this.worldPanels as any).onResurrect?.()
}
}
handleKeyDown(e: KeyboardEvent): void {
if (
typeof HTMLInputElement !== 'undefined' &&
(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement)
) {
return
}
if ((this.isDead || Boolean((this.worldPanels as any).isDead)) && e.key === 'Escape') {
e.preventDefault?.()
this.triggerResurrect()
return
}
if (this.goldDialog) {
e.preventDefault?.()
if (e.key === 'Enter') this.confirmGoldDialog()
else if (e.key === 'Escape') this.cancelGoldDialog()
else this.typeGoldDialogKey(e.key)
this.syncPublishedState()
return
}
if (this.worldPanels.escapeMenuOpen) {
if (e.key === 'Escape') {
e.preventDefault?.()
this.toggleEscapeMenu()
} else if (e.key === 'ArrowUp') {
e.preventDefault?.()
this.moveEscapeMenuSelection(-1)
} else if (e.key === 'ArrowDown') {
e.preventDefault?.()
this.moveEscapeMenuSelection(1)
} else if (e.key === 'Enter') {
e.preventDefault?.()
this.activateEscapeMenuIndex()
}
return
}
const key = e.key.toUpperCase()
if (key === 'I' || key === 'B') {
e.preventDefault?.()
this.toggleRightPanel('inv')
} else if (key === 'A' || key === 'C') {
e.preventDefault?.()
this.toggleLeftPanel('char')
} else if (key === 'O') {
e.preventDefault?.()
if (this.merc && !this.merc.isDead) {
this.toggleLeftPanel('merc')
}
} else if (key === 'P') {
e.preventDefault?.()
this.toggleLeftPanel('party')
} else if (key === 'T' || key === 'S') {
e.preventDefault?.()
this.toggleRightPanel('skill')
} else if (key === 'Q') {
e.preventDefault?.()
this.toggleLeftPanel('quest')
} else if (key === 'V') {
e.preventDefault?.()
if (this.callbacks.onToggleAutomapMode) {
this.callbacks.onToggleAutomapMode()
} else {
this.toggleLeftPanel('waypoint')
}
} else if (key === 'F9') {
e.preventDefault?.()
this.callbacks.onCycleAutomapFade?.()
} else if (key === 'F10') {
e.preventDefault?.()
this.callbacks.onToggleAutomapNames?.()
} else if (e.key === 'Home') {
e.preventDefault?.()
this.callbacks.onResetAutomapPan?.()
} else if (
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight'
) {
const dx = e.key === 'ArrowLeft' ? -8 : e.key === 'ArrowRight' ? 8 : 0
const dy = e.key === 'ArrowUp' ? -8 : e.key === 'ArrowDown' ? 8 : 0
if (this.callbacks.onPanAutomap?.(dx, dy)) {
e.preventDefault?.()
}
} else if (key === 'W') {
e.preventDefault?.()
if (this._commandSink) {
this._commandSink.swapWeapons()
} else if (this.onWeaponSwitch) {
this.onWeaponSwitch()
} else {
this.inventory.toggleWeaponSwap()
}
this.syncPublishedState()
} else if (key === 'R') {
e.preventDefault?.()
this.isRunning = !this.isRunning
this.syncPublishedState()
} else if (e.key === '`' || e.key === '~') {
e.preventDefault?.()
this.belt.togglePopup()
this.syncPublishedState()
} else if (key === '1' || key === '2' || key === '3' || key === '4') {
e.preventDefault?.()
this.drinkBeltSlot(Number(key) - 1, Boolean(e.shiftKey))
} else if (/^F[1-8]$/.test(key)) {
e.preventDefault?.()
this.hotkeys.triggerFunctionKey(key)
this.syncPublishedState()
} else if (e.key === 'Alt') {
e.preventDefault?.()
this.showGroundLabels = true
this.syncPublishedState()
} else if (e.key === 'Escape' || e.code === 'Space' || e.key === ' ') {
if (this.closeAllPanels()) {
e.preventDefault?.()
}
}
}
private bindEvents(): void {
if (this.eventsBound || typeof window === 'undefined') return
this.eventsBound = true
window.addEventListener('keydown', (e) => this.handleKeyDown(e))
window.addEventListener('keyup', (e) => {
if (e.key === 'Alt') {
e.preventDefault()
this.showGroundLabels = false
this.syncPublishedState()
}
})
window.addEventListener('blur', () => {
if (this.showGroundLabels) {
this.showGroundLabels = false
this.syncPublishedState()
}
})
window.addEventListener('mousemove', (e) => {
const pt = this.clientToLogical(e.clientX, e.clientY)
this.mouseX = pt.x
this.mouseY = pt.y
this.cursor.handleMouseMove(pt.x, pt.y)
this.globes.handleMouseMove(pt.x, pt.y)
this.belt.handleMouseMove(pt.x, pt.y)
this.hotkeys.handleMouseMove(pt.x, pt.y)
this.controlBar.handleMouseMove(pt.x, pt.y)
if (this.worldPanels.npcMenu) {
this.worldPanels.handleNpcMenuMove(pt.x, pt.y, this.font)
}
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
this.inventory.handleMouseMove(pt.x - deltaRight, pt.y)
if (this.inventory.hoveredItem) {
this.inventory.hoveredItem.x += deltaRight
}
this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y)
if (
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'waypoint' ||
this.leftPanel === 'vendor'
) {
this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) {
this.inventory.hoveredItem = {
...this.worldPanels.hoveredStashItem,
x: this.worldPanels.hoveredStashItem.x + deltaLeft,
}
} else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) {
this.inventory.hoveredItem = {
...this.worldPanels.hoveredCubeItem,
x: this.worldPanels.hoveredCubeItem.x + deltaLeft,
}
} else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) {
this.inventory.hoveredItem = {
item: this.worldPanels.hoveredVendorItem.item,
x: this.worldPanels.hoveredVendorItem.x + deltaLeft,
y: this.worldPanels.hoveredVendorItem.y,
}
}
}
const intercept =
this.isPointInterceptedByHud(this.mouseX, this.mouseY) ||
this.inventory.cursorItem !== null ||
this.worldPanels.vendorTradeState !== 'idle' ||
this.inventory.isIdentifyMode() ||
this.pendingIdentifyScrollId !== null
this.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
})
window.addEventListener('mousedown', (e) => {
this.cursor.handleMouseDown()
if (
e.target !== this.hudCanvas &&
typeof HTMLCanvasElement !== 'undefined' &&
e.target instanceof HTMLCanvasElement
) {
this.handleHudMouseDown(e)
}
})
window.addEventListener('mouseup', () => {
this.cursor.handleMouseUp()
this.worldPanels.releaseMomentaryVendorButton()
})
this.hudCanvas.addEventListener('contextmenu', (e) => {
e.preventDefault()
})
this.hudCanvas.addEventListener('mousedown', (e) => this.handleHudMouseDown(e))
}
handleHudMouseDown(e: MouseEvent): void {
if (e.button !== 0 && e.button !== 2) return
const pt = this.clientToLogical(e.clientX, e.clientY)
if (this.isDead || Boolean((this.worldPanels as any).isDead)) {
e.preventDefault?.()
e.stopPropagation?.()
this.triggerResurrect()
return
}
if (this.goldDialog) {
e.preventDefault()
e.stopPropagation()
if (e.button === 0) this.handleGoldDialogClick(pt.x, pt.y)
return
}
if (this.worldPanels.escapeMenuOpen) {
e.preventDefault()
e.stopPropagation()
if (e.button === 0) this.handleEscapeMenuClick(pt.x, pt.y)
return
}
if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
this.dismissNpcMenu()
}
if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
if (this.inventory.isIdentifyMode() || this.pendingIdentifyScrollId !== null) {
e.preventDefault()
e.stopPropagation()
this.pendingIdentifyScrollId = null
this.inventory.cancelIdentifyMode()
this.syncPublishedState()
return
}
if (this.worldPanels.vendorTradeState !== 'idle') {
e.preventDefault()
e.stopPropagation()
this.worldPanels.vendorTradeState = 'idle'
this.syncPublishedState()
return
}
// If holding cursorItem and clicking outside panels onto the ground, drop it
if (this.inventory.cursorItem !== null) {
e.preventDefault()
e.stopPropagation()
const dropped = this.inventory.cursorItem
if (this._commandSink) {
this._commandSink.dropItem(serverItemIdOf(dropped))
} else {
this.inventory.cursorItem = null
this.callbacks.onDropItemToGround?.(dropped, pt)
}
this.syncPublishedState()
}
return
}
e.preventDefault()
e.stopPropagation()
// Town NPC Stone Menu click
if (this.worldPanels.npcMenu) {
const npcId = this.worldPanels.npcMenu.npcId
this.worldPanels.handleNpcMenuClick(
pt.x,
pt.y,
{
onOpenVendor: (_desc, mode) => {
const sink = this.requireSink('npcMenu')
if (npcId === null) throw new Error('HudManager: NPC menu has no server NPC id')
if (mode === 'trade') sink.openNpcTrade(npcId)
else sink.openNpcGamble(npcId)
},
onIdentifyAll: (_desc) => {
const sink = this.requireSink('npcMenu')
if (npcId === null) throw new Error('HudManager: NPC menu has no server NPC id')
sink.identifyAllAtNpc(npcId)
sink.closeNpc(npcId)
},
onCancel: () => {
if (npcId !== null) this.requireSink('npcMenu').closeNpc(npcId)
},
},
this.font,
)
this.syncPublishedState()
return
}
// Belt slot click: right-click drinks potion; left-click places/swaps/removes belt item in online mode
const beltHit = this.belt.slotAtPoint(pt.x, pt.y)
if (beltHit) {
if (e.button === 2) {
this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey))
return
}
if (this._commandSink) {
const sink = this._commandSink
const slotIndex = beltHit.row * 4 + beltHit.col
const cursor = this.inventory.cursorItem
const beltPotion = this.belt.getSlot(beltHit.row, beltHit.col)
const rawBeltId =
(beltPotion as { itemId?: number } | null)?.itemId ??
(beltPotion ? Number(beltPotion.id) : NaN)
const beltItemId = Number.isInteger(rawBeltId) && rawBeltId >= 0 ? rawBeltId : undefined
if (cursor) {
const cursorId = serverItemIdOf(cursor)
if (beltPotion && beltItemId !== undefined) {
if (sink.onSwapBeltItem) sink.onSwapBeltItem(cursorId, beltItemId)
else sink.swapBeltItem?.(cursorId, beltItemId)
} else {
if (sink.onPutBeltItem) sink.onPutBeltItem(cursorId, slotIndex)
else sink.putBeltItem?.(cursorId, slotIndex)
}
this.syncPublishedState()
return
}
if (beltPotion && beltItemId !== undefined && (sink.onRemoveBeltItem || sink.removeBeltItem)) {
if (sink.onRemoveBeltItem) sink.onRemoveBeltItem(beltItemId)
else sink.removeBeltItem?.(beltItemId)
this.syncPublishedState()
return
}
}
this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey))
return
}
if (this.globes.handleClick(pt.x, pt.y)) {
this.syncPublishedState()
return
}
if (this.hotkeys.handleClick(pt.x, pt.y)) {
this.syncPublishedState()
return
}
const ctrlState = this.buildControlBarState()
if (
this.controlBar.handleClick(pt.x, pt.y, ctrlState, {
onToggleRun: () => {
this.isRunning = !this.isRunning
},
onMiniPanelAction: (action: MiniPanelAction) => {
if (action === 'char') this.toggleLeftPanel('char')
else if (action === 'inv') this.toggleRightPanel('inv')
else if (action === 'skill') this.toggleRightPanel('skill')
else if (action === 'automap') this.callbacks.onToggleAutomap()
else if (action === 'quest') this.toggleLeftPanel('quest')
else if (action === 'waypoint') this.toggleLeftPanel('waypoint')
},
})
) {
this.syncPublishedState()
return
}
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
// Left-Half Dock Click
if (pt.x <= 400 + deltaLeft) {
const leftX = pt.x - deltaLeft
if (this.leftPanel === 'char') {
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
this._commandSink?.allocateStat(key)
})
if (!this.charSheet.visible) this.leftPanel = 'none'
this.syncPublishedState()
return
} else if (this.leftPanel !== 'none') {
const dockKind = this.leftPanel
if (typeof (this.worldPanels as any).handleActiveLeftPanelClick === 'function') {
const handled = (this.worldPanels as any).handleActiveLeftPanelClick(
dockKind,
leftX,
pt.y,
e.button === 2,
e.shiftKey,
{
onClose: () => {
this.leftPanel = 'none'
;(this.worldPanels as any).activePanel = 'none'
this.leaveLeftPanel(dockKind)
},
onMercItem: (slot: number) => {
if (typeof (this.worldPanels as any).onMercEquipSlotClick === 'function') {
;(this.worldPanels as any).onMercEquipSlotClick(slot)
} else {
this._commandSink?.mercItem?.(slot)
}
},
onHireMerc: (npcId: number, mercId: number) => {
if (typeof (this.worldPanels as any).onHireMercClick === 'function') {
;(this.worldPanels as any).onHireMercClick(npcId, mercId)
} else {
this._commandSink?.hireMerc?.(npcId, mercId)
}
},
onPartyAction: (action: number, targetPlayerId: number) => {
if (typeof (this.worldPanels as any).onPartyActionClick === 'function') {
;(this.worldPanels as any).onPartyActionClick(action, targetPlayerId)
} else {
this._commandSink?.partyAction?.(action, targetPlayerId)
}
},
onSetHostile: (targetPlayerId: number, hostile: boolean, button = 1) => {
if (typeof (this.worldPanels as any).onHostileToggleClick === 'function') {
;(this.worldPanels as any).onHostileToggleClick(targetPlayerId, hostile, button)
} else {
this._commandSink?.setHostile?.(targetPlayerId, hostile, button)
}
},
onClickButton: (buttonId: number, complement = 0) => {
if (typeof (this.worldPanels as any).onTradeButtonClick === 'function') {
;(this.worldPanels as any).onTradeButtonClick(buttonId, complement)
} else {
this._commandSink?.clickButton?.(buttonId, complement)
}
},
onTradeGridClick: (
col: number,
row: number,
hitItem: UiInventoryItem | null,
isRightClick: boolean,
) => {
if (typeof (this.worldPanels as any).onTradeGridClick === 'function') {
;(this.worldPanels as any).onTradeGridClick(col, row, hitItem, isRightClick)
} else {
this.forwardGridClick(2, col, row, hitItem ?? null, isRightClick)
}
},
onConfirmQuestReward: (itemId: number, npcId = 0) => {
if (typeof (this.worldPanels as any).onConfirmQuestRewardClick === 'function') {
;(this.worldPanels as any).onConfirmQuestRewardClick(itemId, npcId)
} else {
;(
this._commandSink?.confirmQuestReward as
| ((itemId: number, npcId?: number) => void)
| undefined
)?.(itemId, npcId)
}
this.leftPanel = 'none'
this.leaveLeftPanel(dockKind)
},
inventory: this.inventory,
},
)
if (handled) {
this.syncPublishedState()
return
}
}
this.worldPanels.handleLeftDockClick(dockKind as any, leftX, pt.y, {
onClose: () => {
this.leftPanel = 'none'
this.leaveLeftPanel(dockKind)
},
onStashGoldButton: () => this.openGoldDialog(4),
onVendorBuy: (item, shiftFill) => this.requireSink('vendorBuy').vendorBuy(serverItemIdOf(item), shiftFill),
onVendorSell: (item) => this.requireSink('vendorSell').vendorSell(serverItemIdOf(item)),
onVendorRepairAll: () => this.requireSink('vendorRepairAll').vendorRepairAll(),
onWaypointTeleport: (act, slug, levelId) => {
if (this._commandSink) {
this._commandSink.teleportToLevel(levelId)
} else {
this.callbacks.onWaypointTeleport(act, slug, levelId)
}
},
...(this._commandSink
? {
onTransmute: () => this._commandSink?.transmuteCube(),
onContainerGridClick: (
container: 'stash' | 'cube',
col: number,
row: number,
hitItem: UiInventoryItem | null,
isRightClick: boolean,
) => this.forwardGridClick(container === 'stash' ? 4 : 3, col, row, hitItem, isRightClick),
}
: {}),
onOpenCube: () => this.toggleLeftPanel('cube'),
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
inventory: this.inventory,
isShiftClick: e.shiftKey,
isRightClick: e.button === 2,
})
this.syncPublishedState()
return
}
}
// Right-Half Dock Click
if (pt.x >= 400 + deltaRight) {
const rightX = pt.x - deltaRight
if (this.rightPanel === 'inv') {
if (!this.inventory.isIdentifyMode() && !this._commandSink) {
// Stash quick transfer (Shift+Click, or Right-Click on non-usable items)
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
if (
rightX >= INV_GRID_ORIGIN.x &&
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
pt.y >= INV_GRID_ORIGIN.y &&
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
) {
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
const hit = this.inventory.gridItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (hit && (e.shiftKey || !isUsableRightClickItem(hit.item.code))) {
this.worldPanels.quickTransferToStash(hit.item, this.inventory)
this.syncPublishedState()
return
}
}
}
// Horadric Cube quick transfer (Shift+Click, or Right-Click on non-usable items)
if (this.leftPanel === 'cube' && (e.shiftKey || e.button === 2)) {
if (
rightX >= INV_GRID_ORIGIN.x &&
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
pt.y >= INV_GRID_ORIGIN.y &&
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
) {
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
const hit = this.inventory.gridItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (hit && (e.shiftKey || !isUsableRightClickItem(hit.item.code))) {
this.worldPanels.quickTransferToCube(hit.item, this.inventory)
this.syncPublishedState()
return
}
}
}
}
if (
this.leftPanel === 'vendor' &&
!this.inventory.isIdentifyMode() &&
this.pendingIdentifyScrollId === null &&
this.handleVendorInventoryClick(rightX, pt.y, e.button === 2)
) {
this.syncPublishedState()
return
}
if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) {
this.syncPublishedState()
return
}
this.syncPlayerContext()
this.inventory.handleClick(
rightX,
pt.y,
{
onOpenCube: () => this.toggleLeftPanel('cube'),
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
onGoldButton: () => this.openGoldDialog(this.leftPanel === 'stash' ? 3 : 0),
},
e.button === 2 ? 2 : 0,
{
stashItems: this.worldPanels.stashItems,
cubeItems: this.worldPanels.cubeItems,
},
)
if (!this.inventory.visible) this.rightPanel = 'none'
this.syncPublishedState()
return
} else if (this.rightPanel === 'skill') {
this.skillTree.handleClick(rightX, pt.y, (skillId) => this.handleSkillAllocated(skillId))
if (!this.skillTree.visible) this.rightPanel = 'none'
this.syncPublishedState()
return
}
}
}
/**
* Inventory click while a store is open and the sell / repair cursor is armed (D2Client 0x6fb472d0):
* right-click disarms; sell sends `0x33` for the bag item; repair sends `0x35` for the bag or equipped item.
* Returns `false` in the idle / buy state so the click picks up / places items as usual.
*/
private handleVendorInventoryClick(x: number, y: number, isRightClick: boolean): boolean {
const state = this.worldPanels.vendorTradeState
if (state !== 'sell' && state !== 'repair') return false
if (isRightClick) {
this.worldPanels.vendorTradeState = 'idle'
return true
}
let target: UiInventoryItem | null = null
if (
x >= INV_GRID_ORIGIN.x &&
x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
y >= INV_GRID_ORIGIN.y &&
y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
) {
const col = Math.floor((x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
target =
this.inventory.gridItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)?.item ?? null
} else if (state === 'repair') {
for (const [slot, rect] of Object.entries(EQUIP_SLOTS_LAYOUT) as [EquipSlotId, (typeof EQUIP_SLOTS_LAYOUT)[EquipSlotId]][]) {
if (x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h) {
target = this.inventory.equipped[slot] ?? null
break
}
}
}
if (!target) return true
const sink = this.requireSink(state === 'sell' ? 'vendorSell' : 'vendorRepairItem')
if (state === 'sell') sink.vendorSell(serverItemIdOf(target))
else sink.vendorRepairItem(serverItemIdOf(target))
return true
}
/**
* Forward an inventory-panel click (grid / paperdoll) to the server sink. Returns `false` when the point is not
* on an item area so the panel's own chrome (close / gold buttons / swap tabs) still handles it.
*/
private forwardInventoryPanelClick(x: number, y: number, isRightClick: boolean): boolean {
const sink = this._commandSink
if (!sink) return false
const swapTabs = [
{ ...WEAPON_SWAP_TABS_BOUNDS.left, set: 0 as const },
{ ...WEAPON_SWAP_TABS_BOUNDS.right, set: 1 as const },
]
for (const tab of swapTabs) {
if (x >= tab.x && x <= tab.x + tab.w && y >= tab.y && y <= tab.y + tab.h) {
if (tab.set !== this.inventory.weaponSwapSet) {
sink.swapWeapons()
this.onWeaponSwitch?.()
}
return true
}
}
const g = INV_GRID_ORIGIN
if (x >= g.x && x < g.x + g.cols * g.cellPx && y >= g.y && y < g.y + g.rows * g.cellPx) {
const col = Math.floor((x - g.x) / g.cellPx)
const row = Math.floor((y - g.y) / g.cellPx)
const hit = this.inventory.gridItems.find(
(p) => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
this.forwardGridClick(0, col, row, hit?.item ?? null, isRightClick)
return true
}
for (const slot of Object.keys(EQUIP_SLOTS_LAYOUT) as EquipSlotId[]) {
const r = EQUIP_SLOTS_LAYOUT[slot]
if (x < r.x || x >= r.x + r.w || y < r.y || y >= r.y + r.h) continue
if (isRightClick) {
if (this.pendingIdentifyScrollId !== null) {
this.pendingIdentifyScrollId = null
this.inventory.cancelIdentifyMode()
}
return true
}
const cursor = this.inventory.cursorItem
const equipped = this.inventory.equipped[slot]
if (cursor) {
const cursorId = serverItemIdOf(cursor)
if (equipped) {
if (sink.onSwapEquipItem) sink.onSwapEquipItem(cursorId, slot)
else if (sink.swapEquipItem) sink.swapEquipItem(cursorId, slot)
else sink.equipItem(cursorId, slot)
} else {
sink.equipItem(cursorId, slot)
}
} else if (equipped) {
sink.unequipItem(slot)
}
return true
}
return false
}
/** Forward a container grid click: place/swap/socket/tome the cursor item, identify, use (right-click) or pick up the hit item. */
private forwardGridClick(
buffer: number,
col: number,
row: number,
hitItem: UiInventoryItem | null,
isRightClick: boolean,
): void {
const sink = this._commandSink
if (!sink) return
if (this.pendingIdentifyScrollId !== null) {
const scrollId = this.pendingIdentifyScrollId
this.pendingIdentifyScrollId = null
this.inventory.cancelIdentifyMode()
if (!isRightClick && hitItem && isUiItemUnidentified(hitItem)) {
const targetId = serverItemIdOf(hitItem)
if (sink.onIdentifyItem) sink.onIdentifyItem(targetId, scrollId)
else sink.identifyItem?.(targetId, scrollId)
}
return
}
const cursor = this.inventory.cursorItem
if (cursor) {
const cursorId = serverItemIdOf(cursor)
if (hitItem) {
const targetId = serverItemIdOf(hitItem)
if (isScrollForTome(cursor.code, hitItem.code)) {
if (sink.onScrollToBook) sink.onScrollToBook(cursorId, targetId)
else sink.scrollToBook?.(cursorId, targetId)
return
}
if (isSocketableUiItem(cursor) && (hitItem.sockets ?? 0) > (hitItem.socketedItems?.length ?? 0)) {
if (sink.onSocketItem) sink.onSocketItem(cursorId, targetId)
else sink.socketItem?.(cursorId, targetId)
return
}
if (sink.onSwapContainerItem) {
sink.onSwapContainerItem(cursorId, targetId, col, row, buffer)
} else if (sink.swapContainerItem) {
sink.swapContainerItem(cursorId, targetId, col, row, buffer)
} else {
sink.placeCursorItem(cursorId, col, row, buffer)
}
return
}
sink.placeCursorItem(cursorId, col, row, buffer)
} else if (hitItem && isRightClick) {
if (buffer === 0 && (hitItem.code === 'isc' || hitItem.code === 'ibk')) {
this.pendingIdentifyScrollId = serverItemIdOf(hitItem)
this.inventory.startIdentifyMode(hitItem, 'inventory')
return
}
if (isUsableRightClickItem(hitItem.code)) {
const itemId = serverItemIdOf(hitItem)
if (sink.onUseInventoryItem) sink.onUseInventoryItem(itemId)
else sink.useItem(itemId)
}
} else if (hitItem) {
sink.pickupItemToCursor(serverItemIdOf(hitItem))
}
}
private handleSkillAllocated(skillId: number): void {
// Server authority: request `AddSkillPoint`; the tree and quickbar update from the server skill list.
if (this._commandSink) {
this._commandSink.allocateSkill(skillId)
return
}
if (isPassiveSkill(skillId)) return
const catalogEntry = SKILLS_BY_ID[skillId]
if (!catalogEntry) return
const currentHard = this.skillTree.hardPoints.get(skillId) ?? 1
const mana = calculateManaCost(catalogEntry, currentHard)
const leftUsable = isLeftUsableSkill(skillId)
this.hotkeys.addOrUpdateSkill({
skillId,
name: catalogEntry.name,
nameZh: catalogEntry.nameZh,
level: currentHard,
manaCost: mana,
leftUsable,
rightUsable: true,
isAura: isAuraSkill(skillId),
})
}
private buildControlBarState() {
return {
level: this.charSheet.attrs.level,
xp: this.charSheet.attrs.xp,
currentLevelXp: D2_EXPERIENCE_TABLE_99[this.charSheet.attrs.level] ?? 0,
nextLevelXp: this.charSheet.attrs.nextLevelXp,
stamina: this.stamina,
maxStamina: this.maxStamina,
isRunning: this.isRunning,
unspentStatPoints: this.charSheet.attrs.statPoints,
unspentSkillPoints: this.skillTree.unspentSkillPoints,
}
}
/**
* Synchronizes the `CursorController` `buysell.dc6` override mode from active HUD states
* (`inventory.isIdentifyMode()` or `worldPanels.vendorTradeState`).
*/
syncCursorMode(nowMs: number = this.cursor.lastNowMs): void {
const tradeMode: BuySellCursorMode | null =
this.inventory.isIdentifyMode() || this.pendingIdentifyScrollId !== null
? 'identify'
: this.leftPanel === 'vendor' && this.worldPanels.vendorTradeState !== 'idle'
? this.worldPanels.vendorTradeState
: null
this.cursor.setTradeCursorMode(tradeMode, nowMs)
}
syncPlayerContext(): void {
// Not ready until the server assigns the class; requirement checks keep their neutral context.
if (this.currentClass === null) return
const playerCtx = {
level: this.charSheet.attrs.level,
str: this.charSheet.attrs.str,
dex: this.charSheet.attrs.dex,
classCode: this.currentClass,
}
this.inventory.setPlayerContext(playerCtx)
this.worldPanels.setPlayerContext(playerCtx)
}
syncPublishedState(): void {
this.syncPlayerContext()
this.syncCursorMode(this.cursor.lastNowMs)
const cursorInfo = this.cursor.getCurrentFrame(this.cursor.lastNowMs)
this.state.dc6DecodeFailures = this.dc6DecodeFailures
this.state.decodedDc6Count = this.decodedDc6Count
this.state.assetsLoaded = {
ctrlPnl: this.images.has('ctrlPnl'),
globes: this.images.has('globeLife') && this.images.has('globeMana'),
borders: this.images.has('borderLeft') && this.images.has('borderRight'),
invSheet: this.images.has('invSheet'),
charSheet: this.images.has('charSheet'),
skillTree: true,
font: this.font.loaded,
itemsAtlas: this.images.has('itemsAtlas'),
questsAtlas: this.images.has('questsAtlas'),
escapeMenu:
this.images.has('escOptions') &&
this.images.has('escExit') &&
this.images.has('escReturn') &&
this.images.has('pentSpin'),
}
this.state.leftPanel = this.leftPanel
this.state.rightPanel = this.rightPanel
this.state.beltExpanded = this.belt.isExpanded
this.state.beltPotionsCount = this.belt.countTotalPotions()
this.state.miniPanelOpen = this.controlBar.miniPanelOpen
this.state.isRunning = this.isRunning
this.state.showGroundLabels = this.showGroundLabels
this.state.cursorState = this.cursor.state
this.state.cursorTradeMode = this.cursor.tradeCursorMode
this.state.cursorAnimation = cursorInfo.animation
this.state.cursorFrameIndex = cursorInfo.frameIndex
this.state.cursorItem = this.inventory.cursorItem?.id ?? null
this.state.equippedSlots = Object.keys(this.inventory.equipped) as EquipSlotId[]
this.state.bagItemsCount = this.inventory.gridItems.length
this.state.weaponSwapSet = this.inventory.weaponSwapSet
this.state.leftSkillId = this.hotkeys.leftSkillId
this.state.rightSkillId = this.hotkeys.rightSkillId
this.state.characterLevel = this.charSheet.attrs.level
this.state.statPoints = this.charSheet.attrs.statPoints
this.state.skillPoints = this.skillTree.unspentSkillPoints
this.state.hp = Math.round(this.hp)
this.state.maxHp = Math.round(this.maxHp)
this.state.mana = Math.round(this.mana)
this.state.maxMana = Math.round(this.maxMana)
this.state.stamina = Math.round(this.stamina)
this.state.maxStamina = Math.round(this.maxStamina)
this.state.docking = this.getDockingLayout()
this.state.viewportProfile = this.viewportProfile
this.state.activeVendor = this.worldPanels.activeVendorDescriptor?.displayName ?? null
this.state.vendorMode = this.worldPanels.vendorMode
this.state.vendorTradeState = this.worldPanels.vendorTradeState
this.state.vendorTab = this.worldPanels.activeVendorTab
this.state.vendorItemsCount = this.worldPanels.getActiveVendorPlacements().length
this.state.npcMenuOpen = this.worldPanels.npcMenu !== null
this.state.escapeMenuOpen = this.worldPanels.escapeMenuOpen
this.state.escapeMenuSelectedIndex = this.worldPanels.escapeMenuSelectedIndex
}
private ensureCanvas2DContext(rawCtx: CanvasRenderingContext2D): CanvasRenderingContext2D {
const noop = () => {}
const methods = [
'save',
'restore',
'clearRect',
'fillRect',
'strokeRect',
'fillText',
'strokeText',
'scale',
'translate',
'rotate',
'transform',
'setTransform',
'resetTransform',
'beginPath',
'closePath',
'moveTo',
'lineTo',
'rect',
'arc',
'fill',
'stroke',
'clip',
'drawImage',
'setLineDash',
] as const
const target = rawCtx as unknown as Record<string, unknown>
for (const m of methods) {
if (typeof target[m] !== 'function') {
target[m] = noop
}
}
if (typeof target.measureText !== 'function') {
target.measureText = (text: string) => ({ width: String(text ?? '').length * 8 })
}
if (typeof target.createLinearGradient !== 'function') {
target.createLinearGradient = () => ({ addColorStop: noop })
}
if (typeof target.createRadialGradient !== 'function') {
target.createRadialGradient = () => ({ addColorStop: noop })
}
return rawCtx
}
/**
* Tick stamina drain/recovery and draw the complete Diablo II HUD for the active `ViewportProfile`.
*/
render(
nowMs: number = performance.now(),
playerMoving: boolean = false,
): void {
this.syncPlayerContext()
// Stamina is server state (stat 10 / 0x95); no client-side drain/regen simulation.
void playerMoving
const profile = this.viewportProfile
const hasWindow = typeof window !== 'undefined'
const dpr = Math.min(hasWindow && window.devicePixelRatio ? window.devicePixelRatio : 1, 2)
const winW = hasWindow && window.innerWidth ? window.innerWidth : profile.width
const winH = hasWindow && window.innerHeight ? window.innerHeight : profile.height
const cssW = Math.max(320, this.hudCanvas.clientWidth || winW)
const cssH = Math.max(240, this.hudCanvas.clientHeight || winH)
const targetW = Math.round(cssW * dpr)
const targetH = Math.round(cssH * dpr)
if (this.hudCanvas.width !== targetW || this.hudCanvas.height !== targetH) {
this.hudCanvas.width = targetW
this.hudCanvas.height = targetH
}
const docking = computeDockingLayout(cssW, cssH, dpr, profile)
const { uiScale, offsetX, offsetY } = docking
// Panels are authored in native 800x600 coordinates (left at x = 0, right at x = 400); shift them onto their docks.
const deltaLeft = docking.leftDockX
const deltaRight = docking.rightDockX - 400
const panelOffsetY = profile.panelOffsetY
const barOffsetX = profile.barOffsetX
const barOffsetY = profile.barOffsetY
// Enable pointer events on HUD canvas only when hovering HUD/Panels, holding cursorItem, or in active trade/identify cursor mode
const intercept =
this.isPointInterceptedByHud(this.mouseX, this.mouseY) ||
this.inventory.cursorItem !== null ||
this.worldPanels.vendorTradeState !== 'idle' ||
this.inventory.isIdentifyMode() ||
this.pendingIdentifyScrollId !== null
if (this.hudCanvas.style) {
this.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
}
const rawCtx = this.ctx ?? this.hudCanvas.getContext('2d')
if (!rawCtx) return
const ctx = this.ensureCanvas2DContext(rawCtx)
ctx.imageSmoothingEnabled = false
ctx.save()
ctx.imageSmoothingEnabled = false
ctx.clearRect(0, 0, targetW, targetH)
// 0. Automap 2D Text Overlay (Warp/Stash Font6 labels, NPC names, Top-Right Gold Font16 Level Name + Difficulty)
// Drawn in full-viewport CSS pixel space (0..cssW, 0..cssH) to align 1:1 with the WebGL automap canvas
ctx.save()
ctx.scale(dpr, dpr)
this.callbacks.onRenderAutomapOverlay?.(ctx, this.font, cssW, cssH)
ctx.restore()
ctx.translate(Math.round(offsetX * dpr), Math.round(offsetY * dpr))
ctx.scale(uiScale * dpr, uiScale * dpr)
// 0b. Top-Left Mercenary Portrait & Life Bar (R3.3) + Party Mini-Portraits (R5.1)
if (this.leftPanel === 'none') {
if (this.merc && !this.merc.isDead) {
const hpRatio = Math.max(0, Math.min(1, Number(this.merc.hp ?? 0) / Math.max(1, Number(this.merc.maxHp ?? 1))))
ctx.fillStyle = 'rgba(16, 16, 20, 0.85)'
ctx.fillRect(8, 8, 48, 48)
ctx.strokeStyle = '#8b7344'
ctx.strokeRect(8.5, 8.5, 47, 47)
ctx.fillStyle = '#181818'
ctx.fillRect(8, 58, 48, 6)
ctx.fillStyle = hpRatio > 0.5 ? '#20a020' : hpRatio > 0.25 ? '#d0a020' : '#c02020'
ctx.fillRect(8, 58, Math.round(48 * hpRatio), 6)
const mercLabel = String(this.merc.name || 'Merc')
ctx.fillStyle = '#d4b468'
ctx.fillText(mercLabel, 32, 22)
this.font.drawText(ctx, mercLabel, 32, 22, {
font: 'font6',
color: 'gold',
align: 'center',
})
}
if (this.partyMembers.length > 0) {
for (let idx = 0; idx < this.partyMembers.length; idx++) {
const member = this.partyMembers[idx]
if (!member) continue
const py = 72 + idx * 52
const lifeRatio = Math.max(0, Math.min(1, Number(member.lifePct ?? 100) / 100))
ctx.fillStyle = 'rgba(16, 16, 20, 0.82)'
ctx.fillRect(8, py, 44, 44)
ctx.strokeStyle = member.hostile ? '#c02020' : '#7a6840'
ctx.strokeRect(8.5, py + 0.5, 43, 43)
ctx.fillStyle = '#181818'
ctx.fillRect(8, py + 45, 44, 5)
ctx.fillStyle = '#20a020'
ctx.fillRect(8, py + 45, Math.round(44 * lifeRatio), 5)
const memberName = String(member.name || `P${member.unitId ?? idx + 1}`)
ctx.fillStyle = '#ffffff'
ctx.fillText(memberName, 30, py + 18)
this.font.drawText(ctx, memberName, 30, py + 18, {
font: 'font6',
color: 'white',
align: 'center',
})
}
}
}
// 0c. Ground Item Labels (`D2Client.dll` Alt / hover item label plaques)
if (this.groundItemLabelBoxes.length > 0) {
ctx.save()
for (const box of this.groundItemLabelBoxes) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.78)'
ctx.fillRect(box.x, box.y, box.width, box.height)
ctx.strokeStyle = 'rgba(96, 84, 56, 0.75)'
ctx.strokeRect(
box.x + 0.5,
box.y + 0.5,
Math.max(0, box.width - 1),
Math.max(0, box.height - 1),
)
const d2Color = groundLabelHexToD2Color(box.color)
const textCenterX = Math.round(box.x + box.width / 2)
const textBaselineY = Math.round(box.y + box.height - 4)
this.font.drawText(ctx, box.text, textCenterX, textBaselineY, {
font: 'font16',
color: d2Color,
align: 'center',
})
}
ctx.restore()
}
// 1. Left-Half Dock Panel (`0..400, panelOffsetY..panelOffsetY+553`)
if (this.leftPanel === 'char') {
ctx.save()
ctx.translate(deltaLeft, panelOffsetY)
this.charSheet.draw(
ctx,
{
hp: this.hp,
maxHp: this.maxHp,
mana: this.mana,
maxMana: this.maxMana,
stamina: this.stamina,
maxStamina: this.maxStamina,
// Skill damage is not shown until it is computed from Skills.txt / missiles via the 1.13c skill calc
// (no fabricated per-level numbers).
leftSkillDmg: '',
rightSkillDmg: '',
},
{
borderLeftImg: this.images.get('borderLeft') ?? null,
charSheetImg: this.images.get('charSheet') ?? null,
buySellBtnImg: this.images.get('buySellBtn') ?? null,
levelBtnImg: this.images.get('levelBtn') ?? null,
},
this.font,
)
ctx.restore()
} else if (this.leftPanel !== 'none') {
ctx.save()
ctx.translate(deltaLeft, panelOffsetY)
if (
this.leftPanel === 'quest' ||
this.leftPanel === 'waypoint' ||
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'vendor'
) {
this.worldPanels.drawLeftDockPanel(
ctx,
this.leftPanel,
{
borderLeftImg: this.images.get('borderLeft') ?? null,
questBgImg: this.images.get('questBg') ?? null,
waypointBgImg: this.images.get('waypointBg') ?? null,
stashBgImg: this.images.get('stashBg') ?? null,
cubeBgImg: this.images.get('cubeBg') ?? null,
vendorBgImg: this.images.get('vendorBg') ?? null,
vendorTabsImg: this.images.get('vendorTabs') ?? null,
buySellBtnImg: this.images.get('buySellBtn') ?? null,
miniConvertImg: this.images.get('miniConvert') ?? null,
questsAtlasImg: this.images.get('questsAtlas') ?? null,
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
waypointTabsImg: this.images.get('waypointTabs') ?? null,
waypointIconsImg: this.images.get('waypointIcons') ?? null,
questTabsImg: this.images.get('questTabs') ?? null,
inventory: this.inventory,
},
this.font,
)
} else if (typeof (this.worldPanels as any).renderActiveLeftPanel === 'function') {
;(this.worldPanels as any).renderActiveLeftPanel(ctx, this.leftPanel, this.font)
} else {
this.worldPanels.drawLeftDockPanel(
ctx,
this.leftPanel as any,
{
borderLeftImg: this.images.get('borderLeft') ?? null,
questBgImg: this.images.get('questBg') ?? null,
waypointBgImg: this.images.get('waypointBg') ?? null,
stashBgImg: this.images.get('stashBg') ?? null,
cubeBgImg: this.images.get('cubeBg') ?? null,
vendorBgImg: this.images.get('vendorBg') ?? null,
vendorTabsImg: this.images.get('vendorTabs') ?? null,
buySellBtnImg: this.images.get('buySellBtn') ?? null,
miniConvertImg: this.images.get('miniConvert') ?? null,
questsAtlasImg: this.images.get('questsAtlas') ?? null,
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
waypointTabsImg: this.images.get('waypointTabs') ?? null,
waypointIconsImg: this.images.get('waypointIcons') ?? null,
questTabsImg: this.images.get('questTabs') ?? null,
inventory: this.inventory,
},
this.font,
)
}
ctx.restore()
}
// 2. Right-Half Dock Panel (`400+deltaRight..800+deltaRight, panelOffsetY..panelOffsetY+553`)
if (this.rightPanel === 'inv') {
ctx.save()
ctx.translate(deltaRight, panelOffsetY)
this.inventory.draw(
ctx,
{
borderRightImg: this.images.get('borderRight') ?? null,
invSheetImg: this.images.get('invSheet') ?? null,
invTab0Img: this.images.get('invTab0') ?? null,
invTab1Img: this.images.get('invTab1') ?? null,
buySellBtnImg: this.images.get('buySellBtn') ?? null,
goldCoinBtnImg: this.images.get('goldCoinBtn') ?? null,
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
itemRects: this.itemRects,
},
this.font,
)
ctx.restore()
} else if (this.rightPanel === 'skill') {
ctx.save()
ctx.translate(deltaRight, panelOffsetY)
this.skillTree.draw(
ctx,
{
borderRightImg: this.images.get('borderRight') ?? null,
skillPointsImg: this.images.get('skillPoints') ?? null,
skillTabsImg: this.images.get('skillTabs') ?? null,
buySellBtnImg: this.images.get('buySellBtn') ?? null,
},
this.font,
)
ctx.restore()
}
// 3..6. Bottom 800px Control Panel, Globes, Skill Hotkeys, and Belt (translated by barOffsetX, barOffsetY)
ctx.save()
ctx.translate(barOffsetX, barOffsetY)
this.controlBar.draw(
ctx,
this.buildControlBarState(),
{
ctrlPnlImg: this.images.get('ctrlPnl') ?? null,
runBtnImg: this.images.get('runBtn') ?? null,
menuBtnImg: this.images.get('menuBtn') ?? null,
miniPanelImg: this.images.get('miniPanel') ?? null,
miniPanelBtnsImg: this.images.get('miniPanelBtns') ?? null,
levelBtnImg: this.images.get('levelBtn') ?? null,
levelSocketImg: this.images.get('levelSocket') ?? null,
},
this.font,
)
// 4. Life & Mana Globes (`Hlthmana.dc6` + `overlap.dc6`)
this.globes.draw(
ctx,
{
hp: this.hp,
maxHp: this.maxHp,
mana: this.mana,
maxMana: this.maxMana,
},
{
lifeImg: this.images.get('globeLife') ?? null,
manaImg: this.images.get('globeMana') ?? null,
poisonImg: this.images.get('globePoison') ?? null,
overlapLeftImg: this.images.get('overlapLeft') ?? null,
overlapRightImg: this.images.get('overlapRight') ?? null,
},
this.font,
)
// 5. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`)
this.hotkeys.draw(
ctx,
this.images.get('genericSkills') ?? null,
this.font,
Boolean(this.inventory.equipped.weapon1),
)
// 6. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`)
this.belt.draw(
ctx,
{
popbeltImg: this.images.get('popbelt') ?? null,
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
itemRects: this.itemRects,
},
this.font,
)
ctx.restore()
// 7. Top-Center Monster Target Health Bar & Plaque (`D2Client.dll` `0x6FB70850`)
this.worldPanels.drawMonsterTargetBar(ctx, this.targetMonster, this.font, {
leftPanelOpen: this.leftPanel !== 'none',
rightPanelOpen: this.rightPanel !== 'none',
canvasWidth: profile.width,
})
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp` + `dialog.cpp` + `boxpieces.dc6`)
if (this.worldPanels.npcMenu) {
this.worldPanels.drawNpcMenu(ctx, this.font, {
boxPiecesImg: this.images.get('boxPieces') ?? null,
focus16Img: this.images.get('focus16') ?? null,
})
}
// 9. Area Entry Gothic Banner (`Entering: <Level Name>`, only when no split panels are open)
if (this.leftPanel === 'none' && this.rightPanel === 'none' && !this.worldPanels.npcMenu && !this.worldPanels.escapeMenuOpen) {
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font)
}
// 10. Hover Item Tooltip (Inventory / Equipment / Stash / Cube / Vendor)
if (
(this.rightPanel === 'inv' ||
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'vendor') &&
this.inventory.hoveredItem &&
!this.inventory.cursorItem
) {
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font, this.leftPanel === 'vendor')
}
// 10b. Gold dialog (drop / deposit / withdraw)
this.drawGoldDialog(ctx)
// 10c. Authentic In-Game Escape Menu (`D2Client.dll` `gmenu.cpp` `0x6fb15230`)
if (this.worldPanels.escapeMenuOpen) {
this.worldPanels.drawEscapeMenu(ctx, profile.width, profile.height, nowMs, {
escOptionsImg: this.images.get('escOptions') ?? null,
escExitImg: this.images.get('escExit') ?? null,
escReturnImg: this.images.get('escReturn') ?? null,
pentSpinImg: this.images.get('pentSpin') ?? null,
})
}
// 10d. Death Overlay (`You Have Died. Press ESC to continue.` — R4.4)
if (this.isDead || Boolean((this.worldPanels as any).isDead)) {
ctx.save()
ctx.fillStyle = 'rgba(80, 0, 0, 0.45)'
ctx.fillRect(0, 0, 800, 600)
ctx.fillStyle = '#d4b468'
ctx.font = 'bold 22px serif'
ctx.textAlign = 'center'
const deathMsg = 'You Have Died. Press ESC to continue. 你挂了,请按 ESC 键继续'
ctx.fillText(deathMsg, 400, 300)
this.font.drawText(ctx, deathMsg, 400, 300, {
font: 'font16',
color: 'gold',
align: 'center',
})
ctx.restore()
}
// 11. Authentic Hand / Trade Cursor & Held Item at Hotspot
this.renderCursor(ctx, nowMs)
ctx.restore()
this.syncPublishedState()
}
/**
* Renders the authentic animated hand cursor, `buysell.dc6` trade/repair/identify cursor, or held cursorItem at cursor hotspot.
*/
renderCursor(ctx: CanvasRenderingContext2D, nowMs: number = performance.now()): void {
this.syncCursorMode(nowMs)
const cursorAtlas = this.images.get('cursorAtlas') ?? this.images.get('cursorHand') ?? null
const heldItem = this.inventory.cursorItem
? {
atlas: this.images.get('itemsAtlas') ?? null,
rect: resolveItemSpriteRect(this.inventory.cursorItem, this.itemRects) ?? null,
}
: null
this.cursor.render(
ctx,
cursorAtlas,
nowMs,
heldItem && heldItem.atlas && heldItem.rect
? { atlas: heldItem.atlas, rect: heldItem.rect }
: null,
)
}
}