468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|