@energy8platform/game-engine 0.38.0 → 0.40.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 +82 -2
- package/dist/devtools.cjs.js +36 -4
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.d.ts +67 -0
- package/dist/devtools.esm.js +36 -4
- package/dist/devtools.esm.js.map +1 -1
- package/dist/host.cjs.js +276 -109
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +5 -0
- package/dist/host.esm.js +276 -109
- package/dist/host.esm.js.map +1 -1
- package/dist/reel-panel-client.cjs.js +36 -4
- package/dist/reel-panel-client.cjs.js.map +1 -1
- package/dist/reel-panel-client.esm.js +36 -4
- package/dist/reel-panel-client.esm.js.map +1 -1
- package/dist/slot.cjs.js +229 -43
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +123 -8
- package/dist/slot.esm.js +229 -44
- package/dist/slot.esm.js.map +1 -1
- package/package.json +2 -2
- package/src/host/autoplay.ts +25 -3
- package/src/host/connectionRecovery.ts +113 -0
- package/src/host/createSlotGame.ts +83 -103
- package/src/host/playError.ts +23 -0
- package/src/host/resumeDrain.ts +156 -0
- package/src/host/shellConfig.ts +9 -0
- package/src/slot/config/ReelSystemConfig.ts +96 -4
- package/src/slot/devtools/fieldSchema.ts +7 -0
- package/src/slot/index.ts +5 -0
- package/src/slot/motion/AnticipationController.ts +62 -17
- package/src/slot/motion/SpinEngine.ts +151 -23
- package/src/slot/system/ReelSystem.ts +59 -9
package/dist/slot.cjs.js
CHANGED
|
@@ -1075,6 +1075,14 @@ class ReelSpinController {
|
|
|
1075
1075
|
// override only what they need.
|
|
1076
1076
|
//
|
|
1077
1077
|
// Design notes are in docs/reels-analysis-and-design.md.
|
|
1078
|
+
/** Resolve a `PerReel<T>` for one reel. `undefined` (or a hole in the array) yields `fallback`. */
|
|
1079
|
+
function perReelValue(value, reel, fallback) {
|
|
1080
|
+
if (value === undefined)
|
|
1081
|
+
return fallback;
|
|
1082
|
+
if (Array.isArray(value))
|
|
1083
|
+
return value[reel] ?? fallback;
|
|
1084
|
+
return value;
|
|
1085
|
+
}
|
|
1078
1086
|
const FEATURE_KEYS = [
|
|
1079
1087
|
'reelModifier', // pre-spin
|
|
1080
1088
|
'giant',
|
|
@@ -1120,6 +1128,11 @@ const DEFAULT_REEL_CONFIG = {
|
|
|
1120
1128
|
intensity: 'full',
|
|
1121
1129
|
slamStop: true,
|
|
1122
1130
|
symbolsPerReel: 6,
|
|
1131
|
+
cellStagger: 24,
|
|
1132
|
+
reelStaggerFactor: 0.4,
|
|
1133
|
+
dropFallFactor: 0.6,
|
|
1134
|
+
dropOrder: 'top-down',
|
|
1135
|
+
dropSequence: 'parallel',
|
|
1123
1136
|
},
|
|
1124
1137
|
anticipation: {
|
|
1125
1138
|
enabled: false,
|
|
@@ -1128,6 +1141,9 @@ const DEFAULT_REEL_CONFIG = {
|
|
|
1128
1141
|
reels: 'trailing',
|
|
1129
1142
|
slowdownFactor: 0.3,
|
|
1130
1143
|
holdMs: 400,
|
|
1144
|
+
decide: null,
|
|
1145
|
+
progressiveSlowdown: 1,
|
|
1146
|
+
progressiveHoldMs: 0,
|
|
1131
1147
|
zoom: { enabled: false, scale: 1.15, ms: 600 },
|
|
1132
1148
|
},
|
|
1133
1149
|
cascade: {
|
|
@@ -1254,11 +1270,28 @@ function mergeReelConfig(base, partial) {
|
|
|
1254
1270
|
function resolveReelConfig(partial) {
|
|
1255
1271
|
return mergeReelConfig(DEFAULT_REEL_CONFIG, partial);
|
|
1256
1272
|
}
|
|
1273
|
+
/** True only for `{}`-shaped objects — a class instance or a Date is NOT one. */
|
|
1274
|
+
function isCloneableRecord(v) {
|
|
1275
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v))
|
|
1276
|
+
return false;
|
|
1277
|
+
const proto = Object.getPrototypeOf(v);
|
|
1278
|
+
return proto === Object.prototype || proto === null;
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Deep-clone a config. Hand-rolled rather than `structuredClone` because a config may carry
|
|
1282
|
+
* functions (`anticipation.decide`), which `structuredClone` refuses to copy. Functions and
|
|
1283
|
+
* anything that is not a plain object/array pass through by reference.
|
|
1284
|
+
*/
|
|
1257
1285
|
function structuredCloneSafe(v) {
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1286
|
+
if (Array.isArray(v))
|
|
1287
|
+
return v.map((item) => structuredCloneSafe(item));
|
|
1288
|
+
if (isCloneableRecord(v)) {
|
|
1289
|
+
const out = {};
|
|
1290
|
+
for (const [k, val] of Object.entries(v))
|
|
1291
|
+
out[k] = structuredCloneSafe(val);
|
|
1292
|
+
return out;
|
|
1293
|
+
}
|
|
1294
|
+
return v;
|
|
1262
1295
|
}
|
|
1263
1296
|
/** Effective per-reel row counts (resolves Megaways `rowsPerReel`, else uniform `rows`). */
|
|
1264
1297
|
function effectiveRowsPerReel(grid) {
|
|
@@ -1495,6 +1528,7 @@ class SpinEngine {
|
|
|
1495
1528
|
_killed = false;
|
|
1496
1529
|
_shaking = false;
|
|
1497
1530
|
_temp = [];
|
|
1531
|
+
_deferred = new Set();
|
|
1498
1532
|
constructor(grid, resolve, cfg, win) {
|
|
1499
1533
|
this._grid = grid;
|
|
1500
1534
|
this._resolve = resolve;
|
|
@@ -1538,6 +1572,8 @@ class SpinEngine {
|
|
|
1538
1572
|
const cols = this._grid.cols;
|
|
1539
1573
|
const order = (reel) => (this._cfg.stopOrder === 'rtl' ? cols - 1 - reel : reel);
|
|
1540
1574
|
const anticipate = new Set(opts?.anticipateReels ?? []);
|
|
1575
|
+
const defer = new Set(opts?.deferReveal ?? []);
|
|
1576
|
+
const holds = [];
|
|
1541
1577
|
const out = [];
|
|
1542
1578
|
for (let reel = 0; reel < cols; reel++) {
|
|
1543
1579
|
const idx = order(reel);
|
|
@@ -1553,23 +1589,70 @@ class SpinEngine {
|
|
|
1553
1589
|
else
|
|
1554
1590
|
stopTime = (this._cfg.spinUp + this._cfg.hold + idx * this._cfg.stopStagger) * f;
|
|
1555
1591
|
const isAnticipated = anticipate.has(reel);
|
|
1556
|
-
|
|
1557
|
-
|
|
1592
|
+
const hold = isAnticipated ? perReelValue(opts?.anticipateHoldMs, reel, 0) * f : 0;
|
|
1593
|
+
stopTime += hold;
|
|
1594
|
+
holds[reel] = hold;
|
|
1595
|
+
const speed = isAnticipated ? perReelValue(opts?.anticipateSlowdown, reel, 1) : 1;
|
|
1558
1596
|
out.push({
|
|
1559
1597
|
reel,
|
|
1560
1598
|
stopTime,
|
|
1561
1599
|
landing: data.targetGrid[reel] ?? [],
|
|
1562
1600
|
settle: { amp: this._cfg.settle.amp, ms: this._cfg.settle.ms * f },
|
|
1563
1601
|
anticipated: isAnticipated,
|
|
1602
|
+
slowdown: Math.max(1, 1 / (speed || 1)),
|
|
1603
|
+
deferred: defer.has(reel),
|
|
1564
1604
|
});
|
|
1565
1605
|
}
|
|
1606
|
+
if (this._cfg.style === 'cascade-drop')
|
|
1607
|
+
this._planDrop(out, holds, f, order);
|
|
1566
1608
|
return out;
|
|
1567
1609
|
}
|
|
1610
|
+
/**
|
|
1611
|
+
* `cascade-drop` lays its reels out on a different clock from the tape styles: a reel is a
|
|
1612
|
+
* sequence of per-cell arrivals, not one deceleration. Overwrite `stopTime` with the moment the
|
|
1613
|
+
* reel has fully landed and fill in `cellStopTimes`, so `plan()` stays the single source of truth
|
|
1614
|
+
* for WHEN anything happens — `_runDrop` below only executes these numbers.
|
|
1615
|
+
*/
|
|
1616
|
+
_planDrop(out, holds, f, order) {
|
|
1617
|
+
const bottomUp = this._cfg.dropOrder === 'bottom-up';
|
|
1618
|
+
// walk the reels in STOP order, so `stopOrder: 'rtl'` reverses the drop as well
|
|
1619
|
+
const byPosition = [];
|
|
1620
|
+
for (let reel = 0; reel < out.length; reel++)
|
|
1621
|
+
byPosition[order(reel)] = reel;
|
|
1622
|
+
// the position from which reels stop overlapping and start queueing behind each other
|
|
1623
|
+
const chainFrom = this._cfg.dropSequence === 'chained'
|
|
1624
|
+
? 0
|
|
1625
|
+
: this._cfg.dropSequence === 'chained-when-anticipated'
|
|
1626
|
+
? byPosition.findIndex((reel) => out[reel].anticipated)
|
|
1627
|
+
: -1;
|
|
1628
|
+
let previousEnd = 0;
|
|
1629
|
+
for (let position = 0; position < byPosition.length; position++) {
|
|
1630
|
+
const p = out[byPosition[position]];
|
|
1631
|
+
const rows = this._grid.rowsOf(p.reel);
|
|
1632
|
+
const scale = f * p.slowdown;
|
|
1633
|
+
const gap = this._cfg.stopStagger * this._cfg.reelStaggerFactor * scale;
|
|
1634
|
+
const fall = this._cfg.spinUp * this._cfg.dropFallFactor * scale;
|
|
1635
|
+
// by formula while reels may overlap; queued behind the previous reel once the chain starts
|
|
1636
|
+
const chained = chainFrom >= 0 && position > 0 && position >= chainFrom;
|
|
1637
|
+
const start = (chained ? previousEnd + gap : position * gap) + (holds[p.reel] ?? 0);
|
|
1638
|
+
const cells = [];
|
|
1639
|
+
for (let i = 0; i < rows; i++) {
|
|
1640
|
+
const row = bottomUp ? rows - 1 - i : i;
|
|
1641
|
+
cells[row] = start + i * this._cfg.cellStagger * scale + fall;
|
|
1642
|
+
}
|
|
1643
|
+
p.cellStopTimes = cells;
|
|
1644
|
+
p.stopTime = cells.length ? Math.max(...cells) : start;
|
|
1645
|
+
previousEnd = p.stopTime;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1568
1648
|
/** Execute the spin for every reel concurrently. */
|
|
1569
1649
|
async run(data, opts) {
|
|
1570
1650
|
this._killed = false;
|
|
1571
1651
|
this._temp = [];
|
|
1572
1652
|
const plan = this.plan(data, opts);
|
|
1653
|
+
// remembered for skip(): a deferred reel's cells belong to the caller, not to us
|
|
1654
|
+
this._deferred = new Set(plan.filter((p) => p.deferred).map((p) => p.reel));
|
|
1655
|
+
opts?.onPlan?.(plan);
|
|
1573
1656
|
const f = this.scale(opts);
|
|
1574
1657
|
await Promise.all(plan.map((p) => this._runReel(p, data, opts, f)));
|
|
1575
1658
|
this._cleanupTemp();
|
|
@@ -1587,9 +1670,9 @@ class SpinEngine {
|
|
|
1587
1670
|
return this._runSwap(p, data, opts, f);
|
|
1588
1671
|
}
|
|
1589
1672
|
}
|
|
1590
|
-
/** Anticipation time-stretch factor for a reel (>=1, longer = slower). */
|
|
1591
|
-
slowOf(p
|
|
1592
|
-
return p.
|
|
1673
|
+
/** Anticipation time-stretch factor for a reel (>=1, longer = slower). Resolved in `plan()`. */
|
|
1674
|
+
slowOf(p) {
|
|
1675
|
+
return p.slowdown;
|
|
1593
1676
|
}
|
|
1594
1677
|
// ── swap: cycle symbols quickly in the real cells, then land ──────────────
|
|
1595
1678
|
async _runSwap(p, data, opts, f) {
|
|
@@ -1600,7 +1683,7 @@ class SpinEngine {
|
|
|
1600
1683
|
const blur = this._applyBlur(cells, true);
|
|
1601
1684
|
const tickMs = 1000 / 30;
|
|
1602
1685
|
// anticipation makes the reel spin longer before it lands
|
|
1603
|
-
const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p
|
|
1686
|
+
const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p)) / tickMs));
|
|
1604
1687
|
for (let i = 0; i < ticks; i++) {
|
|
1605
1688
|
if (this._killed)
|
|
1606
1689
|
break;
|
|
@@ -1609,8 +1692,24 @@ class SpinEngine {
|
|
|
1609
1692
|
await Tween.delay(tickMs);
|
|
1610
1693
|
}
|
|
1611
1694
|
blur?.();
|
|
1612
|
-
|
|
1613
|
-
|
|
1695
|
+
if (p.deferred) {
|
|
1696
|
+
// the caller owns this reel's result — go dark and unseated instead of handing it back.
|
|
1697
|
+
// NB 'swap' cycles the tape THROUGH the real cells, so a deferred reel only truly withholds
|
|
1698
|
+
// its result when `SpinData.strip` supplies filler for it; otherwise the tape is built from
|
|
1699
|
+
// the landing symbols. 'strip' and 'cascade-drop' have no such caveat.
|
|
1700
|
+
cells.forEach((c) => {
|
|
1701
|
+
c.setData({ symbol: null });
|
|
1702
|
+
c.visible = false;
|
|
1703
|
+
});
|
|
1704
|
+
opts?.onReelStop?.(p.reel, p);
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
for (let r = 0; r < cells.length; r++) {
|
|
1708
|
+
const cell = p.landing[r] ?? { symbol: null };
|
|
1709
|
+
cells[r].setData(cell);
|
|
1710
|
+
opts?.onCellSeated?.(p.reel, r, cell);
|
|
1711
|
+
}
|
|
1712
|
+
opts?.onReelStop?.(p.reel, p);
|
|
1614
1713
|
await this._settle(p.reel, p.settle, f);
|
|
1615
1714
|
await this._frameShake(p.landing);
|
|
1616
1715
|
}
|
|
@@ -1645,7 +1744,7 @@ class SpinEngine {
|
|
|
1645
1744
|
this._grid.addChild(tape);
|
|
1646
1745
|
this._temp.push(tape);
|
|
1647
1746
|
const clearBlur = this._applyBlur([tape], true);
|
|
1648
|
-
const slow = this.slowOf(p
|
|
1747
|
+
const slow = this.slowOf(p);
|
|
1649
1748
|
// overshoot/settle honour the configured settle (amp in px, easing)
|
|
1650
1749
|
const overshoot = p.settle.amp || step * 0.18;
|
|
1651
1750
|
await Tween.to(tape, { y: restY + overshoot }, p.stopTime * slow, easingByName('easeInOutQuad'));
|
|
@@ -1655,12 +1754,21 @@ class SpinEngine {
|
|
|
1655
1754
|
}
|
|
1656
1755
|
clearBlur?.();
|
|
1657
1756
|
await Tween.to(tape, { y: restY }, Math.max(120, p.settle.ms), easingByName(this._cfg.settle.easing));
|
|
1658
|
-
// hand the result back to the real cells
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1757
|
+
// hand the result back to the real cells — unless the caller deferred the reveal, in which
|
|
1758
|
+
// case the tape simply goes away and the reel is left dark, unseated and owned by the caller
|
|
1759
|
+
if (!p.deferred) {
|
|
1760
|
+
for (let r = 0; r < rows; r++) {
|
|
1761
|
+
const cell = p.landing[r] ?? { symbol: null };
|
|
1762
|
+
realCells[r].setData(cell);
|
|
1763
|
+
opts?.onCellSeated?.(p.reel, r, cell);
|
|
1764
|
+
}
|
|
1765
|
+
realCells.forEach((c) => (c.visible = true));
|
|
1766
|
+
}
|
|
1662
1767
|
tape.destroy();
|
|
1663
1768
|
this._temp = this._temp.filter((t) => t !== tape);
|
|
1769
|
+
opts?.onReelStop?.(p.reel, p);
|
|
1770
|
+
if (p.deferred)
|
|
1771
|
+
return;
|
|
1664
1772
|
// squash the real cells on impact when enabled
|
|
1665
1773
|
if (this._cfg.squash.enabled)
|
|
1666
1774
|
await Promise.all(realCells.map((c) => this._squashCell(c, f)));
|
|
@@ -1669,22 +1777,36 @@ class SpinEngine {
|
|
|
1669
1777
|
// ── cascade-drop: symbols drop in from above with stagger + bounce + squash ─
|
|
1670
1778
|
async _runDrop(p, opts, f) {
|
|
1671
1779
|
const rows = this._grid.rowsOf(p.reel);
|
|
1780
|
+
if (p.deferred) {
|
|
1781
|
+
// nothing to drop — the caller brings this reel in itself
|
|
1782
|
+
for (let r = 0; r < rows; r++)
|
|
1783
|
+
this._grid.getCell(p.reel, r).visible = false;
|
|
1784
|
+
opts?.onReelStop?.(p.reel, p);
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1672
1787
|
const step = this._grid.cellPosition(p.reel, 1).y - this._grid.cellPosition(p.reel, 0).y;
|
|
1673
|
-
const slow = this.slowOf(p
|
|
1788
|
+
const slow = this.slowOf(p); // anticipation drops the reel in more slowly
|
|
1789
|
+
const fall = this._cfg.spinUp * this._cfg.dropFallFactor * f * slow;
|
|
1790
|
+
const schedule = p.cellStopTimes ?? [];
|
|
1674
1791
|
await Promise.all(Array.from({ length: rows }, (_, r) => r).map(async (r) => {
|
|
1675
1792
|
if (this._killed)
|
|
1676
1793
|
return;
|
|
1677
1794
|
const cell = this._grid.getCell(p.reel, r);
|
|
1678
1795
|
const to = this._grid.cellPosition(p.reel, r);
|
|
1679
|
-
|
|
1796
|
+
const data = p.landing[r] ?? { symbol: null };
|
|
1797
|
+
cell.setData(data);
|
|
1680
1798
|
cell.position.set(to.x, to.y - step * (rows + 1));
|
|
1681
1799
|
cell.alpha = 1;
|
|
1682
|
-
|
|
1800
|
+
// the plan says WHEN this cell seats; back off the fall to get when it must let go
|
|
1801
|
+
const delay = Math.max(0, (schedule[r] ?? fall) - fall);
|
|
1683
1802
|
if (delay)
|
|
1684
1803
|
await Tween.delay(delay);
|
|
1685
|
-
await Tween.to(cell, { 'position.y': to.y },
|
|
1804
|
+
await Tween.to(cell, { 'position.y': to.y }, fall, easingByName(this._cfg.settle.easing));
|
|
1805
|
+
// the impact frame — fired before the squash so a game can sync its own hit feedback
|
|
1806
|
+
opts?.onCellSeated?.(p.reel, r, data);
|
|
1686
1807
|
await this._squashCell(cell, f);
|
|
1687
1808
|
}));
|
|
1809
|
+
opts?.onReelStop?.(p.reel, p);
|
|
1688
1810
|
await this._frameShake(p.landing);
|
|
1689
1811
|
}
|
|
1690
1812
|
// ── shared helpers ────────────────────────────────────────────────────────
|
|
@@ -1748,13 +1870,17 @@ class SpinEngine {
|
|
|
1748
1870
|
Tween.killTweensOf(this._grid);
|
|
1749
1871
|
this._grid.x = 0; // undo any in-flight frame shake
|
|
1750
1872
|
for (let c = 0; c < this._grid.cols; c++) {
|
|
1873
|
+
// a deferred reel's visibility belongs to the caller — a slam stop must not reveal it
|
|
1874
|
+
const deferred = this._deferred.has(c);
|
|
1751
1875
|
for (let r = 0; r < this._grid.rowsOf(c); r++) {
|
|
1752
1876
|
const cell = this._grid.getCell(c, r);
|
|
1753
1877
|
if (cell.destroyed)
|
|
1754
1878
|
continue;
|
|
1755
1879
|
Tween.killTweensOf(cell);
|
|
1756
|
-
|
|
1757
|
-
|
|
1880
|
+
if (!deferred) {
|
|
1881
|
+
cell.visible = true;
|
|
1882
|
+
cell.alpha = 1;
|
|
1883
|
+
}
|
|
1758
1884
|
cell.filters = [];
|
|
1759
1885
|
cell.scale.set(1);
|
|
1760
1886
|
}
|
|
@@ -1767,6 +1893,8 @@ class SpinEngine {
|
|
|
1767
1893
|
//
|
|
1768
1894
|
// Decides which trailing reels get the "anticipation" slow-down treatment, based purely on
|
|
1769
1895
|
// the already-resolved landing grid (presentation only — never a secondary outcome decision).
|
|
1896
|
+
/** Fresh "nothing to anticipate" decision (fresh, so callers may mutate `reels` freely). */
|
|
1897
|
+
const none = () => ({ active: false, reels: [], slowdown: 1, holdMs: 0 });
|
|
1770
1898
|
class AnticipationController {
|
|
1771
1899
|
_cfg;
|
|
1772
1900
|
constructor(cfg) {
|
|
@@ -1790,19 +1918,23 @@ class AnticipationController {
|
|
|
1790
1918
|
*/
|
|
1791
1919
|
decide(targetGrid) {
|
|
1792
1920
|
if (!this._cfg.enabled)
|
|
1793
|
-
return
|
|
1921
|
+
return none();
|
|
1922
|
+
// A game-supplied predicate replaces the symbol counting entirely.
|
|
1923
|
+
if (this._cfg.decide) {
|
|
1924
|
+
const custom = this._cfg.decide(targetGrid);
|
|
1925
|
+
if (!custom)
|
|
1926
|
+
return none();
|
|
1927
|
+
const o = Array.isArray(custom) ? { reels: custom } : custom;
|
|
1928
|
+
if (!o.reels?.length)
|
|
1929
|
+
return none();
|
|
1930
|
+
return this.build(o.reels.slice(), o.slowdown, o.holdMs);
|
|
1931
|
+
}
|
|
1794
1932
|
if (Array.isArray(this._cfg.reels)) {
|
|
1795
1933
|
// explicit reel list — arm only if the threshold is met somewhere on the board
|
|
1796
1934
|
const total = targetGrid.reduce((sum, reel) => sum + this.countOnReel(reel), 0);
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
active,
|
|
1801
|
-
reels: this._cfg.reels.slice(),
|
|
1802
|
-
slowdown: this._cfg.slowdownFactor,
|
|
1803
|
-
holdMs: this._cfg.holdMs,
|
|
1804
|
-
}
|
|
1805
|
-
: { active: false, reels: [], slowdown: 1, holdMs: 0 };
|
|
1935
|
+
if (total < this._cfg.threshold)
|
|
1936
|
+
return none();
|
|
1937
|
+
return this.build(this._cfg.reels.slice());
|
|
1806
1938
|
}
|
|
1807
1939
|
// 'trailing': find the reel where the cumulative count hits the threshold
|
|
1808
1940
|
let running = 0;
|
|
@@ -1815,13 +1947,37 @@ class AnticipationController {
|
|
|
1815
1947
|
}
|
|
1816
1948
|
}
|
|
1817
1949
|
if (armReel < 0)
|
|
1818
|
-
return
|
|
1950
|
+
return none();
|
|
1819
1951
|
const reels = [];
|
|
1820
1952
|
for (let c = armReel + 1; c < targetGrid.length; c++)
|
|
1821
1953
|
reels.push(c);
|
|
1822
1954
|
if (reels.length === 0)
|
|
1823
|
-
return
|
|
1824
|
-
return
|
|
1955
|
+
return none();
|
|
1956
|
+
return this.build(reels);
|
|
1957
|
+
}
|
|
1958
|
+
/** Assemble a decision, applying the configured progression unless the caller pinned values. */
|
|
1959
|
+
build(reels, slowdown, holdMs) {
|
|
1960
|
+
return {
|
|
1961
|
+
active: true,
|
|
1962
|
+
reels,
|
|
1963
|
+
slowdown: slowdown ??
|
|
1964
|
+
this.ramp(reels, this._cfg.slowdownFactor, (base, i) => i === 0 ? base : base * Math.pow(this._cfg.progressiveSlowdown, i)),
|
|
1965
|
+
holdMs: holdMs ??
|
|
1966
|
+
this.ramp(reels, this._cfg.holdMs, (base, i) => base + this._cfg.progressiveHoldMs * i),
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* A flat scalar when the progression is a no-op, else an array INDEXED BY REEL so the engine can
|
|
1971
|
+
* read a per-reel value straight out of `plan()`.
|
|
1972
|
+
*/
|
|
1973
|
+
ramp(reels, base, at) {
|
|
1974
|
+
if (at(base, 1) === base)
|
|
1975
|
+
return base;
|
|
1976
|
+
const out = [];
|
|
1977
|
+
reels.forEach((reel, i) => {
|
|
1978
|
+
out[reel] = at(base, i);
|
|
1979
|
+
});
|
|
1980
|
+
return out;
|
|
1825
1981
|
}
|
|
1826
1982
|
/** Optionally zoom the grid in while anticipating, then settle back. Returns a reset fn. */
|
|
1827
1983
|
async zoomIn(grid) {
|
|
@@ -2873,6 +3029,33 @@ function createReelSystem(opts) {
|
|
|
2873
3029
|
function ctx(freeSpins) {
|
|
2874
3030
|
return { grid, resolve, cfg: config, fx, board, freeSpins, log };
|
|
2875
3031
|
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Which reels get the anticipation treatment for this spin. An explicit `anticipateReels` on the
|
|
3034
|
+
* run options WINS over the configured decision — passing it is how a game drives anticipation
|
|
3035
|
+
* from its own logic. Omit it and the configured `AnticipationController` decides.
|
|
3036
|
+
*/
|
|
3037
|
+
function resolveAnticipation(target, runOpts) {
|
|
3038
|
+
const explicit = runOpts?.anticipateReels;
|
|
3039
|
+
if (!explicit)
|
|
3040
|
+
return anticipation.decide(target);
|
|
3041
|
+
if (!explicit.length)
|
|
3042
|
+
return { active: false, reels: [], slowdown: 1, holdMs: 0 };
|
|
3043
|
+
return {
|
|
3044
|
+
active: true,
|
|
3045
|
+
reels: explicit.slice(),
|
|
3046
|
+
slowdown: runOpts?.anticipateSlowdown ?? config.anticipation.slowdownFactor,
|
|
3047
|
+
holdMs: runOpts?.anticipateHoldMs ?? config.anticipation.holdMs,
|
|
3048
|
+
};
|
|
3049
|
+
}
|
|
3050
|
+
/** Fold a resolved decision back onto the caller's run options (everything else passes through). */
|
|
3051
|
+
function mergeAnticipation(runOpts, decision) {
|
|
3052
|
+
return {
|
|
3053
|
+
...runOpts,
|
|
3054
|
+
anticipateReels: decision.active ? decision.reels : undefined,
|
|
3055
|
+
anticipateSlowdown: decision.slowdown,
|
|
3056
|
+
anticipateHoldMs: decision.holdMs,
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
2876
3059
|
buildGrid();
|
|
2877
3060
|
const api = {
|
|
2878
3061
|
view,
|
|
@@ -2915,20 +3098,22 @@ function createReelSystem(opts) {
|
|
|
2915
3098
|
update(partial) {
|
|
2916
3099
|
api.setConfig(mergeReelConfig(config, partial));
|
|
2917
3100
|
},
|
|
3101
|
+
anticipationFor(target, runOpts) {
|
|
3102
|
+
return resolveAnticipation(target, runOpts);
|
|
3103
|
+
},
|
|
3104
|
+
planSpin(target, runOpts) {
|
|
3105
|
+
const decision = resolveAnticipation(target, runOpts);
|
|
3106
|
+
return spin.plan({ targetGrid: target }, mergeAnticipation(runOpts, decision));
|
|
3107
|
+
},
|
|
2918
3108
|
async spin(target, runOpts) {
|
|
2919
3109
|
const data = { targetGrid: target };
|
|
2920
|
-
const decision =
|
|
3110
|
+
const decision = resolveAnticipation(target, runOpts);
|
|
2921
3111
|
let resetZoom = null;
|
|
2922
3112
|
if (decision.active) {
|
|
2923
3113
|
log?.(`Anticipation on reels [${decision.reels.join(', ')}]`);
|
|
2924
3114
|
resetZoom = await anticipation.zoomIn(grid);
|
|
2925
3115
|
}
|
|
2926
|
-
await spin.run(data,
|
|
2927
|
-
...runOpts,
|
|
2928
|
-
anticipateReels: decision.active ? decision.reels : undefined,
|
|
2929
|
-
anticipateSlowdown: decision.slowdown,
|
|
2930
|
-
anticipateHoldMs: decision.holdMs,
|
|
2931
|
-
});
|
|
3116
|
+
await spin.run(data, mergeAnticipation(runOpts, decision));
|
|
2932
3117
|
if (resetZoom)
|
|
2933
3118
|
await resetZoom();
|
|
2934
3119
|
board = target;
|
|
@@ -3199,6 +3384,7 @@ exports.createReelSystem = createReelSystem;
|
|
|
3199
3384
|
exports.easingByName = easingByName;
|
|
3200
3385
|
exports.effectiveRowsPerReel = effectiveRowsPerReel;
|
|
3201
3386
|
exports.mergeReelConfig = mergeReelConfig;
|
|
3387
|
+
exports.perReelValue = perReelValue;
|
|
3202
3388
|
exports.pickTier = pickTier;
|
|
3203
3389
|
exports.resolveGeometry = resolveGeometry;
|
|
3204
3390
|
exports.resolveGridGeometry = resolveGridGeometry;
|