@energy8platform/game-engine 0.20.0 → 0.22.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 (93) hide show
  1. package/README.md +121 -0
  2. package/dist/animation.cjs.js +18 -3
  3. package/dist/animation.cjs.js.map +1 -1
  4. package/dist/animation.esm.js +18 -3
  5. package/dist/animation.esm.js.map +1 -1
  6. package/dist/core.cjs.js +18 -3
  7. package/dist/core.cjs.js.map +1 -1
  8. package/dist/core.esm.js +18 -3
  9. package/dist/core.esm.js.map +1 -1
  10. package/dist/host.cjs.js +18 -3
  11. package/dist/host.cjs.js.map +1 -1
  12. package/dist/host.d.ts +3 -5
  13. package/dist/host.esm.js +18 -3
  14. package/dist/host.esm.js.map +1 -1
  15. package/dist/index.cjs.js +18 -2535
  16. package/dist/index.cjs.js.map +1 -1
  17. package/dist/index.d.ts +3 -1020
  18. package/dist/index.esm.js +20 -2525
  19. package/dist/index.esm.js.map +1 -1
  20. package/dist/slot.cjs.js +1872 -61
  21. package/dist/slot.cjs.js.map +1 -1
  22. package/dist/slot.d.ts +555 -28
  23. package/dist/slot.esm.js +1857 -62
  24. package/dist/slot.esm.js.map +1 -1
  25. package/dist/vite.cjs.js +0 -5
  26. package/dist/vite.cjs.js.map +1 -1
  27. package/dist/vite.esm.js +0 -5
  28. package/dist/vite.esm.js.map +1 -1
  29. package/package.json +2 -35
  30. package/src/animation/Tween.ts +14 -3
  31. package/src/host/index.ts +0 -1
  32. package/src/host/types.ts +0 -3
  33. package/src/index.ts +0 -28
  34. package/src/slot/anim/easing-map.ts +16 -2
  35. package/src/slot/cascade/TumbleController.ts +230 -0
  36. package/src/slot/config/ReelSystemConfig.ts +559 -0
  37. package/src/slot/config/presets.ts +222 -0
  38. package/src/slot/features/extra.ts +189 -0
  39. package/src/slot/features/index.ts +44 -0
  40. package/src/slot/features/symbols.ts +183 -0
  41. package/src/slot/features/types.ts +136 -0
  42. package/src/slot/features/wilds.ts +151 -0
  43. package/src/slot/grid/ReelGrid.ts +93 -24
  44. package/src/slot/grid/SymbolCell.ts +45 -11
  45. package/src/slot/index.ts +61 -2
  46. package/src/slot/motion/AnticipationController.ts +96 -0
  47. package/src/slot/motion/SpinEngine.ts +384 -0
  48. package/src/slot/system/ReelSystem.ts +295 -0
  49. package/src/vite/index.ts +0 -5
  50. package/dist/react-jsx.cjs.js +0 -3
  51. package/dist/react-jsx.cjs.js.map +0 -1
  52. package/dist/react-jsx.d.ts +0 -602
  53. package/dist/react-jsx.esm.js +0 -2
  54. package/dist/react-jsx.esm.js.map +0 -1
  55. package/dist/react.cjs.js +0 -3787
  56. package/dist/react.cjs.js.map +0 -1
  57. package/dist/react.d.ts +0 -1467
  58. package/dist/react.esm.js +0 -3771
  59. package/dist/react.esm.js.map +0 -1
  60. package/dist/ui.cjs.js +0 -2999
  61. package/dist/ui.cjs.js.map +0 -1
  62. package/dist/ui.d.ts +0 -1116
  63. package/dist/ui.esm.js +0 -2983
  64. package/dist/ui.esm.js.map +0 -1
  65. package/src/react/EngineContext.ts +0 -26
  66. package/src/react/ReactScene.ts +0 -88
  67. package/src/react/applyProps.ts +0 -271
  68. package/src/react/catalogue.ts +0 -17
  69. package/src/react/createPixiRoot.ts +0 -31
  70. package/src/react/extendAll.ts +0 -74
  71. package/src/react/hooks.ts +0 -46
  72. package/src/react/index.ts +0 -29
  73. package/src/react/jsx-runtime.ts +0 -346
  74. package/src/react/reconciler.ts +0 -338
  75. package/src/slot/freeSpins/FreeSpinsSession.ts +0 -40
  76. package/src/state/StateMachine.ts +0 -231
  77. package/src/state/index.ts +0 -1
  78. package/src/ui/BalanceDisplay.ts +0 -159
  79. package/src/ui/Button.ts +0 -304
  80. package/src/ui/FlexContainer.ts +0 -775
  81. package/src/ui/Label.ts +0 -124
  82. package/src/ui/LabelValue.ts +0 -122
  83. package/src/ui/Layout.ts +0 -291
  84. package/src/ui/Modal.ts +0 -170
  85. package/src/ui/Panel.ts +0 -204
  86. package/src/ui/ProgressBar.ts +0 -170
  87. package/src/ui/ScrollContainer.ts +0 -478
  88. package/src/ui/Slider.ts +0 -241
  89. package/src/ui/Toast.ts +0 -150
  90. package/src/ui/Toggle.ts +0 -201
  91. package/src/ui/WinDisplay.ts +0 -145
  92. package/src/ui/index.ts +0 -33
  93. package/src/ui/view.ts +0 -28
