@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
package/dist/slot.esm.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Ticker, AnimatedSprite, Texture, Container, Sprite, Graphics, Text } from 'pixi.js';
1
+ import { Ticker, AnimatedSprite, Texture, Container, Sprite, Graphics, Text, BlurFilter } from 'pixi.js';
2
2
 
3
3
  /**
4
4
  * Collection of easing functions for use with Tween and Timeline.
@@ -7,9 +7,27 @@ import { Ticker, AnimatedSprite, Texture, Container, Sprite, Graphics, Text } fr
7
7
  */
8
8
  const Easing = {
9
9
  linear: (t) => t,
10
+ easeInQuad: (t) => t * t,
10
11
  easeOutQuad: (t) => t * (2 - t),
12
+ easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
13
+ easeInCubic: (t) => t * t * t,
11
14
  easeOutCubic: (t) => --t * t * t + 1,
12
15
  easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
16
+ easeInQuart: (t) => t * t * t * t,
17
+ easeOutQuart: (t) => 1 - --t * t * t * t,
18
+ easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
19
+ easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
20
+ easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
21
+ easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
22
+ easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
23
+ easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
24
+ easeInOutExpo: (t) => t === 0
25
+ ? 0
26
+ : t === 1
27
+ ? 1
28
+ : t < 0.5
29
+ ? Math.pow(2, 20 * t - 10) / 2
30
+ : (2 - Math.pow(2, -20 * t + 10)) / 2,
13
31
  easeInBack: (t) => {
14
32
  const c1 = 1.70158;
15
33
  const c3 = c1 + 1;
@@ -20,6 +38,13 @@ const Easing = {
20
38
  const c3 = c1 + 1;
21
39
  return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
22
40
  },
41
+ easeInOutBack: (t) => {
42
+ const c1 = 1.70158;
43
+ const c2 = c1 * 1.525;
44
+ return t < 0.5
45
+ ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
46
+ : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
47
+ },
23
48
  easeOutBounce: (t) => {
24
49
  const n1 = 7.5625;
25
50
  const d1 = 2.75;
@@ -30,7 +55,28 @@ const Easing = {
30
55
  if (t < 2.5 / d1)
31
56
  return n1 * (t -= 2.25 / d1) * t + 0.9375;
32
57
  return n1 * (t -= 2.625 / d1) * t + 0.984375;
33
- }};
58
+ },
59
+ easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
60
+ easeInOutBounce: (t) => t < 0.5
61
+ ? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
62
+ : (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
63
+ easeOutElastic: (t) => {
64
+ const c4 = (2 * Math.PI) / 3;
65
+ return t === 0
66
+ ? 0
67
+ : t === 1
68
+ ? 1
69
+ : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
70
+ },
71
+ easeInElastic: (t) => {
72
+ const c4 = (2 * Math.PI) / 3;
73
+ return t === 0
74
+ ? 0
75
+ : t === 1
76
+ ? 1
77
+ : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
78
+ },
79
+ };
34
80
 
35
81
  /**
36
82
  * Lightweight tween system integrated with PixiJS Ticker.
@@ -63,6 +109,10 @@ class Tween {
63
109
  * @param onUpdate - Progress callback (0..1)
64
110
  */
65
111
  static to(target, props, duration, easing, onUpdate) {
112
+ // A destroyed (Pixi) target has null transform fields — skip rather than throw. This guards
113
+ // animations whose target is torn down mid-flight (e.g. a reel grid rebuilt during a spin).
114
+ if (target == null || target.destroyed)
115
+ return Promise.resolve();
66
116
  return new Promise((resolve) => {
67
117
  // Capture starting values
68
118
  const from = {};
@@ -88,6 +138,8 @@ class Tween {
88
138
  * Animate properties from given values to current values.
89
139
  */
90
140
  static from(target, props, duration, easing, onUpdate) {
141
+ if (target == null || target.destroyed)
142
+ return Promise.resolve();
91
143
  // Capture current values as "to"
92
144
  const to = {};
93
145
  for (const key of Object.keys(props)) {
@@ -100,6 +152,8 @@ class Tween {
100
152
  * Animate from one set of values to another.
101
153
  */
102
154
  static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
155
+ if (target == null || target.destroyed)
156
+ return Promise.resolve();
103
157
  // Set starting values
104
158
  for (const key of Object.keys(fromProps)) {
105
159
  Tween.setProperty(target, key, fromProps[key]);
@@ -173,6 +227,11 @@ class Tween {
173
227
  const dt = ticker.deltaMS;
174
228
  const completed = [];
175
229
  for (const tw of Tween._tweens) {
230
+ // target torn down mid-tween → finish it quietly
231
+ if (tw.target?.destroyed) {
232
+ completed.push(tw);
233
+ continue;
234
+ }
176
235
  tw.elapsed += dt;
177
236
  if (tw.elapsed < tw.delay)
178
237
  continue;
@@ -210,9 +269,9 @@ class Tween {
210
269
  const parts = key.split('.');
211
270
  let obj = target;
212
271
  for (let i = 0; i < parts.length - 1; i++) {
213
- obj = obj[parts[i]];
272
+ obj = obj?.[parts[i]];
214
273
  }
215
- return obj[parts[parts.length - 1]] ?? 0;
274
+ return obj?.[parts[parts.length - 1]] ?? 0;
216
275
  }
217
276
  /**
218
277
  * Set a potentially nested property.
@@ -221,8 +280,10 @@ class Tween {
221
280
  const parts = key.split('.');
222
281
  let obj = target;
223
282
  for (let i = 0; i < parts.length - 1; i++) {
224
- obj = obj[parts[i]];
283
+ obj = obj?.[parts[i]];
225
284
  }
285
+ if (obj == null)
286
+ return;
226
287
  obj[parts[parts.length - 1]] = value;
227
288
  }
228
289
  }
@@ -486,8 +547,12 @@ class SymbolCell extends Container {
486
547
  this.addChild(this._badges);
487
548
  this._drawFrame('idle');
488
549
  }
489
- get view() { return this._view; }
550
+ get view() {
551
+ return this._view;
552
+ }
490
553
  setData(data) {
554
+ if (this.destroyed)
555
+ return; // a killed-tween chain may resume after the cell is gone
491
556
  // symbol view
492
557
  if (data.symbol == null) {
493
558
  if (this._view) {
@@ -512,7 +577,15 @@ class SymbolCell extends Container {
512
577
  this._setBonus(data.bonus);
513
578
  }
514
579
  setState(state) {
515
- const key = state.winning ? 'winning' : state.removed ? 'removed' : state.fresh ? 'fresh' : 'idle';
580
+ if (this.destroyed)
581
+ return;
582
+ const key = state.winning
583
+ ? 'winning'
584
+ : state.removed
585
+ ? 'removed'
586
+ : state.fresh
587
+ ? 'fresh'
588
+ : 'idle';
516
589
  this.frameStyleKey = key;
517
590
  this._drawFrame(key);
518
591
  }
@@ -521,14 +594,17 @@ class SymbolCell extends Container {
521
594
  return this._view.playWin();
522
595
  // default: scale pop
523
596
  const target = this._view ?? this;
524
- return Tween.to(target, { 'scale.x': 1.15, 'scale.y': 1.15 }, 160, Easing.easeOutBack)
525
- .then(() => Tween.to(target, { 'scale.x': 1, 'scale.y': 1 }, 140, Easing.easeOutQuad));
597
+ return Tween.to(target, { 'scale.x': 1.15, 'scale.y': 1.15 }, 160, Easing.easeOutBack).then(() => Tween.to(target, { 'scale.x': 1, 'scale.y': 1 }, 140, Easing.easeOutQuad));
598
+ }
599
+ playIdle() {
600
+ this._view?.playIdle?.();
526
601
  }
527
- playIdle() { this._view?.playIdle?.(); }
528
602
  hasBadge(kind) {
529
603
  return kind === 'multiplier' ? this._multBadge != null : this._bonusBadge != null;
530
604
  }
531
605
  _drawFrame(key) {
606
+ if (this.destroyed || this._frame.destroyed)
607
+ return;
532
608
  const s = this._style[key];
533
609
  this._frame.clear();
534
610
  this._frame
@@ -568,24 +644,38 @@ class SymbolCell extends Container {
568
644
  }
569
645
  }
570
646
 
647
+ /**
648
+ * A grid of `SymbolCell`s. Supports uniform grids and variable-height reels (Megaways):
649
+ * when `rowsPerReel` is set each reel is vertically centred inside the tallest reel's envelope.
650
+ */
571
651
  class ReelGrid extends Container {
572
652
  __uiComponent = true;
573
653
  _cols;
574
- _rows;
654
+ _rowsPerReel;
655
+ _maxRows;
575
656
  _cellSize;
576
657
  _gap;
577
658
  _cells = [];
578
659
  _cellLayer = new Container();
660
+ _resolve;
661
+ _frameStyle;
662
+ _mask = null;
579
663
  constructor(config) {
580
664
  super();
581
665
  this._cols = config.cols;
582
- this._rows = config.rows;
666
+ this._rowsPerReel =
667
+ config.rowsPerReel && config.rowsPerReel.length === config.cols
668
+ ? config.rowsPerReel.slice()
669
+ : Array.from({ length: config.cols }, () => config.rows);
670
+ this._maxRows = Math.max(1, ...this._rowsPerReel);
583
671
  this._cellSize = config.cellSize;
584
672
  this._gap = config.gap ?? 0;
673
+ this._resolve = config.resolve;
674
+ this._frameStyle = config.frameStyle;
585
675
  if (config.decoration) {
586
676
  const pad = config.decoration.padding ?? 0;
587
677
  const w = this._cols * (this._cellSize + this._gap) - this._gap + pad * 2;
588
- const h = this._rows * (this._cellSize + this._gap) - this._gap + pad * 2;
678
+ const h = this._maxRows * (this._cellSize + this._gap) - this._gap + pad * 2;
589
679
  if (config.decoration.texture) {
590
680
  const deco = new Sprite(config.decoration.texture);
591
681
  deco.width = w;
@@ -595,44 +685,87 @@ class ReelGrid extends Container {
595
685
  }
596
686
  }
597
687
  this.addChild(this._cellLayer);
688
+ this._buildCells();
689
+ if (config.mask)
690
+ this._applyMask();
691
+ }
692
+ _buildCells() {
598
693
  for (let c = 0; c < this._cols; c++) {
599
694
  this._cells[c] = [];
600
- for (let r = 0; r < this._rows; r++) {
601
- const cell = new SymbolCell({ size: this._cellSize, resolve: config.resolve, frameStyle: config.frameStyle });
695
+ for (let r = 0; r < this._rowsPerReel[c]; r++) {
696
+ const cell = new SymbolCell({
697
+ size: this._cellSize,
698
+ resolve: this._resolve,
699
+ frameStyle: this._frameStyle,
700
+ });
602
701
  const { x, y } = this.cellPosition(c, r);
603
702
  cell.position.set(x, y);
604
703
  this._cellLayer.addChild(cell);
605
704
  this._cells[c][r] = cell;
606
705
  }
607
706
  }
608
- if (config.mask) {
609
- const w = this._cols * (this._cellSize + this._gap) - this._gap;
610
- const h = this._rows * (this._cellSize + this._gap) - this._gap;
611
- const m = new Graphics()
612
- .rect(-this._cellSize / 2, -this._cellSize / 2, w, h)
613
- .fill(0xffffff);
614
- this._cellLayer.mask = m;
615
- this.addChild(m);
616
- }
617
707
  }
618
- get cols() { return this._cols; }
619
- get rows() { return this._rows; }
708
+ _applyMask() {
709
+ const w = this._cols * (this._cellSize + this._gap) - this._gap;
710
+ const h = this._maxRows * (this._cellSize + this._gap) - this._gap;
711
+ const m = new Graphics().rect(-this._cellSize / 2, -this._cellSize / 2, w, h).fill(0xffffff);
712
+ this._cellLayer.mask = m;
713
+ this.addChild(m);
714
+ this._mask = m;
715
+ }
716
+ get cols() {
717
+ return this._cols;
718
+ }
719
+ /** Tallest reel's row count (the grid's visual height in rows). */
720
+ get rows() {
721
+ return this._maxRows;
722
+ }
723
+ get cellSize() {
724
+ return this._cellSize;
725
+ }
726
+ rowsOf(col) {
727
+ return this._rowsPerReel[col] ?? 0;
728
+ }
729
+ get rowsPerReel() {
730
+ return this._rowsPerReel.slice();
731
+ }
732
+ /** Cell centre position. Variable-height reels are centred in the max-rows envelope. */
620
733
  cellPosition(col, row) {
621
734
  const step = this._cellSize + this._gap;
622
- return { x: col * step, y: row * step };
735
+ const reelRows = this._rowsPerReel[col] ?? this._maxRows;
736
+ const yOffset = ((this._maxRows - reelRows) / 2) * step;
737
+ return { x: col * step, y: yOffset + row * step };
738
+ }
739
+ getCell(col, row) {
740
+ return this._cells[col][row];
623
741
  }
624
- getCell(col, row) { return this._cells[col][row]; }
625
742
  setGrid(cells) {
626
743
  for (let c = 0; c < this._cols; c++) {
627
- for (let r = 0; r < this._rows; r++) {
744
+ for (let r = 0; r < this._rowsPerReel[c]; r++) {
628
745
  this._cells[c]?.[r]?.setData(cells[c]?.[r] ?? { symbol: null });
629
746
  }
630
747
  }
631
748
  }
749
+ /** Rebuild the grid with new per-reel row counts (Megaways re-roll / dynamic rows). */
750
+ reshape(rowsPerReel) {
751
+ if (rowsPerReel.length !== this._cols)
752
+ return;
753
+ this._cellLayer.removeChildren().forEach((c) => c.destroy());
754
+ this._cells = [];
755
+ this._rowsPerReel = rowsPerReel.slice();
756
+ this._maxRows = Math.max(1, ...this._rowsPerReel);
757
+ this._buildCells();
758
+ if (this._mask) {
759
+ this._mask.destroy();
760
+ this.removeChild(this._mask);
761
+ this._mask = null;
762
+ this._applyMask();
763
+ }
764
+ }
632
765
  resize(cellSize) {
633
766
  this._cellSize = cellSize;
634
767
  for (let c = 0; c < this._cols; c++) {
635
- for (let r = 0; r < this._rows; r++) {
768
+ for (let r = 0; r < this._rowsPerReel[c]; r++) {
636
769
  const { x, y } = this.cellPosition(c, r);
637
770
  this._cells[c][r].position.set(x, y);
638
771
  }
@@ -644,13 +777,26 @@ class ReelGrid extends Container {
644
777
  /** Resolve a descriptor's string easing name to the engine's easing function. */
645
778
  const EASING_BY_NAME = {
646
779
  linear: Easing.linear,
780
+ easeInQuad: Easing.easeInQuad,
647
781
  easeOutQuad: Easing.easeOutQuad,
782
+ easeInOutQuad: Easing.easeInOutQuad,
783
+ easeInCubic: Easing.easeInCubic,
648
784
  easeOutCubic: Easing.easeOutCubic,
785
+ easeInOutCubic: Easing.easeInOutCubic,
786
+ easeInBack: Easing.easeInBack,
649
787
  easeOutBack: Easing.easeOutBack,
788
+ easeInOutBack: Easing.easeInOutBack,
650
789
  easeOutBounce: Easing.easeOutBounce,
651
- easeInBack: Easing.easeInBack,
652
- easeInOutCubic: Easing.easeInOutCubic,
790
+ easeInBounce: Easing.easeInBounce,
791
+ easeOutElastic: Easing.easeOutElastic,
792
+ easeInSine: Easing.easeInSine,
793
+ easeOutSine: Easing.easeOutSine,
794
+ easeInOutSine: Easing.easeInOutSine,
653
795
  };
796
+ /** Resolve an easing name to a function, falling back to easeOutQuad. */
797
+ function easingByName(name) {
798
+ return (name && EASING_BY_NAME[name]) || Easing.easeOutQuad;
799
+ }
654
800
 
655
801
  // packages/game-engine/src/slot/anim/CascadeController.ts
656
802
  const DEFAULT_TIMINGS$1 = { reveal: 300, highlight: 400, remove: 250, drop: 200, refill: 220, wait: 150 };
@@ -791,6 +937,1684 @@ class ReelSpinController {
791
937
  skip() { this._killed = true; this._killOwnTweens(); }
792
938
  }
793
939
 
940
+ // packages/game-engine/src/slot/config/ReelSystemConfig.ts
941
+ //
942
+ // The single, fully-typed configuration object for the configurable reel system.
943
+ // Everything is optional with sensible defaults — `resolveReelConfig(partial)` deep-merges
944
+ // a partial config onto DEFAULT_REEL_CONFIG so games (and the reel-lab playground) can
945
+ // override only what they need.
946
+ //
947
+ // Design notes are in docs/reels-analysis-and-design.md.
948
+ const FEATURE_KEYS = [
949
+ 'reelModifier', // pre-spin
950
+ 'giant',
951
+ 'stacked',
952
+ 'mystery', // post-spin reveal
953
+ 'expandingWild',
954
+ 'walkingWild',
955
+ 'randomWild',
956
+ 'transform',
957
+ 'split',
958
+ 'nudge',
959
+ 'multiplier',
960
+ 'sticky',
961
+ 'holdAndSpin',
962
+ ];
963
+ // ─────────────────────────────────────────────────────────────────────────────
964
+ // Defaults
965
+ // ─────────────────────────────────────────────────────────────────────────────
966
+ const DEFAULT_REEL_CONFIG = {
967
+ grid: {
968
+ cols: 5,
969
+ rows: 3,
970
+ cellSize: 96,
971
+ gap: 6,
972
+ evaluation: 'lines',
973
+ minRows: 2,
974
+ maxRows: 7,
975
+ topReel: null,
976
+ mask: true,
977
+ decoration: { padding: 0 },
978
+ },
979
+ motion: {
980
+ style: 'swap',
981
+ spinUp: 500,
982
+ hold: 200,
983
+ stopStagger: 120,
984
+ stopMode: 'sequential',
985
+ stopOrder: 'ltr',
986
+ settle: { amp: 7, ms: 240, easing: 'easeOutBack' },
987
+ squash: { enabled: false, scaleX: 1.18, scaleY: 0.82, ms: 90 },
988
+ blur: { enabled: false, alpha: 0.85, strength: 8, streaks: false },
989
+ turboFactor: 0.5,
990
+ intensity: 'full',
991
+ slamStop: true,
992
+ symbolsPerReel: 6,
993
+ },
994
+ anticipation: {
995
+ enabled: false,
996
+ triggerSymbols: ['scatter'],
997
+ threshold: 2,
998
+ reels: 'trailing',
999
+ slowdownFactor: 0.3,
1000
+ holdMs: 400,
1001
+ zoom: { enabled: false, scale: 1.15, ms: 600 },
1002
+ },
1003
+ cascade: {
1004
+ enabled: false,
1005
+ gravity: true,
1006
+ timings: { reveal: 300, highlight: 400, remove: 250, drop: 220, refill: 220, wait: 150 },
1007
+ easings: { highlight: 'easeOutQuad', remove: 'easeInBack', drop: 'easeOutBounce' },
1008
+ perStepDecel: 0.08,
1009
+ perStepDecelCap: 1.5,
1010
+ dimNonWinners: false,
1011
+ dimAlpha: 0.35,
1012
+ multiplier: {
1013
+ enabled: false,
1014
+ start: 1,
1015
+ mode: 'add',
1016
+ step: 1,
1017
+ cap: null,
1018
+ persistInFreeSpins: false,
1019
+ },
1020
+ },
1021
+ win: {
1022
+ highlightScale: 1.12,
1023
+ glow: true,
1024
+ frameShake: { enabled: false, amp: 3, ms: 180, onlyOnSymbols: null },
1025
+ },
1026
+ features: {
1027
+ expandingWild: {
1028
+ enabled: false,
1029
+ symbol: 'wild',
1030
+ reels: [],
1031
+ toFullReel: true,
1032
+ ms: 420,
1033
+ easing: 'easeOutBack',
1034
+ onlyInFreeSpins: false,
1035
+ },
1036
+ sticky: { enabled: false, symbols: ['wild'], durationSpins: 3, ringColor: 0xec4899 },
1037
+ walkingWild: {
1038
+ enabled: false,
1039
+ symbol: 'wild',
1040
+ direction: 'left',
1041
+ stepPerSpin: 1,
1042
+ awardsRespin: true,
1043
+ asStacked: false,
1044
+ },
1045
+ multiplier: {
1046
+ enabled: false,
1047
+ symbol: null,
1048
+ scope: 'perSymbol',
1049
+ combine: 'additive',
1050
+ attachedToWild: false,
1051
+ max: 128,
1052
+ accumulateAcrossFreeSpins: false,
1053
+ },
1054
+ mystery: { enabled: false, symbol: 'mystery', revealPool: [], canRevealWild: false, ms: 300 },
1055
+ transform: {
1056
+ enabled: false,
1057
+ trigger: null,
1058
+ source: 'randomLow',
1059
+ target: 'wild',
1060
+ allInstances: true,
1061
+ upgradeOnly: false,
1062
+ ms: 320,
1063
+ },
1064
+ giant: {
1065
+ enabled: false,
1066
+ width: 2,
1067
+ height: 2,
1068
+ symbols: [],
1069
+ chosenPerSpin: false,
1070
+ onlyInFreeSpins: false,
1071
+ },
1072
+ split: { enabled: false, symbol: 'split', factor: 2, reels: [], deferUntilRespinsEnd: true },
1073
+ stacked: { enabled: false, symbols: [], height: 3 },
1074
+ nudge: {
1075
+ enabled: false,
1076
+ reels: [],
1077
+ step: 1,
1078
+ toFullReel: false,
1079
+ multiplierStart: 1,
1080
+ multiplierPerNudge: 1,
1081
+ },
1082
+ reelModifier: { enabled: false, pool: [], appliesIn: 'both' },
1083
+ holdAndSpin: {
1084
+ enabled: false,
1085
+ lockSymbols: ['coin'],
1086
+ triggerThreshold: 6,
1087
+ respinsAwarded: 3,
1088
+ resetOnNewSymbol: true,
1089
+ jackpotTiers: ['Mini', 'Minor', 'Major', 'Grand'],
1090
+ fullGridAwardsGrand: true,
1091
+ },
1092
+ randomWild: {
1093
+ enabled: false,
1094
+ count: [1, 3],
1095
+ sticky: false,
1096
+ multiplier: 1,
1097
+ trigger: 'onFreeSpins',
1098
+ chance: 0.2,
1099
+ },
1100
+ },
1101
+ };
1102
+ /** Intensity → duration scale (accessibility). */
1103
+ const INTENSITY_SCALE = { full: 1, reduced: 0.7, minimal: 0.4 };
1104
+ function isPlainObject(v) {
1105
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
1106
+ }
1107
+ /** Deep-merge `partial` onto `base` (arrays replace, objects merge). Returns a new object. */
1108
+ function mergeReelConfig(base, partial) {
1109
+ if (partial == null)
1110
+ return structuredCloneSafe(base);
1111
+ const out = structuredCloneSafe(base);
1112
+ for (const [k, v] of Object.entries(partial)) {
1113
+ if (v === undefined)
1114
+ continue;
1115
+ const cur = out[k];
1116
+ if (isPlainObject(v) && isPlainObject(cur))
1117
+ out[k] = mergeReelConfig(cur, v);
1118
+ else
1119
+ out[k] = v;
1120
+ }
1121
+ return out;
1122
+ }
1123
+ /** Resolve a partial config to a fully-populated `ReelSystemConfig`. */
1124
+ function resolveReelConfig(partial) {
1125
+ return mergeReelConfig(DEFAULT_REEL_CONFIG, partial);
1126
+ }
1127
+ function structuredCloneSafe(v) {
1128
+ // structuredClone is available in modern browsers + Node 17+; fall back to JSON for safety.
1129
+ if (typeof structuredClone === 'function')
1130
+ return structuredClone(v);
1131
+ return JSON.parse(JSON.stringify(v));
1132
+ }
1133
+ /** Effective per-reel row counts (resolves Megaways `rowsPerReel`, else uniform `rows`). */
1134
+ function effectiveRowsPerReel(grid) {
1135
+ if (grid.rowsPerReel && grid.rowsPerReel.length === grid.cols)
1136
+ return grid.rowsPerReel.slice();
1137
+ return Array.from({ length: grid.cols }, () => grid.rows);
1138
+ }
1139
+ /** Total ways-to-win for a ways/megaways grid (product of per-reel heights). */
1140
+ function waysCount(grid) {
1141
+ return effectiveRowsPerReel(grid).reduce((a, b) => a * b, 1);
1142
+ }
1143
+
1144
+ // packages/game-engine/src/slot/config/presets.ts
1145
+ //
1146
+ // Reel presets distilled from our shipped games (see docs/reels-analysis-and-design.md §2).
1147
+ // Each is a DeepPartial override on DEFAULT_REEL_CONFIG.
1148
+ const PRESETS = {
1149
+ classic: {
1150
+ id: 'classic',
1151
+ name: 'Classic 5×3 lines',
1152
+ note: 'Plain spinning reels, sequential stops, settle bounce.',
1153
+ config: {
1154
+ grid: { cols: 5, rows: 3, evaluation: 'lines', cellSize: 96, gap: 6 },
1155
+ motion: {
1156
+ style: 'strip',
1157
+ spinUp: 600,
1158
+ stopStagger: 140,
1159
+ blur: { enabled: true, alpha: 0.85, strength: 6, streaks: false },
1160
+ },
1161
+ },
1162
+ },
1163
+ 'kitsune-wrath': {
1164
+ id: 'kitsune-wrath',
1165
+ name: 'Kitsune Wrath 7×7 cluster',
1166
+ note: 'Tumble grid, sticky wilds, position multipliers, scatter anticipation.',
1167
+ config: {
1168
+ grid: { cols: 7, rows: 7, evaluation: 'cluster', cellSize: 66, gap: 0 },
1169
+ motion: {
1170
+ style: 'cascade-drop',
1171
+ spinUp: 400,
1172
+ squash: { enabled: true, scaleX: 1.12, scaleY: 0.88, ms: 90 },
1173
+ },
1174
+ cascade: {
1175
+ enabled: true,
1176
+ gravity: true,
1177
+ dimNonWinners: true,
1178
+ multiplier: {
1179
+ enabled: true,
1180
+ start: 1,
1181
+ mode: 'mul',
1182
+ step: 2,
1183
+ cap: 128,
1184
+ persistInFreeSpins: false,
1185
+ },
1186
+ },
1187
+ anticipation: { enabled: true, triggerSymbols: ['scatter'], threshold: 4 },
1188
+ features: {
1189
+ sticky: { enabled: true, durationSpins: 3 },
1190
+ multiplier: { enabled: true, scope: 'perSymbol', combine: 'multiplicative', max: 128 },
1191
+ },
1192
+ },
1193
+ },
1194
+ 'moon-spice': {
1195
+ id: 'moon-spice',
1196
+ name: 'Moon Spice 5×4 ways',
1197
+ note: 'Spin + cascade refill, frame shake on wild/scatter, 1024 ways.',
1198
+ config: {
1199
+ grid: { cols: 5, rows: 4, evaluation: 'ways', cellSize: 90, gap: 4 },
1200
+ motion: {
1201
+ style: 'strip',
1202
+ spinUp: 1200,
1203
+ stopStagger: 120,
1204
+ settle: { amp: 6, ms: 220, easing: 'easeOutQuad' },
1205
+ },
1206
+ cascade: {
1207
+ enabled: true,
1208
+ gravity: true,
1209
+ easings: { highlight: 'easeOutQuad', remove: 'easeInBack', drop: 'easeOutBounce' },
1210
+ },
1211
+ win: { frameShake: { enabled: true, amp: 2.2, ms: 180, onlyOnSymbols: ['wild', 'scatter'] } },
1212
+ features: { multiplier: { enabled: true, scope: 'perSymbol', combine: 'additive' } },
1213
+ },
1214
+ },
1215
+ 'stone-rush': {
1216
+ id: 'stone-rush',
1217
+ name: 'Stone Rush 7×7 tumble',
1218
+ note: 'Weighty gravity with squash/stretch, per-step deceleration, multiplier orbs.',
1219
+ config: {
1220
+ grid: { cols: 7, rows: 7, evaluation: 'cluster', cellSize: 66, gap: 8 },
1221
+ motion: {
1222
+ style: 'cascade-drop',
1223
+ spinUp: 320,
1224
+ squash: { enabled: true, scaleX: 1.3, scaleY: 0.7, ms: 110 },
1225
+ },
1226
+ cascade: {
1227
+ enabled: true,
1228
+ gravity: true,
1229
+ perStepDecel: 0.08,
1230
+ perStepDecelCap: 1.5,
1231
+ multiplier: {
1232
+ enabled: true,
1233
+ start: 1,
1234
+ mode: 'add',
1235
+ step: 1,
1236
+ cap: 100,
1237
+ persistInFreeSpins: true,
1238
+ },
1239
+ },
1240
+ features: {
1241
+ multiplier: { enabled: true, scope: 'global', combine: 'additive', max: 100 },
1242
+ expandingWild: { enabled: true, symbol: 'wild', toFullReel: false },
1243
+ },
1244
+ },
1245
+ },
1246
+ 'hot-ross': {
1247
+ id: 'hot-ross',
1248
+ name: 'Hot Ross 5×5 lines',
1249
+ note: 'Classic strip spin with motion blur, scatter tease, expanding wilds, sticky reels.',
1250
+ config: {
1251
+ grid: { cols: 5, rows: 5, evaluation: 'lines', cellSize: 88, gap: 4 },
1252
+ motion: {
1253
+ style: 'strip',
1254
+ spinUp: 1650,
1255
+ stopStagger: 210,
1256
+ blur: { enabled: true, alpha: 0.82, strength: 8, streaks: true },
1257
+ settle: { amp: 7, ms: 240, easing: 'easeOutBack' },
1258
+ },
1259
+ anticipation: {
1260
+ enabled: true,
1261
+ triggerSymbols: ['scatter'],
1262
+ threshold: 2,
1263
+ slowdownFactor: 0.28,
1264
+ holdMs: 450,
1265
+ },
1266
+ features: {
1267
+ expandingWild: { enabled: true, symbol: 'wild', reels: [1, 2, 3], toFullReel: true },
1268
+ sticky: { enabled: true, durationSpins: 0 },
1269
+ },
1270
+ },
1271
+ },
1272
+ magnus: {
1273
+ id: 'magnus',
1274
+ name: 'Magnus 6×6 transmute',
1275
+ note: 'Cascade with idle-breathing symbols, scatter anticipation + reel zoom, transform.',
1276
+ config: {
1277
+ grid: { cols: 6, rows: 6, evaluation: 'cluster', cellSize: 74, gap: 2 },
1278
+ motion: {
1279
+ style: 'cascade-drop',
1280
+ spinUp: 400,
1281
+ squash: { enabled: true, scaleX: 1.14, scaleY: 0.86, ms: 110 },
1282
+ },
1283
+ cascade: {
1284
+ enabled: true,
1285
+ gravity: true,
1286
+ multiplier: {
1287
+ enabled: true,
1288
+ start: 1,
1289
+ mode: 'add',
1290
+ step: 1,
1291
+ cap: null,
1292
+ persistInFreeSpins: true,
1293
+ },
1294
+ },
1295
+ anticipation: {
1296
+ enabled: true,
1297
+ triggerSymbols: ['scatter'],
1298
+ threshold: 2,
1299
+ holdMs: 280,
1300
+ zoom: { enabled: true, scale: 1.3, ms: 600 },
1301
+ },
1302
+ features: {
1303
+ transform: {
1304
+ enabled: true,
1305
+ source: 'randomLow',
1306
+ target: 'h1',
1307
+ allInstances: true,
1308
+ upgradeOnly: true,
1309
+ },
1310
+ },
1311
+ },
1312
+ },
1313
+ megaways: {
1314
+ id: 'megaways',
1315
+ name: 'Megaways 6× (2–7)',
1316
+ note: 'Variable per-reel heights, tumble + climbing multiplier, anticipation.',
1317
+ config: {
1318
+ grid: {
1319
+ cols: 6,
1320
+ rows: 4,
1321
+ rowsPerReel: [4, 6, 3, 7, 5, 2],
1322
+ evaluation: 'megaways',
1323
+ cellSize: 74,
1324
+ gap: 4,
1325
+ minRows: 2,
1326
+ maxRows: 7,
1327
+ },
1328
+ motion: { style: 'strip', spinUp: 900, stopStagger: 130 },
1329
+ cascade: {
1330
+ enabled: true,
1331
+ gravity: true,
1332
+ multiplier: {
1333
+ enabled: true,
1334
+ start: 1,
1335
+ mode: 'add',
1336
+ step: 1,
1337
+ cap: null,
1338
+ persistInFreeSpins: true,
1339
+ },
1340
+ },
1341
+ anticipation: { enabled: true, triggerSymbols: ['scatter'], threshold: 3 },
1342
+ },
1343
+ },
1344
+ };
1345
+ const PRESET_LIST = Object.values(PRESETS);
1346
+
1347
+ // packages/game-engine/src/slot/motion/SpinEngine.ts
1348
+ //
1349
+ // Configurable spin motion. Supports three styles — 'swap' (texture-swap ring),
1350
+ // 'strip' (a tape of symbols slides past a masked window) and 'cascade-drop'
1351
+ // (symbols drop in from above) — plus stop modes, turbo/intensity scaling, motion
1352
+ // blur, settle-bounce, squash-on-impact and slam (quick) stop.
1353
+ //
1354
+ // Presentation only: the landing grid is the already-resolved outcome. The engine
1355
+ // never re-rolls a result.
1356
+ class SpinEngine {
1357
+ _grid;
1358
+ _resolve;
1359
+ _cfg;
1360
+ _win = DEFAULT_REEL_CONFIG.win;
1361
+ _killed = false;
1362
+ _shaking = false;
1363
+ _temp = [];
1364
+ constructor(grid, resolve, cfg, win) {
1365
+ this._grid = grid;
1366
+ this._resolve = resolve;
1367
+ this._cfg = cfg;
1368
+ if (win)
1369
+ this._win = win;
1370
+ }
1371
+ setConfig(cfg) {
1372
+ this._cfg = cfg;
1373
+ }
1374
+ setWin(win) {
1375
+ this._win = win;
1376
+ }
1377
+ /** Quick decaying frame shake when a landed reel carries a configured trigger symbol. */
1378
+ async _frameShake(landing) {
1379
+ const fs = this._win.frameShake;
1380
+ if (!fs.enabled || this._shaking || this._killed)
1381
+ return;
1382
+ const triggers = fs.onlyOnSymbols;
1383
+ if (triggers && !landing.some((c) => c.symbol && triggers.includes(c.symbol)))
1384
+ return;
1385
+ this._shaking = true;
1386
+ const baseX = this._grid.x;
1387
+ const amp = fs.amp * INTENSITY_SCALE[this._cfg.intensity];
1388
+ const steps = 4;
1389
+ for (let i = 0; i < steps && !this._killed; i++) {
1390
+ const a = amp * (1 - i / steps) * (i % 2 ? -1 : 1);
1391
+ await Tween.to(this._grid, { x: baseX + a }, fs.ms / (steps + 1), EASING_BY_NAME['easeOutQuad']);
1392
+ }
1393
+ if (!this._killed && !this._grid.destroyed)
1394
+ this._grid.x = baseX;
1395
+ this._shaking = false;
1396
+ }
1397
+ scale(opts) {
1398
+ const turbo = opts?.turbo ? this._cfg.turboFactor : 1;
1399
+ return turbo * INTENSITY_SCALE[this._cfg.intensity];
1400
+ }
1401
+ /** PURE: per-reel stop schedule. No Pixi mutation. */
1402
+ plan(data, opts) {
1403
+ const f = this.scale(opts);
1404
+ const cols = this._grid.cols;
1405
+ const order = (reel) => (this._cfg.stopOrder === 'rtl' ? cols - 1 - reel : reel);
1406
+ const anticipate = new Set(opts?.anticipateReels ?? []);
1407
+ const out = [];
1408
+ for (let reel = 0; reel < cols; reel++) {
1409
+ const idx = order(reel);
1410
+ let stopTime;
1411
+ if (this._cfg.stopMode === 'sync')
1412
+ stopTime = (this._cfg.spinUp + this._cfg.hold) * f;
1413
+ else if (this._cfg.stopMode === 'random')
1414
+ stopTime =
1415
+ (this._cfg.spinUp +
1416
+ this._cfg.hold +
1417
+ idx * this._cfg.stopStagger * (0.4 + (reel % 3) * 0.3)) *
1418
+ f;
1419
+ else
1420
+ stopTime = (this._cfg.spinUp + this._cfg.hold + idx * this._cfg.stopStagger) * f;
1421
+ const isAnticipated = anticipate.has(reel);
1422
+ if (isAnticipated)
1423
+ stopTime += (opts?.anticipateHoldMs ?? 0) * f;
1424
+ out.push({
1425
+ reel,
1426
+ stopTime,
1427
+ landing: data.targetGrid[reel] ?? [],
1428
+ settle: { amp: this._cfg.settle.amp, ms: this._cfg.settle.ms * f },
1429
+ anticipated: isAnticipated,
1430
+ });
1431
+ }
1432
+ return out;
1433
+ }
1434
+ /** Execute the spin for every reel concurrently. */
1435
+ async run(data, opts) {
1436
+ this._killed = false;
1437
+ this._temp = [];
1438
+ const plan = this.plan(data, opts);
1439
+ const f = this.scale(opts);
1440
+ await Promise.all(plan.map((p) => this._runReel(p, data, opts, f)));
1441
+ this._cleanupTemp();
1442
+ }
1443
+ async _runReel(p, data, opts, f) {
1444
+ if (this._killed)
1445
+ return;
1446
+ switch (this._cfg.style) {
1447
+ case 'strip':
1448
+ return this._runStrip(p, data, opts, f);
1449
+ case 'cascade-drop':
1450
+ return this._runDrop(p, opts, f);
1451
+ case 'swap':
1452
+ default:
1453
+ return this._runSwap(p, data, opts, f);
1454
+ }
1455
+ }
1456
+ /** Anticipation time-stretch factor for a reel (>=1, longer = slower). */
1457
+ slowOf(p, opts) {
1458
+ return p.anticipated ? Math.max(1, 1 / (opts?.anticipateSlowdown ?? 1)) : 1;
1459
+ }
1460
+ // ── swap: cycle symbols quickly in the real cells, then land ──────────────
1461
+ async _runSwap(p, data, opts, f) {
1462
+ const rows = this._grid.rowsOf(p.reel);
1463
+ const cells = Array.from({ length: rows }, (_, r) => this._grid.getCell(p.reel, r));
1464
+ const strip = data.strip?.(p.reel) ?? p.landing.map((c) => c.symbol ?? '').filter(Boolean);
1465
+ const tape = strip.length ? strip : ['?'];
1466
+ const blur = this._applyBlur(cells, true);
1467
+ const tickMs = 1000 / 30;
1468
+ // anticipation makes the reel spin longer before it lands
1469
+ const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p, opts)) / tickMs));
1470
+ for (let i = 0; i < ticks; i++) {
1471
+ if (this._killed)
1472
+ break;
1473
+ for (let r = 0; r < cells.length; r++)
1474
+ cells[r].setData({ symbol: tape[(i + r) % tape.length] || null });
1475
+ await Tween.delay(tickMs);
1476
+ }
1477
+ blur?.();
1478
+ for (let r = 0; r < cells.length; r++)
1479
+ cells[r].setData(p.landing[r] ?? { symbol: null });
1480
+ await this._settle(p.reel, p.settle, f);
1481
+ await this._frameShake(p.landing);
1482
+ }
1483
+ // ── strip: a tape Container slides down past the window, then lands ────────
1484
+ async _runStrip(p, data, opts, f) {
1485
+ const rows = this._grid.rowsOf(p.reel);
1486
+ const realCells = Array.from({ length: rows }, (_, r) => this._grid.getCell(p.reel, r));
1487
+ const base = this._grid.cellPosition(p.reel, 0);
1488
+ const step = this._grid.cellPosition(p.reel, 1).y - base.y;
1489
+ const tapeLen = Math.max(this._cfg.symbolsPerReel, rows + 4);
1490
+ const strip = data.strip?.(p.reel) ?? p.landing.map((c) => c.symbol ?? '').filter(Boolean);
1491
+ const pool = strip.length ? strip : ['?'];
1492
+ // Tape cells laid out top→bottom at local y = i*step. The bottom `rows` cells carry the
1493
+ // landing symbols; everything above is filler.
1494
+ const tape = new Container();
1495
+ tape.x = base.x;
1496
+ const landingStart = tapeLen - rows;
1497
+ for (let i = 0; i < tapeLen; i++) {
1498
+ const cell = new SymbolCell({ size: this._grid.cellSize, resolve: this._resolve });
1499
+ const sym = i >= landingStart
1500
+ ? (p.landing[i - landingStart]?.symbol ?? null)
1501
+ : (pool[i % pool.length] ?? null);
1502
+ cell.setData({ symbol: sym });
1503
+ cell.position.set(0, i * step);
1504
+ tape.addChild(cell);
1505
+ }
1506
+ // At rest the bottom block aligns with the window; start shifted up by the whole tape.
1507
+ const restY = base.y - landingStart * step;
1508
+ const startY = restY - tapeLen * step;
1509
+ tape.y = startY;
1510
+ realCells.forEach((c) => (c.visible = false));
1511
+ this._grid.addChild(tape);
1512
+ this._temp.push(tape);
1513
+ const clearBlur = this._applyBlur([tape], true);
1514
+ const slow = this.slowOf(p, opts);
1515
+ // overshoot/settle honour the configured settle (amp in px, easing)
1516
+ const overshoot = p.settle.amp || step * 0.18;
1517
+ await Tween.to(tape, { y: restY + overshoot }, p.stopTime * slow, easingByName('easeInOutQuad'));
1518
+ if (this._killed) {
1519
+ clearBlur?.();
1520
+ return;
1521
+ }
1522
+ clearBlur?.();
1523
+ await Tween.to(tape, { y: restY }, Math.max(120, p.settle.ms), easingByName(this._cfg.settle.easing));
1524
+ // hand the result back to the real cells
1525
+ for (let r = 0; r < rows; r++)
1526
+ realCells[r].setData(p.landing[r] ?? { symbol: null });
1527
+ realCells.forEach((c) => (c.visible = true));
1528
+ tape.destroy();
1529
+ this._temp = this._temp.filter((t) => t !== tape);
1530
+ // squash the real cells on impact when enabled
1531
+ if (this._cfg.squash.enabled)
1532
+ await Promise.all(realCells.map((c) => this._squashCell(c, f)));
1533
+ await this._frameShake(p.landing);
1534
+ }
1535
+ // ── cascade-drop: symbols drop in from above with stagger + bounce + squash ─
1536
+ async _runDrop(p, opts, f) {
1537
+ const rows = this._grid.rowsOf(p.reel);
1538
+ const step = this._grid.cellPosition(p.reel, 1).y - this._grid.cellPosition(p.reel, 0).y;
1539
+ const slow = this.slowOf(p, opts); // anticipation drops the reel in more slowly
1540
+ await Promise.all(Array.from({ length: rows }, (_, r) => r).map(async (r) => {
1541
+ if (this._killed)
1542
+ return;
1543
+ const cell = this._grid.getCell(p.reel, r);
1544
+ const to = this._grid.cellPosition(p.reel, r);
1545
+ cell.setData(p.landing[r] ?? { symbol: null });
1546
+ cell.position.set(to.x, to.y - step * (rows + 1));
1547
+ cell.alpha = 1;
1548
+ const delay = (p.reel * this._cfg.stopStagger * 0.4 + r * 24) * f * slow;
1549
+ if (delay)
1550
+ await Tween.delay(delay);
1551
+ await Tween.to(cell, { 'position.y': to.y }, this._cfg.spinUp * 0.6 * f * slow, easingByName(this._cfg.settle.easing));
1552
+ await this._squashCell(cell, f);
1553
+ }));
1554
+ await this._frameShake(p.landing);
1555
+ }
1556
+ // ── shared helpers ────────────────────────────────────────────────────────
1557
+ async _settle(reel, settle, f) {
1558
+ if (this._killed || settle.amp <= 0)
1559
+ return;
1560
+ const cells = Array.from({ length: this._grid.rowsOf(reel) }, (_, r) => this._grid.getCell(reel, r));
1561
+ const parent = cells[0]?.parent;
1562
+ if (!parent)
1563
+ return;
1564
+ // bounce the whole reel column by moving each cell, then squash the impact
1565
+ await Promise.all(cells.map((c) => {
1566
+ const y = c.y;
1567
+ return Tween.fromTo(c, { y: y - settle.amp }, { y }, settle.ms, EASING_BY_NAME[this._cfg.settle.easing] ?? EASING_BY_NAME['easeOutBack']);
1568
+ }));
1569
+ if (this._cfg.squash.enabled)
1570
+ await Promise.all(cells.map((c) => this._squashCell(c, f)));
1571
+ }
1572
+ async _squashCell(cell, f) {
1573
+ if (!this._cfg.squash.enabled || this._killed)
1574
+ return;
1575
+ const { scaleX, scaleY, ms } = this._cfg.squash;
1576
+ await Tween.to(cell, { 'scale.x': scaleX, 'scale.y': scaleY }, ms * 0.5 * f, EASING_BY_NAME['easeOutQuad']);
1577
+ await Tween.to(cell, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.5 * f, EASING_BY_NAME['easeOutBack']);
1578
+ }
1579
+ /** Apply motion blur (alpha + optional BlurFilter) to targets; returns a disposer that clears it. */
1580
+ _applyBlur(targets, _motion) {
1581
+ if (!this._cfg.blur.enabled)
1582
+ return null;
1583
+ const filter = this._cfg.blur.strength > 0
1584
+ ? new BlurFilter({ strength: this._cfg.blur.strength, quality: 2 })
1585
+ : null;
1586
+ for (const t of targets) {
1587
+ t.alpha = this._cfg.blur.alpha;
1588
+ if (filter)
1589
+ t.filters = [filter];
1590
+ }
1591
+ return () => {
1592
+ for (const t of targets) {
1593
+ t.alpha = 1;
1594
+ t.filters = [];
1595
+ }
1596
+ filter?.destroy();
1597
+ };
1598
+ }
1599
+ _cleanupTemp() {
1600
+ for (const t of this._temp) {
1601
+ if (!t.destroyed)
1602
+ t.destroy();
1603
+ }
1604
+ this._temp = [];
1605
+ }
1606
+ /** Slam / quick stop: snap everything to the target and stop animating. */
1607
+ skip() {
1608
+ if (!this._cfg.slamStop && !this._killed) ;
1609
+ this._killed = true;
1610
+ this._shaking = false;
1611
+ this._cleanupTemp();
1612
+ if (this._grid.destroyed)
1613
+ return;
1614
+ Tween.killTweensOf(this._grid);
1615
+ this._grid.x = 0; // undo any in-flight frame shake
1616
+ for (let c = 0; c < this._grid.cols; c++) {
1617
+ for (let r = 0; r < this._grid.rowsOf(c); r++) {
1618
+ const cell = this._grid.getCell(c, r);
1619
+ if (cell.destroyed)
1620
+ continue;
1621
+ Tween.killTweensOf(cell);
1622
+ cell.visible = true;
1623
+ cell.alpha = 1;
1624
+ cell.filters = [];
1625
+ cell.scale.set(1);
1626
+ }
1627
+ }
1628
+ this._cleanupTemp();
1629
+ }
1630
+ }
1631
+
1632
+ // packages/game-engine/src/slot/motion/AnticipationController.ts
1633
+ //
1634
+ // Decides which trailing reels get the "anticipation" slow-down treatment, based purely on
1635
+ // the already-resolved landing grid (presentation only — never a secondary outcome decision).
1636
+ class AnticipationController {
1637
+ _cfg;
1638
+ constructor(cfg) {
1639
+ this._cfg = cfg;
1640
+ }
1641
+ setConfig(cfg) {
1642
+ this._cfg = cfg;
1643
+ }
1644
+ /** Count how many trigger symbols land on a given reel. */
1645
+ countOnReel(reel) {
1646
+ let n = 0;
1647
+ for (const c of reel)
1648
+ if (c?.symbol && this._cfg.triggerSymbols.includes(c.symbol))
1649
+ n++;
1650
+ return n;
1651
+ }
1652
+ /**
1653
+ * Decide anticipation from the landing grid. Trigger symbols are counted left→right;
1654
+ * once the running total reaches `threshold`, every still-spinning reel after that point
1655
+ * is flagged for the slow treatment (this mirrors "searching for the last scatter").
1656
+ */
1657
+ decide(targetGrid) {
1658
+ if (!this._cfg.enabled)
1659
+ return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1660
+ if (Array.isArray(this._cfg.reels)) {
1661
+ // explicit reel list — arm only if the threshold is met somewhere on the board
1662
+ const total = targetGrid.reduce((sum, reel) => sum + this.countOnReel(reel), 0);
1663
+ const active = total >= this._cfg.threshold;
1664
+ return active
1665
+ ? {
1666
+ active,
1667
+ reels: this._cfg.reels.slice(),
1668
+ slowdown: this._cfg.slowdownFactor,
1669
+ holdMs: this._cfg.holdMs,
1670
+ }
1671
+ : { active: false, reels: [], slowdown: 1, holdMs: 0 };
1672
+ }
1673
+ // 'trailing': find the reel where the cumulative count hits the threshold
1674
+ let running = 0;
1675
+ let armReel = -1;
1676
+ for (let c = 0; c < targetGrid.length; c++) {
1677
+ running += this.countOnReel(targetGrid[c] ?? []);
1678
+ if (running >= this._cfg.threshold) {
1679
+ armReel = c;
1680
+ break;
1681
+ }
1682
+ }
1683
+ if (armReel < 0)
1684
+ return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1685
+ const reels = [];
1686
+ for (let c = armReel + 1; c < targetGrid.length; c++)
1687
+ reels.push(c);
1688
+ if (reels.length === 0)
1689
+ return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1690
+ return { active: true, reels, slowdown: this._cfg.slowdownFactor, holdMs: this._cfg.holdMs };
1691
+ }
1692
+ /** Optionally zoom the grid in while anticipating, then settle back. Returns a reset fn. */
1693
+ async zoomIn(grid) {
1694
+ if (!this._cfg.zoom.enabled)
1695
+ return async () => { };
1696
+ const sx = grid.scale.x;
1697
+ const sy = grid.scale.y;
1698
+ await Tween.to(grid, { 'scale.x': sx * this._cfg.zoom.scale, 'scale.y': sy * this._cfg.zoom.scale }, this._cfg.zoom.ms, easingByName('easeOutCubic'));
1699
+ return async () => {
1700
+ await Tween.to(grid, { 'scale.x': sx, 'scale.y': sy }, this._cfg.zoom.ms, easingByName('easeOutCubic'));
1701
+ };
1702
+ }
1703
+ }
1704
+
1705
+ // packages/game-engine/src/slot/cascade/TumbleController.ts
1706
+ //
1707
+ // Richer cascade/tumble than the back-compat CascadeController: animates surviving symbols
1708
+ // sliding into the gaps (gravity), supports per-step deceleration (tension build), dimming of
1709
+ // non-winning symbols, and a running win multiplier (Pragmatic Tumble / NetEnt Avalanche style).
1710
+ //
1711
+ // Presentation only — the settled board is provided by the caller.
1712
+ class TumbleController {
1713
+ _grid;
1714
+ _cfg;
1715
+ _win = DEFAULT_REEL_CONFIG.win;
1716
+ _killed = false;
1717
+ _mult;
1718
+ constructor(grid, cfg, win) {
1719
+ this._grid = grid;
1720
+ this._cfg = cfg;
1721
+ this._mult = cfg.multiplier.start;
1722
+ if (win)
1723
+ this._win = win;
1724
+ }
1725
+ setConfig(cfg) {
1726
+ this._cfg = cfg;
1727
+ }
1728
+ setWin(win) {
1729
+ this._win = win;
1730
+ }
1731
+ /** Killed, or the grid was torn down underneath us (rebuild during a cascade). */
1732
+ get _dead() {
1733
+ return this._killed || this._grid.destroyed;
1734
+ }
1735
+ get multiplier() {
1736
+ return this._mult;
1737
+ }
1738
+ resetMultiplier() {
1739
+ this._mult = this._cfg.multiplier.start;
1740
+ }
1741
+ advanceMultiplier() {
1742
+ const m = this._cfg.multiplier;
1743
+ if (!m.enabled)
1744
+ return;
1745
+ const next = m.mode === 'mul' ? this._mult * m.step : this._mult + m.step;
1746
+ this._mult = m.cap != null ? Math.min(next, m.cap) : next;
1747
+ }
1748
+ /** Derive survivor slides for one column when explicit drops aren't supplied. */
1749
+ deriveDrops(step) {
1750
+ const removedByCol = new Map();
1751
+ for (const r of step.removedCells) {
1752
+ if (!removedByCol.has(r.col))
1753
+ removedByCol.set(r.col, new Set());
1754
+ removedByCol.get(r.col).add(r.row);
1755
+ }
1756
+ const out = [];
1757
+ for (const [col, removed] of removedByCol) {
1758
+ const rows = this._grid.rowsOf(col);
1759
+ // survivors keep order and fall to the bottom; count holes below each survivor
1760
+ const survivors = [];
1761
+ for (let r = 0; r < rows; r++)
1762
+ if (!removed.has(r))
1763
+ survivors.push(r);
1764
+ const newCount = rows - survivors.length;
1765
+ survivors.forEach((fromRow, i) => {
1766
+ const toRow = newCount + i;
1767
+ if (toRow !== fromRow)
1768
+ out.push({ col, fromRow, toRow });
1769
+ });
1770
+ }
1771
+ return out;
1772
+ }
1773
+ /** Run a single cascade step. `stepIndex` drives per-step deceleration. */
1774
+ async step(step, stepIndex = 0, opts) {
1775
+ if (this._grid.destroyed)
1776
+ return;
1777
+ if (!this._cfg.enabled) {
1778
+ this._grid.setGrid(step.settledGrid);
1779
+ return;
1780
+ }
1781
+ this._killed = false;
1782
+ const turbo = opts?.turbo ? 0.5 : 1;
1783
+ const decel = Math.min(this._cfg.perStepDecelCap, 1 + stepIndex * this._cfg.perStepDecel);
1784
+ const f = turbo * decel;
1785
+ const t = this._cfg.timings;
1786
+ // 1. highlight winners (+ dim others). win.highlightScale / win.glow drive the pop.
1787
+ const winSet = new Set(step.winningCells.map((w) => `${w.col}:${w.row}`));
1788
+ const dimmed = this._cfg.dimNonWinners && step.winningCells.length > 0;
1789
+ if (dimmed)
1790
+ this._dim(winSet);
1791
+ const hs = this._win.highlightScale;
1792
+ await Promise.all(step.winningCells.map((w) => {
1793
+ const cell = this._grid.getCell(w.col, w.row);
1794
+ if (this._win.glow)
1795
+ cell.setState({ winning: true });
1796
+ return Tween.to(cell, { 'scale.x': hs, 'scale.y': hs }, t.highlight * f, easingByName(this._cfg.easings.highlight));
1797
+ }));
1798
+ if (this._dead) {
1799
+ if (dimmed)
1800
+ this._undim();
1801
+ return;
1802
+ }
1803
+ await Tween.delay(t.wait * f);
1804
+ // 2. remove the cleared cells (removedCells, falling back to winningCells)
1805
+ const cleared = step.removedCells.length ? step.removedCells : step.winningCells;
1806
+ await Promise.all(cleared.map((w) => {
1807
+ const cell = this._grid.getCell(w.col, w.row);
1808
+ return Tween.to(cell, { 'scale.x': 0, 'scale.y': 0, alpha: 0 }, t.remove * f, easingByName(this._cfg.easings.remove));
1809
+ }));
1810
+ if (this._dead) {
1811
+ if (dimmed)
1812
+ this._undim();
1813
+ return;
1814
+ }
1815
+ for (const w of cleared) {
1816
+ const cell = this._grid.getCell(w.col, w.row);
1817
+ cell.setData({ symbol: null });
1818
+ cell.setState({});
1819
+ cell.scale.set(1);
1820
+ cell.alpha = 1;
1821
+ }
1822
+ this._undim();
1823
+ // 3. gravity: survivors slide down, new cells drop from above
1824
+ const rowStep = this._grid.cellPosition(0, 1).y - this._grid.cellPosition(0, 0).y;
1825
+ const slides = this._cfg.gravity ? (step.drops ?? this.deriveDrops(step)) : [];
1826
+ const anims = [];
1827
+ for (const d of slides) {
1828
+ const cell = this._grid.getCell(d.col, d.toRow);
1829
+ const home = this._grid.cellPosition(d.col, d.toRow);
1830
+ cell.setData(step.settledGrid[d.col]?.[d.toRow] ?? { symbol: null });
1831
+ cell.alpha = 1;
1832
+ cell.scale.set(1);
1833
+ cell.position.set(home.x, this._grid.cellPosition(d.col, d.fromRow).y);
1834
+ anims.push(Tween.to(cell, { 'position.y': home.y }, t.drop * f, easingByName(this._cfg.easings.drop)));
1835
+ }
1836
+ const perCol = {};
1837
+ for (const n of step.newCells) {
1838
+ const cell = this._grid.getCell(n.col, n.row);
1839
+ const home = this._grid.cellPosition(n.col, n.row);
1840
+ cell.setData({ symbol: n.symbol });
1841
+ cell.setState({ fresh: true });
1842
+ cell.alpha = 1;
1843
+ cell.scale.set(1);
1844
+ cell.position.set(home.x, home.y - rowStep * (this._grid.rowsOf(n.col) + 1));
1845
+ const idx = (perCol[n.col] = (perCol[n.col] ?? 0) + 1);
1846
+ anims.push((async () => {
1847
+ await Tween.delay(idx * 28 * f);
1848
+ if (this._dead)
1849
+ return;
1850
+ await Tween.to(cell, { 'position.y': home.y }, t.refill * f, easingByName(this._cfg.easings.drop));
1851
+ cell.setState({});
1852
+ })());
1853
+ }
1854
+ await Promise.all(anims);
1855
+ if (this._dead)
1856
+ return;
1857
+ // 4. normalise + advance multiplier
1858
+ this._grid.setGrid(step.settledGrid);
1859
+ this._resetPositions();
1860
+ if (step.winningCells.length)
1861
+ this.advanceMultiplier();
1862
+ }
1863
+ _dim(winSet) {
1864
+ for (let c = 0; c < this._grid.cols; c++)
1865
+ for (let r = 0; r < this._grid.rowsOf(c); r++)
1866
+ if (!winSet.has(`${c}:${r}`))
1867
+ this._grid.getCell(c, r).alpha = this._cfg.dimAlpha;
1868
+ }
1869
+ _undim() {
1870
+ for (let c = 0; c < this._grid.cols; c++)
1871
+ for (let r = 0; r < this._grid.rowsOf(c); r++) {
1872
+ const cell = this._grid.getCell(c, r);
1873
+ if (cell.alpha !== 0)
1874
+ cell.alpha = 1;
1875
+ }
1876
+ }
1877
+ _resetPositions() {
1878
+ if (this._grid.destroyed)
1879
+ return;
1880
+ for (let c = 0; c < this._grid.cols; c++)
1881
+ for (let r = 0; r < this._grid.rowsOf(c); r++) {
1882
+ const cell = this._grid.getCell(c, r);
1883
+ const { x, y } = this._grid.cellPosition(c, r);
1884
+ cell.position.set(x, y);
1885
+ cell.scale.set(1);
1886
+ cell.alpha = 1;
1887
+ }
1888
+ }
1889
+ skip() {
1890
+ this._killed = true;
1891
+ for (let c = 0; c < this._grid.cols; c++)
1892
+ for (let r = 0; r < this._grid.rowsOf(c); r++)
1893
+ Tween.killTweensOf(this._grid.getCell(c, r));
1894
+ this._resetPositions();
1895
+ }
1896
+ }
1897
+
1898
+ // packages/game-engine/src/slot/features/types.ts
1899
+ //
1900
+ // Uniform interface for special reel feature mechanics. Each feature is a presentation module:
1901
+ // given the current board + its config (and optional per-spin data), it plays an animation that
1902
+ // visualises the mechanic. `demo()` is the self-contained showcase the reel-lab playground triggers.
1903
+ // ── shared animation helpers ────────────────────────────────────────────────
1904
+ /** Center position of a cell, in fx-layer coordinates (fx shares the grid's transform). */
1905
+ function cellCenter(grid, col, row) {
1906
+ return grid.cellPosition(col, row);
1907
+ }
1908
+ function rowStepOf(grid) {
1909
+ return grid.cellPosition(0, 1).y - grid.cellPosition(0, 0).y;
1910
+ }
1911
+ /** A glowing ring drawn around a cell. Returns a disposer. */
1912
+ function glowRing(fx, grid, col, row, color) {
1913
+ if (fx.destroyed)
1914
+ return () => { };
1915
+ const { x, y } = cellCenter(grid, col, row);
1916
+ const s = grid.cellSize;
1917
+ const g = new Graphics()
1918
+ .roundRect(x - s / 2, y - s / 2, s, s, 10)
1919
+ .stroke({ color, width: 3, alpha: 0.9 });
1920
+ fx.addChild(g);
1921
+ return () => g.destroy();
1922
+ }
1923
+ /** Pulse a cell up and back. */
1924
+ async function pulseCell(cell, scale = 1.15, ms = 220) {
1925
+ if (cell.destroyed)
1926
+ return;
1927
+ await Tween.to(cell, { 'scale.x': scale, 'scale.y': scale }, ms * 0.5, easingByName('easeOutBack'));
1928
+ if (cell.destroyed)
1929
+ return;
1930
+ await Tween.to(cell, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.5, easingByName('easeOutQuad'));
1931
+ }
1932
+ /** Floating label that rises and fades. */
1933
+ async function floatLabel(fx, x, y, text, color = 0xffd24a, ms = 700) {
1934
+ if (fx.destroyed)
1935
+ return;
1936
+ const t = new Text({ text, style: { fontSize: 26, fill: color, fontWeight: '800' } });
1937
+ t.anchor.set(0.5);
1938
+ t.position.set(x, y);
1939
+ t.scale.set(0.5);
1940
+ fx.addChild(t);
1941
+ await Tween.to(t, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.25, easingByName('easeOutBack'));
1942
+ await Tween.to(t, { y: y - 50, alpha: 0 }, ms * 0.75, easingByName('easeOutQuad'));
1943
+ t.destroy();
1944
+ }
1945
+ /** Replace a cell's symbol with a quick morph (shrink → swap → pop). */
1946
+ async function morphSymbol(cell, data, ms = 280) {
1947
+ if (cell.destroyed)
1948
+ return;
1949
+ await Tween.to(cell, { 'scale.x': 0.1, 'scale.y': 0.1 }, ms * 0.4, easingByName('easeInBack'));
1950
+ if (cell.destroyed)
1951
+ return;
1952
+ cell.setData(data);
1953
+ cell.scale.set(0.1);
1954
+ await Tween.to(cell, { 'scale.x': 1, 'scale.y': 1 }, ms * 0.6, easingByName('easeOutBack'));
1955
+ }
1956
+ /** Drop a cell in from above its home position. */
1957
+ async function dropCell(grid, cell, col, row, ms = 280) {
1958
+ if (cell.destroyed)
1959
+ return;
1960
+ const home = grid.cellPosition(col, row);
1961
+ cell.position.set(home.x, home.y - rowStepOf(grid) * (row + 2));
1962
+ cell.alpha = 1;
1963
+ cell.scale.set(1);
1964
+ await Tween.to(cell, { 'position.y': home.y }, ms, easingByName('easeOutBounce'));
1965
+ }
1966
+ function pickFromBoard(board, predicate) {
1967
+ const out = [];
1968
+ for (let c = 0; c < board.length; c++)
1969
+ for (let r = 0; r < (board[c]?.length ?? 0); r++)
1970
+ if (board[c][r] && predicate(board[c][r], c, r))
1971
+ out.push({ col: c, row: r });
1972
+ return out;
1973
+ }
1974
+
1975
+ // packages/game-engine/src/slot/features/wilds.ts
1976
+ function reelsOf(cfg, cols) {
1977
+ return cfg.length
1978
+ ? cfg.filter((r) => r >= 0 && r < cols)
1979
+ : Array.from({ length: cols }, (_, i) => i);
1980
+ }
1981
+ const randInt = (a, b, seed) => Math.min(b, a + Math.floor(((Math.sin(seed * 99.13) + 1) / 2) * (b - a + 1)));
1982
+ /** Expanding wild: a landed wild grows to fill its whole reel. */
1983
+ const ExpandingWild = {
1984
+ key: 'expandingWild',
1985
+ label: 'Expanding wild',
1986
+ enabled: (c) => c.features.expandingWild.enabled,
1987
+ async demo(ctx) {
1988
+ const f = ctx.cfg.features.expandingWild;
1989
+ if (f.onlyInFreeSpins && !ctx.freeSpins) {
1990
+ ctx.log?.('Expanding wild: free-spins only');
1991
+ return;
1992
+ }
1993
+ const cols = ctx.grid.cols;
1994
+ const eligible = reelsOf(f.reels, cols);
1995
+ const reel = eligible[randInt(0, eligible.length - 1, cols)];
1996
+ const rows = ctx.grid.rowsOf(reel);
1997
+ ctx.log?.(`Expanding wild on reel ${reel}`);
1998
+ const disposers = [];
1999
+ for (let r = 0; r < (f.toFullReel ? rows : Math.min(2, rows)); r++) {
2000
+ const cell = ctx.grid.getCell(reel, r);
2001
+ await morphSymbol(cell, { symbol: f.symbol }, f.ms / Math.max(1, rows));
2002
+ disposers.push(glowRing(ctx.fx, ctx.grid, reel, r, 0xffd700));
2003
+ if (ctx.board[reel])
2004
+ ctx.board[reel][r] = { symbol: f.symbol };
2005
+ }
2006
+ await Tween.delay(500);
2007
+ disposers.forEach((d) => d());
2008
+ },
2009
+ };
2010
+ /** Sticky symbols: chosen symbols lock in place for N spins. */
2011
+ const StickySymbols = {
2012
+ key: 'sticky',
2013
+ label: 'Sticky symbols',
2014
+ enabled: (c) => c.features.sticky.enabled,
2015
+ async demo(ctx) {
2016
+ const f = ctx.cfg.features.sticky;
2017
+ const targets = pickFromBoard(ctx.board, (c) => !!c.symbol).slice(0, 3);
2018
+ const picks = targets.filter((_, i) => i % 2 === 0);
2019
+ ctx.log?.(`Sticky: locking ${picks.length} cell(s) for ${f.durationSpins || 'feature'} spins`);
2020
+ await Promise.all(picks.map(async (p, i) => {
2021
+ const cell = ctx.grid.getCell(p.col, p.row);
2022
+ cell.setData({ symbol: f.symbols[0] ?? 'wild', sticky: { remaining: f.durationSpins } });
2023
+ glowRing(ctx.fx, ctx.grid, p.col, p.row, f.ringColor);
2024
+ await pulseCell(cell, 1.2, 260);
2025
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2026
+ await floatLabel(ctx.fx, x, y - ctx.grid.cellSize / 2, f.durationSpins ? `STICKY ${f.durationSpins}` : 'STICKY', f.ringColor, 600 + i * 50);
2027
+ }));
2028
+ },
2029
+ };
2030
+ /** Walking wild: a wild shifts one reel each spin until it leaves the grid. */
2031
+ const WalkingWild = {
2032
+ key: 'walkingWild',
2033
+ label: 'Walking wild',
2034
+ enabled: (c) => c.features.walkingWild.enabled,
2035
+ async demo(ctx) {
2036
+ const f = ctx.cfg.features.walkingWild;
2037
+ const cols = ctx.grid.cols;
2038
+ const dir = f.direction === 'left' ? -1 : 1;
2039
+ let col = dir === 1 ? 0 : cols - 1;
2040
+ // clamp the row into each reel's own height (Megaways reels differ in length)
2041
+ const rowIn = (c) => Math.min(Math.floor(ctx.grid.rowsOf(col) / 2), ctx.grid.rowsOf(c) - 1);
2042
+ let row = rowIn(col);
2043
+ await morphSymbol(ctx.grid.getCell(col, row), { symbol: f.symbol }, 240);
2044
+ ctx.log?.(`Walking wild marching ${f.direction}`);
2045
+ // walk across the grid
2046
+ for (let step = 0; step < cols; step++) {
2047
+ const next = col + dir * f.stepPerSpin;
2048
+ if (next < 0 || next >= cols)
2049
+ break;
2050
+ const nextRow = rowIn(next);
2051
+ const from = cellCenter(ctx.grid, col, row);
2052
+ const to = cellCenter(ctx.grid, next, nextRow);
2053
+ ctx.grid.getCell(col, row).setData({ symbol: null });
2054
+ const ghost = ctx.grid.getCell(next, nextRow);
2055
+ ghost.setData({ symbol: f.symbol });
2056
+ ghost.position.set(from.x, from.y);
2057
+ const ring = glowRing(ctx.fx, ctx.grid, next, nextRow, 0xff66cc);
2058
+ await Tween.to(ghost, { 'position.x': to.x, 'position.y': to.y }, 260, easingByName('easeInOutQuad'));
2059
+ ring();
2060
+ col = next;
2061
+ row = nextRow;
2062
+ }
2063
+ },
2064
+ };
2065
+ /** Random wild injection: a few wilds drop onto random positions. */
2066
+ const RandomWild = {
2067
+ key: 'randomWild',
2068
+ label: 'Random wild injection',
2069
+ enabled: (c) => c.features.randomWild.enabled,
2070
+ async demo(ctx) {
2071
+ const f = ctx.cfg.features.randomWild;
2072
+ const n = Array.isArray(f.count) ? randInt(f.count[0], f.count[1], ctx.grid.cols + 7) : f.count;
2073
+ const spots = pickFromBoard(ctx.board, () => true);
2074
+ const chosen = [];
2075
+ for (let i = 0; i < n && spots.length; i++)
2076
+ chosen.push(spots.splice(randInt(0, spots.length - 1, i + 3), 1)[0]);
2077
+ ctx.log?.(`Injecting ${chosen.length} wild(s)${f.sticky ? ' (sticky)' : ''}${f.multiplier > 1 ? ` ×${f.multiplier}` : ''}`);
2078
+ await Promise.all(chosen.map(async (p) => {
2079
+ const cell = ctx.grid.getCell(p.col, p.row);
2080
+ cell.setData({
2081
+ symbol: 'wild',
2082
+ multiplier: f.multiplier > 1 ? f.multiplier : undefined,
2083
+ sticky: f.sticky ? { remaining: 0 } : undefined,
2084
+ });
2085
+ await dropCell(ctx.grid, cell, p.col, p.row, 300);
2086
+ if (f.sticky)
2087
+ glowRing(ctx.fx, ctx.grid, p.col, p.row, 0xec4899);
2088
+ }));
2089
+ },
2090
+ };
2091
+
2092
+ // packages/game-engine/src/slot/features/symbols.ts
2093
+ const pick = (arr, seed) => arr[Math.floor(((Math.sin(seed * 12.9898) + 1) / 2) * arr.length) % arr.length];
2094
+ /** Mystery symbols: all mystery tiles reveal the SAME single random symbol per spin. */
2095
+ const MysterySymbols = {
2096
+ key: 'mystery',
2097
+ label: 'Mystery symbols',
2098
+ enabled: (c) => c.features.mystery.enabled,
2099
+ async demo(ctx) {
2100
+ const f = ctx.cfg.features.mystery;
2101
+ const spots = pickFromBoard(ctx.board, () => true)
2102
+ .filter((_, i) => i % 3 === 0)
2103
+ .slice(0, 5);
2104
+ spots.forEach((p) => ctx.grid.getCell(p.col, p.row).setData({ symbol: f.symbol }));
2105
+ await Promise.all(spots.map((p) => pulseCell(ctx.grid.getCell(p.col, p.row), 1.1, 200)));
2106
+ const pool = f.revealPool.length ? f.revealPool : ['h1', 'h2', 'h3'];
2107
+ const reveal = pick(pool, ctx.grid.cols + spots.length);
2108
+ ctx.log?.(`Mystery: ${spots.length} tiles reveal "${reveal}"`);
2109
+ await Promise.all(spots.map((p) => morphSymbol(ctx.grid.getCell(p.col, p.row), { symbol: reveal }, f.ms)));
2110
+ spots.forEach((p) => {
2111
+ if (ctx.board[p.col])
2112
+ ctx.board[p.col][p.row] = { symbol: reveal };
2113
+ });
2114
+ },
2115
+ };
2116
+ /** Symbol transform / upgrade: every instance of one symbol type becomes another. */
2117
+ const SymbolTransform = {
2118
+ key: 'transform',
2119
+ label: 'Symbol transform / upgrade',
2120
+ enabled: (c) => c.features.transform.enabled,
2121
+ async demo(ctx) {
2122
+ const f = ctx.cfg.features.transform;
2123
+ const present = Array.from(new Set(pickFromBoard(ctx.board, (c) => !!c.symbol).map((p) => ctx.board[p.col][p.row].symbol)));
2124
+ const lows = present.filter((s) => /^(l|o|a|k|q|j|t|10)/i.test(s));
2125
+ const source = f.source === 'randomLow'
2126
+ ? (pick(lows.length ? lows : present, ctx.grid.cols) ?? present[0])
2127
+ : f.source;
2128
+ if (!source) {
2129
+ ctx.log?.('Transform: nothing to convert');
2130
+ return;
2131
+ }
2132
+ const matches = pickFromBoard(ctx.board, (c) => c.symbol === source);
2133
+ const targets = f.allInstances ? matches : matches.slice(0, 1);
2134
+ ctx.log?.(`Transform: ${targets.length}× "${source}" → "${f.target}"`);
2135
+ await Promise.all(targets.map(async (p, i) => {
2136
+ await Tween.delay(i * 40);
2137
+ await morphSymbol(ctx.grid.getCell(p.col, p.row), { symbol: f.target }, f.ms);
2138
+ if (ctx.board[p.col])
2139
+ ctx.board[p.col][p.row] = { symbol: f.target };
2140
+ }));
2141
+ },
2142
+ };
2143
+ /** Giant / colossal symbol spanning width×height cells. */
2144
+ const GiantSymbol = {
2145
+ key: 'giant',
2146
+ label: 'Giant / colossal symbol',
2147
+ enabled: (c) => c.features.giant.enabled,
2148
+ async demo(ctx) {
2149
+ const f = ctx.cfg.features.giant;
2150
+ if (f.onlyInFreeSpins && !ctx.freeSpins) {
2151
+ ctx.log?.('Giant: free-spins only');
2152
+ return;
2153
+ }
2154
+ const cols = ctx.grid.cols;
2155
+ const w = Math.min(f.width, cols);
2156
+ const anchorCol = Math.max(0, Math.floor((cols - w) / 2));
2157
+ // span only as tall as the SHORTEST covered reel so every covered cell exists (Megaways-safe)
2158
+ let minRows = ctx.grid.rowsOf(anchorCol);
2159
+ for (let c = anchorCol; c < anchorCol + w; c++)
2160
+ minRows = Math.min(minRows, ctx.grid.rowsOf(c));
2161
+ const h = Math.min(f.height, minRows);
2162
+ const sym = f.symbols.length ? pick(f.symbols, cols) : 'h1';
2163
+ ctx.log?.(`Giant ${w}×${h} "${sym}"`);
2164
+ const view = ctx.resolve(sym);
2165
+ if (!view)
2166
+ return;
2167
+ const step = rowStepOf(ctx.grid);
2168
+ const tl = cellCenter(ctx.grid, anchorCol, 0);
2169
+ const giant = new Container();
2170
+ giant.addChild(view);
2171
+ view.resize?.(ctx.grid.cellSize * Math.max(w, h));
2172
+ if (view.scale)
2173
+ view.scale.set(((ctx.grid.cellSize * w) / (ctx.grid.cellSize * Math.max(w, h))) * view.scale.x, ((ctx.grid.cellSize * h) / (ctx.grid.cellSize * Math.max(w, h))) * view.scale.y);
2174
+ giant.position.set(tl.x + ((w - 1) * step) / 2, tl.y + ((h - 1) * step) / 2);
2175
+ // hide covered cells
2176
+ for (let c = anchorCol; c < anchorCol + w; c++)
2177
+ for (let r = 0; r < h; r++)
2178
+ ctx.grid.getCell(c, r).visible = false;
2179
+ giant.scale.set(0.2);
2180
+ giant.alpha = 0;
2181
+ ctx.fx.addChild(giant);
2182
+ await Tween.to(giant, { 'scale.x': 1, 'scale.y': 1, alpha: 1 }, 420, easingByName('easeOutBack'));
2183
+ await Tween.delay(600);
2184
+ giant.destroy();
2185
+ for (let c = anchorCol; c < anchorCol + w; c++)
2186
+ for (let r = 0; r < h; r++)
2187
+ ctx.grid.getCell(c, r).visible = true;
2188
+ },
2189
+ };
2190
+ /** Split symbol (xSplit): doubles every symbol to its left. */
2191
+ const SplitSymbol = {
2192
+ key: 'split',
2193
+ label: 'Split symbol (xSplit)',
2194
+ enabled: (c) => c.features.split.enabled,
2195
+ async demo(ctx) {
2196
+ const f = ctx.cfg.features.split;
2197
+ const cols = ctx.grid.cols;
2198
+ const splitReel = f.reels.length ? f.reels[0] : cols - 1;
2199
+ const row = Math.floor(ctx.grid.rowsOf(splitReel) / 2);
2200
+ await morphSymbol(ctx.grid.getCell(splitReel, row), { symbol: f.symbol }, 240);
2201
+ ctx.log?.(`Split ×${f.factor}: doubling symbols left of reel ${splitReel}`);
2202
+ const left = pickFromBoard(ctx.board, (_c, col) => col < splitReel);
2203
+ await Promise.all(left.map(async (p, i) => {
2204
+ await Tween.delay((i % 6) * 30);
2205
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2206
+ await pulseCell(ctx.grid.getCell(p.col, p.row), 1.12, 200);
2207
+ await floatLabel(ctx.fx, x + ctx.grid.cellSize / 3, y - ctx.grid.cellSize / 3, `×${f.factor}`, 0x9b5cff, 600);
2208
+ }));
2209
+ },
2210
+ };
2211
+ /** Stacked symbols: a reel shows a full stack of one symbol. */
2212
+ const StackedSymbols = {
2213
+ key: 'stacked',
2214
+ label: 'Stacked symbols',
2215
+ enabled: (c) => c.features.stacked.enabled,
2216
+ async demo(ctx) {
2217
+ const f = ctx.cfg.features.stacked;
2218
+ const cols = ctx.grid.cols;
2219
+ const reel = Math.floor(cols / 2);
2220
+ const rows = ctx.grid.rowsOf(reel);
2221
+ const sym = f.symbols.length ? pick(f.symbols, reel) : 'h1';
2222
+ const height = Math.min(f.height, rows);
2223
+ ctx.log?.(`Stacked "${sym}" ×${height} on reel ${reel}`);
2224
+ const start = Math.floor((rows - height) / 2);
2225
+ await Promise.all(Array.from({ length: height }, (_, i) => start + i).map(async (r, i) => {
2226
+ await Tween.delay(i * 50);
2227
+ const cell = ctx.grid.getCell(reel, r);
2228
+ await morphSymbol(cell, { symbol: sym }, 220);
2229
+ if (ctx.board[reel])
2230
+ ctx.board[reel][r] = { symbol: sym };
2231
+ }));
2232
+ },
2233
+ };
2234
+
2235
+ // packages/game-engine/src/slot/features/extra.ts
2236
+ const seeded = (a, b, seed) => Math.min(b, a + Math.floor(((Math.sin(seed * 53.17) + 1) / 2) * (b - a + 1)));
2237
+ /** Multiplier symbols: marked cells carry values that combine into the win. */
2238
+ const MultiplierSymbols = {
2239
+ key: 'multiplier',
2240
+ label: 'Multiplier symbols',
2241
+ enabled: (c) => c.features.multiplier.enabled,
2242
+ async demo(ctx) {
2243
+ const f = ctx.cfg.features.multiplier;
2244
+ const spots = pickFromBoard(ctx.board, () => true)
2245
+ .filter((_, i) => i % 4 === 0)
2246
+ .slice(0, 3);
2247
+ const values = [2, 3, 5].slice(0, spots.length);
2248
+ spots.forEach((p, i) => ctx.grid.getCell(p.col, p.row).setData({ symbol: f.symbol ?? 'wild', multiplier: values[i] }));
2249
+ await Promise.all(spots.map((p) => pulseCell(ctx.grid.getCell(p.col, p.row), 1.2, 240)));
2250
+ const total = f.combine === 'additive'
2251
+ ? values.reduce((a, b) => a + b, 0)
2252
+ : values.reduce((a, b) => a * b, 1);
2253
+ const capped = Math.min(total, f.max);
2254
+ ctx.log?.(`Multiplier (${f.combine}, ${f.scope}): ${values.join(f.combine === 'additive' ? ' + ' : ' × ')} = ×${capped}`);
2255
+ // fly each value toward the centre, then show the combined total
2256
+ await Promise.all(spots.map(async (p) => {
2257
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2258
+ await floatLabel(ctx.fx, x, y, `×${values[spots.indexOf(p)]}`, 0xffd24a, 500);
2259
+ }));
2260
+ const cx = (ctx.grid.cols * ctx.grid.cellSize) / 2 - ctx.grid.cellSize / 2;
2261
+ const cy = (ctx.grid.rows * ctx.grid.cellSize) / 2 - ctx.grid.cellSize / 2;
2262
+ await floatLabel(ctx.fx, cx, cy, `×${capped}`, 0xffe24a, 900);
2263
+ },
2264
+ };
2265
+ /** Nudge / xNudge: a reel nudges one position (xNudge fills a stacked wild, +1 mult per nudge). */
2266
+ const NudgeReels = {
2267
+ key: 'nudge',
2268
+ label: 'Nudge / xNudge',
2269
+ enabled: (c) => c.features.nudge.enabled,
2270
+ async demo(ctx) {
2271
+ const f = ctx.cfg.features.nudge;
2272
+ const cols = ctx.grid.cols;
2273
+ const reel = f.reels.length ? f.reels[0] : Math.floor(cols / 2);
2274
+ const rows = ctx.grid.rowsOf(reel);
2275
+ ctx.log?.(f.toFullReel ? `xNudge: filling reel ${reel} with wild` : `Nudge reel ${reel} by ${f.step}`);
2276
+ if (f.toFullReel) {
2277
+ let mult = f.multiplierStart;
2278
+ for (let r = 0; r < rows; r++) {
2279
+ const cell = ctx.grid.getCell(reel, r);
2280
+ await morphSymbol(cell, { symbol: 'wild', multiplier: mult > 1 ? mult : undefined }, 160);
2281
+ mult += f.multiplierPerNudge;
2282
+ }
2283
+ const { x, y } = cellCenter(ctx.grid, reel, Math.floor(rows / 2));
2284
+ await floatLabel(ctx.fx, x, y, `×${mult - f.multiplierPerNudge}`, 0xff7a3c, 800);
2285
+ }
2286
+ else {
2287
+ // shift the whole column down by `step` cells, then settle
2288
+ const step = ctx.grid.cellPosition(reel, 1).y - ctx.grid.cellPosition(reel, 0).y;
2289
+ const cells = Array.from({ length: rows }, (_, r) => ctx.grid.getCell(reel, r));
2290
+ await Promise.all(cells.map((c) => Tween.to(c, { 'position.y': c.y + step * f.step }, 240, easingByName('easeOutBack'))));
2291
+ cells.forEach((c, r) => c.position.set(ctx.grid.cellPosition(reel, r).x, ctx.grid.cellPosition(reel, r).y));
2292
+ }
2293
+ },
2294
+ };
2295
+ /** Hold-and-spin / Hold & Win: special symbols lock and respins reset on each new lock. */
2296
+ const HoldAndSpin = {
2297
+ key: 'holdAndSpin',
2298
+ label: 'Hold & Spin (respin)',
2299
+ enabled: (c) => c.features.holdAndSpin.enabled,
2300
+ async demo(ctx) {
2301
+ const f = ctx.cfg.features.holdAndSpin;
2302
+ const all = pickFromBoard(ctx.board, () => true);
2303
+ const sym = f.lockSymbols[0] ?? 'coin';
2304
+ const locked = new Set();
2305
+ const lock = async (p, value) => {
2306
+ const cell = ctx.grid.getCell(p.col, p.row);
2307
+ cell.setData({ symbol: sym });
2308
+ await dropCell(ctx.grid, cell, p.col, p.row, 240);
2309
+ glowRing(ctx.fx, ctx.grid, p.col, p.row, 0xffcf5c);
2310
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2311
+ await floatLabel(ctx.fx, x, y, value, 0xffe24a, 500);
2312
+ locked.add(`${p.col}:${p.row}`);
2313
+ };
2314
+ // trigger
2315
+ const trigger = all.slice(0, f.triggerThreshold);
2316
+ ctx.log?.(`Hold & Spin triggered with ${trigger.length} ${sym}`);
2317
+ await Promise.all(trigger.map((p, i) => lock(p, `${(i + 1) * 5}`)));
2318
+ let respins = f.respinsAwarded;
2319
+ let round = 0;
2320
+ while (respins > 0) {
2321
+ round++;
2322
+ const free = all.filter((p) => !locked.has(`${p.col}:${p.row}`));
2323
+ const landed = free.filter((_, i) => seeded(0, 3, i + round * 3) === 0).slice(0, 2);
2324
+ ctx.log?.(`Respin ${round}: ${respins} left` + (landed.length ? ` — ${landed.length} new lock` : ''));
2325
+ if (landed.length && f.resetOnNewSymbol) {
2326
+ respins = f.respinsAwarded;
2327
+ await Promise.all(landed.map((p) => lock(p, 'JP')));
2328
+ }
2329
+ else
2330
+ respins--;
2331
+ if (locked.size >= all.length) {
2332
+ ctx.log?.(f.fullGridAwardsGrand ? 'Full grid — GRAND!' : 'Full grid');
2333
+ break;
2334
+ }
2335
+ await Tween.delay(120);
2336
+ }
2337
+ ctx.log?.(`Hold & Spin: ${locked.size} locked`);
2338
+ },
2339
+ };
2340
+ /** Random pre-spin reel modifiers (add rows, inject wilds, set giant, guaranteed wilds). */
2341
+ const ReelModifier = {
2342
+ key: 'reelModifier',
2343
+ label: 'Random reel modifier',
2344
+ enabled: (c) => c.features.reelModifier.enabled,
2345
+ async demo(ctx) {
2346
+ const f = ctx.cfg.features.reelModifier;
2347
+ const pool = f.pool.length
2348
+ ? f.pool
2349
+ : [{ effect: 'addWilds', magnitude: 3, weight: 1 }];
2350
+ const totalW = pool.reduce((a, m) => a + m.weight, 0);
2351
+ let roll = ((Math.sin(ctx.grid.cols * 7.7) + 1) / 2) * totalW;
2352
+ let mod = pool[0];
2353
+ for (const m of pool) {
2354
+ roll -= m.weight;
2355
+ if (roll <= 0) {
2356
+ mod = m;
2357
+ break;
2358
+ }
2359
+ }
2360
+ ctx.log?.(`Reel modifier: ${mod.effect} (+${mod.magnitude})`);
2361
+ if (mod.effect === 'addRows') {
2362
+ const next = ctx.grid.rowsPerReel.map((r) => r + mod.magnitude);
2363
+ // grow the board (new cells on top) and keep the system config in sync so the new rows
2364
+ // aren't blank and a later spin/rebuild doesn't revert the shape
2365
+ for (let c = 0; c < ctx.board.length; c++) {
2366
+ const col = ctx.board[c] ?? (ctx.board[c] = []);
2367
+ const fill = col[0]?.symbol ?? 'h1';
2368
+ for (let i = 0; i < mod.magnitude; i++)
2369
+ col.unshift({ symbol: fill });
2370
+ }
2371
+ ctx.cfg.grid.rowsPerReel = next;
2372
+ ctx.grid.reshape(next);
2373
+ ctx.grid.setGrid(ctx.board);
2374
+ }
2375
+ else if (mod.effect === 'addWilds' || mod.effect === 'guaranteedWilds') {
2376
+ const spots = pickFromBoard(ctx.board, () => true);
2377
+ const chosen = [];
2378
+ for (let i = 0; i < mod.magnitude && spots.length; i++)
2379
+ chosen.push(spots.splice(seeded(0, spots.length - 1, i), 1)[0]);
2380
+ await Promise.all(chosen.map(async (p) => {
2381
+ const cell = ctx.grid.getCell(p.col, p.row);
2382
+ cell.setData({ symbol: 'wild' });
2383
+ await dropCell(ctx.grid, cell, p.col, p.row, 280);
2384
+ }));
2385
+ }
2386
+ else if (mod.effect === 'setGiant') {
2387
+ const cell = ctx.grid.getCell(0, 0);
2388
+ await pulseCell(cell, 1.3, 300);
2389
+ }
2390
+ },
2391
+ };
2392
+
2393
+ /** All feature modules keyed by their FeatureKey. */
2394
+ const FEATURES = {
2395
+ expandingWild: ExpandingWild,
2396
+ sticky: StickySymbols,
2397
+ walkingWild: WalkingWild,
2398
+ randomWild: RandomWild,
2399
+ mystery: MysterySymbols,
2400
+ transform: SymbolTransform,
2401
+ giant: GiantSymbol,
2402
+ split: SplitSymbol,
2403
+ stacked: StackedSymbols,
2404
+ multiplier: MultiplierSymbols,
2405
+ nudge: NudgeReels,
2406
+ holdAndSpin: HoldAndSpin,
2407
+ reelModifier: ReelModifier,
2408
+ };
2409
+ /** Features in canonical resolve order (see docs §3.4). */
2410
+ const FEATURE_LIST = FEATURE_KEYS.map((k) => FEATURES[k]);
2411
+
2412
+ // packages/game-engine/src/slot/system/ReelSystem.ts
2413
+ //
2414
+ // The configurable reel system facade. `createReelSystem({ resolve, config })` builds a grid and
2415
+ // wires the spin engine, anticipation controller, tumble/cascade controller and the special-feature
2416
+ // registry — all driven by one `ReelSystemConfig`. Presentation only: it draws boards you feed it.
2417
+ /** Drop a `rowsPerReel` whose length no longer matches `cols` so geometry/ways stay consistent. */
2418
+ function normalizeGrid(cfg) {
2419
+ if (cfg.grid.rowsPerReel && cfg.grid.rowsPerReel.length !== cfg.grid.cols) {
2420
+ const next = { ...cfg, grid: { ...cfg.grid } };
2421
+ delete next.grid.rowsPerReel;
2422
+ return next;
2423
+ }
2424
+ return cfg;
2425
+ }
2426
+ function createReelSystem(opts) {
2427
+ let config = normalizeGrid(resolveReelConfig(opts.config));
2428
+ const resolve = opts.resolve;
2429
+ const log = opts.log;
2430
+ const view = new Container();
2431
+ let grid;
2432
+ let fx;
2433
+ let spin;
2434
+ let anticipation;
2435
+ let tumble;
2436
+ let board = opts.board ?? emptyBoard(config);
2437
+ // custom features keyed by id; built-ins live in FEATURES/FEATURE_LIST
2438
+ const custom = new Map();
2439
+ for (const f of opts.features ?? [])
2440
+ custom.set(f.key, f);
2441
+ const allFeatures = () => {
2442
+ const seen = new Set();
2443
+ const out = [];
2444
+ for (const f of [...FEATURE_LIST, ...custom.values()]) {
2445
+ const eff = custom.get(f.key) ?? f; // a custom feature overrides a built-in with the same key
2446
+ if (seen.has(eff.key))
2447
+ continue;
2448
+ seen.add(eff.key);
2449
+ out.push(eff);
2450
+ }
2451
+ return out;
2452
+ };
2453
+ const findFeature = (key) => custom.get(key) ?? FEATURES[key];
2454
+ function buildGrid() {
2455
+ if (grid) {
2456
+ spin?.skip();
2457
+ tumble?.skip();
2458
+ for (const child of fx?.children.slice() ?? [])
2459
+ Tween.killTweensOf(child);
2460
+ grid.destroy({ children: true });
2461
+ }
2462
+ grid = new ReelGrid({
2463
+ cols: config.grid.cols,
2464
+ rows: config.grid.rows,
2465
+ rowsPerReel: config.grid.rowsPerReel ?? effectiveRowsPerReel(config.grid),
2466
+ cellSize: config.grid.cellSize,
2467
+ gap: config.grid.gap,
2468
+ resolve,
2469
+ frameStyle: config.grid.frameStyle,
2470
+ mask: config.grid.mask,
2471
+ decoration: config.grid.decoration?.padding
2472
+ ? { padding: config.grid.decoration.padding }
2473
+ : undefined,
2474
+ });
2475
+ fx = new Container();
2476
+ grid.addChild(fx);
2477
+ view.addChild(grid);
2478
+ spin = new SpinEngine(grid, resolve, config.motion, config.win);
2479
+ anticipation = new AnticipationController(config.anticipation);
2480
+ tumble = new TumbleController(grid, config.cascade, config.win);
2481
+ grid.setGrid(board);
2482
+ }
2483
+ function geometryChanged(next) {
2484
+ const a = config.grid, b = next.grid;
2485
+ return (a.cols !== b.cols ||
2486
+ a.rows !== b.rows ||
2487
+ a.cellSize !== b.cellSize ||
2488
+ a.gap !== b.gap ||
2489
+ a.mask !== b.mask ||
2490
+ JSON.stringify(a.rowsPerReel) !== JSON.stringify(b.rowsPerReel) ||
2491
+ (a.decoration?.padding ?? 0) !== (b.decoration?.padding ?? 0));
2492
+ }
2493
+ function ctx(freeSpins) {
2494
+ return { grid, resolve, cfg: config, fx, board, freeSpins, log };
2495
+ }
2496
+ buildGrid();
2497
+ const api = {
2498
+ view,
2499
+ get grid() {
2500
+ return grid;
2501
+ },
2502
+ get fx() {
2503
+ return fx;
2504
+ },
2505
+ get config() {
2506
+ return config;
2507
+ },
2508
+ get board() {
2509
+ return board;
2510
+ },
2511
+ get ways() {
2512
+ return waysCount(config.grid);
2513
+ },
2514
+ setBoard(next) {
2515
+ board = next;
2516
+ // grow the board to the grid shape so feature code can index safely
2517
+ grid.setGrid(next);
2518
+ },
2519
+ setConfig(next) {
2520
+ const norm = normalizeGrid(next);
2521
+ const rebuild = geometryChanged(norm);
2522
+ config = norm;
2523
+ if (rebuild)
2524
+ buildGrid();
2525
+ else {
2526
+ spin.setConfig(config.motion);
2527
+ spin.setWin(config.win);
2528
+ anticipation.setConfig(config.anticipation);
2529
+ tumble.setConfig(config.cascade);
2530
+ tumble.setWin(config.win);
2531
+ }
2532
+ },
2533
+ update(partial) {
2534
+ api.setConfig(mergeReelConfig(config, partial));
2535
+ },
2536
+ async spin(target, runOpts) {
2537
+ const data = { targetGrid: target };
2538
+ const decision = anticipation.decide(target);
2539
+ let resetZoom = null;
2540
+ if (decision.active) {
2541
+ log?.(`Anticipation on reels [${decision.reels.join(', ')}]`);
2542
+ resetZoom = await anticipation.zoomIn(grid);
2543
+ }
2544
+ await spin.run(data, {
2545
+ ...runOpts,
2546
+ anticipateReels: decision.active ? decision.reels : undefined,
2547
+ anticipateSlowdown: decision.slowdown,
2548
+ anticipateHoldMs: decision.holdMs,
2549
+ });
2550
+ if (resetZoom)
2551
+ await resetZoom();
2552
+ board = target;
2553
+ },
2554
+ get multiplier() {
2555
+ return tumble.multiplier;
2556
+ },
2557
+ async cascade(steps, cOpts) {
2558
+ // keep the multiplier climbing across free-spins when configured; otherwise reset per spin
2559
+ const persist = config.cascade.multiplier.persistInFreeSpins && !!cOpts?.freeSpins;
2560
+ if (!persist)
2561
+ tumble.resetMultiplier();
2562
+ for (let i = 0; i < steps.length; i++) {
2563
+ await tumble.step(steps[i], i, cOpts);
2564
+ board = steps[i].settledGrid;
2565
+ }
2566
+ if (config.cascade.multiplier.enabled)
2567
+ log?.(`Cascade multiplier ×${tumble.multiplier}`);
2568
+ },
2569
+ registerFeature(feature) {
2570
+ custom.set(feature.key, feature);
2571
+ },
2572
+ features() {
2573
+ return allFeatures();
2574
+ },
2575
+ enabledFeatures() {
2576
+ return allFeatures().filter((f) => f.enabled(config));
2577
+ },
2578
+ featureContext(fOpts) {
2579
+ return ctx(fOpts?.freeSpins);
2580
+ },
2581
+ async runFeature(key, fOpts) {
2582
+ const feature = findFeature(key);
2583
+ if (!feature) {
2584
+ log?.(`Unknown feature "${key}"`);
2585
+ return;
2586
+ }
2587
+ if (!feature.enabled(config)) {
2588
+ log?.(`${feature.label} is disabled`);
2589
+ return;
2590
+ }
2591
+ await feature.demo(ctx(fOpts?.freeSpins));
2592
+ },
2593
+ resize(cellSize) {
2594
+ config = mergeReelConfig(config, { grid: { cellSize } });
2595
+ grid.resize(cellSize);
2596
+ },
2597
+ skip() {
2598
+ spin.skip();
2599
+ tumble.skip();
2600
+ // kill in-flight overlay tweens (labels/rings) so a rebuild never animates destroyed nodes
2601
+ for (const child of fx.children.slice())
2602
+ Tween.killTweensOf(child);
2603
+ fx.removeChildren().forEach((c) => c.destroy());
2604
+ },
2605
+ destroy() {
2606
+ api.skip();
2607
+ grid.destroy({ children: true });
2608
+ view.destroy({ children: true });
2609
+ },
2610
+ };
2611
+ return api;
2612
+ }
2613
+ function emptyBoard(cfg) {
2614
+ const rows = effectiveRowsPerReel(cfg.grid);
2615
+ return Array.from({ length: cfg.grid.cols }, (_, c) => Array.from({ length: rows[c] }, () => ({ symbol: null })));
2616
+ }
2617
+
794
2618
  /** Highest tier whose minMultiplier <= win/bet, or null if below the lowest. */
795
2619
  function pickTier(tiers, win, bet) {
796
2620
  if (bet <= 0)
@@ -928,35 +2752,6 @@ class BigWinOverlay extends Container {
928
2752
  hide() { this.visible = false; Tween.killTweensOf(this); }
929
2753
  }
930
2754
 
931
- /** Headless free-spins state machine. The scene drives it; rendering/HUD reflect it. */
932
- class FreeSpinsSession {
933
- remaining;
934
- total;
935
- totalWin = 0;
936
- cfg;
937
- constructor(cfg) {
938
- this.cfg = cfg;
939
- this.remaining = cfg.initialSpins;
940
- this.total = cfg.initialSpins;
941
- }
942
- award(extra) {
943
- if (extra > 0) {
944
- this.remaining += extra;
945
- this.total += extra;
946
- }
947
- }
948
- /** Convenience: award using the configured retrigger rule. */
949
- applyRetrigger(result) {
950
- this.award(this.cfg.retrigger?.(result) ?? 0);
951
- }
952
- addWin(amount) { this.totalWin += amount; }
953
- consume() { if (this.remaining > 0)
954
- this.remaining -= 1; }
955
- get isComplete() {
956
- return this.remaining <= 0 || (this.cfg.isMaxWin?.() ?? false);
957
- }
958
- }
959
-
960
2755
  // How long each policy survives: cascade (shortest) < spin < session (longest).
961
2756
  const RANK = { cascade: 0, spin: 1, session: 2 };
962
2757
  /**
@@ -981,5 +2776,5 @@ class MultiplierAccumulator {
981
2776
  }
982
2777
  }
983
2778
 
984
- export { AnimatedSymbol, BigWinOverlay, CascadeController, CountUpDisplay, FreeSpinsSession, MultiplierAccumulator, ReelGrid, ReelSpinController, SymbolCell, pickTier, tierIndexAtValue, valueAt };
2779
+ export { AnimatedSymbol, AnticipationController, BigWinOverlay, CascadeController, CountUpDisplay, DEFAULT_REEL_CONFIG, EASING_BY_NAME, FEATURES, FEATURE_KEYS, FEATURE_LIST, INTENSITY_SCALE, MultiplierAccumulator, PRESETS, PRESET_LIST, ReelGrid, ReelSpinController, SpinEngine, SymbolCell, TumbleController, createReelSystem, easingByName, effectiveRowsPerReel, mergeReelConfig, pickTier, resolveReelConfig, tierIndexAtValue, valueAt, waysCount };
985
2780
  //# sourceMappingURL=slot.esm.js.map