@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.
- package/README.md +46 -2
- package/dist/devtools.cjs.js +32 -4
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.d.ts +44 -0
- package/dist/devtools.esm.js +32 -4
- package/dist/devtools.esm.js.map +1 -1
- package/dist/host.cjs.js +279 -109
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +5 -0
- package/dist/host.esm.js +279 -109
- package/dist/host.esm.js.map +1 -1
- package/dist/reel-panel-client.cjs.js +32 -4
- package/dist/reel-panel-client.cjs.js.map +1 -1
- package/dist/reel-panel-client.esm.js +32 -4
- package/dist/reel-panel-client.esm.js.map +1 -1
- package/dist/slot.cjs.js +184 -42
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +86 -8
- package/dist/slot.esm.js +184 -43
- package/dist/slot.esm.js.map +1 -1
- package/package.json +3 -3
- 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 +12 -0
- package/src/slot/config/ReelSystemConfig.ts +71 -4
- package/src/slot/devtools/fieldSchema.ts +5 -0
- package/src/slot/index.ts +3 -0
- package/src/slot/motion/AnticipationController.ts +62 -17
- package/src/slot/motion/SpinEngine.ts +95 -18
- 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. */
|
|
@@ -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
|
-
|
|
560
|
-
if (
|
|
561
|
-
|
|
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
|
-
|
|
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,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
|
|
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
|
|
162
|
-
return p.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 *
|
|
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
|
-
|
|
377
|
-
|
|
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 {
|
|
13
|
-
|
|
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 =
|
|
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
|
},
|