"use client"; import { Gem, Volume2, VolumeX } from "lucide-react"; import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react"; import { ActionSwapCascadeIcon } from "@/components/motion/action-swap-cascade"; import { AdaptiveStepper, AdaptiveStepperDecrement, AdaptiveStepperIncrement, AdaptiveStepperValue, } from "@/components/motion/adaptive-stepper"; import { Button } from "@/components/motion/button"; import { NumberTicker } from "@/components/motion/number-ticker"; import { Tooltip } from "@/components/motion/tooltip"; import { EASE_OUT } from "@/lib/ease"; import { createGameSounds, type GameSounds } from "@/lib/game-sounds"; import { PresenceGate, type PresenceGateRenderProps } from "@/lib/presence-gate"; import { cn } from "@/lib/utils"; export type MinesTile = "gem" | "mine"; export interface MinesProps { /** Tiles per side, rounded, at least 2. Default 5 (a 5 × 5 board). Changing it ends the round. */ size?: number; /** Mines on the board (controlled), rounded and kept between 1 and tiles − 1. Takes effect from the next round. */ mines?: number; /** Initial mine count when uncontrolled. Default 3. */ defaultMines?: number; /** Called when the mine count changes between rounds. */ onMinesChange?: (mines: number) => void; /** Called for every tile picked during a round; a mine reports multiplier 0 and ends it. */ onReveal?: (tile: number, result: MinesTile, multiplier: number) => void; /** Called when the player cashes out, or clears every gem. */ onCashOut?: (multiplier: number, gems: number) => void; /** Play synthesized sounds (controlled): gem chimes, a mine blast and a cash-out arpeggio. */ sound?: boolean; /** Initial sound state when uncontrolled. Default false. */ defaultSound?: boolean; /** Called when the sound button is toggled. */ onSoundChange?: (sound: boolean) => void; /** Show the result card when a round ends. Default true. */ showResult?: boolean; /** Accessible name for the game. Default "Mines". */ "aria-label"?: string; className?: string; } type Phase = "idle" | "playing" | "busted" | "cashed"; /** * Fair payout after `gems` safe picks on a board of `tiles` with `mines`: * the inverse of the chance of picking that many gems in a row. `mines` is * kept between 0 and tiles − 1 and `gems` between 0 and the safe tiles, so * the result is always finite. */ export function minesMultiplier(tiles: number, mines: number, gems: number) { const safeMines = Math.max(0, Math.min(Math.round(mines), tiles - 1)); const safeGems = Math.max(0, Math.min(Math.round(gems), tiles - safeMines)); let multiplier = 1; for (let i = 0; i < safeGems; i++) multiplier *= (tiles - i) / (tiles - safeMines - i); return multiplier; } // Revealed icons arrive from a quarter size, transparent and blurred, on a // spring with no bounce. A round's closing reveal ripples outward from the // last pick, one ring of tiles every 25ms. const ICON_SPRING = { type: "spring", duration: 0.3, bounce: 0 } as const; const RIPPLE_STEP = 0.025; // A mine shakes once, quickly: 300ms, settling to rest. const SHAKE = { duration: 0.3, ease: EASE_OUT } as const; // A found gem's tile springs back from a slight squash. const GEM_POP = { type: "spring", duration: 0.35, bounce: 0.4 } as const; // The cash-out pulse runs through the picked gems 40ms apart over 350ms, in // seconds. The result opens once the last tile's closing animation is done. const PULSE_STEP = 0.04; const PULSE_TIME = 0.35; // Exits are shorter than entrances. const EXIT = { duration: 0.12, ease: EASE_OUT } as const; /** * A mines board: pick tiles to find gems, each raising the multiplier, and * cash out before you hit a mine. The round's end reveals every tile. */ export function Mines({ size: sizeProp = 5, mines: minesProp, defaultMines = 3, onMinesChange, onReveal, onCashOut, sound, defaultSound = false, onSoundChange, showResult = true, "aria-label": ariaLabel = "Mines", className, }: MinesProps) { const reduce = useReducedMotion() ?? false; const size = Math.max(2, Math.round(sizeProp) || 2); useEffect(() => { if (process.env.NODE_ENV !== "production" && size !== sizeProp) console.warn(`Mines: size must be a whole number of at least 2; using ${size}.`); }, [size, sizeProp]); const tiles = size * size; const [internalMines, setInternalMines] = useState(defaultMines); const chosenMines = Math.min(Math.max(1, Math.round(minesProp ?? internalMines) || 1), tiles - 1); const [phase, setPhase] = useState("idle"); const [mineSet, setMineSet] = useState>(new Set()); const [picked, setPicked] = useState([]); const [cursor, setCursor] = useState(0); const [announcement, setAnnouncement] = useState(""); const [resultOpen, setResultOpen] = useState(false); const sectionRef = useRef(null); const mainButtonRef = useRef(null); const tileRefs = useRef<(HTMLButtonElement | null)[]>([]); const [, animate] = useAnimate(); // Only sets state, so the render-time size reset below can call it. const resetBoard = () => { setPhase("idle"); setPicked([]); setMineSet(new Set()); setResultOpen(false); }; // A new size renumbers every tile, so the mines placed for the old board no // longer match it. End the round in the same render. const [boardSize, setBoardSize] = useState(size); if (boardSize !== size) { setBoardSize(size); resetBoard(); setCursor(0); } // Once a round starts, its payout follows the mines actually on the board, // even if a controlled `mines` changes before the next round. const mines = phase === "idle" ? chosenMines : mineSet.size; const gems = picked.filter((tile) => !mineSet.has(tile)).length; const multiplier = minesMultiplier(tiles, mines, gems); const next = minesMultiplier(tiles, mines, gems + 1); const over = phase === "busted" || phase === "cashed"; // Chance the next pick is a gem. const safe = (tiles - mines - gems) / (tiles - gems); const lastPick = picked.at(-1) ?? Math.floor(tiles / 2); // Turning showResult off hides an open ticket too. const resultShown = resultOpen && showResult && over; const [internalSound, setInternalSound] = useState(defaultSound); const soundOn = sound ?? internalSound; const player = useRef(null); const getPlayer = () => { if (!player.current) player.current = createGameSounds(); return player.current; }; useEffect( () => () => { player.current?.dispose(); player.current = null; }, [], ); const setMines = (value: number) => { // A finished board shows the old count's mines; clear it for the new one. if (over) resetBoard(); if (minesProp === undefined) setInternalMines(value); onMinesChange?.(value); }; const closeResult = () => { setResultOpen(false); mainButtonRef.current?.focus(); }; const start = () => { resetBoard(); // Mines are placed when the round starts, never on the server. const order = Array.from({ length: tiles }, (_, i) => i); for (let i = order.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; } setMineSet(new Set(order.slice(0, chosenMines))); setPhase("playing"); if (!reduce) { tileRefs.current.forEach((tile, index) => { if (!tile) return; const wave = Math.floor(index / size) + (index % size); animate(tile, { scale: [0.94, 1], opacity: [0.5, 1] }, { duration: 0.2, delay: wave * 0.025, ease: EASE_OUT }); }); } setAnnouncement(`Round started with ${chosenMines} ${chosenMines === 1 ? "mine" : "mines"}.`); if (soundOn) getPlayer().prepare(); }; const cashOut = (finalGems = gems) => { if (phase !== "playing" || finalGems === 0) return; const value = minesMultiplier(tiles, mines, finalGems); setPhase("cashed"); setAnnouncement(`Cashed out at ${formatMultiplier(value)}.`); if (soundOn) getPlayer().cashOut(); onCashOut?.(value, finalGems); }; const pick = (tile: number) => { if (phase !== "playing" || picked.includes(tile)) return; setPicked((current) => [...current, tile]); if (mineSet.has(tile)) { setPhase("busted"); setAnnouncement("Mine. The round is over."); if (soundOn) getPlayer().mine(); onReveal?.(tile, "mine", 0); return; } const found = gems + 1; const node = tileRefs.current[tile]; if (node && !reduce) animate(node, { scale: [0.92, 1] }, GEM_POP); const value = minesMultiplier(tiles, mines, found); if (soundOn) getPlayer().gem(found); onReveal?.(tile, "gem", value); if (found === tiles - mines) { cashOut(found); return; } setAnnouncement(`Gem. ${formatMultiplier(value)}.`); }; const onKeyDown = (event: KeyboardEvent, tile: number) => { const row = Math.floor(tile / size); const col = tile % size; const target = { ArrowRight: row * size + Math.min(size - 1, col + 1), ArrowLeft: row * size + Math.max(0, col - 1), ArrowDown: Math.min(size - 1, row + 1) * size + col, ArrowUp: Math.max(0, row - 1) * size + col, Home: row * size, End: row * size + size - 1, }[event.key]; if (target === undefined) return; event.preventDefault(); setCursor(target); tileRefs.current[target]?.focus(); }; // When the board's closing animation ends, in seconds: unpicked tiles // reveal in a ripple from the last pick, and picked gems pulse on a cash out. let closingEnd = 0; if (over) { for (let tile = 0; tile < tiles; tile++) { const order = picked.indexOf(tile); const end = order === -1 ? ringFrom(lastPick, tile, size) * RIPPLE_STEP + ICON_SPRING.duration : phase === "cashed" && !reduce ? order * PULSE_STEP + PULSE_TIME : 0; closingEnd = Math.max(closingEnd, end); } } // The ticket rises once the closing animation has played. A timer, not an // animation callback, so a cut-short animation can't keep it shut; a new // round or a reset clears it. useEffect(() => { if (!over || !showResult) return; const id = setTimeout(() => { // The main button already says Play again, so a player still on the // board lands there. Focus elsewhere on the page stays put. if (sectionRef.current?.contains(document.activeElement)) mainButtonRef.current?.focus({ preventScroll: true }); setResultOpen(true); }, closingEnd * 1000); return () => clearTimeout(id); }, [over, showResult, closingEnd]); const toggleSound = () => { const nextSound = !soundOn; if (nextSound) getPlayer().prepare(); if (sound === undefined) setInternalSound(nextSound); onSoundChange?.(nextSound); }; return (
{ if (event.key !== "Escape" || !resultShown) return; // Escape is used up here; an enclosing dialog must not close too. event.stopPropagation(); closeResult(); }} >
`${value} ${value === 1 ? "mine" : "mines"}`} aria-label="Mine count" > {(value) => ( {value} )}
{phase === "busted" ? ( // The multiplier is lost: struck through, as on the ticket. A plain // span, since a line-through skips the ticker's inline-block digits. {formatMultiplier(multiplier)} ) : ( )}

{over ? ( phase === "busted" ? "Multiplier lost" : "Multiplier" ) : ( <> {phase === "playing" ? `${Math.round(safe * 100)}% safe · next ${formatMultiplier(next)}` : `First pick ${Math.round(safe * 100)}% safe`} )}

{/* Board panel: 12px tiles + 12px padding give the 24px outer radius. */}
{/* Under the result card the tiles leave the tab order. */}
Tiles {Array.from({ length: tiles }, (_, tile) => { const isPicked = picked.includes(tile); const isMine = mineSet.has(tile); const shown = isPicked || over; const content: MinesTile | null = shown ? (isMine ? "mine" : "gem") : null; const hit = isPicked && isMine; const row = Math.floor(tile / size); const col = tile % size; const ripple = ringFrom(lastPick, tile, size); const order = picked.indexOf(tile); const celebrate = phase === "cashed" && isPicked && !reduce; const interactive = phase === "playing" && !isPicked; return ( { tileRefs.current[tile] = node; }} type="button" tabIndex={tile === cursor ? 0 : -1} aria-disabled={!interactive} aria-label={`Row ${row + 1}, column ${col + 1}, ${tileState(content, isPicked, order)}`} onFocus={() => setCursor(tile)} onClick={() => pick(tile)} onKeyDown={(event) => onKeyDown(event, tile)} animate={hit && !reduce ? { x: [0, -5, 5, -3, 3, 0] } : { x: 0 }} transition={SHAKE} className={cn( "relative grid aspect-square place-items-center rounded-xl border outline-none", // The mine's shockwave paints over its neighbours. hit && "z-10", "transition-[translate,scale,background-color,border-color] duration-150 ease-out", "focus-visible:ring-2 focus-visible:ring-ring", interactive ? "cursor-pointer border-border bg-card hover:-translate-y-0.5 hover:border-(--color-border-strong) active:scale-[0.96]" : "cursor-default border-transparent", !interactive && !shown && "bg-card/60", content === "gem" && (isPicked ? "bg-primary/15" : "bg-card/40"), content === "mine" && (hit ? "bg-rose-500/20" : "bg-card/40"), )} > {hit && !reduce ? ( ); })}
{resultShown ? ( {({ gate }) => ( )} ) : null}
{/* aria-disabled, not disabled: Start turns into this button, and a disabled button would drop the keyboard focus that pressed it. */} {/* aria-label names the button; the tooltip shows the same label on hover and focus. Tooltip's asLabel would hydrate differently from the server. */}

{announcement}

); } // The ticket's tear line sits this far down; the side notches are cut there. const TEAR = 104; const NOTCH_MASK = `radial-gradient(circle 9px at 0 ${TEAR}px, transparent 98%, black), radial-gradient(circle 9px at 100% ${TEAR}px, transparent 98%, black)`; // Rises with a little settle (a rare, end-of-round moment), leaves quickly. const CARD_IN = { type: "spring", duration: 0.4, bounce: 0.15 } as const; const CARD_OUT = { duration: 0.15, ease: EASE_OUT } as const; interface ResultProps { gate: PresenceGateRenderProps["gate"]; outcome: "cashed" | "busted"; multiplier: number; gems: number; safeTiles: number; mines: number; size: number; picked: number[]; mineSet: ReadonlySet; reduce: boolean; onClose: () => void; } /** * The end-of-round summary over a dimmed board. It has no buttons: the main * action below the board already says Play again, and tapping anywhere on * the board (or Escape) puts the card away to study the board. */ function ResultCard(props: ResultProps) { const { gate, reduce, onClose } = props; return ( <>
); } // Inside the ticket, chunks arrive in reading order: outcome, multiplier, // stats, then the run replays on the mini map in pick order. const STAGE = { duration: 0.25, ease: EASE_OUT } as const; const REPLAY_START = 0.3; const REPLAY_STEP = 0.05; // Mini map cells pop in with a small overshoot: each is 8px, so a bounce // reads as a cell landing rather than a wobble. const CELL_SPRING = { type: "spring", duration: 0.3, bounce: 0.3 } as const; function Stage({ delay, reduce, className, children }: { delay: number; reduce: boolean; className?: string; children: ReactNode }) { return ( {children} ); } function MiniMap({ size, picked, mineSet, reduce }: Pick) { const hit = picked.at(-1); // 8px cells 3px apart, kept within 88px so big boards leave the stats room. const gap = size > 10 ? 1 : 3; return ( ); } const CARD_SHADOW = "shadow-[0_1px_2px_rgb(0_0_0/0.12),0_16px_40px_-12px_rgb(0_0_0/0.45)]"; /** A ticket stub, notched at a dashed tear line. */ function Ticket({ outcome, multiplier, gems, safeTiles, mines, size, picked, mineSet, reduce }: ResultProps) { const cashed = outcome === "cashed"; return (

{cashed ? "Cashed out" : "Mine hit"}

{cashed ? ( ) : ( {formatMultiplier(multiplier)} )}

); } function Row({ label, value }: { label: string; value: string | number }) { return (
{label}
{value}
); } // The classic minesweeper mine: a solid ball (so it never reads as a sun), // four thick spikes on the axes and four shorter ones on the diagonals. const SPIKES = Array.from({ length: 8 }, (_, i) => { const angle = (i * Math.PI) / 4; const main = i % 2 === 0; const [cos, sin] = [Math.cos(angle), Math.sin(angle)]; const reach = main ? 10.5 : 9; return { main, x1: 12 + cos * 6, y1: 12 + sin * 6, x2: 12 + cos * reach, y2: 12 + sin * reach }; }); /** A mine on Lucide's 24px grid, sized to pair with the gem. */ function MineIcon({ className }: { className?: string }) { // Decorative: tiles and the stepper name the mine in text. return ( ); } /** A small ring that fills with the chance the next pick is safe. */ function OddsRing({ safe, reduce }: { safe: number; reduce: boolean }) { return ( ); } /** What a tile shows, and for revealed tiles whether the player picked it. */ function tileState(content: MinesTile | null, isPicked: boolean, order: number) { if (!content) return "hidden"; if (!isPicked) return `${content}, not picked`; return content === "mine" ? "mine, hit" : `gem, pick ${order + 1}`; } /** Rings between two tiles: 0 for the same tile, 1 for its neighbours. */ function ringFrom(from: number, tile: number, size: number) { return Math.max( Math.abs(Math.floor(tile / size) - Math.floor(from / size)), Math.abs((tile % size) - (from % size)), ); } // Payouts grow without limit (a cleared 5 × 5 board with 12 mines pays over // five million), so big ones are shortened to fit: 5.2M, then 1.3E29. const SHORT = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }); const SCIENTIFIC = new Intl.NumberFormat("en", { notation: "scientific", maximumFractionDigits: 1 }); function compact(multiplier: number) { if (multiplier < 10_000) return multiplier.toFixed(2); return (multiplier < 1e15 ? SHORT : SCIENTIFIC).format(multiplier); } /** A multiplier as Mines shows it: 2.50×, or shortened when large (5.2M×). */ export function formatMultiplier(multiplier: number) { return `${compact(multiplier)}×`; }