SPB Git

spb/spinza

Public

game screen: visible reel spin every round (state reset, spin-up, motion blur, landing bounce, scatter anticipation); per-game identity (frame materials, tile plates, animated world backdrops, themed HUD)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 8, 2026) parent 0b40ac1 Browse files

6 changed files +1,030 −94

modified apps/api/src/routes/games.ts +3 −0
@@ -28,6 +28,9 @@ function toCard(g: GameRow, extra: { popularity?: number; favorite?: boolean; la
28 features: (s.features as string[]) ?? [], 28 features: (s.features as string[]) ?? [],
29 tags: (s.tags as string[]) ?? [], 29 tags: (s.tags as string[]) ?? [],
30 palette: s.palette as GameCard["palette"], 30 palette: s.palette as GameCard["palette"],
31+ presentation: s.presentation
32+ ? { frame: (s.presentation as GameCard["presentation"])!.frame, backdrop: (s.presentation as GameCard["presentation"])!.backdrop, particles: (s.presentation as GameCard["presentation"])!.particles, ambience: (s.presentation as GameCard["presentation"])!.ambience }
33+ : undefined,
31 lifecycle: g.lifecycle as GameCard["lifecycle"], 34 lifecycle: g.lifecycle as GameCard["lifecycle"],
32 isNew: g.isNew, 35 isNew: g.isNew,
33 isFeatured: g.isFeatured, 36 isFeatured: g.isFeatured,
modified apps/web/src/components/game/game-client.tsx +30 −2
@@ -339,6 +339,7 @@ export function GameClient({ game, definition }: Props) {
339 339
340 const betIndex = bets.indexOf(bet); 340 const betIndex = bets.indexOf(bet);
341 const palette = definition.presentation.palette; 341 const palette = definition.presentation.palette;
342+ const frameStyle = FRAME_HUD[definition.presentation.frame] ?? FRAME_HUD.metal;
342 const meter = definition.meter && state ? { value: state.meters[definition.meter.id] ?? 0, max: definition.meter.max } : null; 343 const meter = definition.meter && state ? { value: state.meters[definition.meter.id] ?? 0, max: definition.meter.max } : null;
343 const heat = definition.heat && state ? { value: state.heat, max: definition.heat.max } : null; 344 const heat = definition.heat && state ? { value: state.heat, max: definition.heat.max } : null;
344 const meterLevel = definition.meter && state ? state.meterLevels[definition.meter.id] ?? 0 : 0; 345 const meterLevel = definition.meter && state ? state.meterLevels[definition.meter.id] ?? 0 : 0;
@@ -470,7 +471,7 @@ export function GameClient({ game, definition }: Props) {
470 </div> 471 </div>
471 472
472 {/* Controls */} 473 {/* Controls */}
473− <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)" }}> 474+ <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)", borderTop: `1px solid ${palette.primary}55`, boxShadow: `inset 0 1px 0 ${palette.primary}33`, background: frameStyle.controls }}>
474 <div className="mx-auto grid max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-3"> 475 <div className="mx-auto grid max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-3">
475 {/* Balance + bet */} 476 {/* Balance + bet */}
476 <div className="flex flex-col gap-2"> 477 <div className="flex flex-col gap-2">
@@ -504,7 +505,7 @@ export function GameClient({ game, definition }: Props) {
504 onClick={() => void spin()} 505 onClick={() => void spin()}
505 disabled={!ready || spinning} 506 disabled={!ready || spinning}
506 className={cn("relative grid h-[84px] w-[84px] place-items-center rounded-full text-[#1a1406] transition-transform active:scale-95 focus-ring disabled:opacity-70", spinning && "animate-pulse-soft")} 507 className={cn("relative grid h-[84px] w-[84px] place-items-center rounded-full text-[#1a1406] transition-transform active:scale-95 focus-ring disabled:opacity-70", spinning && "animate-pulse-soft")}
507− style={{ background: "linear-gradient(180deg,#f3e2ad,#c9a961 60%,#9a7b3a)", boxShadow: "0 0 0 4px rgba(201,169,97,0.18), 0 12px 40px -10px rgba(201,169,97,0.7)" }} 508+ style={{ background: `linear-gradient(180deg, ${shade(palette.primary, 0.35)}, ${palette.primary} 60%, ${shade(palette.primary, -0.35)})`, boxShadow: `0 0 0 4px ${palette.primary}2e, 0 12px 40px -10px ${palette.primary}b3`, color: textOn(palette.primary) }}
508 aria-label="Spin" 509 aria-label="Spin"
509 > 510 >
510 <span className="text-base font-extrabold tracking-[0.12em]">SPIN</span> 511 <span className="text-base font-extrabold tracking-[0.12em]">SPIN</span>
@@ -563,6 +564,33 @@ export function GameClient({ game, definition }: Props) {
563 ); 564 );
564 } 565 }
565 566
567+/** Per-frame HUD treatment so the control bar matches the reel frame material. */
568+const FRAME_HUD: Record<string, { controls: string }> = {
569+ metal: { controls: "linear-gradient(180deg, rgba(20,24,33,0.85), rgba(9,11,16,0.92))" },
570+ glass: { controls: "linear-gradient(180deg, rgba(255,255,255,0.06), rgba(10,12,20,0.85))" },
571+ gold: { controls: "linear-gradient(180deg, rgba(40,30,12,0.85), rgba(12,9,4,0.95))" },
572+ stone: { controls: "linear-gradient(180deg, rgba(28,25,18,0.9), rgba(10,9,6,0.95))" },
573+ ice: { controls: "linear-gradient(180deg, rgba(120,190,230,0.12), rgba(8,14,22,0.92))" },
574+ carbon: { controls: "repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0 2px, rgba(0,0,0,0) 2px 5px), rgba(7,8,11,0.95)" },
575+ neon: { controls: "linear-gradient(180deg, rgba(10,8,24,0.85), rgba(4,3,12,0.95))" },
576+ obsidian: { controls: "rgba(0,0,0,0.92)" },
577+};
578+
579+function shade(hexColor: string, amt: number): string {
580+ const n = parseInt(hexColor.replace("#", ""), 16);
581+ const ch = (v: number) => Math.max(0, Math.min(255, Math.round(v + 255 * amt)));
582+ const r = ch((n >> 16) & 255);
583+ const g = ch((n >> 8) & 255);
584+ const b = ch(n & 255);
585+ return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
586+}
587+
588+function textOn(hexColor: string): string {
589+ const n = parseInt(hexColor.replace("#", ""), 16);
590+ const lum = (0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;
591+ return lum > 0.55 ? "#0b0a06" : "#ffffff";
592+}
593+
566 function errorTitle(code: string): string { 594 function errorTitle(code: string): string {
567 return ( 595 return (
568 { 596 {
modified apps/web/src/components/game/renderer.ts +548 −85
@@ -3,7 +3,7 @@
3 import { Application, Container, Graphics, Rectangle, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js"; 3 import { Application, Container, Graphics, Rectangle, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js";
4 import type { SpinStep } from "@spinza/game-core/client"; 4 import type { SpinStep } from "@spinza/game-core/client";
5 import type { ClientDefinition } from "./types"; 5 import type { ClientDefinition } from "./types";
6−import { drawSymbol, hexColor, lighten } from "./symbol-art"; 6+import { drawSymbol, hexColor } from "./symbol-art";
7 import { formatSC } from "@spinza/shared"; 7 import { formatSC } from "@spinza/shared";
8 8
9 /* --------------------------------------------------------------- helpers */ 9 /* --------------------------------------------------------------- helpers */
@@ -152,27 +152,109 @@ export class SlotRenderer {
152 const p = this.opts.def.presentation; 152 const p = this.opts.def.presentation;
153 const g = this.frame; 153 const g = this.frame;
154 g.clear(); 154 g.clear();
155− const pad = this.gap * 1.6; 155+ const pad = this.gap * 1.8;
156 const x = this.originX - pad; 156 const x = this.originX - pad;
157 const y = this.originY - pad; 157 const y = this.originY - pad;
158 const w = totalW + pad * 2; 158 const w = totalW + pad * 2;
159 const h = totalH + pad * 2; 159 const h = totalH + pad * 2;
160 const primary = hexColor(p.palette.primary); 160 const primary = hexColor(p.palette.primary);
161− const frameColors: Record<string, number> = { metal: 0x9aa3b5, glass: 0xffffff, gold: 0xe0c070, stone: 0x8a8578, ice: 0xbfe9ff, carbon: 0x5c6270, neon: primary, obsidian: 0x3a3a44 }; 161+ const secondary = hexColor(p.palette.secondary);
162− const fc = frameColors[p.frame] ?? primary; 162+ const rows = this.rows;
163− g.roundRect(x, y, w, h, 18).fill({ color: 0x000000, alpha: 0.38 }); 163+ const cols = this.cols;
164− g.roundRect(x, y, w, h, 18).stroke({ color: fc, alpha: p.frame === "neon" ? 0.85 : 0.35, width: 2 }); 164+ switch (p.frame) {
165− g.roundRect(x + 3, y + 3, w - 6, h - 6, 15).stroke({ color: 0xffffff, alpha: 0.06, width: 1 }); 165+ case "gold": {
166− if (p.frame === "neon") g.roundRect(x - 3, y - 3, w + 6, h + 6, 21).stroke({ color: primary, alpha: 0.25, width: 6 }); 166+ g.roundRect(x, y, w, h, 10).fill({ color: 0x120e07, alpha: 0.7 });
167+ g.roundRect(x, y, w, h, 10).stroke({ color: 0xe0c070, alpha: 0.8, width: 3 });
168+ g.roundRect(x + 6, y + 6, w - 12, h - 12, 7).stroke({ color: 0x8a6d2e, alpha: 0.7, width: 1.5 });
169+ for (const [cx, cy] of [[x, y], [x + w, y], [x, y + h], [x + w, y + h]]) {
170+ g.circle(cx, cy, 9).fill({ color: 0x1a1408 }).circle(cx, cy, 9).stroke({ color: 0xf3e2ad, width: 2 });
171+ g.circle(cx, cy, 3).fill(0xf3e2ad);
172+ }
173+ // Top ornament.
174+ g.moveTo(x + w / 2 - 40, y).lineTo(x + w / 2, y - 10).lineTo(x + w / 2 + 40, y).stroke({ color: 0xe0c070, width: 2 });
175+ break;
176+ }
177+ case "stone": {
178+ g.roundRect(x, y, w, h, 6).fill({ color: 0x14120d, alpha: 0.8 });
179+ g.roundRect(x, y, w, h, 6).stroke({ color: 0x8a8578, alpha: 0.6, width: 5 });
180+ // Stone blocks along the border.
181+ const bw = 34;
182+ for (let i = 0; i < Math.floor(w / bw); i++) {
183+ g.moveTo(x + i * bw, y).lineTo(x + i * bw, y - 6).stroke({ color: 0x000000, alpha: 0.5, width: 2 });
184+ g.moveTo(x + i * bw, y + h).lineTo(x + i * bw, y + h + 6).stroke({ color: 0x000000, alpha: 0.5, width: 2 });
185+ }
186+ g.moveTo(x + 8, y + 12).lineTo(x + 22, y + 30).stroke({ color: 0x000000, alpha: 0.6, width: 2 });
187+ g.moveTo(x + w - 10, y + h - 40).lineTo(x + w - 26, y + h - 18).stroke({ color: 0x000000, alpha: 0.6, width: 2 });
188+ break;
189+ }
190+ case "ice": {
191+ g.roundRect(x, y, w, h, 22).fill({ color: 0x9fdcff, alpha: 0.07 });
192+ g.roundRect(x, y, w, h, 22).stroke({ color: 0xdff6ff, alpha: 0.55, width: 2 });
193+ g.roundRect(x - 4, y - 4, w + 8, h + 8, 26).stroke({ color: 0xbfe9ff, alpha: 0.18, width: 6 });
194+ // Frost shards on corners.
195+ for (const [cx, cy, d] of [[x, y, 1], [x + w, y, -1]]) {
196+ g.moveTo(cx, cy + 30).lineTo(cx + 18 * d, cy + 4).lineTo(cx + 40 * d, cy).stroke({ color: 0xffffff, alpha: 0.5, width: 2 });
197+ g.moveTo(cx + 8 * d, cy + 44).lineTo(cx + 26 * d, cy + 14).stroke({ color: 0xffffff, alpha: 0.3, width: 1.5 });
198+ }
199+ break;
200+ }
201+ case "carbon": {
202+ g.roundRect(x, y, w, h, 12).fill({ color: 0x07080b, alpha: 0.85 });
203+ for (let i = 0; i < h; i += 6) g.moveTo(x, y + i).lineTo(x + w, y + i).stroke({ color: 0xffffff, alpha: 0.025, width: 1 });
204+ g.roundRect(x, y, w, h, 12).stroke({ color: 0x5c6270, alpha: 0.8, width: 2 });
205+ g.roundRect(x, y, w, h, 12).stroke({ color: primary, alpha: 0.35, width: 1 });
206+ // Hex bolts.
207+ for (const [cx, cy] of [[x + 14, y + 14], [x + w - 14, y + 14], [x + 14, y + h - 14], [x + w - 14, y + h - 14]]) polygonN(g, cx, cy, 6, 6).fill({ color: 0x2a2f3a }).stroke({ color: 0x9aa3b5, alpha: 0.5, width: 1 });
208+ break;
209+ }
210+ case "neon": {
211+ g.roundRect(x, y, w, h, 18).fill({ color: 0x03040a, alpha: 0.55 });
212+ g.roundRect(x - 6, y - 6, w + 12, h + 12, 24).stroke({ color: primary, alpha: 0.18, width: 12 });
213+ g.roundRect(x - 2, y - 2, w + 4, h + 4, 20).stroke({ color: primary, alpha: 0.5, width: 5 });
214+ g.roundRect(x, y, w, h, 18).stroke({ color: 0xffffff, alpha: 0.9, width: 1.5 });
215+ // Secondary neon underline.
216+ g.moveTo(x + 24, y + h + 12).lineTo(x + w - 24, y + h + 12).stroke({ color: secondary, alpha: 0.7, width: 3 });
217+ break;
218+ }
219+ case "glass": {
220+ g.roundRect(x, y, w, h, 20).fill({ color: 0xffffff, alpha: 0.04 });
221+ g.roundRect(x, y, w, h, 20).stroke({ color: 0xffffff, alpha: 0.35, width: 1.5 });
222+ g.roundRect(x + 10, y + 8, w - 20, h * 0.18, 14).fill({ color: 0xffffff, alpha: 0.04 });
223+ g.moveTo(x + 30, y + 4).lineTo(x + w * 0.4, y + 4).stroke({ color: 0xffffff, alpha: 0.5, width: 2 });
224+ break;
225+ }
226+ case "obsidian": {
227+ g.roundRect(x, y, w, h, 4).fill({ color: 0x000000, alpha: 0.7 });
228+ g.roundRect(x, y, w, h, 4).stroke({ color: 0xffffff, alpha: 0.14, width: 1 });
229+ g.moveTo(x + w * 0.2, y - 10).lineTo(x + w * 0.8, y - 10).stroke({ color: 0xe8cf8f, alpha: 0.6, width: 1 });
230+ g.moveTo(x + w * 0.35, y + h + 10).lineTo(x + w * 0.65, y + h + 10).stroke({ color: 0xe8cf8f, alpha: 0.35, width: 1 });
231+ break;
232+ }
233+ case "metal":
234+ default: {
235+ g.roundRect(x, y, w, h, 14).fill({ color: 0x0b0e15, alpha: 0.75 });
236+ g.roundRect(x, y, w, h, 14).stroke({ color: 0x9aa3b5, alpha: 0.5, width: 3 });
237+ g.roundRect(x + 4, y + 4, w - 8, h - 8, 11).stroke({ color: 0xffffff, alpha: 0.08, width: 1 });
238+ g.roundRect(x, y, w, h * 0.35, 14).fill({ color: 0xffffff, alpha: 0.03 });
239+ // Rivets.
240+ for (let i = 0; i <= cols; i++) {
241+ const rx = x + (w / cols) * i;
242+ g.circle(Math.min(Math.max(rx, x + 10), x + w - 10), y + 8, 3).fill({ color: 0xc8d0dc, alpha: 0.55 });
243+ g.circle(Math.min(Math.max(rx, x + 10), x + w - 10), y + h - 8, 3).fill({ color: 0xc8d0dc, alpha: 0.55 });
244+ }
245+ break;
246+ }
247+ }
167 // Cell wells. 248 // Cell wells.
168− for (let r = 0; r < this.cols; r++) 249+ for (let r = 0; r < cols; r++)
169− for (let yy = 0; yy < this.rows; yy++) { 250+ for (let yy = 0; yy < rows; yy++) {
170− g.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(yy) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).fill({ color: 0xffffff, alpha: 0.025 }); 251+ g.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(yy) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).fill({ color: 0xffffff, alpha: p.frame === "obsidian" ? 0.012 : 0.03 });
171 } 252 }
172 } 253 }
173 254
174 /* ------------------------------------------------------------- backdrop */ 255 /* ------------------------------------------------------------- backdrop */
175 256
257+ /** Draw the game's world behind the reels: a static scene + a set of animated layers (see animateBackdrop). */
176 private drawBackdrop() { 258 private drawBackdrop() {
177 const p = this.opts.def.presentation; 259 const p = this.opts.def.presentation;
178 const b = this.backdrop; 260 const b = this.backdrop;
@@ -183,115 +265,365 @@ export class SlotRenderer {
183 const primary = hexColor(p.palette.primary); 265 const primary = hexColor(p.palette.primary);
184 const secondary = hexColor(p.palette.secondary); 266 const secondary = hexColor(p.palette.secondary);
185 const bg = hexColor(p.palette.bg); 267 const bg = hexColor(p.palette.bg);
186− g.rect(0, 0, W, H).fill(bg); 268+ const surface = hexColor(p.palette.surface);
187− // Vignette glow blobs.
188− g.ellipse(W * 0.3, H * 0.2, W * 0.5, H * 0.45).fill({ color: primary, alpha: 0.08 });
189− g.ellipse(W * 0.75, H * 0.85, W * 0.5, H * 0.4).fill({ color: secondary, alpha: 0.07 });
190 const seed = hashString(this.opts.def.slug); 269 const seed = hashString(this.opts.def.slug);
191 const rnd = mulberry32(seed); 270 const rnd = mulberry32(seed);
271+ const items: { g: Container; a: number; b: number; c: number }[] = [];
272+ const layer = (fn: (gg: Graphics) => void): Graphics => {
273+ const gg = new Graphics();
274+ fn(gg);
275+ b.addChild(gg);
276+ return gg;
277+ };
278+ // Base: vertical gradient bg → surface → bg.
279+ g.rect(0, 0, W, H).fill(bg);
280+ g.rect(0, 0, W, H * 0.55).fill({ color: surface, alpha: 0.55 });
281+ g.ellipse(W * 0.5, H * 0.35, W * 0.7, H * 0.5).fill({ color: primary, alpha: 0.07 });
282+ g.ellipse(W * 0.8, H * 0.95, W * 0.55, H * 0.35).fill({ color: secondary, alpha: 0.07 });
283+ b.addChild(g);
284+
192 switch (p.backdrop) { 285 switch (p.backdrop) {
193− case "grid":
194− case "circuit":
195 case "vault": { 286 case "vault": {
196− const step = Math.max(28, W / 22); 287+ // Rotating security rings + scanning grid.
197− for (let x = 0; x <= W; x += step) g.moveTo(x, 0).lineTo(x, H).stroke({ color: primary, alpha: 0.06, width: 1 }); 288+ const grid = layer((gg) => {
198− for (let y = 0; y <= H; y += step) g.moveTo(0, y).lineTo(W, y).stroke({ color: primary, alpha: 0.06, width: 1 }); 289+ const step = Math.max(30, W / 20);
199− if (p.backdrop === "vault") for (let i = 1; i <= 4; i++) g.circle(W / 2, H / 2, Math.min(W, H) * 0.2 * i).stroke({ color: primary, alpha: 0.05, width: 2 }); 290+ for (let x = 0; x <= W; x += step) gg.moveTo(x, -H).lineTo(x, H * 2).stroke({ color: primary, alpha: 0.07, width: 1 });
200− if (p.backdrop === "circuit") 291+ for (let y = -H; y <= H * 2; y += step) gg.moveTo(0, y).lineTo(W, y).stroke({ color: primary, alpha: 0.07, width: 1 });
201− for (let i = 0; i < 12; i++) { 292+ });
293+ void grid;
294+ for (let i = 1; i <= 3; i++) {
295+ const ring = layer((gg) => {
296+ const r = Math.min(W, H) * 0.22 * i;
297+ gg.circle(0, 0, r).stroke({ color: primary, alpha: 0.1, width: 2 });
298+ for (let k = 0; k < 8; k++) {
299+ const a = (k / 8) * Math.PI * 2;
300+ gg.moveTo(Math.cos(a) * (r - 14), Math.sin(a) * (r - 14)).lineTo(Math.cos(a) * (r + 14), Math.sin(a) * (r + 14)).stroke({ color: secondary, alpha: 0.35, width: 3 });
301+ }
302+ });
303+ ring.position.set(W / 2, H / 2);
304+ items.push({ g: ring, a: (i % 2 ? 1 : -1) * 0.12 / i, b: 0, c: 0 });
305+ }
306+ this.anim = { kind: "vault", items, t: 0 };
307+ break;
308+ }
309+ case "grid":
310+ case "circuit": {
311+ const traces = layer((gg) => {
312+ for (let i = 0; i < 18; i++) {
202 const x = rnd() * W; 313 const x = rnd() * W;
203 const y = rnd() * H; 314 const y = rnd() * H;
204− g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 200, y).lineTo(x + (rnd() - 0.5) * 200, y + (rnd() - 0.5) * 160).stroke({ color: secondary, alpha: 0.12, width: 2 }); 315+ gg.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 240, y).lineTo(x + (rnd() - 0.5) * 240, y + (rnd() - 0.5) * 180).stroke({ color: primary, alpha: 0.16, width: 2 });
205− g.circle(x, y, 3).fill({ color: secondary, alpha: 0.35 }); 316+ gg.circle(x, y, 3).fill({ color: secondary, alpha: 0.5 });
206 } 317 }
318+ });
319+ void traces;
320+ for (let i = 0; i < 3; i++) {
321+ const pulse = layer((gg) => gg.rect(0, 0, W, 3).fill({ color: secondary, alpha: 0.35 }));
322+ pulse.y = (H / 3) * i;
323+ items.push({ g: pulse, a: 40 + i * 25, b: i, c: 0 });
324+ }
325+ this.anim = { kind: "grid", items, t: 0 };
326+ break;
327+ }
328+ case "nebula": {
329+ for (let i = 0; i < 6; i++) {
330+ const cloud = layer((gg) => gg.ellipse(0, 0, 90 + rnd() * W * 0.3, 40 + rnd() * H * 0.25).fill({ color: i % 2 ? primary : secondary, alpha: 0.07 }));
331+ cloud.position.set(rnd() * W, rnd() * H);
332+ items.push({ g: cloud, a: (rnd() - 0.5) * 12, b: (rnd() - 0.5) * 6, c: 0 });
333+ }
334+ const stars = layer((gg) => {
335+ for (let i = 0; i < 120; i++) gg.circle(rnd() * W, rnd() * H, rnd() * 1.6 + 0.3).fill({ color: 0xffffff, alpha: 0.3 + rnd() * 0.6 });
336+ });
337+ void stars;
338+ // Black hole.
339+ const hole = layer((gg) => {
340+ gg.circle(0, 0, Math.min(W, H) * 0.09).fill(0x000000);
341+ gg.circle(0, 0, Math.min(W, H) * 0.11).stroke({ color: primary, alpha: 0.7, width: 4 });
342+ gg.ellipse(0, 0, Math.min(W, H) * 0.22, Math.min(W, H) * 0.05).stroke({ color: secondary, alpha: 0.35, width: 2 });
343+ });
344+ hole.position.set(W * 0.78, H * 0.22);
345+ items.push({ g: hole, a: 0, b: 0, c: 0.6 });
346+ this.anim = { kind: "nebula", items, t: 0 };
207 break; 347 break;
208 } 348 }
209− case "nebula":
210 case "universe": 349 case "universe":
211 case "gravity": { 350 case "gravity": {
212− for (let i = 0; i < 6; i++) g.ellipse(rnd() * W, rnd() * H, 80 + rnd() * W * 0.3, 40 + rnd() * H * 0.25).fill({ color: i % 2 ? primary : secondary, alpha: 0.05 }); 351+ const stars = layer((gg) => {
213− for (let i = 0; i < 90; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.6 + 0.3).fill({ color: 0xffffff, alpha: 0.25 + rnd() * 0.5 }); 352+ for (let i = 0; i < 110; i++) gg.circle(rnd() * W, rnd() * H, rnd() * 1.5 + 0.3).fill({ color: 0xffffff, alpha: 0.3 + rnd() * 0.6 });
214− if (p.backdrop === "gravity" || p.backdrop === "universe") for (let i = 1; i <= 3; i++) g.ellipse(W / 2, H / 2, W * 0.28 * i, H * 0.18 * i).stroke({ color: primary, alpha: 0.08, width: 1.5 }); 353+ });
354+ void stars;
355+ for (let i = 1; i <= 3; i++) {
356+ const orbit = layer((gg) => {
357+ gg.ellipse(0, 0, W * 0.24 * i, H * 0.16 * i).stroke({ color: i % 2 ? primary : secondary, alpha: 0.14, width: 1.5 });
358+ gg.circle(W * 0.24 * i, 0, 5).fill({ color: i % 2 ? secondary : primary, alpha: 0.9 });
359+ });
360+ orbit.position.set(W / 2, H / 2);
361+ orbit.rotation = rnd() * Math.PI;
362+ items.push({ g: orbit, a: (i % 2 ? 0.25 : -0.18) / i, b: 0, c: 0 });
363+ }
364+ if (p.backdrop === "universe") {
365+ const burst = layer((gg) => {
366+ for (let k = 0; k < 24; k++) {
367+ const a = (k / 24) * Math.PI * 2;
368+ gg.moveTo(Math.cos(a) * 40, Math.sin(a) * 40).lineTo(Math.cos(a) * Math.max(W, H), Math.sin(a) * Math.max(W, H)).stroke({ color: primary, alpha: 0.05, width: 2 });
369+ }
370+ });
371+ burst.position.set(W / 2, H / 2);
372+ items.push({ g: burst, a: 0.05, b: 0, c: 0 });
373+ }
374+ this.anim = { kind: "gravity", items, t: 0 };
215 break; 375 break;
216 } 376 }
217− case "pillars": 377+ case "pillars": {
218− case "temple": {
219 const n = 6; 378 const n = 6;
220 for (let i = 0; i < n; i++) { 379 for (let i = 0; i < n; i++) {
221 const x = (W / (n + 1)) * (i + 1); 380 const x = (W / (n + 1)) * (i + 1);
222− g.rect(x - 18, 0, 36, H).fill({ color: primary, alpha: 0.05 }); 381+ const col = layer((gg) => {
223− g.rect(x - 26, H * 0.08, 52, 14).fill({ color: primary, alpha: 0.1 }); 382+ gg.rect(-20, 0, 40, H).fill({ color: primary, alpha: 0.06 });
383+ gg.rect(-30, H * 0.06, 60, 16).fill({ color: primary, alpha: 0.14 });
384+ gg.rect(-30, H * 0.92, 60, 16).fill({ color: primary, alpha: 0.14 });
385+ gg.rect(-12, 0, 6, H).fill({ color: 0xffffff, alpha: 0.03 });
386+ });
387+ col.position.set(x, 0);
388+ items.push({ g: col, a: 0.8 + rnd(), b: rnd() * 6, c: 1 });
224 } 389 }
390+ // Lantern glows.
391+ for (let i = 0; i < 4; i++) {
392+ const lamp = layer((gg) => gg.circle(0, 0, 40).fill({ color: 0xffb347, alpha: 0.12 }));
393+ lamp.position.set(W * (0.15 + 0.23 * i), H * 0.25);
394+ items.push({ g: lamp, a: 2 + rnd() * 2, b: rnd() * 6, c: 1 });
395+ }
396+ this.anim = { kind: "pillars", items, t: 0 };
225 break; 397 break;
226 } 398 }
227 case "aurora": { 399 case "aurora": {
228− for (let k = 0; k < 4; k++) { 400+ for (let k = 0; k < 5; k++) {
229− g.moveTo(0, H * (0.2 + k * 0.12)); 401+ const band = layer((gg) => {
230− for (let x = 0; x <= W; x += 40) g.lineTo(x, H * (0.2 + k * 0.12) + Math.sin(x / 90 + k) * 30); 402+ gg.moveTo(-60, H * (0.14 + k * 0.1));
231− g.stroke({ color: k % 2 ? primary : secondary, alpha: 0.1, width: 26 }); 403+ for (let x = -60; x <= W + 60; x += 30) gg.lineTo(x, H * (0.14 + k * 0.1) + Math.sin(x / 110 + k) * 34);
404+ gg.stroke({ color: k % 2 ? primary : secondary, alpha: 0.13, width: 34 });
405+ });
406+ items.push({ g: band, a: 0.4 + k * 0.12, b: k, c: 0 });
232 } 407 }
408+ // Mountains.
409+ const mountains = layer((gg) => {
410+ gg.moveTo(0, H);
411+ for (let x = 0; x <= W; x += 60) gg.lineTo(x, H * 0.78 - Math.abs(Math.sin(x / 140)) * H * 0.18);
412+ gg.lineTo(W, H).fill({ color: 0x06090f, alpha: 0.9 });
413+ });
414+ void mountains;
415+ this.anim = { kind: "aurora", items, t: 0 };
233 break; 416 break;
234 } 417 }
235− case "lava": 418+ case "lava": {
236− case "core": 419+ const cracks = layer((gg) => {
237− case "reactor": { 420+ for (let i = 0; i < 14; i++) {
238− for (let i = 0; i < 14; i++) { 421+ let x = rnd() * W;
239− const x = rnd() * W; 422+ let y = H * 0.55 + rnd() * H * 0.45;
240− const y = rnd() * H; 423+ gg.moveTo(x, y);
241− g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 220, y + (rnd() - 0.5) * 220).stroke({ color: primary, alpha: 0.18, width: 2 }); 424+ for (let k = 0; k < 5; k++) {
242− } 425+ x += (rnd() - 0.5) * 120;
243− g.circle(W / 2, H / 2, Math.min(W, H) * 0.42).stroke({ color: secondary, alpha: 0.12, width: 10 }); 426+ y += (rnd() - 0.5) * 80;
427+ gg.lineTo(x, y);
428+ }
429+ gg.stroke({ color: primary, alpha: 0.35, width: 3 });
430+ }
431+ });
432+ items.push({ g: cracks, a: 1.5, b: 0, c: 0.5 });
433+ const glow = layer((gg) => gg.rect(0, H * 0.6, W, H * 0.4).fill({ color: primary, alpha: 0.12 }));
434+ items.push({ g: glow, a: 0.7, b: 1, c: 0.6 });
435+ const volcano = layer((gg) => gg.poly([W * 0.15, H * 0.62, W * 0.42, H * 0.12, W * 0.7, H * 0.62]).fill({ color: 0x0a0505, alpha: 0.85 }));
436+ void volcano;
437+ this.anim = { kind: "lava", items, t: 0 };
244 break; 438 break;
245 } 439 }
246 case "skyline": { 440 case "skyline": {
247− for (let x = 0; x < W; x += 30 + rnd() * 40) { 441+ const buildings = layer((gg) => {
248− const h = 60 + rnd() * H * 0.45; 442+ for (let x = 0; x < W; x += 34 + rnd() * 40) {
249− g.rect(x, H - h, 24 + rnd() * 40, h).fill({ color: 0x000000, alpha: 0.45 }); 443+ const h = 80 + rnd() * H * 0.5;
250− for (let i = 0; i < 5; i++) g.rect(x + 4 + rnd() * 30, H - h + 8 + rnd() * (h - 16), 4, 6).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.6 }); 444+ gg.rect(x, H - h, 28 + rnd() * 44, h).fill({ color: 0x05060c, alpha: 0.85 });
445+ }
446+ });
447+ void buildings;
448+ for (let i = 0; i < 60; i++) {
449+ const win = layer((gg) => gg.rect(0, 0, 5, 7).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.85 }));
450+ win.position.set(rnd() * W, H * 0.45 + rnd() * H * 0.5);
451+ items.push({ g: win, a: 0.4 + rnd() * 1.5, b: rnd() * 6, c: 0 });
251 } 452 }
453+ // Neon sign strokes.
454+ const signs = layer((gg) => {
455+ gg.roundRect(W * 0.08, H * 0.18, 120, 40, 6).stroke({ color: primary, alpha: 0.7, width: 3 });
456+ gg.roundRect(W * 0.7, H * 0.28, 150, 34, 6).stroke({ color: secondary, alpha: 0.7, width: 3 });
457+ });
458+ void signs;
459+ this.anim = { kind: "skyline", items, t: 0 };
252 break; 460 break;
253 } 461 }
254 case "pyramid": { 462 case "pyramid": {
255− g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.05, H]).fill({ color: primary, alpha: 0.06 }); 463+ const pyr = layer((gg) => {
256− g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.5, H]).fill({ color: 0x000000, alpha: 0.15 }); 464+ gg.poly([W * 0.5, H * 0.08, W * 0.98, H, W * 0.02, H]).fill({ color: primary, alpha: 0.08 });
465+ gg.poly([W * 0.5, H * 0.08, W * 0.98, H, W * 0.5, H]).fill({ color: 0x000000, alpha: 0.2 });
466+ for (let i = 1; i < 8; i++) gg.moveTo(W * 0.5 - (W * 0.48 * i) / 8, H * 0.08 + ((H * 0.92) * i) / 8).lineTo(W * 0.5 + (W * 0.48 * i) / 8, H * 0.08 + ((H * 0.92) * i) / 8).stroke({ color: primary, alpha: 0.08, width: 1 });
467+ });
468+ void pyr;
469+ const eye = layer((gg) => {
470+ gg.circle(0, 0, 26).stroke({ color: secondary, alpha: 0.8, width: 3 });
471+ gg.circle(0, 0, 9).fill({ color: secondary, alpha: 0.9 });
472+ });
473+ eye.position.set(W * 0.5, H * 0.3);
474+ items.push({ g: eye, a: 1.2, b: 0, c: 0.5 });
475+ const beam = layer((gg) => gg.poly([W * 0.5 - 4, H * 0.3, W * 0.5 + 4, H * 0.3, W * 0.62, H, W * 0.38, H]).fill({ color: secondary, alpha: 0.12 }));
476+ items.push({ g: beam, a: 0.9, b: 2, c: 0.3 });
477+ this.anim = { kind: "pyramid", items, t: 0 };
257 break; 478 break;
258 } 479 }
259 case "arcade": { 480 case "arcade": {
260− for (let i = 0; i < 60; i++) g.rect(Math.floor((rnd() * W) / 24) * 24, Math.floor((rnd() * H) / 24) * 24, 22, 22).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.08 }); 481+ for (let i = 0; i < 70; i++) {
482+ const px = layer((gg) => gg.rect(0, 0, 22, 22).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.4 }));
483+ px.position.set(Math.floor((rnd() * W) / 24) * 24, Math.floor((rnd() * H) / 24) * 24);
484+ items.push({ g: px, a: 0, b: rnd() * 6, c: 0 });
485+ }
486+ // Bezel stripes.
487+ const stripes = layer((gg) => {
488+ for (let i = 0; i < 4; i++) gg.rect(0, H - 24 - i * 30, W, 10).fill({ color: i % 2 ? primary : secondary, alpha: 0.25 });
489+ });
490+ void stripes;
491+ this.anim = { kind: "arcade", items, t: 0 };
261 break; 492 break;
262 } 493 }
263 case "asteroids": { 494 case "asteroids": {
264− for (let i = 0; i < 16; i++) polygonRandom(g, rnd() * W, rnd() * H, 10 + rnd() * 40, rnd).fill({ color: 0xffffff, alpha: 0.05 }); 495+ const stars = layer((gg) => {
265− for (let i = 0; i < 60; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.5 }); 496+ for (let i = 0; i < 80; i++) gg.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.6 });
497+ });
498+ void stars;
499+ for (let i = 0; i < 14; i++) {
500+ const rock = layer((gg) => polygonRandom(gg, 0, 0, 14 + rnd() * 44, rnd).fill({ color: 0x2b2e36, alpha: 0.9 }).stroke({ color: primary, alpha: 0.35, width: 1.5 }));
501+ rock.position.set(rnd() * W, rnd() * H);
502+ items.push({ g: rock, a: (rnd() - 0.5) * 30, b: (rnd() - 0.5) * 20, c: (rnd() - 0.5) * 0.6 });
503+ }
504+ this.anim = { kind: "asteroids", items, t: 0 };
266 break; 505 break;
267 } 506 }
268 case "track": { 507 case "track": {
269− for (let k = 0; k < 5; k++) { 508+ const road = layer((gg) => {
270− g.moveTo(-50, H * 0.9 - k * 40); 509+ gg.moveTo(-50, H * 0.9).bezierCurveTo(W * 0.3, H * 0.15, W * 0.7, H * 1.15, W + 50, H * 0.25).stroke({ color: 0x0c0f16, alpha: 1, width: 90 });
271− g.bezierCurveTo(W * 0.3, H * 0.1 - k * 30, W * 0.7, H * 1.1 - k * 30, W + 50, H * 0.2 - k * 40); 510+ gg.moveTo(-50, H * 0.9).bezierCurveTo(W * 0.3, H * 0.15, W * 0.7, H * 1.15, W + 50, H * 0.25).stroke({ color: primary, alpha: 0.25, width: 4 });
272− g.stroke({ color: k % 2 ? primary : 0xffffff, alpha: 0.06, width: 12 }); 511+ });
512+ void road;
513+ for (let i = 0; i < 10; i++) {
514+ const dash = layer((gg) => gg.rect(0, -3, 40, 6).fill({ color: 0xffffff, alpha: 0.35 }));
515+ dash.position.set((W / 10) * i, H * 0.55 + Math.sin(i) * 40);
516+ items.push({ g: dash, a: 220 + i * 10, b: 0, c: 0 });
517+ }
518+ const flag = layer((gg) => {
519+ for (let i = 0; i < 6; i++) for (let j = 0; j < 2; j++) gg.rect(W * 0.82 + i * 14, H * 0.1 + j * 14, 14, 14).fill({ color: (i + j) % 2 ? 0xffffff : 0x000000, alpha: 0.5 });
520+ });
521+ void flag;
522+ this.anim = { kind: "track", items, t: 0 };
523+ break;
524+ }
525+ case "reactor":
526+ case "core": {
527+ for (let i = 1; i <= 3; i++) {
528+ const ring = layer((gg) => {
529+ polygonN(gg, 0, 0, Math.min(W, H) * 0.16 * i, 6).stroke({ color: i === 2 ? secondary : primary, alpha: 0.35, width: 3 });
530+ for (let k = 0; k < 6; k++) {
531+ const a = (k / 6) * Math.PI * 2;
532+ gg.circle(Math.cos(a) * Math.min(W, H) * 0.16 * i, Math.sin(a) * Math.min(W, H) * 0.16 * i, 4).fill({ color: secondary, alpha: 0.8 });
533+ }
534+ });
535+ ring.position.set(W / 2, H / 2);
536+ items.push({ g: ring, a: (i % 2 ? 0.2 : -0.14) / i, b: i, c: 0 });
273 } 537 }
538+ const coreGlow = layer((gg) => gg.circle(0, 0, Math.min(W, H) * 0.12).fill({ color: primary, alpha: 0.35 }));
539+ coreGlow.position.set(W / 2, H / 2);
540+ items.push({ g: coreGlow, a: 0, b: 1, c: 0 });
541+ const pipes = layer((gg) => {
542+ for (const yy of [H * 0.12, H * 0.88]) gg.rect(0, yy - 5, W, 10).fill({ color: 0x1a1e27, alpha: 0.9 }).rect(0, yy - 2, W, 4).fill({ color: primary, alpha: 0.2 });
543+ });
544+ void pipes;
545+ this.anim = { kind: "reactor", items, t: 0 };
274 break; 546 break;
275 } 547 }
276 case "abyss": { 548 case "abyss": {
277− for (let i = 0; i < 40; i++) g.circle(rnd() * W, rnd() * H, 2 + rnd() * 10).stroke({ color: primary, alpha: 0.12, width: 1 }); 549+ for (let i = 0; i < 4; i++) {
278− g.rect(0, H * 0.6, W, H * 0.4).fill({ color: 0x000000, alpha: 0.35 }); 550+ const depth = layer((gg) => gg.rect(0, 0, W, H * 0.25).fill({ color: 0x000000, alpha: 0.12 * (i + 1) }));
551+ depth.y = (H * 0.25) * i;
552+ void depth;
553+ }
554+ for (let k = 0; k < 4; k++) {
555+ const ray = layer((gg) => gg.poly([W * (0.2 + k * 0.2) - 30, 0, W * (0.2 + k * 0.2) + 30, 0, W * (0.2 + k * 0.2) + 120, H, W * (0.2 + k * 0.2) - 120, H]).fill({ color: secondary, alpha: 0.05 }));
556+ items.push({ g: ray, a: 0.3 + k * 0.1, b: k, c: 0 });
557+ }
558+ const ruins = layer((gg) => {
559+ for (let i = 0; i < 5; i++) gg.rect(W * 0.1 + i * W * 0.18, H * 0.7 + rnd() * 40, 30, H).fill({ color: 0x03060a, alpha: 0.9 });
560+ });
561+ void ruins;
562+ this.anim = { kind: "abyss", items, t: 0 };
279 break; 563 break;
280 } 564 }
281 case "moon": { 565 case "moon": {
282− g.circle(W * 0.8, H * 0.2, Math.min(W, H) * 0.18).fill({ color: lighten(p.palette.primary, 0.1), alpha: 0.12 }); 566+ const stars = layer((gg) => {
283− for (let i = 0; i < 8; i++) g.circle(W * 0.8 + (rnd() - 0.5) * 120, H * 0.2 + (rnd() - 0.5) * 120, 4 + rnd() * 14).fill({ color: 0x000000, alpha: 0.12 }); 567+ for (let i = 0; i < 90; i++) gg.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.6 });
284− for (let i = 0; i < 70; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.5 }); 568+ });
569+ void stars;
570+ const earth = layer((gg) => {
571+ gg.circle(0, 0, 44).fill({ color: 0x3b82f6, alpha: 0.7 });
572+ gg.ellipse(-10, -6, 18, 10).fill({ color: 0x86efac, alpha: 0.5 });
573+ gg.circle(0, 0, 44).stroke({ color: 0xffffff, alpha: 0.3, width: 2 });
574+ });
575+ earth.position.set(W * 0.82, H * 0.18);
576+ items.push({ g: earth, a: 0, b: 0, c: 0.05 });
577+ const ground = layer((gg) => {
578+ gg.ellipse(W / 2, H * 1.05, W * 0.8, H * 0.3).fill({ color: 0x1a1d24, alpha: 0.95 });
579+ for (let i = 0; i < 9; i++) gg.ellipse(rnd() * W, H * 0.82 + rnd() * H * 0.15, 10 + rnd() * 30, 4 + rnd() * 10).fill({ color: 0x000000, alpha: 0.35 });
580+ });
581+ void ground;
582+ const dome = layer((gg) => {
583+ gg.arc(0, 0, 70, Math.PI, 0).fill({ color: primary, alpha: 0.15 });
584+ gg.arc(0, 0, 70, Math.PI, 0).stroke({ color: primary, alpha: 0.6, width: 2 });
585+ });
586+ dome.position.set(W * 0.2, H * 0.85);
587+ void dome;
588+ this.anim = { kind: "moon", items, t: 0 };
589+ break;
590+ }
591+ case "temple": {
592+ const steps = layer((gg) => {
593+ for (let i = 0; i < 6; i++) gg.rect(W * 0.1 + i * 26, H * 0.35 + i * (H * 0.1), W * 0.8 - i * 52, H * 0.1).fill({ color: 0x0d0f0a, alpha: 0.85 }).stroke({ color: primary, alpha: 0.15, width: 1 });
594+ });
595+ void steps;
596+ const vines = layer((gg) => {
597+ for (let i = 0; i < 8; i++) {
598+ const x = rnd() * W;
599+ gg.moveTo(x, 0).bezierCurveTo(x + 40, H * 0.2, x - 40, H * 0.4, x + 10, H * 0.6).stroke({ color: 0x2f7d4a, alpha: 0.5, width: 3 });
600+ }
601+ });
602+ void vines;
603+ for (let i = 0; i < 4; i++) {
604+ const torch = layer((gg) => gg.circle(0, 0, 34).fill({ color: 0xffa040, alpha: 0.18 }));
605+ torch.position.set(W * (0.12 + i * 0.25), H * 0.32);
606+ items.push({ g: torch, a: 4 + rnd() * 3, b: rnd() * 6, c: 0.5 });
607+ }
608+ this.anim = { kind: "temple", items, t: 0 };
285 break; 609 break;
286 } 610 }
287 case "minimal": { 611 case "minimal": {
288− g.moveTo(W * 0.1, H * 0.5).lineTo(W * 0.9, H * 0.5).stroke({ color: primary, alpha: 0.18, width: 1 }); 612+ const line = layer((gg) => gg.moveTo(W * 0.08, H * 0.5).lineTo(W * 0.92, H * 0.5).stroke({ color: 0xe8cf8f, alpha: 0.18, width: 1 }));
613+ items.push({ g: line, a: 0, b: 0, c: 0 });
614+ this.anim = { kind: "minimal", items, t: 0 };
289 break; 615 break;
290 } 616 }
291 } 617 }
292− b.addChild(g); 618+ // Vault scan line (moving) is handled as its own small anim group when the backdrop is a vault.
619+ if (p.backdrop === "vault") {
620+ const scan = layer((gg) => gg.rect(0, 0, W, 2).fill({ color: secondary, alpha: 0.45 }));
621+ this.vaultScan = scan;
622+ } else this.vaultScan = null;
293 } 623 }
294 624
625+ private vaultScan: Graphics | null = null;
626+
295 private initAmbient() { 627 private initAmbient() {
296 if (!this.app || this.opts.reduceMotion || this.opts.intensity === "low") return; 628 if (!this.app || this.opts.reduceMotion || this.opts.intensity === "low") return;
297 const p = this.opts.def.presentation; 629 const p = this.opts.def.presentation;
@@ -349,7 +681,7 @@ export class SlotRenderer {
349 private buildTextures(renderer: Renderer) { 681 private buildTextures(renderer: Renderer) {
350 const size = 176; 682 const size = 176;
351 for (const s of this.opts.def.symbols) { 683 for (const s of this.opts.def.symbols) {
352− const art = drawSymbol(s.style, { size, tier: s.tier, kind: s.kind }); 684+ const art = drawSymbol(s.style, { size, tier: s.tier, kind: s.kind, frame: this.opts.def.presentation.frame });
353 const tex = renderer.generateTexture({ target: art, resolution: 2, frame: new Rectangle(-size / 2, -size / 2, size, size) }); 685 const tex = renderer.generateTexture({ target: art, resolution: 2, frame: new Rectangle(-size / 2, -size / 2, size, size) });
354 this.textures.set(s.id, tex); 686 this.textures.set(s.id, tex);
355 art.destroy({ children: true }); 687 art.destroy({ children: true });
@@ -447,9 +779,12 @@ export class SlotRenderer {
447 /** Start reels spinning (called when the request is sent). */ 779 /** Start reels spinning (called when the request is sent). */
448 startSpin() { 780 startSpin() {
449 this.clearFx(); 781 this.clearFx();
782+ this.reelSpinning = [];
450 this.spinning = true; 783 this.spinning = true;
451 this.spinVel = 0; 784 this.spinVel = 0;
785+ this.spinStartedAt = performance.now();
452 } 786 }
787+ private spinStartedAt = 0;
453 788
454 /** Land the given grid reel by reel. Resolves when the last reel stops. */ 789 /** Land the given grid reel by reel. Resolves when the last reel stops. */
455 async landGrid(grid: string[][], opts: { minSpinMs?: number } = {}): Promise<void> { 790 async landGrid(grid: string[][], opts: { minSpinMs?: number } = {}): Promise<void> {
@@ -459,15 +794,25 @@ export class SlotRenderer {
459 await this.dropIn(grid); 794 await this.dropIn(grid);
460 return; 795 return;
461 } 796 }
462− const minSpin = (opts.minSpinMs ?? 500) * this.speed; 797+ const minSpin = (opts.minSpinMs ?? (this.opts.quick ? 380 : 780)) * this.speed;
463− if (!this.spinning) { 798+ if (!this.spinning) this.startSpin();
464− this.startSpin(); 799+ // Make sure the reels have visibly spun for at least `minSpin` since the spin started.
465− } 800+ const elapsed = performance.now() - this.spinStartedAt;
466− await wait(minSpin); 801+ if (elapsed < minSpin) await wait(minSpin - elapsed);
467− const stagger = (this.opts.quick ? 70 : 130) * this.speed; 802+ const stagger = (this.opts.quick ? 80 : 160) * this.speed;
803+ // Anticipation: when the landed reels already show (trigger − 1) scatters, the remaining reels slow down and glow.
804+ const scatterId = this.opts.def.scatter?.id;
805+ const triggerAt = scatterId ? Math.min(...Object.keys(this.opts.def.scatter!.triggers).map(Number)) : Infinity;
806+ let scattersSoFar = 0;
468 for (let r = 0; r < this.cols; r++) { 807 for (let r = 0; r < this.cols; r++) {
808+ const anticipate = scatterId !== undefined && scattersSoFar >= triggerAt - 1 && r < this.cols && !this.opts.quick;
809+ if (anticipate) {
810+ this.anticipationGlow(r);
811+ await wait(650 * this.speed);
812+ }
469 await this.stopReel(r, grid[r]); 813 await this.stopReel(r, grid[r]);
470 this.opts.onReelStop?.(r); 814 this.opts.onReelStop?.(r);
815+ if (scatterId) scattersSoFar += grid[r].filter((id) => id === scatterId).length;
471 if (r < this.cols - 1) await wait(stagger); 816 if (r < this.cols - 1) await wait(stagger);
472 } 817 }
473 this.spinning = false; 818 this.spinning = false;
@@ -504,29 +849,126 @@ export class SlotRenderer {
504 this.particles.splice(i, 1); 849 this.particles.splice(i, 1);
505 } 850 }
506 } 851 }
852+ // Animated backdrop layers (per game world).
853+ this.animateBackdrop(dt);
507 // Reel spin: scroll symbols downward, wrapping with placeholder art. 854 // Reel spin: scroll symbols downward, wrapping with placeholder art.
508 if (this.spinning) { 855 if (this.spinning) {
509− this.spinVel = Math.min(2600, this.spinVel + 9000 * dt); 856+ const maxVel = this.cellSize * (this.opts.quick ? 26 : 20);
857+ // Quick spin-up, then cruise. A short "pull back" at the very start reads as a physical launch.
858+ const since = (performance.now() - this.spinStartedAt) / 1000;
859+ const target = since < 0.08 ? -this.cellSize * 3 : maxVel;
860+ this.spinVel += (target - this.spinVel) * Math.min(1, dt * 14);
510 const regular = this.opts.def.symbols.filter((s) => s.kind === "regular" || s.kind === "wild"); 861 const regular = this.opts.def.symbols.filter((s) => s.kind === "regular" || s.kind === "wild");
862+ const blur = Math.min(1, Math.max(0, this.spinVel / maxVel));
511 for (let r = 0; r < this.cols; r++) { 863 for (let r = 0; r < this.cols; r++) {
512 if (this.reelSpinning[r] === false) continue; 864 if (this.reelSpinning[r] === false) continue;
513 this.reelSpinning[r] = true; 865 this.reelSpinning[r] = true;
514 const col = this.cells[r]; 866 const col = this.cells[r];
515− const bottom = this.originY + this.rows * (this.cellSize + this.gap); 867+ const span = this.rows * (this.cellSize + this.gap);
868+ const bottom = this.originY + span;
516 for (const cell of col) { 869 for (const cell of col) {
517− cell.sprite.y += this.spinVel * dt * (0.9 + r * 0.04); 870+ cell.sprite.y += this.spinVel * dt * (0.92 + r * 0.05);
518 if (cell.sprite.y - this.cellSize / 2 > bottom) { 871 if (cell.sprite.y - this.cellSize / 2 > bottom) {
519− cell.sprite.y -= this.rows * (this.cellSize + this.gap); 872+ cell.sprite.y -= span;
520− const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only 873+ const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only, never an outcome
521 this.swapTexture(cell, id); 874 this.swapTexture(cell, id);
875+ } else if (cell.sprite.y + this.cellSize / 2 < this.originY - this.gap) {
876+ cell.sprite.y += span;
522 } 877 }
878+ // Motion blur: stretch vertically and fade while fast.
879+ const sp = cell.sprite.children[0] as Sprite;
880+ sp.alpha = 1 - 0.45 * blur;
881+ cell.sprite.scale.set(1 - 0.06 * blur, 1 + 0.55 * blur);
523 } 882 }
524− // Motion blur feel.
525− for (const cell of col) (cell.sprite.children[0] as Sprite).alpha = 0.85;
526 } 883 }
527 } 884 }
528 } 885 }
529 886
887+ /* --------------------------------------------------------- backdrop anim */
888+
889+ private anim: { kind: string; items: { g: Container; a: number; b: number; c: number }[]; t: number } | null = null;
890+
891+ private animateBackdrop(dt: number) {
892+ if (this.vaultScan && !this.opts.reduceMotion) {
893+ this.vaultScan.y += dt * 70;
894+ if (this.vaultScan.y > this.height) this.vaultScan.y = 0;
895+ }
896+ if (!this.anim || this.opts.reduceMotion) return;
897+ const A = this.anim;
898+ A.t += dt;
899+ const W = this.width;
900+ const H = this.height;
901+ switch (A.kind) {
902+ case "vault":
903+ case "gravity":
904+ case "universe":
905+ case "reactor":
906+ case "core":
907+ for (const it of A.items) it.g.rotation += dt * it.a;
908+ if (A.kind === "reactor" || A.kind === "core") for (const it of A.items) it.g.alpha = 0.35 + Math.sin(A.t * 1.6 + it.b) * 0.25;
909+ break;
910+ case "grid":
911+ case "circuit":
912+ case "arcade":
913+ for (const it of A.items) {
914+ it.g.y += dt * it.a;
915+ if (it.g.y > H + 40) it.g.y = -40;
916+ if (A.kind === "arcade") it.g.alpha = 0.2 + Math.abs(Math.sin(A.t * 2 + it.b)) * 0.5;
917+ }
918+ break;
919+ case "nebula":
920+ case "asteroids":
921+ case "moon":
922+ for (const it of A.items) {
923+ it.g.x += dt * it.a;
924+ it.g.y += dt * it.b;
925+ if (it.g.x > W + 60) it.g.x = -60;
926+ if (it.g.x < -60) it.g.x = W + 60;
927+ if (it.g.y > H + 60) it.g.y = -60;
928+ if (it.g.y < -60) it.g.y = H + 60;
929+ it.g.rotation += dt * it.c;
930+ }
931+ break;
932+ case "aurora":
933+ case "abyss":
934+ for (const it of A.items) {
935+ it.g.x = Math.sin(A.t * it.a + it.b) * 26;
936+ it.g.alpha = 0.5 + Math.sin(A.t * it.a * 1.3 + it.b) * 0.25;
937+ }
938+ break;
939+ case "lava":
940+ case "pyramid":
941+ case "temple":
942+ for (const it of A.items) it.g.alpha = it.c + Math.sin(A.t * it.a + it.b) * it.c * 0.8;
943+ break;
944+ case "skyline":
945+ for (const it of A.items) it.g.alpha = Math.sin(A.t * it.a + it.b) > 0.3 ? 0.85 : 0.2;
946+ break;
947+ case "track":
948+ for (const it of A.items) {
949+ it.g.x -= dt * it.a;
950+ if (it.g.x < -80) it.g.x = W + 80;
951+ }
952+ break;
953+ case "minimal":
954+ for (const it of A.items) it.g.alpha = 0.12 + Math.sin(A.t * 0.6) * 0.06;
955+ break;
956+ case "pillars":
957+ for (const it of A.items) it.g.alpha = it.c + Math.sin(A.t * it.a + it.b) * 0.05;
958+ break;
959+ }
960+ }
961+
962+ private anticipationGlow(reel: number) {
963+ const g = new Graphics();
964+ const p = hexColor(this.opts.def.presentation.palette.glow);
965+ g.roundRect(this.cellX(reel) - this.cellSize / 2 - 4, this.originY - 4, this.cellSize + 8, this.rows * (this.cellSize + this.gap) - this.gap + 8, 14).stroke({ color: p, alpha: 0.9, width: 4 });
966+ this.fxLayer.addChild(g);
967+ void this.animate(650 * this.speed, (t) => {
968+ g.alpha = 0.4 + Math.sin(t * Math.PI * 6) * 0.4;
969+ }).then(() => g.destroy());
970+ }
971+
530 private swapTexture(cell: Cell, id: string) { 972 private swapTexture(cell: Cell, id: string) {
531 const sp = cell.sprite.children[0] as Sprite; 973 const sp = cell.sprite.children[0] as Sprite;
532 sp.texture = this.textures.get(id) ?? this.textures.get("__")!; 974 sp.texture = this.textures.get(id) ?? this.textures.get("__")!;
@@ -537,20 +979,32 @@ export class SlotRenderer {
537 private async stopReel(r: number, ids: string[]) { 979 private async stopReel(r: number, ids: string[]) {
538 this.reelSpinning[r] = false; 980 this.reelSpinning[r] = false;
539 const col = this.cells[r]; 981 const col = this.cells[r];
540− // Snap: place final symbols slightly above target, then ease into place with a bounce. 982+ // Snap: place final symbols above target, then drop into place with a bounce and a squash.
541 col.forEach((cell, y) => { 983 col.forEach((cell, y) => {
542 this.swapTexture(cell, ids[y]); 984 this.swapTexture(cell, ids[y]);
543 (cell.sprite.children[0] as Sprite).alpha = 1; 985 (cell.sprite.children[0] as Sprite).alpha = 1;
544− cell.sprite.y = this.cellY(y) - this.cellSize * 0.35; 986+ cell.sprite.scale.set(1, 1.3);
987+ cell.sprite.y = this.cellY(y) - this.cellSize * 0.6;
545 }); 988 });
546− const dur = (this.opts.quick ? 120 : 220) * this.speed; 989+ // Landing flash on the reel.
990+ const flash = new Graphics();
991+ flash.roundRect(this.cellX(r) - this.cellSize / 2 - 2, this.originY - 2, this.cellSize + 4, this.rows * (this.cellSize + this.gap) - this.gap + 4, 12).fill({ color: 0xffffff, alpha: 0.18 });
992+ this.fxLayer.addChild(flash);
993+ const dur = (this.opts.quick ? 140 : 260) * this.speed;
547 await this.animate(dur, (t) => { 994 await this.animate(dur, (t) => {
548 const e = easeOutBack(t); 995 const e = easeOutBack(t);
996+ flash.alpha = 1 - t;
549 col.forEach((cell, y) => { 997 col.forEach((cell, y) => {
550− cell.sprite.y = this.cellY(y) - this.cellSize * 0.35 * (1 - e); 998+ cell.sprite.y = this.cellY(y) - this.cellSize * 0.6 * (1 - e);
999+ const squash = t < 0.5 ? 1 + (1 - t * 2) * 0.3 : 1 - Math.sin((t - 0.5) * Math.PI) * 0.08;
1000+ cell.sprite.scale.set(1 + (1 - squash) * 0.5, squash);
551 }); 1001 });
552 }); 1002 });
553− col.forEach((cell, y) => (cell.sprite.y = this.cellY(y))); 1003+ flash.destroy();
1004+ col.forEach((cell, y) => {
1005+ cell.sprite.y = this.cellY(y);
1006+ cell.sprite.scale.set(1);
1007+ });
554 } 1008 }
555 1009
556 private async dropIn(grid: string[][]) { 1010 private async dropIn(grid: string[][]) {
@@ -875,6 +1329,15 @@ function mulberry32(seed: number) {
875 }; 1329 };
876 } 1330 }
877 1331
1332+function polygonN(g: Graphics, cx: number, cy: number, r: number, sides: number): Graphics {
1333+ const pts: number[] = [];
1334+ for (let i = 0; i < sides; i++) {
1335+ const a = -Math.PI / 2 + (i * Math.PI * 2) / sides;
1336+ pts.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
1337+ }
1338+ return g.poly(pts);
1339+}
1340+
878 function polygonRandom(g: Graphics, cx: number, cy: number, r: number, rnd: () => number): Graphics { 1341 function polygonRandom(g: Graphics, cx: number, cy: number, r: number, rnd: () => number): Graphics {
879 const pts: number[] = []; 1342 const pts: number[] = [];
880 const n = 6 + Math.floor(rnd() * 4); 1343 const n = 6 + Math.floor(rnd() * 4);
modified apps/web/src/components/game/symbol-art.ts +64 −6
@@ -51,6 +51,68 @@ export interface SymbolArtOptions {
51 /** Tier drives frame richness. */ 51 /** Tier drives frame richness. */
52 tier: "low" | "mid" | "high" | "premium" | "special"; 52 tier: "low" | "mid" | "high" | "premium" | "special";
53 kind: string; 53 kind: string;
54+ /** Game frame preset — drives the tile plate look so every game has its own tiles. */
55+ frame?: "metal" | "glass" | "gold" | "stone" | "ice" | "carbon" | "neon" | "obsidian";
56+}
57+
58+/** Tile plate behind the icon, styled per game frame preset. */
59+function drawPlate(size: number, style: SymbolStyle, opts: SymbolArtOptions): Graphics {
60+ const plate = new Graphics();
61+ const pad = size * 0.06;
62+ const x = -size / 2 + pad;
63+ const w = size - pad * 2;
64+ const premium = opts.tier === "premium" || opts.tier === "special";
65+ const main = hex(style.color);
66+ const accent = style.accent ? hex(style.accent) : lighten(style.color, 0.35);
67+ const line = Math.max(1, size * 0.012);
68+ switch (opts.frame) {
69+ case "glass":
70+ plate.roundRect(x, x, w, w, size * 0.2).fill({ color: 0xffffff, alpha: 0.07 });
71+ plate.roundRect(x, x, w, w, size * 0.2).stroke({ color: 0xffffff, alpha: premium ? 0.5 : 0.22, width: line });
72+ plate.roundRect(x + w * 0.08, x + w * 0.06, w * 0.84, w * 0.28, size * 0.12).fill({ color: 0xffffff, alpha: 0.06 });
73+ break;
74+ case "gold":
75+ plate.roundRect(x, x, w, w, size * 0.1).fill({ color: 0x14100a, alpha: 0.8 });
76+ plate.roundRect(x, x, w, w, size * 0.1).stroke({ color: premium ? 0xf3e2ad : 0xa88a4a, alpha: premium ? 0.9 : 0.5, width: line * 1.6 });
77+ plate.roundRect(x + line * 3, x + line * 3, w - line * 6, w - line * 6, size * 0.08).stroke({ color: 0xc9a961, alpha: 0.25, width: line });
78+ for (const [cx, cy] of [[x + size * 0.09, x + size * 0.09], [x + w - size * 0.09, x + size * 0.09], [x + size * 0.09, x + w - size * 0.09], [x + w - size * 0.09, x + w - size * 0.09]])
79+ plate.circle(cx, cy, size * 0.018).fill({ color: 0xf3e2ad, alpha: 0.8 });
80+ break;
81+ case "stone":
82+ plate.roundRect(x, x, w, w, size * 0.06).fill({ color: 0x1a1712, alpha: 0.85 });
83+ plate.roundRect(x, x, w, w, size * 0.06).stroke({ color: premium ? accent : 0x7a6f5a, alpha: premium ? 0.7 : 0.45, width: line * 1.4 });
84+ plate.moveTo(x + w * 0.15, x).lineTo(x + w * 0.22, x + w * 0.12).stroke({ color: 0x000000, alpha: 0.5, width: line });
85+ plate.moveTo(x + w, x + w * 0.7).lineTo(x + w * 0.85, x + w * 0.78).stroke({ color: 0x000000, alpha: 0.5, width: line });
86+ break;
87+ case "ice":
88+ plate.roundRect(x, x, w, w, size * 0.18).fill({ color: 0x9fdcff, alpha: 0.1 });
89+ plate.roundRect(x, x, w, w, size * 0.18).stroke({ color: 0xdff6ff, alpha: premium ? 0.8 : 0.35, width: line });
90+ plate.moveTo(x + w * 0.1, x + w * 0.9).lineTo(x + w * 0.35, x + w * 0.62).stroke({ color: 0xffffff, alpha: 0.35, width: line });
91+ plate.moveTo(x + w * 0.16, x + w * 0.9).lineTo(x + w * 0.4, x + w * 0.66).stroke({ color: 0xffffff, alpha: 0.2, width: line });
92+ break;
93+ case "carbon":
94+ plate.roundRect(x, x, w, w, size * 0.08).fill({ color: 0x0a0b0e, alpha: 0.92 });
95+ for (let i = 0; i < 6; i++) plate.moveTo(x, x + (i / 6) * w).lineTo(x + w, x + (i / 6) * w).stroke({ color: 0xffffff, alpha: 0.035, width: line });
96+ plate.roundRect(x, x, w, w, size * 0.08).stroke({ color: premium ? accent : 0x5c6270, alpha: premium ? 0.8 : 0.45, width: line * 1.3 });
97+ break;
98+ case "neon":
99+ plate.roundRect(x, x, w, w, size * 0.16).fill({ color: 0x05060c, alpha: 0.75 });
100+ plate.roundRect(x, x, w, w, size * 0.16).stroke({ color: main, alpha: premium ? 0.9 : 0.45, width: line * 1.4 });
101+ plate.roundRect(x - line * 2, x - line * 2, w + line * 4, w + line * 4, size * 0.17).stroke({ color: main, alpha: premium ? 0.25 : 0.1, width: line * 4 });
102+ break;
103+ case "obsidian":
104+ plate.roundRect(x, x, w, w, size * 0.04).fill({ color: 0x000000, alpha: 0.9 });
105+ plate.roundRect(x, x, w, w, size * 0.04).stroke({ color: premium ? 0xe8cf8f : 0xffffff, alpha: premium ? 0.7 : 0.12, width: line });
106+ break;
107+ case "metal":
108+ default:
109+ plate.roundRect(x, x, w, w, size * 0.14).fill({ color: 0x0b0d14, alpha: 0.75 });
110+ plate.roundRect(x, x, w, w, size * 0.14).stroke({ color: premium ? accent : 0x9aa3b5, alpha: premium ? 0.6 : 0.2, width: line });
111+ plate.roundRect(x + line * 2, x + line * 2, w - line * 4, w * 0.5, size * 0.1).fill({ color: 0xffffff, alpha: 0.025 });
112+ if (opts.frame === "metal") for (const [cx, cy] of [[x + size * 0.07, x + size * 0.07], [x + w - size * 0.07, x + size * 0.07], [x + size * 0.07, x + w - size * 0.07], [x + w - size * 0.07, x + w - size * 0.07]]) plate.circle(cx, cy, size * 0.014).fill({ color: 0xc8d0dc, alpha: 0.5 });
113+ break;
114+ }
115+ return plate;
54 } 116 }
55 117
56 export function drawSymbol(style: SymbolStyle, opts: SymbolArtOptions): Container { 118 export function drawSymbol(style: SymbolStyle, opts: SymbolArtOptions): Container {
@@ -62,12 +124,8 @@ export function drawSymbol(style: SymbolStyle, opts: SymbolArtOptions): Containe
62 const r = size * 0.36; // icon radius 124 const r = size * 0.36; // icon radius
63 const g = new Graphics(); 125 const g = new Graphics();
64 126
65− // Tile plate. 127+ // Tile plate (per game frame preset).
66− const plate = new Graphics(); 128+ c.addChild(drawPlate(size, style, opts));
67− const pad = size * 0.06;
68− plate.roundRect(-size / 2 + pad, -size / 2 + pad, size - pad * 2, size - pad * 2, size * 0.16).fill({ color: 0x0b0d14, alpha: 0.72 });
69− plate.roundRect(-size / 2 + pad, -size / 2 + pad, size - pad * 2, size - pad * 2, size * 0.16).stroke({ color: opts.tier === "premium" || opts.tier === "special" ? accent : 0xffffff, alpha: opts.tier === "premium" || opts.tier === "special" ? 0.45 : 0.08, width: Math.max(1, size * 0.012) });
70− c.addChild(plate);
71 129
72 // Glow halo. 130 // Glow halo.
73 if (glow > 0) { 131 if (glow > 0) {
added apps/web/src/components/lobby/symbol-svg.tsx +382 −0
@@ -0,0 +1,382 @@
1+import * as React from "react";
2+import type { SymbolStyle, SymbolTier } from "@spinza/game-core/client";
3+
4+/* ------------------------------------------------------------------------
5+ SymbolSvg — the exact same procedural symbol artwork the PixiJS renderer
6+ draws on the reels (`components/game/symbol-art.ts`), ported to SVG so
7+ lobby posters show the symbols the player will actually meet in-game.
8+ Geometry, colours, highlights and glow follow symbol-art.ts 1:1; `size` is
9+ the tile edge, the glyph is centred on (x, y).
10+ ------------------------------------------------------------------------ */
11+
12+const INK = "#0b0d14";
13+const FONT = "var(--font-geist-sans), Geist, Inter, ui-sans-serif, system-ui, sans-serif";
14+
15+function clamp8(n: number): number {
16+ return Math.max(0, Math.min(255, n));
17+}
18+
19+function parseHex(c: string): [number, number, number] {
20+ const h = c.replace("#", "");
21+ const full = h.length === 3 ? h.split("").map((ch) => ch + ch).join("") : h;
22+ const n = parseInt(full, 16);
23+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
24+}
25+
26+function toHex(r: number, g: number, b: number): string {
27+ return `#${((clamp8(r) << 16) | (clamp8(g) << 8) | clamp8(b)).toString(16).padStart(6, "0")}`;
28+}
29+
30+/** Same arithmetic as symbol-art.ts `lighten` (adds 255*amt per channel). */
31+export function lightenHex(c: string, amt: number): string {
32+ const [r, g, b] = parseHex(c);
33+ const d = Math.round(255 * amt);
34+ return toHex(r + d, g + d, b + d);
35+}
36+
37+/** Same arithmetic as symbol-art.ts `darken`. */
38+export function darkenHex(c: string, amt: number): string {
39+ const [r, g, b] = parseHex(c);
40+ const d = Math.round(255 * amt);
41+ return toHex(r - d, g - d, b - d);
42+}
43+
44+const f = (n: number) => Math.round(n * 100) / 100;
45+
46+/** Flat [x0,y0,x1,y1,…] list → SVG points string. */
47+function pts(list: number[]): string {
48+ const out: string[] = [];
49+ for (let i = 0; i < list.length; i += 2) out.push(`${f(list[i])},${f(list[i + 1])}`);
50+ return out.join(" ");
51+}
52+
53+function polygonPts(cx: number, cy: number, r: number, sides: number, rot = -Math.PI / 2): string {
54+ const list: number[] = [];
55+ for (let i = 0; i < sides; i++) {
56+ const a = rot + (i * Math.PI * 2) / sides;
57+ list.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
58+ }
59+ return pts(list);
60+}
61+
62+function starPts(cx: number, cy: number, outer: number, inner: number, points = 5): string {
63+ const list: number[] = [];
64+ for (let i = 0; i < points * 2; i++) {
65+ const r = i % 2 === 0 ? outer : inner;
66+ const a = -Math.PI / 2 + (i * Math.PI) / points;
67+ list.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
68+ }
69+ return pts(list);
70+}
71+
72+function arcPath(cx: number, cy: number, r: number, a0: number, a1: number): string {
73+ const x0 = cx + Math.cos(a0) * r;
74+ const y0 = cy + Math.sin(a0) * r;
75+ const x1 = cx + Math.cos(a1) * r;
76+ const y1 = cy + Math.sin(a1) * r;
77+ const large = Math.abs(a1 - a0) > Math.PI ? 1 : 0;
78+ const sweep = a1 > a0 ? 1 : 0;
79+ return `M${f(x0)} ${f(y0)} A${f(r)} ${f(r)} 0 ${large} ${sweep} ${f(x1)} ${f(y1)}`;
80+}
81+
82+export { pts as svgPoints, polygonPts as svgPolygon, starPts as svgStar, arcPath as svgArc };
83+
84+function Label({ text, fontSize, color, bold = true }: { text: string; fontSize: number; color: string; bold?: boolean }) {
85+ return (
86+ <text x={0} y={0} textAnchor="middle" dominantBaseline="central" fontSize={f(fontSize)} fontWeight={bold ? 800 : 600} fill={color} style={{ fontFamily: FONT, letterSpacing: 1 }}>
87+ {text}
88+ </text>
89+ );
90+}
91+
92+/* ------------------------------------------------------------------ glyph */
93+
94+function Glyph({ style, size }: { style: SymbolStyle; size: number }) {
95+ const main = style.color;
96+ const accent = style.accent ?? lightenHex(style.color, 0.35);
97+ const r = size * 0.36;
98+ const sw = (k: number) => f(size * k);
99+
100+ switch (style.shape) {
101+ case "gem":
102+ return (
103+ <>
104+ <polygon points={pts([0, -r, r * 0.85, -r * 0.25, r * 0.55, r, -r * 0.55, r, -r * 0.85, -r * 0.25])} fill={main} />
105+ <polygon points={pts([0, -r, r * 0.85, -r * 0.25, 0, -r * 0.1, -r * 0.85, -r * 0.25])} fill={accent} fillOpacity={0.85} />
106+ <polygon points={pts([0, -r * 0.1, r * 0.85, -r * 0.25, r * 0.55, r])} fill={darkenHex(main, 0.15)} />
107+ <polygon points={pts([-r * 0.2, -r * 0.7, r * 0.15, -r * 0.55, -r * 0.35, -r * 0.35])} fill="#fff" fillOpacity={0.65} />
108+ </>
109+ );
110+ case "hex":
111+ return (
112+ <>
113+ <polygon points={polygonPts(0, 0, r, 6)} fill={main} />
114+ <polygon points={polygonPts(0, 0, r * 0.68, 6)} fill={darkenHex(main, 0.25)} />
115+ <polygon points={polygonPts(0, 0, r * 0.42, 6)} fill={accent} />
116+ {style.label ? <Label text={style.label} fontSize={size * 0.26} color={INK} /> : null}
117+ </>
118+ );
119+ case "star":
120+ return (
121+ <>
122+ <polygon points={starPts(0, 0, r, r * 0.45)} fill={main} />
123+ <polygon points={starPts(0, 0, r * 0.55, r * 0.25)} fill="#fff" fillOpacity={0.85} />
124+ </>
125+ );
126+ case "circle":
127+ return (
128+ <>
129+ <circle r={f(r)} fill={main} stroke={accent} strokeWidth={sw(0.02)} />
130+ <circle cx={f(-r * 0.3)} cy={f(-r * 0.3)} r={f(r * 0.35)} fill="#fff" fillOpacity={0.35} />
131+ </>
132+ );
133+ case "diamond":
134+ return (
135+ <>
136+ <polygon points={pts([0, -r, r, 0, 0, r, -r, 0])} fill={main} />
137+ <polygon points={pts([0, -r, r, 0, 0, 0])} fill={accent} fillOpacity={0.8} />
138+ <polygon points={pts([0, 0, 0, r, -r, 0])} fill={darkenHex(main, 0.2)} />
139+ </>
140+ );
141+ case "shield":
142+ return (
143+ <>
144+ <polygon points={pts([0, -r, r * 0.85, -r * 0.65, r * 0.75, r * 0.15, 0, r, -r * 0.75, r * 0.15, -r * 0.85, -r * 0.65])} fill={main} />
145+ <polygon points={pts([0, -r * 0.7, r * 0.5, -r * 0.5, r * 0.45, r * 0.05, 0, r * 0.6, -r * 0.45, r * 0.05, -r * 0.5, -r * 0.5])} fill={darkenHex(main, 0.28)} />
146+ <path d={`M${f(-r * 0.08)} ${f(-r * 0.5)}h${f(r * 0.16)}v${f(r * 0.9)}h${f(-r * 0.16)}z M${f(-r * 0.38)} ${f(-r * 0.25)}h${f(r * 0.76)}v${f(r * 0.16)}h${f(-r * 0.76)}z`} fill={accent} />
147+ </>
148+ );
149+ case "bolt":
150+ return (
151+ <>
152+ <polygon points={pts([r * 0.15, -r, -r * 0.55, r * 0.1, -r * 0.05, r * 0.1, -r * 0.25, r, r * 0.6, -r * 0.15, r * 0.05, -r * 0.15])} fill={main} />
153+ <polygon points={pts([r * 0.1, -r * 0.8, -r * 0.3, 0, 0, 0])} fill="#fff" fillOpacity={0.5} />
154+ </>
155+ );
156+ case "ring":
157+ return (
158+ <>
159+ <circle r={f(r * 0.81)} fill="none" stroke={main} strokeWidth={f(r * 0.38)} />
160+ <circle r={f(r * 0.62)} fill="none" stroke={accent} strokeWidth={sw(0.02)} strokeOpacity={0.9} />
161+ <path d={arcPath(0, 0, r * 0.81, -Math.PI * 0.9, -Math.PI * 0.3)} fill="none" stroke="#fff" strokeWidth={sw(0.04)} strokeOpacity={0.55} />
162+ </>
163+ );
164+ case "chip": {
165+ let pins = "";
166+ for (let i = -2; i <= 2; i++) {
167+ const y = f(i * r * 0.28 - r * 0.06);
168+ pins += `M${f(-r - r * 0.18)} ${y}h${f(r * 0.18)}v${f(r * 0.12)}h${f(-r * 0.18)}z M${f(r)} ${y}h${f(r * 0.18)}v${f(r * 0.12)}h${f(-r * 0.18)}z `;
169+ }
170+ return (
171+ <>
172+ <rect x={f(-r)} y={f(-r * 0.75)} width={f(r * 2)} height={f(r * 1.5)} rx={f(r * 0.25)} fill={main} />
173+ <rect x={f(-r * 0.75)} y={f(-r * 0.5)} width={f(r * 1.5)} height={f(r)} rx={f(r * 0.15)} fill={darkenHex(main, 0.35)} />
174+ <path d={pins} fill={accent} />
175+ {style.label ? <Label text={style.label} fontSize={size * 0.22} color={accent} /> : null}
176+ </>
177+ );
178+ }
179+ case "crystal":
180+ return (
181+ <>
182+ <polygon points={pts([0, -r, r * 0.5, -r * 0.3, r * 0.35, r * 0.8, -r * 0.35, r * 0.8, -r * 0.5, -r * 0.3])} fill={main} />
183+ <polygon points={pts([0, -r, r * 0.5, -r * 0.3, 0, r * 0.1])} fill={accent} fillOpacity={0.75} />
184+ <polygon points={pts([0, r * 0.1, r * 0.35, r * 0.8, -r * 0.35, r * 0.8])} fill={darkenHex(main, 0.2)} />
185+ </>
186+ );
187+ case "letter":
188+ return (
189+ <>
190+ <rect x={f(-r * 0.95)} y={f(-r * 0.95)} width={f(r * 1.9)} height={f(r * 1.9)} rx={f(r * 0.3)} fill={darkenHex(main, 0.45)} fillOpacity={0.9} stroke={main} strokeWidth={sw(0.025)} />
191+ <Label text={style.label ?? "?"} fontSize={size * 0.4} color={main} />
192+ </>
193+ );
194+ case "vault": {
195+ let spokes = "";
196+ for (let i = 0; i < 6; i++) {
197+ const a = (i * Math.PI) / 3;
198+ spokes += `M${f(Math.cos(a) * r * 0.25)} ${f(Math.sin(a) * r * 0.25)}L${f(Math.cos(a) * r * 0.62)} ${f(Math.sin(a) * r * 0.62)} `;
199+ }
200+ return (
201+ <>
202+ <circle r={f(r)} fill={darkenHex(main, 0.4)} stroke={main} strokeWidth={sw(0.03)} />
203+ <circle r={f(r * 0.7)} fill="none" stroke={main} strokeWidth={sw(0.02)} strokeOpacity={0.7} />
204+ <path d={spokes} stroke={accent} strokeWidth={sw(0.035)} fill="none" />
205+ <circle r={f(r * 0.22)} fill={accent} />
206+ </>
207+ );
208+ }
209+ case "orb":
210+ return (
211+ <>
212+ <circle r={f(r)} fill={main} stroke={accent} strokeWidth={sw(0.02)} strokeOpacity={0.9} />
213+ <circle r={f(r * 0.75)} fill={accent} fillOpacity={0.35} />
214+ <circle cx={f(-r * 0.25)} cy={f(-r * 0.3)} r={f(r * 0.25)} fill="#fff" fillOpacity={0.5} />
215+ {style.label ? <Label text={style.label} fontSize={size * 0.36} color={accent} /> : null}
216+ </>
217+ );
218+ case "coin":
219+ return (
220+ <>
221+ <circle r={f(r)} fill={main} stroke={accent} strokeWidth={sw(0.02)} />
222+ <circle r={f(r * 0.8)} fill="none" stroke={darkenHex(main, 0.25)} strokeWidth={sw(0.03)} />
223+ <Label text={style.label ?? "SC"} fontSize={size * 0.24} color={darkenHex(main, 0.5)} />
224+ </>
225+ );
226+ case "skull":
227+ return (
228+ <>
229+ <circle cy={f(-r * 0.15)} r={f(r * 0.8)} fill={main} />
230+ <rect x={f(-r * 0.45)} y={f(r * 0.3)} width={f(r * 0.9)} height={f(r * 0.55)} rx={f(r * 0.15)} fill={main} />
231+ <circle cx={f(-r * 0.3)} cy={f(-r * 0.2)} r={f(r * 0.22)} fill={INK} />
232+ <circle cx={f(r * 0.3)} cy={f(-r * 0.2)} r={f(r * 0.22)} fill={INK} />
233+ <polygon points={pts([0, r * 0.05, r * 0.12, r * 0.3, -r * 0.12, r * 0.3])} fill={INK} />
234+ </>
235+ );
236+ case "flame":
237+ return (
238+ <>
239+ <polygon points={pts([0, -r, r * 0.55, -r * 0.2, r * 0.7, r * 0.45, r * 0.3, r, -r * 0.3, r, -r * 0.7, r * 0.45, -r * 0.55, -r * 0.2])} fill={main} />
240+ <polygon points={pts([0, -r * 0.4, r * 0.3, r * 0.1, r * 0.25, r * 0.6, -r * 0.25, r * 0.6, -r * 0.3, r * 0.1])} fill={accent} />
241+ </>
242+ );
243+ case "snow": {
244+ let axes = "";
245+ let branches = "";
246+ for (let i = 0; i < 3; i++) {
247+ const a = (i * Math.PI) / 3;
248+ axes += `M${f(Math.cos(a) * r)} ${f(Math.sin(a) * r)}L${f(-Math.cos(a) * r)} ${f(-Math.sin(a) * r)} `;
249+ for (const s of [1, -1]) {
250+ const bx = Math.cos(a) * r * 0.6 * s;
251+ const by = Math.sin(a) * r * 0.6 * s;
252+ branches += `M${f(bx)} ${f(by)}L${f(bx + Math.cos(a + 0.6) * r * 0.3 * s)} ${f(by + Math.sin(a + 0.6) * r * 0.3 * s)} `;
253+ branches += `M${f(bx)} ${f(by)}L${f(bx + Math.cos(a - 0.6) * r * 0.3 * s)} ${f(by + Math.sin(a - 0.6) * r * 0.3 * s)} `;
254+ }
255+ }
256+ return (
257+ <>
258+ <path d={axes} stroke={main} strokeWidth={sw(0.035)} fill="none" strokeLinecap="round" />
259+ <path d={branches} stroke={accent} strokeWidth={sw(0.025)} fill="none" strokeLinecap="round" />
260+ <circle r={f(r * 0.14)} fill="#fff" />
261+ </>
262+ );
263+ }
264+ case "eye":
265+ return (
266+ <>
267+ <ellipse rx={f(r)} ry={f(r * 0.6)} fill={main} />
268+ <circle r={f(r * 0.42)} fill={darkenHex(main, 0.5)} />
269+ <circle r={f(r * 0.2)} fill={accent} />
270+ <circle cx={f(r * 0.12)} cy={f(-r * 0.14)} r={f(r * 0.07)} fill="#fff" />
271+ </>
272+ );
273+ case "moon":
274+ return (
275+ <>
276+ <circle r={f(r)} fill={main} />
277+ <circle cx={f(r * 0.45)} cy={f(-r * 0.2)} r={f(r * 0.78)} fill={INK} fillOpacity={0.85} />
278+ <circle cx={f(-r * 0.4)} cy={f(r * 0.3)} r={f(r * 0.12)} fill={darkenHex(main, 0.2)} />
279+ </>
280+ );
281+ case "leaf":
282+ return (
283+ <>
284+ <path d={`M0 ${f(r)} C${f(-r * 1.1)} ${f(r * 0.2)} ${f(-r * 0.6)} ${f(-r * 0.9)} 0 ${f(-r)} C${f(r * 0.6)} ${f(-r * 0.9)} ${f(r * 1.1)} ${f(r * 0.2)} 0 ${f(r)}Z`} fill={main} />
285+ <line x1={0} y1={f(r * 0.9)} x2={0} y2={f(-r * 0.8)} stroke={accent} strokeWidth={sw(0.02)} />
286+ </>
287+ );
288+ case "atom":
289+ return (
290+ <>
291+ <circle r={f(r * 0.2)} fill={accent} />
292+ <ellipse rx={f(r)} ry={f(r * 0.38)} fill="none" stroke={main} strokeWidth={sw(0.025)} />
293+ <ellipse rx={f(r)} ry={f(r * 0.38)} fill="none" stroke={main} strokeWidth={sw(0.025)} transform="rotate(60)" />
294+ <ellipse rx={f(r)} ry={f(r * 0.38)} fill="none" stroke={main} strokeWidth={sw(0.025)} transform="rotate(120)" />
295+ </>
296+ );
297+ case "crown":
298+ return (
299+ <>
300+ <polygon points={pts([-r, r * 0.6, -r, -r * 0.4, -r * 0.5, r * 0.05, 0, -r, r * 0.5, r * 0.05, r, -r * 0.4, r, r * 0.6])} fill={main} />
301+ <rect x={f(-r)} y={f(r * 0.4)} width={f(r * 2)} height={f(r * 0.25)} fill={darkenHex(main, 0.3)} />
302+ <circle cy={f(-r * 0.95)} r={f(r * 0.12)} fill={accent} />
303+ <circle cx={f(-r)} cy={f(-r * 0.4)} r={f(r * 0.1)} fill={accent} />
304+ <circle cx={f(r)} cy={f(-r * 0.4)} r={f(r * 0.1)} fill={accent} />
305+ </>
306+ );
307+ case "wave": {
308+ const wave = (y: number) => `M${f(-r)} ${f(y)} C${f(-r * 0.5)} ${f(y - r * 0.4)} ${f(-r * 0.2)} ${f(y + r * 0.4)} 0 ${f(y)} C${f(r * 0.2)} ${f(y - r * 0.4)} ${f(r * 0.5)} ${f(y + r * 0.4)} ${f(r)} ${f(y)}`;
309+ return (
310+ <>
311+ <path d={`${wave(-r * 0.5)} ${wave(r * 0.5)}`} fill="none" stroke={main} strokeWidth={sw(0.04)} strokeOpacity={0.8} strokeLinecap="round" />
312+ <path d={wave(0)} fill="none" stroke={accent} strokeWidth={sw(0.04)} strokeLinecap="round" />
313+ </>
314+ );
315+ }
316+ case "cube":
317+ return (
318+ <>
319+ <polygon points={pts([0, -r, r * 0.87, -r * 0.5, r * 0.87, r * 0.5, 0, r, -r * 0.87, r * 0.5, -r * 0.87, -r * 0.5])} fill={main} />
320+ <polygon points={pts([0, -r, r * 0.87, -r * 0.5, 0, 0, -r * 0.87, -r * 0.5])} fill={accent} />
321+ <polygon points={pts([0, 0, r * 0.87, -r * 0.5, r * 0.87, r * 0.5, 0, r])} fill={darkenHex(main, 0.3)} />
322+ {style.label ? <Label text={style.label} fontSize={size * 0.3} color="#fff" /> : null}
323+ </>
324+ );
325+ default:
326+ return <circle r={f(r)} fill={main} />;
327+ }
328+}
329+
330+/* -------------------------------------------------------------- SymbolSvg */
331+
332+export interface SymbolSvgProps {
333+ style: SymbolStyle;
334+ /** Tile edge in user units; the glyph radius is 0.36 × size (as on the reels). */
335+ size: number;
336+ tier?: SymbolTier;
337+ /** Centre position inside the parent SVG (default 0,0). */
338+ x?: number;
339+ y?: number;
340+ rotate?: number;
341+ opacity?: number;
342+ /** Dark reel tile behind the glyph (default true, matches the in-game plate). */
343+ plate?: boolean;
344+ /** Coloured halo behind the glyph (default true, driven by `style.glow`). */
345+ halo?: boolean;
346+ className?: string;
347+}
348+
349+/**
350+ * Renders a reel symbol as an SVG `<g>` centred on (x, y). Drop it inside any
351+ * `<svg>`; use `SymbolIcon` for a standalone image.
352+ */
353+export function SymbolSvg({ style, size, tier = "mid", x = 0, y = 0, rotate = 0, opacity, plate = true, halo = true, className }: SymbolSvgProps) {
354+ const main = style.color;
355+ const accent = style.accent ?? lightenHex(style.color, 0.35);
356+ const glow = style.glow ?? 0.3;
357+ const r = size * 0.36;
358+ const pad = size * 0.06;
359+ const rich = tier === "premium" || tier === "special";
360+ const transform = `translate(${f(x)} ${f(y)})${rotate ? ` rotate(${f(rotate)})` : ""}`;
361+ return (
362+ <g transform={transform} opacity={opacity} className={className}>
363+ {plate ? <rect x={f(-size / 2 + pad)} y={f(-size / 2 + pad)} width={f(size - pad * 2)} height={f(size - pad * 2)} rx={f(size * 0.16)} fill={INK} fillOpacity={0.72} stroke={rich ? accent : "#fff"} strokeOpacity={rich ? 0.45 : 0.08} strokeWidth={f(Math.max(1, size * 0.012))} /> : null}
364+ {halo && glow > 0 ? (
365+ <>
366+ <circle r={f(r * 1.25)} fill={main} fillOpacity={f(0.18 * glow)} />
367+ <circle r={f(r)} fill={main} fillOpacity={f(0.16 * glow)} />
368+ </>
369+ ) : null}
370+ <Glyph style={style} size={size} />
371+ </g>
372+ );
373+}
374+
375+/** Standalone `<svg>` wrapper around `SymbolSvg` (paytable rows, tooltips…). */
376+export function SymbolIcon({ style, tier, size = 64, className, label }: { style: SymbolStyle; tier?: SymbolTier; size?: number; className?: string; label?: string }) {
377+ return (
378+ <svg viewBox={`${-size / 2} ${-size / 2} ${size} ${size}`} width={size} height={size} className={className} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
379+ <SymbolSvg style={style} tier={tier} size={size} />
380+ </svg>
381+ );
382+}
modified packages/shared/src/types.ts +3 −1
@@ -46,7 +46,9 @@ export interface GameCard {
46 maxMultiplier: number; 46 maxMultiplier: number;
47 features: string[]; 47 features: string[];
48 tags: string[]; 48 tags: string[];
49− palette: { primary: string; secondary: string; glow: string; bg: string }; 49+ palette: { primary: string; secondary: string; glow: string; bg: string; surface?: string };
50+ /** Renderer presentation hints (frame, backdrop, particles, ambience). */
51+ presentation?: { frame: string; backdrop: string; particles: string; ambience: string };
50 lifecycle: GameLifecycle; 52 lifecycle: GameLifecycle;
51 isNew: boolean; 53 isNew: boolean;
52 isFeatured: boolean; 54 isFeatured: boolean;
53 55
Menu

Theme