// Undercooked — the composition root (phase 07 task 03). // // The page's one entry module (`index.html` loads `/static/js/main.js` // and nothing else): it parses the URL params, builds the pieces — // the screen machine (`app.js`), the snapshot buffer, the event queue, // the single render loop — and routes the wire: // // * `snap` → the snapshot buffer (gap / resync / freeze rules; the // buffer is the *render* path) **and** the app's reducer (whose // merged view is the *DOM* path the lobby / HUD / choreography // read); the frame's `e` also feeds the event queue; // * everything else → the screen machine's reducer, untouched. // // The seams: `window.__undercooked` is the app instance (the state // machine the e2e suites drive — unchanged from task 02, plus the // `params` / `seed` / `snapshotBuffer` / `eventQueue` fields); // `window.__uc` is the loop census and the pure modules // (`frameMs` p50/p95 self-measured, `buffer`, `events`, `loop`, // `params`, the last interpolated `lastView`). // // Still a plain ES module: no bundler, no framework, no remote import // (PLAN A10/A12). The boot is synchronous at module evaluation // (static imports only), so by the time the page's module graph has // settled, `window.__undercooked` is set — no async boot race. import "./main-flag.js"; // the auto-boot guard (must precede app.js) import { createApp } from "./app.js"; import { createSnapshotBuffer, lerpState } from "./net/snapshots.js"; import { createEventQueue } from "./net/events.js"; import { createRenderLoop } from "./render/loop.js"; import { createCamera } from "./render/camera.js"; import { createRenderer } from "./render/canvas.js"; import { createFx } from "./render/fx.js"; import { createHud } from "./ui/hud.js"; import { createKeyboardInput } from "./input/keyboard.js"; import { createHelpOverlay } from "./ui/help.js"; /** The URL params (docs/CLIENT.md "The URL params"): * `room` — join this code on boot (a test harness seam); * `seed` — the campaign's seed (exposed for the deterministic * suites; the server is authoritative, it never simulates); * `controls` — `keyboard` (default) / `touch` / `off` (task 05's * input module reads it; `off` means no input module binds); * `freeze` — `1` holds the sample clock at the first playing frame * (a pixel-diff test sees a still image); * `motion` — `1` (default) / `0` (the reduced-motion mode; task 04's * FX and phase 14's a11y read it). */ function parseParams(search = new URLSearchParams(location.search)) { return { room: search.get("room") ?? "", seed: search.get("seed") ?? "", controls: search.get("controls") ?? "keyboard", freeze: search.get("freeze") === "1" || search.get("freeze") === "true", motion: search.get("motion") === "0" ? "0" : "1", }; } /** The one-time boot (synchronous; `index.html`'s module evaluation runs it). */ export function bootstrap() { const params = parseParams(); const buffer = createSnapshotBuffer({ onResync: () => { // The resync request (the buffer's debounce is the only gate): // the client's half is to *hold* — the stream's full cadence // (phase 06, every 20 encoded frames) is the resync. Nothing to // send; the hook exists so a phase-08 reconnect can hook in. }, }); const events = createEventQueue(); const app = createApp(document, { onWireMessage: (frame) => { if (frame.type === "snap" && frame.raw !== undefined) { buffer.push(frame.raw); // the render path (gap / resync / freeze) if (Array.isArray(frame.raw.e)) events.push(frame.raw.t, frame.raw.e); } else if (frame.type === "ev" && Array.isArray(frame.events)) { events.push(null, frame.events); // the standalone `ev` (no tick) } }, }); app.params = params; if (params.seed !== "") app.seed = params.seed; app.snapshotBuffer = buffer; app.eventQueue = events; // The in-game help (task 05): one body-level overlay, created once, // persisting across screens; the keyboard's `?`/`Esc` branches call // into it. const help = createHelpOverlay(); // The keyboard input (task 05): `?controls=keyboard` (the default) // binds the WASD/arrow + Space/Enter + emote + `?`/Esc listeners and // the keep-alive pump; `touch`/`off` leave the seams, bind nothing. const input = createKeyboardInput({ app, controls: params.controls, help, }); app.help = help; app.input = input; let freezeAt = null; // The play session's runtime (task 04): the camera, the FX, the // canvas renderer and the HUD — bound to the *mounted* `#kcanvas` // (the play screen's DOM is rebuilt per round: a new canvas means a // fresh runtime, a vanished one means the screen left and the // runtime tears down). The single-owner rule is untouched: the loop // is the only thing that drives them. let runtime = null; function playRuntime(nowMs) { const canvas = document.getElementById("kcanvas"); if (canvas === null) { if (runtime !== null) { if (runtime.hud !== null) runtime.hud.unmount(); runtime.renderer.dispose(); runtime.camera.detach(); runtime = null; } return null; } if (runtime === null || runtime.canvas !== canvas) { const camera = createCamera({ canvas }); const fx = createFx({ canvas }); const renderer = createRenderer({ canvas, camera, fx, ownSeat: () => (app.you !== null ? app.you.seat : null), }); const hudRoot = document.getElementById("hud"); const hud = hudRoot !== null ? createHud({ root: hudRoot, app }) : null; if (params.motion === "0") canvas.dataset.motion = "reduced"; camera.attach(); renderer.ensureReady(); // the atlas's one-time load (a failed load throws into the promise; the first frames wait for it) runtime = { canvas, camera, fx, renderer, hud }; } if (runtime.renderer.isReady() && canvas.hidden) { canvas.hidden = false; // the renderer is ready: the canvas may show } if (runtime.renderer.isReady()) { const sample = buffer.sample(params.freeze ? (freezeAt ??= nowMs) : nowMs); if (sample !== null) { const state = lerpState(sample.a, sample.b, sample.alpha); globalThis.__uc.lastView = state; const due = events.due(nowMs); // one drain per frame (FX + HUD share it) runtime.fx.update(nowMs); runtime.fx.consume(due, state, nowMs); runtime.renderer.drawFrame(state, nowMs); if (runtime.hud !== null) { runtime.hud.update(state, buffer.latest(), app, due, nowMs); } } } return runtime; } const loop = createRenderLoop({ active: () => app.screen === "playing", render: (nowMs) => { // Task 04's half: the sample feeds the renderer (the canvas // layers, docs/CLIENT.md's order) and the HUD (the diff- // written readouts); `lastView` stays the smoothness suites' // seam. playRuntime(nowMs); }, }); loop.start(); globalThis.__uc = { params, app, buffer, events, loop, lastView: null, get frameMs() { return loop.frameStats(); }, /** The renderer's frame census (the perf assertions' seam; the * loop's own rAF census is `frameMs`). */ renderStats: () => runtime !== null ? runtime.renderer.renderStats() : { drawCalls: 0, frames: 0 }, /** The play session's runtime (the camera / the FX / the * renderer / the HUD; `null` off the play screen). */ get runtime() { return runtime; }, /** The FX engine (the reduced-motion + the nudge's census). */ get fx() { return runtime !== null ? runtime.fx : null; }, /** The keyboard input module (task 05; its `stats()` is the * held-key / last-frame / sent-count census). */ input, /** The help overlay (task 05; `?` toggles it). */ help, }; globalThis.__undercooked = app; if (params.room !== "") app.join(params.room); return app; } // The page loads this module for the side effect: boot once, at // module evaluation (the module graph settles before the first paint, // so there is no visible flash and no async boot race). if (typeof document !== "undefined" && document.getElementById("screen-root")) { bootstrap(); }