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
+361
View File
@@ -0,0 +1,361 @@
// Undercooked — the event FX (phase 07, task 04).
//
// The wire's transient events (the snapshot's `e[]`, the standalone
// `ev`) become on-screen effects here: `chopped`'s spark, `cooked` /
// `ready`'s steam, `served`'s pop + the score float, `burnt`'s smoke,
// `denied`'s cross + the nudge, `stunned`'s spin, `order_missed`'s
// flash, `score`'s float, plus `washed` / `trashed` / `picked` /
// `handed_off`'s small marks.
//
// The renderer drains the event queue (`net/events.js`'s `due(now)`)
// each frame and hands the live entries to `consume`; `consume`
// spawns each entry's effect **once** — its own 100 ms dedup window
// (the same key the queue uses) suppresses the frames the same queued
// entry is repeated over, and a genuinely new event (more than the
// window later, or the queue's tick-reset cleared the old one) spawns
// again. The queue's TTL is the effect's lifetime source: a `served`
// lives 1200 ms, a `chopped` 800.
//
// **A small particle pool, no allocation per event.** `PARTICLES` is a
// fixed array of pre-built particle objects; `spawn` reuses them by a
// cursor (a full pool wraps and steals the oldest), and the one-shot
// effects (the sprite marks: the steam, the deny cross, the flash)
// live in a second fixed `MARKS` array the same way. Nothing is
// `new`-ed after `createFx` — a chop burst cannot grow the heap.
//
// `[data-motion="reduced"]` (or `prefers-reduced-motion`) is the
// fallback: the markers stay (the deny cross, the spin mark, the
// flash's border — the *static* versions), the particles do not
// launch, the nudge's shake never applies (`shakeApplied` stays 0),
// and nothing moves. `reduced()` reads the canvas's `data-motion`
// attribute first (the composition sets it from the `?motion=0`
// param, `docs/CLIENT.md` "The URL params"), then the media query.
import * as atlas from "./atlas.js";
const PARTICLE_COUNT = 96; // the pool size (a full burst wraps, never grows)
const MARK_COUNT = 24; // the one-shot effect slots
const DEDUP_WINDOW_MS = 100; // the queue's window — the same dedup rule
/**
* The event → effect table. `sprite` is the atlas id of the one-shot
* mark (the `reduced` fallback's static marker); `particle` the pool
* particle's colour and shape (the non-reduced launch); `lifeMs` the
* effect's own lifetime (within the queue kind's TTL).
*/
const EFFECTS = Object.freeze({
chopped: { sprite: "fx-chop", particle: { color: "#ffd166" }, lifeMs: 500 },
cooked: { sprite: "fx-steam", particle: { color: "#d9d4c8" }, lifeMs: 800 },
ready: { sprite: "fx-steam", particle: { color: "#d9d4c8" }, lifeMs: 600 },
washed: { sprite: "fx-steam", particle: { color: "#bfe3ff" }, lifeMs: 600 },
served: { sprite: "fx-serve", particle: { color: "#ffe066" }, lifeMs: 1000 },
burnt: { sprite: "fx-flame", particle: { color: "#8a8a8a" }, lifeMs: 900 },
denied: { sprite: "fx-deny", particle: null, lifeMs: 600, nudge: true },
stunned: { sprite: "fx-steam", particle: null, lifeMs: 800, spin: true },
order_missed: { sprite: "fx-deny", particle: null, lifeMs: 1000, flash: true },
score: { sprite: "fx-score", particle: { color: "#ffe066" }, lifeMs: 1000, float: true },
trashed: { sprite: "fx-splash", particle: { color: "#9a8f7a" }, lifeMs: 500 },
picked: { sprite: "fx-chop", particle: { color: "#d9d4c8" }, lifeMs: 350 },
dropped: { sprite: "fx-splash", particle: { color: "#9a8f7a" }, lifeMs: 350 },
handed_off: { sprite: "fx-score", particle: { color: "#d9d4c8" }, lifeMs: 350 },
fire_started: { sprite: "fx-flame", particle: { color: "#ffa63d" }, lifeMs: 800 },
ghost_on: { sprite: "ui-ghost", particle: null, lifeMs: 1200 },
ghost_off: { sprite: "ui-ghost", particle: null, lifeMs: 800 },
});
function keyOf(entry) {
const payload =
entry.payload !== undefined && entry.payload !== null
? JSON.stringify(entry.payload, Object.keys(entry.payload).sort())
: "";
return `${entry.k}|${Math.round((entry.x ?? 0) * 100)}|${Math.round((entry.y ?? 0) * 100)}|${payload}`;
}
/**
* One FX engine. `consume(entries, view, nowMs)` — the queued entries
* (the queue's `due(now)`), the frame (for the nudge's player lookup),
* the clock. `update(nowMs)` — advances the particle pool (a clamped
* `dt`: a resume's jump cannot fling the particles). `draw(ctx,
* toLocal, tile, nowMs)` — draws the live marks + particles (the
* renderer's layer 9). `shakeOffset(playerId, nowMs)` — the nudge's
* decaying offset in *tiles* (the renderer adds it to the player's
* draw position). `stats()` — the census (the e2e's reduced-motion
* seam: `processed`, `shakeApplied`, `particlesLaunched`).
*/
export function createFx({
canvas = null,
reduced = null,
media = null,
} = {}) {
const query =
typeof media === "function"
? media
: () =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false;
const particles = Array.from({ length: PARTICLE_COUNT }, () => ({
active: false,
x: 0,
y: 0,
vx: 0,
vy: 0,
born: 0,
life: 0,
color: "#ffffff",
size: 1,
}));
let particleCursor = 0;
const marks = Array.from({ length: MARK_COUNT }, () => ({
active: false,
x: 0,
y: 0,
born: 0,
life: 0,
sprite: "",
text: "",
color: "#ffffff",
alpha: 1,
}));
let markCursor = 0;
const seen = new Map(); // dedup key -> the last accepted spawn (nowMs)
const shakes = new Map(); // player id -> { born, life } (the nudge's window)
let lastNow = null;
const stats = {
processed: {},
shakeApplied: 0,
particlesLaunched: 0,
deduped: 0,
};
/** The reduced-motion half: the `data-motion` attribute first, then
* the media query (either one is enough). */
function reducedMotion() {
if (typeof reduced === "function") {
const forced = reduced();
if (forced !== null && forced !== undefined) {
return Boolean(forced);
}
}
if (canvas !== null && canvas.dataset !== null) {
const attr = canvas.dataset.motion;
if (attr === "reduced") return true;
}
return query();
}
function stealMark() {
const slot = marks[markCursor];
markCursor = (markCursor + 1) % MARK_COUNT;
return slot;
}
function stealParticle() {
const slot = particles[particleCursor];
particleCursor = (particleCursor + 1) % PARTICLE_COUNT;
return slot;
}
function launchParticles(x, y, color, nowMs, count) {
for (let i = 0; i < count; i += 1) {
const p = stealParticle();
p.active = true;
p.x = x;
p.y = y;
const angle = (i / count) * Math.PI * 2 + (nowMs % 100) * 0.01;
p.vx = Math.cos(angle) * 0.6;
p.vy = Math.sin(angle) * 0.6 - 0.4;
p.born = nowMs;
p.life = 500 + (i % 4) * 80;
p.color = color;
p.size = 0.08 + (i % 3) * 0.03;
stats.particlesLaunched += 1;
}
}
/**
* One queued entry (its `k` is the event kind, `x`/`y` tile units,
* `payload` the short-keyed map). Spawns the effect once; the 100 ms
* window suppresses the same entry's repeat frames.
*/
function consumeOne(entry, view, nowMs) {
const spec = EFFECTS[entry.k];
if (spec === undefined) return; // an unlisted kind (the queue's admission gate already skipped it)
const key = keyOf(entry);
const last = seen.get(key);
if (last !== undefined && nowMs - last < DEDUP_WINDOW_MS) {
stats.deduped += 1; // the echo: the effect already plays
return;
}
seen.set(key, nowMs);
stats.processed[entry.k] = (stats.processed[entry.k] ?? 0) + 1;
const reducedNow = reducedMotion();
if (spec.sprite !== "") {
const mark = stealMark();
mark.active = true;
mark.x = entry.x ?? 0;
mark.y = entry.y ?? 0;
mark.born = nowMs;
mark.life = spec.lifeMs;
mark.sprite = spec.sprite;
mark.alpha = 1;
// The float text (the score's `+pts`, the served's `pts`).
const pts =
entry.payload !== null && entry.payload !== undefined
? entry.payload.pts
: undefined;
mark.text =
spec.float && Number.isFinite(pts) ? `+${pts}` : "";
mark.color = spec.particle !== null ? spec.particle.color : "#ffffff";
}
if (spec.particle !== null && !reducedNow) {
launchParticles(entry.x ?? 0, entry.y ?? 0, spec.particle.color, nowMs, 5);
}
if (spec.nudge && view !== null && view !== undefined) {
const pid =
entry.payload !== null && entry.payload !== undefined
? entry.payload.i
: undefined;
if (pid !== undefined) {
const existing = shakes.get(pid);
if (existing === undefined || nowMs - existing.born > DEDUP_WINDOW_MS) {
shakes.set(pid, { born: nowMs, life: 250 });
}
}
}
}
/**
* The queued entries (the queue's `due(now)` — a list; a single
* entry is tolerated) — each is spawned once.
*/
function consume(entries, view, nowMs) {
const list = Array.isArray(entries) ? entries : [entries];
for (const entry of list) consumeOne(entry, view, nowMs);
}
/** The queue's prune's mirror: drop the dedup keys and the nudge
* windows past their lifetime (the queue's `drift` does its half). */
function prune(nowMs) {
for (const [key, at] of [...seen]) {
if (nowMs - at >= DEDUP_WINDOW_MS) seen.delete(key);
}
for (const [pid, win] of [...shakes]) {
if (nowMs - win.born >= win.life) shakes.delete(pid);
}
}
function update(nowMs) {
const dt =
lastNow === null ? 0 : Math.min(100, Math.max(0, nowMs - lastNow));
lastNow = nowMs;
if (dt > 0) {
const seconds = dt / 1000;
for (const p of particles) {
if (!p.active) continue;
if (nowMs - p.born >= p.life) {
p.active = false;
continue;
}
p.x += p.vx * seconds;
p.y += p.vy * seconds;
p.vy += 1.4 * seconds; // the gravity (tiles/s²)
}
}
prune(nowMs);
}
/**
* The nudge's offset in tiles (the denied's push-back): a damped
* 250 ms wobble along the event's arrival — zero when reduced motion
* (the task's "no shake" half) or outside the window.
*/
function shakeOffset(playerId, nowMs) {
const win = shakes.get(playerId);
if (win === undefined || reducedMotion() || nowMs - win.born >= win.life) {
return null;
}
const t = (nowMs - win.born) / win.life; // 0..1
const wobble = Math.sin(t * Math.PI * 3) * 0.06 * (1 - t);
stats.shakeApplied += 1;
return { dx: wobble, dy: -Math.abs(wobble) * 0.5 };
}
/** Draw the live marks + particles (the renderer's layer 9). */
function draw(ctx, toLocal, tile, nowMs) {
for (const mark of marks) {
if (!mark.active) continue;
const age = nowMs - mark.born;
if (age >= mark.life) {
mark.active = false;
continue;
}
const [x, y] = toLocal(mark.x, mark.y);
const alpha = 1 - age / mark.life;
if (mark.sprite === "fx-flame" && mark.text === "" && mark.color === "#8a8a8a") {
// The burnt's smoke: the flame sprite, grey, rising.
ctx.save();
ctx.globalAlpha = alpha * 0.55;
ctx.translate(0, -age * 0.0012 * tile);
// `fx-flame` is the art's only smoke-able symbol: greyed, it
// reads as smoke.
drawSprite(ctx, "fx-flame", x - tile * 0.2, y - tile * 0.6, tile * 0.55, tile * 0.7, { alpha: 1 });
ctx.restore();
continue;
}
drawSprite(ctx, mark.sprite, x - tile * 0.3, y - tile * 0.55, tile * 0.6, tile * 0.55, { alpha });
if (mark.text !== "") {
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = "#ffe066";
ctx.font = `bold ${Math.max(12, tile * 0.34)}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.fillText(mark.text, x, y - tile * 0.7 - age * 0.0015 * tile);
ctx.restore();
}
}
for (const p of particles) {
if (!p.active) continue;
const age = nowMs - p.born;
if (age >= p.life) continue;
const [x, y] = toLocal(p.x, p.y);
ctx.save();
ctx.globalAlpha = (1 - age / p.life) * 0.9;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(x, y - tile * 0.4, Math.max(1.5, p.size * tile), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// The marks draw through the atlas directly; a throw (a missing
// symbol) cannot blank the frame — the mark's slot just does not
// draw this frame (the renderer's own sprite helper owns the
// once-logged fallback for the *layer* sprites).
function drawSprite(ctx, id, x, y, w, h, opts) {
try {
atlas.drawSync(ctx, id, x, y, w, h, opts);
} catch {
/* a missing symbol: the mark's slot stays, the frame goes on */
}
}
return {
consume,
update,
draw,
shakeOffset,
reduced: reducedMotion,
stats: () => ({
processed: { ...stats.processed },
shakeApplied: stats.shakeApplied,
particlesLaunched: stats.particlesLaunched,
deduped: stats.deduped,
activeParticles: particles.filter((p) => p.active).length,
}),
};
}