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 <Motif {...ctx} />754 <Particles {...ctx} kind={pt} />755 <rect width={W} height={H} fill={`url(#${id}-vignette)`} />756 <rect x="0.5" y="0.5" width={W - 1} height={H - 1} fill="none" stroke="#fff" strokeOpacity="0.06" />757 {showName ? <Title name={name} W={W} H={H} pad={pad} maxFont={maxFont} oneLine={oneLine} p={palette} /> : null}758 </svg>759 );760}761