@@ -0,0 +1,136 @@
1
+ // packages/game-engine/src/slot/features/types.ts
2
+ //
3
+ // Uniform interface for special reel feature mechanics. Each feature is a presentation module:
4
+ // given the current board + its config (and optional per-spin data), it plays an animation that
5
+ // visualises the mechanic. `demo()` is the self-contained showcase the reel-lab playground triggers.
6
+
7
+ import { Container, Graphics, Text } from 'pixi.js';
8
+ import { Tween } from '../../animation';
9
+ import { easingByName } from '../anim/easing-map';
10
+ import type { ReelGrid } from '../grid/ReelGrid';
11
+ import type { SymbolCell, CellData } from '../grid/SymbolCell';
12
+ import type { SymbolResolver } from '../grid/SymbolView';
13
+ import type { FeatureKey, ReelSystemConfig } from '../config/ReelSystemConfig';
14
+
15
+ export interface FeatureContext {
16
+ grid: ReelGrid;
17
+ resolve: SymbolResolver;
18
+ cfg: ReelSystemConfig;
19
+ /** Overlay layer above the grid for rings/labels/sprites. */
20
+ fx: Container;
21
+ /** Mutable view of the current board (featureN may rewrite it). */
22
+ board: CellData[][];
23
+ /** Whether the current spin is a free-spin (gates onlyInFreeSpins features). */
24
+ freeSpins?: boolean;
25
+ /** Emit a human-readable note for the playground log. */
26
+ log?: (msg: string) => void;
27
+ }
28
+
29
+ export interface ReelFeature {
30
+ /** Unique id. Built-ins use a `FeatureKey`; custom features may use any string. */
31
+ readonly key: FeatureKey | string;
32
+ readonly label: string;
33
+ /** Whether this feature is active for the given config. Custom features can just return `true`. */
34
+ enabled(cfg: ReelSystemConfig): boolean;
35
+ /** Run the feature presentation on the current board. */
36
+ demo(ctx: FeatureContext): Promise<void>;
37
+ }
38
+
39
+ // ── shared animation helpers ────────────────────────────────────────────────
40
+
41
+ /** Center position of a cell, in fx-layer coordinates (fx shares the grid's transform). */
42
+ export function cellCenter(grid: ReelGrid, col: number, row: number): { x: number; y: number } {
43
+ return grid.cellPosition(col, row);
44
+ }
45
+
46
+ export function rowStepOf(grid: ReelGrid): number {
47
+ return grid.cellPosition(0, 1).y - grid.cellPosition(0, 0).y;
48
+ }
49
+
50
+ /** A glowing ring drawn around a cell. Returns a disposer. */
51
+ export function glowRing(
52
+ fx: Container,
53
+ grid: ReelGrid,
54
+ col: number,
55
+ row: number,
56
+ color: number,
57
+ ): () => void {
58
+ if (fx.destroyed) return () => {};
59
+ const { x, y } = cellCenter(grid, col, row);
60
+ const s = grid.cellSize;
61
+ const g = new Graphics()
62
+ .roundRect(x - s / 2, y - s / 2, s, s, 10)
63
+ .stroke({ color, width: 3, alpha: 0.9 });
64
+ fx.addChild(g);
65
+ return () => g.destroy();
66
+ }
67
+
68
+ /** Pulse a cell up and back. */
69
+ export async function pulseCell(cell: SymbolCell, scale = 1.15, ms = 220): Promise<void> {
70
+ if (cell.destroyed) return;
71
+ await Tween.to(
72
+ cell,
73
+ { 'scale.x': scale, 'scale.y': scale },
74
+ ms * 0.5,
75
+ easingByName('easeOutBack'),
76
+ );
77
+ if (cell.destroyed) return;
78
+ await Tween.to(cell, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.5, easingByName('easeOutQuad'));
79
+ }
80
+
81
+ /** Floating label that rises and fades. */
82
+ export async function floatLabel(
83
+ fx: Container,
84
+ x: number,
85
+ y: number,
86
+ text: string,
87
+ color = 0xffd24a,
88
+ ms = 700,
89
+ ): Promise<void> {
90
+ if (fx.destroyed) return;
91
+ const t = new Text({ text, style: { fontSize: 26, fill: color, fontWeight: '800' } });
92
+ t.anchor.set(0.5);
93
+ t.position.set(x, y);
94
+ t.scale.set(0.5);
95
+ fx.addChild(t);
96
+ await Tween.to(t, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.25, easingByName('easeOutBack'));
97
+ await Tween.to(t, { y: y - 50, alpha: 0 }, ms * 0.75, easingByName('easeOutQuad'));
98
+ t.destroy();
99
+ }
100
+
101
+ /** Replace a cell's symbol with a quick morph (shrink → swap → pop). */
102
+ export async function morphSymbol(cell: SymbolCell, data: CellData, ms = 280): Promise<void> {
103
+ if (cell.destroyed) return;
104
+ await Tween.to(cell, { 'scale.x': 0.1, 'scale.y': 0.1 }, ms * 0.4, easingByName('easeInBack'));
105
+ if (cell.destroyed) return;
106
+ cell.setData(data);
107
+ cell.scale.set(0.1);
108
+ await Tween.to(cell, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.6, easingByName('easeOutBack'));
109
+ }
110
+
111
+ /** Drop a cell in from above its home position. */
112
+ export async function dropCell(
113
+ grid: ReelGrid,
114
+ cell: SymbolCell,
115
+ col: number,
116
+ row: number,
117
+ ms = 280,
118
+ ): Promise<void> {
119
+ if (cell.destroyed) return;
120
+ const home = grid.cellPosition(col, row);
121
+ cell.position.set(home.x, home.y - rowStepOf(grid) * (row + 2));
122
+ cell.alpha = 1;
123
+ cell.scale.set(1);
124
+ await Tween.to(cell, { 'position.y': home.y }, ms, easingByName('easeOutBounce'));
125
+ }
126
+
127
+ export function pickFromBoard(
128
+ board: CellData[][],
129
+ predicate: (c: CellData, col: number, row: number) => boolean,
130
+ ): { col: number; row: number }[] {
131
+ const out: { col: number; row: number }[] = [];
132
+ for (let c = 0; c < board.length; c++)
133
+ for (let r = 0; r < (board[c]?.length ?? 0); r++)
134
+ if (board[c][r] && predicate(board[c][r], c, r)) out.push({ col: c, row: r });
135
+ return out;
136
+ }
@@ -0,0 +1,151 @@
1
+ // packages/game-engine/src/slot/features/wilds.ts
2
+ import { Tween } from '../../animation';
3
+ import { easingByName } from '../anim/easing-map';
4
+ import {
5
+ type FeatureContext,
6
+ type ReelFeature,
7
+ cellCenter,
8
+ dropCell,
9
+ floatLabel,
10
+ glowRing,
11
+ morphSymbol,
12
+ pickFromBoard,
13
+ pulseCell,
14
+ } from './types';
15
+
16
+ function reelsOf(cfg: number[], cols: number): number[] {
17
+ return cfg.length
18
+ ? cfg.filter((r) => r >= 0 && r < cols)
19
+ : Array.from({ length: cols }, (_, i) => i);
20
+ }
21
+ const randInt = (a: number, b: number, seed: number) =>
22
+ Math.min(b, a + Math.floor(((Math.sin(seed * 99.13) + 1) / 2) * (b - a + 1)));
23
+
24
+ /** Expanding wild: a landed wild grows to fill its whole reel. */
25
+ export const ExpandingWild: ReelFeature = {
26
+ key: 'expandingWild',
27
+ label: 'Expanding wild',
28
+ enabled: (c) => c.features.expandingWild.enabled,
29
+ async demo(ctx: FeatureContext) {
30
+ const f = ctx.cfg.features.expandingWild;
31
+ if (f.onlyInFreeSpins && !ctx.freeSpins) {
32
+ ctx.log?.('Expanding wild: free-spins only');
33
+ return;
34
+ }
35
+ const cols = ctx.grid.cols;
36
+ const eligible = reelsOf(f.reels, cols);
37
+ const reel = eligible[randInt(0, eligible.length - 1, cols)];
38
+ const rows = ctx.grid.rowsOf(reel);
39
+ ctx.log?.(`Expanding wild on reel ${reel}`);
40
+ const disposers: (() => void)[] = [];
41
+ for (let r = 0; r < (f.toFullReel ? rows : Math.min(2, rows)); r++) {
42
+ const cell = ctx.grid.getCell(reel, r);
43
+ await morphSymbol(cell, { symbol: f.symbol }, f.ms / Math.max(1, rows));
44
+ disposers.push(glowRing(ctx.fx, ctx.grid, reel, r, 0xffd700));
45
+ if (ctx.board[reel]) ctx.board[reel][r] = { symbol: f.symbol };
46
+ }
47
+ await Tween.delay(500);
48
+ disposers.forEach((d) => d());
49
+ },
50
+ };
51
+
52
+ /** Sticky symbols: chosen symbols lock in place for N spins. */
53
+ export const StickySymbols: ReelFeature = {
54
+ key: 'sticky',
55
+ label: 'Sticky symbols',
56
+ enabled: (c) => c.features.sticky.enabled,
57
+ async demo(ctx: FeatureContext) {
58
+ const f = ctx.cfg.features.sticky;
59
+ const targets = pickFromBoard(ctx.board, (c) => !!c.symbol).slice(0, 3);
60
+ const picks = targets.filter((_, i) => i % 2 === 0);
61
+ ctx.log?.(`Sticky: locking ${picks.length} cell(s) for ${f.durationSpins || 'feature'} spins`);
62
+ await Promise.all(
63
+ picks.map(async (p, i) => {
64
+ const cell = ctx.grid.getCell(p.col, p.row);
65
+ cell.setData({ symbol: f.symbols[0] ?? 'wild', sticky: { remaining: f.durationSpins } });
66
+ glowRing(ctx.fx, ctx.grid, p.col, p.row, f.ringColor);
67
+ await pulseCell(cell, 1.2, 260);
68
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
69
+ await floatLabel(
70
+ ctx.fx,
71
+ x,
72
+ y - ctx.grid.cellSize / 2,
73
+ f.durationSpins ? `STICKY ${f.durationSpins}` : 'STICKY',
74
+ f.ringColor,
75
+ 600 + i * 50,
76
+ );
77
+ }),
78
+ );
79
+ },
80
+ };
81
+
82
+ /** Walking wild: a wild shifts one reel each spin until it leaves the grid. */
83
+ export const WalkingWild: ReelFeature = {
84
+ key: 'walkingWild',
85
+ label: 'Walking wild',
86
+ enabled: (c) => c.features.walkingWild.enabled,
87
+ async demo(ctx: FeatureContext) {
88
+ const f = ctx.cfg.features.walkingWild;
89
+ const cols = ctx.grid.cols;
90
+ const dir = f.direction === 'left' ? -1 : 1;
91
+ let col = dir === 1 ? 0 : cols - 1;
92
+ // clamp the row into each reel's own height (Megaways reels differ in length)
93
+ const rowIn = (c: number) =>
94
+ Math.min(Math.floor(ctx.grid.rowsOf(col) / 2), ctx.grid.rowsOf(c) - 1);
95
+ let row = rowIn(col);
96
+ await morphSymbol(ctx.grid.getCell(col, row), { symbol: f.symbol }, 240);
97
+ ctx.log?.(`Walking wild marching ${f.direction}`);
98
+ // walk across the grid
99
+ for (let step = 0; step < cols; step++) {
100
+ const next = col + dir * f.stepPerSpin;
101
+ if (next < 0 || next >= cols) break;
102
+ const nextRow = rowIn(next);
103
+ const from = cellCenter(ctx.grid, col, row);
104
+ const to = cellCenter(ctx.grid, next, nextRow);
105
+ ctx.grid.getCell(col, row).setData({ symbol: null });
106
+ const ghost = ctx.grid.getCell(next, nextRow);
107
+ ghost.setData({ symbol: f.symbol });
108
+ ghost.position.set(from.x, from.y);
109
+ const ring = glowRing(ctx.fx, ctx.grid, next, nextRow, 0xff66cc);
110
+ await Tween.to(
111
+ ghost,
112
+ { 'position.x': to.x, 'position.y': to.y },
113
+ 260,
114
+ easingByName('easeInOutQuad'),
115
+ );
116
+ ring();
117
+ col = next;
118
+ row = nextRow;
119
+ }
120
+ },
121
+ };
122
+
123
+ /** Random wild injection: a few wilds drop onto random positions. */
124
+ export const RandomWild: ReelFeature = {
125
+ key: 'randomWild',
126
+ label: 'Random wild injection',
127
+ enabled: (c) => c.features.randomWild.enabled,
128
+ async demo(ctx: FeatureContext) {
129
+ const f = ctx.cfg.features.randomWild;
130
+ const n = Array.isArray(f.count) ? randInt(f.count[0], f.count[1], ctx.grid.cols + 7) : f.count;
131
+ const spots = pickFromBoard(ctx.board, () => true);
132
+ const chosen: { col: number; row: number }[] = [];
133
+ for (let i = 0; i < n && spots.length; i++)
134
+ chosen.push(spots.splice(randInt(0, spots.length - 1, i + 3), 1)[0]);
135
+ ctx.log?.(
136
+ `Injecting ${chosen.length} wild(s)${f.sticky ? ' (sticky)' : ''}${f.multiplier > 1 ? ` ×${f.multiplier}` : ''}`,
137
+ );
138
+ await Promise.all(
139
+ chosen.map(async (p) => {
140
+ const cell = ctx.grid.getCell(p.col, p.row);
141
+ cell.setData({
142
+ symbol: 'wild',
143
+ multiplier: f.multiplier > 1 ? f.multiplier : undefined,
144
+ sticky: f.sticky ? { remaining: 0 } : undefined,
145
+ });
146
+ await dropCell(ctx.grid, cell, p.col, p.row, 300);
147
+ if (f.sticky) glowRing(ctx.fx, ctx.grid, p.col, p.row, 0xec4899);
148
+ }),
149
+ );
150
+ },
151
+ };
@@ -2,88 +2,157 @@ import { Container, Graphics, Sprite, type Texture } from 'pixi.js';
2
2
  import { SymbolCell, type CellData, type CellFrameStyle } from './SymbolCell';
