SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
33.0 KB · 918 lines tsx
Raw Blame History
1"use client";23/**4 * ORBIT — browser side. The server places the objects and resolves the impulse;5 * this file draws `summary.orbits` (angles in degrees, 0 = up, clockwise) and6 * replays `outcome.steps` (miss / hit / deflection / spawned orbit / supernova)7 * on a 2D canvas. Object rotation is purely cosmetic and frozen while a round plays.8 */9import { useCallback, useEffect, useRef, useState } from "react";10import { AnimatePresence, motion } from "framer-motion";11import { RotateCcw, RotateCw } from "lucide-react";12import { formatMultiplier, formatSC } from "@spinza/shared";13import type { ArcadeOutcome, OrbitConfig, OrbitObject, OrbitStep } from "@spinza/game-core/client";14import { cn } from "@/lib/utils";15import { useInstantPlay, type ArcadeGameProps } from "./contract";1617interface OrbitOutcome extends ArcadeOutcome {18  steps: OrbitStep[];19  summary: { angle: number | null; orbits: OrbitObject[][]; supernova: { remaining: number; multiplier: number } | null; hits: number };20}2122/* --------------------------------------------------------------- helpers */2324const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));25const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);26const easeInCubic = (t: number) => t * t * t;27const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);28const lerp = (a: number, b: number, t: number) => a + (b - a) * t;29const rad = (deg: number) => ((deg - 90) * Math.PI) / 180;30const norm = (deg: number) => ((deg % 360) + 360) % 360;3132function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> {33  return new Promise((resolve) => {34    const start = performance.now();35    const frame = (now: number) => {36      if (!alive()) return resolve();37      const t = Math.min(1, (now - start) / Math.max(1, ms));38      fn(t);39      if (t < 1) requestAnimationFrame(frame);40      else resolve();41    };42    requestAnimationFrame(frame);43  });44}4546function rgba(hex: string, a: number): string {47  const h = hex.replace("#", "");48  const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);49  return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;50}5152function mixHex(a: string, b: string, t: number): string {53  const pa = parseInt(a.replace("#", ""), 16);54  const pb = parseInt(b.replace("#", ""), 16);55  const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t));56  return `rgb(${ch(16)},${ch(8)},${ch(0)})`;57}5859/* ----------------------------------------------------------------- scene */6061interface Obj {62  id: string;63  type: string;64  angle: number;65  value: number; // already payScale-scaled (from summary.orbits)66  alpha: number;67  scale: number;68  flash: number;69  collapse: number; // 0 = on orbit, 1 = in the core70  hit: boolean;71}7273interface Ring {74  radius: number; // px, animated75  target: number;76  alpha: number;77  rot: number;78  speed: number; // deg/s (cosmetic)79  objs: Obj[];80  flashAt: number;81}8283interface Particle {84  x: number;85  y: number;86  vx: number;87  vy: number;88  life: number;89  max: number;90  color: string;91  size: number;92}9394interface Pop {95  x: number;96  y: number;97  text: string;98  color: string;99  at: number;100  big?: boolean;101}102103interface Scene {104  t: number;105  rings: Ring[];106  aim: number;107  phase: "idle" | "firing";108  frozen: boolean;109  preview: boolean;110  path: { x: number; y: number }[]; // screen-space polyline (normalised units of Rmax)111  head: { x: number; y: number } | null;112  pathAlpha: number;113  ripples: { angle: number; radius: number; at: number }[];114  particles: Particle[];115  pops: Pop[];116  coreFlare: number;117  supernova: number; // 0..1 visual intensity118  reduceMotion: boolean;119}120121interface Layout {122  w: number;123  h: number;124  cx: number;125  cy: number;126  rmax: number;127  rc: number;128}129130function layoutFor(w: number, h: number): Layout {131  const rmax = Math.max(60, Math.min(w, h) / 2 - 26);132  return { w, h, cx: w / 2, cy: h / 2, rmax, rc: rmax * 0.13 };133}134135function ringTargets(n: number, L: Layout): number[] {136  const out: number[] = [];137  for (let i = 0; i < n; i++) out.push(L.rc + (L.rmax - L.rc) * ((i + 1) / n));138  return out;139}140141function toObj(o: OrbitObject): Obj {142  return { id: o.id, type: o.type, angle: o.angle, value: o.value, alpha: 1, scale: 1, flash: 0, collapse: 0, hit: false };143}144145const TYPE_COLORS: Record<string, string> = {146  debris: "#9ca3af",147  satellite: "#93c5fd",148  planet: "#34d399",149  comet: "#f0abfc",150  gasgiant: "#fb923c",151  quasar: "#fde68a",152};153154const TYPE_SIZE: Record<string, number> = { debris: 0.45, satellite: 0.55, planet: 0.85, comet: 0.7, gasgiant: 1.25, quasar: 1.1 };155156/* Deterministic cosmetic preview before the first round. */157function previewOrbits(cfg: OrbitConfig): OrbitObject[][] {158  const types = cfg.objectTypes;159  const out: OrbitObject[][] = [];160  let seq = 0;161  for (let i = 0; i < cfg.orbits; i++) {162    const n = 3 + ((i * 2) % 3);163    const objs: OrbitObject[] = [];164    for (let j = 0; j < n; j++) {165      const t = types[(i * 3 + j * 2) % Math.min(types.length, 5)];166      objs.push({ id: `p${seq++}`, type: t.id, angle: norm((360 / n) * j + i * 37 + j * 11), value: t.value });167    }168    out.push(objs);169  }170  return out;171}172173/* --------------------------------------------------------------- drawing */174175interface Palette {176  primary: string;177  secondary: string;178  glow: string;179  bg: string;180  surface: string;181}182183function drawObject(ctx: CanvasRenderingContext2D, o: Obj, x: number, y: number, unit: number, t: number, palette: Palette) {184  const color = TYPE_COLORS[o.type] ?? palette.primary;185  const r = unit * (TYPE_SIZE[o.type] ?? 0.6) * o.scale;186  ctx.save();187  ctx.globalAlpha = o.alpha;188  ctx.translate(x, y);189  if (o.flash > 0) {190    ctx.shadowColor = "#ffffff";191    ctx.shadowBlur = 30 * o.flash;192  } else {193    ctx.shadowColor = color;194    ctx.shadowBlur = o.type === "quasar" || o.type === "comet" ? 18 : 8;195  }196  switch (o.type) {197    case "debris": {198      ctx.rotate(t * 0.8 + o.angle);199      ctx.fillStyle = mixHex(color, "#000000", 0.25);200      ctx.beginPath();201      ctx.moveTo(-r, -r * 0.4);202      ctx.lineTo(-r * 0.2, -r);203      ctx.lineTo(r * 0.9, -r * 0.5);204      ctx.lineTo(r, r * 0.5);205      ctx.lineTo(r * 0.1, r);206      ctx.lineTo(-r * 0.8, r * 0.6);207      ctx.closePath();208      ctx.fill();209      ctx.strokeStyle = "rgba(255,255,255,0.35)";210      ctx.lineWidth = 1;211      ctx.stroke();212      break;213    }214    case "satellite": {215      ctx.rotate(rad(o.angle) + Math.PI / 2);216      ctx.fillStyle = "#1e3a8a";217      ctx.fillRect(-r * 2.1, -r * 0.35, r * 1.3, r * 0.7);218      ctx.fillRect(r * 0.8, -r * 0.35, r * 1.3, r * 0.7);219      ctx.strokeStyle = color;220      ctx.lineWidth = 1;221      ctx.strokeRect(-r * 2.1, -r * 0.35, r * 1.3, r * 0.7);222      ctx.strokeRect(r * 0.8, -r * 0.35, r * 1.3, r * 0.7);223      ctx.fillStyle = "#e5e7eb";224      ctx.beginPath();225      ctx.roundRect(-r * 0.7, -r * 0.6, r * 1.4, r * 1.2, 3);226      ctx.fill();227      ctx.fillStyle = color;228      ctx.beginPath();229      ctx.arc(0, 0, r * 0.28, 0, Math.PI * 2);230      ctx.fill();231      break;232    }233    case "planet": {234      const g = ctx.createRadialGradient(-r * 0.35, -r * 0.35, r * 0.1, 0, 0, r);235      g.addColorStop(0, "#d1fae5");236      g.addColorStop(0.4, color);237      g.addColorStop(1, mixHex(color, "#000000", 0.6));238      ctx.fillStyle = g;239      ctx.beginPath();240      ctx.arc(0, 0, r, 0, Math.PI * 2);241      ctx.fill();242      ctx.strokeStyle = "rgba(255,255,255,0.35)";243      ctx.lineWidth = 1.2;244      ctx.beginPath();245      ctx.ellipse(0, r * 0.15, r * 0.9, r * 0.28, -0.3, 0, Math.PI * 2);246      ctx.stroke();247      break;248    }249    case "comet": {250      const dir = rad(o.angle) + Math.PI / 2; // tangent251      ctx.rotate(dir);252      const tail = ctx.createLinearGradient(0, 0, r * 4.2, 0);253      tail.addColorStop(0, rgba(color, 0.9));254      tail.addColorStop(1, rgba(color, 0));255      ctx.fillStyle = tail;256      ctx.beginPath();257      ctx.moveTo(0, -r * 0.7);258      ctx.lineTo(r * 4.2, 0);259      ctx.lineTo(0, r * 0.7);260      ctx.closePath();261      ctx.fill();262      const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);263      g.addColorStop(0, "#ffffff");264      g.addColorStop(1, color);265      ctx.fillStyle = g;266      ctx.beginPath();267      ctx.arc(0, 0, r * 0.75, 0, Math.PI * 2);268      ctx.fill();269      break;270    }271    case "gasgiant": {272      const g = ctx.createRadialGradient(-r * 0.4, -r * 0.4, r * 0.1, 0, 0, r);273      g.addColorStop(0, "#fed7aa");274      g.addColorStop(0.5, color);275      g.addColorStop(1, mixHex(color, "#000000", 0.6));276      ctx.fillStyle = g;277      ctx.beginPath();278      ctx.arc(0, 0, r, 0, Math.PI * 2);279      ctx.fill();280      ctx.strokeStyle = "rgba(0,0,0,0.25)";281      ctx.lineWidth = r * 0.16;282      for (const k of [-0.45, 0, 0.45]) {283        ctx.beginPath();284        ctx.ellipse(0, r * k, Math.sqrt(1 - k * k) * r, r * 0.08, 0, 0, Math.PI * 2);285        ctx.stroke();286      }287      ctx.strokeStyle = rgba("#fde68a", 0.8);288      ctx.lineWidth = 2;289      ctx.beginPath();290      ctx.ellipse(0, 0, r * 1.75, r * 0.45, -0.45, 0, Math.PI * 2);291      ctx.stroke();292      break;293    }294    case "quasar": {295      ctx.rotate(t * 1.6);296      ctx.strokeStyle = rgba(color, 0.9);297      ctx.lineWidth = 1.5;298      for (let i = 0; i < 6; i++) {299        ctx.rotate(Math.PI / 3);300        ctx.beginPath();301        ctx.moveTo(0, -r * 0.4);302        ctx.lineTo(0, -r * 1.9);303        ctx.stroke();304      }305      const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);306      g.addColorStop(0, "#ffffff");307      g.addColorStop(0.5, color);308      g.addColorStop(1, rgba(color, 0));309      ctx.fillStyle = g;310      ctx.beginPath();311      ctx.arc(0, 0, r, 0, Math.PI * 2);312      ctx.fill();313      break;314    }315    default: {316      ctx.fillStyle = color;317      ctx.beginPath();318      ctx.arc(0, 0, r, 0, Math.PI * 2);319      ctx.fill();320    }321  }322  ctx.restore();323}324325function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: OrbitConfig, palette: Palette) {326  const { w, h, cx, cy, rmax, rc } = L;327  ctx.clearRect(0, 0, w, h);328  const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`;329  const unit = rmax * 0.06;330331  // Star dust332  ctx.save();333  ctx.fillStyle = "rgba(255,255,255,0.25)";334  for (let i = 0; i < 40; i++) {335    const a = i * 2.399 + 0.3;336    const r = ((i * 97) % 100) / 100;337    const x = cx + Math.cos(a) * r * Math.max(w, h) * 0.7;338    const y = cy + Math.sin(a) * r * Math.max(w, h) * 0.7;339    const tw = 0.5 + 0.5 * Math.sin(s.t * (0.6 + (i % 5) * 0.2) + i);340    ctx.globalAlpha = 0.15 + 0.35 * tw;341    ctx.fillRect(x, y, 1.5, 1.5);342  }343  ctx.restore();344345  // Aim cone (idle)346  if (s.phase === "idle" && !s.reduceMotion) {347    ctx.save();348    ctx.translate(cx, cy);349    const a = rad(s.aim);350    const half = (cfg.beamHalfWidth * Math.PI) / 180;351    const g = ctx.createLinearGradient(0, 0, Math.cos(a) * rmax, Math.sin(a) * rmax);352    g.addColorStop(0, rgba(palette.primary, 0.22));353    g.addColorStop(1, rgba(palette.primary, 0));354    ctx.fillStyle = g;355    ctx.beginPath();356    ctx.moveTo(0, 0);357    ctx.arc(0, 0, rmax + 8, a - half, a + half);358    ctx.closePath();359    ctx.fill();360    ctx.setLineDash([4, 6]);361    ctx.strokeStyle = rgba(palette.glow, 0.7);362    ctx.lineWidth = 1.5;363    ctx.beginPath();364    ctx.moveTo(Math.cos(a) * rc, Math.sin(a) * rc);365    ctx.lineTo(Math.cos(a) * (rmax + 8), Math.sin(a) * (rmax + 8));366    ctx.stroke();367    ctx.setLineDash([]);368    // handle369    ctx.fillStyle = palette.glow;370    ctx.shadowColor = palette.glow;371    ctx.shadowBlur = 16;372    ctx.beginPath();373    ctx.arc(Math.cos(a) * (rmax + 14), Math.sin(a) * (rmax + 14), 7, 0, Math.PI * 2);374    ctx.fill();375    ctx.restore();376  } else if (s.phase === "idle") {377    ctx.save();378    ctx.translate(cx, cy);379    const a = rad(s.aim);380    ctx.strokeStyle = rgba(palette.glow, 0.7);381    ctx.lineWidth = 1.5;382    ctx.beginPath();383    ctx.moveTo(Math.cos(a) * rc, Math.sin(a) * rc);384    ctx.lineTo(Math.cos(a) * (rmax + 8), Math.sin(a) * (rmax + 8));385    ctx.stroke();386    ctx.fillStyle = palette.glow;387    ctx.beginPath();388    ctx.arc(Math.cos(a) * (rmax + 14), Math.sin(a) * (rmax + 14), 7, 0, Math.PI * 2);389    ctx.fill();390    ctx.restore();391  }392393  // Orbit rings394  for (const ring of s.rings) {395    if (ring.alpha <= 0) continue;396    ctx.save();397    ctx.globalAlpha = ring.alpha;398    const flash = Math.max(0, 1 - (s.t - ring.flashAt) / 0.9);399    ctx.strokeStyle = rgba(palette.primary, 0.18 + 0.6 * flash);400    ctx.lineWidth = 1 + 2 * flash;401    ctx.shadowColor = palette.primary;402    ctx.shadowBlur = 18 * flash;403    ctx.beginPath();404    ctx.arc(cx, cy, ring.radius, 0, Math.PI * 2);405    ctx.stroke();406    ctx.restore();407  }408409  // Core410  const flare = s.coreFlare;411  const coreR = rc * (1 + 0.06 * Math.sin(s.t * 3)) * (1 + 0.6 * flare) + s.supernova * rmax * 1.3;412  ctx.save();413  const halo = ctx.createRadialGradient(cx, cy, coreR * 0.5, cx, cy, coreR * 2.6);414  halo.addColorStop(0, rgba(palette.secondary, 0.45 + 0.4 * flare));415  halo.addColorStop(1, rgba(palette.secondary, 0));416  ctx.fillStyle = halo;417  ctx.beginPath();418  ctx.arc(cx, cy, coreR * 2.6, 0, Math.PI * 2);419  ctx.fill();420  const core = ctx.createRadialGradient(cx - coreR * 0.2, cy - coreR * 0.2, coreR * 0.1, cx, cy, coreR);421  core.addColorStop(0, "#fff7ed");422  core.addColorStop(0.45, palette.secondary);423  core.addColorStop(1, mixHex(palette.secondary, "#7c2d12", 0.7));424  ctx.fillStyle = core;425  ctx.shadowColor = palette.secondary;426  ctx.shadowBlur = 30 + 60 * flare;427  ctx.beginPath();428  ctx.arc(cx, cy, coreR, 0, Math.PI * 2);429  ctx.fill();430  ctx.restore();431432  // Objects433  for (const ring of s.rings) {434    for (const o of ring.objs) {435      if (o.alpha <= 0) continue;436      const a = rad(o.angle + ring.rot);437      const r = lerp(ring.radius, 0, o.collapse);438      drawObject(ctx, o, cx + Math.cos(a) * r, cy + Math.sin(a) * r, unit, s.t, palette);439    }440  }441442  // Impulse path443  if (s.path.length > 1 && s.pathAlpha > 0) {444    ctx.save();445    ctx.globalAlpha = s.pathAlpha;446    ctx.strokeStyle = palette.glow;447    ctx.lineWidth = 3;448    ctx.lineCap = "round";449    ctx.lineJoin = "round";450    ctx.shadowColor = palette.primary;451    ctx.shadowBlur = 18;452    ctx.beginPath();453    ctx.moveTo(cx + s.path[0].x * rmax, cy + s.path[0].y * rmax);454    for (let i = 1; i < s.path.length; i++) ctx.lineTo(cx + s.path[i].x * rmax, cy + s.path[i].y * rmax);455    if (s.head) ctx.lineTo(cx + s.head.x * rmax, cy + s.head.y * rmax);456    ctx.stroke();457    ctx.strokeStyle = "#ffffff";458    ctx.lineWidth = 1;459    ctx.stroke();460    if (s.head && s.phase === "firing") {461      ctx.fillStyle = "#ffffff";462      ctx.shadowBlur = 24;463      ctx.beginPath();464      ctx.arc(cx + s.head.x * rmax, cy + s.head.y * rmax, 5, 0, Math.PI * 2);465      ctx.fill();466    }467    ctx.restore();468  }469470  // Ripples (misses)471  for (const rp of s.ripples) {472    const age = s.t - rp.at;473    if (age > 0.6) continue;474    const a = rad(rp.angle);475    ctx.save();476    ctx.globalAlpha = 1 - age / 0.6;477    ctx.strokeStyle = "rgba(255,255,255,0.6)";478    ctx.lineWidth = 1.5;479    ctx.beginPath();480    ctx.arc(cx + Math.cos(a) * rp.radius, cy + Math.sin(a) * rp.radius, 4 + 22 * easeOutCubic(age / 0.6), 0, Math.PI * 2);481    ctx.stroke();482    ctx.restore();483  }484485  // Particles486  for (const p of s.particles) {487    const k = p.life / p.max;488    ctx.globalAlpha = k;489    ctx.fillStyle = p.color;490    ctx.beginPath();491    ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * k), 0, Math.PI * 2);492    ctx.fill();493  }494  ctx.globalAlpha = 1;495496  // Pops497  for (const pop of s.pops) {498    const age = s.t - pop.at;499    const life = pop.big ? 1.8 : 1.2;500    if (age > life) continue;501    const k = age < 0.22 ? easeOutBack(age / 0.22) : 1;502    const fade = age > life - 0.4 ? (life - age) / 0.4 : 1;503    ctx.save();504    ctx.globalAlpha = Math.max(0, fade);505    ctx.translate(pop.x, pop.y - (pop.big ? 0 : age * 22));506    ctx.scale(k, k);507    ctx.font = font(pop.big ? Math.min(64, w * 0.15) : 15, 900);508    ctx.textAlign = "center";509    ctx.textBaseline = "middle";510    ctx.shadowColor = pop.color;511    ctx.shadowBlur = pop.big ? 40 : 12;512    ctx.fillStyle = pop.color;513    ctx.fillText(pop.text, 0, 0);514    ctx.restore();515  }516517  // Angle ticks518  ctx.save();519  ctx.strokeStyle = "rgba(255,255,255,0.18)";520  ctx.lineWidth = 1;521  for (let d = 0; d < 360; d += 30) {522    const a = rad(d);523    ctx.beginPath();524    ctx.moveTo(cx + Math.cos(a) * (rmax + 18), cy + Math.sin(a) * (rmax + 18));525    ctx.lineTo(cx + Math.cos(a) * (rmax + 24), cy + Math.sin(a) * (rmax + 24));526    ctx.stroke();527  }528  ctx.restore();529530  if (s.preview) {531    ctx.save();532    ctx.font = font(11, 700);533    ctx.textAlign = "center";534    ctx.textBaseline = "bottom";535    ctx.fillStyle = "rgba(255,255,255,0.45)";536    ctx.fillText("PREVIEW SYSTEM · FIRE TO GENERATE THE REAL ORBITS", w / 2, h - 6);537    ctx.restore();538  }539}540541/* ------------------------------------------------------------- component */542543export function OrbitGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {544  const cfg = definition.config as unknown as OrbitConfig;545  const palette = definition.presentation.palette;546  const payScale = definition.payScale;547  const { play, error, clearError } = useInstantPlay<OrbitOutcome>(definition.slug);548  const [aim, setAim] = useState(0);549  const [phase, setPhase] = useState<"idle" | "firing">("idle");550  const [collected, setCollected] = useState(0);551  const [live, setLive] = useState<string | null>(null);552  const [result, setResult] = useState<{ win: number; multiplier: number; hits: number; supernova: number | null } | null>(null);553  const canvasRef = useRef<HTMLCanvasElement>(null);554  const sceneRef = useRef<Scene | null>(null);555  const aliveRef = useRef(true);556  const phaseRef = useRef<"idle" | "firing">("idle");557558  const getScene = useCallback((): Scene => {559    if (!sceneRef.current) {560      const orbits = previewOrbits(cfg);561      sceneRef.current = {562        t: 0,563        rings: orbits.map((objs, i) => ({ radius: 0, target: 0, alpha: 1, rot: 0, speed: (i % 2 === 0 ? 1 : -1) * (5 - i), objs: objs.map(toObj), flashAt: -10 })),564        aim: 0,565        phase: "idle",566        frozen: false,567        preview: true,568        path: [],569        head: null,570        pathAlpha: 0,571        ripples: [],572        particles: [],573        pops: [],574        coreFlare: 0,575        supernova: 0,576        reduceMotion: false,577      };578    }579    return sceneRef.current;580  }, [cfg]);581582  useEffect(() => {583    aliveRef.current = true;584    const canvas = canvasRef.current;585    if (!canvas) return;586    const ctx = canvas.getContext("2d");587    if (!ctx) return;588    const s = getScene();589    let raf = 0;590    let last = performance.now();591    const loop = (now: number) => {592      const dt = Math.min(0.05, (now - last) / 1000);593      last = now;594      s.t += dt;595      const dpr = Math.min(2, window.devicePixelRatio || 1);596      const rect = canvas.getBoundingClientRect();597      const W = Math.max(1, Math.round(rect.width * dpr));598      const H = Math.max(1, Math.round(rect.height * dpr));599      if (canvas.width !== W || canvas.height !== H) {600        canvas.width = W;601        canvas.height = H;602      }603      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);604      const L = layoutFor(rect.width, rect.height);605      const targets = ringTargets(s.rings.length, L);606      s.rings.forEach((ring, i) => {607        ring.target = targets[i];608        ring.radius = ring.radius === 0 ? ring.target : lerp(ring.radius, ring.target, Math.min(1, dt * 6));609        if (!s.frozen && !s.reduceMotion) ring.rot += ring.speed * dt;610      });611      for (let i = s.particles.length - 1; i >= 0; i--) {612        const p = s.particles[i];613        p.life -= dt;614        p.x += p.vx * dt;615        p.y += p.vy * dt;616        p.vx *= 0.985;617        p.vy *= 0.985;618        if (p.life <= 0) s.particles.splice(i, 1);619      }620      s.ripples = s.ripples.filter((r) => s.t - r.at < 0.7);621      s.pops = s.pops.filter((p) => s.t - p.at < 2);622      s.coreFlare = Math.max(0, s.coreFlare - dt * 1.4);623      drawScene(ctx, L, s, cfg, palette);624      raf = requestAnimationFrame(loop);625    };626    raf = requestAnimationFrame(loop);627    return () => {628      aliveRef.current = false;629      cancelAnimationFrame(raf);630    };631  }, [cfg, palette, getScene]);632633  useEffect(() => {634    const s = getScene();635    s.aim = aim;636    s.reduceMotion = reduceMotion;637  }, [aim, reduceMotion, getScene]);638639  const onPointer = useCallback(640    (e: React.PointerEvent<HTMLCanvasElement>) => {641      if (phaseRef.current !== "idle") return;642      if (e.type === "pointermove" && e.buttons === 0) return;643      const rect = e.currentTarget.getBoundingClientRect();644      const L = layoutFor(rect.width, rect.height);645      const dx = e.clientX - rect.left - L.cx;646      const dy = e.clientY - rect.top - L.cy;647      if (Math.hypot(dx, dy) < L.rc * 0.6) return;648      const deg = Math.round(norm((Math.atan2(dy, dx) * 180) / Math.PI + 90));649      setAim((prev) => {650        if (Math.abs(prev - deg) >= 3) sound("tick");651        return deg;652      });653    },654    [sound],655  );656657  const burst = useCallback((s: Scene, x: number, y: number, n: number, color: string, speed: number) => {658    if (s.reduceMotion) return;659    for (let i = 0; i < n; i++) {660      const a = Math.random() * Math.PI * 2;661      const v = speed * (0.3 + Math.random() * 0.9);662      s.particles.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.4 + Math.random() * 0.5, max: 0.9, color, size: 1.5 + Math.random() * 2.5 });663    }664  }, []);665666  const animate = useCallback(667    async (outcome: OrbitOutcome) => {668      const s = getScene();669      const alive = () => aliveRef.current;670      const rm = s.reduceMotion;671      const canvas = canvasRef.current;672      const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 500 };673      const L = layoutFor(rect.width, rect.height);674      const orbits = outcome.summary.orbits;675      s.phase = "firing";676      s.frozen = true;677      s.preview = false;678      s.particles = [];679      s.pops = [];680      s.ripples = [];681      s.path = [];682      s.head = null;683      s.pathAlpha = 0;684      s.supernova = 0;685686      // Swap in the real system: old objects fade, new ones appear at the server's angles.687      const old = s.rings;688      await tween(rm ? 60 : 260, (t) => {689        for (const r of old) for (const o of r.objs) o.alpha = 1 - t;690      }, alive);691      const initial = Math.min(cfg.orbits, orbits.length);692      s.rings = orbits.slice(0, initial).map((objs, i) => ({ radius: old[i]?.radius ?? 0, target: 0, alpha: 1, rot: 0, speed: (i % 2 === 0 ? 1 : -1) * (5 - i), objs: objs.map(toObj), flashAt: -10 }));693      for (const r of s.rings) for (const o of r.objs) o.alpha = 0;694      await tween(rm ? 60 : 260, (t) => {695        for (const r of s.rings) for (const o of r.objs) o.alpha = t;696      }, alive);697698      // Impulse leaves the core.699      s.coreFlare = 1;700      sound("click");701      const startA = rad(outcome.steps[0]?.angle ?? outcome.summary.angle ?? 0);702      const unitPt = (angleDeg: number, radiusPx: number) => ({ x: (Math.cos(rad(angleDeg)) * radiusPx) / L.rmax, y: (Math.sin(rad(angleDeg)) * radiusPx) / L.rmax });703      s.path = [{ x: (Math.cos(startA) * L.rc) / L.rmax, y: (Math.sin(startA) * L.rc) / L.rmax }];704      s.pathAlpha = 1;705      let spawnIndex = initial;706      let running = 0;707      for (let i = 0; i < outcome.steps.length; i++) {708        if (!alive()) return;709        const step = outcome.steps[i];710        const ring = s.rings[step.orbit];711        if (!ring) break;712        const from = s.path[s.path.length - 1];713        const to = unitPt(step.angle, ring.target || ringTargets(s.rings.length, L)[step.orbit]);714        await tween(rm ? 50 : 240, (t) => {715          const k = easeInCubic(t) * 0.4 + t * 0.6;716          s.head = { x: lerp(from.x, to.x, k), y: lerp(from.y, to.y, k) };717        }, alive);718        s.path.push(to);719        s.head = null;720        const hx = L.cx + to.x * L.rmax;721        const hy = L.cy + to.y * L.rmax;722        if (!step.hit) {723          s.ripples.push({ angle: step.angle, radius: ring.radius, at: s.t });724          continue;725        }726        const obj = ring.objs.find((o) => o.id === step.hit?.id);727        const type = cfg.objectTypes.find((t) => t.id === step.hit?.type);728        const shownValue = obj?.value ?? Math.round(step.hit.value * payScale * 100) / 100;729        running += step.hit.value;730        const color = TYPE_COLORS[step.hit.type] ?? palette.primary;731        if (obj) {732          obj.hit = true;733          await tween(rm ? 40 : 180, (t) => {734            obj.flash = Math.sin(t * Math.PI);735            obj.scale = 1 + 0.5 * Math.sin(t * Math.PI);736          }, alive);737          burst(s, hx, hy, 16, color, 200);738          void tween(rm ? 40 : 240, (t) => {739            obj.alpha = 1 - t;740            obj.scale = 1 + t;741          }, alive);742        }743        s.pops.push({ x: hx, y: hy - 18, text: `+${formatMultiplier(shownValue)}`, color, at: s.t });744        const runningScaled = Math.round(running * payScale * 100) / 100;745        setCollected(runningScaled);746        setLive(`${type?.label ?? step.hit.type} +${formatMultiplier(shownValue)}`);747        sound(step.hit.type === "gasgiant" || step.hit.type === "quasar" ? "bonus" : "tick");748        if (step.deflectedTo !== undefined) {749          setLive(`${type?.label ?? "Object"} deflects the impulse → ${Math.round(step.deflectedTo)}°`);750          await wait(rm ? 40 : 160);751        }752        if (step.spawned && spawnIndex < orbits.length) {753          const objs = orbits[spawnIndex];754          const newRing: Ring = { radius: L.rmax + 40, target: 0, alpha: 0, rot: 0, speed: (spawnIndex % 2 === 0 ? 1 : -1) * Math.max(1.5, 5 - spawnIndex), objs: objs.map(toObj), flashAt: s.t };755          for (const o of newRing.objs) o.alpha = 0;756          s.rings.push(newRing);757          spawnIndex++;758          sound("bonus");759          setLive(`New orbit spawned · ${s.rings.length} orbits`);760          await tween(rm ? 60 : 420, (t) => {761            newRing.alpha = t;762            for (const o of newRing.objs) o.alpha = t;763          }, alive);764        } else {765          await wait(rm ? 30 : 120);766        }767      }768769      if (!alive()) return;770      // Impulse leaves the system.771      const last = s.path[s.path.length - 1];772      const dirLen = Math.hypot(last.x, last.y) || 1;773      const out = { x: (last.x / dirLen) * 1.25, y: (last.y / dirLen) * 1.25 };774      await tween(rm ? 40 : 200, (t) => {775        s.head = { x: lerp(last.x, out.x, t), y: lerp(last.y, out.y, t) };776      }, alive);777      s.path.push(out);778      s.head = null;779      void tween(rm ? 200 : 1400, (t) => (s.pathAlpha = 1 - t), alive);780781      // Supernova782      const sn = outcome.summary.supernova;783      if (sn) {784        sound("bonus");785        setLive(`SUPERNOVA · ${sn.remaining} survivors`);786        s.pops.push({ x: L.cx, y: L.cy - L.rmax * 0.55, text: "SUPERNOVA", color: palette.secondary, at: s.t, big: true });787        await wait(rm ? 80 : 500);788        const survivors = s.rings.flatMap((r) => r.objs.filter((o) => !o.hit));789        await tween(rm ? 120 : 900, (t) => {790          const k = easeInCubic(t);791          for (const o of survivors) {792            o.collapse = k;793            o.scale = 1 - 0.6 * k;794          }795          s.coreFlare = 1;796        }, alive);797        for (const o of survivors) o.alpha = 0;798        burst(s, L.cx, L.cy, 60, "#ffffff", 420);799        sound("bigWin");800        await tween(rm ? 100 : 700, (t) => {801          s.supernova = Math.sin(t * Math.PI) * 0.9;802          s.coreFlare = 1;803        }, alive);804        s.supernova = 0;805        s.pops.push({ x: L.cx, y: L.cy, text: `×${sn.multiplier.toFixed(1).replace(/\.0$/, "")}`, color: "#ffd66b", at: s.t, big: true });806        setCollected(Math.round(running * sn.multiplier * payScale * 100) / 100);807        await wait(rm ? 100 : 900);808      }809810      if (!alive()) return;811      setCollected(outcome.multiplier);812      setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, hits: outcome.summary.hits, supernova: sn ? sn.multiplier : null });813      if (outcome.totalWin <= 0) sound("lose");814      else if (outcome.multiplier >= 15) sound("bigWin");815      else sound("win");816      onResult({ win: outcome.totalWin, multiplier: outcome.multiplier });817      s.phase = "idle";818      s.frozen = false;819    },820    [cfg, palette, payScale, getScene, sound, onResult, burst],821  );822823  const fire = useCallback(async () => {824    if (phaseRef.current !== "idle") return;825    phaseRef.current = "firing";826    setPhase("firing");827    setResult(null);828    setLive(null);829    setCollected(0);830    onBusy(true);831    sound("click");832    const res = await play(bet, { angle: aim });833    if (res && aliveRef.current) await animate(res.outcome);834    phaseRef.current = "idle";835    if (aliveRef.current) {836      setPhase("idle");837      const s = sceneRef.current;838      if (s) {839        s.phase = "idle";840        s.frozen = false;841      }842    }843    onBusy(false);844  }, [play, bet, aim, animate, onBusy, sound]);845846  const firing = phase === "firing";847  const nudge = (d: number) => {848    sound("tick");849    setAim((a) => norm(a + d));850  };851852  return (853    <div className="absolute inset-0 flex flex-col">854      {/* Aim + collected */}855      <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2">856        <div className="flex items-center gap-1">857          <button disabled={firing} onClick={() => nudge(-5)} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Rotate aim counter-clockwise">858            <RotateCcw className="h-4 w-4" />859          </button>860          <div className="flex h-11 min-w-[78px] flex-col items-center justify-center rounded-md surface-2 px-2">861            <span className="text-[10px] uppercase tracking-wider text-fg-3">Aim</span>862            <span className="text-sm font-bold tabular">{aim}°</span>863          </div>864          <button disabled={firing} onClick={() => nudge(5)} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Rotate aim clockwise">865            <RotateCw className="h-4 w-4" />866          </button>867        </div>868        <div className="text-right leading-tight">869          <div className="eyebrow">{firing ? "Collected" : "Round"}</div>870          <div className={cn("text-sm font-bold tabular", collected > 0 ? "text-credit" : "text-fg-3")}>{collected > 0 ? formatMultiplier(collected) : "—"}</div>871        </div>872      </div>873874      {/* System */}875      <div className="relative min-h-0 flex-1">876        <canvas ref={canvasRef} className={cn("absolute inset-0 h-full w-full touch-none", firing ? "cursor-default" : "cursor-crosshair")} onPointerDown={onPointer} onPointerMove={onPointer} role="img" aria-label="Orbital system. Drag around the core to aim the impulse." />877        <AnimatePresence>878          {live && firing ? (879            <motion.div key={live} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-1 -translate-x-1/2 whitespace-nowrap rounded-full px-3 py-1 text-[12px] font-bold uppercase tracking-wider" style={{ background: rgba(palette.secondary, 0.18), color: palette.secondary }}>880              {live}881            </motion.div>882          ) : null}883          {result && !firing ? (884            <motion.div key="result" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-1 -translate-x-1/2 glass whitespace-nowrap rounded-full px-4 py-1.5 text-center">885              <span className={cn("text-sm font-bold tabular", result.win > 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "The impulse missed everything"}</span>886              <span className="ml-2 text-[11px] text-fg-3">887                {result.hits} hit{result.hits === 1 ? "" : "s"}888                {result.supernova ? ` · supernova ×${result.supernova.toFixed(1).replace(/\.0$/, "")}` : ""}889              </span>890            </motion.div>891          ) : null}892          {error ? (893            <motion.div key="err" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 mx-auto max-w-sm glass rounded-md p-3 text-center text-sm">894              <div className="text-fg-2">{error}</div>895              <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring">896                Dismiss897              </button>898            </motion.div>899          ) : null}900        </AnimatePresence>901      </div>902903      {/* Action */}904      <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2">905        <button906          onClick={() => void fire()}907          disabled={firing}908          className="tap relative h-14 flex-1 rounded-lg text-base font-extrabold uppercase tracking-[0.18em] text-[#1a0b26] transition-transform active:scale-[0.98] disabled:opacity-70 focus-ring"909          style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 55%, ${mixHex(palette.primary, "#000000", 0.25)})`, boxShadow: `0 0 0 4px ${rgba(palette.primary, 0.18)}, 0 18px 50px -14px ${palette.primary}` }}910          aria-label={definition.presentation.verb}911        >912          {firing ? "IMPULSE IN FLIGHT…" : `${definition.presentation.verb} · ${formatSC(bet)}`}913        </button>914      </div>915    </div>916  );917}918