@energy8platform/game-engine 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/audio.cjs.js +15 -5
  2. package/dist/audio.cjs.js.map +1 -1
  3. package/dist/audio.d.ts +5 -0
  4. package/dist/audio.esm.js +15 -5
  5. package/dist/audio.esm.js.map +1 -1
  6. package/dist/core.cjs.js +108 -19
  7. package/dist/core.cjs.js.map +1 -1
  8. package/dist/core.d.ts +46 -3
  9. package/dist/core.esm.js +109 -21
  10. package/dist/core.esm.js.map +1 -1
  11. package/dist/game-spec.cjs.js +13 -0
  12. package/dist/game-spec.cjs.js.map +1 -0
  13. package/dist/game-spec.d.ts +1 -0
  14. package/dist/game-spec.esm.js +2 -0
  15. package/dist/game-spec.esm.js.map +1 -0
  16. package/dist/host.cjs.js +3612 -0
  17. package/dist/host.cjs.js.map +1 -0
  18. package/dist/host.d.ts +1000 -0
  19. package/dist/host.esm.js +3603 -0
  20. package/dist/host.esm.js.map +1 -0
  21. package/dist/index.cjs.js +59 -19
  22. package/dist/index.cjs.js.map +1 -1
  23. package/dist/index.d.ts +19 -2
  24. package/dist/index.esm.js +59 -19
  25. package/dist/index.esm.js.map +1 -1
  26. package/dist/react.cjs.js.map +1 -1
  27. package/dist/react.d.ts +19 -2
  28. package/dist/react.esm.js.map +1 -1
  29. package/dist/shell.cjs.js +19 -0
  30. package/dist/shell.cjs.js.map +1 -0
  31. package/dist/shell.d.ts +1 -0
  32. package/dist/shell.esm.js +2 -0
  33. package/dist/shell.esm.js.map +1 -0
  34. package/dist/slot.cjs.js +998 -0
  35. package/dist/slot.cjs.js.map +1 -0
  36. package/dist/slot.d.ts +333 -0
  37. package/dist/slot.esm.js +985 -0
  38. package/dist/slot.esm.js.map +1 -0
  39. package/package.json +28 -2
  40. package/src/audio/AudioManager.ts +17 -5
  41. package/src/core/GameApplication.ts +38 -6
  42. package/src/core/index.ts +2 -0
  43. package/src/game-spec/index.ts +1 -0
  44. package/src/host/autoplay.ts +78 -0
  45. package/src/host/balanceGate.ts +46 -0
  46. package/src/host/buildConfig.ts +28 -0
  47. package/src/host/createSlotGame.ts +543 -0
  48. package/src/host/fatalError.ts +104 -0
  49. package/src/host/freeSpinsCounter.ts +44 -0
  50. package/src/host/index.ts +21 -0
  51. package/src/host/overlayController.ts +81 -0
  52. package/src/host/pauseController.ts +21 -0
  53. package/src/host/playError.ts +64 -0
  54. package/src/host/preboot.ts +25 -0
  55. package/src/host/replay.ts +9 -0
  56. package/src/host/runRound.ts +55 -0
  57. package/src/host/sceneAudio.ts +14 -0
  58. package/src/host/sceneController.ts +96 -0
  59. package/src/host/sceneStart.ts +25 -0
  60. package/src/host/shellConfig.ts +384 -0
  61. package/src/host/skipGesture.ts +24 -0
  62. package/src/host/slotPlay.ts +62 -0
  63. package/src/host/types.ts +74 -0
  64. package/src/scenes/IntroScene.ts +66 -0
  65. package/src/shell/index.ts +20 -0
  66. package/src/slot/anim/CascadeController.ts +102 -0
  67. package/src/slot/anim/ReelSpinController.ts +81 -0
  68. package/src/slot/anim/easing-map.ts +14 -0
  69. package/src/slot/freeSpins/FreeSpinsSession.ts +40 -0
  70. package/src/slot/grid/AnimatedSymbol.ts +68 -0
  71. package/src/slot/grid/ReelGrid.ts +92 -0
  72. package/src/slot/grid/SymbolCell.ts +127 -0
  73. package/src/slot/grid/SymbolView.ts +13 -0
  74. package/src/slot/index.ts +21 -0
  75. package/src/slot/multiplier/MultiplierAccumulator.ts +29 -0
  76. package/src/slot/overlay/BigWinOverlay.ts +89 -0
  77. package/src/slot/overlay/CountUpDisplay.ts +56 -0
  78. package/src/slot/overlay/tiers.ts +29 -0
  79. package/src/types.ts +3 -0
  80. package/src/viewport/ViewportManager.ts +19 -9
