TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
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