1002 lines
34 KiB
TypeScript
1002 lines
34 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 } from './hotkeys.ts'
|
|
import { ControlBarHud, type MiniPanelAction } from './control-bar.ts'
|
|
import { InventoryPanel, type EquipSlotId, resolveItemSpriteRect, INV_GRID_ORIGIN } 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 } from './world-panels.ts'
|
|
import { CursorController, type CursorState } from './cursor.ts'
|
|
|
|
export interface PublishedHudState {
|
|
ready: boolean
|
|
dc6DecodeFailures: number
|
|
decodedDc6Count: number
|
|
cursorState: CursorState
|
|
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
|
|
}
|
|
|
|
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 } 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',
|
|
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),
|
|
}
|
|
|
|
constructor(
|
|
private readonly hudCanvas: HTMLCanvasElement,
|
|
private readonly callbacks: {
|
|
onToggleAutomap: () => void
|
|
onWaypointTeleport: (act: number, slug: string, levelId?: number) => void
|
|
onCastTownPortal?: () => void
|
|
onDropItemToGround?: (name: string) => void
|
|
},
|
|
) {
|
|
if (this.hudCanvas) {
|
|
this.hudCanvas.style.cursor = 'none'
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
window.__d2webHud = this.state
|
|
window.__d2webHudInstance = this
|
|
}
|
|
}
|
|
|
|
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`,
|
|
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`,
|
|
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`,
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
} else {
|
|
this.leftPanel = kind
|
|
this.charSheet.visible = kind === 'char'
|
|
if (kind === 'stash' && 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.leftPanel = 'none'
|
|
this.rightPanel = 'none'
|
|
this.charSheet.visible = false
|
|
this.inventory.visible = false
|
|
this.skillTree.visible = false
|
|
this.belt.lockedOpen = false
|
|
this.hotkeys.openPopup = null
|
|
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 (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') {
|
|
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') {
|
|
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)
|
|
|
|
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.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
|
|
if (this.worldPanels.hoveredStashItem) {
|
|
this.inventory.hoveredItem = {
|
|
...this.worldPanels.hoveredStashItem,
|
|
x: this.worldPanels.hoveredStashItem.x + deltaLeft,
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
window.addEventListener('mousedown', () => {
|
|
this.cursor.handleMouseDown()
|
|
})
|
|
|
|
window.addEventListener('mouseup', () => {
|
|
this.cursor.handleMouseUp()
|
|
})
|
|
|
|
this.hudCanvas.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault()
|
|
})
|
|
|
|
this.hudCanvas.addEventListener('mousedown', (e) => {
|
|
if (e.button !== 0 && e.button !== 2) return
|
|
const pt = this.clientToLogical(e.clientX, e.clientY)
|
|
if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
|
|
// 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.nameZh)
|
|
this.syncPublishedState()
|
|
}
|
|
return
|
|
}
|
|
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
// 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)
|
|
},
|
|
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.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) {
|
|
this.worldPanels.quickTransferToStash(hit.item, this.inventory)
|
|
this.syncPublishedState()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
this.inventory.handleClick(rightX, pt.y, {
|
|
onOpenCube: () => this.toggleLeftPanel('cube'),
|
|
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
|
})
|
|
if (!this.inventory.visible) this.rightPanel = 'none'
|
|
this.syncPublishedState()
|
|
return
|
|
} else if (this.rightPanel === 'skill') {
|
|
this.skillTree.handleClick(rightX, pt.y)
|
|
if (!this.skillTree.visible) this.rightPanel = 'none'
|
|
this.syncPublishedState()
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
syncPublishedState(): void {
|
|
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.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()
|
|
}
|
|
|
|
/**
|
|
* Tick stamina drain/recovery and draw the complete 800x600 Diablo II HUD.
|
|
*/
|
|
render(
|
|
nowMs: number,
|
|
playerMoving: boolean,
|
|
): 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 or holding cursorItem
|
|
const intercept =
|
|
this.isPointInterceptedByHud(this.mouseX, this.mouseY) || this.inventory.cursorItem !== null
|
|
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,
|
|
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,
|
|
},
|
|
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,
|
|
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)
|
|
|
|
// 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. Area Entry Gothic Banner (`Entering: <Level Name>`, only when no split panels are open)
|
|
if (this.leftPanel === 'none' && this.rightPanel === 'none') {
|
|
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font)
|
|
}
|
|
|
|
// 9. Hover Item Tooltip (Inventory / Equipment / Stash)
|
|
if (
|
|
(this.rightPanel === 'inv' || this.leftPanel === 'stash') &&
|
|
this.inventory.hoveredItem &&
|
|
!this.inventory.cursorItem
|
|
) {
|
|
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font)
|
|
}
|
|
|
|
// 10. Authentic Hand Cursor & Held Item at Hotspot
|
|
this.renderCursor(ctx, nowMs)
|
|
|
|
ctx.restore()
|
|
this.syncPublishedState()
|
|
}
|
|
|
|
/**
|
|
* Renders the authentic animated hand cursor or held cursorItem at cursor hotspot.
|
|
*/
|
|
renderCursor(ctx: CanvasRenderingContext2D, nowMs: number = performance.now()): void {
|
|
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,
|
|
)
|
|
}
|
|
}
|