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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user