"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 (
);
}