Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/tf2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Deliver faithful TF2 gameplay and presentation at `https://playsrc.online/tf2`.

- Assemble the TF2 game, selected TF2 rulesets, simulation, networking, presentation, assets, and product UI.
- Own browser lifecycle, input, settings, navigation, and application state.
- Measure one positive integer CSS-pixel presentation viewport from the shared application content box; coalesce content-box, visual-viewport, orientation, fullscreen, pointer-lock, and DPR notifications into one animation-frame publication shared by Rendering and every VGUI owner. Zero size suspends publication, duplicate records are inert, and teardown removes every observer, listener, media query, and pending frame.
- Play the configured Valve WebM once per page process while Main Menu VGUI initializes hidden, inert, unfocusable, and accessibility-hidden; admit exact audio/video through a browser gesture, accept Escape skip, and reveal the ready menu atomically. Drive Console map loading through the configured `stamp_background_map` layer and 380×112 VGUI loading dialog using only owner-reported status/progress, then preserve pause/resume/disconnect, TF2 HUD resources, and persistent Options through the game-owned adapters. HUD/GameUI/Options resources share the selected desktop condition, and HUD geometry reapplies exact proportional coordinates on integer viewport changes. Standard and Advanced Options construct independently on first request, and hidden Options perform no frame work.
- Adapt typed VGUI console submissions to owner-defined command and convar operations. `map <catalog-map-name>` selects one declared map; `map https://<allowed-origin>/<path>/<map-name>.bsp` is an explicit bounded playsrc acquisition operation and is not a TF2 parity capability.
- Own typed `cl_showfps 0|1|2` and `cl_showpos 0|1|2` values, immutable diagnostic inputs, exact catalog revisions/current-value output, and explicit unavailable distinctions; VGUI owns the bounded diagnostic panel.
Expand Down
1 change: 1 addition & 0 deletions apps/web/tf2/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ This integration refines `TF2-WEB-004` through `TF2-WEB-008` and `TF2-WEB-012` t
| Video, Audio, Mouse, Keyboard, Multiplayer, and Advanced Options stage one current settings profile, apply or cancel atomically, reset defaults, capture/unbind conflicting bindings, and report restart requirements. | Configured Options resources present one `TF2_SELECTED_OPTIONS` state. VGUI applies exact alignment, scheme state colors/borders, SectionedList flags, slider geometry and ComboBox popup state; untouched clamped controls cannot become pending changes. | Deterministic codec/state vectors plus resource-derived control inventory and headed 1,280×720/390×844 default/armed/selected/disabled/dropdown/edit/cancel/apply/default/binding/conflict/unbind/reload schedules pass. | Ready |
| Startup media, GameUI, loading VGUI, HUD, Options, Console, Rendering, Audio, input, Simulation, immutable objects, and derived caching retain sole ownership and release every browser resource in reverse lifecycle order. | Preact owns only application mounts and orchestration; startup owns its media URL/listeners and hidden-menu handle; VGUI owns Main Menu/loading/HUD descendants, modal state, input, animation clock, and cleanup. Map/disconnect transitions reset one mounted HUD runtime; application release destroys it once. | DOM-owner snapshots, exact one-HUD/one-GameUI panel counts, hidden/inert accessibility checks, listener/font/image/object-URL/worker/audio/cache cleanup, strict builds, privacy scans, and repeated destroy evidence pass. | Ready |
| Application frame admission advances visible Options and active GameUI/HUD owners; hidden Options retain immutable state without layout, animation, DOM, raster, input, or request work until shown. Standard and Advanced Options initialize independently from complete shared settings state. | The browser lazily constructs the selected Options family, advances only that visible runtime, rebases its post-construction frame clock, and tracks deferred GameUI owner requests for cleanup. MainMenu no longer creates or frames either Options runtime; standard Options does not create Advanced's 88 rows. | **Owner suspension trace:** checked MainMenu/standard-Options profiles plus GameUI/HUD/Options unit and browser schedules compare owner frame work, resumed clocks, snapshots, requests, DOM mutations, pixels, and teardown. | Ready |
| The client surface and proportional HUD share one current physical presentation viewport through size, visual-viewport, orientation, fullscreen, pointer-lock, and device-pixel-ratio transitions. | One application owner measures the shared mount content box, admits only positive integer CSS dimensions plus DPR, coalesces notifications into one animation-frame commit, and publishes one immutable record to Rendering and every mounted VGUI owner. Zero size suspends publication; an identical record is inert. | **Composed viewport schedule:** headed fixed-size, height-only, width-only, portrait, landscape, ultrawide, DPR, restore, duplicate and teardown vectors compare owner revisions, complete DOM rectangles, configured bottom-panel offsets, layer alpha/color occupancy, and final composed rows. | Ready |

