import * as React from "react"; import { cn } from "@/lib/utils"; /* ------------------------------------------------------------------------ Spinza key art — original procedural artwork, rendered as inline SVG. Deterministic per slug (seeded PRNG, no Math.random at render). ------------------------------------------------------------------------ */ export type ArtVariant = "card" | "hero" | "banner" | "tile"; export interface ArtPalette { primary: string; secondary: string; glow: string; bg: string; } export interface GameArtProps { slug: string; name: string; palette: ArtPalette; backdrop?: string; particles?: string; variant?: ArtVariant; className?: string; /** Render the game name inside the artwork (default true). */ showName?: boolean; /** Decorative by default; pass a label to expose the art to assistive tech. */ label?: string; } /** Presentation hints per game (the public /api/games payload does not carry them). */ export const GAME_PRESENTATION: Record = { "arctic-fortune": { backdrop: "aurora", particles: "snow" }, "cosmic-collapse": { backdrop: "nebula", particles: "stars" }, "deep-treasure": { backdrop: "abyss", particles: "bubbles" }, "diamond-heist": { backdrop: "vault", particles: "diamonds" }, "dragon-core": { backdrop: "core", particles: "fire" }, "golden-emperor": { backdrop: "pillars", particles: "coins" }, "inferno-reels": { backdrop: "lava", particles: "fire" }, "lucky-circuit": { backdrop: "arcade", particles: "confetti" }, "midnight-tokyo": { backdrop: "skyline", particles: "sparks" }, "moonbase-77": { backdrop: "moon", particles: "stars" }, "neon-vault": { backdrop: "vault", particles: "sparks" }, obsidian: { backdrop: "minimal", particles: "dust" }, "pharaoh-protocol": { backdrop: "pyramid", particles: "dust" }, "quantum-jackpot": { backdrop: "circuit", particles: "energy" }, "reel-reactor": { backdrop: "reactor", particles: "energy" }, "royal-circuit": { backdrop: "track", particles: "sparks" }, "spinza-original": { backdrop: "universe", particles: "energy" }, "void-miner": { backdrop: "asteroids", particles: "dust" }, "wild-temple": { backdrop: "temple", particles: "dust" }, "zero-gravity": { backdrop: "gravity", particles: "stars" }, }; const BACKDROPS = ["vault", "nebula", "pillars", "aurora", "lava", "circuit", "skyline", "pyramid", "arcade", "asteroids", "track", "reactor", "abyss", "moon", "temple", "gravity", "core", "minimal", "universe", "grid"] as const; type Backdrop = (typeof BACKDROPS)[number]; const DIMS: Record = { card: { w: 360, h: 480, pad: 24, maxFont: 54, oneLine: false }, tile: { w: 240, h: 240, pad: 16, maxFont: 30, oneLine: false }, hero: { w: 1600, h: 900, pad: 72, maxFont: 132, oneLine: true }, banner: { w: 1200, h: 400, pad: 48, maxFont: 84, oneLine: true }, }; /* ------------------------------------------------------------ seeded PRNG */ function hashString(s: string): number { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } interface Ctx { W: number; H: number; rnd: () => number; p: ArtPalette; id: string; } const r2 = (n: number) => Math.round(n * 100) / 100; const between = (rnd: () => number, a: number, b: number) => a + (b - a) * rnd(); /* ------------------------------------------------------------------ motifs */ function Vault({ W, H, rnd, p, id }: Ctx) { const cx = W * 0.62; const cy = H * 0.42; const maxR = Math.max(W, H) * 0.55; const rings: React.ReactNode[] = []; for (let i = 1; i <= 7; i++) { const r = (maxR / 7) * i; rings.push(); if (i === 3 || i === 5) { for (let k = 0; k < 8; k++) { const a = (Math.PI * 2 * k) / 8 + rnd() * 0.2; rings.push(); } } } const step = W / 8; const grid: React.ReactNode[] = []; for (let x = step / 2; x < W; x += step) grid.push(); for (let y = step / 2; y < H; y += step) grid.push(); return ( {grid} {rings} ); } function Nebula({ W, H, rnd, p, id }: Ctx) { const blobs: React.ReactNode[] = []; for (let i = 0; i < 5; i++) { blobs.push(); } const stars: React.ReactNode[] = []; for (let i = 0; i < 60; i++) { const big = rnd() > 0.85; stars.push(); } return ( {blobs} {stars} ); } function Pillars({ W, H, rnd, p, id }: Ctx) { const n = W > H ? 9 : 5; const gap = W / n; const cols: React.ReactNode[] = []; for (let i = 0; i < n; i++) { const cw = gap * 0.42; const x = gap * i + (gap - cw) / 2; const top = H * between(rnd, 0.12, 0.22); cols.push( , ); } return ( {cols} ); } function Aurora({ W, H, rnd, p, id }: Ctx) { const bands: React.ReactNode[] = []; for (let i = 0; i < 4; i++) { const y = H * (0.2 + i * 0.14); const amp = H * between(rnd, 0.04, 0.1); const d = `M${-W * 0.1} ${r2(y)} C ${r2(W * 0.2)} ${r2(y - amp)}, ${r2(W * 0.35)} ${r2(y + amp)}, ${r2(W * 0.55)} ${r2(y)} S ${r2(W * 0.9)} ${r2(y - amp)}, ${r2(W * 1.1)} ${r2(y + amp * 0.5)}`; bands.push(); } const stars: React.ReactNode[] = []; for (let i = 0; i < 30; i++) stars.push(); const peaks: number[] = []; for (let x = 0; x <= W; x += W / 9) peaks.push(H * between(rnd, 0.72, 0.88)); const ridge = `M0 ${H} ` + peaks.map((y, i) => `L${r2((i * W) / 9)} ${r2(y)}`).join(" ") + ` L${W} ${H} Z`; return ( {stars} {bands} ); } function Lava({ W, H, rnd, p, id }: Ctx) { const cracks: React.ReactNode[] = []; for (let i = 0; i < 6; i++) { let x = between(rnd, W * 0.05, W * 0.95); let y = H; const pts = [`M${r2(x)} ${r2(y)}`]; while (y > H * 0.15) { x += between(rnd, -W * 0.08, W * 0.08); y -= between(rnd, H * 0.06, H * 0.14); pts.push(`L${r2(x)} ${r2(y)}`); } const d = pts.join(" "); cracks.push(); cracks.push(); } return ( {cracks} ); } function Circuit({ W, H, rnd, p }: Ctx) { const traces: React.ReactNode[] = []; const step = Math.min(W, H) / 10; for (let i = 0; i < 16; i++) { let x = Math.round(between(rnd, 0, W / step)) * step; let y = Math.round(between(rnd, 0, H / step)) * step; const pts = [`M${r2(x)} ${r2(y)}`]; const segs = 2 + Math.floor(rnd() * 3); for (let s = 0; s < segs; s++) { const horiz = rnd() > 0.5; const len = step * (1 + Math.floor(rnd() * 3)); if (horiz) x += rnd() > 0.5 ? len : -len; else y += rnd() > 0.5 ? len : -len; pts.push(`L${r2(x)} ${r2(y)}`); } const color = i % 3 === 0 ? p.secondary : p.primary; traces.push(); traces.push(); } return {traces}; } function Skyline({ W, H, rnd, p, id }: Ctx) { const buildings: React.ReactNode[] = []; let x = -10; let i = 0; while (x < W + 10) { const bw = between(rnd, W * 0.05, W * 0.14); const bh = H * between(rnd, 0.25, 0.7); const y = H - bh; buildings.push(); const cols = Math.max(1, Math.floor(bw / 14)); const rows = Math.floor(bh / 18); for (let c = 0; c < cols; c++) { for (let r = 0; r < rows; r++) { if (rnd() > 0.45) continue; buildings.push( 0.7 ? p.secondary : p.primary} fillOpacity={r2(between(rnd, 0.4, 0.95))} />); } } if (rnd() > 0.7) buildings.push(); x += bw + between(rnd, 4, 14); i++; } return ( {buildings} ); } function Pyramid({ W, H, rnd, p, id }: Ctx) { const tri = (cx: number, base: number, h: number, key: string, fill: string, op: number) => ; const rays: React.ReactNode[] = []; for (let i = 0; i < 14; i++) { const a = -Math.PI * 0.95 + (i / 13) * Math.PI * 0.9; rays.push( 0.5 ? 0.05 : 0)} strokeWidth={1.5} />); } return ( {rays} {tri(W * 0.2, W * 0.5, H * 0.34, "l", "#120c05", 1)} {tri(W * 0.82, W * 0.55, H * 0.4, "r", "#0f0a04", 1)} {tri(W * 0.5, W * 0.72, H * 0.56, "c", "#1a1208", 1)} ); } function Arcade({ W, H, rnd, p }: Ctx) { const size = Math.min(W, H) / 12; const cells: React.ReactNode[] = []; const colors = [p.primary, p.secondary, p.glow]; for (let y = 0; y < H; y += size) { for (let x = 0; x < W; x += size) { const r = rnd(); if (r > 0.22) continue; cells.push(); } } return ( {cells} ); } function Asteroids({ W, H, rnd, p, id }: Ctx) { const rocks: React.ReactNode[] = []; for (let i = 0; i < 9; i++) { const cx = between(rnd, 0, W); const cy = between(rnd, 0, H * 0.8); const rad = between(rnd, Math.min(W, H) * 0.03, Math.min(W, H) * 0.14); const n = 7 + Math.floor(rnd() * 4); const pts: string[] = []; for (let k = 0; k < n; k++) { const a = (k / n) * Math.PI * 2; const rr = rad * between(rnd, 0.72, 1.1); pts.push(`${r2(cx + Math.cos(a) * rr)},${r2(cy + Math.sin(a) * rr)}`); } rocks.push(); if (rnd() > 0.5) rocks.push(); } const stars: React.ReactNode[] = []; for (let i = 0; i < 40; i++) stars.push(); return ( {stars} {rocks} ); } function Track({ W, H, p }: Ctx) { const lanes: React.ReactNode[] = []; const cx = W * 1.05; const cy = H * 0.55; for (let i = 0; i < 6; i++) { const r = Math.max(W, H) * (0.28 + i * 0.13); lanes.push(); if (i > 0 && i < 5) lanes.push(); } const kerb: React.ReactNode[] = []; const kr = Math.max(W, H) * 0.28; for (let k = 0; k < 40; k++) { const a = Math.PI * 0.5 + (k / 40) * Math.PI; kerb.push(); } return ( {lanes} {kerb} ); } function hexPoints(cx: number, cy: number, r: number, rot = 0): string { const pts: string[] = []; for (let k = 0; k < 6; k++) { const a = rot + (k / 6) * Math.PI * 2; pts.push(`${r2(cx + Math.cos(a) * r)},${r2(cy + Math.sin(a) * r)}`); } return pts.join(" "); } function Reactor({ W, H, rnd, p, id }: Ctx) { const cx = W * 0.5; const cy = H * 0.42; const base = Math.min(W, H) * 0.12; const rings: React.ReactNode[] = []; for (let i = 1; i <= 4; i++) rings.push(); const spokes: React.ReactNode[] = []; for (let k = 0; k < 12; k++) { const a = (k / 12) * Math.PI * 2; const len = base * between(rnd, 3.2, 5); spokes.push(); } const cells: React.ReactNode[] = []; for (let i = 0; i < 14; i++) { const a = rnd() * Math.PI * 2; const d = base * between(rnd, 3.5, 6); cells.push(); } return ( {cells} {spokes} {rings} ); } function Abyss({ W, H, rnd, p, id }: Ctx) { const bubbles: React.ReactNode[] = []; for (let i = 0; i < 26; i++) { const r = between(rnd, 2, Math.min(W, H) * 0.035); const cx = rnd() * W; const cy = between(rnd, H * 0.2, H); bubbles.push(); bubbles.push(); } const rays: React.ReactNode[] = []; for (let i = 0; i < 5; i++) { const x = W * (0.2 + i * 0.15); rays.push(); } return ( {rays} {bubbles} ); } function Moon({ W, H, rnd, p, id }: Ctx) { const R = Math.min(W, H) * 0.42; const cx = W * 0.62; const cy = H * 0.5; const craters: React.ReactNode[] = []; for (let i = 0; i < 12; i++) { const a = rnd() * Math.PI * 2; const d = rnd() * R * 0.8; const r = between(rnd, R * 0.04, R * 0.16); const x = cx + Math.cos(a) * d; const y = cy + Math.sin(a) * d; craters.push(); craters.push(); craters.push(); } const stars: React.ReactNode[] = []; for (let i = 0; i < 40; i++) stars.push(); return ( {stars} {craters} ); } function Temple({ W, H, rnd, p, id }: Ctx) { const steps: React.ReactNode[] = []; const n = 6; for (let i = 0; i < n; i++) { const wdt = W * (0.95 - i * 0.14); const h = H * 0.11; const y = H - h * (i + 1); steps.push(); const blocks = Math.floor(wdt / (W * 0.08)); for (let b = 0; b < blocks; b++) if (rnd() > 0.5) steps.push(); } const vines: React.ReactNode[] = []; for (let i = 0; i < 8; i++) { const x = between(rnd, W * 0.05, W * 0.95); vines.push(); } return ( {steps} {vines} ); } function Gravity({ W, H, rnd, p, id }: Ctx) { const cx = W * 0.5; const cy = H * 0.45; const orbits: React.ReactNode[] = []; for (let i = 0; i < 5; i++) { const rx = Math.min(W, H) * (0.22 + i * 0.1); const ry = rx * between(rnd, 0.3, 0.5); const rot = between(rnd, -60, 60); orbits.push(); const a = rnd() * Math.PI * 2; const px = cx + Math.cos(a) * rx; const py = cy + Math.sin(a) * ry; orbits.push(); } const stars: React.ReactNode[] = []; for (let i = 0; i < 40; i++) stars.push(); return ( {stars} {orbits} ); } function Core({ W, H, rnd, p, id }: Ctx) { const cx = W * 0.5; const cy = H * 0.44; const R = Math.min(W, H) * 0.22; const arcs: React.ReactNode[] = []; for (let i = 0; i < 3; i++) { const r = R * (1.5 + i * 0.35); arcs.push(); } const embers: React.ReactNode[] = []; for (let i = 0; i < 30; i++) { const a = rnd() * Math.PI * 2; const d = R * between(rnd, 1.1, 3.2); embers.push( 0.5 ? p.secondary : p.primary} fillOpacity={r2(between(rnd, 0.3, 0.9))} />); } return ( {embers} {arcs} ); } function Minimal({ W, H, p }: Ctx) { const y = H * 0.5; return ( ); } function Universe({ W, H, rnd, p, id }: Ctx) { const cx = W * 0.5; const cy = H * 0.44; const R = Math.min(W, H) * 0.3; const rays: React.ReactNode[] = []; for (let i = 0; i < 48; i++) { const a = (i / 48) * Math.PI * 2; const len = R * between(rnd, 1.2, 2.6); rays.push(); } const stars: React.ReactNode[] = []; for (let i = 0; i < 50; i++) stars.push(); return ( {stars} {rays} ); } function PerspectiveGrid({ W, H, p }: Ctx) { const horizon = H * 0.42; const lines: React.ReactNode[] = []; for (let i = -8; i <= 8; i++) { lines.push(); } for (let k = 1; k <= 10; k++) { const t = k / 10; const y = horizon + (H - horizon) * t * t; lines.push(); } return ( {lines} ); } const MOTIFS: Record React.ReactNode> = { vault: Vault, nebula: Nebula, pillars: Pillars, aurora: Aurora, lava: Lava, circuit: Circuit, skyline: Skyline, pyramid: Pyramid, arcade: Arcade, asteroids: Asteroids, track: Track, reactor: Reactor, abyss: Abyss, moon: Moon, temple: Temple, gravity: Gravity, core: Core, minimal: Minimal, universe: Universe, grid: PerspectiveGrid, }; /* --------------------------------------------------------------- particles */ function Particles({ W, H, rnd, p, kind }: Ctx & { kind: string }) { const out: React.ReactNode[] = []; const n = 22; for (let i = 0; i < n; i++) { const x = r2(rnd() * W); const y = r2(rnd() * H); const op = r2(between(rnd, 0.25, 0.8)); const s = between(rnd, 1.5, 4); switch (kind) { case "snow": out.push(); break; case "coins": out.push(); break; case "diamonds": out.push(); break; case "fire": case "sparks": out.push( 0.5 ? p.primary : p.secondary} strokeOpacity={op} strokeWidth={1.5} strokeLinecap="round" />); break; case "confetti": out.push(); break; case "bubbles": out.push(); break; case "energy": out.push(); break; case "dust": out.push(); break; default: out.push(); } } return {out}; } /* ------------------------------------------------------------------- title */ function splitName(name: string, oneLine: boolean): string[] { if (oneLine) return [name]; const words = name.trim().split(/\s+/); if (words.length <= 1) return words; if (words.length === 2) return words; const mid = Math.ceil(words.length / 2); return [words.slice(0, mid).join(" "), words.slice(mid).join(" ")]; } function Title({ name, W, H, pad, maxFont, oneLine, p }: { name: string; W: number; H: number; pad: number; maxFont: number; oneLine: boolean; p: ArtPalette }) { const lines = splitName(name.toUpperCase(), oneLine); const longest = Math.max(...lines.map((l) => l.length), 1); const size = Math.min(maxFont, ((W - pad * 2) / longest) * 1.55); const lineH = size * 0.92; const baseY = H - pad - (lines.length - 1) * lineH; return ( {lines.map((l, i) => ( {l} ))} {lines.map((l, i) => ( {l} ))} ); } /* ----------------------------------------------------------------- GameArt */ export function GameArt({ slug, name, palette, backdrop, particles, variant = "card", className, showName = true, label }: GameArtProps) { const reactId = React.useId().replace(/[^a-zA-Z0-9]/g, ""); const id = `ga${reactId}`; const preset = GAME_PRESENTATION[slug]; const bd = (backdrop ?? preset?.backdrop ?? BACKDROPS[hashString(slug) % BACKDROPS.length]) as string; const motifKey: Backdrop = (BACKDROPS as readonly string[]).includes(bd) ? (bd as Backdrop) : "grid"; const pt = particles ?? preset?.particles ?? "dust"; const { w: W, h: H, pad, maxFont, oneLine } = DIMS[variant]; const rnd = mulberry32(hashString(`${slug}:${variant}`)); const ctx: Ctx = { W, H, rnd, p: palette, id }; const motif = MOTIFS[motifKey]; return ( {/* Called as plain functions (not components) so the seeded PRNG sequence is consumed exactly once per render — keeps SSR and client output identical even under StrictMode double-rendering. */} {motif(ctx)} {Particles({ ...ctx, kind: pt })} {showName ? : null} ); }