@energy8platform/game-engine 0.39.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/game-engine",
3
- "version": "0.39.0",
3
+ "version": "0.40.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
  // ─────────────────────────────────────────────────────────────────────────────
@@ -467,6 +490,8 @@ export const DEFAULT_REEL_CONFIG: ReelSystemConfig = {
467
490
  cellStagger: 24,
468
491
  reelStaggerFactor: 0.4,
469
492
  dropFallFactor: 0.6,
493
+ dropOrder: 'top-down',
494
+ dropSequence: 'parallel',
470
495
  },
471
496
  anticipation: {
472
497
  enabled: false,
@@ -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
  {
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,
@@ -68,6 +68,13 @@ export interface ReelStopPlan {
68
68
  slowdown: number;
69
69
  /** True when `deferReveal` withheld this reel's landing (see `SpinRunOpts.deferReveal`). */
70
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[];
71
78
  }
72
79
 
73
80
  export class SpinEngine {
@@ -129,6 +136,7 @@ export class SpinEngine {
129
136
  const order = (reel: number) => (this._cfg.stopOrder === 'rtl' ? cols - 1 - reel : reel);
130
137
  const anticipate = new Set(opts?.anticipateReels ?? []);
131
138
  const defer = new Set(opts?.deferReveal ?? []);
139
+ const holds: number[] = [];
132
140
  const out: ReelStopPlan[] = [];
133
141
  for (let reel = 0; reel < cols; reel++) {
134
142
  const idx = order(reel);
@@ -143,7 +151,9 @@ export class SpinEngine {
143
151
  else stopTime = (this._cfg.spinUp + this._cfg.hold + idx * this._cfg.stopStagger) * f;
144
152
 
145
153
  const isAnticipated = anticipate.has(reel);
146
- if (isAnticipated) stopTime += perReelValue(opts?.anticipateHoldMs, reel, 0) * f;
154
+ const hold = isAnticipated ? perReelValue(opts?.anticipateHoldMs, reel, 0) * f : 0;
155
+ stopTime += hold;
156
+ holds[reel] = hold;
147
157
  const speed = isAnticipated ? perReelValue(opts?.anticipateSlowdown, reel, 1) : 1;
148
158
 
149
159
  out.push({
@@ -156,9 +166,56 @@ export class SpinEngine {
156
166
  deferred: defer.has(reel),
157
167
  });
158
168
  }
169
+ if (this._cfg.style === 'cascade-drop') this._planDrop(out, holds, f, order);
159
170
  return out;
160
171
  }
161
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
+
162
219
  /** Execute the spin for every reel concurrently. */
163
220
  async run(data: SpinData, opts?: SpinRunOpts): Promise<void> {
164
221
  this._killed = false;
@@ -328,6 +385,8 @@ export class SpinEngine {
328
385
  }
329
386
  const step = this._grid.cellPosition(p.reel, 1).y - this._grid.cellPosition(p.reel, 0).y;
330
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 ?? [];
331
390
  await Promise.all(
332
391
  Array.from({ length: rows }, (_, r) => r).map(async (r) => {
333
392
  if (this._killed) return;
@@ -337,18 +396,10 @@ export class SpinEngine {
337
396
  cell.setData(data);
338
397
  cell.position.set(to.x, to.y - step * (rows + 1));
339
398
  cell.alpha = 1;
340
- const delay =
341
- (p.reel * this._cfg.stopStagger * this._cfg.reelStaggerFactor +
342
- r * this._cfg.cellStagger) *
343
- f *
344
- 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);
345
401
  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
- );
402
+ await Tween.to(cell, { 'position.y': to.y }, fall, easingByName(this._cfg.settle.easing));
352
403
  // the impact frame — fired before the squash so a game can sync its own hit feedback
353
404
  opts?.onCellSeated?.(p.reel, r, data);
354
405
  await this._squashCell(cell, f);