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