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%
29.4 KB · 797 lines tsx
Raw Blame History
1"use client";23/**4 * GRID//BREAK — browser side. The server resolves every chain step; this file5 * replays `outcome.steps` (grid before the step, destroyed blocks, specials,6 * chain index, step win) on a 2D canvas: wave beam → detonation → gravity/refill.7 */8import { useCallback, useEffect, useRef, useState } from "react";9import { AnimatePresence, motion } from "framer-motion";10import { ChevronLeft, ChevronRight } from "lucide-react";11import { formatMultiplier, formatSC } from "@spinza/shared";12import type { ArcadeOutcome, GridCell, GridStep, GridbreakConfig } from "@spinza/game-core/client";13import { cn } from "@/lib/utils";14import { useInstantPlay, type ArcadeGameProps } from "./contract";1516interface GridOutcome extends ArcadeOutcome {17  steps: GridStep[];18  summary: { column: number; chains: number; blocksDestroyed: number };19}2021/* --------------------------------------------------------------- helpers */2223const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));24const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);25const easeInCubic = (t: number) => t * t * t;26const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);27const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));28const lerp = (a: number, b: number, t: number) => a + (b - a) * t;2930function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> {31  return new Promise((resolve) => {32    const start = performance.now();33    const frame = (now: number) => {34      if (!alive()) return resolve();35      const t = Math.min(1, (now - start) / Math.max(1, ms));36      fn(t);37      if (t < 1) requestAnimationFrame(frame);38      else resolve();39    };40    requestAnimationFrame(frame);41  });42}4344function rgba(hex: string, a: number): string {45  const h = hex.replace("#", "");46  const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);47  return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;48}4950function mixHex(a: string, b: string, t: number): string {51  const pa = parseInt(a.replace("#", ""), 16);52  const pb = parseInt(b.replace("#", ""), 16);53  const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t));54  return `rgb(${ch(16)},${ch(8)},${ch(0)})`;55}5657/* ----------------------------------------------------------------- scene */5859interface Block {60  c: number;61  s?: GridCell["s"];62  x: number; // column63  y: number; // display row (float during falls)64  alpha: number;65  scale: number;66  flash: number; // 0..1 highlight before detonation67}6869interface Particle {70  x: number;71  y: number;72  vx: number;73  vy: number;74  life: number;75  max: number;76  color: string;77  size: number;78  rot: number;79  vr: number;80}8182interface Pop {83  x: number;84  y: number;85  text: string;86  color: string;87  at: number;88}8990interface Scene {91  t: number;92  cols: Block[][];93  column: number;94  phase: "idle" | "firing";95  placeholder: boolean;96  beam: { p: number; alpha: number } | null;97  shocks: { x: number; y: number; at: number; kind: "bomb" | "line" | "x2" | "miss" }[];98  sweeps: { row: number; at: number }[];99  particles: Particle[];100  pops: Pop[];101  reduceMotion: boolean;102}103104interface Layout {105  w: number;106  h: number;107  ox: number;108  oy: number;109  cell: number;110  headH: number;111}112113function layoutFor(w: number, h: number, size: number): Layout {114  const headH = 26;115  const side = Math.max(120, Math.min(w - 16, h - headH - 30, 560));116  const cell = side / size;117  const ox = (w - side) / 2;118  const oy = headH + (h - headH - side) / 2;119  return { w, h, ox, oy, cell, headH };120}121122function placeholderGrid(size: number, colors: number): GridCell[][] {123  const g: GridCell[][] = [];124  for (let x = 0; x < size; x++) {125    const col: GridCell[] = [];126    for (let y = 0; y < size; y++) col.push({ c: (x * 2 + y * 3 + ((x * y) % 5)) % colors });127    g.push(col);128  }129  return g;130}131132function blocksFrom(grid: GridCell[][]): Block[][] {133  return grid.map((col, x) => col.map((cell, y) => ({ c: cell.c, s: cell.s, x, y, alpha: 1, scale: 1, flash: 0 })));134}135136/* --------------------------------------------------------------- drawing */137138interface Palette {139  primary: string;140  secondary: string;141  glow: string;142  bg: string;143  surface: string;144}145146function blockColors(palette: Palette): string[] {147  return [palette.secondary, palette.primary, "#fbbf24", "#f472b6", "#a78bfa", "#f8fafc"];148}149150function drawBlock(ctx: CanvasRenderingContext2D, b: Block, L: Layout, color: string, dim: boolean) {151  const size = L.cell;152  const cx = L.ox + (b.x + 0.5) * size;153  const cy = L.oy + (b.y + 0.5) * size;154  const s = size * 0.86 * b.scale;155  ctx.save();156  ctx.globalAlpha = b.alpha * (dim ? 0.5 : 1);157  ctx.translate(cx, cy);158  const r = Math.min(7, size * 0.18);159  const grad = ctx.createLinearGradient(0, -s / 2, 0, s / 2);160  grad.addColorStop(0, rgba(color, 0.95));161  grad.addColorStop(1, mixHex(color, "#000000", 0.42));162  ctx.fillStyle = grad;163  ctx.shadowColor = color;164  ctx.shadowBlur = b.flash > 0 ? 8 + 26 * b.flash : dim ? 0 : size * 0.16;165  ctx.beginPath();166  ctx.roundRect(-s / 2, -s / 2, s, s, r);167  ctx.fill();168  ctx.shadowBlur = 0;169  // gloss170  const gloss = ctx.createLinearGradient(0, -s / 2, 0, 0);171  gloss.addColorStop(0, "rgba(255,255,255,0.35)");172  gloss.addColorStop(1, "rgba(255,255,255,0)");173  ctx.fillStyle = gloss;174  ctx.beginPath();175  ctx.roundRect(-s / 2 + 2, -s / 2 + 2, s - 4, s / 2, r);176  ctx.fill();177  ctx.strokeStyle = b.flash > 0 ? `rgba(255,255,255,${0.4 + 0.6 * b.flash})` : "rgba(255,255,255,0.18)";178  ctx.lineWidth = b.flash > 0 ? 2 : 1;179  ctx.beginPath();180  ctx.roundRect(-s / 2, -s / 2, s, s, r);181  ctx.stroke();182  // Special icons183  if (b.s === "bomb") {184    ctx.fillStyle = "#0b0f14";185    ctx.beginPath();186    ctx.arc(0, s * 0.06, s * 0.24, 0, Math.PI * 2);187    ctx.fill();188    ctx.strokeStyle = "#f8fafc";189    ctx.lineWidth = 1.5;190    ctx.beginPath();191    ctx.moveTo(s * 0.08, -s * 0.14);192    ctx.quadraticCurveTo(s * 0.2, -s * 0.34, s * 0.3, -s * 0.28);193    ctx.stroke();194    ctx.fillStyle = "#fde68a";195    ctx.beginPath();196    ctx.arc(s * 0.31, -s * 0.29, s * 0.06, 0, Math.PI * 2);197    ctx.fill();198  } else if (b.s === "line") {199    ctx.strokeStyle = "#0b0f14";200    ctx.lineWidth = Math.max(2, s * 0.09);201    ctx.lineCap = "round";202    ctx.beginPath();203    ctx.moveTo(-s * 0.3, 0);204    ctx.lineTo(s * 0.3, 0);205    ctx.moveTo(-s * 0.3, 0);206    ctx.lineTo(-s * 0.16, -s * 0.13);207    ctx.moveTo(-s * 0.3, 0);208    ctx.lineTo(-s * 0.16, s * 0.13);209    ctx.moveTo(s * 0.3, 0);210    ctx.lineTo(s * 0.16, -s * 0.13);211    ctx.moveTo(s * 0.3, 0);212    ctx.lineTo(s * 0.16, s * 0.13);213    ctx.stroke();214  } else if (b.s === "x2") {215    ctx.fillStyle = "#0b0f14";216    ctx.font = `900 ${Math.max(10, s * 0.46)}px Geist, "Geist Fallback", system-ui, sans-serif`;217    ctx.textAlign = "center";218    ctx.textBaseline = "middle";219    ctx.fillText("×2", 0, 1);220  }221  ctx.restore();222}223224function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: GridbreakConfig, palette: Palette) {225  const { w, h, ox, oy, cell } = L;226  const side = cell * cfg.size;227  ctx.clearRect(0, 0, w, h);228  const colors = blockColors(palette);229  const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`;230231  // Board frame232  ctx.save();233  ctx.fillStyle = "rgba(255,255,255,0.03)";234  ctx.strokeStyle = rgba(palette.primary, 0.22);235  ctx.lineWidth = 1;236  ctx.beginPath();237  ctx.roundRect(ox - 6, oy - 6, side + 12, side + 12, 14);238  ctx.fill();239  ctx.stroke();240  // grid lines241  ctx.strokeStyle = "rgba(255,255,255,0.05)";242  for (let i = 1; i < cfg.size; i++) {243    ctx.beginPath();244    ctx.moveTo(ox + i * cell, oy);245    ctx.lineTo(ox + i * cell, oy + side);246    ctx.moveTo(ox, oy + i * cell);247    ctx.lineTo(ox + side, oy + i * cell);248    ctx.stroke();249  }250  ctx.restore();251252  // Selected column glow + headers253  for (let x = 0; x < cfg.size; x++) {254    const cx = ox + (x + 0.5) * cell;255    const sel = x === s.column;256    if (sel) {257      const g = ctx.createLinearGradient(0, oy, 0, oy + side);258      g.addColorStop(0, rgba(palette.primary, s.phase === "idle" ? 0.16 : 0.08));259      g.addColorStop(1, rgba(palette.primary, 0));260      ctx.fillStyle = g;261      ctx.fillRect(ox + x * cell + 1, oy, cell - 2, side);262    }263    // chevron header264    ctx.save();265    ctx.translate(cx, oy - 13 + (sel ? Math.sin(s.t * 3) * 1.5 : 0));266    ctx.strokeStyle = sel ? palette.glow : "rgba(255,255,255,0.28)";267    ctx.lineWidth = sel ? 2.5 : 1.5;268    ctx.lineCap = "round";269    ctx.beginPath();270    ctx.moveTo(-5, -3);271    ctx.lineTo(0, 3);272    ctx.lineTo(5, -3);273    ctx.stroke();274    if (sel) {275      ctx.shadowColor = palette.glow;276      ctx.shadowBlur = 12;277      ctx.stroke();278    }279    ctx.restore();280  }281282  // Clip to board for falling blocks283  ctx.save();284  ctx.beginPath();285  ctx.rect(ox - 2, oy - 2, side + 4, side + 4);286  ctx.clip();287  for (const col of s.cols) for (const b of col) if (b.alpha > 0) drawBlock(ctx, b, L, colors[b.c % colors.length], s.placeholder);288  ctx.restore();289290  // Beam291  if (s.beam && s.beam.alpha > 0) {292    const x = ox + (s.column + 0.5) * cell;293    const yEnd = oy + side * s.beam.p;294    ctx.save();295    ctx.globalAlpha = s.beam.alpha;296    ctx.strokeStyle = palette.glow;297    ctx.lineWidth = Math.max(3, cell * 0.14);298    ctx.lineCap = "round";299    ctx.shadowColor = palette.primary;300    ctx.shadowBlur = 24;301    ctx.beginPath();302    ctx.moveTo(x, oy - 8);303    ctx.lineTo(x, yEnd);304    ctx.stroke();305    ctx.strokeStyle = "#ffffff";306    ctx.lineWidth = 1.5;307    ctx.stroke();308    ctx.fillStyle = "#ffffff";309    ctx.beginPath();310    ctx.arc(x, yEnd, cell * 0.18, 0, Math.PI * 2);311    ctx.fill();312    ctx.restore();313  }314315  // Shocks (bomb rings, x2 rings)316  for (const sh of s.shocks) {317    const age = s.t - sh.at;318    if (age > 0.7) continue;319    const k = age / 0.7;320    const cx = ox + (sh.x + 0.5) * cell;321    const cy = oy + (sh.y + 0.5) * cell;322    ctx.save();323    ctx.globalAlpha = 1 - k;324    if (sh.kind === "bomb") {325      ctx.strokeStyle = "#fde68a";326      ctx.lineWidth = 4 * (1 - k) + 1;327      ctx.beginPath();328      ctx.roundRect(cx - cell * 1.5 * easeOutCubic(k), cy - cell * 1.5 * easeOutCubic(k), cell * 3 * easeOutCubic(k), cell * 3 * easeOutCubic(k), 8);329      ctx.stroke();330      ctx.fillStyle = rgba("#fde68a", 0.25 * (1 - k));331      ctx.fill();332    } else if (sh.kind === "x2") {333      ctx.strokeStyle = palette.glow;334      ctx.lineWidth = 3;335      ctx.beginPath();336      ctx.arc(cx, cy, cell * (0.3 + 1.6 * easeOutCubic(k)), 0, Math.PI * 2);337      ctx.stroke();338    } else if (sh.kind === "miss") {339      ctx.strokeStyle = "rgba(255,255,255,0.5)";340      ctx.lineWidth = 2;341      ctx.beginPath();342      ctx.arc(cx, cy, cell * (0.2 + 0.6 * easeOutCubic(k)), 0, Math.PI * 2);343      ctx.stroke();344    }345    ctx.restore();346  }347  // Line sweeps348  for (const sw of s.sweeps) {349    const age = s.t - sw.at;350    if (age > 0.6) continue;351    const k = easeOutCubic(age / 0.6);352    const y = oy + (sw.row + 0.5) * cell;353    ctx.save();354    ctx.globalAlpha = 1 - age / 0.6;355    const g = ctx.createLinearGradient(ox, 0, ox + side * k, 0);356    g.addColorStop(0, rgba(palette.secondary, 0));357    g.addColorStop(0.8, rgba(palette.secondary, 0.6));358    g.addColorStop(1, "#ffffff");359    ctx.fillStyle = g;360    ctx.fillRect(ox, y - cell * 0.42, side * k, cell * 0.84);361    ctx.restore();362  }363364  // Particles365  for (const p of s.particles) {366    const k = p.life / p.max;367    ctx.save();368    ctx.globalAlpha = k;369    ctx.translate(p.x, p.y);370    ctx.rotate(p.rot);371    ctx.fillStyle = p.color;372    ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);373    ctx.restore();374  }375376  // Pops377  for (const pop of s.pops) {378    const age = s.t - pop.at;379    if (age > 1.1) continue;380    const k = age < 0.2 ? easeOutBack(age / 0.2) : 1;381    const fade = age > 0.7 ? 1 - (age - 0.7) / 0.4 : 1;382    ctx.save();383    ctx.globalAlpha = Math.max(0, fade);384    ctx.translate(pop.x, pop.y - age * 26);385    ctx.scale(k, k);386    ctx.font = font(Math.max(12, cell * 0.5), 900);387    ctx.textAlign = "center";388    ctx.textBaseline = "middle";389    ctx.shadowColor = pop.color;390    ctx.shadowBlur = 14;391    ctx.fillStyle = pop.color;392    ctx.fillText(pop.text, 0, 0);393    ctx.restore();394  }395396  // Placeholder watermark397  if (s.placeholder) {398    const label = "PREVIEW GRID · FIRE TO REVEAL THE REAL ONE";399    ctx.save();400    ctx.font = font(11, 700);401    ctx.textAlign = "center";402    ctx.textBaseline = "middle";403    const tw = Math.min(side - 16, ctx.measureText(label).width + 28);404    const cx = ox + side / 2;405    const cy = oy + side / 2;406    ctx.fillStyle = "rgba(5,8,12,0.78)";407    ctx.strokeStyle = rgba(palette.primary, 0.35);408    ctx.lineWidth = 1;409    ctx.beginPath();410    ctx.roundRect(cx - tw / 2, cy - 15, tw, 30, 15);411    ctx.fill();412    ctx.stroke();413    ctx.fillStyle = "rgba(255,255,255,0.8)";414    ctx.fillText(label, cx, cy + 0.5, tw - 20);415    ctx.restore();416  }417}418419/* ------------------------------------------------------------- component */420421export function GridbreakGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {422  const cfg = definition.config as unknown as GridbreakConfig;423  const palette = definition.presentation.palette;424  const { play, error, clearError } = useInstantPlay<GridOutcome>(definition.slug);425  const [column, setColumn] = useState(Math.floor(cfg.size / 2));426  const [phase, setPhase] = useState<"idle" | "firing">("idle");427  const [chain, setChain] = useState<number | null>(null);428  const [running, setRunning] = useState(0);429  const [live, setLive] = useState<string | null>(null);430  const [result, setResult] = useState<{ win: number; multiplier: number; chains: number; blocks: number } | null>(null);431  const canvasRef = useRef<HTMLCanvasElement>(null);432  const sceneRef = useRef<Scene | null>(null);433  const aliveRef = useRef(true);434  const phaseRef = useRef<"idle" | "firing">("idle");435436  const getScene = useCallback((): Scene => {437    if (!sceneRef.current) {438      sceneRef.current = {439        t: 0,440        cols: blocksFrom(placeholderGrid(cfg.size, cfg.colors)),441        column: Math.floor(cfg.size / 2),442        phase: "idle",443        placeholder: true,444        beam: null,445        shocks: [],446        sweeps: [],447        particles: [],448        pops: [],449        reduceMotion: false,450      };451    }452    return sceneRef.current;453  }, [cfg]);454455  useEffect(() => {456    aliveRef.current = true;457    const canvas = canvasRef.current;458    if (!canvas) return;459    const ctx = canvas.getContext("2d");460    if (!ctx) return;461    const s = getScene();462    let raf = 0;463    let last = performance.now();464    const loop = (now: number) => {465      const dt = Math.min(0.05, (now - last) / 1000);466      last = now;467      s.t += dt;468      const dpr = Math.min(2, window.devicePixelRatio || 1);469      const rect = canvas.getBoundingClientRect();470      const W = Math.max(1, Math.round(rect.width * dpr));471      const H = Math.max(1, Math.round(rect.height * dpr));472      if (canvas.width !== W || canvas.height !== H) {473        canvas.width = W;474        canvas.height = H;475      }476      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);477      const L = layoutFor(rect.width, rect.height, cfg.size);478      for (let i = s.particles.length - 1; i >= 0; i--) {479        const p = s.particles[i];480        p.life -= dt;481        p.x += p.vx * dt;482        p.y += p.vy * dt;483        p.vy += 900 * dt;484        p.rot += p.vr * dt;485        if (p.life <= 0) s.particles.splice(i, 1);486      }487      s.shocks = s.shocks.filter((x) => s.t - x.at < 0.8);488      s.sweeps = s.sweeps.filter((x) => s.t - x.at < 0.7);489      s.pops = s.pops.filter((x) => s.t - x.at < 1.2);490      drawScene(ctx, L, s, cfg, palette);491      raf = requestAnimationFrame(loop);492    };493    raf = requestAnimationFrame(loop);494    return () => {495      aliveRef.current = false;496      cancelAnimationFrame(raf);497    };498  }, [cfg, palette, getScene]);499500  useEffect(() => {501    const s = getScene();502    s.column = column;503    s.reduceMotion = reduceMotion;504  }, [column, reduceMotion, getScene]);505506  const onPointer = useCallback(507    (e: React.PointerEvent<HTMLCanvasElement>) => {508      if (phaseRef.current !== "idle") return;509      if (e.type === "pointermove" && e.buttons === 0) return;510      const rect = e.currentTarget.getBoundingClientRect();511      const L = layoutFor(rect.width, rect.height, cfg.size);512      const col = clamp(Math.floor((e.clientX - rect.left - L.ox) / L.cell), 0, cfg.size - 1);513      setColumn((prev) => {514        if (prev !== col) sound("tick");515        return col;516      });517    },518    [cfg.size, sound],519  );520521  const explode = useCallback((s: Scene, L: Layout, b: Block, color: string) => {522    if (s.reduceMotion) return;523    const cx = L.ox + (b.x + 0.5) * L.cell;524    const cy = L.oy + (b.y + 0.5) * L.cell;525    const n = 7;526    for (let i = 0; i < n; i++) {527      const a = Math.random() * Math.PI * 2;528      const v = 120 + Math.random() * 220;529      s.particles.push({ x: cx, y: cy, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 160, life: 0.45 + Math.random() * 0.35, max: 0.8, color, size: 2 + Math.random() * L.cell * 0.18, rot: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 12 });530    }531  }, []);532533  const animate = useCallback(534    async (outcome: GridOutcome) => {535      const s = getScene();536      const alive = () => aliveRef.current;537      const rm = s.reduceMotion;538      const canvas = canvasRef.current;539      const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 500 };540      const L = () => layoutFor(rect.width, rect.height, cfg.size);541      const colors = blockColors(palette);542      const steps = outcome.steps;543      const size = cfg.size;544      s.phase = "firing";545      s.placeholder = false;546      s.particles = [];547      s.shocks = [];548      s.sweeps = [];549      s.pops = [];550      s.column = outcome.summary.column;551552      // 1) The real grid drops in.553      s.cols = blocksFrom(steps[0].grid);554      for (const col of s.cols) for (const b of col) b.y = b.y - size - 0.5 - Math.random() * 0.8;555      await tween(rm ? 80 : 380, (t) => {556        for (const col of s.cols)557          for (let y = 0; y < col.length; y++) {558            const b = col[y];559            const start = -size - 0.5 - (b.x % 3) * 0.25;560            b.y = lerp(start, y, easeOutCubic(clamp(t * 1.15 - b.x * 0.02, 0, 1)));561          }562      }, alive);563      for (const col of s.cols) for (let y = 0; y < col.length; y++) col[y].y = y;564      sound("tick");565566      // 2) The wave beam down the chosen column.567      s.beam = { p: 0, alpha: 1 };568      await tween(rm ? 80 : 300, (t) => {569        if (s.beam) s.beam.p = easeInCubic(t);570      }, alive);571      const first = steps[0];572      if (first.destroyed.length === 0) {573        s.shocks.push({ x: s.column, y: size - 1, at: s.t, kind: "miss" });574        setLive("No cluster on that column");575      }576      await tween(rm ? 60 : 220, (t) => {577        if (s.beam) s.beam.alpha = 1 - t;578      }, alive);579      s.beam = null;580581      let cum = 0;582      for (let i = 0; i < steps.length - 1; i++) {583        if (!alive()) return;584        const step = steps[i];585        const next = steps[i + 1];586        const destroyed = new Set(step.destroyed.map(([x, y]) => `${x}:${y}`));587        if (destroyed.size === 0) break;588        setChain(step.chain);589        const chainMult = cfg.chainLadder[Math.min(step.chain, cfg.chainLadder.length - 1)];590        const x2s = step.specials.filter((sp) => sp.type === "x2").length;591        setLive(step.chain > 0 ? `Chain ${step.chain + 1} · ×${chainMult}${x2s ? ` · ×${2 ** x2s} block` : ""}` : x2s ? `×${2 ** x2s} block` : `Wave hit ${destroyed.size} blocks`);592593        // Highlight + specials594        const hitBlocks: Block[] = [];595        for (const col of s.cols) for (const b of col) if (destroyed.has(`${b.x}:${Math.round(b.y)}`)) hitBlocks.push(b);596        await tween(rm ? 50 : 200, (t) => {597          for (const b of hitBlocks) {598            b.flash = Math.sin(t * Math.PI);599            b.scale = 1 + 0.12 * Math.sin(t * Math.PI);600          }601        }, alive);602        for (const sp of step.specials) {603          if (sp.type === "bomb") {604            s.shocks.push({ x: sp.at[0], y: sp.at[1], at: s.t, kind: "bomb" });605            sound("bonus");606          } else if (sp.type === "line") {607            s.sweeps.push({ row: sp.at[1], at: s.t });608            sound("bonus");609          } else if (sp.type === "x2") {610            s.shocks.push({ x: sp.at[0], y: sp.at[1], at: s.t, kind: "x2" });611            s.pops.push({ x: L().ox + (sp.at[0] + 0.5) * L().cell, y: L().oy + (sp.at[1] + 0.5) * L().cell, text: "×2", color: palette.glow, at: s.t });612            sound("bonus");613          }614        }615        if (step.specials.length) await wait(rm ? 60 : 260);616617        // Detonate618        for (const b of hitBlocks) explode(s, L(), b, colors[b.c % colors.length]);619        sound(step.chain >= 2 ? "bonus" : "tick");620        await tween(rm ? 50 : 170, (t) => {621          for (const b of hitBlocks) {622            b.alpha = 1 - t;623            b.scale = 1 + 0.45 * t;624            b.flash = 1 - t;625          }626        }, alive);627        // Step win counts up628        const from = cum;629        cum += step.win;630        if (step.win > 0) {631          const Lc = L();632          const cxs = step.destroyed.reduce((a, [x]) => a + x, 0) / step.destroyed.length;633          const cys = step.destroyed.reduce((a, [, y]) => a + y, 0) / step.destroyed.length;634          s.pops.push({ x: Lc.ox + (cxs + 0.5) * Lc.cell, y: Lc.oy + (cys + 0.5) * Lc.cell, text: `+${formatSC(step.win, { unit: false })}`, color: step.chain >= 2 ? "#ffd66b" : "#ffffff", at: s.t });635        }636        void tween(rm ? 80 : 360, (t) => setRunning(Math.round(lerp(from, cum, easeOutCubic(t)))), alive);637638        // Gravity + refill toward the next grid639        type Fall = { b: Block; from: number; to: number };640        const falls: Fall[] = [];641        const newCols: Block[][] = [];642        for (let x = 0; x < size; x++) {643          const kept = s.cols[x].filter((b) => !destroyed.has(`${b.x}:${Math.round(b.y)}`)).sort((a, b) => a.y - b.y);644          const freshCount = size - kept.length;645          const col: Block[] = [];646          for (let j = 0; j < freshCount; j++) {647            const cell = next.grid[x][j];648            const b: Block = { c: cell.c, s: cell.s, x, y: j - freshCount - 0.3, alpha: 1, scale: 1, flash: 0 };649            col.push(b);650            falls.push({ b, from: b.y, to: j });651          }652          kept.forEach((b, j) => {653            const target = freshCount + j;654            const cell = next.grid[x][target];655            // trust the server grid for colour/special (they should match)656            b.c = cell.c;657            b.s = cell.s;658            b.alpha = 1;659            b.scale = 1;660            b.flash = 0;661            if (b.y !== target) falls.push({ b, from: b.y, to: target });662            col.push(b);663          });664          newCols.push(col);665        }666        s.cols = newCols;667        if (falls.length) {668          await tween(rm ? 70 : 340, (t) => {669            for (const f of falls) {670              const k = easeOutBack(clamp(t * 1.05, 0, 1));671              f.b.y = lerp(f.from, f.to, Math.min(1, Math.max(0, k)));672            }673          }, alive);674          for (const f of falls) f.b.y = f.to;675          sound("tick");676        }677        await wait(rm ? 40 : 140);678      }679680      if (!alive()) return;681      // Final grid = last step's grid (already what we show; enforce exactly).682      s.cols = blocksFrom(steps[steps.length - 1].grid);683      setRunning(outcome.totalWin);684      setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, chains: outcome.summary.chains, blocks: outcome.summary.blocksDestroyed });685      if (outcome.totalWin <= 0) sound("lose");686      else if (outcome.multiplier >= 15) sound("bigWin");687      else sound("win");688      onResult({ win: outcome.totalWin, multiplier: outcome.multiplier });689      s.phase = "idle";690    },691    [cfg, palette, getScene, sound, onResult, explode],692  );693694  const fire = useCallback(async () => {695    if (phaseRef.current !== "idle") return;696    phaseRef.current = "firing";697    setPhase("firing");698    setResult(null);699    setLive(null);700    setChain(null);701    setRunning(0);702    onBusy(true);703    sound("click");704    const res = await play(bet, { column });705    if (res && aliveRef.current) await animate(res.outcome);706    phaseRef.current = "idle";707    if (aliveRef.current) {708      setPhase("idle");709      const s = sceneRef.current;710      if (s) s.phase = "idle";711    }712    onBusy(false);713  }, [play, bet, column, animate, onBusy, sound]);714715  const firing = phase === "firing";716717  return (718    <div className="absolute inset-0 flex flex-col">719      {/* Chain ladder + running win */}720      <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2">721        <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto scrollbar-none" aria-label="Chain multiplier ladder">722          {cfg.chainLadder.map((m, i) => {723            const lit = chain !== null && i <= chain;724            const current = chain === i;725            return (726              <span727                key={i}728                className={cn("shrink-0 rounded-full px-2 py-0.5 text-[11px] font-bold tabular transition-all", lit ? "text-[#061018]" : "bg-white/5 text-fg-4")}729                style={lit ? { background: current ? palette.glow : rgba(palette.primary, 0.75), boxShadow: current ? `0 0 18px ${rgba(palette.primary, 0.7)}` : undefined, transform: current ? "scale(1.12)" : undefined } : undefined}730              >731                ×{m}732              </span>733            );734          })}735        </div>736        <div className="text-right leading-tight">737          <div className="eyebrow">{firing ? "Running" : "Round win"}</div>738          <div className={cn("text-sm font-bold tabular", running > 0 ? "text-credit" : "text-fg-3")}>{running > 0 ? formatSC(running) : "—"}</div>739        </div>740      </div>741742      {/* Grid */}743      <div className="relative min-h-0 flex-1">744        <canvas ref={canvasRef} className={cn("absolute inset-0 h-full w-full touch-none", firing ? "cursor-default" : "cursor-pointer")} onPointerDown={onPointer} onPointerMove={onPointer} role="img" aria-label="Energy grid. Tap a column to aim the wave." />745        <AnimatePresence>746          {live && firing ? (747            <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 }}>748              {live}749            </motion.div>750          ) : null}751          {result && !firing ? (752            <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">753              <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)}` : "No cluster detonated"}</span>754              <span className="ml-2 text-[11px] text-fg-3">755                {result.chains} chain{result.chains === 1 ? "" : "s"} · {result.blocks} blocks756              </span>757            </motion.div>758          ) : null}759          {error ? (760            <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">761              <div className="text-fg-2">{error}</div>762              <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring">763                Dismiss764              </button>765            </motion.div>766          ) : null}767        </AnimatePresence>768      </div>769770      {/* Column + action */}771      <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2">772        <div className="flex items-center gap-1">773          <button disabled={firing || column <= 0} onClick={() => { sound("tick"); setColumn((c) => Math.max(0, c - 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Column left">774            <ChevronLeft className="h-4 w-4" />775          </button>776          <div className="flex h-11 min-w-[72px] flex-col items-center justify-center rounded-md surface-2 px-2">777            <span className="text-[10px] uppercase tracking-wider text-fg-3">Column</span>778            <span className="text-sm font-bold tabular">{column + 1} / {cfg.size}</span>779          </div>780          <button disabled={firing || column >= cfg.size - 1} onClick={() => { sound("tick"); setColumn((c) => Math.min(cfg.size - 1, c + 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Column right">781            <ChevronRight className="h-4 w-4" />782          </button>783        </div>784        <button785          onClick={() => void fire()}786          disabled={firing}787          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"788          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}` }}789          aria-label={definition.presentation.verb}790        >791          {firing ? "WAVE ACTIVE…" : `${definition.presentation.verb} · ${formatSC(bet)}`}792        </button>793      </div>794    </div>795  );796}797