diff --git a/play.html b/play.html
index 4c16fac..04ea13b 100644
--- a/play.html
+++ b/play.html
@@ -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 {
diff --git a/src/client/main.ts b/src/client/main.ts
index 5a13693..da526ba 100644
--- a/src/client/main.ts
+++ b/src/client/main.ts
@@ -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 {
const toolbarContainer = document.querySelector('#d2-toolbar-container')
const inspectorContainer = document.querySelector('#d2-inspector-container')
+ const stageWrapperEl = document.querySelector('#play-stage-wrapper')
const stageEl = document.querySelector('#play-stage')
const bnetShell = document.querySelector('#bnet-shell')
const bnetCanvas = document.querySelector('#bnet-canvas')
@@ -172,6 +181,20 @@ async function bootPlayApp(): Promise {
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 {
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 {
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 {
}
if (next.inspectorOpen !== prev.inspectorOpen) {
inspector.setOpen(next.inspectorOpen)
+ syncStageViewportFit(next.viewport)
}
})
diff --git a/src/client/view/viewport-profile.ts b/src/client/view/viewport-profile.ts
index 454039a..bdd0902 100644
--- a/src/client/view/viewport-profile.ts
+++ b/src/client/view/viewport-profile.ts
@@ -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
}
+
diff --git a/tests/client/settings-toolbar-view.test.ts b/tests/client/settings-toolbar-view.test.ts
index 2bcf569..f4ad88a 100644
--- a/tests/client/settings-toolbar-view.test.ts
+++ b/tests/client/settings-toolbar-view.test.ts
@@ -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)', () => {