@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.
@@ -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. */
@@ -201,6 +206,9 @@ export function toBonusOptions(model: GameModel, t: (s: string) => string = (s)
201
206
  // Hero art (SSOT) → card thumbnail. Passed verbatim: the shell loads it as-is, so no URL
202
207
  // resolver is needed (matches a static buyBonus `thumbnail`). Keeps i18n/price/accent/social.
203
208
  ...(action.art ? { thumbnail: action.art } : {}),
209
+ // Variant grouping (SSOT): actions sharing the key share one card in the pixi shell, flipped
210
+ // through with arrows. Each stays its own action, so the id on activate/buy is unambiguous.
211
+ ...(action.groupedBy ? { groupedBy: action.groupedBy } : {}),
204
212
  });
205
213
  }
206
214
  return out;
@@ -489,6 +497,10 @@ export function buildShellConfig(
489
497
  ...(opts.features ?? {}),
490
498
  } as ShellFeatures;
491
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;
492
504
  return {
493
505
  language: runtime.language ?? 'en',
494
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
  // ─────────────────────────────────────────────────────────────────────────────
@@ -127,12 +141,28 @@ export interface MotionConfig {
127
141
  slamStop: boolean;
128
142
  /** Symbols visible on a reel tape while spinning (swap/strip). */
129
143
  symbolsPerReel: number;
144
+ /** `cascade-drop`: ms between consecutive cells of ONE reel (top→bottom). Default 24. */
145
+ cellStagger: number;
146
+ /** `cascade-drop`: multiplier on `stopStagger` for the per-reel offset. Default 0.4. */
147
+ reelStaggerFactor: number;
148
+ /** `cascade-drop`: fall duration as a fraction of `spinUp`. Default 0.6. */
149
+ dropFallFactor: number;
130
150
  }
131
151
 
132
152
  // ─────────────────────────────────────────────────────────────────────────────
133
153
  // Anticipation
134
154
  // ─────────────────────────────────────────────────────────────────────────────
135
155
 
156
+ /** What a game-supplied `AnticipationConfig.decide` may return instead of a bare reel list. */
157
+ export interface AnticipationOverride {
158
+ /** Reels to anticipate, in the order the progression should ramp. Empty = no anticipation. */
159
+ reels: number[];
160
+ /** Speed factor (lower = slower). Scalar, or per-reel indexed by reel index. */
161
+ slowdown?: PerReel<number>;
162
+ /** Extra hold before landing. Scalar, or per-reel indexed by reel index. */
163
+ holdMs?: PerReel<number>;
164
+ }
165
+
136
166
  export interface AnticipationConfig {
137
167
  enabled: boolean;
138
168
  /** Symbols that count toward the anticipation threshold (scatter/bonus). */
@@ -145,6 +175,21 @@ export interface AnticipationConfig {
145
175
  slowdownFactor: number;
146
176
  /** Extra hold (ms) before the final anticipation reel lands (300–500 typical). */
147
177
  holdMs: number;
178
+ /**
179
+ * Game-supplied decision, REPLACING the built-in `triggerSymbols`/`threshold` counting.
180
+ * Return the reels to anticipate (or an `AnticipationOverride`); `null` / `[]` = no anticipation.
181
+ * Use this when the trigger is not expressible as "N of symbol X landed" — e.g. "the round is
182
+ * still alive on every reel so far", or "reel 3 missed its symbol, so let 4 and 5 stop normally".
183
+ */
184
+ decide?: ((targetGrid: CellData[][]) => number[] | AnticipationOverride | null) | null;
185
+ /**
186
+ * Ramp the slowdown across successive anticipated reels: reel #i of the decision gets
187
+ * `slowdownFactor * progressiveSlowdown ** i`. 1 = flat (default); < 1 = each reel slower
188
+ * than the last.
189
+ */
190
+ progressiveSlowdown: number;
191
+ /** Extra hold (ms) added per successive anticipated reel: reel #i gets `holdMs + i * this`. */
192
+ progressiveHoldMs: number;
148
193
  /** Optional grid zoom while anticipating (magnum-opus uses 1.3×). */
149
194
  zoom: { enabled: boolean; scale: number; ms: number };
150
195
  }
@@ -419,6 +464,9 @@ export const DEFAULT_REEL_CONFIG: ReelSystemConfig = {
419
464
  intensity: 'full',
420
465
  slamStop: true,
421
466
  symbolsPerReel: 6,
467
+ cellStagger: 24,
468
+ reelStaggerFactor: 0.4,
469
+ dropFallFactor: 0.6,
422
470
  },
423
471
  anticipation: {
424
472
  enabled: false,
@@ -427,6 +475,9 @@ export const DEFAULT_REEL_CONFIG: ReelSystemConfig = {
427
475
  reels: 'trailing',
428
476
  slowdownFactor: 0.3,
429
477
  holdMs: 400,
478
+ decide: null,
479
+ progressiveSlowdown: 1,
480
+ progressiveHoldMs: 0,
430
481
  zoom: { enabled: false, scale: 1.15, ms: 600 },
431
482
  },
432
483
  cascade: {
@@ -555,10 +606,26 @@ export function resolveReelConfig(partial?: DeepPartial<ReelSystemConfig>): Reel
555
606
  return mergeReelConfig(DEFAULT_REEL_CONFIG, partial);
556
607
  }
557
608
 
609
+ /** True only for `{}`-shaped objects — a class instance or a Date is NOT one. */
610
+ function isCloneableRecord(v: unknown): v is Record<string, unknown> {
611
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) return false;
612
+ const proto = Object.getPrototypeOf(v) as object | null;
613
+ return proto === Object.prototype || proto === null;
614
+ }
615
+
616
+ /**
617
+ * Deep-clone a config. Hand-rolled rather than `structuredClone` because a config may carry
618
+ * functions (`anticipation.decide`), which `structuredClone` refuses to copy. Functions and
619
+ * anything that is not a plain object/array pass through by reference.
620
+ */
558
621
  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;
622
+ if (Array.isArray(v)) return v.map((item) => structuredCloneSafe(item)) as unknown as T;
623
+ if (isCloneableRecord(v)) {
624
+ const out: Record<string, unknown> = {};
625
+ for (const [k, val] of Object.entries(v)) out[k] = structuredCloneSafe(val);
626
+ return out as T;
627
+ }
628
+ return v;
562
629
  }
563
630
 
564
631
  /** Effective per-reel row counts (resolves Megaways `rowsPerReel`, else uniform `rows`). */
@@ -134,6 +134,9 @@ 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 },
137
140
  ],
