"use client"; /** * DROPZONE — browser side. The server resolves the whole descent; this file * only animates `outcome.steps` (half-lane x per row, gates, portals), the * landing bucket and the optional Deep Drop extension on a 2D canvas. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { formatMultiplier, formatSC } from "@spinza/shared"; import type { ArcadeGameDefinition, ArcadeOutcome, DropStep, DropzoneConfig } from "@spinza/game-core/client"; import { cn } from "@/lib/utils"; import { useInstantPlay, type ArcadeGameProps } from "./contract"; type Risk = "low" | "medium" | "high"; type DropSummary = { risk: Risk; startLane: number; bucket: number; bucketValue: number; gateMult: number; deep: { steps: DropStep[]; bucket: number; value: number } | null; table: number[]; }; interface DropOutcome extends ArcadeOutcome { steps: DropStep[]; summary: DropSummary; } /* ------------------------------------------------------------------ math */ /* Pure replica of the server's displayed bucket values (see game-core/arcade/dropzone.ts). */ function bucketDistribution(cfg: DropzoneConfig, startLane: number): number[] { const width = cfg.lanes * 2; let dist: number[] = new Array(width).fill(0); dist[startLane * 2 + 1] = 1; for (let row = 0; row < cfg.rows; row++) { const next: number[] = new Array(width).fill(0); for (let x = 0; x < width; x++) { if (!dist[x]) continue; next[Math.max(0, x - 1)] += dist[x] / 2; next[Math.min(width - 1, x + 1)] += dist[x] / 2; } dist = next; } const buckets: number[] = new Array(cfg.lanes).fill(0); for (let x = 0; x < width; x++) buckets[Math.min(cfg.lanes - 1, Math.floor(x / 2))] += dist[x]; return buckets; } function laneNormalizer(cfg: DropzoneConfig, risk: Risk, startLane: number): number { const table = cfg.buckets[risk]; const center = Math.floor(cfg.lanes / 2); const ev = (lane: number) => bucketDistribution(cfg, lane).reduce((a, p, k) => a + p * table[k], 0); return ev(center) / ev(startLane); } function displayedBuckets(def: ArcadeGameDefinition, cfg: DropzoneConfig, risk: Risk, startLane: number): number[] { const norm = laneNormalizer(cfg, risk, startLane); return cfg.buckets[risk].map((v) => Math.round(v * norm * def.payScale * 100) / 100); } /* --------------------------------------------------------------- helpers */ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); const easeInQuad = (t: number) => 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 Particle { x: number; y: number; vx: number; vy: number; life: number; max: number; color: string; size: number; } interface Capsule { x: number; // half-lane units y: number; // row units (0 = release line, r+1 = after row r) deep: boolean; visible: boolean; alpha: number; squash: number; // 1 = round glow: number; } interface Scene { t: number; lane: number; risk: Risk; values: number[]; phase: "idle" | "dropping"; camY: number; // in row units capsule: Capsule; trail: { x: number; y: number; deep: boolean; life: number }[]; gates: { x: number; y: number; value: number; at: number }[]; portals: { x: number; to: number; y: number; at: number }[]; landed: number | null; landedAt: number; deepReveal: number; deepLanded: number | null; deepLandedAt: number; particles: Particle[]; flash: { text: string; at: number; color: string } | null; reduceMotion: boolean; } const MARKER_UNITS = 1.2; const BUCKET_UNITS = 1.5; const DEEP_GAP_UNITS = 0.9; interface Layout { w: number; h: number; pad: number; hl: number; rowH: number; left: number; deepLeft: number; deepStart: number; // row units where the deep release line sits totalDeepUnits: number; } function layoutFor(w: number, h: number, cfg: DropzoneConfig): Layout { const mainUnits = MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.5; let hl = (Math.min(w, 620) - 20) / (cfg.lanes * 2); let rowH = Math.min((h - 20) / mainUnits, hl * 2.1); if (rowH < hl * 1.05) { // very short viewport: shrink horizontally to keep pegs readable hl = rowH / 1.05; rowH = hl * 1.05; } // Centre the tower vertically when the viewport is taller than needed (tall phones). const pad = Math.max(10, (h - mainUnits * rowH) / 2); const left = (w - hl * cfg.lanes * 2) / 2; const deepLeft = left + ((cfg.lanes * 2 - cfg.deepBuckets.length * 2) / 2) * hl; const deepStart = cfg.rows + BUCKET_UNITS + DEEP_GAP_UNITS; return { w, h, pad, hl, rowH, left, deepLeft, deepStart, totalDeepUnits: deepStart + cfg.deepRows + BUCKET_UNITS }; } function toPx(L: Layout, s: Scene, x: number, y: number, deep: boolean): { px: number; py: number } { const px = (deep ? L.deepLeft : L.left) + (x + 0.5) * L.hl; const py = L.pad + (MARKER_UNITS + (deep ? L.deepStart + y : y) - s.camY) * L.rowH; return { px, py }; } function heat(v: number, max: number): number { if (max <= 0) return 0; return clamp(Math.log1p(v) / Math.log1p(max), 0, 1); } /* --------------------------------------------------------------- drawing */ interface Palette { primary: string; secondary: string; glow: string; bg: string; surface: string; } function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: DropzoneConfig, palette: Palette) { const { w, h, hl, rowH } = L; ctx.clearRect(0, 0, w, h); const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`; // Tower glass backdrop const towerX = L.left - hl * 0.6; const towerW = hl * cfg.lanes * 2 + hl * 1.2; const towerTop = L.pad + (MARKER_UNITS - 0.6 - s.camY) * rowH; const towerBottom = L.pad + (MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.15 - s.camY) * rowH; const bg = ctx.createLinearGradient(0, towerTop, 0, towerBottom); bg.addColorStop(0, "rgba(255,255,255,0.035)"); bg.addColorStop(1, "rgba(255,255,255,0.012)"); ctx.fillStyle = bg; ctx.beginPath(); ctx.roundRect(towerX, towerTop, towerW, towerBottom - towerTop, 18); ctx.fill(); ctx.strokeStyle = rgba(palette.primary, 0.18); ctx.lineWidth = 1; ctx.stroke(); // Side rails ctx.strokeStyle = rgba(palette.primary, 0.35); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(towerX, towerTop + 14); ctx.lineTo(towerX, towerBottom - 14); ctx.moveTo(towerX + towerW, towerTop + 14); ctx.lineTo(towerX + towerW, towerBottom - 14); ctx.stroke(); // Pegs (main) const pegR = Math.max(2, hl * 0.17); for (let r = 0; r < cfg.rows; r++) { const parity = r % 2 === 0 ? 1 : 0; const y = L.pad + (MARKER_UNITS + r + 0.5 - s.camY) * rowH; if (y < -10 || y > h + 10) continue; for (let x = parity; x < cfg.lanes * 2; x += 2) { const px = L.left + (x + 0.5) * hl; ctx.beginPath(); ctx.arc(px, y, pegR, 0, Math.PI * 2); ctx.fillStyle = "rgba(255,255,255,0.55)"; ctx.fill(); ctx.beginPath(); ctx.arc(px, y, pegR * 2.2, 0, Math.PI * 2); ctx.fillStyle = rgba(palette.primary, 0.08); ctx.fill(); } } // Buckets (main) const maxV = Math.max(...s.values, 1); const bTop = L.pad + (MARKER_UNITS + cfg.rows + 0.15 - s.camY) * rowH; const bH = (BUCKET_UNITS - 0.3) * rowH; for (let k = 0; k < cfg.lanes; k++) { const x0 = L.left + k * 2 * hl + 1.5; const bw = 2 * hl - 3; const v = s.values[k] ?? 0; const t = heat(v, maxV); const base = v >= 50 ? "#ffd66b" : mixHex(palette.primary, palette.secondary, t); const lit = s.landed === k; const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.landedAt) * 8) : 0; ctx.beginPath(); ctx.roundRect(x0, bTop, bw, bH, Math.min(6, hl * 0.35)); ctx.fillStyle = lit ? rgba(base.startsWith("#") ? base : palette.glow, 0.55 + 0.35 * pulse) : rgba("#ffffff", 0.05 + t * 0.06); ctx.fill(); ctx.strokeStyle = lit ? "#ffffff" : base.startsWith("#") ? rgba(base, 0.35 + t * 0.4) : base; ctx.lineWidth = lit ? 2 : 1; ctx.stroke(); // Value const label = v > 0 && v < 0.01 ? "<0.01×" : formatMultiplier(v); const size = Math.max(8, Math.min(13, hl * 0.72)); ctx.font = font(size, 800); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = lit ? "#0b1020" : base.startsWith("#") ? base : mixHex(palette.primary, palette.secondary, t); ctx.fillText(label, x0 + bw / 2, bTop + bH / 2, bw - 2); } // Deep section if (s.deepReveal > 0) { ctx.save(); ctx.globalAlpha = s.deepReveal; const dTop = L.pad + (MARKER_UNITS + L.deepStart - 0.6 - s.camY) * rowH; const dBottom = L.pad + (MARKER_UNITS + L.totalDeepUnits + 0.15 - s.camY) * rowH; const dX = L.deepLeft - hl * 0.6; const dW = hl * cfg.deepBuckets.length * 2 + hl * 1.2; const dg = ctx.createLinearGradient(0, dTop, 0, dBottom); dg.addColorStop(0, rgba(palette.secondary, 0.12)); dg.addColorStop(1, rgba(palette.secondary, 0.03)); ctx.fillStyle = dg; ctx.beginPath(); ctx.roundRect(dX, dTop, dW, dBottom - dTop, 18); ctx.fill(); ctx.strokeStyle = rgba(palette.secondary, 0.4); ctx.lineWidth = 1.5; ctx.stroke(); // Funnel between main buckets and deep tower ctx.strokeStyle = rgba(palette.secondary, 0.5); ctx.setLineDash([4, 6]); ctx.beginPath(); ctx.moveTo(towerX + 8, towerBottom); ctx.lineTo(dX + 8, dTop); ctx.moveTo(towerX + towerW - 8, towerBottom); ctx.lineTo(dX + dW - 8, dTop); ctx.stroke(); ctx.setLineDash([]); ctx.font = font(11, 800); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = palette.secondary; ctx.fillText("DEEP DROP", w / 2, dTop + 14); for (let r = 0; r < cfg.deepRows; r++) { const parity = r % 2 === 0 ? 1 : 0; const y = L.pad + (MARKER_UNITS + L.deepStart + r + 0.5 - s.camY) * rowH; if (y < -10 || y > h + 10) continue; for (let x = parity; x < cfg.deepBuckets.length * 2; x += 2) { const px = L.deepLeft + (x + 0.5) * hl; ctx.beginPath(); ctx.arc(px, y, pegR, 0, Math.PI * 2); ctx.fillStyle = rgba(palette.secondary, 0.8); ctx.fill(); } } const dbTop = L.pad + (MARKER_UNITS + L.deepStart + cfg.deepRows + 0.15 - s.camY) * rowH; for (let k = 0; k < cfg.deepBuckets.length; k++) { const x0 = L.deepLeft + k * 2 * hl + 1.5; const bw = 2 * hl - 3; const v = cfg.deepBuckets[k]; const lit = s.deepLanded === k; const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.deepLandedAt) * 8) : 0; const col = v === 0 ? "#ff5c7a" : v >= 10 ? "#ffd66b" : palette.secondary; ctx.beginPath(); ctx.roundRect(x0, dbTop, bw, bH, Math.min(6, hl * 0.35)); ctx.fillStyle = lit ? rgba(col, 0.55 + 0.35 * pulse) : rgba(col, 0.1); ctx.fill(); ctx.strokeStyle = lit ? "#ffffff" : rgba(col, 0.5); ctx.lineWidth = lit ? 2 : 1; ctx.stroke(); ctx.font = font(Math.max(9, Math.min(13, hl * 0.78)), 800); ctx.fillStyle = lit ? "#0b1020" : col; ctx.fillText(`×${v}`, x0 + bw / 2, dbTop + bH / 2, bw - 2); } ctx.restore(); } // Gate rings for (const g of s.gates) { const age = s.t - g.at; const { px, py } = toPx(L, s, g.x, g.y, false); const k = Math.max(0, 1 - age / 1.6); ctx.save(); ctx.globalAlpha = 0.35 + 0.65 * k; ctx.strokeStyle = palette.secondary; ctx.lineWidth = 2 + 2 * k; ctx.shadowColor = palette.secondary; ctx.shadowBlur = 16 * k; ctx.beginPath(); ctx.ellipse(px, py, hl * (1.05 + 0.4 * Math.min(1, age * 3)), hl * 0.42, 0, 0, Math.PI * 2); ctx.stroke(); ctx.shadowBlur = 0; ctx.font = font(Math.max(11, hl * 0.95), 900); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = "#ffffff"; const rise = Math.min(1, age * 1.4); ctx.fillText(`×${g.value}`, px, py - hl * 1.1 - rise * hl * 0.8); ctx.restore(); } // Portals for (const p of s.portals) { const age = s.t - p.at; const k = Math.max(0, 1 - age / 1.8); const a = toPx(L, s, p.x, p.y, false); const b = toPx(L, s, p.to, p.y, false); ctx.save(); ctx.globalAlpha = 0.25 + 0.75 * k; ctx.strokeStyle = "#a78bfa"; ctx.lineWidth = 1.5; ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.moveTo(a.px, a.py); ctx.lineTo(b.px, b.py); ctx.stroke(); ctx.setLineDash([]); for (const q of [a, b]) { ctx.beginPath(); ctx.ellipse(q.px, q.py, hl * 0.75, hl * 0.35, 0, 0, Math.PI * 2); ctx.strokeStyle = "#c4b5fd"; ctx.lineWidth = 2; ctx.shadowColor = "#a78bfa"; ctx.shadowBlur = 12 * k; ctx.stroke(); } ctx.restore(); } // Trail for (const tr of s.trail) { const { px, py } = toPx(L, s, tr.x, tr.y, tr.deep); ctx.beginPath(); ctx.arc(px, py, hl * 0.32 * tr.life, 0, Math.PI * 2); ctx.fillStyle = rgba(palette.glow, 0.25 * tr.life); ctx.fill(); } // Release marker (idle) if (s.phase === "idle") { const { px, py } = toPx(L, s, s.lane * 2 + 1, -0.75, false); const bob = Math.sin(s.t * 2.2) * 2; ctx.save(); ctx.strokeStyle = rgba(palette.primary, 0.35); ctx.setLineDash([2, 6]); ctx.beginPath(); ctx.moveTo(px, py + hl * 0.6); ctx.lineTo(px, L.pad + (MARKER_UNITS + cfg.rows - s.camY) * rowH); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = rgba(palette.primary, 0.9); ctx.beginPath(); ctx.moveTo(px - hl * 0.7, py - hl * 0.35 + bob); ctx.lineTo(px + hl * 0.7, py - hl * 0.35 + bob); ctx.lineTo(px, py + hl * 0.45 + bob); ctx.closePath(); ctx.fill(); ctx.restore(); } // Capsule if (s.capsule.visible && s.capsule.alpha > 0) { const c = s.capsule; const { px, py } = toPx(L, s, c.x, c.y, c.deep); const r = hl * 0.42; ctx.save(); ctx.globalAlpha = c.alpha; ctx.translate(px, py); ctx.scale(1 / Math.sqrt(c.squash), Math.sqrt(c.squash)); ctx.shadowColor = palette.glow; ctx.shadowBlur = 14 + 22 * c.glow; const grad = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.1, 0, 0, r); grad.addColorStop(0, "#ffffff"); grad.addColorStop(0.35, palette.glow); grad.addColorStop(1, palette.primary); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; ctx.strokeStyle = "rgba(255,255,255,0.75)"; ctx.lineWidth = 1.2; ctx.stroke(); ctx.restore(); } // Particles for (const p of s.particles) { const k = p.life / p.max; ctx.globalAlpha = k; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * k), 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; // Centre flash text (deep multiplier etc.) if (s.flash) { const age = s.t - s.flash.at; if (age < 1.6) { const k = age < 0.25 ? easeOutBack(age / 0.25) : 1; const fade = age > 1.1 ? 1 - (age - 1.1) / 0.5 : 1; ctx.save(); ctx.globalAlpha = Math.max(0, fade); ctx.translate(w / 2, h * 0.42); ctx.scale(k, k); ctx.font = font(Math.min(54, w * 0.13), 900); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.shadowColor = s.flash.color; ctx.shadowBlur = 30; ctx.fillStyle = s.flash.color; ctx.fillText(s.flash.text, 0, 0); ctx.restore(); } } } /* ------------------------------------------------------------- component */ const RISKS: { id: Risk; label: string }[] = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ]; export function DropzoneGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { const cfg = definition.config as unknown as DropzoneConfig; const palette = definition.presentation.palette; const { play, error, clearError } = useInstantPlay(definition.slug); const [risk, setRisk] = useState("medium"); const [lane, setLane] = useState(Math.floor(cfg.lanes / 2)); const [phase, setPhase] = useState<"idle" | "dropping">("idle"); const [live, setLive] = useState(null); const [result, setResult] = useState<{ win: number; multiplier: number; parts: string } | null>(null); const canvasRef = useRef(null); const sceneRef = useRef(null); const aliveRef = useRef(true); const phaseRef = useRef<"idle" | "dropping">("idle"); const values = useMemo(() => displayedBuckets(definition, cfg, risk, lane), [definition, cfg, risk, lane]); const getScene = useCallback((): Scene => { if (!sceneRef.current) { const mid = Math.floor(cfg.lanes / 2); sceneRef.current = { t: 0, lane: mid, risk: "medium", values: displayedBuckets(definition, cfg, "medium", mid), phase: "idle", camY: 0, capsule: { x: mid * 2 + 1, y: 0, deep: false, visible: false, alpha: 1, squash: 1, glow: 0 }, trail: [], gates: [], portals: [], landed: null, landedAt: 0, deepReveal: 0, deepLanded: null, deepLandedAt: 0, particles: [], flash: null, reduceMotion: false, }; } return sceneRef.current; }, [definition, cfg]); /* Render loop */ 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); // particles 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 += 420 * dt; p.vx *= 0.98; if (p.life <= 0) s.particles.splice(i, 1); } for (let i = s.trail.length - 1; i >= 0; i--) { s.trail[i].life -= dt * 3.2; if (s.trail[i].life <= 0) s.trail.splice(i, 1); } 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 }); s.capsule.glow = Math.max(0, s.capsule.glow - dt * 1.5); drawScene(ctx, L, s, cfg, palette); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => { aliveRef.current = false; cancelAnimationFrame(raf); }; }, [cfg, palette, getScene]); /* Sync UI state into the scene */ useEffect(() => { const s = getScene(); s.lane = lane; s.risk = risk; s.values = values; s.reduceMotion = reduceMotion; if (s.phase === "idle") { s.capsule.x = lane * 2 + 1; } }, [lane, risk, values, reduceMotion, getScene]); const laneFromPointer = useCallback( (e: React.PointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const L = layoutFor(rect.width, rect.height, cfg); const x = e.clientX - rect.left; return clamp(Math.floor((x - L.left) / (L.hl * 2)), 0, cfg.lanes - 1); }, [cfg], ); const onPointer = useCallback( (e: React.PointerEvent) => { if (phaseRef.current !== "idle") return; if (e.type === "pointermove" && e.buttons === 0) return; const l = laneFromPointer(e); setLane((prev) => { if (prev !== l) sound("tick"); return l; }); }, [laneFromPointer, sound], ); const spawnBurst = useCallback((s: Scene, L: Layout, x: number, y: number, deep: boolean, n: number, color: string, speed: number) => { if (s.reduceMotion) return; const { px, py } = toPx(L, s, x, y, deep); for (let i = 0; i < n; i++) { const a = Math.random() * Math.PI * 2; const v = speed * (0.4 + Math.random() * 0.8); 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 }); } }, []); const animate = useCallback( async (outcome: DropOutcome) => { const s = getScene(); const canvas = canvasRef.current; const alive = () => aliveRef.current; const rm = s.reduceMotion; const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 600 }; const L = () => layoutFor(rect.width, rect.height, cfg); const rowMs = rm ? 45 : 150; // reset s.phase = "dropping"; s.gates = []; s.portals = []; s.landed = null; s.deepLanded = null; s.deepReveal = 0; s.flash = null; s.particles = []; s.trail = []; s.values = outcome.summary.table; if (s.camY > 0) await tween(rm ? 60 : 320, (t) => (s.camY = lerp(s.camY, 0, easeOutCubic(t))), alive); s.camY = 0; const cap = s.capsule; cap.deep = false; cap.x = outcome.summary.startLane * 2 + 1; cap.y = -0.75; cap.alpha = 1; cap.squash = 1; cap.visible = true; cap.glow = 1; await tween(rm ? 60 : 220, (t) => (cap.y = lerp(-0.75, 0, easeInQuad(t))), alive); let gateMult = 1; const runSteps = async (steps: DropStep[], deep: boolean) => { for (let i = 0; i < steps.length; i++) { if (!alive()) return; const step = steps[i]; const fromX = cap.x; const toX = step.x; const r = step.row; await tween(rowMs, (t) => { if (t < 0.5) { cap.y = lerp(r, r + 0.5, easeInQuad(t / 0.5)); cap.squash = 1; } else { const k = (t - 0.5) / 0.5; cap.x = lerp(fromX, toX, easeOutCubic(k)); cap.y = lerp(r + 0.5, r + 1, k); cap.squash = 1 + 0.45 * Math.sin(k * Math.PI) * (k < 0.5 ? 1 : 0.4); } }, alive); spawnBurst(s, L(), fromX, r + 0.5, deep, 3, "#ffffff", 90); if (i % 2 === 0) sound("tick"); if (!deep && step.event?.type === "gate") { gateMult *= step.event.value; s.gates.push({ x: toX, y: r + 1, value: step.event.value, at: s.t }); cap.glow = 1; spawnBurst(s, L(), toX, r + 1, false, 14, palette.secondary, 160); sound("bonus"); setLive(`Gate ×${step.event.value} · total ×${gateMult}`); await wait(rm ? 80 : 260); } else if (!deep && step.event?.type === "portal") { const to = step.event.to; s.portals.push({ x: toX, to, y: r + 1, at: s.t }); sound("bonus"); setLive(`Portal → lane ${Math.floor(to / 2) + 1}`); await tween(rm ? 40 : 160, (t) => (cap.alpha = 1 - t), alive); spawnBurst(s, L(), toX, r + 1, false, 10, "#a78bfa", 120); cap.x = to; spawnBurst(s, L(), to, r + 1, false, 10, "#a78bfa", 120); await tween(rm ? 40 : 160, (t) => (cap.alpha = t), alive); cap.alpha = 1; } } }; await runSteps(outcome.steps, false); if (!alive()) return; // Landing in the main bucket const bucket = outcome.summary.bucket; const fromX = cap.x; await tween(rm ? 60 : 260, (t) => { cap.x = lerp(fromX, bucket * 2 + 1, easeOutCubic(t)); cap.y = lerp(cfg.rows, cfg.rows + 0.85, easeOutBack(t)); cap.squash = 1 + 0.3 * Math.sin(t * Math.PI); }, alive); s.landed = bucket; s.landedAt = s.t; cap.glow = 1; spawnBurst(s, L(), bucket * 2 + 1, cfg.rows + 0.85, false, 18, palette.glow, 180); const bucketValue = outcome.summary.bucketValue; setLive(gateMult > 1 ? `${formatMultiplier(bucketValue)} bucket × gates ×${gateMult}` : `${formatMultiplier(bucketValue)} bucket`); sound(bucketValue * gateMult >= 1 ? "win" : "tick"); // Deep Drop const deep = outcome.summary.deep; if (deep) { await wait(rm ? 80 : 420); sound("bonus"); s.flash = { text: "DEEP DROP", at: s.t, color: palette.secondary }; const Ld = L(); const visibleUnits = (Ld.h - Ld.pad * 2) / Ld.rowH - MARKER_UNITS; const targetCam = Math.max(0, Ld.totalDeepUnits + 0.4 - visibleUnits); const deepStartX = clamp(bucket * 2 + 1, 0, cfg.deepBuckets.length * 2 - 1); await tween(rm ? 120 : 800, (t) => { const k = easeOutCubic(t); s.deepReveal = Math.min(1, t * 1.6); s.camY = targetCam * k; }, alive); // capsule falls through the bucket floor into the deep release line await tween(rm ? 60 : 360, (t) => { cap.alpha = t < 0.5 ? 1 - t * 2 : (t - 0.5) * 2; if (t >= 0.5 && !cap.deep) { cap.deep = true; cap.x = deepStartX; cap.y = -0.6; } if (t >= 0.5) cap.y = lerp(-0.6, 0, (t - 0.5) * 2); }, alive); cap.alpha = 1; cap.deep = true; cap.x = deepStartX; cap.y = 0; await runSteps(deep.steps, true); if (!alive()) return; const fx = cap.x; await tween(rm ? 60 : 260, (t) => { cap.x = lerp(fx, deep.bucket * 2 + 1, easeOutCubic(t)); cap.y = lerp(cfg.deepRows, cfg.deepRows + 0.85, easeOutBack(t)); cap.squash = 1 + 0.3 * Math.sin(t * Math.PI); }, alive); s.deepLanded = deep.bucket; s.deepLandedAt = s.t; const col = deep.value === 0 ? "#ff5c7a" : deep.value >= 10 ? "#ffd66b" : palette.secondary; s.flash = { text: `×${deep.value}`, at: s.t, color: col }; spawnBurst(s, L(), deep.bucket * 2 + 1, cfg.deepRows + 0.85, true, 24, col, 220); setLive(deep.value === 0 ? "Deep bucket ×0 — vanished" : `Deep bucket ×${deep.value}`); await wait(rm ? 100 : 500); } if (!alive()) return; const parts = gateMult > 1 || deep ? [`${formatMultiplier(bucketValue)} bucket`, gateMult > 1 ? `×${gateMult} gates` : null, deep ? `×${deep.value} deep` : null].filter(Boolean).join(" · ") : `Bucket ${bucket + 1}`; setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, parts }); 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, spawnBurst], ); const drop = useCallback(async () => { if (phaseRef.current !== "idle") return; phaseRef.current = "dropping"; setPhase("dropping"); setResult(null); setLive(null); onBusy(true); sound("click"); const res = await play(bet, { risk, lane }); 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, risk, lane, animate, onBusy, sound]); const dropping = phase === "dropping"; const maxValue = Math.max(...values); return (
{/* Risk selector */}
{RISKS.map((r) => { const active = risk === r.id; return ( ); })}
Top bucket
{formatMultiplier(maxValue)}
{/* Tower */}
{live && dropping ? ( {live} ) : null} {result && !dropping ? ( 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "No win this drop"} {result.parts} ) : null} {error ? (
{error}
) : null}
{/* Lane + action */}
Lane {lane + 1} / {cfg.lanes}
); }