"use client"; /** * ORBIT — browser side. The server places the objects and resolves the impulse; * this file draws `summary.orbits` (angles in degrees, 0 = up, clockwise) and * replays `outcome.steps` (miss / hit / deflection / spawned orbit / supernova) * on a 2D canvas. Object rotation is purely cosmetic and frozen while a round plays. */ import { useCallback, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { RotateCcw, RotateCw } from "lucide-react"; import { formatMultiplier, formatSC } from "@spinza/shared"; import type { ArcadeOutcome, OrbitConfig, OrbitObject, OrbitStep } from "@spinza/game-core/client"; import { cn } from "@/lib/utils"; import { useInstantPlay, type ArcadeGameProps } from "./contract"; interface OrbitOutcome extends ArcadeOutcome { steps: OrbitStep[]; summary: { angle: number | null; orbits: OrbitObject[][]; supernova: { remaining: number; multiplier: number } | null; hits: number }; } /* --------------------------------------------------------------- helpers */ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); const easeInCubic = (t: number) => t * t * t; const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; const rad = (deg: number) => ((deg - 90) * Math.PI) / 180; const norm = (deg: number) => ((deg % 360) + 360) % 360; function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise { return new Promise((resolve) => { const start = performance.now(); const frame = (now: number) => { if (!alive()) return resolve(); const t = Math.min(1, (now - start) / Math.max(1, ms)); fn(t); if (t < 1) requestAnimationFrame(frame); else resolve(); }; requestAnimationFrame(frame); }); } function rgba(hex: string, a: number): string { const h = hex.replace("#", ""); const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; } function mixHex(a: string, b: string, t: number): string { const pa = parseInt(a.replace("#", ""), 16); const pb = parseInt(b.replace("#", ""), 16); const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t)); return `rgb(${ch(16)},${ch(8)},${ch(0)})`; } /* ----------------------------------------------------------------- scene */ interface Obj { id: string; type: string; angle: number; value: number; // already payScale-scaled (from summary.orbits) alpha: number; scale: number; flash: number; collapse: number; // 0 = on orbit, 1 = in the core hit: boolean; } interface Ring { radius: number; // px, animated target: number; alpha: number; rot: number; speed: number; // deg/s (cosmetic) objs: Obj[]; flashAt: number; } interface Particle { x: number; y: number; vx: number; vy: number; life: number; max: number; color: string; size: number; } interface Pop { x: number; y: number; text: string; color: string; at: number; big?: boolean; } interface Scene { t: number; rings: Ring[]; aim: number; phase: "idle" | "firing"; frozen: boolean; preview: boolean; path: { x: number; y: number }[]; // screen-space polyline (normalised units of Rmax) head: { x: number; y: number } | null; pathAlpha: number; ripples: { angle: number; radius: number; at: number }[]; particles: Particle[]; pops: Pop[]; coreFlare: number; supernova: number; // 0..1 visual intensity reduceMotion: boolean; } interface Layout { w: number; h: number; cx: number; cy: number; rmax: number; rc: number; } function layoutFor(w: number, h: number): Layout { const rmax = Math.max(60, Math.min(w, h) / 2 - 26); return { w, h, cx: w / 2, cy: h / 2, rmax, rc: rmax * 0.13 }; } function ringTargets(n: number, L: Layout): number[] { const out: number[] = []; for (let i = 0; i < n; i++) out.push(L.rc + (L.rmax - L.rc) * ((i + 1) / n)); return out; } function toObj(o: OrbitObject): Obj { return { id: o.id, type: o.type, angle: o.angle, value: o.value, alpha: 1, scale: 1, flash: 0, collapse: 0, hit: false }; } const TYPE_COLORS: Record = { debris: "#9ca3af", satellite: "#93c5fd", planet: "#34d399", comet: "#f0abfc", gasgiant: "#fb923c", quasar: "#fde68a", }; const TYPE_SIZE: Record = { debris: 0.45, satellite: 0.55, planet: 0.85, comet: 0.7, gasgiant: 1.25, quasar: 1.1 }; /* Deterministic cosmetic preview before the first round. */ function previewOrbits(cfg: OrbitConfig): OrbitObject[][] { const types = cfg.objectTypes; const out: OrbitObject[][] = []; let seq = 0; for (let i = 0; i < cfg.orbits; i++) { const n = 3 + ((i * 2) % 3); const objs: OrbitObject[] = []; for (let j = 0; j < n; j++) { const t = types[(i * 3 + j * 2) % Math.min(types.length, 5)]; objs.push({ id: `p${seq++}`, type: t.id, angle: norm((360 / n) * j + i * 37 + j * 11), value: t.value }); } out.push(objs); } return out; } /* --------------------------------------------------------------- drawing */ interface Palette { primary: string; secondary: string; glow: string; bg: string; surface: string; } function drawObject(ctx: CanvasRenderingContext2D, o: Obj, x: number, y: number, unit: number, t: number, palette: Palette) { const color = TYPE_COLORS[o.type] ?? palette.primary; const r = unit * (TYPE_SIZE[o.type] ?? 0.6) * o.scale; ctx.save(); ctx.globalAlpha = o.alpha; ctx.translate(x, y); if (o.flash > 0) { ctx.shadowColor = "#ffffff"; ctx.shadowBlur = 30 * o.flash; } else { ctx.shadowColor = color; ctx.shadowBlur = o.type === "quasar" || o.type === "comet" ? 18 : 8; } switch (o.type) { case "debris": { ctx.rotate(t * 0.8 + o.angle); ctx.fillStyle = mixHex(color, "#000000", 0.25); ctx.beginPath(); ctx.moveTo(-r, -r * 0.4); ctx.lineTo(-r * 0.2, -r); ctx.lineTo(r * 0.9, -r * 0.5); ctx.lineTo(r, r * 0.5); ctx.lineTo(r * 0.1, r); ctx.lineTo(-r * 0.8, r * 0.6); ctx.closePath(); ctx.fill(); ctx.strokeStyle = "rgba(255,255,255,0.35)"; ctx.lineWidth = 1; ctx.stroke(); break; } case "satellite": { ctx.rotate(rad(o.angle) + Math.PI / 2); ctx.fillStyle = "#1e3a8a"; ctx.fillRect(-r * 2.1, -r * 0.35, r * 1.3, r * 0.7); ctx.fillRect(r * 0.8, -r * 0.35, r * 1.3, r * 0.7); ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.strokeRect(-r * 2.1, -r * 0.35, r * 1.3, r * 0.7); ctx.strokeRect(r * 0.8, -r * 0.35, r * 1.3, r * 0.7); ctx.fillStyle = "#e5e7eb"; ctx.beginPath(); ctx.roundRect(-r * 0.7, -r * 0.6, r * 1.4, r * 1.2, 3); ctx.fill(); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(0, 0, r * 0.28, 0, Math.PI * 2); ctx.fill(); break; } case "planet": { const g = ctx.createRadialGradient(-r * 0.35, -r * 0.35, r * 0.1, 0, 0, r); g.addColorStop(0, "#d1fae5"); g.addColorStop(0.4, color); g.addColorStop(1, mixHex(color, "#000000", 0.6)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "rgba(255,255,255,0.35)"; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.ellipse(0, r * 0.15, r * 0.9, r * 0.28, -0.3, 0, Math.PI * 2); ctx.stroke(); break; } case "comet": { const dir = rad(o.angle) + Math.PI / 2; // tangent ctx.rotate(dir); const tail = ctx.createLinearGradient(0, 0, r * 4.2, 0); tail.addColorStop(0, rgba(color, 0.9)); tail.addColorStop(1, rgba(color, 0)); ctx.fillStyle = tail; ctx.beginPath(); ctx.moveTo(0, -r * 0.7); ctx.lineTo(r * 4.2, 0); ctx.lineTo(0, r * 0.7); ctx.closePath(); ctx.fill(); const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r); g.addColorStop(0, "#ffffff"); g.addColorStop(1, color); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, r * 0.75, 0, Math.PI * 2); ctx.fill(); break; } case "gasgiant": { const g = ctx.createRadialGradient(-r * 0.4, -r * 0.4, r * 0.1, 0, 0, r); g.addColorStop(0, "#fed7aa"); g.addColorStop(0.5, color); g.addColorStop(1, mixHex(color, "#000000", 0.6)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "rgba(0,0,0,0.25)"; ctx.lineWidth = r * 0.16; for (const k of [-0.45, 0, 0.45]) { ctx.beginPath(); ctx.ellipse(0, r * k, Math.sqrt(1 - k * k) * r, r * 0.08, 0, 0, Math.PI * 2); ctx.stroke(); } ctx.strokeStyle = rgba("#fde68a", 0.8); ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(0, 0, r * 1.75, r * 0.45, -0.45, 0, Math.PI * 2); ctx.stroke(); break; } case "quasar": { ctx.rotate(t * 1.6); ctx.strokeStyle = rgba(color, 0.9); ctx.lineWidth = 1.5; for (let i = 0; i < 6; i++) { ctx.rotate(Math.PI / 3); ctx.beginPath(); ctx.moveTo(0, -r * 0.4); ctx.lineTo(0, -r * 1.9); ctx.stroke(); } const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r); g.addColorStop(0, "#ffffff"); g.addColorStop(0.5, color); g.addColorStop(1, rgba(color, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); break; } default: { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); } } ctx.restore(); } function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: OrbitConfig, palette: Palette) { const { w, h, cx, cy, rmax, rc } = L; ctx.clearRect(0, 0, w, h); const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`; const unit = rmax * 0.06; // Star dust ctx.save(); ctx.fillStyle = "rgba(255,255,255,0.25)"; for (let i = 0; i < 40; i++) { const a = i * 2.399 + 0.3; const r = ((i * 97) % 100) / 100; const x = cx + Math.cos(a) * r * Math.max(w, h) * 0.7; const y = cy + Math.sin(a) * r * Math.max(w, h) * 0.7; const tw = 0.5 + 0.5 * Math.sin(s.t * (0.6 + (i % 5) * 0.2) + i); ctx.globalAlpha = 0.15 + 0.35 * tw; ctx.fillRect(x, y, 1.5, 1.5); } ctx.restore(); // Aim cone (idle) if (s.phase === "idle" && !s.reduceMotion) { ctx.save(); ctx.translate(cx, cy); const a = rad(s.aim); const half = (cfg.beamHalfWidth * Math.PI) / 180; const g = ctx.createLinearGradient(0, 0, Math.cos(a) * rmax, Math.sin(a) * rmax); g.addColorStop(0, rgba(palette.primary, 0.22)); g.addColorStop(1, rgba(palette.primary, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, rmax + 8, a - half, a + half); ctx.closePath(); ctx.fill(); ctx.setLineDash([4, 6]); ctx.strokeStyle = rgba(palette.glow, 0.7); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(Math.cos(a) * rc, Math.sin(a) * rc); ctx.lineTo(Math.cos(a) * (rmax + 8), Math.sin(a) * (rmax + 8)); ctx.stroke(); ctx.setLineDash([]); // handle ctx.fillStyle = palette.glow; ctx.shadowColor = palette.glow; ctx.shadowBlur = 16; ctx.beginPath(); ctx.arc(Math.cos(a) * (rmax + 14), Math.sin(a) * (rmax + 14), 7, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } else if (s.phase === "idle") { ctx.save(); ctx.translate(cx, cy); const a = rad(s.aim); ctx.strokeStyle = rgba(palette.glow, 0.7); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(Math.cos(a) * rc, Math.sin(a) * rc); ctx.lineTo(Math.cos(a) * (rmax + 8), Math.sin(a) * (rmax + 8)); ctx.stroke(); ctx.fillStyle = palette.glow; ctx.beginPath(); ctx.arc(Math.cos(a) * (rmax + 14), Math.sin(a) * (rmax + 14), 7, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } // Orbit rings for (const ring of s.rings) { if (ring.alpha <= 0) continue; ctx.save(); ctx.globalAlpha = ring.alpha; const flash = Math.max(0, 1 - (s.t - ring.flashAt) / 0.9); ctx.strokeStyle = rgba(palette.primary, 0.18 + 0.6 * flash); ctx.lineWidth = 1 + 2 * flash; ctx.shadowColor = palette.primary; ctx.shadowBlur = 18 * flash; ctx.beginPath(); ctx.arc(cx, cy, ring.radius, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } // Core const flare = s.coreFlare; const coreR = rc * (1 + 0.06 * Math.sin(s.t * 3)) * (1 + 0.6 * flare) + s.supernova * rmax * 1.3; ctx.save(); const halo = ctx.createRadialGradient(cx, cy, coreR * 0.5, cx, cy, coreR * 2.6); halo.addColorStop(0, rgba(palette.secondary, 0.45 + 0.4 * flare)); halo.addColorStop(1, rgba(palette.secondary, 0)); ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(cx, cy, coreR * 2.6, 0, Math.PI * 2); ctx.fill(); const core = ctx.createRadialGradient(cx - coreR * 0.2, cy - coreR * 0.2, coreR * 0.1, cx, cy, coreR); core.addColorStop(0, "#fff7ed"); core.addColorStop(0.45, palette.secondary); core.addColorStop(1, mixHex(palette.secondary, "#7c2d12", 0.7)); ctx.fillStyle = core; ctx.shadowColor = palette.secondary; ctx.shadowBlur = 30 + 60 * flare; ctx.beginPath(); ctx.arc(cx, cy, coreR, 0, Math.PI * 2); ctx.fill(); ctx.restore(); // Objects for (const ring of s.rings) { for (const o of ring.objs) { if (o.alpha <= 0) continue; const a = rad(o.angle + ring.rot); const r = lerp(ring.radius, 0, o.collapse); drawObject(ctx, o, cx + Math.cos(a) * r, cy + Math.sin(a) * r, unit, s.t, palette); } } // Impulse path if (s.path.length > 1 && s.pathAlpha > 0) { ctx.save(); ctx.globalAlpha = s.pathAlpha; ctx.strokeStyle = palette.glow; ctx.lineWidth = 3; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.shadowColor = palette.primary; ctx.shadowBlur = 18; ctx.beginPath(); ctx.moveTo(cx + s.path[0].x * rmax, cy + s.path[0].y * rmax); for (let i = 1; i < s.path.length; i++) ctx.lineTo(cx + s.path[i].x * rmax, cy + s.path[i].y * rmax); if (s.head) ctx.lineTo(cx + s.head.x * rmax, cy + s.head.y * rmax); ctx.stroke(); ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1; ctx.stroke(); if (s.head && s.phase === "firing") { ctx.fillStyle = "#ffffff"; ctx.shadowBlur = 24; ctx.beginPath(); ctx.arc(cx + s.head.x * rmax, cy + s.head.y * rmax, 5, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } // Ripples (misses) for (const rp of s.ripples) { const age = s.t - rp.at; if (age > 0.6) continue; const a = rad(rp.angle); ctx.save(); ctx.globalAlpha = 1 - age / 0.6; ctx.strokeStyle = "rgba(255,255,255,0.6)"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(cx + Math.cos(a) * rp.radius, cy + Math.sin(a) * rp.radius, 4 + 22 * easeOutCubic(age / 0.6), 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } // Particles for (const p of s.particles) { const k = p.life / p.max; ctx.globalAlpha = k; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * k), 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; // Pops for (const pop of s.pops) { const age = s.t - pop.at; const life = pop.big ? 1.8 : 1.2; if (age > life) continue; const k = age < 0.22 ? easeOutBack(age / 0.22) : 1; const fade = age > life - 0.4 ? (life - age) / 0.4 : 1; ctx.save(); ctx.globalAlpha = Math.max(0, fade); ctx.translate(pop.x, pop.y - (pop.big ? 0 : age * 22)); ctx.scale(k, k); ctx.font = font(pop.big ? Math.min(64, w * 0.15) : 15, 900); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.shadowColor = pop.color; ctx.shadowBlur = pop.big ? 40 : 12; ctx.fillStyle = pop.color; ctx.fillText(pop.text, 0, 0); ctx.restore(); } // Angle ticks ctx.save(); ctx.strokeStyle = "rgba(255,255,255,0.18)"; ctx.lineWidth = 1; for (let d = 0; d < 360; d += 30) { const a = rad(d); ctx.beginPath(); ctx.moveTo(cx + Math.cos(a) * (rmax + 18), cy + Math.sin(a) * (rmax + 18)); ctx.lineTo(cx + Math.cos(a) * (rmax + 24), cy + Math.sin(a) * (rmax + 24)); ctx.stroke(); } ctx.restore(); if (s.preview) { ctx.save(); ctx.font = font(11, 700); ctx.textAlign = "center"; ctx.textBaseline = "bottom"; ctx.fillStyle = "rgba(255,255,255,0.45)"; ctx.fillText("PREVIEW SYSTEM · FIRE TO GENERATE THE REAL ORBITS", w / 2, h - 6); ctx.restore(); } } /* ------------------------------------------------------------- component */ export function OrbitGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { const cfg = definition.config as unknown as OrbitConfig; const palette = definition.presentation.palette; const payScale = definition.payScale; const { play, error, clearError } = useInstantPlay(definition.slug); const [aim, setAim] = useState(0); const [phase, setPhase] = useState<"idle" | "firing">("idle"); const [collected, setCollected] = useState(0); const [live, setLive] = useState(null); const [result, setResult] = useState<{ win: number; multiplier: number; hits: number; supernova: number | null } | null>(null); const canvasRef = useRef(null); const sceneRef = useRef(null); const aliveRef = useRef(true); const phaseRef = useRef<"idle" | "firing">("idle"); const getScene = useCallback((): Scene => { if (!sceneRef.current) { const orbits = previewOrbits(cfg); sceneRef.current = { t: 0, 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 })), aim: 0, phase: "idle", frozen: false, preview: true, path: [], head: null, pathAlpha: 0, ripples: [], particles: [], pops: [], coreFlare: 0, supernova: 0, reduceMotion: false, }; } return sceneRef.current; }, [cfg]); useEffect(() => { aliveRef.current = true; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const s = getScene(); let raf = 0; let last = performance.now(); const loop = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000); last = now; s.t += dt; const dpr = Math.min(2, window.devicePixelRatio || 1); const rect = canvas.getBoundingClientRect(); const W = Math.max(1, Math.round(rect.width * dpr)); const H = Math.max(1, Math.round(rect.height * dpr)); if (canvas.width !== W || canvas.height !== H) { canvas.width = W; canvas.height = H; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const L = layoutFor(rect.width, rect.height); const targets = ringTargets(s.rings.length, L); s.rings.forEach((ring, i) => { ring.target = targets[i]; ring.radius = ring.radius === 0 ? ring.target : lerp(ring.radius, ring.target, Math.min(1, dt * 6)); if (!s.frozen && !s.reduceMotion) ring.rot += ring.speed * dt; }); for (let i = s.particles.length - 1; i >= 0; i--) { const p = s.particles[i]; p.life -= dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vx *= 0.985; p.vy *= 0.985; if (p.life <= 0) s.particles.splice(i, 1); } s.ripples = s.ripples.filter((r) => s.t - r.at < 0.7); s.pops = s.pops.filter((p) => s.t - p.at < 2); s.coreFlare = Math.max(0, s.coreFlare - dt * 1.4); drawScene(ctx, L, s, cfg, palette); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => { aliveRef.current = false; cancelAnimationFrame(raf); }; }, [cfg, palette, getScene]); useEffect(() => { const s = getScene(); s.aim = aim; s.reduceMotion = reduceMotion; }, [aim, reduceMotion, getScene]); const onPointer = useCallback( (e: React.PointerEvent) => { if (phaseRef.current !== "idle") return; if (e.type === "pointermove" && e.buttons === 0) return; const rect = e.currentTarget.getBoundingClientRect(); const L = layoutFor(rect.width, rect.height); const dx = e.clientX - rect.left - L.cx; const dy = e.clientY - rect.top - L.cy; if (Math.hypot(dx, dy) < L.rc * 0.6) return; const deg = Math.round(norm((Math.atan2(dy, dx) * 180) / Math.PI + 90)); setAim((prev) => { if (Math.abs(prev - deg) >= 3) sound("tick"); return deg; }); }, [sound], ); const burst = useCallback((s: Scene, x: number, y: number, n: number, color: string, speed: number) => { if (s.reduceMotion) return; for (let i = 0; i < n; i++) { const a = Math.random() * Math.PI * 2; const v = speed * (0.3 + Math.random() * 0.9); 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 }); } }, []); const animate = useCallback( async (outcome: OrbitOutcome) => { const s = getScene(); const alive = () => aliveRef.current; const rm = s.reduceMotion; const canvas = canvasRef.current; const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 500 }; const L = layoutFor(rect.width, rect.height); const orbits = outcome.summary.orbits; s.phase = "firing"; s.frozen = true; s.preview = false; s.particles = []; s.pops = []; s.ripples = []; s.path = []; s.head = null; s.pathAlpha = 0; s.supernova = 0; // Swap in the real system: old objects fade, new ones appear at the server's angles. const old = s.rings; await tween(rm ? 60 : 260, (t) => { for (const r of old) for (const o of r.objs) o.alpha = 1 - t; }, alive); const initial = Math.min(cfg.orbits, orbits.length); 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 })); for (const r of s.rings) for (const o of r.objs) o.alpha = 0; await tween(rm ? 60 : 260, (t) => { for (const r of s.rings) for (const o of r.objs) o.alpha = t; }, alive); // Impulse leaves the core. s.coreFlare = 1; sound("click"); const startA = rad(outcome.steps[0]?.angle ?? outcome.summary.angle ?? 0); const unitPt = (angleDeg: number, radiusPx: number) => ({ x: (Math.cos(rad(angleDeg)) * radiusPx) / L.rmax, y: (Math.sin(rad(angleDeg)) * radiusPx) / L.rmax }); s.path = [{ x: (Math.cos(startA) * L.rc) / L.rmax, y: (Math.sin(startA) * L.rc) / L.rmax }]; s.pathAlpha = 1; let spawnIndex = initial; let running = 0; for (let i = 0; i < outcome.steps.length; i++) { if (!alive()) return; const step = outcome.steps[i]; const ring = s.rings[step.orbit]; if (!ring) break; const from = s.path[s.path.length - 1]; const to = unitPt(step.angle, ring.target || ringTargets(s.rings.length, L)[step.orbit]); await tween(rm ? 50 : 240, (t) => { const k = easeInCubic(t) * 0.4 + t * 0.6; s.head = { x: lerp(from.x, to.x, k), y: lerp(from.y, to.y, k) }; }, alive); s.path.push(to); s.head = null; const hx = L.cx + to.x * L.rmax; const hy = L.cy + to.y * L.rmax; if (!step.hit) { s.ripples.push({ angle: step.angle, radius: ring.radius, at: s.t }); continue; } const obj = ring.objs.find((o) => o.id === step.hit?.id); const type = cfg.objectTypes.find((t) => t.id === step.hit?.type); const shownValue = obj?.value ?? Math.round(step.hit.value * payScale * 100) / 100; running += step.hit.value; const color = TYPE_COLORS[step.hit.type] ?? palette.primary; if (obj) { obj.hit = true; await tween(rm ? 40 : 180, (t) => { obj.flash = Math.sin(t * Math.PI); obj.scale = 1 + 0.5 * Math.sin(t * Math.PI); }, alive); burst(s, hx, hy, 16, color, 200); void tween(rm ? 40 : 240, (t) => { obj.alpha = 1 - t; obj.scale = 1 + t; }, alive); } s.pops.push({ x: hx, y: hy - 18, text: `+${formatMultiplier(shownValue)}`, color, at: s.t }); const runningScaled = Math.round(running * payScale * 100) / 100; setCollected(runningScaled); setLive(`${type?.label ?? step.hit.type} +${formatMultiplier(shownValue)}`); sound(step.hit.type === "gasgiant" || step.hit.type === "quasar" ? "bonus" : "tick"); if (step.deflectedTo !== undefined) { setLive(`${type?.label ?? "Object"} deflects the impulse → ${Math.round(step.deflectedTo)}°`); await wait(rm ? 40 : 160); } if (step.spawned && spawnIndex < orbits.length) { const objs = orbits[spawnIndex]; 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 }; for (const o of newRing.objs) o.alpha = 0; s.rings.push(newRing); spawnIndex++; sound("bonus"); setLive(`New orbit spawned · ${s.rings.length} orbits`); await tween(rm ? 60 : 420, (t) => { newRing.alpha = t; for (const o of newRing.objs) o.alpha = t; }, alive); } else { await wait(rm ? 30 : 120); } } if (!alive()) return; // Impulse leaves the system. const last = s.path[s.path.length - 1]; const dirLen = Math.hypot(last.x, last.y) || 1; const out = { x: (last.x / dirLen) * 1.25, y: (last.y / dirLen) * 1.25 }; await tween(rm ? 40 : 200, (t) => { s.head = { x: lerp(last.x, out.x, t), y: lerp(last.y, out.y, t) }; }, alive); s.path.push(out); s.head = null; void tween(rm ? 200 : 1400, (t) => (s.pathAlpha = 1 - t), alive); // Supernova const sn = outcome.summary.supernova; if (sn) { sound("bonus"); setLive(`SUPERNOVA · ${sn.remaining} survivors`); s.pops.push({ x: L.cx, y: L.cy - L.rmax * 0.55, text: "SUPERNOVA", color: palette.secondary, at: s.t, big: true }); await wait(rm ? 80 : 500); const survivors = s.rings.flatMap((r) => r.objs.filter((o) => !o.hit)); await tween(rm ? 120 : 900, (t) => { const k = easeInCubic(t); for (const o of survivors) { o.collapse = k; o.scale = 1 - 0.6 * k; } s.coreFlare = 1; }, alive); for (const o of survivors) o.alpha = 0; burst(s, L.cx, L.cy, 60, "#ffffff", 420); sound("bigWin"); await tween(rm ? 100 : 700, (t) => { s.supernova = Math.sin(t * Math.PI) * 0.9; s.coreFlare = 1; }, alive); s.supernova = 0; s.pops.push({ x: L.cx, y: L.cy, text: `×${sn.multiplier.toFixed(1).replace(/\.0$/, "")}`, color: "#ffd66b", at: s.t, big: true }); setCollected(Math.round(running * sn.multiplier * payScale * 100) / 100); await wait(rm ? 100 : 900); } if (!alive()) return; setCollected(outcome.multiplier); setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, hits: outcome.summary.hits, supernova: sn ? sn.multiplier : null }); if (outcome.totalWin <= 0) sound("lose"); else if (outcome.multiplier >= 15) sound("bigWin"); else sound("win"); onResult({ win: outcome.totalWin, multiplier: outcome.multiplier }); s.phase = "idle"; s.frozen = false; }, [cfg, palette, payScale, getScene, sound, onResult, burst], ); const fire = useCallback(async () => { if (phaseRef.current !== "idle") return; phaseRef.current = "firing"; setPhase("firing"); setResult(null); setLive(null); setCollected(0); onBusy(true); sound("click"); const res = await play(bet, { angle: aim }); if (res && aliveRef.current) await animate(res.outcome); phaseRef.current = "idle"; if (aliveRef.current) { setPhase("idle"); const s = sceneRef.current; if (s) { s.phase = "idle"; s.frozen = false; } } onBusy(false); }, [play, bet, aim, animate, onBusy, sound]); const firing = phase === "firing"; const nudge = (d: number) => { sound("tick"); setAim((a) => norm(a + d)); }; return (
{/* Aim + collected */}
Aim {aim}°
{firing ? "Collected" : "Round"}
0 ? "text-credit" : "text-fg-3")}>{collected > 0 ? formatMultiplier(collected) : "—"}
{/* System */}
{live && firing ? ( {live} ) : null} {result && !firing ? ( 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "The impulse missed everything"} {result.hits} hit{result.hits === 1 ? "" : "s"} {result.supernova ? ` · supernova ×${result.supernova.toFixed(1).replace(/\.0$/, "")}` : ""} ) : null} {error ? (
{error}
) : null}
{/* Action */}
); }