Merge branch 'feat/viewport-fit-browser': stretch game stage to fit browser across resolutions
This commit is contained in:
commit
6780018ada
12
play.html
12
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue