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

1423 lines
52 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.
* - Publishes `window.__d2webHud` for automated headless browser verification (Issue #27).
*/
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 '../data/skills-catalog.ts'
import { calculateManaCost } from '../game/skill-calc-engine.ts'
import {
InventoryPanel,
type EquipSlotId,
type UiInventoryItem,
resolveItemSpriteRect,
INV_GRID_ORIGIN,
isUsableRightClickItem,
} from './inventory.ts'
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
import { WorldPanelsHud, type LeftDockPanelKind, type VendorTradeState } from './world-panels.ts'
import {
CursorController,
type BuySellCursorMode,
type CursorAnimation,
type CursorState,
} from './cursor.ts'
import type { CharacterClassCode } from '../game/classes.ts'
import {
createStarterProfileForClass,
type ClassProfileState,
} from '../game/class-starter-profiles.ts'
import type { TownNpcServiceDescriptor, VendorNpcId } from '../game/npc-table.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
activeVendor?: string | null
vendorMode?: 'trade' | 'gamble'
vendorTradeState?: VendorTradeState
vendorTab?: string
vendorItemsCount?: number
npcMenuOpen?: boolean
}
declare global {
interface Window {
__d2webHud?: PublishedHudState
__d2webHudInstance?: HudManager
}
}
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
}
/**
* Computes 1:1 uniform aspect ratio scaling and horizontal centering margins
* for the canonical 800x600 Diablo II HUD viewport onto an arbitrary window/canvas size.
*/
export function computeHudLayout(cssW: number, cssH: number, dpr: number = 1): 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 / 800, safeH / 600)
const offsetX = Math.max(0, (safeW - 800 * uiScale) / 2)
const offsetY = Math.max(0, safeH - 600 * 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, 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): DockingLayout {
const layout = computeHudLayout(cssW, cssH, dpr)
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,
}
}
export function clientToLogical(
clientX: number,
clientY: number,
rect: { left: number; top: number; width: number; height: number },
dpr: number = 1,
): { 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)
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,
): { 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)
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,
}
}
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
stamina = 465
maxStamina = 465
hp = 1180
maxHp = 1180
mana = 940
maxMana = 940
mouseX = 400
mouseY = 300
/** Optional target monster on top plaque. */
targetMonster: {
name: string
hp: number
maxHp: number
rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'boss'
affixes?: readonly string[]
subType?: string
} | 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: 16,
miniPanelOpen: true,
isRunning: true,
showGroundLabels: false,
cursorItem: null,
equippedSlots: [],
bagItemsCount: 0,
weaponSwapSet: 0,
leftSkillId: 47,
rightSkillId: 64,
characterLevel: 85,
statPoints: 15,
skillPoints: 12,
hp: 1180,
maxHp: 1180,
mana: 940,
maxMana: 940,
stamina: 465,
maxStamina: 465,
docking: computeDockingLayout(800, 600, 1),
vendorTradeState: 'idle',
}
currentClass: CharacterClassCode | null = null
private classProfiles = new Map<CharacterClassCode, ClassProfileState>()
constructor(
private readonly hudCanvas: HTMLCanvasElement,
private readonly callbacks: {
onToggleAutomap: () => void
onWaypointTeleport: (act: number, slug: string, levelId?: 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.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId)
if (this.hudCanvas) {
this.hudCanvas.style.cursor = 'none'
}
if (typeof window !== 'undefined') {
window.__d2webHud = this.state
window.__d2webHudInstance = this
}
}
/**
* Switches active character class with complete isolation and persistence across
* inventory, exclusive signature sets on body ONLY, stash treasures (runes/gems/jewels/gold),
* attributes, 30-skill trees, and hotkeys (Parity with Issue #379).
*/
switchClass(classCode: CharacterClassCode): void {
// 1. Snapshot current class state if currentClass is already initialized
if (this.currentClass && this.classProfiles.has(this.currentClass)) {
const currentProfile: ClassProfileState = {
classCode: this.currentClass,
equipped: { ...this.inventory.equipped },
bagItems: this.inventory.gridItems.map(gp => ({ ...gp, item: { ...gp.item } })),
stashItems: this.worldPanels.stashItems.map(gp => ({ ...gp, item: { ...gp.item } })),
stashGold: this.worldPanels.stashGold,
attrs: { ...this.charSheet.attrs },
hardPoints: new Map(this.skillTree.hardPoints),
unspentSkillPoints: this.skillTree.unspentSkillPoints,
activeSkillTab: this.skillTree.activeTab,
defaultLeftSkill: this.hotkeys.leftSkillId,
defaultRightSkill: this.hotkeys.rightSkillId,
availableSkills: [...this.hotkeys.availableSkills],
hp: this.hp,
mana: this.mana,
}
this.classProfiles.set(this.currentClass, currentProfile)
}
// 2. Fetch target profile or create fresh starter profile
let targetProfile = this.classProfiles.get(classCode)
if (!targetProfile) {
targetProfile = createStarterProfileForClass(classCode)
this.classProfiles.set(classCode, targetProfile)
}
// 3. Apply profile to current systems
this.currentClass = classCode
// 3.1 Inventory & Equipment: Body has ONLY exclusive set items
for (const key of Object.keys(this.inventory.equipped) as EquipSlotId[]) {
delete this.inventory.equipped[key]
}
Object.assign(this.inventory.equipped, targetProfile.equipped)
this.inventory.gridItems = targetProfile.bagItems.map(gp => ({ ...gp, item: { ...gp.item } }))
// 3.2 Stash: gems, runes, jewels, and gold
this.worldPanels.stashItems = targetProfile.stashItems.map(gp => ({ ...gp, item: { ...gp.item } }))
this.worldPanels.stashGold = targetProfile.stashGold
// 3.3 Character Sheet
this.charSheet.attrs = { ...targetProfile.attrs }
// 3.4 Skill Tree
this.skillTree.loadProfile(
targetProfile.classCode,
targetProfile.hardPoints,
targetProfile.unspentSkillPoints,
targetProfile.activeSkillTab,
)
// 3.5 Hotkeys
this.hotkeys.setAvailableSkills(
targetProfile.availableSkills,
targetProfile.defaultLeftSkill,
targetProfile.defaultRightSkill,
)
this.hotkeys.hasEquippedWeapon = Boolean(this.inventory.equipped.weapon1 || this.inventory.equipped.weapon2)
// 3.6 Published vitals
this.maxHp = targetProfile.attrs.baseMaxHp
this.hp = Math.min(targetProfile.hp, this.maxHp)
this.maxMana = targetProfile.attrs.baseMaxMana
this.mana = Math.min(targetProfile.mana, this.maxMana)
this.stamina = targetProfile.attrs.baseMaxStamina
this.maxStamina = targetProfile.attrs.baseMaxStamina
this.syncPublishedState()
}
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`,
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
}
/**
* Opens a Town NPC Vendor or Gamble session on the Left Dock and ensures Player Inventory is open on the Right Dock.
*/
openVendorSession(
descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string,
mode: 'trade' | 'gamble' = 'trade',
difficulty: 0 | 1 | 2 = 0,
): boolean {
this.worldPanels.activePlayerInventory = this.inventory
this.worldPanels.setPlayerContext({
level: this.charSheet.attrs.level,
str: this.charSheet.attrs.str,
dex: this.charSheet.attrs.dex,
classCode: this.currentClass ?? 'sor',
})
const ok = this.worldPanels.openVendorForNpc(
descriptorOrName,
mode,
this.charSheet.attrs.level,
difficulty,
)
if (!ok) return false
this.worldPanels.npcMenu = null
this.leftPanel = 'vendor'
this.charSheet.visible = false
if (this.rightPanel !== 'inv') {
this.toggleRightPanel('inv')
}
this.syncPublishedState()
return true
}
/**
* 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 {
if (this.leftPanel === kind) {
this.leftPanel = 'none'
this.charSheet.visible = false
this.worldPanels.vendorTradeState = 'idle'
this.worldPanels.releaseMomentaryVendorButton()
} else {
if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) {
throw new Error('Cannot open vendor panel without an active vendor session')
}
this.leftPanel = kind
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.inventory.isIdentifyMode()
this.leftPanel = 'none'
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
}
/**
* Convert client CSS pixel coordinates into the 800x600 Diablo II logical viewport,
* 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())
}
/**
* Convert 800x600 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())
}
/**
* 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 : 800
const winH = hasWindow && window.innerHeight ? window.innerHeight : 600
const cssW = Math.max(320, canvasW || winW)
const cssH = Math.max(240, canvasH || winH)
return computeDockingLayout(cssW, cssH, dpr)
}
/**
* 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 > 800 + marginW / 600) fall outside the canvas
* and must return false so scene navigation is not blocked.
* Central vision corridor (400 - marginW < logicalX < 400 + marginW) remains open and returns false.
*/
isPointInterceptedByHud(logicalX: number, logicalY: number): boolean {
const docking = this.getDockingLayout()
const { marginW } = docking
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 {
const consumed = this.belt.useSlot(col, 0)
if (!consumed) return false
this.hp = Math.min(this.maxHp, this.hp + consumed.healHp)
this.mana = Math.min(this.maxMana, this.mana + consumed.healMana)
this.syncPublishedState()
return true
}
private bindEvents(): void {
window.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) 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()
this.toggleLeftPanel('waypoint')
} else if (key === 'W') {
e.preventDefault()
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.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
this.worldPanels.npcMenu = null
this.syncPublishedState()
}
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
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) {
this.worldPanels.handleNpcMenuClick(
pt.x,
pt.y,
{
onOpenVendor: (desc, mode) => {
this.openVendorSession(desc, mode)
},
onIdentifyAll: (desc) => {
const count = this.worldPanels.identifyAllUiItems(this.inventory)
this.worldPanels.showAreaBanner(
count > 0 ? `已鉴定 ${count} 件物品` : '背包中没有未鉴定物品',
desc.displayName,
)
},
},
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) => {
if (key === 'vit') {
this.maxHp = this.charSheet.attrs.baseMaxHp
this.hp = this.maxHp
this.maxStamina = this.charSheet.attrs.baseMaxStamina
this.stamina = this.maxStamina
} else if (key === 'ene') {
this.maxMana = this.charSheet.attrs.baseMaxMana
this.mana = this.maxMana
}
})
if (!this.charSheet.visible) this.leftPanel = 'none'
this.syncPublishedState()
return
} else if (this.leftPanel !== 'none') {
this.worldPanels.handleLeftDockClick(this.leftPanel, leftX, pt.y, {
onClose: () => {
this.leftPanel = 'none'
},
onWaypointTeleport: (act, slug, levelId) => {
this.callbacks.onWaypointTeleport(act, slug, levelId)
},
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()) {
// 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
}
}
}
// Vendor Repair or Sell/Quick-Sell from Player Inventory
if (this.leftPanel === 'vendor') {
// D2Client.dll 0x6fb472d0: Right-clicking while in Buy/Sell/Repair cursor mode cancels back to idle
if (e.button === 2 && this.worldPanels.vendorTradeState !== 'idle') {
this.worldPanels.vendorTradeState = 'idle'
this.syncPublishedState()
return
}
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 hitIdx = this.inventory.gridItems.findIndex(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (hitIdx !== -1) {
const hit = this.inventory.gridItems[hitIdx]!
if (this.worldPanels.vendorTradeState === 'repair') {
this.worldPanels.repairSingleUiItem(hit.item, this.inventory)
this.syncPublishedState()
return
}
if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) {
const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory)
if (res.ok) {
this.inventory.gridItems.splice(hitIdx, 1)
this.inventory.hoveredItem = null
}
this.syncPublishedState()
return
}
}
} else if (this.worldPanels.vendorTradeState === 'repair' && this.inventory.hoveredItem) {
this.worldPanels.repairSingleUiItem(this.inventory.hoveredItem.item, this.inventory)
this.syncPublishedState()
return
}
}
}
this.inventory.handleClick(
rightX,
pt.y,
{
onOpenCube: () => this.toggleLeftPanel('cube'),
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
onDropGold: (amt) => {
const dropped = this.inventory.dropGold(amt)
if (dropped > 0) {
this.callbacks.onDropGoldToGround?.(dropped, pt)
}
},
},
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
}
}
}
private handleSkillAllocated(skillId: number): void {
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),
})
const profile = this.currentClass ? this.classProfiles.get(this.currentClass) : undefined
if (profile) {
profile.availableSkills = [...this.hotkeys.availableSkills]
profile.hardPoints = new Map(this.skillTree.hardPoints)
profile.unspentSkillPoints = this.skillTree.unspentSkillPoints
}
}
private buildControlBarState() {
return {
level: this.charSheet.attrs.level,
xp: this.charSheet.attrs.xp,
currentLevelXp: 1_000_000_000,
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)
}
syncPublishedState(): void {
this.worldPanels.setPlayerContext({
level: this.charSheet.attrs.level,
str: this.charSheet.attrs.str,
dex: this.charSheet.attrs.dex,
classCode: this.currentClass ?? 'sor',
})
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.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 800x600 Diablo II HUD.
*/
render(
nowMs: number = performance.now(),
playerMoving: boolean = false,
): void {
if (playerMoving && this.isRunning) {
this.stamina = Math.max(0, this.stamina - 0.12)
if (this.stamina <= 0) this.isRunning = false
} else {
this.stamina = Math.min(this.maxStamina, this.stamina + 0.35)
}
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)
const { uiScale, offsetX, offsetY, marginW } = docking
const deltaLeft = -marginW
const deltaRight = marginW
// 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.save()
ctx.imageSmoothingEnabled = false
ctx.clearRect(0, 0, targetW, targetH)
ctx.translate(Math.round(offsetX * dpr), Math.round(offsetY * dpr))
ctx.scale(uiScale * dpr, uiScale * dpr)
// 1. Left-Half Dock Panel (`0..400, 0..553`)
if (this.leftPanel === 'char') {
const leftNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.leftSkillId)
const rightNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.rightSkillId)
const lEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.leftSkillId))
const rEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.rightSkillId))
ctx.save()
ctx.translate(deltaLeft, 0)
this.charSheet.draw(
ctx,
{
hp: this.hp,
maxHp: this.maxHp,
mana: this.mana,
maxMana: this.maxMana,
stamina: this.stamina,
maxStamina: this.maxStamina,
leftSkillDmg: leftNode
? `${leftNode.baseDmgMin + lEff * leftNode.dmgPerLevel}-${leftNode.baseDmgMax + lEff * Math.round(leftNode.dmgPerLevel * 1.25)}`
: '45-82',
rightSkillDmg: rightNode
? `${rightNode.baseDmgMin + rEff * rightNode.dmgPerLevel}-${rightNode.baseDmgMax + rEff * Math.round(rightNode.dmgPerLevel * 1.25)}`
: '520-585',
},
{
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, 0)
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,
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..800, 0..553`)
if (this.rightPanel === 'inv') {
ctx.save()
ctx.translate(deltaRight, 0)
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, 0)
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. Bottom 800px Control Panel (`800CtrlPnl7.dc6`)
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,
)
// 7. Top-Center Monster Target Health Bar & Plaque
this.worldPanels.drawMonsterTargetBar(ctx, this.targetMonster, this.font)
// 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')
}
// 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,
)
}
}