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:
parent
f356c8b1d3
commit
26e4265e5b
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<PlayAppController> {
|
|||
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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
*/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<Record<ViewportResolution, ViewportProfile>> = {
|
||||
'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') {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue