964 lines
38 KiB
TypeScript
964 lines
38 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
||
import { ViewportCamera } from '../../src/client/input/command-mapper.ts'
|
||
import {
|
||
normalizeViewportResolution,
|
||
sanitizeClientSettings,
|
||
VALID_VIEWPORT_RESOLUTIONS,
|
||
type ViewportResolution,
|
||
} from '../../src/client/settings/client-settings.ts'
|
||
import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts'
|
||
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
|
||
import {
|
||
computeDockingLayout,
|
||
CUBE_GRID_ORIGIN,
|
||
fromLeftDockLocal,
|
||
fromRightDockLocal,
|
||
HudManager,
|
||
INV_GRID_ORIGIN,
|
||
STASH_GRID_ORIGIN,
|
||
toControlBarLocal,
|
||
toLeftDockLocal,
|
||
toRightDockLocal,
|
||
VENDOR_GRID_ORIGIN,
|
||
type HudCommandSink,
|
||
} from '../../src/client/ui/hud-manager.ts'
|
||
import type { UiInventoryItem } from '../../src/client/ui/inventory.ts'
|
||
import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts'
|
||
import { getTownNpcDescriptorByName } from '../../src/common/units/npc-table.ts'
|
||
import {
|
||
computeViewportAnchors,
|
||
computeViewportFitSize,
|
||
createViewportProfile,
|
||
formatViewportLabel,
|
||
getViewportProfile,
|
||
getViewportProfileBySize,
|
||
isExtendedViewport,
|
||
VIEWPORT_1024x768,
|
||
VIEWPORT_1068x600,
|
||
VIEWPORT_1280x720,
|
||
VIEWPORT_800x600,
|
||
VIEWPORT_PROFILES,
|
||
type ViewportProfile,
|
||
} from '../../src/client/view/viewport-profile.ts'
|
||
|
||
class MemoryStorage implements StorageLike {
|
||
readonly data = new Map<string, string>()
|
||
|
||
getItem(key: string): string | null {
|
||
return this.data.get(key) ?? null
|
||
}
|
||
|
||
setItem(key: string, value: string): void {
|
||
this.data.set(key, value)
|
||
}
|
||
|
||
removeItem(key: string): void {
|
||
this.data.delete(key)
|
||
}
|
||
}
|
||
|
||
interface ExpectedProfile {
|
||
readonly profile: ViewportProfile
|
||
readonly id: ViewportResolution
|
||
readonly width: number
|
||
readonly height: number
|
||
readonly barOffsetX: number
|
||
readonly barOffsetY: number
|
||
readonly panelOffsetY: number
|
||
readonly label: string
|
||
}
|
||
|
||
/** Expected HUD anchors per registered resolution: bar `(W-800)/2, H-600`, panels `(H-600)/2`. */
|
||
const EXPECTED: readonly ExpectedProfile[] = [
|
||
{
|
||
profile: VIEWPORT_800x600,
|
||
id: '800x600',
|
||
width: 800,
|
||
height: 600,
|
||
barOffsetX: 0,
|
||
barOffsetY: 0,
|
||
panelOffsetY: 0,
|
||
label: '800×600',
|
||
},
|
||
{
|
||
profile: VIEWPORT_1024x768,
|
||
id: '1024x768',
|
||
width: 1024,
|
||
height: 768,
|
||
barOffsetX: 112,
|
||
barOffsetY: 168,
|
||
panelOffsetY: 84,
|
||
label: '1024×768',
|
||
},
|
||
{
|
||
profile: VIEWPORT_1068x600,
|
||
id: '1068x600',
|
||
width: 1068,
|
||
height: 600,
|
||
barOffsetX: 134,
|
||
barOffsetY: 0,
|
||
panelOffsetY: 0,
|
||
label: '1068×600',
|
||
},
|
||
{
|
||
profile: VIEWPORT_1280x720,
|
||
id: '1280x720',
|
||
width: 1280,
|
||
height: 720,
|
||
barOffsetX: 240,
|
||
barOffsetY: 120,
|
||
panelOffsetY: 60,
|
||
label: '1280×720',
|
||
},
|
||
]
|
||
|
||
function createHudAt(profile: ViewportProfile): HudManager {
|
||
const { width, height } = profile
|
||
const mockCtx = {
|
||
canvas: { width, height },
|
||
fillStyle: '',
|
||
strokeStyle: '',
|
||
lineWidth: 1,
|
||
globalAlpha: 1,
|
||
font: '',
|
||
textAlign: 'left',
|
||
textBaseline: 'alphabetic',
|
||
imageSmoothingEnabled: false,
|
||
save: () => {},
|
||
restore: () => {},
|
||
translate: () => {},
|
||
scale: () => {},
|
||
clearRect: () => {},
|
||
fillRect: () => {},
|
||
strokeRect: () => {},
|
||
beginPath: () => {},
|
||
closePath: () => {},
|
||
moveTo: () => {},
|
||
lineTo: () => {},
|
||
arc: () => {},
|
||
rect: () => {},
|
||
clip: () => {},
|
||
fill: () => {},
|
||
stroke: () => {},
|
||
fillText: () => {},
|
||
drawImage: () => {},
|
||
measureText: (t: string) => ({ width: t.length * 8 }),
|
||
createLinearGradient: () => ({ addColorStop: () => {} }),
|
||
} as unknown as CanvasRenderingContext2D
|
||
const canvas = {
|
||
width,
|
||
height,
|
||
clientWidth: width,
|
||
clientHeight: height,
|
||
style: { cursor: '', pointerEvents: '' },
|
||
addEventListener: () => {},
|
||
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
|
||
getContext: () => mockCtx,
|
||
} as unknown as HTMLCanvasElement
|
||
const hud = new HudManager(canvas, {
|
||
onToggleAutomap: () => {},
|
||
onWaypointTeleport: () => {},
|
||
})
|
||
hud.resize(width, height)
|
||
return hud
|
||
}
|
||
|
||
describe('Formula-derived viewport profiles (800x600, 1024x768, 1068x600, 1280x720)', () => {
|
||
it('derives every registered profile from barOffsetX=(W-800)/2, barOffsetY=H-600, panelOffsetY=(H-600)/2', () => {
|
||
for (const e of EXPECTED) {
|
||
expect(e.profile).toEqual({
|
||
id: e.id,
|
||
width: e.width,
|
||
height: e.height,
|
||
barOffsetX: e.barOffsetX,
|
||
barOffsetY: e.barOffsetY,
|
||
panelOffsetY: e.panelOffsetY,
|
||
})
|
||
expect(e.profile.barOffsetX).toBe((e.profile.width - 800) / 2)
|
||
expect(e.profile.barOffsetY).toBe(e.profile.height - 600)
|
||
expect(e.profile.panelOffsetY).toBe((e.profile.height - 600) / 2)
|
||
expect(formatViewportLabel(e.id)).toBe(e.label)
|
||
}
|
||
})
|
||
|
||
it('keeps the profile table, the settings whitelist and the selector order in sync, with unique widths', () => {
|
||
expect(Object.keys(VIEWPORT_PROFILES)).toEqual(Array.from(VALID_VIEWPORT_RESOLUTIONS))
|
||
expect(VALID_VIEWPORT_RESOLUTIONS).toEqual(['800x600', '1024x768', '1068x600', '1280x720'])
|
||
for (const [id, profile] of Object.entries(VIEWPORT_PROFILES)) {
|
||
expect(profile.id).toBe(id)
|
||
expect(`${profile.width}x${profile.height}`).toBe(id)
|
||
}
|
||
const widths = Object.values(VIEWPORT_PROFILES).map((p) => p.width)
|
||
expect(new Set(widths).size).toBe(widths.length)
|
||
})
|
||
|
||
it('fails fast on profiles that cannot host the native 1:1 HUD', () => {
|
||
expect(() => createViewportProfile('1280x720', 1280, 768)).toThrow(/does not match/)
|
||
expect(() => createViewportProfile('640x480' as ViewportResolution, 640, 480)).toThrow(
|
||
/smaller than the native/,
|
||
)
|
||
expect(() => createViewportProfile('1067x600' as ViewportResolution, 1067, 600)).toThrow(/even margins/)
|
||
expect(() => createViewportProfile('1068x601' as ViewportResolution, 1068, 601)).toThrow(/even margins/)
|
||
expect(() => createViewportProfile('1068.5x600' as ViewportResolution, 1068.5, 600)).toThrow(/integer/)
|
||
})
|
||
|
||
it('classifies only profiles larger than 800x600 as extended', () => {
|
||
expect(isExtendedViewport(VIEWPORT_800x600)).toBe(false)
|
||
expect(isExtendedViewport(VIEWPORT_1024x768)).toBe(true)
|
||
expect(isExtendedViewport(VIEWPORT_1068x600)).toBe(true)
|
||
expect(isExtendedViewport(VIEWPORT_1280x720)).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('Viewport profile lookups', () => {
|
||
it('resolves ids (case/whitespace-insensitive) and profile-like objects by exact size', () => {
|
||
expect(getViewportProfile('1068x600')).toBe(VIEWPORT_1068x600)
|
||
expect(getViewportProfile(' 1280X720 ')).toBe(VIEWPORT_1280x720)
|
||
expect(getViewportProfile(Object.assign({}, VIEWPORT_1280x720))).toBe(VIEWPORT_1280x720)
|
||
expect(getViewportProfile(VIEWPORT_1068x600)).toBe(VIEWPORT_1068x600)
|
||
// Documented permissive fallback (tier2 F17.B1); prototype keys are not mistaken for ids.
|
||
expect(getViewportProfile('toString')).toBe(VIEWPORT_800x600)
|
||
expect(getViewportProfile('1920x1080')).toBe(VIEWPORT_800x600)
|
||
})
|
||
|
||
it('strict size lookup returns registered profiles and throws for anything else', () => {
|
||
expect(getViewportProfileBySize(1068, 600)).toBe(VIEWPORT_1068x600)
|
||
expect(getViewportProfileBySize(1280, 720)).toBe(VIEWPORT_1280x720)
|
||
expect(getViewportProfileBySize(1024)).toBe(VIEWPORT_1024x768)
|
||
expect(() => getViewportProfileBySize(1920, 1080)).toThrow(/No viewport profile for 1920x1080/)
|
||
expect(() => getViewportProfileBySize(1024, 600)).toThrow(/No viewport profile/)
|
||
expect(() => getViewportProfileBySize(1366)).toThrow(/No viewport profile/)
|
||
})
|
||
})
|
||
|
||
describe('Settings, SettingsStore and Toolbar with the new resolutions', () => {
|
||
it('normalizes ids and bare-width shorthands, and still sanitizes unknown values to 800x600', () => {
|
||
expect(normalizeViewportResolution('1068x600')).toBe('1068x600')
|
||
expect(normalizeViewportResolution(' 1280X720 ')).toBe('1280x720')
|
||
expect(normalizeViewportResolution('1068')).toBe('1068x600')
|
||
expect(normalizeViewportResolution('1280')).toBe('1280x720')
|
||
expect(normalizeViewportResolution('1024')).toBe('1024x768')
|
||
expect(normalizeViewportResolution('1920x1080')).toBe('800x600')
|
||
expect(normalizeViewportResolution(1280)).toBe('800x600')
|
||
expect(sanitizeClientSettings({ viewport: '1280x720' }).viewport).toBe('1280x720')
|
||
})
|
||
|
||
it('persists 1068x600 across a SettingsStore reload', () => {
|
||
const storage = new MemoryStorage()
|
||
const store = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
|
||
store.set({ viewport: '1068x600' })
|
||
const reloaded = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
|
||
expect(reloaded.get().viewport).toBe('1068x600')
|
||
})
|
||
|
||
it('toggleViewport cycles 800x600 -> 1024x768 -> 1068x600 -> 1280x720 -> 800x600 and reports each profile', () => {
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const onViewportChange = vi.fn()
|
||
const toolbar = new Toolbar({ store, callbacks: { onViewportChange } })
|
||
expect(store.get().viewport).toBe('800x600')
|
||
|
||
expect(toolbar.toggleViewport()).toBe(VIEWPORT_1024x768)
|
||
expect(toolbar.toggleViewport()).toBe(VIEWPORT_1068x600)
|
||
expect(toolbar.toggleViewport()).toBe(VIEWPORT_1280x720)
|
||
expect(store.get().viewport).toBe('1280x720')
|
||
expect(onViewportChange).toHaveBeenLastCalledWith(VIEWPORT_1280x720)
|
||
expect(toolbar.toggleViewport()).toBe(VIEWPORT_800x600)
|
||
expect(store.get().viewport).toBe('800x600')
|
||
|
||
expect(toolbar.setViewport('1068x600')).toBe(VIEWPORT_1068x600)
|
||
expect(onViewportChange).toHaveBeenLastCalledWith(VIEWPORT_1068x600)
|
||
toolbar.destroy()
|
||
})
|
||
})
|
||
|
||
describe('HUD anchors, docking and interception for 1068x600 / 1280x720', () => {
|
||
it('anchors the bar centered at the bottom and the panels to the edges', () => {
|
||
const a1068 = computeViewportAnchors('1068x600')
|
||
expect(a1068.bottomBar).toEqual({ x: 134, y: 552, width: 800, height: 48 })
|
||
expect(a1068.leftPanel).toEqual({ x: 0, y: 0, width: 400, height: 552 })
|
||
expect(a1068.rightPanel).toEqual({ x: 668, y: 0, width: 400, height: 552 })
|
||
expect(a1068.centerGapWidth).toBe(268)
|
||
|
||
const a1280 = computeViewportAnchors('1280x720')
|
||
expect(a1280.bottomBar).toEqual({ x: 240, y: 672, width: 800, height: 48 })
|
||
expect(a1280.leftPanel).toEqual({ x: 0, y: 60, width: 400, height: 552 })
|
||
expect(a1280.rightPanel).toEqual({ x: 880, y: 60, width: 400, height: 552 })
|
||
expect(a1280.centerGapWidth).toBe(480)
|
||
})
|
||
|
||
it('docks panels at 0 and width-400 with a width-800 corridor (1024x768 keeps 624/224)', () => {
|
||
const cases = [
|
||
{ profile: VIEWPORT_1024x768, rightDockX: 624, corridor: 224 },
|
||
{ profile: VIEWPORT_1068x600, rightDockX: 668, corridor: 268 },
|
||
{ profile: VIEWPORT_1280x720, rightDockX: 880, corridor: 480 },
|
||
]
|
||
for (const { profile, rightDockX, corridor } of cases) {
|
||
const dock = computeDockingLayout(profile.width, profile.height, 1, profile)
|
||
expect(dock.uiScale).toBe(1)
|
||
expect(dock.marginW).toBe(0)
|
||
expect(dock.leftDockX).toBe(0)
|
||
expect(dock.rightDockX).toBe(rightDockX)
|
||
expect(dock.channelLeft).toBe(400)
|
||
expect(dock.channelRight).toBe(rightDockX)
|
||
expect(dock.channelWidthLogical).toBe(corridor)
|
||
expect(dock.channelWidthCss).toBe(corridor)
|
||
expect(dock.isWidescreen).toBe(true)
|
||
expect(dock.barOffsetX).toBe(profile.barOffsetX)
|
||
expect(dock.barOffsetY).toBe(profile.barOffsetY)
|
||
expect(dock.panelOffsetY).toBe(profile.panelOffsetY)
|
||
// render() shifts the natively-authored right panel (x = 400) onto its dock by rightDockX - 400.
|
||
expect(dock.rightDockX - 400).toBe(profile.width - 800)
|
||
}
|
||
|
||
// Scaled stage: 1280x720 stretched to 1920x1080 CSS keeps the logical dock and scales the corridor.
|
||
const scaled = computeDockingLayout(1920, 1080, 1, VIEWPORT_1280x720)
|
||
expect(scaled.uiScale).toBe(1.5)
|
||
expect(scaled.rightDockX).toBe(880)
|
||
expect(scaled.channelWidthCss).toBe(720)
|
||
|
||
// The classic 800x600 profile still docks against the window margins.
|
||
const classic = computeDockingLayout(1920, 1080)
|
||
expect(classic.marginW).toBeCloseTo(133.333, 2)
|
||
expect(classic.leftDockX).toBeCloseTo(-133.333, 2)
|
||
expect(classic.rightDockX).toBeCloseTo(533.333, 2)
|
||
})
|
||
|
||
it('intercepts panels and the bar in 1280x720 while leaving the 480px center corridor open', () => {
|
||
const hud = createHudAt(VIEWPORT_1280x720)
|
||
expect(hud.getViewportProfile()).toBe(VIEWPORT_1280x720)
|
||
hud.toggleLeftPanel('char')
|
||
hud.toggleRightPanel('inv')
|
||
|
||
expect(hud.isPointInterceptedByHud(200, 300)).toBe(true) // left panel, y from 60 to 613
|
||
expect(hud.isPointInterceptedByHud(200, 40)).toBe(false) // above the left panel
|
||
expect(hud.isPointInterceptedByHud(1000, 300)).toBe(true) // right panel, x from 880 to 1280
|
||
expect(hud.isPointInterceptedByHud(640, 300)).toBe(false) // corridor, x from 400 to 880
|
||
expect(hud.isPointInterceptedByHud(640, 700)).toBe(true) // bar, x from 240 to 1040, y from 672
|
||
expect(hud.isPointInterceptedByHud(100, 700)).toBe(false) // left of the bar
|
||
expect(hud.isPointInterceptedByHud(1200, 700)).toBe(false) // right of the bar
|
||
})
|
||
|
||
it('intercepts panels and the bar in 1068x600 while leaving the 268px center corridor open', () => {
|
||
const hud = createHudAt(VIEWPORT_1068x600)
|
||
expect(hud.getViewportProfile()).toBe(VIEWPORT_1068x600)
|
||
hud.toggleLeftPanel('char')
|
||
hud.toggleRightPanel('inv')
|
||
|
||
expect(hud.isPointInterceptedByHud(200, 300)).toBe(true) // left panel
|
||
expect(hud.isPointInterceptedByHud(900, 300)).toBe(true) // right panel, x from 668 to 1068
|
||
expect(hud.isPointInterceptedByHud(534, 300)).toBe(false) // corridor, x from 400 to 668
|
||
expect(hud.isPointInterceptedByHud(534, 580)).toBe(true) // bar, x from 134 to 934, y from 552
|
||
expect(hud.isPointInterceptedByHud(60, 580)).toBe(false) // left of the bar
|
||
expect(hud.isPointInterceptedByHud(1040, 580)).toBe(false) // right of the bar
|
||
})
|
||
|
||
it('HudManager.resize and ViewportCamera select the exact profile and reject unregistered sizes', () => {
|
||
const hud = createHudAt(VIEWPORT_800x600)
|
||
expect(hud.resize(1068, 600)).toBe(VIEWPORT_1068x600)
|
||
expect(hud.screenWidth).toBe(1068)
|
||
expect(hud.resize(1280, 720)).toBe(VIEWPORT_1280x720)
|
||
expect(hud.screenHeight).toBe(720)
|
||
expect(() => hud.resize(1920, 1080)).toThrow(/No viewport profile/)
|
||
expect(hud.getViewportProfile()).toBe(VIEWPORT_1280x720)
|
||
|
||
const camera = new ViewportCamera(1280, 720)
|
||
expect(camera.viewportProfile).toBe(VIEWPORT_1280x720)
|
||
camera.snapTo(100, 100)
|
||
expect(camera.subtileToScreen(100, 100)).toEqual({ x: 640, y: 360 })
|
||
expect(camera.screenToSubtile(640, 360)).toEqual({ x: 100, y: 100 })
|
||
camera.resize(1068, 600)
|
||
expect(camera.viewportProfile).toBe(VIEWPORT_1068x600)
|
||
expect(camera.subtileToScreen(100, 100)).toEqual({ x: 534, y: 300 })
|
||
expect(() => new ViewportCamera(1366, 768)).toThrow(/No viewport profile/)
|
||
})
|
||
|
||
it('fits 16:9 profiles into a 16:9 container without pillarboxing', () => {
|
||
const fit1280 = computeViewportFitSize('1280x720', 1920, 1080)
|
||
expect(fit1280).toMatchObject({
|
||
scale: 1.5,
|
||
cssWidth: 1920,
|
||
cssHeight: 1080,
|
||
letterboxLeft: 0,
|
||
letterboxTop: 0,
|
||
})
|
||
const fit1068 = computeViewportFitSize('1068x600', 1920, 1080)
|
||
expect(fit1068).toMatchObject({ cssWidth: 1920, cssHeight: 1079, letterboxLeft: 0, letterboxTop: 0 })
|
||
})
|
||
})
|
||
|
||
function makeTestUiItem(id: string, code: string, name: string, w = 1, h = 1): UiInventoryItem {
|
||
return {
|
||
id,
|
||
code,
|
||
invFile: code,
|
||
name,
|
||
nameZh: name,
|
||
baseNameZh: name,
|
||
quality: 'normal',
|
||
invWidth: w,
|
||
invHeight: h,
|
||
allowedSlots: ['helm'],
|
||
stats: [],
|
||
}
|
||
}
|
||
|
||
describe('HUD input and hover coordinate translation across all 4 viewport profiles (Issue #542)', () => {
|
||
it('converts logical <-> region-local coordinates using active docking offsets across all 4 profiles', () => {
|
||
for (const { profile } of EXPECTED) {
|
||
const docking = computeDockingLayout(profile.width, profile.height, 1, profile)
|
||
const deltaRight = docking.rightDockX - 400
|
||
|
||
expect(toControlBarLocal(263 + profile.barOffsetX, 582 + profile.barOffsetY, docking)).toEqual({
|
||
x: 263,
|
||
y: 582,
|
||
})
|
||
expect(toLeftDockLocal(217 + docking.leftDockX, 153 + profile.panelOffsetY, docking)).toEqual({
|
||
x: 217,
|
||
y: 153,
|
||
})
|
||
expect(fromLeftDockLocal(217, 153, docking)).toEqual({
|
||
x: 217 + docking.leftDockX,
|
||
y: 153 + profile.panelOffsetY,
|
||
})
|
||
expect(toRightDockLocal(432.5 + deltaRight, 330.5 + profile.panelOffsetY, docking)).toEqual({
|
||
x: 432.5,
|
||
y: 330.5,
|
||
})
|
||
expect(fromRightDockLocal(432.5, 330.5, docking)).toEqual({
|
||
x: 432.5 + deltaRight,
|
||
y: 330.5 + profile.panelOffsetY,
|
||
})
|
||
}
|
||
})
|
||
|
||
it.each(EXPECTED)(
|
||
'translates bottom control bar clicks and hovers ($id: barOffset=($barOffsetX,$barOffsetY))',
|
||
({ profile, barOffsetX, barOffsetY }) => {
|
||
const hud = createHudAt(profile)
|
||
const useBeltSlot = vi.fn(() => true)
|
||
hud.commandSink = { useBeltSlot } as unknown as HudCommandSink
|
||
|
||
// 1. Run/Walk toggle button (canonical 255..271, 572..592 -> center 263, 582)
|
||
const runX = 263 + barOffsetX
|
||
const runY = 582 + barOffsetY
|
||
hud.handleMouseMove(runX, runY)
|
||
expect(hud.controlBar.hoveredElement).toBe('run')
|
||
const prevRun = hud.isRunning
|
||
hud.handleClick(runX, runY, 0)
|
||
expect(hud.isRunning).toBe(!prevRun)
|
||
|
||
// 2. Mini-panel buttons (canonical 312..488, 522..548 -> char at 325, 535; inv at 352, 535)
|
||
const charBtnX = 325 + barOffsetX
|
||
const charBtnY = 535 + barOffsetY
|
||
hud.handleMouseMove(charBtnX, charBtnY)
|
||
expect(hud.controlBar.hoveredElement).toBe('char')
|
||
hud.handleClick(charBtnX, charBtnY, 0)
|
||
expect(hud.leftPanel).toBe('char')
|
||
|
||
const invBtnX = 352 + barOffsetX
|
||
const invBtnY = 535 + barOffsetY
|
||
hud.handleMouseMove(invBtnX, invBtnY)
|
||
expect(hud.controlBar.hoveredElement).toBe('inv')
|
||
hud.handleClick(invBtnX, invBtnY, 0)
|
||
expect(hud.rightPanel).toBe('inv')
|
||
|
||
// 3. Belt slot col 0, row 0 (canonical 423..451, 561..589 -> center 435, 575)
|
||
const beltX = 435 + barOffsetX
|
||
const beltY = 575 + barOffsetY
|
||
hud.handleMouseMove(beltX, beltY)
|
||
expect(hud.belt.hoveredSlot).toEqual({ row: 0, col: 0 })
|
||
hud.handleClick(beltX, beltY, 2)
|
||
expect(useBeltSlot).toHaveBeenCalledWith(0, false)
|
||
|
||
// 4. Life & Mana globes (canonical centers 68, 555 and 732, 555)
|
||
const lifeX = 68 + barOffsetX
|
||
const lifeY = 555 + barOffsetY
|
||
hud.handleMouseMove(lifeX, lifeY)
|
||
expect(hud.globes.lifeHovered).toBe(true)
|
||
const prevLifeLock = hud.globes.lifeTextLocked
|
||
hud.handleClick(lifeX, lifeY, 0)
|
||
expect(hud.globes.lifeTextLocked).toBe(!prevLifeLock)
|
||
|
||
const manaX = 732 + barOffsetX
|
||
const manaY = 555 + barOffsetY
|
||
hud.handleMouseMove(manaX, manaY)
|
||
expect(hud.globes.manaHovered).toBe(true)
|
||
const prevManaLock = hud.globes.manaTextLocked
|
||
hud.handleClick(manaX, manaY, 0)
|
||
expect(hud.globes.manaTextLocked).toBe(!prevManaLock)
|
||
|
||
// 5. Left & Right skill hotkey slots (canonical centers 141, 575 and 659, 575)
|
||
const leftSkillX = 141 + barOffsetX
|
||
const leftSkillY = 575 + barOffsetY
|
||
hud.handleClick(leftSkillX, leftSkillY, 0)
|
||
expect(hud.hotkeys.openPopup).toBe('left')
|
||
hud.handleClick(leftSkillX, leftSkillY, 0)
|
||
expect(hud.hotkeys.openPopup).toBeNull()
|
||
},
|
||
)
|
||
|
||
it.each(EXPECTED)(
|
||
'translates left dock clicks and reverse-translates stash/cube/vendor hover tooltips ($id: panelOffsetY=$panelOffsetY)',
|
||
({ profile, panelOffsetY }) => {
|
||
const hud = createHudAt(profile)
|
||
const docking = hud.getDockingLayout()
|
||
const leftDockX = docking.leftDockX
|
||
const allocateStat = vi.fn(() => true)
|
||
hud.commandSink = { allocateStat } as unknown as HudCommandSink
|
||
|
||
// 1. Character sheet stat '+' button (STR canonical 202..232, 138..168 -> center 217, 153)
|
||
hud.toggleLeftPanel('char')
|
||
hud.charSheet.attrs.statPoints = 5
|
||
hud.handleClick(217 + leftDockX, 153 + panelOffsetY, 0)
|
||
expect(allocateStat).toHaveBeenCalledWith('str')
|
||
|
||
// Character sheet close button (canonical 208..240, 448..480 -> center 224, 464)
|
||
hud.handleClick(224 + leftDockX, 464 + panelOffsetY, 0)
|
||
expect(hud.leftPanel).toBe('none')
|
||
|
||
// Clear commandSink to test offline stash pickup as well
|
||
hud.commandSink = null
|
||
|
||
// 2. Stash grid cell (col 0, row 0) click & hover tooltip writeback
|
||
hud.leftPanel = 'stash'
|
||
const stashItem = makeTestUiItem('stash-1', 'rin', 'Stone of Jordan')
|
||
hud.worldPanels.stashItems = [{ item: stashItem, col: 0, row: 0 }]
|
||
const stashHoverX = STASH_GRID_ORIGIN.x + 14.5 + leftDockX
|
||
const stashHoverY = STASH_GRID_ORIGIN.y + 14.5 + panelOffsetY
|
||
hud.handleMouseMove(stashHoverX, stashHoverY)
|
||
expect(hud.inventory.hoveredItem).toEqual({
|
||
item: stashItem,
|
||
x: STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
|
||
y: STASH_GRID_ORIGIN.y + panelOffsetY,
|
||
})
|
||
|
||
// Clicking stash cell (0, 0) picks up the item onto cursor
|
||
hud.handleClick(stashHoverX, stashHoverY, 0)
|
||
expect(hud.inventory.cursorItem?.id).toBe('stash-1')
|
||
hud.inventory.cursorItem = null
|
||
|
||
// 3. Cube grid cell (col 0, row 0) hover tooltip writeback
|
||
hud.leftPanel = 'cube'
|
||
const cubeItem = makeTestUiItem('cube-1', 'rvl', 'Full Rejuvenation Potion')
|
||
hud.worldPanels.cubeItems = [{ item: cubeItem, col: 0, row: 0 }]
|
||
const cubeHoverX = CUBE_GRID_ORIGIN.x + 14.5 + leftDockX
|
||
const cubeHoverY = CUBE_GRID_ORIGIN.y + 14.5 + panelOffsetY
|
||
hud.handleMouseMove(cubeHoverX, cubeHoverY)
|
||
expect(hud.inventory.hoveredItem).toEqual({
|
||
item: cubeItem,
|
||
x: CUBE_GRID_ORIGIN.x + 14.5 + leftDockX,
|
||
y: CUBE_GRID_ORIGIN.y + panelOffsetY,
|
||
})
|
||
|
||
// 4. Vendor grid cell (col 0, row 0) hover tooltip writeback
|
||
const akara = getTownNpcDescriptorByName('Akara')!
|
||
const vendorItem = makeTestUiItem('vendor-1', 'cap', 'Cap', 2, 2)
|
||
hud.worldPanels.setVendorShop({
|
||
npcId: 1,
|
||
descriptor: akara,
|
||
mode: 'trade',
|
||
items: [{ storePage: 1, placement: { item: vendorItem, col: 0, row: 0 } }],
|
||
})
|
||
hud.leftPanel = 'vendor'
|
||
const vendorHoverX = VENDOR_GRID_ORIGIN.x + 14.5 + leftDockX
|
||
const vendorHoverY = VENDOR_GRID_ORIGIN.y + 14.5 + panelOffsetY
|
||
hud.handleMouseMove(vendorHoverX, vendorHoverY)
|
||
expect(hud.inventory.hoveredItem).toEqual({
|
||
item: vendorItem,
|
||
x: VENDOR_GRID_ORIGIN.x + 29 + leftDockX,
|
||
y: VENDOR_GRID_ORIGIN.y + panelOffsetY,
|
||
})
|
||
},
|
||
)
|
||
|
||
it.each(EXPECTED)(
|
||
'translates right dock inventory/equipment/skill-tree clicks and hover tooltips ($id: panelOffsetY=$panelOffsetY)',
|
||
({ profile, panelOffsetY }) => {
|
||
const hud = createHudAt(profile)
|
||
const docking = hud.getDockingLayout()
|
||
const deltaRight = docking.rightDockX - 400
|
||
|
||
// 1. Inventory grid cell (col 0, row 0) hover & click at docked right panel coordinates
|
||
hud.toggleRightPanel('inv')
|
||
const bagItem = makeTestUiItem('bag-1', 'rin', 'Bul-Kathos Ring')
|
||
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
|
||
const invHoverX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
|
||
const invHoverY = INV_GRID_ORIGIN.y + 14.5 + panelOffsetY
|
||
|
||
hud.handleMouseMove(invHoverX, invHoverY)
|
||
expect(hud.inventory.hoveredItem).toEqual({
|
||
item: bagItem,
|
||
x: INV_GRID_ORIGIN.x + 14.5 + deltaRight,
|
||
y: INV_GRID_ORIGIN.y + panelOffsetY,
|
||
})
|
||
|
||
// Clicking at the docked right-panel coordinate picks up the item
|
||
hud.handleClick(invHoverX, invHoverY, 0)
|
||
expect(hud.inventory.cursorItem?.id).toBe('bag-1')
|
||
hud.inventory.cursorItem = null
|
||
|
||
// 2. Equipped helm slot (canonical 532..590, 64..122 -> center 561, 93; tooltip anchor 561, 64)
|
||
const helmItem = makeTestUiItem('helm-1', 'ci3', 'Griffon Eye', 2, 2)
|
||
hud.inventory.equipped.helm = helmItem
|
||
hud.handleMouseMove(561 + deltaRight, 93 + panelOffsetY)
|
||
expect(hud.inventory.hoveredItem).toEqual({
|
||
item: helmItem,
|
||
x: 561 + deltaRight,
|
||
y: 64 + panelOffsetY,
|
||
})
|
||
|
||
// 2b. Tooltip box clamping uses active viewport width/height (not hardcoded 800x600)
|
||
hud.inventory.gridItems = [{ item: bagItem, col: 9, row: 0 }]
|
||
const rightColHoverX = INV_GRID_ORIGIN.x + 9 * 29 + 14.5 + deltaRight
|
||
const rightColHoverY = INV_GRID_ORIGIN.y + 14.5 + panelOffsetY
|
||
hud.handleMouseMove(rightColHoverX, rightColHoverY)
|
||
const mockCtx = {
|
||
fillStyle: '',
|
||
strokeStyle: '',
|
||
save: () => {},
|
||
restore: () => {},
|
||
fillRect: () => {},
|
||
strokeRect: () => {},
|
||
fillText: () => {},
|
||
drawImage: () => {},
|
||
measureText: () => ({ width: 50 }),
|
||
} as unknown as CanvasRenderingContext2D
|
||
hud.worldPanels.drawItemTooltip(
|
||
mockCtx,
|
||
hud.inventory.hoveredItem,
|
||
hud.font,
|
||
false,
|
||
profile.width,
|
||
profile.height,
|
||
)
|
||
expect(hud.worldPanels.lastTooltipRect).not.toBeNull()
|
||
const tipRect = hud.worldPanels.lastTooltipRect!
|
||
expect(tipRect.bx + tipRect.boxW).toBeLessThanOrEqual(profile.width - 8)
|
||
if (profile.width > 800) {
|
||
expect(tipRect.bx + tipRect.boxW).toBeGreaterThan(800)
|
||
}
|
||
|
||
// 3. Skill tree node hover & click at docked right panel coordinates
|
||
hud.toggleRightPanel('skill')
|
||
hud.skillTree.setClass('sor')
|
||
hud.skillTree.characterLevel = 30
|
||
hud.skillTree.unspentSkillPoints = 5
|
||
const allocateSkill = vi.fn(() => true)
|
||
hud.commandSink = { allocateSkill } as unknown as HudCommandSink
|
||
|
||
const firstTabNode = hud.skillTree
|
||
.getSkillTree()
|
||
.find((n) => n.tab === hud.skillTree.activeTab && n.prereqs.length === 0)!
|
||
const nodeRect = SkillTreePanel.nodeScreenPos(firstTabNode)
|
||
const nodeClickX = nodeRect.x + nodeRect.w / 2 + deltaRight
|
||
const nodeClickY = nodeRect.y + nodeRect.h / 2 + panelOffsetY
|
||
|
||
hud.handleMouseMove(nodeClickX, nodeClickY)
|
||
expect(hud.skillTree.hoveredSkill?.skillId).toBe(firstTabNode.skillId)
|
||
|
||
hud.handleClick(nodeClickX, nodeClickY, 0)
|
||
expect(allocateSkill).toHaveBeenCalledWith(firstTabNode.skillId)
|
||
},
|
||
)
|
||
|
||
it('does not trigger right-dock or bottom-bar actions when clicking old un-offset 800x600 coordinates in 1024x768 and 1280x720', () => {
|
||
for (const profile of [VIEWPORT_1024x768, VIEWPORT_1280x720]) {
|
||
const hud = createHudAt(profile)
|
||
hud.toggleRightPanel('inv')
|
||
const bagItem = makeTestUiItem('bag-corridor', 'rin', 'Ring')
|
||
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
|
||
|
||
// Un-offset (432.5, 330.5) falls inside the open center corridor (400..624 in 1024x768, 400..880 in 1280x720)
|
||
expect(hud.isPointInterceptedByHud(432.5, 330.5)).toBe(false)
|
||
hud.handleClick(432.5, 330.5, 0)
|
||
expect(hud.inventory.cursorItem).toBeNull()
|
||
|
||
// Un-offset Run button (263, 582) is above the bottom bar in 1024x768 (bar y=708..768) and 1280x720 (bar y=660..720)
|
||
const prevRun = hud.isRunning
|
||
hud.handleClick(263, 582, 0)
|
||
expect(hud.isRunning).toBe(prevRun)
|
||
}
|
||
})
|
||
|
||
it.each(EXPECTED)(
|
||
'translates window mousemove listener hover coordinates and pointer-events across $id',
|
||
({ profile, barOffsetX, barOffsetY, panelOffsetY }) => {
|
||
const prevWindow = (globalThis as { window?: unknown }).window
|
||
const mockWindow = Object.assign(new EventTarget(), {
|
||
innerWidth: profile.width,
|
||
innerHeight: profile.height,
|
||
devicePixelRatio: 1,
|
||
})
|
||
;(globalThis as { window?: unknown }).window = mockWindow
|
||
try {
|
||
const hud = createHudAt(profile)
|
||
const hudCanvas = (hud as unknown as { hudCanvas: HTMLCanvasElement }).hudCanvas
|
||
const docking = hud.getDockingLayout()
|
||
const deltaRight = docking.rightDockX - 400
|
||
const leftDockX = docking.leftDockX
|
||
|
||
hud.toggleRightPanel('inv')
|
||
hud.leftPanel = 'stash'
|
||
const bagItem = makeTestUiItem('win-bag-1', 'rin', 'Raven Frost')
|
||
const stashItem = makeTestUiItem('win-stash-1', 'amu', 'Highlord Wrath')
|
||
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
|
||
hud.worldPanels.stashItems = [{ item: stashItem, col: 0, row: 0 }]
|
||
|
||
// Dispatch window mousemove over right-dock inventory item
|
||
const invClient = hud.logicalToClient(
|
||
INV_GRID_ORIGIN.x + 14.5 + deltaRight,
|
||
INV_GRID_ORIGIN.y + 14.5 + panelOffsetY,
|
||
)
|
||
const invEv = Object.assign(new Event('mousemove'), {
|
||
clientX: invClient.x,
|
||
clientY: invClient.y,
|
||
})
|
||
mockWindow.dispatchEvent(invEv)
|
||
expect(hud.inventory.hoveredItem).toEqual({
|
||
item: bagItem,
|
||
x: INV_GRID_ORIGIN.x + 14.5 + deltaRight,
|
||
y: INV_GRID_ORIGIN.y + panelOffsetY,
|
||
})
|
||
expect(hudCanvas.style.pointerEvents).toBe('auto')
|
||
|
||
// Dispatch window mousemove over left-dock stash item
|
||
const stashClient = hud.logicalToClient(
|
||
STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
|
||
STASH_GRID_ORIGIN.y + 14.5 + panelOffsetY,
|
||
)
|
||
const stashEv = Object.assign(new Event('mousemove'), {
|
||
clientX: stashClient.x,
|
||
clientY: stashClient.y,
|
||
})
|
||
mockWindow.dispatchEvent(stashEv)
|
||
expect(hud.inventory.hoveredItem).toEqual({
|
||
item: stashItem,
|
||
x: STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
|
||
y: STASH_GRID_ORIGIN.y + panelOffsetY,
|
||
})
|
||
expect(hudCanvas.style.pointerEvents).toBe('auto')
|
||
|
||
// Dispatch window mousemove over bottom bar Run/Walk toggle button
|
||
const runClient = hud.logicalToClient(263 + barOffsetX, 582 + barOffsetY)
|
||
const runEv = Object.assign(new Event('mousemove'), {
|
||
clientX: runClient.x,
|
||
clientY: runClient.y,
|
||
})
|
||
mockWindow.dispatchEvent(runEv)
|
||
expect(hud.controlBar.hoveredElement).toBe('run')
|
||
expect(hudCanvas.style.pointerEvents).toBe('auto')
|
||
} finally {
|
||
if (prevWindow === undefined) {
|
||
delete (globalThis as { window?: unknown }).window
|
||
} else {
|
||
;(globalThis as { window?: unknown }).window = prevWindow
|
||
}
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
describe('Skill tooltips and full HUD UI audit across >800x600 viewports (Issue #557)', () => {
|
||
it.each(EXPECTED)(
|
||
'synchronizes skillTree.visible on direct rightPanel assignment and renders SkillTreePanel & Hotkeys skill tooltips on top layer ($id)',
|
||
({ profile, barOffsetX, barOffsetY, panelOffsetY }) => {
|
||
const hud = createHudAt(profile)
|
||
const docking = hud.getDockingLayout()
|
||
const deltaRight = docking.rightDockX - 400
|
||
|
||
// 1. Direct assignment (as done by HudModel.syncFromWorld) must keep skillTree.visible in sync
|
||
hud.rightPanel = 'skill'
|
||
expect(hud.skillTree.visible).toBe(true)
|
||
hud.skillTree.setClass('sor')
|
||
hud.skillTree.characterLevel = 30
|
||
hud.skillTree.hardPoints.set(36, 5) // Fire Bolt
|
||
|
||
const col1Node = hud.skillTree
|
||
.getSkillTree()
|
||
.find((n) => n.tab === hud.skillTree.activeTab && n.col === 1)!
|
||
const nodeRect = SkillTreePanel.nodeScreenPos(col1Node)
|
||
hud.handleMouseMove(
|
||
nodeRect.x + nodeRect.w / 2 + deltaRight,
|
||
nodeRect.y + nodeRect.h / 2 + panelOffsetY,
|
||
)
|
||
expect(hud.skillTree.hoveredSkill?.skillId).toBe(col1Node.skillId)
|
||
|
||
// Verify tooltip placement with minLocalX = 8 - deltaRight does not clamp over col-1 node on >800x600
|
||
const placement = SkillTreePanel.computeTooltipPlacement(
|
||
nodeRect,
|
||
[{ text: 'Fire Bolt Level 5 (Mana Cost: 2.5)', color: 'gold', font: 'font8' }],
|
||
hud.font,
|
||
8 - deltaRight,
|
||
)
|
||
if (deltaRight > 0) {
|
||
expect(placement.x + placement.w).toBeLessThanOrEqual(nodeRect.x)
|
||
}
|
||
|
||
// 2. Quickbar active skill slot hover & Speedbar popup skill hover at translated barOffset
|
||
hud.hotkeys.setAvailableSkills(
|
||
[
|
||
{
|
||
skillId: 0,
|
||
name: 'Attack',
|
||
nameZh: '攻击',
|
||
level: 1,
|
||
manaCost: 0,
|
||
leftUsable: true,
|
||
rightUsable: true,
|
||
},
|
||
{
|
||
skillId: 36,
|
||
name: 'Fire Bolt',
|
||
nameZh: '火弹',
|
||
level: 5,
|
||
manaCost: 3,
|
||
leftUsable: true,
|
||
rightUsable: true,
|
||
},
|
||
{
|
||
skillId: 38,
|
||
name: 'Charged Bolt',
|
||
nameZh: '充能弹',
|
||
level: 3,
|
||
manaCost: 4,
|
||
leftUsable: true,
|
||
rightUsable: true,
|
||
},
|
||
],
|
||
36,
|
||
38,
|
||
)
|
||
|
||
const leftSlotX = 141 + barOffsetX
|
||
const leftSlotY = 575 + barOffsetY
|
||
hud.handleMouseMove(leftSlotX, leftSlotY)
|
||
expect(hud.hotkeys.hoveredSlot).toBe('left')
|
||
expect(hud.hotkeys.hoverSkillId).toBe(36)
|
||
|
||
// Open left speedbar popup and hover its first skill cell
|
||
hud.handleClick(leftSlotX, leftSlotY, 0)
|
||
expect(hud.hotkeys.openPopup).toBe('left')
|
||
const popupCells = hud.hotkeys.getPopupCells('left')
|
||
expect(popupCells.length).toBeGreaterThan(0)
|
||
const firstCell = popupCells[0]!
|
||
hud.handleMouseMove(firstCell.x + 24 + barOffsetX, firstCell.y + 24 + barOffsetY)
|
||
expect(hud.hotkeys.hoveredPopupSkill).toEqual({
|
||
side: 'left',
|
||
skillId: firstCell.skill.skillId,
|
||
})
|
||
expect(hud.hotkeys.hoverSpeedbarSkillId).toBe(firstCell.skill.skillId)
|
||
|
||
// 3. Verify HudManager.render invokes hotkeys.draw AFTER belt.draw, and draws skillTree tooltip on top layer
|
||
const callOrder: string[] = []
|
||
const origBeltDraw = hud.belt.draw.bind(hud.belt)
|
||
const origHotkeysDraw = hud.hotkeys.draw.bind(hud.hotkeys)
|
||
const origSkillTooltipDraw = hud.skillTree.drawSkillTooltip.bind(hud.skillTree)
|
||
vi.spyOn(hud.belt, 'draw').mockImplementation((...args) => {
|
||
callOrder.push('belt.draw')
|
||
return origBeltDraw(...args)
|
||
})
|
||
vi.spyOn(hud.hotkeys, 'draw').mockImplementation((...args) => {
|
||
callOrder.push('hotkeys.draw')
|
||
return origHotkeysDraw(...args)
|
||
})
|
||
vi.spyOn(hud.skillTree, 'drawSkillTooltip').mockImplementation((...args) => {
|
||
callOrder.push('skillTree.drawSkillTooltip')
|
||
return origSkillTooltipDraw(...args)
|
||
})
|
||
|
||
// Hover the skill tree node again before rendering so skillTree.hoveredSkill is active during render()
|
||
hud.handleMouseMove(
|
||
nodeRect.x + nodeRect.w / 2 + deltaRight,
|
||
nodeRect.y + nodeRect.h / 2 + panelOffsetY,
|
||
)
|
||
expect(hud.skillTree.hoveredSkill?.skillId).toBe(col1Node.skillId)
|
||
|
||
hud.render(1000)
|
||
|
||
expect(callOrder.indexOf('belt.draw')).toBeGreaterThanOrEqual(0)
|
||
expect(callOrder.indexOf('hotkeys.draw')).toBeGreaterThan(callOrder.indexOf('belt.draw'))
|
||
expect(callOrder.indexOf('skillTree.drawSkillTooltip')).toBeGreaterThan(
|
||
callOrder.indexOf('hotkeys.draw'),
|
||
)
|
||
},
|
||
)
|
||
|
||
it.each(EXPECTED)(
|
||
'translates Mercenary, Trade, and Quest Reward left-dock item hover tooltips and clamps NPC menu / Area Banner / Death Overlay ($id)',
|
||
({ profile, panelOffsetY }) => {
|
||
const hud = createHudAt(profile)
|
||
const docking = hud.getDockingLayout()
|
||
const leftDockX = docking.leftDockX
|
||
|
||
// 1. Mercenary equipment hover tooltip (helm slot: x=212..268, y=104..160 -> center 240, 132)
|
||
hud.leftPanel = 'merc'
|
||
const mercHelm = makeTestUiItem('merc-helm', 'usk', 'Andariel Visage', 2, 2)
|
||
hud.worldPanels.merc = { headItem: mercHelm }
|
||
hud.handleMouseMove(240 + leftDockX, 132 + panelOffsetY)
|
||
expect(hud.inventory.hoveredItem).toEqual({
|
||
item: mercHelm,
|
||
x: 240 + leftDockX,
|
||
y: 104 + panelOffsetY,
|
||
})
|
||
|
||
// 2. Quest Reward (imbue) slot hover tooltip (x=196..284, y=180..296 -> center 240, 238)
|
||
hud.leftPanel = 'imbue'
|
||
const imbueItem = makeTestUiItem('imbue-boots', 'xtb', 'War Boots', 2, 2)
|
||
hud.worldPanels.questRewardItem = imbueItem
|
||
hud.handleMouseMove(240 + leftDockX, 238 + panelOffsetY)
|
||
expect(hud.inventory.hoveredItem).toEqual({
|
||
item: imbueItem,
|
||
x: 240 + leftDockX,
|
||
y: 180 + panelOffsetY,
|
||
})
|
||
|
||
// 3. NPC interaction menu clamps against active viewport width/height (not hardcoded 800x600)
|
||
const akara = getTownNpcDescriptorByName('Akara')!
|
||
hud.worldPanels.openNpcMenu(akara, profile.width - 60, 220, 1)
|
||
expect(
|
||
hud.worldPanels.isPointInNpcMenu(
|
||
profile.width - 60,
|
||
240,
|
||
hud.font,
|
||
profile.width,
|
||
profile.height,
|
||
),
|
||
).toBe(true)
|
||
expect(hud.isPointInterceptedByHud(profile.width - 60, 240)).toBe(true)
|
||
hud.worldPanels.npcMenu = null
|
||
|
||
// 4. Area Banner & Death Overlay center across profile.width / profile.height
|
||
hud.leftPanel = 'none'
|
||
hud.rightPanel = 'none'
|
||
hud.worldPanels.areaBanner = {
|
||
titleZh: '鲜血荒地',
|
||
titleEn: 'Blood Moor',
|
||
untilMs: 5000,
|
||
}
|
||
hud.isDead = true
|
||
|
||
const fillRectCalls: Array<[number, number, number, number]> = []
|
||
const drawTextXs: number[] = []
|
||
const hudCanvas = (hud as unknown as { hudCanvas: HTMLCanvasElement }).hudCanvas
|
||
const hudCtx = hudCanvas.getContext('2d')!
|
||
const origFillRect = hudCtx.fillRect.bind(hudCtx)
|
||
vi.spyOn(hudCtx, 'fillRect').mockImplementation((x, y, w, h) => {
|
||
fillRectCalls.push([x, y, w, h])
|
||
return origFillRect(x, y, w, h)
|
||
})
|
||
vi.spyOn(hud.font, 'drawText').mockImplementation((_ctx, _text, x) => {
|
||
drawTextXs.push(x)
|
||
})
|
||
|
||
hud.render(1500)
|
||
|
||
// Death overlay must cover (0, 0, profile.width, profile.height)
|
||
expect(fillRectCalls).toContainEqual([0, 0, profile.width, profile.height])
|
||
// Area banner and Death overlay text must center at Math.round(profile.width / 2)
|
||
expect(drawTextXs).toContain(Math.round(profile.width / 2))
|
||
},
|
||
)
|
||
})
|
||
|
||
|