TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
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