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 {
|
#play-stage-wrapper {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: radial-gradient(circle at center, #14110d 0%, #040404 75%);
|
background: #000;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#play-stage {
|
#play-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 800px;
|
aspect-ratio: 4 / 3;
|
||||||
height: 600px;
|
width: min(100%, calc((100vh - 36px) * 4 / 3));
|
||||||
|
height: min(100%, calc(100vw * 3 / 4));
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
background: #000;
|
background: #000;
|
||||||
box-shadow: 0 0 42px rgba(0, 0, 0, 0.95), 0 0 0 1px #2e251b;
|
box-shadow: 0 0 42px rgba(0, 0, 0, 0.95), 0 0 0 1px #2e251b;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -243,6 +248,7 @@
|
||||||
display: block;
|
display: block;
|
||||||
background: #080706;
|
background: #080706;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
#d2-hud-canvas {
|
#d2-hud-canvas {
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,14 @@ function drawEscapeMenuOverlay(
|
||||||
height: number,
|
height: number,
|
||||||
): void {
|
): void {
|
||||||
ctx.save()
|
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.fillStyle = 'rgba(0, 0, 0, 0.52)'
|
||||||
ctx.fillRect(0, 0, width, height)
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
|
@ -153,6 +161,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
|
|
||||||
const toolbarContainer = document.querySelector<HTMLElement>('#d2-toolbar-container')
|
const toolbarContainer = document.querySelector<HTMLElement>('#d2-toolbar-container')
|
||||||
const inspectorContainer = document.querySelector<HTMLElement>('#d2-inspector-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 stageEl = document.querySelector<HTMLElement>('#play-stage')
|
||||||
const bnetShell = document.querySelector<HTMLElement>('#bnet-shell')
|
const bnetShell = document.querySelector<HTMLElement>('#bnet-shell')
|
||||||
const bnetCanvas = document.querySelector<HTMLCanvasElement>('#bnet-canvas')
|
const bnetCanvas = document.querySelector<HTMLCanvasElement>('#bnet-canvas')
|
||||||
|
|
@ -172,6 +181,20 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
|
|
||||||
let sessionRef: OnlineSession | null = null
|
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({
|
const toolbar = new Toolbar({
|
||||||
store: settingsStore,
|
store: settingsStore,
|
||||||
container: toolbarContainer,
|
container: toolbarContainer,
|
||||||
|
|
@ -181,19 +204,14 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
sessionRef?.hudManager.setLang(lang)
|
sessionRef?.hudManager.setLang(lang)
|
||||||
},
|
},
|
||||||
onViewportChange: (profile: ViewportProfile) => {
|
onViewportChange: (profile: ViewportProfile) => {
|
||||||
applyViewportProfileToDom(profile, {
|
syncStageViewportFit(profile)
|
||||||
stageEl,
|
|
||||||
glCanvas: viewCanvas,
|
|
||||||
hudCanvas,
|
|
||||||
})
|
|
||||||
sessionRef?.hudManager.resize(profile.width, profile.height)
|
|
||||||
sessionRef?.camera.resize(profile)
|
|
||||||
},
|
},
|
||||||
onAutomapRevealChange: (mode) => {
|
onAutomapRevealChange: (mode) => {
|
||||||
sessionRef?.automapView.setMode(mode)
|
sessionRef?.automapView.setMode(mode)
|
||||||
},
|
},
|
||||||
onInspectorToggle: (open) => {
|
onInspectorToggle: (open) => {
|
||||||
sessionRef?.packetInspector.setOpen(open)
|
sessionRef?.packetInspector.setOpen(open)
|
||||||
|
syncStageViewportFit()
|
||||||
},
|
},
|
||||||
onServerHostReconnect: (newHost) => {
|
onServerHostReconnect: (newHost) => {
|
||||||
void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
|
void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
|
||||||
|
|
@ -375,26 +393,23 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes)
|
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Apply initial viewport dimensions (800×600 or 1024×768)
|
// Apply initial viewport dimensions (800×600 or 1024×768) stretched to fit browser
|
||||||
const initialProfile = getViewportProfile(initialSettings.viewport)
|
syncStageViewportFit(initialSettings.viewport)
|
||||||
applyViewportProfileToDom(initialProfile, {
|
|
||||||
stageEl,
|
window.addEventListener('resize', () => {
|
||||||
glCanvas: viewCanvas,
|
syncStageViewportFit()
|
||||||
hudCanvas,
|
|
||||||
})
|
})
|
||||||
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) => {
|
settingsStore.subscribe((next, prev) => {
|
||||||
if (next.viewport !== prev.viewport) {
|
if (next.viewport !== prev.viewport) {
|
||||||
const profile = getViewportProfile(next.viewport)
|
syncStageViewportFit(next.viewport)
|
||||||
applyViewportProfileToDom(profile, {
|
|
||||||
stageEl,
|
|
||||||
glCanvas: viewCanvas,
|
|
||||||
hudCanvas,
|
|
||||||
})
|
|
||||||
session.hudManager.resize(profile.width, profile.height)
|
|
||||||
session.camera.resize(profile)
|
|
||||||
}
|
}
|
||||||
if (next.lang !== prev.lang) {
|
if (next.lang !== prev.lang) {
|
||||||
setLang(next.lang)
|
setLang(next.lang)
|
||||||
|
|
@ -405,6 +420,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
}
|
}
|
||||||
if (next.inspectorOpen !== prev.inspectorOpen) {
|
if (next.inspectorOpen !== prev.inspectorOpen) {
|
||||||
inspector.setOpen(next.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 {
|
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 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 hudCanvas?: { width: number; height: number; style?: { width: string; height: string } } | null | undefined
|
||||||
readonly onResize?: ((profile: ViewportProfile) => void) | 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.
|
* 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(
|
export function applyViewportProfileToDom(
|
||||||
profileOrId: ViewportProfile | ViewportResolution,
|
profileOrId: ViewportProfile | ViewportResolution,
|
||||||
targets: ViewportDomTargets,
|
targets: ViewportDomTargets,
|
||||||
): ViewportProfile {
|
): ViewportProfile {
|
||||||
const profile = getViewportProfile(profileOrId)
|
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) {
|
if (targets.stageEl) {
|
||||||
targets.stageEl.style.width = `${profile.width}px`
|
targets.stageEl.style.width = fit ? `${fit.cssWidth}px` : `${profile.width}px`
|
||||||
targets.stageEl.style.height = `${profile.height}px`
|
targets.stageEl.style.height = fit ? `${fit.cssHeight}px` : `${profile.height}px`
|
||||||
|
targets.stageEl.setAttribute?.('data-viewport', profile.id)
|
||||||
}
|
}
|
||||||
if (targets.glCanvas) {
|
if (targets.glCanvas) {
|
||||||
targets.glCanvas.width = profile.width
|
targets.glCanvas.width = profile.width
|
||||||
targets.glCanvas.height = profile.height
|
targets.glCanvas.height = profile.height
|
||||||
if (targets.glCanvas.style) {
|
if (targets.glCanvas.style) {
|
||||||
targets.glCanvas.style.width = `${profile.width}px`
|
targets.glCanvas.style.width = fit ? '100%' : `${profile.width}px`
|
||||||
targets.glCanvas.style.height = `${profile.height}px`
|
targets.glCanvas.style.height = fit ? '100%' : `${profile.height}px`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (targets.hudCanvas) {
|
if (targets.hudCanvas) {
|
||||||
targets.hudCanvas.width = profile.width
|
targets.hudCanvas.width = profile.width
|
||||||
targets.hudCanvas.height = profile.height
|
targets.hudCanvas.height = profile.height
|
||||||
if (targets.hudCanvas.style) {
|
if (targets.hudCanvas.style) {
|
||||||
targets.hudCanvas.style.width = `${profile.width}px`
|
targets.hudCanvas.style.width = fit ? '100%' : `${profile.width}px`
|
||||||
targets.hudCanvas.style.height = `${profile.height}px`
|
targets.hudCanvas.style.height = fit ? '100%' : `${profile.height}px`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
targets.onResize?.(profile)
|
targets.onResize?.(profile)
|
||||||
return profile
|
return profile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import {
|
||||||
import {
|
import {
|
||||||
applyViewportProfileToDom,
|
applyViewportProfileToDom,
|
||||||
computeViewportAnchors,
|
computeViewportAnchors,
|
||||||
|
computeViewportFitSize,
|
||||||
getViewportProfile,
|
getViewportProfile,
|
||||||
screenToSubTile,
|
screenToSubTile,
|
||||||
screenToWorld,
|
screenToWorld,
|
||||||
|
|
@ -363,6 +364,51 @@ describe('Dual Viewport Profiles, HudManager Anchoring & Coordinate Transforms (
|
||||||
expect(glCanvas.width).toBe(1024)
|
expect(glCanvas.width).toBe(1024)
|
||||||
expect(hudCanvas.height).toBe(768)
|
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)', () => {
|
describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F17)', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue