TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
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 frameStyle = FRAME_HUD[definition.presentation.frame] ?? FRAME_HUD.metal;343 const meter = definition.meter && state ? { value: state.meters[definition.meter.id] ?? 0, max: definition.meter.max } : null;344 const heat = definition.heat && state ? { value: state.heat, max: definition.heat.max } : null;345 const meterLevel = definition.meter && state ? state.meterLevels[definition.meter.id] ?? 0 : 0;346347 if (status === "guest") return null;348349 return (350 <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%)` }}>351 {/* Top bar */}352 <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}>353 <div className="flex items-center gap-2">354 <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Back to lobby">355 <ArrowLeft className="h-5 w-5" />356 </Link>357 <div className="leading-tight">358 <div className="text-[15px] font-semibold tracking-tight">{game.name}</div>359 <div className="text-[11px] text-fg-3">360 {game.grid.reels}×{game.grid.rows} · {game.features[0]}361 </div>362 </div>363 </div>364 <div className="flex items-center gap-1">365 <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Favourite">366 <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} />367 </button>368 <button onClick={() => setHistorySheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="History">369 <History className="h-5 w-5" />370 </button>371 <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">372 <Info className="h-5 w-5" />373 </button>374 <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">375 {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}376 </button>377 <button onClick={() => setSettingsSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Settings">378 <Settings2 className="h-5 w-5" />379 </button>380 </div>381 </div>382383 {/* Feature HUD */}384 <div className="flex min-h-7 items-center justify-center gap-2 px-3 text-[12px]">385 <AnimatePresence>386 {featureLabel ? (387 <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 }}>388 {featureLabel}389 {fsInfo ? ` · ${fsInfo.left} left of ${fsInfo.total}` : ""}390 </motion.div>391 ) : null}392 </AnimatePresence>393 {multiplier > 1 ? <span className="rounded-full bg-accent-soft px-2.5 py-1 font-bold text-accent-2">×{multiplier}</span> : null}394 {meter && definition.meter ? (395 <div className="flex items-center gap-2 rounded-full bg-surface-2 px-3 py-1">396 <span className="text-fg-3">{definition.meter.name}</span>397 <div className="h-1.5 w-20 overflow-hidden rounded-full bg-surface-3">398 <div className="h-full rounded-full transition-[width] duration-700" style={{ width: `${Math.min(100, (meter.value / meter.max) * 100)}%`, background: palette.primary }} />399 </div>400 <span className="tabular text-fg-2">401 {meter.value}/{meter.max}402 </span>403 {meterLevel > 0 ? <span className="font-bold text-accent-2">Lv {meterLevel}</span> : null}404 </div>405 ) : null}406 {heat && definition.heat ? (407 <div className="flex items-center gap-2 rounded-full bg-surface-2 px-3 py-1">408 <span className="text-fg-3">Heat</span>409 <div className="h-1.5 w-20 overflow-hidden rounded-full bg-surface-3">410 <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)}%` }} />411 </div>412 <span className="tabular text-fg-2">×{definition.heat.ladder[Math.min(Math.floor(heat.value / definition.heat.bandSize), definition.heat.ladder.length - 1)]}</span>413 </div>414 ) : null}415 </div>416417 {/* Canvas */}418 <div className="relative flex-1 min-h-0">419 <div ref={canvasRef} className="spz-canvas absolute inset-0" />420 {/* Splash */}421 <AnimatePresence>422 {!ready ? (423 <motion.div key="splash" className="absolute inset-0 grid place-items-center" style={{ background: palette.bg }} exit={{ opacity: 0 }} transition={{ duration: 0.5 }}>424 <div className="flex flex-col items-center gap-5 px-8 text-center">425 <SpinzaMark className="h-12 w-12" />426 <div>427 <div className="eyebrow">Spinza presents</div>428 <div className="mt-1 text-3xl font-semibold tracking-tight" style={{ color: palette.primary }}>429 {game.name}430 </div>431 <div className="mt-1 text-sm text-fg-3">{game.tagline}</div>432 </div>433 <div className="w-56">434 <div className="h-1 w-full overflow-hidden rounded-full bg-surface-3">435 <div className="h-full rounded-full transition-[width] duration-300" style={{ width: `${progress}%`, background: palette.primary }} />436 </div>437 <div className="mt-2 text-[12px] tabular text-fg-3">Loading {progress}%</div>438 </div>439 </div>440 </motion.div>441 ) : null}442 </AnimatePresence>443 <Overlays overlay={overlay} palette={palette} />444 {/* Error */}445 <AnimatePresence>446 {error ? (447 <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 }}>448 <div className="font-semibold">{errorTitle(error.code)}</div>449 <div className="mt-0.5 text-fg-2">{error.message}</div>450 {error.code === "INSUFFICIENT_CREDITS" ? (451 <div className="mt-2 flex justify-center gap-2">452 <Button size="sm" variant="secondary" onClick={() => setBetSheet(true)}>453 Lower bet454 </Button>455 <Button size="sm" variant="accent" href="/rewards">456 Get rewards457 </Button>458 </div>459 ) : error.code === "MAINTENANCE" ? (460 <Button size="sm" className="mt-2" href="/">461 Back to lobby462 </Button>463 ) : (464 <Button size="sm" variant="secondary" className="mt-2" onClick={() => setError(null)}>465 Dismiss466 </Button>467 )}468 </motion.div>469 ) : null}470 </AnimatePresence>471 </div>472473 {/* Controls */}474 <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)", borderTop: `1px solid ${palette.primary}55`, boxShadow: `inset 0 1px 0 ${palette.primary}33`, background: frameStyle.controls }}>475 <div className="mx-auto grid max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-3">476 {/* Balance + bet */}477 <div className="flex flex-col gap-2">478 <div>479 <div className="eyebrow">Balance</div>480 <Credits amount={balance} size="md" />481 </div>482 <div className="flex items-center gap-1">483 <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">484 <Minus className="h-4 w-4" />485 </button>486 <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">487 <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span>488 <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span>489 </button>490 <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">491 <Plus className="h-4 w-4" />492 </button>493 </div>494 </div>495496 {/* Spin */}497 <div className="flex flex-col items-center gap-2">498 {auto > 0 ? (499 <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">500 <span className="text-base font-extrabold tracking-wide">STOP</span>501 <span className="absolute -bottom-1 rounded-full bg-bg px-2 text-[11px] font-bold tabular text-fg-2">{auto}</span>502 </button>503 ) : (504 <button505 onClick={() => void spin()}506 disabled={!ready || spinning}507 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")}508 style={{ background: `linear-gradient(180deg, ${shade(palette.primary, 0.35)}, ${palette.primary} 60%, ${shade(palette.primary, -0.35)})`, boxShadow: `0 0 0 4px ${palette.primary}2e, 0 12px 40px -10px ${palette.primary}b3`, color: textOn(palette.primary) }}509 aria-label="Spin"510 >511 <span className="text-base font-extrabold tracking-[0.12em]">SPIN</span>512 </button>513 )}514 <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">515 {auto ? "Auto running" : "Auto spin"}516 </button>517 </div>518519 {/* Win */}520 <div className="flex flex-col items-end gap-2 text-right">521 <div>522 <div className="eyebrow">Win</div>523 <div className={cn("text-base font-semibold tabular", displayWin > 0 ? "text-credit" : "text-fg-4")}>524 {displayWin > 0 ? formatSC(displayWin) : "—"}525 </div>526 </div>527 <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}>528 <Zap className="h-3.5 w-3.5" /> Quick529 </button>530 </div>531 </div>532 </div>533534 {/* Sheets */}535 <Sheet open={betSheet} onClose={() => setBetSheet(false)} title="Bet per spin">536 <div className="grid grid-cols-4 gap-2">537 {bets.map((b) => (538 <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")}>539 {formatSC(b, { unit: false })}540 </button>541 ))}542 </div>543 <p className="mt-4 text-[12px] text-fg-3">544 Bets are in fictional Spinza Credits. Max win {formatMultiplier(game.maxMultiplier)} the bet.545 </p>546 </Sheet>547548 <Sheet open={autoSheet} onClose={() => setAutoSheet(false)} title="Auto spin">549 <div className="grid grid-cols-4 gap-2">550 {AUTO_SPIN_OPTIONS.map((n) => (551 <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">552 {n}553 </button>554 ))}555 </div>556 <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>557 </Sheet>558559 <GameInfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} />560 <SettingsSheet open={settingsSheet} onClose={() => setSettingsSheet(false)} />561 <HistorySheet open={historySheet} onClose={() => setHistorySheet(false)} slug={game.slug} />562 <span className="sr-only">{user?.username}</span>563 </div>564 );565}566567/** Per-frame HUD treatment so the control bar matches the reel frame material. */568const FRAME_HUD: Record<string, { controls: string }> = {569 metal: { controls: "linear-gradient(180deg, rgba(20,24,33,0.85), rgba(9,11,16,0.92))" },570 glass: { controls: "linear-gradient(180deg, rgba(255,255,255,0.06), rgba(10,12,20,0.85))" },571 gold: { controls: "linear-gradient(180deg, rgba(40,30,12,0.85), rgba(12,9,4,0.95))" },572 stone: { controls: "linear-gradient(180deg, rgba(28,25,18,0.9), rgba(10,9,6,0.95))" },573 ice: { controls: "linear-gradient(180deg, rgba(120,190,230,0.12), rgba(8,14,22,0.92))" },574 carbon: { controls: "repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0 2px, rgba(0,0,0,0) 2px 5px), rgba(7,8,11,0.95)" },575 neon: { controls: "linear-gradient(180deg, rgba(10,8,24,0.85), rgba(4,3,12,0.95))" },576 obsidian: { controls: "rgba(0,0,0,0.92)" },577};578579function shade(hexColor: string, amt: number): string {580 const n = parseInt(hexColor.replace("#", ""), 16);581 const ch = (v: number) => Math.max(0, Math.min(255, Math.round(v + 255 * amt)));582 const r = ch((n >> 16) & 255);583 const g = ch((n >> 8) & 255);584 const b = ch(n & 255);585 return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;586}587588function textOn(hexColor: string): string {589 const n = parseInt(hexColor.replace("#", ""), 16);590 const lum = (0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;591 return lum > 0.55 ? "#0b0a06" : "#ffffff";592}593594function errorTitle(code: string): string {595 return (596 {597 INSUFFICIENT_CREDITS: "Insufficient credits",598 NETWORK: "Connection lost",599 MAINTENANCE: "Spinza is getting an upgrade",600 GAME_UNAVAILABLE: "Game unavailable",601 RATE_LIMITED: "Slow down",602 UNAUTHORIZED: "Session expired",603 }[code] ?? "Something went wrong"604 );605}606607/* ----------------------------------------------------------------- overlays */608609function Overlays({ overlay, palette }: { overlay: Overlay; palette: ClientDefinition["presentation"]["palette"] }) {610 return (611 <AnimatePresence>612 {overlay ? (613 <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 }}>614 <div className="absolute inset-0 bg-black/45" />615 {overlay.kind === "banner" ? (616 <motion.div initial={{ scale: 0.7, y: 20 }} animate={{ scale: 1, y: 0 }} transition={{ type: "spring", stiffness: 260, damping: 18 }} className="relative text-center">617 <div className="text-[clamp(28px,7vw,56px)] font-extrabold tracking-tight" style={{ color: palette.primary, textShadow: `0 0 40px ${palette.glow}` }}>618 {overlay.title}619 </div>620 {overlay.subtitle ? <div className="mt-1 text-lg text-fg-2">{overlay.subtitle}</div> : null}621 </motion.div>622 ) : null}623 {overlay.kind === "freespins" ? (624 <motion.div initial={{ scale: 0.6 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 220, damping: 16 }} className="relative text-center">625 <div className="eyebrow" style={{ color: palette.secondary }}>626 Feature unlocked627 </div>628 <div className="text-[clamp(32px,8vw,64px)] font-extrabold tracking-tight" style={{ color: palette.primary, textShadow: `0 0 50px ${palette.glow}` }}>629 {overlay.title}630 </div>631 <div className="mt-2 text-2xl font-semibold text-fg">{overlay.spins} spins</div>632 </motion.div>633 ) : null}634 {overlay.kind === "jackpot" ? (635 <motion.div initial={{ scale: 0.5, rotate: -4 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 200, damping: 14 }} className="relative text-center">636 <div className="text-[clamp(30px,7vw,60px)] font-extrabold uppercase tracking-tight shimmer-text">{overlay.tier} jackpot</div>637 <div className="mt-2 text-3xl font-bold tabular text-credit">{formatSC(overlay.amount)}</div>638 <div className="mt-1 text-xs text-fg-3">Fictional credits · no cash value</div>639 </motion.div>640 ) : null}641 {overlay.kind === "win" ? <WinPresentation cls={overlay.cls} amount={overlay.amount} multiplier={overlay.multiplier} /> : null}642 {overlay.kind === "bonus" ? <BonusReveal name={overlay.name} picks={overlay.picks} cells={overlay.cells} total={overlay.total} palette={palette} /> : null}643 </motion.div>644 ) : null}645 </AnimatePresence>646 );647}648649function WinPresentation({ cls, amount, multiplier }: { cls: string; amount: number; multiplier: number }) {650 const label = WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN";651 const [shown, setShown] = useState(0);652 useEffect(() => {653 const start = performance.now();654 const dur = 1400;655 let raf = 0;656 const step = () => {657 const t = Math.min(1, (performance.now() - start) / dur);658 setShown(Math.round(amount * (1 - Math.pow(1 - t, 3))));659 if (t < 1) raf = requestAnimationFrame(step);660 };661 raf = requestAnimationFrame(step);662 return () => cancelAnimationFrame(raf);663 }, [amount]);664 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";665 return (666 <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">667 <div className={cn("font-extrabold uppercase tracking-tight shimmer-text", size)}>{label}</div>668 <div className="mt-3 text-4xl font-bold tabular text-credit sm:text-5xl">{formatSC(shown)}</div>669 <div className="mt-1 text-base font-semibold text-fg-2">{formatMultiplier(multiplier)} the bet</div>670 </motion.div>671 );672}673674function BonusReveal({ name, picks, cells, total, palette }: { name: string; picks: { cell: number; value: number; amount: number }[]; cells: number; total: number; palette: ClientDefinition["presentation"]["palette"] }) {675 const [revealed, setRevealed] = useState(0);676 useEffect(() => {677 if (revealed >= picks.length) return;678 const t = setTimeout(() => setRevealed((r) => r + 1), 900);679 return () => clearTimeout(t);680 }, [revealed, picks.length]);681 const byCell = new Map(picks.slice(0, revealed).map((p) => [p.cell, p]));682 const cols = cells <= 6 ? 3 : cells <= 9 ? 3 : 4;683 return (684 <motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="relative w-[min(92vw,460px)] text-center">685 <div className="eyebrow" style={{ color: palette.secondary }}>686 Bonus game687 </div>688 <div className="text-3xl font-extrabold tracking-tight" style={{ color: palette.primary }}>689 {name}690 </div>691 <div className="mt-4 grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>692 {Array.from({ length: cells }, (_, i) => {693 const p = byCell.get(i);694 return (695 <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] } : {}}>696 {p ? formatSC(p.amount, { unit: false }) : "?"}697 </motion.div>698 );699 })}700 </div>701 <div className="mt-4 text-lg font-semibold">702 Total <span className="tabular text-credit">{formatSC(revealed >= picks.length ? total : picks.slice(0, revealed).reduce((a, p) => a + p.amount, 0))}</span>703 </div>704 </motion.div>705 );706}707708/* ------------------------------------------------------------------ sheets */709710function GameInfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ClientDefinition }) {711 const [tab, setTab] = useState<"rules" | "paytable" | "about">("rules");712 const symbols = new Map(definition.symbols.map((s) => [s.id, s]));713 return (714 <Sheet open={open} onClose={onClose} title={game.name} side="right">715 <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "Rules" }, { value: "paytable", label: "Paytable" }, { value: "about", label: "About" }]} className="mb-4" />716 {tab === "rules" ? (717 <ul className="space-y-3 text-sm text-fg-2">718 {game.rules.map((r, i) => (719 <li key={i} className="flex gap-3">720 <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>721 <span>{r}</span>722 </li>723 ))}724 </ul>725 ) : null}726 {tab === "paytable" ? (727 <div className="space-y-2">728 <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>729 {game.paytable.map((row) => {730 const s = symbols.get(row.symbol);731 return (732 <div key={row.symbol} className="surface flex items-center gap-3 rounded-md p-3">733 <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" }}>734 {s?.style.label ?? row.symbol}735 </span>736 <div className="min-w-0 flex-1">737 <div className="truncate text-sm font-semibold">{row.label}</div>738 <div className="text-[11px] uppercase tracking-wider text-fg-3">{s?.kind}</div>739 </div>740 <div className="flex gap-2 text-[12px] tabular">741 {Object.entries(row.pays)742 .sort((a, b) => Number(a[0]) - Number(b[0]))743 .map(([k, v]) => (744 <span key={k} className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-2">745 {k}× <span className="text-credit">{v}</span>746 </span>747 ))}748 </div>749 </div>750 );751 })}752 </div>753 ) : null}754 {tab === "about" ? (755 <div className="space-y-4 text-sm text-fg-2">756 <p>{game.description}</p>757 <dl className="grid grid-cols-2 gap-3">758 <Stat label="Volatility" value={game.volatility} />759 <Stat label="RTP (configured)" value={`${(game.rtp * 100).toFixed(2)}%`} />760 <Stat label="Hit frequency" value={game.hitFrequency ? `${(game.hitFrequency * 100).toFixed(1)}%` : "—"} />761 <Stat label="Max win" value={formatMultiplier(game.maxMultiplier)} />762 <Stat label="Grid" value={`${game.grid.reels} × ${game.grid.rows}`} />763 <Stat label="Version" value={game.version} />764 </dl>765 {game.certification ? (766 <div className="surface rounded-md p-3 text-[12px]">767 <div className="font-semibold text-fg">Internal certification · {game.certification.status}</div>768 <div className="mt-1 text-fg-3">769 {game.certification.spins.toLocaleString("en-US")} simulated spins · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%770 </div>771 </div>772 ) : null}773 <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>774 </div>775 ) : null}776 </Sheet>777 );778}779780function Stat({ label, value }: { label: string; value: string }) {781 return (782 <div className="surface rounded-md p-3">783 <dt className="eyebrow">{label}</dt>784 <dd className="mt-1 text-sm font-semibold capitalize text-fg">{value}</dd>785 </div>786 );787}788789function SettingsSheet({ open, onClose }: { open: boolean; onClose: () => void }) {790 const { settings, setSettings } = useSession();791 if (!settings) return null;792 return (793 <Sheet open={open} onClose={onClose} title="Game settings">794 <div className="space-y-4">795 <Slider label="Master volume" value={settings.masterVolume} onChange={(v) => setSettings({ masterVolume: v })} />796 <Slider label="Music" value={settings.musicVolume} onChange={(v) => setSettings({ musicVolume: v })} />797 <Slider label="Effects" value={settings.effectsVolume} onChange={(v) => setSettings({ effectsVolume: v })} />798 <div>799 <div className="mb-2 text-sm text-fg-2">Animation intensity</div>800 <Tabs value={settings.animationIntensity} onChange={(v) => setSettings({ animationIntensity: v })} items={[{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }]} />801 </div>802 <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">803 <span>Reduce motion</span>804 <span className={cn("font-semibold", settings.reduceMotion ? "text-success" : "text-fg-3")}>{settings.reduceMotion ? "On" : "Off"}</span>805 </button>806 <Link href="/settings" className="block text-center text-[13px] text-fg-3 underline-offset-4 hover:underline">807 All account settings808 </Link>809 </div>810 </Sheet>811 );812}813814function Slider({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {815 return (816 <label className="block">817 <div className="mb-1 flex justify-between text-sm">818 <span className="text-fg-2">{label}</span>819 <span className="tabular text-fg-3">{Math.round(value * 100)}%</span>820 </div>821 <input type="range" min={0} max={1} step={0.05} value={value} onChange={(e) => onChange(Number(e.target.value))} className="w-full accent-[#c9a961]" />822 </label>823 );824}825826function HistorySheet({ open, onClose, slug }: { open: boolean; onClose: () => void; slug: string }) {827 return (828 <Sheet open={open} onClose={onClose} title="Recent rounds" side="right">829 {open ? <HistoryList slug={slug} /> : null}830 </Sheet>831 );832}833834type HistoryRow = { roundId: string; bet: number; win: number; multiplier: number; balanceAfter: number; createdAt: string; features: string[] };835836function HistoryList({ slug }: { slug: string }) {837 const [rows, setRows] = useState<HistoryRow[] | null>(null);838 const [expanded, setExpanded] = useState<string | null>(null);839 useEffect(() => {840 let alive = true;841 api<{ entries: HistoryRow[] }>(`/api/games/${slug}/history?limit=30`)842 .then((r) => alive && setRows(r.entries))843 .catch(() => alive && setRows([]));844 return () => {845 alive = false;846 };847 }, [slug]);848 return (849 <>850 {rows === null ? (851 <div className="text-sm text-fg-3">Loading…</div>852 ) : rows.length === 0 ? (853 <div className="text-sm text-fg-3">No rounds yet on this game.</div>854 ) : (855 <ul className="divide-y divide-line">856 {rows.map((r) => (857 <li key={r.roundId}>858 <button onClick={() => setExpanded(expanded === r.roundId ? null : r.roundId)} className="flex w-full items-center justify-between py-3 text-left">859 <div>860 <div className="text-sm font-medium">861 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>862 </div>863 <div className="text-[11px] text-fg-3">{new Date(r.createdAt).toLocaleString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</div>864 </div>865 <div className="flex items-center gap-2 text-[12px] tabular text-fg-2">866 {formatMultiplier(r.multiplier)}867 {expanded === r.roundId ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}868 </div>869 </button>870 {expanded === r.roundId ? (871 <div className="pb-3 text-[12px] text-fg-3">872 <div className="font-mono">{r.roundId}</div>873 <div>Balance after: {formatSC(r.balanceAfter)}</div>874 {r.features.length ? <div>Features: {r.features.join(", ")}</div> : null}875 </div>876 ) : null}877 </li>878 ))}879 </ul>880 )}881 </>882 );883}884