feat(ui): implement widescreen left/right panel docking and central vision corridor (Fixes #153)
This commit is contained in:
parent
0df0dfede2
commit
82e32a813e
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue