"use client"; import { Application, Container, Graphics, 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, lighten } 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.6; 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 frameColors: Record = { metal: 0x9aa3b5, glass: 0xffffff, gold: 0xe0c070, stone: 0x8a8578, ice: 0xbfe9ff, carbon: 0x5c6270, neon: primary, obsidian: 0x3a3a44 }; const fc = frameColors[p.frame] ?? primary; g.roundRect(x, y, w, h, 18).fill({ color: 0x000000, alpha: 0.38 }); g.roundRect(x, y, w, h, 18).stroke({ color: fc, alpha: p.frame === "neon" ? 0.85 : 0.35, width: 2 }); g.roundRect(x + 3, y + 3, w - 6, h - 6, 15).stroke({ color: 0xffffff, alpha: 0.06, width: 1 }); if (p.frame === "neon") g.roundRect(x - 3, y - 3, w + 6, h + 6, 21).stroke({ color: primary, alpha: 0.25, width: 6 }); // Cell wells. for (let r = 0; r < this.cols; r++) for (let yy = 0; yy < this.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: 0.025 }); } } /* ------------------------------------------------------------- backdrop */ 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); g.rect(0, 0, W, H).fill(bg); // Vignette glow blobs. g.ellipse(W * 0.3, H * 0.2, W * 0.5, H * 0.45).fill({ color: primary, alpha: 0.08 }); g.ellipse(W * 0.75, H * 0.85, W * 0.5, H * 0.4).fill({ color: secondary, alpha: 0.07 }); const seed = hashString(this.opts.def.slug); const rnd = mulberry32(seed); switch (p.backdrop) { case "grid": case "circuit": case "vault": { const step = Math.max(28, W / 22); for (let x = 0; x <= W; x += step) g.moveTo(x, 0).lineTo(x, H).stroke({ color: primary, alpha: 0.06, width: 1 }); for (let y = 0; y <= H; y += step) g.moveTo(0, y).lineTo(W, y).stroke({ color: primary, alpha: 0.06, width: 1 }); 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 }); if (p.backdrop === "circuit") for (let i = 0; i < 12; i++) { const x = rnd() * W; const y = rnd() * H; 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 }); g.circle(x, y, 3).fill({ color: secondary, alpha: 0.35 }); } break; } case "nebula": case "universe": case "gravity": { 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 }); 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 }); 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 }); break; } case "pillars": case "temple": { const n = 6; for (let i = 0; i < n; i++) { const x = (W / (n + 1)) * (i + 1); g.rect(x - 18, 0, 36, H).fill({ color: primary, alpha: 0.05 }); g.rect(x - 26, H * 0.08, 52, 14).fill({ color: primary, alpha: 0.1 }); } break; } case "aurora": { for (let k = 0; k < 4; k++) { g.moveTo(0, H * (0.2 + k * 0.12)); for (let x = 0; x <= W; x += 40) g.lineTo(x, H * (0.2 + k * 0.12) + Math.sin(x / 90 + k) * 30); g.stroke({ color: k % 2 ? primary : secondary, alpha: 0.1, width: 26 }); } break; } case "lava": case "core": case "reactor": { for (let i = 0; i < 14; i++) { const x = rnd() * W; const y = rnd() * H; g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 220, y + (rnd() - 0.5) * 220).stroke({ color: primary, alpha: 0.18, width: 2 }); } g.circle(W / 2, H / 2, Math.min(W, H) * 0.42).stroke({ color: secondary, alpha: 0.12, width: 10 }); break; } case "skyline": { for (let x = 0; x < W; x += 30 + rnd() * 40) { const h = 60 + rnd() * H * 0.45; g.rect(x, H - h, 24 + rnd() * 40, h).fill({ color: 0x000000, alpha: 0.45 }); 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 }); } break; } case "pyramid": { g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.05, H]).fill({ color: primary, alpha: 0.06 }); g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.5, H]).fill({ color: 0x000000, alpha: 0.15 }); break; } case "arcade": { 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 }); break; } case "asteroids": { for (let i = 0; i < 16; i++) polygonRandom(g, rnd() * W, rnd() * H, 10 + rnd() * 40, rnd).fill({ color: 0xffffff, alpha: 0.05 }); for (let i = 0; i < 60; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.5 }); break; } case "track": { for (let k = 0; k < 5; k++) { g.moveTo(-50, H * 0.9 - k * 40); 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); g.stroke({ color: k % 2 ? primary : 0xffffff, alpha: 0.06, width: 12 }); } break; } case "abyss": { for (let i = 0; i < 40; i++) g.circle(rnd() * W, rnd() * H, 2 + rnd() * 10).stroke({ color: primary, alpha: 0.12, width: 1 }); g.rect(0, H * 0.6, W, H * 0.4).fill({ color: 0x000000, alpha: 0.35 }); break; } case "moon": { 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 }); 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 }); for (let i = 0; i < 70; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.5 }); break; } case "minimal": { g.moveTo(W * 0.1, H * 0.5).lineTo(W * 0.9, H * 0.5).stroke({ color: primary, alpha: 0.18, width: 1 }); break; } } b.addChild(g); } 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 }); const tex = renderer.generateTexture({ target: art, resolution: 2, frame: { x: -size / 2, y: -size / 2, width: size, height: size } as never }); 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.spinning = true; this.spinVel = 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 ?? 500) * this.speed; if (!this.spinning) { this.startSpin(); } await wait(minSpin); const stagger = (this.opts.quick ? 70 : 130) * this.speed; for (let r = 0; r < this.cols; r++) { await this.stopReel(r, grid[r]); this.opts.onReelStop?.(r); 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); } } // Reel spin: scroll symbols downward, wrapping with placeholder art. if (this.spinning) { this.spinVel = Math.min(2600, this.spinVel + 9000 * dt); const regular = this.opts.def.symbols.filter((s) => s.kind === "regular" || s.kind === "wild"); for (let r = 0; r < this.cols; r++) { if (this.reelSpinning[r] === false) continue; this.reelSpinning[r] = true; const col = this.cells[r]; const bottom = this.originY + this.rows * (this.cellSize + this.gap); for (const cell of col) { cell.sprite.y += this.spinVel * dt * (0.9 + r * 0.04); if (cell.sprite.y - this.cellSize / 2 > bottom) { cell.sprite.y -= this.rows * (this.cellSize + this.gap); const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only this.swapTexture(cell, id); } } // Motion blur feel. for (const cell of col) (cell.sprite.children[0] as Sprite).alpha = 0.85; } } } 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 slightly above target, then ease into place with a bounce. col.forEach((cell, y) => { this.swapTexture(cell, ids[y]); (cell.sprite.children[0] as Sprite).alpha = 1; cell.sprite.y = this.cellY(y) - this.cellSize * 0.35; }); const dur = (this.opts.quick ? 120 : 220) * this.speed; await this.animate(dur, (t) => { const e = easeOutBack(t); col.forEach((cell, y) => { cell.sprite.y = this.cellY(y) - this.cellSize * 0.35 * (1 - e); }); }); col.forEach((cell, y) => (cell.sprite.y = this.cellY(y))); } 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 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 };