Files
ducoterra 95ca3fd1fa 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`.
2026-10-10 18:03:52 -04:00

274 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
}