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