"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * CRUMB: a dough bun that eats the cookies you allow * * Drawn in SVG and moved by one frame loop that sleeps when * nothing moves. Its face is continuous, not swapped pictures: * how full it is (cookies eaten / cookies there are) sets its * width, smile, blush and, once it has everything, its happy * eyes and sparkles. * * flight a cookie arcs from a switch; Crumb watches it come * and opens up as it gets close * nibble it catches the cookie in both arms and takes three * bites, a chunk out of the cookie and crumbs each * time, then swallows and fills out * give handing one back it pouts and lets go of a cookie * with a bite out of it, which arcs back to its switch * * Cookies fly in an <svg> overlay that covers the whole block, * so they can travel from a switch to the mouth; positions are * in the block's own px. The engine lives outside React and * writes the SVG directly, so a flight costs no renders. * ───────────────────────────────────────────────────────── */export type Point = { x: number; y: number };export interface CrumbHandle { /** A cookie flies from `from` (block px) into the mouth. */ feed: (from: Point, delay?: number) => void; /** A bitten cookie flies from the mouth back to `to` (block px). */ giveBack: (to: Point, delay?: number) => void; /** A happy wave, e.g. once the choices are saved. */ wave: () => void;}const VB = { w: 200, h: 150 };const BASE = 138; // the floor, in the stage's own unitsconst CX = 100;const NS = "http://www.w3.org/2000/svg";const HOLD = 860; // ms the cookie is held and nibbledconst BITES = [150, 430, 710];const SIZE = 1.3; // cookies at the bun's scaleconst ease = (k: number) => 1 - Math.pow(1 - k, 3);const inOut = (k: number) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2);const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v));const lerp = (a: number, b: number, k: number) => a + (b - a) * k;type Spring = { x: number; v: number };/** One step of a spring toward its target; true once it has settled. */function step(s: Spring, target: number, dt: number, k = 170, c = 18) { s.v += ((target - s.x) * k - s.v * c) * dt; s.x += s.v * dt; return Math.abs(target - s.x) < 1e-3 && Math.abs(s.v) < 1e-3;}/** The bun's outline: a dome with a soft, flat bottom, wider as it fills. */function bodyPath(w: number, h: number) { const top = BASE - h; const mid = BASE - h * 0.46; return ( `M${CX - w * 0.84} ${BASE}` + `C${CX - w * 1.03} ${BASE} ${CX - w * 1.05} ${mid + h * 0.17} ${CX - w} ${mid}` + `C${CX - w * 0.95} ${top + h * 0.1} ${CX - w * 0.54} ${top} ${CX} ${top}` + `C${CX + w * 0.54} ${top} ${CX + w * 0.95} ${top + h * 0.1} ${CX + w} ${mid}` + `C${CX + w * 1.05} ${mid + h * 0.17} ${CX + w * 1.03} ${BASE} ${CX + w * 0.84} ${BASE}Z` );}/** A cookie as SVG markup: a disc, a darker rim and chips, and three bites hidden in its mask. */const COOKIE = (id: string, bitten: number) => `<defs><mask id="${id}"><rect x="-14" y="-14" width="28" height="28" fill="white"/>` + [ [9, -8, 6, 12.5, -2, 4.5], [-9.5, -7.5, 5.5, -4, -12, 4.5], [1, 11.5, 6, 8, 9, 4], ] .map(([x1, y1, r1, x2, y2, r2], i) => `<g data-bite="${i}" style="transform:scale(${i < bitten ? 1 : 0});transform-origin:${x1}px ${y1}px"><circle cx="${x1}" cy="${y1}" r="${r1}" fill="black"/><circle cx="${x2}" cy="${y2}" r="${r2}" fill="black"/></g>`) .join("") + `</mask></defs><g mask="url(#${id})"><circle r="11" fill="var(--crumb-cookie)" stroke="var(--crumb-cookie-rim)" stroke-width="1.4"/>` + `<circle cx="-4" cy="-3.5" r="1.7" fill="var(--crumb-chip)"/><circle cx="3.5" cy="-1" r="1.5" fill="var(--crumb-chip)"/>` + `<circle cx="-1.5" cy="4" r="1.6" fill="var(--crumb-chip)"/><circle cx="5" cy="5.5" r="1.2" fill="var(--crumb-chip)"/><circle cx="-6.5" cy="3" r="1.1" fill="var(--crumb-chip)"/></g>`;type Flight = { id: number; dir: 1 | -1; a: Point; b: Point; t0: number; dur: number; el: SVGGElement; held: number; bites: number };type Speck = { x: number; y: number; vx: number; vy: number; t0: number; el: SVGCircleElement };export class CrumbEngine { parts: Record<string, SVGElement | null> = {}; overlayRef: React.RefObject<SVGSVGElement | null> | null = null; stageRef: React.RefObject<HTMLElement | null> | null = null; reduced = false; target = 0; full: Spring = { x: 0, v: 0 }; joy: Spring = { x: 0, v: 0 }; squash: Spring = { x: 0, v: 0 }; open: Spring = { x: 0, v: 0 }; arms: Spring = { x: 0, v: 0 }; hug: Spring = { x: 0, v: 0 }; pout: Spring = { x: 0, v: 0 }; lookX: Spring = { x: 0, v: 0 }; lookY: Spring = { x: 1, v: 0 }; blinkAt = 0; chewUntil = 0; waveUntil = 0; flights: Flight[] = []; specks: Speck[] = []; seq = 0; raf = 0; last = 0; blinkTimer = 0; frame = (now: number) => this.tick(now); get overlay() { return this.overlayRef?.current ?? null; } get stage() { return this.stageRef?.current ?? null; } part = (name: string) => (el: SVGElement | null) => { this.parts[name] = el; }; mount(overlay: React.RefObject<SVGSVGElement | null>, stage: React.RefObject<HTMLElement | null>, reduced: boolean, fed: number, total: number) { this.overlayRef = overlay; this.stageRef = stage; this.reduced = reduced; this.target = total ? clamp(fed / total) : 0; this.full.x = this.joy.x = this.target; this.draw(performance.now()); this.scheduleBlink(); } destroy() { cancelAnimationFrame(this.raf); window.clearTimeout(this.blinkTimer); this.raf = 0; this.flights.forEach((f) => f.el.remove()); this.specks.forEach((s) => s.el.remove()); this.flights = []; this.specks = []; } setTarget(fed: number, total: number) { this.target = total ? clamp(fed / total) : 0; if (this.reduced) { this.full.x = this.joy.x = this.target; this.draw(performance.now()); return; } this.wake(); } scheduleBlink() { if (this.reduced) return; window.clearTimeout(this.blinkTimer); this.blinkTimer = window.setTimeout(() => { this.blinkAt = performance.now(); this.wake(); this.scheduleBlink(); }, 2400 + ((this.seq * 977 + 311) % 1900)); } wake() { if (this.raf || !this.parts.body) return; this.last = performance.now(); this.raf = requestAnimationFrame(this.frame); } /** The mouth in block px: the stage's place in the block (layout offsets ignore transforms) plus the bun's face. */ mouth(): Point { const host = this.stage; const root = this.overlay?.parentElement; if (!host || !root) return { x: 0, y: 0 }; let x = 0, y = 0; for (let n: HTMLElement | null = host; n && n !== root; n = n.offsetParent as HTMLElement | null) { x += n.offsetLeft; y += n.offsetTop; } const k = Math.min(host.clientWidth / VB.w, host.clientHeight / VB.h); const h = 84 + 4 * this.full.x; return { x: x + (host.clientWidth - VB.w * k) / 2 + CX * k, y: y + (host.clientHeight - VB.h * k) / 2 + (BASE - h * 0.5 + 14) * k }; } fly(dir: 1 | -1, p: Point, delay: number) { const overlay = this.overlay; if (this.reduced || !overlay) return; const id = ++this.seq; const el = document.createElementNS(NS, "g") as SVGGElement; el.innerHTML = COOKIE(`crumb-cookie-${id}-${Math.round(p.x)}-${Math.round(p.y)}`, dir === -1 ? 1 : 0); el.style.opacity = "0"; overlay.appendChild(el); const m = this.mouth(); this.flights.push({ id, dir, a: dir === 1 ? p : { x: m.x, y: m.y + 6 }, b: dir === 1 ? { x: m.x, y: m.y + 6 } : p, t0: performance.now() + delay, dur: dir === 1 ? 600 : 640, el, held: 0, bites: 0 }); if (dir === -1) window.setTimeout(() => (this.pout.v += 7), delay); this.wake(); } wave() { if (this.reduced) return; this.waveUntil = performance.now() + 1300; this.wake(); } crumbs(at: Point, count: number, seed: number, now: number) { const overlay = this.overlay; if (!overlay) return; for (let i = 0; i < count; i++) { const a = -Math.PI * (0.1 + 0.8 * ((i * 0.37 + seed * 0.13) % 1)); const sp = 45 + ((i * 53 + seed * 31) % 60); const el = document.createElementNS(NS, "circle") as SVGCircleElement; el.setAttribute("fill", "var(--crumb-cookie)"); el.setAttribute("r", String(1.2 + ((i * 7 + seed) % 3) * 0.5)); overlay.appendChild(el); this.specks.push({ x: at.x, y: at.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, t0: now, el }); } } tick(now: number) { const dt = Math.min(0.05, (now - this.last) / 1000); this.last = now; let busy = false; let near = 0; let holding = false; let watch: Point | null = null; for (const f of this.flights) { if (now < f.t0) { busy = true; continue; } busy = true; if (f.held) { // held at the mouth and nibbled: a bite at each mark, a chunk out of the cookie, crumbs, a snap of the jaw holding = true; const t = now - f.held; while (f.bites < BITES.length && t >= BITES[f.bites]) { f.el.querySelector<SVGGElement>(`[data-bite="${f.bites}"]`)?.style.setProperty("transform", "scale(1)"); f.bites++; this.open.v -= 11; this.squash.v += 5; this.chewUntil = now + 260; this.crumbs({ x: f.b.x + (f.bites % 2 ? 7 : -7), y: f.b.y - 2 }, 4, f.id + f.bites, now); } const bob = Math.sin(t / 55) * 0.8; f.el.setAttribute("transform", `translate(${f.b.x} ${f.b.y + bob}) rotate(${-8 + f.bites * 6}) scale(${(1 - f.bites * 0.08) * SIZE})`); f.el.style.opacity = String(1 - clamp((t - HOLD + 120) / 120)); if (t >= HOLD) { f.el.remove(); this.chewUntil = now + 520; this.squash.v += 6; } continue; } const k = clamp((now - f.t0) / f.dur); const e = f.dir === 1 ? ease(k) : inOut(k); const mx = (f.a.x + f.b.x) / 2; const my = Math.min(f.a.y, f.b.y) - (f.dir === 1 ? 74 : 58); const x = (1 - e) * (1 - e) * f.a.x + 2 * (1 - e) * e * mx + e * e * f.b.x; const y = (1 - e) * (1 - e) * f.a.y + 2 * (1 - e) * e * my + e * e * f.b.y; const away = f.dir === -1 ? clamp((k - 0.86) / 0.14) : 0; const pop = (f.dir === 1 ? lerp(0.5, 1, clamp(k / 0.18)) : lerp(0.85, 1, k)) * SIZE; f.el.setAttribute("transform", `translate(${x} ${y}) rotate(${(f.dir === 1 ? 300 : -240) * e - (f.dir === 1 ? 8 : 0)}) scale(${pop})`); f.el.style.opacity = String(1 - away); if (f.dir === 1) { near = Math.max(near, clamp((k - 0.4) / 0.42)); watch = { x, y }; } else if (k < 0.65) watch = { x, y }; if (k >= 1) { if (f.dir === 1) { f.held = now; this.squash.v += 7; this.open.v -= 6; } else f.el.remove(); } } this.flights = this.flights.filter((f) => f.el.isConnected); for (const s of this.specks) { const t = (now - s.t0) / 1000; s.el.setAttribute("cx", String(s.x + s.vx * t)); s.el.setAttribute("cy", String(s.y + s.vy * t + 320 * t * t)); s.el.setAttribute("opacity", String(clamp(1 - t / 0.75))); if (t >= 0.75) s.el.remove(); } this.specks = this.specks.filter((s) => s.el.isConnected); if (this.specks.length) busy = true; const chewing = now < this.chewUntil; const chew = chewing ? 0.3 * Math.max(0, Math.sin(((this.chewUntil - now) / 260) * Math.PI)) : 0; const waving = now < this.waveUntil; const m = this.mouth(); const lx = watch ? clamp((watch.x - m.x) / 40, -1, 1) : 0; const ly = watch ? clamp((watch.y - (m.y - 18)) / 40, -1, 1) : holding ? 0.6 : this.target < 0.34 ? 0.9 : 0; const eating = this.flights.some((f) => f.dir === 1 && now >= f.t0); const settled = [ step(this.full, eating ? this.full.x : this.target, dt, 120, 15), step(this.joy, this.target, dt, 90, 14), step(this.squash, 0, dt, 260, 13), step(this.open, holding ? 0.5 + chew : near + chew, dt, 320, 22), step(this.arms, near > 0.2 ? 1 : waving ? 0.55 : 0, dt, 200, 18), step(this.hug, holding ? 1 : 0, dt, 220, 19), step(this.pout, 0, dt, 60, 9), step(this.lookX, lx, dt, 240, 22), step(this.lookY, ly, dt, 240, 22), ].every(Boolean); if (!settled || chewing || waving) busy = true; if (this.blinkAt && now - this.blinkAt < 160) busy = true; else this.blinkAt = 0; this.draw(now); this.raf = busy ? requestAnimationFrame(this.frame) : 0; } draw(now: number) { const P = this.parts; const f = clamp(this.full.x); const j = clamp(this.joy.x); const open = clamp(this.open.x); const hug = clamp(this.hug.x); const sad = clamp(1 - j * 4) * (1 - open); const glee = clamp((j - 0.82) / 0.18) * (1 - open) * (1 - hug); const blink = this.blinkAt ? Math.sin(clamp((now - this.blinkAt) / 150) * Math.PI) : 0; const w = 48 + 15 * f; const h = 84 + 4 * f; const sq = this.squash.x * 0.012; P.bun?.setAttribute("transform", `translate(${CX} ${BASE}) scale(${1 + sq} ${1 - sq * 1.2}) translate(${-CX} ${-BASE})`); P.body?.setAttribute("d", bodyPath(w, h)); const top = BASE - h; const fy = BASE - h * 0.5; P.gloss?.setAttribute("d", `M${CX - w * 0.7} ${top + h * 0.34} Q${CX - w * 0.64} ${top + h * 0.1} ${CX - w * 0.3} ${top + h * 0.06}`); P.nubL?.setAttribute("transform", `translate(${CX - 17} ${top + 5}) rotate(-14)`); P.nubR?.setAttribute("transform", `translate(${CX + 17} ${top + 5}) rotate(14)`); P.footL?.setAttribute("cx", String(CX - w * 0.42)); P.footR?.setAttribute("cx", String(CX + w * 0.42)); const wave = now < this.waveUntil ? Math.sin((this.waveUntil - now) / 85) * 20 : 0; const arm = this.arms.x * (1 - hug); // arms lift as a cookie comes, and fold in round it while it's held P.armL?.setAttribute("transform", `translate(${CX - w * 0.97 + 9 * hug} ${fy + 16 - 2 * hug}) rotate(${-14 - 72 * arm + 76 * hug})`); P.armR?.setAttribute("transform", `translate(${CX + w * 0.97 - 9 * hug} ${fy + 16 - 2 * hug}) rotate(${14 + 72 * arm - 76 * hug + wave})`); const ex = 15 + 2 * f; const lid = (0.8 + 0.2 * (1 - sad)) * (1 - blink) * (1 - glee); const lx = this.lookX.x * 2.6; const ly = this.lookY.x * 2.2; for (const [side, dir] of [["L", -1], ["R", 1]] as const) { const big = 1 + 0.16 * sad; P[`eye${side}`]?.setAttribute("transform", `translate(${CX + dir * ex + lx} ${fy - 4 + ly}) scale(${big} ${Math.max(0.06, lid) * big})`); const glint = P[`glint${side}`]; if (glint) glint.style.opacity = String(sad); const happy = P[`happy${side}`]; if (happy) { happy.setAttribute("transform", `translate(${CX + dir * ex} ${fy - 3})`); happy.style.opacity = String(glee); } const brow = P[`brow${side}`]; if (brow) { brow.setAttribute("transform", `translate(${CX + dir * ex} ${fy - 14 + 2 * (1 - sad)}) rotate(${dir * 20 * sad})`); brow.style.opacity = String(sad); } const cheek = P[`cheek${side}`]; if (cheek) { cheek.setAttribute("transform", `translate(${CX + dir * (ex + 11 + 4 * f)} ${fy + 7}) scale(${1 + 0.45 * f + 0.15 * hug})`); cheek.style.opacity = String(0.15 + 0.75 * j); } } const smile = lerp(-0.85, 1, Math.sqrt(j)) - this.pout.x * 0.12; const gape = open + clamp(j - 0.55) * 0.55 * (1 - open); const my = fy + 12; const mw = 7 + 2 * j + 2 * open; const mouth = P.mouth; if (mouth) { const curve = 6 * smile; const depth = 4 + 12 * gape; mouth.setAttribute( "d", gape > 0.05 ? `M${CX - mw} ${my} Q${CX} ${my + curve * 0.5 - 1} ${CX + mw} ${my} Q${CX} ${my + depth + Math.max(0, curve)} ${CX - mw} ${my}Z` : `M${CX - mw} ${my} Q${CX} ${my + curve} ${CX + mw} ${my}`, ); mouth.setAttribute("fill", gape > 0.05 ? "var(--crumb-ink)" : "none"); } const sp = P.sparkles; if (sp) { sp.style.opacity = String(glee); sp.setAttribute("transform", `translate(${CX} ${top + 4}) scale(${0.85 + 0.15 * glee})`); } P.floor?.setAttribute("d", `M${CX - w * 1.3} ${BASE + 0.5}H${CX + w * 1.3}`); }}export function Crumb({ fed, total, reduced, overlay, stage, ref,}: { /** Cookies eaten so far. */ fed: number; /** Cookies there are to eat. */ total: number; reduced: boolean; /** The block-wide <svg> the cookies fly in. */ overlay: React.RefObject<SVGSVGElement | null>; /** The stage the bun stands on, to place its mouth in block px. */ stage: React.RefObject<HTMLElement | null>; ref?: React.Ref<CrumbHandle>;}) { const [engine] = React.useState(() => new CrumbEngine()); const uid = React.useId().replace(/[^a-z0-9]/gi, ""); React.useLayoutEffect(() => { engine.mount(overlay, stage, reduced, fed, total); return () => engine.destroy(); // mounted once per reduced-motion setting; fed/total arrive through setTarget below // eslint-disable-next-line react-hooks/exhaustive-deps }, [engine, overlay, stage, reduced]); React.useEffect(() => { engine.setTarget(fed, total); }, [engine, fed, total]); React.useImperativeHandle(ref, () => ({ feed: (p, d = 0) => engine.fly(1, p, d), giveBack: (p, d = 0) => engine.fly(-1, p, d), wave: () => engine.wave() }), [engine]); const dough = `url(#${uid}-dough)`; const line = { stroke: "var(--crumb-line)", strokeWidth: 1.5 }; return ( <svg viewBox={`0 0 ${VB.w} ${VB.h}`} className="absolute inset-0 size-full" aria-hidden="true"> <defs> {/* the dough is lit from its top left, like everything else on the stage */} <radialGradient id={`${uid}-dough`} cx="0.36" cy="0.26" r="0.85"> <stop offset="0" style={{ stopColor: "var(--crumb-hi)" }} /> <stop offset="0.55" style={{ stopColor: "var(--crumb-body)" }} /> <stop offset="1" style={{ stopColor: "var(--crumb-edge)" }} /> </radialGradient> </defs> <path ref={engine.part("floor")} stroke="var(--crumb-floor)" strokeWidth="1" fill="none" /> <g ref={engine.part("bun")}> <ellipse ref={engine.part("footL")} cy={BASE - 1} rx="10" ry="4.5" fill="var(--crumb-shade)" /> <ellipse ref={engine.part("footR")} cy={BASE - 1} rx="10" ry="4.5" fill="var(--crumb-shade)" /> <g ref={engine.part("nubL")}> <ellipse rx="10" ry="8" fill={dough} {...line} /> </g> <g ref={engine.part("nubR")}> <ellipse rx="10" ry="8" fill={dough} {...line} /> </g> <path ref={engine.part("body")} fill={dough} stroke="var(--crumb-line)" strokeWidth="1.6" strokeLinejoin="round" /> <path ref={engine.part("gloss")} stroke="var(--crumb-hi)" strokeWidth="3" strokeLinecap="round" fill="none" opacity="0.85" /> <ellipse ref={engine.part("cheekL")} rx="7" ry="4.5" fill="var(--crumb-blush)" /> <ellipse ref={engine.part("cheekR")} rx="7" ry="4.5" fill="var(--crumb-blush)" /> {(["L", "R"] as const).map((side) => ( <React.Fragment key={side}> <g ref={engine.part(`eye${side}`)}> <ellipse rx="5.6" ry="7" fill="var(--crumb-ink)" /> <circle cx="1.9" cy="-2.6" r="1.9" fill="var(--crumb-hi)" /> <circle ref={engine.part(`glint${side}`)} cx="-1.8" cy="2.4" r="1" fill="var(--crumb-hi)" style={{ opacity: 0 }} /> </g> <path ref={engine.part(`happy${side}`)} d="M-6 2 Q0 -6 6 2" stroke="var(--crumb-ink)" strokeWidth="2.4" fill="none" strokeLinecap="round" style={{ opacity: 0 }} /> <path ref={engine.part(`brow${side}`)} d="M-5 0 H5" stroke="var(--crumb-ink)" strokeWidth="2" strokeLinecap="round" /> </React.Fragment> ))} <path ref={engine.part("mouth")} stroke="var(--crumb-ink)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /> {/* arms in front, so they can fold round a cookie */} <g ref={engine.part("armL")}> <ellipse cy="-8" rx="7" ry="11" fill={dough} {...line} /> </g> <g ref={engine.part("armR")}> <ellipse cy="-8" rx="7" ry="11" fill={dough} {...line} /> </g> </g> <g ref={engine.part("sparkles")} style={{ opacity: 0 }} fill="var(--crumb-spark)"> {[ [-48, 10, 1], [46, -2, 0.8], [-30, -14, 0.6], [58, 24, 0.55], ].map(([x, y, s], i) => ( <path key={i} transform={`translate(${x} ${y}) scale(${s})`} d="M0 -7 Q1 -1 7 0 Q1 1 0 7 Q-1 1 -7 0 Q-1 -1 0 -7Z" /> ))} </g> </svg> );}export default Crumb;