phase: 07_canvas_client_and_hud

**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
This commit is contained in:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+208
View File
@@ -0,0 +1,208 @@
// 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();
}