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