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