@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.d.ts CHANGED
@@ -366,6 +366,14 @@ declare function easingByName(name?: string): EasingFunction;
366
366
 
367
367
  /** Names of easing functions available in the engine's `Easing` map (see anim/easing-map.ts). */
368
368
  type EasingName = 'linear' | 'easeInQuad' | 'easeOutQuad' | 'easeInOutQuad' | 'easeInCubic' | 'easeOutCubic' | 'easeInOutCubic' | 'easeInBack' | 'easeOutBack' | 'easeInOutBack' | 'easeOutBounce' | 'easeInBounce' | 'easeOutElastic' | 'easeInSine' | 'easeOutSine' | 'easeInOutSine';
369
+ /**
370
+ * A value that is either flat across the board, or per-reel: an array indexed by REEL INDEX
371
+ * (holes fall back to the scalar default). Used for anticipation timings so a game can make
372
+ * each successive reel slower than the last.
373
+ */
374
+ type PerReel<T> = T | (T | undefined)[];
375
+ /** Resolve a `PerReel<T>` for one reel. `undefined` (or a hole in the array) yields `fallback`. */
376
+ declare function perReelValue<T>(value: PerReel<T> | undefined, reel: number, fallback: T): T;
369
377
  /** Win-evaluation model. Purely presentational here (affects geometry + highlight), the math lives in Lua. */
370
378
  type EvaluationMode = 'lines' | 'ways' | 'anywhere' | 'cluster' | 'megaways' | 'infinity';