3
3
  import type { SymbolResolver } from './SymbolView';
4
4
 
5
- export interface DecorationConfig { texture?: Texture; padding?: number; }
5
+ export interface DecorationConfig {
6
+ texture?: Texture;
7
+ padding?: number;
8
+ }
6
9
  export interface ReelGridConfig {
7
- cols: number; rows: number; cellSize: number; gap?: number;
8
- resolve: SymbolResolver; frameStyle?: CellFrameStyle;
10
+ cols: number;
11
+ /** Uniform row count. Ignored per-reel when `rowsPerReel` is provided. */
12
+ rows: number;
13
+ /** Per-reel row counts for Megaways / variable-height reels. Length must equal `cols`. */
14
+ rowsPerReel?: number[];
15
+ cellSize: number;
16
+ gap?: number;
17
+ resolve: SymbolResolver;
18
+ frameStyle?: CellFrameStyle;
9
19
  decoration?: DecorationConfig;
10
20
  mask?: boolean;
11
21
  }
12
22
 
23
+ /**
24
+ * A grid of `SymbolCell`s. Supports uniform grids and variable-height reels (Megaways):
25
+ * when `rowsPerReel` is set each reel is vertically centred inside the tallest reel's envelope.
26
+ */
13
27
  export class ReelGrid extends Container {
14
28
  readonly __uiComponent = true as const;
15
29
 
16
30
  private _cols: number;
17
- private _rows: number;
31
+ private _rowsPerReel: number[];
32
+ private _maxRows: number;
18
33
  private _cellSize: number;
19
34
  private _gap: number;
20
35
  private _cells: SymbolCell[][] = [];
21
36
  private _cellLayer = new Container();
37
+ private _resolve: SymbolResolver;
38
+ private _frameStyle?: CellFrameStyle;
39
+ private _mask: Graphics | null = null;
22
40
 
23
41
  constructor(config: ReelGridConfig) {
24
42
  super();
25
43
  this._cols = config.cols;
26
- this._rows = config.rows;
44
+ this._rowsPerReel =
45
+ config.rowsPerReel && config.rowsPerReel.length === config.cols
46
+ ? config.rowsPerReel.slice()
47
+ : Array.from({ length: config.cols }, () => config.rows);
48
+ this._maxRows = Math.max(1, ...this._rowsPerReel);
27
49
  this._cellSize = config.cellSize;
28
50
  this._gap = config.gap ?? 0;
51
+ this._resolve = config.resolve;
52
+ this._frameStyle = config.frameStyle;
29
53
 
30
54
  if (config.decoration) {
31
55
  const pad = config.decoration.padding ?? 0;
32
56
  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;
57
+ const h = this._maxRows * (this._cellSize + this._gap) - this._gap + pad * 2;
34
58
  if (config.decoration.texture) {
35
59
  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);
60
+ deco.width = w;
61
+ deco.height = h;
62
+ deco.position.set(-pad - this._cellSize / 2, -pad - this._cellSize / 2);
37
63
  this.addChild(deco);
38
64
  }
39
65
  }
40
66
 
41
67
  this.addChild(this._cellLayer);
68
+ this._buildCells();
69
+
70
+ if (config.mask) this._applyMask();
71
+ }
42
72
 
