// 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 `
` 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 `
`). 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
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 };
}