Merge branch 'issue-153': Widescreen UI panels left/right docking & central vision corridor (Fixes #153)
This commit is contained in:
commit
609149fa5f
|
|
@ -63,6 +63,7 @@ export interface PublishedHudState {
|
||||||
maxMana: number
|
maxMana: number
|
||||||
stamina: number
|
stamina: number
|
||||||
maxStamina: number
|
maxStamina: number
|
||||||
|
docking: DockingLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -90,6 +91,17 @@ export interface HudLayout {
|
||||||
readonly physicalOffsetY: number
|
readonly physicalOffsetY: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DockingLayout extends HudLayout {
|
||||||
|
marginW: number
|
||||||
|
leftDockX: number
|
||||||
|
rightDockX: number
|
||||||
|
channelLeft: number
|
||||||
|
channelRight: number
|
||||||
|
channelWidthLogical: number
|
||||||
|
channelWidthCss: number
|
||||||
|
isWidescreen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes 1:1 uniform aspect ratio scaling and horizontal centering margins
|
* 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.
|
* 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(
|
export function clientToLogical(
|
||||||
clientX: number,
|
clientX: number,
|
||||||
clientY: number,
|
clientY: number,
|
||||||
|
|
@ -227,6 +271,7 @@ export class HudManager {
|
||||||
maxMana: 940,
|
maxMana: 940,
|
||||||
stamina: 465,
|
stamina: 465,
|
||||||
maxStamina: 465,
|
maxStamina: 465,
|
||||||
|
docking: computeDockingLayout(800, 600, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -386,24 +431,42 @@ export class HudManager {
|
||||||
return logicalToClient(logicalX, logicalY, this.hudCanvas.getBoundingClientRect())
|
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,
|
* Whether a logical `(x, y)` point hits an active HUD panel or control bar,
|
||||||
* so scene clicks/walks underneath are suppressed.
|
* so scene clicks/walks underneath are suppressed.
|
||||||
*
|
*
|
||||||
* Out-of-bounds coordinates (< 0 or > 800 / 600) fall in the letterbox/pillarbox
|
* Out-of-bounds coordinates (< -marginW or > 800 + marginW / 600) fall outside the canvas
|
||||||
* widescreen margins and must return false so scene navigation is not blocked.
|
* 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 {
|
isPointInterceptedByHud(logicalX: number, logicalY: number): boolean {
|
||||||
if (logicalX < 0 || logicalX > 800 || logicalY < 0 || logicalY > 600) return false
|
const docking = this.getDockingLayout()
|
||||||
if (logicalY >= 540) return true
|
const { marginW } = docking
|
||||||
if (logicalX <= 117 && logicalY >= 496) return true
|
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false
|
||||||
if (logicalX >= 683 && logicalY >= 496) return true
|
if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
|
||||||
|
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
|
||||||
|
if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true
|
||||||
if (this.controlBar.miniPanelOpen && logicalX >= 312 && logicalX <= 488 && logicalY >= 522) return true
|
if (this.controlBar.miniPanelOpen && logicalX >= 312 && logicalX <= 488 && logicalY >= 522) return true
|
||||||
if (this.belt.isExpanded && logicalX >= 420 && logicalX <= 550 && logicalY >= 460) return true
|
if (this.belt.isExpanded && logicalX >= 420 && logicalX <= 550 && logicalY >= 460) return true
|
||||||
if (this.hotkeys.openPopup !== null) return true
|
if (this.hotkeys.openPopup !== null) return true
|
||||||
if (this.leftPanel !== '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 && logicalY <= 553) return true
|
if (this.rightPanel !== 'none' && logicalX >= 400 + marginW && logicalX <= 800 + marginW && logicalY <= 553) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -481,12 +544,23 @@ export class HudManager {
|
||||||
this.belt.handleMouseMove(pt.x, pt.y)
|
this.belt.handleMouseMove(pt.x, pt.y)
|
||||||
this.hotkeys.handleMouseMove(pt.x, pt.y)
|
this.hotkeys.handleMouseMove(pt.x, pt.y)
|
||||||
this.controlBar.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') {
|
if (this.leftPanel === 'stash') {
|
||||||
this.worldPanels.handleMouseMove(pt.x, pt.y)
|
this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
|
||||||
if (this.worldPanels.hoveredStashItem) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left-Half Dock Click (`0..400`)
|
const docking = this.getDockingLayout()
|
||||||
if (pt.x <= 400) {
|
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') {
|
if (this.leftPanel === 'char') {
|
||||||
this.charSheet.handleClick(pt.x, pt.y, (key: BaseStatKey) => {
|
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
|
||||||
if (key === 'vit') {
|
if (key === 'vit') {
|
||||||
this.maxHp = this.charSheet.attrs.baseMaxHp
|
this.maxHp = this.charSheet.attrs.baseMaxHp
|
||||||
this.hp = this.maxHp
|
this.hp = this.maxHp
|
||||||
|
|
@ -577,7 +656,7 @@ export class HudManager {
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
return
|
return
|
||||||
} else if (this.leftPanel !== 'none') {
|
} else if (this.leftPanel !== 'none') {
|
||||||
this.worldPanels.handleLeftDockClick(this.leftPanel, pt.x, pt.y, {
|
this.worldPanels.handleLeftDockClick(this.leftPanel, leftX, pt.y, {
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.leftPanel = 'none'
|
this.leftPanel = 'none'
|
||||||
},
|
},
|
||||||
|
|
@ -593,17 +672,18 @@ export class HudManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right-Half Dock Click (`400..800`)
|
// Right-Half Dock Click
|
||||||
if (pt.x >= 400) {
|
if (pt.x >= 400 + deltaRight) {
|
||||||
|
const rightX = pt.x - deltaRight
|
||||||
if (this.rightPanel === 'inv') {
|
if (this.rightPanel === 'inv') {
|
||||||
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
||||||
if (
|
if (
|
||||||
pt.x >= INV_GRID_ORIGIN.x &&
|
rightX >= INV_GRID_ORIGIN.x &&
|
||||||
pt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
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 &&
|
||||||
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
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 row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||||
const hit = this.inventory.gridItems.find(
|
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,
|
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'),
|
onOpenCube: () => this.toggleLeftPanel('cube'),
|
||||||
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
||||||
})
|
})
|
||||||
|
|
@ -623,7 +703,7 @@ export class HudManager {
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
return
|
return
|
||||||
} else if (this.rightPanel === 'skill') {
|
} 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'
|
if (!this.skillTree.visible) this.rightPanel = 'none'
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
return
|
return
|
||||||
|
|
@ -683,6 +763,7 @@ export class HudManager {
|
||||||
this.state.maxMana = Math.round(this.maxMana)
|
this.state.maxMana = Math.round(this.maxMana)
|
||||||
this.state.stamina = Math.round(this.stamina)
|
this.state.stamina = Math.round(this.stamina)
|
||||||
this.state.maxStamina = Math.round(this.maxStamina)
|
this.state.maxStamina = Math.round(this.maxStamina)
|
||||||
|
this.state.docking = this.getDockingLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -709,7 +790,10 @@ export class HudManager {
|
||||||
this.hudCanvas.height = targetH
|
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
|
// Enable pointer events on HUD canvas only when hovering HUD/Panels or holding cursorItem
|
||||||
const intercept =
|
const intercept =
|
||||||
|
|
@ -730,6 +814,8 @@ export class HudManager {
|
||||||
const rightNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.rightSkillId)
|
const rightNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.rightSkillId)
|
||||||
const lEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.leftSkillId))
|
const lEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.leftSkillId))
|
||||||
const rEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.rightSkillId))
|
const rEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.rightSkillId))
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(deltaLeft, 0)
|
||||||
this.charSheet.draw(
|
this.charSheet.draw(
|
||||||
ctx,
|
ctx,
|
||||||
{
|
{
|
||||||
|
|
@ -754,7 +840,10 @@ export class HudManager {
|
||||||
},
|
},
|
||||||
this.font,
|
this.font,
|
||||||
)
|
)
|
||||||
|
ctx.restore()
|
||||||
} else if (this.leftPanel !== 'none') {
|
} else if (this.leftPanel !== 'none') {
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(deltaLeft, 0)
|
||||||
this.worldPanels.drawLeftDockPanel(
|
this.worldPanels.drawLeftDockPanel(
|
||||||
ctx,
|
ctx,
|
||||||
this.leftPanel,
|
this.leftPanel,
|
||||||
|
|
@ -771,10 +860,13 @@ export class HudManager {
|
||||||
},
|
},
|
||||||
this.font,
|
this.font,
|
||||||
)
|
)
|
||||||
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Right-Half Dock Panel (`400..800, 0..553`)
|
// 2. Right-Half Dock Panel (`400..800, 0..553`)
|
||||||
if (this.rightPanel === 'inv') {
|
if (this.rightPanel === 'inv') {
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(deltaRight, 0)
|
||||||
this.inventory.draw(
|
this.inventory.draw(
|
||||||
ctx,
|
ctx,
|
||||||
{
|
{
|
||||||
|
|
@ -788,7 +880,10 @@ export class HudManager {
|
||||||
},
|
},
|
||||||
this.font,
|
this.font,
|
||||||
)
|
)
|
||||||
|
ctx.restore()
|
||||||
} else if (this.rightPanel === 'skill') {
|
} else if (this.rightPanel === 'skill') {
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(deltaRight, 0)
|
||||||
this.skillTree.draw(
|
this.skillTree.draw(
|
||||||
ctx,
|
ctx,
|
||||||
{
|
{
|
||||||
|
|
@ -798,6 +893,7 @@ export class HudManager {
|
||||||
},
|
},
|
||||||
this.font,
|
this.font,
|
||||||
)
|
)
|
||||||
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Bottom 800px Control Panel (`800CtrlPnl7.dc6`)
|
// 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