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

1974 lines
71 KiB
TypeScript

/**
* 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 } from './font.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,
isUsableRightClickItem,
} 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 {
WorldPanelsHud,
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,
VIEWPORT_800x600,
type ViewportAnchorMetrics,
type ViewportProfile,
} from '../view/viewport-profile.ts'
import type { ViewportResolution } from '../settings/client-settings.ts'
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
}
leftPanel: LeftDockPanelKind
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
}
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 canonical Diablo II HUD viewport (`800x600` or `1024x768`) 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,
} from './world-panels.ts'
/**
* Computes widescreen panel docking layout metrics, margins, and central vision corridor.
*/
export function computeDockingLayout(
cssW: number,
cssH: number,
dpr: number = 1,
profile: ViewportProfile = VIEWPORT_800x600,
): DockingLayout {
if (profile.width === 1024) {
const layout = computeHudLayout(cssW, cssH, dpr, profile)
const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0
return {
...layout,
marginW,
leftDockX: 0,
rightDockX: 624,
channelLeft: 400,
channelRight: 624,
channelWidthLogical: 224,
channelWidthCss: 224 * 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): 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
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
}
/** 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
}
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: LeftDockPanelKind = 'none'
rightPanel: 'none' | 'inv' | 'skill' = 'none'
isRunning = true
showGroundLabels = false
/** 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
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
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
}
private readonly hudCanvas: HTMLCanvasElement
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
}
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
},
) {
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.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'
}
}
/** 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): boolean {
return this.drinkBeltSlot(col)
}
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
}
resize(width: number, height?: number): ViewportProfile {
const isWidescreen = width >= 1024 || (height !== undefined && height >= 768)
return this.setViewportProfile(isWidescreen ? '1024x768' : '800x600')
}
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`,
itemsAtlas: `${baseUrl}/items-atlas.png`,
questsAtlas: `${baseUrl}/quests-atlas.png`,
}
await Promise.all(
Object.entries(imageFiles).map(
([key, src]) =>
new Promise<void>((resolve) => {
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: LeftDockPanelKind): 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()
}
}
/** 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`).
* Strictly follows D2Client.dll mutual exclusion on the Left Dock (`0..400`).
*/
toggleLeftPanel(kind: Exclude<LeftDockPanelKind, 'none'>): void {
const prev = this.leftPanel
if (prev === kind) {
this.leftPanel = '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
if (prev !== 'none') this.leaveLeftPanel(prev)
this.charSheet.visible = kind === 'char'
if ((kind === 'stash' || kind === 'vendor') && 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()
}
closeAllPanels(): boolean {
const anyOpen =
this.leftPanel !== 'none' ||
this.rightPanel !== 'none' ||
this.belt.lockedOpen ||
this.hotkeys.openPopup !== null ||
this.worldPanels.npcMenu !== null ||
this.goldDialog !== null ||
this.inventory.isIdentifyMode()
const prevLeft = this.leftPanel
this.goldDialog = 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.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 (`800x600` or `1024x768`),
* 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.goldDialog) return true
const profile = this.viewportProfile
const docking = this.getDockingLayout()
const { marginW } = docking
if (profile.width === 1024) {
if (logicalX < -marginW || logicalX > 1024 + marginW || logicalY < 0 || logicalY > 768) {
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
}
if (this.rightPanel !== 'none' && logicalX >= 624 && logicalX <= 1024 && 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): boolean {
// Server authority: request `0x26 BeltUse`; healing and belt contents come back from the server.
if (this._commandSink) return this._commandSink.useBeltSlot(col)
return false
}
private bindEvents(): void {
window.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) 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
}
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 === '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 {
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)
} 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()
}
}
})
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.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
})
window.addEventListener('mousedown', (e) => {
this.cursor.handleMouseDown()
if (e.target !== this.hudCanvas && 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.goldDialog) {
e.preventDefault()
e.stopPropagation()
if (e.button === 0) this.handleGoldDialogClick(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()) {
e.preventDefault()
e.stopPropagation()
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
}
// Right-click on belt slot drinks potion
const beltHit = this.belt.slotAtPoint(pt.x, pt.y)
if (beltHit) {
this.drinkBeltSlot(beltHit.col)
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
this.worldPanels.handleLeftDockClick(dockKind, 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.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 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
const cursor = this.inventory.cursorItem
if (cursor) {
sink.equipItem(serverItemIdOf(cursor), slot)
} else if (this.inventory.equipped[slot]) {
sink.unequipItem(slot)
}
return true
}
return false
}
/** Forward a container grid click: place the cursor item, 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
const cursor = this.inventory.cursorItem
if (cursor) {
sink.placeCursorItem(serverItemIdOf(cursor), col, row, buffer)
} else if (hitItem && isRightClick) {
if (isUsableRightClickItem(hitItem.code)) sink.useItem(serverItemIdOf(hitItem))
} 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()
? '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'),
}
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
}
/**
* Tick stamina drain/recovery and draw the complete Diablo II HUD (`800x600` or `1024x768`).
*/
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 dpr = Math.min(window.devicePixelRatio || 1, 2)
const cssW = Math.max(320, this.hudCanvas.clientWidth || window.innerWidth)
const cssH = Math.max(240, this.hudCanvas.clientHeight || window.innerHeight)
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, marginW } = docking
const deltaLeft = profile.width === 1024 ? 0 : -marginW
const deltaRight = profile.width === 1024 ? 224 : marginW
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.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
const ctx = this.hudCanvas.getContext('2d')
if (!ctx) return
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)
// 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)
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,
)
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.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)
// 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,
)
}
}