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)', () => {