SPB Git

spb/spinza

Public
34.4 KB · 762 lines tsx
1import * as React from "react";2import { cn } from "@/lib/utils";34/* ------------------------------------------------------------------------5   Spinza key art — original procedural artwork, rendered as inline SVG.6   Deterministic per slug (seeded PRNG, no Math.random at render).7   ------------------------------------------------------------------------ */89export type ArtVariant = "card" | "hero" | "banner" | "tile";1011export interface ArtPalette {12  primary: string;13  secondary: string;14  glow: string;15  bg: string;16}1718export interface GameArtProps {19  slug: string;20  name: string;21  palette: ArtPalette;22  backdrop?: string;23  particles?: string;24  variant?: ArtVariant;25  className?: string;26  /** Render the game name inside the artwork (default true). */27  showName?: boolean;28  /** Decorative by default; pass a label to expose the art to assistive tech. */29  label?: string;30}3132/** Presentation hints per game (the public /api/games payload does not carry them). */33export const GAME_PRESENTATION: Record<string, { backdrop: string; particles: string }> = {34  "arctic-fortune": { backdrop: "aurora", particles: "snow" },35  "cosmic-collapse": { backdrop: "nebula", particles: "stars" },36  "deep-treasure": { backdrop: "abyss", particles: "bubbles" },37  "diamond-heist": { backdrop: "vault", particles: "diamonds" },38  "dragon-core": { backdrop: "core", particles: "fire" },39  "golden-emperor": { backdrop: "pillars", particles: "coins" },40  "inferno-reels": { backdrop: "lava", particles: "fire" },41  "lucky-circuit": { backdrop: "arcade", particles: "confetti" },42  "midnight-tokyo": { backdrop: "skyline", particles: "sparks" },43  "moonbase-77": { backdrop: "moon", particles: "stars" },44  "neon-vault": { backdrop: "vault", particles: "sparks" },45  obsidian: { backdrop: "minimal", particles: "dust" },46  "pharaoh-protocol": { backdrop: "pyramid", particles: "dust" },47  "quantum-jackpot": { backdrop: "circuit", particles: "energy" },48  "reel-reactor": { backdrop: "reactor", particles: "energy" },49  "royal-circuit": { backdrop: "track", particles: "sparks" },50  "spinza-original": { backdrop: "universe", particles: "energy" },51  "void-miner": { backdrop: "asteroids", particles: "dust" },52  "wild-temple": { backdrop: "temple", particles: "dust" },53  "zero-gravity": { backdrop: "gravity", particles: "stars" },54};5556const BACKDROPS = ["vault", "nebula", "pillars", "aurora", "lava", "circuit", "skyline", "pyramid", "arcade", "asteroids", "track", "reactor", "abyss", "moon", "temple", "gravity", "core", "minimal", "universe", "grid"] as const;57type Backdrop = (typeof BACKDROPS)[number];5859const DIMS: Record<ArtVariant, { w: number; h: number; pad: number; maxFont: number; oneLine: boolean }> = {60  card: { w: 360, h: 480, pad: 24, maxFont: 54, oneLine: false },61  tile: { w: 240, h: 240, pad: 16, maxFont: 30, oneLine: false },62  hero: { w: 1600, h: 900, pad: 72, maxFont: 132, oneLine: true },63  banner: { w: 1200, h: 400, pad: 48, maxFont: 84, oneLine: true },64};6566/* ------------------------------------------------------------ seeded PRNG */6768function hashString(s: string): number {69  let h = 0x811c9dc5;70  for (let i = 0; i < s.length; i++) {71    h ^= s.charCodeAt(i);72    h = Math.imul(h, 0x01000193);73  }74  return h >>> 0;75}7677function mulberry32(seed: number): () => number {78  let a = seed >>> 0;79  return () => {80    a = (a + 0x6d2b79f5) >>> 0;81    let t = a;82    t = Math.imul(t ^ (t >>> 15), t | 1);83    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);84    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;85  };86}8788interface Ctx {89  W: number;90  H: number;91  rnd: () => number;92  p: ArtPalette;93  id: string;94}9596const r2 = (n: number) => Math.round(n * 100) / 100;97const between = (rnd: () => number, a: number, b: number) => a + (b - a) * rnd();9899/* ------------------------------------------------------------------ motifs */100101function Vault({ W, H, rnd, p, id }: Ctx) {102  const cx = W * 0.62;103  const cy = H * 0.42;104  const maxR = Math.max(W, H) * 0.55;105  const rings: React.ReactNode[] = [];106  for (let i = 1; i <= 7; i++) {107    const r = (maxR / 7) * i;108    rings.push(<circle key={`r${i}`} cx={cx} cy={cy} r={r2(r)} fill="none" stroke={i % 2 ? p.primary : p.secondary} strokeOpacity={r2(0.06 + (7 - i) * 0.035)} strokeWidth={i === 3 ? 6 : 1.5} />);109    if (i === 3 || i === 5) {110      for (let k = 0; k < 8; k++) {111        const a = (Math.PI * 2 * k) / 8 + rnd() * 0.2;112        rings.push(<circle key={`b${i}${k}`} cx={r2(cx + Math.cos(a) * r)} cy={r2(cy + Math.sin(a) * r)} r={i === 3 ? 4 : 2.5} fill={p.glow} fillOpacity={0.7} />);113      }114    }115  }116  const step = W / 8;117  const grid: React.ReactNode[] = [];118  for (let x = step / 2; x < W; x += step) grid.push(<line key={`gx${x}`} x1={r2(x)} y1={0} x2={r2(x)} y2={H} stroke={p.primary} strokeOpacity={0.05} />);119  for (let y = step / 2; y < H; y += step) grid.push(<line key={`gy${y}`} x1={0} y1={r2(y)} x2={W} y2={r2(y)} stroke={p.primary} strokeOpacity={0.05} />);120  return (121    <g>122      {grid}123      {rings}124      <circle cx={cx} cy={cy} r={r2(maxR / 14)} fill={`url(#${id}-glow)`} />125      <circle cx={cx} cy={cy} r={r2(maxR / 28)} fill={p.glow} fillOpacity={0.9} />126    </g>127  );128}129130function Nebula({ W, H, rnd, p, id }: Ctx) {131  const blobs: React.ReactNode[] = [];132  for (let i = 0; i < 5; i++) {133    blobs.push(<ellipse key={i} cx={r2(between(rnd, W * 0.2, W * 0.8))} cy={r2(between(rnd, H * 0.15, H * 0.7))} rx={r2(between(rnd, W * 0.18, W * 0.42))} ry={r2(between(rnd, H * 0.1, H * 0.28))} fill={i % 2 ? p.primary : p.secondary} fillOpacity={r2(between(rnd, 0.18, 0.34))} transform={`rotate(${r2(between(rnd, -30, 30))} ${W / 2} ${H / 2})`} filter={`url(#${id}-blur)`} />);134  }135  const stars: React.ReactNode[] = [];136  for (let i = 0; i < 60; i++) {137    const big = rnd() > 0.85;138    stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H)} r={big ? r2(between(rnd, 1.6, 2.6)) : r2(between(rnd, 0.5, 1.2))} fill={big ? p.glow : "#fff"} fillOpacity={r2(between(rnd, 0.35, 0.95))} />);139  }140  return (141    <g>142      {blobs}143      {stars}144    </g>145  );146}147148function Pillars({ W, H, rnd, p, id }: Ctx) {149  const n = W > H ? 9 : 5;150  const gap = W / n;151  const cols: React.ReactNode[] = [];152  for (let i = 0; i < n; i++) {153    const cw = gap * 0.42;154    const x = gap * i + (gap - cw) / 2;155    const top = H * between(rnd, 0.12, 0.22);156    cols.push(157      <g key={i}>158        <rect x={r2(x)} y={r2(top)} width={r2(cw)} height={r2(H - top)} fill={`url(#${id}-col)`} />159        <rect x={r2(x - cw * 0.18)} y={r2(top - 10)} width={r2(cw * 1.36)} height={12} fill={p.primary} fillOpacity={0.5} />160        <rect x={r2(x + cw * 0.3)} y={r2(top)} width={2} height={r2(H - top)} fill={p.glow} fillOpacity={0.12} />161      </g>,162    );163  }164  return (165    <g>166      <circle cx={W * 0.5} cy={H * 0.3} r={Math.min(W, H) * 0.22} fill={`url(#${id}-glow)`} />167      {cols}168      <rect x={0} y={H * 0.88} width={W} height={H * 0.12} fill={p.secondary} fillOpacity={0.25} />169    </g>170  );171}172173function Aurora({ W, H, rnd, p, id }: Ctx) {174  const bands: React.ReactNode[] = [];175  for (let i = 0; i < 4; i++) {176    const y = H * (0.2 + i * 0.14);177    const amp = H * between(rnd, 0.04, 0.1);178    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)}`;179    bands.push(<path key={i} d={d} fill="none" stroke={i % 2 ? p.primary : p.secondary} strokeOpacity={r2(0.55 - i * 0.08)} strokeWidth={r2(H * between(rnd, 0.05, 0.09))} strokeLinecap="round" filter={`url(#${id}-blur)`} />);180  }181  const stars: React.ReactNode[] = [];182  for (let i = 0; i < 30; i++) stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H * 0.5)} r={r2(between(rnd, 0.6, 1.6))} fill="#fff" fillOpacity={r2(between(rnd, 0.3, 0.9))} />);183  const peaks: number[] = [];184  for (let x = 0; x <= W; x += W / 9) peaks.push(H * between(rnd, 0.72, 0.88));185  const ridge = `M0 ${H} ` + peaks.map((y, i) => `L${r2((i * W) / 9)} ${r2(y)}`).join(" ") + ` L${W} ${H} Z`;186  return (187    <g>188      {stars}189      {bands}190      <path d={ridge} fill={p.bg} fillOpacity={0.95} />191      <path d={ridge} fill={p.primary} fillOpacity={0.08} />192    </g>193  );194}195196function Lava({ W, H, rnd, p, id }: Ctx) {197  const cracks: React.ReactNode[] = [];198  for (let i = 0; i < 6; i++) {199    let x = between(rnd, W * 0.05, W * 0.95);200    let y = H;201    const pts = [`M${r2(x)} ${r2(y)}`];202    while (y > H * 0.15) {203      x += between(rnd, -W * 0.08, W * 0.08);204      y -= between(rnd, H * 0.06, H * 0.14);205      pts.push(`L${r2(x)} ${r2(y)}`);206    }207    const d = pts.join(" ");208    cracks.push(<path key={`g${i}`} d={d} fill="none" stroke={p.glow} strokeOpacity={0.55} strokeWidth={8} strokeLinecap="round" strokeLinejoin="round" filter={`url(#${id}-blur)`} />);209    cracks.push(<path key={`c${i}`} d={d} fill="none" stroke={p.primary} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />);210  }211  return (212    <g>213      <rect x={0} y={H * 0.6} width={W} height={H * 0.4} fill={`url(#${id}-heat)`} />214      {cracks}215    </g>216  );217}218219function Circuit({ W, H, rnd, p }: Ctx) {220  const traces: React.ReactNode[] = [];221  const step = Math.min(W, H) / 10;222  for (let i = 0; i < 16; i++) {223    let x = Math.round(between(rnd, 0, W / step)) * step;224    let y = Math.round(between(rnd, 0, H / step)) * step;225    const pts = [`M${r2(x)} ${r2(y)}`];226    const segs = 2 + Math.floor(rnd() * 3);227    for (let s = 0; s < segs; s++) {228      const horiz = rnd() > 0.5;229      const len = step * (1 + Math.floor(rnd() * 3));230      if (horiz) x += rnd() > 0.5 ? len : -len;231      else y += rnd() > 0.5 ? len : -len;232      pts.push(`L${r2(x)} ${r2(y)}`);233    }234    const color = i % 3 === 0 ? p.secondary : p.primary;235    traces.push(<path key={`t${i}`} d={pts.join(" ")} fill="none" stroke={color} strokeOpacity={0.35} strokeWidth={2} strokeLinejoin="round" />);236    traces.push(<circle key={`p${i}`} cx={r2(x)} cy={r2(y)} r={4} fill={p.bg} stroke={color} strokeWidth={2} />);237  }238  return <g>{traces}</g>;239}240241function Skyline({ W, H, rnd, p, id }: Ctx) {242  const buildings: React.ReactNode[] = [];243  let x = -10;244  let i = 0;245  while (x < W + 10) {246    const bw = between(rnd, W * 0.05, W * 0.14);247    const bh = H * between(rnd, 0.25, 0.7);248    const y = H - bh;249    buildings.push(<rect key={`b${i}`} x={r2(x)} y={r2(y)} width={r2(bw)} height={r2(bh)} fill={i % 2 ? "#0a0a12" : "#0e0d18"} stroke={p.primary} strokeOpacity={0.35} strokeWidth={1} />);250    const cols = Math.max(1, Math.floor(bw / 14));251    const rows = Math.floor(bh / 18);252    for (let c = 0; c < cols; c++) {253      for (let r = 0; r < rows; r++) {254        if (rnd() > 0.45) continue;255        buildings.push(<rect key={`w${i}-${c}-${r}`} x={r2(x + 5 + c * 14)} y={r2(y + 8 + r * 18)} width={5} height={7} fill={rnd() > 0.7 ? p.secondary : p.primary} fillOpacity={r2(between(rnd, 0.4, 0.95))} />);256      }257    }258    if (rnd() > 0.7) buildings.push(<line key={`a${i}`} x1={r2(x + bw / 2)} y1={r2(y)} x2={r2(x + bw / 2)} y2={r2(y - H * 0.08)} stroke={p.primary} strokeOpacity={0.7} strokeWidth={1.5} />);259    x += bw + between(rnd, 4, 14);260    i++;261  }262  return (263    <g>264      <circle cx={W * 0.72} cy={H * 0.28} r={Math.min(W, H) * 0.14} fill={`url(#${id}-glow)`} />265      <circle cx={W * 0.72} cy={H * 0.28} r={Math.min(W, H) * 0.07} fill={p.primary} fillOpacity={0.55} />266      {buildings}267      <rect x={0} y={H * 0.995} width={W} height={H * 0.005} fill={p.secondary} />268    </g>269  );270}271272function Pyramid({ W, H, rnd, p, id }: Ctx) {273  const tri = (cx: number, base: number, h: number, key: string, fill: string, op: number) => <polygon key={key} points={`${r2(cx - base / 2)},${r2(H * 0.86)} ${r2(cx)},${r2(H * 0.86 - h)} ${r2(cx + base / 2)},${r2(H * 0.86)}`} fill={fill} fillOpacity={op} stroke={p.primary} strokeOpacity={0.4} />;274  const rays: React.ReactNode[] = [];275  for (let i = 0; i < 14; i++) {276    const a = -Math.PI * 0.95 + (i / 13) * Math.PI * 0.9;277    rays.push(<line key={i} x1={W * 0.5} y1={H * 0.36} x2={r2(W * 0.5 + Math.cos(a) * W)} y2={r2(H * 0.36 + Math.sin(a) * W)} stroke={p.primary} strokeOpacity={0.06 + (rnd() > 0.5 ? 0.05 : 0)} strokeWidth={1.5} />);278  }279  return (280    <g>281      {rays}282      <circle cx={W * 0.5} cy={H * 0.36} r={Math.min(W, H) * 0.22} fill={`url(#${id}-glow)`} />283      <circle cx={W * 0.5} cy={H * 0.36} r={Math.min(W, H) * 0.1} fill={p.primary} fillOpacity={0.9} />284      {tri(W * 0.2, W * 0.5, H * 0.34, "l", "#120c05", 1)}285      {tri(W * 0.82, W * 0.55, H * 0.4, "r", "#0f0a04", 1)}286      {tri(W * 0.5, W * 0.72, H * 0.56, "c", "#1a1208", 1)}287      <polygon points={`${r2(W * 0.5)},${r2(H * 0.3)} ${r2(W * 0.86)},${r2(H * 0.86)} ${r2(W * 0.5)},${r2(H * 0.86)}`} fill={p.primary} fillOpacity={0.12} />288      <rect x={0} y={H * 0.86} width={W} height={H * 0.14} fill={p.secondary} fillOpacity={0.1} />289    </g>290  );291}292293function Arcade({ W, H, rnd, p }: Ctx) {294  const size = Math.min(W, H) / 12;295  const cells: React.ReactNode[] = [];296  const colors = [p.primary, p.secondary, p.glow];297  for (let y = 0; y < H; y += size) {298    for (let x = 0; x < W; x += size) {299      const r = rnd();300      if (r > 0.22) continue;301      cells.push(<rect key={`${x}-${y}`} x={r2(x + 2)} y={r2(y + 2)} width={r2(size - 4)} height={r2(size - 4)} rx={3} fill={colors[Math.floor(rnd() * 3)]} fillOpacity={r2(between(rnd, 0.25, 0.85))} />);302    }303  }304  return (305    <g>306      {cells}307      <rect x={W * 0.3} y={H * 0.36} width={W * 0.4} height={size * 0.6} rx={size * 0.3} fill={p.primary} fillOpacity={0.9} />308      <rect x={W * 0.3 + size * 0.3} y={H * 0.36 + size * 0.15} width={W * 0.4 - size * 0.6} height={size * 0.3} rx={size * 0.15} fill="#fff" fillOpacity={0.35} />309    </g>310  );311}312313function Asteroids({ W, H, rnd, p, id }: Ctx) {314  const rocks: React.ReactNode[] = [];315  for (let i = 0; i < 9; i++) {316    const cx = between(rnd, 0, W);317    const cy = between(rnd, 0, H * 0.8);318    const rad = between(rnd, Math.min(W, H) * 0.03, Math.min(W, H) * 0.14);319    const n = 7 + Math.floor(rnd() * 4);320    const pts: string[] = [];321    for (let k = 0; k < n; k++) {322      const a = (k / n) * Math.PI * 2;323      const rr = rad * between(rnd, 0.72, 1.1);324      pts.push(`${r2(cx + Math.cos(a) * rr)},${r2(cy + Math.sin(a) * rr)}`);325    }326    rocks.push(<polygon key={i} points={pts.join(" ")} fill={i % 3 === 0 ? "#171226" : "#100c1c"} stroke={p.primary} strokeOpacity={0.45} strokeWidth={1.5} strokeLinejoin="round" />);327    if (rnd() > 0.5) rocks.push(<circle key={`v${i}`} cx={r2(cx + rad * 0.2)} cy={r2(cy - rad * 0.1)} r={r2(rad * 0.18)} fill={p.secondary} fillOpacity={0.8} />);328  }329  const stars: React.ReactNode[] = [];330  for (let i = 0; i < 40; i++) stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H)} r={r2(between(rnd, 0.5, 1.3))} fill="#fff" fillOpacity={r2(between(rnd, 0.3, 0.8))} />);331  return (332    <g>333      {stars}334      <circle cx={W * 0.5} cy={H * 0.45} r={Math.min(W, H) * 0.2} fill={`url(#${id}-glow)`} />335      {rocks}336    </g>337  );338}339340function Track({ W, H, p }: Ctx) {341  const lanes: React.ReactNode[] = [];342  const cx = W * 1.05;343  const cy = H * 0.55;344  for (let i = 0; i < 6; i++) {345    const r = Math.max(W, H) * (0.28 + i * 0.13);346    lanes.push(<circle key={`l${i}`} cx={cx} cy={cy} r={r2(r)} fill="none" stroke={i % 5 === 0 ? p.primary : "#ffffff"} strokeOpacity={i % 5 === 0 ? 0.5 : 0.12} strokeWidth={i % 5 === 0 ? 5 : 2} />);347    if (i > 0 && i < 5) lanes.push(<circle key={`d${i}`} cx={cx} cy={cy} r={r2(r - Math.max(W, H) * 0.065)} fill="none" stroke="#fff" strokeOpacity={0.18} strokeWidth={2} strokeDasharray="18 22" />);348  }349  const kerb: React.ReactNode[] = [];350  const kr = Math.max(W, H) * 0.28;351  for (let k = 0; k < 40; k++) {352    const a = Math.PI * 0.5 + (k / 40) * Math.PI;353    kerb.push(<circle key={k} cx={r2(cx + Math.cos(a) * kr)} cy={r2(cy + Math.sin(a) * kr)} r={4} fill={k % 2 ? p.secondary : "#f5f5f5"} fillOpacity={0.9} />);354  }355  return (356    <g>357      {lanes}358      {kerb}359      <line x1={W * 0.05} y1={H * 0.2} x2={W * 0.55} y2={H * 0.2} stroke={p.glow} strokeOpacity={0.5} strokeWidth={2} strokeDasharray="4 10" />360    </g>361  );362}363364function hexPoints(cx: number, cy: number, r: number, rot = 0): string {365  const pts: string[] = [];366  for (let k = 0; k < 6; k++) {367    const a = rot + (k / 6) * Math.PI * 2;368    pts.push(`${r2(cx + Math.cos(a) * r)},${r2(cy + Math.sin(a) * r)}`);369  }370  return pts.join(" ");371}372373function Reactor({ W, H, rnd, p, id }: Ctx) {374  const cx = W * 0.5;375  const cy = H * 0.42;376  const base = Math.min(W, H) * 0.12;377  const rings: React.ReactNode[] = [];378  for (let i = 1; i <= 4; i++) rings.push(<polygon key={i} points={hexPoints(cx, cy, base * i, Math.PI / 6)} fill="none" stroke={i % 2 ? p.primary : p.secondary} strokeOpacity={r2(0.5 - i * 0.09)} strokeWidth={i === 1 ? 3 : 1.5} />);379  const spokes: React.ReactNode[] = [];380  for (let k = 0; k < 12; k++) {381    const a = (k / 12) * Math.PI * 2;382    const len = base * between(rnd, 3.2, 5);383    spokes.push(<line key={k} x1={r2(cx + Math.cos(a) * base * 1.2)} y1={r2(cy + Math.sin(a) * base * 1.2)} x2={r2(cx + Math.cos(a) * len)} y2={r2(cy + Math.sin(a) * len)} stroke={p.primary} strokeOpacity={0.18} strokeWidth={1.5} />);384  }385  const cells: React.ReactNode[] = [];386  for (let i = 0; i < 14; i++) {387    const a = rnd() * Math.PI * 2;388    const d = base * between(rnd, 3.5, 6);389    cells.push(<polygon key={i} points={hexPoints(cx + Math.cos(a) * d, cy + Math.sin(a) * d, base * between(rnd, 0.25, 0.5), Math.PI / 6)} fill={p.primary} fillOpacity={r2(between(rnd, 0.08, 0.25))} />);390  }391  return (392    <g>393      {cells}394      {spokes}395      <circle cx={cx} cy={cy} r={base * 1.6} fill={`url(#${id}-glow)`} />396      {rings}397      <polygon points={hexPoints(cx, cy, base * 0.55, Math.PI / 6)} fill={p.glow} fillOpacity={0.9} />398    </g>399  );400}401402function Abyss({ W, H, rnd, p, id }: Ctx) {403  const bubbles: React.ReactNode[] = [];404  for (let i = 0; i < 26; i++) {405    const r = between(rnd, 2, Math.min(W, H) * 0.035);406    const cx = rnd() * W;407    const cy = between(rnd, H * 0.2, H);408    bubbles.push(<circle key={`b${i}`} cx={r2(cx)} cy={r2(cy)} r={r2(r)} fill="none" stroke={p.primary} strokeOpacity={r2(between(rnd, 0.25, 0.6))} strokeWidth={1.2} />);409    bubbles.push(<circle key={`h${i}`} cx={r2(cx - r * 0.35)} cy={r2(cy - r * 0.35)} r={r2(r * 0.22)} fill="#fff" fillOpacity={0.5} />);410  }411  const rays: React.ReactNode[] = [];412  for (let i = 0; i < 5; i++) {413    const x = W * (0.2 + i * 0.15);414    rays.push(<polygon key={i} points={`${r2(x)},0 ${r2(x + W * 0.05)},0 ${r2(x + W * 0.2)},${H} ${r2(x - W * 0.02)},${H}`} fill={`url(#${id}-ray)`} />);415  }416  return (417    <g>418      {rays}419      <ellipse cx={W * 0.5} cy={H * 0.95} rx={W * 0.5} ry={H * 0.12} fill={p.secondary} fillOpacity={0.18} filter={`url(#${id}-blur)`} />420      {bubbles}421    </g>422  );423}424425function Moon({ W, H, rnd, p, id }: Ctx) {426  const R = Math.min(W, H) * 0.42;427  const cx = W * 0.62;428  const cy = H * 0.5;429  const craters: React.ReactNode[] = [];430  for (let i = 0; i < 12; i++) {431    const a = rnd() * Math.PI * 2;432    const d = rnd() * R * 0.8;433    const r = between(rnd, R * 0.04, R * 0.16);434    const x = cx + Math.cos(a) * d;435    const y = cy + Math.sin(a) * d;436    craters.push(<circle key={`c${i}`} cx={r2(x)} cy={r2(y)} r={r2(r)} fill="#000" fillOpacity={0.35} />);437    craters.push(<circle key={`r${i}`} cx={r2(x)} cy={r2(y)} r={r2(r)} fill="none" stroke={p.glow} strokeOpacity={0.35} strokeWidth={1.5} />);438    craters.push(<circle key={`s${i}`} cx={r2(x + r * 0.3)} cy={r2(y + r * 0.3)} r={r2(r * 0.55)} fill="#000" fillOpacity={0.2} />);439  }440  const stars: React.ReactNode[] = [];441  for (let i = 0; i < 40; i++) stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H)} r={r2(between(rnd, 0.5, 1.4))} fill="#fff" fillOpacity={r2(between(rnd, 0.3, 0.9))} />);442  return (443    <g>444      {stars}445      <circle cx={cx} cy={cy} r={R * 1.15} fill={`url(#${id}-glow)`} />446      <circle cx={cx} cy={cy} r={R} fill={`url(#${id}-moon)`} />447      {craters}448      <path d={`M${r2(cx - R)} ${r2(cy)} A ${r2(R)} ${r2(R)} 0 0 0 ${r2(cx + R)} ${r2(cy)} Z`} fill="#000" fillOpacity={0.18} transform={`rotate(-28 ${cx} ${cy})`} />449      <line x1={0} y1={H * 0.82} x2={W} y2={H * 0.82} stroke={p.primary} strokeOpacity={0.5} strokeWidth={1.5} />450      <rect x={W * 0.1} y={H * 0.72} width={W * 0.16} height={H * 0.1} fill={p.bg} stroke={p.primary} strokeOpacity={0.6} />451      <rect x={W * 0.13} y={H * 0.75} width={W * 0.04} height={H * 0.03} fill={p.primary} fillOpacity={0.9} />452    </g>453  );454}455456function Temple({ W, H, rnd, p, id }: Ctx) {457  const steps: React.ReactNode[] = [];458  const n = 6;459  for (let i = 0; i < n; i++) {460    const wdt = W * (0.95 - i * 0.14);461    const h = H * 0.11;462    const y = H - h * (i + 1);463    steps.push(<rect key={i} x={r2((W - wdt) / 2)} y={r2(y)} width={r2(wdt)} height={r2(h)} fill={i % 2 ? "#08170f" : "#0a1c12"} stroke={p.primary} strokeOpacity={0.35} />);464    const blocks = Math.floor(wdt / (W * 0.08));465    for (let b = 0; b < blocks; b++) if (rnd() > 0.5) steps.push(<line key={`${i}-${b}`} x1={r2((W - wdt) / 2 + (b + 1) * (wdt / (blocks + 1)))} y1={r2(y)} x2={r2((W - wdt) / 2 + (b + 1) * (wdt / (blocks + 1)))} y2={r2(y + h)} stroke={p.primary} strokeOpacity={0.15} />);466  }467  const vines: React.ReactNode[] = [];468  for (let i = 0; i < 8; i++) {469    const x = between(rnd, W * 0.05, W * 0.95);470    vines.push(<path key={i} d={`M${r2(x)} ${H} q ${r2(between(rnd, -30, 30))} ${r2(-H * 0.2)} ${r2(between(rnd, -20, 20))} ${r2(-H * between(rnd, 0.3, 0.6))}`} fill="none" stroke={p.primary} strokeOpacity={0.35} strokeWidth={2} strokeLinecap="round" />);471  }472  return (473    <g>474      <circle cx={W * 0.5} cy={H * 0.3} r={Math.min(W, H) * 0.24} fill={`url(#${id}-glow)`} />475      <circle cx={W * 0.5} cy={H * 0.3} r={Math.min(W, H) * 0.08} fill={p.secondary} fillOpacity={0.85} />476      {steps}477      {vines}478    </g>479  );480}481482function Gravity({ W, H, rnd, p, id }: Ctx) {483  const cx = W * 0.5;484  const cy = H * 0.45;485  const orbits: React.ReactNode[] = [];486  for (let i = 0; i < 5; i++) {487    const rx = Math.min(W, H) * (0.22 + i * 0.1);488    const ry = rx * between(rnd, 0.3, 0.5);489    const rot = between(rnd, -60, 60);490    orbits.push(<ellipse key={`o${i}`} cx={cx} cy={cy} rx={r2(rx)} ry={r2(ry)} fill="none" stroke={i % 2 ? p.primary : p.secondary} strokeOpacity={r2(0.55 - i * 0.08)} strokeWidth={1.5} transform={`rotate(${r2(rot)} ${cx} ${cy})`} />);491    const a = rnd() * Math.PI * 2;492    const px = cx + Math.cos(a) * rx;493    const py = cy + Math.sin(a) * ry;494    orbits.push(<circle key={`p${i}`} cx={r2(px)} cy={r2(py)} r={r2(between(rnd, 3, 8))} fill={i % 2 ? p.secondary : p.primary} transform={`rotate(${r2(rot)} ${cx} ${cy})`} />);495  }496  const stars: React.ReactNode[] = [];497  for (let i = 0; i < 40; i++) stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H)} r={r2(between(rnd, 0.5, 1.3))} fill="#fff" fillOpacity={r2(between(rnd, 0.3, 0.8))} />);498  return (499    <g>500      {stars}501      <circle cx={cx} cy={cy} r={Math.min(W, H) * 0.16} fill={`url(#${id}-glow)`} />502      <circle cx={cx} cy={cy} r={Math.min(W, H) * 0.045} fill="#fff" fillOpacity={0.95} />503      {orbits}504    </g>505  );506}507508function Core({ W, H, rnd, p, id }: Ctx) {509  const cx = W * 0.5;510  const cy = H * 0.44;511  const R = Math.min(W, H) * 0.22;512  const arcs: React.ReactNode[] = [];513  for (let i = 0; i < 3; i++) {514    const r = R * (1.5 + i * 0.35);515    arcs.push(<circle key={i} cx={cx} cy={cy} r={r2(r)} fill="none" stroke={i ? p.secondary : p.primary} strokeOpacity={0.35 - i * 0.08} strokeWidth={i ? 1.5 : 3} strokeDasharray={`${r2(r * 1.2)} ${r2(r * 0.9)}`} transform={`rotate(${r2(rnd() * 360)} ${cx} ${cy})`} />);516  }517  const embers: React.ReactNode[] = [];518  for (let i = 0; i < 30; i++) {519    const a = rnd() * Math.PI * 2;520    const d = R * between(rnd, 1.1, 3.2);521    embers.push(<circle key={i} cx={r2(cx + Math.cos(a) * d)} cy={r2(cy + Math.sin(a) * d * 0.8)} r={r2(between(rnd, 1, 3))} fill={rnd() > 0.5 ? p.secondary : p.primary} fillOpacity={r2(between(rnd, 0.3, 0.9))} />);522  }523  return (524    <g>525      {embers}526      <circle cx={cx} cy={cy} r={R * 2.2} fill={`url(#${id}-glow)`} />527      {arcs}528      <circle cx={cx} cy={cy} r={R} fill={`url(#${id}-sphere)`} />529      <circle cx={r2(cx - R * 0.3)} cy={r2(cy - R * 0.35)} r={R * 0.28} fill="#fff" fillOpacity={0.35} filter={`url(#${id}-blur)`} />530    </g>531  );532}533534function Minimal({ W, H, p }: Ctx) {535  const y = H * 0.5;536  return (537    <g>538      <line x1={W * 0.1} y1={y} x2={W * 0.9} y2={y} stroke={p.secondary} strokeWidth={1.5} strokeOpacity={0.85} />539      <line x1={W * 0.1} y1={y} x2={W * 0.9} y2={y} stroke={p.secondary} strokeWidth={6} strokeOpacity={0.15} />540      <circle cx={W * 0.9} cy={y} r={3} fill={p.primary} />541      <circle cx={W * 0.1} cy={y} r={1.5} fill={p.secondary} />542      <line x1={W * 0.5} y1={H * 0.3} x2={W * 0.5} y2={H * 0.34} stroke={p.secondary} strokeOpacity={0.5} />543      <line x1={W * 0.5} y1={H * 0.66} x2={W * 0.5} y2={H * 0.7} stroke={p.secondary} strokeOpacity={0.5} />544    </g>545  );546}547548function Universe({ W, H, rnd, p, id }: Ctx) {549  const cx = W * 0.5;550  const cy = H * 0.44;551  const R = Math.min(W, H) * 0.3;552  const rays: React.ReactNode[] = [];553  for (let i = 0; i < 48; i++) {554    const a = (i / 48) * Math.PI * 2;555    const len = R * between(rnd, 1.2, 2.6);556    rays.push(<line key={i} x1={r2(cx + Math.cos(a) * R * 0.5)} y1={r2(cy + Math.sin(a) * R * 0.5)} x2={r2(cx + Math.cos(a) * len)} y2={r2(cy + Math.sin(a) * len)} stroke={i % 4 === 0 ? p.secondary : p.primary} strokeOpacity={r2(between(rnd, 0.1, 0.45))} strokeWidth={i % 6 === 0 ? 2 : 1} strokeLinecap="round" />);557  }558  const stars: React.ReactNode[] = [];559  for (let i = 0; i < 50; i++) stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H)} r={r2(between(rnd, 0.5, 1.5))} fill="#fff" fillOpacity={r2(between(rnd, 0.3, 0.9))} />);560  return (561    <g>562      {stars}563      <circle cx={cx} cy={cy} r={R * 2.4} fill={`url(#${id}-glow)`} />564      {rays}565      <circle cx={cx} cy={cy} r={R} fill="none" stroke={`url(#${id}-ring)`} strokeWidth={Math.max(3, R * 0.06)} />566      <circle cx={cx} cy={cy} r={R * 0.82} fill="none" stroke={p.secondary} strokeOpacity={0.35} strokeWidth={1.5} strokeDasharray="6 10" />567      <circle cx={cx} cy={cy} r={R * 0.12} fill="#fff" />568    </g>569  );570}571572function PerspectiveGrid({ W, H, p }: Ctx) {573  const horizon = H * 0.42;574  const lines: React.ReactNode[] = [];575  for (let i = -8; i <= 8; i++) {576    lines.push(<line key={`v${i}`} x1={W * 0.5} y1={horizon} x2={r2(W * 0.5 + i * W * 0.28)} y2={H} stroke={p.primary} strokeOpacity={0.3} strokeWidth={1.2} />);577  }578  for (let k = 1; k <= 10; k++) {579    const t = k / 10;580    const y = horizon + (H - horizon) * t * t;581    lines.push(<line key={`h${k}`} x1={0} y1={r2(y)} x2={W} y2={r2(y)} stroke={p.primary} strokeOpacity={r2(0.15 + t * 0.35)} strokeWidth={1.2} />);582  }583  return (584    <g>585      <circle cx={W * 0.5} cy={horizon} r={Math.min(W, H) * 0.2} fill={p.secondary} fillOpacity={0.25} />586      <rect x={0} y={horizon} width={W} height={H - horizon} fill="#000" fillOpacity={0.35} />587      {lines}588      <line x1={0} y1={horizon} x2={W} y2={horizon} stroke={p.secondary} strokeOpacity={0.8} strokeWidth={2} />589    </g>590  );591}592593const MOTIFS: Record<Backdrop, (c: Ctx) => React.ReactNode> = {594  vault: Vault,595  nebula: Nebula,596  pillars: Pillars,597  aurora: Aurora,598  lava: Lava,599  circuit: Circuit,600  skyline: Skyline,601  pyramid: Pyramid,602  arcade: Arcade,603  asteroids: Asteroids,604  track: Track,605  reactor: Reactor,606  abyss: Abyss,607  moon: Moon,608  temple: Temple,609  gravity: Gravity,610  core: Core,611  minimal: Minimal,612  universe: Universe,613  grid: PerspectiveGrid,614};615616/* --------------------------------------------------------------- particles */617618function Particles({ W, H, rnd, p, kind }: Ctx & { kind: string }) {619  const out: React.ReactNode[] = [];620  const n = 22;621  for (let i = 0; i < n; i++) {622    const x = r2(rnd() * W);623    const y = r2(rnd() * H);624    const op = r2(between(rnd, 0.25, 0.8));625    const s = between(rnd, 1.5, 4);626    switch (kind) {627      case "snow":628        out.push(<circle key={i} cx={x} cy={y} r={r2(s * 0.7)} fill="#fff" fillOpacity={op} />);629        break;630      case "coins":631        out.push(<ellipse key={i} cx={x} cy={y} rx={r2(s * 1.2)} ry={r2(s * 0.8)} fill={p.primary} fillOpacity={op} />);632        break;633      case "diamonds":634        out.push(<polygon key={i} points={`${x},${r2(y - s)} ${r2(x + s)},${y} ${x},${r2(y + s)} ${r2(x - s)},${y}`} fill={p.glow} fillOpacity={op} />);635        break;636      case "fire":637      case "sparks":638        out.push(<line key={i} x1={x} y1={y} x2={r2(x + between(rnd, -6, 6))} y2={r2(y - between(rnd, 4, 12))} stroke={rnd() > 0.5 ? p.primary : p.secondary} strokeOpacity={op} strokeWidth={1.5} strokeLinecap="round" />);639        break;640      case "confetti":641        out.push(<rect key={i} x={x} y={y} width={r2(s * 1.4)} height={r2(s * 0.7)} fill={[p.primary, p.secondary, p.glow][i % 3]} fillOpacity={op} transform={`rotate(${r2(rnd() * 90)} ${x} ${y})`} />);642        break;643      case "bubbles":644        out.push(<circle key={i} cx={x} cy={y} r={r2(s)} fill="none" stroke="#fff" strokeOpacity={op * 0.6} />);645        break;646      case "energy":647        out.push(<circle key={i} cx={x} cy={y} r={r2(s * 0.5)} fill={p.secondary} fillOpacity={op} />);648        break;649      case "dust":650        out.push(<circle key={i} cx={x} cy={y} r={r2(s * 0.35)} fill={p.primary} fillOpacity={op * 0.7} />);651        break;652      default:653        out.push(<circle key={i} cx={x} cy={y} r={r2(s * 0.4)} fill="#fff" fillOpacity={op} />);654    }655  }656  return <g>{out}</g>;657}658659/* ------------------------------------------------------------------- title */660661function splitName(name: string, oneLine: boolean): string[] {662  if (oneLine) return [name];663  const words = name.trim().split(/\s+/);664  if (words.length <= 1) return words;665  if (words.length === 2) return words;666  const mid = Math.ceil(words.length / 2);667  return [words.slice(0, mid).join(" "), words.slice(mid).join(" ")];668}669670function Title({ name, W, H, pad, maxFont, oneLine, p }: { name: string; W: number; H: number; pad: number; maxFont: number; oneLine: boolean; p: ArtPalette }) {671  const lines = splitName(name.toUpperCase(), oneLine);672  const longest = Math.max(...lines.map((l) => l.length), 1);673  const size = Math.min(maxFont, ((W - pad * 2) / longest) * 1.55);674  const lineH = size * 0.92;675  const baseY = H - pad - (lines.length - 1) * lineH;676  return (677    <g fontFamily="var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif" fontWeight={700} style={{ letterSpacing: "-0.045em" }}>678      {lines.map((l, i) => (679        <text key={`s${i}`} x={pad} y={r2(baseY + i * lineH)} fontSize={r2(size)} fill={p.glow} fillOpacity={0.45} style={{ filter: "blur(10px)" }}>680          {l}681        </text>682      ))}683      {lines.map((l, i) => (684        <text key={i} x={pad} y={r2(baseY + i * lineH)} fontSize={r2(size)} fill="#ffffff">685          {l}686        </text>687      ))}688    </g>689  );690}691692/* ----------------------------------------------------------------- GameArt */693694export function GameArt({ slug, name, palette, backdrop, particles, variant = "card", className, showName = true, label }: GameArtProps) {695  const reactId = React.useId().replace(/[^a-zA-Z0-9]/g, "");696  const id = `ga${reactId}`;697  const preset = GAME_PRESENTATION[slug];698  const bd = (backdrop ?? preset?.backdrop ?? BACKDROPS[hashString(slug) % BACKDROPS.length]) as string;699  const motifKey: Backdrop = (BACKDROPS as readonly string[]).includes(bd) ? (bd as Backdrop) : "grid";700  const pt = particles ?? preset?.particles ?? "dust";701  const { w: W, h: H, pad, maxFont, oneLine } = DIMS[variant];702  const rnd = mulberry32(hashString(`${slug}:${variant}`));703  const ctx: Ctx = { W, H, rnd, p: palette, id };704  const motif = MOTIFS[motifKey];705706  return (707    <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid slice" className={cn("block h-full w-full", className)} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}>708      <defs>709        <linearGradient id={`${id}-bg`} x1="0" y1="0" x2="0.4" y2="1">710          <stop offset="0" stopColor={palette.bg} />711          <stop offset="1" stopColor="#05060a" />712        </linearGradient>713        <radialGradient id={`${id}-glow`} cx="0.5" cy="0.5" r="0.5">714          <stop offset="0" stopColor={palette.glow} stopOpacity="0.55" />715          <stop offset="0.5" stopColor={palette.primary} stopOpacity="0.18" />716          <stop offset="1" stopColor={palette.primary} stopOpacity="0" />717        </radialGradient>718        <linearGradient id={`${id}-vignette`} x1="0" y1="0" x2="0" y2="1">719          <stop offset="0.35" stopColor={palette.bg} stopOpacity="0" />720          <stop offset="1" stopColor="#03040a" stopOpacity="0.92" />721        </linearGradient>722        <linearGradient id={`${id}-col`} x1="0" y1="0" x2="1" y2="0">723          <stop offset="0" stopColor="#1c1410" />724          <stop offset="0.5" stopColor="#2a1d14" />725          <stop offset="1" stopColor="#120c08" />726        </linearGradient>727        <linearGradient id={`${id}-heat`} x1="0" y1="0" x2="0" y2="1">728          <stop offset="0" stopColor={palette.primary} stopOpacity="0" />729          <stop offset="1" stopColor={palette.primary} stopOpacity="0.45" />730        </linearGradient>731        <linearGradient id={`${id}-ray`} x1="0" y1="0" x2="0" y2="1">732          <stop offset="0" stopColor={palette.primary} stopOpacity="0.16" />733          <stop offset="1" stopColor={palette.primary} stopOpacity="0" />734        </linearGradient>735        <radialGradient id={`${id}-moon`} cx="0.35" cy="0.3" r="0.8">736          <stop offset="0" stopColor="#3a3d4a" />737          <stop offset="1" stopColor="#0d0e14" />738        </radialGradient>739        <radialGradient id={`${id}-sphere`} cx="0.4" cy="0.35" r="0.7">740          <stop offset="0" stopColor={palette.glow} />741          <stop offset="0.55" stopColor={palette.primary} />742          <stop offset="1" stopColor={palette.bg} />743        </radialGradient>744        <linearGradient id={`${id}-ring`} x1="0" y1="0" x2="1" y2="1">745          <stop offset="0" stopColor={palette.primary} />746          <stop offset="1" stopColor={palette.secondary} />747        </linearGradient>748        <filter id={`${id}-blur`} x="-30%" y="-30%" width="160%" height="160%">749          <feGaussianBlur stdDeviation={Math.max(W, H) / 60} />750        </filter>751      </defs>752      <rect width={W} height={H} fill={`url(#${id}-bg)`} />753      {/* 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. */}754      {motif(ctx)}755      {Particles({ ...ctx, kind: pt })}756      <rect width={W} height={H} fill={`url(#${id}-vignette)`} />757      <rect x="0.5" y="0.5" width={W - 1} height={H - 1} fill="none" stroke="#fff" strokeOpacity="0.06" />758      {showName ? <Title name={name} W={W} H={H} pad={pad} maxFont={maxFont} oneLine={oneLine} p={palette} /> : null}759    </svg>760  );761}762
Files · 0b40ac1a7181cdec915de0b36dee3e78427df0c3
Menu

Theme