SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
43.6 KB · 884 lines tsx
Raw Blame History
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