// 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, }; }