138
141
  },
139
142
  {
@@ -144,6 +147,8 @@ export const REEL_FIELD_SCHEMA: Section[] = [
144
147
  { kind: 'range', path: 'anticipation.threshold', label: 'Threshold (N−1)', min: 1, max: 6, step: 1 },
145
148
  { kind: 'range', path: 'anticipation.slowdownFactor', label: 'Slowdown', min: 0.1, max: 1, step: 0.05 },
146
149
  { kind: 'range', path: 'anticipation.holdMs', label: 'Hold (ms)', min: 0, max: 1200, step: 50 },
150
+ { kind: 'range', path: 'anticipation.progressiveSlowdown', label: 'Slowdown ramp ×/reel', min: 0.3, max: 1, step: 0.05 },
151
+ { kind: 'range', path: 'anticipation.progressiveHoldMs', label: 'Hold ramp (ms/reel)', min: 0, max: 600, step: 25 },
147
152
  { kind: 'toggle', path: 'anticipation.zoom.enabled', label: 'Reel zoom' },
148
153
  { kind: 'range', path: 'anticipation.zoom.scale', label: 'Zoom scale', min: 1, max: 1.6, step: 0.05 },
149
154
  { 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,
@@ -39,6 +40,8 @@ export type {
39
40
  SquashConfig,
40
41
  BlurConfig,
41
42
  AnticipationConfig,
43
+ AnticipationOverride,
44
+ PerReel,
42
45
  CascadeConfig,
43
46
  WinConfig,
44
47
  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,10 @@ 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;
46
71
  }
47
72
 
48
73
  export class SpinEngine {
@@ -53,6 +78,7 @@ export class SpinEngine {
53
78
  private _killed = false;
54
79
  private _shaking = false;
55
80
  private _temp: Container[] = [];
81
+ private _deferred = new Set<number>();
56
82
 
57
83
  constructor(grid: ReelGrid, resolve: SymbolResolver, cfg: MotionConfig, win?: WinConfig) {
58
84
  this._grid = grid;
@@ -102,6 +128,7 @@ export class SpinEngine {
102
128
  const cols = this._grid.cols;
103
129
  const order = (reel: number) => (this._cfg.stopOrder === 'rtl' ? cols - 1 - reel : reel);
104
130
  const anticipate = new Set(opts?.anticipateReels ?? []);
131
+ const defer = new Set(opts?.deferReveal ?? []);
105
132
  const out: ReelStopPlan[] = [];
106
133
  for (let reel = 0; reel < cols; reel++) {
107
134
  const idx = order(reel);
@@ -116,7 +143,8 @@ export class SpinEngine {
116
143
  else stopTime = (this._cfg.spinUp + this._cfg.hold + idx * this._cfg.stopStagger) * f;
117
144
 
118
145
  const isAnticipated = anticipate.has(reel);
119
- if (isAnticipated) stopTime += (opts?.anticipateHoldMs ?? 0) * f;
146
+ if (isAnticipated) stopTime += perReelValue(opts?.anticipateHoldMs, reel, 0) * f;
147
+ const speed = isAnticipated ? perReelValue(opts?.anticipateSlowdown, reel, 1) : 1;
120
148
 
121
149
  out.push({
122
150
  reel,
@@ -124,6 +152,8 @@ export class SpinEngine {
124
152
  landing: data.targetGrid[reel] ?? [],
125
153
  settle: { amp: this._cfg.settle.amp, ms: this._cfg.settle.ms * f },
126
154
  anticipated: isAnticipated,
155
+ slowdown: Math.max(1, 1 / (speed || 1)),
156
+ deferred: defer.has(reel),
127
157
  });
128
158
  }
129
159
  return out;
@@ -134,6 +164,9 @@ export class SpinEngine {
134
164
  this._killed = false;
135
165
  this._temp = [];
136
166
  const plan = this.plan(data, opts);
167
+ // remembered for skip(): a deferred reel's cells belong to the caller, not to us
168
+ this._deferred = new Set(plan.filter((p) => p.deferred).map((p) => p.reel));
169
+ opts?.onPlan?.(plan);
137
170
  const f = this.scale(opts);
138
171
  await Promise.all(plan.map((p) => this._runReel(p, data, opts, f)));
139
172
  this._cleanupTemp();
@@ -157,9 +190,9 @@ export class SpinEngine {
157
190
  }
158
191
  }
159
192
 
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;
193
+ /** Anticipation time-stretch factor for a reel (>=1, longer = slower). Resolved in `plan()`. */
194
+ private slowOf(p: ReelStopPlan): number {
195
+ return p.slowdown;
163
196
  }
164
197
 
165
198
  // ── swap: cycle symbols quickly in the real cells, then land ──────────────
@@ -176,7 +209,7 @@ export class SpinEngine {
176
209
  const blur = this._applyBlur(cells, true);
177
210
  const tickMs = 1000 / 30;
178
211
  // anticipation makes the reel spin longer before it lands
179
- const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p, opts)) / tickMs));
212
+ const ticks = Math.max(6, Math.floor((p.stopTime * this.slowOf(p)) / tickMs));
180
213
  for (let i = 0; i < ticks; i++) {
181
214
  if (this._killed) break;
182
215
  for (let r = 0; r < cells.length; r++)
@@ -184,7 +217,24 @@ export class SpinEngine {
184
217
  await Tween.delay(tickMs);
185
218
  }
186
219
  blur?.();
187
- for (let r = 0; r < cells.length; r++) cells[r].setData(p.landing[r] ?? { symbol: null });
220
+ if (p.deferred) {
221
+ // the caller owns this reel's result — go dark and unseated instead of handing it back.
222
+ // NB 'swap' cycles the tape THROUGH the real cells, so a deferred reel only truly withholds
223
+ // its result when `SpinData.strip` supplies filler for it; otherwise the tape is built from
224
+ // the landing symbols. 'strip' and 'cascade-drop' have no such caveat.
225
+ cells.forEach((c) => {
226
+ c.setData({ symbol: null });
227
+ c.visible = false;
228
+ });
229
+ opts?.onReelStop?.(p.reel, p);
230
+ return;
231
+ }
232
+ for (let r = 0; r < cells.length; r++) {
233
+ const cell = p.landing[r] ?? { symbol: null };
234
+ cells[r].setData(cell);
235
+ opts?.onCellSeated?.(p.reel, r, cell);
236
+ }
237
+ opts?.onReelStop?.(p.reel, p);
188
238
  await this._settle(p.reel, p.settle, f);
189
239
  await this._frameShake(p.landing);
190
240
  }
@@ -228,7 +278,7 @@ export class SpinEngine {
228
278
  this._temp.push(tape);
229
279
  const clearBlur = this._applyBlur([tape], true);
230
280
 
231
- const slow = this.slowOf(p, opts);
281
+ const slow = this.slowOf(p);
232
282
  // overshoot/settle honour the configured settle (amp in px, easing)
233
283
  const overshoot = p.settle.amp || step * 0.18;
234
284
  await Tween.to(
@@ -248,11 +298,20 @@ export class SpinEngine {
248
298
  Math.max(120, p.settle.ms),
249
299
  easingByName(this._cfg.settle.easing),
250
300
  );
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));
301
+ // hand the result back to the real cells — unless the caller deferred the reveal, in which
302
+ // case the tape simply goes away and the reel is left dark, unseated and owned by the caller
303
+ if (!p.deferred) {
304
+ for (let r = 0; r < rows; r++) {
305
+ const cell = p.landing[r] ?? { symbol: null };
306
+ realCells[r].setData(cell);
307
+ opts?.onCellSeated?.(p.reel, r, cell);
308
+ }
309
+ realCells.forEach((c) => (c.visible = true));
310
+ }
254
311
  tape.destroy();
255
312
  this._temp = this._temp.filter((t) => t !== tape);
313
+ opts?.onReelStop?.(p.reel, p);
314
+ if (p.deferred) return;
256
315
  // squash the real cells on impact when enabled
257
316
  if (this._cfg.squash.enabled) await Promise.all(realCells.map((c) => this._squashCell(c, f)));
258
317
  await this._frameShake(p.landing);
@@ -261,27 +320,41 @@ export class SpinEngine {
261
320
  // ── cascade-drop: symbols drop in from above with stagger + bounce + squash ─
262
321
  private async _runDrop(p: ReelStopPlan, opts: SpinRunOpts | undefined, f: number): Promise<void> {
263
322
  const rows = this._grid.rowsOf(p.reel);
323
+ if (p.deferred) {
324
+ // nothing to drop — the caller brings this reel in itself
325
+ for (let r = 0; r < rows; r++) this._grid.getCell(p.reel, r).visible = false;
326
+ opts?.onReelStop?.(p.reel, p);
327
+ return;
328
+ }
264
329
  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
330
+ const slow = this.slowOf(p); // anticipation drops the reel in more slowly
266
331
  await Promise.all(
267
332
  Array.from({ length: rows }, (_, r) => r).map(async (r) => {
268
333
  if (this._killed) return;
269
334
  const cell = this._grid.getCell(p.reel, r);
270
335
  const to = this._grid.cellPosition(p.reel, r);
271
- cell.setData(p.landing[r] ?? { symbol: null });
336
+ const data = p.landing[r] ?? { symbol: null };
337
+ cell.setData(data);
272
338
  cell.position.set(to.x, to.y - step * (rows + 1));
273
339
  cell.alpha = 1;
274
- const delay = (p.reel * this._cfg.stopStagger * 0.4 + r * 24) * f * slow;
340
+ const delay =
341
+ (p.reel * this._cfg.stopStagger * this._cfg.reelStaggerFactor +
342
+ r * this._cfg.cellStagger) *
343
+ f *
344
+ slow;
275
345
  if (delay) await Tween.delay(delay);
276
346
  await Tween.to(
277
347
  cell,
278
348
  { 'position.y': to.y },
279
- this._cfg.spinUp * 0.6 * f * slow,
349
+ this._cfg.spinUp * this._cfg.dropFallFactor * f * slow,
280
350
  easingByName(this._cfg.settle.easing),
281
351
  );
352
+ // the impact frame — fired before the squash so a game can sync its own hit feedback
353
+ opts?.onCellSeated?.(p.reel, r, data);
282
354
  await this._squashCell(cell, f);
283
355
  }),
284
356
  );
357
+ opts?.onReelStop?.(p.reel, p);
285
358
  await this._frameShake(p.landing);
286
359
  }
287
360
 
@@ -369,12 +442,16 @@ export class SpinEngine {
369
442
  Tween.killTweensOf(this._grid);
370
443
  this._grid.x = 0; // undo any in-flight frame shake
371
444
  for (let c = 0; c < this._grid.cols; c++) {
445
+ // a deferred reel's visibility belongs to the caller — a slam stop must not reveal it
446
+ const deferred = this._deferred.has(c);
372
447
  for (let r = 0; r < this._grid.rowsOf(c); r++) {
373
448
  const cell = this._grid.getCell(c, r);
374
449
  if (cell.destroyed) continue;
375
450
  Tween.killTweensOf(cell);
376
- cell.visible = true;
377
- cell.alpha = 1;
451
+ if (!deferred) {
452
+ cell.visible = true;
453
+ cell.alpha = 1;
454
+ }
378
455
  cell.filters = [];
379
456
  cell.scale.set(1);
380
457
  }
@@ -9,8 +9,16 @@ import { Tween } from '../../animation';
9
9
  import { ReelGrid } from '../grid/ReelGrid';
10
10
  import type { CellData } from '../grid/SymbolCell';
11
11
  import type { SymbolResolver } from '../grid/SymbolView';
12
- import { SpinEngine, type SpinData, type SpinRunOpts } from '../motion/SpinEngine';
13
- import { AnticipationController } from '../motion/AnticipationController';
12
+ import {
13
+ SpinEngine,
14
+ type ReelStopPlan,
15
+ type SpinData,
16
+ type SpinRunOpts,
17
+ } from '../motion/SpinEngine';
18
+ import {
19
+ AnticipationController,
20
+ type AnticipationDecision,
21
+ } from '../motion/AnticipationController';
14
22
  import { TumbleController, type TumbleStep } from '../cascade/TumbleController';
15
23
  import { ReelStepController, type ReelStepData } from '../cascade/ReelStepController';
16
24
  import { FEATURES, FEATURE_LIST, type FeatureContext, type ReelFeature } from '../features';
@@ -65,6 +73,14 @@ export interface ReelSystem {
65
73
  /** Replace the whole config. */
66
74
  setConfig(config: ReelSystemConfig): void;
67
75
  spin(target: CellData[][], opts?: SpinRunOpts): Promise<void>;
76
+ /**
77
+ * The schedule `spin(target, opts)` WOULD run, without running it — same anticipation decision,
78
+ * same numbers. Schedule landing sounds / camera moves against this instead of re-deriving the
79
+ * engine's formula. (`spin`'s `onPlan` hands you the same array once the spin is under way.)
80
+ */
81
+ planSpin(target: CellData[][], opts?: SpinRunOpts): ReelStopPlan[];
82
+ /** The anticipation decision `spin(target, opts)` would use (run options override the config). */
83
+ anticipationFor(target: CellData[][], opts?: SpinRunOpts): AnticipationDecision;
68
84
  /** Run a cascade chain. With `freeSpins` + `cascade.multiplier.persistInFreeSpins`, the multiplier
69
85
  * carries over instead of resetting. Generic in the step type, so `onStep` hands back the game's
70
86
  * own step (with its per-step win) rather than the bare TumbleStep. */
@@ -191,6 +207,36 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
191
207
  return { grid, resolve, cfg: config, fx, board, freeSpins, log };
192
208
  }
193
209
 
210
+ /**
211
+ * Which reels get the anticipation treatment for this spin. An explicit `anticipateReels` on the
212
+ * run options WINS over the configured decision — passing it is how a game drives anticipation
213
+ * from its own logic. Omit it and the configured `AnticipationController` decides.
214
+ */
215
+ function resolveAnticipation(target: CellData[][], runOpts?: SpinRunOpts): AnticipationDecision {
216
+ const explicit = runOpts?.anticipateReels;
217
+ if (!explicit) return anticipation.decide(target);
218
+ if (!explicit.length) return { active: false, reels: [], slowdown: 1, holdMs: 0 };
219
+ return {
220
+ active: true,
221
+ reels: explicit.slice(),
222
+ slowdown: runOpts?.anticipateSlowdown ?? config.anticipation.slowdownFactor,
223
+ holdMs: runOpts?.anticipateHoldMs ?? config.anticipation.holdMs,
224
+ };
225
+ }
226
+
227
+ /** Fold a resolved decision back onto the caller's run options (everything else passes through). */
228
+ function mergeAnticipation(
229
+ runOpts: SpinRunOpts | undefined,
230
+ decision: AnticipationDecision,
231
+ ): SpinRunOpts {
232
+ return {
233
+ ...runOpts,
234
+ anticipateReels: decision.active ? decision.reels : undefined,
235
+ anticipateSlowdown: decision.slowdown,
236
+ anticipateHoldMs: decision.holdMs,
237
+ };
238
+ }
239
+
194
240
  buildGrid();
195
241
 
196
242
  const api: ReelSystem = {
@@ -237,20 +283,24 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
237
283
  api.setConfig(mergeReelConfig(config, partial));
238
284
  },
239
285
 
286
+ anticipationFor(target, runOpts) {
287
+ return resolveAnticipation(target, runOpts);
288
+ },
289
+
290
+ planSpin(target, runOpts) {
291
+ const decision = resolveAnticipation(target, runOpts);
292
+ return spin.plan({ targetGrid: target }, mergeAnticipation(runOpts, decision));
293
+ },
294
+
240
295
  async spin(target, runOpts) {
241
296
  const data: SpinData = { targetGrid: target };
242
- const decision = anticipation.decide(target);
297
+ const decision = resolveAnticipation(target, runOpts);
243
298
  let resetZoom: (() => Promise<void>) | null = null;
244
299
  if (decision.active) {
245
300
  log?.(`Anticipation on reels [${decision.reels.join(', ')}]`);
246
301
  resetZoom = await anticipation.zoomIn(grid);
247
302
  }
248
- await spin.run(data, {
249
- ...runOpts,
250
- anticipateReels: decision.active ? decision.reels : undefined,
251
- anticipateSlowdown: decision.slowdown,
252
- anticipateHoldMs: decision.holdMs,
253
- });
303
+ await spin.run(data, mergeAnticipation(runOpts, decision));
254
304
  if (resetZoom) await resetZoom();
255
305
  board = target;
256
306
  },