### Active visual-output audit

Expand Down
196 changes: 196 additions & 0 deletions apps/web/tf2/src/presentation-viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
export type ApplicationPresentationViewport = Readonly<{ width: number; height: number; devicePixelRatio: number; revision: number }>

type ListenerTarget = Readonly<{
addEventListener(type: string, listener: () => void): void
removeEventListener(type: string, listener: () => void): void
}>

export type PresentationViewportPlatform = Readonly<{
measure(): Readonly<{ width: number; height: number; devicePixelRatio: number }>
requestFrame(callback: () => void): number
cancelFrame(handle: number): void
observeResize(callback: () => void): Readonly<{ disconnect(): void }>
visualViewport: ListenerTarget | null
orientation: ListenerTarget | null
document: ListenerTarget
resolutionQuery(devicePixelRatio: number): ListenerTarget
}>

export type PresentationViewportOwnerSnapshot = Readonly<{
lifecycle: "live" | "destroyed"
viewport: ApplicationPresentationViewport | null
notifications: number
measurements: number
publications: number
suspensions: number
pendingFrames: number
listeners: number
observers: number
}>

export type PresentationViewportOwner = Readonly<{
first(): Promise<ApplicationPresentationViewport>
notify(): void
snapshot(): PresentationViewportOwnerSnapshot
destroy(): void
}>

