"use client"; import { AnimatePresence, motion, type Transition } from "motion/react"; import { useId } from "react"; import { EASE_IN, EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; import { CHUTE, type CrashMiniatureProps, CrashShell, MINI, MINI_REPLAY, niceAtLeast, Parachute, PRIMARY_TEXT, STAGE_LABEL, STAGE_W, wrap, } from "./crash/crash-parts"; import { BURST, type CrashProps, EXIT, formatShort, GROWTH, growth, SWAP, timeAt, useCrashGame, } from "./crash/use-crash-game"; export { type CrashProps, type CrashReveal, crashPoint, formatMultiplier } from "./crash/use-crash-game"; // Graph geometry in SVG units, matching the shell's 10:7 stage. const W = STAGE_W; const H = 280; // The y labels sit in the right pad; the left pad clears the parked plane's tail. const PAD = { left: 18, right: 40, top: 20, bottom: 24 }; // The axes start at 8s and 2.2×, then grow so the head stays near 82% across // and 72% up, which keeps them continuous with no rescale jumps. const X_MIN = 8; // 2.2×, not lower: while the axes are fixed the head must stay under the // readout's corner (top 40%, left 45%) even on a narrow stage. const Y_MIN = 2.2; const HEAD_X = 0.82; const HEAD_Y = 0.72; /** * A crash game: a paper plane flies the multiplier up a curve until it * crashes at a random point. Cash out before it does. After a cash out the * flight keeps going, so the player sees what they left on the table. */ export function Crash({ "aria-label": ariaLabel = "Crash", className, ...options }: CrashProps) { const game = useCrashGame(options); // The shell pins the readout top left: an exponential curve never reaches // that corner, so the flight can't run under the numbers. return ( }> ); } interface GraphProps { t: number; cashed: number | null; crashed: boolean; round: number; reduce: boolean; skipping: boolean; target: number | null; } /** The flight: a curve from 1× at 0s to the current multiplier, on axes that grow with it. */ function Graph({ t, cashed, crashed, round, reduce, target, skipping }: GraphProps) { const fill = `crash-fill-${useId().replace(/:/g, "")}`; const m = growth(t); const xMax = Math.max(X_MIN, t / HEAD_X); const yMax = Math.max(Y_MIN, 1 + (m - 1) / HEAD_Y); const x = (seconds: number) => PAD.left + (seconds / xMax) * (W - PAD.left - PAD.right); const y = (value: number) => H - PAD.bottom - ((value - 1) / (yMax - 1)) * (H - PAD.top - PAD.bottom); const base = y(1); // Sampled densely enough that the bend reads as smooth at any scale. const path = (from: number, to: number) => { let d = ""; for (let i = 0; i <= 48; i++) { const s = from + ((to - from) * i) / 48; d += `${i === 0 ? "M" : "L"}${x(s).toFixed(2)},${y(growth(s)).toFixed(2)}`; } return d; }; const cashT = cashed === null ? null : timeAt(cashed); const flown = cashT === null ? t : Math.min(t, cashT); const line = path(0, flown); const area = `${line}L${x(flown).toFixed(2)},${base}L${x(0)},${base}Z`; const lost = crashed && cashed === null; const missed = cashT !== null && t > cashT && !crashed ? path(cashT, t) : null; const yStep = niceAtLeast((yMax - 1) / 4); // 1× plus round multiples of the step (500×, 1K×, not 501×), skipping one // too close to the baseline. The epsilon keeps 0.8 / 0.2 from flooring to 3. const yTicks = [1]; for (let k = Math.ceil(1 / yStep + 1e-9); k * yStep <= yMax + 1e-9; k++) { if (k * yStep - 1 >= yStep * 0.5) yTicks.push(+(k * yStep).toFixed(6)); } const xStep = niceAtLeast(xMax / 4); const xTicks = Array.from({ length: Math.floor(xMax / xStep + 1e-9) }, (_, i) => (i + 1) * xStep); const head = { x: x(t), y: y(m) }; // Bank along the curve's on-screen slope, levelling out on the runway at // 0s and easing up over the first 0.6s of take-off. A slight bob keeps it // airborne; reduced motion drops the bob and the take-off ease, so the // plane just points along the curve. const slope = Math.atan2( -((H - PAD.top - PAD.bottom) / (yMax - 1)) * GROWTH * m, (W - PAD.left - PAD.right) / xMax, ); const flying = t > 0 && !crashed; const bob = flying && !reduce ? Math.sin(t * 6) * 3 : 0; const takeOff = reduce ? (t > 0 ? 1 : 0) : Math.min(1, t / 0.6); const bank = Math.max(-70, (slope * 180) / Math.PI) * takeOff + bob; const travel = reduce ? 0 : 30 * t + 9 * Math.min(t, 20) ** 2 + 360 * Math.max(0, t - 20); const near = travel; const far = travel * 0.35; const streakLength = 4 + Math.min(14, (30 + 18 * Math.min(t, 20)) * 0.035); const marker = cashT === null || cashed === null ? null : { x: x(cashT), y: y(cashed) }; const targetLine = target === null ? null : { y: y(Math.min(target, yMax)), above: target > yMax, label: formatShort(target) }; return ( ); } // The crashed plane tumbles and drops out of the sky, accelerating. const TUMBLE: Transition = { duration: 0.6, ease: EASE_IN, opacity: { duration: 0.6, times: [0, 0.6, 1] } }; // Paper shreds thrown out by the crash, spread round the head and pulled // down a little, as if by gravity. Fixed, so every crash is the same shape. const SHREDS = Array.from({ length: 7 }, (_, i) => { const angle = ((-160 + i * 50 + (i % 2) * 14) * Math.PI) / 180; const reach = 20 + (i % 3) * 8; return { id: i, x: Math.cos(angle) * reach, y: Math.sin(angle) * reach + 12, spin: (i % 2 ? 1 : -1) * (140 + i * 30), }; }); // Speed field points, scattered over the plot by fixed strides. const PLOT_H = H - PAD.top - PAD.bottom; const FAR = Array.from({ length: 22 }, (_, i) => ({ id: i, x: (i * 97.3) % W, y: PAD.top + ((i * 53.7) % PLOT_H) })); const NEAR = Array.from({ length: 9 }, (_, i) => ({ id: i, x: (i * 151.9) % W, y: PAD.top + 8 + ((i * 71.3) % (PLOT_H - 16)) })); /** A folded paper plane, nose to the right, about 26 units long. */ function PaperPlane() { return ( ); } /** * The round's flight in miniature, on its own axes so a skip to a huge crash * still reads: the curve replays up to your cash out, runs on dashed to the * crash, and ends in a cross; a missed target is a dashed line above. */ function MiniFlight({ crash, cashed, target, reduce, }: CrashMiniatureProps) { const top = Math.max(crash, target ?? 1, 1.01); const end = timeAt(top); const x = (seconds: number) => 4 + (seconds / end) * (MINI.width - 8); const y = (value: number) => MINI.height - 4 - ((value - 1) / (top - 1)) * (MINI.height - 8); const path = (from: number, to: number) => { let d = ""; for (let i = 0; i <= 24; i++) { const s = from + ((to - from) * i) / 24; d += `${i === 0 ? "M" : "L"}${x(s).toFixed(2)},${y(growth(s)).toFixed(2)}`; } return d; }; const crashT = timeAt(crash); const cashT = cashed === null ? null : timeAt(cashed); const draw = (delay: number) => ({ initial: { pathLength: reduce ? 1 : 0 }, animate: { pathLength: 1 }, transition: { ...MINI_REPLAY, delay: reduce ? 0 : delay }, }); return ( ); }