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%
9.9 KB · 201 lines tsx
Raw Blame History
1"use client";23import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";4import Link from "next/link";5import { useRouter } from "next/navigation";6import { AnimatePresence, motion } from "framer-motion";7import { ArrowLeft, Heart, Info, Minus, Plus, Volume2, VolumeX } from "lucide-react";8import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared";9import type { ArcadeGameDefinition } from "@spinza/game-core/client";10import { api } from "@/lib/api";11import { useSession } from "@/lib/store";12import { cn } from "@/lib/utils";13import { Credits, Sheet, Tabs } from "@/components/ui";14import { getSound } from "@/components/game/sound";15import type { ArcadeGameProps } from "./contract";1617/**18 * Shared chrome for the Beyond Slots originals: top bar, bet controls,19 * balance, win banner, info sheet. The game component fills the middle.20 */21export function ArcadeShell({ game, definition, Game }: { game: GameInfo; definition: ArcadeGameDefinition; Game: ComponentType<ArcadeGameProps> }) {22  const router = useRouter();23  const { status, wallet, settings } = useSession();24  const [bet, setBet] = useState(100);25  const [busy, setBusy] = useState(false);26  const [last, setLast] = useState<{ win: number; multiplier: number } | null>(null);27  const [banner, setBanner] = useState<{ label: string; win: number; multiplier: number } | null>(null);28  const [infoSheet, setInfoSheet] = useState(false);29  const [favorite, setFavorite] = useState(!!game.favorite);30  const soundOn = settings?.soundEnabled ?? true;31  const palette = definition.presentation.palette;32  const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]);33  const balance = wallet?.balance ?? 0;34  const betIndex = bets.indexOf(bet);3536  useEffect(() => {37    if (status === "guest") router.replace(`/login?next=/games/${game.slug}`);38  }, [status, router, game.slug]);3940  useEffect(() => {41    const s = getSound();42    s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 });43    s.setAmbience(definition.presentation.ambience);44    api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {});45    return () => s.destroy();46  }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience, game.slug]);4748  const sound = useCallback<ArcadeGameProps["sound"]>((name) => {49    const s = getSound();50    s.unlock();51    switch (name) {52      case "click":53        s.click();54        break;55      case "tick":56        s.tick();57        break;58      case "win":59        s.win(2);60        break;61      case "bigWin":62        s.bigWin();63        break;64      case "lose":65        s.error();66        break;67      case "bonus":68        s.bonus();69        break;70    }71  }, []);7273  const onResult = useCallback<ArcadeGameProps["onResult"]>((r) => {74    setLast(r);75    const cls = classifyWin(r.multiplier);76    if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") {77      setBanner({ label: WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN", win: r.win, multiplier: r.multiplier });78      setTimeout(() => setBanner(null), 2600);79    }80  }, []);8182  const toggleFavorite = async () => {83    setFavorite((f) => !f);84    try {85      const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" });86      setFavorite(r.favorite);87    } catch {88      setFavorite((f) => !f);89    }90  };9192  if (status === "guest") return null;9394  return (95    <div className="fixed inset-0 flex flex-col" style={{ background: `radial-gradient(120% 80% at 50% 0%, ${palette.surface} 0%, ${palette.bg} 60%, #050608 100%)` }}>96      <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}>97        <div className="flex items-center gap-2">98          <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Back to lobby">99            <ArrowLeft className="h-5 w-5" />100          </Link>101          <div className="leading-tight">102            <div className="text-[15px] font-semibold tracking-tight">{game.name}</div>103            <div className="text-[11px] text-fg-3">Spinza Original · Beyond Slots</div>104          </div>105        </div>106        <div className="flex items-center gap-1">107          <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Favourite">108            <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} />109          </button>110          <button onClick={() => setInfoSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Game info">111            <Info className="h-5 w-5" />112          </button>113          <button onClick={() => useSession.getState().setSettings({ soundEnabled: !soundOn })} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Sound">114            {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}115          </button>116        </div>117      </div>118119      <div className="relative flex-1 min-h-0">120        <Game game={game} definition={definition} bet={bet} onBusy={setBusy} onResult={onResult} sound={sound} reduceMotion={settings?.reduceMotion ?? false} />121        <AnimatePresence>122          {banner ? (123            <motion.div key={banner.win} initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} className="pointer-events-none absolute inset-0 grid place-items-center bg-black/40" style={{ zIndex: 5 }}>124              <div className="text-center">125                <div className="text-[clamp(34px,9vw,72px)] font-extrabold uppercase tracking-tight shimmer-text">{banner.label}</div>126                <div className="mt-2 text-3xl font-bold tabular text-credit">{formatSC(banner.win)}</div>127                <div className="text-sm text-fg-2">{formatMultiplier(banner.multiplier)} the bet</div>128              </div>129            </motion.div>130          ) : null}131        </AnimatePresence>132      </div>133134      <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` }}>135        <div className="mx-auto flex max-w-3xl items-center justify-between gap-3">136          <div>137            <div className="eyebrow">Balance</div>138            <Credits amount={balance} size="md" />139          </div>140          <div className="flex items-center gap-1">141            <button disabled={busy || 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">142              <Minus className="h-4 w-4" />143            </button>144            <div className="flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2">145              <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span>146              <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span>147            </div>148            <button disabled={busy || 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">149              <Plus className="h-4 w-4" />150            </button>151          </div>152          <div className="text-right">153            <div className="eyebrow">Last win</div>154            <div className={cn("text-base font-semibold tabular", last && last.win > 0 ? "text-credit" : "text-fg-4")}>{last && last.win > 0 ? `${formatSC(last.win)} · ${formatMultiplier(last.multiplier)}` : "—"}</div>155          </div>156        </div>157      </div>158159      <InfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} />160    </div>161  );162}163164function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ArcadeGameDefinition }) {165  const [tab, setTab] = useState<"rules" | "about">("rules");166  return (167    <Sheet open={open} onClose={onClose} title={game.name} side="right">168      <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "How to play" }, { value: "about", label: "About" }]} className="mb-4" />169      {tab === "rules" ? (170        <ul className="space-y-3 text-sm text-fg-2">171          {game.rules.map((r, i) => (172            <li key={i} className="flex gap-3">173              <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>174              <span>{r}</span>175            </li>176          ))}177        </ul>178      ) : (179        <div className="space-y-4 text-sm text-fg-2">180          <p>{game.description}</p>181          <dl className="grid grid-cols-2 gap-3">182            {[183              ["Volatility", game.volatility],184              ["RTP", `${(game.rtp * 100).toFixed(2)}%`],185              ["Max win", formatMultiplier(definition.maxMultiplier)],186              ["Mode", definition.mode === "ladder" ? "Step by step" : "Instant"],187            ].map(([k, v]) => (188              <div key={k} className="surface rounded-md p-3">189                <dt className="eyebrow">{k}</dt>190                <dd className="mt-1 text-sm font-semibold capitalize text-fg">{v}</dd>191              </div>192            ))}193          </dl>194          {game.certification ? <p className="text-[12px] text-fg-3">Certified on {game.certification.spins.toLocaleString("en-US")} simulated rounds · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%.</p> : null}195          <p className="text-[12px] text-fg-3">Spinza Credits are fictional and have no cash value.</p>196        </div>197      )}198    </Sheet>199  );200}201