feat(client): formula-derived viewport profiles + 1068x600 / 1280x720

Derive every ViewportProfile's HUD anchors from its size instead of
hard-coded literals (1024x768 still yields 112 / 168 / 84):
  barOffsetX   = (W - 800) / 2
  barOffsetY   = H - 600
  panelOffsetY = (H - 600) / 2
and register two widescreen profiles next to 800x600 / 1024x768:
  1068x600 -> bar (134, 0),   panels y = 0,  center corridor 268px
  1280x720 -> bar (240, 120), panels y = 60, center corridor 480px

- viewport-profile: createViewportProfile() fails fast on an id/size
  mismatch, non-integer or sub-800x600 sizes and odd margins; add the
  strict getViewportProfileBySize(), isExtendedViewport() and
  formatViewportLabel(); id/object lookups resolve every profile.
- client-settings: whitelist the new ids (and bare-width shorthands).
- toolbar: the selector lists every profile; toggleViewport() cycles
  800 -> 1024 -> 1068 -> 1280 -> 800.
- hud-manager: every profile larger than 800x600 docks the panels at
  x = 0 / W - 400 with a W - 800 corridor (render + hit-testing);
  resize() resolves the exact registered profile or throws.
- command-mapper: ViewportCamera(width, height) resolves the exact
  registered profile or throws.
- play.html: keep the 800x600 Battle.net front-end canvas 4:3
  (pillarboxed) when the stage is sized for a 16:9 profile.

Tests: tests/client/viewport-formula-profiles.test.ts

TAG=agy
CONV=47cdfa86-71f2-47e6-b71e-1271cc315e1d
This commit is contained in:
troytt 2026-10-02 03:37:56 +00:00
parent f356c8b1d3
commit 26e4265e5b
8 changed files with 513 additions and 71 deletions

View File

@ -234,8 +234,11 @@
} }
#bnet-canvas { #bnet-canvas {
width: 100%; /* The 800x600 front-end stays 4:3 (pillarboxed) when the stage is sized for a 16:9 profile. */
height: 100%; height: 100%;
width: auto;
max-width: 100%;
aspect-ratio: 4 / 3;
display: block; display: block;
image-rendering: pixelated; image-rendering: pixelated;
} }

View File

