1"use client";23import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import Link from "next/link";5import { useRouter } from "next/navigation";6import { AnimatePresence, motion } from "framer-motion";7import { ArrowLeft, ChevronDown, ChevronUp, Heart, History, Info, Minus, Plus, Settings2, Volume2, VolumeX, Zap } from "lucide-react";8import { AUTO_SPIN_OPTIONS, BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo, type SpinResponse } from "@spinza/shared";9import type { SpinStep } from "@spinza/game-core/client";10import { api, ApiClientError } from "@/lib/api";11import { toast, useSession } from "@/lib/store";12import { cn } from "@/lib/utils";13import { Button, Credits, Sheet, Tabs } from "@/components/ui";14import { SpinzaMark } from "@/components/brand/logo";15import type { ClientDefinition, ClientOutcome } from "./types";16import { SlotRenderer } from "./renderer";17import { getSound } from "./sound";1819interface Props {20 game: GameInfo;21 definition: ClientDefinition;22}2324type Overlay =25 | { kind: "banner"; title: string; subtitle?: string }26 | { kind: "freespins"; title: string; spins: number }27 | { kind: "bonus"; name: string; picks: { cell: number; value: number; amount: number }[]; cells: number; total: number }28 | { kind: "jackpot"; tier: string; amount: number }29 | { kind: "win"; cls: string; amount: number; multiplier: number }30 | null;3132const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));3334export function GameClient({ game, definition }: Props) {35 const router = useRouter();36 const { status, wallet, settings, setBalance, setUserXp, user } = useSession();37 const canvasRef = useRef<HTMLDivElement>(null);38 const rendererRef = useRef<SlotRenderer | null>(null);39 const soundRef = useRef(getSound());40 const [ready, setReady] = useState(false);41 const [progress, setProgress] = useState(0);42 const [bet, setBet] = useState<number>(() => Math.max(definition.minBet, Math.min(100, definition.maxBet)));43 const [spinning, setSpinning] = useState(false);44 const [lastWin, setLastWin] = useState(0);45 const [displayWin, setDisplayWin] = useState(0);46 const [overlay, setOverlay] = useState<Overlay>(null);47 const [featureLabel, setFeatureLabel] = useState<string | null>(null);48 const [fsInfo, setFsInfo] = useState<{ left: number; total: number } | null>(null);49 const [multiplier, setMultiplier] = useState<number>(1);50 const [state, setState] = useState<ClientOutcome["stateAfter"] | null>(null);51 const [auto, setAuto] = useState<number>(0);52 const autoRef = useRef(0);53 const [autoSheet, setAutoSheet] = useState(false);54 const [betSheet, setBetSheet] = useState(false);55 const [infoSheet, setInfoSheet] = useState(false);56 const [settingsSheet, setSettingsSheet] = useState(false);57 const [historySheet, setHistorySheet] = useState(false);58 const [favorite, setFavorite] = useState(!!game.favorite);59 const [quick, setQuick] = useState(false);60 const [error, setError] = useState<{ code: string; message: string } | null>(null);61 const stopRequested = useRef(false);62 const soundOn = settings?.soundEnabled ?? true;6364 const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]);65 const balance = wallet?.balance ?? 0;6667 /* ------------------------------------------------------------- mount */68 useEffect(() => {69 if (status === "guest") {70 router.replace(`/login?next=/games/${game.slug}`);71 return;72 }73 }, [status, router, game.slug]);7475 useEffect(() => {76 if (!canvasRef.current || status !== "authenticated") return;77 const renderer = new SlotRenderer({78 def: definition,79 reduceMotion: settings?.reduceMotion ?? false,80 intensity: settings?.animationIntensity ?? "high",81 quick,82 onReelStop: (i) => soundRef.current.reelStop(i),83 onCascade: () => soundRef.current.cascade(),84 onCoin: () => soundRef.current.coin(),85 });86 rendererRef.current = renderer;87 let cancelled = false;88 (async () => {89 setProgress(15);90 try {91 await renderer.mount(canvasRef.current!);92 } catch (e) {93 console.error("renderer mount failed", e);94 if (!cancelled) setError({ code: "GAME_UNAVAILABLE", message: "Your browser could not start the game renderer (WebGL required)." });95 return;96 }97 setProgress(60);98 try {99 const launch = await api<{ state: ClientOutcome["stateAfter"] | null }>(`/api/games/${game.slug}/launch`, { method: "POST" });100 if (launch.state) setState(launch.state);101 } catch {102 /* non-blocking */103 }104 setProgress(100);105 await wait(250);106 if (!cancelled) setReady(true);107 })();108 return () => {109 cancelled = true;110 renderer.destroy();111 rendererRef.current = null;112 };113 // eslint-disable-next-line react-hooks/exhaustive-deps114 }, [status, game.slug]);115116 useEffect(() => {117 rendererRef.current?.setOptions({ reduceMotion: settings?.reduceMotion ?? false, intensity: settings?.animationIntensity ?? "high", quick });118 }, [settings?.reduceMotion, settings?.animationIntensity, quick]);119120 useEffect(() => {121 const s = soundRef.current;122 s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 });123 s.setAmbience(definition.presentation.ambience);124 }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience]);125126 useEffect(() => () => soundRef.current.destroy(), []);127128 // Win counter animation.129 useEffect(() => {130 if (displayWin === lastWin) return;131 const from = displayWin;132 const to = lastWin;133 const start = performance.now();134 const dur = to > from ? Math.min(1400, 300 + Math.log10(Math.max(1, to - from)) * 250) : 0;135 let raf = 0;136 const step = () => {137 const t = dur ? Math.min(1, (performance.now() - start) / dur) : 1;138 setDisplayWin(Math.round(from + (to - from) * (1 - Math.pow(1 - t, 3))));139 if (t < 1) raf = requestAnimationFrame(step);140 };141 raf = requestAnimationFrame(step);142 return () => cancelAnimationFrame(raf);143 // eslint-disable-next-line react-hooks/exhaustive-deps144 }, [lastWin]);145146 /* -------------------------------------------------------------- spin */147 const playOutcome = useCallback(148 async (res: SpinResponse) => {149 const renderer = rendererRef.current;150 if (!renderer) return;151 const outcome = res.result as ClientOutcome;152 const sound = soundRef.current;153 let running = 0;154 let first = true;155 let inFreeSpins = false;156 for (let i = 0; i < outcome.steps.length; i++) {157 const step: SpinStep = outcome.steps[i];158 const meta = step.meta;159 if (step.type === "freespin" && meta.label) {160 inFreeSpins = true;161 sound.bonus();162 setOverlay({ kind: "freespins", title: meta.label, spins: meta.freeSpinsTotal ?? 0 });163 await wait(quick ? 900 : 1700);164 setOverlay(null);165 setFeatureLabel(meta.label);166 }167 if (step.type === "freespin") setFsInfo({ left: meta.freeSpinsLeft ?? 0, total: meta.freeSpinsTotal ?? 0 });168 if (step.type === "bonus" && meta.picks) {169 sound.bonus();170 const cfg = definition.pickBonuses.find((b) => b.name === meta.bonusName);171 setOverlay({ kind: "bonus", name: meta.bonusName ?? "Bonus", picks: meta.picks, cells: cfg?.cells ?? 12, total: step.win });172 await wait((quick ? 700 : 1100) * (meta.picks.length + 1));173 setOverlay(null);174 }175 if (step.type === "jackpot" && meta.jackpot) {176 sound.jackpot();177 setOverlay({ kind: "jackpot", tier: meta.jackpot.tier, amount: meta.jackpot.amount });178 renderer.celebrate(4);179 await wait(quick ? 1500 : 2600);180 setOverlay(null);181 }182 if (step.type === "feature" && meta.label) {183 setOverlay({ kind: "banner", title: meta.label, subtitle: `Multiplier ×${step.multiplier}` });184 await wait(quick ? 700 : 1300);185 setOverlay(null);186 }187 if (step.type === "respin" && meta.label === "Lock & Respin") {188 sound.bonus();189 setOverlay({ kind: "banner", title: "Lock & Respin", subtitle: "Collect every coin" });190 await wait(quick ? 700 : 1200);191 setOverlay(null);192 setFeatureLabel("Lock & Respin");193 }194 if (meta.randomFeature) {195 setOverlay({ kind: "banner", title: meta.randomFeature });196 await wait(quick ? 500 : 900);197 setOverlay(null);198 }199 if (meta.freeSpinsAwarded && step.type !== "base" && inFreeSpins && i > 0) {200 setOverlay({ kind: "banner", title: "Retrigger", subtitle: `+${meta.freeSpinsAwarded} free spins` });201 await wait(quick ? 500 : 900);202 setOverlay(null);203 }204 setMultiplier(step.multiplier);205 await renderer.playStep(step, { first, showWins: true });206 first = false;207 if (step.win > 0) {208 running += step.win;209 setLastWin(Math.min(running, outcome.totalWin));210 const cls = classifyWin(step.win / res.bet);211 sound.win(cls === "none" || cls === "regular" ? 0 : cls === "win" ? 1 : cls === "big" ? 2 : 3);212 }213 if (step.type === "respin" && i === outcome.steps.length - 1 && step.win > 0) {214 running = outcome.totalWin;215 setLastWin(outcome.totalWin);216 }217 if (meta.freeSpinsAwarded && step.type === "base") {218 sound.bonus();219 }220 }221 setFsInfo(null);222 setFeatureLabel(null);223 setMultiplier(1);224 setLastWin(outcome.totalWin);225 setState(outcome.stateAfter);226 // Win presentation.227 const cls = res.winClass;228 if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") {229 sound.bigWin();230 const intensity = { big: 1, mega: 2, epic: 3, legendary: 5 }[cls];231 renderer.celebrate(intensity);232 setOverlay({ kind: "win", cls, amount: outcome.totalWin, multiplier: res.multiplier });233 await wait(quick ? 1400 : 2200 + intensity * 400);234 setOverlay(null);235 }236 setBalance(res.balance);237 setUserXp(res.xp.total, res.xp.level);238 if (res.xp.leveledUp) toast({ title: `Level ${res.xp.level} reached`, description: `+${formatSC(res.xp.levelReward)} level reward`, tone: "credit" });239 for (const a of res.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" });240 for (const m of res.unlocked.missions) toast({ title: "Mission complete", description: m.replace(/-/g, " "), tone: "success" });241 return { bonus: outcome.freeSpinsTriggered || outcome.bonusTriggered };242 },243 [definition.pickBonuses, quick, setBalance, setUserXp],244 );245246 const spin = useCallback(async (): Promise<{ ok: boolean; bonus?: boolean }> => {247 if (spinning || !ready) return { ok: false };248 const renderer = rendererRef.current;249 if (!renderer) return { ok: false };250 if (balance < bet) {251 setError({ code: "INSUFFICIENT_CREDITS", message: "Not enough Spinza Credits for this bet." });252 soundRef.current.error();253 return { ok: false };254 }255 setError(null);256 setSpinning(true);257 setLastWin(0);258 setDisplayWin(0);259 soundRef.current.unlock();260 soundRef.current.spinStart();261 setBalance(balance - bet); // optimistic; corrected by the server response262 renderer.startSpin();263 const clientRoundId = crypto.randomUUID();264 const started = performance.now();265 try {266 const res = await api<SpinResponse>(`/api/games/${game.slug}/spin`, { json: { bet, clientRoundId } });267 const elapsed = performance.now() - started;268 if (elapsed < 350) await wait(350 - elapsed);269 const r = await playOutcome(res);270 setSpinning(false);271 return { ok: true, bonus: r?.bonus };272 } catch (e) {273 setSpinning(false);274 renderer.clearFx();275 renderer.setGrid(renderer["currentGrid"] as string[][]);276 setBalance(balance);277 if (e instanceof ApiClientError) {278 if (e.status === 401) {279 router.replace(`/login?next=/games/${game.slug}`);280 return { ok: false };281 }282 setError({ code: e.code, message: e.message });283 if (e.code === "INSUFFICIENT_CREDITS" && e.details && typeof e.details === "object" && "balance" in e.details) setBalance((e.details as { balance: number }).balance);284 } else setError({ code: "UNKNOWN", message: "Something went wrong. Please try again." });285 soundRef.current.error();286 return { ok: false };287 }288 }, [spinning, ready, balance, bet, game.slug, playOutcome, setBalance, router]);289290 // Auto-spin loop.291 const startAuto = useCallback(292 async (count: number) => {293 autoRef.current = count;294 setAuto(count);295 stopRequested.current = false;296 while (autoRef.current > 0 && !stopRequested.current) {297 const r = await spin();298 if (!r.ok) break;299 autoRef.current -= 1;300 setAuto(autoRef.current);301 if (r.bonus) break; // stop on bonus302 if ((useSession.getState().wallet?.balance ?? 0) < bet) break;303 await wait(quick ? 250 : 450);304 }305 autoRef.current = 0;306 setAuto(0);307 },308 [spin, bet, quick],309 );310311 const stopAuto = () => {312 stopRequested.current = true;313 autoRef.current = 0;314 setAuto(0);315 };316317 // Keyboard: space to spin.318 useEffect(() => {319 const onKey = (e: KeyboardEvent) => {320 if (e.code === "Space" && !e.repeat && !overlay && !betSheet && !infoSheet && !settingsSheet) {321 e.preventDefault();322 if (auto) stopAuto();323 else void spin();324 }325 };326 window.addEventListener("keydown", onKey);327 return () => window.removeEventListener("keydown", onKey);328 }, [spin, auto, overlay, betSheet, infoSheet, settingsSheet]);329330 const toggleFavorite = async () => {331 setFavorite((f) => !f);332 try {333 const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" });334 setFavorite(r.favorite);335 } catch {336 setFavorite((f) => !f);337 }338 };339340 const betIndex = bets.indexOf(bet);341 const palette = definition.presentation.palette;342 const meter = definition.meter && state ? { value: state.meters[definition.meter.id] ?? 0, max: definition.meter.max } : null;343 const heat = definition.heat && state ? { value: state.heat, max: definition.heat.max } : null;344 const meterLevel = definition.meter && state ? state.meterLevels[definition.meter.id] ?? 0 : 0;345346 if (status === "guest") return null;347348 return (349 <div className="fixed inset-0 flex flex-col bg-bg" style={{ background: `radial-gradient(120% 80% at 50% 0%, ${palette.surface} 0%, ${palette.bg} 60%, #050608 100%)` }}>350 {/* Top bar */}351 <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}>352 <div className="flex items-center gap-2">353 <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Back to lobby">354 <ArrowLeft className="h-5 w-5" />355 </Link>356 <div className="leading-tight">357 <div className="text-[15px] font-semibold tracking-tight">{game.name}</div>358 <div className="text-[11px] text-fg-3">359 {game.grid.reels}×{game.grid.rows} · {game.features[0]}360 </div>361 </div>362 </div>363 <div className="flex items-center gap-1">364 <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Favourite">365 <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} />366 </button>367 <button onClick={() => setHistorySheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="History">368 <History className="h-5 w-5" />369 </button>370 <button onClick={() => setInfoSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Game info">371 <Info className="h-5 w-5" />372 </button>373 <button onClick={() => useSession.getState().setSettings({ soundEnabled: !soundOn })} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Sound">374 {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}375 </button>376 <button onClick={() => setSettingsSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Settings">377 <Settings2 className="h-5 w-5" />378 </button>379 </div>380 </div>381382 {/* Feature HUD */}383 <div className="flex min-h-7 items-center justify-center gap-2 px-3 text-[12px]">384 <AnimatePresence>385 {featureLabel ? (386 <motion.div initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="rounded-full px-3 py-1 font-semibold" style={{ background: `${palette.primary}22`, color: palette.primary }}>387 {featureLabel}388 {fsInfo ? ` · ${fsInfo.left} left of ${fsInfo.total}` : ""}389 </motion.div>390 ) : null}391 </AnimatePresence>392 {multiplier > 1 ? <span className="rounded-full bg-accent-soft px-2.5 py-1 font-bold text-accent-2">×{multiplier}</span> : null}393 {meter && definition.meter ? (394 <div className="flex items-center gap-2 rounded-full bg-surface-2 px-3 py-1">395 <span className="text-fg-3">{definition.meter.name}</span>396 <div className="h-1.5 w-20 overflow-hidden rounded-full bg-surface-3">397 <div className="h-full rounded-full transition-[width] duration-700" style={{ width: `${Math.min(100, (meter.value / meter.max) * 100)}%`, background: palette.primary }} />398 </div>399 <span className="tabular text-fg-2">400 {meter.value}/{meter.max}401 </span>402 {meterLevel > 0 ? <span className="font-bold text-accent-2">Lv {meterLevel}</span> : null}403 </div>404 ) : null}405 {heat && definition.heat ? (406 <div className="flex items-center gap-2 rounded-full bg-surface-2 px-3 py-1">407 <span className="text-fg-3">Heat</span>408 <div className="h-1.5 w-20 overflow-hidden rounded-full bg-surface-3">409 <div className="h-full rounded-full bg-[linear-gradient(90deg,#ffb347,#ff5c3d)] transition-[width] duration-700" style={{ width: `${Math.min(100, (heat.value / heat.max) * 100)}%` }} />410 </div>411 <span className="tabular text-fg-2">×{definition.heat.ladder[Math.min(Math.floor(heat.value / definition.heat.bandSize), definition.heat.ladder.length - 1)]}</span>412 </div>413 ) : null}414 </div>415416 {/* Canvas */}417 <div className="relative flex-1 min-h-0">418 <div ref={canvasRef} className="spz-canvas absolute inset-0" />419 {/* Splash */}420 <AnimatePresence>421 {!ready ? (422 <motion.div key="splash" className="absolute inset-0 grid place-items-center" style={{ background: palette.bg }} exit={{ opacity: 0 }} transition={{ duration: 0.5 }}>423 <div className="flex flex-col items-center gap-5 px-8 text-center">424 <SpinzaMark className="h-12 w-12" />425 <div>426 <div className="eyebrow">Spinza presents</div>427 <div className="mt-1 text-3xl font-semibold tracking-tight" style={{ color: palette.primary }}>428 {game.name}429 </div>430 <div className="mt-1 text-sm text-fg-3">{game.tagline}</div>431 </div>432 <div className="w-56">433 <div className="h-1 w-full overflow-hidden rounded-full bg-surface-3">434 <div className="h-full rounded-full transition-[width] duration-300" style={{ width: `${progress}%`, background: palette.primary }} />435 </div>436 <div className="mt-2 text-[12px] tabular text-fg-3">Loading {progress}%</div>437 </div>438 </div>439 </motion.div>440 ) : null}441 </AnimatePresence>442 <Overlays overlay={overlay} palette={palette} />443 {/* Error */}444 <AnimatePresence>445 {error ? (446 <motion.div 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" style={{ zIndex: 5 }}>447 <div className="font-semibold">{errorTitle(error.code)}</div>448 <div className="mt-0.5 text-fg-2">{error.message}</div>449 {error.code === "INSUFFICIENT_CREDITS" ? (450 <div className="mt-2 flex justify-center gap-2">451 <Button size="sm" variant="secondary" onClick={() => setBetSheet(true)}>452 Lower bet453 </Button>454 <Button size="sm" variant="accent" href="/rewards">455 Get rewards456 </Button>457 </div>458 ) : error.code === "MAINTENANCE" ? (459 <Button size="sm" className="mt-2" href="/">460 Back to lobby461 </Button>462 ) : (463 <Button size="sm" variant="secondary" className="mt-2" onClick={() => setError(null)}>464 Dismiss465 </Button>466 )}467 </motion.div>468 ) : null}469 </AnimatePresence>470 </div>471472 {/* Controls */}473 <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)" }}>474 <div className="mx-auto grid max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-3">475 {/* Balance + bet */}476 <div className="flex flex-col gap-2">477 <div>478 <div className="eyebrow">Balance</div>479 <Credits amount={balance} size="md" />480 </div>481 <div className="flex items-center gap-1">482 <button disabled={spinning || betIndex <= 0} onClick={() => setBet(bets[betIndex - 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Decrease bet">483 <Minus className="h-4 w-4" />484 </button>485 <button disabled={spinning} onClick={() => setBetSheet(true)} className="tap flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2 focus-ring">486 <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span>487 <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span>488 </button>489 <button disabled={spinning || betIndex >= bets.length - 1} onClick={() => setBet(bets[betIndex + 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Increase bet">490 <Plus className="h-4 w-4" />491 </button>492 </div>493 </div>494495 {/* Spin */}496 <div className="flex flex-col items-center gap-2">497 {auto > 0 ? (498 <button onClick={stopAuto} className="relative grid h-[84px] w-[84px] place-items-center rounded-full border-2 border-danger/60 bg-danger/15 text-danger shadow-[0_0_40px_-10px_rgba(255,92,122,0.8)] focus-ring">499 <span className="text-base font-extrabold tracking-wide">STOP</span>500 <span className="absolute -bottom-1 rounded-full bg-bg px-2 text-[11px] font-bold tabular text-fg-2">{auto}</span>501 </button>502 ) : (503 <button504 onClick={() => void spin()}505 disabled={!ready || spinning}506 className={cn("relative grid h-[84px] w-[84px] place-items-center rounded-full text-[#1a1406] transition-transform active:scale-95 focus-ring disabled:opacity-70", spinning && "animate-pulse-soft")}507 style={{ background: "linear-gradient(180deg,#f3e2ad,#c9a961 60%,#9a7b3a)", boxShadow: "0 0 0 4px rgba(201,169,97,0.18), 0 12px 40px -10px rgba(201,169,97,0.7)" }}508 aria-label="Spin"509 >510 <span className="text-base font-extrabold tracking-[0.12em]">SPIN</span>511 </button>512 )}513 <button disabled={spinning && auto === 0} onClick={() => (auto ? stopAuto() : setAutoSheet(true))} className="text-[11px] font-semibold uppercase tracking-wider text-fg-3 hover:text-fg-2 focus-ring rounded">514 {auto ? "Auto running" : "Auto spin"}515 </button>516 </div>517518 {/* Win */}519 <div className="flex flex-col items-end gap-2 text-right">520 <div>521 <div className="eyebrow">Win</div>522 <div className={cn("text-base font-semibold tabular", displayWin > 0 ? "text-credit" : "text-fg-4")}>523 {displayWin > 0 ? formatSC(displayWin) : "—"}524 </div>525 </div>526 <button onClick={() => setQuick((q) => !q)} className={cn("tap flex h-10 items-center gap-1.5 rounded-md px-3 text-[12px] font-semibold focus-ring", quick ? "bg-accent-soft text-accent-2" : "surface-2 text-fg-3")} aria-pressed={quick}>527 <Zap className="h-3.5 w-3.5" /> Quick528 </button>529 </div>530 </div>531 </div>532533 {/* Sheets */}534 <Sheet open={betSheet} onClose={() => setBetSheet(false)} title="Bet per spin">535 <div className="grid grid-cols-4 gap-2">536 {bets.map((b) => (537 <button key={b} onClick={() => { setBet(b); setBetSheet(false); }} className={cn("tap rounded-md border px-2 py-3 text-sm font-bold tabular focus-ring", b === bet ? "border-accent bg-accent-soft text-accent-2" : "border-line surface text-fg-2 hover:text-fg")}>538 {formatSC(b, { unit: false })}539 </button>540 ))}541 </div>542 <p className="mt-4 text-[12px] text-fg-3">543 Bets are in fictional Spinza Credits. Max win {formatMultiplier(game.maxMultiplier)} the bet.544 </p>545 </Sheet>546547 <Sheet open={autoSheet} onClose={() => setAutoSheet(false)} title="Auto spin">548 <div className="grid grid-cols-4 gap-2">549 {AUTO_SPIN_OPTIONS.map((n) => (550 <button key={n} onClick={() => { setAutoSheet(false); void startAuto(n); }} className="tap rounded-md border border-line surface px-2 py-3 text-sm font-bold text-fg-2 hover:text-fg focus-ring">551 {n}552 </button>553 ))}554 </div>555 <p className="mt-4 text-[12px] text-fg-3">Auto spin stops automatically when a bonus triggers, when your balance is too low for the bet, or when you press STOP.</p>556 </Sheet>557558 <GameInfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} />559 <SettingsSheet open={settingsSheet} onClose={() => setSettingsSheet(false)} />560 <HistorySheet open={historySheet} onClose={() => setHistorySheet(false)} slug={game.slug} />561 <span className="sr-only">{user?.username}</span>562 </div>563 );564}565566function errorTitle(code: string): string {567 return (568 {569 INSUFFICIENT_CREDITS: "Insufficient credits",570 NETWORK: "Connection lost",571 MAINTENANCE: "Spinza is getting an upgrade",572 GAME_UNAVAILABLE: "Game unavailable",573 RATE_LIMITED: "Slow down",574 UNAUTHORIZED: "Session expired",575 }[code] ?? "Something went wrong"576 );577}578579/* ----------------------------------------------------------------- overlays */580581function Overlays({ overlay, palette }: { overlay: Overlay; palette: ClientDefinition["presentation"]["palette"] }) {582 return (583 <AnimatePresence>584 {overlay ? (585 <motion.div key={overlay.kind + ("title" in overlay ? overlay.title : "")} className="pointer-events-none absolute inset-0 grid place-items-center" style={{ zIndex: 4 }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>586 <div className="absolute inset-0 bg-black/45" />587 {overlay.kind === "banner" ? (588 <motion.div initial={{ scale: 0.7, y: 20 }} animate={{ scale: 1, y: 0 }} transition={{ type: "spring", stiffness: 260, damping: 18 }} className="relative text-center">589 <div className="text-[clamp(28px,7vw,56px)] font-extrabold tracking-tight" style={{ color: palette.primary, textShadow: `0 0 40px ${palette.glow}` }}>590 {overlay.title}591 </div>592 {overlay.subtitle ? <div className="mt-1 text-lg text-fg-2">{overlay.subtitle}</div> : null}593 </motion.div>594 ) : null}595 {overlay.kind === "freespins" ? (596 <motion.div initial={{ scale: 0.6 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 220, damping: 16 }} className="relative text-center">597 <div className="eyebrow" style={{ color: palette.secondary }}>598 Feature unlocked599 </div>600 <div className="text-[clamp(32px,8vw,64px)] font-extrabold tracking-tight" style={{ color: palette.primary, textShadow: `0 0 50px ${palette.glow}` }}>601 {overlay.title}602 </div>603 <div className="mt-2 text-2xl font-semibold text-fg">{overlay.spins} spins</div>604 </motion.div>605 ) : null}606 {overlay.kind === "jackpot" ? (607 <motion.div initial={{ scale: 0.5, rotate: -4 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 200, damping: 14 }} className="relative text-center">608 <div className="text-[clamp(30px,7vw,60px)] font-extrabold uppercase tracking-tight shimmer-text">{overlay.tier} jackpot</div>609 <div className="mt-2 text-3xl font-bold tabular text-credit">{formatSC(overlay.amount)}</div>610 <div className="mt-1 text-xs text-fg-3">Fictional credits · no cash value</div>611 </motion.div>612 ) : null}613 {overlay.kind === "win" ? <WinPresentation cls={overlay.cls} amount={overlay.amount} multiplier={overlay.multiplier} /> : null}614 {overlay.kind === "bonus" ? <BonusReveal name={overlay.name} picks={overlay.picks} cells={overlay.cells} total={overlay.total} palette={palette} /> : null}615 </motion.div>616 ) : null}617 </AnimatePresence>618 );619}620621function WinPresentation({ cls, amount, multiplier }: { cls: string; amount: number; multiplier: number }) {622 const label = WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN";623 const [shown, setShown] = useState(0);624 useEffect(() => {625 const start = performance.now();626 const dur = 1400;627 let raf = 0;628 const step = () => {629 const t = Math.min(1, (performance.now() - start) / dur);630 setShown(Math.round(amount * (1 - Math.pow(1 - t, 3))));631 if (t < 1) raf = requestAnimationFrame(step);632 };633 raf = requestAnimationFrame(step);634 return () => cancelAnimationFrame(raf);635 }, [amount]);636 const size = { big: "text-[clamp(34px,9vw,72px)]", mega: "text-[clamp(38px,10vw,84px)]", epic: "text-[clamp(42px,11vw,96px)]", legendary: "text-[clamp(46px,12vw,110px)]" }[cls] ?? "text-5xl";637 return (638 <motion.div initial={{ scale: 0.4, opacity: 0 }} animate={{ scale: [0.4, 1.08, 1], opacity: 1 }} transition={{ duration: 0.7, times: [0, 0.7, 1] }} className="relative text-center">639 <div className={cn("font-extrabold uppercase tracking-tight shimmer-text", size)}>{label}</div>640 <div className="mt-3 text-4xl font-bold tabular text-credit sm:text-5xl">{formatSC(shown)}</div>641 <div className="mt-1 text-base font-semibold text-fg-2">{formatMultiplier(multiplier)} the bet</div>642 </motion.div>643 );644}645646function BonusReveal({ name, picks, cells, total, palette }: { name: string; picks: { cell: number; value: number; amount: number }[]; cells: number; total: number; palette: ClientDefinition["presentation"]["palette"] }) {647 const [revealed, setRevealed] = useState(0);648 useEffect(() => {649 if (revealed >= picks.length) return;650 const t = setTimeout(() => setRevealed((r) => r + 1), 900);651 return () => clearTimeout(t);652 }, [revealed, picks.length]);653 const byCell = new Map(picks.slice(0, revealed).map((p) => [p.cell, p]));654 const cols = cells <= 6 ? 3 : cells <= 9 ? 3 : 4;655 return (656 <motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="relative w-[min(92vw,460px)] text-center">657 <div className="eyebrow" style={{ color: palette.secondary }}>658 Bonus game659 </div>660 <div className="text-3xl font-extrabold tracking-tight" style={{ color: palette.primary }}>661 {name}662 </div>663 <div className="mt-4 grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>664 {Array.from({ length: cells }, (_, i) => {665 const p = byCell.get(i);666 return (667 <motion.div key={i} layout className={cn("grid aspect-[4/3] place-items-center rounded-md border text-sm font-bold tabular", p ? "border-accent/50 bg-accent-soft text-credit" : "border-line bg-surface-2 text-fg-4")} animate={p ? { scale: [0.8, 1.1, 1] } : {}}>668 {p ? formatSC(p.amount, { unit: false }) : "?"}669 </motion.div>670 );671 })}672 </div>673 <div className="mt-4 text-lg font-semibold">674 Total <span className="tabular text-credit">{formatSC(revealed >= picks.length ? total : picks.slice(0, revealed).reduce((a, p) => a + p.amount, 0))}</span>675 </div>676 </motion.div>677 );678}679680/* ------------------------------------------------------------------ sheets */681682function GameInfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ClientDefinition }) {683 const [tab, setTab] = useState<"rules" | "paytable" | "about">("rules");684 const symbols = new Map(definition.symbols.map((s) => [s.id, s]));685 return (686 <Sheet open={open} onClose={onClose} title={game.name} side="right">687 <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "Rules" }, { value: "paytable", label: "Paytable" }, { value: "about", label: "About" }]} className="mb-4" />688 {tab === "rules" ? (689 <ul className="space-y-3 text-sm text-fg-2">690 {game.rules.map((r, i) => (691 <li key={i} className="flex gap-3">692 <span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-surface-2 text-[11px] font-bold text-fg-3">{i + 1}</span>693 <span>{r}</span>694 </li>695 ))}696 </ul>697 ) : null}698 {tab === "paytable" ? (699 <div className="space-y-2">700 <p className="text-[12px] text-fg-3">Pays shown as multiples of the total bet{definition.payModel.type === "ways" ? " per way" : " per line"}. Scatter pays apply anywhere.</p>701 {game.paytable.map((row) => {702 const s = symbols.get(row.symbol);703 return (704 <div key={row.symbol} className="surface flex items-center gap-3 rounded-md p-3">705 <span className="grid h-9 w-9 shrink-0 place-items-center rounded-md text-[11px] font-bold" style={{ background: `${s?.style.color ?? "#fff"}22`, color: s?.style.color ?? "#fff" }}>706 {s?.style.label ?? row.symbol}707 </span>708 <div className="min-w-0 flex-1">709 <div className="truncate text-sm font-semibold">{row.label}</div>710 <div className="text-[11px] uppercase tracking-wider text-fg-3">{s?.kind}</div>711 </div>712 <div className="flex gap-2 text-[12px] tabular">713 {Object.entries(row.pays)714 .sort((a, b) => Number(a[0]) - Number(b[0]))715 .map(([k, v]) => (716 <span key={k} className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-2">717 {k}× <span className="text-credit">{v}</span>718 </span>719 ))}720 </div>721 </div>722 );723 })}724 </div>725 ) : null}726 {tab === "about" ? (727 <div className="space-y-4 text-sm text-fg-2">728 <p>{game.description}</p>729 <dl className="grid grid-cols-2 gap-3">730 <Stat label="Volatility" value={game.volatility} />731 <Stat label="RTP (configured)" value={`${(game.rtp * 100).toFixed(2)}%`} />732 <Stat label="Hit frequency" value={game.hitFrequency ? `${(game.hitFrequency * 100).toFixed(1)}%` : "—"} />733 <Stat label="Max win" value={formatMultiplier(game.maxMultiplier)} />734 <Stat label="Grid" value={`${game.grid.reels} × ${game.grid.rows}`} />735 <Stat label="Version" value={game.version} />736 </dl>737 {game.certification ? (738 <div className="surface rounded-md p-3 text-[12px]">739 <div className="font-semibold text-fg">Internal certification · {game.certification.status}</div>740 <div className="mt-1 text-fg-3">741 {game.certification.spins.toLocaleString("en-US")} simulated spins · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%742 </div>743 </div>744 ) : null}745 <p className="text-[12px] text-fg-3">RTP is a gameplay-balancing statistic over millions of simulated spins. Spinza Credits are fictional and have no cash value.</p>746 </div>747 ) : null}748 </Sheet>749 );750}751752function Stat({ label, value }: { label: string; value: string }) {753 return (754 <div className="surface rounded-md p-3">755 <dt className="eyebrow">{label}</dt>756 <dd className="mt-1 text-sm font-semibold capitalize text-fg">{value}</dd>757 </div>758 );759}760761function SettingsSheet({ open, onClose }: { open: boolean; onClose: () => void }) {762 const { settings, setSettings } = useSession();763 if (!settings) return null;764 return (765 <Sheet open={open} onClose={onClose} title="Game settings">766 <div className="space-y-4">767 <Slider label="Master volume" value={settings.masterVolume} onChange={(v) => setSettings({ masterVolume: v })} />768 <Slider label="Music" value={settings.musicVolume} onChange={(v) => setSettings({ musicVolume: v })} />769 <Slider label="Effects" value={settings.effectsVolume} onChange={(v) => setSettings({ effectsVolume: v })} />770 <div>771 <div className="mb-2 text-sm text-fg-2">Animation intensity</div>772 <Tabs value={settings.animationIntensity} onChange={(v) => setSettings({ animationIntensity: v })} items={[{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }]} />773 </div>774 <button onClick={() => setSettings({ reduceMotion: !settings.reduceMotion })} className="flex w-full items-center justify-between rounded-md surface px-4 py-3 text-sm focus-ring">775 <span>Reduce motion</span>776 <span className={cn("font-semibold", settings.reduceMotion ? "text-success" : "text-fg-3")}>{settings.reduceMotion ? "On" : "Off"}</span>777 </button>778 <Link href="/settings" className="block text-center text-[13px] text-fg-3 underline-offset-4 hover:underline">779 All account settings780 </Link>781 </div>782 </Sheet>783 );784}785786function Slider({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {787 return (788 <label className="block">789 <div className="mb-1 flex justify-between text-sm">790 <span className="text-fg-2">{label}</span>791 <span className="tabular text-fg-3">{Math.round(value * 100)}%</span>792 </div>793 <input type="range" min={0} max={1} step={0.05} value={value} onChange={(e) => onChange(Number(e.target.value))} className="w-full accent-[#c9a961]" />794 </label>795 );796}797798function HistorySheet({ open, onClose, slug }: { open: boolean; onClose: () => void; slug: string }) {799 return (800 <Sheet open={open} onClose={onClose} title="Recent rounds" side="right">801 {open ? <HistoryList slug={slug} /> : null}802 </Sheet>803 );804}805806type HistoryRow = { roundId: string; bet: number; win: number; multiplier: number; balanceAfter: number; createdAt: string; features: string[] };807808function HistoryList({ slug }: { slug: string }) {809 const [rows, setRows] = useState<HistoryRow[] | null>(null);810 const [expanded, setExpanded] = useState<string | null>(null);811 useEffect(() => {812 let alive = true;813 api<{ entries: HistoryRow[] }>(`/api/games/${slug}/history?limit=30`)814 .then((r) => alive && setRows(r.entries))815 .catch(() => alive && setRows([]));816 return () => {817 alive = false;818 };819 }, [slug]);820 return (821 <>822 {rows === null ? (823 <div className="text-sm text-fg-3">Loading…</div>824 ) : rows.length === 0 ? (825 <div className="text-sm text-fg-3">No rounds yet on this game.</div>826 ) : (827 <ul className="divide-y divide-line">828 {rows.map((r) => (829 <li key={r.roundId}>830 <button onClick={() => setExpanded(expanded === r.roundId ? null : r.roundId)} className="flex w-full items-center justify-between py-3 text-left">831 <div>832 <div className="text-sm font-medium">833 Bet {formatSC(r.bet, { unit: false })} · <span className={r.win > 0 ? "text-credit" : "text-fg-3"}>{r.win > 0 ? `+${formatSC(r.win, { unit: false })}` : "—"}</span>834 </div>835 <div className="text-[11px] text-fg-3">{new Date(r.createdAt).toLocaleString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</div>836 </div>837 <div className="flex items-center gap-2 text-[12px] tabular text-fg-2">838 {formatMultiplier(r.multiplier)}839 {expanded === r.roundId ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}840 </div>841 </button>842 {expanded === r.roundId ? (843 <div className="pb-3 text-[12px] text-fg-3">844 <div className="font-mono">{r.roundId}</div>845 <div>Balance after: {formatSC(r.balanceAfter)}</div>846 {r.features.length ? <div>Features: {r.features.join(", ")}</div> : null}847 </div>848 ) : null}849 </li>850 ))}851 </ul>852 )}853 </>854 );855}856