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