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