SPB Git

spb/spinza

Public
41.9 KB · 856 lines tsx
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
Files · 0b40ac1a7181cdec915de0b36dee3e78427df0c3
Menu

Theme