Merge branch 'feat/viewport-fit-browser': stretch game stage to fit browser across resolutions

This commit is contained in:
troytt 2026-10-01 13:14:52 +00:00
commit 6780018ada
4 changed files with 192 additions and 33 deletions

View File

@ -199,20 +199,25 @@
#play-stage-wrapper {
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, #14110d 0%, #040404 75%);
background: #000;
position: relative;
overflow: hidden;
}
#play-stage {
position: relative;
width: 800px;
height: 600px;
aspect-ratio: 4 / 3;
width: min(100%, calc((100vh - 36px) * 4 / 3));
height: min(100%, calc(100vw * 3 / 4));
max-width: 100%;
max-height: 100%;
background: #000;
box-shadow: 0 0 42px rgba(0, 0, 0, 0.95), 0 0 0 1px #2e251b;
overflow: hidden;
@ -243,6 +248,7 @@
display: block;
background: #080706;
z-index: 5;
image-rendering: pixelated;
}
#d2-hud-canvas {

View File

@ -123,6 +123,14 @@ function drawEscapeMenuOverlay(
height: number,
): void {
ctx.save()
const canvasW = ctx.canvas?.width ?? width
const canvasH = ctx.canvas?.height ?? height
const scaleX = width > 0 && canvasW > 0 ? canvasW / width : 1
const scaleY = height > 0 && canvasH > 0 ? canvasH / height : 1
if (scaleX !== 1 || scaleY !== 1) {
ctx.scale(scaleX, scaleY)
}
ctx.fillStyle = 'rgba(0, 0, 0, 0.52)'
ctx.fillRect(0, 0, width, height)
@ -153,6 +161,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
const toolbarContainer = document.querySelector<HTMLElement>('#d2-toolbar-container')
const inspectorContainer = document.querySelector<HTMLElement>('#d2-inspector-container')
const stageWrapperEl = document.querySelector<HTMLElement>('#play-stage-wrapper')
const stageEl = document.querySelector<HTMLElement>('#play-stage')
const bnetShell = document.querySelector<HTMLElement>('#bnet-shell')
const bnetCanvas = document.querySelector<HTMLCanvasElement>('#bnet-canvas')
@ -172,6 +181,20 @@ async function bootPlayApp(): Promise<PlayAppController> {
let sessionRef: OnlineSession | null = null
const syncStageViewportFit = (
profileOrId: ViewportProfile | typeof initialSettings.viewport = settingsStore.get().viewport,
): ViewportProfile => {
const profile = applyViewportProfileToDom(profileOrId, {
containerEl: stageWrapperEl ?? stageEl.parentElement,
stageEl,
glCanvas: viewCanvas,
hudCanvas,
})
sessionRef?.hudManager.resize(profile.width, profile.height)
sessionRef?.camera.resize(profile)
return profile
}
const toolbar = new Toolbar({
store: settingsStore,
container: toolbarContainer,
@ -181,19 +204,14 @@ async function bootPlayApp(): Promise<PlayAppController> {
sessionRef?.hudManager.setLang(lang)
},
onViewportChange: (profile: ViewportProfile) => {
applyViewportProfileToDom(profile, {
stageEl,
glCanvas: viewCanvas,
hudCanvas,
})
sessionRef?.hudManager.resize(profile.width, profile.height)
sessionRef?.camera.resize(profile)
syncStageViewportFit(profile)
},
onAutomapRevealChange: (mode) => {
sessionRef?.automapView.setMode(mode)
},
onInspectorToggle: (open) => {
sessionRef?.packetInspector.setOpen(open)
syncStageViewportFit()
},
onServerHostReconnect: (newHost) => {
void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
@ -375,26 +393,23 @@ async function bootPlayApp(): Promise<PlayAppController> {
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes)
})
// Apply initial viewport dimensions (800×600 or 1024×768)
const initialProfile = getViewportProfile(initialSettings.viewport)
applyViewportProfileToDom(initialProfile, {
stageEl,
glCanvas: viewCanvas,
hudCanvas,
// Apply initial viewport dimensions (800×600 or 1024×768) stretched to fit browser
syncStageViewportFit(initialSettings.viewport)
window.addEventListener('resize', () => {
syncStageViewportFit()
})
session.hudManager.resize(initialProfile.width, initialProfile.height)
session.camera.resize(initialProfile)
if (typeof ResizeObserver !== 'undefined' && stageWrapperEl) {
const stageResizeObserver = new ResizeObserver(() => {
syncStageViewportFit()
})
stageResizeObserver.observe(stageWrapperEl)
}
settingsStore.subscribe((next, prev) => {
if (next.viewport !== prev.viewport) {
const profile = getViewportProfile(next.viewport)
applyViewportProfileToDom(profile, {
stageEl,
glCanvas: viewCanvas,
hudCanvas,
})
session.hudManager.resize(profile.width, profile.height)
session.camera.resize(profile)
syncStageViewportFit(next.viewport)
}
if (next.lang !== prev.lang) {
setLang(next.lang)
@ -405,6 +420,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
}
if (next.inspectorOpen !== prev.inspectorOpen) {
inspector.setOpen(next.inspectorOpen)
syncStageViewportFit(next.viewport)
}
})

View File

@ -232,8 +232,83 @@ export function screenToSubTile(
}
}
export interface ViewportFitDimensions {
readonly logicalWidth: number
readonly logicalHeight: number
readonly containerWidth: number
readonly containerHeight: number
readonly scale: number
readonly cssWidth: number
readonly cssHeight: number
readonly letterboxLeft: number
readonly letterboxTop: number
}
/**
* Compute the largest aspect-ratio-preserving (`4:3`) CSS box (`cssWidth × cssHeight`)
* that fits inside `(containerWidth × containerHeight)`, retaining side black bars (pillarboxing)
* on widescreen containers or top/bottom black bars (letterboxing) on narrow containers.
*/
export function computeViewportFitSize(
profileOrId: ViewportProfile | ViewportResolution,
containerWidth: number,
containerHeight: number,
): ViewportFitDimensions {
const profile = getViewportProfile(profileOrId)
const validContainer =
Number.isFinite(containerWidth) &&
Number.isFinite(containerHeight) &&
containerWidth > 0 &&
containerHeight > 0
if (!validContainer) {
return {
logicalWidth: profile.width,
logicalHeight: profile.height,
containerWidth: profile.width,
containerHeight: profile.height,
scale: 1,
cssWidth: profile.width,
cssHeight: profile.height,
letterboxLeft: 0,
letterboxTop: 0,
}
}
const scale = Math.min(containerWidth / profile.width, containerHeight / profile.height)
const cssWidth = Math.max(1, Math.round(profile.width * scale))
const cssHeight = Math.max(1, Math.round(profile.height * scale))
const letterboxLeft = Math.max(0, Math.floor((containerWidth - cssWidth) / 2))
const letterboxTop = Math.max(0, Math.floor((containerHeight - cssHeight) / 2))
return {
logicalWidth: profile.width,
logicalHeight: profile.height,
containerWidth,
containerHeight,
scale,
cssWidth,
cssHeight,
letterboxLeft,
letterboxTop,
}
}
export interface ViewportContainerLike {
readonly clientWidth: number
readonly clientHeight: number
}
export interface ViewportDomTargets {
readonly stageEl?: { style: { width: string; height: string } } | null | undefined
readonly containerEl?: ViewportContainerLike | null | undefined
readonly stageEl?:
| {
style: { width: string; height: string }
parentElement?: ViewportContainerLike | null
setAttribute?: (name: string, value: string) => void
}
| null
| undefined
readonly glCanvas?: { width: number; height: number; style?: { width: string; height: string } } | null | undefined
readonly hudCanvas?: { width: number; height: number; style?: { width: string; height: string } } | null | undefined
readonly onResize?: ((profile: ViewportProfile) => void) | undefined
@ -241,32 +316,48 @@ 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.
*/
export function applyViewportProfileToDom(
profileOrId: ViewportProfile | ViewportResolution,
targets: ViewportDomTargets,
): ViewportProfile {
const profile = getViewportProfile(profileOrId)
const container = targets.containerEl ?? targets.stageEl?.parentElement ?? null
const hasContainerBounds =
container !== null &&
Number.isFinite(container.clientWidth) &&
Number.isFinite(container.clientHeight) &&
container.clientWidth > 0 &&
container.clientHeight > 0
const fit = hasContainerBounds
? computeViewportFitSize(profile, container.clientWidth, container.clientHeight)
: null
if (targets.stageEl) {
targets.stageEl.style.width = `${profile.width}px`
targets.stageEl.style.height = `${profile.height}px`
targets.stageEl.style.width = fit ? `${fit.cssWidth}px` : `${profile.width}px`
targets.stageEl.style.height = fit ? `${fit.cssHeight}px` : `${profile.height}px`
targets.stageEl.setAttribute?.('data-viewport', profile.id)
}
if (targets.glCanvas) {
targets.glCanvas.width = profile.width
targets.glCanvas.height = profile.height
if (targets.glCanvas.style) {
targets.glCanvas.style.width = `${profile.width}px`
targets.glCanvas.style.height = `${profile.height}px`
targets.glCanvas.style.width = fit ? '100%' : `${profile.width}px`
targets.glCanvas.style.height = fit ? '100%' : `${profile.height}px`
}
}
if (targets.hudCanvas) {
targets.hudCanvas.width = profile.width
targets.hudCanvas.height = profile.height
if (targets.hudCanvas.style) {
targets.hudCanvas.style.width = `${profile.width}px`
targets.hudCanvas.style.height = `${profile.height}px`
targets.hudCanvas.style.width = fit ? '100%' : `${profile.width}px`
targets.hudCanvas.style.height = fit ? '100%' : `${profile.height}px`
}
}
targets.onResize?.(profile)
return profile
}

View File

@ -41,6 +41,7 @@ import {
import {
applyViewportProfileToDom,
computeViewportAnchors,
computeViewportFitSize,
getViewportProfile,
screenToSubTile,
screenToWorld,
@ -363,6 +364,51 @@ describe('Dual Viewport Profiles, HudManager Anchoring & Coordinate Transforms (
expect(glCanvas.width).toBe(1024)
expect(hudCanvas.height).toBe(768)
})
it('stretches both 800x600 and 1024x768 to fit the browser container while retaining 4:3 aspect ratio and side black bars', () => {
// Widescreen 1920x896 wrapper (1920x932 browser minus 36px toolbar):
const fit800 = computeViewportFitSize('800x600', 1920, 896)
expect(fit800.cssHeight).toBe(896)
expect(fit800.cssWidth).toBe(Math.round(896 * (4 / 3)))
expect(fit800.letterboxLeft).toBeGreaterThan(0)
expect(fit800.letterboxTop).toBe(0)
const fit1024 = computeViewportFitSize('1024x768', 1920, 896)
expect(fit1024.cssHeight).toBe(896)
expect(fit1024.cssWidth).toBe(fit800.cssWidth)
expect(fit1024.letterboxLeft).toBe(fit800.letterboxLeft)
// Narrow/portrait 800x1000 container:
const fitNarrow = computeViewportFitSize('1024x768', 800, 1000)
expect(fitNarrow.cssWidth).toBe(800)
expect(fitNarrow.cssHeight).toBe(600)
expect(fitNarrow.letterboxLeft).toBe(0)
expect(fitNarrow.letterboxTop).toBe(200)
// DOM application with containerEl stretches stage CSS to fit while keeping internal canvas resolution exact
const containerEl = { clientWidth: 1920, clientHeight: 1080 }
const stageEl = { style: { width: '', height: '' } }
const glCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
const hudCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
applyViewportProfileToDom('800x600', { containerEl, stageEl, glCanvas, hudCanvas })
expect(stageEl.style.width).toBe('1440px')
expect(stageEl.style.height).toBe('1080px')
expect(glCanvas.width).toBe(800)
expect(glCanvas.height).toBe(600)
expect(glCanvas.style.width).toBe('100%')
expect(glCanvas.style.height).toBe('100%')
expect(hudCanvas.style.width).toBe('100%')
expect(hudCanvas.style.height).toBe('100%')
applyViewportProfileToDom('1024x768', { containerEl, stageEl, glCanvas, hudCanvas })
expect(stageEl.style.width).toBe('1440px')
expect(stageEl.style.height).toBe('1080px')
expect(glCanvas.width).toBe(1024)
expect(glCanvas.height).toBe(768)
expect(glCanvas.style.width).toBe('100%')
expect(glCanvas.style.height).toBe('100%')
})
})
describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F17)', () => {