From 82e32a813e7875433bbb4d6d7e374cea6f43071f Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 22 Sep 2026 07:56:21 +0000 Subject: [PATCH] feat(ui): implement widescreen left/right panel docking and central vision corridor (Fixes #153) --- src/ui/hud-manager.ts | 144 ++++++++-- tests/widescreen-docking.test.ts | 467 +++++++++++++++++++++++++++++++ 2 files changed, 587 insertions(+), 24 deletions(-) create mode 100644 tests/widescreen-docking.test.ts diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index df110c7..a6aea3b 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -63,6 +63,7 @@ export interface PublishedHudState { maxMana: number stamina: number maxStamina: number + docking: DockingLayout } declare global { @@ -90,6 +91,17 @@ export interface HudLayout { readonly physicalOffsetY: number } +export interface DockingLayout extends HudLayout { + marginW: number + leftDockX: number + rightDockX: number + channelLeft: number + channelRight: number + channelWidthLogical: number + channelWidthCss: number + isWidescreen: boolean +} + /** * Computes 1:1 uniform aspect ratio scaling and horizontal centering margins * for the canonical 800x600 Diablo II HUD viewport onto an arbitrary window/canvas size. @@ -113,6 +125,38 @@ export function computeHudLayout(cssW: number, cssH: number, dpr: number = 1): H } } +export { CHAR_PANEL_ORIGIN } from './character-sheet.ts' +export { INV_PANEL_ORIGIN, INV_GRID_ORIGIN } from './inventory.ts' +export { SKILL_PANEL_ORIGIN } from './skill-tree-panel.ts' +export { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN } from './world-panels.ts' + +/** + * Computes widescreen panel docking layout metrics, margins, and central vision corridor. + */ +export function computeDockingLayout(cssW: number, cssH: number, dpr: number = 1): DockingLayout { + const layout = computeHudLayout(cssW, cssH, dpr) + const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0 + const leftDockX = -marginW || 0 + const rightDockX = 400 + marginW + const channelLeft = 400 - marginW + const channelRight = 400 + marginW + const channelWidthLogical = 2 * marginW + const channelWidthCss = 2 * layout.offsetX + const isWidescreen = marginW > 0 + + return { + ...layout, + marginW, + leftDockX, + rightDockX, + channelLeft, + channelRight, + channelWidthLogical, + channelWidthCss, + isWidescreen, + } +} + export function clientToLogical( clientX: number, clientY: number, @@ -227,6 +271,7 @@ export class HudManager { maxMana: 940, stamina: 465, maxStamina: 465, + docking: computeDockingLayout(800, 600, 1), } constructor( @@ -386,24 +431,42 @@ export class HudManager { return logicalToClient(logicalX, logicalY, this.hudCanvas.getBoundingClientRect()) } + /** + * Returns current widescreen panel docking layout metrics. + */ + getDockingLayout(): DockingLayout { + const hasWindow = typeof window !== 'undefined' + const dpr = Math.min(hasWindow && window.devicePixelRatio ? window.devicePixelRatio : 1, 2) + const rect = this.hudCanvas?.getBoundingClientRect?.() + const canvasW = this.hudCanvas?.clientWidth || rect?.width || (this.hudCanvas?.width ?? 0) + const canvasH = this.hudCanvas?.clientHeight || rect?.height || (this.hudCanvas?.height ?? 0) + const winW = hasWindow && window.innerWidth ? window.innerWidth : 800 + const winH = hasWindow && window.innerHeight ? window.innerHeight : 600 + const cssW = Math.max(320, canvasW || winW) + const cssH = Math.max(240, canvasH || winH) + return computeDockingLayout(cssW, cssH, dpr) + } /** * Whether a logical `(x, y)` point hits an active HUD panel or control bar, * so scene clicks/walks underneath are suppressed. * - * Out-of-bounds coordinates (< 0 or > 800 / 600) fall in the letterbox/pillarbox - * widescreen margins and must return false so scene navigation is not blocked. + * Out-of-bounds coordinates (< -marginW or > 800 + marginW / 600) fall outside the canvas + * and must return false so scene navigation is not blocked. + * Central vision corridor (400 - marginW < logicalX < 400 + marginW) remains open and returns false. */ isPointInterceptedByHud(logicalX: number, logicalY: number): boolean { - if (logicalX < 0 || logicalX > 800 || logicalY < 0 || logicalY > 600) return false - if (logicalY >= 540) return true - if (logicalX <= 117 && logicalY >= 496) return true - if (logicalX >= 683 && logicalY >= 496) return true + const docking = this.getDockingLayout() + const { marginW } = docking + if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false + if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true + if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true + if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true if (this.controlBar.miniPanelOpen && logicalX >= 312 && logicalX <= 488 && logicalY >= 522) return true if (this.belt.isExpanded && logicalX >= 420 && logicalX <= 550 && logicalY >= 460) return true if (this.hotkeys.openPopup !== null) return true - if (this.leftPanel !== 'none' && logicalX <= 400 && logicalY <= 553) return true - if (this.rightPanel !== 'none' && logicalX >= 400 && logicalY <= 553) 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 } @@ -481,12 +544,23 @@ export class HudManager { this.belt.handleMouseMove(pt.x, pt.y) this.hotkeys.handleMouseMove(pt.x, pt.y) this.controlBar.handleMouseMove(pt.x, pt.y) - this.inventory.handleMouseMove(pt.x, pt.y) - this.skillTree.handleMouseMove(pt.x, pt.y) + + const docking = this.getDockingLayout() + const deltaLeft = -docking.marginW + const deltaRight = docking.marginW + + this.inventory.handleMouseMove(pt.x - deltaRight, pt.y) + if (this.inventory.hoveredItem) { + this.inventory.hoveredItem.x += deltaRight + } + this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y) if (this.leftPanel === 'stash') { - this.worldPanels.handleMouseMove(pt.x, pt.y) + this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y) if (this.worldPanels.hoveredStashItem) { - this.inventory.hoveredItem = this.worldPanels.hoveredStashItem + this.inventory.hoveredItem = { + ...this.worldPanels.hoveredStashItem, + x: this.worldPanels.hoveredStashItem.x + deltaLeft, + } } } }) @@ -559,10 +633,15 @@ export class HudManager { return } - // Left-Half Dock Click (`0..400`) - if (pt.x <= 400) { + 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(pt.x, pt.y, (key: BaseStatKey) => { + this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => { if (key === 'vit') { this.maxHp = this.charSheet.attrs.baseMaxHp this.hp = this.maxHp @@ -577,7 +656,7 @@ export class HudManager { this.syncPublishedState() return } else if (this.leftPanel !== 'none') { - this.worldPanels.handleLeftDockClick(this.leftPanel, pt.x, pt.y, { + this.worldPanels.handleLeftDockClick(this.leftPanel, leftX, pt.y, { onClose: () => { this.leftPanel = 'none' }, @@ -593,17 +672,18 @@ export class HudManager { } } - // Right-Half Dock Click (`400..800`) - if (pt.x >= 400) { + // Right-Half Dock Click + if (pt.x >= 400 + deltaRight) { + const rightX = pt.x - deltaRight if (this.rightPanel === 'inv') { if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) { if ( - pt.x >= INV_GRID_ORIGIN.x && - pt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + 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((pt.x - INV_GRID_ORIGIN.x) / 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, @@ -615,7 +695,7 @@ export class HudManager { } } } - this.inventory.handleClick(pt.x, pt.y, { + this.inventory.handleClick(rightX, pt.y, { onOpenCube: () => this.toggleLeftPanel('cube'), onCastTownPortal: () => this.callbacks.onCastTownPortal?.(), }) @@ -623,7 +703,7 @@ export class HudManager { this.syncPublishedState() return } else if (this.rightPanel === 'skill') { - this.skillTree.handleClick(pt.x, pt.y) + this.skillTree.handleClick(rightX, pt.y) if (!this.skillTree.visible) this.rightPanel = 'none' this.syncPublishedState() return @@ -683,6 +763,7 @@ export class HudManager { 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() } /** @@ -709,7 +790,10 @@ export class HudManager { this.hudCanvas.height = targetH } - const { uiScale, offsetX, offsetY } = computeHudLayout(cssW, cssH) + const docking = computeDockingLayout(cssW, cssH, dpr) + const { uiScale, offsetX, offsetY, marginW } = docking + const deltaLeft = -marginW + const deltaRight = marginW // Enable pointer events on HUD canvas only when hovering HUD/Panels or holding cursorItem const intercept = @@ -730,6 +814,8 @@ export class HudManager { const rightNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.rightSkillId) const lEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.leftSkillId)) const rEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.rightSkillId)) + ctx.save() + ctx.translate(deltaLeft, 0) this.charSheet.draw( ctx, { @@ -754,7 +840,10 @@ export class HudManager { }, this.font, ) + ctx.restore() } else if (this.leftPanel !== 'none') { + ctx.save() + ctx.translate(deltaLeft, 0) this.worldPanels.drawLeftDockPanel( ctx, this.leftPanel, @@ -771,10 +860,13 @@ export class HudManager { }, this.font, ) + ctx.restore() } // 2. Right-Half Dock Panel (`400..800, 0..553`) if (this.rightPanel === 'inv') { + ctx.save() + ctx.translate(deltaRight, 0) this.inventory.draw( ctx, { @@ -788,7 +880,10 @@ export class HudManager { }, this.font, ) + ctx.restore() } else if (this.rightPanel === 'skill') { + ctx.save() + ctx.translate(deltaRight, 0) this.skillTree.draw( ctx, { @@ -798,6 +893,7 @@ export class HudManager { }, this.font, ) + ctx.restore() } // 3. Bottom 800px Control Panel (`800CtrlPnl7.dc6`) diff --git a/tests/widescreen-docking.test.ts b/tests/widescreen-docking.test.ts new file mode 100644 index 0000000..a7fe30c --- /dev/null +++ b/tests/widescreen-docking.test.ts @@ -0,0 +1,467 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + HudManager, + computeDockingLayout, + computeHudLayout, + CHAR_PANEL_ORIGIN, + INV_PANEL_ORIGIN, + INV_GRID_ORIGIN, + SKILL_PANEL_ORIGIN, +} from '../src/ui/hud-manager.ts' +import { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN } from '../src/ui/world-panels.ts' +import type { UiInventoryItem } from '../src/ui/inventory.ts' + +class MockWindow extends EventTarget { + innerWidth = 1920 + innerHeight = 1080 + devicePixelRatio = 1 +} + +if (typeof globalThis.window === 'undefined') { + // @ts-ignore + globalThis.window = new MockWindow() +} +if (typeof globalThis.HTMLInputElement === 'undefined') { + // @ts-ignore + globalThis.HTMLInputElement = class HTMLInputElement {} +} +if (typeof globalThis.HTMLSelectElement === 'undefined') { + // @ts-ignore + globalThis.HTMLSelectElement = class HTMLSelectElement {} +} + +function createMockCanvas(width: number, height: number): HTMLCanvasElement { + return { + clientWidth: width, + clientHeight: height, + width, + height, + style: { pointerEvents: 'none' }, + getBoundingClientRect: () => ({ left: 0, top: 0, width, height }), + getContext: () => null, + addEventListener: () => {}, + removeEventListener: () => {}, + } as unknown as HTMLCanvasElement +} + +function createDummyItem(id: string, code: string, invWidth: number, invHeight: number, nameZh: string): UiInventoryItem { + return { + id, + code, + invFile: code, + name: nameZh, + nameZh, + baseNameZh: nameZh, + quality: 'normal', + invWidth, + invHeight, + allowedSlots: [], + stats: [], + } +} + +describe('Widescreen UI Panels Left/Right Docking & Central Vision Corridor (Issue #153)', () => { + describe('Docking Layout Mathematics & Metrics Across Resolutions', () => { + it('computes 800x600 classic layout with 0px margin and 0px central channel (classic D2 parity)', () => { + const docking = computeDockingLayout(800, 600) + expect(docking.uiScale).toBe(1.0) + expect(docking.offsetX).toBe(0) + expect(docking.offsetY).toBe(0) + expect(docking.marginW).toBe(0) + expect(docking.leftDockX).toBe(0) + expect(docking.rightDockX).toBe(400) + expect(docking.channelLeft).toBe(400) + expect(docking.channelRight).toBe(400) + expect(docking.channelWidthLogical).toBe(0) + expect(docking.channelWidthCss).toBe(0) + expect(docking.isWidescreen).toBe(false) + }) + + it('computes 1024x768 4:3 layout with 0px margin and 0px central channel', () => { + const docking = computeDockingLayout(1024, 768) + expect(docking.uiScale).toBe(1.28) + expect(docking.offsetX).toBe(0) + expect(docking.offsetY).toBe(0) + expect(docking.marginW).toBe(0) + expect(docking.leftDockX).toBe(0) + expect(docking.rightDockX).toBe(400) + expect(docking.channelLeft).toBe(400) + expect(docking.channelRight).toBe(400) + expect(docking.channelWidthLogical).toBe(0) + expect(docking.channelWidthCss).toBe(0) + expect(docking.isWidescreen).toBe(false) + }) + + it('computes 1280x720 16:9 layout with 320px CSS central channel', () => { + const docking = computeDockingLayout(1280, 720) + expect(docking.uiScale).toBeCloseTo(1.2, 3) + expect(docking.offsetX).toBe(160) + expect(docking.offsetY).toBe(0) + expect(docking.marginW).toBeCloseTo(133.333, 2) + expect(docking.leftDockX).toBeCloseTo(-133.333, 2) + expect(docking.rightDockX).toBeCloseTo(533.333, 2) + expect(docking.channelLeft).toBeCloseTo(266.667, 2) + expect(docking.channelRight).toBeCloseTo(533.333, 2) + expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2) + expect(docking.channelWidthCss).toBe(320) + expect(docking.isWidescreen).toBe(true) + }) + + it('computes 1920x1080 Full HD 16:9 layout with 480px CSS central channel', () => { + const docking = computeDockingLayout(1920, 1080) + expect(docking.uiScale).toBeCloseTo(1.8, 3) + expect(docking.offsetX).toBe(240) + expect(docking.offsetY).toBe(0) + expect(docking.marginW).toBeCloseTo(133.333, 2) + expect(docking.leftDockX).toBeCloseTo(-133.333, 2) + expect(docking.rightDockX).toBeCloseTo(533.333, 2) + expect(docking.channelLeft).toBeCloseTo(266.667, 2) + expect(docking.channelRight).toBeCloseTo(533.333, 2) + expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2) + expect(docking.channelWidthCss).toBe(480) + expect(docking.isWidescreen).toBe(true) + }) + + it('computes 2560x1440 QHD 16:9 layout with 640px CSS central channel', () => { + const docking = computeDockingLayout(2560, 1440) + expect(docking.uiScale).toBeCloseTo(2.4, 3) + expect(docking.offsetX).toBe(320) + expect(docking.offsetY).toBe(0) + expect(docking.marginW).toBeCloseTo(133.333, 2) + expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2) + expect(docking.channelWidthCss).toBe(640) + expect(docking.isWidescreen).toBe(true) + }) + + it('computes 3440x1440 Ultrawide 21:9 layout with 1520px CSS central channel', () => { + const docking = computeDockingLayout(3440, 1440) + expect(docking.uiScale).toBeCloseTo(2.4, 3) + expect(docking.offsetX).toBe(760) + expect(docking.offsetY).toBe(0) + expect(docking.marginW).toBeCloseTo(316.667, 2) + expect(docking.channelWidthLogical).toBeCloseTo(633.333, 2) + expect(docking.channelWidthCss).toBe(1520) + expect(docking.isWidescreen).toBe(true) + }) + + it('guarantees channelRight >= channelLeft and zero panel overlap across all resolutions', () => { + const resolutions = [ + [800, 600], + [1024, 768], + [1280, 720], + [1280, 800], + [1366, 768], + [1600, 900], + [1920, 1080], + [1920, 1200], + [2560, 1440], + [3440, 1440], + [3840, 2160], + [5120, 1440], + ] + + for (const [w, h] of resolutions) { + const d = computeDockingLayout(w, h) + expect(d.channelRight).toBeGreaterThanOrEqual(d.channelLeft) + expect(d.channelWidthLogical).toBeGreaterThanOrEqual(0) + expect(d.channelWidthCss).toBeGreaterThanOrEqual(0) + + // Left panel right edge = channelLeft. Right panel left edge = channelRight. + // Overlap amount = Math.max(0, channelLeft - channelRight) must be 0. + const overlap = Math.max(0, d.channelLeft - d.channelRight) + expect(overlap).toBe(0) + } + }) + }) + + describe('Invariant Preservation', () => { + it('preserves canonical panel and grid origin constants unchanged', () => { + expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 }) + expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 }) + expect(INV_GRID_ORIGIN).toEqual({ x: 418, y: 316, cols: 10, rows: 4, cellPx: 29 }) + expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 }) + expect(STASH_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 }) + expect(STASH_GRID_ORIGIN).toEqual({ x: 154, y: 142, cols: 6, rows: 8, cellPx: 29 }) + }) + }) + + describe('Central Vision Corridor & Input Interception (isPointInterceptedByHud)', () => { + it('returns false at (400, 300) when dual panels are open on widescreen (1920x1080)', () => { + const canvas = createMockCanvas(1920, 1080) + const hud = new HudManager(canvas, { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + + hud.leftPanel = 'char' + hud.rightPanel = 'inv' + + // Screen center (400, 300) in logical coordinates is in the open central corridor + expect(hud.isPointInterceptedByHud(400, 300)).toBe(false) + + // Test across the entire central corridor: 400 - marginW < x < 400 + marginW + const docking = hud.getDockingLayout() + expect(docking.isWidescreen).toBe(true) + + const channelMid = (docking.channelLeft + docking.channelRight) / 2 + expect(channelMid).toBeCloseTo(400, 2) + expect(hud.isPointInterceptedByHud(channelMid, 250)).toBe(false) + expect(hud.isPointInterceptedByHud(docking.channelLeft + 10, 250)).toBe(false) + expect(hud.isPointInterceptedByHud(docking.channelRight - 10, 250)).toBe(false) + + // Points inside left panel are intercepted + expect(hud.isPointInterceptedByHud(docking.channelLeft - 20, 250)).toBe(true) + expect(hud.isPointInterceptedByHud(docking.leftDockX + 10, 250)).toBe(true) + + // Points inside right panel are intercepted + expect(hud.isPointInterceptedByHud(docking.channelRight + 20, 250)).toBe(true) + expect(hud.isPointInterceptedByHud(800 + docking.marginW - 10, 250)).toBe(true) + }) + + it('returns true at (400, 300) when dual panels are open in 800x600 (classic D2)', () => { + const canvas = createMockCanvas(800, 600) + const hud = new HudManager(canvas, { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + + hud.leftPanel = 'char' + hud.rightPanel = 'inv' + + // In 800x600, panels touch at 400 with 0px corridor, intercepting x=400 + expect(hud.isPointInterceptedByHud(400, 300)).toBe(true) + + // When panels are closed, x=400 is not intercepted + hud.leftPanel = 'none' + hud.rightPanel = 'none' + expect(hud.isPointInterceptedByHud(400, 300)).toBe(false) + }) + }) + + describe('HudManager getDockingLayout and syncPublishedState', () => { + it('exposes getDockingLayout() matching current canvas dimensions', () => { + const canvas = createMockCanvas(1920, 1080) + const hud = new HudManager(canvas, { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + + const layout = hud.getDockingLayout() + expect(layout.isWidescreen).toBe(true) + expect(layout.channelWidthCss).toBe(480) + expect(layout.marginW).toBeCloseTo(133.333, 2) + }) + + it('includes docking metrics in published state', () => { + const canvas = createMockCanvas(1920, 1080) + const hud = new HudManager(canvas, { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + + hud.syncPublishedState() + expect(hud.state.docking).toBeDefined() + expect(hud.state.docking.isWidescreen).toBe(true) + expect(hud.state.docking.channelWidthCss).toBe(480) + expect(hud.state.docking.channelLeft).toBeCloseTo(266.667, 2) + expect(hud.state.docking.channelRight).toBeCloseTo(533.333, 2) + }) + }) + + describe('Pointer Event Coordinate Translation in Widescreen', () => { + class MockCanvas extends EventTarget { + clientWidth = 1920 + clientHeight = 1080 + width = 1920 + height = 1080 + style = { pointerEvents: 'none' } + getBoundingClientRect() { + return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight } + } + getContext() { + return null + } + } + + class SyntheticMouseEvent extends Event { + readonly clientX: number + readonly clientY: number + readonly button: number + readonly shiftKey: boolean + + constructor( + type: string, + init: { clientX: number; clientY: number; button?: number; shiftKey?: boolean }, + ) { + super(type, { cancelable: true, bubbles: true }) + this.clientX = init.clientX + this.clientY = init.clientY + this.button = init.button ?? 0 + this.shiftKey = init.shiftKey ?? false + } + } + + let canvas: MockCanvas + let hud: HudManager + + beforeEach(() => { + canvas = new MockCanvas() + hud = new HudManager(canvas as unknown as HTMLCanvasElement, { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + hud['bindEvents']() + }) + + it('translates mousemove coordinates for right dock panels and adjusts item tooltip hover position', () => { + hud.toggleRightPanel('inv') + const item = createDummyItem('item1', 'rin', 1, 1, '乔丹之石') + hud.inventory.gridItems = [{ item, col: 0, row: 0 }] + + const docking = hud.getDockingLayout() + const deltaRight = docking.marginW // ~133.333 + + // Target item in col 0: canonical logical position is INV_GRID_ORIGIN.x + 14.5 = 418 + 14.5 = 432.5 + // In widescreen, visual position is 432.5 + deltaRight. + const visualLogicalX = INV_GRID_ORIGIN.x + 14.5 + deltaRight + const clientX = visualLogicalX * docking.uiScale + docking.offsetX + const clientY = (INV_GRID_ORIGIN.y + 14.5) * docking.uiScale + + window.dispatchEvent(new SyntheticMouseEvent('mousemove', { clientX, clientY })) + + // Inventory should have detected hover + expect(hud.inventory.hoveredItem).not.toBeNull() + expect(hud.inventory.hoveredItem?.item.id).toBe('item1') + // hoveredItem.x must be translated by +deltaRight so the tooltip renders over visual position + expect(hud.inventory.hoveredItem?.x).toBeCloseTo(visualLogicalX, 1) + }) + + it('translates mousemove coordinates for left dock stash items', () => { + hud.leftPanel = 'stash' + const item = createDummyItem('stash-item1', 'rin', 1, 1, '储物箱戒指') + hud.worldPanels.stashItems = [{ item, col: 0, row: 0 }] + + const docking = hud.getDockingLayout() + const deltaLeft = -docking.marginW // ~ -133.333 + + // Stash col 0 cell center: STASH_GRID_ORIGIN.x + 14.5 = 154 + 14.5 = 168.5 + // In widescreen, visual position is 168.5 + deltaLeft = 168.5 - 133.333 = 35.167 + const visualLogicalX = STASH_GRID_ORIGIN.x + 14.5 + deltaLeft + const clientX = visualLogicalX * docking.uiScale + docking.offsetX + const clientY = (STASH_GRID_ORIGIN.y + 14.5) * docking.uiScale + + window.dispatchEvent(new SyntheticMouseEvent('mousemove', { clientX, clientY })) + + expect(hud.inventory.hoveredItem).not.toBeNull() + expect(hud.inventory.hoveredItem?.item.id).toBe('stash-item1') + expect(hud.inventory.hoveredItem?.x).toBeCloseTo(visualLogicalX, 1) + }) + + it('executes quick transfer from Inventory to Stash via Shift+Click in widescreen', () => { + hud.leftPanel = 'stash' + hud.rightPanel = 'inv' + + const item = createDummyItem('quick1', 'rin', 1, 1, '传送戒指') + hud.inventory.gridItems = [{ item, col: 0, row: 0 }] + expect(hud.worldPanels.stashItems).toHaveLength(0) + + const docking = hud.getDockingLayout() + const deltaRight = docking.marginW + + // Visual position of item in Inventory col 0, row 0 + const visualLogicalX = INV_GRID_ORIGIN.x + 10 + deltaRight + const visualLogicalY = INV_GRID_ORIGIN.y + 10 + const clientX = visualLogicalX * docking.uiScale + docking.offsetX + const clientY = visualLogicalY * docking.uiScale + docking.offsetY + + canvas.dispatchEvent( + new SyntheticMouseEvent('mousedown', { + clientX, + clientY, + button: 0, + shiftKey: true, + }), + ) + + // Quick transfer should succeed: item moved from inventory to stash + expect(hud.inventory.gridItems).toHaveLength(0) + expect(hud.worldPanels.stashItems).toHaveLength(1) + expect(hud.worldPanels.stashItems[0]!.item.id).toBe('quick1') + }) + + it('executes quick transfer from Stash to Inventory via Shift+Click in widescreen', () => { + hud.leftPanel = 'stash' + hud.rightPanel = 'inv' + + const item = createDummyItem('stash-quick1', 'rin', 1, 1, '马拉的万花筒') + hud.worldPanels.stashItems = [{ item, col: 0, row: 0 }] + hud.inventory.gridItems = [] + + const docking = hud.getDockingLayout() + const deltaLeft = -docking.marginW + + // Visual position of item in Stash col 0, row 0 + const visualLogicalX = STASH_GRID_ORIGIN.x + 10 + deltaLeft + const visualLogicalY = STASH_GRID_ORIGIN.y + 10 + const clientX = visualLogicalX * docking.uiScale + docking.offsetX + const clientY = visualLogicalY * docking.uiScale + docking.offsetY + + canvas.dispatchEvent( + new SyntheticMouseEvent('mousedown', { + clientX, + clientY, + button: 0, + shiftKey: true, + }), + ) + + // Quick transfer should succeed: item moved from stash to inventory + expect(hud.worldPanels.stashItems).toHaveLength(0) + expect(hud.inventory.gridItems).toHaveLength(1) + expect(hud.inventory.gridItems[0]!.item.id).toBe('stash-quick1') + }) + + it('closes left and right panels via close buttons at widescreen coordinates', () => { + hud.leftPanel = 'char' + hud.rightPanel = 'inv' + + const docking = hud.getDockingLayout() + const deltaLeft = -docking.marginW + const deltaRight = docking.marginW + + // Left panel close button: CHAR_CLOSE_BTN_BOUNDS: x = 208, y = 448 + const visualLeftCloseX = 208 + deltaLeft + const visualLeftCloseY = 448 + const clientLeftX = visualLeftCloseX * docking.uiScale + docking.offsetX + const clientLeftY = visualLeftCloseY * docking.uiScale + docking.offsetY + + canvas.dispatchEvent( + new SyntheticMouseEvent('mousedown', { + clientX: clientLeftX, + clientY: clientLeftY, + button: 0, + shiftKey: false, + }), + ) + + expect(hud.leftPanel).toBe('none') + + // Right panel close button: INV_CLOSE_BTN_BOUNDS: x = 418, y = 445 + const visualRightCloseX = 418 + deltaRight + const visualRightCloseY = 445 + const clientRightX = visualRightCloseX * docking.uiScale + docking.offsetX + const clientRightY = visualRightCloseY * docking.uiScale + docking.offsetY + + canvas.dispatchEvent( + new SyntheticMouseEvent('mousedown', { + clientX: clientRightX, + clientY: clientRightY, + button: 0, + shiftKey: false, + }), + ) + + expect(hud.rightPanel).toBe('none') + }) + }) +})