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
+411 -112
View File
@@ -1,134 +1,433 @@
// Undercooked — client shell entry point (phase 01).
// Undercooked — the screen state machine (phase 07 task 02; the
// composition root — URL params, the snapshot buffer, the render
// loop, the wire routing — is `main.js`, phase 07 task 03).
//
// There is no game logic here on purpose. Phase 05 shipped the server-side
// room shell (supervisor, room FSM, session store); phase 06 adds the WebSocket
// transport and phase 07 replaces the placeholder button handlers with the lobby
// flow and adds render/ (Canvas 2D). What this file owns today:
// * the anonymous per-tab session token (PLAN.md A16),
// * the last room code typed in this tab, so a reload can rejoin fast,
// * mounting the lobby screen and announcing state into #status.
// It is a plain ES module served from /static/ — no bundler, no framework, no
// remote import (PLAN.md A10/A12).
// `createApp()` owns the one client-side state machine: an explicit
// screen enum (`LANDING, CONNECTING, LOBBY, COUNTDOWN, PLAYING,
// ROUND_END, ERROR`), `show(screen, data)`, and the message reducer
// that maps the wire contract (docs/PROTOCOL.md) onto screens —
// `welcome`/`lobby` by the room's state, `countdown` → COUNTDOWN,
// `snap` → PLAYING, `round` → ROUND_END, fatal `err` codes → ERROR.
// Every screen is a module exporting `{ mount, unmount }` (plus an
// optional same-screen `update`); no global DOM lives outside them.
//
// `identity.js` keeps the per-tab identity (PLAN A16): the anonymous
// session token and the last room code, both in `sessionStorage`.
// The app instance is exposed as `window.__undercooked` — the documented test seam the
// e2e suites drive (and the task-05 keyboard module will feed).
//
// It is a plain ES module served from /static/ — no bundler, no
// framework, no remote import (PLAN A10/A12).
const TOKEN_KEY = "uc.token";
const LAST_CODE_KEY = "uc.lastCode";
const CODE_LENGTH = 5;
const NOT_IMPLEMENTED = "Not implemented until phase 07.";
const READY_MESSAGE = "Pick a kitchen to start.";
import { createConnection, FATAL_ERR_CODES } from "./net/connection.js";
import { getToken, lastCode, rememberCode, randomToken } from "./identity.js";
import * as landingScreen from "./screens/landing.js";
import * as connectingScreen from "./screens/connecting.js";
import * as lobbyScreen from "./screens/lobby.js";
import * as countdownScreen from "./screens/countdown.js";
import * as playScreen from "./screens/play.js";
import * as roundEndScreen from "./screens/round_end.js";
import * as errorScreen from "./screens/error.js";
/** Base64url without padding, so the token is safe in a query string or header. */
function base64url(bytes) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export { randomToken, getToken, lastCode, rememberCode };
/** A random, unguessable id for this browser tab (identity, not an account). */
export function randomToken() {
const bytes = new Uint8Array(24);
globalThis.crypto.getRandomValues(bytes);
return base64url(bytes);
}
/** The screen enum (the task's list, frozen). */
export const SCREENS = Object.freeze({
LANDING: "landing",
CONNECTING: "connecting",
LOBBY: "lobby",
COUNTDOWN: "countdown",
PLAYING: "playing",
ROUND_END: "round_end",
ERROR: "error",
});
function read(storage, key) {
try {
return storage.getItem(key);
} catch {
return null; // private browsing / storage disabled: stay functional for this tab only
}
}
const SCREEN_MODULES = {
landing: landingScreen,
connecting: connectingScreen,
lobby: lobbyScreen,
countdown: countdownScreen,
playing: playScreen,
round_end: roundEndScreen,
error: errorScreen,
};
function write(storage, key, value) {
try {
storage.setItem(key, value);
} catch {
/* nothing to persist to; the in-memory value still works */
}
}
/** The room-state value → screen (the `welcome`/`lobby` mapping half). */
const SCREEN_BY_ROOM_STATE = {
lobby: SCREENS.LOBBY,
countdown: SCREENS.COUNTDOWN,
playing: SCREENS.PLAYING,
round_end: SCREENS.ROUND_END,
closed: SCREENS.ERROR,
};
/** The tab's session token, created once per tab and kept in sessionStorage. */
export function getToken(storage = globalThis.sessionStorage) {
const existing = read(storage, TOKEN_KEY);
if (typeof existing === "string" && existing.length > 0) return existing;
const token = randomToken();
write(storage, TOKEN_KEY, token);
return token;
}
const LOG_CAP = 2000;
/** Uppercase what the user typed and keep only characters a code can contain. */
export function normalizeCode(value) {
return String(value ?? "")
.toUpperCase()
.replace(/[^0-9A-Z]/g, "")
.slice(0, CODE_LENGTH);
}
export function createApp(root = document, options = {}) {
const { onWireMessage = null } = options;
const screenRoot = root.getElementById("screen-root");
const app = {
token: getToken(),
lastCode: lastCode(),
screen: null, // set by the first show()
data: {},
roomCode: "",
welcome: null,
you: null, // the viewer's seat (YouWire)
spectator: false, // joined mid-round (or the token holds no seat)
lobby: null, // the latest `lobby` frame
round: null, // the latest `round` frame
lastSnapshot: null, // the latest `snap` raw body (the render path is task 03's buffer; this view is the DOM path the screens and the choreography read)
session: null, // the `/api/session` projection
levels: null, // the `/api/levels` cards (null = not fetched yet)
view: makeView(), // the merged snapshot view (full frames reset, deltas patch)
error: null, // the fatal `err` frame, when one caused ERROR
banner: null, // non-fatal `err` text for the mounted screen
messageLog: [], // bounded wire-census for the e2e suites
leaving: false, // we closed the socket on purpose
};
const connection = createConnection();
const warnedUnknown = new Set();
let mounted = null;
export function lastCode(storage = globalThis.sessionStorage) {
return read(storage, LAST_CODE_KEY) || "";
}
export function rememberCode(code, storage = globalThis.sessionStorage) {
write(storage, LAST_CODE_KEY, normalizeCode(code));
}
/** Write the live region. `kind` is "info" | "error" and only changes styling. */
export function announce(message, root = document, kind = "info") {
const region = root.getElementById("status");
if (!region) return;
region.textContent = message;
region.dataset.kind = kind;
}
/**
* Wire the lobby screen. Returns the tab identity so later phases can reuse it.
*/
export function mount(root = document) {
const token = getToken();
const createButton = root.getElementById("create-lobby");
const joinForm = root.getElementById("join-form");
const joinInput = root.getElementById("join-code");
const remembered = lastCode();
if (joinInput && remembered) joinInput.value = remembered;
if (createButton) {
createButton.addEventListener("click", () => {
// Phase 07 (on the phase-06 transport): POST to the room supervisor,
// open /ws/{code}.
announce(NOT_IMPLEMENTED, root);
});
/**
* The merged snapshot view — the task-02 seed of task 03's buffer: a
* full frame resets the entity maps, a delta patches them (an upsert on
* the identity key, a `gone` tombstone drops). The stream's full cadence
* (every 20 encoded frames) plus the fresh-connection full keeps this
* resyncable; a client-side drift is what task 03's decoder handles.
*/
function makeView() {
return { players: {}, stations: {}, orders: {} };
}
if (joinForm) {
joinForm.addEventListener("submit", (event) => {
event.preventDefault();
const code = normalizeCode(joinInput ? joinInput.value : "");
if (joinInput) joinInput.value = code;
if (code.length !== CODE_LENGTH) {
announce(`A room code is ${CODE_LENGTH} characters.`, root, "error");
if (joinInput) joinInput.focus();
return;
function stationKey(entry) {
return `${entry.k}@${entry.x},${entry.y}`;
}
function applySnapshot(raw) {
app.lastSnapshot = raw;
const view = app.view;
if (raw.full) {
view.players = {};
view.stations = {};
view.orders = {};
for (const entry of raw.p ?? []) view.players[entry.i] = entry;
for (const entry of raw.st ?? []) view.stations[stationKey(entry)] = entry;
for (const entry of raw.o ?? []) view.orders[entry.id] = entry;
return;
}
const delta = raw.d ?? {};
for (const entry of delta.p ?? []) {
if (entry.gone) delete view.players[entry.i];
else view.players[entry.i] = entry;
}
for (const entry of delta.st ?? []) {
const key = stationKey(entry);
if (entry.gone) delete view.stations[key];
else view.stations[key] = entry;
}
for (const entry of delta.o ?? []) {
if (entry.gone) delete view.orders[entry.id];
else view.orders[entry.id] = entry;
}
}
function pushLog(frame) {
app.messageLog.push(frame);
if (app.messageLog.length > LOG_CAP) app.messageLog.shift();
}
/**
* Show one screen. An unknown screen name (a bug) logs once and
* becomes ERROR; the same screen re-shown in place calls the
* module's `update` (a re-mount would drop focus off live controls).
*/
function show(screen, data = {}) {
if (!(screen in SCREEN_MODULES)) {
if (!warnedUnknown.has(screen)) {
console.warn(
`undercooked: unknown screen "${String(screen)}" — going to the error screen`,
);
warnedUnknown.add(screen);
}
rememberCode(code);
// Phase 07 (on the phase-06 transport): join the room over the
// WebSocket.
announce(NOT_IMPLEMENTED, root);
});
screen = SCREENS.ERROR;
}
app.data = data;
if (app.screen === screen && mounted !== null) {
if (typeof mounted.update === "function") mounted.update(data, app);
return screen;
}
if (mounted !== null && typeof mounted.unmount === "function") mounted.unmount();
mounted = null;
app.screen = screen;
mounted = SCREEN_MODULES[screen];
// The previous screen's DOM is the previous screen's problem no
// more: a screen module appends, it does not replace, so the
// machine clears the root before mounting (a re-mount would stack
// a second copy of the screen on top of the first).
screenRoot.replaceChildren();
if (typeof mounted.mount === "function") mounted.mount(screenRoot, data, app);
return screen;
}
if (!lastCode() && root.getElementById("status")) {
announce(READY_MESSAGE, root);
function failToLanding(banner) {
app.banner = banner;
show(SCREENS.LANDING, {});
}
return { token, code: lastCode() };
// ------------------------------------------------------------- verbs
async function createLobby({ name = null } = {}) {
app.leaving = false;
show(SCREENS.CONNECTING, { what: "creating" });
let response;
try {
response = await fetch("/api/rooms", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
token: app.token,
...(name !== null && name !== "" ? { name } : {}),
}),
});
} catch {
failToLanding("Cannot reach the server — check the address and try again.");
return;
}
if (response.status === 503) {
failToLanding("The server is at capacity — try again in a moment.");
return;
}
if (response.status !== 201) {
failToLanding("The server refused the room. Try again.");
return;
}
const body = await response.json().catch(() => null);
if (body === null || typeof body.room_code !== "string") {
failToLanding("The server answered something unrecognisable. Try again.");
return;
}
app.roomCode = body.room_code;
rememberCode(body.room_code);
connection.connect(body.room_code, { token: app.token });
}
function join(code) {
app.leaving = false;
app.roomCode = code;
rememberCode(code);
show(SCREENS.CONNECTING, { what: "joining", code });
connection.connect(code, { token: app.token });
}
function leave() {
app.leaving = true;
connection.close();
show(SCREENS.LANDING, {});
}
function backToLanding() {
app.leaving = true;
connection.close();
app.banner = null;
show(SCREENS.LANDING, {});
}
function retryJoin() {
if (app.roomCode === "") return;
app.leaving = false;
show(SCREENS.CONNECTING, { what: "joining", code: app.roomCode });
connection.connect(app.roomCode, { token: app.token });
}
function ready() {
connection.send({ type: "ready" });
}
function start() {
connection.send({ type: "start" });
}
function setLevel(level) {
connection.send({ type: "set_level", level });
}
function emote(id) {
connection.send({ type: "emote", id });
}
function toLobby() {
connection.send({ type: "to_lobby" });
}
function nextLevel() {
connection.send({ type: "next_level" });
}
function sendInput(move, act) {
connection.sendInput(move, act);
}
async function fetchSession() {
try {
const response = await fetch("/api/session", {
headers: { "X-Undercooked-Token": app.token },
});
if (response.ok) {
app.session = await response.json();
if (mounted !== null && typeof mounted.renderBest === "function") {
mounted.renderBest(app);
}
}
} catch {
/* offline: the previous session stays */
}
return app.session;
}
async function fetchLevels() {
if (app.levels !== null) return app.levels;
try {
const response = await fetch("/api/levels");
app.levels = response.ok ? await response.json() : [];
} catch {
app.levels = [];
}
return app.levels;
}
/** The merged view's own half (a `snap` landed, delta or full). */
function orderOpen() {
return Object.keys(app.view.orders).length > 0;
}
function showReconnecting(visible) {
if (mounted !== null && typeof mounted.showReconnecting === "function") {
mounted.showReconnecting(visible);
}
}
// ------------------------------------------------------------- reducer
connection.onMessage = (frame) => {
// The composition root's half (phase 07 task 03): the `snap` frames
// also feed the render buffer before the reducer's DOM view.
if (onWireMessage !== null) onWireMessage(frame);
pushLog(frame);
switch (frame.type) {
case "welcome": {
app.welcome = frame;
app.roomCode = frame.room;
app.you = frame.you;
// A fresh token in a non-lobby room is a spectator (a rejoin
// reclaims its seat, so `rejoined` is the discriminator).
app.spectator = frame.state !== "lobby" && frame.rejoined !== true;
show(SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR, { code: frame.room });
break;
}
case "lobby": {
app.lobby = frame;
app.roomCode = frame.code;
show(
frame.state === "lobby" ? SCREENS.LOBBY : SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR,
{},
);
break;
}
case "countdown":
show(SCREENS.COUNTDOWN, { n: frame.n });
break;
case "snap":
if (frame.raw !== undefined) applySnapshot(frame.raw);
show(SCREENS.PLAYING, {});
break;
case "round":
app.round = frame;
show(SCREENS.ROUND_END, {});
break;
case "err": {
connection.noteError(frame);
if (FATAL_ERR_CODES.includes(frame.code)) {
app.error = frame;
show(SCREENS.ERROR, { code: frame.code, msg: frame.msg });
} else if (mounted !== null && typeof mounted.setBanner === "function") {
app.banner = frame.msg;
mounted.setBanner(frame.msg);
}
break;
}
case "pong":
break; // the keep-alive's answer: nothing to render
case "ev":
break; // FX/log (task 04's renderer consumes these)
default:
break; // unknown server types are ignored (patch-compatible)
}
};
connection.onStatus = (status) => {
if (status === "mismatch") {
show(SCREENS.ERROR, { code: "protocol_mismatch" });
return;
}
if (status !== "closed") return;
if (app.leaving) return; // an intentional close: no error screen
const code = connection.lastCloseCode;
if (code === 1000 && app.screen === SCREENS.PLAYING) {
// A drop mid-round: the seat is kept for the grace; phase 08
// reconnects. This task's half is the overlay.
showReconnecting(true);
return;
}
show(SCREENS.ERROR, { code: code === 1006 ? "unreachable" : "closed" });
};
// The public verbs — assigned onto `app` itself so the screens (which
// hold `app`) and the e2e seam (`window.__undercooked`) see one and
// the same live object: a `show()` later is visible through the seam.
Object.assign(app, {
connection,
show,
createLobby,
join,
leave,
backToLanding,
retryJoin,
ready,
start,
setLevel,
emote,
toLobby,
nextLevel,
sendInput,
fetchSession,
fetchLevels,
orderOpen,
showReconnecting,
});
show(SCREENS.LANDING, {});
return app;
}
// Auto-mount when loaded as the page's module, but stay importable (and
// therefore testable) without a lobby in the document.
if (typeof document !== "undefined" && document.getElementById("screen-lobby")) {
// Auto-mount when loaded directly as the page's module; stay
// importable (and therefore testable) without one. The instance is
// the e2e test seam. `main.js` (the composition root) sets
// `globalThis.__uc_main` before importing this module — when it is
// set, main.js boots the app and this auto-boot stays out of the way.
if (
typeof document !== "undefined" &&
document.getElementById("screen-root") &&
!globalThis.__uc_main
) {
const boot = () => {
globalThis.__undercooked = createApp();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => mount(), { once: true });
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
mount();
boot();
}
}
+70
View File
@@ -0,0 +1,70 @@
// Undercooked — the tab identity (PLAN A16; carried over from the
// phase-01 shell, where these helpers lived in `app.js`).
//
// Identity is a 24-byte random token per browser tab (the browser's
// `crypto.getRandomValues` is the same entropy the server's
// `secrets.token_urlsafe(24)` uses, and the only option a browser has),
// held in `sessionStorage` so a reload re-joins instead of re-minting.
// The last room code is remembered the same way, so a reload can
// rejoin fast. Nothing here is personal: no names, no PII (PLAN §11).
const TOKEN_KEY = "uc.token";
const LAST_CODE_KEY = "uc.lastCode";
const CODE_LENGTH = 5;
/** Base64url without padding, so the token is safe in a query string or header. */
function base64url(bytes) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** A random, unguessable id for this browser tab (identity, not an account). */
export function randomToken() {
const bytes = new Uint8Array(24);
globalThis.crypto.getRandomValues(bytes);
return base64url(bytes);
}
function read(storage, key) {
try {
return storage.getItem(key);
} catch {
return null; // private browsing / storage disabled: stay functional for this tab only
}
}
function write(storage, key, value) {
try {
storage.setItem(key, value);
} catch {
/* nothing to persist to; the in-memory value still works */
}
}
/** The tab's session token, created once per tab and kept in sessionStorage. */
export function getToken(storage = globalThis.sessionStorage) {
const existing = read(storage, TOKEN_KEY);
if (typeof existing === "string" && existing.length > 0) return existing;
const token = randomToken();
write(storage, TOKEN_KEY, token);
return token;
}
/** Uppercase what the user typed and keep only characters a code can contain. */
export function normalizeCode(value) {
return String(value ?? "")
.toUpperCase()
.replace(/[^0-9A-Z]/g, "")
.slice(0, CODE_LENGTH);
}
export function lastCode(storage = globalThis.sessionStorage) {
return read(storage, LAST_CODE_KEY) || "";
}
export function rememberCode(code, storage = globalThis.sessionStorage) {
write(storage, LAST_CODE_KEY, normalizeCode(code));
}
export { CODE_LENGTH };
+317
View File
@@ -0,0 +1,317 @@
// Undercooked — the keyboard input module (phase 07, task 05).
//
// Desktop control for the play screen, wired to the protocol's `input`
// frame (docs/PROTOCOL.md "Input rules"): one frame of intent, sent on
// change plus a keep-alive at ≥ 5 Hz. The `seq` is the connection's
// (`net/connection.js` — a monotonic counter per connection, reset only
// on a new socket); this module never touches it.
//
// * `WASD` + the arrow keys → an 8-way intent vector; opposite keys
// cancel (W+S is a stop, not a nudge); a diagonal is normalised
// (the server clamps the magnitude to 1 — a diagonal must not be
// faster than a cardinal move).
// * `Space` / `Enter` = the one action button. A press is a rising
// edge (`action_held_ticks` semantics from the protocol: the hold
// rides the frame's `act` flag, `sim/interact.py`'s gate turns the
// edge into one event and the hold counters into the hold progress).
// * `1`–`4` = the emote whitelist's first four (`wave`, `cheer`,
// `thumbs_up`, `thumbs_down` — `server/seats.py`); legal in `LOBBY`
// and `PLAYING` (the server's per-state whitelist decides; this
// module only knows the screen, and the lobby screen has no
// competing control).
// * `?` = the help overlay's toggle; `Escape` = close the help, or —
// on the play screen with the help closed — leave the room (the
// Leave button's behaviour).
// * Key events are ignored when focus is in an input field, when
// `document.hidden`, or when a modifier is held (browser shortcuts
// survive: no `preventDefault` on Ctrl/Cmd/Meta combos).
// * `blur` / `visibilitychange` (hidden) / `pagehide` clear every held
// key and send one zero-intent frame with the action released (no
// cook running away when you alt-tab).
//
// `?controls=off` (and `touch`, until phase 08 ships it) binds nothing:
// the module still exposes `getKeyMap()` and a `stats()` census so the
// suites can tell the mode apart. The send pump holds the intent while
// a key is down (the sim brakes a tick with no frame — a held key must
// ride every tick window, ~48 ms ≈ 20.8 Hz) and, idle, a zero-intent
// keep-alive at 10 Hz (the protocol's ≥ 5 Hz; it is also what asserts
// "no input" after a release).
import { getKeyMap } from "../ui/help.js";
export { getKeyMap };
/** The movement key map (key code → axis). WASD and the arrows; the
* `e.code` (layout-independent physical key) is what a game binds —
* a AZERTY user's `Z` is the same physical key as a QWERTY `W`. */
const MOVE_CODES = {
KeyW: "up",
ArrowUp: "up",
KeyS: "down",
ArrowDown: "down",
KeyA: "left",
ArrowLeft: "left",
KeyD: "right",
ArrowRight: "right",
};
/** The action key codes (one button, two ways to reach it). */
const ACTION_CODES = new Set(["Space", "Enter"]);
/** `1`–`4` → the emote whitelist's first four (the server's
* `EMOTES` order, `server/seats.py`). */
const EMOTES_BY_KEY = {
Digit1: "wave",
Numpad1: "wave",
Digit2: "cheer",
Numpad2: "cheer",
Digit3: "thumbs_up",
Numpad3: "thumbs_up",
Digit4: "thumbs_down",
Numpad4: "thumbs_down",
};
/** A diagonal's length (both axes held at 1): normalising by
* `1 / √2` keeps the wire's magnitude ≤ 1 — the server's clamp must
* never see a diagonal (it would be faster than a cardinal move). */
const DIAGONAL = Math.SQRT1_2;
/** The pump's cadence: 25 ms (40 Hz — it checks every frame the
* `requestAnimationFrame`-free setInterval gives it); a held key
* re-sends at ~48 ms (above the 20 Hz tick, the hold stays
* continuous), an idle one at 100 ms (10 Hz, the ≥ 5 Hz keep-alive).
*/
const PUMP_MS = 25;
const HELD_RESEND_MS = 48;
const IDLE_KEEPALIVE_MS = 100;
/** Whether a key event is not game input (the ignore rules, one place). */
function ignored(e) {
if (document.hidden) return true;
if (e.ctrlKey || e.metaKey || e.altKey) return true;
const t = e.target;
if (t === null || t === undefined) return true;
return (
t instanceof HTMLInputElement ||
t instanceof HTMLTextAreaElement ||
t instanceof HTMLSelectElement ||
(t instanceof Element && t.isContentEditable)
);
}
/**
* The keyboard input module.
*
* `app` — the app instance (`app.js`): its `sendInput` is the wire,
* `screen` / `spectator` the gate, `emote` / `leave` the verbs.
* `controls` — the `?controls=` param (`keyboard` binds, anything else
* exposes the seams and does nothing). `help` — the help overlay's
* `{ toggle(), visible() }` (optional; `?` and `Esc` skip it when
* absent).
*
* Returns `{ active, getKeyMap, stats, dispose }`; `stats()` is the
* census (a `sent` count, the last frame sent, what is held).
*/
export function createKeyboardInput({ app, controls = "keyboard", help = null } = {}) {
const active = controls === "keyboard";
const held = { up: false, down: false, left: false, right: false };
let actionHeld = false;
let sent = 0;
let lastSent = { x: 0, y: 0, act: false };
let lastSentAt = 0;
let disposed = false;
let timer = null;
/** The 8-way intent vector (opposites cancel; a diagonal is normalised). */
function intentVector() {
const x = (held.right ? 1 : 0) - (held.left ? 1 : 0);
const y = (held.down ? 1 : 0) - (held.up ? 1 : 0);
if (x !== 0 && y !== 0) {
return { x: x * DIAGONAL, y: y * DIAGONAL };
}
return { x, y };
}
/** One frame out (the census + the wire). */
function sendFrame(v, act) {
lastSent = { x: v.x, y: v.y, act };
lastSentAt = performance.now();
sent += 1;
app.sendInput(v, act);
}
/** A change in intent: send the new frame (the keep-alive pump
* resends while a key is held; this is the "on change" half). */
function sendOnChange() {
if (app.screen !== "playing" || app.spectator) return;
const v = intentVector();
if (v.x === lastSent.x && v.y === lastSent.y && actionHeld === lastSent.act) {
return;
}
sendFrame(v, actionHeld);
}
/** The forced zero (focus loss / tab hidden): clear everything and
* assert one zero-intent frame with the action released. */
function releaseAll() {
held.up = held.down = held.left = held.right = false;
actionHeld = false;
if (app.screen === "playing" && !app.spectator) {
sendFrame({ x: 0, y: 0 }, false);
} else {
lastSentAt = performance.now();
}
}
function anyHeld() {
return held.up || held.down || held.left || held.right || actionHeld;
}
/** The pump: while a key is held, resend ~every 48 ms (the sim
* brakes a tick with no frame — the hold must ride every tick
* window); idle, a zero-intent keep-alive at 100 ms (10 Hz ≥ 5 Hz —
* it also re-asserts "no input" after a release). */
function pump() {
const now = performance.now();
const due = anyHeld()
? now - lastSentAt >= HELD_RESEND_MS
: now - lastSentAt >= IDLE_KEEPALIVE_MS;
if (!due) return;
if (app.screen === "playing" && !app.spectator) {
sendFrame(intentVector(), actionHeld);
} else {
lastSentAt = now; // off-screen: reset the clock, no burst on return
}
}
function onKeydown(e) {
if (ignored(e)) return;
const move = MOVE_CODES[e.code];
if (move !== undefined) {
e.preventDefault(); // the arrows (and some pages' Space) would scroll
if (!e.repeat) {
held[move] = true;
sendOnChange();
}
return;
}
if (ACTION_CODES.has(e.code)) {
// A focused button/anchor keeps its native activation (the play
// screen's Leave / emote row); a bare page only ever scrolls.
// `HTMLDetailsElement` is a real interface (the `open` attribute)
// but `<summary>` has none — there is no `HTMLSummaryElement`, so
// the summary is matched by `tagName` (checking the identifier
// would throw a `ReferenceError` and swallow the press).
const t = e.target;
const interactive =
t instanceof HTMLButtonElement ||
t instanceof HTMLAnchorElement ||
t instanceof HTMLDetailsElement ||
(t instanceof Element && t.tagName === "SUMMARY");
if (app.screen === "playing" && !interactive) e.preventDefault();
if (!e.repeat) {
actionHeld = true;
sendOnChange();
}
return;
}
if (e.repeat) return;
const emote = EMOTES_BY_KEY[e.code];
if (emote !== undefined) {
if (app.screen === "playing" || app.screen === "lobby") {
e.preventDefault();
app.emote(emote);
}
return;
}
if (e.key === "?") {
e.preventDefault();
if (help !== null) help.toggle();
return;
}
if (e.key === "Escape") {
if (help !== null && help.visible()) {
help.hide();
} else if (app.screen === "playing") {
app.leave();
}
}
}
function onKeyup(e) {
// A keyup always clears (even when the keydown was ignored — a
// held key that was not game input must not leak into the vector
// once the target changes).
const move = MOVE_CODES[e.code];
if (move !== undefined) {
if (held[move]) {
held[move] = false;
sendOnChange();
}
return;
}
if (ACTION_CODES.has(e.code)) {
if (actionHeld) {
actionHeld = false;
sendOnChange();
}
}
}
function onBlur() {
releaseAll();
}
function onVisibility() {
if (document.hidden) releaseAll();
}
function onPagehide() {
releaseAll();
}
const listeners = [];
if (active) {
const add = (target, type, fn, options) => {
target.addEventListener(type, fn, options);
listeners.push([target, type, fn, options]);
};
add(window, "keydown", onKeydown);
add(window, "keyup", onKeyup);
add(window, "blur", onBlur);
add(document, "visibilitychange", onVisibility);
add(window, "pagehide", onPagehide);
timer = setInterval(pump, PUMP_MS);
}
return {
active,
getKeyMap,
/** The census (the e2e suites' seam): what is held, what the last
* frame sent was, how many frames went out. */
stats() {
return {
active,
sent,
last: { ...lastSent },
lastSentAt,
held: { ...held },
actionHeld,
};
},
/** Tear down (a page reload's `beforeunload` is the caller; the
* suites call it to reset between scenarios). */
dispose() {
if (disposed) return;
disposed = true;
if (timer !== null) {
clearInterval(timer);
timer = null;
}
for (const [target, type, fn, options] of listeners) {
target.removeEventListener(type, fn, options);
}
listeners.length = 0;
},
};
}
+9
View File
@@ -0,0 +1,9 @@
// Undercooked — the `main.js` flag (phase 07 task 03).
//
// Evaluated before `app.js` (import order in `main.js`), so when
// `app.js`'s module top-level runs its auto-boot check, the flag is
// already set: the composition root is bootstrapping the app, and the
// legacy auto-boot (for a direct `app.js` load, e.g. a developer
// opening the file) must not boot a second instance.
globalThis.__uc_main = true;
+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();
}
+189
View File
@@ -0,0 +1,189 @@
// Undercooked — the WebSocket connection (phase 07 task 02).
//
// A thin, protocol-only layer (hardened into the rejoin/backoff machine
// in phase 08): it opens `/ws/{code}`, sends the `hello` (PLAN A14 —
// the version travels in the hello only), dispatches every server frame
// to a single `onMessage` handler, sends client frames, and classifies
// how the socket died so the app's state machine can pick the right
// screen (the close-code decision table, docs/PROTOCOL.md "Heartbeats,
// drops, and seat grace").
//
// Deliberate gaps in this task: no reconnect/backoff (phase 08), no
// snapshot buffering (task 03), no input prediction (task 08). The
// `protocol_mismatch` path *is* final in this task: a `p` the server
// refuses means this client must not retry — it stops and the app
// shows the error screen without a retry affordance.
const PROTOCOL_VERSION = 1;
/** The close codes the client can act on (docs/PROTOCOL.md "Close codes"). */
export const CLOSE_CODES = Object.freeze({
normal: 1000,
protocol_mismatch: 4400,
room_gone: 4401,
room_full: 4402,
evicted: 4403,
abuse: 4404,
});
/** The `err` codes that end the session on this socket (fatal for the app). */
export const FATAL_ERR_CODES = Object.freeze([
"room_gone",
"room_full",
"protocol_mismatch",
"evicted",
"internal",
]);
/**
* Build a connection. The returned object's surface:
*
* connect(code, { token, name }) — open `/ws/{code}` and `hello`
* send(model) — one JSON frame out (false when closed)
* sendInput(move, act) — one `input` frame, `seq` owned here
* onMessage(frame) — the single reducer the app installs
* onStatus(status) — "idle" | "connecting" | "open" | "closed" | "mismatch"
* lastCloseCode — the close code of the last death (1006 if the
* transport died without a close frame)
* noteError(frame) — feed an `err` frame; `protocol_mismatch`
* latches the mismatch (no retry, ever)
* close() — a clean close (1000)
*/
export function createConnection() {
let socket = null;
let status = "idle";
let lastCloseCode = 1006;
let mismatchLatched = false;
let seq = 0;
let pingTimer = null;
let pingClock = 0;
let onMessage = null;
let onStatus = null;
function setStatus(next) {
if (status === next) return;
status = next;
if (onStatus) onStatus(status);
}
function stopPing() {
if (pingTimer !== null) clearInterval(pingTimer);
pingTimer = null;
}
function send(model) {
if (socket === null || socket.readyState !== 1 /* OPEN */) return false;
socket.send(JSON.stringify(model));
return true;
}
function connect(code, { token = null, name = null } = {}) {
if (socket !== null && socket.readyState !== 3 /* CLOSED */) close();
lastCloseCode = 1006;
seq = 0; // a fresh socket: the server's `last_seq` starts at 0, so the
// first `input` frame must be seq >= 1
const scheme = location.protocol === "https:" ? "wss" : "ws";
const next = new WebSocket(
`${scheme}://${location.host}/ws/${encodeURIComponent(code)}`,
);
socket = next;
setStatus("connecting");
next.onopen = () => {
// `extra="forbid"` on the server: only `p`, `token`, `name` may ride
// the hello, and nulls are omitted rather than sent.
const hello = { type: "hello", p: PROTOCOL_VERSION };
if (token !== null) hello.token = token;
if (name !== null) hello.name = name;
send(hello);
setStatus("open");
// The keep-alive (docs/PROTOCOL.md "Heartbeats"): any inbound frame
// resets the server's 12 s dead window; a 1 s `ping` keeps a half-open
// TCP honest. Phase 08 raises this to the documented 5 Hz RTT probe.
stopPing();
pingTimer = setInterval(() => {
send({ type: "ping", t: pingClock += 1 });
}, 1000);
};
next.onmessage = (event) => {
let frame = null;
try {
frame = JSON.parse(event.data);
} catch {
return; // the server must not send non-JSON; if it does, ignore
}
if (frame !== null && onMessage !== null) onMessage(frame);
};
next.onerror = () => {
/* the close event follows; classified there */
};
next.onclose = (event) => {
// A replaced socket's close is stale (a `connect()` swapped it
// out): only the current socket's death is the connection's.
if (socket !== next) return;
stopPing();
lastCloseCode = event.wasClean ? event.code : 1006;
if (mismatchLatched) setStatus("mismatch");
else setStatus("closed");
};
return next;
}
function sendInput(move, act) {
seq += 1;
const x = move === null ? 0 : move.x;
const y = move === null ? 0 : move.y;
// `face: null` = "derive from move" (docs/PROTOCOL.md "Input rules");
// an explicit facing is task 05's (the keyboard module).
return send({
type: "input",
seq,
move: { x, y },
act: Boolean(act),
face: null,
});
}
function noteError(frame) {
if (frame !== null && frame.code === "protocol_mismatch") {
mismatchLatched = true;
if (status !== "mismatch") setStatus("mismatch");
}
}
function close() {
stopPing();
const current = socket;
socket = null;
if (current !== null && current.readyState !== 3) {
try {
current.close(1000);
} catch {
/* already gone */
}
}
if (status !== "mismatch" && status !== "idle") setStatus("closed");
}
return {
connect,
send,
sendInput,
close,
noteError,
get status() {
return status;
},
get lastCloseCode() {
return lastCloseCode;
},
get mismatchLatched() {
return mismatchLatched;
},
set onMessage(handler) {
onMessage = handler;
},
set onStatus(handler) {
onStatus = handler;
},
};
}
+169
View File
@@ -0,0 +1,169 @@
// Undercooked — the wire decoder (phase 07 task 03).
//
// The client-side counterpart of `server/encoding.py`. `applyDelta(frame,
// prev)` reconstructs the full frame the server would have sent from a
// `full: false` frame and the previous frame (a full one or a delta one
// — the mirror of the encoder's `_prev_entries`: a delta frame's entity
// lists live under `d`, and its `gone` tombstones are *not* part of the
// previous state). The enum mapping goes through `shared/enums.js` (the
// wire's string values and the facing index, parity-guarded by
// `tests/unit/test_client_enum_parity.py`).
//
// The delta contract (docs/PROTOCOL.md "The `snap` frame"): `d` carries
// *only the changed entities* — new, moved, or state-changed since
// `prev` (an entity back at its `prev` values is out) — plus a
// `{"gone": true}` tombstone for each entity that vanished. The scalars
// (`t`, `s`, `tgt`, `tl`) and the tick's events (`e`) ride every frame,
// full or delta, so a decoded frame takes them from the frame itself.
//
// The identity keys mirror the encoder's: the player's `i`, the station's
// `k` + coordinates (its internal id never appears on the wire), the
// order's `id`, the hazard's `t` + coordinates.
import { FACING, EVENT_KIND, STATION_KIND, ITEM_KIND, ITEM_FORM } from "../shared/enums.js";
const COLLECTIONS = [
["p", "player"],
["st", "station"],
["o", "order"],
["hz", "hazard"],
];
/** One entity's identity key (the encoder's `_key_of_*`, as a string). */
export function keyOf(entity, kind) {
switch (kind) {
case "player":
return `p:${entity.i}`;
case "station":
return `st:${entity.k}@${entity.x},${entity.y}`;
case "order":
return `o:${entity.id}`;
case "hazard":
return `hz:${entity.t}@${entity.x},${entity.y}`;
default:
throw new Error(`unknown entity kind ${String(kind)}`);
}
}
function isEntry(value) {
return typeof value === "object" && value !== null;
}
/**
* The frame's entity entries for one collection (the mirror of
* `_prev_entries`): top-level on a full frame, under `d` on a delta
* frame; `gone` tombstones filtered out — they are not part of the
* state the delta is computed against.
*/
export function effectiveEntries(frame, key) {
if (!isEntry(frame)) return [];
const delta = frame.d;
const raw =
typeof delta === "object" && delta !== null ? delta[key] : frame[key];
if (!Array.isArray(raw)) return [];
return raw.filter((entry) => isEntry(entry) && entry.gone !== true);
}
/**
* Upsert / tombstone one collection: start from `previous` (in its
* order), replace a changed entry in place, append a new one, drop a
* tombstoned one. Deterministic — the same (prev, delta) pair always
* yields the same list.
*/
function applyCollection(previous, entries, kind) {
const out = previous.slice();
for (const entry of entries) {
if (!isEntry(entry)) continue;
const k = keyOf(entry, kind);
const idx = out.findIndex((existing) => keyOf(existing, kind) === k);
if (entry.gone === true) {
if (idx !== -1) out.splice(idx, 1); // the tombstone: gone
continue;
}
if (idx === -1) out.push(entry); // new: appended
else out[idx] = entry; // changed: replaced in place
}
return out;
}
function normalizeFull(frame) {
const list = (key) => (Array.isArray(frame[key]) ? frame[key] : []);
return {
full: true,
t: frame.t,
s: frame.s,
tgt: frame.tgt,
tl: frame.tl,
p: list("p"),
st: list("st"),
o: list("o"),
hz: list("hz"),
e: Array.isArray(frame.e) ? frame.e : [],
};
}
/**
* One raw `snap` frame in, one decoded full frame out.
*
* A `full: true` frame passes through (normalized: the entity arrays and
* `e` always present). A delta is applied against `prev` (the previous
* decoded full — or a raw full/delta frame, the encoder's own rule;
* `null` applies the delta's `d` against the empty state, best effort).
* The result is a full frame: `full: true`, the entity arrays top-level,
* the scalars and events from the frame itself.
*/
export function applyDelta(frame, prev = null) {
if (!isEntry(frame)) throw new Error("applyDelta: the frame is not an object");
if (frame.full === true) return normalizeFull(frame);
const d = typeof frame.d === "object" && frame.d !== null ? frame.d : {};
const base = isEntry(prev) ? prev : {};
const out = {
full: true,
t: frame.t,
s: frame.s,
tgt: frame.tgt,
tl: frame.tl,
e: Array.isArray(frame.e) ? frame.e : [],
};
for (const [key, kind] of COLLECTIONS) {
out[key] = applyCollection(
effectiveEntries(base, key),
Array.isArray(d[key]) ? d[key] : [],
kind,
);
}
return out;
}
// ------------------------------------------------------------ enum mapping
/** The wire's `f` (an index) → the facing's string value, or undefined. */
export function facingName(f) {
return Number.isInteger(f) && f >= 0 && f < FACING.length ? FACING[f] : undefined;
}
/** Whether a string is a known event kind (the FX queue's admission gate). */
export function isEventKind(k) {
return typeof k === "string" && EVENT_KIND.includes(k);
}
/** Whether a string is a known station kind (the renderer's sprite gate). */
export function isStationKind(k) {
return typeof k === "string" && STATION_KIND.includes(k);
}
/**
* A short item form (`"tomato_chopped"`, the wire's `h` / `g`) →
* `{kind, form}`; null when the shape is not a `_`-joined pair of known
* values (a carried `plate_plated` is the plated dish; a null short is a
* nothing-in-hand, which the caller handles before calling this).
*/
export function splitItemShort(short) {
if (typeof short !== "string" || short === "") return null;
const idx = short.lastIndexOf("_");
if (idx <= 0 || idx === short.length - 1) return null;
const kind = short.slice(0, idx);
const form = short.slice(idx + 1);
if (!ITEM_KIND.includes(kind) || !ITEM_FORM.includes(form)) return null;
return { kind, form };
}
+161
View File
@@ -0,0 +1,161 @@
// Undercooked — the event queue (phase 07 task 03).
//
// A pure module (no DOM, injected clock) that turns the wire's
// transient events — the `e[]` of a `snap` frame and the standalone
// `ev` message — into FX work with a bounded lifetime, so a burst of
// `chopped` events cannot leak listeners or replay after a resync.
//
// The rules:
//
// * **per-kind `ttlMs`** — each queued event lives until
// `now + TTL_MS[kind]` (a short table, a default for the rest); the
// renderer drains `due(now)` and the event's `until` is its lifetime;
// * **the dedup window** — the same event (kind + position + payload,
// the dedup key) pushed again within `dedupWindowMs` (100 ms) of the
// last accepted push is counted (`deduped`) and not queued again:
// the 20 Hz cadence can echo one tick's events, and the FX for a
// chop plays once;
// * **`drift(now)`** — prunes the expired (events past their `until`)
// and the dedup keys past their window; the renderer calls it each
// frame, so a queue left unattended shrinks to nothing on its own;
// * **the tick-reset detection** — a `push(t, ...)` whose `t` is
// *below* the last seen tick is a resync (a new round, a rejoin):
// the queue is cleared first, so a `served` from the previous round
// cannot replay over the new one. (`t` omitted — the standalone `ev`
// message has no tick — never resets.)
import { isEventKind } from "./decode.js";
/** The FX lifetime per event kind (ms); anything unlisted gets the default. */
const TTL_MS = Object.freeze({
served: 1200,
score: 1200,
round_end: 3000,
order_missed: 1000,
denied: 600,
ghost_on: 2000,
ghost_off: 1500,
ghost_park: 2000,
resync: 400,
});
const DEFAULT_TTL_MS = 800;
const DEFAULT_DEDUP_WINDOW_MS = 100;
/**
* One event queue. `push(t, entries, nowMs?)` — `t` the frame's tick
* (or null/undefined), `entries` the raw event entries (the frame's
* short-keyed `e` shape or the `ev` message's `{kind, payload}` shape),
* returns how many were queued. `due(nowMs?)` — the live events (in
* arrival order, raw entries). `drift(nowMs?)` — the prune; returns the
* number of entries dropped. `stats()` — `pushed`, `deduped`,
* `resyncs`, `pruned`, `size`.
*/
export function createEventQueue({
now = null,
dedupWindowMs = DEFAULT_DEDUP_WINDOW_MS,
} = {}) {
const clock = typeof now === "function" ? now : () => performance.now();
let entries = []; // { k, x, y, payload, raw, until }
let dedup = new Map(); // dedup key -> the last accepted push's time
let lastT = null;
const stats = { pushed: 0, deduped: 0, resyncs: 0, pruned: 0 };
function at(nowMs) {
return typeof nowMs === "number" ? nowMs : clock();
}
function reset() {
entries = [];
dedup = new Map();
stats.resyncs += 1;
}
/** The one wire shape the queue admits (both message shapes map here). */
function normalize(entry) {
if (entry === null || typeof entry !== "object") return null;
const k =
typeof entry.k === "string"
? entry.k
: typeof entry.kind === "string"
? entry.kind
: null;
if (k === null || !isEventKind(k)) return null; // unknown kinds: no FX
return {
k,
x: typeof entry.x === "number" ? entry.x : 0,
y: typeof entry.y === "number" ? entry.y : 0,
payload:
entry.payload !== undefined && entry.payload !== null
? entry.payload
: undefined,
};
}
function dedupKey(entry) {
const payload =
entry.payload !== undefined
? JSON.stringify(entry.payload, Object.keys(entry.payload).sort())
: "";
return `${entry.k}|${Math.round(entry.x * 100)}|${Math.round(entry.y * 100)}|${payload}`;
}
function push(t, eventEntries, nowMs) {
const nowAt = at(nowMs);
if (typeof t === "number") {
if (lastT !== null && t < lastT) {
reset(); // the tick went backwards: a new round (or a resync)
}
lastT = t; // monotonicity resumes from the new tick
}
let added = 0;
for (const raw of Array.isArray(eventEntries) ? eventEntries : []) {
const entry = normalize(raw);
if (entry === null) continue;
const key = dedupKey(entry);
const lastAccepted = dedup.get(key);
if (lastAccepted !== undefined && nowAt - lastAccepted < dedupWindowMs) {
stats.deduped += 1; // the echo: the FX already plays
continue;
}
const ttl = TTL_MS[entry.k] ?? DEFAULT_TTL_MS;
entries.push({ ...entry, until: nowAt + ttl });
dedup.set(key, nowAt);
stats.pushed += 1;
added += 1;
}
return added;
}
/** The live events (their `until` is at least `now`), in arrival order. */
function due(nowMs) {
const nowAt = at(nowMs);
return entries
.filter((entry) => entry.until >= nowAt)
.map(({ k, x, y, payload }) => ({ k, x, y, payload }));
}
/** The prune: events past their `until`, dedup keys past their window. */
function drift(nowMs) {
const nowAt = at(nowMs);
const kept = entries.filter((entry) => entry.until >= nowAt);
const dropped = entries.length - kept.length;
entries = kept;
let dedupDropped = 0;
for (const [key, seen] of [...dedup]) {
if (nowAt - seen >= dedupWindowMs) {
dedup.delete(key);
dedupDropped += 1;
}
}
stats.pruned += dropped + dedupDropped;
return dropped + dedupDropped;
}
return {
push,
due,
drift,
stats: () => ({ ...stats, size: entries.length }),
};
}
+317
View File
@@ -0,0 +1,317 @@
// Undercooked — the snapshot buffer and render-time interpolation
// (phase 07 task 03).
//
// A pure module: no DOM, no network, no wall clock — the clock is
// injected (`now`), which is what the e2e suite
// (`tests/e2e/test_us06_interp_math.py`) drives with synthetic streams.
//
// The buffer turns the `snap` stream into render-time frames:
//
// * frames are keyed by `t` (the server tick) and arrive in order;
// an older-or-duplicate `t` is **dropped** (counted), a `t` jump is
// a **gap** (recorded with its size);
// * a gap *longer than* `MAX_GAP_TICKS` (6) sets the resync latch and
// fires `onResync` — **debounced to one request per
// `RESYNC_DEBOUNCE_MS` (500 ms)**. While the latch is set, delta
// frames are *skipped* (their changes were partly missed; applying
// them against a stale `prev` would freeze half-changed state in the
// view): the stream's full cadence (`FULL_SNAPSHOT_EVERY_TICKS`,
// phase 06) guarantees a full frame arrives, and it clears the
// latch. That is the whole resync contract: never ask, just hold —
// the next full frame is the resync.
// * if no frame arrives for `maxAgeMs`, `sample()` **freezes** on the
// latest frame instead of extrapolating: the buffer never animates a
// ghost cook into a wall.
//
// `sample(now)` renders at `now - interpDelayMs` (the fixed 100 ms
// delay, docs/CLIENT.md "why 100 ms"): it returns the two bracketing
// decoded frames `{a, b}` plus the lerp factor `alpha` (0..1, by the
// frames' arrival times), or the frozen hold (`a === b`, `alpha 0`).
// `lerpState(a, b, alpha)` turns the bracket into a render-ready state:
// positions lerp along the straight line (the shorter path — a cook
// never passes through a wall, so the chord is the path), the facing
// snaps at the `alpha >= 0.5` boundary, progress / time-left / hazard
// life lerp, and entities added or removed between the frames appear at
// `b` (visible from `alpha 0`, at `b`'s position) and vanish at the
// `b` boundary (held at `a`'s position until `alpha >= 0.5`, then gone).
// Stations and orders diff by their wire identity (`k` + coordinates /
// `id`), players and hazards the same.
//
// `applyDelta` (imported from `net/decode.js`, re-exported here for the
// buffer's surface) decodes a raw frame; the buffer only ever stores
// decoded full frames.
import { applyDelta as decodeApplyDelta } from "./decode.js";
export { decodeApplyDelta as applyDelta };
/** A `t` jump longer than this is a resync (6 = three 200 ms
* snapshot windows: beyond that the missing frames have changed too
* much to bridge by interpolation). */
export const MAX_GAP_TICKS = 6;
/** A resync request fires at most once per this window (debounce). */
export const RESYNC_DEBOUNCE_MS = 500;
/**
* One jitter-tolerant snapshot buffer.
*
* `push(raw)` — a raw `snap` frame (`full: true`, or a delta applied
* against the last decoded full); returns true when it was applied.
* `sample(nowMs?)` — `{a, b, alpha}` (or the frozen hold), null while
* empty. `latest()` — the last decoded full frame (the delta's `prev`),
* or null. `needsFullResync()` — the latch. `stats()` — the census
* (`pushed`, `dropped`, `gaps`, `maxGapTicks`, `skippedUntilFull`,
* `resyncs`, `lastT`).
*/
export function createSnapshotBuffer({
interpDelayMs = 100,
maxAgeMs = 1000,
now = null,
onResync = null,
} = {}) {
const clock = typeof now === "function" ? now : () => performance.now();
const frames = new Map(); // t -> { frame (decoded full), at (clock ms) }
let lastT = null; // the greatest tick applied
let lastPushAt = null; // the clock time of the last applied frame
let resyncPending = false;
let lastResyncAt = null; // the debounce window's start
const stats = {
pushed: 0,
dropped: 0,
gaps: 0,
maxGapTicks: 0,
skippedUntilFull: 0,
resyncs: 0,
};
function requestResync() {
const at = clock();
if (lastResyncAt !== null && at - lastResyncAt < RESYNC_DEBOUNCE_MS) {
return; // debounced: one request per window
}
lastResyncAt = at;
stats.resyncs += 1;
if (typeof onResync === "function") onResync();
}
function prune() {
// Keep the interpolation window (a 100 ms delay is two 50 ms ticks,
// plus margin) and the most recent frames; anything older than
// `maxAgeMs + interpDelayMs` and at least 4 ticks back is dropped.
for (const [t, entry] of frames) {
if (
lastT - t >= 4 &&
lastPushAt !== null &&
lastPushAt - entry.at > maxAgeMs + interpDelayMs
) {
frames.delete(t);
}
}
}
function push(raw) {
if (raw === null || typeof raw !== "object" || typeof raw.t !== "number") {
stats.dropped += 1; // malformed: counted, never fatal
return false;
}
if (lastT !== null && raw.t <= lastT) {
stats.dropped += 1; // older (or duplicate): the stream is ordered
return false;
}
if (lastT !== null && raw.t > lastT + 1) {
const gap = raw.t - lastT;
stats.gaps += 1;
if (gap > stats.maxGapTicks) stats.maxGapTicks = gap;
if (gap > MAX_GAP_TICKS) {
resyncPending = true;
requestResync();
}
}
if (resyncPending && raw.full !== true) {
// The chain is broken (a delta whose `prev` we do not have):
// hold until the next full frame clears the latch.
stats.skippedUntilFull += 1;
return false;
}
const at = clock();
const decoded =
raw.full === true ? raw : decodeApplyDelta(raw, latest());
frames.set(raw.t, { frame: decoded, at });
lastT = raw.t;
lastPushAt = at;
stats.pushed += 1;
if (raw.full === true) resyncPending = false;
prune();
return true;
}
function latest() {
if (lastT === null) return null;
const entry = frames.get(lastT);
return entry === undefined ? null : entry.frame;
}
function isFrozen(nowMs) {
return lastPushAt !== null && nowMs - lastPushAt > maxAgeMs;
}
/**
* The two bracketing decoded frames plus the lerp factor, at
* `nowMs - interpDelayMs` (an explicit `nowMs` wins over the clock —
* the synthetic-stream seam). Null while empty; the frozen hold
* (`a === b`, `alpha 0`, `frozen: true`) when the stream went quiet
* or when the render time outran every frame (a latency spike:
* hold, never extrapolate).
*/
function sample(nowMs) {
const at = typeof nowMs === "number" ? nowMs : clock();
if (frames.size === 0) return null;
const ordered = [...frames.values()].sort((x, y) => x.at - y.at);
const newest = ordered[ordered.length - 1];
if (isFrozen(at) || at - interpDelayMs >= newest.at) {
return { a: newest.frame, b: newest.frame, alpha: 0, frozen: true };
}
const renderAt = at - interpDelayMs;
let bIdx = -1;
for (let i = 0; i < ordered.length; i += 1) {
if (ordered[i].at >= renderAt) {
bIdx = i;
break;
}
}
if (bIdx === -1) {
// `renderAt` is before the first frame (a late start): hold.
const oldest = ordered[0];
return { a: oldest.frame, b: oldest.frame, alpha: 0 };
}
if (bIdx === 0) {
const b = ordered[0];
return { a: b.frame, b: b.frame, alpha: 0 };
}
const aEntry = ordered[bIdx - 1];
const bEntry = ordered[bIdx];
const span = bEntry.at - aEntry.at;
const alpha =
span > 0
? Math.min(1, Math.max(0, (renderAt - aEntry.at) / span))
: 0;
return { a: aEntry.frame, b: bEntry.frame, alpha };
}
return {
push,
sample,
latest,
needsFullResync: () => resyncPending,
stats: () => ({ ...stats, lastT, frozen: isFrozen(clock()) }),
};
}
// ------------------------------------------------------------ lerpState
function lerp(v0, v1, alpha) {
return v0 + (v1 - v0) * alpha;
}
function num(value, fallback = 0) {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
/**
* One collection between two frames: the union of the identity keys, in
* `left`'s order then the new ones; an entity in both is lerped (the
* per-kind fields below), one only in `right` appears at `b` (its own
* values, visible for the whole interval), one only in `left` vanishes
* at the `b` boundary (held until `alpha >= 0.5`, then dropped).
*/
function lerpCollection(left, right, key, kind, alpha, atHalf) {
const rightKeys = new Set(right.map((e) => key(e)));
const out = [];
for (const a of left) {
const k = key(a);
if (!rightKeys.has(k)) {
if (!atHalf) out.push(a); // still visible: held at a's values
continue;
}
const b = right.find((e) => key(e) === k);
out.push(lerpEntry(a, b, kind, alpha, atHalf));
}
for (const b of right) {
if (!left.some((a) => key(a) === key(b))) out.push(b); // appears at b
}
return out;
}
function lerpEntry(a, b, kind, alpha, atHalf) {
if (kind === "player") {
return {
...b,
x: lerp(num(a.x), num(b.x), alpha),
y: lerp(num(a.y), num(b.y), alpha),
f: atHalf ? b.f : a.f, // the facing snaps at the boundary
h: atHalf ? b.h : a.h, // the carried item snaps with it
s: atHalf ? b.s : a.s, // the state snaps with it
};
}
if (kind === "station") {
return {
...b,
p: lerp(num(a.p), num(b.p), alpha), // the progress lerps
q: lerp(num(a.q, 1), num(b.q, 1), alpha), // the content quality
g: atHalf ? b.g : a.g,
s: atHalf ? b.s : a.s,
};
}
if (kind === "order") {
return {
...b,
l: lerp(num(a.l), num(b.l), alpha), // the time-left share lerps
};
}
// hazard: the life lerps; a `moving_obstacle`'s position does too
return {
...b,
x: lerp(num(a.x), num(b.x), alpha),
y: lerp(num(a.y), num(b.y), alpha),
l: lerp(num(a.l), num(b.l), alpha),
};
}
const PLAYERS = (f) => (Array.isArray(f.p) ? f.p : []);
const STATIONS = (f) => (Array.isArray(f.st) ? f.st : []);
const ORDERS = (f) => (Array.isArray(f.o) ? f.o : []);
const HAZARDS = (f) => (Array.isArray(f.hz) ? f.hz : []);
const P_KEY = (e) => `p:${e.i}`;
const S_KEY = (e) => `st:${e.k}@${e.x},${e.y}`;
const O_KEY = (e) => `o:${e.id}`;
const H_KEY = (e) => `hz:${e.t}@${e.x},${e.y}`;
/**
* A render-ready state from the bracketing frames: `lerpState(a, b,
* alpha)` with `a === b` (the frozen hold) returns `b`'s values
* verbatim for the discrete fields. The scalars: `t`/`s`/`tgt` from
* `b` (discrete), `tl` lerped; `e` rides with `b` (the renderer
* consumes events through the event queue, not the lerp).
*/
export function lerpState(a, b, alpha) {
if (b === null || typeof b !== "object") {
throw new Error("lerpState: `b` is not a frame");
}
const left = a === null || typeof a !== "object" ? b : a;
const factor = typeof alpha === "number" ? Math.min(1, Math.max(0, alpha)) : 0;
const atHalf = factor >= 0.5;
return {
full: true,
t: b.t,
s: b.s,
tgt: b.tgt,
tl: lerp(num(left.tl), num(b.tl), factor),
p: lerpCollection(PLAYERS(left), PLAYERS(b), P_KEY, "player", factor, atHalf),
st: lerpCollection(STATIONS(left), STATIONS(b), S_KEY, "station", factor, atHalf),
o: lerpCollection(ORDERS(left), ORDERS(b), O_KEY, "order", factor, atHalf),
hz: lerpCollection(HAZARDS(left), HAZARDS(b), H_KEY, "hazard", factor, atHalf),
e: Array.isArray(b.e) ? b.e : [],
};
}
+312
View File
@@ -0,0 +1,312 @@
// Undercooked — the SVG atlas (phase 07, task 01).
//
// Loads `app/static/assets/index.json` (written by
// `undercooked.tools.gen_art_index`) and turns every symbol into an
// `<img>` the renderer can draw. Browsers do not render a
// `file.svg#symbol-id` fragment as an image (the symbol paints
// nothing), and the page CSP (`style-src 'self'`) blocks the
// `style`-attribute colour hooks the art uses — both in the decoded
// image and while `DOMParser` parses the file in the page context —
// so each symbol is decoded the way the engine actually supports: the
// file is fetched as text, the `style` hooks are pulled into neutral
// attributes, the wanted symbol is spliced into a standalone document
// (its `<use>` references expanded to plain geometry, the hooks
// resolved to literal `fill`s), and that document is decoded through a
// `data:` URL — the CSP already allows it (`img-src data:`), and the
// bytes are the app's own.
//
// Contract:
// * `atlas.ready()` resolves when every symbol has decoded; it rejects
// (naming every sprite that failed) when the index or a sprite does
// not load, and a failed load clears the cache so a later `ready()`
// retries from scratch.
// * `atlas.draw(ctx, id, x, y, w, h, opts)` throws an `Error` for an
// unknown id or a sprite that decoded without pixels — it never
// draws nothing silently.
// * `opts.tint` is a CSS colour painted over the non-transparent part
// of the sprite (`source-atop`), `opts.alpha` a 0..1 opacity,
// `opts.flipX` mirrors the sprite in the target rect.
const INDEX_URL = "/static/assets/index.json";
const SVG_BASE = "/static/assets/";
const index = new Map(); // id -> { file, w, h }
const images = new Map(); // id -> HTMLImageElement
const svgTexts = new Map(); // file -> the fetched file text
let readyPromise = null;
/**
* Resolve one `color: var(--uc-*, #hex)` (or literal `color: #hex`)
* style into a hex colour, or `null` when the style has no colour.
* An image document cannot resolve custom properties — the page's
* styles are CSP-blocked there and the decode fails with them — so
* the art's cook-colour hooks (a `style` on the `<use>`, a
* `currentColor` on the shape it pulls in) are resolved here, before
* the document is serialised.
*/
function colourFromStyle(style) {
if (!style) return null;
const varMatch = style.match(
/color\s*:\s*var\(\s*(--[\w-]+)\s*,\s*(#[0-9a-fA-F]{3,8})\s*\)/i,
);
if (varMatch) return varMatch[2];
const literal = style.match(/color\s*:\s*(#[0-9a-fA-F]{3,8})(\s*[;,]|\s*$)/i);
return literal ? literal[1] : null;
}
/**
* Expand every `<use>` in a (cloned) subtree into a clone of its
* target, then paint each `fill="currentColor"` with `color`. The
* expansion happens because the colour hooks ride on a `style`
* attribute the image context forbids; once expanded, the shapes are
* plain geometry and the colour is a plain `fill`.
*/
function paintColour(container, color, document) {
const walk = (node) => {
for (const element of Array.from(node.children)) {
const isUse = element.tagName === "use";
const isLink = element.tagName === "a";
if (isUse || isLink) {
const href = element.getAttribute("href");
const target = href ? document.getElementById(href.slice(1)) : null;
if (target) {
const expansion = target.cloneNode(true);
expansion.removeAttribute("id");
element.replaceWith(expansion);
walk(expansion);
} else {
element.remove();
}
continue;
}
if (element.getAttribute("fill") === "currentColor") {
element.setAttribute("fill", color);
}
walk(element);
}
};
walk(container);
}
/**
* Pull the `style` hooks out of the file text before parsing: a
* `DOMParser` of `image/svg+xml` applies inline `style` attributes in
* the embedding document's context, where the page CSP
* (`style-src 'self'`) blocks the `var()` colours before they can be
* read. The hooks are carried on a neutral attribute instead, in
* document order, so the parsed tree can be walked and each hook's
* colour resolved from the extracted value.
*/
function extractStyleHooks(fileText) {
const hooks = [];
const sanitized = fileText.replace(/\sstyle="([^"]*)"/g, (_match, value) => {
hooks.push(value);
return ` ucstyle="${hooks.length - 1}"`;
});
return { hooks, sanitized };
}
/**
* The standalone document one symbol decodes into: the file's root with
* every other symbol removed and the wanted one replaced by a plain
* `<g>` (a `<symbol>` renders nothing on its own). `<use>` references
* are expanded to plain geometry and the cook-colour `style` hooks are
* resolved to literal fills, because the image context forbids the
* `style` attribute (and any `var()`) the hooks would otherwise need.
* The symbol's `viewBox` (or `width`/`height`) sizes the root's
* viewport, so the geometry lands 1:1.
*/
function buildSvgDocument(fileText, file, id) {
const { hooks, sanitized } = extractStyleHooks(fileText);
const document = new DOMParser().parseFromString(sanitized, "image/svg+xml");
if (document.querySelector("parsererror")) {
throw new Error(`atlas file failed to parse: ${file}`);
}
const root = document.documentElement.cloneNode(true);
const symbol = root.querySelector(`symbol#${CSS.escape(id)}`);
if (!symbol) throw new Error(`atlas symbol not found in ${file}: ${id}`);
for (const other of root.querySelectorAll("symbol")) {
if (other !== symbol) other.remove();
}
const viewBox = symbol.getAttribute("viewBox");
const group = document.createElementNS(document.documentElement.namespaceURI, "g");
while (symbol.firstChild) group.appendChild(symbol.firstChild);
symbol.replaceWith(group);
// The colour hooks: a `ucstyle` on the <use> that pulls in the shape.
for (const use of Array.from(group.children)) {
if (use.tagName !== "use") continue;
const hook = use.getAttribute("ucstyle");
const color =
hook === null ? null : colourFromStyle(hooks[Number(hook)]);
use.removeAttribute("ucstyle");
if (color === null) continue;
const href = use.getAttribute("href");
const target = href ? document.getElementById(href.slice(1)) : null;
if (!target) {
use.remove();
continue;
}
const expansion = target.cloneNode(true);
expansion.removeAttribute("id");
use.replaceWith(expansion);
paintColour(expansion, color, document);
}
if (viewBox) {
root.setAttribute("viewBox", viewBox);
} else {
const width = symbol.getAttribute("width");
const height = symbol.getAttribute("height");
if (width) root.setAttribute("width", width);
if (height) root.setAttribute("height", height);
}
return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(
new XMLSerializer().serializeToString(root),
);
}
function decodeImage(file, id) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`atlas image failed to decode: ${file}#${id}`));
const text = svgTexts.get(file);
if (text === undefined) {
reject(new Error(`atlas has no text for ${file} (id ${id})`));
return;
}
img.src = buildSvgDocument(text, file, id);
dataUrls.set(id, img.src); // the `data:` URL the DOM icons use (dataUrl)
});
}
async function load() {
const response = await fetch(INDEX_URL);
if (!response.ok) {
throw new Error(`atlas index fetch failed: HTTP ${response.status}`);
}
const table = await response.json();
const entries = Object.entries(table);
if (entries.length === 0) throw new Error("atlas index is empty");
const files = [...new Set(entries.map(([, entry]) => entry.file))];
await Promise.all(
files.map(async (file) => {
const fileResponse = await fetch(`${SVG_BASE}${file}`);
if (!fileResponse.ok) {
throw new Error(`atlas fetch failed for ${file}: HTTP ${fileResponse.status}`);
}
svgTexts.set(file, await fileResponse.text());
}),
);
const settled = await Promise.allSettled(
entries.map(async ([id, entry]) => {
images.set(id, await decodeImage(entry.file, id));
}),
);
const failures = settled
.filter((outcome) => outcome.status === "rejected")
.map((outcome) => outcome.reason.message);
if (failures.length > 0) throw new Error(`atlas load failed: ${failures.join("; ")}`);
for (const [id, entry] of entries) index.set(id, entry);
}
/** Resolve when every symbol has decoded; reject naming the failures. */
export function ready() {
if (readyPromise === null) {
readyPromise = load().catch((error) => {
readyPromise = null; // a failed load may be retried from scratch
images.clear();
index.clear();
svgTexts.clear();
dataUrls.clear();
throw error;
});
}
return readyPromise;
}
/** The number of symbols the index names (0 before `ready()` resolves). */
export function size() {
return index.size;
}
/** The `{ file, w, h }` entry for one id, or `null` when unknown. */
export function meta(id) {
return index.get(id) ?? null;
}
/**
* The decoded `data:` URL for one id (or `null` when unknown / not
* ready) — the DOM half of the atlas: the HUD's recipe-ticket and
* streak icons are `<img>` elements on these URLs (the CSP allows
* `img-src data:`; the bytes are the app's own, the same decode the
* canvas uses). `#symbol` fragments never render from a cross-file
* `<img>`, so the `data:` URL is the only same-origin way to put a
* symbol in the DOM.
*/
export function dataUrl(id) {
const entry = index.get(id);
if (entry === undefined) return null;
return dataUrls.get(id) ?? null;
}
const dataUrls = new Map(); // id -> the decoded data: URL (the decode's cache)
/**
* Draw one symbol, scaled to the `(x, y, w, h)` rect, onto `ctx`.
*
* @param {CanvasRenderingContext2D} ctx the target 2D context
* @param {string} id a symbol id from index.json
* @param {number} x the rect's left edge
* @param {number} y the rect's top edge
* @param {number} w the rect's width
* @param {number} h the rect's height
* @param {{tint?: string, alpha?: number, flipX?: boolean}} [opts]
* @throws {Error} when the id is unknown or the sprite has no pixels —
* the atlas never draws nothing silently.
*/
export async function draw(ctx, id, x, y, w, h, opts = {}) {
await ready();
paint(ctx, id, x, y, w, h, opts);
}
/**
* The synchronous half of `draw` (the renderer's hot path: after
* `ready()` has resolved, every symbol is decoded and this draws
* without a promise round-trip). Throws like `draw` on an unknown id
* or a pixel-less sprite; throws `atlas not ready` before the load has
* settled (the renderer awaits `ready()` first).
*/
export function drawSync(ctx, id, x, y, w, h, opts = {}) {
if (readyPromise === null) {
throw new Error("atlas not ready (call ready() first)");
}
paint(ctx, id, x, y, w, h, opts);
}
function paint(ctx, id, x, y, w, h, opts = {}) {
const entry = index.get(id);
if (!entry) throw new Error(`unknown atlas id: ${id}`);
const img = images.get(id);
if (!img || !img.complete || img.naturalWidth === 0 || img.naturalHeight === 0) {
throw new Error(`atlas image has no pixels: ${id}`);
}
ctx.save();
if (opts.flipX) {
// Mirror in the target rect: flip about its centre, draw as usual.
ctx.translate(x + w / 2, y + h / 2);
ctx.scale(-1, 1);
ctx.translate(-(x + w / 2), -(y + h / 2));
}
if (typeof opts.alpha === "number") {
ctx.globalAlpha = Math.max(0, Math.min(1, opts.alpha));
}
ctx.drawImage(img, x, y, w, h);
if (opts.tint) {
// `source-atop` paints the tint over the sprite's non-transparent
// pixels only, so the background stays untouched.
ctx.globalCompositeOperation = "source-atop";
ctx.fillStyle = opts.tint;
ctx.fillRect(x, y, w, h);
}
ctx.restore();
}
+197
View File
@@ -0,0 +1,197 @@
// Undercooked — the camera (phase 07, task 04).
//
// Fits the (approximated) kitchen into the canvas viewport: one scale
// (pixels per tile) plus a centred letterbox offset. The scale is the
// fit-to-viewport value, snapped *down* to an integer-ish step
// (`snapScale`) so tile boundaries land on whole device pixels (a
// sub-pixel drift that changes every frame is shimmer), then clamped to
// the zoom bounds, which the task states in tiles-per-pixel
// (`minTilesPerPx` = the most zoomed-in, `maxTilesPerPx` = the most
// zoomed-out; `scale = 1 / tilesPerPx`).
//
// DPR-aware: the backing store is `cssSize × dpr` with
// `dpr = min(devicePixelRatio, 2)` (a 4K 3× display is not worth the
// fill-rate); `snapScale`'s step is `1 / (2 × dpr)`, so the snap is
// integer at the device-pixel level. `resize()` re-reads the CSS size
// (and re-applies the backing store), so a window resize or a DPR
// change (a drag between monitors) refits.
//
// A pure core (`computeFit`, `snapScale`) for the e2e suites plus a
// `createCamera` wrapper that owns the DOM plumbing (the canvas
// backing store, the `resize` / `visibilitychange` listeners). The
// world bounds are the kitchen's: the renderer derives them from the
// snapshot (the grid never leaves the server, PLAN §8 — the floor is
// the entity bounding box + a one-tile margin, the client-side
// approximation documented in `docs/CLIENT.md`), `setWorld(w, h)`
// refits only when the (rounded) bounds change, so a cook walking does
// not re-snap the scale mid-step.
/** The `devicePixelRatio` cap (a 3× display renders at 2×). */
export const MAX_DPR = 2;
/**
* Snap a scale down to the integer-ish step. `step = 1 / (2 × dpr)`
* (dpr 1 → 0.5 px/tile, dpr 2 → 0.25): the largest multiple of the
* step at or below `scale`, at least one step (a zero-sized viewport
* must not produce a zero scale). `scale ≤ 0` is `0` (the caller's
* letterbox handles it — nothing to fit).
*/
export function snapScale(scale, dpr) {
const ratio = Math.max(1, Number.isFinite(dpr) ? dpr : 1);
if (!(scale > 0)) return 0;
const step = 1 / (2 * ratio);
const whole = Math.floor(scale / step - 1e-9);
return Math.max(1, whole) * step;
}
/**
* One fit: `{scale, offX, offY}` in CSS pixels. The raw fit is
* `min(view / world)` on each axis; it is snapped down (`snapScale`)
* and clamped to `[1 / maxTilesPerPx, 1 / minTilesPerPx]`; the offset
* centres the world on the remainder (the letterbox, negative when the
* clamp zoomed in past the fit — the kitchen is bigger than the view,
* and the centre of both coincide).
*/
export function computeFit({
viewW,
viewH,
worldW,
worldH,
dpr = 1,
minTilesPerPx = 0.02,
maxTilesPerPx = 0.5,
} = {}) {
const vw = Math.max(0, Number(viewW) || 0);
const vh = Math.max(0, Number(viewH) || 0);
const ww = Math.max(1e-6, Number(worldW) || 0);
const wh = Math.max(1e-6, Number(worldH) || 0);
if (vw === 0 || vh === 0) return { scale: 0, offX: 0, offY: 0 };
const minScale = 1 / Math.max(1e-9, Number(maxTilesPerPx) || 0.5);
const maxScale = 1 / Math.max(1e-9, Number(minTilesPerPx) || 0.02);
const raw = Math.min(vw / ww, vh / wh);
const scale = Math.min(maxScale, Math.max(minScale, snapScale(raw, dpr)));
return {
scale,
offX: (vw - ww * scale) / 2,
offY: (vh - wh * scale) / 2,
};
}
/**
* One camera.
*
* `resize()` — re-reads the CSS size (`getSize()`), applies the backing
* store at the capped DPR and refits. `setWorld(w, h)` — the kitchen's
* bounds; a change (integer-rounded, so sub-pixel drift does not
* refit) refits. `worldToScreen(x, y)` / `screenToWorld(x, y)` — the
* transforms in CSS pixels (the renderer scales the context by the DPR
* itself, so the math stays in CSS units). `attach()` / `detach()` —
* the `resize` and `visibilitychange` listeners (the DPR can change
* with the window).
*/
export function createCamera({
canvas = null,
minTilesPerPx = 0.02,
maxTilesPerPx = 0.5,
getSize = null,
dprOf = null,
onResize = null,
} = {}) {
const sizeOf =
typeof getSize === "function"
? getSize
: () => ({
w: canvas !== null ? canvas.clientWidth : 0,
h: canvas !== null ? canvas.clientHeight : 0,
});
const dprGet =
typeof dprOf === "function"
? dprOf
: () =>
typeof window !== "undefined" &&
Number.isFinite(window.devicePixelRatio)
? Math.min(MAX_DPR, window.devicePixelRatio)
: 1;
let worldW = 0;
let worldH = 0;
let key = null; // the rounded world + size + dpr the fit was computed for
let fit = { scale: 0, offX: 0, offY: 0 };
let dpr = 1;
let cssW = 0;
let cssH = 0;
const listeners = [];
function applySize() {
const size = sizeOf();
cssW = size.w;
cssH = size.h;
dpr = dprGet();
if (canvas !== null) {
canvas.width = Math.max(0, Math.round(cssW * dpr));
canvas.height = Math.max(0, Math.round(cssH * dpr));
}
refit();
}
function refit() {
const k = `${Math.round(worldW)}x${Math.round(worldH)}@${cssW}x${cssH}×${dpr}`;
if (k === key) return;
key = k;
fit = computeFit({
viewW: cssW,
viewH: cssH,
worldW,
worldH,
dpr,
minTilesPerPx,
maxTilesPerPx,
});
if (typeof onResize === "function") onResize();
}
function attach() {
if (typeof window !== "undefined") {
for (const name of ["resize", "visibilitychange"]) {
window.addEventListener(name, applySize);
listeners.push(name);
}
}
applySize();
}
function detach() {
if (typeof window !== "undefined") {
for (const name of listeners.splice(0)) window.removeEventListener(name, applySize);
}
}
return {
attach,
detach,
resize: applySize,
setWorld: (w, h) => {
worldW = w;
worldH = h;
refit();
},
worldToScreen: (x, y) => ({
x: fit.offX + x * fit.scale,
y: fit.offY + y * fit.scale,
}),
screenToWorld: (x, y) => ({
x: (x - fit.offX) / (fit.scale || 1),
y: (y - fit.offY) / (fit.scale || 1),
}),
stats: () => ({
scale: fit.scale,
offX: fit.offX,
offY: fit.offY,
worldW,
worldH,
cssW,
cssH,
dpr,
}),
};
}
+648
View File
@@ -0,0 +1,648 @@
// Undercooked — the canvas renderer (phase 07, task 04).
//
// `drawFrame(view, nowMs)` draws one render-ready state (the loop's
// `lerpState` output, docs/CLIENT.md "The snapshot buffer and the
// render loop") in a fixed layer order — `LAYERS`, in draw order:
//
// floor → floor decals (spills) → station bases → carried-under
// items (the content a station holds) → players (y-sorted) → carried
// overlays (the held item) → station progress rings → hazards →
// particles (the FX) → selection / target highlight.
//
// Sprite selection maps the wire's vocabulary onto atlas ids:
// `stationSpriteId(kind, state)` (the stove has a sprite per state —
// `STATION_STATE_VARIANT`; the other fixtures are state-blind),
// `itemSpriteId(kind, form)` (the `REACHABLE_ITEM_FORMS` table —
// `tests/unit/test_client_sprite_map.py` keeps it equal to the sim's
// `reachable_item_forms()`, so a pair the simulation cannot produce
// falls back to the kind's `raw` sprite instead of a blank),
// `cookSpriteId(seat, facing, frame)` (4 bodies × 4 facings × 2 walk
// frames) and `tileSpriteId(kind, x, y)` (the floor's 4 texture
// variants, chosen by a deterministic cell hash). An atlas id that
// throws (an unknown id, a sprite with no pixels) is logged **once**
// (console, naming the id) and drawn as a plain fallback rect —
// never silently blank, never spammed.
//
// Animation is fixed-step: the walk cycle is `floor(now / 125) % 2`
// (8 steps/s, a 120 Hz display does not animate faster), the stun spin
// and the hazard pulses step the same way, and every clock is derived
// from the loop's `now` — the loop itself pauses on `document.hidden`
// (`render/loop.js`), so the sprites pause with it; a large `now` jump
// (a resume) cannot jump the animation, only land on the next step.
//
// The kitchen's floor is a client-side approximation: the grid never
// leaves the server (PLAN §8), so the floor is the stations' + the
// players' bounding box plus a one-tile margin, with the wall as the
// rectangle's edge. The target highlight is the same kind of
// approximation: `candidateCells(x, y, facing)` mirrors
// `sim/interact.py`'s `candidate_cells` (the front tile, the two
// diagonal-front, the tile under the cook) so reaching for a station
// reads the way the server will resolve it.
//
// `renderStats()` — the frame census the e2e perf assertions read off
// `window.__uc`: `drawCalls` (this frame), `frameMs` (moving average
// of the measured draw time), `frameMsP95` (over every frame since the
// renderer was created), `frames`, `warnedSprites`.
import * as atlas from "./atlas.js";
import { facingName, splitItemShort } from "../net/decode.js";
/** The layer order (the task's list, frozen — docs/CLIENT.md's table). */
export const LAYERS = Object.freeze([
"floor",
"floorDecals",
"stationBases",
"itemsUnder",
"players",
"carryOverlays",
"progressRings",
"hazards",
"particles",
"targetHighlight",
]);
/** The walk cycle's step (ms per half-cycle: 8 steps/s, fixed-step). */
export const WALK_STEP_MS = 125;
/** One sprite of the stove per state (`burning` is a hazard, drawn in
* the hazard layer — a `fire` on the stove's cell switches the base). */
export const STATION_STATE_VARIANT = Object.freeze({
idle: "off",
working: "working",
busy: "working",
ready: "ready",
dirty: "off",
blocked: "off",
});
/**
* The (kind, form) pairs the simulation can produce — the client's copy
* of `undercooked.tools.gen_art_index.reachable_item_forms()`.
* `tests/unit/test_client_sprite_map.py` keeps it equal to the sim's
* fixpoint walk (a sim change that orphans a pair is a build
* failure, naming the pair). An unreachable pair maps to the kind's
* `raw` sprite (every kind has one): a protocol bug then draws a
* *something*, and the atlas-throw path stays reserved for art that
* is genuinely missing.
*/
export const REACHABLE_ITEM_FORMS = Object.freeze({
tomato: ["raw", "chopped", "cooking", "cooked", "burnt"],
onion: ["raw", "chopped", "cooking", "cooked", "burnt"],
lettuce: ["raw", "chopped", "cooking", "cooked", "burnt"],
dough: ["raw", "chopped", "cooking", "cooked", "burnt"],
patty: ["raw", "chopped", "cooking", "cooked", "burnt"],
fish: ["raw", "chopped", "cooking", "cooked", "burnt"],
rice: ["raw", "cooking", "cooked", "burnt"],
plate: ["raw", "dirty", "plated"],
});
/** One station's atlas id (the stove's state, the others state-blind). */
export function stationSpriteId(kind, state) {
if (kind === "stove") {
const variant = STATION_STATE_VARIANT[state] ?? "off";
return `station-stove-${variant}`;
}
return `station-${kind}`;
}
/**
* One (kind, form) pair's atlas id. A reachable pair is direct
* (`item-tomato-chopped`); an unreachable one (the sim cannot produce
* it — `rice_chopped`, a non-plate `plated`) falls back to the kind's
* `raw` sprite.
*/
export function itemSpriteId(kind, form) {
const forms = REACHABLE_ITEM_FORMS[kind];
if (forms !== undefined && forms.includes(form)) {
return `item-${kind}-${form}`;
}
return `item-${kind}-raw`;
}
/** One cook's atlas id (seat 0-3, facing index 0-3, frame 0-1). */
export function cookSpriteId(seat, facing, frame) {
const name = facingName(facing) ?? "down";
const body = (Math.max(0, Math.trunc(seat)) % 4) + 1;
const step = Number.isInteger(frame) ? Math.abs(frame) % 2 : 0;
return `cook-${body}-${name}-${step}`;
}
/** The floor's variant for a cell (deterministic: ~1/3 are textured). */
export function tileSpriteId(kind, x, y) {
if (kind !== "floor") return `tile-${kind}`;
const n = (Math.floor(x) * 3 + Math.floor(y) * 5) % 5;
return n < 2 ? `tile-floor-${n + 1}` : "tile-floor";
}
/** The stove's cookware for a kind (the sim's `STOVE_RAW_SECONDS` half:
* the pan at 5 s for patty/fish, the pot for the rest, rice included). */
export function cookwareSpriteId(kind) {
return kind === "patty" || kind === "fish" ? "station-pan" : "station-pot";
}
/**
* The reach zone per facing (the client-side mirror of
* `sim/interact.py`'s `candidate_cells`: the front tile, the two
* diagonal-front tiles, the tile under the cook — in priority order,
* deduplicated). The facing is the wire's *name* (`"up"` / …).
*/
const CANDIDATE_OFFSETS = Object.freeze({
up: [
[0, -1],
[-1, -1],
[1, -1],
[0, 0],
],
right: [
[1, 0],
[1, -1],
[1, 1],
[0, 0],
],
down: [
[0, 1],
[1, 1],
[-1, 1],
[0, 0],
],
left: [
[-1, 0],
[-1, 1],
[-1, -1],
[0, 0],
],
});
/**
* The cells the reaching hand walks, in priority order — the
* `interact.candidate_cells` mirror. `(x, y)` is the cook's continuous
* position; the cells are the (possibly out-of-bounds) integer pairs.
*/
export function candidateCells(x, y, facing) {
const base = [Math.floor(x), Math.floor(y)];
const offsets = CANDIDATE_OFFSETS[facing] ?? CANDIDATE_OFFSETS.down;
const cells = [];
const seen = new Set();
for (const [dx, dy] of offsets) {
const key = `${base[0] + dx},${base[1] + dy}`;
if (!seen.has(key)) {
seen.add(key);
cells.push([base[0] + dx, base[1] + dy]);
}
}
return cells;
}
const FLOOR_MARGIN = 1; // the bounding box's padding (tiles)
const FALLBACK_WORLD = { minX: 0, minY: 0, w: 12, h: 8 };
/** The kitchen's bounds: the stations' + players' cells, padded. */
function kitchenBounds(view) {
const cells = [];
for (const entry of view.st ?? []) {
if (Number.isFinite(entry.x) && Number.isFinite(entry.y)) {
cells.push([Math.trunc(entry.x), Math.trunc(entry.y)]);
}
}
for (const entry of view.p ?? []) {
if (Number.isFinite(entry.x) && Number.isFinite(entry.y)) {
cells.push([Math.floor(entry.x), Math.floor(entry.y)]);
}
}
if (cells.length === 0) return { ...FALLBACK_WORLD };
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const [cx, cy] of cells) {
minX = Math.min(minX, cx);
minY = Math.min(minY, cy);
maxX = Math.max(maxX, cx);
maxY = Math.max(maxY, cy);
}
minX -= FLOOR_MARGIN;
minY -= FLOOR_MARGIN;
return {
minX,
minY,
w: maxX - minX + 1 + FLOOR_MARGIN,
h: maxY - minY + 1 + FLOOR_MARGIN,
};
}
/**
* One renderer. `ensureReady()` awaits the atlas once (a failed load
* throws — the composition surfaces it on the error path).
* `drawFrame(view, nowMs)` draws the frame (and counts the draw
* calls). `renderStats()` is the census. `dispose()` drops the
* canvas (a new round mounts a fresh play screen — the composition
* rebuilds the renderer).
*/
export function createRenderer({
canvas = null,
camera = null,
fx = null,
ownSeat = null,
reducedMotion = () => false,
now = null,
} = {}) {
const ctx = canvas !== null ? canvas.getContext("2d") : null;
const warned = new Set();
const frameTimes = []; // every measured draw time (ms), since creation
const frameAvgWindow = 30;
let drawCalls = 0;
let frames = 0;
let lastNow = null;
let readyPromise = null;
let ready = false;
let boundsCache = null; // the kitchen's absolute corners, grow-only per runtime
/** The reduced-motion half (the fx.js rule, mirrored): the canvas's
* `data-motion` attribute first (the composition sets it from
* `?motion=0`), a forced override, then the media query. */
function reducedNow() {
if (canvas !== null && canvas.dataset !== null) {
if (canvas.dataset.motion === "reduced") return true;
}
if (typeof reducedMotion === "function") {
const forced = reducedMotion();
if (forced === true) return true;
if (forced === false) return false;
}
return typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false;
}
/** Await the atlas once (the first draw's one-time cost). */
function ensureReady() {
if (ready) return Promise.resolve();
if (readyPromise === null) {
readyPromise = atlas.ready().then(() => {
ready = true;
return true;
});
}
return readyPromise;
}
function statClock() {
return typeof now === "function" ? now() : performance.now();
}
/**
* Draw one sprite (a counted draw call). A throw — an unknown id, a
* sprite with no pixels — is logged once (naming the id), suppressed
* afterwards (no spam), and drawn as a fallback rect: the frame never
* loses a tile to a missing symbol.
*/
function sprite(id, x, y, w, h, opts = {}) {
drawCalls += 1;
try {
atlas.drawSync(ctx, id, x, y, w, h, opts);
} catch (error) {
if (!warned.has(id)) {
warned.add(id);
console.error(`undercooked: sprite ${id} failed (${error.message})`);
}
ctx.save();
ctx.globalAlpha = (typeof opts.alpha === "number" ? opts.alpha : 1) * 0.55;
ctx.fillStyle = "#857259";
ctx.fillRect(x, y, w, h);
ctx.restore();
}
}
function stepFrame(nowMs) {
// The fixed-step walk frame (8 steps/s — a 120 Hz display lands on
// the same frame sequence a 60 Hz one does).
return Math.floor(nowMs / WALK_STEP_MS) % 2;
}
function stepIndex(nowMs, divisorMs) {
return Math.floor(nowMs / divisorMs);
}
/** Draw one player (the layer 5 body + overlays are drawn here). */
function drawPlayer(p, nowMs, toLocal, shake, s) {
const [lx, ly] = toLocal(p.x, p.y);
const cx = lx + (shake !== null ? shake.dx : 0) * s;
const cy = ly + (shake !== null ? shake.dy : 0) * s;
const body = s * 0.95;
const walk =
p.s === "moving" ? stepFrame(nowMs) : 0;
sprite(cookSpriteId(p.pi ?? 0, p.f ?? 2, walk), cx - body / 2, cy - body * 0.92, body, body);
if (p.s === "disconnected") {
sprite("ui-ghost", cx - s * 0.3, cy - s * 1.05, s * 0.6, s * 0.6, { alpha: 0.85 });
}
if (p.s === "stunned") {
const spin = reducedNow()
? 0
: (stepIndex(nowMs, WALK_STEP_MS) % 4) * Math.PI * 0.5;
ctx.save();
ctx.translate(cx, cy - s * 0.5);
ctx.rotate(spin);
sprite("cook-stun", -s * 0.42, -s * 0.42, s * 0.84, s * 0.84, { alpha: 0.9 });
ctx.restore();
}
// The carried item (layer 6) with its overlay: the held item above
// the head, the carry layer at the hand.
if (p.h !== null && p.h !== undefined && p.h !== "") {
const item = splitItemShort(p.h);
if (item !== null) {
sprite("cook-carry", cx - s * 0.3, cy - s * 0.72, s * 0.6, s * 0.6, { alpha: 0.95 });
sprite(
itemSpriteId(item.kind, item.form),
cx - s * 0.25,
cy - s * 1.32,
s * 0.5,
s * 0.5,
);
}
}
}
/**
* One frame. `view` is the `lerpState` output (the entity lists
* already interpolated); `nowMs` the loop's clock (the animation's
* only time source — the loop's pause on `document.hidden` is the
* pause, and a resume's delta is dropped, not measured).
*/
function drawFrame(view, nowMs) {
if (ctx === null) return;
const t0 = statClock();
lastNow = nowMs;
drawCalls = 0;
frames += 1;
const bounds = growBounds(kitchenBounds(view));
// The size reconciliation: the canvas can grow after the fit (the
// `hidden` un-hide, a window resize the camera's listeners miss on
// a canvas that was detached) — re-read the CSS size only when it
// changed, so the backing store and the fit track the element.
if (camera !== null && canvas !== null && canvas.isConnected) {
const css = camera.stats();
if (canvas.clientWidth !== css.cssW || canvas.clientHeight !== css.cssH) {
camera.resize();
}
}
if (camera !== null) camera.setWorld(bounds.w, bounds.h);
const stats =
camera !== null
? camera.stats()
: { scale: 32, offX: 0, offY: 0, cssW: 1024, cssH: 640, dpr: 1 };
const s = stats.scale;
const toLocal = (x, y) => {
const p = camera !== null ? camera.worldToScreen(x - bounds.minX, y - bounds.minY) : { x: 0, y: 0 };
return [p.x, p.y];
};
// The backing store's transform (DPR-aware; the math stays in CSS px).
const dpr = stats.dpr || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, stats.cssW, stats.cssH);
drawCalls += 1;
// 1 — the floor (the approximated kitchen, its edge the wall).
for (let cy = 0; cy < bounds.h; cy += 1) {
for (let cx = 0; cx < bounds.w; cx += 1) {
const [lx, ly] = toLocal(cx, cy);
sprite(tileSpriteId("floor", cx + bounds.minX, cy + bounds.minY), lx, ly, s, s);
}
}
const [ex, ey] = toLocal(0, 0);
ctx.save();
ctx.strokeStyle = "#4a4036";
ctx.lineWidth = Math.max(2, s * 0.08);
ctx.globalAlpha = 0.9;
ctx.strokeRect(ex, ey, bounds.w * s, bounds.h * s);
ctx.restore();
drawCalls += 1;
// 2 — floor decals (the spills: a splashed stain under everything).
for (const hz of view.hz ?? []) {
if (hz.t !== "spill") continue;
const [lx, ly] = toLocal(hz.x, hz.y);
sprite("fx-splash", lx + s * 0.05, ly + s * 0.2, s * 0.9, s * 0.55, { alpha: 0.5 });
}
// 3 — the station bases (a fire on a stove's cell is the burning
// variant — the hazard's visual half; the flame itself is layer 8).
const fireCells = new Set(
(view.hz ?? []).filter((hz) => hz.t === "fire").map((hz) => `${Math.trunc(hz.x)},${Math.trunc(hz.y)}`),
);
for (const st of view.st ?? []) {
const [lx, ly] = toLocal(st.x, st.y);
let id = stationSpriteId(st.k, st.s);
if (st.k === "stove" && fireCells.has(`${Math.trunc(st.x)},${Math.trunc(st.y)}`)) {
id = "station-stove-burning";
}
sprite(id, lx + s * 0.05, ly + s * 0.1, s * 0.9, s * 0.9);
}
// 4 — the carried-under items (a station's content, on the fixture).
for (const st of view.st ?? []) {
if (st.g === null || st.g === undefined || st.g === "") continue;
const item = splitItemShort(st.g);
if (item === null) continue;
const [lx, ly] = toLocal(st.x, st.y);
if (st.k === "stove") {
sprite(cookwareSpriteId(item.kind), lx + s * 0.18, ly + s * 0.3, s * 0.64, s * 0.64);
}
sprite(
itemSpriteId(item.kind, item.form),
lx + s * 0.28,
ly + s * 0.22,
s * 0.44,
s * 0.44,
);
}
// 5 — the players, y-sorted (a deeper cook draws over a shallower).
const players = [...(view.p ?? [])].sort((a, b) => a.y - b.y);
for (const p of players) {
let shake = null;
if (fx !== null && p.i !== undefined) shake = fx.shakeOffset(p.i, nowMs);
drawPlayer(p, nowMs, toLocal, shake, s);
}
// 7 — the progress rings (a working station's arc; the states' full
// rings). Drawn before the hazards (layer 8) so a flame sits over
// the ring it is burning.
for (const st of view.st ?? []) {
const [lx, ly] = toLocal(st.x, st.y);
const cx = lx + s * 0.5;
const cy = ly + s * 0.5;
const r = s * 0.46;
let color = null;
let fraction = null;
const progress = Number.isFinite(st.p) ? st.p : 0;
if (st.s === "working" && progress > 0 && progress < 1) {
color = "#ffa63d";
fraction = progress;
} else if (st.s === "ready") {
color = "#67d16c";
fraction = 1;
} else if (st.s === "busy") {
color = "#ffa63d";
fraction = 1;
} else if (st.s === "blocked") {
color = "#ff5a4e";
fraction = 1;
} else if (st.s === "dirty") {
color = "#9a8f7a";
fraction = 1;
}
if (color !== null) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(1.5, s * 0.07);
ctx.beginPath();
if (fraction === 1) {
ctx.arc(cx, cy, r, 0, Math.PI * 2);
} else {
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + fraction * Math.PI * 2);
}
ctx.stroke();
ctx.restore();
drawCalls += 1;
}
}
// 8 — the hazards (fires the animated flame, the moving obstacle a
// solid block, the rush a pulsing border — the reduced-motion
// fallbacks are static: same markers, no pulse).
for (const hz of view.hz ?? []) {
const [lx, ly] = toLocal(hz.x, hz.y);
if (hz.t === "fire") {
const pulse = reducedNow()
? 1
: 1 + 0.12 * Math.sin((stepIndex(nowMs, WALK_STEP_MS) / 4) * Math.PI * 2);
sprite("fx-flame", lx + s * 0.12, ly + s * 0.05 * pulse, s * 0.76, s * 0.8 * pulse, { alpha: 0.95 });
} else if (hz.t === "moving_obstacle") {
ctx.save();
ctx.globalAlpha = 0.9;
ctx.fillStyle = "#5c5248";
ctx.fillRect(lx + s * 0.1, ly + s * 0.1, s * 0.8, s * 0.8);
ctx.strokeStyle = "#ff5a4e";
ctx.lineWidth = Math.max(1.5, s * 0.06);
ctx.strokeRect(lx + s * 0.1, ly + s * 0.1, s * 0.8, s * 0.8);
ctx.restore();
drawCalls += 1;
} else if (hz.t === "rush") {
const [rx, ry] = toLocal(0, 0);
const alpha = reducedNow() ? 0.35 : 0.25 + 0.2 * Math.abs(Math.sin((stepIndex(nowMs, 125) / 2) * Math.PI * 2));
ctx.save();
ctx.strokeStyle = "#ff5a4e";
ctx.globalAlpha = alpha;
ctx.lineWidth = Math.max(2, s * 0.1);
ctx.strokeRect(rx + s * 0.05, ry + s * 0.05, bounds.w * s - s * 0.1, bounds.h * s - s * 0.1);
ctx.restore();
drawCalls += 1;
}
// a `spill` is a floor decal (layer 2) — no hazard-layer mark.
}
// 9 — the FX's particles (the event effects; `update` advances the
// pool, this draws it).
if (fx !== null) fx.draw(ctx, toLocal, s, nowMs);
// 10 — the target highlight: the viewer's own cook's reach zone,
// the `candidate_cells` mirror, faint (the server's own target,
// approximated — reaching reads predictably).
const own =
typeof ownSeat === "function"
? ownSeat()
: ownSeat !== null && ownSeat !== undefined
? ownSeat
: null;
const me =
own !== null
? (view.p ?? []).find((p) => p.pi === own)
: undefined;
if (me !== undefined) {
const facing = facingName(me.f) ?? "down";
ctx.save();
ctx.strokeStyle = "#67d16c";
ctx.globalAlpha = 0.28;
ctx.lineWidth = Math.max(1.5, s * 0.06);
for (const [cx, cy] of candidateCells(me.x, me.y, facing)) {
// The candidate cells are absolute grid cells; `toLocal` wants
// bounding-box-local coordinates — subtract the box's origin.
const px = camera !== null
? camera.worldToScreen(cx - bounds.minX, cy - bounds.minY)
: { x: 0, y: 0 };
ctx.strokeRect(px.x + s * 0.08, px.y + s * 0.08, s * 0.84, s * 0.84);
}
ctx.restore();
drawCalls += 1;
}
const dtMeasured = statClock() - t0;
frameTimes.push(dtMeasured);
return { drawCalls, bounds };
}
/** The grow-only bounds cache: a wandering cook can extend the box
* (a new cell) but never shrink it — the camera refits only when the
* kitchen is genuinely discovered further, not on every sub-cell
* crossing (a mid-walk re-snap is the shimmer the task forbids). */
function growBounds(bounds) {
const box = {
minX: bounds.minX,
minY: bounds.minY,
maxX: bounds.minX + bounds.w - 1,
maxY: bounds.minY + bounds.h - 1,
};
if (boundsCache === null) {
boundsCache = box;
} else {
boundsCache.minX = Math.min(boundsCache.minX, box.minX);
boundsCache.minY = Math.min(boundsCache.minY, box.minY);
boundsCache.maxX = Math.max(boundsCache.maxX, box.maxX);
boundsCache.maxY = Math.max(boundsCache.maxY, box.maxY);
}
return {
minX: boundsCache.minX,
minY: boundsCache.minY,
w: boundsCache.maxX - boundsCache.minX + 1,
h: boundsCache.maxY - boundsCache.minY + 1,
};
}
function dispose() {
ready = false;
readyPromise = null;
boundsCache = null;
}
/** The frame census (the e2e perf assertions' seam, on `window.__uc`). */
function renderStats() {
const recent = frameTimes.slice(-frameAvgWindow);
const avg =
recent.length === 0
? 0
: recent.reduce((sum, v) => sum + v, 0) / recent.length;
const sorted = [...frameTimes].sort((a, b) => a - b);
const p95 =
sorted.length === 0
? 0
: sorted[Math.min(sorted.length - 1, Math.ceil(0.95 * sorted.length) - 1)];
return {
drawCalls,
frames,
frameMs: avg,
frameMsP95: p95,
frameMsMax: sorted.length === 0 ? 0 : sorted[sorted.length - 1],
warnedSprites: [...warned],
};
}
return {
ensureReady,
drawFrame,
renderStats,
dispose,
isReady: () => ready,
};
}
+361
View File
@@ -0,0 +1,361 @@
// Undercooked — the event FX (phase 07, task 04).
//
// The wire's transient events (the snapshot's `e[]`, the standalone
// `ev`) become on-screen effects here: `chopped`'s spark, `cooked` /
// `ready`'s steam, `served`'s pop + the score float, `burnt`'s smoke,
// `denied`'s cross + the nudge, `stunned`'s spin, `order_missed`'s
// flash, `score`'s float, plus `washed` / `trashed` / `picked` /
// `handed_off`'s small marks.
//
// The renderer drains the event queue (`net/events.js`'s `due(now)`)
// each frame and hands the live entries to `consume`; `consume`
// spawns each entry's effect **once** — its own 100 ms dedup window
// (the same key the queue uses) suppresses the frames the same queued
// entry is repeated over, and a genuinely new event (more than the
// window later, or the queue's tick-reset cleared the old one) spawns
// again. The queue's TTL is the effect's lifetime source: a `served`
// lives 1200 ms, a `chopped` 800.
//
// **A small particle pool, no allocation per event.** `PARTICLES` is a
// fixed array of pre-built particle objects; `spawn` reuses them by a
// cursor (a full pool wraps and steals the oldest), and the one-shot
// effects (the sprite marks: the steam, the deny cross, the flash)
// live in a second fixed `MARKS` array the same way. Nothing is
// `new`-ed after `createFx` — a chop burst cannot grow the heap.
//
// `[data-motion="reduced"]` (or `prefers-reduced-motion`) is the
// fallback: the markers stay (the deny cross, the spin mark, the
// flash's border — the *static* versions), the particles do not
// launch, the nudge's shake never applies (`shakeApplied` stays 0),
// and nothing moves. `reduced()` reads the canvas's `data-motion`
// attribute first (the composition sets it from the `?motion=0`
// param, `docs/CLIENT.md` "The URL params"), then the media query.
import * as atlas from "./atlas.js";
const PARTICLE_COUNT = 96; // the pool size (a full burst wraps, never grows)
const MARK_COUNT = 24; // the one-shot effect slots
const DEDUP_WINDOW_MS = 100; // the queue's window — the same dedup rule
/**
* The event → effect table. `sprite` is the atlas id of the one-shot
* mark (the `reduced` fallback's static marker); `particle` the pool
* particle's colour and shape (the non-reduced launch); `lifeMs` the
* effect's own lifetime (within the queue kind's TTL).
*/
const EFFECTS = Object.freeze({
chopped: { sprite: "fx-chop", particle: { color: "#ffd166" }, lifeMs: 500 },
cooked: { sprite: "fx-steam", particle: { color: "#d9d4c8" }, lifeMs: 800 },
ready: { sprite: "fx-steam", particle: { color: "#d9d4c8" }, lifeMs: 600 },
washed: { sprite: "fx-steam", particle: { color: "#bfe3ff" }, lifeMs: 600 },
served: { sprite: "fx-serve", particle: { color: "#ffe066" }, lifeMs: 1000 },
burnt: { sprite: "fx-flame", particle: { color: "#8a8a8a" }, lifeMs: 900 },
denied: { sprite: "fx-deny", particle: null, lifeMs: 600, nudge: true },
stunned: { sprite: "fx-steam", particle: null, lifeMs: 800, spin: true },
order_missed: { sprite: "fx-deny", particle: null, lifeMs: 1000, flash: true },
score: { sprite: "fx-score", particle: { color: "#ffe066" }, lifeMs: 1000, float: true },
trashed: { sprite: "fx-splash", particle: { color: "#9a8f7a" }, lifeMs: 500 },
picked: { sprite: "fx-chop", particle: { color: "#d9d4c8" }, lifeMs: 350 },
dropped: { sprite: "fx-splash", particle: { color: "#9a8f7a" }, lifeMs: 350 },
handed_off: { sprite: "fx-score", particle: { color: "#d9d4c8" }, lifeMs: 350 },
fire_started: { sprite: "fx-flame", particle: { color: "#ffa63d" }, lifeMs: 800 },
ghost_on: { sprite: "ui-ghost", particle: null, lifeMs: 1200 },
ghost_off: { sprite: "ui-ghost", particle: null, lifeMs: 800 },
});
function keyOf(entry) {
const payload =
entry.payload !== undefined && entry.payload !== null
? JSON.stringify(entry.payload, Object.keys(entry.payload).sort())
: "";
return `${entry.k}|${Math.round((entry.x ?? 0) * 100)}|${Math.round((entry.y ?? 0) * 100)}|${payload}`;
}
/**
* One FX engine. `consume(entries, view, nowMs)` — the queued entries
* (the queue's `due(now)`), the frame (for the nudge's player lookup),
* the clock. `update(nowMs)` — advances the particle pool (a clamped
* `dt`: a resume's jump cannot fling the particles). `draw(ctx,
* toLocal, tile, nowMs)` — draws the live marks + particles (the
* renderer's layer 9). `shakeOffset(playerId, nowMs)` — the nudge's
* decaying offset in *tiles* (the renderer adds it to the player's
* draw position). `stats()` — the census (the e2e's reduced-motion
* seam: `processed`, `shakeApplied`, `particlesLaunched`).
*/
export function createFx({
canvas = null,
reduced = null,
media = null,
} = {}) {
const query =
typeof media === "function"
? media
: () =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false;
const particles = Array.from({ length: PARTICLE_COUNT }, () => ({
active: false,
x: 0,
y: 0,
vx: 0,
vy: 0,
born: 0,
life: 0,
color: "#ffffff",
size: 1,
}));
let particleCursor = 0;
const marks = Array.from({ length: MARK_COUNT }, () => ({
active: false,
x: 0,
y: 0,
born: 0,
life: 0,
sprite: "",
text: "",
color: "#ffffff",
alpha: 1,
}));
let markCursor = 0;
const seen = new Map(); // dedup key -> the last accepted spawn (nowMs)
const shakes = new Map(); // player id -> { born, life } (the nudge's window)
let lastNow = null;
const stats = {
processed: {},
shakeApplied: 0,
particlesLaunched: 0,
deduped: 0,
};
/** The reduced-motion half: the `data-motion` attribute first, then
* the media query (either one is enough). */
function reducedMotion() {
if (typeof reduced === "function") {
const forced = reduced();
if (forced !== null && forced !== undefined) {
return Boolean(forced);
}
}
if (canvas !== null && canvas.dataset !== null) {
const attr = canvas.dataset.motion;
if (attr === "reduced") return true;
}
return query();
}
function stealMark() {
const slot = marks[markCursor];
markCursor = (markCursor + 1) % MARK_COUNT;
return slot;
}
function stealParticle() {
const slot = particles[particleCursor];
particleCursor = (particleCursor + 1) % PARTICLE_COUNT;
return slot;
}
function launchParticles(x, y, color, nowMs, count) {
for (let i = 0; i < count; i += 1) {
const p = stealParticle();
p.active = true;
p.x = x;
p.y = y;
const angle = (i / count) * Math.PI * 2 + (nowMs % 100) * 0.01;
p.vx = Math.cos(angle) * 0.6;
p.vy = Math.sin(angle) * 0.6 - 0.4;
p.born = nowMs;
p.life = 500 + (i % 4) * 80;
p.color = color;
p.size = 0.08 + (i % 3) * 0.03;
stats.particlesLaunched += 1;
}
}
/**
* One queued entry (its `k` is the event kind, `x`/`y` tile units,
* `payload` the short-keyed map). Spawns the effect once; the 100 ms
* window suppresses the same entry's repeat frames.
*/
function consumeOne(entry, view, nowMs) {
const spec = EFFECTS[entry.k];
if (spec === undefined) return; // an unlisted kind (the queue's admission gate already skipped it)
const key = keyOf(entry);
const last = seen.get(key);
if (last !== undefined && nowMs - last < DEDUP_WINDOW_MS) {
stats.deduped += 1; // the echo: the effect already plays
return;
}
seen.set(key, nowMs);
stats.processed[entry.k] = (stats.processed[entry.k] ?? 0) + 1;
const reducedNow = reducedMotion();
if (spec.sprite !== "") {
const mark = stealMark();
mark.active = true;
mark.x = entry.x ?? 0;
mark.y = entry.y ?? 0;
mark.born = nowMs;
mark.life = spec.lifeMs;
mark.sprite = spec.sprite;
mark.alpha = 1;
// The float text (the score's `+pts`, the served's `pts`).
const pts =
entry.payload !== null && entry.payload !== undefined
? entry.payload.pts
: undefined;
mark.text =
spec.float && Number.isFinite(pts) ? `+${pts}` : "";
mark.color = spec.particle !== null ? spec.particle.color : "#ffffff";
}
if (spec.particle !== null && !reducedNow) {
launchParticles(entry.x ?? 0, entry.y ?? 0, spec.particle.color, nowMs, 5);
}
if (spec.nudge && view !== null && view !== undefined) {
const pid =
entry.payload !== null && entry.payload !== undefined
? entry.payload.i
: undefined;
if (pid !== undefined) {
const existing = shakes.get(pid);
if (existing === undefined || nowMs - existing.born > DEDUP_WINDOW_MS) {
shakes.set(pid, { born: nowMs, life: 250 });
}
}
}
}
/**
* The queued entries (the queue's `due(now)` — a list; a single
* entry is tolerated) — each is spawned once.
*/
function consume(entries, view, nowMs) {
const list = Array.isArray(entries) ? entries : [entries];
for (const entry of list) consumeOne(entry, view, nowMs);
}
/** The queue's prune's mirror: drop the dedup keys and the nudge
* windows past their lifetime (the queue's `drift` does its half). */
function prune(nowMs) {
for (const [key, at] of [...seen]) {
if (nowMs - at >= DEDUP_WINDOW_MS) seen.delete(key);
}
for (const [pid, win] of [...shakes]) {
if (nowMs - win.born >= win.life) shakes.delete(pid);
}
}
function update(nowMs) {
const dt =
lastNow === null ? 0 : Math.min(100, Math.max(0, nowMs - lastNow));
lastNow = nowMs;
if (dt > 0) {
const seconds = dt / 1000;
for (const p of particles) {
if (!p.active) continue;
if (nowMs - p.born >= p.life) {
p.active = false;
continue;
}
p.x += p.vx * seconds;
p.y += p.vy * seconds;
p.vy += 1.4 * seconds; // the gravity (tiles/s²)
}
}
prune(nowMs);
}
/**
* The nudge's offset in tiles (the denied's push-back): a damped
* 250 ms wobble along the event's arrival — zero when reduced motion
* (the task's "no shake" half) or outside the window.
*/
function shakeOffset(playerId, nowMs) {
const win = shakes.get(playerId);
if (win === undefined || reducedMotion() || nowMs - win.born >= win.life) {
return null;
}
const t = (nowMs - win.born) / win.life; // 0..1
const wobble = Math.sin(t * Math.PI * 3) * 0.06 * (1 - t);
stats.shakeApplied += 1;
return { dx: wobble, dy: -Math.abs(wobble) * 0.5 };
}
/** Draw the live marks + particles (the renderer's layer 9). */
function draw(ctx, toLocal, tile, nowMs) {
for (const mark of marks) {
if (!mark.active) continue;
const age = nowMs - mark.born;
if (age >= mark.life) {
mark.active = false;
continue;
}
const [x, y] = toLocal(mark.x, mark.y);
const alpha = 1 - age / mark.life;
if (mark.sprite === "fx-flame" && mark.text === "" && mark.color === "#8a8a8a") {
// The burnt's smoke: the flame sprite, grey, rising.
ctx.save();
ctx.globalAlpha = alpha * 0.55;
ctx.translate(0, -age * 0.0012 * tile);
// `fx-flame` is the art's only smoke-able symbol: greyed, it
// reads as smoke.
drawSprite(ctx, "fx-flame", x - tile * 0.2, y - tile * 0.6, tile * 0.55, tile * 0.7, { alpha: 1 });
ctx.restore();
continue;
}
drawSprite(ctx, mark.sprite, x - tile * 0.3, y - tile * 0.55, tile * 0.6, tile * 0.55, { alpha });
if (mark.text !== "") {
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = "#ffe066";
ctx.font = `bold ${Math.max(12, tile * 0.34)}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.fillText(mark.text, x, y - tile * 0.7 - age * 0.0015 * tile);
ctx.restore();
}
}
for (const p of particles) {
if (!p.active) continue;
const age = nowMs - p.born;
if (age >= p.life) continue;
const [x, y] = toLocal(p.x, p.y);
ctx.save();
ctx.globalAlpha = (1 - age / p.life) * 0.9;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(x, y - tile * 0.4, Math.max(1.5, p.size * tile), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// The marks draw through the atlas directly; a throw (a missing
// symbol) cannot blank the frame — the mark's slot just does not
// draw this frame (the renderer's own sprite helper owns the
// once-logged fallback for the *layer* sprites).
function drawSprite(ctx, id, x, y, w, h, opts) {
try {
atlas.drawSync(ctx, id, x, y, w, h, opts);
} catch {
/* a missing symbol: the mark's slot stays, the frame goes on */
}
}
return {
consume,
update,
draw,
shakeOffset,
reduced: reducedMotion,
stats: () => ({
processed: { ...stats.processed },
shakeApplied: stats.shakeApplied,
particlesLaunched: stats.particlesLaunched,
deduped: stats.deduped,
activeParticles: particles.filter((p) => p.active).length,
}),
};
}
+144
View File
@@ -0,0 +1,144 @@
// 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,
};
}
+52
View File
@@ -0,0 +1,52 @@
// Undercooked palette (phase 07, task 01).
//
// The canvas half of app/static/assets/palette.css: the same hex values,
// re-exported for the 2D renderer, so a tint in the HUD and a tint on
// the floor can never disagree. tests/unit/test_assets.py asserts the two
// files agree in both directions; edit one and the build fails until the
// other follows. No bundler, no framework, no remote import (A10/A12).
export const DEFAULT_PALETTE = Object.freeze({
cook1: "#e0533d",
cook2: "#3d84a8",
cook3: "#7da84b",
cook4: "#b0699a",
danger: "#ff5a4e",
hot: "#ffa63d",
ready: "#67d16c",
dirty: "#9a8f7a",
});
// Okabe-Ito (colourblind-safe, deutan/protan) cook colours: the
// [data-palette="cb"] block of the CSS, the same semantics.
export const COLORBLIND_PALETTE = Object.freeze({
...DEFAULT_PALETTE,
cook1: "#d55e00",
cook2: "#0072b2",
cook3: "#009e73",
cook4: "#cc79a7",
});
/** The four seat colours, in seat order (index 0 is seat 1). */
export const COOK_COLORS = Object.freeze([
DEFAULT_PALETTE.cook1,
DEFAULT_PALETTE.cook2,
DEFAULT_PALETTE.cook3,
DEFAULT_PALETTE.cook4,
]);
/** The seat colours under [data-palette="cb"], in seat order. */
export const COOK_COLORS_COLORBLIND = Object.freeze([
COLORBLIND_PALETTE.cook1,
COLORBLIND_PALETTE.cook2,
COLORBLIND_PALETTE.cook3,
COLORBLIND_PALETTE.cook4,
]);
/** The semantic state colours shared by the HUD and the canvas. */
export const SEMANTIC = Object.freeze({
danger: DEFAULT_PALETTE.danger,
hot: DEFAULT_PALETTE.hot,
ready: DEFAULT_PALETTE.ready,
dirty: DEFAULT_PALETTE.dirty,
});
+32
View File
@@ -0,0 +1,32 @@
// Undercooked — the connecting screen (phase 07 task 02).
//
// The brief in-between: a room is being created (or a code is being
// joined) and the `welcome` has not landed yet. *Cancel* is a clean
// close of the socket — the room, once created, lives on server-side
// (the 30 s seat grace / the GC decide its fate), so cancelling never
// deletes anything.
import { el } from "./dom.js";
export function mount(target, data, app) {
const text =
data.what === "creating"
? "Creating your kitchen…"
: `Connecting to ${data.code ?? "…"}…`;
target.append(
el("div", { class: "panel connecting" },
el("h2", {}, "Connecting"),
el("p", { id: "connecting-text" }, text),
el("button", {
id: "cancel-connect",
class: "btn",
type: "button",
onClick: () => app.backToLanding(),
}, "Cancel"),
),
);
}
export function unmount() {
/* static */
}
+38
View File
@@ -0,0 +1,38 @@
// Undercooked — the countdown screen (phase 07 task 02).
//
// The 3-second `COUNTDOWN` in big numerals (one `countdown` frame per
// whole second: 3, 2, 1). The pulse is decorative: under
// `prefers-reduced-motion` the numeral just changes (the CSS in
// `screens.css` gates the animation), and a re-mount per numeral is the
// only motion machinery.
import { el } from "./dom.js";
let numEl = null;
export function mount(target, data, app) {
numEl = el("p", { id: "countdown-num", class: "countdown-num" }, String(data.n ?? 3));
target.append(
el("div", { class: "panel countdown" },
el("h2", {}, "Get ready"),
numEl,
),
);
}
/** A `countdown` frame landed (same-screen re-show): the numeral
* replaces itself so the (motion-gated) pulse restarts. */
export function update(data, _app) {
if (numEl === null) return;
numEl.textContent = String(data.n ?? "");
const parent = numEl.parentElement;
if (parent !== null) {
const fresh = el("p", { id: "countdown-num", class: "countdown-num" }, numEl.textContent);
parent.replaceChildren(fresh);
numEl = fresh;
}
}
export function unmount() {
numEl = null;
}
+72
View File
@@ -0,0 +1,72 @@
// Undercooked — the screens' tiny DOM helper (phase 07 task 02).
//
// The screens are built with `document.createElement` and `textContent`
// only — never `innerHTML` with server data (names, messages and level
// ids arrive from the server and must not parse as HTML). This module
// is that rule made cheap: `el()` builds a subtree from a declarative
// description, and every string lands in a text node or an attribute.
/**
* Build a DOM node.
*
* `el("button", { class: "btn", type: "button", "aria-label": "Copy" },
* el("span", {}, "Copy"), "Copy")`
*
* Children may be nodes, strings (become text nodes) or null (skipped).
* Attribute values that are boolean are set/unset as flags; numbers are
* converted; `value`/`textContent` shortcuts are not needed — `text` in
* the attributes object sets `textContent` (and forbids children).
*/
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [name, value] of Object.entries(attrs)) {
if (value === null || value === undefined || value === false) continue;
if (name === "text") {
node.textContent = String(value);
} else if (name === "value") {
node.value = value;
} else if (name === "dataset") {
for (const [key, item] of Object.entries(value)) {
node.dataset[key] = item;
}
} else if (name.startsWith("on") && typeof value === "function") {
// `onClick` → the DOM event `click` (listeners are case-sensitive).
node.addEventListener(name.slice(2).toLowerCase(), value);
} else if (value === true) {
node.setAttribute(name, "");
} else {
node.setAttribute(name, String(value));
}
}
if ("text" in attrs) return node;
for (const child of children) {
if (child === null || child === undefined) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return node;
}
/** A `document.createDocumentFragment` of the given children. */
export function frag(...children) {
const node = document.createDocumentFragment();
for (const child of children) {
if (child === null || child === undefined) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return node;
}
/**
* The 5-character code the user typed, the way the server normalises it
* (docs/PROTOCOL.md: Crockford base32 — the confusables `I L O U` are
* the server's to map; the client only uppercases and keeps what a code
* can contain). Returns "" when the input is not a full code.
*/
export function normalizeCode(value) {
return String(value ?? "")
.toUpperCase()
.replace(/[^0-9A-Z]/g, "")
.slice(0, 5);
}
export const CODE_LENGTH = 5;
+87
View File
@@ -0,0 +1,87 @@
// Undercooked — the error screen (phase 07 task 02).
//
// Friendly copy per failure (the `err` codes of docs/PROTOCOL.md and
// the close codes the connection classifies), with the *right* retry
// affordance for each: a `4400` protocol mismatch must not auto-retry
// (the client is out of date), a `4401` room-gone must not retry the
// dead code, a `4402` full room points at another room — and the room
// code is shown for support.
import { el } from "./dom.js";
/** code → { title, detail, retry } (retry = "a retry is offered"). */
const COPY = {
room_gone: {
title: "That kitchen is gone",
detail: "It may have closed, or the code was mistyped.",
retry: false,
},
room_full: {
title: "That kitchen is full",
detail: "Every seat is taken. Start your own room, or try another code.",
retry: false,
},
protocol_mismatch: {
title: "Protocol mismatch",
detail:
"This server speaks a protocol version this client does not. Retrying will not help — the client needs an update.",
retry: false,
},
evicted: {
title: "You are on another tab",
detail: "A second tab took this seat. Use the other tab, or come back later.",
retry: false,
},
internal: {
title: "The kitchen hit an internal error",
detail: "The round was aborted for everyone. Start a new room.",
retry: false,
},
closed: {
title: "The connection closed",
detail: "The server closed the socket. You can try the same room again.",
retry: true,
},
unreachable: {
title: "Cannot reach the server",
detail: "The socket could not be opened. Check the address and try again.",
retry: true,
},
};
export function mount(target, data, app) {
const code = data.code ?? "closed";
const copy = COPY[code] ?? COPY.closed;
const buttonRow = el(
"div",
{ class: "error-actions" },
el("button", {
id: "error-back",
class: "btn btn-primary",
type: "button",
onClick: () => app.backToLanding(),
}, "Back to landing"),
);
if (copy.retry && app.roomCode !== "") {
buttonRow.prepend(
el("button", {
id: "error-retry",
class: "btn",
type: "button",
onClick: () => app.retryJoin(),
}, "Try again"),
);
}
const children = [
el("h2", { id: "error-title" }, copy.title),
el("p", { id: "error-detail" }, copy.detail),
];
if (app.roomCode !== "") {
children.push(el("p", { id: "error-code" }, `Room code: ${app.roomCode}`));
}
target.append(el("div", { class: "panel error" }, el("div", { role: "alert" }, ...children), buttonRow));
}
export function unmount() {
/* static */
}
+84
View File
@@ -0,0 +1,84 @@
// Undercooked — the landing screen (phase 07 task 02).
//
// The door of the app: *Create lobby* (a `POST /api/rooms`, the token in
// the body — the session is minted server-side and returned once) and the
// join form (code normalised to five uppercase characters, remembered in
// `sessionStorage` so a reload rejoins fast). Server refusals that land
// here — the process's `room_full` on create, an unreachable server —
// render in the status region, which stays on the screen (the app only
// leaves for a real room).
import { el, frag, normalizeCode, CODE_LENGTH } from "./dom.js";
let statusEl = null;
let joinInput = null;
let bannerText = "";
export function mount(target, _data, app) {
bannerText = app.banner ?? "";
statusEl = el(
"p",
{ id: "status", class: "status", role: "status", "aria-live": "polite" },
bannerText || "Pick a kitchen to start.",
);
joinInput = el("input", {
id: "join-code",
name: "code",
type: "text",
inputmode: "text",
autocapitalize: "characters",
spellcheck: "false",
maxlength: String(CODE_LENGTH),
size: String(CODE_LENGTH),
placeholder: "ABCDE",
"aria-describedby": "join-hint",
required: true,
value: app.lastCode || "",
});
const form = el("form", { id: "join-form", class: "panel", autocomplete: "off" },
el("h2", { id: "lobby-heading" }, "Kitchen lobby"),
el("p", { id: "lobby-help", class: "tagline" },
"Create a kitchen and read its 5-character code out loud, or join one that is already open. Nothing is saved and nothing is asked for."),
el("button", {
id: "create-lobby",
class: "btn btn-primary",
type: "button",
onClick: () => {
app.banner = null;
app.createLobby();
},
}, "Create lobby"),
el("label", { for: "join-code" }, "Room code"),
el("div", { class: "join-row" }, joinInput, el("button", { class: "btn", type: "submit" }, "Join")),
el("p", { id: "join-hint", class: "hint" },
"Five characters, letters and digits, never ",
el("code", {}, "I"), ", ",
el("code", {}, "L"), ", ",
el("code", {}, "O"), " or ",
el("code", {}, "U"), "."),
statusEl,
);
form.addEventListener("submit", (event) => {
event.preventDefault();
const code = normalizeCode(joinInput.value);
joinInput.value = code;
if (code.length !== CODE_LENGTH) {
setBanner(`A room code is ${CODE_LENGTH} characters.`);
joinInput.focus();
return;
}
app.banner = null;
app.join(code);
});
target.append(frag(form));
}
export function unmount() {
/* no timers; the app's show() clears the root */
}
export function setBanner(message) {
if (statusEl === null) return;
bannerText = message;
statusEl.textContent = message || "Pick a kitchen to start.";
}
+292
View File
@@ -0,0 +1,292 @@
// Undercooked — the lobby screen (phase 07 task 02).
//
// The room, as the server broadcasts it: the code with a copy button
// (the clipboard API, with a select-and-copy fallback), the seat list
// (index, name, colour chip, ready, the crown, disconnected), the
// *Ready* toggle, the host-only level picker (fed by `/api/levels` —
// difficulty pips, target, time, blurb in the option's tooltip), the
// *Start* button (enabled from `lobby.can_start`, disabled with a
// reason otherwise — the client's mirror of `Room.can_start`), the
// session panel (`/api/session`'s public projection) and a
// spectator notice.
//
// The DOM is built once on mount; every `lobby` re-broadcast (a join,
// a ready-up, a host change, a level pick) calls `update()`, which
// patches the dynamic parts in place — a re-mount would drop focus off
// the very control the player just touched.
import { el, frag } from "./dom.js";
let built = false;
let seatsUl = null;
let readyBtn = null;
let readyLabel = null;
let levelSelect = null;
let levelLabel = null;
let startBtn = null;
let startReason = null;
let sessionPanel = null;
let specNote = null;
let bannerEl = null;
let codeEl = null;
/** The level a `lobby` frame points at, with the `welcome`'s as fallback. */
function currentLobby(app) {
if (app.lobby !== null) return app.lobby;
const welcome = app.welcome;
return {
code: app.roomCode,
state: "lobby",
level: welcome !== null ? welcome.level : null,
host_seat: null,
can_start: false,
seats: welcome !== null ? welcome.seats : [],
spectators: [],
unlocked: null,
round_no: 0,
};
}
/**
* The server's 4-colour seat palette (server/seats.py `SEAT_PALETTE`,
* docs/PROTOCOL.md "Lobby") as CSS classes — an inline `style`
* attribute would trip the CSP's `style-src 'self'` (a console error),
* so the colour rides on a class. An unknown colour (the palette ever
* grows) falls back to the neutral `.chip` skin.
*/
const CHIP_CLASS = {
"#e5484d": "chip-red",
"#3b9eff": "chip-blue",
"#3ddc84": "chip-green",
"#ffb224": "chip-amber",
};
function seatRow(seat, ownSeatIndex) {
const children = [
el("span", {
class: `chip ${CHIP_CLASS[seat.color ?? ""] ?? ""}`.trim(),
"aria-hidden": "true",
}),
el("span", { class: "seat-index", "aria-hidden": "true" }, `#${seat.index + 1}`),
el("span", { class: "seat-name" }, seat.name),
];
if (seat.host) children.push(el("span", { class: "crown", "aria-label": "host" }, "♛"));
if (seat.ready) children.push(el("span", { class: "ready-badge" }, "ready"));
if (seat.connected === false) children.push(el("span", { class: "ghost-badge" }, "disconnected"));
const row = el("li", { class: "seat" }, frag(...children));
if (seat.index === ownSeatIndex) row.dataset.you = "";
return row;
}
function renderSeats(app) {
const lobby = currentLobby(app);
const rows = lobby.seats.map((seat) => seatRow(seat, app.you !== null ? app.you.seat : null));
seatsUl.replaceChildren(frag(...rows));
}
/** The client's mirror of `Room.can_start`'s refusal prose. */
function startReasonFor(app) {
const lobby = currentLobby(app);
const ownHost = app.you !== null && app.you.host;
if (!ownHost) return "Only the host can start";
if (lobby.level === null) return "Pick a level first";
return lobby.can_start ? "" : "Waiting for ready cooks";
}
function renderControls(app) {
const lobby = currentLobby(app);
const own = app.you !== null ? lobby.seats.find((seat) => seat.index === app.you.seat) : undefined;
// The ready toggle is a toggle on the wire: the label shows the state.
readyLabel.textContent = own !== undefined && own.ready ? "Unready" : "Ready up";
readyBtn.dataset.ready = own !== undefined && own.ready ? "true" : "false";
// The picker: host-only; the selected level is the server's truth.
const ownHost = app.you !== null && app.you.host;
levelSelect.disabled = !ownHost;
levelLabel.textContent = ownHost ? "Level" : "Level (host only)";
if (ownHost && lobby.level !== null) {
const options = levelSelect.querySelectorAll("option");
const match = [...options].find((option) => option.value === lobby.level);
if (match !== undefined) levelSelect.value = match.value;
}
// Start: enabled from `can_start`, disabled with a reason otherwise.
const reason = startReasonFor(app);
startBtn.disabled = reason !== "";
startReason.textContent = reason;
startReason.hidden = reason === "";
}
function renderLevelOptions(app) {
const levels = app.levels ?? [];
const loading = app.levels === null || app.levels === undefined;
levelSelect.replaceChildren(
frag(
el("option", { value: "" }, loading ? "Loading levels…" : "Pick a level"),
...levels.map((level) => {
const pips = Math.ceil(level.difficulty / 2);
const pipText = "●".repeat(pips) + "○".repeat(5 - pips);
return el(
"option",
{ value: level.id, title: level.blurb },
`#${level.order} ${level.name} — ${level.target_score} pts / ${Math.round(level.time_limit)} s ${pipText}`,
);
}),
),
);
const lobby = currentLobby(app);
if (lobby.level !== null) {
const match = [...levelSelect.querySelectorAll("option")].find((option) => option.value === lobby.level);
if (match !== undefined) levelSelect.value = match.value;
}
}
function renderSession(app) {
const session = app.session;
if (session === null) {
sessionPanel.replaceChildren(el("p", {}, "Your session: loading…"));
return;
}
const bests = Object.entries(session.best ?? {}).map(([levelId, run]) =>
el("li", {}, `${levelId} — ${run.score} pts, ${"★".repeat(run.stars)}${"☆".repeat(3 - run.stars)}`),
);
sessionPanel.replaceChildren(
frag(
el("h3", {}, "Your session"),
el("p", {}, `Unlocked: level ${session.unlocked}`),
el("p", {}, `Rounds: ${session.rounds_played} · Dishes served: ${session.dishes_served}`),
el("p", {}, "Best:"),
bests.length === 0
? el("p", { class: "hint" }, "no best runs yet")
: el("ul", { class: "bests" }, frag(...bests)),
),
);
}
function renderSpectators(app) {
const spectators = currentLobby(app).spectators;
specNote.hidden = spectators.length === 0;
specNote.textContent =
spectators.length === 0 ? "" : `Watching: ${spectators.map((spec) => spec.name).join(", ")}`;
}
export function mount(target, _data, app) {
const copyButton = el("button", {
id: "copy-code",
class: "btn",
type: "button",
"aria-label": "Copy the room code",
}, "Copy code");
copyButton.addEventListener("click", () => void copyCode(app, copyButton));
codeEl = el("p", { id: "room-code", class: "room-code" }, app.roomCode || "…");
seatsUl = el("ul", { id: "seat-list", class: "seat-list" });
readyBtn = el("button", {
id: "ready-btn",
class: "btn",
type: "button",
onClick: () => app.ready(),
});
readyLabel = el("span", { class: "ready-label" }, "Ready");
readyBtn.append(readyLabel);
levelSelect = el("select", {
id: "level-picker",
"aria-labelledby": "level-label",
}, el("option", { value: "" }, "Loading levels…"));
levelSelect.addEventListener("change", () => {
if (levelSelect.value !== "") app.setLevel(levelSelect.value);
});
levelLabel = el("label", { id: "level-label", for: "level-picker" }, "Level");
startBtn = el("button", {
id: "start-btn",
class: "btn btn-primary",
type: "button",
disabled: true,
onClick: () => app.start(),
}, "Start");
startReason = el("p", { id: "start-reason", class: "hint", role: "status" }, "");
sessionPanel = el("div", { id: "session-panel", class: "panel session" });
specNote = el("p", { id: "spectator-note", class: "hint", hidden: true });
bannerEl = el("p", { id: "lobby-banner", class: "status", role: "status", "aria-live": "polite", hidden: true });
target.append(
el("div", { class: "panel lobby" },
el("div", { class: "lobby-head" },
el("h2", {}, "Kitchen lobby"),
el("div", { class: "code-row" }, codeEl, copyButton),
),
el("h3", { class: "section-label" }, "Cooks"),
seatsUl,
el("div", { class: "lobby-controls" }, readyBtn),
el("div", { class: "level-pick" }, levelLabel, levelSelect),
el("div", { class: "lobby-controls" }, startBtn, startReason),
specNote,
sessionPanel,
bannerEl,
),
);
built = true;
renderAll(app);
void app.fetchLevels().then(() => {
if (built) {
renderLevelOptions(app);
renderControls(app);
}
});
void app.fetchSession().then(() => {
if (built) renderSession(app);
});
}
function renderAll(app) {
renderSeats(app);
renderLevelOptions(app);
renderControls(app);
renderSession(app);
renderSpectators(app);
}
/** The app's same-screen re-show: a `lobby` re-broadcast while mounted
* (the reducer keeps `app.lobby` fresh before calling this). */
export function update(_data, app) {
if (!built || app === undefined) return;
codeEl.textContent = app.roomCode || "…";
renderAll(app);
}
export function unmount() {
built = false;
/* the app's show() clears the root */
}
export function setBanner(message) {
if (bannerEl === null) return;
bannerEl.textContent = message ?? "";
bannerEl.hidden = message === null || message === "";
}
async function copyCode(app, button) {
const code = app.roomCode;
let copied = false;
try {
await navigator.clipboard.writeText(code);
copied = true;
} catch {
// The clipboard API needs a secure context and a gesture fallback:
// select a throwaway input and copy from it. The hidden class, not
// an inline `style` (the CSP's `style-src 'self'` blocks the
// latter and the console would log it).
const input = document.createElement("input");
input.value = code;
input.setAttribute("readonly", "");
input.classList.add("visually-hidden");
document.body.append(input);
input.select();
try {
copied = document.execCommand("copy");
} catch {
copied = false;
}
input.remove();
}
setBanner(copied ? "Code copied — read it out loud." : `Copy failed — the code is ${code}.`);
button.setAttribute("data-copied", copied ? "true" : "false");
}
+94
View File
@@ -0,0 +1,94 @@
// Undercooked — the play screen (phase 07 task 02).
//
// The kitchen's frame: the canvas host (`#kcanvas` — hidden until the
// task-04 renderer sizes and unhides it), the HUD root (`#hud`, filled
// by task 04), the *Leave* control, the reconnecting overlay (its
// *logic* lands in phase 08; this task shows the sign), the emote row
// (the `docs/PROTOCOL.md` whitelist: `wave`, `cheer`, `thumbs_up`,
// `thumbs_down`, `angry`, `star`), the spectator notice (a join during
// `COUNTDOWN`/`PLAYING`/`ROUND_END` watches, per the wire contract) and
// the banner region (non-fatal `err` codes, e.g. a `start` refusal).
import { el, frag } from "./dom.js";
/** The emote whitelist (docs/PROTOCOL.md "EmoteMsg"): id → label. */
const EMOTES = [
["wave", "Wave", "👋"],
["cheer", "Cheer", "🎉"],
["thumbs_up", "Thumbs up", "👍"],
["thumbs_down", "Thumbs down", "👎"],
["angry", "Angry", "😠"],
["star", "Star", "⭐"],
];
let overlay = null;
let bannerEl = null;
export function mount(target, _data, app) {
overlay = el(
"div",
{ id: "reconnect-overlay", class: "overlay", hidden: true, role: "status" },
el("p", {}, "Connection lost — reconnecting… (phase 08)"),
);
bannerEl = el("p", {
id: "play-banner",
class: "status",
role: "status",
"aria-live": "polite",
hidden: true,
});
target.append(
el("div", { class: "play-screen" },
el("div", { class: "play-head" },
el("p", {
id: "spectator-note",
class: "hint",
hidden: !app.spectator,
}, "You are watching — the room is full."),
el("button", {
id: "leave-btn",
class: "btn",
type: "button",
onClick: () => app.leave(),
}, "Leave"),
),
bannerEl,
el("canvas", { id: "kcanvas", hidden: true, "aria-hidden": "true" }),
el("div", { id: "hud", class: "hud" }),
el("div", { class: "emote-row", role: "group", "aria-label": "Emotes" },
frag(
...EMOTES.map(([id, label, glyph]) =>
el(
"button",
{
class: "btn emote",
type: "button",
"aria-label": label,
"data-emote": id,
onClick: () => app.emote(id),
},
el("span", { "aria-hidden": "true" }, glyph),
),
),
),
),
overlay,
),
);
}
/** The reconnecting overlay (populated in phase 08). */
export function showReconnecting(visible) {
if (overlay !== null) overlay.hidden = !visible;
}
export function setBanner(message) {
if (bannerEl === null) return;
bannerEl.textContent = message ?? "";
bannerEl.hidden = message === null || message === "";
}
export function unmount() {
overlay = null;
bannerEl = null;
}
+143
View File
@@ -0,0 +1,143 @@
// Undercooked — the result screen (phase 07 task 02).
//
// The verdict, as the `round` frame carries it: the result word, the
// stars (1–3, the pop is decorative and `prefers-reduced-motion`-gated),
// score against target, the session's best for the level that was
// played, the per-player contribution list (display only — PLAN §5:
// never competitive gatekeeping), the counters (`served` / `missed` /
// `wasted`, the longest streak), and the three exits — *Next level*
// (only when `round.unlocked` names a level), *Retry* (`next_level`)
// and *Back to lobby* (`to_lobby`). The outcome is announced to screen
// readers through an `aria-live="polite"` region.
import { el, frag } from "./dom.js";
let bestEl = null;
let bannerEl = null;
function starsFor(stars) {
const count = Math.max(0, Math.min(3, stars));
const spans = [];
for (let i = 0; i < 3; i += 1) {
spans.push(
el("span", { class: i < count ? "star filled" : "star", "aria-hidden": "true" },
i < count ? "★" : "☆"),
);
}
return el("p", {
id: "stars",
class: "stars",
"aria-label": `${count} of 3 stars`,
}, frag(...spans));
}
function resultWord(result) {
if (result === "won") return "Won";
if (result === "lost") return "Lost";
if (result === "aborted") return "Aborted";
return "Round over";
}
function announcement(result, score, target, stars) {
const word = result === "won" ? "won" : result === "lost" ? "lost" : "ended";
return `You ${word} ${score} of ${target} points with ${stars} star${stars === 1 ? "" : "s"}.`;
}
export function mount(target, _data, app) {
const round = app.round ?? {};
const result = round.result ?? null;
const stars = round.stars ?? 0;
const score = round.score ?? 0;
const targetScore = round.target ?? 0;
const players = (round.players ?? []).map((player) =>
el("li", { class: "result-player" },
el("span", { class: "seat-name" }, player.name),
el("span", {}, `${player.score} pts · ${player.dishes_served} dish${player.dishes_served === 1 ? "" : "es"}`),
),
);
const counters = round.counters ?? {};
bestEl = el("p", { id: "best-line" }, "Best: loading…");
bannerEl = el("p", { class: "hint", id: "result-banner", hidden: true, role: "status", "aria-live": "polite" });
const actions = el("div", { class: "result-actions" },
el("button", {
id: "retry-btn",
class: "btn",
type: "button",
onClick: () => app.nextLevel(),
}, "Retry"),
el("button", {
id: "to-lobby-btn",
class: "btn btn-primary",
type: "button",
onClick: () => app.toLobby(),
}, "Back to lobby"),
);
if (round.unlocked !== null && round.unlocked !== undefined) {
actions.prepend(
el("button", {
id: "next-level-btn",
class: "btn btn-primary",
type: "button",
onClick: () => app.nextLevel(),
}, "Next level"),
);
}
target.append(
el("div", { class: "panel result" },
el("h2", { id: "result-word" }, resultWord(result)),
starsFor(stars),
el("p", { id: "score-line" }, `Score ${score} of ${targetScore}`),
bestEl,
el("h3", { class: "section-label" }, "Cooks"),
players.length === 0
? el("p", { class: "hint" }, "no players")
: el("ul", { id: "result-players", class: "seat-list" }, frag(...players)),
el("p", { id: "counters-line" },
`Served ${counters.served ?? 0} · Missed ${counters.missed ?? 0} · Wasted ${counters.wasted ?? 0} · Longest streak ${round.longest_streak ?? 0}`),
actions,
bannerEl,
),
el("p", {
id: "result-announce",
class: "visually-hidden",
role: "status",
"aria-live": "polite",
}, announcement(result, score, targetScore, stars)),
);
// The best for the level that was played (the session update lands
// with the `round` frame — `round_over` records before it broadcasts).
void app.fetchSession().then(() => {
if (bestEl !== null) renderBest(app);
});
}
export function renderBest(app) {
if (bestEl === null) return;
const round = app.round ?? {};
const session = app.session;
const best = session !== null && round.level_id !== undefined ? session.best?.[round.level_id] : undefined;
bestEl.textContent =
best !== undefined
? `Best: ${best.score} pts, ${"★".repeat(best.stars)}${"☆".repeat(3 - best.stars)}`
: "Best: —";
}
/** Same-screen re-show: re-render the best line (a `round` re-broadcast
* or a session refresh). */
export function update(_data, app) {
if (app !== undefined) renderBest(app);
}
export function setBanner(message) {
if (bannerEl === null) return;
bannerEl.textContent = message ?? "";
bannerEl.hidden = message === null || message === "";
}
export function unmount() {
bestEl = null;
bannerEl = null;
}
+137
View File
@@ -0,0 +1,137 @@
// Undercooked — the wire enums (phase 07 task 03).
//
// The client's vocabulary for the sim's StrEnums (src/undercooked/sim/model.py)
// and the protocol's `ErrorCode` (src/undercooked/server/protocol.py). The
// wire carries the *string values* (a StrEnum's `.value`); these frozen sets
// are what `net/decode.js` maps against and what a frame's values are
// checked against.
//
// Drift is a build failure, not a surprise: `tests/unit/test_client_enum_parity.py`
// reads this file and asserts each set is set-equal to the Python enum, in
// both directions (the same guard pattern as `test_protocol_doc_sync.py`).
// Add a value to the sim and forget it here (or vice versa) and the gate
// fails, naming the missing value.
/** The terrain under a grid cell (`TileKind`). */
export const TILE_KIND = Object.freeze([
"floor",
"counter",
"wall",
"void",
]);
/** The kitchen fixture types (`StationKind`). */
export const STATION_KIND = Object.freeze([
"spawn_shelf",
"chop_board",
"stove",
"prep",
"serve",
"sink",
"trash",
]);
/** Ingredient / dish types (`ItemKind`). */
export const ITEM_KIND = Object.freeze([
"tomato",
"onion",
"lettuce",
"dough",
"patty",
"rice",
"fish",
"plate",
]);
/** The preparation stage of an item (`ItemForm`). */
export const ITEM_FORM = Object.freeze([
"raw",
"chopped",
"cooking",
"cooked",
"burnt",
"plated",
"dirty",
]);
/** The four directions; the wire's `f` is an index into this list (PLAN §4: 0-3). */
export const FACING = Object.freeze(["up", "right", "down", "left"]);
/** The transient FX/log event kinds (`EventKind`; `encode_events`' `k`). */
export const EVENT_KIND = Object.freeze([
"chopped",
"cooked",
"ready",
"served",
"burnt",
"order_missed",
"fire_started",
"denied",
"score",
"stunned",
"washed",
"trashed",
"round_end",
"wasted",
"picked",
"dropped",
"handed_off",
"trashed_already_empty",
"tick_slow",
"err",
"resync",
"ghost_on",
"ghost_off",
"ghost_park",
]);
/** The stable rejection codes (`ErrorCode`, docs/PROTOCOL.md "Error codes"). */
export const ERROR_CODE = Object.freeze([
"unknown_type",
"bad_json",
"bad_field",
"too_big",
"rate_limited",
"not_seated",
"not_host",
"level_locked",
"wrong_state",
"room_full",
"room_gone",
"protocol_mismatch",
"evicted",
"internal",
]);
// The extras the renderer (task 04) needs. Not in the parity gate's
// seven, but the same discipline applies: the wire's short strings.
/** What a cook is doing this tick (`PlayerState`; the wire's `s`). */
export const PLAYER_STATE = Object.freeze([
"idle",
"moving",
"using",
"stunned",
"disconnected",
]);
/** A station's lifecycle (`StationState`; the wire's `s`). */
export const STATION_STATE = Object.freeze([
"idle",
"working",
"ready",
"busy",
"dirty",
"blocked",
]);
/** The hazard families (`HazardKind`; the wire's `t`). */
export const HAZARD_KIND = Object.freeze([
"fire",
"spill",
"moving_obstacle",
"rush",
]);
/** The terminal outcome of a round (`RoundResult`). */
export const ROUND_RESULT = Object.freeze(["won", "lost", "aborted"]);
+118
View File
@@ -0,0 +1,118 @@
// Undercooked — the help overlay (phase 07, task 05; the task's
// `app/static/ui/help.js`, served from `js/ui/` like the HUD).
//
// The `?`-key in-game help: a body-level, fixed, dismissible panel
// listing the key map. Created once at boot, persists across screens
// (it is not a screen — the state machine never knows it), hidden by
// default. `?` toggles it; `Escape` closes it (the keyboard module's
// `Esc` branch defers to it while it is open); a click on the panel's
// backdrop or its Close button dismisses it. No external docs needed
// mid-game.
//
// The rows are `getKeyMap()`'s — `input/keyboard.js` re-exports it
// (the canonical source) and the README's controls section renders the
// same rows (a test keeps them identical: the browser-rendered map
// against the README's block).
import { el } from "../screens/dom.js";
/**
* The key map the help panel (and the README's controls section — a
* test keeps them identical) renders: `{ keys, action }` rows in
* display order. `keys` are joined with " / " when rendered.
*/
export function getKeyMap() {
return [
{ keys: ["W", "A", "S", "D"], action: "move (or the arrow keys)" },
{ keys: ["Space", "Enter"], action: "action (pick, work, set down, serve)" },
{ keys: ["1", "2", "3", "4"], action: "emote (wave, cheer, thumbs up, thumbs down)" },
{ keys: ["?"], action: "help overlay" },
{ keys: ["Esc"], action: "close help / leave the room" },
];
}
/**
* The help overlay itself.
*
* `onToggle` — optional `(visible) => void` (the census seam; the
* keyboard module's `?`/`Esc` branches need no hook — they call
* `toggle()` / `hide()` directly).
*
* Returns `{ show, hide, toggle, visible }`.
*/
export function createHelpOverlay({ onToggle = null } = {}) {
let panel = null;
let visible = false;
function build() {
if (panel !== null) return panel;
const title = el(
"div",
{ class: "help-title" },
el("span", { class: "help-heading", text: "Controls" }),
el("button", {
class: "btn help-close",
type: "button",
"aria-label": "Close help",
text: "Close",
onClick: () => hide(),
})
);
const rows = getKeyMap().map((row) =>
el(
"div",
{ class: "help-row" },
el("kbd", { class: "help-keys", text: row.keys.join(" / ") }),
el("span", { class: "help-action", text: row.action })
)
);
panel = el(
"div",
{
id: "help-overlay",
class: "help-overlay",
role: "dialog",
"aria-label": "Help",
"aria-hidden": "true",
},
el(
"div",
{ class: "panel help-panel", onClick: (e) => e.stopPropagation() },
title,
el("div", { class: "help-rows" }, ...rows),
el("p", {
class: "help-foot",
text: "Press ? or Esc to close. On the play screen, Esc leaves the room.",
})
)
);
panel.addEventListener("click", () => hide());
return panel;
}
function show() {
const node = build();
if (visible) return;
node.setAttribute("aria-hidden", "false");
document.body.append(node);
visible = true;
if (onToggle !== null) onToggle(true);
}
function hide() {
if (!visible) return;
visible = false;
if (panel !== null) {
panel.setAttribute("aria-hidden", "true");
panel.remove();
}
if (onToggle !== null) onToggle(false);
}
function toggle() {
if (visible) hide();
else show();
}
return { show, hide, toggle, visible: () => visible };
}
+273
View File
@@ -0,0 +1,273 @@
// Undercooked — the HUD (phase 07, task 04).
//
// The game's readouts, all DOM (not canvas) so they stay crisp and
// readable by assistive tech: the order rail (one ticket per order —
// the recipe icon, the recipe name, the deadline's time bar, the
// points; the *oldest* ticket — the rail's head — highlighted), the
// score + target bar, the countdown timer (amber below 20 s, red below
// 10 s, each with a **non-colour cue**: the label's text —
// `hurry` / `now` — and the striped bar pattern, so the state reads
// without colour), the streak indicator (the flame, from the
// `score`/`order_missed` events — the snapshot carries no streak
// field), the dirty-dish counter (the `dirty` stations of the latest
// frame), the connection indicator (the socket's status) and the
// spectator banner.
//
// Updates are **coalesced to one write per animation frame**: the
// single render loop (`render/loop.js`) calls `update()` once per
// frame, and `update` diff-writes — a node's `textContent` / class is
// touched only when the value actually changed, so a 60 fps frame that
// changes nothing writes nothing. The HUD owns no `requestAnimationFrame`
// of its own (the single-owner rule) and no event listeners: it is a
// pure function of the frame + the app's live state.
//
// The recipe icon is a `<img>` on the atlas's decoded `data:` URL
// (`atlas.dataUrl("ui-ticket")` — the CSP allows `img-src data:`; the
// bytes are the app's own, the same decode the canvas uses; a
// `#symbol` fragment never renders from a cross-file `<img>`). The
// streak flame rides the same path (`ui-streak`).
import * as atlas from "../render/atlas.js";
import { el } from "../screens/dom.js";
/** The timer's colour thresholds (seconds; the task's bands). */
export const TIMER_LOW_S = 20;
export const TIMER_CRITICAL_S = 10;
/**
* The timer's visual state for a time-left (seconds): `ok` / `low`
* (amber, below 20 s) / `critical` (red, below 10 s) — with the
* non-colour half: `label` (the text cue: `""` / `hurry` / `now`).
*/
export function timerState(timeLeftS) {
if (timeLeftS < TIMER_CRITICAL_S) {
return { state: "critical", label: "now" };
}
if (timeLeftS < TIMER_LOW_S) {
return { state: "low", label: "hurry" };
}
return { state: "ok", label: "" };
}
/** The socket's status → the indicator's word (the e2e asserts on it). */
export function connectionWord(status) {
if (status === "open") return "Live";
if (status === "connecting") return "Connecting…";
if (status === "mismatch") return "Out of date";
return "Dropped";
}
/**
* One HUD. `update(view, latest, app, eventsDue, nowMs)` — the
* `lerpState` output (the score / target / timer / players), the
* buffer's latest decoded frame (the orders — the rail's source; the
* latest frame, not the lerp: an order's `l` lerps but its identity is
* discrete), the app (the connection status, the spectator flag) and
* the event queue's live entries (the streak's events). Diff-writes
* only.
*/
export function createHud({ root, app } = {}) {
// ----------------------------------------------------------- the DOM
const orderList = el("div", { class: "ticket-list" });
const ticketIcon = el("img", { class: "ticket-icon", alt: "", "aria-hidden": "true" });
const ticketIconSet = { done: false };
const scoreEl = el("b", { id: "hud-score" }, "0");
const targetEl = el("span", { id: "hud-score-target" }, "/—");
const scoreBarFill = el("div", { class: "score-bar-fill" });
const streakEl = el("span", {
id: "hud-streak",
class: "hud-streak",
hidden: true,
"aria-label": "streak",
});
const streakIcon = el("img", { alt: "", "aria-hidden": "true" });
const streakNum = el("b", {}, "×2");
streakEl.append(streakIcon, streakNum);
const timerEl = el("b", { id: "hud-timer" }, "—");
const timerSection = el("section", {
class: "hud-timer",
"aria-label": "Time left",
});
const timerLabel = el("span", { id: "hud-timer-label", class: "hud-timer-label" });
timerSection.append(timerEl, timerLabel);
const dirtyEl = el("span", { id: "hud-dirty", class: "hud-dirty" }, "Dirty 0");
const connEl = el("span", { id: "hud-conn", class: "hud-conn", role: "status" }, "—");
const spectatorEl = el(
"p",
{ id: "hud-spectator", class: "status", hidden: true },
"You are watching — the room is full.",
);
root.append(
el("div", { class: "hud-grid" },
el("section", { class: "hud-orders", "aria-label": "Orders" },
el("p", { class: "section-label" }, "Orders"),
orderList,
),
el("section", { class: "hud-scorebox", "aria-label": "Score" },
el("p", { class: "hud-score-line" },
"Score ",
scoreEl,
" ",
targetEl,
" · ",
streakEl,
),
el("div", { class: "score-bar" }, scoreBarFill),
),
timerSection,
el("section", { class: "hud-meta", "aria-label": "Status" },
dirtyEl,
connEl,
spectatorEl,
),
),
);
// ------------------------------------------------- the diff-write half
const tickets = new Map(); // order id -> the <article>
const lastStreak = { value: 0 };
const seenEvents = new Map(); // the streak events' dedup keys (the 1.5 s window: a kind's TTL + margin)
const EVENT_DEDUP_MS = 1500;
function setText(node, value) {
const text = String(value);
if (node.textContent !== text) node.textContent = text;
}
function ensureIcon(img, id) {
if (ticketIconSet.done && img.dataset.loaded === "1") return;
const url = atlas.dataUrl(id);
if (url !== null) {
if (img.src !== url) img.src = url;
img.dataset.loaded = "1";
if (id === "ui-ticket") ticketIconSet.done = true;
}
}
function update(view, latest, app, eventsDue, nowMs) {
const frame = latest !== null && latest !== undefined ? latest : view;
// The streak (from the events — the snapshot carries no streak
// field): a `score` event's `stk` is the streak after the serve,
// an `order_missed` resets it. The 1.5 s window suppresses the
// same queued entry's repeat frames (a `score` lives 1200 ms).
const now = typeof nowMs === "number" ? nowMs : 0;
for (const entry of Array.isArray(eventsDue) ? eventsDue : []) {
if (entry.k !== "score" && entry.k !== "order_missed") continue;
const payload =
entry.payload !== null && entry.payload !== undefined ? entry.payload : {};
const key = `${entry.k}|${payload.stk ?? ""}|${payload.d ?? ""}`;
const last = seenEvents.get(key);
if (last !== undefined && now - last < EVENT_DEDUP_MS) continue;
seenEvents.set(key, now);
if (entry.k === "score" && Number.isFinite(payload.stk)) {
lastStreak.value = Math.max(0, Math.trunc(payload.stk));
} else if (entry.k === "order_missed") {
lastStreak.value = 0;
}
}
for (const [key, at] of [...seenEvents]) {
if (now - at >= EVENT_DEDUP_MS) seenEvents.delete(key);
}
// Score + target bar.
const score = Number.isFinite(view.s) ? Math.trunc(view.s) : 0;
const target = Number.isFinite(view.tgt) ? Math.trunc(view.tgt) : 0;
setText(scoreEl, score);
setText(targetEl, target > 0 ? `/${target}` : "/—");
const fraction = target > 0 ? Math.min(1, score / target) : 0;
const barWidth = `${Math.round(fraction * 100)}%`;
if (scoreBarFill.style.width !== barWidth) scoreBarFill.style.width = barWidth;
// The streak (the flame + the ×N, visible from a 2).
const streak = lastStreak.value;
const visible = streak >= 2;
if (Boolean(streakEl.hidden) === visible) {
streakEl.hidden = !visible;
if (visible) {
setText(streakNum, `×${streak}`);
streakEl.setAttribute("aria-label", `streak ${streak}`);
ensureIcon(streakIcon, "ui-streak");
}
}
// The timer (the interpolated `tl`, seconds; the colour bands +
// the non-colour label).
const tl = Number.isFinite(view.tl) ? view.tl : 0;
const shown = Math.max(0, Math.ceil(tl));
setText(timerEl, shown);
const { state, label } = timerState(tl);
const classes = timerSection.className;
const wanted =
state === "ok" ? "hud-timer" : `hud-timer hud-timer-${state}`;
if (classes !== wanted) timerSection.className = wanted;
setText(timerLabel, label);
// The dirty-dish counter (the `dirty` stations of the frame).
let dirty = 0;
for (const st of frame.st ?? []) {
if (st.s === "dirty") dirty += 1;
}
setText(dirtyEl, `Dirty ${dirty}`);
// The connection indicator.
setText(connEl, connectionWord(app !== undefined ? app.connection.status : "closed"));
const connClass =
app !== undefined && app.connection.status === "open"
? "hud-conn is-live"
: "hud-conn is-down";
if (connEl.className !== connClass) connEl.className = connClass;
// The spectator banner.
const spectating = Boolean(app !== undefined && app.spectator);
if (Boolean(spectatorEl.hidden) === spectating) spectatorEl.hidden = !spectating;
// The order rail: reconcile by id (the `o[]` of the frame), the
// head (index 0 — the oldest: the rail is FIFO) highlighted.
const orders = (frame.o ?? []).filter(
(o) => o !== null && typeof o === "object",
);
const ids = new Set();
orders.forEach((o, idx) => {
ids.add(o.id);
let ticket = tickets.get(o.id);
if (ticket === undefined) {
ticket = el("article", { class: "ticket", "data-order-id": String(o.id) });
const icon = el("img", { class: "ticket-icon", alt: "", "aria-hidden": "true" });
const name = el("span", { class: "ticket-recipe" });
const pts = el("span", { class: "ticket-pts" });
const bar = el("div", { class: "ticket-bar" }, el("div", { class: "ticket-bar-fill" }));
ticket.append(icon, name, pts, bar);
orderList.append(ticket);
tickets.set(o.id, ticket);
}
ensureIcon(ticket.querySelector("img"), "ui-ticket");
setText(ticket.querySelector(".ticket-recipe"), o.r ?? "dish");
setText(ticket.querySelector(".ticket-pts"), Number.isFinite(o.pts) ? `${Math.trunc(o.pts)} pts` : "");
const fill = ticket.querySelector(".ticket-bar-fill");
const share = Number.isFinite(o.l) ? Math.max(0, Math.min(1, o.l)) : 0;
const width = `${Math.round(share * 100)}%`;
if (fill.style.width !== width) fill.style.width = width;
const isOldest = idx === 0;
ticket.classList.toggle("oldest", isOldest);
});
for (const [id, ticket] of [...tickets]) {
if (!ids.has(id)) {
ticket.remove();
tickets.delete(id);
}
}
}
/** Clear the built DOM (the screen machine clears the root on the
* screen change; this is the module's half, for a tidy unmount). */
function unmount() {
tickets.clear();
seenEvents.clear();
lastStreak.value = 0;
root.replaceChildren();
}
return { update, unmount, timerEl, scoreEl, orderList };
}