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