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
+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 };
}