@ -1,8 +1,8 @@
/** /**
* Input -> ClientCommand Mapper (Milestone M5 — F17) * Input -> ClientCommand Mapper (Milestone M5 — F17)
* *
* Translates browser mouse/pointer and keyboard events across 800×600 and 1024×768 * Translates browser mouse/pointer and keyboard events in any registered viewport profile
* viewports into rate-limited `ClientCommand`s and local movement predictions: * into rate-limited `ClientCommand`s and local movement predictions:
* - Screen-to-world subtile conversion via `screenToSubTile` (isometric 2:1 projection). * - Screen-to-world subtile conversion via `screenToSubTile` (isometric 2:1 projection).
* - HUD hit-test gating via `HudManager.isPointInterceptedByHud(x, y)` so clicks on * - 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. * 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 { import {
VIEWPORT_800x600, VIEWPORT_800x600,
getViewportProfile, getViewportProfile,
getViewportProfileBySize,
screenToSubTile, screenToSubTile,
subTileToScreen, subTileToScreen,
type ViewportProfile, 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 { export class ViewportCamera implements CameraLike {
subX = 0 subX = 0
@ -61,13 +62,10 @@ export class ViewportCamera implements CameraLike {
widthOrProfile: number | ViewportProfile | ViewportResolution = VIEWPORT_800x600, widthOrProfile: number | ViewportProfile | ViewportResolution = VIEWPORT_800x600,
height?: number, height?: number,
) { ) {
if (typeof widthOrProfile === 'number') { this.viewportProfile =
this.viewportProfile = getViewportProfile( typeof widthOrProfile === 'number'
widthOrProfile === 1024 || height === 768 ? '1024x768' : '800x600', ? getViewportProfileBySize(widthOrProfile, height)
) : getViewportProfile(widthOrProfile)
} else {
this.viewportProfile = getViewportProfile(widthOrProfile)
}
} }
screenToSubtile(screenX: number, screenY: number): { readonly x: number; readonly y: number } { 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 { resize(widthOrProfile: number | ViewportProfile | ViewportResolution, height?: number): void {
if (typeof widthOrProfile === 'number') { this.viewportProfile =
this.viewportProfile = getViewportProfile( typeof widthOrProfile === 'number'
widthOrProfile === 1024 || height === 768 ? '1024x768' : '800x600', ? getViewportProfileBySize(widthOrProfile, height)
) : getViewportProfile(widthOrProfile)
} else {
this.viewportProfile = getViewportProfile(widthOrProfile)
}
} }
} }
@ -225,7 +220,7 @@ export class CommandMapper {
/** /**
* Convert screen coordinates `(screenX, screenY)` (in canvas pixels, supporting * 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 { resolvePointerTarget(screenX: number, screenY: number, hitRadiusSubtiles = 2.5): PointerWorldTarget {
if (this.predictor) { 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. * or an external UI overlay.
*/ */
isPointIntercepted(screenX: number, screenY: number): boolean { isPointIntercepted(screenX: number, screenY: number): boolean {

View File

@ -5,7 +5,7 @@
* - Persistent 32px Top Toolbar (`Toolbar` + `SettingsStore`) * - Persistent 32px Top Toolbar (`Toolbar` + `SettingsStore`)
* - Battle.net Pre-Game & Live Lobby (`FrontendRenderer` + `BnetFlowState` + `OnlineSession`) * - Battle.net Pre-Game & Live Lobby (`FrontendRenderer` + `BnetFlowState` + `OnlineSession`)
* - In-Game Isometric World + Automap + Lighting (`ClientWorldSceneSource`, `AutomapView`) * - 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`) * - Dockable Packet Inspector & Deterministic `.d2cap` Capture Replay (`PacketInspector`)
* - Graceful disconnect & error recovery banner (`#play-disconnect-banner`) * - Graceful disconnect & error recovery banner (`#play-disconnect-banner`)
* *
@ -396,7 +396,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes) 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) syncStageViewportFit(initialSettings.viewport)
window.addEventListener('resize', () => { window.addEventListener('resize', () => {

View File

@ -13,7 +13,7 @@ export type ClientLightingPreset =
| 'fullbright' | 'fullbright'
export type AutomapRevealMode = 'off' | 'level' | 'act' export type AutomapRevealMode = 'off' | 'level' | 'act'
export type ViewportResolution = '800x600' | '1024x768' export type ViewportResolution = '800x600' | '1024x768' | '1068x600' | '1280x720'
export interface ClientSettings { export interface ClientSettings {
readonly version: 1 readonly version: 1
@ -46,6 +46,8 @@ export const VALID_AUTOMAP_REVEAL_MODES: readonly AutomapRevealMode[] = [
export const VALID_VIEWPORT_RESOLUTIONS: readonly ViewportResolution[] = [ export const VALID_VIEWPORT_RESOLUTIONS: readonly ViewportResolution[] = [
'800x600', '800x600',
'1024x768', '1024x768',
'1068x600',
'1280x720',
] as const ] as const
export function createDefaultClientSettings(defaultLang?: GameLang): ClientSettings { export function createDefaultClientSettings(defaultLang?: GameLang): ClientSettings {
@ -292,7 +294,10 @@ export function normalizeAutomapReveal(value: unknown): AutomapRevealMode {
export function normalizeViewportResolution(value: unknown): ViewportResolution { export function normalizeViewportResolution(value: unknown): ViewportResolution {
if (typeof value !== 'string') return '800x600' if (typeof value !== 'string') return '800x600'
const clean = value.trim().toLowerCase() 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' return '800x600'
} }

View File

@ -25,6 +25,7 @@ import {
formatCacheBytesMb, formatCacheBytesMb,
} from '../sw/register.ts' } from '../sw/register.ts'
import { import {
formatViewportLabel,
getViewportProfile, getViewportProfile,
type ViewportProfile, type ViewportProfile,
} from '../view/viewport-profile.ts' } from '../view/viewport-profile.ts'
@ -87,7 +88,7 @@ export interface ToolbarSnapshot {
* 2. Live Language Toggle (`中` / `EN`) * 2. Live Language Toggle (`中` / `EN`)
* 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`) * 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`)
* 4. Automap Reveal Selector (`off | level | act`) * 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 { export class Toolbar {
private readonly store: SettingsStore private readonly store: SettingsStore
@ -312,9 +313,10 @@ export class Toolbar {
return getViewportProfile(viewport) return getViewportProfile(viewport)
} }
/** Advance to the next resolution in `VALID_VIEWPORT_RESOLUTIONS` order, wrapping back to the first. */
toggleViewport(): ViewportProfile { toggleViewport(): ViewportProfile {
const next: ViewportResolution = const order = VALID_VIEWPORT_RESOLUTIONS
this.store.get().viewport === '800x600' ? '1024x768' : '800x600' const next = order[(order.indexOf(this.store.get().viewport) + 1) % order.length]
return this.setViewport(next) return this.setViewport(next)
} }
@ -614,7 +616,7 @@ export class Toolbar {
automapGroup.appendChild(automapLabel) automapGroup.appendChild(automapLabel)
automapGroup.appendChild(automapSelect) 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') const viewportGroup = doc.createElement('div')
viewportGroup.className = 'd2-toolbar__group' viewportGroup.className = 'd2-toolbar__group'
const viewportLabel = doc.createElement('label') const viewportLabel = doc.createElement('label')
@ -626,7 +628,7 @@ export class Toolbar {
for (const vp of VALID_VIEWPORT_RESOLUTIONS) { for (const vp of VALID_VIEWPORT_RESOLUTIONS) {
const opt = doc.createElement('option') const opt = doc.createElement('option')
opt.value = vp opt.value = vp
opt.textContent = vp === '800x600' ? '800×600' : '1024×768' opt.textContent = formatViewportLabel(vp)
viewportSelect.appendChild(opt) viewportSelect.appendChild(opt)
} }
viewportSelect.addEventListener('change', () => { viewportSelect.addEventListener('change', () => {

View File

@ -66,6 +66,8 @@ import { requireTbl } from '../i18n/lang.ts'
import { import {
computeViewportAnchors, computeViewportAnchors,
getViewportProfile, getViewportProfile,
getViewportProfileBySize,
isExtendedViewport,
VIEWPORT_800x600, VIEWPORT_800x600,
type ViewportAnchorMetrics, type ViewportAnchorMetrics,
type ViewportProfile, type ViewportProfile,
@ -170,7 +172,7 @@ export interface DockingLayout extends HudLayout {
/** /**
* Computes 1:1 uniform aspect ratio scaling and horizontal centering margins * Computes 1:1 uniform aspect ratio scaling and horizontal centering margins
* for the canonical 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( export function computeHudLayout(
cssW: number, cssW: number,
@ -217,6 +219,10 @@ export {
/** /**
* Computes widescreen panel docking layout metrics, margins, and central vision corridor. * 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( export function computeDockingLayout(
cssW: number, cssW: number,
@ -224,18 +230,20 @@ export function computeDockingLayout(
dpr: number = 1, dpr: number = 1,
profile: ViewportProfile = VIEWPORT_800x600, profile: ViewportProfile = VIEWPORT_800x600,
): DockingLayout { ): DockingLayout {
if (profile.width === 1024) { if (isExtendedViewport(profile)) {
const layout = computeHudLayout(cssW, cssH, dpr, profile) const layout = computeHudLayout(cssW, cssH, dpr, profile)
const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0 const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0
const rightDockX = profile.width - 400
const channelWidthLogical = rightDockX - 400
return { return {
...layout, ...layout,
marginW, marginW,
leftDockX: 0, leftDockX: 0,
rightDockX: 624, rightDockX,
channelLeft: 400, channelLeft: 400,
channelRight: 624, channelRight: rightDockX,
channelWidthLogical: 224, channelWidthLogical,
channelWidthCss: 224 * layout.uiScale, channelWidthCss: channelWidthLogical * layout.uiScale,
isWidescreen: true, isWidescreen: true,
barOffsetX: profile.barOffsetX, barOffsetX: profile.barOffsetX,
barOffsetY: profile.barOffsetY, barOffsetY: profile.barOffsetY,
@ -687,9 +695,9 @@ export class HudManager {
return this.viewportProfile.height 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 { resize(width: number, height?: number): ViewportProfile {
const isWidescreen = width >= 1024 || (height !== undefined && height >= 768) return this.setViewportProfile(getViewportProfileBySize(width, height))
return this.setViewportProfile(isWidescreen ? '1024x768' : '800x600')
} }
load(baseUrl = '/ui'): Promise<void> { load(baseUrl = '/ui'): Promise<void> {
@ -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. * inverting 1:1 uniform aspect ratio scaling and horizontal centering margins.
*/ */
clientToLogical(clientX: number, clientY: number): { x: number; y: number } { clientToLogical(clientX: number, clientY: number): { x: number; y: number } {
@ -1236,8 +1244,8 @@ export class HudManager {
const docking = this.getDockingLayout() const docking = this.getDockingLayout()
const { marginW } = docking const { marginW } = docking
if (profile.width === 1024) { if (isExtendedViewport(profile)) {
if (logicalX < -marginW || logicalX > 1024 + marginW || logicalY < 0 || logicalY > 768) { if (logicalX < -marginW || logicalX > profile.width + marginW || logicalY < 0 || logicalY > profile.height) {
return false return false
} }
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true 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) { if (this.leftPanel !== 'none' && logicalX >= 0 && logicalX <= 400 && panelY >= 0 && panelY <= 553) {
return true 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 true
} }
return false 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( render(
nowMs: number = performance.now(), nowMs: number = performance.now(),
@ -2262,9 +2271,10 @@ export class HudManager {
} }
const docking = computeDockingLayout(cssW, cssH, dpr, profile) const docking = computeDockingLayout(cssW, cssH, dpr, profile)
const { uiScale, offsetX, offsetY, marginW } = docking const { uiScale, offsetX, offsetY } = docking
const deltaLeft = profile.width === 1024 ? 0 : -marginW // Panels are authored in native 800x600 coordinates (left at x = 0, right at x = 400); shift them onto their docks.
const deltaRight = profile.width === 1024 ? 224 : marginW const deltaLeft = docking.leftDockX
const deltaRight = docking.rightDockX - 400
const panelOffsetY = profile.panelOffsetY const panelOffsetY = profile.panelOffsetY
const barOffsetX = profile.barOffsetX const barOffsetX = profile.barOffsetX
const barOffsetY = profile.barOffsetY const barOffsetY = profile.barOffsetY

View File

@ -3,50 +3,136 @@ import type { ViewportResolution } from '../settings/client-settings.ts'
export type { ViewportResolution } 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 { export interface ViewportProfile {
readonly id: ViewportResolution readonly id: ViewportResolution
readonly width: 800 | 1024 readonly width: number
readonly height: 600 | 768 readonly height: number
readonly barOffsetX: 0 | 112 /** Control bar x shift `(width - 800) / 2`: the 800px bar stays horizontally centered. */
readonly barOffsetY: 0 | 168 readonly barOffsetX: number
readonly panelOffsetY: 0 | 84 /** 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', * Build a profile whose HUD anchors follow from its size alone:
width: 800, * - `barOffsetX = (width - 800) / 2`
height: 600, * - `barOffsetY = height - 600`
barOffsetX: 0, * - `panelOffsetY = (height - 600) / 2`
barOffsetY: 0, *
panelOffsetY: 0, * (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 = { export const VIEWPORT_800x600: ViewportProfile = createViewportProfile('800x600', 800, 600)
id: '1024x768', export const VIEWPORT_1024x768: ViewportProfile = createViewportProfile('1024x768', 1024, 768)
width: 1024, /** ≈16:9 at the native 600-line height (the common HD-mod widescreen size): bar at x = 134, panels at y = 0. */
height: 768, export const VIEWPORT_1068x600: ViewportProfile = createViewportProfile('1068x600', 1068, 600)
barOffsetX: 112, /** 16:9 720p: bar at (240, 120), panels at y = 60. */
barOffsetY: 168, export const VIEWPORT_1280x720: ViewportProfile = createViewportProfile('1280x720', 1280, 720)
panelOffsetY: 84,
}
export const VIEWPORT_PROFILES: Readonly<Record<ViewportResolution, ViewportProfile>> = { export const VIEWPORT_PROFILES: Readonly<Record<ViewportResolution, ViewportProfile>> = {
'800x600': VIEWPORT_800x600, '800x600': VIEWPORT_800x600,
'1024x768': VIEWPORT_1024x768, '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( export function getViewportProfile(
input: ViewportResolution | ViewportProfile | string | null | undefined, input: ViewportResolution | ViewportProfile | string | null | undefined,
): ViewportProfile { ): ViewportProfile {
if (typeof input === 'object' && input !== null && 'width' in input && 'height' in input) { 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') {
const clean = input.trim().toLowerCase()
if (Object.hasOwn(VIEWPORT_PROFILES, clean)) {
return VIEWPORT_PROFILES[clean as ViewportResolution]
} }
if (typeof input === 'string' && input.trim().toLowerCase() === '1024x768') {
return VIEWPORT_1024x768
} }
return VIEWPORT_800x600 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 { export interface ViewportRect {
readonly x: number readonly x: number
readonly y: 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`. * - 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`. * - Left panel (400×552) is anchored at `x = 0`, `y = panelOffsetY`.
* - Right panel (400×552) is anchored at `x = width - 400`, `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( export function computeViewportAnchors(
profileOrId: ViewportProfile | ViewportResolution = VIEWPORT_800x600, 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) * that fits inside `(containerWidth × containerHeight)`, retaining side black bars (pillarboxing)
* on widescreen containers or top/bottom black bars (letterboxing) on narrow containers. * 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. * 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, * 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( export function applyViewportProfileToDom(
profileOrId: ViewportProfile | ViewportResolution, profileOrId: ViewportProfile | ViewportResolution,

View File

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