**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`.
362 lines
13 KiB
JavaScript
362 lines
13 KiB
JavaScript
// 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,
|
|
}),
|
|
};
|
|
}
|