@energy8platform/game-engine 0.37.0 → 0.39.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/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,9 @@ 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,
1123
1134
  },
1124
1135
  anticipation: {
1125
1136
  enabled: false,
@@ -1128,6 +1139,9 @@ const DEFAULT_REEL_CONFIG = {
1128
1139
  reels: 'trailing',
1129
1140
  slowdownFactor: 0.3,
1130
1141
  holdMs: 400,
1142
+ decide: null,
1143
+ progressiveSlowdown: 1,
1144
+ progressiveHoldMs: 0,
1131
1145
  zoom: { enabled: false, scale: 1.15, ms: 600 },
1132
1146
  },
1133
1147
  cascade: {
@@ -1254,11 +1268,28 @@ function mergeReelConfig(base, partial) {
1254
1268
  function resolveReelConfig(partial) {
1255
1269
  return mergeReelConfig(DEFAULT_REEL_CONFIG, partial);
1256
1270
  }
1271
+ /** True only for `{}`-shaped objects — a class instance or a Date is NOT one. */
1272
+ function isCloneableRecord(v) {
1273
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
1274
+ return false;
1275
+ const proto = Object.getPrototypeOf(v);
1276
+ return proto === Object.prototype || proto === null;
1277
+ }
1278
+ /**
1279
+ * Deep-clone a config. Hand-rolled rather than `structuredClone` because a config may carry
1280
+ * functions (`anticipation.decide`), which `structuredClone` refuses to copy. Functions and
1281
+ * anything that is not a plain object/array pass through by reference.
1282
+ */
1257
1283
  function structuredCloneSafe(v) {
1258
- // structuredClone is available in modern browsers + Node 17+; fall back to JSON for safety.
1259
- if (typeof structuredClone === 'function')
1260
- return structuredClone(v);
1261
- return JSON.parse(JSON.stringify(v));
1284
+ if (Array.isArray(v))
1285
+ return v.map((item) => structuredCloneSafe(item));
1286
+ if (isCloneableRecord(v)) {
1287
+ const out = {};
1288
+ for (const [k, val] of Object.entries(v))
1289
+ out[k] = structuredCloneSafe(val);
1290
+ return out;
1291
+ }
1292
+ return v;
1262
1293
  }
1263
1294
  /** Effective per-reel row counts (resolves Megaways `rowsPerReel`, else uniform `rows`). */
1264
1295
  function effectiveRowsPerReel(grid) {
@@ -1495,6 +1526,7 @@ class SpinEngine {
1495
1526
  _killed = false;
1496
1527
  _shaking = false;
1497
1528
  _temp = [];
1529
+ _deferred = new Set();
1498
1530
  constructor(grid, resolve, cfg, win) {
1499
1531
  this._grid = grid;
1500
1532
  this._resolve = resolve;
@@ -1538,6 +1570,7 @@ class SpinEngine {
1538
1570
  const cols = this._grid.cols;
1539
1571
  const order = (reel) => (this._cfg.stopOrder === 'rtl' ? cols - 1 - reel : reel);
1540
1572
  const anticipate = new Set(opts?.anticipateReels ?? []);
1573
+ const defer = new Set(opts?.deferReveal ?? []);
1541
1574
  const out = [];
1542
1575
  for (let reel = 0; reel < cols; reel++) {
1543
1576
  const idx = order(reel);
@@ -1554,13 +1587,16 @@ class SpinEngine {
1554
1587
  stopTime = (this._cfg.spinUp + this._cfg.hold + idx * this._cfg.stopStagger) * f;
1555
1588
  const isAnticipated = anticipate.has(reel);
1556
1589
  if (isAnticipated)
1557
- stopTime += (opts?.anticipateHoldMs ?? 0) * f;
1590
+ stopTime += perReelValue(opts?.anticipateHoldMs, reel, 0) * f;
1591
+ const speed = isAnticipated ? perReelValue(opts?.anticipateSlowdown, reel, 1) : 1;
1558
1592
  out.push({
1559
1593
  reel,
1560
1594
  stopTime,
1561
1595
  landing: data.targetGrid[reel] ?? [],
1562
1596
  settle: { amp: this._cfg.settle.amp, ms: this._cfg.settle.ms * f },
1563
1597
  anticipated: isAnticipated,
1598
+ slowdown: Math.max(1, 1 / (speed || 1)),
1599
+ deferred: defer.has(reel),
1564
1600
  });
1565
1601
  }
1566
1602
  return out;
@@ -1570,6 +1606,9 @@ class SpinEngine {
1570
1606
  this._killed = false;
1571
1607
  this._temp = [];
1572
1608
  const plan = this.plan(data, opts);
1609
+ // remembered for skip(): a deferred reel's cells belong to the caller, not to us
1610
+ this._deferred = new Set(plan.filter((p) => p.deferred).map((p) => p.reel));
1611
+ opts?.onPlan?.(plan);
1573
1612
  const f = this.scale(opts);
1574
1613
  await Promise.all(plan.map((p) => this._runReel(p, data, opts, f)));
1575
1614
  this._cleanupTemp();
@@ -1587,9 +1626,9 @@ class SpinEngine {
1587
1626
  return this._runSwap(p, data, opts, f);
1588
1627
  }
1589
1628
  }
1590
- /** Anticipation time-stretch factor for a reel (>=1, longer = slower). */
1591
- slowOf(p, opts) {
1592
- return p.anticipated ? Math.max(1, 1 / (opts?.anticipateSlowdown ?? 1)) : 1;
1629
+ /** Anticipation time-stretch factor for a reel (>=1, longer = slower). Resolved in `plan()`. */
1630
+ slowOf(p) {
1631
+ return p.slowdown;
1593
1632
  }
1594
1633
  // ── swap: cycle symbols quickly in the real cells, then land ──────────────
1595
1634
  async _runSwap(p, data, opts, f) {
@@ -1600,7 +1639,7 @@ class SpinEngine {
1600
1639
  const blur = this._applyBlur(cells, true);
1601
1640
  const tickMs = 1000 / 30;
1602
1641
  // anticipation makes the reel spin longer before it lands
1603
- const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p, opts)) / tickMs));
1642
+ const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p)) / tickMs));
1604
1643
  for (let i = 0; i < ticks; i++) {
1605
1644
  if (this._killed)
1606
1645
  break;
@@ -1609,8 +1648,24 @@ class SpinEngine {
1609
1648
  await Tween.delay(tickMs);
1610
1649
  }
1611
1650
  blur?.();
1612
- for (let r = 0; r < cells.length; r++)
1613
- cells[r].setData(p.landing[r] ?? { symbol: null });
1651
+ if (p.deferred) {
1652
+ // the caller owns this reel's result — go dark and unseated instead of handing it back.
1653
+ // NB 'swap' cycles the tape THROUGH the real cells, so a deferred reel only truly withholds
1654
+ // its result when `SpinData.strip` supplies filler for it; otherwise the tape is built from
1655
+ // the landing symbols. 'strip' and 'cascade-drop' have no such caveat.
1656
+ cells.forEach((c) => {
1657
+ c.setData({ symbol: null });
1658
+ c.visible = false;
1659
+ });
1660
+ opts?.onReelStop?.(p.reel, p);
1661
+ return;
1662
+ }
1663
+ for (let r = 0; r < cells.length; r++) {
1664
+ const cell = p.landing[r] ?? { symbol: null };
1665
+ cells[r].setData(cell);
1666
+ opts?.onCellSeated?.(p.reel, r, cell);
1667
+ }
1668
+ opts?.onReelStop?.(p.reel, p);
1614
1669
  await this._settle(p.reel, p.settle, f);
1615
1670
  await this._frameShake(p.landing);
1616
1671
  }
@@ -1645,7 +1700,7 @@ class SpinEngine {
1645
1700
  this._grid.addChild(tape);
1646
1701
  this._temp.push(tape);
1647
1702
  const clearBlur = this._applyBlur([tape], true);
1648
- const slow = this.slowOf(p, opts);
1703
+ const slow = this.slowOf(p);
1649
1704
  // overshoot/settle honour the configured settle (amp in px, easing)
1650
1705
  const overshoot = p.settle.amp || step * 0.18;
1651
1706
  await Tween.to(tape, { y: restY + overshoot }, p.stopTime * slow, easingByName('easeInOutQuad'));
@@ -1655,12 +1710,21 @@ class SpinEngine {
1655
1710
  }
1656
1711
  clearBlur?.();
1657
1712
  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
- for (let r = 0; r < rows; r++)
1660
- realCells[r].setData(p.landing[r] ?? { symbol: null });
1661
- realCells.forEach((c) => (c.visible = true));
1713
+ // hand the result back to the real cells — unless the caller deferred the reveal, in which
1714
+ // case the tape simply goes away and the reel is left dark, unseated and owned by the caller
1715
+ if (!p.deferred) {
1716
+ for (let r = 0; r < rows; r++) {
1717
+ const cell = p.landing[r] ?? { symbol: null };
1718
+ realCells[r].setData(cell);
1719
+ opts?.onCellSeated?.(p.reel, r, cell);
1720
+ }
1721
+ realCells.forEach((c) => (c.visible = true));
1722
+ }
1662
1723
  tape.destroy();
1663
1724
  this._temp = this._temp.filter((t) => t !== tape);
1725
+ opts?.onReelStop?.(p.reel, p);
1726
+ if (p.deferred)
1727
+ return;
1664
1728
  // squash the real cells on impact when enabled
1665
1729
  if (this._cfg.squash.enabled)
1666
1730
  await Promise.all(realCells.map((c) => this._squashCell(c, f)));
@@ -1669,22 +1733,36 @@ class SpinEngine {
1669
1733
  // ── cascade-drop: symbols drop in from above with stagger + bounce + squash ─
1670
1734
  async _runDrop(p, opts, f) {
1671
1735
  const rows = this._grid.rowsOf(p.reel);
1736
+ if (p.deferred) {
1737
+ // nothing to drop — the caller brings this reel in itself
1738
+ for (let r = 0; r < rows; r++)
1739
+ this._grid.getCell(p.reel, r).visible = false;
1740
+ opts?.onReelStop?.(p.reel, p);
1741
+ return;
1742
+ }
1672
1743
  const step = this._grid.cellPosition(p.reel, 1).y - this._grid.cellPosition(p.reel, 0).y;
1673
- const slow = this.slowOf(p, opts); // anticipation drops the reel in more slowly
1744
+ const slow = this.slowOf(p); // anticipation drops the reel in more slowly
1674
1745
  await Promise.all(Array.from({ length: rows }, (_, r) => r).map(async (r) => {
1675
1746
  if (this._killed)
1676
1747
  return;
1677
1748
  const cell = this._grid.getCell(p.reel, r);
1678
1749
  const to = this._grid.cellPosition(p.reel, r);
1679
- cell.setData(p.landing[r] ?? { symbol: null });
1750
+ const data = p.landing[r] ?? { symbol: null };
1751
+ cell.setData(data);
1680
1752
  cell.position.set(to.x, to.y - step * (rows + 1));
1681
1753
  cell.alpha = 1;
1682
- const delay = (p.reel * this._cfg.stopStagger * 0.4 + r * 24) * f * slow;
1754
+ const delay = (p.reel * this._cfg.stopStagger * this._cfg.reelStaggerFactor +
1755
+ r * this._cfg.cellStagger) *
1756
+ f *
1757
+ slow;
1683
1758
  if (delay)
1684
1759
  await Tween.delay(delay);
1685
- await Tween.to(cell, { 'position.y': to.y }, this._cfg.spinUp * 0.6 * f * slow, easingByName(this._cfg.settle.easing));
1760
+ await Tween.to(cell, { 'position.y': to.y }, this._cfg.spinUp * this._cfg.dropFallFactor * f * slow, easingByName(this._cfg.settle.easing));
1761
+ // the impact frame — fired before the squash so a game can sync its own hit feedback
1762
+ opts?.onCellSeated?.(p.reel, r, data);
1686
1763
  await this._squashCell(cell, f);
1687
1764
  }));
1765
+ opts?.onReelStop?.(p.reel, p);
1688
1766
  await this._frameShake(p.landing);
1689
1767
  }
1690
1768
  // ── shared helpers ────────────────────────────────────────────────────────
@@ -1748,13 +1826,17 @@ class SpinEngine {
1748
1826
  Tween.killTweensOf(this._grid);
1749
1827
  this._grid.x = 0; // undo any in-flight frame shake
1750
1828
  for (let c = 0; c < this._grid.cols; c++) {
1829
+ // a deferred reel's visibility belongs to the caller — a slam stop must not reveal it
1830
+ const deferred = this._deferred.has(c);
1751
1831
  for (let r = 0; r < this._grid.rowsOf(c); r++) {
1752
1832
  const cell = this._grid.getCell(c, r);
1753
1833
  if (cell.destroyed)
1754
1834
  continue;
1755
1835
  Tween.killTweensOf(cell);
1756
- cell.visible = true;
1757
- cell.alpha = 1;
1836
+ if (!deferred) {
1837
+ cell.visible = true;
1838
+ cell.alpha = 1;
1839
+ }
1758
1840
  cell.filters = [];
1759
1841
  cell.scale.set(1);
1760
1842
  }
@@ -1767,6 +1849,8 @@ class SpinEngine {
1767
1849
  //
1768
1850
  // Decides which trailing reels get the "anticipation" slow-down treatment, based purely on
1769
1851
  // the already-resolved landing grid (presentation only — never a secondary outcome decision).
1852
+ /** Fresh "nothing to anticipate" decision (fresh, so callers may mutate `reels` freely). */
1853
+ const none = () => ({ active: false, reels: [], slowdown: 1, holdMs: 0 });
1770
1854
  class AnticipationController {
1771
1855
  _cfg;
1772
1856
  constructor(cfg) {
@@ -1790,19 +1874,23 @@ class AnticipationController {
1790
1874
  */
1791
1875
  decide(targetGrid) {
1792
1876
  if (!this._cfg.enabled)
1793
- return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1877
+ return none();
1878
+ // A game-supplied predicate replaces the symbol counting entirely.
1879
+ if (this._cfg.decide) {
1880
+ const custom = this._cfg.decide(targetGrid);
1881
+ if (!custom)
1882
+ return none();
1883
+ const o = Array.isArray(custom) ? { reels: custom } : custom;
1884
+ if (!o.reels?.length)
1885
+ return none();
1886
+ return this.build(o.reels.slice(), o.slowdown, o.holdMs);
1887
+ }
1794
1888
  if (Array.isArray(this._cfg.reels)) {
1795
1889
  // explicit reel list — arm only if the threshold is met somewhere on the board
1796
1890
  const total = targetGrid.reduce((sum, reel) => sum + this.countOnReel(reel), 0);
1797
- const active = total >= this._cfg.threshold;
1798
- return active
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 };
1891
+ if (total < this._cfg.threshold)
1892
+ return none();
1893
+ return this.build(this._cfg.reels.slice());
1806
1894
  }
1807
1895
  // 'trailing': find the reel where the cumulative count hits the threshold
1808
1896
  let running = 0;
@@ -1815,13 +1903,37 @@ class AnticipationController {
1815
1903
  }
1816
1904
  }
1817
1905
  if (armReel < 0)
1818
- return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1906
+ return none();
1819
1907
  const reels = [];
1820
1908
  for (let c = armReel + 1; c < targetGrid.length; c++)
1821
1909
  reels.push(c);
1822
1910
  if (reels.length === 0)
1823
- return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1824
- return { active: true, reels, slowdown: this._cfg.slowdownFactor, holdMs: this._cfg.holdMs };
1911
+ return none();
1912
+ return this.build(reels);
1913
+ }
1914
+ /** Assemble a decision, applying the configured progression unless the caller pinned values. */
1915
+ build(reels, slowdown, holdMs) {
1916
+ return {
1917
+ active: true,
1918
+ reels,
1919
+ slowdown: slowdown ??
1920
+ this.ramp(reels, this._cfg.slowdownFactor, (base, i) => i === 0 ? base : base * Math.pow(this._cfg.progressiveSlowdown, i)),
1921
+ holdMs: holdMs ??
1922
+ this.ramp(reels, this._cfg.holdMs, (base, i) => base + this._cfg.progressiveHoldMs * i),
1923
+ };
1924
+ }
1925
+ /**
1926
+ * A flat scalar when the progression is a no-op, else an array INDEXED BY REEL so the engine can
1927
+ * read a per-reel value straight out of `plan()`.
1928
+ */
1929
+ ramp(reels, base, at) {
1930
+ if (at(base, 1) === base)
1931
+ return base;
1932
+ const out = [];
1933
+ reels.forEach((reel, i) => {
1934
+ out[reel] = at(base, i);
1935
+ });
1936
+ return out;
1825
1937
  }
1826
1938
  /** Optionally zoom the grid in while anticipating, then settle back. Returns a reset fn. */
1827
1939
  async zoomIn(grid) {
@@ -2873,6 +2985,33 @@ function createReelSystem(opts) {
2873
2985
  function ctx(freeSpins) {
2874
2986
  return { grid, resolve, cfg: config, fx, board, freeSpins, log };
2875
2987
  }
2988
+ /**
2989
+ * Which reels get the anticipation treatment for this spin. An explicit `anticipateReels` on the
2990
+ * run options WINS over the configured decision — passing it is how a game drives anticipation
2991
+ * from its own logic. Omit it and the configured `AnticipationController` decides.
2992
+ */
2993
+ function resolveAnticipation(target, runOpts) {
2994
+ const explicit = runOpts?.anticipateReels;
2995
+ if (!explicit)
2996
+ return anticipation.decide(target);
2997
+ if (!explicit.length)
2998
+ return { active: false, reels: [], slowdown: 1, holdMs: 0 };
2999
+ return {
3000
+ active: true,
3001
+ reels: explicit.slice(),
3002
+ slowdown: runOpts?.anticipateSlowdown ?? config.anticipation.slowdownFactor,
3003
+ holdMs: runOpts?.anticipateHoldMs ?? config.anticipation.holdMs,
3004
+ };
3005
+ }
3006
+ /** Fold a resolved decision back onto the caller's run options (everything else passes through). */
3007
+ function mergeAnticipation(runOpts, decision) {
3008
+ return {
3009
+ ...runOpts,
3010
+ anticipateReels: decision.active ? decision.reels : undefined,
3011
+ anticipateSlowdown: decision.slowdown,
3012
+ anticipateHoldMs: decision.holdMs,
3013
+ };
3014
+ }
2876
3015
  buildGrid();
2877
3016
  const api = {
2878
3017
  view,
@@ -2915,20 +3054,22 @@ function createReelSystem(opts) {
2915
3054
  update(partial) {
2916
3055
  api.setConfig(mergeReelConfig(config, partial));
2917
3056
  },
3057
+ anticipationFor(target, runOpts) {
3058
+ return resolveAnticipation(target, runOpts);
3059
+ },
3060
+ planSpin(target, runOpts) {
3061
+ const decision = resolveAnticipation(target, runOpts);
3062
+ return spin.plan({ targetGrid: target }, mergeAnticipation(runOpts, decision));
3063
+ },
2918
3064
  async spin(target, runOpts) {
2919
3065
  const data = { targetGrid: target };
2920
- const decision = anticipation.decide(target);
3066
+ const decision = resolveAnticipation(target, runOpts);
2921
3067
  let resetZoom = null;
2922
3068
  if (decision.active) {
2923
3069
  log?.(`Anticipation on reels [${decision.reels.join(', ')}]`);
2924
3070
  resetZoom = await anticipation.zoomIn(grid);
2925
3071
  }
2926
- await spin.run(data, {
2927
- ...runOpts,
2928
- anticipateReels: decision.active ? decision.reels : undefined,
2929
- anticipateSlowdown: decision.slowdown,
2930
- anticipateHoldMs: decision.holdMs,
2931
- });
3072
+ await spin.run(data, mergeAnticipation(runOpts, decision));
2932
3073
  if (resetZoom)
2933
3074
  await resetZoom();
2934
3075
  board = target;
@@ -3199,6 +3340,7 @@ exports.createReelSystem = createReelSystem;
3199
3340
  exports.easingByName = easingByName;
3200
3341
  exports.effectiveRowsPerReel = effectiveRowsPerReel;
3201
3342
  exports.mergeReelConfig = mergeReelConfig;
3343
+ exports.perReelValue = perReelValue;
3202
3344
  exports.pickTier = pickTier;
3203
3345
  exports.resolveGeometry = resolveGeometry;
3204
3346
  exports.resolveGridGeometry = resolveGridGeometry;