diff --git a/play.html b/play.html index ba3cd63..1d0f1cb 100644 --- a/play.html +++ b/play.html @@ -234,8 +234,11 @@ } #bnet-canvas { - width: 100%; + /* The 800x600 front-end stays 4:3 (pillarboxed) when the stage is sized for a 16:9 profile. */ height: 100%; + width: auto; + max-width: 100%; + aspect-ratio: 4 / 3; display: block; image-rendering: pixelated; } diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index c9702c1..8ef5dda 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -1,8 +1,8 @@ /** * Input -> ClientCommand Mapper (Milestone M5 — F17) * - * Translates browser mouse/pointer and keyboard events across 800×600 and 1024×768 - * viewports into rate-limited `ClientCommand`s and local movement predictions: + * Translates browser mouse/pointer and keyboard events in any registered viewport profile + * into rate-limited `ClientCommand`s and local movement predictions: * - Screen-to-world subtile conversion via `screenToSubTile` (isometric 2:1 projection). * - HUD hit-test gating via `HudManager.isPointInterceptedByHud(x, y)` so clicks on * status bar, orbs, belt, skill popups, or open panels never trigger ground movement. @@ -30,6 +30,7 @@ import type { HudModel } from '../ui-model/hud-model.ts' import { VIEWPORT_800x600, getViewportProfile, + getViewportProfileBySize, screenToSubTile, subTileToScreen, type ViewportProfile, @@ -50,7 +51,7 @@ export interface CameraLike { } /** - * Isometric sub-tile camera tracking the player center across `800×600` and `1024×768` viewports. + * Isometric sub-tile camera tracking the player center for any registered `ViewportProfile`. */ export class ViewportCamera implements CameraLike { subX = 0 @@ -61,13 +62,10 @@ export class ViewportCamera implements CameraLike { widthOrProfile: number | ViewportProfile | ViewportResolution = VIEWPORT_800x600, height?: number, ) { - if (typeof widthOrProfile === 'number') { - this.viewportProfile = getViewportProfile( - widthOrProfile === 1024 || height === 768 ? '1024x768' : '800x600', - ) - } else { - this.viewportProfile = getViewportProfile(widthOrProfile) - } + this.viewportProfile = + typeof widthOrProfile === 'number' + ? getViewportProfileBySize(widthOrProfile, height) + : getViewportProfile(widthOrProfile) } screenToSubtile(screenX: number, screenY: number): { readonly x: number; readonly y: number } { @@ -93,13 +91,10 @@ export class ViewportCamera implements CameraLike { } resize(widthOrProfile: number | ViewportProfile | ViewportResolution, height?: number): void { - if (typeof widthOrProfile === 'number') { - this.viewportProfile = getViewportProfile( - widthOrProfile === 1024 || height === 768 ? '1024x768' : '800x600', - ) - } else { - this.viewportProfile = getViewportProfile(widthOrProfile) - } + this.viewportProfile = + typeof widthOrProfile === 'number' + ? getViewportProfileBySize(widthOrProfile, height) + : getViewportProfile(widthOrProfile) } } @@ -225,7 +220,7 @@ export class CommandMapper { /** * Convert screen coordinates `(screenX, screenY)` (in canvas pixels, supporting - * 800×600 and 1024×768 viewports) to world subtile coordinates and hit-test units. + * any registered `ViewportProfile`) to world subtile coordinates and hit-test units. */ resolvePointerTarget(screenX: number, screenY: number, hitRadiusSubtiles = 2.5): PointerWorldTarget { if (this.predictor) { @@ -292,7 +287,7 @@ export class CommandMapper { } /** - * Returns true if `(screenX, screenY)` is intercepted by the HUD (`800×600` or `1024×768`) + * Returns true if `(screenX, screenY)` is intercepted by the HUD (any `ViewportProfile`) * or an external UI overlay. */ isPointIntercepted(screenX: number, screenY: number): boolean { diff --git a/src/client/main.ts b/src/client/main.ts index 207bca4..16b047f 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -5,7 +5,7 @@ * - Persistent 32px Top Toolbar (`Toolbar` + `SettingsStore`) * - Battle.net Pre-Game & Live Lobby (`FrontendRenderer` + `BnetFlowState` + `OnlineSession`) * - In-Game Isometric World + Automap + Lighting (`ClientWorldSceneSource`, `AutomapView`) - * - Authentic D2 HUD Overlay (`HudManager` + `HudModel` + `CommandMapper` across 800×600 & 1024×768) + * - Authentic D2 HUD Overlay (`HudManager` + `HudModel` + `CommandMapper` for every registered `ViewportProfile`) * - Dockable Packet Inspector & Deterministic `.d2cap` Capture Replay (`PacketInspector`) * - Graceful disconnect & error recovery banner (`#play-disconnect-banner`) * @@ -396,7 +396,7 @@ async function bootPlayApp(): Promise { toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes) }) - // Apply initial viewport dimensions (800×600 or 1024×768) stretched to fit browser + // Apply the initial viewport profile's dimensions, stretched to fit the browser at its own aspect ratio syncStageViewportFit(initialSettings.viewport) window.addEventListener('resize', () => { diff --git a/src/client/settings/client-settings.ts b/src/client/settings/client-settings.ts index 3a3cb3c..b992db7 100644 --- a/src/client/settings/client-settings.ts +++ b/src/client/settings/client-settings.ts @@ -13,7 +13,7 @@ export type ClientLightingPreset = | 'fullbright' export type AutomapRevealMode = 'off' | 'level' | 'act' -export type ViewportResolution = '800x600' | '1024x768' +export type ViewportResolution = '800x600' | '1024x768' | '1068x600' | '1280x720' export interface ClientSettings { readonly version: 1 @@ -46,6 +46,8 @@ export const VALID_AUTOMAP_REVEAL_MODES: readonly AutomapRevealMode[] = [ export const VALID_VIEWPORT_RESOLUTIONS: readonly ViewportResolution[] = [ '800x600', '1024x768', + '1068x600', + '1280x720', ] as const export function createDefaultClientSettings(defaultLang?: GameLang): ClientSettings { @@ -292,7 +294,10 @@ export function normalizeAutomapReveal(value: unknown): AutomapRevealMode { export function normalizeViewportResolution(value: unknown): ViewportResolution { if (typeof value !== 'string') return '800x600' const clean = value.trim().toLowerCase() - if (clean === '1024x768' || clean === '1024') return '1024x768' + for (const id of VALID_VIEWPORT_RESOLUTIONS) { + // Canonical `WxH` id, or the bare width shorthand (`'1280'`): registered widths are unique. + if (clean === id || clean === id.slice(0, id.indexOf('x'))) return id + } return '800x600' } diff --git a/src/client/toolbar/toolbar.ts b/src/client/toolbar/toolbar.ts index 666eccc..44982b1 100644 --- a/src/client/toolbar/toolbar.ts +++ b/src/client/toolbar/toolbar.ts @@ -25,6 +25,7 @@ import { formatCacheBytesMb, } from '../sw/register.ts' import { + formatViewportLabel, getViewportProfile, type ViewportProfile, } from '../view/viewport-profile.ts' @@ -87,7 +88,7 @@ export interface ToolbarSnapshot { * 2. Live Language Toggle (`中` / `EN`) * 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`) * 4. Automap Reveal Selector (`off | level | act`) - * 5. Viewport Size Toggle (`800x600 | 1024x768`) + Asset Cache Clear button + * 5. Viewport Size Selector (`800x600 | 1024x768 | 1068x600 | 1280x720`) + Asset Cache Clear button */ export class Toolbar { private readonly store: SettingsStore @@ -312,9 +313,10 @@ export class Toolbar { return getViewportProfile(viewport) } + /** Advance to the next resolution in `VALID_VIEWPORT_RESOLUTIONS` order, wrapping back to the first. */ toggleViewport(): ViewportProfile { - const next: ViewportResolution = - this.store.get().viewport === '800x600' ? '1024x768' : '800x600' + const order = VALID_VIEWPORT_RESOLUTIONS + const next = order[(order.indexOf(this.store.get().viewport) + 1) % order.length] return this.setViewport(next) } @@ -614,7 +616,7 @@ export class Toolbar { automapGroup.appendChild(automapLabel) automapGroup.appendChild(automapSelect) - // Group 5: Viewport Size Selector (800x600 / 1024x768) + Asset Cache Clear button + // Group 5: Viewport Size Selector (every VALID_VIEWPORT_RESOLUTIONS entry) + Asset Cache Clear button const viewportGroup = doc.createElement('div') viewportGroup.className = 'd2-toolbar__group' const viewportLabel = doc.createElement('label') @@ -626,7 +628,7 @@ export class Toolbar { for (const vp of VALID_VIEWPORT_RESOLUTIONS) { const opt = doc.createElement('option') opt.value = vp - opt.textContent = vp === '800x600' ? '800×600' : '1024×768' + opt.textContent = formatViewportLabel(vp) viewportSelect.appendChild(opt) } viewportSelect.addEventListener('change', () => { diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 4e60e81..f1b3271 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -66,6 +66,8 @@ import { requireTbl } from '../i18n/lang.ts' import { computeViewportAnchors, getViewportProfile, + getViewportProfileBySize, + isExtendedViewport, VIEWPORT_800x600, type ViewportAnchorMetrics, type ViewportProfile, @@ -170,7 +172,7 @@ export interface DockingLayout extends HudLayout { /** * Computes 1:1 uniform aspect ratio scaling and horizontal centering margins - * for the canonical Diablo II HUD viewport (`800x600` or `1024x768`) onto an arbitrary window/canvas size. + * for the active Diablo II HUD viewport (`ViewportProfile` size) onto an arbitrary window/canvas size. */ export function computeHudLayout( cssW: number, @@ -217,6 +219,10 @@ export { /** * Computes widescreen panel docking layout metrics, margins, and central vision corridor. + * + * Extended profiles (larger than 800×600) pin the 400px dock panels to the logical viewport edges: + * left dock at `x = 0`, right dock at `x = width - 400`, corridor `400..width - 400`. The 800×600 + * profile docks the panels against the browser window margins instead (`marginW`). */ export function computeDockingLayout( cssW: number, @@ -224,18 +230,20 @@ export function computeDockingLayout( dpr: number = 1, profile: ViewportProfile = VIEWPORT_800x600, ): DockingLayout { - if (profile.width === 1024) { + if (isExtendedViewport(profile)) { const layout = computeHudLayout(cssW, cssH, dpr, profile) const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0 + const rightDockX = profile.width - 400 + const channelWidthLogical = rightDockX - 400 return { ...layout, marginW, leftDockX: 0, - rightDockX: 624, + rightDockX, channelLeft: 400, - channelRight: 624, - channelWidthLogical: 224, - channelWidthCss: 224 * layout.uiScale, + channelRight: rightDockX, + channelWidthLogical, + channelWidthCss: channelWidthLogical * layout.uiScale, isWidescreen: true, barOffsetX: profile.barOffsetX, barOffsetY: profile.barOffsetY, @@ -687,9 +695,9 @@ export class HudManager { return this.viewportProfile.height } + /** Switch to the registered profile of exactly this logical size; throws for an unregistered size. */ resize(width: number, height?: number): ViewportProfile { - const isWidescreen = width >= 1024 || (height !== undefined && height >= 768) - return this.setViewportProfile(isWidescreen ? '1024x768' : '800x600') + return this.setViewportProfile(getViewportProfileBySize(width, height)) } load(baseUrl = '/ui'): Promise { @@ -1185,7 +1193,7 @@ export class HudManager { } /** - * Convert client CSS pixel coordinates into the active Diablo II logical viewport (`800x600` or `1024x768`), + * Convert client CSS pixel coordinates into the active Diablo II logical viewport (`ViewportProfile` size), * inverting 1:1 uniform aspect ratio scaling and horizontal centering margins. */ clientToLogical(clientX: number, clientY: number): { x: number; y: number } { @@ -1236,8 +1244,8 @@ export class HudManager { const docking = this.getDockingLayout() const { marginW } = docking - if (profile.width === 1024) { - if (logicalX < -marginW || logicalX > 1024 + marginW || logicalY < 0 || logicalY > 768) { + if (isExtendedViewport(profile)) { + if (logicalX < -marginW || logicalX > profile.width + marginW || logicalY < 0 || logicalY > profile.height) { return false } if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true @@ -1253,7 +1261,8 @@ export class HudManager { if (this.leftPanel !== 'none' && logicalX >= 0 && logicalX <= 400 && panelY >= 0 && panelY <= 553) { return true } - if (this.rightPanel !== 'none' && logicalX >= 624 && logicalX <= 1024 && panelY >= 0 && panelY <= 553) { + const rightDockX = profile.width - 400 + if (this.rightPanel !== 'none' && logicalX >= rightDockX && logicalX <= profile.width && panelY >= 0 && panelY <= 553) { return true } return false @@ -2237,7 +2246,7 @@ export class HudManager { } /** - * Tick stamina drain/recovery and draw the complete Diablo II HUD (`800x600` or `1024x768`). + * Tick stamina drain/recovery and draw the complete Diablo II HUD for the active `ViewportProfile`. */ render( nowMs: number = performance.now(), @@ -2262,9 +2271,10 @@ export class HudManager { } const docking = computeDockingLayout(cssW, cssH, dpr, profile) - const { uiScale, offsetX, offsetY, marginW } = docking - const deltaLeft = profile.width === 1024 ? 0 : -marginW - const deltaRight = profile.width === 1024 ? 224 : marginW + const { uiScale, offsetX, offsetY } = docking + // Panels are authored in native 800x600 coordinates (left at x = 0, right at x = 400); shift them onto their docks. + const deltaLeft = docking.leftDockX + const deltaRight = docking.rightDockX - 400 const panelOffsetY = profile.panelOffsetY const barOffsetX = profile.barOffsetX const barOffsetY = profile.barOffsetY diff --git a/src/client/view/viewport-profile.ts b/src/client/view/viewport-profile.ts index bdd0902..96a3f89 100644 --- a/src/client/view/viewport-profile.ts +++ b/src/client/view/viewport-profile.ts @@ -3,50 +3,136 @@ import type { ViewportResolution } from '../settings/client-settings.ts' export type { ViewportResolution } +/** + * Logical size the native D2 1.13c HUD art is authored for: the control panel (`800CtrlPnl7.dc6`) is + * 800×48 and each dock panel (`800BorderFrame.dc6`) is 400×552. Larger profiles draw that art 1:1 and + * only re-anchor it (see {@link createViewportProfile}). + */ +export const NATIVE_HUD_WIDTH = 800 +export const NATIVE_HUD_HEIGHT = 600 + export interface ViewportProfile { readonly id: ViewportResolution - readonly width: 800 | 1024 - readonly height: 600 | 768 - readonly barOffsetX: 0 | 112 - readonly barOffsetY: 0 | 168 - readonly panelOffsetY: 0 | 84 + readonly width: number + readonly height: number + /** Control bar x shift `(width - 800) / 2`: the 800px bar stays horizontally centered. */ + readonly barOffsetX: number + /** Control bar y shift `height - 600`: the bar stays anchored to the bottom edge. */ + readonly barOffsetY: number + /** Dock panel y shift `(height - 600) / 2`: the native 800×600 panel block stays vertically centered. */ + readonly panelOffsetY: number } -export const VIEWPORT_800x600: ViewportProfile = { - id: '800x600', - width: 800, - height: 600, - barOffsetX: 0, - barOffsetY: 0, - panelOffsetY: 0, +/** + * Build a profile whose HUD anchors follow from its size alone: + * - `barOffsetX = (width - 800) / 2` + * - `barOffsetY = height - 600` + * - `panelOffsetY = (height - 600) / 2` + * + * (1024×768 gives the classic 112 / 168 / 84.) Throws when `id` does not spell `${width}x${height}`, + * when the size is smaller than the native 800×600 HUD, or when a margin is odd, which would put the + * centered bar or panels on half pixels. + */ +export function createViewportProfile( + id: ViewportResolution, + width: number, + height: number, +): ViewportProfile { + if (id !== `${width}x${height}`) { + throw new Error(`Viewport profile id "${id}" does not match its size ${width}x${height}`) + } + if (!Number.isInteger(width) || !Number.isInteger(height)) { + throw new Error(`Viewport profile ${id} must have integer dimensions`) + } + if (width < NATIVE_HUD_WIDTH || height < NATIVE_HUD_HEIGHT) { + throw new Error( + `Viewport profile ${id} is smaller than the native ${NATIVE_HUD_WIDTH}x${NATIVE_HUD_HEIGHT} HUD`, + ) + } + const marginX = width - NATIVE_HUD_WIDTH + const marginY = height - NATIVE_HUD_HEIGHT + if (marginX % 2 !== 0 || marginY % 2 !== 0) { + throw new Error(`Viewport profile ${id} needs even margins to center the HUD on whole pixels`) + } + return { + id, + width, + height, + barOffsetX: marginX / 2, + barOffsetY: marginY, + panelOffsetY: marginY / 2, + } } -export const VIEWPORT_1024x768: ViewportProfile = { - id: '1024x768', - width: 1024, - height: 768, - barOffsetX: 112, - barOffsetY: 168, - panelOffsetY: 84, -} +export const VIEWPORT_800x600: ViewportProfile = createViewportProfile('800x600', 800, 600) +export const VIEWPORT_1024x768: ViewportProfile = createViewportProfile('1024x768', 1024, 768) +/** ≈16:9 at the native 600-line height (the common HD-mod widescreen size): bar at x = 134, panels at y = 0. */ +export const VIEWPORT_1068x600: ViewportProfile = createViewportProfile('1068x600', 1068, 600) +/** 16:9 720p: bar at (240, 120), panels at y = 60. */ +export const VIEWPORT_1280x720: ViewportProfile = createViewportProfile('1280x720', 1280, 720) export const VIEWPORT_PROFILES: Readonly> = { '800x600': VIEWPORT_800x600, '1024x768': VIEWPORT_1024x768, + '1068x600': VIEWPORT_1068x600, + '1280x720': VIEWPORT_1280x720, } +/** + * Resolve a profile from a resolution id (case/whitespace-insensitive) or from a profile-like object + * (matched by exact width × height). Anything unrecognized resolves to `VIEWPORT_800x600`; use + * {@link getViewportProfileBySize} where an unknown size must fail instead. + */ export function getViewportProfile( input: ViewportResolution | ViewportProfile | string | null | undefined, ): ViewportProfile { if (typeof input === 'object' && input !== null && 'width' in input && 'height' in input) { - return input.width === 1024 ? VIEWPORT_1024x768 : VIEWPORT_800x600 + return findViewportProfileBySize(input.width, input.height) ?? VIEWPORT_800x600 } - if (typeof input === 'string' && input.trim().toLowerCase() === '1024x768') { - return VIEWPORT_1024x768 + if (typeof input === 'string') { + const clean = input.trim().toLowerCase() + if (Object.hasOwn(VIEWPORT_PROFILES, clean)) { + return VIEWPORT_PROFILES[clean as ViewportResolution] + } } return VIEWPORT_800x600 } +/** + * Strict numeric lookup of a registered profile by its logical size. `height` may be omitted because + * registered widths are unique. Throws for any size that is not a registered profile. + */ +export function getViewportProfileBySize(width: number, height?: number): ViewportProfile { + const profile = findViewportProfileBySize(width, height) + if (!profile) { + const known = Object.keys(VIEWPORT_PROFILES).join(', ') + throw new Error(`No viewport profile for ${width}x${height ?? '?'} (registered: ${known})`) + } + return profile +} + +function findViewportProfileBySize(width: number, height?: number): ViewportProfile | undefined { + return Object.values(VIEWPORT_PROFILES).find( + (p) => p.width === width && (height === undefined || p.height === height), + ) +} + +/** + * Whether `profile` is larger than the native 800×600 HUD. Extended profiles pin the 400px dock panels + * to the viewport edges (left at `x = 0`, right at `x = width - 400`, leaving a `width - 800` px center + * corridor) and shift the control bar by `barOffsetX/Y`. The 800×600 profile instead docks the panels + * against the browser window margins (`marginW`). + */ +export function isExtendedViewport(profile: ViewportProfile): boolean { + return profile.width > NATIVE_HUD_WIDTH || profile.height > NATIVE_HUD_HEIGHT +} + +/** Toolbar label for a registered resolution, e.g. `1280×720`. */ +export function formatViewportLabel(id: ViewportResolution): string { + const profile = VIEWPORT_PROFILES[id] + return `${profile.width}×${profile.height}` +} + export interface ViewportRect { readonly x: number readonly y: number @@ -70,7 +156,8 @@ export interface ViewportAnchorMetrics { * - Bottom bar (800×48) is horizontally centered at `x = barOffsetX`, `y = height - 48 = 552 + barOffsetY`. * - Left panel (400×552) is anchored at `x = 0`, `y = panelOffsetY`. * - Right panel (400×552) is anchored at `x = width - 400`, `y = panelOffsetY`. - * - In 1024×768 mode, opening both panels leaves a `224px` (`x = 400..624`) playable gap in the center. + * - With both panels open, extended profiles leave a `width - 800` px playable gap in the center + * (`x = 400..width - 400`): 224px in 1024×768, 268px in 1068×600, 480px in 1280×720. */ export function computeViewportAnchors( profileOrId: ViewportProfile | ViewportResolution = VIEWPORT_800x600, @@ -245,7 +332,8 @@ export interface ViewportFitDimensions { } /** - * Compute the largest aspect-ratio-preserving (`4:3`) CSS box (`cssWidth × cssHeight`) + * Compute the largest CSS box (`cssWidth × cssHeight`) that preserves the profile's aspect ratio + * (4:3 for 800×600 / 1024×768, ≈16:9 for 1068×600, 16:9 for 1280×720) * that fits inside `(containerWidth × containerHeight)`, retaining side black bars (pillarboxing) * on widescreen containers or top/bottom black bars (letterboxing) on narrow containers. */ @@ -317,7 +405,7 @@ export interface ViewportDomTargets { /** * Resize the stage container, WebGL2 canvas, and 2D HUD overlay canvas in lockstep. * When a container (`containerEl` or `stageEl.parentElement`) with positive dimensions is available, - * stretches the stage CSS box to fit the container while preserving the `4:3` aspect ratio. + * stretches the stage CSS box to fit the container while preserving the profile's aspect ratio. */ export function applyViewportProfileToDom( profileOrId: ViewportProfile | ViewportResolution, diff --git a/tests/client/viewport-formula-profiles.test.ts b/tests/client/viewport-formula-profiles.test.ts new file mode 100644 index 0000000..c97a9e7 --- /dev/null +++ b/tests/client/viewport-formula-profiles.test.ts @@ -0,0 +1,339 @@ +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, HudManager } from '../../src/client/ui/hud-manager.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() + + 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 canvas = { + width, + height, + clientWidth: width, + clientHeight: height, + style: { cursor: '', pointerEvents: '' }, + addEventListener: () => {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width, height }), + } 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 }) + }) +})