"use client"; import { Application, Container, Graphics, Rectangle, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js"; import type { SpinStep } from "@spinza/game-core/client"; import type { ClientDefinition } from "./types"; import { drawSymbol, hexColor } from "./symbol-art"; import { formatSC } from "@spinza/shared"; /* --------------------------------------------------------------- helpers */ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); const easeOutBack = (t: number) => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }; const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2); export interface RendererOptions { def: ClientDefinition; reduceMotion: boolean; intensity: "low" | "medium" | "high"; quick: boolean; onReelStop?: (index: number) => void; onCascade?: () => void; onWinHighlight?: (amount: number) => void; onCoin?: () => void; } interface Cell { sprite: Container; id: string; } /** * Spinza slot renderer (PixiJS v8). One renderer serves all 20 games: it * reads the client definition for grid, symbols and presentation and plays * back server-resolved steps. It never decides outcomes. */ export class SlotRenderer { private app: Application | null = null; private root = new Container(); private backdrop = new Container(); private ambient = new Container(); private reelLayer = new Container(); private fxLayer = new Container(); private overlayLayer = new Container(); private textures = new Map(); private cells: Cell[][] = []; // [reel][row] private reelContainers: Container[] = []; private masks: Graphics[] = []; private cols = 5; private rows = 3; private cellSize = 100; private gap = 8; private originX = 0; private originY = 0; private frame = new Graphics(); private particles: { s: Sprite; vx: number; vy: number; life: number; max: number }[] = []; private ambientParticles: { s: Sprite; vx: number; vy: number; phase: number }[] = []; private destroyed = false; private currentGrid: string[][] = []; private multiplicity: number[][] | undefined; private badges = new Container(); private width = 0; private height = 0; private opts: RendererOptions; constructor(opts: RendererOptions) { this.opts = opts; this.cols = opts.def.grid.reels; this.rows = opts.def.grid.rows; } get speed(): number { return this.opts.quick ? 0.55 : this.opts.reduceMotion ? 0.7 : 1; } setOptions(o: Partial) { this.opts = { ...this.opts, ...o }; } async mount(el: HTMLElement): Promise { const app = new Application(); await app.init({ resizeTo: el, backgroundAlpha: 0, antialias: true, autoDensity: true, resolution: Math.min(2, window.devicePixelRatio || 1), preference: "webgl", }); if (this.destroyed) { app.destroy(true); return; } this.app = app; el.appendChild(app.canvas); this.root.addChild(this.backdrop, this.ambient, this.frame, this.reelLayer, this.fxLayer, this.badges, this.overlayLayer); app.stage.addChild(this.root); this.buildTextures(app.renderer); this.layout(); this.drawBackdrop(); this.initAmbient(); this.buildReels(this.randomGrid()); app.renderer.on("resize", () => this.onResize()); app.ticker.add((t) => this.tick(t.deltaMS)); } destroy() { this.destroyed = true; if (this.app) { this.app.destroy(true, { children: true, texture: true }); this.app = null; } } /* ---------------------------------------------------------------- layout */ private onResize() { this.layout(); this.drawBackdrop(); this.repositionCells(); } private layout() { if (!this.app) return; this.width = this.app.screen.width; this.height = this.app.screen.height; const padX = Math.min(24, this.width * 0.04); const padY = Math.min(24, this.height * 0.05); const availW = this.width - padX * 2; const availH = this.height - padY * 2; this.gap = Math.max(4, Math.min(10, availW / this.cols / 14)); this.cellSize = Math.floor(Math.min((availW - this.gap * (this.cols - 1)) / this.cols, (availH - this.gap * (this.rows - 1)) / this.rows)); const totalW = this.cellSize * this.cols + this.gap * (this.cols - 1); const totalH = this.cellSize * this.rows + this.gap * (this.rows - 1); this.originX = (this.width - totalW) / 2; this.originY = (this.height - totalH) / 2; this.drawFrame(totalW, totalH); } private cellX(reel: number) { return this.originX + reel * (this.cellSize + this.gap) + this.cellSize / 2; } private cellY(row: number) { return this.originY + row * (this.cellSize + this.gap) + this.cellSize / 2; } private drawFrame(totalW: number, totalH: number) { const p = this.opts.def.presentation; const g = this.frame; g.clear(); const pad = this.gap * 1.8; const x = this.originX - pad; const y = this.originY - pad; const w = totalW + pad * 2; const h = totalH + pad * 2; const primary = hexColor(p.palette.primary); const secondary = hexColor(p.palette.secondary); const rows = this.rows; const cols = this.cols; switch (p.frame) { case "gold": { g.roundRect(x, y, w, h, 10).fill({ color: 0x120e07, alpha: 0.7 }); g.roundRect(x, y, w, h, 10).stroke({ color: 0xe0c070, alpha: 0.8, width: 3 }); g.roundRect(x + 6, y + 6, w - 12, h - 12, 7).stroke({ color: 0x8a6d2e, alpha: 0.7, width: 1.5 }); for (const [cx, cy] of [[x, y], [x + w, y], [x, y + h], [x + w, y + h]]) { g.circle(cx, cy, 9).fill({ color: 0x1a1408 }).circle(cx, cy, 9).stroke({ color: 0xf3e2ad, width: 2 }); g.circle(cx, cy, 3).fill(0xf3e2ad); } // Top ornament. g.moveTo(x + w / 2 - 40, y).lineTo(x + w / 2, y - 10).lineTo(x + w / 2 + 40, y).stroke({ color: 0xe0c070, width: 2 }); break; } case "stone": { g.roundRect(x, y, w, h, 6).fill({ color: 0x14120d, alpha: 0.8 }); g.roundRect(x, y, w, h, 6).stroke({ color: 0x8a8578, alpha: 0.6, width: 5 }); // Stone blocks along the border. const bw = 34; for (let i = 0; i < Math.floor(w / bw); i++) { g.moveTo(x + i * bw, y).lineTo(x + i * bw, y - 6).stroke({ color: 0x000000, alpha: 0.5, width: 2 }); g.moveTo(x + i * bw, y + h).lineTo(x + i * bw, y + h + 6).stroke({ color: 0x000000, alpha: 0.5, width: 2 }); } g.moveTo(x + 8, y + 12).lineTo(x + 22, y + 30).stroke({ color: 0x000000, alpha: 0.6, width: 2 }); g.moveTo(x + w - 10, y + h - 40).lineTo(x + w - 26, y + h - 18).stroke({ color: 0x000000, alpha: 0.6, width: 2 }); break; } case "ice": { g.roundRect(x, y, w, h, 22).fill({ color: 0x9fdcff, alpha: 0.07 }); g.roundRect(x, y, w, h, 22).stroke({ color: 0xdff6ff, alpha: 0.55, width: 2 }); g.roundRect(x - 4, y - 4, w + 8, h + 8, 26).stroke({ color: 0xbfe9ff, alpha: 0.18, width: 6 }); // Frost shards on corners. for (const [cx, cy, d] of [[x, y, 1], [x + w, y, -1]]) { g.moveTo(cx, cy + 30).lineTo(cx + 18 * d, cy + 4).lineTo(cx + 40 * d, cy).stroke({ color: 0xffffff, alpha: 0.5, width: 2 }); g.moveTo(cx + 8 * d, cy + 44).lineTo(cx + 26 * d, cy + 14).stroke({ color: 0xffffff, alpha: 0.3, width: 1.5 }); } break; } case "carbon": { g.roundRect(x, y, w, h, 12).fill({ color: 0x07080b, alpha: 0.85 }); 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 }); g.roundRect(x, y, w, h, 12).stroke({ color: 0x5c6270, alpha: 0.8, width: 2 }); g.roundRect(x, y, w, h, 12).stroke({ color: primary, alpha: 0.35, width: 1 }); // Hex bolts. 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 }); break; } case "neon": { g.roundRect(x, y, w, h, 18).fill({ color: 0x03040a, alpha: 0.55 }); g.roundRect(x - 6, y - 6, w + 12, h + 12, 24).stroke({ color: primary, alpha: 0.18, width: 12 }); g.roundRect(x - 2, y - 2, w + 4, h + 4, 20).stroke({ color: primary, alpha: 0.5, width: 5 }); g.roundRect(x, y, w, h, 18).stroke({ color: 0xffffff, alpha: 0.9, width: 1.5 }); // Secondary neon underline. g.moveTo(x + 24, y + h + 12).lineTo(x + w - 24, y + h + 12).stroke({ color: secondary, alpha: 0.7, width: 3 }); break; } case "glass": { g.roundRect(x, y, w, h, 20).fill({ color: 0xffffff, alpha: 0.04 }); g.roundRect(x, y, w, h, 20).stroke({ color: 0xffffff, alpha: 0.35, width: 1.5 }); g.roundRect(x + 10, y + 8, w - 20, h * 0.18, 14).fill({ color: 0xffffff, alpha: 0.04 }); g.moveTo(x + 30, y + 4).lineTo(x + w * 0.4, y + 4).stroke({ color: 0xffffff, alpha: 0.5, width: 2 }); break; } case "obsidian": { g.roundRect(x, y, w, h, 4).fill({ color: 0x000000, alpha: 0.7 }); g.roundRect(x, y, w, h, 4).stroke({ color: 0xffffff, alpha: 0.14, width: 1 }); g.moveTo(x + w * 0.2, y - 10).lineTo(x + w * 0.8, y - 10).stroke({ color: 0xe8cf8f, alpha: 0.6, width: 1 }); 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 }); break; } case "metal": default: { g.roundRect(x, y, w, h, 14).fill({ color: 0x0b0e15, alpha: 0.75 }); g.roundRect(x, y, w, h, 14).stroke({ color: 0x9aa3b5, alpha: 0.5, width: 3 }); g.roundRect(x + 4, y + 4, w - 8, h - 8, 11).stroke({ color: 0xffffff, alpha: 0.08, width: 1 }); g.roundRect(x, y, w, h * 0.35, 14).fill({ color: 0xffffff, alpha: 0.03 }); // Rivets. for (let i = 0; i <= cols; i++) { const rx = x + (w / cols) * i; g.circle(Math.min(Math.max(rx, x + 10), x + w - 10), y + 8, 3).fill({ color: 0xc8d0dc, alpha: 0.55 }); g.circle(Math.min(Math.max(rx, x + 10), x + w - 10), y + h - 8, 3).fill({ color: 0xc8d0dc, alpha: 0.55 }); } break; } } // Cell wells. for (let r = 0; r < cols; r++) for (let yy = 0; yy < rows; yy++) { 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 }); } } /* ------------------------------------------------------------- backdrop */ /** Draw the game's world behind the reels: a static scene + a set of animated layers (see animateBackdrop). */ private drawBackdrop() { const p = this.opts.def.presentation; const b = this.backdrop; b.removeChildren(); const g = new Graphics(); const W = this.width; const H = this.height; const primary = hexColor(p.palette.primary); const secondary = hexColor(p.palette.secondary); const bg = hexColor(p.palette.bg); const surface = hexColor(p.palette.surface); const seed = hashString(this.opts.def.slug); const rnd = mulberry32(seed); const items: { g: Container; a: number; b: number; c: number }[] = []; const layer = (fn: (gg: Graphics) => void): Graphics => { const gg = new Graphics(); fn(gg); b.addChild(gg); return gg; }; // Base: vertical gradient bg → surface → bg. g.rect(0, 0, W, H).fill(bg); g.rect(0, 0, W, H * 0.55).fill({ color: surface, alpha: 0.55 }); g.ellipse(W * 0.5, H * 0.35, W * 0.7, H * 0.5).fill({ color: primary, alpha: 0.07 }); g.ellipse(W * 0.8, H * 0.95, W * 0.55, H * 0.35).fill({ color: secondary, alpha: 0.07 }); b.addChild(g); switch (p.backdrop) { case "vault": { // Rotating security rings + scanning grid. const grid = layer((gg) => { const step = Math.max(30, W / 20); for (let x = 0; x <= W; x += step) gg.moveTo(x, -H).lineTo(x, H * 2).stroke({ color: primary, alpha: 0.07, width: 1 }); for (let y = -H; y <= H * 2; y += step) gg.moveTo(0, y).lineTo(W, y).stroke({ color: primary, alpha: 0.07, width: 1 }); }); void grid; for (let i = 1; i <= 3; i++) { const ring = layer((gg) => { const r = Math.min(W, H) * 0.22 * i; gg.circle(0, 0, r).stroke({ color: primary, alpha: 0.1, width: 2 }); for (let k = 0; k < 8; k++) { const a = (k / 8) * Math.PI * 2; 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 }); } }); ring.position.set(W / 2, H / 2); items.push({ g: ring, a: (i % 2 ? 1 : -1) * 0.12 / i, b: 0, c: 0 }); } this.anim = { kind: "vault", items, t: 0 }; break; } case "grid": case "circuit": { const traces = layer((gg) => { for (let i = 0; i < 18; i++) { const x = rnd() * W; const y = rnd() * H; 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 }); gg.circle(x, y, 3).fill({ color: secondary, alpha: 0.5 }); } }); void traces; for (let i = 0; i < 3; i++) { const pulse = layer((gg) => gg.rect(0, 0, W, 3).fill({ color: secondary, alpha: 0.35 })); pulse.y = (H / 3) * i; items.push({ g: pulse, a: 40 + i * 25, b: i, c: 0 }); } this.anim = { kind: "grid", items, t: 0 }; break; } case "nebula": { for (let i = 0; i < 6; i++) { 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 })); cloud.position.set(rnd() * W, rnd() * H); items.push({ g: cloud, a: (rnd() - 0.5) * 12, b: (rnd() - 0.5) * 6, c: 0 }); } const stars = layer((gg) => { 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 }); }); void stars; // Black hole. const hole = layer((gg) => { gg.circle(0, 0, Math.min(W, H) * 0.09).fill(0x000000); gg.circle(0, 0, Math.min(W, H) * 0.11).stroke({ color: primary, alpha: 0.7, width: 4 }); gg.ellipse(0, 0, Math.min(W, H) * 0.22, Math.min(W, H) * 0.05).stroke({ color: secondary, alpha: 0.35, width: 2 }); }); hole.position.set(W * 0.78, H * 0.22); items.push({ g: hole, a: 0, b: 0, c: 0.6 }); this.anim = { kind: "nebula", items, t: 0 }; break; } case "universe": case "gravity": { const stars = layer((gg) => { 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 }); }); void stars; for (let i = 1; i <= 3; i++) { const orbit = layer((gg) => { gg.ellipse(0, 0, W * 0.24 * i, H * 0.16 * i).stroke({ color: i % 2 ? primary : secondary, alpha: 0.14, width: 1.5 }); gg.circle(W * 0.24 * i, 0, 5).fill({ color: i % 2 ? secondary : primary, alpha: 0.9 }); }); orbit.position.set(W / 2, H / 2); orbit.rotation = rnd() * Math.PI; items.push({ g: orbit, a: (i % 2 ? 0.25 : -0.18) / i, b: 0, c: 0 }); } if (p.backdrop === "universe") { const burst = layer((gg) => { for (let k = 0; k < 24; k++) { const a = (k / 24) * Math.PI * 2; 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 }); } }); burst.position.set(W / 2, H / 2); items.push({ g: burst, a: 0.05, b: 0, c: 0 }); } this.anim = { kind: "gravity", items, t: 0 }; break; } case "pillars": { const n = 6; for (let i = 0; i < n; i++) { const x = (W / (n + 1)) * (i + 1); const col = layer((gg) => { gg.rect(-20, 0, 40, H).fill({ color: primary, alpha: 0.06 }); gg.rect(-30, H * 0.06, 60, 16).fill({ color: primary, alpha: 0.14 }); gg.rect(-30, H * 0.92, 60, 16).fill({ color: primary, alpha: 0.14 }); gg.rect(-12, 0, 6, H).fill({ color: 0xffffff, alpha: 0.03 }); }); col.position.set(x, 0); items.push({ g: col, a: 0.8 + rnd(), b: rnd() * 6, c: 1 }); } // Lantern glows. for (let i = 0; i < 4; i++) { const lamp = layer((gg) => gg.circle(0, 0, 40).fill({ color: 0xffb347, alpha: 0.12 })); lamp.position.set(W * (0.15 + 0.23 * i), H * 0.25); items.push({ g: lamp, a: 2 + rnd() * 2, b: rnd() * 6, c: 1 }); } this.anim = { kind: "pillars", items, t: 0 }; break; } case "aurora": { for (let k = 0; k < 5; k++) { const band = layer((gg) => { gg.moveTo(-60, H * (0.14 + k * 0.1)); for (let x = -60; x <= W + 60; x += 30) gg.lineTo(x, H * (0.14 + k * 0.1) + Math.sin(x / 110 + k) * 34); gg.stroke({ color: k % 2 ? primary : secondary, alpha: 0.13, width: 34 }); }); items.push({ g: band, a: 0.4 + k * 0.12, b: k, c: 0 }); } // Mountains. const mountains = layer((gg) => { gg.moveTo(0, H); for (let x = 0; x <= W; x += 60) gg.lineTo(x, H * 0.78 - Math.abs(Math.sin(x / 140)) * H * 0.18); gg.lineTo(W, H).fill({ color: 0x06090f, alpha: 0.9 }); }); void mountains; this.anim = { kind: "aurora", items, t: 0 }; break; } case "lava": { const cracks = layer((gg) => { for (let i = 0; i < 14; i++) { let x = rnd() * W; let y = H * 0.55 + rnd() * H * 0.45; gg.moveTo(x, y); for (let k = 0; k < 5; k++) { x += (rnd() - 0.5) * 120; y += (rnd() - 0.5) * 80; gg.lineTo(x, y); } gg.stroke({ color: primary, alpha: 0.35, width: 3 }); } }); items.push({ g: cracks, a: 1.5, b: 0, c: 0.5 }); const glow = layer((gg) => gg.rect(0, H * 0.6, W, H * 0.4).fill({ color: primary, alpha: 0.12 })); items.push({ g: glow, a: 0.7, b: 1, c: 0.6 }); 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 })); void volcano; this.anim = { kind: "lava", items, t: 0 }; break; } case "skyline": { const buildings = layer((gg) => { for (let x = 0; x < W; x += 34 + rnd() * 40) { const h = 80 + rnd() * H * 0.5; gg.rect(x, H - h, 28 + rnd() * 44, h).fill({ color: 0x05060c, alpha: 0.85 }); } }); void buildings; for (let i = 0; i < 60; i++) { const win = layer((gg) => gg.rect(0, 0, 5, 7).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.85 })); win.position.set(rnd() * W, H * 0.45 + rnd() * H * 0.5); items.push({ g: win, a: 0.4 + rnd() * 1.5, b: rnd() * 6, c: 0 }); } // Neon sign strokes. const signs = layer((gg) => { gg.roundRect(W * 0.08, H * 0.18, 120, 40, 6).stroke({ color: primary, alpha: 0.7, width: 3 }); gg.roundRect(W * 0.7, H * 0.28, 150, 34, 6).stroke({ color: secondary, alpha: 0.7, width: 3 }); }); void signs; this.anim = { kind: "skyline", items, t: 0 }; break; } case "pyramid": { const pyr = layer((gg) => { gg.poly([W * 0.5, H * 0.08, W * 0.98, H, W * 0.02, H]).fill({ color: primary, alpha: 0.08 }); gg.poly([W * 0.5, H * 0.08, W * 0.98, H, W * 0.5, H]).fill({ color: 0x000000, alpha: 0.2 }); 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 }); }); void pyr; const eye = layer((gg) => { gg.circle(0, 0, 26).stroke({ color: secondary, alpha: 0.8, width: 3 }); gg.circle(0, 0, 9).fill({ color: secondary, alpha: 0.9 }); }); eye.position.set(W * 0.5, H * 0.3); items.push({ g: eye, a: 1.2, b: 0, c: 0.5 }); 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 })); items.push({ g: beam, a: 0.9, b: 2, c: 0.3 }); this.anim = { kind: "pyramid", items, t: 0 }; break; } case "arcade": { for (let i = 0; i < 70; i++) { const px = layer((gg) => gg.rect(0, 0, 22, 22).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.4 })); px.position.set(Math.floor((rnd() * W) / 24) * 24, Math.floor((rnd() * H) / 24) * 24); items.push({ g: px, a: 0, b: rnd() * 6, c: 0 }); } // Bezel stripes. const stripes = layer((gg) => { 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 }); }); void stripes; this.anim = { kind: "arcade", items, t: 0 }; break; } case "asteroids": { const stars = layer((gg) => { for (let i = 0; i < 80; i++) gg.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.6 }); }); void stars; for (let i = 0; i < 14; i++) { 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 })); rock.position.set(rnd() * W, rnd() * H); items.push({ g: rock, a: (rnd() - 0.5) * 30, b: (rnd() - 0.5) * 20, c: (rnd() - 0.5) * 0.6 }); } this.anim = { kind: "asteroids", items, t: 0 }; break; } case "track": { const road = layer((gg) => { 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 }); 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 }); }); void road; for (let i = 0; i < 10; i++) { const dash = layer((gg) => gg.rect(0, -3, 40, 6).fill({ color: 0xffffff, alpha: 0.35 })); dash.position.set((W / 10) * i, H * 0.55 + Math.sin(i) * 40); items.push({ g: dash, a: 220 + i * 10, b: 0, c: 0 }); } const flag = layer((gg) => { 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 }); }); void flag; this.anim = { kind: "track", items, t: 0 }; break; } case "reactor": case "core": { for (let i = 1; i <= 3; i++) { const ring = layer((gg) => { polygonN(gg, 0, 0, Math.min(W, H) * 0.16 * i, 6).stroke({ color: i === 2 ? secondary : primary, alpha: 0.35, width: 3 }); for (let k = 0; k < 6; k++) { const a = (k / 6) * Math.PI * 2; 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 }); } }); ring.position.set(W / 2, H / 2); items.push({ g: ring, a: (i % 2 ? 0.2 : -0.14) / i, b: i, c: 0 }); } const coreGlow = layer((gg) => gg.circle(0, 0, Math.min(W, H) * 0.12).fill({ color: primary, alpha: 0.35 })); coreGlow.position.set(W / 2, H / 2); items.push({ g: coreGlow, a: 0, b: 1, c: 0 }); const pipes = layer((gg) => { 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 }); }); void pipes; this.anim = { kind: "reactor", items, t: 0 }; break; } case "abyss": { for (let i = 0; i < 4; i++) { const depth = layer((gg) => gg.rect(0, 0, W, H * 0.25).fill({ color: 0x000000, alpha: 0.12 * (i + 1) })); depth.y = (H * 0.25) * i; void depth; } for (let k = 0; k < 4; k++) { 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 })); items.push({ g: ray, a: 0.3 + k * 0.1, b: k, c: 0 }); } const ruins = layer((gg) => { 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 }); }); void ruins; this.anim = { kind: "abyss", items, t: 0 }; break; } case "moon": { const stars = layer((gg) => { for (let i = 0; i < 90; i++) gg.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.6 }); }); void stars; const earth = layer((gg) => { gg.circle(0, 0, 44).fill({ color: 0x3b82f6, alpha: 0.7 }); gg.ellipse(-10, -6, 18, 10).fill({ color: 0x86efac, alpha: 0.5 }); gg.circle(0, 0, 44).stroke({ color: 0xffffff, alpha: 0.3, width: 2 }); }); earth.position.set(W * 0.82, H * 0.18); items.push({ g: earth, a: 0, b: 0, c: 0.05 }); const ground = layer((gg) => { gg.ellipse(W / 2, H * 1.05, W * 0.8, H * 0.3).fill({ color: 0x1a1d24, alpha: 0.95 }); 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 }); }); void ground; const dome = layer((gg) => { gg.arc(0, 0, 70, Math.PI, 0).fill({ color: primary, alpha: 0.15 }); gg.arc(0, 0, 70, Math.PI, 0).stroke({ color: primary, alpha: 0.6, width: 2 }); }); dome.position.set(W * 0.2, H * 0.85); void dome; this.anim = { kind: "moon", items, t: 0 }; break; } case "temple": { const steps = layer((gg) => { 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 }); }); void steps; const vines = layer((gg) => { for (let i = 0; i < 8; i++) { const x = rnd() * W; 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 }); } }); void vines; for (let i = 0; i < 4; i++) { const torch = layer((gg) => gg.circle(0, 0, 34).fill({ color: 0xffa040, alpha: 0.18 })); torch.position.set(W * (0.12 + i * 0.25), H * 0.32); items.push({ g: torch, a: 4 + rnd() * 3, b: rnd() * 6, c: 0.5 }); } this.anim = { kind: "temple", items, t: 0 }; break; } case "minimal": { 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 })); items.push({ g: line, a: 0, b: 0, c: 0 }); this.anim = { kind: "minimal", items, t: 0 }; break; } } // Vault scan line (moving) is handled as its own small anim group when the backdrop is a vault. if (p.backdrop === "vault") { const scan = layer((gg) => gg.rect(0, 0, W, 2).fill({ color: secondary, alpha: 0.45 })); this.vaultScan = scan; } else this.vaultScan = null; } private vaultScan: Graphics | null = null; private initAmbient() { if (!this.app || this.opts.reduceMotion || this.opts.intensity === "low") return; const p = this.opts.def.presentation; const count = this.opts.intensity === "high" ? 40 : 18; const tex = this.particleTexture(p.particles); this.ambient.removeChildren(); this.ambientParticles = []; for (let i = 0; i < count; i++) { const s = new Sprite(tex); s.anchor.set(0.5); s.alpha = 0.15 + Math.random() * 0.35; s.scale.set(0.3 + Math.random() * 0.8); s.x = Math.random() * this.width; s.y = Math.random() * this.height; const dir = p.particles === "snow" || p.particles === "dust" || p.particles === "confetti" ? 1 : p.particles === "bubbles" || p.particles === "fire" || p.particles === "sparks" ? -1 : 0; this.ambientParticles.push({ s, vx: (Math.random() - 0.5) * 8, vy: dir * (6 + Math.random() * 18) || (Math.random() - 0.5) * 6, phase: Math.random() * Math.PI * 2 }); this.ambient.addChild(s); } } private particleTexture(kind: string): Texture { const key = `p:${kind}`; const cached = this.textures.get(key); if (cached) return cached; const g = new Graphics(); const p = this.opts.def.presentation.palette; const c = hexColor(kind === "coins" ? "#ffd66b" : kind === "diamonds" ? "#e8f7ff" : kind === "fire" ? "#ff8a3d" : kind === "snow" ? "#ffffff" : kind === "bubbles" ? p.secondary : p.glow); switch (kind) { case "coins": g.circle(8, 8, 7).fill(c).circle(8, 8, 4).stroke({ color: 0x8a6d2e, width: 1.5 }); break; case "diamonds": g.poly([8, 0, 16, 8, 8, 16, 0, 8]).fill(c); break; case "sparks": case "energy": g.rect(6, 0, 4, 16).fill(c).rect(0, 6, 16, 4).fill(c); break; case "confetti": g.rect(2, 4, 12, 6).fill(c); break; case "stars": g.poly([8, 0, 10, 6, 16, 8, 10, 10, 8, 16, 6, 10, 0, 8, 6, 6]).fill(c); break; default: g.circle(8, 8, 6).fill(c); } const tex = this.app!.renderer.generateTexture({ target: g, resolution: 2 }); this.textures.set(key, tex); return tex; } /* ------------------------------------------------------------- textures */ private buildTextures(renderer: Renderer) { const size = 176; for (const s of this.opts.def.symbols) { const art = drawSymbol(s.style, { size, tier: s.tier, kind: s.kind, frame: this.opts.def.presentation.frame }); const tex = renderer.generateTexture({ target: art, resolution: 2, frame: new Rectangle(-size / 2, -size / 2, size, size) }); this.textures.set(s.id, tex); art.destroy({ children: true }); } // Blank cell (hold & respin). const blank = new Graphics().roundRect(-80, -80, 160, 160, 22).fill({ color: 0x000000, alpha: 0.2 }); this.textures.set("__", renderer.generateTexture({ target: blank, resolution: 1 })); } private makeSprite(id: string): Container { const tex = this.textures.get(id) ?? this.textures.get("__")!; const sp = new Sprite(tex); sp.anchor.set(0.5); const c = new Container(); c.addChild(sp); this.fitSprite(c); return c; } private fitSprite(c: Container) { const sp = c.children[0] as Sprite; const scale = this.cellSize / sp.texture.width; sp.scale.set(scale); } /* ---------------------------------------------------------------- reels */ private randomGrid(): string[][] { // Visual placeholder only (never an outcome): deterministic pattern from symbol list. const regular = this.opts.def.symbols.filter((s) => s.kind === "regular"); return Array.from({ length: this.cols }, (_, r) => Array.from({ length: this.rows }, (_, y) => regular[(r * 3 + y * 5) % regular.length].id)); } private buildReels(grid: string[][]) { this.reelLayer.removeChildren(); this.reelContainers = []; this.masks = []; this.cells = []; this.currentGrid = grid.map((c) => c.slice()); for (let r = 0; r < this.cols; r++) { const rc = new Container(); const m = new Graphics(); rc.mask = m; this.reelLayer.addChild(m, rc); this.reelContainers.push(rc); this.masks.push(m); const col: Cell[] = []; for (let y = 0; y < this.rows; y++) { const id = grid[r]?.[y] ?? "__"; const sp = this.makeSprite(id); rc.addChild(sp); col.push({ sprite: sp, id }); } this.cells.push(col); } this.repositionCells(); } private repositionCells() { for (let r = 0; r < this.cols; r++) { const m = this.masks[r]; if (!m) continue; m.clear().roundRect(this.cellX(r) - this.cellSize / 2 - 2, this.originY - this.gap / 2, this.cellSize + 4, this.rows * (this.cellSize + this.gap), 12).fill(0xffffff); for (let y = 0; y < this.rows; y++) { const cell = this.cells[r]?.[y]; if (!cell) continue; this.fitSprite(cell.sprite); cell.sprite.position.set(this.cellX(r), this.cellY(y)); cell.sprite.alpha = 1; cell.sprite.scale.set(1); } } this.fxLayer.removeChildren(); this.badges.removeChildren(); } /** Change grid dimensions (Zero Gravity). */ setGridSize(reels: number, rows: number) { if (reels === this.cols && rows === this.rows) return; this.cols = reels; this.rows = rows; this.layout(); this.buildReels(this.randomGrid()); } /** Instantly show a grid (used on load / restore). */ setGrid(grid: string[][]) { if (grid.length !== this.cols || grid[0]?.length !== this.rows) this.setGridSize(grid.length, grid[0].length); this.buildReels(grid); } private spinning = false; private spinVel = 0; /** Start reels spinning (called when the request is sent). */ startSpin() { this.clearFx(); this.reelSpinning = []; this.spinning = true; this.spinVel = 0; this.spinStartedAt = performance.now(); } private spinStartedAt = 0; /** Land the given grid reel by reel. Resolves when the last reel stops. */ async landGrid(grid: string[][], opts: { minSpinMs?: number } = {}): Promise { if (grid.length !== this.cols || grid[0].length !== this.rows) { this.spinning = false; this.setGridSize(grid.length, grid[0].length); await this.dropIn(grid); return; } const minSpin = (opts.minSpinMs ?? (this.opts.quick ? 380 : 780)) * this.speed; if (!this.spinning) this.startSpin(); // Make sure the reels have visibly spun for at least `minSpin` since the spin started. const elapsed = performance.now() - this.spinStartedAt; if (elapsed < minSpin) await wait(minSpin - elapsed); const stagger = (this.opts.quick ? 80 : 160) * this.speed; // Anticipation: when the landed reels already show (trigger − 1) scatters, the remaining reels slow down and glow. const scatterId = this.opts.def.scatter?.id; const triggerAt = scatterId ? Math.min(...Object.keys(this.opts.def.scatter!.triggers).map(Number)) : Infinity; let scattersSoFar = 0; for (let r = 0; r < this.cols; r++) { const anticipate = scatterId !== undefined && scattersSoFar >= triggerAt - 1 && r < this.cols && !this.opts.quick; if (anticipate) { this.anticipationGlow(r); await wait(650 * this.speed); } await this.stopReel(r, grid[r]); this.opts.onReelStop?.(r); if (scatterId) scattersSoFar += grid[r].filter((id) => id === scatterId).length; if (r < this.cols - 1) await wait(stagger); } this.spinning = false; this.currentGrid = grid.map((c) => c.slice()); } private reelSpinning: boolean[] = []; private tick(deltaMS: number) { if (!this.app) return; const dt = deltaMS / 1000; // Ambient particles. for (const p of this.ambientParticles) { p.phase += dt; p.s.x += (p.vx + Math.sin(p.phase) * 6) * dt; p.s.y += p.vy * dt; if (p.s.y > this.height + 20) p.s.y = -20; if (p.s.y < -20) p.s.y = this.height + 20; if (p.s.x > this.width + 20) p.s.x = -20; if (p.s.x < -20) p.s.x = this.width + 20; p.s.rotation += dt * 0.5; } // Burst particles. for (let i = this.particles.length - 1; i >= 0; i--) { const p = this.particles[i]; p.life += dt; p.vy += 600 * dt; p.s.x += p.vx * dt; p.s.y += p.vy * dt; p.s.rotation += dt * 3; p.s.alpha = Math.max(0, 1 - p.life / p.max); if (p.life >= p.max) { p.s.destroy(); this.particles.splice(i, 1); } } // Animated backdrop layers (per game world). this.animateBackdrop(dt); // Reel spin: scroll symbols downward, wrapping with placeholder art. if (this.spinning) { const maxVel = this.cellSize * (this.opts.quick ? 26 : 20); // Quick spin-up, then cruise. A short "pull back" at the very start reads as a physical launch. const since = (performance.now() - this.spinStartedAt) / 1000; const target = since < 0.08 ? -this.cellSize * 3 : maxVel; this.spinVel += (target - this.spinVel) * Math.min(1, dt * 14); const regular = this.opts.def.symbols.filter((s) => s.kind === "regular" || s.kind === "wild"); const blur = Math.min(1, Math.max(0, this.spinVel / maxVel)); for (let r = 0; r < this.cols; r++) { if (this.reelSpinning[r] === false) continue; this.reelSpinning[r] = true; const col = this.cells[r]; const span = this.rows * (this.cellSize + this.gap); const bottom = this.originY + span; for (const cell of col) { cell.sprite.y += this.spinVel * dt * (0.92 + r * 0.05); if (cell.sprite.y - this.cellSize / 2 > bottom) { cell.sprite.y -= span; const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only, never an outcome this.swapTexture(cell, id); } else if (cell.sprite.y + this.cellSize / 2 < this.originY - this.gap) { cell.sprite.y += span; } // Motion blur: stretch vertically and fade while fast. const sp = cell.sprite.children[0] as Sprite; sp.alpha = 1 - 0.45 * blur; cell.sprite.scale.set(1 - 0.06 * blur, 1 + 0.55 * blur); } } } } /* --------------------------------------------------------- backdrop anim */ private anim: { kind: string; items: { g: Container; a: number; b: number; c: number }[]; t: number } | null = null; private animateBackdrop(dt: number) { if (this.vaultScan && !this.opts.reduceMotion) { this.vaultScan.y += dt * 70; if (this.vaultScan.y > this.height) this.vaultScan.y = 0; } if (!this.anim || this.opts.reduceMotion) return; const A = this.anim; A.t += dt; const W = this.width; const H = this.height; switch (A.kind) { case "vault": case "gravity": case "universe": case "reactor": case "core": for (const it of A.items) it.g.rotation += dt * it.a; 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; break; case "grid": case "circuit": case "arcade": for (const it of A.items) { it.g.y += dt * it.a; if (it.g.y > H + 40) it.g.y = -40; if (A.kind === "arcade") it.g.alpha = 0.2 + Math.abs(Math.sin(A.t * 2 + it.b)) * 0.5; } break; case "nebula": case "asteroids": case "moon": for (const it of A.items) { it.g.x += dt * it.a; it.g.y += dt * it.b; if (it.g.x > W + 60) it.g.x = -60; if (it.g.x < -60) it.g.x = W + 60; if (it.g.y > H + 60) it.g.y = -60; if (it.g.y < -60) it.g.y = H + 60; it.g.rotation += dt * it.c; } break; case "aurora": case "abyss": for (const it of A.items) { it.g.x = Math.sin(A.t * it.a + it.b) * 26; it.g.alpha = 0.5 + Math.sin(A.t * it.a * 1.3 + it.b) * 0.25; } break; case "lava": case "pyramid": case "temple": for (const it of A.items) it.g.alpha = it.c + Math.sin(A.t * it.a + it.b) * it.c * 0.8; break; case "skyline": for (const it of A.items) it.g.alpha = Math.sin(A.t * it.a + it.b) > 0.3 ? 0.85 : 0.2; break; case "track": for (const it of A.items) { it.g.x -= dt * it.a; if (it.g.x < -80) it.g.x = W + 80; } break; case "minimal": for (const it of A.items) it.g.alpha = 0.12 + Math.sin(A.t * 0.6) * 0.06; break; case "pillars": for (const it of A.items) it.g.alpha = it.c + Math.sin(A.t * it.a + it.b) * 0.05; break; } } private anticipationGlow(reel: number) { const g = new Graphics(); const p = hexColor(this.opts.def.presentation.palette.glow); 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 }); this.fxLayer.addChild(g); void this.animate(650 * this.speed, (t) => { g.alpha = 0.4 + Math.sin(t * Math.PI * 6) * 0.4; }).then(() => g.destroy()); } private swapTexture(cell: Cell, id: string) { const sp = cell.sprite.children[0] as Sprite; sp.texture = this.textures.get(id) ?? this.textures.get("__")!; cell.id = id; this.fitSprite(cell.sprite); } private async stopReel(r: number, ids: string[]) { this.reelSpinning[r] = false; const col = this.cells[r]; // Snap: place final symbols above target, then drop into place with a bounce and a squash. col.forEach((cell, y) => { this.swapTexture(cell, ids[y]); (cell.sprite.children[0] as Sprite).alpha = 1; cell.sprite.scale.set(1, 1.3); cell.sprite.y = this.cellY(y) - this.cellSize * 0.6; }); // Landing flash on the reel. const flash = new Graphics(); 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 }); this.fxLayer.addChild(flash); const dur = (this.opts.quick ? 140 : 260) * this.speed; await this.animate(dur, (t) => { const e = easeOutBack(t); flash.alpha = 1 - t; col.forEach((cell, y) => { cell.sprite.y = this.cellY(y) - this.cellSize * 0.6 * (1 - e); const squash = t < 0.5 ? 1 + (1 - t * 2) * 0.3 : 1 - Math.sin((t - 0.5) * Math.PI) * 0.08; cell.sprite.scale.set(1 + (1 - squash) * 0.5, squash); }); }); flash.destroy(); col.forEach((cell, y) => { cell.sprite.y = this.cellY(y); cell.sprite.scale.set(1); }); } private async dropIn(grid: string[][]) { for (let r = 0; r < this.cols; r++) for (let y = 0; y < this.rows; y++) { const cell = this.cells[r][y]; this.swapTexture(cell, grid[r][y]); cell.sprite.y = this.cellY(y) - this.height; } await this.animate(500 * this.speed, (t) => { const e = easeOutCubic(t); for (let r = 0; r < this.cols; r++) for (let y = 0; y < this.rows; y++) this.cells[r][y].sprite.y = this.cellY(y) - this.height * (1 - e); }); this.currentGrid = grid.map((c) => c.slice()); } /* ---------------------------------------------------------------- steps */ /** Play a resolved step. `first` = the initial landing of a spin sequence. */ async playStep(step: SpinStep, ctx: { first: boolean; showWins: boolean }): Promise { if (this.destroyed) return; const meta = step.meta; if (step.type === "respin") { await this.playRespin(step); return; } if (step.type === "bonus" || step.type === "jackpot" || step.type === "feature") { // Overlays are rendered by React; keep the grid visible. await wait(200 * this.speed); return; } if (step.type === "cascade") { await this.playCascadeLanding(step); } else if (ctx.first) { await this.landGrid(step.grid); } else { // Free spin: quick respin of the whole grid. this.startSpin(); await this.landGrid(step.grid, { minSpinMs: 260 }); } this.multiplicity = step.multiplicity; // Feature decorations. if (meta.stickyWilds?.length) this.markCells(meta.stickyWilds, 0xffffff, "STICKY"); if (meta.movingWilds?.length) this.markCells(meta.movingWilds, 0xffffff, ""); if (meta.addedWilds?.length) await this.popCells(meta.addedWilds); if (meta.expandedReels?.length) await this.flashReels(meta.expandedReels); if (meta.mysteryReveal) await this.pulseCells(meta.mysteryReveal.positions); if (meta.wildMultipliers?.length) for (const wm of meta.wildMultipliers) this.badge(wm.position, `×${wm.value}`); if (meta.quantumSplits?.length) for (const q of meta.quantumSplits) this.badge(q.position, `×${q.multiplicity}`, 0x67e8f9); if (meta.scatterPositions?.length && (meta.freeSpinsAwarded || meta.scatterWin)) await this.pulseCells(meta.scatterPositions, 0xffffff); if (meta.exploded?.length) await this.explode(meta.exploded); if (ctx.showWins && step.wins.length) { await this.highlightWins(step); } if (step.meta.removed?.length && step.wins.length === 0 && !meta.exploded?.length) { // nothing } } private async playCascadeLanding(step: SpinStep) { // Previous grid already has removed cells faded (see highlightWins/explode). Drop survivors + new symbols. const prev = this.currentGrid; const next = step.grid; const removedPrev = this.lastRemoved; this.lastRemoved = []; const removedSet = new Set(removedPrev.map(([r, y]) => `${r}:${y}`)); const moves: { cell: Cell; fromY: number; toY: number }[] = []; for (let r = 0; r < this.cols; r++) { // Survivors from bottom up map to bottom of next. const survivors: number[] = []; for (let y = this.rows - 1; y >= 0; y--) if (!removedSet.has(`${r}:${y}`)) survivors.push(y); const newCells: Cell[] = []; let target = this.rows - 1; for (const y of survivors) { const cell = this.cells[r][y]; moves.push({ cell, fromY: this.cellY(y), toY: this.cellY(target) }); newCells[target] = cell; target--; } // Fresh symbols enter from above. for (let y = target; y >= 0; y--) { const cell = this.cells[r].find((c) => removedSet.has(`${r}:${this.cells[r].indexOf(c)}`) && !newCells.includes(c))!; this.swapTexture(cell, next[r][y]); cell.sprite.alpha = 1; cell.sprite.scale.set(1); moves.push({ cell, fromY: this.cellY(y) - (target + 1) * (this.cellSize + this.gap) - this.cellSize, toY: this.cellY(y) }); newCells[y] = cell; } this.cells[r] = newCells; } void prev; this.opts.onCascade?.(); await this.animate(380 * this.speed, (t) => { const e = easeOutCubic(t); for (const m of moves) m.cell.sprite.y = m.fromY + (m.toY - m.fromY) * e; }); // Ensure textures match the server grid exactly. for (let r = 0; r < this.cols; r++) for (let y = 0; y < this.rows; y++) if (this.cells[r][y].id !== next[r][y]) this.swapTexture(this.cells[r][y], next[r][y]); this.currentGrid = next.map((c) => c.slice()); } private lastRemoved: [number, number][] = []; private async highlightWins(step: SpinStep) { const fx = new Graphics(); this.fxLayer.addChild(fx); const winCells = new Set(); for (const w of step.wins) for (const [r, y] of w.positions) winCells.add(`${r}:${y}`); const primary = hexColor(this.opts.def.presentation.palette.glow); // Dim non-winning. for (let r = 0; r < this.cols; r++) for (let y = 0; y < this.rows; y++) if (!winCells.has(`${r}:${y}`)) this.cells[r][y].sprite.alpha = 0.35; // Paylines. if (this.opts.def.payModel.type === "lines") { for (const w of step.wins) { const pts = w.positions.map(([r, y]) => [this.cellX(r), this.cellY(y)]); fx.moveTo(pts[0][0], pts[0][1]); for (const p of pts.slice(1)) fx.lineTo(p[0], p[1]); fx.stroke({ color: primary, alpha: 0.7, width: 3 }); } } for (const k of winCells) { const [r, y] = k.split(":").map(Number); fx.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(y) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).stroke({ color: primary, alpha: 0.9, width: 3 }); } this.opts.onWinHighlight?.(step.win); const dur = (this.opts.quick ? 380 : 720) * this.speed; await this.animate(dur, (t) => { const pulse = 1 + Math.sin(t * Math.PI * 2) * 0.05; for (const k of winCells) { const [r, y] = k.split(":").map(Number); this.cells[r][y].sprite.scale.set(pulse); } fx.alpha = 0.6 + Math.sin(t * Math.PI * 4) * 0.4; }); if (this.opts.intensity !== "low" && !this.opts.reduceMotion) { const cellsArr = [...winCells].slice(0, 12); for (const k of cellsArr) { const [r, y] = k.split(":").map(Number); this.burst(this.cellX(r), this.cellY(y), step.multiplier > 1 ? 6 : 3); } } // If a cascade follows, fade the winning symbols out. if (step.meta.removed?.length) { this.lastRemoved = step.meta.removed; const rem = step.meta.removed; await this.animate(200 * this.speed, (t) => { for (const [r, y] of rem) { const c = this.cells[r][y]; c.sprite.alpha = 1 - t; c.sprite.scale.set(1 - t * 0.5); } }); } fx.destroy(); for (let r = 0; r < this.cols; r++) for (let y = 0; y < this.rows; y++) if (!this.lastRemoved.some(([rr, yy]) => rr === r && yy === y)) { this.cells[r][y].sprite.alpha = 1; this.cells[r][y].sprite.scale.set(1); } } private async explode(cells: [number, number][]) { for (const [r, y] of cells) this.burst(this.cellX(r), this.cellY(y), 8); await this.animate(240 * this.speed, (t) => { for (const [r, y] of cells) { const c = this.cells[r]?.[y]; if (!c) continue; c.sprite.alpha = 1 - t; c.sprite.scale.set(1 + t * 0.4); } }); this.lastRemoved = [...this.lastRemoved, ...cells]; } private async playRespin(step: SpinStep) { const coins = step.meta.coins ?? []; const cs = this.opts.def.holdRespin?.symbolId ?? "__"; // Reels spin for blanks only. const grid = step.grid; if (step.meta.label === "Lock & Respin") { await wait(150 * this.speed); } else { this.startSpin(); for (let r = 0; r < this.cols; r++) this.reelSpinning[r] = true; await wait(320 * this.speed); } this.spinning = false; this.reelSpinning = []; for (let r = 0; r < this.cols; r++) for (let y = 0; y < this.rows; y++) { const c = this.cells[r][y]; this.swapTexture(c, grid[r][y] === cs ? cs : "__"); (c.sprite.children[0] as Sprite).alpha = 1; c.sprite.y = this.cellY(y); c.sprite.alpha = grid[r][y] === cs ? 1 : 0.6; } this.badges.removeChildren(); for (const coin of coins) { const label = coin.value !== null ? formatSC(coin.value, { unit: false }) : (coin.jackpot ?? "").toUpperCase(); this.badge([coin.reel, coin.row], label, coin.jackpot ? 0xffd66b : 0xffffff, true); if (coin.isNew) { this.burst(this.cellX(coin.reel), this.cellY(coin.row), 6); this.opts.onCoin?.(); } } this.currentGrid = grid.map((c) => c.slice()); await wait((step.meta.label === "Lock & Respin" ? 500 : 420) * this.speed); } /* --------------------------------------------------------------- effects */ private badge(pos: [number, number], text: string, color = 0xffd66b, center = false) { const [r, y] = pos; const t = new Text({ text, style: new TextStyle({ fontFamily: "Geist, Inter, system-ui, sans-serif", fontSize: Math.max(11, this.cellSize * (center ? 0.2 : 0.17)), fontWeight: "800", fill: color, stroke: { color: 0x000000, width: 4 } }) }); t.anchor.set(center ? 0.5 : 1, center ? 0.5 : 0); t.position.set(center ? this.cellX(r) : this.cellX(r) + this.cellSize / 2 - 6, center ? this.cellY(y) + this.cellSize * 0.22 : this.cellY(y) - this.cellSize / 2 + 4); this.badges.addChild(t); } private markCells(cells: [number, number][], color: number, text: string) { const g = new Graphics(); for (const [r, y] of cells) g.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(y) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).stroke({ color, alpha: 0.6, width: 2 }); this.badges.addChild(g); if (text) for (const c of cells) this.badge(c, text, color); } private async popCells(cells: [number, number][]) { for (const [r, y] of cells) this.burst(this.cellX(r), this.cellY(y), 5); await this.animate(260 * this.speed, (t) => { const e = easeOutBack(t); for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(0.6 + 0.4 * e); }); } private async flashReels(reels: number[]) { const g = new Graphics(); for (const r of reels) g.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.5 }); this.fxLayer.addChild(g); await this.animate(320 * this.speed, (t) => (g.alpha = 1 - t)); g.destroy(); } private async pulseCells(cells: [number, number][], color = 0xffffff) { const g = new Graphics(); for (const [r, y] of cells) g.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(y) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).stroke({ color, alpha: 0.9, width: 3 }); this.fxLayer.addChild(g); await this.animate(360 * this.speed, (t) => { g.alpha = 1 - t; for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(1 + Math.sin(t * Math.PI) * 0.12); }); g.destroy(); for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(1); } burst(x: number, y: number, n: number) { if (!this.app || this.opts.reduceMotion) return; const tex = this.particleTexture(this.opts.def.presentation.particles); const count = this.opts.intensity === "high" ? n * 2 : n; for (let i = 0; i < count; i++) { const s = new Sprite(tex); s.anchor.set(0.5); s.position.set(x, y); s.scale.set(0.4 + Math.random() * 0.6); const a = Math.random() * Math.PI * 2; const v = 120 + Math.random() * 260; this.fxLayer.addChild(s); this.particles.push({ s, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 150, life: 0, max: 0.7 + Math.random() * 0.5 }); } } /** Big celebration shower across the whole canvas. */ celebrate(intensity: number) { if (!this.app || this.opts.reduceMotion) return; const n = Math.min(160, 30 * intensity) * (this.opts.intensity === "high" ? 1 : 0.5); for (let i = 0; i < n; i++) setTimeout(() => this.burst(Math.random() * this.width, this.height * 0.3 + Math.random() * this.height * 0.4, 3), i * 12); } clearFx() { this.fxLayer.removeChildren(); this.badges.removeChildren(); this.lastRemoved = []; for (const col of this.cells) for (const c of col) { c.sprite.alpha = 1; c.sprite.scale.set(1); (c.sprite.children[0] as Sprite).alpha = 1; } } private animate(durationMs: number, fn: (t: number) => void): Promise { return new Promise((resolve) => { if (!this.app) return resolve(); const start = performance.now(); const step = () => { if (this.destroyed || !this.app) return resolve(); const t = Math.min(1, (performance.now() - start) / Math.max(1, durationMs)); fn(t); if (t >= 1) { this.app.ticker.remove(step); resolve(); } }; this.app.ticker.add(step); }); } } function hashString(s: string): number { let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return h >>> 0; } /** Deterministic PRNG for backdrop art only (never for outcomes). */ function mulberry32(seed: number) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function polygonN(g: Graphics, cx: number, cy: number, r: number, sides: number): Graphics { const pts: number[] = []; for (let i = 0; i < sides; i++) { const a = -Math.PI / 2 + (i * Math.PI * 2) / sides; pts.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r); } return g.poly(pts); } function polygonRandom(g: Graphics, cx: number, cy: number, r: number, rnd: () => number): Graphics { const pts: number[] = []; const n = 6 + Math.floor(rnd() * 4); for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; const rr = r * (0.7 + rnd() * 0.5); pts.push(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr); } return g.poly(pts); } export { easeInOut };