@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.
- package/README.md +82 -2
- package/dist/devtools.cjs.js +36 -4
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.d.ts +67 -0
- package/dist/devtools.esm.js +36 -4
- package/dist/devtools.esm.js.map +1 -1
- package/dist/host.cjs.js +276 -109
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +5 -0
- package/dist/host.esm.js +276 -109
- package/dist/host.esm.js.map +1 -1
- package/dist/reel-panel-client.cjs.js +36 -4
- package/dist/reel-panel-client.cjs.js.map +1 -1
- package/dist/reel-panel-client.esm.js +36 -4
- package/dist/reel-panel-client.esm.js.map +1 -1
- package/dist/slot.cjs.js +229 -43
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +123 -8
- package/dist/slot.esm.js +229 -44
- package/dist/slot.esm.js.map +1 -1
- package/package.json +2 -2
- package/src/host/autoplay.ts +25 -3
- package/src/host/connectionRecovery.ts +113 -0
- package/src/host/createSlotGame.ts +83 -103
- package/src/host/playError.ts +23 -0
- package/src/host/resumeDrain.ts +156 -0
- package/src/host/shellConfig.ts +9 -0
- package/src/slot/config/ReelSystemConfig.ts +96 -4
- package/src/slot/devtools/fieldSchema.ts +7 -0
- package/src/slot/index.ts +5 -0
- package/src/slot/motion/AnticipationController.ts +62 -17
- package/src/slot/motion/SpinEngine.ts +151 -23
- package/src/slot/system/ReelSystem.ts +59 -9
package/src/host/shellConfig.ts
CHANGED
|
@@ -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
|
-
|
|
560
|
-
if (
|
|
561
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
return
|
|
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
|
|
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
|
|
73
|
-
return
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
162
|
-
return p.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
377
|
-
|
|
502
|
+
if (!deferred) {
|
|
503
|
+
cell.visible = true;
|
|
504
|
+
cell.alpha = 1;
|
|
505
|
+
}
|
|
378
506
|
cell.filters = [];
|
|
379
507
|
cell.scale.set(1);
|
|
380
508
|
}
|