**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`.
145 lines
4.9 KiB
JavaScript
145 lines
4.9 KiB
JavaScript
// Undercooked — the single render loop (phase 07 task 03).
|
|
//
|
|
// One `requestAnimationFrame` loop per page — the single-owner rule:
|
|
// exactly one loop instance exists (the composition root, `main.js`,
|
|
// creates it), and it is the only thing that samples the buffer,
|
|
// calls the renderer and updates the HUD
|
|
// (`sample → render → hud.update` per frame, docs/CLIENT.md "The
|
|
// render loop"). No screen, no module, no test spawns a second loop:
|
|
// two owners of the clock is how the frame-time accounting goes wrong.
|
|
//
|
|
// The loop's lifecycle:
|
|
//
|
|
// * **paused when `document.hidden`** — the rAF chain stops entirely
|
|
// (a pending frame that finds `hidden` renders nothing and does not
|
|
// reschedule; the `visibilitychange` back to visible restarts it).
|
|
// The delta spanning the pause is *dropped*, not measured, so a
|
|
// resume cannot pollute the p50/p95 window.
|
|
// * **idle when the screen is not `PLAYING`** — `active()` is the
|
|
// composition's predicate (the screen's name); a non-active frame
|
|
// renders nothing but keeps the chain alive, so the loop resumes on
|
|
// the very next frame after the screen turns `PLAYING` (no event
|
|
// needed).
|
|
// * **`?freeze=1`** — the composition's business (it holds the *sample
|
|
// time* at the first playing frame); the loop itself just renders
|
|
// whatever `render(nowMs)` asks for, so a frozen render is a still
|
|
// image without the loop pausing.
|
|
//
|
|
// Self-measured: every rendered frame's time (the `now` deltas) lands
|
|
// in a sliding 120-sample window; `frameStats()` reports `frames`,
|
|
// `running`, `hidden`, `p50` / `p95` (the `frameMs` the spec wants on
|
|
// `window.__uc`) and `max`. The window resets on `start()`.
|
|
|
|
export function createRenderLoop({
|
|
active = () => true,
|
|
render = null,
|
|
now = null,
|
|
raf = null,
|
|
unraf = null,
|
|
owner = null,
|
|
} = {}) {
|
|
const clock = typeof now === "function" ? now : () => performance.now();
|
|
const schedule = typeof raf === "function" ? raf : (fn) => requestAnimationFrame(fn);
|
|
const cancel = typeof unraf === "function" ? unraf : (id) => cancelAnimationFrame(id);
|
|
const visOwner = owner !== null ? owner : typeof document !== "undefined" ? document : null;
|
|
|
|
let running = false;
|
|
let scheduled = null;
|
|
let hidden = visOwner !== null ? Boolean(visOwner.hidden) : false;
|
|
let frames = 0;
|
|
let lastNow = null;
|
|
let pausedAt = null; // the pause boundary (the resume's delta drops)
|
|
const sampleWindow = 120;
|
|
const deltas = [];
|
|
|
|
const onVisibilityChange = () => {
|
|
const wasHidden = hidden;
|
|
hidden = visOwner !== null ? Boolean(visOwner.hidden) : false;
|
|
if (hidden && !wasHidden) pausedAt = clock(); // mark the pause
|
|
if (running && !hidden && scheduled === null) scheduleFrame(); // resume
|
|
};
|
|
|
|
function frame() {
|
|
scheduled = null;
|
|
if (!running || hidden) return; // stopped or paused: wait for a resume
|
|
const t = clock();
|
|
if (active()) {
|
|
if (lastNow !== null) {
|
|
const dt = t - lastNow;
|
|
const resumed = pausedAt !== null; // a pause just ended
|
|
pausedAt = null; // consumed once, by the first resumed frame
|
|
if (!resumed) {
|
|
deltas.push(dt);
|
|
if (deltas.length > sampleWindow) deltas.shift();
|
|
}
|
|
}
|
|
lastNow = t;
|
|
frames += 1;
|
|
if (typeof render === "function") render(t);
|
|
if (running && !hidden) scheduleFrame();
|
|
} else {
|
|
// Idle (the screen is not PLAYING): keep the chain alive so the
|
|
// active() flip is picked up on the next frame; a fresh round
|
|
// starts with a fresh delta, and a pending pause boundary cannot
|
|
// survive an idle period (it would drop a real delta later).
|
|
pausedAt = null;
|
|
lastNow = null;
|
|
scheduleFrame();
|
|
}
|
|
}
|
|
|
|
function scheduleFrame() {
|
|
scheduled = schedule(frame);
|
|
}
|
|
|
|
function start() {
|
|
if (running) return;
|
|
running = true;
|
|
frames = 0;
|
|
lastNow = null;
|
|
pausedAt = null;
|
|
deltas.length = 0;
|
|
if (visOwner !== null) {
|
|
visOwner.addEventListener("visibilitychange", onVisibilityChange);
|
|
}
|
|
if (!hidden) scheduleFrame();
|
|
}
|
|
|
|
function stop() {
|
|
if (!running) return;
|
|
running = false;
|
|
if (scheduled !== null) {
|
|
cancel(scheduled);
|
|
scheduled = null;
|
|
}
|
|
if (visOwner !== null) {
|
|
visOwner.removeEventListener("visibilitychange", onVisibilityChange);
|
|
}
|
|
}
|
|
|
|
/** The self-measured frame census (the `frameMs` p50/p95 on `window.__uc`). */
|
|
function frameStats() {
|
|
const sorted = [...deltas].sort((a, b) => a - b);
|
|
const quantile = (q) =>
|
|
sorted.length === 0
|
|
? 0
|
|
: sorted[Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1)];
|
|
return {
|
|
frames,
|
|
running,
|
|
hidden,
|
|
p50: quantile(0.5),
|
|
p95: quantile(0.95),
|
|
max: sorted.length === 0 ? 0 : sorted[sorted.length - 1],
|
|
};
|
|
}
|
|
|
|
return {
|
|
start,
|
|
stop,
|
|
frameStats,
|
|
isRunning: () => running,
|
|
isHidden: () => hidden,
|
|
};
|
|
}
|