feat(ui): implement widescreen left/right panel docking and central vision corridor (Fixes #153)

This commit is contained in:
troytt 2026-09-22 07:56:21 +00:00
parent 0df0dfede2
commit 82e32a813e
2 changed files with 587 additions and 24 deletions

View File

@ -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`)

View File

@ -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')
})
})
})