73
+ private _buildCells(): void {
43
74
  for (let c = 0; c < this._cols; c++) {
44
75
  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 });
76
+ for (let r = 0; r < this._rowsPerReel[c]; r++) {
77
+ const cell = new SymbolCell({
78
+ size: this._cellSize,
79
+ resolve: this._resolve,
80
+ frameStyle: this._frameStyle,
81
+ });
47
82
  const { x, y } = this.cellPosition(c, r);
48
83
  cell.position.set(x, y);
49
84
  this._cellLayer.addChild(cell);
50
85
  this._cells[c][r] = cell;
51
86
  }
52
87
  }
88
+ }
53
89
 
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
- }
90
+ private _applyMask(): void {
91
+ const w = this._cols * (this._cellSize + this._gap) - this._gap;
92
+ const h = this._maxRows * (this._cellSize + this._gap) - this._gap;
93
+ const m = new Graphics().rect(-this._cellSize / 2, -this._cellSize / 2, w, h).fill(0xffffff);
94
+ this._cellLayer.mask = m;
95
+ this.addChild(m);
96
+ this._mask = m;
63
97
  }
64
98
 
65
- get cols(): number { return this._cols; }
66
- get rows(): number { return this._rows; }
99
+ get cols(): number {
100
+ return this._cols;
101
+ }
102
+ /** Tallest reel's row count (the grid's visual height in rows). */
103
+ get rows(): number {
104
+ return this._maxRows;
105
+ }
106
+ get cellSize(): number {
107
+ return this._cellSize;
108
+ }
109
+ rowsOf(col: number): number {
110
+ return this._rowsPerReel[col] ?? 0;
111
+ }
112
+ get rowsPerReel(): number[] {
113
+ return this._rowsPerReel.slice();
114
+ }
67
115
 
