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%
56.6 KB · 1,353 lines typescript
Raw Blame History
1"use client";23import { Application, Container, Graphics, Rectangle, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js";4import type { SpinStep } from "@spinza/game-core/client";5import type { ClientDefinition } from "./types";6import { drawSymbol, hexColor } from "./symbol-art";7import { formatSC } from "@spinza/shared";89/* --------------------------------------------------------------- helpers */1011const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));12const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);13const easeOutBack = (t: number) => {14  const c1 = 1.70158;15  const c3 = c1 + 1;16  return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);17};18const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);1920export interface RendererOptions {21  def: ClientDefinition;22  reduceMotion: boolean;23  intensity: "low" | "medium" | "high";24  quick: boolean;25  onReelStop?: (index: number) => void;26  onCascade?: () => void;27  onWinHighlight?: (amount: number) => void;28  onCoin?: () => void;29}3031interface Cell {32  sprite: Container;33  id: string;34}3536/**37 * Spinza slot renderer (PixiJS v8). One renderer serves all 20 games: it38 * reads the client definition for grid, symbols and presentation and plays39 * back server-resolved steps. It never decides outcomes.40 */41export class SlotRenderer {42  private app: Application | null = null;43  private root = new Container();44  private backdrop = new Container();45  private ambient = new Container();46  private reelLayer = new Container();47  private fxLayer = new Container();48  private overlayLayer = new Container();49  private textures = new Map<string, Texture>();50  private cells: Cell[][] = []; // [reel][row]51  private reelContainers: Container[] = [];52  private masks: Graphics[] = [];53  private cols = 5;54  private rows = 3;55  private cellSize = 100;56  private gap = 8;57  private originX = 0;58  private originY = 0;59  private frame = new Graphics();60  private particles: { s: Sprite; vx: number; vy: number; life: number; max: number }[] = [];61  private ambientParticles: { s: Sprite; vx: number; vy: number; phase: number }[] = [];62  private destroyed = false;63  private currentGrid: string[][] = [];64  private multiplicity: number[][] | undefined;65  private badges = new Container();66  private width = 0;67  private height = 0;68  private opts: RendererOptions;6970  constructor(opts: RendererOptions) {71    this.opts = opts;72    this.cols = opts.def.grid.reels;73    this.rows = opts.def.grid.rows;74  }7576  get speed(): number {77    return this.opts.quick ? 0.55 : this.opts.reduceMotion ? 0.7 : 1;78  }7980  setOptions(o: Partial<RendererOptions>) {81    this.opts = { ...this.opts, ...o };82  }8384  async mount(el: HTMLElement): Promise<void> {85    const app = new Application();86    await app.init({87      resizeTo: el,88      backgroundAlpha: 0,89      antialias: true,90      autoDensity: true,91      resolution: Math.min(2, window.devicePixelRatio || 1),92      preference: "webgl",93    });94    if (this.destroyed) {95      app.destroy(true);96      return;97    }98    this.app = app;99    el.appendChild(app.canvas);100    this.root.addChild(this.backdrop, this.ambient, this.frame, this.reelLayer, this.fxLayer, this.badges, this.overlayLayer);101    app.stage.addChild(this.root);102    this.buildTextures(app.renderer);103    this.layout();104    this.drawBackdrop();105    this.initAmbient();106    this.buildReels(this.randomGrid());107    app.renderer.on("resize", () => this.onResize());108    app.ticker.add((t) => this.tick(t.deltaMS));109  }110111  destroy() {112    this.destroyed = true;113    if (this.app) {114      this.app.destroy(true, { children: true, texture: true });115      this.app = null;116    }117  }118119  /* ---------------------------------------------------------------- layout */120121  private onResize() {122    this.layout();123    this.drawBackdrop();124    this.repositionCells();125  }126127  private layout() {128    if (!this.app) return;129    this.width = this.app.screen.width;130    this.height = this.app.screen.height;131    const padX = Math.min(24, this.width * 0.04);132    const padY = Math.min(24, this.height * 0.05);133    const availW = this.width - padX * 2;134    const availH = this.height - padY * 2;135    this.gap = Math.max(4, Math.min(10, availW / this.cols / 14));136    this.cellSize = Math.floor(Math.min((availW - this.gap * (this.cols - 1)) / this.cols, (availH - this.gap * (this.rows - 1)) / this.rows));137    const totalW = this.cellSize * this.cols + this.gap * (this.cols - 1);138    const totalH = this.cellSize * this.rows + this.gap * (this.rows - 1);139    this.originX = (this.width - totalW) / 2;140    this.originY = (this.height - totalH) / 2;141    this.drawFrame(totalW, totalH);142  }143144  private cellX(reel: number) {145    return this.originX + reel * (this.cellSize + this.gap) + this.cellSize / 2;146  }147  private cellY(row: number) {148    return this.originY + row * (this.cellSize + this.gap) + this.cellSize / 2;149  }150151  private drawFrame(totalW: number, totalH: number) {152    const p = this.opts.def.presentation;153    const g = this.frame;154    g.clear();155    const pad = this.gap * 1.8;156    const x = this.originX - pad;157    const y = this.originY - pad;158    const w = totalW + pad * 2;159    const h = totalH + pad * 2;160    const primary = hexColor(p.palette.primary);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    }248    // Cell wells.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 });252      }253  }254255  /* ------------------------------------------------------------- backdrop */256257  /** Draw the game's world behind the reels: a static scene + a set of animated layers (see animateBackdrop). */258  private drawBackdrop() {259    const p = this.opts.def.presentation;260    const b = this.backdrop;261    b.removeChildren();262    const g = new Graphics();263    const W = this.width;264    const H = this.height;265    const primary = hexColor(p.palette.primary);266    const secondary = hexColor(p.palette.secondary);267    const bg = hexColor(p.palette.bg);268    const surface = hexColor(p.palette.surface);269    const seed = hashString(this.opts.def.slug);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);284285    switch (p.backdrop) {286      case "vault": {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++) {313            const x = rnd() * W;314            const y = rnd() * H;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 });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 };347        break;348      }349      case "universe":350      case "gravity": {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 };375        break;376      }377      case "pillars": {378        const n = 6;379        for (let i = 0; i < n; i++) {380          const x = (W / (n + 1)) * (i + 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 });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 };397        break;398      }399      case "aurora": {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 });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 };416        break;417      }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 };438        break;439      }440      case "skyline": {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 });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 };460        break;461      }462      case "pyramid": {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 };478        break;479      }480      case "arcade": {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 };492        break;493      }494      case "asteroids": {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 };505        break;506      }507      case "track": {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 });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 };546        break;547      }548      case "abyss": {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 };563        break;564      }565      case "moon": {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 };609        break;610      }611      case "minimal": {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 };615        break;616      }617    }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;623  }624625  private vaultScan: Graphics | null = null;626627  private initAmbient() {628    if (!this.app || this.opts.reduceMotion || this.opts.intensity === "low") return;629    const p = this.opts.def.presentation;630    const count = this.opts.intensity === "high" ? 40 : 18;631    const tex = this.particleTexture(p.particles);632    this.ambient.removeChildren();633    this.ambientParticles = [];634    for (let i = 0; i < count; i++) {635      const s = new Sprite(tex);636      s.anchor.set(0.5);637      s.alpha = 0.15 + Math.random() * 0.35;638      s.scale.set(0.3 + Math.random() * 0.8);639      s.x = Math.random() * this.width;640      s.y = Math.random() * this.height;641      const dir = p.particles === "snow" || p.particles === "dust" || p.particles === "confetti" ? 1 : p.particles === "bubbles" || p.particles === "fire" || p.particles === "sparks" ? -1 : 0;642      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 });643      this.ambient.addChild(s);644    }645  }646647  private particleTexture(kind: string): Texture {648    const key = `p:${kind}`;649    const cached = this.textures.get(key);650    if (cached) return cached;651    const g = new Graphics();652    const p = this.opts.def.presentation.palette;653    const c = hexColor(kind === "coins" ? "#ffd66b" : kind === "diamonds" ? "#e8f7ff" : kind === "fire" ? "#ff8a3d" : kind === "snow" ? "#ffffff" : kind === "bubbles" ? p.secondary : p.glow);654    switch (kind) {655      case "coins":656        g.circle(8, 8, 7).fill(c).circle(8, 8, 4).stroke({ color: 0x8a6d2e, width: 1.5 });657        break;658      case "diamonds":659        g.poly([8, 0, 16, 8, 8, 16, 0, 8]).fill(c);660        break;661      case "sparks":662      case "energy":663        g.rect(6, 0, 4, 16).fill(c).rect(0, 6, 16, 4).fill(c);664        break;665      case "confetti":666        g.rect(2, 4, 12, 6).fill(c);667        break;668      case "stars":669        g.poly([8, 0, 10, 6, 16, 8, 10, 10, 8, 16, 6, 10, 0, 8, 6, 6]).fill(c);670        break;671      default:672        g.circle(8, 8, 6).fill(c);673    }674    const tex = this.app!.renderer.generateTexture({ target: g, resolution: 2 });675    this.textures.set(key, tex);676    return tex;677  }678679  /* ------------------------------------------------------------- textures */680681  private buildTextures(renderer: Renderer) {682    const size = 176;683    for (const s of this.opts.def.symbols) {684      const art = drawSymbol(s.style, { size, tier: s.tier, kind: s.kind, frame: this.opts.def.presentation.frame });685      const tex = renderer.generateTexture({ target: art, resolution: 2, frame: new Rectangle(-size / 2, -size / 2, size, size) });686      this.textures.set(s.id, tex);687      art.destroy({ children: true });688    }689    // Blank cell (hold & respin).690    const blank = new Graphics().roundRect(-80, -80, 160, 160, 22).fill({ color: 0x000000, alpha: 0.2 });691    this.textures.set("__", renderer.generateTexture({ target: blank, resolution: 1 }));692  }693694  private makeSprite(id: string): Container {695    const tex = this.textures.get(id) ?? this.textures.get("__")!;696    const sp = new Sprite(tex);697    sp.anchor.set(0.5);698    const c = new Container();699    c.addChild(sp);700    this.fitSprite(c);701    return c;702  }703704  private fitSprite(c: Container) {705    const sp = c.children[0] as Sprite;706    const scale = this.cellSize / sp.texture.width;707    sp.scale.set(scale);708  }709710  /* ---------------------------------------------------------------- reels */711712  private randomGrid(): string[][] {713    // Visual placeholder only (never an outcome): deterministic pattern from symbol list.714    const regular = this.opts.def.symbols.filter((s) => s.kind === "regular");715    return Array.from({ length: this.cols }, (_, r) => Array.from({ length: this.rows }, (_, y) => regular[(r * 3 + y * 5) % regular.length].id));716  }717718  private buildReels(grid: string[][]) {719    this.reelLayer.removeChildren();720    this.reelContainers = [];721    this.masks = [];722    this.cells = [];723    this.currentGrid = grid.map((c) => c.slice());724    for (let r = 0; r < this.cols; r++) {725      const rc = new Container();726      const m = new Graphics();727      rc.mask = m;728      this.reelLayer.addChild(m, rc);729      this.reelContainers.push(rc);730      this.masks.push(m);731      const col: Cell[] = [];732      for (let y = 0; y < this.rows; y++) {733        const id = grid[r]?.[y] ?? "__";734        const sp = this.makeSprite(id);735        rc.addChild(sp);736        col.push({ sprite: sp, id });737      }738      this.cells.push(col);739    }740    this.repositionCells();741  }742743  private repositionCells() {744    for (let r = 0; r < this.cols; r++) {745      const m = this.masks[r];746      if (!m) continue;747      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);748      for (let y = 0; y < this.rows; y++) {749        const cell = this.cells[r]?.[y];750        if (!cell) continue;751        this.fitSprite(cell.sprite);752        cell.sprite.position.set(this.cellX(r), this.cellY(y));753        cell.sprite.alpha = 1;754        cell.sprite.scale.set(1);755      }756    }757    this.fxLayer.removeChildren();758    this.badges.removeChildren();759  }760761  /** Change grid dimensions (Zero Gravity). */762  setGridSize(reels: number, rows: number) {763    if (reels === this.cols && rows === this.rows) return;764    this.cols = reels;765    this.rows = rows;766    this.layout();767    this.buildReels(this.randomGrid());768  }769770  /** Instantly show a grid (used on load / restore). */771  setGrid(grid: string[][]) {772    if (grid.length !== this.cols || grid[0]?.length !== this.rows) this.setGridSize(grid.length, grid[0].length);773    this.buildReels(grid);774  }775776  private spinning = false;777  private spinVel = 0;778779  /** Start reels spinning (called when the request is sent). */780  startSpin() {781    this.clearFx();782    this.reelSpinning = [];783    this.spinning = true;784    this.spinVel = 0;785    this.spinStartedAt = performance.now();786  }787  private spinStartedAt = 0;788789  /** Land the given grid reel by reel. Resolves when the last reel stops. */790  async landGrid(grid: string[][], opts: { minSpinMs?: number } = {}): Promise<void> {791    if (grid.length !== this.cols || grid[0].length !== this.rows) {792      this.spinning = false;793      this.setGridSize(grid.length, grid[0].length);794      await this.dropIn(grid);795      return;796    }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;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      }813      await this.stopReel(r, grid[r]);814      this.opts.onReelStop?.(r);815      if (scatterId) scattersSoFar += grid[r].filter((id) => id === scatterId).length;816      if (r < this.cols - 1) await wait(stagger);817    }818    this.spinning = false;819    this.currentGrid = grid.map((c) => c.slice());820  }821822  private reelSpinning: boolean[] = [];823824  private tick(deltaMS: number) {825    if (!this.app) return;826    const dt = deltaMS / 1000;827    // Ambient particles.828    for (const p of this.ambientParticles) {829      p.phase += dt;830      p.s.x += (p.vx + Math.sin(p.phase) * 6) * dt;831      p.s.y += p.vy * dt;832      if (p.s.y > this.height + 20) p.s.y = -20;833      if (p.s.y < -20) p.s.y = this.height + 20;834      if (p.s.x > this.width + 20) p.s.x = -20;835      if (p.s.x < -20) p.s.x = this.width + 20;836      p.s.rotation += dt * 0.5;837    }838    // Burst particles.839    for (let i = this.particles.length - 1; i >= 0; i--) {840      const p = this.particles[i];841      p.life += dt;842      p.vy += 600 * dt;843      p.s.x += p.vx * dt;844      p.s.y += p.vy * dt;845      p.s.rotation += dt * 3;846      p.s.alpha = Math.max(0, 1 - p.life / p.max);847      if (p.life >= p.max) {848        p.s.destroy();849        this.particles.splice(i, 1);850      }851    }852    // Animated backdrop layers (per game world).853    this.animateBackdrop(dt);854    // Reel spin: scroll symbols downward, wrapping with placeholder art.855    if (this.spinning) {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);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));863      for (let r = 0; r < this.cols; r++) {864        if (this.reelSpinning[r] === false) continue;865        this.reelSpinning[r] = true;866        const col = this.cells[r];867        const span = this.rows * (this.cellSize + this.gap);868        const bottom = this.originY + span;869        for (const cell of col) {870          cell.sprite.y += this.spinVel * dt * (0.92 + r * 0.05);871          if (cell.sprite.y - this.cellSize / 2 > bottom) {872            cell.sprite.y -= span;873            const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only, never an outcome874            this.swapTexture(cell, id);875          } else if (cell.sprite.y + this.cellSize / 2 < this.originY - this.gap) {876            cell.sprite.y += span;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);882        }883      }884    }885  }886887  /* --------------------------------------------------------- backdrop anim */888889  private anim: { kind: string; items: { g: Container; a: number; b: number; c: number }[]; t: number } | null = null;890891  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  }961962  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  }971972  private swapTexture(cell: Cell, id: string) {973    const sp = cell.sprite.children[0] as Sprite;974    sp.texture = this.textures.get(id) ?? this.textures.get("__")!;975    cell.id = id;976    this.fitSprite(cell.sprite);977  }978979  private async stopReel(r: number, ids: string[]) {980    this.reelSpinning[r] = false;981    const col = this.cells[r];982    // Snap: place final symbols above target, then drop into place with a bounce and a squash.983    col.forEach((cell, y) => {984      this.swapTexture(cell, ids[y]);985      (cell.sprite.children[0] as Sprite).alpha = 1;986      cell.sprite.scale.set(1, 1.3);987      cell.sprite.y = this.cellY(y) - this.cellSize * 0.6;988    });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;994    await this.animate(dur, (t) => {995      const e = easeOutBack(t);996      flash.alpha = 1 - t;997      col.forEach((cell, y) => {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);1001      });1002    });1003    flash.destroy();1004    col.forEach((cell, y) => {1005      cell.sprite.y = this.cellY(y);1006      cell.sprite.scale.set(1);1007    });1008  }10091010  private async dropIn(grid: string[][]) {1011    for (let r = 0; r < this.cols; r++)1012      for (let y = 0; y < this.rows; y++) {1013        const cell = this.cells[r][y];1014        this.swapTexture(cell, grid[r][y]);1015        cell.sprite.y = this.cellY(y) - this.height;1016      }1017    await this.animate(500 * this.speed, (t) => {1018      const e = easeOutCubic(t);1019      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);1020    });1021    this.currentGrid = grid.map((c) => c.slice());1022  }10231024  /* ---------------------------------------------------------------- steps */10251026  /** Play a resolved step. `first` = the initial landing of a spin sequence. */1027  async playStep(step: SpinStep, ctx: { first: boolean; showWins: boolean }): Promise<void> {1028    if (this.destroyed) return;1029    const meta = step.meta;1030    if (step.type === "respin") {1031      await this.playRespin(step);1032      return;1033    }1034    if (step.type === "bonus" || step.type === "jackpot" || step.type === "feature") {1035      // Overlays are rendered by React; keep the grid visible.1036      await wait(200 * this.speed);1037      return;1038    }1039    if (step.type === "cascade") {1040      await this.playCascadeLanding(step);1041    } else if (ctx.first) {1042      await this.landGrid(step.grid);1043    } else {1044      // Free spin: quick respin of the whole grid.1045      this.startSpin();1046      await this.landGrid(step.grid, { minSpinMs: 260 });1047    }1048    this.multiplicity = step.multiplicity;1049    // Feature decorations.1050    if (meta.stickyWilds?.length) this.markCells(meta.stickyWilds, 0xffffff, "STICKY");1051    if (meta.movingWilds?.length) this.markCells(meta.movingWilds, 0xffffff, "");1052    if (meta.addedWilds?.length) await this.popCells(meta.addedWilds);1053    if (meta.expandedReels?.length) await this.flashReels(meta.expandedReels);1054    if (meta.mysteryReveal) await this.pulseCells(meta.mysteryReveal.positions);1055    if (meta.wildMultipliers?.length) for (const wm of meta.wildMultipliers) this.badge(wm.position, `×${wm.value}`);1056    if (meta.quantumSplits?.length) for (const q of meta.quantumSplits) this.badge(q.position, `×${q.multiplicity}`, 0x67e8f9);1057    if (meta.scatterPositions?.length && (meta.freeSpinsAwarded || meta.scatterWin)) await this.pulseCells(meta.scatterPositions, 0xffffff);1058    if (meta.exploded?.length) await this.explode(meta.exploded);1059    if (ctx.showWins && step.wins.length) {1060      await this.highlightWins(step);1061    }1062    if (step.meta.removed?.length && step.wins.length === 0 && !meta.exploded?.length) {1063      // nothing1064    }1065  }10661067  private async playCascadeLanding(step: SpinStep) {1068    // Previous grid already has removed cells faded (see highlightWins/explode). Drop survivors + new symbols.1069    const prev = this.currentGrid;1070    const next = step.grid;1071    const removedPrev = this.lastRemoved;1072    this.lastRemoved = [];1073    const removedSet = new Set(removedPrev.map(([r, y]) => `${r}:${y}`));1074    const moves: { cell: Cell; fromY: number; toY: number }[] = [];1075    for (let r = 0; r < this.cols; r++) {1076      // Survivors from bottom up map to bottom of next.1077      const survivors: number[] = [];1078      for (let y = this.rows - 1; y >= 0; y--) if (!removedSet.has(`${r}:${y}`)) survivors.push(y);1079      const newCells: Cell[] = [];1080      let target = this.rows - 1;1081      for (const y of survivors) {1082        const cell = this.cells[r][y];1083        moves.push({ cell, fromY: this.cellY(y), toY: this.cellY(target) });1084        newCells[target] = cell;1085        target--;1086      }1087      // Fresh symbols enter from above.1088      for (let y = target; y >= 0; y--) {1089        const cell = this.cells[r].find((c) => removedSet.has(`${r}:${this.cells[r].indexOf(c)}`) && !newCells.includes(c))!;1090        this.swapTexture(cell, next[r][y]);1091        cell.sprite.alpha = 1;1092        cell.sprite.scale.set(1);1093        moves.push({ cell, fromY: this.cellY(y) - (target + 1) * (this.cellSize + this.gap) - this.cellSize, toY: this.cellY(y) });1094        newCells[y] = cell;1095      }1096      this.cells[r] = newCells;1097    }1098    void prev;1099    this.opts.onCascade?.();1100    await this.animate(380 * this.speed, (t) => {1101      const e = easeOutCubic(t);1102      for (const m of moves) m.cell.sprite.y = m.fromY + (m.toY - m.fromY) * e;1103    });1104    // Ensure textures match the server grid exactly.1105    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]);1106    this.currentGrid = next.map((c) => c.slice());1107  }11081109  private lastRemoved: [number, number][] = [];11101111  private async highlightWins(step: SpinStep) {1112    const fx = new Graphics();1113    this.fxLayer.addChild(fx);1114    const winCells = new Set<string>();1115    for (const w of step.wins) for (const [r, y] of w.positions) winCells.add(`${r}:${y}`);1116    const primary = hexColor(this.opts.def.presentation.palette.glow);1117    // Dim non-winning.1118    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;1119    // Paylines.1120    if (this.opts.def.payModel.type === "lines") {1121      for (const w of step.wins) {1122        const pts = w.positions.map(([r, y]) => [this.cellX(r), this.cellY(y)]);1123        fx.moveTo(pts[0][0], pts[0][1]);1124        for (const p of pts.slice(1)) fx.lineTo(p[0], p[1]);1125        fx.stroke({ color: primary, alpha: 0.7, width: 3 });1126      }1127    }1128    for (const k of winCells) {1129      const [r, y] = k.split(":").map(Number);1130      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 });1131    }1132    this.opts.onWinHighlight?.(step.win);1133    const dur = (this.opts.quick ? 380 : 720) * this.speed;1134    await this.animate(dur, (t) => {1135      const pulse = 1 + Math.sin(t * Math.PI * 2) * 0.05;1136      for (const k of winCells) {1137        const [r, y] = k.split(":").map(Number);1138        this.cells[r][y].sprite.scale.set(pulse);1139      }1140      fx.alpha = 0.6 + Math.sin(t * Math.PI * 4) * 0.4;1141    });1142    if (this.opts.intensity !== "low" && !this.opts.reduceMotion) {1143      const cellsArr = [...winCells].slice(0, 12);1144      for (const k of cellsArr) {1145        const [r, y] = k.split(":").map(Number);1146        this.burst(this.cellX(r), this.cellY(y), step.multiplier > 1 ? 6 : 3);1147      }1148    }1149    // If a cascade follows, fade the winning symbols out.1150    if (step.meta.removed?.length) {1151      this.lastRemoved = step.meta.removed;1152      const rem = step.meta.removed;1153      await this.animate(200 * this.speed, (t) => {1154        for (const [r, y] of rem) {1155          const c = this.cells[r][y];1156          c.sprite.alpha = 1 - t;1157          c.sprite.scale.set(1 - t * 0.5);1158        }1159      });1160    }1161    fx.destroy();1162    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)) {1163      this.cells[r][y].sprite.alpha = 1;1164      this.cells[r][y].sprite.scale.set(1);1165    }1166  }11671168  private async explode(cells: [number, number][]) {1169    for (const [r, y] of cells) this.burst(this.cellX(r), this.cellY(y), 8);1170    await this.animate(240 * this.speed, (t) => {1171      for (const [r, y] of cells) {1172        const c = this.cells[r]?.[y];1173        if (!c) continue;1174        c.sprite.alpha = 1 - t;1175        c.sprite.scale.set(1 + t * 0.4);1176      }1177    });1178    this.lastRemoved = [...this.lastRemoved, ...cells];1179  }11801181  private async playRespin(step: SpinStep) {1182    const coins = step.meta.coins ?? [];1183    const cs = this.opts.def.holdRespin?.symbolId ?? "__";1184    // Reels spin for blanks only.1185    const grid = step.grid;1186    if (step.meta.label === "Lock & Respin") {1187      await wait(150 * this.speed);1188    } else {1189      this.startSpin();1190      for (let r = 0; r < this.cols; r++) this.reelSpinning[r] = true;1191      await wait(320 * this.speed);1192    }1193    this.spinning = false;1194    this.reelSpinning = [];1195    for (let r = 0; r < this.cols; r++)1196      for (let y = 0; y < this.rows; y++) {1197        const c = this.cells[r][y];1198        this.swapTexture(c, grid[r][y] === cs ? cs : "__");1199        (c.sprite.children[0] as Sprite).alpha = 1;1200        c.sprite.y = this.cellY(y);1201        c.sprite.alpha = grid[r][y] === cs ? 1 : 0.6;1202      }1203    this.badges.removeChildren();1204    for (const coin of coins) {1205      const label = coin.value !== null ? formatSC(coin.value, { unit: false }) : (coin.jackpot ?? "").toUpperCase();1206      this.badge([coin.reel, coin.row], label, coin.jackpot ? 0xffd66b : 0xffffff, true);1207      if (coin.isNew) {1208        this.burst(this.cellX(coin.reel), this.cellY(coin.row), 6);1209        this.opts.onCoin?.();1210      }1211    }1212    this.currentGrid = grid.map((c) => c.slice());1213    await wait((step.meta.label === "Lock & Respin" ? 500 : 420) * this.speed);1214  }12151216  /* --------------------------------------------------------------- effects */12171218  private badge(pos: [number, number], text: string, color = 0xffd66b, center = false) {1219    const [r, y] = pos;1220    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 } }) });1221    t.anchor.set(center ? 0.5 : 1, center ? 0.5 : 0);1222    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);1223    this.badges.addChild(t);1224  }12251226  private markCells(cells: [number, number][], color: number, text: string) {1227    const g = new Graphics();1228    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 });1229    this.badges.addChild(g);1230    if (text) for (const c of cells) this.badge(c, text, color);1231  }12321233  private async popCells(cells: [number, number][]) {1234    for (const [r, y] of cells) this.burst(this.cellX(r), this.cellY(y), 5);1235    await this.animate(260 * this.speed, (t) => {1236      const e = easeOutBack(t);1237      for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(0.6 + 0.4 * e);1238    });1239  }12401241  private async flashReels(reels: number[]) {1242    const g = new Graphics();1243    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 });1244    this.fxLayer.addChild(g);1245    await this.animate(320 * this.speed, (t) => (g.alpha = 1 - t));1246    g.destroy();1247  }12481249  private async pulseCells(cells: [number, number][], color = 0xffffff) {1250    const g = new Graphics();1251    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 });1252    this.fxLayer.addChild(g);1253    await this.animate(360 * this.speed, (t) => {1254      g.alpha = 1 - t;1255      for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(1 + Math.sin(t * Math.PI) * 0.12);1256    });1257    g.destroy();1258    for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(1);1259  }12601261  burst(x: number, y: number, n: number) {1262    if (!this.app || this.opts.reduceMotion) return;1263    const tex = this.particleTexture(this.opts.def.presentation.particles);1264    const count = this.opts.intensity === "high" ? n * 2 : n;1265    for (let i = 0; i < count; i++) {1266      const s = new Sprite(tex);1267      s.anchor.set(0.5);1268      s.position.set(x, y);1269      s.scale.set(0.4 + Math.random() * 0.6);1270      const a = Math.random() * Math.PI * 2;1271      const v = 120 + Math.random() * 260;1272      this.fxLayer.addChild(s);1273      this.particles.push({ s, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 150, life: 0, max: 0.7 + Math.random() * 0.5 });1274    }1275  }12761277  /** Big celebration shower across the whole canvas. */1278  celebrate(intensity: number) {1279    if (!this.app || this.opts.reduceMotion) return;1280    const n = Math.min(160, 30 * intensity) * (this.opts.intensity === "high" ? 1 : 0.5);1281    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);1282  }12831284  clearFx() {1285    this.fxLayer.removeChildren();1286    this.badges.removeChildren();1287    this.lastRemoved = [];1288    for (const col of this.cells)1289      for (const c of col) {1290        c.sprite.alpha = 1;1291        c.sprite.scale.set(1);1292        (c.sprite.children[0] as Sprite).alpha = 1;1293      }1294  }12951296  private animate(durationMs: number, fn: (t: number) => void): Promise<void> {1297    return new Promise((resolve) => {1298      if (!this.app) return resolve();1299      const start = performance.now();1300      const step = () => {1301        if (this.destroyed || !this.app) return resolve();1302        const t = Math.min(1, (performance.now() - start) / Math.max(1, durationMs));1303        fn(t);1304        if (t >= 1) {1305          this.app.ticker.remove(step);1306          resolve();1307        }1308      };1309      this.app.ticker.add(step);1310    });1311  }1312}13131314function hashString(s: string): number {1315  let h = 2166136261;1316  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);1317  return h >>> 0;1318}13191320/** Deterministic PRNG for backdrop art only (never for outcomes). */1321function mulberry32(seed: number) {1322  let a = seed >>> 0;1323  return () => {1324    a = (a + 0x6d2b79f5) >>> 0;1325    let t = a;1326    t = Math.imul(t ^ (t >>> 15), t | 1);1327    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);1328    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;1329  };1330}13311332function 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}13401341function polygonRandom(g: Graphics, cx: number, cy: number, r: number, rnd: () => number): Graphics {1342  const pts: number[] = [];1343  const n = 6 + Math.floor(rnd() * 4);1344  for (let i = 0; i < n; i++) {1345    const a = (i / n) * Math.PI * 2;1346    const rr = r * (0.7 + rnd() * 0.5);1347    pts.push(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr);1348  }1349  return g.poly(pts);1350}13511352export { easeInOut };1353