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