1974 lines
71 KiB
TypeScript
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,
|
|
)
|
|
}
|
|
}
|