@@ -0,0 +1,102 @@
1
+ // packages/game-engine/src/slot/anim/CascadeController.ts
2
+ import { Tween } from '../../animation';
3
+ import { EASING_BY_NAME } from './easing-map';
4
+ import type { ReelGrid } from '../grid/ReelGrid';
5
+ import type { CellData } from '../grid/SymbolCell';
6
+
7
+ export interface CascadeStepData {
8
+ winningCells: { col: number; row: number }[];
9
+ removedCells: { col: number; row: number }[];
10
+ newCells: { col: number; row: number; symbol: string }[];
11
+ settledGrid: CellData[][];
12
+ }
13
+ export interface CascadeTimings { reveal: number; highlight: number; remove: number; drop: number; refill: number; wait: number; }
14
+ export interface CascadeAnim {
15
+ col: number; row: number;
16
+ phase: 'reveal' | 'highlight' | 'remove' | 'drop' | 'refill';
17
+ from?: { x: number; y: number };
18
+ to?: { x: number; y: number };
19
+ scale?: number;
20
+ alpha?: number;
21
+ duration: number;
22
+ easing?: string;
23
+ delay?: number;
24
+ }
25
+
26
+ const DEFAULT_TIMINGS: CascadeTimings = { reveal: 300, highlight: 400, remove: 250, drop: 200, refill: 220, wait: 150 };
27
+
28
+ export class CascadeController {
29
+ private _grid: ReelGrid;
30
+ private _t: CascadeTimings;
31
+ private _killed = false;
32
+
33
+ constructor(grid: ReelGrid, timings?: Partial<CascadeTimings>) {
34
+ this._grid = grid;
35
+ this._t = { ...DEFAULT_TIMINGS, ...(timings ?? {}) };
36
+ }
37
+
38
+ /** PURE: ordered animation descriptors for a cascade step. */
39
+ plan(step: CascadeStepData, opts?: { turbo?: boolean }): CascadeAnim[] {
40
+ const f = opts?.turbo ? 0.5 : 1;
41
+ const out: CascadeAnim[] = [];
42
+
43
+ for (const w of step.winningCells) {
44
+ out.push({ col: w.col, row: w.row, phase: 'highlight', scale: 1.08, duration: this._t.highlight * f, easing: 'easeOutQuad' });
45
+ }
46
+ for (const w of step.winningCells) {
47
+ out.push({ col: w.col, row: w.row, phase: 'remove', scale: 0, alpha: 0, duration: this._t.remove * f, easing: 'easeInBack' });
48
+ }
49
+ // new cells drop from two row-heights above their target, staggered per column.
50
+ // Row height is derived purely from public geometry (no private grid access).
51
+ const rowStep = this._grid.cellPosition(0, 1).y - this._grid.cellPosition(0, 0).y;
52
+ const perCol: Record<number, number> = {};
53
+ for (const n of step.newCells) {
54
+ const to = this._grid.cellPosition(n.col, n.row);
55
+ const from = { x: to.x, y: to.y - rowStep * 2 };
56
+ const idx = (perCol[n.col] = (perCol[n.col] ?? 0) + 1);
57
+ out.push({ col: n.col, row: n.row, phase: 'drop', from, to, duration: this._t.drop * f, easing: 'easeOutBounce', delay: idx * 30 * f });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** Execute the plan via Tween. Not unit-tested (Ticker doesn't tick in node). */
63
+ async run(step: CascadeStepData, opts?: { turbo?: boolean }): Promise<void> {
64
+ this._killed = false;
65
+ const plan = this.plan(step, opts);
66
+ // highlight + remove first
67
+ for (const a of plan.filter((p) => p.phase === 'highlight')) {
68
+ if (this._killed) return;
69
+ const cell = this._grid.getCell(a.col, a.row);
70
+ cell.setState({ winning: true });
71
+ await Tween.to(cell, { 'scale.x': a.scale!, 'scale.y': a.scale! }, a.duration, EASING_BY_NAME[a.easing ?? 'easeOutQuad']);
72
+ }
73
+ for (const a of plan.filter((p) => p.phase === 'remove')) {
74
+ if (this._killed) return;
75
+ const cell = this._grid.getCell(a.col, a.row);
76
+ await Tween.to(cell, { 'scale.x': 0, 'scale.y': 0, alpha: 0 }, a.duration, EASING_BY_NAME[a.easing ?? 'easeInBack']);
77
+ }
78
+ // settle data, then drop new cells in
79
+ this._grid.setGrid(step.settledGrid);
80
+ await Promise.all(
81
+ plan.filter((p) => p.phase === 'drop').map(async (a) => {
82
+ if (this._killed) return;
83
+ const cell = this._grid.getCell(a.col, a.row);
84
+ cell.alpha = 1; cell.scale.set(1);
85
+ cell.position.set(a.from!.x, a.from!.y);
86
+ if (a.delay) await Tween.delay(a.delay);
87
+ await Tween.to(cell, { 'position.y': a.to!.y }, a.duration, EASING_BY_NAME[a.easing ?? 'easeOutBounce']);
88
+ }),
89
+ );
90
+ }
91
+
92
+ private _killOwnTweens(): void {
93
+ for (let c = 0; c < this._grid.cols; c++) {
94
+ for (let r = 0; r < this._grid.rows; r++) {
95
+ Tween.killTweensOf(this._grid.getCell(c, r));
96
+ }
97
+ }
98
+ }
99
+
100
+ skip(): void { this._killOwnTweens(); }
101
+ kill(): void { this._killed = true; this._killOwnTweens(); }
102
+ }
@@ -0,0 +1,81 @@
1
+ // packages/game-engine/src/slot/anim/ReelSpinController.ts
2
+ import { Tween, Easing } from '../../animation';
3
+ import type { ReelGrid } from '../grid/ReelGrid';
4
+ import type { CellData } from '../grid/SymbolCell';
5
+
6
+ export interface ReelSpinData { targetGrid: CellData[][]; strip?: (reel: number) => string[]; }
7
+ export interface ReelSpinTimings { spinUp: number; hold: number; stopStagger: number; settle: number; }
8
+ export interface ReelStopPlan {
9
+ reel: number;
10
+ stopTime: number;
11
+ landing: CellData[];
12
+ settle: { amp: number; ms: number };
13
+ }
14
+
15
+ const DEFAULT_TIMINGS: ReelSpinTimings = { spinUp: 500, hold: 200, stopStagger: 120, settle: 240 };
16
+
17
+ export class ReelSpinController {
18
+ private _grid: ReelGrid;
19
+ private _t: ReelSpinTimings;
20
+ private _killed = false;
21
+
22
+ constructor(grid: ReelGrid, timings?: Partial<ReelSpinTimings>) {
23
+ this._grid = grid;
24
+ this._t = { ...DEFAULT_TIMINGS, ...(timings ?? {}) };
25
+ }
26
+
27
+ /** PURE: per-reel stop timing + landing window. No Pixi mutation. */
28
+ plan(data: ReelSpinData, opts?: { turbo?: boolean }): ReelStopPlan[] {
29
+ const f = opts?.turbo ? 0.5 : 1;
30
+ const out: ReelStopPlan[] = [];
31
+ for (let reel = 0; reel < this._grid.cols; reel++) {
32
+ out.push({
33
+ reel,
34
+ stopTime: this._t.spinUp * f + reel * this._t.stopStagger * f,
35
+ landing: data.targetGrid[reel] ?? [],
36
+ settle: { amp: 7, ms: this._t.settle * f },
37
+ });
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Execute the spin: scroll each reel, decelerate, land on target, settle-bounce. Not unit-tested. */
43
+ async run(data: ReelSpinData, opts?: { turbo?: boolean }): Promise<void> {
44
+ this._killed = false;
45
+ const plan = this.plan(data, opts);
46
+ await Promise.all(
47
+ plan.map(async (p) => {
48
+ if (this._killed) return;
49
+ const strip = data.strip?.(p.reel) ?? p.landing.map((c) => c.symbol ?? '');
50
+ // texture-swap spin: cycle symbols quickly while decelerating, then land
51
+ const cells = Array.from({ length: this._grid.rows }, (_, r) => this._grid.getCell(p.reel, r));
52
+ const ticks = Math.max(6, Math.floor(p.stopTime / 60));
53
+ for (let i = 0; i < ticks; i++) {
54
+ if (this._killed) break;
55
+ for (let r = 0; r < cells.length; r++) {
56
+ const sym = strip[(i + r) % strip.length] || null;
57
+ cells[r].setData({ symbol: sym });
58
+ }
59
+ await Tween.delay(Math.min(60, p.stopTime / ticks));
60
+ }
61
+ // land on the real target
62
+ for (let r = 0; r < cells.length; r++) cells[r].setData(p.landing[r] ?? { symbol: null });
63
+ // settle bounce on the column parent
64
+ if (!this._killed && cells[0]?.parent) {
65
+ const colY = cells[0].parent.y;
66
+ await Tween.fromTo(cells[0].parent, { y: colY - p.settle.amp }, { y: colY }, p.settle.ms, Easing.easeOutBack);
67
+ }
68
+ }),
69
+ );
70
+ }
71
+
72
+ private _killOwnTweens(): void {
73
+ for (let c = 0; c < this._grid.cols; c++) {
74
+ for (let r = 0; r < this._grid.rows; r++) {
75
+ Tween.killTweensOf(this._grid.getCell(c, r));
76
+ }
77
+ }
78
+ }
79
+
80
+ skip(): void { this._killed = true; this._killOwnTweens(); }
81
+ }
@@ -0,0 +1,14 @@
1
+ // packages/game-engine/src/slot/anim/easing-map.ts
2
+ import { Easing } from '../../animation';
3
+ import type { EasingFunction } from '../../types';
4
+
5
+ /** Resolve a descriptor's string easing name to the engine's easing function. */
6
+ export const EASING_BY_NAME: Record<string, EasingFunction> = {
7
+ linear: Easing.linear,
8
+ easeOutQuad: Easing.easeOutQuad,
9
+ easeOutCubic: Easing.easeOutCubic,
10
+ easeOutBack: Easing.easeOutBack,
11
+ easeOutBounce: Easing.easeOutBounce,
12
+ easeInBack: Easing.easeInBack,
13
+ easeInOutCubic: Easing.easeInOutCubic,
14
+ };
@@ -0,0 +1,40 @@
1
+ import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
2
+
3
+ export interface FreeSpinsSessionConfig {
4
+ initialSpins: number;
5
+ /** Optional: extra spins to award from a result (retrigger). Default: none. */
6
+ retrigger?: (result: SlotSpinResultBase) => number;
7
+ /** Optional hard exit (e.g. max-win reached). */
8
+ isMaxWin?: () => boolean;
9
+ }
10
+
11
+ /** Headless free-spins state machine. The scene drives it; rendering/HUD reflect it. */
12
+ export class FreeSpinsSession {
13
+ remaining: number;
14
+ total: number;
15
+ totalWin = 0;
16
+ private readonly cfg: FreeSpinsSessionConfig;
17
+
18
+ constructor(cfg: FreeSpinsSessionConfig) {
19
+ this.cfg = cfg;
20
+ this.remaining = cfg.initialSpins;
21
+ this.total = cfg.initialSpins;
22
+ }
23
+
24
+ award(extra: number): void {
25
+ if (extra > 0) { this.remaining += extra; this.total += extra; }
26
+ }
27
+
28
+ /** Convenience: award using the configured retrigger rule. */
29
+ applyRetrigger(result: SlotSpinResultBase): void {
30
+ this.award(this.cfg.retrigger?.(result) ?? 0);
31
+ }
32
+
33
+ addWin(amount: number): void { this.totalWin += amount; }
34
+
35
+ consume(): void { if (this.remaining > 0) this.remaining -= 1; }
36
+
37
+ get isComplete(): boolean {
38
+ return this.remaining <= 0 || (this.cfg.isMaxWin?.() ?? false);
39
+ }
40
+ }
@@ -0,0 +1,68 @@
1
+ import { Container, Sprite, AnimatedSprite, type Texture } from 'pixi.js';
2
+ import { SpriteAnimation } from '../../animation';
3
+ import type { SymbolView } from './SymbolView';
4
+
5
+ export interface SymbolTextures { base: Texture; idle?: Texture[]; win?: Texture[]; }
6
+ export interface AnimatedSymbolConfig { textures: SymbolTextures; size: number; fps?: number; }
7
+
8
+ /** Built-in SymbolView: a static base sprite with optional idle/win spritesheet frames. */
9
+ export class AnimatedSymbol extends Container implements SymbolView {
10
+ private _base: Sprite;
11
+ private _anim: AnimatedSprite | null = null;
12
+ private _textures: SymbolTextures;
13
+ private _size: number;
14
+ private _fps: number;
15
+
16
+ constructor(config: AnimatedSymbolConfig) {
17
+ super();
18
+ this._textures = config.textures;
19
+ this._size = config.size;
20
+ this._fps = config.fps ?? 24;
21
+ this._base = new Sprite(config.textures.base);
22
+ this._base.anchor.set(0.5);
23
+ this.addChild(this._base);
24
+ this.resize(this._size);
25
+ }
26
+
27
+ setTextures(t: SymbolTextures): void {
28
+ this._textures = t;
29
+ this._base.texture = t.base;
30
+ this.showStatic();
31
+ }
32
+
33
+ resize(size: number): void {
34
+ this._size = size;
35
+ this._base.width = size;
36
+ this._base.height = size;
37
+ if (this._anim) { this._anim.width = size; this._anim.height = size; }
38
+ }
39
+
40
+ showStatic(): void {
41
+ if (this._anim) { this._anim.destroy(); this._anim = null; }
42
+ this._base.visible = true;
43
+ }
44
+
45
+ playIdle(): void {
46
+ if (!this._textures.idle?.length) return;
47
+ this._swap(this._textures.idle, true);
48
+ }
49
+
50
+ playWin(): Promise<void> {
51
+ if (!this._textures.win?.length) return Promise.resolve();
52
+ return new Promise<void>((resolve) => {
53
+ this._swap(this._textures.win!, false, () => { this.showStatic(); resolve(); });
54
+ });
55
+ }
56
+
57
+ private _swap(frames: Texture[], loop: boolean, onComplete?: () => void): void {
58
+ if (this._anim) { this._anim.destroy(); this._anim = null; }
59
+ this._base.visible = false;
60
+ const a = SpriteAnimation.create(frames, { loop, autoPlay: true, onComplete });
61
+ a.anchor.set(0.5);
62
+ a.width = this._size;
63
+ a.height = this._size;
64
+ a.animationSpeed = this._fps / 60;
65
+ this.addChild(a);
66
+ this._anim = a;
67
+ }
68
+ }
@@ -0,0 +1,92 @@
1
+ import { Container, Graphics, Sprite, type Texture } from 'pixi.js';
2
+ import { SymbolCell, type CellData, type CellFrameStyle } from './SymbolCell';
3
+ import type { SymbolResolver } from './SymbolView';
4
+
5
+ export interface DecorationConfig { texture?: Texture; padding?: number; }
6
+ export interface ReelGridConfig {
7
+ cols: number; rows: number; cellSize: number; gap?: number;
8
+ resolve: SymbolResolver; frameStyle?: CellFrameStyle;
9
+ decoration?: DecorationConfig;
10
+ mask?: boolean;
11
+ }
12
+
13
+ export class ReelGrid extends Container {
14
+ readonly __uiComponent = true as const;
15
+
16
+ private _cols: number;
17
+ private _rows: number;
18
+ private _cellSize: number;
19
+ private _gap: number;
20
+ private _cells: SymbolCell[][] = [];
21
+ private _cellLayer = new Container();
22
+
23
+ constructor(config: ReelGridConfig) {
24
+ super();
25
+ this._cols = config.cols;
26
+ this._rows = config.rows;
27
+ this._cellSize = config.cellSize;
28
+ this._gap = config.gap ?? 0;
29
+
30
+ if (config.decoration) {
31
+ const pad = config.decoration.padding ?? 0;
32
+ const w = this._cols * (this._cellSize + this._gap) - this._gap + pad * 2;
33
+ const h = this._rows * (this._cellSize + this._gap) - this._gap + pad * 2;
34
+ if (config.decoration.texture) {
35
+ const deco = new Sprite(config.decoration.texture);
36
+ deco.width = w; deco.height = h; deco.position.set(-pad - this._cellSize / 2, -pad - this._cellSize / 2);
37
+ this.addChild(deco);
38
+ }
39
+ }
40
+
41
+ this.addChild(this._cellLayer);
42
+
43
+ for (let c = 0; c < this._cols; c++) {
44
+ this._cells[c] = [];
45
+ for (let r = 0; r < this._rows; r++) {
46
+ const cell = new SymbolCell({ size: this._cellSize, resolve: config.resolve, frameStyle: config.frameStyle });
47
+ const { x, y } = this.cellPosition(c, r);
48
+ cell.position.set(x, y);
49
+ this._cellLayer.addChild(cell);
50
+ this._cells[c][r] = cell;
51
+ }
52
+ }
53
+
54
+ if (config.mask) {
55
+ const w = this._cols * (this._cellSize + this._gap) - this._gap;
56
+ const h = this._rows * (this._cellSize + this._gap) - this._gap;
57
+ const m = new Graphics()
58
+ .rect(-this._cellSize / 2, -this._cellSize / 2, w, h)
59
+ .fill(0xffffff);
60
+ this._cellLayer.mask = m;
61
+ this.addChild(m);
62
+ }
63
+ }
64
+
65
+ get cols(): number { return this._cols; }
66
+ get rows(): number { return this._rows; }
67
+
68
+ cellPosition(col: number, row: number): { x: number; y: number } {
69
+ const step = this._cellSize + this._gap;
70
+ return { x: col * step, y: row * step };
71
+ }
72
+
73
+ getCell(col: number, row: number): SymbolCell { return this._cells[col][row]; }
74
+
75
+ setGrid(cells: CellData[][]): void {
76
+ for (let c = 0; c < this._cols; c++) {
77
+ for (let r = 0; r < this._rows; r++) {
78
+ this._cells[c]?.[r]?.setData(cells[c]?.[r] ?? { symbol: null });
79
+ }
80
+ }
81
+ }
82
+
83
+ resize(cellSize: number): void {
84
+ this._cellSize = cellSize;
85
+ for (let c = 0; c < this._cols; c++) {
86
+ for (let r = 0; r < this._rows; r++) {
87
+ const { x, y } = this.cellPosition(c, r);
88
+ this._cells[c][r].position.set(x, y);
89
+ }
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,127 @@
1
+ import { Container, Graphics, Text } from 'pixi.js';
2
+ import { Tween, Easing } from '../../animation';
3
+ import type { SymbolResolver, SymbolView } from './SymbolView';
4
+
5
+ export interface CellFrameStyle {
6
+ radius?: number;
7
+ idle?: { color: number; alpha: number };
8
+ winning?: { color: number; alpha: number };
9
+ removed?: { color: number; alpha: number };
10
+ fresh?: { color: number; alpha: number };
11
+ }
12
+ export interface CellData {
13
+ symbol: string | null;
14
+ multiplier?: number;
15
+ bonus?: number;
16
+ sticky?: { remaining: number };
17
+ }
18
+ export interface CellState { winning?: boolean; removed?: boolean; fresh?: boolean; }
19
+ export interface SymbolCellConfig { size: number; resolve: SymbolResolver; frameStyle?: CellFrameStyle; }
20
+
21
+ const DEFAULT_STYLE: Required<CellFrameStyle> = {
22
+ radius: 8,
23
+ idle: { color: 0x223047, alpha: 0.34 },
24
+ winning: { color: 0x00d4ff, alpha: 0.95 },
25
+ removed: { color: 0x223047, alpha: 0.12 },
26
+ fresh: { color: 0xffffff, alpha: 0.6 },
27
+ };
28
+
29
+ export class SymbolCell extends Container {
30
+ readonly __uiComponent = true as const;
31
+
32
+ private _size: number;
33
+ private _resolve: SymbolResolver;
34
+ private _style: Required<CellFrameStyle>;
35
+ private _frame: Graphics;
36
+ private _view: SymbolView | null = null;
37
+ private _badges = new Container();
38
+ private _multBadge: Container | null = null;
39
+ private _bonusBadge: Container | null = null;
40
+ /** Last applied state key — exposed for tests/inspection. */
41
+ frameStyleKey: 'idle' | 'winning' | 'removed' | 'fresh' = 'idle';
42
+
43
+ constructor(config: SymbolCellConfig) {
44
+ super();
45
+ this._size = config.size;
46
+ this._resolve = config.resolve;
47
+ this._style = { ...DEFAULT_STYLE, ...(config.frameStyle ?? {}) } as Required<CellFrameStyle>;
48
+ this._frame = new Graphics();
49
+ this.addChild(this._frame);
50
+ this.addChild(this._badges);
51
+ this._drawFrame('idle');
52
+ }
53
+
54
+ get view(): SymbolView | null { return this._view; }
55
+
56
+ setData(data: CellData): void {
57
+ // symbol view
58
+ if (data.symbol == null) {
59
+ if (this._view) { this._view.destroy(); this._view = null; }
60
+ } else {
61
+ if (this._view) { this._view.destroy(); this._view = null; }
62
+ const v = this._resolve(data.symbol);
63
+ if (v) {
64
+ v.resize?.(this._size);
65
+ this.addChildAt(v, 1); // above frame, below badges
66
+ this._view = v;
67
+ }
68
+ }
69
+ // badges
70
+ this._setMultiplier(data.multiplier);
71
+ this._setBonus(data.bonus);
72
+ }
73
+
74
+ setState(state: CellState): void {
75
+ const key = state.winning ? 'winning' : state.removed ? 'removed' : state.fresh ? 'fresh' : 'idle';
76
+ this.frameStyleKey = key;
77
+ this._drawFrame(key);
78
+ }
79
+
80
+ playWin(): Promise<void> {
81
+ if (this._view?.playWin) return this._view.playWin();
82
+ // default: scale pop
83
+ const target = this._view ?? this;
84
+ return Tween.to(target, { 'scale.x': 1.15, 'scale.y': 1.15 }, 160, Easing.easeOutBack)
85
+ .then(() => Tween.to(target, { 'scale.x': 1, 'scale.y': 1 }, 140, Easing.easeOutQuad));
86
+ }
87
+
88
+ playIdle(): void { this._view?.playIdle?.(); }
89
+
90
+ hasBadge(kind: 'multiplier' | 'bonus'): boolean {
91
+ return kind === 'multiplier' ? this._multBadge != null : this._bonusBadge != null;
92
+ }
93
+
94
+ private _drawFrame(key: 'idle' | 'winning' | 'removed' | 'fresh'): void {
95
+ const s = this._style[key];
96
+ this._frame.clear();
97
+ this._frame
98
+ .roundRect(-this._size / 2, -this._size / 2, this._size, this._size, this._style.radius)
99
+ .fill({ color: s.color, alpha: s.alpha });
100
+ // store the colour as tint for cheap inspection/testing
101
+ this._frame.tint = s.color;
102
+ }
103
+
104
+ private _setMultiplier(value?: number): void {
105
+ if (this._multBadge) { this._multBadge.destroy(); this._multBadge = null; }
106
+ if (!value || value <= 1) return;
107
+ this._multBadge = this._badge(`×${value}`, 0xffd24a);
108
+ this._multBadge.position.set(this._size / 2 - 12, -this._size / 2 + 12);
109
+ this._badges.addChild(this._multBadge);
110
+ }
111
+
112
+ private _setBonus(value?: number): void {
113
+ if (this._bonusBadge) { this._bonusBadge.destroy(); this._bonusBadge = null; }
114
+ if (!value || value <= 0) return;
115
+ this._bonusBadge = this._badge(`+${value}`, 0x7ad7ff);
116
+ this._bonusBadge.position.set(-this._size / 2 + 12, -this._size / 2 + 12);
117
+ this._badges.addChild(this._bonusBadge);
118
+ }
119
+
120
+ private _badge(label: string, color: number): Container {
121
+ const c = new Container();
122
+ const t = new Text({ text: label, style: { fontSize: 18, fill: color, fontWeight: '700' } });
123
+ t.anchor.set(0.5);
124
+ c.addChild(t);
125
+ return c;
126
+ }
127
+ }
@@ -0,0 +1,13 @@
1
+ import type { Container } from 'pixi.js';
2
+
3
+ /** A symbol's visual. Any Container; optionally implements the lifecycle the cell drives. */
4
+ export interface SymbolView extends Container {
5
+ playIdle?(): void;
6
+ playWin?(): Promise<void>;
7
+ showStatic?(): void;
8
+ /** Resize the symbol to the given cell size in pixels. */
9
+ resize?(size: number): void;
10
+ }
11
+
12
+ /** Game-supplied factory: build the view for a symbol id (sprite / layered sprites / Spine / composite). */
13
+ export type SymbolResolver = (symbolId: string) => SymbolView | null;
@@ -0,0 +1,21 @@
1
+ export type { SymbolView, SymbolResolver } from './grid/SymbolView';
2
+ export { AnimatedSymbol } from './grid/AnimatedSymbol';
3
+ export type { SymbolTextures, AnimatedSymbolConfig } from './grid/AnimatedSymbol';
4
+ export { SymbolCell } from './grid/SymbolCell';
5
+ export type { CellFrameStyle, CellData, CellState, SymbolCellConfig } from './grid/SymbolCell';
6
+ export { ReelGrid } from './grid/ReelGrid';
7
+ export type { DecorationConfig, ReelGridConfig } from './grid/ReelGrid';
8
+ export { CascadeController } from './anim/CascadeController';
9
+ export type { CascadeStepData, CascadeTimings, CascadeAnim } from './anim/CascadeController';
10
+ export { ReelSpinController } from './anim/ReelSpinController';
11
+ export type { ReelSpinData, ReelSpinTimings, ReelStopPlan } from './anim/ReelSpinController';
12
+ export { pickTier, tierIndexAtValue } from './overlay/tiers';
13
+ export type { WinTier } from './overlay/tiers';
14
+ export { valueAt, CountUpDisplay } from './overlay/CountUpDisplay';
15
+ export type { CountUpConfig } from './overlay/CountUpDisplay';
16
+ export { BigWinOverlay } from './overlay/BigWinOverlay';
17
+ export type { BigWinOverlayConfig } from './overlay/BigWinOverlay';
18
+ export { FreeSpinsSession } from './freeSpins/FreeSpinsSession';
19
+ export type { FreeSpinsSessionConfig } from './freeSpins/FreeSpinsSession';
20
+ export { MultiplierAccumulator } from './multiplier/MultiplierAccumulator';
21
+ export type { CarryPolicy } from './multiplier/MultiplierAccumulator';
@@ -0,0 +1,29 @@
1
+ /** The boundary at which a collected multiplier resets. */
2
+ export type CarryPolicy = 'spin' | 'cascade' | 'session';
3
+
4
+ // How long each policy survives: cascade (shortest) < spin < session (longest).
5
+ const RANK: Record<CarryPolicy, number> = { cascade: 0, spin: 1, session: 2 };
6
+
7
+ /**
8
+ * Headless sticky/collector multiplier — the unified abstraction behind
9
+ * kitsunebi / recipe / orb / stage multipliers. reset(boundary) clears the
10
+ * value only when the boundary is at or above the configured policy scope.
11
+ */
12
+ export class MultiplierAccumulator {
13
+ value: number;
14
+ private readonly base: number;
15
+ private readonly policy: CarryPolicy;
16
+
17
+ constructor(cfg: { policy: CarryPolicy; base?: number }) {
18
+ this.policy = cfg.policy;
19
+ this.base = cfg.base ?? 1;
20
+ this.value = this.base;
21
+ }
22
+
23
+ add(delta: number): void { this.value += delta; }
24
+ set(value: number): void { this.value = value; }
25
+
26
+ reset(boundary: CarryPolicy): void {
27
+ if (RANK[boundary] >= RANK[this.policy]) this.value = this.base;
28
+ }
29
+ }
@@ -0,0 +1,89 @@
1
+ import { Container, Graphics, Sprite, Text } from 'pixi.js';
2
+ import { Tween, Easing } from '../../animation';
3
+ import { pickTier, tierIndexAtValue, type WinTier } from './tiers';
4
+ import { CountUpDisplay } from './CountUpDisplay';
5
+
6
+ export interface BigWinOverlayConfig {
7
+ tiers: WinTier[];
8
+ formatMoney: (v: number) => string;
9
+ countUpDuration?: (win: number) => number;
10
+ particleCount?: number;
11
+ width: number;
12
+ height: number;
13
+ }
14
+
15
+ const DEFAULT_DURATION = (win: number) => Math.min(2500, Math.max(800, win * 20));
16
+
17
+ export class BigWinOverlay extends Container {
18
+ readonly __uiComponent = true as const;
19
+
20
+ private _cfg: BigWinOverlayConfig;
21
+ private _dim: Graphics;
22
+ private _banner: Sprite | null = null;
23
+ private _title: Text;
24
+ private _count: CountUpDisplay;
25
+
26
+ constructor(config: BigWinOverlayConfig) {
27
+ super();
28
+ this._cfg = config;
29
+ this.visible = false;
30
+
31
+ this._dim = new Graphics();
32
+ this.addChild(this._dim);
33
+
34
+ this._title = new Text({ text: '', style: { fontFamily: 'sans-serif', fontSize: 72, fill: 0xffffff, fontWeight: '900' } });
35
+ this._title.anchor.set(0.5);
36
+ this.addChild(this._title);
37
+
38
+ this._count = new CountUpDisplay({ format: config.formatMoney });
39
+ this.addChild(this._count);
40
+
41
+ this.resize(config.width, config.height);
42
+ }
43
+
44
+ /** Pure helper (testable): the tier title for a given win/bet, or null below the lowest tier. */
45
+ tierTitleFor(win: number, bet: number): string | null {
46
+ return pickTier(this._cfg.tiers, win, bet)?.title ?? null;
47
+ }
48
+
49
+ resize(width: number, height: number): void {
50
+ this._cfg.width = width; this._cfg.height = height;
51
+ this._dim.clear();
52
+ this._dim.rect(0, 0, width, height).fill({ color: 0x000000, alpha: 0.72 });
53
+ this._title.position.set(width / 2, height * 0.4);
54
+ this._count.position.set(width / 2, height * 0.56);
55
+ }
56
+
57
+ async show(win: number, bet: number, format?: (v: number) => string): Promise<void> {
58
+ const tier = pickTier(this._cfg.tiers, win, bet);
59
+ if (!tier) return;
60
+ if (format) this._count.setFormat(format);
61
+ this.visible = true;
62
+ this.alpha = 0;
63
+ this._title.text = tier.title;
64
+ this._title.style.fill = tier.accentColor;
65
+
66
+ if (this._banner) { this._banner.destroy(); this._banner = null; }
67
+ if (tier.bannerTexture) {
68
+ this._banner = new Sprite(tier.bannerTexture);
69
+ this._banner.anchor.set(0.5);
70
+ this._banner.position.set(this._cfg.width / 2, this._cfg.height * 0.4);
71
+ this.addChildAt(this._banner, 1);
72
+ }
73
+
74
+ await Tween.to(this, { alpha: 1 }, 200, Easing.easeOutQuad);
75
+ const dur = (this._cfg.countUpDuration ?? DEFAULT_DURATION)(win);
76
+ let lastIdx = tierIndexAtValue(this._cfg.tiers, 0, bet);
77
+ await this._count.countTo(win, dur, undefined);
78
+ // tier-promotion title updates as the value climbs (sampled at the end for simplicity)
79
+ const finalIdx = tierIndexAtValue(this._cfg.tiers, win, bet);
80
+ if (finalIdx > lastIdx && this._cfg.tiers[finalIdx]) {
81
+ this._title.text = this._cfg.tiers[finalIdx].title;
82
+ }
83
+ void lastIdx;
84
+ }
85
+
86
+ skip(): void { Tween.killTweensOf(this); this._count.skip(); }
87
+
88
+ hide(): void { this.visible = false; Tween.killTweensOf(this); }
89
+ }