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.9 KB · 904 lines tsx
Raw Blame History
1"use client";23/**4 * DROPZONE — browser side. The server resolves the whole descent; this file5 * only animates `outcome.steps` (half-lane x per row, gates, portals), the6 * landing bucket and the optional Deep Drop extension on a 2D canvas.7 */8import { useCallback, useEffect, useMemo, useRef, useState } from "react";9import { AnimatePresence, motion } from "framer-motion";10import { ChevronLeft, ChevronRight } from "lucide-react";11import { formatMultiplier, formatSC } from "@spinza/shared";12import type { ArcadeGameDefinition, ArcadeOutcome, DropStep, DropzoneConfig } from "@spinza/game-core/client";13import { cn } from "@/lib/utils";14import { useInstantPlay, type ArcadeGameProps } from "./contract";1516type Risk = "low" | "medium" | "high";1718type DropSummary = {19  risk: Risk;20  startLane: number;21  bucket: number;22  bucketValue: number;23  gateMult: number;24  deep: { steps: DropStep[]; bucket: number; value: number } | null;25  table: number[];26};2728interface DropOutcome extends ArcadeOutcome {29  steps: DropStep[];30  summary: DropSummary;31}3233/* ------------------------------------------------------------------ math */34/* Pure replica of the server's displayed bucket values (see game-core/arcade/dropzone.ts). */3536function bucketDistribution(cfg: DropzoneConfig, startLane: number): number[] {37  const width = cfg.lanes * 2;38  let dist: number[] = new Array<number>(width).fill(0);39  dist[startLane * 2 + 1] = 1;40  for (let row = 0; row < cfg.rows; row++) {41    const next: number[] = new Array<number>(width).fill(0);42    for (let x = 0; x < width; x++) {43      if (!dist[x]) continue;44      next[Math.max(0, x - 1)] += dist[x] / 2;45      next[Math.min(width - 1, x + 1)] += dist[x] / 2;46    }47    dist = next;48  }49  const buckets: number[] = new Array<number>(cfg.lanes).fill(0);50  for (let x = 0; x < width; x++) buckets[Math.min(cfg.lanes - 1, Math.floor(x / 2))] += dist[x];51  return buckets;52}5354function laneNormalizer(cfg: DropzoneConfig, risk: Risk, startLane: number): number {55  const table = cfg.buckets[risk];56  const center = Math.floor(cfg.lanes / 2);57  const ev = (lane: number) => bucketDistribution(cfg, lane).reduce((a, p, k) => a + p * table[k], 0);58  return ev(center) / ev(startLane);59}6061function displayedBuckets(def: ArcadeGameDefinition, cfg: DropzoneConfig, risk: Risk, startLane: number): number[] {62  const norm = laneNormalizer(cfg, risk, startLane);63  return cfg.buckets[risk].map((v) => Math.round(v * norm * def.payScale * 100) / 100);64}6566/* --------------------------------------------------------------- helpers */6768const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));69const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);70const easeInQuad = (t: number) => t * t;71const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);72const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));73const lerp = (a: number, b: number, t: number) => a + (b - a) * t;7475function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> {76  return new Promise((resolve) => {77    const start = performance.now();78    const frame = (now: number) => {79      if (!alive()) return resolve();80      const t = Math.min(1, (now - start) / Math.max(1, ms));81      fn(t);82      if (t < 1) requestAnimationFrame(frame);83      else resolve();84    };85    requestAnimationFrame(frame);86  });87}8889function rgba(hex: string, a: number): string {90  const h = hex.replace("#", "");91  const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);92  return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;93}9495function mixHex(a: string, b: string, t: number): string {96  const pa = parseInt(a.replace("#", ""), 16);97  const pb = parseInt(b.replace("#", ""), 16);98  const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t));99  return `rgb(${ch(16)},${ch(8)},${ch(0)})`;100}101102/* ----------------------------------------------------------------- scene */103104interface Particle {105  x: number;106  y: number;107  vx: number;108  vy: number;109  life: number;110  max: number;111  color: string;112  size: number;113}114115interface Capsule {116  x: number; // half-lane units117  y: number; // row units (0 = release line, r+1 = after row r)118  deep: boolean;119  visible: boolean;120  alpha: number;121  squash: number; // 1 = round122  glow: number;123}124125interface Scene {126  t: number;127  lane: number;128  risk: Risk;129  values: number[];130  phase: "idle" | "dropping";131  camY: number; // in row units132  capsule: Capsule;133  trail: { x: number; y: number; deep: boolean; life: number }[];134  gates: { x: number; y: number; value: number; at: number }[];135  portals: { x: number; to: number; y: number; at: number }[];136  landed: number | null;137  landedAt: number;138  deepReveal: number;139  deepLanded: number | null;140  deepLandedAt: number;141  particles: Particle[];142  flash: { text: string; at: number; color: string } | null;143  reduceMotion: boolean;144}145146const MARKER_UNITS = 1.2;147const BUCKET_UNITS = 1.5;148const DEEP_GAP_UNITS = 0.9;149150interface Layout {151  w: number;152  h: number;153  pad: number;154  hl: number;155  rowH: number;156  left: number;157  deepLeft: number;158  deepStart: number; // row units where the deep release line sits159  totalDeepUnits: number;160}161162function layoutFor(w: number, h: number, cfg: DropzoneConfig): Layout {163  const mainUnits = MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.5;164  let hl = (Math.min(w, 620) - 20) / (cfg.lanes * 2);165  let rowH = Math.min((h - 20) / mainUnits, hl * 2.1);166  if (rowH < hl * 1.05) {167    // very short viewport: shrink horizontally to keep pegs readable168    hl = rowH / 1.05;169    rowH = hl * 1.05;170  }171  // Centre the tower vertically when the viewport is taller than needed (tall phones).172  const pad = Math.max(10, (h - mainUnits * rowH) / 2);173  const left = (w - hl * cfg.lanes * 2) / 2;174  const deepLeft = left + ((cfg.lanes * 2 - cfg.deepBuckets.length * 2) / 2) * hl;175  const deepStart = cfg.rows + BUCKET_UNITS + DEEP_GAP_UNITS;176  return { w, h, pad, hl, rowH, left, deepLeft, deepStart, totalDeepUnits: deepStart + cfg.deepRows + BUCKET_UNITS };177}178179function toPx(L: Layout, s: Scene, x: number, y: number, deep: boolean): { px: number; py: number } {180  const px = (deep ? L.deepLeft : L.left) + (x + 0.5) * L.hl;181  const py = L.pad + (MARKER_UNITS + (deep ? L.deepStart + y : y) - s.camY) * L.rowH;182  return { px, py };183}184185function heat(v: number, max: number): number {186  if (max <= 0) return 0;187  return clamp(Math.log1p(v) / Math.log1p(max), 0, 1);188}189190/* --------------------------------------------------------------- drawing */191192interface Palette {193  primary: string;194  secondary: string;195  glow: string;196  bg: string;197  surface: string;198}199200function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: DropzoneConfig, palette: Palette) {201  const { w, h, hl, rowH } = L;202  ctx.clearRect(0, 0, w, h);203  const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`;204205  // Tower glass backdrop206  const towerX = L.left - hl * 0.6;207  const towerW = hl * cfg.lanes * 2 + hl * 1.2;208  const towerTop = L.pad + (MARKER_UNITS - 0.6 - s.camY) * rowH;209  const towerBottom = L.pad + (MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.15 - s.camY) * rowH;210  const bg = ctx.createLinearGradient(0, towerTop, 0, towerBottom);211  bg.addColorStop(0, "rgba(255,255,255,0.035)");212  bg.addColorStop(1, "rgba(255,255,255,0.012)");213  ctx.fillStyle = bg;214  ctx.beginPath();215  ctx.roundRect(towerX, towerTop, towerW, towerBottom - towerTop, 18);216  ctx.fill();217  ctx.strokeStyle = rgba(palette.primary, 0.18);218  ctx.lineWidth = 1;219  ctx.stroke();220221  // Side rails222  ctx.strokeStyle = rgba(palette.primary, 0.35);223  ctx.lineWidth = 2;224  ctx.beginPath();225  ctx.moveTo(towerX, towerTop + 14);226  ctx.lineTo(towerX, towerBottom - 14);227  ctx.moveTo(towerX + towerW, towerTop + 14);228  ctx.lineTo(towerX + towerW, towerBottom - 14);229  ctx.stroke();230231  // Pegs (main)232  const pegR = Math.max(2, hl * 0.17);233  for (let r = 0; r < cfg.rows; r++) {234    const parity = r % 2 === 0 ? 1 : 0;235    const y = L.pad + (MARKER_UNITS + r + 0.5 - s.camY) * rowH;236    if (y < -10 || y > h + 10) continue;237    for (let x = parity; x < cfg.lanes * 2; x += 2) {238      const px = L.left + (x + 0.5) * hl;239      ctx.beginPath();240      ctx.arc(px, y, pegR, 0, Math.PI * 2);241      ctx.fillStyle = "rgba(255,255,255,0.55)";242      ctx.fill();243      ctx.beginPath();244      ctx.arc(px, y, pegR * 2.2, 0, Math.PI * 2);245      ctx.fillStyle = rgba(palette.primary, 0.08);246      ctx.fill();247    }248  }249250  // Buckets (main)251  const maxV = Math.max(...s.values, 1);252  const bTop = L.pad + (MARKER_UNITS + cfg.rows + 0.15 - s.camY) * rowH;253  const bH = (BUCKET_UNITS - 0.3) * rowH;254  for (let k = 0; k < cfg.lanes; k++) {255    const x0 = L.left + k * 2 * hl + 1.5;256    const bw = 2 * hl - 3;257    const v = s.values[k] ?? 0;258    const t = heat(v, maxV);259    const base = v >= 50 ? "#ffd66b" : mixHex(palette.primary, palette.secondary, t);260    const lit = s.landed === k;261    const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.landedAt) * 8) : 0;262    ctx.beginPath();263    ctx.roundRect(x0, bTop, bw, bH, Math.min(6, hl * 0.35));264    ctx.fillStyle = lit ? rgba(base.startsWith("#") ? base : palette.glow, 0.55 + 0.35 * pulse) : rgba("#ffffff", 0.05 + t * 0.06);265    ctx.fill();266    ctx.strokeStyle = lit ? "#ffffff" : base.startsWith("#") ? rgba(base, 0.35 + t * 0.4) : base;267    ctx.lineWidth = lit ? 2 : 1;268    ctx.stroke();269    // Value270    const label = v > 0 && v < 0.01 ? "<0.01×" : formatMultiplier(v);271    const size = Math.max(8, Math.min(13, hl * 0.72));272    ctx.font = font(size, 800);273    ctx.textAlign = "center";274    ctx.textBaseline = "middle";275    ctx.fillStyle = lit ? "#0b1020" : base.startsWith("#") ? base : mixHex(palette.primary, palette.secondary, t);276    ctx.fillText(label, x0 + bw / 2, bTop + bH / 2, bw - 2);277  }278279  // Deep section280  if (s.deepReveal > 0) {281    ctx.save();282    ctx.globalAlpha = s.deepReveal;283    const dTop = L.pad + (MARKER_UNITS + L.deepStart - 0.6 - s.camY) * rowH;284    const dBottom = L.pad + (MARKER_UNITS + L.totalDeepUnits + 0.15 - s.camY) * rowH;285    const dX = L.deepLeft - hl * 0.6;286    const dW = hl * cfg.deepBuckets.length * 2 + hl * 1.2;287    const dg = ctx.createLinearGradient(0, dTop, 0, dBottom);288    dg.addColorStop(0, rgba(palette.secondary, 0.12));289    dg.addColorStop(1, rgba(palette.secondary, 0.03));290    ctx.fillStyle = dg;291    ctx.beginPath();292    ctx.roundRect(dX, dTop, dW, dBottom - dTop, 18);293    ctx.fill();294    ctx.strokeStyle = rgba(palette.secondary, 0.4);295    ctx.lineWidth = 1.5;296    ctx.stroke();297    // Funnel between main buckets and deep tower298    ctx.strokeStyle = rgba(palette.secondary, 0.5);299    ctx.setLineDash([4, 6]);300    ctx.beginPath();301    ctx.moveTo(towerX + 8, towerBottom);302    ctx.lineTo(dX + 8, dTop);303    ctx.moveTo(towerX + towerW - 8, towerBottom);304    ctx.lineTo(dX + dW - 8, dTop);305    ctx.stroke();306    ctx.setLineDash([]);307    ctx.font = font(11, 800);308    ctx.textAlign = "center";309    ctx.textBaseline = "middle";310    ctx.fillStyle = palette.secondary;311    ctx.fillText("DEEP DROP", w / 2, dTop + 14);312    for (let r = 0; r < cfg.deepRows; r++) {313      const parity = r % 2 === 0 ? 1 : 0;314      const y = L.pad + (MARKER_UNITS + L.deepStart + r + 0.5 - s.camY) * rowH;315      if (y < -10 || y > h + 10) continue;316      for (let x = parity; x < cfg.deepBuckets.length * 2; x += 2) {317        const px = L.deepLeft + (x + 0.5) * hl;318        ctx.beginPath();319        ctx.arc(px, y, pegR, 0, Math.PI * 2);320        ctx.fillStyle = rgba(palette.secondary, 0.8);321        ctx.fill();322      }323    }324    const dbTop = L.pad + (MARKER_UNITS + L.deepStart + cfg.deepRows + 0.15 - s.camY) * rowH;325    for (let k = 0; k < cfg.deepBuckets.length; k++) {326      const x0 = L.deepLeft + k * 2 * hl + 1.5;327      const bw = 2 * hl - 3;328      const v = cfg.deepBuckets[k];329      const lit = s.deepLanded === k;330      const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.deepLandedAt) * 8) : 0;331      const col = v === 0 ? "#ff5c7a" : v >= 10 ? "#ffd66b" : palette.secondary;332      ctx.beginPath();333      ctx.roundRect(x0, dbTop, bw, bH, Math.min(6, hl * 0.35));334      ctx.fillStyle = lit ? rgba(col, 0.55 + 0.35 * pulse) : rgba(col, 0.1);335      ctx.fill();336      ctx.strokeStyle = lit ? "#ffffff" : rgba(col, 0.5);337      ctx.lineWidth = lit ? 2 : 1;338      ctx.stroke();339      ctx.font = font(Math.max(9, Math.min(13, hl * 0.78)), 800);340      ctx.fillStyle = lit ? "#0b1020" : col;341      ctx.fillText(`×${v}`, x0 + bw / 2, dbTop + bH / 2, bw - 2);342    }343    ctx.restore();344  }345346  // Gate rings347  for (const g of s.gates) {348    const age = s.t - g.at;349    const { px, py } = toPx(L, s, g.x, g.y, false);350    const k = Math.max(0, 1 - age / 1.6);351    ctx.save();352    ctx.globalAlpha = 0.35 + 0.65 * k;353    ctx.strokeStyle = palette.secondary;354    ctx.lineWidth = 2 + 2 * k;355    ctx.shadowColor = palette.secondary;356    ctx.shadowBlur = 16 * k;357    ctx.beginPath();358    ctx.ellipse(px, py, hl * (1.05 + 0.4 * Math.min(1, age * 3)), hl * 0.42, 0, 0, Math.PI * 2);359    ctx.stroke();360    ctx.shadowBlur = 0;361    ctx.font = font(Math.max(11, hl * 0.95), 900);362    ctx.textAlign = "center";363    ctx.textBaseline = "middle";364    ctx.fillStyle = "#ffffff";365    const rise = Math.min(1, age * 1.4);366    ctx.fillText(`×${g.value}`, px, py - hl * 1.1 - rise * hl * 0.8);367    ctx.restore();368  }369370  // Portals371  for (const p of s.portals) {372    const age = s.t - p.at;373    const k = Math.max(0, 1 - age / 1.8);374    const a = toPx(L, s, p.x, p.y, false);375    const b = toPx(L, s, p.to, p.y, false);376    ctx.save();377    ctx.globalAlpha = 0.25 + 0.75 * k;378    ctx.strokeStyle = "#a78bfa";379    ctx.lineWidth = 1.5;380    ctx.setLineDash([3, 4]);381    ctx.beginPath();382    ctx.moveTo(a.px, a.py);383    ctx.lineTo(b.px, b.py);384    ctx.stroke();385    ctx.setLineDash([]);386    for (const q of [a, b]) {387      ctx.beginPath();388      ctx.ellipse(q.px, q.py, hl * 0.75, hl * 0.35, 0, 0, Math.PI * 2);389      ctx.strokeStyle = "#c4b5fd";390      ctx.lineWidth = 2;391      ctx.shadowColor = "#a78bfa";392      ctx.shadowBlur = 12 * k;393      ctx.stroke();394    }395    ctx.restore();396  }397398  // Trail399  for (const tr of s.trail) {400    const { px, py } = toPx(L, s, tr.x, tr.y, tr.deep);401    ctx.beginPath();402    ctx.arc(px, py, hl * 0.32 * tr.life, 0, Math.PI * 2);403    ctx.fillStyle = rgba(palette.glow, 0.25 * tr.life);404    ctx.fill();405  }406407  // Release marker (idle)408  if (s.phase === "idle") {409    const { px, py } = toPx(L, s, s.lane * 2 + 1, -0.75, false);410    const bob = Math.sin(s.t * 2.2) * 2;411    ctx.save();412    ctx.strokeStyle = rgba(palette.primary, 0.35);413    ctx.setLineDash([2, 6]);414    ctx.beginPath();415    ctx.moveTo(px, py + hl * 0.6);416    ctx.lineTo(px, L.pad + (MARKER_UNITS + cfg.rows - s.camY) * rowH);417    ctx.stroke();418    ctx.setLineDash([]);419    ctx.fillStyle = rgba(palette.primary, 0.9);420    ctx.beginPath();421    ctx.moveTo(px - hl * 0.7, py - hl * 0.35 + bob);422    ctx.lineTo(px + hl * 0.7, py - hl * 0.35 + bob);423    ctx.lineTo(px, py + hl * 0.45 + bob);424    ctx.closePath();425    ctx.fill();426    ctx.restore();427  }428429  // Capsule430  if (s.capsule.visible && s.capsule.alpha > 0) {431    const c = s.capsule;432    const { px, py } = toPx(L, s, c.x, c.y, c.deep);433    const r = hl * 0.42;434    ctx.save();435    ctx.globalAlpha = c.alpha;436    ctx.translate(px, py);437    ctx.scale(1 / Math.sqrt(c.squash), Math.sqrt(c.squash));438    ctx.shadowColor = palette.glow;439    ctx.shadowBlur = 14 + 22 * c.glow;440    const grad = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.1, 0, 0, r);441    grad.addColorStop(0, "#ffffff");442    grad.addColorStop(0.35, palette.glow);443    grad.addColorStop(1, palette.primary);444    ctx.fillStyle = grad;445    ctx.beginPath();446    ctx.arc(0, 0, r, 0, Math.PI * 2);447    ctx.fill();448    ctx.shadowBlur = 0;449    ctx.strokeStyle = "rgba(255,255,255,0.75)";450    ctx.lineWidth = 1.2;451    ctx.stroke();452    ctx.restore();453  }454455  // Particles456  for (const p of s.particles) {457    const k = p.life / p.max;458    ctx.globalAlpha = k;459    ctx.fillStyle = p.color;460    ctx.beginPath();461    ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * k), 0, Math.PI * 2);462    ctx.fill();463  }464  ctx.globalAlpha = 1;465466  // Centre flash text (deep multiplier etc.)467  if (s.flash) {468    const age = s.t - s.flash.at;469    if (age < 1.6) {470      const k = age < 0.25 ? easeOutBack(age / 0.25) : 1;471      const fade = age > 1.1 ? 1 - (age - 1.1) / 0.5 : 1;472      ctx.save();473      ctx.globalAlpha = Math.max(0, fade);474      ctx.translate(w / 2, h * 0.42);475      ctx.scale(k, k);476      ctx.font = font(Math.min(54, w * 0.13), 900);477      ctx.textAlign = "center";478      ctx.textBaseline = "middle";479      ctx.shadowColor = s.flash.color;480      ctx.shadowBlur = 30;481      ctx.fillStyle = s.flash.color;482      ctx.fillText(s.flash.text, 0, 0);483      ctx.restore();484    }485  }486}487488/* ------------------------------------------------------------- component */489490const RISKS: { id: Risk; label: string }[] = [491  { id: "low", label: "Low" },492  { id: "medium", label: "Medium" },493  { id: "high", label: "High" },494];495496export function DropzoneGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {497  const cfg = definition.config as unknown as DropzoneConfig;498  const palette = definition.presentation.palette;499  const { play, error, clearError } = useInstantPlay<DropOutcome>(definition.slug);500  const [risk, setRisk] = useState<Risk>("medium");501  const [lane, setLane] = useState(Math.floor(cfg.lanes / 2));502  const [phase, setPhase] = useState<"idle" | "dropping">("idle");503  const [live, setLive] = useState<string | null>(null);504  const [result, setResult] = useState<{ win: number; multiplier: number; parts: string } | null>(null);505  const canvasRef = useRef<HTMLCanvasElement>(null);506  const sceneRef = useRef<Scene | null>(null);507  const aliveRef = useRef(true);508  const phaseRef = useRef<"idle" | "dropping">("idle");509  const values = useMemo(() => displayedBuckets(definition, cfg, risk, lane), [definition, cfg, risk, lane]);510511  const getScene = useCallback((): Scene => {512    if (!sceneRef.current) {513      const mid = Math.floor(cfg.lanes / 2);514      sceneRef.current = {515        t: 0,516        lane: mid,517        risk: "medium",518        values: displayedBuckets(definition, cfg, "medium", mid),519        phase: "idle",520        camY: 0,521        capsule: { x: mid * 2 + 1, y: 0, deep: false, visible: false, alpha: 1, squash: 1, glow: 0 },522        trail: [],523        gates: [],524        portals: [],525        landed: null,526        landedAt: 0,527        deepReveal: 0,528        deepLanded: null,529        deepLandedAt: 0,530        particles: [],531        flash: null,532        reduceMotion: false,533      };534    }535    return sceneRef.current;536  }, [definition, cfg]);537538  /* Render loop */539  useEffect(() => {540    aliveRef.current = true;541    const canvas = canvasRef.current;542    if (!canvas) return;543    const ctx = canvas.getContext("2d");544    if (!ctx) return;545    const s = getScene();546    let raf = 0;547    let last = performance.now();548    const loop = (now: number) => {549      const dt = Math.min(0.05, (now - last) / 1000);550      last = now;551      s.t += dt;552      const dpr = Math.min(2, window.devicePixelRatio || 1);553      const rect = canvas.getBoundingClientRect();554      const W = Math.max(1, Math.round(rect.width * dpr));555      const H = Math.max(1, Math.round(rect.height * dpr));556      if (canvas.width !== W || canvas.height !== H) {557        canvas.width = W;558        canvas.height = H;559      }560      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);561      const L = layoutFor(rect.width, rect.height, cfg);562      // particles563      for (let i = s.particles.length - 1; i >= 0; i--) {564        const p = s.particles[i];565        p.life -= dt;566        p.x += p.vx * dt;567        p.y += p.vy * dt;568        p.vy += 420 * dt;569        p.vx *= 0.98;570        if (p.life <= 0) s.particles.splice(i, 1);571      }572      for (let i = s.trail.length - 1; i >= 0; i--) {573        s.trail[i].life -= dt * 3.2;574        if (s.trail[i].life <= 0) s.trail.splice(i, 1);575      }576      if (s.phase === "dropping" && s.capsule.visible && !s.reduceMotion) s.trail.push({ x: s.capsule.x, y: s.capsule.y, deep: s.capsule.deep, life: 1 });577      s.capsule.glow = Math.max(0, s.capsule.glow - dt * 1.5);578      drawScene(ctx, L, s, cfg, palette);579      raf = requestAnimationFrame(loop);580    };581    raf = requestAnimationFrame(loop);582    return () => {583      aliveRef.current = false;584      cancelAnimationFrame(raf);585    };586  }, [cfg, palette, getScene]);587588  /* Sync UI state into the scene */589  useEffect(() => {590    const s = getScene();591    s.lane = lane;592    s.risk = risk;593    s.values = values;594    s.reduceMotion = reduceMotion;595    if (s.phase === "idle") {596      s.capsule.x = lane * 2 + 1;597    }598  }, [lane, risk, values, reduceMotion, getScene]);599600  const laneFromPointer = useCallback(601    (e: React.PointerEvent<HTMLCanvasElement>) => {602      const rect = e.currentTarget.getBoundingClientRect();603      const L = layoutFor(rect.width, rect.height, cfg);604      const x = e.clientX - rect.left;605      return clamp(Math.floor((x - L.left) / (L.hl * 2)), 0, cfg.lanes - 1);606    },607    [cfg],608  );609610  const onPointer = useCallback(611    (e: React.PointerEvent<HTMLCanvasElement>) => {612      if (phaseRef.current !== "idle") return;613      if (e.type === "pointermove" && e.buttons === 0) return;614      const l = laneFromPointer(e);615      setLane((prev) => {616        if (prev !== l) sound("tick");617        return l;618      });619    },620    [laneFromPointer, sound],621  );622623  const spawnBurst = useCallback((s: Scene, L: Layout, x: number, y: number, deep: boolean, n: number, color: string, speed: number) => {624    if (s.reduceMotion) return;625    const { px, py } = toPx(L, s, x, y, deep);626    for (let i = 0; i < n; i++) {627      const a = Math.random() * Math.PI * 2;628      const v = speed * (0.4 + Math.random() * 0.8);629      s.particles.push({ x: px, y: py, vx: Math.cos(a) * v, vy: Math.sin(a) * v - speed * 0.4, life: 0.35 + Math.random() * 0.4, max: 0.75, color, size: 1.5 + Math.random() * 2.2 });630    }631  }, []);632633  const animate = useCallback(634    async (outcome: DropOutcome) => {635      const s = getScene();636      const canvas = canvasRef.current;637      const alive = () => aliveRef.current;638      const rm = s.reduceMotion;639      const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 600 };640      const L = () => layoutFor(rect.width, rect.height, cfg);641      const rowMs = rm ? 45 : 150;642643      // reset644      s.phase = "dropping";645      s.gates = [];646      s.portals = [];647      s.landed = null;648      s.deepLanded = null;649      s.deepReveal = 0;650      s.flash = null;651      s.particles = [];652      s.trail = [];653      s.values = outcome.summary.table;654      if (s.camY > 0) await tween(rm ? 60 : 320, (t) => (s.camY = lerp(s.camY, 0, easeOutCubic(t))), alive);655      s.camY = 0;656      const cap = s.capsule;657      cap.deep = false;658      cap.x = outcome.summary.startLane * 2 + 1;659      cap.y = -0.75;660      cap.alpha = 1;661      cap.squash = 1;662      cap.visible = true;663      cap.glow = 1;664      await tween(rm ? 60 : 220, (t) => (cap.y = lerp(-0.75, 0, easeInQuad(t))), alive);665666      let gateMult = 1;667      const runSteps = async (steps: DropStep[], deep: boolean) => {668        for (let i = 0; i < steps.length; i++) {669          if (!alive()) return;670          const step = steps[i];671          const fromX = cap.x;672          const toX = step.x;673          const r = step.row;674          await tween(rowMs, (t) => {675            if (t < 0.5) {676              cap.y = lerp(r, r + 0.5, easeInQuad(t / 0.5));677              cap.squash = 1;678            } else {679              const k = (t - 0.5) / 0.5;680              cap.x = lerp(fromX, toX, easeOutCubic(k));681              cap.y = lerp(r + 0.5, r + 1, k);682              cap.squash = 1 + 0.45 * Math.sin(k * Math.PI) * (k < 0.5 ? 1 : 0.4);683            }684          }, alive);685          spawnBurst(s, L(), fromX, r + 0.5, deep, 3, "#ffffff", 90);686          if (i % 2 === 0) sound("tick");687          if (!deep && step.event?.type === "gate") {688            gateMult *= step.event.value;689            s.gates.push({ x: toX, y: r + 1, value: step.event.value, at: s.t });690            cap.glow = 1;691            spawnBurst(s, L(), toX, r + 1, false, 14, palette.secondary, 160);692            sound("bonus");693            setLive(`Gate ×${step.event.value} · total ×${gateMult}`);694            await wait(rm ? 80 : 260);695          } else if (!deep && step.event?.type === "portal") {696            const to = step.event.to;697            s.portals.push({ x: toX, to, y: r + 1, at: s.t });698            sound("bonus");699            setLive(`Portal → lane ${Math.floor(to / 2) + 1}`);700            await tween(rm ? 40 : 160, (t) => (cap.alpha = 1 - t), alive);701            spawnBurst(s, L(), toX, r + 1, false, 10, "#a78bfa", 120);702            cap.x = to;703            spawnBurst(s, L(), to, r + 1, false, 10, "#a78bfa", 120);704            await tween(rm ? 40 : 160, (t) => (cap.alpha = t), alive);705            cap.alpha = 1;706          }707        }708      };709710      await runSteps(outcome.steps, false);711      if (!alive()) return;712713      // Landing in the main bucket714      const bucket = outcome.summary.bucket;715      const fromX = cap.x;716      await tween(rm ? 60 : 260, (t) => {717        cap.x = lerp(fromX, bucket * 2 + 1, easeOutCubic(t));718        cap.y = lerp(cfg.rows, cfg.rows + 0.85, easeOutBack(t));719        cap.squash = 1 + 0.3 * Math.sin(t * Math.PI);720      }, alive);721      s.landed = bucket;722      s.landedAt = s.t;723      cap.glow = 1;724      spawnBurst(s, L(), bucket * 2 + 1, cfg.rows + 0.85, false, 18, palette.glow, 180);725      const bucketValue = outcome.summary.bucketValue;726      setLive(gateMult > 1 ? `${formatMultiplier(bucketValue)} bucket × gates ×${gateMult}` : `${formatMultiplier(bucketValue)} bucket`);727      sound(bucketValue * gateMult >= 1 ? "win" : "tick");728729      // Deep Drop730      const deep = outcome.summary.deep;731      if (deep) {732        await wait(rm ? 80 : 420);733        sound("bonus");734        s.flash = { text: "DEEP DROP", at: s.t, color: palette.secondary };735        const Ld = L();736        const visibleUnits = (Ld.h - Ld.pad * 2) / Ld.rowH - MARKER_UNITS;737        const targetCam = Math.max(0, Ld.totalDeepUnits + 0.4 - visibleUnits);738        const deepStartX = clamp(bucket * 2 + 1, 0, cfg.deepBuckets.length * 2 - 1);739        await tween(rm ? 120 : 800, (t) => {740          const k = easeOutCubic(t);741          s.deepReveal = Math.min(1, t * 1.6);742          s.camY = targetCam * k;743        }, alive);744        // capsule falls through the bucket floor into the deep release line745        await tween(rm ? 60 : 360, (t) => {746          cap.alpha = t < 0.5 ? 1 - t * 2 : (t - 0.5) * 2;747          if (t >= 0.5 && !cap.deep) {748            cap.deep = true;749            cap.x = deepStartX;750            cap.y = -0.6;751          }752          if (t >= 0.5) cap.y = lerp(-0.6, 0, (t - 0.5) * 2);753        }, alive);754        cap.alpha = 1;755        cap.deep = true;756        cap.x = deepStartX;757        cap.y = 0;758        await runSteps(deep.steps, true);759        if (!alive()) return;760        const fx = cap.x;761        await tween(rm ? 60 : 260, (t) => {762          cap.x = lerp(fx, deep.bucket * 2 + 1, easeOutCubic(t));763          cap.y = lerp(cfg.deepRows, cfg.deepRows + 0.85, easeOutBack(t));764          cap.squash = 1 + 0.3 * Math.sin(t * Math.PI);765        }, alive);766        s.deepLanded = deep.bucket;767        s.deepLandedAt = s.t;768        const col = deep.value === 0 ? "#ff5c7a" : deep.value >= 10 ? "#ffd66b" : palette.secondary;769        s.flash = { text: `×${deep.value}`, at: s.t, color: col };770        spawnBurst(s, L(), deep.bucket * 2 + 1, cfg.deepRows + 0.85, true, 24, col, 220);771        setLive(deep.value === 0 ? "Deep bucket ×0 — vanished" : `Deep bucket ×${deep.value}`);772        await wait(rm ? 100 : 500);773      }774775      if (!alive()) return;776      const parts = gateMult > 1 || deep ? [`${formatMultiplier(bucketValue)} bucket`, gateMult > 1 ? `×${gateMult} gates` : null, deep ? `×${deep.value} deep` : null].filter(Boolean).join(" · ") : `Bucket ${bucket + 1}`;777      setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, parts });778      if (outcome.totalWin <= 0) sound("lose");779      else if (outcome.multiplier >= 15) sound("bigWin");780      else sound("win");781      onResult({ win: outcome.totalWin, multiplier: outcome.multiplier });782      s.phase = "idle";783    },784    [cfg, palette, getScene, sound, onResult, spawnBurst],785  );786787  const drop = useCallback(async () => {788    if (phaseRef.current !== "idle") return;789    phaseRef.current = "dropping";790    setPhase("dropping");791    setResult(null);792    setLive(null);793    onBusy(true);794    sound("click");795    const res = await play(bet, { risk, lane });796    if (res && aliveRef.current) {797      await animate(res.outcome);798    }799    phaseRef.current = "idle";800    if (aliveRef.current) {801      setPhase("idle");802      const s = sceneRef.current;803      if (s) s.phase = "idle";804    }805    onBusy(false);806  }, [play, bet, risk, lane, animate, onBusy, sound]);807808  const dropping = phase === "dropping";809  const maxValue = Math.max(...values);810811  return (812    <div className="absolute inset-0 flex flex-col">813      {/* Risk selector */}814      <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2">815        <div role="radiogroup" aria-label="Risk profile" className="flex h-11 flex-1 max-w-[330px] rounded-md p-1 surface-2">816          {RISKS.map((r) => {817            const active = risk === r.id;818            return (819              <button820                key={r.id}821                role="radio"822                aria-checked={active}823                disabled={dropping}824                onClick={() => {825                  if (risk !== r.id) sound("click");826                  setRisk(r.id);827                }}828                className={cn("flex-1 rounded-[8px] text-[13px] font-semibold transition-all focus-ring disabled:opacity-60", active ? "text-[#061018]" : "text-fg-2 hover:text-fg")}829                style={active ? { background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary})`, boxShadow: `0 6px 20px -8px ${palette.primary}` } : undefined}830              >831                {r.label}832              </button>833            );834          })}835        </div>836        <div className="text-right leading-tight">837          <div className="eyebrow">Top bucket</div>838          <div className="text-sm font-bold tabular" style={{ color: palette.glow }}>839            {formatMultiplier(maxValue)}840          </div>841        </div>842      </div>843844      {/* Tower */}845      <div className="relative min-h-0 flex-1">846        <canvas847          ref={canvasRef}848          className={cn("absolute inset-0 h-full w-full touch-none", dropping ? "cursor-default" : "cursor-pointer")}849          onPointerDown={onPointer}850          onPointerMove={onPointer}851          aria-label="Drop tower. Tap or drag to choose the release lane."852          role="img"853        />854        <AnimatePresence>855          {live && dropping ? (856            <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-2 -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 }}>857              {live}858            </motion.div>859          ) : null}860          {result && !dropping ? (861            <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-2 flex -translate-x-1/2 flex-col items-center glass rounded-lg px-4 py-1.5 text-center leading-tight">862              <span className={cn("whitespace-nowrap text-sm font-bold tabular", result.win > 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "No win this drop"}</span>863              <span className="whitespace-nowrap text-[11px] text-fg-3">{result.parts}</span>864            </motion.div>865          ) : null}866          {error ? (867            <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">868              <div className="text-fg-2">{error}</div>869              <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring">870                Dismiss871              </button>872            </motion.div>873          ) : null}874        </AnimatePresence>875      </div>876877      {/* Lane + action */}878      <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2">879        <div className="flex items-center gap-1">880          <button disabled={dropping || lane <= 0} onClick={() => { sound("tick"); setLane((l) => Math.max(0, l - 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Lane left">881            <ChevronLeft className="h-4 w-4" />882          </button>883          <div className="flex h-11 min-w-[72px] flex-col items-center justify-center rounded-md surface-2 px-2">884            <span className="text-[10px] uppercase tracking-wider text-fg-3">Lane</span>885            <span className="text-sm font-bold tabular">{lane + 1} / {cfg.lanes}</span>886          </div>887          <button disabled={dropping || lane >= cfg.lanes - 1} onClick={() => { sound("tick"); setLane((l) => Math.min(cfg.lanes - 1, l + 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Lane right">888            <ChevronRight className="h-4 w-4" />889          </button>890        </div>891        <button892          onClick={() => void drop()}893          disabled={dropping}894          className="tap relative h-14 flex-1 rounded-lg text-base font-extrabold uppercase tracking-[0.18em] text-[#061018] transition-transform active:scale-[0.98] disabled:opacity-70 focus-ring"895          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}` }}896          aria-label={definition.presentation.verb}897        >898          {dropping ? "DROPPING…" : `${definition.presentation.verb} · ${formatSC(bet)}`}899        </button>900      </div>901    </div>902  );903}904