export function initializePresentationViewportOwner(request: Readonly<{
platform: PresentationViewportPlatform
onViewport(viewport: ApplicationPresentationViewport): void
onSuspended(): void
}>): PresentationViewportOwner {
let lifecycle: "live" | "destroyed" = "live"
let viewport: ApplicationPresentationViewport | null = null
let revision = 0
let notifications = 0
let measurements = 0
let publications = 0
let suspensions = 0
let suspended = false
let frame: number | null = null
let resolutionQuery: ListenerTarget | null = null
let resolutionDevicePixelRatio: number | null = null
let firstSettled = false
let resolveFirst!: (value: ApplicationPresentationViewport) => void
let rejectFirst!: (reason: Error) => void
const first = new Promise<ApplicationPresentationViewport>((resolve, reject) => {
resolveFirst = resolve
rejectFirst = reject
})

const same = (left: ApplicationPresentationViewport | null, right: Readonly<{ width: number; height: number; devicePixelRatio: number }>): boolean =>
left !== null
&& left.width === right.width
&& left.height === right.height
&& left.devicePixelRatio === right.devicePixelRatio

const schedule = (): void => {
if (lifecycle === "destroyed") return
notifications += 1
if (frame !== null) return
frame = request.platform.requestFrame(commit)
}

const resolutionChanged = (): void => schedule()

const armResolutionQuery = (devicePixelRatio: number): void => {
if (resolutionDevicePixelRatio === devicePixelRatio) return
resolutionQuery?.removeEventListener("change", resolutionChanged)
resolutionQuery = request.platform.resolutionQuery(devicePixelRatio)
resolutionDevicePixelRatio = devicePixelRatio
resolutionQuery.addEventListener("change", resolutionChanged)
}

function commit(): void {
frame = null
if (lifecycle === "destroyed") return
measurements += 1
const measured = request.platform.measure()
const width = Math.trunc(measured.width)
const height = Math.trunc(measured.height)
const devicePixelRatio = measured.devicePixelRatio
const valid = Number.isFinite(width) && Number.isFinite(height)
&& width > 0 && width <= 32767 && height > 0 && height <= 32767
&& Number.isFinite(devicePixelRatio) && devicePixelRatio >= 0.5 && devicePixelRatio <= 8
if (!valid) {
if (!suspended) {
viewport = null
suspended = true
suspensions += 1
request.onSuspended()
}
return
}
suspended = false
armResolutionQuery(devicePixelRatio)
if (same(viewport, { width, height, devicePixelRatio })) return
revision += 1
viewport = Object.freeze({ width, height, devicePixelRatio, revision })
publications += 1
request.onViewport(viewport)
if (!firstSettled) {
firstSettled = true
resolveFirst(viewport)
}
}

const resizeObserver = request.platform.observeResize(schedule)
request.platform.visualViewport?.addEventListener("resize", schedule)
request.platform.orientation?.addEventListener("change", schedule)
request.platform.document.addEventListener("fullscreenchange", schedule)
request.platform.document.addEventListener("pointerlockchange", schedule)
schedule()

return Object.freeze({
first: () => first,
notify: schedule,
snapshot: () => Object.freeze({
lifecycle,
viewport,
notifications,
measurements,
publications,
suspensions,
pendingFrames: frame === null ? 0 : 1,
listeners: lifecycle === "destroyed" ? 0 : 2 + Number(request.platform.visualViewport !== null) + Number(request.platform.orientation !== null) + Number(resolutionQuery !== null),
observers: lifecycle === "destroyed" ? 0 : 1,
}),
destroy: () => {
if (lifecycle === "destroyed") return
lifecycle = "destroyed"
if (frame !== null) request.platform.cancelFrame(frame)
frame = null
resizeObserver.disconnect()
request.platform.visualViewport?.removeEventListener("resize", schedule)
request.platform.orientation?.removeEventListener("change", schedule)
request.platform.document.removeEventListener("fullscreenchange", schedule)
request.platform.document.removeEventListener("pointerlockchange", schedule)
resolutionQuery?.removeEventListener("change", resolutionChanged)
resolutionQuery = null
if (!firstSettled) {
firstSettled = true
rejectFirst(new Error("Presentation viewport owner was destroyed before a positive content box was admitted"))
}
},
})
}

export function initializeBrowserPresentationViewportOwner(request: Readonly<{
root: HTMLElement
onViewport(viewport: ApplicationPresentationViewport): void
onSuspended(): void
}>): PresentationViewportOwner {
const owner = initializePresentationViewportOwner({
platform: {
measure: () => Object.freeze({
width: request.root.clientWidth,
height: request.root.clientHeight,
devicePixelRatio: window.devicePixelRatio,
}),
requestFrame: (callback) => requestAnimationFrame(callback),
cancelFrame: (handle) => cancelAnimationFrame(handle),
observeResize: (callback) => {
const observer = new ResizeObserver(callback)
observer.observe(request.root, { box: "content-box" })
return Object.freeze({ disconnect: () => observer.disconnect() })
},
visualViewport: window.visualViewport,
orientation: screen.orientation ?? null,
document,
resolutionQuery: (devicePixelRatio) => matchMedia(`(resolution: ${devicePixelRatio}dppx)`),
},
onViewport: (viewport) => {
request.root.dataset.presentationViewportState = "active"
request.root.dataset.presentationViewport = `${viewport.width}x${viewport.height}@${viewport.devicePixelRatio}`
request.root.dataset.presentationViewportRevision = String(viewport.revision)
request.onViewport(viewport)
},
onSuspended: () => {
request.root.dataset.presentationViewportState = "suspended"
delete request.root.dataset.presentationViewport
request.onSuspended()
},
})
return owner
}
Loading