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