@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.
- package/README.md +121 -0
- package/dist/animation.cjs.js +18 -3
- package/dist/animation.cjs.js.map +1 -1
- package/dist/animation.esm.js +18 -3
- package/dist/animation.esm.js.map +1 -1
- package/dist/core.cjs.js +18 -3
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.esm.js +18 -3
- package/dist/core.esm.js.map +1 -1
- package/dist/host.cjs.js +18 -3
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +3 -5
- package/dist/host.esm.js +18 -3
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +18 -2535
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -1020
- package/dist/index.esm.js +20 -2525
- package/dist/index.esm.js.map +1 -1
- package/dist/slot.cjs.js +1872 -61
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +555 -28
- package/dist/slot.esm.js +1857 -62
- package/dist/slot.esm.js.map +1 -1
- package/dist/vite.cjs.js +0 -5
- package/dist/vite.cjs.js.map +1 -1
- package/dist/vite.esm.js +0 -5
- package/dist/vite.esm.js.map +1 -1
- package/package.json +2 -35
- package/src/animation/Tween.ts +14 -3
- package/src/host/index.ts +0 -1
- package/src/host/types.ts +0 -3
- package/src/index.ts +0 -28
- package/src/slot/anim/easing-map.ts +16 -2
- package/src/slot/cascade/TumbleController.ts +230 -0
- package/src/slot/config/ReelSystemConfig.ts +559 -0
- package/src/slot/config/presets.ts +222 -0
- package/src/slot/features/extra.ts +189 -0
- package/src/slot/features/index.ts +44 -0
- package/src/slot/features/symbols.ts +183 -0
- package/src/slot/features/types.ts +136 -0
- package/src/slot/features/wilds.ts +151 -0
- package/src/slot/grid/ReelGrid.ts +93 -24
- package/src/slot/grid/SymbolCell.ts +45 -11
- package/src/slot/index.ts +61 -2
- package/src/slot/motion/AnticipationController.ts +96 -0
- package/src/slot/motion/SpinEngine.ts +384 -0
- package/src/slot/system/ReelSystem.ts +295 -0
- package/src/vite/index.ts +0 -5
- package/dist/react-jsx.cjs.js +0 -3
- package/dist/react-jsx.cjs.js.map +0 -1
- package/dist/react-jsx.d.ts +0 -602
- package/dist/react-jsx.esm.js +0 -2
- package/dist/react-jsx.esm.js.map +0 -1
- package/dist/react.cjs.js +0 -3787
- package/dist/react.cjs.js.map +0 -1
- package/dist/react.d.ts +0 -1467
- package/dist/react.esm.js +0 -3771
- package/dist/react.esm.js.map +0 -1
- package/dist/ui.cjs.js +0 -2999
- package/dist/ui.cjs.js.map +0 -1
- package/dist/ui.d.ts +0 -1116
- package/dist/ui.esm.js +0 -2983
- package/dist/ui.esm.js.map +0 -1
- package/src/react/EngineContext.ts +0 -26
- package/src/react/ReactScene.ts +0 -88
- package/src/react/applyProps.ts +0 -271
- package/src/react/catalogue.ts +0 -17
- package/src/react/createPixiRoot.ts +0 -31
- package/src/react/extendAll.ts +0 -74
- package/src/react/hooks.ts +0 -46
- package/src/react/index.ts +0 -29
- package/src/react/jsx-runtime.ts +0 -346
- package/src/react/reconciler.ts +0 -338
- package/src/slot/freeSpins/FreeSpinsSession.ts +0 -40
- package/src/state/StateMachine.ts +0 -231
- package/src/state/index.ts +0 -1
- package/src/ui/BalanceDisplay.ts +0 -159
- package/src/ui/Button.ts +0 -304
- package/src/ui/FlexContainer.ts +0 -775
- package/src/ui/Label.ts +0 -124
- package/src/ui/LabelValue.ts +0 -122
- package/src/ui/Layout.ts +0 -291
- package/src/ui/Modal.ts +0 -170
- package/src/ui/Panel.ts +0 -204
- package/src/ui/ProgressBar.ts +0 -170
- package/src/ui/ScrollContainer.ts +0 -478
- package/src/ui/Slider.ts +0 -241
- package/src/ui/Toast.ts +0 -150
- package/src/ui/Toggle.ts +0 -201
- package/src/ui/WinDisplay.ts +0 -145
- package/src/ui/index.ts +0 -33
- 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() {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
603
|
-
const cell = new SymbolCell({
|
|
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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
654
|
-
|
|
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.
|
|
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
|