**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`.
274 lines
11 KiB
JavaScript
274 lines
11 KiB
JavaScript
// 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 };
|
||
}
|