371
379
  interface GridConfig {
@@ -448,6 +456,21 @@ interface MotionConfig {
448
456
  slamStop: boolean;
449
457
  /** Symbols visible on a reel tape while spinning (swap/strip). */
450
458
  symbolsPerReel: number;
459
+ /** `cascade-drop`: ms between consecutive cells of ONE reel (top→bottom). Default 24. */
460
+ cellStagger: number;
461
+ /** `cascade-drop`: multiplier on `stopStagger` for the per-reel offset. Default 0.4. */
462
+ reelStaggerFactor: number;
463
+ /** `cascade-drop`: fall duration as a fraction of `spinUp`. Default 0.6. */
464
+ dropFallFactor: number;
465
+ }
466
+ /** What a game-supplied `AnticipationConfig.decide` may return instead of a bare reel list. */
467
+ interface AnticipationOverride {
468
+ /** Reels to anticipate, in the order the progression should ramp. Empty = no anticipation. */
469
+ reels: number[];
470
+ /** Speed factor (lower = slower). Scalar, or per-reel indexed by reel index. */
471
+ slowdown?: PerReel<number>;
472
+ /** Extra hold before landing. Scalar, or per-reel indexed by reel index. */
473
+ holdMs?: PerReel<number>;
451
474
  }
452
475
  interface AnticipationConfig {
453
476
  enabled: boolean;
@@ -461,6 +484,21 @@ interface AnticipationConfig {
461
484
  slowdownFactor: number;
462
485
  /** Extra hold (ms) before the final anticipation reel lands (300–500 typical). */
463
486
  holdMs: number;
487
+ /**
488
+ * Game-supplied decision, REPLACING the built-in `triggerSymbols`/`threshold` counting.
489
+ * Return the reels to anticipate (or an `AnticipationOverride`); `null` / `[]` = no anticipation.
490
+ * Use this when the trigger is not expressible as "N of symbol X landed" — e.g. "the round is
491
+ * still alive on every reel so far", or "reel 3 missed its symbol, so let 4 and 5 stop normally".
492
+ */
493
+ decide?: ((targetGrid: CellData[][]) => number[] | AnticipationOverride | null) | null;
494
+ /**
495
+ * Ramp the slowdown across successive anticipated reels: reel #i of the decision gets
496
+ * `slowdownFactor * progressiveSlowdown ** i`. 1 = flat (default); < 1 = each reel slower
497
+ * than the last.
498
+ */
499
+ progressiveSlowdown: number;
500
+ /** Extra hold (ms) added per successive anticipated reel: reel #i gets `holdMs + i * this`. */
501
+ progressiveHoldMs: number;
464
502
  /** Optional grid zoom while anticipating (magnum-opus uses 1.3×). */
465
503
  zoom: {
466
504
  enabled: boolean;
@@ -701,8 +739,25 @@ interface SpinRunOpts {
701
739
  turbo?: boolean;
702
740
  /** Reels to slow for anticipation (computed by the ReelSystem from config + targetGrid). */
703
741
  anticipateReels?: number[];
704
- anticipateSlowdown?: number;
705
- anticipateHoldMs?: number;
742
+ /** Speed factor for anticipated reels (lower = slower). Scalar, or per-reel indexed by reel. */
743
+ anticipateSlowdown?: PerReel<number>;
744
+ /** Extra hold (ms) for anticipated reels. Scalar, or per-reel indexed by reel. */
745
+ anticipateHoldMs?: PerReel<number>;
746
+ /**
747
+ * Reels whose tape runs normally but whose landing is NOT handed back. The engine stops and
748
+ * disposes of the tape as usual and leaves the real cells hidden and unseated; the caller owns
749
+ * their data and visibility from that point (and `skip()` will not reveal them either).
750
+ */
751
+ deferReveal?: number[];
752
+ /** The resolved schedule, handed over before the first frame runs. Schedule against THESE
753
+ * numbers rather than re-deriving `plan()`'s formula. */
754
+ onPlan?: (plan: ReelStopPlan[]) => void;
755
+ /** Fires on the frame a reel lands — after its cells are seated, before settle/squash/shake.
756
+ * For a deferred reel it still fires (the reel DID stop); nothing was seated. */
757
+ onReelStop?: (reel: number, plan: ReelStopPlan) => void;
758
+ /** Fires as each cell takes its landing symbol. In `cascade-drop` this is the per-cell impact
759
+ * frame (after the fall, before the squash) — the hook for a per-cell sound or shake. */
760
+ onCellSeated?: (reel: number, row: number, data: CellData) => void;
706
761
  }
707
762
  interface ReelStopPlan {
708
763
  reel: number;
@@ -714,6 +769,10 @@ interface ReelStopPlan {
714
769
  ms: number;
715
770
  };
716
771
  anticipated: boolean;
772
+ /** Time-stretch applied to this reel's tape (>= 1, longer = slower). 1 when not anticipated. */
773
+ slowdown: number;
774
+ /** True when `deferReveal` withheld this reel's landing (see `SpinRunOpts.deferReveal`). */
775
+ deferred: boolean;
717
776
  }
718
777
  declare class SpinEngine {
719
778
  private _grid;
@@ -723,6 +782,7 @@ declare class SpinEngine {
723
782
  private _killed;
724
783
  private _shaking;
725
784
  private _temp;
785
+ private _deferred;
726
786
  constructor(grid: ReelGrid, resolve: SymbolResolver, cfg: MotionConfig, win?: WinConfig);
727
787
  setConfig(cfg: MotionConfig): void;
728
788
  setWin(win: WinConfig): void;
@@ -734,7 +794,7 @@ declare class SpinEngine {
734
794
  /** Execute the spin for every reel concurrently. */
735
795
  run(data: SpinData, opts?: SpinRunOpts): Promise<void>;
736
796
  private _runReel;
737
- /** Anticipation time-stretch factor for a reel (>=1, longer = slower). */
797
+ /** Anticipation time-stretch factor for a reel (>=1, longer = slower). Resolved in `plan()`. */
738
798
  private slowOf;
739
799
  private _runSwap;
740
800
  private _runStrip;
@@ -750,10 +810,13 @@ declare class SpinEngine {
750
810
 
751
811
  interface AnticipationDecision {
752
812
  active: boolean;
753
- /** Reel indices that should spin slower / longer. */
813
+ /** Reel indices that should spin slower / longer, in the order the ramp applies. */
754
814
  reels: number[];
755
- slowdown: number;
756
- holdMs: number;
815
+ /** Speed factor. A scalar when flat; a per-reel array when the decision ramps (see
816
+ * `progressiveSlowdown` / a game-supplied `decide`). Resolve with `perReelValue`. */
817
+ slowdown: PerReel<number>;
818
+ /** Extra hold before landing. Scalar or per-reel array, same as `slowdown`. */
819
+ holdMs: PerReel<number>;
757
820
  }
758
821
  declare class AnticipationController {
759
822
  private _cfg;
@@ -767,6 +830,13 @@ declare class AnticipationController {
767
830
  * is flagged for the slow treatment (this mirrors "searching for the last scatter").
768
831
  */
769
832
  decide(targetGrid: CellData[][]): AnticipationDecision;
833
+ /** Assemble a decision, applying the configured progression unless the caller pinned values. */
834
+ private build;
835
+ /**
836
+ * A flat scalar when the progression is a no-op, else an array INDEXED BY REEL so the engine can
837
+ * read a per-reel value straight out of `plan()`.
838
+ */
839
+ private ramp;
770
840
  /** Optionally zoom the grid in while anticipating, then settle back. Returns a reset fn. */
771
841
  zoomIn(grid: ReelGrid): Promise<() => Promise<void>>;
772
842
  }
@@ -947,6 +1017,14 @@ interface ReelSystem {
947
1017
  /** Replace the whole config. */
948
1018
  setConfig(config: ReelSystemConfig): void;
949
1019
  spin(target: CellData[][], opts?: SpinRunOpts): Promise<void>;
1020
+ /**
1021
+ * The schedule `spin(target, opts)` WOULD run, without running it — same anticipation decision,
1022
+ * same numbers. Schedule landing sounds / camera moves against this instead of re-deriving the
1023
+ * engine's formula. (`spin`'s `onPlan` hands you the same array once the spin is under way.)
1024
+ */
1025
+ planSpin(target: CellData[][], opts?: SpinRunOpts): ReelStopPlan[];
1026
+ /** The anticipation decision `spin(target, opts)` would use (run options override the config). */
1027
+ anticipationFor(target: CellData[][], opts?: SpinRunOpts): AnticipationDecision;
950
1028
  /** Run a cascade chain. With `freeSpins` + `cascade.multiplier.persistInFreeSpins`, the multiplier
951
1029
  * carries over instead of resetting. Generic in the step type, so `onStep` hands back the game's
952
1030
  * own step (with its per-step win) rather than the bare TumbleStep. */
@@ -1056,5 +1134,5 @@ declare class MultiplierAccumulator {
1056
1134
  reset(boundary: CarryPolicy): void;
1057
1135
  }
1058
1136
 
1059
- 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, ReelStepController, SpinEngine, SymbolCell, TumbleController, buildReelStepTape, cellPositionOf, createReelSystem, easingByName, effectiveRowsPerReel, mergeReelConfig, pickTier, resolveGeometry, resolveGridGeometry, resolveReelConfig, tierIndexAtValue, valueAt, waysCount };
1060
- export type { AnimatedSymbolConfig, AnticipationConfig, AnticipationDecision, BigWinOverlayConfig, BlurConfig, CarryPolicy, CascadeAnim, CascadeConfig, CascadeStepData, CascadeTimings, CellData, CellFrameStyle, CellSizeSpec, CellState, CountUpConfig, CreateReelSystemOptions, DecorationConfig, DeepPartial, EasingName, EvaluationMode, ExpandingWildConfig, FeatureContext, FeatureKey, FeaturesConfig, GeometryInput, GiantConfig, GridConfig, HoldAndSpinConfig, Intensity, MotionConfig, MotionStyle, MultiplierConfig, MysteryConfig, NudgeConfig, PresetId, RandomWildConfig, ReelFeature, ReelGridConfig, ReelModifierConfig, ReelPreset, ReelSpinData, ReelSpinTimings, ReelStepData, ReelStopPlan$1 as ReelStopPlan, ReelSystem, ReelSystemConfig, ResolvedGeometry, SettleConfig, SpinData, SpinRunOpts, ReelStopPlan as SpinStopPlan, SplitConfig, SquashConfig, StackedConfig, StepRunOpts, StickyConfig, StopMode, StopOrder, SymbolCellConfig, SymbolResolver, SymbolTextures, SymbolView, TransformConfig, TumbleStep, WalkingWildConfig, WinConfig, WinTier };
1137
+ 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, ReelStepController, SpinEngine, SymbolCell, TumbleController, buildReelStepTape, cellPositionOf, createReelSystem, easingByName, effectiveRowsPerReel, mergeReelConfig, perReelValue, pickTier, resolveGeometry, resolveGridGeometry, resolveReelConfig, tierIndexAtValue, valueAt, waysCount };
1138
+ export type { AnimatedSymbolConfig, AnticipationConfig, AnticipationDecision, AnticipationOverride, BigWinOverlayConfig, BlurConfig, CarryPolicy, CascadeAnim, CascadeConfig, CascadeStepData, CascadeTimings, CellData, CellFrameStyle, CellSizeSpec, CellState, CountUpConfig, CreateReelSystemOptions, DecorationConfig, DeepPartial, EasingName, EvaluationMode, ExpandingWildConfig, FeatureContext, FeatureKey, FeaturesConfig, GeometryInput, GiantConfig, GridConfig, HoldAndSpinConfig, Intensity, MotionConfig, MotionStyle, MultiplierConfig, MysteryConfig, NudgeConfig, PerReel, PresetId, RandomWildConfig, ReelFeature, ReelGridConfig, ReelModifierConfig, ReelPreset, ReelSpinData, ReelSpinTimings, ReelStepData, ReelStopPlan$1 as ReelStopPlan, ReelSystem, ReelSystemConfig, ResolvedGeometry, SettleConfig, SpinData, SpinRunOpts, ReelStopPlan as SpinStopPlan, SplitConfig, SquashConfig, StackedConfig, StepRunOpts, StickyConfig, StopMode, StopOrder, SymbolCellConfig, SymbolResolver, SymbolTextures, SymbolView, TransformConfig, TumbleStep, WalkingWildConfig, WinConfig, WinTier };
package/dist/slot.esm.js CHANGED
@@ -1073,6 +1073,14 @@ class ReelSpinController {
1073
1073
  // override only what they need.
1074
1074
  //
1075
1075
  // Design notes are in docs/reels-analysis-and-design.md.
1076
+ /** Resolve a `PerReel<T>` for one reel. `undefined` (or a hole in the array) yields `fallback`. */
1077
+ function perReelValue(value, reel, fallback) {
1078
+ if (value === undefined)
1079
+ return fallback;
1080
+ if (Array.isArray(value))
1081
+ return value[reel] ?? fallback;
1082
+ return value;
1083
+ }
1076
1084
  const FEATURE_KEYS = [
1077
1085
  'reelModifier', // pre-spin
1078
1086
  'giant',
@@ -1118,6 +1126,9 @@ const DEFAULT_REEL_CONFIG = {
1118
1126
  intensity: 'full',
1119
1127
  slamStop: true,
1120
1128
  symbolsPerReel: 6,
1129
+ cellStagger: 24,
1130
+ reelStaggerFactor: 0.4,
1131
+ dropFallFactor: 0.6,
1121
1132
  },
1122
1133
  anticipation: {
1123
1134
  enabled: false,
@@ -1126,6 +1137,9 @@ const DEFAULT_REEL_CONFIG = {
1126
1137
  reels: 'trailing',
1127
1138
  slowdownFactor: 0.3,
1128
1139
  holdMs: 400,
1140
+ decide: null,
1141
+ progressiveSlowdown: 1,
1142
+ progressiveHoldMs: 0,
1129
1143
  zoom: { enabled: false, scale: 1.15, ms: 600 },
1130
1144
  },
1131
1145
  cascade: {
@@ -1252,11 +1266,28 @@ function mergeReelConfig(base, partial) {
1252
1266
  function resolveReelConfig(partial) {
1253
1267
  return mergeReelConfig(DEFAULT_REEL_CONFIG, partial);
1254
1268
  }
1269
+ /** True only for `{}`-shaped objects — a class instance or a Date is NOT one. */
1270
+ function isCloneableRecord(v) {
1271
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
1272
+ return false;
1273
+ const proto = Object.getPrototypeOf(v);
1274
+ return proto === Object.prototype || proto === null;
1275
+ }
1276
+ /**
1277
+ * Deep-clone a config. Hand-rolled rather than `structuredClone` because a config may carry
1278
+ * functions (`anticipation.decide`), which `structuredClone` refuses to copy. Functions and
1279
+ * anything that is not a plain object/array pass through by reference.
1280
+ */
1255
1281
  function structuredCloneSafe(v) {
1256
- // structuredClone is available in modern browsers + Node 17+; fall back to JSON for safety.
1257
- if (typeof structuredClone === 'function')
1258
- return structuredClone(v);
1259
- return JSON.parse(JSON.stringify(v));
1282
+ if (Array.isArray(v))
1283
+ return v.map((item) => structuredCloneSafe(item));
1284
+ if (isCloneableRecord(v)) {
1285
+ const out = {};
1286
+ for (const [k, val] of Object.entries(v))
1287
+ out[k] = structuredCloneSafe(val);
1288
+ return out;
1289
+ }
1290
+ return v;
1260
1291
  }
1261
1292
  /** Effective per-reel row counts (resolves Megaways `rowsPerReel`, else uniform `rows`). */
1262
1293
  function effectiveRowsPerReel(grid) {
@@ -1493,6 +1524,7 @@ class SpinEngine {
1493
1524
  _killed = false;
1494
1525
  _shaking = false;
1495
1526
  _temp = [];
1527
+ _deferred = new Set();
1496
1528
  constructor(grid, resolve, cfg, win) {
1497
1529
  this._grid = grid;
1498
1530
  this._resolve = resolve;
@@ -1536,6 +1568,7 @@ class SpinEngine {
1536
1568
  const cols = this._grid.cols;
1537
1569
  const order = (reel) => (this._cfg.stopOrder === 'rtl' ? cols - 1 - reel : reel);
1538
1570
  const anticipate = new Set(opts?.anticipateReels ?? []);
1571
+ const defer = new Set(opts?.deferReveal ?? []);
1539
1572
  const out = [];
1540
1573
  for (let reel = 0; reel < cols; reel++) {
1541
1574
  const idx = order(reel);
@@ -1552,13 +1585,16 @@ class SpinEngine {
1552
1585
  stopTime = (this._cfg.spinUp + this._cfg.hold + idx * this._cfg.stopStagger) * f;
1553
1586
  const isAnticipated = anticipate.has(reel);
1554
1587
  if (isAnticipated)
1555
- stopTime += (opts?.anticipateHoldMs ?? 0) * f;
1588
+ stopTime += perReelValue(opts?.anticipateHoldMs, reel, 0) * f;
1589
+ const speed = isAnticipated ? perReelValue(opts?.anticipateSlowdown, reel, 1) : 1;
1556
1590
  out.push({
1557
1591
  reel,
1558
1592
  stopTime,
1559
1593
  landing: data.targetGrid[reel] ?? [],
1560
1594
  settle: { amp: this._cfg.settle.amp, ms: this._cfg.settle.ms * f },
1561
1595
  anticipated: isAnticipated,
1596
+ slowdown: Math.max(1, 1 / (speed || 1)),
1597
+ deferred: defer.has(reel),
1562
1598
  });
1563
1599
  }
1564
1600
  return out;
@@ -1568,6 +1604,9 @@ class SpinEngine {
1568
1604
  this._killed = false;
1569
1605
  this._temp = [];
1570
1606
  const plan = this.plan(data, opts);
1607
+ // remembered for skip(): a deferred reel's cells belong to the caller, not to us
1608
+ this._deferred = new Set(plan.filter((p) => p.deferred).map((p) => p.reel));
1609
+ opts?.onPlan?.(plan);
1571
1610
  const f = this.scale(opts);
1572
1611
  await Promise.all(plan.map((p) => this._runReel(p, data, opts, f)));
1573
1612
  this._cleanupTemp();
@@ -1585,9 +1624,9 @@ class SpinEngine {
1585
1624
  return this._runSwap(p, data, opts, f);
1586
1625
  }
1587
1626
  }
1588
- /** Anticipation time-stretch factor for a reel (>=1, longer = slower). */
1589
- slowOf(p, opts) {
1590
- return p.anticipated ? Math.max(1, 1 / (opts?.anticipateSlowdown ?? 1)) : 1;
1627
+ /** Anticipation time-stretch factor for a reel (>=1, longer = slower). Resolved in `plan()`. */
1628
+ slowOf(p) {
1629
+ return p.slowdown;
1591
1630
  }
1592
1631
  // ── swap: cycle symbols quickly in the real cells, then land ──────────────
1593
1632
  async _runSwap(p, data, opts, f) {
@@ -1598,7 +1637,7 @@ class SpinEngine {
1598
1637
  const blur = this._applyBlur(cells, true);
1599
1638
  const tickMs = 1000 / 30;
1600
1639
  // anticipation makes the reel spin longer before it lands
1601
- const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p, opts)) / tickMs));
1640
+ const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p)) / tickMs));
1602
1641
  for (let i = 0; i < ticks; i++) {
1603
1642
  if (this._killed)
1604
1643
  break;
@@ -1607,8 +1646,24 @@ class SpinEngine {
1607
1646
  await Tween.delay(tickMs);
1608
1647
  }
1609
1648
  blur?.();
1610
- for (let r = 0; r < cells.length; r++)
1611
- cells[r].setData(p.landing[r] ?? { symbol: null });
1649
+ if (p.deferred) {
1650
+ // the caller owns this reel's result — go dark and unseated instead of handing it back.
1651
+ // NB 'swap' cycles the tape THROUGH the real cells, so a deferred reel only truly withholds
1652
+ // its result when `SpinData.strip` supplies filler for it; otherwise the tape is built from
1653
+ // the landing symbols. 'strip' and 'cascade-drop' have no such caveat.
1654
+ cells.forEach((c) => {
1655
+ c.setData({ symbol: null });
1656
+ c.visible = false;
1657
+ });
1658
+ opts?.onReelStop?.(p.reel, p);
1659
+ return;
1660
+ }
1661
+ for (let r = 0; r < cells.length; r++) {
1662
+ const cell = p.landing[r] ?? { symbol: null };
1663
+ cells[r].setData(cell);
1664
+ opts?.onCellSeated?.(p.reel, r, cell);
1665
+ }
1666
+ opts?.onReelStop?.(p.reel, p);
1612
1667
  await this._settle(p.reel, p.settle, f);
1613
1668
  await this._frameShake(p.landing);
1614
1669
  }
@@ -1643,7 +1698,7 @@ class SpinEngine {
1643
1698
  this._grid.addChild(tape);
1644
1699
  this._temp.push(tape);
1645
1700
  const clearBlur = this._applyBlur([tape], true);
1646
- const slow = this.slowOf(p, opts);
1701
+ const slow = this.slowOf(p);
1647
1702
  // overshoot/settle honour the configured settle (amp in px, easing)
1648
1703
  const overshoot = p.settle.amp || step * 0.18;
1649
1704
  await Tween.to(tape, { y: restY + overshoot }, p.stopTime * slow, easingByName('easeInOutQuad'));
@@ -1653,12 +1708,21 @@ class SpinEngine {
1653
1708
  }
1654
1709
  clearBlur?.();
1655
1710
  await Tween.to(tape, { y: restY }, Math.max(120, p.settle.ms), easingByName(this._cfg.settle.easing));
1656
- // hand the result back to the real cells
1657
- for (let r = 0; r < rows; r++)
1658
- realCells[r].setData(p.landing[r] ?? { symbol: null });
1659
- realCells.forEach((c) => (c.visible = true));
1711
+ // hand the result back to the real cells — unless the caller deferred the reveal, in which
1712
+ // case the tape simply goes away and the reel is left dark, unseated and owned by the caller
1713
+ if (!p.deferred) {
1714
+ for (let r = 0; r < rows; r++) {
1715
+ const cell = p.landing[r] ?? { symbol: null };
1716
+ realCells[r].setData(cell);
1717
+ opts?.onCellSeated?.(p.reel, r, cell);
1718
+ }
1719
+ realCells.forEach((c) => (c.visible = true));
1720
+ }
1660
1721
  tape.destroy();
1661
1722
  this._temp = this._temp.filter((t) => t !== tape);
1723
+ opts?.onReelStop?.(p.reel, p);
1724
+ if (p.deferred)
1725
+ return;
1662
1726
  // squash the real cells on impact when enabled
1663
1727
  if (this._cfg.squash.enabled)
1664
1728
  await Promise.all(realCells.map((c) => this._squashCell(c, f)));
@@ -1667,22 +1731,36 @@ class SpinEngine {
1667
1731
  // ── cascade-drop: symbols drop in from above with stagger + bounce + squash ─
1668
1732
  async _runDrop(p, opts, f) {
1669
1733
  const rows = this._grid.rowsOf(p.reel);
1734
+ if (p.deferred) {
1735
+ // nothing to drop — the caller brings this reel in itself
1736
+ for (let r = 0; r < rows; r++)
1737
+ this._grid.getCell(p.reel, r).visible = false;
1738
+ opts?.onReelStop?.(p.reel, p);
1739
+ return;
1740
+ }
1670
1741
  const step = this._grid.cellPosition(p.reel, 1).y - this._grid.cellPosition(p.reel, 0).y;
1671
- const slow = this.slowOf(p, opts); // anticipation drops the reel in more slowly
1742
+ const slow = this.slowOf(p); // anticipation drops the reel in more slowly
1672
1743
  await Promise.all(Array.from({ length: rows }, (_, r) => r).map(async (r) => {
1673
1744
  if (this._killed)
1674
1745
  return;
1675
1746
  const cell = this._grid.getCell(p.reel, r);
1676
1747
  const to = this._grid.cellPosition(p.reel, r);
1677
- cell.setData(p.landing[r] ?? { symbol: null });
1748
+ const data = p.landing[r] ?? { symbol: null };
1749
+ cell.setData(data);
1678
1750
  cell.position.set(to.x, to.y - step * (rows + 1));
1679
1751
  cell.alpha = 1;
1680
- const delay = (p.reel * this._cfg.stopStagger * 0.4 + r * 24) * f * slow;
1752
+ const delay = (p.reel * this._cfg.stopStagger * this._cfg.reelStaggerFactor +
1753
+ r * this._cfg.cellStagger) *
1754
+ f *
1755
+ slow;
1681
1756
  if (delay)
1682
1757
  await Tween.delay(delay);
1683
- await Tween.to(cell, { 'position.y': to.y }, this._cfg.spinUp * 0.6 * f * slow, easingByName(this._cfg.settle.easing));
1758
+ await Tween.to(cell, { 'position.y': to.y }, this._cfg.spinUp * this._cfg.dropFallFactor * f * slow, easingByName(this._cfg.settle.easing));
1759
+ // the impact frame — fired before the squash so a game can sync its own hit feedback
1760
+ opts?.onCellSeated?.(p.reel, r, data);
1684
1761
  await this._squashCell(cell, f);
1685
1762
  }));
1763
+ opts?.onReelStop?.(p.reel, p);
1686
1764
  await this._frameShake(p.landing);
1687
1765
  }
1688
1766
  // ── shared helpers ────────────────────────────────────────────────────────
@@ -1746,13 +1824,17 @@ class SpinEngine {
1746
1824
  Tween.killTweensOf(this._grid);
1747
1825
  this._grid.x = 0; // undo any in-flight frame shake
1748
1826
  for (let c = 0; c < this._grid.cols; c++) {
1827
+ // a deferred reel's visibility belongs to the caller — a slam stop must not reveal it
1828
+ const deferred = this._deferred.has(c);
1749
1829
  for (let r = 0; r < this._grid.rowsOf(c); r++) {
1750
1830
  const cell = this._grid.getCell(c, r);
1751
1831
  if (cell.destroyed)
1752
1832
  continue;
1753
1833
  Tween.killTweensOf(cell);
1754
- cell.visible = true;
1755
- cell.alpha = 1;
1834
+ if (!deferred) {
1835
+ cell.visible = true;
1836
+ cell.alpha = 1;
1837
+ }
1756
1838
  cell.filters = [];
1757
1839
  cell.scale.set(1);
1758
1840
  }
@@ -1765,6 +1847,8 @@ class SpinEngine {
1765
1847
  //
1766
1848
  // Decides which trailing reels get the "anticipation" slow-down treatment, based purely on
1767
1849
  // the already-resolved landing grid (presentation only — never a secondary outcome decision).
1850
+ /** Fresh "nothing to anticipate" decision (fresh, so callers may mutate `reels` freely). */
1851
+ const none = () => ({ active: false, reels: [], slowdown: 1, holdMs: 0 });
1768
1852
  class AnticipationController {
1769
1853
  _cfg;
1770
1854
  constructor(cfg) {
@@ -1788,19 +1872,23 @@ class AnticipationController {
1788
1872
  */
1789
1873
  decide(targetGrid) {
1790
1874
  if (!this._cfg.enabled)
1791
- return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1875
+ return none();
1876
+ // A game-supplied predicate replaces the symbol counting entirely.
1877
+ if (this._cfg.decide) {
1878
+ const custom = this._cfg.decide(targetGrid);
1879
+ if (!custom)
1880
+ return none();
1881
+ const o = Array.isArray(custom) ? { reels: custom } : custom;
1882
+ if (!o.reels?.length)
1883
+ return none();
1884
+ return this.build(o.reels.slice(), o.slowdown, o.holdMs);
1885
+ }
1792
1886
  if (Array.isArray(this._cfg.reels)) {
1793
1887
  // explicit reel list — arm only if the threshold is met somewhere on the board
1794
1888
  const total = targetGrid.reduce((sum, reel) => sum + this.countOnReel(reel), 0);
1795
- const active = total >= this._cfg.threshold;
1796
- return active
1797
- ? {
1798
- active,
1799
- reels: this._cfg.reels.slice(),
1800
- slowdown: this._cfg.slowdownFactor,
1801
- holdMs: this._cfg.holdMs,
1802
- }
1803
- : { active: false, reels: [], slowdown: 1, holdMs: 0 };
1889
+ if (total < this._cfg.threshold)
1890
+ return none();
1891
+ return this.build(this._cfg.reels.slice());
1804
1892
  }
1805
1893
  // 'trailing': find the reel where the cumulative count hits the threshold
1806
1894
  let running = 0;
@@ -1813,13 +1901,37 @@ class AnticipationController {
1813
1901
  }
1814
1902
  }
1815
1903
  if (armReel < 0)
1816
- return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1904
+ return none();
1817
1905
  const reels = [];
1818
1906
  for (let c = armReel + 1; c < targetGrid.length; c++)
1819
1907
  reels.push(c);
1820
1908
  if (reels.length === 0)
1821
- return { active: false, reels: [], slowdown: 1, holdMs: 0 };
1822
- return { active: true, reels, slowdown: this._cfg.slowdownFactor, holdMs: this._cfg.holdMs };
1909
+ return none();
1910
+ return this.build(reels);
1911
+ }
1912
+ /** Assemble a decision, applying the configured progression unless the caller pinned values. */
1913
+ build(reels, slowdown, holdMs) {
1914
+ return {
1915
+ active: true,
1916
+ reels,
1917
+ slowdown: slowdown ??
1918
+ this.ramp(reels, this._cfg.slowdownFactor, (base, i) => i === 0 ? base : base * Math.pow(this._cfg.progressiveSlowdown, i)),
1919
+ holdMs: holdMs ??
1920
+ this.ramp(reels, this._cfg.holdMs, (base, i) => base + this._cfg.progressiveHoldMs * i),
1921
+ };
1922
+ }
1923
+ /**
1924
+ * A flat scalar when the progression is a no-op, else an array INDEXED BY REEL so the engine can
1925
+ * read a per-reel value straight out of `plan()`.
1926
+ */
1927
+ ramp(reels, base, at) {
1928
+ if (at(base, 1) === base)
1929
+ return base;
1930
+ const out = [];
1931
+ reels.forEach((reel, i) => {
1932
+ out[reel] = at(base, i);
1933
+ });
1934
+ return out;
1823
1935
  }
1824
1936
  /** Optionally zoom the grid in while anticipating, then settle back. Returns a reset fn. */
1825
1937
  async zoomIn(grid) {
@@ -2871,6 +2983,33 @@ function createReelSystem(opts) {
2871
2983
  function ctx(freeSpins) {
2872
2984
  return { grid, resolve, cfg: config, fx, board, freeSpins, log };
2873
2985
  }
2986
+ /**
2987
+ * Which reels get the anticipation treatment for this spin. An explicit `anticipateReels` on the
2988
+ * run options WINS over the configured decision — passing it is how a game drives anticipation
2989
+ * from its own logic. Omit it and the configured `AnticipationController` decides.
2990
+ */
2991
+ function resolveAnticipation(target, runOpts) {
2992
+ const explicit = runOpts?.anticipateReels;
2993
+ if (!explicit)
2994
+ return anticipation.decide(target);
2995
+ if (!explicit.length)
2996
+ return { active: false, reels: [], slowdown: 1, holdMs: 0 };
2997
+ return {
2998
+ active: true,
2999
+ reels: explicit.slice(),
3000
+ slowdown: runOpts?.anticipateSlowdown ?? config.anticipation.slowdownFactor,
3001
+ holdMs: runOpts?.anticipateHoldMs ?? config.anticipation.holdMs,
3002
+ };
3003
+ }
3004
+ /** Fold a resolved decision back onto the caller's run options (everything else passes through). */
3005
+ function mergeAnticipation(runOpts, decision) {
3006
+ return {
3007
+ ...runOpts,
3008
+ anticipateReels: decision.active ? decision.reels : undefined,
3009
+ anticipateSlowdown: decision.slowdown,
3010
+ anticipateHoldMs: decision.holdMs,
3011
+ };
3012
+ }
2874
3013
  buildGrid();
2875
3014
  const api = {
2876
3015
  view,
@@ -2913,20 +3052,22 @@ function createReelSystem(opts) {
2913
3052
  update(partial) {
2914
3053
  api.setConfig(mergeReelConfig(config, partial));
2915
3054
  },
3055
+ anticipationFor(target, runOpts) {
3056
+ return resolveAnticipation(target, runOpts);
3057
+ },
3058
+ planSpin(target, runOpts) {
3059
+ const decision = resolveAnticipation(target, runOpts);
3060
+ return spin.plan({ targetGrid: target }, mergeAnticipation(runOpts, decision));
3061
+ },
2916
3062
  async spin(target, runOpts) {
2917
3063
  const data = { targetGrid: target };
2918
- const decision = anticipation.decide(target);
3064
+ const decision = resolveAnticipation(target, runOpts);
2919
3065
  let resetZoom = null;
2920
3066
  if (decision.active) {
2921
3067
  log?.(`Anticipation on reels [${decision.reels.join(', ')}]`);
2922
3068
  resetZoom = await anticipation.zoomIn(grid);
2923
3069
  }
2924
- await spin.run(data, {
2925
- ...runOpts,
2926
- anticipateReels: decision.active ? decision.reels : undefined,
2927
- anticipateSlowdown: decision.slowdown,
2928
- anticipateHoldMs: decision.holdMs,
2929
- });
3070
+ await spin.run(data, mergeAnticipation(runOpts, decision));
2930
3071
  if (resetZoom)
2931
3072
  await resetZoom();
2932
3073
  board = target;
@@ -3171,5 +3312,5 @@ class MultiplierAccumulator {
3171
3312
  }
3172
3313
  }
3173
3314
 
3174
- 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, ReelStepController, SpinEngine, SymbolCell, TumbleController, buildReelStepTape, cellPositionOf, createReelSystem, easingByName, effectiveRowsPerReel, mergeReelConfig, pickTier, resolveGeometry, resolveGridGeometry, resolveReelConfig, tierIndexAtValue, valueAt, waysCount };
3315
+ 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, ReelStepController, SpinEngine, SymbolCell, TumbleController, buildReelStepTape, cellPositionOf, createReelSystem, easingByName, effectiveRowsPerReel, mergeReelConfig, perReelValue, pickTier, resolveGeometry, resolveGridGeometry, resolveReelConfig, tierIndexAtValue, valueAt, waysCount };
3175
3316
  //# sourceMappingURL=slot.esm.js.map