@energy8platform/game-engine 0.32.2 → 0.32.4

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.
@@ -0,0 +1,270 @@
1
+ // packages/game-engine/src/slot/cascade/ReelStepController.ts
2
+ //
3
+ // ReelStep™ mechanic. Flow: reels stop → winning lines are paid → each reel scrolls DOWN by N
4
+ // positions (N = winning symbols that played on that reel) → the board is re-evaluated → repeat,
5
+ // until no wins remain. Unlike a cascade/tumble, nothing is removed: the existing symbols ride
6
+ // down and N fresh symbols enter from the top. Reels with N=0 stay put; each reel moves
7
+ // independently by its own N.
8
+ //
9
+ // Presentation only — the caller supplies each step's per-reel shift vector and the post-shift
10
+ // board. Fits classic fixed-line grids (5×3, 5×4, 5×5, …), not ways/cluster.
11
+
12
+ import { Container } from 'pixi.js';
13
+ import { Tween } from '../../animation';
14
+ import { easingByName } from '../anim/easing-map';
15
+ import type { ReelGrid } from '../grid/ReelGrid';
16
+ import { SymbolCell, type CellData } from '../grid/SymbolCell';
17
+ import type { SymbolResolver } from '../grid/SymbolView';
18
+ import {
19
+ DEFAULT_REEL_CONFIG,
20
+ type CascadeConfig,
21
+ type WinConfig,
22
+ } from '../config/ReelSystemConfig';
23
+
24
+ export interface ReelStepData {
25
+ /** Cells that won on the current board — highlighted/paid before the shift. */
26
+ winningCells: { col: number; row: number }[];
27
+ /** How far to scroll each reel down (length = cols). 0 = the reel stays put. */
28
+ shifts: number[];
29
+ /** Board after every reel has scrolled down by shifts[col]. */
30
+ settledGrid: CellData[][];
31
+ }
32
+
33
+ /**
34
+ * PURE: lay out one reel's scroll tape (top→bottom). The `shift` fresh symbols (the top of the
35
+ * settled reel) stack above the reel's current symbols; the tape starts `shift` cells high so the
36
+ * current symbols fill the window, then slides down by `shift` to reveal the fresh ones. `shift` is
37
+ * clamped to the visible window height. Returns the stacked cells and the start offset (in cells,
38
+ * relative to row 0) the tape animates from.
39
+ */
40
+ export function buildReelStepTape(
41
+ before: CellData[],
42
+ settledCol: CellData[],
43
+ shift: number,
44
+ ): { stack: CellData[]; shift: number; startOffsetCells: number } {
45
+ const rows = before.length;
46
+ const s = Math.max(0, Math.min(shift, rows));
47
+ const incoming = Array.from({ length: s }, (_, i) => settledCol[i] ?? { symbol: null });
48
+ return { stack: [...incoming, ...before], shift: s, startOffsetCells: 0 - s };
49
+ }
50
+
51
+ export class ReelStepController {
52
+ private _grid: ReelGrid;
53
+ private _resolve: SymbolResolver;
54
+ private _cfg: CascadeConfig;
55
+ private _win: WinConfig = DEFAULT_REEL_CONFIG.win;
56
+ private _killed = false;
57
+ private _mult: number;
58
+ private _temp: Container[] = [];
59
+ /** Board the in-flight step settles to — used to snap on skip(). */
60
+ private _pending: CellData[][] | null = null;
61
+
62
+ constructor(grid: ReelGrid, resolve: SymbolResolver, cfg: CascadeConfig, win?: WinConfig) {
63
+ this._grid = grid;
64
+ this._resolve = resolve;
65
+ this._cfg = cfg;
66
+ this._mult = cfg.multiplier.start;
67
+ if (win) this._win = win;
68
+ }
69
+
70
+ setConfig(cfg: CascadeConfig): void {
71
+ this._cfg = cfg;
72
+ }
73
+ setWin(win: WinConfig): void {
74
+ this._win = win;
75
+ }
76
+ /** Killed, or the grid was torn down underneath us (rebuild mid-chain). */
77
+ private get _dead(): boolean {
78
+ return this._killed || this._grid.destroyed;
79
+ }
80
+ get multiplier(): number {
81
+ return this._mult;
82
+ }
83
+ resetMultiplier(): void {
84
+ this._mult = this._cfg.multiplier.start;
85
+ }
86
+
87
+ private advanceMultiplier(): void {
88
+ const m = this._cfg.multiplier;
89
+ if (!m.enabled) return;
90
+ const next = m.mode === 'mul' ? this._mult * m.step : this._mult + m.step;
91
+ this._mult = m.cap != null ? Math.min(next, m.cap) : next;
92
+ }
93
+
94
+ /** Run one ReelStep: pay the winning cells, then scroll each reel down by shifts[col]. */
95
+ async step(step: ReelStepData, stepIndex = 0, opts?: { turbo?: boolean }): Promise<void> {
96
+ if (this._grid.destroyed) return;
97
+ this._killed = false;
98
+ this._pending = step.settledGrid;
99
+
100
+ // 1. celebrate/pay the winning cells.
101
+ await this._payWins(step, opts);
102
+ if (this._dead) return;
103
+
104
+ // No shift → just settle the board (defensive; a real ReelStep always shifts something).
105
+ const hasShift = step.shifts.some((n) => n > 0);
106
+ if (!this._cfg.enabled || !hasShift) {
107
+ this._grid.setGrid(step.settledGrid);
108
+ if (step.winningCells.length) this.advanceMultiplier();
109
+ this._pending = null;
110
+ return;
111
+ }
112
+
113
+ // 2. scroll every reel down by its own N (0 = untouched), all reels concurrently.
114
+ const turbo = opts?.turbo ? 0.5 : 1;
115
+ const decel = Math.min(this._cfg.perStepDecelCap, 1 + stepIndex * this._cfg.perStepDecel);
116
+ const f = turbo * decel;
117
+ await Promise.all(
118
+ step.shifts.map((n, col) =>
119
+ n > 0 ? this._scrollReel(col, n, step.settledGrid, f) : Promise.resolve(),
120
+ ),
121
+ );
122
+ if (this._dead) {
123
+ this._cleanupTemp();
124
+ return;
125
+ }
126
+
127
+ // 3. normalise + advance multiplier.
128
+ this._grid.setGrid(step.settledGrid);
129
+ this._resetPositions();
130
+ this._cleanupTemp();
131
+ this._pending = null;
132
+ if (step.winningCells.length) this.advanceMultiplier();
133
+ }
134
+
135
+ /** Highlight + hold the winning cells, then release them back to rest before the shift. */
136
+ private async _payWins(step: ReelStepData, opts?: { turbo?: boolean }): Promise<void> {
137
+ if (!step.winningCells.length) return;
138
+ const turbo = opts?.turbo ? 0.5 : 1;
139
+ const t = this._cfg.timings;
140
+ const hs = this._win.highlightScale;
141
+ const winSet = new Set(step.winningCells.map((w) => `${w.col}:${w.row}`));
142
+ if (this._cfg.dimNonWinners) this._dim(winSet);
143
+ await Promise.all(
144
+ step.winningCells.map((w) => {
145
+ const cell = this._grid.getCell(w.col, w.row);
146
+ if (this._win.glow) cell.setState({ winning: true });
147
+ return Tween.to(
148
+ cell,
149
+ { 'scale.x': hs, 'scale.y': hs },
150
+ t.highlight * turbo,
151
+ easingByName(this._cfg.easings.highlight),
152
+ );
153
+ }),
154
+ );
155
+ if (this._dead) {
156
+ this._undim();
157
+ return;
158
+ }
159
+ await Tween.delay(t.wait * turbo);
160
+ for (const w of step.winningCells) {
161
+ const cell = this._grid.getCell(w.col, w.row);
162
+ cell.setState({});
163
+ cell.scale.set(1);
164
+ }
165
+ this._undim();
166
+ }
167
+
168
+ /**
169
+ * Scroll one reel down by `n` positions. A tape carrying the reel's current symbols with `n`
170
+ * fresh symbols stacked on top slides down by `n` cells: the fresh symbols enter from the top,
171
+ * the existing ones ride down, and the bottom `n` ride off below the window. Ends on
172
+ * settledGrid[col]. The tape shares the cells' parent (so the reel mask, if any, clips it).
173
+ */
174
+ private async _scrollReel(
175
+ col: number,
176
+ n: number,
177
+ settledGrid: CellData[][],
178
+ f: number,
179
+ ): Promise<void> {
180
+ const rows = this._grid.rowsOf(col);
181
+ if (rows === 0 || this._dead) return;
182
+ const realCells = Array.from({ length: rows }, (_, r) => this._grid.getCell(col, r));
183
+ const layer = realCells[0].parent ?? this._grid;
184
+ const base = this._grid.cellPosition(col, 0);
185
+ const step =
186
+ rows > 1 ? this._grid.cellPosition(col, 1).y - base.y : this._grid.cellSize(col).height;
187
+
188
+ // current visible symbols (top→bottom), captured before we hide them
189
+ const before = realCells.map((c) => ({ ...c.data }));
190
+ const { stack, startOffsetCells } = buildReelStepTape(before, settledGrid[col] ?? [], n);
191
+
192
+ // Tape laid out top→bottom at local y = i*step: [incoming(shift)] above [before(rows)].
193
+ const tape = new Container();
194
+ tape.x = base.x;
195
+ for (let i = 0; i < stack.length; i++) {
196
+ const cell = new SymbolCell({ size: this._grid.cellSize(col), resolve: this._resolve });
197
+ cell.setData(stack[i]);
198
+ cell.position.set(0, i * step);
199
+ tape.addChild(cell);
200
+ }
201
+ // Start: the `before` block fills the window; the incoming block sits above it (masked off).
202
+ tape.y = base.y + startOffsetCells * step;
203
+ realCells.forEach((c) => (c.visible = false));
204
+ layer.addChild(tape);
205
+ this._temp.push(tape);
206
+
207
+ // Slide down by `shift` positions, with a small overshoot then settle-back.
208
+ const overshoot = step * 0.12;
209
+ await Tween.to(
210
+ tape,
211
+ { y: base.y + overshoot },
212
+ this._cfg.timings.drop * f,
213
+ easingByName(this._cfg.easings.drop),
214
+ );
215
+ if (this._dead) return;
216
+ await Tween.to(
217
+ tape,
218
+ { y: base.y },
219
+ Math.max(90, this._cfg.timings.refill * f),
220
+ easingByName('easeOutQuad'),
221
+ );
222
+ if (this._dead) return;
223
+
224
+ // Hand the settled symbols back to the real cells.
225
+ for (let r = 0; r < rows; r++) realCells[r].setData(settledGrid[col]?.[r] ?? { symbol: null });
226
+ realCells.forEach((c) => (c.visible = true));
227
+ tape.destroy();
228
+ this._temp = this._temp.filter((tp) => tp !== tape);
229
+ }
230
+
231
+ private _dim(winSet: Set<string>): void {
232
+ for (let c = 0; c < this._grid.cols; c++)
233
+ for (let r = 0; r < this._grid.rowsOf(c); r++)
234
+ if (!winSet.has(`${c}:${r}`)) this._grid.getCell(c, r).alpha = this._cfg.dimAlpha;
235
+ }
236
+ private _undim(): void {
237
+ for (let c = 0; c < this._grid.cols; c++)
238
+ for (let r = 0; r < this._grid.rowsOf(c); r++) {
239
+ const cell = this._grid.getCell(c, r);
240
+ if (cell.alpha !== 0) cell.alpha = 1;
241
+ }
242
+ }
243
+ private _resetPositions(): void {
244
+ if (this._grid.destroyed) return;
245
+ for (let c = 0; c < this._grid.cols; c++)
246
+ for (let r = 0; r < this._grid.rowsOf(c); r++) {
247
+ const cell = this._grid.getCell(c, r);
248
+ const { x, y } = this._grid.cellPosition(c, r);
249
+ cell.position.set(x, y);
250
+ cell.scale.set(1);
251
+ cell.alpha = 1;
252
+ cell.visible = true;
253
+ }
254
+ }
255
+ private _cleanupTemp(): void {
256
+ for (const t of this._temp) if (!t.destroyed) t.destroy();
257
+ this._temp = [];
258
+ }
259
+
260
+ /** Hard-cancel: kill tweens, drop tapes, snap to the in-flight step's settled board. */
261
+ skip(): void {
262
+ this._killed = true;
263
+ for (let c = 0; c < this._grid.cols; c++)
264
+ for (let r = 0; r < this._grid.rowsOf(c); r++) Tween.killTweensOf(this._grid.getCell(c, r));
265
+ this._cleanupTemp();
266
+ if (this._pending && !this._grid.destroyed) this._grid.setGrid(this._pending);
267
+ this._pending = null;
268
+ this._resetPositions();
269
+ }
270
+ }
@@ -47,6 +47,7 @@ export class SymbolCell extends Container {
47
47
  private _style: Required<CellFrameStyle>;
48
48
  private _frame: Graphics;
49
49
  private _view: SymbolView | null = null;
50
+ private _data: CellData = { symbol: null };
50
51
  private _badges = new Container();
51
52
  private _multBadge: Container | null = null;
52
53
  private _bonusBadge: Container | null = null;
@@ -70,8 +71,14 @@ export class SymbolCell extends Container {
70
71
  return this._view;
71
72
  }
72
73
 
74
+ /** The last applied cell data (symbol + badges). Read by motion controllers that rebuild tapes. */
75
+ get data(): CellData {
76
+ return this._data;
77
+ }
78
+
73
79
  setData(data: CellData): void {
74
80
  if (this.destroyed) return; // a killed-tween chain may resume after the cell is gone
81
+ this._data = data;
75
82
  // symbol view
76
83
  if (data.symbol == null) {
77
84
  if (this._view) {
package/src/slot/index.ts CHANGED
@@ -66,6 +66,8 @@ export { AnticipationController } from './motion/AnticipationController';
66
66
  export type { AnticipationDecision } from './motion/AnticipationController';
67
67
  export { TumbleController } from './cascade/TumbleController';
68
68
  export type { TumbleStep } from './cascade/TumbleController';
69
+ export { ReelStepController, buildReelStepTape } from './cascade/ReelStepController';
70
+ export type { ReelStepData } from './cascade/ReelStepController';
69
71
 
70
72
  export { FEATURES, FEATURE_LIST } from './features';
71
73
  export type { ReelFeature, FeatureContext } from './features';
@@ -12,6 +12,7 @@ import type { SymbolResolver } from '../grid/SymbolView';
12
12
  import { SpinEngine, type SpinData, type SpinRunOpts } from '../motion/SpinEngine';
13
13
  import { AnticipationController } from '../motion/AnticipationController';
14
14
  import { TumbleController, type TumbleStep } from '../cascade/TumbleController';
15
+ import { ReelStepController, type ReelStepData } from '../cascade/ReelStepController';
15
16
  import { FEATURES, FEATURE_LIST, type FeatureContext, type ReelFeature } from '../features';
16
17
  import {
17
18
  DEFAULT_REEL_CONFIG,
@@ -53,7 +54,13 @@ export interface ReelSystem {
53
54
  spin(target: CellData[][], opts?: SpinRunOpts): Promise<void>;
54
55
  /** Run a cascade chain. With `freeSpins` + `cascade.multiplier.persistInFreeSpins`, the multiplier carries over instead of resetting. */
55
56
  cascade(steps: TumbleStep[], opts?: { turbo?: boolean; freeSpins?: boolean }): Promise<void>;
56
- /** Current running cascade multiplier. */
57
+ /**
58
+ * Run a ReelStep™ chain: each step pays its winning cells, then scrolls every reel down by
59
+ * `shifts[col]` positions (0 = reel stays put). Multiplier carries over with `freeSpins` +
60
+ * `cascade.multiplier.persistInFreeSpins`, same as `cascade`.
61
+ */
62
+ reelStep(steps: ReelStepData[], opts?: { turbo?: boolean; freeSpins?: boolean }): Promise<void>;
63
+ /** Current running cascade / reel-step multiplier. */
57
64
  readonly multiplier: number;
58
65
  /** Register a custom feature (or override a built-in by reusing its key). */
59
66
  registerFeature(feature: ReelFeature): void;
@@ -91,6 +98,7 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
91
98
  let spin!: SpinEngine;
92
99
  let anticipation!: AnticipationController;
93
100
  let tumble!: TumbleController;
101
+ let reelStepCtl!: ReelStepController;
94
102
  let board: CellData[][] = opts.board ?? emptyBoard(config);
95
103
  // custom features keyed by id; built-ins live in FEATURES/FEATURE_LIST
96
104
  const custom = new Map<string, ReelFeature>();
@@ -113,6 +121,7 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
113
121
  if (grid) {
114
122
  spin?.skip();
115
123
  tumble?.skip();
124
+ reelStepCtl?.skip();
116
125
  for (const child of fx?.children.slice() ?? []) Tween.killTweensOf(child);
117
126
  grid.destroy({ children: true });
118
127
  }
@@ -140,6 +149,7 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
140
149
  spin = new SpinEngine(grid, resolve, config.motion, config.win);
141
150
  anticipation = new AnticipationController(config.anticipation);
142
151
  tumble = new TumbleController(grid, config.cascade, config.win);
152
+ reelStepCtl = new ReelStepController(grid, resolve, config.cascade, config.win);
143
153
  grid.setGrid(board);
144
154
  }
145
155
 
@@ -203,6 +213,8 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
203
213
  anticipation.setConfig(config.anticipation);
204
214
  tumble.setConfig(config.cascade);
205
215
  tumble.setWin(config.win);
216
+ reelStepCtl.setConfig(config.cascade);
217
+ reelStepCtl.setWin(config.win);
206
218
  }
207
219
  },
208
220
 
@@ -229,7 +241,8 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
229
241
  },
230
242
 
231
243
  get multiplier() {
232
- return tumble.multiplier;
244
+ // cascade and reelStep share the same config start; only the active mechanic climbs.
245
+ return Math.max(tumble.multiplier, reelStepCtl.multiplier);
233
246
  },
234
247
 
235
248
  async cascade(steps, cOpts) {
@@ -243,6 +256,17 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
243
256
  if (config.cascade.multiplier.enabled) log?.(`Cascade multiplier ×${tumble.multiplier}`);
244
257
  },
245
258
 
259
+ async reelStep(steps, rOpts) {
260
+ const persist = config.cascade.multiplier.persistInFreeSpins && !!rOpts?.freeSpins;
261
+ if (!persist) reelStepCtl.resetMultiplier();
262
+ for (let i = 0; i < steps.length; i++) {
263
+ await reelStepCtl.step(steps[i], i, rOpts);
264
+ board = steps[i].settledGrid;
265
+ }
266
+ if (config.cascade.multiplier.enabled)
267
+ log?.(`ReelStep multiplier ×${reelStepCtl.multiplier}`);
268
+ },
269
+
246
270
  registerFeature(feature) {
247
271
  custom.set(feature.key, feature);
248
272
  },
@@ -280,6 +304,7 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
280
304
  skip() {
281
305
  spin.skip();
282
306
  tumble.skip();
307
+ reelStepCtl.skip();
283
308
  // kill in-flight overlay tweens (labels/rings) so a rebuild never animates destroyed nodes
284
309
  for (const child of fx.children.slice()) Tween.killTweensOf(child);
285
310
  fx.removeChildren().forEach((c) => c.destroy());