diablo2-web/tests/client/viewport-formula-profiles.t...

964 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))
},
)
})