116
+ /** Cell centre position. Variable-height reels are centred in the max-rows envelope. */
68
117
  cellPosition(col: number, row: number): { x: number; y: number } {
69
118
  const step = this._cellSize + this._gap;
70
- return { x: col * step, y: row * step };
119
+ const reelRows = this._rowsPerReel[col] ?? this._maxRows;
120
+ const yOffset = ((this._maxRows - reelRows) / 2) * step;
121
+ return { x: col * step, y: yOffset + row * step };
71
122
  }
72
123
 
73
- getCell(col: number, row: number): SymbolCell { return this._cells[col][row]; }
124
+ getCell(col: number, row: number): SymbolCell {
125
+ return this._cells[col][row];
126
+ }
74
127
 
75
128
  setGrid(cells: CellData[][]): void {
76
129
  for (let c = 0; c < this._cols; c++) {
77
- for (let r = 0; r < this._rows; r++) {
130
+ for (let r = 0; r < this._rowsPerReel[c]; r++) {
78
131
  this._cells[c]?.[r]?.setData(cells[c]?.[r] ?? { symbol: null });
79
132
  }
80
133
  }
81
134
  }
82
135
 
136
+ /** Rebuild the grid with new per-reel row counts (Megaways re-roll / dynamic rows). */
137
+ reshape(rowsPerReel: number[]): void {
138
+ if (rowsPerReel.length !== this._cols) return;
139
+ this._cellLayer.removeChildren().forEach((c) => c.destroy());
140
+ this._cells = [];
141
+ this._rowsPerReel = rowsPerReel.slice();
142
+ this._maxRows = Math.max(1, ...this._rowsPerReel);
143
+ this._buildCells();
144
+ if (this._mask) {
145
+ this._mask.destroy();
146
+ this.removeChild(this._mask);
147
+ this._mask = null;
148
+ this._applyMask();
149
+ }
150
+ }
151
+
83
152
  resize(cellSize: number): void {
84
153
  this._cellSize = cellSize;
85
154
  for (let c = 0; c < this._cols; c++) {
86
- for (let r = 0; r < this._rows; r++) {
155
+ for (let r = 0; r < this._rowsPerReel[c]; r++) {
87
156
  const { x, y } = this.cellPosition(c, r);
88
157
  this._cells[c][r].position.set(x, y);
89
158
  }
@@ -15,8 +15,16 @@ export interface CellData {
15
15
  bonus?: number;
16
16
  sticky?: { remaining: number };
17
17
  }
18
- export interface CellState { winning?: boolean; removed?: boolean; fresh?: boolean; }
19
- export interface SymbolCellConfig { size: number; resolve: SymbolResolver; frameStyle?: CellFrameStyle; }
18
+ export interface CellState {
19
+ winning?: boolean;
20
+ removed?: boolean;
21
+ fresh?: boolean;
22
+ }
23
+ export interface SymbolCellConfig {
24
+ size: number;
25
+ resolve: SymbolResolver;
26
+ frameStyle?: CellFrameStyle;
27
+ }
20
28
 
21
29
  const DEFAULT_STYLE: Required<CellFrameStyle> = {
22
30
  radius: 8,
@@ -51,14 +59,23 @@ export class SymbolCell extends Container {
51
59
  this._drawFrame('idle');
52
60
  }
53
61
 
54
- get view(): SymbolView | null { return this._view; }
62
+ get view(): SymbolView | null {
63
+ return this._view;
64
+ }
55
65
 
56
66
  setData(data: CellData): void {
67
+ if (this.destroyed) return; // a killed-tween chain may resume after the cell is gone
57
68
  // symbol view
58
69
  if (data.symbol == null) {
59
- if (this._view) { this._view.destroy(); this._view = null; }
70
+ if (this._view) {
71
+ this._view.destroy();
72
+ this._view = null;
73
+ }
60
74
  } else {
61
- if (this._view) { this._view.destroy(); this._view = null; }
75
+ if (this._view) {
76
+ this._view.destroy();
77
+ this._view = null;
78
+ }
62
79
  const v = this._resolve(data.symbol);
63
80
  if (v) {
64
81
  v.resize?.(this._size);
@@ -72,7 +89,14 @@ export class SymbolCell extends Container {
72
89
  }
73
90
 
74
91
  setState(state: CellState): void {
75
- const key = state.winning ? 'winning' : state.removed ? 'removed' : state.fresh ? 'fresh' : 'idle';
92
+ if (this.destroyed) return;
93
+ const key = state.winning
94
+ ? 'winning'
95
+ : state.removed
96
+ ? 'removed'
97
+ : state.fresh
98
+ ? 'fresh'
99
+ : 'idle';
76
100
  this.frameStyleKey = key;
77
101
  this._drawFrame(key);
78
102
  }
@@ -81,17 +105,21 @@ export class SymbolCell extends Container {
81
105
  if (this._view?.playWin) return this._view.playWin();
82
106
  // default: scale pop
83
107
  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));
108
+ return Tween.to(target, { 'scale.x': 1.15, 'scale.y': 1.15 }, 160, Easing.easeOutBack).then(
109
+ () => Tween.to(target, { 'scale.x': 1, 'scale.y': 1 }, 140, Easing.easeOutQuad),
110
+ );
86
111
  }
87
112
 
88
- playIdle(): void { this._view?.playIdle?.(); }
113
+ playIdle(): void {
114
+ this._view?.playIdle?.();
115
+ }
89
116
 
90
117
  hasBadge(kind: 'multiplier' | 'bonus'): boolean {
91
118
  return kind === 'multiplier' ? this._multBadge != null : this._bonusBadge != null;
92
119
  }
93
120
 
94
121
  private _drawFrame(key: 'idle' | 'winning' | 'removed' | 'fresh'): void {
122
+ if (this.destroyed || this._frame.destroyed) return;
95
123
  const s = this._style[key];
96
124
  this._frame.clear();
97
125
  this._frame
@@ -102,7 +130,10 @@ export class SymbolCell extends Container {
102
130
  }
103
131
 
104
132
  private _setMultiplier(value?: number): void {
105
- if (this._multBadge) { this._multBadge.destroy(); this._multBadge = null; }
133
+ if (this._multBadge) {
134
+ this._multBadge.destroy();
135
+ this._multBadge = null;
136
+ }
106
137
  if (!value || value <= 1) return;
107
138
  this._multBadge = this._badge(`×${value}`, 0xffd24a);
108
139
  this._multBadge.position.set(this._size / 2 - 12, -this._size / 2 + 12);
@@ -110,7 +141,10 @@ export class SymbolCell extends Container {
110
141
  }
111
142
 
112
143
  private _setBonus(value?: number): void {
113
- if (this._bonusBadge) { this._bonusBadge.destroy(); this._bonusBadge = null; }
144
+ if (this._bonusBadge) {
145
+ this._bonusBadge.destroy();
146
+ this._bonusBadge = null;
147
+ }
114
148
  if (!value || value <= 0) return;
115
149
  this._bonusBadge = this._badge(`+${value}`, 0x7ad7ff);
116
150
  this._bonusBadge.position.set(-this._size / 2 + 12, -this._size / 2 + 12);
package/src/slot/index.ts CHANGED
@@ -9,13 +9,72 @@ export { CascadeController } from './anim/CascadeController';
9
9
  export type { CascadeStepData, CascadeTimings, CascadeAnim } from './anim/CascadeController';
10
10
  export { ReelSpinController } from './anim/ReelSpinController';
11
11
  export type { ReelSpinData, ReelSpinTimings, ReelStopPlan } from './anim/ReelSpinController';
12
+ export { EASING_BY_NAME, easingByName } from './anim/easing-map';
13
+
14
+ // ── configurable reel system ────────────────────────────────────────────────
15
+ export {
16
+ DEFAULT_REEL_CONFIG,
17
+ INTENSITY_SCALE,
18
+ FEATURE_KEYS,
19
+ resolveReelConfig,
20
+ mergeReelConfig,
21
+ effectiveRowsPerReel,
22
+ waysCount,
23
+ } from './config/ReelSystemConfig';
24
+ export type {
25
+ ReelSystemConfig,
26
+ DeepPartial,
27
+ EasingName,
28
+ EvaluationMode,
29
+ MotionStyle,
30
+ StopMode,
31
+ StopOrder,
32
+ Intensity,
33
+ GridConfig,
34
+ MotionConfig,
35
+ SettleConfig,
36
+ SquashConfig,
37
+ BlurConfig,
38
+ AnticipationConfig,
39
+ CascadeConfig,
40
+ WinConfig,
41
+ FeaturesConfig,
42
+ FeatureKey,
43
+ ExpandingWildConfig,
44
+ StickyConfig,
45
+ WalkingWildConfig,
46
+ MultiplierConfig,
47
+ MysteryConfig,
48
+ TransformConfig,
49
+ GiantConfig,
50
+ SplitConfig,
51
+ StackedConfig,
52
+ NudgeConfig,
53
+ ReelModifierConfig,
54
+ HoldAndSpinConfig,
55
+ RandomWildConfig,
56
+ } from './config/ReelSystemConfig';
57
+ export { PRESETS, PRESET_LIST } from './config/presets';
58
+ export type { PresetId, ReelPreset } from './config/presets';
59
+
60
+ export { SpinEngine } from './motion/SpinEngine';
61
+ export type { SpinData, SpinRunOpts, ReelStopPlan as SpinStopPlan } from './motion/SpinEngine';
62
+ export { AnticipationController } from './motion/AnticipationController';
63
+ export type { AnticipationDecision } from './motion/AnticipationController';
64
+ export { TumbleController } from './cascade/TumbleController';
65
+ export type { TumbleStep } from './cascade/TumbleController';
66
+
67
+ export { FEATURES, FEATURE_LIST } from './features';
68
+ export type { ReelFeature, FeatureContext } from './features';
69
+
70
+ export { createReelSystem } from './system/ReelSystem';
71
+ export type { ReelSystem, CreateReelSystemOptions } from './system/ReelSystem';
72
+
12
73
  export { pickTier, tierIndexAtValue } from './overlay/tiers';
13
74
  export type { WinTier } from './overlay/tiers';
14
75
  export { valueAt, CountUpDisplay } from './overlay/CountUpDisplay';
15
76
  export type { CountUpConfig } from './overlay/CountUpDisplay';
16
77
  export { BigWinOverlay } from './overlay/BigWinOverlay';
17
78
  export type { BigWinOverlayConfig } from './overlay/BigWinOverlay';
18
- export { FreeSpinsSession } from './freeSpins/FreeSpinsSession';
19
- export type { FreeSpinsSessionConfig } from './freeSpins/FreeSpinsSession';
20
79
  export { MultiplierAccumulator } from './multiplier/MultiplierAccumulator';
21
80
  export type { CarryPolicy } from './multiplier/MultiplierAccumulator';