@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.
@@ -66,6 +66,11 @@ export interface ShellRuntime {
66
66
  /** Jurisdiction flags from initData (`config.jurisdiction`). Restrict shell features — applied
67
67
  * OVER the author's features so a jurisdiction restriction always wins. */
68
68
  jurisdiction?: JurisdictionRestrictions;
69
+ /** The client the platform launched us on (`initData.device`; both bridges read it off the launch
70
+ * URL). `'mobile'` turns the whole keyboard surface off — the shortcuts and the Hotkeys section
71
+ * that documents them. Left unset the shell measures the client itself (shell's core/device.ts);
72
+ * the host only speaks when the platform told it outright. */
73
+ device?: 'desktop' | 'mobile' | string;
69
74
  /** Bet ladder from `/wallet/authenticate` (`initData.config.betLevels`, major units). Stake ladders
70
75
  * are CURRENCY-SPECIFIC (us_/non_us_/social_), so this overrides the spec's static `betLevels` on a
71
76
  * Stake launch; falls back to the spec on dev/devBridge. */
@@ -492,6 +497,10 @@ export function buildShellConfig(
492
497
  ...(opts.features ?? {}),
493
498
  } as ShellFeatures;
494
499
  applyJurisdiction(features, runtime.jurisdiction);
500
+ // A phone has no keys to press: no Spacebar-to-spin, and no keycap chart advertising it. Applied
501
+ // after the author's features for the same reason a jurisdiction restriction is — the platform
502
+ // knows what it launched us on, and a game can't opt out of the hardware.
503
+ if (runtime.device === 'mobile') features.hotkeys = false;
495
504
  return {
496
505
  language: runtime.language ?? 'en',
497
506
  isSocial,
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // Design notes are in docs/reels-analysis-and-design.md.
9
9
 
10
- import type { CellFrameStyle } from '../grid/SymbolCell';
10
+ import type { CellData, CellFrameStyle } from '../grid/SymbolCell';
11
11
  import { resolveGeometry, type CellSizeSpec, type ResolvedGeometry } from '../grid/geometry';
12
12
 
13
13
  export type { CellSizeSpec, ResolvedGeometry };
@@ -31,6 +31,20 @@ export type EasingName =
31
31
  | 'easeOutSine'
32
32
  | 'easeInOutSine';
33
33
 
34
+ /**
35
+ * A value that is either flat across the board, or per-reel: an array indexed by REEL INDEX
36
+ * (holes fall back to the scalar default). Used for anticipation timings so a game can make
37
+ * each successive reel slower than the last.
38
+ */
39
+ export type PerReel<T> = T | (T | undefined)[];
40
+
41
+ /** Resolve a `PerReel<T>` for one reel. `undefined` (or a hole in the array) yields `fallback`. */
42
+ export function perReelValue<T>(value: PerReel<T> | undefined, reel: number, fallback: T): T {
43
+ if (value === undefined) return fallback;
44
+ if (Array.isArray(value)) return (value[reel] as T | undefined) ?? fallback;
45
+ return value;
46
+ }
47
+
34
48
  // ─────────────────────────────────────────────────────────────────────────────
35
49
  // Grid
36
50
  // ─────────────────────────────────────────────────────────────────────────────
@@ -78,6 +92,10 @@ export type MotionStyle =
78
92
  | 'cascade-drop'; // symbols drop in from above (tumble-style boards)
79
93
 
80
94
  export type StopMode = 'sequential' | 'sync' | 'random';
95
+ /** `cascade-drop` fill direction within one reel. */
96
+ export type DropOrder = 'top-down' | 'bottom-up';
97
+ /** How `cascade-drop` spaces reels: by formula, or one strictly after the other. */
98
+ export type DropSequence = 'parallel' | 'chained' | 'chained-when-anticipated';
81
99
  export type StopOrder = 'ltr' | 'rtl';
82
100
  export type Intensity = 'full' | 'reduced' | 'minimal';
83
101
 
@@ -127,12 +145,47 @@ export interface MotionConfig {
127
145
  slamStop: boolean;
128
146
  /** Symbols visible on a reel tape while spinning (swap/strip). */
129
147
  symbolsPerReel: number;
148
+ /** `cascade-drop`: ms between consecutive cells of ONE reel (top→bottom). Default 24. */
149
+ cellStagger: number;
150
+ /** `cascade-drop`: multiplier on `stopStagger` for the per-reel offset. Default 0.4. */
151
+ reelStaggerFactor: number;
152
+ /** `cascade-drop`: fall duration as a fraction of `spinUp`. Default 0.6. */
153
+ dropFallFactor: number;
154
+ /**
155
+ * `cascade-drop`: which cell of a reel lands first. `'top-down'` (default, the engine's original
156
+ * behaviour) deals the reel like cards from the top; `'bottom-up'` fills it the way gravity
157
+ * would — the lowest cell arrives first and the rest stack on top of it.
158
+ */
159
+ dropOrder: DropOrder;
160
+ /**
161
+ * `cascade-drop`: how reels are spaced.
162
+ * - `'parallel'` (default) starts every reel at its own formula offset —
163
+ * `reel * stopStagger * reelStaggerFactor` — so a reel can open while the previous one is
164
+ * still dropping. Fast, and what a normal spin wants.
165
+ * - `'chained'` starts a reel only once the previous one has seated its LAST cell, plus that
166
+ * same offset as the gap. One reel at a time, always — correct, but it makes every spin as
167
+ * long as the sum of its reels.
168
+ * - `'chained-when-anticipated'` runs the un-armed reels in parallel and switches to the chain
169
+ * from the first ANTICIPATED reel onwards. The base spin keeps its stop window; the hunt for
170
+ * the last scatter goes strictly reel by reel, each slower than the last.
171
+ */
172
+ dropSequence: DropSequence;
130
173
  }
131
174
 
132
175
  // ─────────────────────────────────────────────────────────────────────────────
133
176
  // Anticipation
134
177
  // ─────────────────────────────────────────────────────────────────────────────
135
178
 
179
+ /** What a game-supplied `AnticipationConfig.decide` may return instead of a bare reel list. */
180
+ export interface AnticipationOverride {
181
+ /** Reels to anticipate, in the order the progression should ramp. Empty = no anticipation. */
182
+ reels: number[];
183
+ /** Speed factor (lower = slower). Scalar, or per-reel indexed by reel index. */
184
+ slowdown?: PerReel<number>;
185
+ /** Extra hold before landing. Scalar, or per-reel indexed by reel index. */
186
+ holdMs?: PerReel<number>;
187
+ }
188
+
136
189
  export interface AnticipationConfig {
137
190
  enabled: boolean;
138
191
  /** Symbols that count toward the anticipation threshold (scatter/bonus). */
@@ -145,6 +198,21 @@ export interface AnticipationConfig {
145
198
  slowdownFactor: number;
146
199
  /** Extra hold (ms) before the final anticipation reel lands (300–500 typical). */
147
200
  holdMs: number;
201
+ /**
202
+ * Game-supplied decision, REPLACING the built-in `triggerSymbols`/`threshold` counting.
203
+ * Return the reels to anticipate (or an `AnticipationOverride`); `null` / `[]` = no anticipation.
204
+ * Use this when the trigger is not expressible as "N of symbol X landed" — e.g. "the round is
205
+ * still alive on every reel so far", or "reel 3 missed its symbol, so let 4 and 5 stop normally".
206
+ */
207
+ decide?: ((targetGrid: CellData[][]) => number[] | AnticipationOverride | null) | null;
208
+ /**
209
+ * Ramp the slowdown across successive anticipated reels: reel #i of the decision gets
210
+ * `slowdownFactor * progressiveSlowdown ** i`. 1 = flat (default); < 1 = each reel slower
211
+ * than the last.
212
+ */
213
+ progressiveSlowdown: number;
214
+ /** Extra hold (ms) added per successive anticipated reel: reel #i gets `holdMs + i * this`. */
215
+ progressiveHoldMs: number;
148
216
  /** Optional grid zoom while anticipating (magnum-opus uses 1.3×). */
149
217
  zoom: { enabled: boolean; scale: number; ms: number };
150
218
  }
@@ -419,6 +487,11 @@ export const DEFAULT_REEL_CONFIG: ReelSystemConfig = {
419
487
  intensity: 'full',
420
488
  slamStop: true,
421
489
  symbolsPerReel: 6,
490
+ cellStagger: 24,
491
+ reelStaggerFactor: 0.4,
492
+ dropFallFactor: 0.6,
493
+ dropOrder: 'top-down',
494
+ dropSequence: 'parallel',
422
495
  },
423
496
  anticipation: {
424
497
  enabled: false,
@@ -427,6 +500,9 @@ export const DEFAULT_REEL_CONFIG: ReelSystemConfig = {
427
500
  reels: 'trailing',
428
501
  slowdownFactor: 0.3,
429
502
  holdMs: 400,
503
+ decide: null,
504
+ progressiveSlowdown: 1,
505
+ progressiveHoldMs: 0,
430
506
  zoom: { enabled: false, scale: 1.15, ms: 600 },
431
507
  },
432
508
  cascade: {
@@ -555,10 +631,26 @@ export function resolveReelConfig(partial?: DeepPartial<ReelSystemConfig>): Reel
555
631
  return mergeReelConfig(DEFAULT_REEL_CONFIG, partial);
556
632
  }
557
633
 
634
+ /** True only for `{}`-shaped objects — a class instance or a Date is NOT one. */
635
+ function isCloneableRecord(v: unknown): v is Record<string, unknown> {
636
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) return false;
637
+ const proto = Object.getPrototypeOf(v) as object | null;
638
+ return proto === Object.prototype || proto === null;
639
+ }
640
+
641
+ /**
642
+ * Deep-clone a config. Hand-rolled rather than `structuredClone` because a config may carry
643
+ * functions (`anticipation.decide`), which `structuredClone` refuses to copy. Functions and
644
+ * anything that is not a plain object/array pass through by reference.
645
+ */
558
646
  function structuredCloneSafe<T>(v: T): T {
559
- // structuredClone is available in modern browsers + Node 17+; fall back to JSON for safety.
560
- if (typeof structuredClone === 'function') return structuredClone(v);
561
- return JSON.parse(JSON.stringify(v)) as T;
647
+ if (Array.isArray(v)) return v.map((item) => structuredCloneSafe(item)) as unknown as T;
648
+ if (isCloneableRecord(v)) {
649
+ const out: Record<string, unknown> = {};
650
+ for (const [k, val] of Object.entries(v)) out[k] = structuredCloneSafe(val);
651
+ return out as T;
652
+ }
653
+ return v;
562
654
  }
563
655
 
564
656
  /** Effective per-reel row counts (resolves Megaways `rowsPerReel`, else uniform `rows`). */
@@ -134,6 +134,11 @@ export const REEL_FIELD_SCHEMA: Section[] = [
134
134
  { kind: 'select', path: 'motion.intensity', label: 'Intensity', options: ['full', 'reduced', 'minimal'] },
135
135
  { kind: 'toggle', path: 'motion.slamStop', label: 'Slam stop' },
136
136
  { kind: 'range', path: 'motion.symbolsPerReel', label: 'Tape length', min: 3, max: 24, step: 1 },
137
+ { kind: 'range', path: 'motion.cellStagger', label: 'Cell stagger (ms)', min: 0, max: 700, step: 5 },
138
+ { kind: 'range', path: 'motion.reelStaggerFactor', label: 'Reel stagger ×', min: 0, max: 3, step: 0.05 },
139
+ { kind: 'range', path: 'motion.dropFallFactor', label: 'Drop fall ×', min: 0.1, max: 3, step: 0.05 },
140
+ { kind: 'select', path: 'motion.dropOrder', label: 'Drop order', options: ['top-down', 'bottom-up'] },
141
+ { kind: 'select', path: 'motion.dropSequence', label: 'Drop sequence', options: ['parallel', 'chained', 'chained-when-anticipated'] },
137
142
  ],
138
143
  },
139
144
  {
@@ -144,6 +149,8 @@ export const REEL_FIELD_SCHEMA: Section[] = [
144
149
  { kind: 'range', path: 'anticipation.threshold', label: 'Threshold (N−1)', min: 1, max: 6, step: 1 },
145
150
  { kind: 'range', path: 'anticipation.slowdownFactor', label: 'Slowdown', min: 0.1, max: 1, step: 0.05 },
146
151
  { kind: 'range', path: 'anticipation.holdMs', label: 'Hold (ms)', min: 0, max: 1200, step: 50 },
152
+ { kind: 'range', path: 'anticipation.progressiveSlowdown', label: 'Slowdown ramp ×/reel', min: 0.3, max: 1, step: 0.05 },
153
+ { kind: 'range', path: 'anticipation.progressiveHoldMs', label: 'Hold ramp (ms/reel)', min: 0, max: 600, step: 25 },
147
154
  { kind: 'toggle', path: 'anticipation.zoom.enabled', label: 'Reel zoom' },
148
155
  { kind: 'range', path: 'anticipation.zoom.scale', label: 'Zoom scale', min: 1, max: 1.6, step: 0.05 },
149
156
  { kind: 'range', path: 'anticipation.zoom.ms', label: 'Zoom (ms)', min: 200, max: 1200, step: 50 },
package/src/slot/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  effectiveRowsPerReel,
24
24
  resolveGridGeometry,
25
25
  waysCount,
26
+ perReelValue,
26
27
  } from './config/ReelSystemConfig';
27
28
  export type {
28
29
  ReelSystemConfig,
@@ -32,6 +33,8 @@ export type {
32
33
  MotionStyle,
33
34
  StopMode,
34
35
  StopOrder,
36
+ DropOrder,
37
+ DropSequence,
35
38
  Intensity,
36
39
  GridConfig,
37
40
  MotionConfig,
@@ -39,6 +42,8 @@ export type {
39
42
  SquashConfig,
40
43
  BlurConfig,
41
44
  AnticipationConfig,
45
+ AnticipationOverride,
46
+ PerReel,
42
47
  CascadeConfig,
43
48
  WinConfig,
44
49
  FeaturesConfig,
@@ -7,16 +7,22 @@ import { Tween } from '../../animation';
7
7
  import { easingByName } from '../anim/easing-map';
8
8
  import type { ReelGrid } from '../grid/ReelGrid';
9
9
  import type { CellData } from '../grid/SymbolCell';
10
- import type { AnticipationConfig } from '../config/ReelSystemConfig';
10
+ import type { AnticipationConfig, AnticipationOverride, PerReel } from '../config/ReelSystemConfig';
11
11
 
12
12
  export interface AnticipationDecision {
13
13
  active: boolean;
14
- /** Reel indices that should spin slower / longer. */
14
+ /** Reel indices that should spin slower / longer, in the order the ramp applies. */
15
15
  reels: number[];
16
- slowdown: number;
17
- holdMs: number;
16
+ /** Speed factor. A scalar when flat; a per-reel array when the decision ramps (see
17
+ * `progressiveSlowdown` / a game-supplied `decide`). Resolve with `perReelValue`. */
18
+ slowdown: PerReel<number>;
19
+ /** Extra hold before landing. Scalar or per-reel array, same as `slowdown`. */
20
+ holdMs: PerReel<number>;
18
21
  }
19
22
 
23
+ /** Fresh "nothing to anticipate" decision (fresh, so callers may mutate `reels` freely). */
24
+ const none = (): AnticipationDecision => ({ active: false, reels: [], slowdown: 1, holdMs: 0 });
25
+
20
26
  export class AnticipationController {
21
27
  private _cfg: AnticipationConfig;
22
28
  constructor(cfg: AnticipationConfig) {
@@ -39,20 +45,22 @@ export class AnticipationController {
39
45
  * is flagged for the slow treatment (this mirrors "searching for the last scatter").
40
46
  */
41
47
  decide(targetGrid: CellData[][]): AnticipationDecision {
42
- if (!this._cfg.enabled) return { active: false, reels: [], slowdown: 1, holdMs: 0 };
48
+ if (!this._cfg.enabled) return none();
49
+
50
+ // A game-supplied predicate replaces the symbol counting entirely.
51
+ if (this._cfg.decide) {
52
+ const custom = this._cfg.decide(targetGrid);
53
+ if (!custom) return none();
54
+ const o: AnticipationOverride = Array.isArray(custom) ? { reels: custom } : custom;
55
+ if (!o.reels?.length) return none();
56
+ return this.build(o.reels.slice(), o.slowdown, o.holdMs);
57
+ }
43
58
 
44
59
  if (Array.isArray(this._cfg.reels)) {
45
60
  // explicit reel list — arm only if the threshold is met somewhere on the board
46
61
  const total = targetGrid.reduce((sum, reel) => sum + this.countOnReel(reel), 0);
47
- const active = total >= this._cfg.threshold;
48
- return active
49
- ? {
50
- active,
51
- reels: this._cfg.reels.slice(),
52
- slowdown: this._cfg.slowdownFactor,
53
- holdMs: this._cfg.holdMs,
54
- }
55
- : { active: false, reels: [], slowdown: 1, holdMs: 0 };
62
+ if (total < this._cfg.threshold) return none();
63
+ return this.build(this._cfg.reels.slice());
56
64
  }
57
65
 
58
66
  // 'trailing': find the reel where the cumulative count hits the threshold
@@ -65,12 +73,49 @@ export class AnticipationController {
65
73
  break;
66
74
  }
67
75
  }
68
- if (armReel < 0) return { active: false, reels: [], slowdown: 1, holdMs: 0 };
76
+ if (armReel < 0) return none();
69
77
 
70
78
  const reels: number[] = [];
71
79
  for (let c = armReel + 1; c < targetGrid.length; c++) reels.push(c);
72
- if (reels.length === 0) return { active: false, reels: [], slowdown: 1, holdMs: 0 };
73
- return { active: true, reels, slowdown: this._cfg.slowdownFactor, holdMs: this._cfg.holdMs };
80
+ if (reels.length === 0) return none();
81
+ return this.build(reels);
82
+ }
83
+
84
+ /** Assemble a decision, applying the configured progression unless the caller pinned values. */
85
+ private build(
86
+ reels: number[],
87
+ slowdown?: PerReel<number>,
88
+ holdMs?: PerReel<number>,
89
+ ): AnticipationDecision {
90
+ return {
91
+ active: true,
92
+ reels,
93
+ slowdown:
94
+ slowdown ??
95
+ this.ramp(reels, this._cfg.slowdownFactor, (base, i) =>
96
+ i === 0 ? base : base * Math.pow(this._cfg.progressiveSlowdown, i),
97
+ ),
98
+ holdMs:
99
+ holdMs ??
100
+ this.ramp(reels, this._cfg.holdMs, (base, i) => base + this._cfg.progressiveHoldMs * i),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * A flat scalar when the progression is a no-op, else an array INDEXED BY REEL so the engine can
106
+ * read a per-reel value straight out of `plan()`.
107
+ */
108
+ private ramp(
109
+ reels: number[],
110
+ base: number,
111
+ at: (base: number, i: number) => number,
112
+ ): PerReel<number> {
113
+ if (at(base, 1) === base) return base;
114
+ const out: (number | undefined)[] = [];
115
+ reels.forEach((reel, i) => {
116
+ out[reel] = at(base, i);
117
+ });
118
+ return out;
74
119
  }
75
120
 
76
121
  /** Optionally zoom the grid in while anticipating, then settle back. Returns a reset fn. */
@@ -17,7 +17,9 @@ import type { SymbolResolver } from '../grid/SymbolView';
17
17
  import {
18
18
  DEFAULT_REEL_CONFIG,
19
19
  INTENSITY_SCALE,
20
+ perReelValue,
20
21
  type MotionConfig,
22
+ type PerReel,
21
23
  type WinConfig,
22
24
  } from '../config/ReelSystemConfig';
23
25
 
@@ -32,8 +34,27 @@ export interface SpinRunOpts {
32
34
  turbo?: boolean;
33
35
  /** Reels to slow for anticipation (computed by the ReelSystem from config + targetGrid). */
34
36
  anticipateReels?: number[];
35
- anticipateSlowdown?: number;
36
- anticipateHoldMs?: number;
37
+ /** Speed factor for anticipated reels (lower = slower). Scalar, or per-reel indexed by reel. */
38
+ anticipateSlowdown?: PerReel<number>;
39
+ /** Extra hold (ms) for anticipated reels. Scalar, or per-reel indexed by reel. */
40
+ anticipateHoldMs?: PerReel<number>;
41
+ /**
42
+ * Reels whose tape runs normally but whose landing is NOT handed back. The engine stops and
43
+ * disposes of the tape as usual and leaves the real cells hidden and unseated; the caller owns
44
+ * their data and visibility from that point (and `skip()` will not reveal them either).
45
+ */
46
+ deferReveal?: number[];
47
+
48
+ // ── lifecycle ────────────────────────────────────────────────────────────
49
+ /** The resolved schedule, handed over before the first frame runs. Schedule against THESE
50
+ * numbers rather than re-deriving `plan()`'s formula. */
51
+ onPlan?: (plan: ReelStopPlan[]) => void;
52
+ /** Fires on the frame a reel lands — after its cells are seated, before settle/squash/shake.
53
+ * For a deferred reel it still fires (the reel DID stop); nothing was seated. */
54
+ onReelStop?: (reel: number, plan: ReelStopPlan) => void;
55
+ /** Fires as each cell takes its landing symbol. In `cascade-drop` this is the per-cell impact
56
+ * frame (after the fall, before the squash) — the hook for a per-cell sound or shake. */
57
+ onCellSeated?: (reel: number, row: number, data: CellData) => void;
37
58
  }
38
59
 
39
60
  export interface ReelStopPlan {
@@ -43,6 +64,17 @@ export interface ReelStopPlan {
43
64
  landing: CellData[];
44
65
  settle: { amp: number; ms: number };
45
66
  anticipated: boolean;
67
+ /** Time-stretch applied to this reel's tape (>= 1, longer = slower). 1 when not anticipated. */
68
+ slowdown: number;
69
+ /** True when `deferReveal` withheld this reel's landing (see `SpinRunOpts.deferReveal`). */
70
+ deferred: boolean;
71
+ /**
72
+ * `cascade-drop` only: ms from spin start at which each cell of this reel seats, INDEXED BY ROW.
73
+ * The reel's fill direction lives in these numbers (`motion.dropOrder` decides which row is the
74
+ * smallest), as does its place in the chain (`motion.dropSequence`). `stopTime` is the largest
75
+ * of them — the frame the whole reel has landed.
76
+ */
77
+ cellStopTimes?: number[];
46
78
  }
47
79
 
48
80
  export class SpinEngine {
@@ -53,6 +85,7 @@ export class SpinEngine {
53
85
  private _killed = false;
54
86
  private _shaking = false;
55
87
  private _temp: Container[] = [];
88
+ private _deferred = new Set<number>();
56
89
 
57
90
  constructor(grid: ReelGrid, resolve: SymbolResolver, cfg: MotionConfig, win?: WinConfig) {
58
91
  this._grid = grid;
@@ -102,6 +135,8 @@ export class SpinEngine {
102
135
  const cols = this._grid.cols;
103
136
  const order = (reel: number) => (this._cfg.stopOrder === 'rtl' ? cols - 1 - reel : reel);
104
137
  const anticipate = new Set(opts?.anticipateReels ?? []);
138
+ const defer = new Set(opts?.deferReveal ?? []);
139
+ const holds: number[] = [];
105
140
  const out: ReelStopPlan[] = [];
106
141
  for (let reel = 0; reel < cols; reel++) {
107
142
  const idx = order(reel);
@@ -116,7 +151,10 @@ export class SpinEngine {
116
151
  else stopTime = (this._cfg.spinUp + this._cfg.hold + idx * this._cfg.stopStagger) * f;
117
152
 
118
153
  const isAnticipated = anticipate.has(reel);
119
- if (isAnticipated) stopTime += (opts?.anticipateHoldMs ?? 0) * f;
154
+ const hold = isAnticipated ? perReelValue(opts?.anticipateHoldMs, reel, 0) * f : 0;
155
+ stopTime += hold;
156
+ holds[reel] = hold;
157
+ const speed = isAnticipated ? perReelValue(opts?.anticipateSlowdown, reel, 1) : 1;
120
158
 
121
159
  out.push({
122
160
  reel,
@@ -124,16 +162,68 @@ export class SpinEngine {
124
162
  landing: data.targetGrid[reel] ?? [],
125
163
  settle: { amp: this._cfg.settle.amp, ms: this._cfg.settle.ms * f },
126
164
  anticipated: isAnticipated,
165
+ slowdown: Math.max(1, 1 / (speed || 1)),
166
+ deferred: defer.has(reel),
127
167
  });
128
168
  }
169
+ if (this._cfg.style === 'cascade-drop') this._planDrop(out, holds, f, order);
129
170
  return out;
130
171
  }
131
172
 
173
+ /**
174
+ * `cascade-drop` lays its reels out on a different clock from the tape styles: a reel is a
175
+ * sequence of per-cell arrivals, not one deceleration. Overwrite `stopTime` with the moment the
176
+ * reel has fully landed and fill in `cellStopTimes`, so `plan()` stays the single source of truth
177
+ * for WHEN anything happens — `_runDrop` below only executes these numbers.
178
+ */
179
+ private _planDrop(
180
+ out: ReelStopPlan[],
181
+ holds: number[],
182
+ f: number,
183
+ order: (reel: number) => number,
184
+ ): void {
185
+ const bottomUp = this._cfg.dropOrder === 'bottom-up';
186
+ // walk the reels in STOP order, so `stopOrder: 'rtl'` reverses the drop as well
187
+ const byPosition: number[] = [];
188
+ for (let reel = 0; reel < out.length; reel++) byPosition[order(reel)] = reel;
189
+ // the position from which reels stop overlapping and start queueing behind each other
190
+ const chainFrom =
191
+ this._cfg.dropSequence === 'chained'
192
+ ? 0
193
+ : this._cfg.dropSequence === 'chained-when-anticipated'
194
+ ? byPosition.findIndex((reel) => out[reel].anticipated)
195
+ : -1;
196
+
197
+ let previousEnd = 0;
198
+ for (let position = 0; position < byPosition.length; position++) {
199
+ const p = out[byPosition[position]];
200
+ const rows = this._grid.rowsOf(p.reel);
201
+ const scale = f * p.slowdown;
202
+ const gap = this._cfg.stopStagger * this._cfg.reelStaggerFactor * scale;
203
+ const fall = this._cfg.spinUp * this._cfg.dropFallFactor * scale;
204
+ // by formula while reels may overlap; queued behind the previous reel once the chain starts
205
+ const chained = chainFrom >= 0 && position > 0 && position >= chainFrom;
206
+ const start = (chained ? previousEnd + gap : position * gap) + (holds[p.reel] ?? 0);
207
+
208
+ const cells: number[] = [];
209
+ for (let i = 0; i < rows; i++) {
210
+ const row = bottomUp ? rows - 1 - i : i;
211
+ cells[row] = start + i * this._cfg.cellStagger * scale + fall;
212
+ }
213
+ p.cellStopTimes = cells;
214
+ p.stopTime = cells.length ? Math.max(...cells) : start;
215
+ previousEnd = p.stopTime;
216
+ }
217
+ }
218
+
132
219
  /** Execute the spin for every reel concurrently. */
133
220
  async run(data: SpinData, opts?: SpinRunOpts): Promise<void> {
134
221
  this._killed = false;
135
222
  this._temp = [];
136
223
  const plan = this.plan(data, opts);
224
+ // remembered for skip(): a deferred reel's cells belong to the caller, not to us
225
+ this._deferred = new Set(plan.filter((p) => p.deferred).map((p) => p.reel));
226
+ opts?.onPlan?.(plan);
137
227
  const f = this.scale(opts);
138
228
  await Promise.all(plan.map((p) => this._runReel(p, data, opts, f)));
139
229
  this._cleanupTemp();
@@ -157,9 +247,9 @@ export class SpinEngine {
157
247
  }
158
248
  }
159
249
 
160
- /** Anticipation time-stretch factor for a reel (>=1, longer = slower). */
161
- private slowOf(p: ReelStopPlan, opts?: SpinRunOpts): number {
162
- return p.anticipated ? Math.max(1, 1 / (opts?.anticipateSlowdown ?? 1)) : 1;
250
+ /** Anticipation time-stretch factor for a reel (>=1, longer = slower). Resolved in `plan()`. */
251
+ private slowOf(p: ReelStopPlan): number {
252
+ return p.slowdown;
163
253
  }
164
254
 
165
255
  // ── swap: cycle symbols quickly in the real cells, then land ──────────────
@@ -176,7 +266,7 @@ export class SpinEngine {
176
266
  const blur = this._applyBlur(cells, true);
177
267
  const tickMs = 1000 / 30;
178
268
  // anticipation makes the reel spin longer before it lands
179
- const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p, opts)) / tickMs));
269
+ const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p)) / tickMs));
180
270
  for (let i = 0; i < ticks; i++) {
181
271
  if (this._killed) break;
182
272
  for (let r = 0; r < cells.length; r++)
@@ -184,7 +274,24 @@ export class SpinEngine {
184
274
  await Tween.delay(tickMs);
185
275
  }
186
276
  blur?.();
187
- for (let r = 0; r < cells.length; r++) cells[r].setData(p.landing[r] ?? { symbol: null });
277
+ if (p.deferred) {
278
+ // the caller owns this reel's result — go dark and unseated instead of handing it back.
279
+ // NB 'swap' cycles the tape THROUGH the real cells, so a deferred reel only truly withholds
280
+ // its result when `SpinData.strip` supplies filler for it; otherwise the tape is built from
281
+ // the landing symbols. 'strip' and 'cascade-drop' have no such caveat.
282
+ cells.forEach((c) => {
283
+ c.setData({ symbol: null });
284
+ c.visible = false;
285
+ });
286
+ opts?.onReelStop?.(p.reel, p);
287
+ return;
288
+ }
289
+ for (let r = 0; r < cells.length; r++) {
290
+ const cell = p.landing[r] ?? { symbol: null };
291
+ cells[r].setData(cell);
292
+ opts?.onCellSeated?.(p.reel, r, cell);
293
+ }
294
+ opts?.onReelStop?.(p.reel, p);
188
295
  await this._settle(p.reel, p.settle, f);
189
296
  await this._frameShake(p.landing);
190
297
  }
@@ -228,7 +335,7 @@ export class SpinEngine {
228
335
  this._temp.push(tape);
229
336
  const clearBlur = this._applyBlur([tape], true);
230
337
 
231
- const slow = this.slowOf(p, opts);
338
+ const slow = this.slowOf(p);
232
339
  // overshoot/settle honour the configured settle (amp in px, easing)
233
340
  const overshoot = p.settle.amp || step * 0.18;
234
341
  await Tween.to(
@@ -248,11 +355,20 @@ export class SpinEngine {
248
355
  Math.max(120, p.settle.ms),
249
356
  easingByName(this._cfg.settle.easing),
250
357
  );
251
- // hand the result back to the real cells
252
- for (let r = 0; r < rows; r++) realCells[r].setData(p.landing[r] ?? { symbol: null });
253
- realCells.forEach((c) => (c.visible = true));
358
+ // hand the result back to the real cells — unless the caller deferred the reveal, in which
359
+ // case the tape simply goes away and the reel is left dark, unseated and owned by the caller
360
+ if (!p.deferred) {
361
+ for (let r = 0; r < rows; r++) {
362
+ const cell = p.landing[r] ?? { symbol: null };
363
+ realCells[r].setData(cell);
364
+ opts?.onCellSeated?.(p.reel, r, cell);
365
+ }
366
+ realCells.forEach((c) => (c.visible = true));
367
+ }
254
368
  tape.destroy();
255
369
  this._temp = this._temp.filter((t) => t !== tape);
370
+ opts?.onReelStop?.(p.reel, p);
371
+ if (p.deferred) return;
256
372
  // squash the real cells on impact when enabled
257
373
  if (this._cfg.squash.enabled) await Promise.all(realCells.map((c) => this._squashCell(c, f)));
258
374
  await this._frameShake(p.landing);
@@ -261,27 +377,35 @@ export class SpinEngine {
261
377
  // ── cascade-drop: symbols drop in from above with stagger + bounce + squash ─
262
378
  private async _runDrop(p: ReelStopPlan, opts: SpinRunOpts | undefined, f: number): Promise<void> {
263
379
  const rows = this._grid.rowsOf(p.reel);
380
+ if (p.deferred) {
381
+ // nothing to drop — the caller brings this reel in itself
382
+ for (let r = 0; r < rows; r++) this._grid.getCell(p.reel, r).visible = false;
383
+ opts?.onReelStop?.(p.reel, p);
384
+ return;
385
+ }
264
386
  const step = this._grid.cellPosition(p.reel, 1).y - this._grid.cellPosition(p.reel, 0).y;
265
- const slow = this.slowOf(p, opts); // anticipation drops the reel in more slowly
387
+ const slow = this.slowOf(p); // anticipation drops the reel in more slowly
388
+ const fall = this._cfg.spinUp * this._cfg.dropFallFactor * f * slow;
389
+ const schedule = p.cellStopTimes ?? [];
266
390
  await Promise.all(
267
391
  Array.from({ length: rows }, (_, r) => r).map(async (r) => {
268
392
  if (this._killed) return;
269
393
  const cell = this._grid.getCell(p.reel, r);
270
394
  const to = this._grid.cellPosition(p.reel, r);
271
- cell.setData(p.landing[r] ?? { symbol: null });
395
+ const data = p.landing[r] ?? { symbol: null };
396
+ cell.setData(data);
272
397
  cell.position.set(to.x, to.y - step * (rows + 1));
273
398
  cell.alpha = 1;
274
- const delay = (p.reel * this._cfg.stopStagger * 0.4 + r * 24) * f * slow;
399
+ // the plan says WHEN this cell seats; back off the fall to get when it must let go
400
+ const delay = Math.max(0, (schedule[r] ?? fall) - fall);
275
401
  if (delay) await Tween.delay(delay);
276
- await Tween.to(
277
- cell,
278
- { 'position.y': to.y },
279
- this._cfg.spinUp * 0.6 * f * slow,
280
- easingByName(this._cfg.settle.easing),
281
- );
402
+ await Tween.to(cell, { 'position.y': to.y }, fall, easingByName(this._cfg.settle.easing));
403
+ // the impact frame — fired before the squash so a game can sync its own hit feedback
404
+ opts?.onCellSeated?.(p.reel, r, data);
282
405
  await this._squashCell(cell, f);
283
406
  }),
284
407
  );
408
+ opts?.onReelStop?.(p.reel, p);
285
409
  await this._frameShake(p.landing);
286
410
  }
287
411
 
@@ -369,12 +493,16 @@ export class SpinEngine {
369
493
  Tween.killTweensOf(this._grid);
370
494
  this._grid.x = 0; // undo any in-flight frame shake
371
495
  for (let c = 0; c < this._grid.cols; c++) {
496
+ // a deferred reel's visibility belongs to the caller — a slam stop must not reveal it
497
+ const deferred = this._deferred.has(c);
372
498
  for (let r = 0; r < this._grid.rowsOf(c); r++) {
373
499
  const cell = this._grid.getCell(c, r);
374
500
  if (cell.destroyed) continue;
375
501
  Tween.killTweensOf(cell);
376
- cell.visible = true;
377
- cell.alpha = 1;
502
+ if (!deferred) {
503
+ cell.visible = true;
504
+ cell.alpha = 1;
505
+ }
378
506
  cell.filters = [];
379
507
  cell.scale.set(1);
380
508
  }