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