@energy8platform/game-engine 0.39.0 → 0.41.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/game-engine",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "description": "Universal casino game engine built on PixiJS v8 and @energy8platform/game-sdk",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs.js",
@@ -92,6 +92,10 @@ export type MotionStyle =
92
92
  | 'cascade-drop'; // symbols drop in from above (tumble-style boards)
93
93
 
94
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';
95
99
  export type StopOrder = 'ltr' | 'rtl';
96
100
  export type Intensity = 'full' | 'reduced' | 'minimal';
97
101
 
@@ -147,6 +151,25 @@ export interface MotionConfig {
147
151
  reelStaggerFactor: number;
148
152
  /** `cascade-drop`: fall duration as a fraction of `spinUp`. Default 0.6. */
149
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;
150
173
  }
151
174
 
152
175
  // ─────────────────────────────────────────────────────────────────────────────
@@ -161,6 +184,8 @@ export interface AnticipationOverride {
161
184
  slowdown?: PerReel<number>;
162
185
  /** Extra hold before landing. Scalar, or per-reel indexed by reel index. */
163
186
  holdMs?: PerReel<number>;
187
+ /** Growth of the gap between successive cells within a reel. Scalar, or per-reel. */
188
+ cellStaggerRamp?: PerReel<number>;
164
189
  }
165
190
 
