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:
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user