SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%

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 16 days ago (Sep 8, 2026) parent 0b40ac1

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 28 features: (s.features as string[]) ?? [],
29 29 tags: (s.tags as string[]) ?? [],
30 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 34 lifecycle: g.lifecycle as GameCard["lifecycle"],
32 35 isNew: g.isNew,
33 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 340 const betIndex = bets.indexOf(bet);
341 341 const palette = definition.presentation.palette;
342 + const frameStyle = FRAME_HUD[definition.presentation.frame] ?? FRAME_HUD.metal;
342 343 const meter = definition.meter && state ? { value: state.meters[definition.meter.id] ?? 0, max: definition.meter.max } : null;
343 344 const heat = definition.heat && state ? { value: state.heat, max: definition.heat.max } : null;
344 345 const meterLevel = definition.meter && state ? state.meterLevels[definition.meter.id] ?? 0 : 0;
@@ -470,7 +471,7 @@ export function GameClient({ game, definition }: Props) {
470 471 </div>
471 472
472 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 475 <div className="mx-auto grid max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-3">
475 476 {/* Balance + bet */}
476 477 <div className="flex flex-col gap-2">
@@ -504,7 +505,7 @@ export function GameClient({ game, definition }: Props) {
504 505 onClick={() => void spin()}
505 506 disabled={!ready || spinning}
506 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 509 aria-label="Spin"
509 510 >
510 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 594 function errorTitle(code: string): string {
567 595 return (
568 596 {
modified apps/web/src/components/game/renderer.ts +548 −85
@@ -3,7 +3,7 @@
3 3 import { Application, Container, Graphics, Rectangle, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js";
4 4 import type { SpinStep } from "@spinza/game-core/client";
5 5 import type { ClientDefinition } from "./types";
6 import { drawSymbol, hexColor, lighten } from "./symbol-art";
6 +import { drawSymbol, hexColor } from "./symbol-art";
7 7 import { formatSC } from "@spinza/shared";
8 8
9 9 /* --------------------------------------------------------------- helpers */
@@ -152,27 +152,109 @@ export class SlotRenderer {
152 152 const p = this.opts.def.presentation;
153 153 const g = this.frame;
154 154 g.clear();
155 const pad = this.gap * 1.6;
155 + const pad = this.gap * 1.8;
156 156 const x = this.originX - pad;
157 157 const y = this.originY - pad;
158 158 const w = totalW + pad * 2;
159 159 const h = totalH + pad * 2;
160 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 };
162 const fc = frameColors[p.frame] ?? primary;
163 g.roundRect(x, y, w, h, 18).fill({ color: 0x000000, alpha: 0.38 });
164 g.roundRect(x, y, w, h, 18).stroke({ color: fc, alpha: p.frame === "neon" ? 0.85 : 0.35, width: 2 });
165 g.roundRect(x + 3, y + 3, w - 6, h - 6, 15).stroke({ color: 0xffffff, alpha: 0.06, width: 1 });
166 if (p.frame === "neon") g.roundRect(x - 3, y - 3, w + 6, h + 6, 21).stroke({ color: primary, alpha: 0.25, width: 6 });
161 + const secondary = hexColor(p.palette.secondary);
162 + const rows = this.rows;
163 + const cols = this.cols;
164 + switch (p.frame) {
165 + case "gold": {
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 248 // Cell wells.
168 for (let r = 0; r < this.cols; r++)
169 for (let yy = 0; yy < this.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 });
249 + for (let r = 0; r < cols; r++)
250 + for (let yy = 0; yy < rows; yy++) {
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 255 /* ------------------------------------------------------------- backdrop */
175 256
257 + /** Draw the game's world behind the reels: a static scene + a set of animated layers (see animateBackdrop). */
176 258 private drawBackdrop() {
177 259 const p = this.opts.def.presentation;
178 260 const b = this.backdrop;
@@ -183,115 +265,365 @@ export class SlotRenderer {
183 265 const primary = hexColor(p.palette.primary);
184 266 const secondary = hexColor(p.palette.secondary);
185 267 const bg = hexColor(p.palette.bg);
186 g.rect(0, 0, W, H).fill(bg);
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 });
268 + const surface = hexColor(p.palette.surface);
190 269 const seed = hashString(this.opts.def.slug);
191 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 285 switch (p.backdrop) {
193 case "grid":
194 case "circuit":
195 286 case "vault": {
196 const step = Math.max(28, W / 22);
197 for (let x = 0; x <= W; x += step) g.moveTo(x, 0).lineTo(x, H).stroke({ color: primary, alpha: 0.06, width: 1 });
198 for (let y = 0; y <= H; y += step) g.moveTo(0, y).lineTo(W, y).stroke({ color: primary, alpha: 0.06, width: 1 });
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 });
200 if (p.backdrop === "circuit")
201 for (let i = 0; i < 12; i++) {
287 + // Rotating security rings + scanning grid.
288 + const grid = layer((gg) => {
289 + const step = Math.max(30, W / 20);
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 });
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 });
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 313 const x = rnd() * W;
203 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 });
205 g.circle(x, y, 3).fill({ color: secondary, alpha: 0.35 });
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 });
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 347 break;
208 348 }
209 case "nebula":
210 349 case "universe":
211 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 });
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 });
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 });
351 + const stars = layer((gg) => {
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 });
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 375 break;
216 376 }
217 case "pillars":
218 case "temple": {
377 + case "pillars": {
219 378 const n = 6;
220 379 for (let i = 0; i < n; i++) {
221 380 const x = (W / (n + 1)) * (i + 1);
222 g.rect(x - 18, 0, 36, H).fill({ color: primary, alpha: 0.05 });
223 g.rect(x - 26, H * 0.08, 52, 14).fill({ color: primary, alpha: 0.1 });
381 + const col = layer((gg) => {
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 397 break;
226 398 }
227 399 case "aurora": {
228 for (let k = 0; k < 4; k++) {
229 g.moveTo(0, H * (0.2 + k * 0.12));
230 for (let x = 0; x <= W; x += 40) g.lineTo(x, H * (0.2 + k * 0.12) + Math.sin(x / 90 + k) * 30);
231 g.stroke({ color: k % 2 ? primary : secondary, alpha: 0.1, width: 26 });
400 + for (let k = 0; k < 5; k++) {
401 + const band = layer((gg) => {
402 + gg.moveTo(-60, H * (0.14 + k * 0.1));
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 416 break;
234 417 }
235 case "lava":
236 case "core":
237 case "reactor": {
238 for (let i = 0; i < 14; i++) {
239 const x = rnd() * W;
240 const y = rnd() * H;
241 g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 220, y + (rnd() - 0.5) * 220).stroke({ color: primary, alpha: 0.18, width: 2 });
242 }
243 g.circle(W / 2, H / 2, Math.min(W, H) * 0.42).stroke({ color: secondary, alpha: 0.12, width: 10 });
418 + case "lava": {
419 + const cracks = layer((gg) => {
420 + for (let i = 0; i < 14; i++) {
421 + let x = rnd() * W;
422 + let y = H * 0.55 + rnd() * H * 0.45;
423 + gg.moveTo(x, y);
424 + for (let k = 0; k < 5; k++) {
425 + x += (rnd() - 0.5) * 120;
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 438 break;
245 439 }
246 440 case "skyline": {
247 for (let x = 0; x < W; x += 30 + rnd() * 40) {
248 const h = 60 + rnd() * H * 0.45;
249 g.rect(x, H - h, 24 + rnd() * 40, h).fill({ color: 0x000000, alpha: 0.45 });
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 });
441 + const buildings = layer((gg) => {
442 + for (let x = 0; x < W; x += 34 + rnd() * 40) {
443 + const h = 80 + rnd() * H * 0.5;
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 460 break;
253 461 }
254 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 });
256 g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.5, H]).fill({ color: 0x000000, alpha: 0.15 });
463 + const pyr = layer((gg) => {
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 478 break;
258 479 }
259 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 492 break;
262 493 }
263 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 });
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 });
495 + const stars = layer((gg) => {
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 505 break;
267 506 }
268 507 case "track": {
269 for (let k = 0; k < 5; k++) {
270 g.moveTo(-50, H * 0.9 - k * 40);
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);
272 g.stroke({ color: k % 2 ? primary : 0xffffff, alpha: 0.06, width: 12 });
508 + const road = layer((gg) => {
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 });
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 });
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 546 break;
275 547 }
276 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 });
278 g.rect(0, H * 0.6, W, H * 0.4).fill({ color: 0x000000, alpha: 0.35 });
549 + for (let i = 0; i < 4; i++) {
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 563 break;
280 564 }
281 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 });
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 });
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 });
566 + const stars = layer((gg) => {
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 });
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 609 break;
286 610 }
287 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 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 627 private initAmbient() {
296 628 if (!this.app || this.opts.reduceMotion || this.opts.intensity === "low") return;
297 629 const p = this.opts.def.presentation;
@@ -349,7 +681,7 @@ export class SlotRenderer {
349 681 private buildTextures(renderer: Renderer) {
350 682 const size = 176;
351 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 685 const tex = renderer.generateTexture({ target: art, resolution: 2, frame: new Rectangle(-size / 2, -size / 2, size, size) });
354 686 this.textures.set(s.id, tex);
355 687 art.destroy({ children: true });
@@ -447,9 +779,12 @@ export class SlotRenderer {
447 779 /** Start reels spinning (called when the request is sent). */
448 780 startSpin() {
449 781 this.clearFx();
782 + this.reelSpinning = [];
450 783 this.spinning = true;
451 784 this.spinVel = 0;
785 + this.spinStartedAt = performance.now();
452 786 }
787 + private spinStartedAt = 0;
453 788
454 789 /** Land the given grid reel by reel. Resolves when the last reel stops. */
455 790 async landGrid(grid: string[][], opts: { minSpinMs?: number } = {}): Promise<void> {
@@ -459,15 +794,25 @@ export class SlotRenderer {
459 794 await this.dropIn(grid);
460 795 return;
461 796 }
462 const minSpin = (opts.minSpinMs ?? 500) * this.speed;
463 if (!this.spinning) {
464 this.startSpin();
465 }
466 await wait(minSpin);
467 const stagger = (this.opts.quick ? 70 : 130) * this.speed;
797 + const minSpin = (opts.minSpinMs ?? (this.opts.quick ? 380 : 780)) * this.speed;
798 + if (!this.spinning) this.startSpin();
799 + // Make sure the reels have visibly spun for at least `minSpin` since the spin started.
800 + const elapsed = performance.now() - this.spinStartedAt;
801 + if (elapsed < minSpin) await wait(minSpin - elapsed);
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 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 813 await this.stopReel(r, grid[r]);
470 814 this.opts.onReelStop?.(r);
815 + if (scatterId) scattersSoFar += grid[r].filter((id) => id === scatterId).length;
471 816 if (r < this.cols - 1) await wait(stagger);
472 817 }
473 818 this.spinning = false;
@@ -504,29 +849,126 @@ export class SlotRenderer {
504 849 this.particles.splice(i, 1);
505 850 }
506 851 }
852 + // Animated backdrop layers (per game world).
853 + this.animateBackdrop(dt);
507 854 // Reel spin: scroll symbols downward, wrapping with placeholder art.
508 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 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 863 for (let r = 0; r < this.cols; r++) {
512 864 if (this.reelSpinning[r] === false) continue;
513 865 this.reelSpinning[r] = true;
514 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 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 871 if (cell.sprite.y - this.cellSize / 2 > bottom) {
519 cell.sprite.y -= this.rows * (this.cellSize + this.gap);
520 const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only
872 + cell.sprite.y -= span;
873 + const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only, never an outcome
521 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 972 private swapTexture(cell: Cell, id: string) {
531 973 const sp = cell.sprite.children[0] as Sprite;
532 974 sp.texture = this.textures.get(id) ?? this.textures.get("__")!;
@@ -537,20 +979,32 @@ export class SlotRenderer {
537 979 private async stopReel(r: number, ids: string[]) {
538 980 this.reelSpinning[r] = false;
539 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 983 col.forEach((cell, y) => {
542 984 this.swapTexture(cell, ids[y]);
543 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 994 await this.animate(dur, (t) => {
548 995 const e = easeOutBack(t);
996 + flash.alpha = 1 - t;
549 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 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 1341 function polygonRandom(g: Graphics, cx: number, cy: number, r: number, rnd: () => number): Graphics {
879 1342 const pts: number[] = [];
880 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 51 /** Tier drives frame richness. */
52 52 tier: "low" | "mid" | "high" | "premium" | "special";
53 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 118 export function drawSymbol(style: SymbolStyle, opts: SymbolArtOptions): Container {
@@ -62,12 +124,8 @@ export function drawSymbol(style: SymbolStyle, opts: SymbolArtOptions): Containe
62 124 const r = size * 0.36; // icon radius
63 125 const g = new Graphics();
64 126
65 // Tile plate.
66 const plate = new Graphics();
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);
127 + // Tile plate (per game frame preset).
128 + c.addChild(drawPlate(size, style, opts));
71 129
72 130 // Glow halo.
73 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 46 maxMultiplier: number;
47 47 features: string[];
48 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 52 lifecycle: GameLifecycle;
51 53 isNew: boolean;
52 54 isFeatured: boolean;
53 55