SPB Git

spb/spinza

Public
35.0 KB · 890 lines typescript
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, lighten } 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.6;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 frameColors: Record<string, number> = { metal: 0x9aa3b5, glass: 0xffffff, gold: 0xe0c070, stone: 0x8a8578, ice: 0xbfe9ff, carbon: 0x5c6270, neon: primary, obsidian: 0x3a3a44 };162    const fc = frameColors[p.frame] ?? primary;163    g.roundRect(x, y, w, h, 18).fill({ color: 0x000000, alpha: 0.38 });164    g.roundRect(x, y, w, h, 18).stroke({ color: fc, alpha: p.frame === "neon" ? 0.85 : 0.35, width: 2 });165    g.roundRect(x + 3, y + 3, w - 6, h - 6, 15).stroke({ color: 0xffffff, alpha: 0.06, width: 1 });166    if (p.frame === "neon") g.roundRect(x - 3, y - 3, w + 6, h + 6, 21).stroke({ color: primary, alpha: 0.25, width: 6 });167    // Cell wells.168    for (let r = 0; r < this.cols; r++)169      for (let yy = 0; yy < this.rows; yy++) {170        g.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(yy) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).fill({ color: 0xffffff, alpha: 0.025 });171      }172  }173174  /* ------------------------------------------------------------- backdrop */175176  private drawBackdrop() {177    const p = this.opts.def.presentation;178    const b = this.backdrop;179    b.removeChildren();180    const g = new Graphics();181    const W = this.width;182    const H = this.height;183    const primary = hexColor(p.palette.primary);184    const secondary = hexColor(p.palette.secondary);185    const bg = hexColor(p.palette.bg);186    g.rect(0, 0, W, H).fill(bg);187    // Vignette glow blobs.188    g.ellipse(W * 0.3, H * 0.2, W * 0.5, H * 0.45).fill({ color: primary, alpha: 0.08 });189    g.ellipse(W * 0.75, H * 0.85, W * 0.5, H * 0.4).fill({ color: secondary, alpha: 0.07 });190    const seed = hashString(this.opts.def.slug);191    const rnd = mulberry32(seed);192    switch (p.backdrop) {193      case "grid":194      case "circuit":195      case "vault": {196        const step = Math.max(28, W / 22);197        for (let x = 0; x <= W; x += step) g.moveTo(x, 0).lineTo(x, H).stroke({ color: primary, alpha: 0.06, width: 1 });198        for (let y = 0; y <= H; y += step) g.moveTo(0, y).lineTo(W, y).stroke({ color: primary, alpha: 0.06, width: 1 });199        if (p.backdrop === "vault") for (let i = 1; i <= 4; i++) g.circle(W / 2, H / 2, Math.min(W, H) * 0.2 * i).stroke({ color: primary, alpha: 0.05, width: 2 });200        if (p.backdrop === "circuit")201          for (let i = 0; i < 12; i++) {202            const x = rnd() * W;203            const y = rnd() * H;204            g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 200, y).lineTo(x + (rnd() - 0.5) * 200, y + (rnd() - 0.5) * 160).stroke({ color: secondary, alpha: 0.12, width: 2 });205            g.circle(x, y, 3).fill({ color: secondary, alpha: 0.35 });206          }207        break;208      }209      case "nebula":210      case "universe":211      case "gravity": {212        for (let i = 0; i < 6; i++) g.ellipse(rnd() * W, rnd() * H, 80 + rnd() * W * 0.3, 40 + rnd() * H * 0.25).fill({ color: i % 2 ? primary : secondary, alpha: 0.05 });213        for (let i = 0; i < 90; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.6 + 0.3).fill({ color: 0xffffff, alpha: 0.25 + rnd() * 0.5 });214        if (p.backdrop === "gravity" || p.backdrop === "universe") for (let i = 1; i <= 3; i++) g.ellipse(W / 2, H / 2, W * 0.28 * i, H * 0.18 * i).stroke({ color: primary, alpha: 0.08, width: 1.5 });215        break;216      }217      case "pillars":218      case "temple": {219        const n = 6;220        for (let i = 0; i < n; i++) {221          const x = (W / (n + 1)) * (i + 1);222          g.rect(x - 18, 0, 36, H).fill({ color: primary, alpha: 0.05 });223          g.rect(x - 26, H * 0.08, 52, 14).fill({ color: primary, alpha: 0.1 });224        }225        break;226      }227      case "aurora": {228        for (let k = 0; k < 4; k++) {229          g.moveTo(0, H * (0.2 + k * 0.12));230          for (let x = 0; x <= W; x += 40) g.lineTo(x, H * (0.2 + k * 0.12) + Math.sin(x / 90 + k) * 30);231          g.stroke({ color: k % 2 ? primary : secondary, alpha: 0.1, width: 26 });232        }233        break;234      }235      case "lava":236      case "core":237      case "reactor": {238        for (let i = 0; i < 14; i++) {239          const x = rnd() * W;240          const y = rnd() * H;241          g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 220, y + (rnd() - 0.5) * 220).stroke({ color: primary, alpha: 0.18, width: 2 });242        }243        g.circle(W / 2, H / 2, Math.min(W, H) * 0.42).stroke({ color: secondary, alpha: 0.12, width: 10 });244        break;245      }246      case "skyline": {247        for (let x = 0; x < W; x += 30 + rnd() * 40) {248          const h = 60 + rnd() * H * 0.45;249          g.rect(x, H - h, 24 + rnd() * 40, h).fill({ color: 0x000000, alpha: 0.45 });250          for (let i = 0; i < 5; i++) g.rect(x + 4 + rnd() * 30, H - h + 8 + rnd() * (h - 16), 4, 6).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.6 });251        }252        break;253      }254      case "pyramid": {255        g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.05, H]).fill({ color: primary, alpha: 0.06 });256        g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.5, H]).fill({ color: 0x000000, alpha: 0.15 });257        break;258      }259      case "arcade": {260        for (let i = 0; i < 60; i++) g.rect(Math.floor((rnd() * W) / 24) * 24, Math.floor((rnd() * H) / 24) * 24, 22, 22).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.08 });261        break;262      }263      case "asteroids": {264        for (let i = 0; i < 16; i++) polygonRandom(g, rnd() * W, rnd() * H, 10 + rnd() * 40, rnd).fill({ color: 0xffffff, alpha: 0.05 });265        for (let i = 0; i < 60; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.5 });266        break;267      }268      case "track": {269        for (let k = 0; k < 5; k++) {270          g.moveTo(-50, H * 0.9 - k * 40);271          g.bezierCurveTo(W * 0.3, H * 0.1 - k * 30, W * 0.7, H * 1.1 - k * 30, W + 50, H * 0.2 - k * 40);272          g.stroke({ color: k % 2 ? primary : 0xffffff, alpha: 0.06, width: 12 });273        }274        break;275      }276      case "abyss": {277        for (let i = 0; i < 40; i++) g.circle(rnd() * W, rnd() * H, 2 + rnd() * 10).stroke({ color: primary, alpha: 0.12, width: 1 });278        g.rect(0, H * 0.6, W, H * 0.4).fill({ color: 0x000000, alpha: 0.35 });279        break;280      }281      case "moon": {282        g.circle(W * 0.8, H * 0.2, Math.min(W, H) * 0.18).fill({ color: lighten(p.palette.primary, 0.1), alpha: 0.12 });283        for (let i = 0; i < 8; i++) g.circle(W * 0.8 + (rnd() - 0.5) * 120, H * 0.2 + (rnd() - 0.5) * 120, 4 + rnd() * 14).fill({ color: 0x000000, alpha: 0.12 });284        for (let i = 0; i < 70; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.5 });285        break;286      }287      case "minimal": {288        g.moveTo(W * 0.1, H * 0.5).lineTo(W * 0.9, H * 0.5).stroke({ color: primary, alpha: 0.18, width: 1 });289        break;290      }291    }292    b.addChild(g);293  }294295  private initAmbient() {296    if (!this.app || this.opts.reduceMotion || this.opts.intensity === "low") return;297    const p = this.opts.def.presentation;298    const count = this.opts.intensity === "high" ? 40 : 18;299    const tex = this.particleTexture(p.particles);300    this.ambient.removeChildren();301    this.ambientParticles = [];302    for (let i = 0; i < count; i++) {303      const s = new Sprite(tex);304      s.anchor.set(0.5);305      s.alpha = 0.15 + Math.random() * 0.35;306      s.scale.set(0.3 + Math.random() * 0.8);307      s.x = Math.random() * this.width;308      s.y = Math.random() * this.height;309      const dir = p.particles === "snow" || p.particles === "dust" || p.particles === "confetti" ? 1 : p.particles === "bubbles" || p.particles === "fire" || p.particles === "sparks" ? -1 : 0;310      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 });311      this.ambient.addChild(s);312    }313  }314315  private particleTexture(kind: string): Texture {316    const key = `p:${kind}`;317    const cached = this.textures.get(key);318    if (cached) return cached;319    const g = new Graphics();320    const p = this.opts.def.presentation.palette;321    const c = hexColor(kind === "coins" ? "#ffd66b" : kind === "diamonds" ? "#e8f7ff" : kind === "fire" ? "#ff8a3d" : kind === "snow" ? "#ffffff" : kind === "bubbles" ? p.secondary : p.glow);322    switch (kind) {323      case "coins":324        g.circle(8, 8, 7).fill(c).circle(8, 8, 4).stroke({ color: 0x8a6d2e, width: 1.5 });325        break;326      case "diamonds":327        g.poly([8, 0, 16, 8, 8, 16, 0, 8]).fill(c);328        break;329      case "sparks":330      case "energy":331        g.rect(6, 0, 4, 16).fill(c).rect(0, 6, 16, 4).fill(c);332        break;333      case "confetti":334        g.rect(2, 4, 12, 6).fill(c);335        break;336      case "stars":337        g.poly([8, 0, 10, 6, 16, 8, 10, 10, 8, 16, 6, 10, 0, 8, 6, 6]).fill(c);338        break;339      default:340        g.circle(8, 8, 6).fill(c);341    }342    const tex = this.app!.renderer.generateTexture({ target: g, resolution: 2 });343    this.textures.set(key, tex);344    return tex;345  }346347  /* ------------------------------------------------------------- textures */348349  private buildTextures(renderer: Renderer) {350    const size = 176;351    for (const s of this.opts.def.symbols) {352      const art = drawSymbol(s.style, { size, tier: s.tier, kind: s.kind });353      const tex = renderer.generateTexture({ target: art, resolution: 2, frame: new Rectangle(-size / 2, -size / 2, size, size) });354      this.textures.set(s.id, tex);355      art.destroy({ children: true });356    }357    // Blank cell (hold & respin).358    const blank = new Graphics().roundRect(-80, -80, 160, 160, 22).fill({ color: 0x000000, alpha: 0.2 });359    this.textures.set("__", renderer.generateTexture({ target: blank, resolution: 1 }));360  }361362  private makeSprite(id: string): Container {363    const tex = this.textures.get(id) ?? this.textures.get("__")!;364    const sp = new Sprite(tex);365    sp.anchor.set(0.5);366    const c = new Container();367    c.addChild(sp);368    this.fitSprite(c);369    return c;370  }371372  private fitSprite(c: Container) {373    const sp = c.children[0] as Sprite;374    const scale = this.cellSize / sp.texture.width;375    sp.scale.set(scale);376  }377378  /* ---------------------------------------------------------------- reels */379380  private randomGrid(): string[][] {381    // Visual placeholder only (never an outcome): deterministic pattern from symbol list.382    const regular = this.opts.def.symbols.filter((s) => s.kind === "regular");383    return Array.from({ length: this.cols }, (_, r) => Array.from({ length: this.rows }, (_, y) => regular[(r * 3 + y * 5) % regular.length].id));384  }385386  private buildReels(grid: string[][]) {387    this.reelLayer.removeChildren();388    this.reelContainers = [];389    this.masks = [];390    this.cells = [];391    this.currentGrid = grid.map((c) => c.slice());392    for (let r = 0; r < this.cols; r++) {393      const rc = new Container();394      const m = new Graphics();395      rc.mask = m;396      this.reelLayer.addChild(m, rc);397      this.reelContainers.push(rc);398      this.masks.push(m);399      const col: Cell[] = [];400      for (let y = 0; y < this.rows; y++) {401        const id = grid[r]?.[y] ?? "__";402        const sp = this.makeSprite(id);403        rc.addChild(sp);404        col.push({ sprite: sp, id });405      }406      this.cells.push(col);407    }408    this.repositionCells();409  }410411  private repositionCells() {412    for (let r = 0; r < this.cols; r++) {413      const m = this.masks[r];414      if (!m) continue;415      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);416      for (let y = 0; y < this.rows; y++) {417        const cell = this.cells[r]?.[y];418        if (!cell) continue;419        this.fitSprite(cell.sprite);420        cell.sprite.position.set(this.cellX(r), this.cellY(y));421        cell.sprite.alpha = 1;422        cell.sprite.scale.set(1);423      }424    }425    this.fxLayer.removeChildren();426    this.badges.removeChildren();427  }428429  /** Change grid dimensions (Zero Gravity). */430  setGridSize(reels: number, rows: number) {431    if (reels === this.cols && rows === this.rows) return;432    this.cols = reels;433    this.rows = rows;434    this.layout();435    this.buildReels(this.randomGrid());436  }437438  /** Instantly show a grid (used on load / restore). */439  setGrid(grid: string[][]) {440    if (grid.length !== this.cols || grid[0]?.length !== this.rows) this.setGridSize(grid.length, grid[0].length);441    this.buildReels(grid);442  }443444  private spinning = false;445  private spinVel = 0;446447  /** Start reels spinning (called when the request is sent). */448  startSpin() {449    this.clearFx();450    this.spinning = true;451    this.spinVel = 0;452  }453454  /** Land the given grid reel by reel. Resolves when the last reel stops. */455  async landGrid(grid: string[][], opts: { minSpinMs?: number } = {}): Promise<void> {456    if (grid.length !== this.cols || grid[0].length !== this.rows) {457      this.spinning = false;458      this.setGridSize(grid.length, grid[0].length);459      await this.dropIn(grid);460      return;461    }462    const minSpin = (opts.minSpinMs ?? 500) * this.speed;463    if (!this.spinning) {464      this.startSpin();465    }466    await wait(minSpin);467    const stagger = (this.opts.quick ? 70 : 130) * this.speed;468    for (let r = 0; r < this.cols; r++) {469      await this.stopReel(r, grid[r]);470      this.opts.onReelStop?.(r);471      if (r < this.cols - 1) await wait(stagger);472    }473    this.spinning = false;474    this.currentGrid = grid.map((c) => c.slice());475  }476477  private reelSpinning: boolean[] = [];478479  private tick(deltaMS: number) {480    if (!this.app) return;481    const dt = deltaMS / 1000;482    // Ambient particles.483    for (const p of this.ambientParticles) {484      p.phase += dt;485      p.s.x += (p.vx + Math.sin(p.phase) * 6) * dt;486      p.s.y += p.vy * dt;487      if (p.s.y > this.height + 20) p.s.y = -20;488      if (p.s.y < -20) p.s.y = this.height + 20;489      if (p.s.x > this.width + 20) p.s.x = -20;490      if (p.s.x < -20) p.s.x = this.width + 20;491      p.s.rotation += dt * 0.5;492    }493    // Burst particles.494    for (let i = this.particles.length - 1; i >= 0; i--) {495      const p = this.particles[i];496      p.life += dt;497      p.vy += 600 * dt;498      p.s.x += p.vx * dt;499      p.s.y += p.vy * dt;500      p.s.rotation += dt * 3;501      p.s.alpha = Math.max(0, 1 - p.life / p.max);502      if (p.life >= p.max) {503        p.s.destroy();504        this.particles.splice(i, 1);505      }506    }507    // Reel spin: scroll symbols downward, wrapping with placeholder art.508    if (this.spinning) {509      this.spinVel = Math.min(2600, this.spinVel + 9000 * dt);510      const regular = this.opts.def.symbols.filter((s) => s.kind === "regular" || s.kind === "wild");511      for (let r = 0; r < this.cols; r++) {512        if (this.reelSpinning[r] === false) continue;513        this.reelSpinning[r] = true;514        const col = this.cells[r];515        const bottom = this.originY + this.rows * (this.cellSize + this.gap);516        for (const cell of col) {517          cell.sprite.y += this.spinVel * dt * (0.9 + r * 0.04);518          if (cell.sprite.y - this.cellSize / 2 > bottom) {519            cell.sprite.y -= this.rows * (this.cellSize + this.gap);520            const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only521            this.swapTexture(cell, id);522          }523        }524        // Motion blur feel.525        for (const cell of col) (cell.sprite.children[0] as Sprite).alpha = 0.85;526      }527    }528  }529530  private swapTexture(cell: Cell, id: string) {531    const sp = cell.sprite.children[0] as Sprite;532    sp.texture = this.textures.get(id) ?? this.textures.get("__")!;533    cell.id = id;534    this.fitSprite(cell.sprite);535  }536537  private async stopReel(r: number, ids: string[]) {538    this.reelSpinning[r] = false;539    const col = this.cells[r];540    // Snap: place final symbols slightly above target, then ease into place with a bounce.541    col.forEach((cell, y) => {542      this.swapTexture(cell, ids[y]);543      (cell.sprite.children[0] as Sprite).alpha = 1;544      cell.sprite.y = this.cellY(y) - this.cellSize * 0.35;545    });546    const dur = (this.opts.quick ? 120 : 220) * this.speed;547    await this.animate(dur, (t) => {548      const e = easeOutBack(t);549      col.forEach((cell, y) => {550        cell.sprite.y = this.cellY(y) - this.cellSize * 0.35 * (1 - e);551      });552    });553    col.forEach((cell, y) => (cell.sprite.y = this.cellY(y)));554  }555556  private async dropIn(grid: string[][]) {557    for (let r = 0; r < this.cols; r++)558      for (let y = 0; y < this.rows; y++) {559        const cell = this.cells[r][y];560        this.swapTexture(cell, grid[r][y]);561        cell.sprite.y = this.cellY(y) - this.height;562      }563    await this.animate(500 * this.speed, (t) => {564      const e = easeOutCubic(t);565      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);566    });567    this.currentGrid = grid.map((c) => c.slice());568  }569570  /* ---------------------------------------------------------------- steps */571572  /** Play a resolved step. `first` = the initial landing of a spin sequence. */573  async playStep(step: SpinStep, ctx: { first: boolean; showWins: boolean }): Promise<void> {574    if (this.destroyed) return;575    const meta = step.meta;576    if (step.type === "respin") {577      await this.playRespin(step);578      return;579    }580    if (step.type === "bonus" || step.type === "jackpot" || step.type === "feature") {581      // Overlays are rendered by React; keep the grid visible.582      await wait(200 * this.speed);583      return;584    }585    if (step.type === "cascade") {586      await this.playCascadeLanding(step);587    } else if (ctx.first) {588      await this.landGrid(step.grid);589    } else {590      // Free spin: quick respin of the whole grid.591      this.startSpin();592      await this.landGrid(step.grid, { minSpinMs: 260 });593    }594    this.multiplicity = step.multiplicity;595    // Feature decorations.596    if (meta.stickyWilds?.length) this.markCells(meta.stickyWilds, 0xffffff, "STICKY");597    if (meta.movingWilds?.length) this.markCells(meta.movingWilds, 0xffffff, "");598    if (meta.addedWilds?.length) await this.popCells(meta.addedWilds);599    if (meta.expandedReels?.length) await this.flashReels(meta.expandedReels);600    if (meta.mysteryReveal) await this.pulseCells(meta.mysteryReveal.positions);601    if (meta.wildMultipliers?.length) for (const wm of meta.wildMultipliers) this.badge(wm.position, `×${wm.value}`);602    if (meta.quantumSplits?.length) for (const q of meta.quantumSplits) this.badge(q.position, `×${q.multiplicity}`, 0x67e8f9);603    if (meta.scatterPositions?.length && (meta.freeSpinsAwarded || meta.scatterWin)) await this.pulseCells(meta.scatterPositions, 0xffffff);604    if (meta.exploded?.length) await this.explode(meta.exploded);605    if (ctx.showWins && step.wins.length) {606      await this.highlightWins(step);607    }608    if (step.meta.removed?.length && step.wins.length === 0 && !meta.exploded?.length) {609      // nothing610    }611  }612613  private async playCascadeLanding(step: SpinStep) {614    // Previous grid already has removed cells faded (see highlightWins/explode). Drop survivors + new symbols.615    const prev = this.currentGrid;616    const next = step.grid;617    const removedPrev = this.lastRemoved;618    this.lastRemoved = [];619    const removedSet = new Set(removedPrev.map(([r, y]) => `${r}:${y}`));620    const moves: { cell: Cell; fromY: number; toY: number }[] = [];621    for (let r = 0; r < this.cols; r++) {622      // Survivors from bottom up map to bottom of next.623      const survivors: number[] = [];624      for (let y = this.rows - 1; y >= 0; y--) if (!removedSet.has(`${r}:${y}`)) survivors.push(y);625      const newCells: Cell[] = [];626      let target = this.rows - 1;627      for (const y of survivors) {628        const cell = this.cells[r][y];629        moves.push({ cell, fromY: this.cellY(y), toY: this.cellY(target) });630        newCells[target] = cell;631        target--;632      }633      // Fresh symbols enter from above.634      for (let y = target; y >= 0; y--) {635        const cell = this.cells[r].find((c) => removedSet.has(`${r}:${this.cells[r].indexOf(c)}`) && !newCells.includes(c))!;636        this.swapTexture(cell, next[r][y]);637        cell.sprite.alpha = 1;638        cell.sprite.scale.set(1);639        moves.push({ cell, fromY: this.cellY(y) - (target + 1) * (this.cellSize + this.gap) - this.cellSize, toY: this.cellY(y) });640        newCells[y] = cell;641      }642      this.cells[r] = newCells;643    }644    void prev;645    this.opts.onCascade?.();646    await this.animate(380 * this.speed, (t) => {647      const e = easeOutCubic(t);648      for (const m of moves) m.cell.sprite.y = m.fromY + (m.toY - m.fromY) * e;649    });650    // Ensure textures match the server grid exactly.651    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]);652    this.currentGrid = next.map((c) => c.slice());653  }654655  private lastRemoved: [number, number][] = [];656657  private async highlightWins(step: SpinStep) {658    const fx = new Graphics();659    this.fxLayer.addChild(fx);660    const winCells = new Set<string>();661    for (const w of step.wins) for (const [r, y] of w.positions) winCells.add(`${r}:${y}`);662    const primary = hexColor(this.opts.def.presentation.palette.glow);663    // Dim non-winning.664    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;665    // Paylines.666    if (this.opts.def.payModel.type === "lines") {667      for (const w of step.wins) {668        const pts = w.positions.map(([r, y]) => [this.cellX(r), this.cellY(y)]);669        fx.moveTo(pts[0][0], pts[0][1]);670        for (const p of pts.slice(1)) fx.lineTo(p[0], p[1]);671        fx.stroke({ color: primary, alpha: 0.7, width: 3 });672      }673    }674    for (const k of winCells) {675      const [r, y] = k.split(":").map(Number);676      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 });677    }678    this.opts.onWinHighlight?.(step.win);679    const dur = (this.opts.quick ? 380 : 720) * this.speed;680    await this.animate(dur, (t) => {681      const pulse = 1 + Math.sin(t * Math.PI * 2) * 0.05;682      for (const k of winCells) {683        const [r, y] = k.split(":").map(Number);684        this.cells[r][y].sprite.scale.set(pulse);685      }686      fx.alpha = 0.6 + Math.sin(t * Math.PI * 4) * 0.4;687    });688    if (this.opts.intensity !== "low" && !this.opts.reduceMotion) {689      const cellsArr = [...winCells].slice(0, 12);690      for (const k of cellsArr) {691        const [r, y] = k.split(":").map(Number);692        this.burst(this.cellX(r), this.cellY(y), step.multiplier > 1 ? 6 : 3);693      }694    }695    // If a cascade follows, fade the winning symbols out.696    if (step.meta.removed?.length) {697      this.lastRemoved = step.meta.removed;698      const rem = step.meta.removed;699      await this.animate(200 * this.speed, (t) => {700        for (const [r, y] of rem) {701          const c = this.cells[r][y];702          c.sprite.alpha = 1 - t;703          c.sprite.scale.set(1 - t * 0.5);704        }705      });706    }707    fx.destroy();708    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)) {709      this.cells[r][y].sprite.alpha = 1;710      this.cells[r][y].sprite.scale.set(1);711    }712  }713714  private async explode(cells: [number, number][]) {715    for (const [r, y] of cells) this.burst(this.cellX(r), this.cellY(y), 8);716    await this.animate(240 * this.speed, (t) => {717      for (const [r, y] of cells) {718        const c = this.cells[r]?.[y];719        if (!c) continue;720        c.sprite.alpha = 1 - t;721        c.sprite.scale.set(1 + t * 0.4);722      }723    });724    this.lastRemoved = [...this.lastRemoved, ...cells];725  }726727  private async playRespin(step: SpinStep) {728    const coins = step.meta.coins ?? [];729    const cs = this.opts.def.holdRespin?.symbolId ?? "__";730    // Reels spin for blanks only.731    const grid = step.grid;732    if (step.meta.label === "Lock & Respin") {733      await wait(150 * this.speed);734    } else {735      this.startSpin();736      for (let r = 0; r < this.cols; r++) this.reelSpinning[r] = true;737      await wait(320 * this.speed);738    }739    this.spinning = false;740    this.reelSpinning = [];741    for (let r = 0; r < this.cols; r++)742      for (let y = 0; y < this.rows; y++) {743        const c = this.cells[r][y];744        this.swapTexture(c, grid[r][y] === cs ? cs : "__");745        (c.sprite.children[0] as Sprite).alpha = 1;746        c.sprite.y = this.cellY(y);747        c.sprite.alpha = grid[r][y] === cs ? 1 : 0.6;748      }749    this.badges.removeChildren();750    for (const coin of coins) {751      const label = coin.value !== null ? formatSC(coin.value, { unit: false }) : (coin.jackpot ?? "").toUpperCase();752      this.badge([coin.reel, coin.row], label, coin.jackpot ? 0xffd66b : 0xffffff, true);753      if (coin.isNew) {754        this.burst(this.cellX(coin.reel), this.cellY(coin.row), 6);755        this.opts.onCoin?.();756      }757    }758    this.currentGrid = grid.map((c) => c.slice());759    await wait((step.meta.label === "Lock & Respin" ? 500 : 420) * this.speed);760  }761762  /* --------------------------------------------------------------- effects */763764  private badge(pos: [number, number], text: string, color = 0xffd66b, center = false) {765    const [r, y] = pos;766    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 } }) });767    t.anchor.set(center ? 0.5 : 1, center ? 0.5 : 0);768    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);769    this.badges.addChild(t);770  }771772  private markCells(cells: [number, number][], color: number, text: string) {773    const g = new Graphics();774    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 });775    this.badges.addChild(g);776    if (text) for (const c of cells) this.badge(c, text, color);777  }778779  private async popCells(cells: [number, number][]) {780    for (const [r, y] of cells) this.burst(this.cellX(r), this.cellY(y), 5);781    await this.animate(260 * this.speed, (t) => {782      const e = easeOutBack(t);783      for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(0.6 + 0.4 * e);784    });785  }786787  private async flashReels(reels: number[]) {788    const g = new Graphics();789    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 });790    this.fxLayer.addChild(g);791    await this.animate(320 * this.speed, (t) => (g.alpha = 1 - t));792    g.destroy();793  }794795  private async pulseCells(cells: [number, number][], color = 0xffffff) {796    const g = new Graphics();797    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 });798    this.fxLayer.addChild(g);799    await this.animate(360 * this.speed, (t) => {800      g.alpha = 1 - t;801      for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(1 + Math.sin(t * Math.PI) * 0.12);802    });803    g.destroy();804    for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(1);805  }806807  burst(x: number, y: number, n: number) {808    if (!this.app || this.opts.reduceMotion) return;809    const tex = this.particleTexture(this.opts.def.presentation.particles);810    const count = this.opts.intensity === "high" ? n * 2 : n;811    for (let i = 0; i < count; i++) {812      const s = new Sprite(tex);813      s.anchor.set(0.5);814      s.position.set(x, y);815      s.scale.set(0.4 + Math.random() * 0.6);816      const a = Math.random() * Math.PI * 2;817      const v = 120 + Math.random() * 260;818      this.fxLayer.addChild(s);819      this.particles.push({ s, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 150, life: 0, max: 0.7 + Math.random() * 0.5 });820    }821  }822823  /** Big celebration shower across the whole canvas. */824  celebrate(intensity: number) {825    if (!this.app || this.opts.reduceMotion) return;826    const n = Math.min(160, 30 * intensity) * (this.opts.intensity === "high" ? 1 : 0.5);827    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);828  }829830  clearFx() {831    this.fxLayer.removeChildren();832    this.badges.removeChildren();833    this.lastRemoved = [];834    for (const col of this.cells)835      for (const c of col) {836        c.sprite.alpha = 1;837        c.sprite.scale.set(1);838        (c.sprite.children[0] as Sprite).alpha = 1;839      }840  }841842  private animate(durationMs: number, fn: (t: number) => void): Promise<void> {843    return new Promise((resolve) => {844      if (!this.app) return resolve();845      const start = performance.now();846      const step = () => {847        if (this.destroyed || !this.app) return resolve();848        const t = Math.min(1, (performance.now() - start) / Math.max(1, durationMs));849        fn(t);850        if (t >= 1) {851          this.app.ticker.remove(step);852          resolve();853        }854      };855      this.app.ticker.add(step);856    });857  }858}859860function hashString(s: string): number {861  let h = 2166136261;862  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);863  return h >>> 0;864}865866/** Deterministic PRNG for backdrop art only (never for outcomes). */867function mulberry32(seed: number) {868  let a = seed >>> 0;869  return () => {870    a = (a + 0x6d2b79f5) >>> 0;871    let t = a;872    t = Math.imul(t ^ (t >>> 15), t | 1);873    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);874    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;875  };876}877878function polygonRandom(g: Graphics, cx: number, cy: number, r: number, rnd: () => number): Graphics {879  const pts: number[] = [];880  const n = 6 + Math.floor(rnd() * 4);881  for (let i = 0; i < n; i++) {882    const a = (i / n) * Math.PI * 2;883    const rr = r * (0.7 + rnd() * 0.5);884    pts.push(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr);885  }886  return g.poly(pts);887}888889export { easeInOut };890
Files · 0b40ac1a7181cdec915de0b36dee3e78427df0c3
Menu

Theme