"use client"; /** * GRID//BREAK — browser side. The server resolves every chain step; this file * replays `outcome.steps` (grid before the step, destroyed blocks, specials, * chain index, step win) on a 2D canvas: wave beam → detonation → gravity/refill. */ import { useCallback, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { formatMultiplier, formatSC } from "@spinza/shared"; import type { ArcadeOutcome, GridCell, GridStep, GridbreakConfig } from "@spinza/game-core/client"; import { cn } from "@/lib/utils"; import { useInstantPlay, type ArcadeGameProps } from "./contract"; interface GridOutcome extends ArcadeOutcome { steps: GridStep[]; summary: { column: number; chains: number; blocksDestroyed: 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 clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v)); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; 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 Block { c: number; s?: GridCell["s"]; x: number; // column y: number; // display row (float during falls) alpha: number; scale: number; flash: number; // 0..1 highlight before detonation } interface Particle { x: number; y: number; vx: number; vy: number; life: number; max: number; color: string; size: number; rot: number; vr: number; } interface Pop { x: number; y: number; text: string; color: string; at: number; } interface Scene { t: number; cols: Block[][]; column: number; phase: "idle" | "firing"; placeholder: boolean; beam: { p: number; alpha: number } | null; shocks: { x: number; y: number; at: number; kind: "bomb" | "line" | "x2" | "miss" }[]; sweeps: { row: number; at: number }[]; particles: Particle[]; pops: Pop[]; reduceMotion: boolean; } interface Layout { w: number; h: number; ox: number; oy: number; cell: number; headH: number; } function layoutFor(w: number, h: number, size: number): Layout { const headH = 26; const side = Math.max(120, Math.min(w - 16, h - headH - 30, 560)); const cell = side / size; const ox = (w - side) / 2; const oy = headH + (h - headH - side) / 2; return { w, h, ox, oy, cell, headH }; } function placeholderGrid(size: number, colors: number): GridCell[][] { const g: GridCell[][] = []; for (let x = 0; x < size; x++) { const col: GridCell[] = []; for (let y = 0; y < size; y++) col.push({ c: (x * 2 + y * 3 + ((x * y) % 5)) % colors }); g.push(col); } return g; } function blocksFrom(grid: GridCell[][]): Block[][] { return grid.map((col, x) => col.map((cell, y) => ({ c: cell.c, s: cell.s, x, y, alpha: 1, scale: 1, flash: 0 }))); } /* --------------------------------------------------------------- drawing */ interface Palette { primary: string; secondary: string; glow: string; bg: string; surface: string; } function blockColors(palette: Palette): string[] { return [palette.secondary, palette.primary, "#fbbf24", "#f472b6", "#a78bfa", "#f8fafc"]; } function drawBlock(ctx: CanvasRenderingContext2D, b: Block, L: Layout, color: string, dim: boolean) { const size = L.cell; const cx = L.ox + (b.x + 0.5) * size; const cy = L.oy + (b.y + 0.5) * size; const s = size * 0.86 * b.scale; ctx.save(); ctx.globalAlpha = b.alpha * (dim ? 0.5 : 1); ctx.translate(cx, cy); const r = Math.min(7, size * 0.18); const grad = ctx.createLinearGradient(0, -s / 2, 0, s / 2); grad.addColorStop(0, rgba(color, 0.95)); grad.addColorStop(1, mixHex(color, "#000000", 0.42)); ctx.fillStyle = grad; ctx.shadowColor = color; ctx.shadowBlur = b.flash > 0 ? 8 + 26 * b.flash : dim ? 0 : size * 0.16; ctx.beginPath(); ctx.roundRect(-s / 2, -s / 2, s, s, r); ctx.fill(); ctx.shadowBlur = 0; // gloss const gloss = ctx.createLinearGradient(0, -s / 2, 0, 0); gloss.addColorStop(0, "rgba(255,255,255,0.35)"); gloss.addColorStop(1, "rgba(255,255,255,0)"); ctx.fillStyle = gloss; ctx.beginPath(); ctx.roundRect(-s / 2 + 2, -s / 2 + 2, s - 4, s / 2, r); ctx.fill(); ctx.strokeStyle = b.flash > 0 ? `rgba(255,255,255,${0.4 + 0.6 * b.flash})` : "rgba(255,255,255,0.18)"; ctx.lineWidth = b.flash > 0 ? 2 : 1; ctx.beginPath(); ctx.roundRect(-s / 2, -s / 2, s, s, r); ctx.stroke(); // Special icons if (b.s === "bomb") { ctx.fillStyle = "#0b0f14"; ctx.beginPath(); ctx.arc(0, s * 0.06, s * 0.24, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "#f8fafc"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(s * 0.08, -s * 0.14); ctx.quadraticCurveTo(s * 0.2, -s * 0.34, s * 0.3, -s * 0.28); ctx.stroke(); ctx.fillStyle = "#fde68a"; ctx.beginPath(); ctx.arc(s * 0.31, -s * 0.29, s * 0.06, 0, Math.PI * 2); ctx.fill(); } else if (b.s === "line") { ctx.strokeStyle = "#0b0f14"; ctx.lineWidth = Math.max(2, s * 0.09); ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(-s * 0.3, 0); ctx.lineTo(s * 0.3, 0); ctx.moveTo(-s * 0.3, 0); ctx.lineTo(-s * 0.16, -s * 0.13); ctx.moveTo(-s * 0.3, 0); ctx.lineTo(-s * 0.16, s * 0.13); ctx.moveTo(s * 0.3, 0); ctx.lineTo(s * 0.16, -s * 0.13); ctx.moveTo(s * 0.3, 0); ctx.lineTo(s * 0.16, s * 0.13); ctx.stroke(); } else if (b.s === "x2") { ctx.fillStyle = "#0b0f14"; ctx.font = `900 ${Math.max(10, s * 0.46)}px Geist, "Geist Fallback", system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("×2", 0, 1); } ctx.restore(); } function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: GridbreakConfig, palette: Palette) { const { w, h, ox, oy, cell } = L; const side = cell * cfg.size; ctx.clearRect(0, 0, w, h); const colors = blockColors(palette); const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`; // Board frame ctx.save(); ctx.fillStyle = "rgba(255,255,255,0.03)"; ctx.strokeStyle = rgba(palette.primary, 0.22); ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(ox - 6, oy - 6, side + 12, side + 12, 14); ctx.fill(); ctx.stroke(); // grid lines ctx.strokeStyle = "rgba(255,255,255,0.05)"; for (let i = 1; i < cfg.size; i++) { ctx.beginPath(); ctx.moveTo(ox + i * cell, oy); ctx.lineTo(ox + i * cell, oy + side); ctx.moveTo(ox, oy + i * cell); ctx.lineTo(ox + side, oy + i * cell); ctx.stroke(); } ctx.restore(); // Selected column glow + headers for (let x = 0; x < cfg.size; x++) { const cx = ox + (x + 0.5) * cell; const sel = x === s.column; if (sel) { const g = ctx.createLinearGradient(0, oy, 0, oy + side); g.addColorStop(0, rgba(palette.primary, s.phase === "idle" ? 0.16 : 0.08)); g.addColorStop(1, rgba(palette.primary, 0)); ctx.fillStyle = g; ctx.fillRect(ox + x * cell + 1, oy, cell - 2, side); } // chevron header ctx.save(); ctx.translate(cx, oy - 13 + (sel ? Math.sin(s.t * 3) * 1.5 : 0)); ctx.strokeStyle = sel ? palette.glow : "rgba(255,255,255,0.28)"; ctx.lineWidth = sel ? 2.5 : 1.5; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(-5, -3); ctx.lineTo(0, 3); ctx.lineTo(5, -3); ctx.stroke(); if (sel) { ctx.shadowColor = palette.glow; ctx.shadowBlur = 12; ctx.stroke(); } ctx.restore(); } // Clip to board for falling blocks ctx.save(); ctx.beginPath(); ctx.rect(ox - 2, oy - 2, side + 4, side + 4); ctx.clip(); 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); ctx.restore(); // Beam if (s.beam && s.beam.alpha > 0) { const x = ox + (s.column + 0.5) * cell; const yEnd = oy + side * s.beam.p; ctx.save(); ctx.globalAlpha = s.beam.alpha; ctx.strokeStyle = palette.glow; ctx.lineWidth = Math.max(3, cell * 0.14); ctx.lineCap = "round"; ctx.shadowColor = palette.primary; ctx.shadowBlur = 24; ctx.beginPath(); ctx.moveTo(x, oy - 8); ctx.lineTo(x, yEnd); ctx.stroke(); ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1.5; ctx.stroke(); ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.arc(x, yEnd, cell * 0.18, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } // Shocks (bomb rings, x2 rings) for (const sh of s.shocks) { const age = s.t - sh.at; if (age > 0.7) continue; const k = age / 0.7; const cx = ox + (sh.x + 0.5) * cell; const cy = oy + (sh.y + 0.5) * cell; ctx.save(); ctx.globalAlpha = 1 - k; if (sh.kind === "bomb") { ctx.strokeStyle = "#fde68a"; ctx.lineWidth = 4 * (1 - k) + 1; ctx.beginPath(); ctx.roundRect(cx - cell * 1.5 * easeOutCubic(k), cy - cell * 1.5 * easeOutCubic(k), cell * 3 * easeOutCubic(k), cell * 3 * easeOutCubic(k), 8); ctx.stroke(); ctx.fillStyle = rgba("#fde68a", 0.25 * (1 - k)); ctx.fill(); } else if (sh.kind === "x2") { ctx.strokeStyle = palette.glow; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(cx, cy, cell * (0.3 + 1.6 * easeOutCubic(k)), 0, Math.PI * 2); ctx.stroke(); } else if (sh.kind === "miss") { ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(cx, cy, cell * (0.2 + 0.6 * easeOutCubic(k)), 0, Math.PI * 2); ctx.stroke(); } ctx.restore(); } // Line sweeps for (const sw of s.sweeps) { const age = s.t - sw.at; if (age > 0.6) continue; const k = easeOutCubic(age / 0.6); const y = oy + (sw.row + 0.5) * cell; ctx.save(); ctx.globalAlpha = 1 - age / 0.6; const g = ctx.createLinearGradient(ox, 0, ox + side * k, 0); g.addColorStop(0, rgba(palette.secondary, 0)); g.addColorStop(0.8, rgba(palette.secondary, 0.6)); g.addColorStop(1, "#ffffff"); ctx.fillStyle = g; ctx.fillRect(ox, y - cell * 0.42, side * k, cell * 0.84); ctx.restore(); } // Particles for (const p of s.particles) { const k = p.life / p.max; ctx.save(); ctx.globalAlpha = k; ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.color; ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size); ctx.restore(); } // Pops for (const pop of s.pops) { const age = s.t - pop.at; if (age > 1.1) continue; const k = age < 0.2 ? easeOutBack(age / 0.2) : 1; const fade = age > 0.7 ? 1 - (age - 0.7) / 0.4 : 1; ctx.save(); ctx.globalAlpha = Math.max(0, fade); ctx.translate(pop.x, pop.y - age * 26); ctx.scale(k, k); ctx.font = font(Math.max(12, cell * 0.5), 900); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.shadowColor = pop.color; ctx.shadowBlur = 14; ctx.fillStyle = pop.color; ctx.fillText(pop.text, 0, 0); ctx.restore(); } // Placeholder watermark if (s.placeholder) { const label = "PREVIEW GRID · FIRE TO REVEAL THE REAL ONE"; ctx.save(); ctx.font = font(11, 700); ctx.textAlign = "center"; ctx.textBaseline = "middle"; const tw = Math.min(side - 16, ctx.measureText(label).width + 28); const cx = ox + side / 2; const cy = oy + side / 2; ctx.fillStyle = "rgba(5,8,12,0.78)"; ctx.strokeStyle = rgba(palette.primary, 0.35); ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(cx - tw / 2, cy - 15, tw, 30, 15); ctx.fill(); ctx.stroke(); ctx.fillStyle = "rgba(255,255,255,0.8)"; ctx.fillText(label, cx, cy + 0.5, tw - 20); ctx.restore(); } } /* ------------------------------------------------------------- component */ export function GridbreakGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { const cfg = definition.config as unknown as GridbreakConfig; const palette = definition.presentation.palette; const { play, error, clearError } = useInstantPlay(definition.slug); const [column, setColumn] = useState(Math.floor(cfg.size / 2)); const [phase, setPhase] = useState<"idle" | "firing">("idle"); const [chain, setChain] = useState(null); const [running, setRunning] = useState(0); const [live, setLive] = useState(null); const [result, setResult] = useState<{ win: number; multiplier: number; chains: number; blocks: number } | 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) { sceneRef.current = { t: 0, cols: blocksFrom(placeholderGrid(cfg.size, cfg.colors)), column: Math.floor(cfg.size / 2), phase: "idle", placeholder: true, beam: null, shocks: [], sweeps: [], particles: [], pops: [], 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, cfg.size); 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.vy += 900 * dt; p.rot += p.vr * dt; if (p.life <= 0) s.particles.splice(i, 1); } s.shocks = s.shocks.filter((x) => s.t - x.at < 0.8); s.sweeps = s.sweeps.filter((x) => s.t - x.at < 0.7); s.pops = s.pops.filter((x) => s.t - x.at < 1.2); 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.column = column; s.reduceMotion = reduceMotion; }, [column, 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, cfg.size); const col = clamp(Math.floor((e.clientX - rect.left - L.ox) / L.cell), 0, cfg.size - 1); setColumn((prev) => { if (prev !== col) sound("tick"); return col; }); }, [cfg.size, sound], ); const explode = useCallback((s: Scene, L: Layout, b: Block, color: string) => { if (s.reduceMotion) return; const cx = L.ox + (b.x + 0.5) * L.cell; const cy = L.oy + (b.y + 0.5) * L.cell; const n = 7; for (let i = 0; i < n; i++) { const a = Math.random() * Math.PI * 2; const v = 120 + Math.random() * 220; 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 }); } }, []); const animate = useCallback( async (outcome: GridOutcome) => { 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, cfg.size); const colors = blockColors(palette); const steps = outcome.steps; const size = cfg.size; s.phase = "firing"; s.placeholder = false; s.particles = []; s.shocks = []; s.sweeps = []; s.pops = []; s.column = outcome.summary.column; // 1) The real grid drops in. s.cols = blocksFrom(steps[0].grid); for (const col of s.cols) for (const b of col) b.y = b.y - size - 0.5 - Math.random() * 0.8; await tween(rm ? 80 : 380, (t) => { for (const col of s.cols) for (let y = 0; y < col.length; y++) { const b = col[y]; const start = -size - 0.5 - (b.x % 3) * 0.25; b.y = lerp(start, y, easeOutCubic(clamp(t * 1.15 - b.x * 0.02, 0, 1))); } }, alive); for (const col of s.cols) for (let y = 0; y < col.length; y++) col[y].y = y; sound("tick"); // 2) The wave beam down the chosen column. s.beam = { p: 0, alpha: 1 }; await tween(rm ? 80 : 300, (t) => { if (s.beam) s.beam.p = easeInCubic(t); }, alive); const first = steps[0]; if (first.destroyed.length === 0) { s.shocks.push({ x: s.column, y: size - 1, at: s.t, kind: "miss" }); setLive("No cluster on that column"); } await tween(rm ? 60 : 220, (t) => { if (s.beam) s.beam.alpha = 1 - t; }, alive); s.beam = null; let cum = 0; for (let i = 0; i < steps.length - 1; i++) { if (!alive()) return; const step = steps[i]; const next = steps[i + 1]; const destroyed = new Set(step.destroyed.map(([x, y]) => `${x}:${y}`)); if (destroyed.size === 0) break; setChain(step.chain); const chainMult = cfg.chainLadder[Math.min(step.chain, cfg.chainLadder.length - 1)]; const x2s = step.specials.filter((sp) => sp.type === "x2").length; setLive(step.chain > 0 ? `Chain ${step.chain + 1} · ×${chainMult}${x2s ? ` · ×${2 ** x2s} block` : ""}` : x2s ? `×${2 ** x2s} block` : `Wave hit ${destroyed.size} blocks`); // Highlight + specials const hitBlocks: Block[] = []; for (const col of s.cols) for (const b of col) if (destroyed.has(`${b.x}:${Math.round(b.y)}`)) hitBlocks.push(b); await tween(rm ? 50 : 200, (t) => { for (const b of hitBlocks) { b.flash = Math.sin(t * Math.PI); b.scale = 1 + 0.12 * Math.sin(t * Math.PI); } }, alive); for (const sp of step.specials) { if (sp.type === "bomb") { s.shocks.push({ x: sp.at[0], y: sp.at[1], at: s.t, kind: "bomb" }); sound("bonus"); } else if (sp.type === "line") { s.sweeps.push({ row: sp.at[1], at: s.t }); sound("bonus"); } else if (sp.type === "x2") { s.shocks.push({ x: sp.at[0], y: sp.at[1], at: s.t, kind: "x2" }); 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 }); sound("bonus"); } } if (step.specials.length) await wait(rm ? 60 : 260); // Detonate for (const b of hitBlocks) explode(s, L(), b, colors[b.c % colors.length]); sound(step.chain >= 2 ? "bonus" : "tick"); await tween(rm ? 50 : 170, (t) => { for (const b of hitBlocks) { b.alpha = 1 - t; b.scale = 1 + 0.45 * t; b.flash = 1 - t; } }, alive); // Step win counts up const from = cum; cum += step.win; if (step.win > 0) { const Lc = L(); const cxs = step.destroyed.reduce((a, [x]) => a + x, 0) / step.destroyed.length; const cys = step.destroyed.reduce((a, [, y]) => a + y, 0) / step.destroyed.length; 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 }); } void tween(rm ? 80 : 360, (t) => setRunning(Math.round(lerp(from, cum, easeOutCubic(t)))), alive); // Gravity + refill toward the next grid type Fall = { b: Block; from: number; to: number }; const falls: Fall[] = []; const newCols: Block[][] = []; for (let x = 0; x < size; x++) { const kept = s.cols[x].filter((b) => !destroyed.has(`${b.x}:${Math.round(b.y)}`)).sort((a, b) => a.y - b.y); const freshCount = size - kept.length; const col: Block[] = []; for (let j = 0; j < freshCount; j++) { const cell = next.grid[x][j]; const b: Block = { c: cell.c, s: cell.s, x, y: j - freshCount - 0.3, alpha: 1, scale: 1, flash: 0 }; col.push(b); falls.push({ b, from: b.y, to: j }); } kept.forEach((b, j) => { const target = freshCount + j; const cell = next.grid[x][target]; // trust the server grid for colour/special (they should match) b.c = cell.c; b.s = cell.s; b.alpha = 1; b.scale = 1; b.flash = 0; if (b.y !== target) falls.push({ b, from: b.y, to: target }); col.push(b); }); newCols.push(col); } s.cols = newCols; if (falls.length) { await tween(rm ? 70 : 340, (t) => { for (const f of falls) { const k = easeOutBack(clamp(t * 1.05, 0, 1)); f.b.y = lerp(f.from, f.to, Math.min(1, Math.max(0, k))); } }, alive); for (const f of falls) f.b.y = f.to; sound("tick"); } await wait(rm ? 40 : 140); } if (!alive()) return; // Final grid = last step's grid (already what we show; enforce exactly). s.cols = blocksFrom(steps[steps.length - 1].grid); setRunning(outcome.totalWin); setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, chains: outcome.summary.chains, blocks: outcome.summary.blocksDestroyed }); 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"; }, [cfg, palette, getScene, sound, onResult, explode], ); const fire = useCallback(async () => { if (phaseRef.current !== "idle") return; phaseRef.current = "firing"; setPhase("firing"); setResult(null); setLive(null); setChain(null); setRunning(0); onBusy(true); sound("click"); const res = await play(bet, { column }); 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"; } onBusy(false); }, [play, bet, column, animate, onBusy, sound]); const firing = phase === "firing"; return (
{/* Chain ladder + running win */}
{cfg.chainLadder.map((m, i) => { const lit = chain !== null && i <= chain; const current = chain === i; return ( ×{m} ); })}
{firing ? "Running" : "Round win"}
0 ? "text-credit" : "text-fg-3")}>{running > 0 ? formatSC(running) : "—"}
{/* Grid */}
{live && firing ? ( {live} ) : null} {result && !firing ? ( 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "No cluster detonated"} {result.chains} chain{result.chains === 1 ? "" : "s"} · {result.blocks} blocks ) : null} {error ? (
{error}
) : null}
{/* Column + action */}
Column {column + 1} / {cfg.size}
); }