166
191
  export interface AnticipationConfig {
@@ -190,6 +215,16 @@ export interface AnticipationConfig {
190
215
  progressiveSlowdown: number;
191
216
  /** Extra hold (ms) added per successive anticipated reel: reel #i gets `holdMs + i * this`. */
192
217
  progressiveHoldMs: number;
218
+ /**
219
+ * `cascade-drop`: how the gap between SUCCESSIVE CELLS grows inside an anticipated reel. The
220
+ * gap before cell #i is `cellStagger * <reel slowdown> * progressiveCellStagger ** i`, so 1
221
+ * (the default) keeps the reel's cells evenly spaced and > 1 makes each symbol land later than
222
+ * the last — the drip that turns a reel into a countdown.
223
+ *
224
+ * It compounds with `progressiveSlowdown`: that widens the base gap reel by reel, this widens
225
+ * it cell by cell, so a later reel drips both slower AND with a steeper ramp.
226
+ */
227
+ progressiveCellStagger: number;
193
228
  /** Optional grid zoom while anticipating (magnum-opus uses 1.3×). */
194
229
  zoom: { enabled: boolean; scale: number; ms: number };
195
230
  }
@@ -467,6 +502,8 @@ export const DEFAULT_REEL_CONFIG: ReelSystemConfig = {
467
502
  cellStagger: 24,
468
503
  reelStaggerFactor: 0.4,
469
504
  dropFallFactor: 0.6,
505
+ dropOrder: 'top-down',
506
+ dropSequence: 'parallel',
470
507
  },
471
508
  anticipation: {
472
509
  enabled: false,
@@ -478,6 +515,7 @@ export const DEFAULT_REEL_CONFIG: ReelSystemConfig = {
478
515
  decide: null,
479
516
  progressiveSlowdown: 1,
480
517
  progressiveHoldMs: 0,
518
+ progressiveCellStagger: 1,
481
519
  zoom: { enabled: false, scale: 1.15, ms: 600 },
482
520
  },
483
521
  cascade: {
@@ -137,6 +137,8 @@ export const REEL_FIELD_SCHEMA: Section[] = [
137
137
  { kind: 'range', path: 'motion.cellStagger', label: 'Cell stagger (ms)', min: 0, max: 700, step: 5 },
138
138
  { kind: 'range', path: 'motion.reelStaggerFactor', label: 'Reel stagger ×', min: 0, max: 3, step: 0.05 },
139
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'] },
140
142
  ],
141
143
  },
142
144
  {
@@ -149,6 +151,7 @@ export const REEL_FIELD_SCHEMA: Section[] = [
149
151
  { kind: 'range', path: 'anticipation.holdMs', label: 'Hold (ms)', min: 0, max: 1200, step: 50 },
150
152
  { kind: 'range', path: 'anticipation.progressiveSlowdown', label: 'Slowdown ramp ×/reel', min: 0.3, max: 1, step: 0.05 },
151
153
  { kind: 'range', path: 'anticipation.progressiveHoldMs', label: 'Hold ramp (ms/reel)', min: 0, max: 600, step: 25 },
154
+ { kind: 'range', path: 'anticipation.progressiveCellStagger', label: 'Cell ramp ×/cell', min: 1, max: 2.5, step: 0.05 },
152
155
  { kind: 'toggle', path: 'anticipation.zoom.enabled', label: 'Reel zoom' },
153
156
  { kind: 'range', path: 'anticipation.zoom.scale', label: 'Zoom scale', min: 1, max: 1.6, step: 0.05 },
154
157
  { kind: 'range', path: 'anticipation.zoom.ms', label: 'Zoom (ms)', min: 200, max: 1200, step: 50 },
package/src/slot/index.ts CHANGED
@@ -33,6 +33,8 @@ export type {
33
33
  MotionStyle,
34
34
  StopMode,
35
35
  StopOrder,
36
+ DropOrder,
37
+ DropSequence,
36
38
  Intensity,
37
39
  GridConfig,
38
40
  MotionConfig,
@@ -18,10 +18,18 @@ export interface AnticipationDecision {
18
18
  slowdown: PerReel<number>;
19
19
  /** Extra hold before landing. Scalar or per-reel array, same as `slowdown`. */
20
20
  holdMs: PerReel<number>;
21
+ /** `cascade-drop`: growth of the gap between successive cells inside the reel (1 = even). */
22
+ cellStaggerRamp: PerReel<number>;
21
23
  }
22
24
 
23
25
  /** Fresh "nothing to anticipate" decision (fresh, so callers may mutate `reels` freely). */
24
- const none = (): AnticipationDecision => ({ active: false, reels: [], slowdown: 1, holdMs: 0 });
26
+ const none = (): AnticipationDecision => ({
27
+ active: false,
28
+ reels: [],
29
+ slowdown: 1,
30
+ holdMs: 0,
31
+ cellStaggerRamp: 1,
32
+ });
25
33
 
26
34
  export class AnticipationController {
27
35
  private _cfg: AnticipationConfig;
@@ -53,7 +61,7 @@ export class AnticipationController {
53
61
  if (!custom) return none();
54
62
  const o: AnticipationOverride = Array.isArray(custom) ? { reels: custom } : custom;
55
63
  if (!o.reels?.length) return none();
56
- return this.build(o.reels.slice(), o.slowdown, o.holdMs);
64
+ return this.build(o.reels.slice(), o.slowdown, o.holdMs, o.cellStaggerRamp);
57
65
  }
58
66
 
59
67
  if (Array.isArray(this._cfg.reels)) {
@@ -86,10 +94,12 @@ export class AnticipationController {
86
94
  reels: number[],
87
95
  slowdown?: PerReel<number>,
88
96
  holdMs?: PerReel<number>,
97
+ cellStaggerRamp?: PerReel<number>,
89
98
  ): AnticipationDecision {
90
99
  return {
91
100
  active: true,
92
101
  reels,
102
+ cellStaggerRamp: cellStaggerRamp ?? this._cfg.progressiveCellStagger,
93
103
  slowdown:
94
104
  slowdown ??
95
105
  this.ramp(reels, this._cfg.slowdownFactor, (base, i) =>
@@ -38,6 +38,12 @@ export interface SpinRunOpts {
38
38
  anticipateSlowdown?: PerReel<number>;
39
39
  /** Extra hold (ms) for anticipated reels. Scalar, or per-reel indexed by reel. */
40
40
  anticipateHoldMs?: PerReel<number>;
41
+ /**
42
+ * `cascade-drop`: growth of the gap between successive cells inside an anticipated reel — the
43
+ * gap before cell #i is `cellStagger * <reel slowdown> * ramp ** i`. 1 keeps the reel evenly
44
+ * spaced. Scalar, or per-reel indexed by reel.
45
+ */
46
+ anticipateCellStaggerRamp?: PerReel<number>;
41
47
  /**
42
48
  * Reels whose tape runs normally but whose landing is NOT handed back. The engine stops and
43
49
  * disposes of the tape as usual and leaves the real cells hidden and unseated; the caller owns
@@ -68,6 +74,13 @@ export interface ReelStopPlan {
68
74
  slowdown: number;
69
75
  /** True when `deferReveal` withheld this reel's landing (see `SpinRunOpts.deferReveal`). */
70
76
  deferred: boolean;
77
+ /**
78
+ * `cascade-drop` only: ms from spin start at which each cell of this reel seats, INDEXED BY ROW.
79
+ * The reel's fill direction lives in these numbers (`motion.dropOrder` decides which row is the
80
+ * smallest), as does its place in the chain (`motion.dropSequence`). `stopTime` is the largest
81
+ * of them — the frame the whole reel has landed.
82
+ */
83
+ cellStopTimes?: number[];
71
84
  }
72
85
 
73
86
  export class SpinEngine {
@@ -129,6 +142,8 @@ export class SpinEngine {
129
142
  const order = (reel: number) => (this._cfg.stopOrder === 'rtl' ? cols - 1 - reel : reel);
130
143
  const anticipate = new Set(opts?.anticipateReels ?? []);
131
144
  const defer = new Set(opts?.deferReveal ?? []);
145
+ const holds: number[] = [];
146
+ const ramps: number[] = [];
132
147
  const out: ReelStopPlan[] = [];
133
148
  for (let reel = 0; reel < cols; reel++) {
134
149
  const idx = order(reel);
@@ -143,7 +158,10 @@ export class SpinEngine {
143
158
  else stopTime = (this._cfg.spinUp + this._cfg.hold + idx * this._cfg.stopStagger) * f;
144
159
 
145
160
  const isAnticipated = anticipate.has(reel);
146
- if (isAnticipated) stopTime += perReelValue(opts?.anticipateHoldMs, reel, 0) * f;
161
+ const hold = isAnticipated ? perReelValue(opts?.anticipateHoldMs, reel, 0) * f : 0;
162
+ stopTime += hold;
163
+ holds[reel] = hold;
164
+ ramps[reel] = isAnticipated ? perReelValue(opts?.anticipateCellStaggerRamp, reel, 1) : 1;
147
165
  const speed = isAnticipated ? perReelValue(opts?.anticipateSlowdown, reel, 1) : 1;
148
166
 
149
167
  out.push({
@@ -156,9 +174,62 @@ export class SpinEngine {
156
174
  deferred: defer.has(reel),
157
175
  });
158
176
  }
177
+ if (this._cfg.style === 'cascade-drop') this._planDrop(out, holds, ramps, f, order);
159
178
  return out;
160
179
  }
161
180
 
181
+ /**
182
+ * `cascade-drop` lays its reels out on a different clock from the tape styles: a reel is a
183
+ * sequence of per-cell arrivals, not one deceleration. Overwrite `stopTime` with the moment the
184
+ * reel has fully landed and fill in `cellStopTimes`, so `plan()` stays the single source of truth
185
+ * for WHEN anything happens — `_runDrop` below only executes these numbers.
186
+ */
187
+ private _planDrop(
188
+ out: ReelStopPlan[],
189
+ holds: number[],
190
+ ramps: number[],
191
+ f: number,
192
+ order: (reel: number) => number,
193
+ ): void {
194
+ const bottomUp = this._cfg.dropOrder === 'bottom-up';
195
+ // walk the reels in STOP order, so `stopOrder: 'rtl'` reverses the drop as well
196
+ const byPosition: number[] = [];
197
+ for (let reel = 0; reel < out.length; reel++) byPosition[order(reel)] = reel;
198
+ // the position from which reels stop overlapping and start queueing behind each other
199
+ const chainFrom =
200
+ this._cfg.dropSequence === 'chained'
201
+ ? 0
202
+ : this._cfg.dropSequence === 'chained-when-anticipated'
203
+ ? byPosition.findIndex((reel) => out[reel].anticipated)
204
+ : -1;
205
+
206
+ let previousEnd = 0;
207
+ for (let position = 0; position < byPosition.length; position++) {
208
+ const p = out[byPosition[position]];
209
+ const rows = this._grid.rowsOf(p.reel);
210
+ const scale = f * p.slowdown;
211
+ const gap = this._cfg.stopStagger * this._cfg.reelStaggerFactor * scale;
212
+ const fall = this._cfg.spinUp * this._cfg.dropFallFactor * scale;
213
+ // by formula while reels may overlap; queued behind the previous reel once the chain starts
214
+ const chained = chainFrom >= 0 && position > 0 && position >= chainFrom;
215
+ const start = (chained ? previousEnd + gap : position * gap) + (holds[p.reel] ?? 0);
216
+
217
+ // The gap before cell #i is `cellStagger * scale * ramp**i` — accumulate rather than
218
+ // multiply, so a ramp of 1 stays exactly the old evenly-spaced schedule.
219
+ const ramp = ramps[p.reel] ?? 1;
220
+ const cells: number[] = [];
221
+ let offset = 0;
222
+ for (let i = 0; i < rows; i++) {
223
+ const row = bottomUp ? rows - 1 - i : i;
224
+ cells[row] = start + offset + fall;
225
+ offset += this._cfg.cellStagger * scale * Math.pow(ramp, i);
226
+ }
227
+ p.cellStopTimes = cells;
228
+ p.stopTime = cells.length ? Math.max(...cells) : start;
229
+ previousEnd = p.stopTime;
230
+ }
231
+ }
232
+
162
233
  /** Execute the spin for every reel concurrently. */
163
234
  async run(data: SpinData, opts?: SpinRunOpts): Promise<void> {
164
235
  this._killed = false;
@@ -328,6 +399,8 @@ export class SpinEngine {
328
399
  }
329
400
  const step = this._grid.cellPosition(p.reel, 1).y - this._grid.cellPosition(p.reel, 0).y;
330
401
  const slow = this.slowOf(p); // anticipation drops the reel in more slowly
402
+ const fall = this._cfg.spinUp * this._cfg.dropFallFactor * f * slow;
403
+ const schedule = p.cellStopTimes ?? [];
331
404
  await Promise.all(
332
405
  Array.from({ length: rows }, (_, r) => r).map(async (r) => {
333
406
  if (this._killed) return;
@@ -337,18 +410,10 @@ export class SpinEngine {
337
410
  cell.setData(data);
338
411
  cell.position.set(to.x, to.y - step * (rows + 1));
339
412
  cell.alpha = 1;
340
- const delay =
341
- (p.reel * this._cfg.stopStagger * this._cfg.reelStaggerFactor +
342
- r * this._cfg.cellStagger) *
343
- f *
344
- slow;
413
+ // the plan says WHEN this cell seats; back off the fall to get when it must let go
414
+ const delay = Math.max(0, (schedule[r] ?? fall) - fall);
345
415
  if (delay) await Tween.delay(delay);
346
- await Tween.to(
347
- cell,
348
- { 'position.y': to.y },
349
- this._cfg.spinUp * this._cfg.dropFallFactor * f * slow,
350
- easingByName(this._cfg.settle.easing),
351
- );
416
+ await Tween.to(cell, { 'position.y': to.y }, fall, easingByName(this._cfg.settle.easing));
352
417
  // the impact frame — fired before the squash so a game can sync its own hit feedback
353
418
  opts?.onCellSeated?.(p.reel, r, data);
354
419
  await this._squashCell(cell, f);
@@ -215,12 +215,15 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
215
215
  function resolveAnticipation(target: CellData[][], runOpts?: SpinRunOpts): AnticipationDecision {
216
216
  const explicit = runOpts?.anticipateReels;
217
217
  if (!explicit) return anticipation.decide(target);
218
- if (!explicit.length) return { active: false, reels: [], slowdown: 1, holdMs: 0 };
218
+ if (!explicit.length)
219
+ return { active: false, reels: [], slowdown: 1, holdMs: 0, cellStaggerRamp: 1 };
219
220
  return {
220
221
  active: true,
221
222
  reels: explicit.slice(),
222
223
  slowdown: runOpts?.anticipateSlowdown ?? config.anticipation.slowdownFactor,
223
224
  holdMs: runOpts?.anticipateHoldMs ?? config.anticipation.holdMs,
225
+ cellStaggerRamp:
226
+ runOpts?.anticipateCellStaggerRamp ?? config.anticipation.progressiveCellStagger,
224
227
  };
225
228
  }
226
229
 
@@ -234,6 +237,7 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
234
237
  anticipateReels: decision.active ? decision.reels : undefined,
235
238
  anticipateSlowdown: decision.slowdown,
236
239
  anticipateHoldMs: decision.holdMs,
240
+ anticipateCellStaggerRamp: decision.cellStaggerRamp,
237
241
  };
238
242
  }
239
243