@energy8platform/game-engine 0.30.0 → 0.32.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/dist/core.cjs.js +3 -2
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.esm.js +3 -2
- package/dist/core.esm.js.map +1 -1
- package/dist/host.cjs.js +132 -35
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +10 -2
- package/dist/host.esm.js +132 -35
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +3 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +3 -2
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/core/GameApplication.ts +7 -5
- package/src/host/createSlotGame.ts +99 -36
- package/src/host/runRound.ts +75 -14
- package/src/host/sceneController.ts +10 -2
|
@@ -241,9 +241,10 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
|
|
|
241
241
|
|
|
242
242
|
const pixiOpts = {
|
|
243
243
|
preference: 'webgl' as const,
|
|
244
|
-
background:
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
background:
|
|
245
|
+
typeof this.config.loading?.backgroundColor === 'number'
|
|
246
|
+
? this.config.loading.backgroundColor
|
|
247
|
+
: 0x000000,
|
|
247
248
|
antialias: true,
|
|
248
249
|
resolution: Math.min(window.devicePixelRatio, 2),
|
|
249
250
|
autoDensity: true,
|
|
@@ -286,8 +287,9 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
|
|
|
286
287
|
}
|
|
287
288
|
|
|
288
289
|
private initSubSystems(): void {
|
|
289
|
-
// Asset Manager
|
|
290
|
-
|
|
290
|
+
// Asset Manager. Base defaults to '/' (site root) when the bridge sends no assetsUrl — the
|
|
291
|
+
// asset folder lives in the manifest paths, not the base, so it needn't be named `assets`.
|
|
292
|
+
const basePath = this.initData?.assetsUrl ?? '/';
|
|
291
293
|
this.assets = new AssetManager(basePath, this.config.manifest);
|
|
292
294
|
|
|
293
295
|
// Audio Manager
|
|
@@ -7,7 +7,7 @@ import { showFatalError, installGlobalErrorHandlers } from './fatalError';
|
|
|
7
7
|
import type { CreateSlotGameOptions, SlotGameHandle } from './types';
|
|
8
8
|
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
9
9
|
import type { ShellMode } from '@energy8platform/shell/pixi';
|
|
10
|
-
import type { SceneApi, SlotSceneController } from './sceneController';
|
|
10
|
+
import type { SceneApi, SlotSceneController, RenderContext } from './sceneController';
|
|
11
11
|
import type { FreeSpinsView } from './freeSpinsCounter';
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -319,6 +319,12 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
319
319
|
};
|
|
320
320
|
|
|
321
321
|
const roleOf = (action: string) => opts.model.spec.actions[action]?.role;
|
|
322
|
+
const isBonusAction = (action: string) => roleOf(action) === 'free';
|
|
323
|
+
// Per-SEGMENT mode string. modeMap intentionally excludes `free` actions (they'd pollute the
|
|
324
|
+
// Game-Info modes table), so a free segment falls back to its spec `mode` (or the action key).
|
|
325
|
+
// This is what distinguishes nested bonuses (FREESPINS vs ADVENTURE) at the transition boundary.
|
|
326
|
+
const segmentModeOf = (action: string) =>
|
|
327
|
+
opts.model.spec.actions[action]?.mode ?? opts.model.modeMap[action] ?? action.toUpperCase();
|
|
322
328
|
// The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
|
|
323
329
|
// attaches its own. So makeContext returns everything BUT `signal`.
|
|
324
330
|
const makeContext = (
|
|
@@ -326,7 +332,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
326
332
|
): Omit<import('./sceneController').RenderContext, 'signal'> => ({
|
|
327
333
|
bet: currentBet,
|
|
328
334
|
action,
|
|
329
|
-
mode:
|
|
335
|
+
mode: segmentModeOf(action),
|
|
330
336
|
formatAmount: (v) => shell!.formatWin(v),
|
|
331
337
|
get turbo() {
|
|
332
338
|
return currentTurbo;
|
|
@@ -450,18 +456,86 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
450
456
|
* else 'freeSpins' (the back-compat default the shell already renders). */
|
|
451
457
|
const bonusShellMode: ShellMode = opts.bonus ? 'bonus' : 'freeSpins';
|
|
452
458
|
|
|
459
|
+
/** Apply an authoritative book override (freeSpins.total/remaining) onto an accumulated view —
|
|
460
|
+
* the host counts by default, but if the book resends the count (e.g. a resumed parent after a
|
|
461
|
+
* nested sub-bonus) that wins. */
|
|
462
|
+
const overrideView = (
|
|
463
|
+
view: FreeSpinsView,
|
|
464
|
+
fs?: SlotSpinResultBase['freeSpins'],
|
|
465
|
+
): FreeSpinsView => {
|
|
466
|
+
if (!fs) return view;
|
|
467
|
+
const total = fs.total ?? view.total;
|
|
468
|
+
const current = fs.remaining != null ? Math.max(0, total - fs.remaining) : view.current;
|
|
469
|
+
return { current, total, totalWin: view.totalWin };
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
interface BonusLevel {
|
|
473
|
+
mode: string;
|
|
474
|
+
counter: ReturnType<typeof createFreeSpinsCounter>;
|
|
475
|
+
view: FreeSpinsView;
|
|
476
|
+
}
|
|
477
|
+
/** A per-round stack of active bonus levels driving the shell bar as levels push/pop. Supports
|
|
478
|
+
* NESTED bonuses (e.g. free spins → adventure → free spins): each level keeps its own counter,
|
|
479
|
+
* so a resumed parent restores its remaining count. A single-bonus round pushes once and
|
|
480
|
+
* unwinds once — byte-identical to the pre-nesting behaviour. */
|
|
481
|
+
const createBonusStack = (scene: SlotSceneController<T>) => {
|
|
482
|
+
const stack: BonusLevel[] = [];
|
|
483
|
+
return {
|
|
484
|
+
inBonus: (): boolean => stack.length > 0,
|
|
485
|
+
/** A bonus level becomes active — a fresh push, or a resumed parent (already on the stack). */
|
|
486
|
+
async enter(mode: string, trigger: T, ctx: RenderContext, resumed: boolean): Promise<void> {
|
|
487
|
+
if (resumed) {
|
|
488
|
+
const lvl = stack[stack.length - 1]; // the parent stayed on the stack; the child popped
|
|
489
|
+
lvl.view = overrideView(lvl.view, trigger.freeSpins);
|
|
490
|
+
applyBonusReadout(trigger, lvl.view, lvl.mode);
|
|
491
|
+
} else {
|
|
492
|
+
const counter = createFreeSpinsCounter();
|
|
493
|
+
const view = overrideView(
|
|
494
|
+
counter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0),
|
|
495
|
+
trigger.freeSpins,
|
|
496
|
+
);
|
|
497
|
+
stack.push({ mode, counter, view });
|
|
498
|
+
if (stack.length === 1) shell!.setMode(bonusShellMode); // base → bonus
|
|
499
|
+
applyBonusReadout(trigger, view, mode);
|
|
500
|
+
}
|
|
501
|
+
// ctx.mode is overridden to the mode being ENTERED so the scene reads the right level.
|
|
502
|
+
await scene.onEnterMode?.(trigger, { ...ctx, mode, resumed });
|
|
503
|
+
},
|
|
504
|
+
/** A bonus level ends — a nested pop back to its parent, or the last level back to base. */
|
|
505
|
+
async exit(mode: string, last: T, ctx: RenderContext): Promise<void> {
|
|
506
|
+
stack.pop();
|
|
507
|
+
await scene.onExitMode?.(last, { ...ctx, mode });
|
|
508
|
+
if (stack.length === 0) {
|
|
509
|
+
shell!.setMode('base');
|
|
510
|
+
// Back in base the FS "Total win" block is gone, so WIN must carry the round's
|
|
511
|
+
// CUMULATIVE total (what got credited) — not the last segment's per-spin delta.
|
|
512
|
+
shell!.setWin(last.totalWin);
|
|
513
|
+
}
|
|
514
|
+
// else: an intermediate pop; the following resume-enter (or next exit) re-paints the bar.
|
|
515
|
+
},
|
|
516
|
+
/** Advance the active level's counter with a settled segment. */
|
|
517
|
+
settle(r: T): void {
|
|
518
|
+
if (stack.length === 0) return;
|
|
519
|
+
const top = stack[stack.length - 1];
|
|
520
|
+
top.view = overrideView(
|
|
521
|
+
top.counter.spin(r.freeSpins?.awarded ?? 0, r.totalWin),
|
|
522
|
+
r.freeSpins,
|
|
523
|
+
);
|
|
524
|
+
applyBonusReadout(r, top.view, top.mode);
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
};
|
|
528
|
+
|
|
453
529
|
/** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
|
|
454
530
|
* update only AFTER each onSpin(), per the HUD-timing requirement. */
|
|
455
531
|
const playRound = (action: string) => {
|
|
456
532
|
const scene = gameScene();
|
|
457
533
|
if (!scene) return;
|
|
458
|
-
// Per-round
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
let bonusMode = ''; // the mode string captured on bonus-enter, reused for each settled spin
|
|
534
|
+
// Per-round bonus stack: the shell enters bonus mode on the first level's push and shows the
|
|
535
|
+
// active level's counter + cumulative win. The stack handles NESTED bonuses (free spins →
|
|
536
|
+
// adventure → free spins); a single-bonus round pushes/unwinds once (unchanged).
|
|
537
|
+
const bonus = createBonusStack(scene);
|
|
463
538
|
let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
|
|
464
|
-
const fsCounter = createFreeSpinsCounter();
|
|
465
539
|
shell!.setBusy(true); // block re-spin / spacebar while the round plays out
|
|
466
540
|
presenting = true; // open the skip window for the whole play→drain
|
|
467
541
|
// RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
|
|
@@ -476,7 +550,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
476
550
|
ack: slotPlay.ack,
|
|
477
551
|
scene,
|
|
478
552
|
context: makeContext,
|
|
479
|
-
|
|
553
|
+
modeOf: segmentModeOf,
|
|
554
|
+
isBonusAction,
|
|
480
555
|
// Hand the host the per-segment AbortController so a double-tap can skip the live segment.
|
|
481
556
|
beforeSegment: (ac) => {
|
|
482
557
|
currentSegmentAbort = ac;
|
|
@@ -485,33 +560,14 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
485
560
|
onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
|
|
486
561
|
afterPresent: (r) => {
|
|
487
562
|
// WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
|
|
488
|
-
//
|
|
563
|
+
// bonus counter (totalWin) via settle(), not the WIN readout.
|
|
489
564
|
shell!.setWin(r.totalWin - prevWin);
|
|
490
565
|
prevWin = r.totalWin;
|
|
491
566
|
balanceGate.afterPresent();
|
|
492
|
-
|
|
493
|
-
applyBonusReadout(
|
|
494
|
-
r,
|
|
495
|
-
fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin),
|
|
496
|
-
bonusMode,
|
|
497
|
-
);
|
|
498
|
-
},
|
|
499
|
-
onEnterMode: async (trigger, ctx) => {
|
|
500
|
-
inBonus = true;
|
|
501
|
-
bonusMode = ctx.mode;
|
|
502
|
-
shell!.setMode(bonusShellMode);
|
|
503
|
-
applyBonusReadout(
|
|
504
|
-
trigger,
|
|
505
|
-
fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0),
|
|
506
|
-
bonusMode,
|
|
507
|
-
);
|
|
508
|
-
await scene.onEnterMode?.(trigger, ctx);
|
|
509
|
-
},
|
|
510
|
-
onExitMode: async (last, ctx) => {
|
|
511
|
-
inBonus = false;
|
|
512
|
-
await scene.onExitMode?.(last, ctx);
|
|
513
|
-
shell!.setMode('base');
|
|
567
|
+
bonus.settle(r);
|
|
514
568
|
},
|
|
569
|
+
onModeEnter: (mode, trigger, ctx, resumed) => bonus.enter(mode, trigger, ctx, resumed),
|
|
570
|
+
onModeExit: (mode, last, ctx) => bonus.exit(mode, last, ctx),
|
|
515
571
|
},
|
|
516
572
|
action,
|
|
517
573
|
)
|
|
@@ -536,8 +592,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
536
592
|
const scene = gameScene();
|
|
537
593
|
if (!scene || !ps) return;
|
|
538
594
|
// A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
|
|
539
|
-
// never-aborted signal to satisfy onSpin's RenderContext.
|
|
540
|
-
|
|
595
|
+
// never-aborted signal to satisfy onSpin's RenderContext. ctx carries the round identity (built
|
|
596
|
+
// once from the trigger action) — recovery drains a single flat bonus using the bridge session
|
|
597
|
+
// counts; the full per-level nesting is a LIVE-play concern (playRound).
|
|
598
|
+
const ctx: RenderContext = {
|
|
541
599
|
...makeContext((firstRaw as { action?: string }).action ?? 'spin'),
|
|
542
600
|
signal: new AbortController().signal,
|
|
543
601
|
};
|
|
@@ -556,7 +614,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
556
614
|
let inBonus = false;
|
|
557
615
|
let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
|
|
558
616
|
const applySegment = async (): Promise<void> => {
|
|
559
|
-
// A recovered open round with remaining segments is a bonus → show
|
|
617
|
+
// A recovered open round with remaining segments is a bonus → show bonus mode + counter.
|
|
560
618
|
if (!inBonus && !r.complete) {
|
|
561
619
|
inBonus = true;
|
|
562
620
|
shell!.setMode(bonusShellMode);
|
|
@@ -582,7 +640,12 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
582
640
|
r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
583
641
|
await applySegment();
|
|
584
642
|
}
|
|
585
|
-
if (inBonus)
|
|
643
|
+
if (inBonus) {
|
|
644
|
+
shell!.setMode('base');
|
|
645
|
+
// Same as playRound: on return to base the WIN readout must show the round's cumulative
|
|
646
|
+
// total (r is the final drained segment), not the last segment's per-spin delta.
|
|
647
|
+
shell!.setWin(r.totalWin);
|
|
648
|
+
}
|
|
586
649
|
} finally {
|
|
587
650
|
shell!.setBusy(false);
|
|
588
651
|
}
|
package/src/host/runRound.ts
CHANGED
|
@@ -5,22 +5,63 @@ export interface RunRoundDeps<T extends SlotSpinResultBase> {
|
|
|
5
5
|
play(action: string, bet: number, roundId?: string): Promise<T>;
|
|
6
6
|
ack(): void;
|
|
7
7
|
scene: Pick<SlotSceneController<T>, 'onSpin'>;
|
|
8
|
-
/** Build the
|
|
8
|
+
/** Build the render context (without signal — runRound injects it per segment). Called with the
|
|
9
|
+
* ROUND action, so `ctx.action`/`ctx.mode` carry the round identity across every drained segment
|
|
10
|
+
* (a buy_bonus round's free spins still read as BONUS). Nested-bonus detection uses `modeOf`, not
|
|
11
|
+
* `ctx.mode`, and the scene learns level changes via onModeEnter/onModeExit. */
|
|
9
12
|
context(action: string): Omit<RenderContext, 'signal'> & { signal?: AbortSignal };
|
|
10
|
-
|
|
13
|
+
/** Bonus mode string for a segment action (FREESPINS / ADVENTURE / …) — drives nested-bonus
|
|
14
|
+
* transition detection. Defaults to `action.toUpperCase()`. */
|
|
15
|
+
modeOf?(action: string): string;
|
|
16
|
+
/** True when an action is a bonus (free-play) segment, false for base/trigger segments.
|
|
17
|
+
* Omit for base-only games (no bonus transitions ever fire). */
|
|
18
|
+
isBonusAction?(action: string): boolean;
|
|
11
19
|
afterPresent?(result: T): void;
|
|
12
20
|
/** Once, before the first segment is played (player pressed spin). */
|
|
13
21
|
onSpinStart?(): void;
|
|
14
22
|
/** Once, after the full drain. */
|
|
15
23
|
onSpinEnd?(last: T, ctx: RenderContext): void;
|
|
16
|
-
/** Fires when
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
/** Fires when a bonus LEVEL becomes active — a fresh push (`resumed=false`) or a return to a
|
|
25
|
+
* suspended parent after a nested sub-bonus popped (`resumed=true`). Fires per boundary, so a
|
|
26
|
+
* round may enter several levels. `trigger`/`ctx` are the segment that caused the transition. */
|
|
27
|
+
onModeEnter?(mode: string, trigger: T, ctx: RenderContext, resumed: boolean): Promise<void>;
|
|
28
|
+
/** Fires when a bonus LEVEL ends (pop) — either descending past it into base, or unwinding at
|
|
29
|
+
* round end. Fires once per popped level, top-first. */
|
|
30
|
+
onModeExit?(mode: string, last: T, ctx: RenderContext): Promise<void>;
|
|
20
31
|
/** Hands the host the AbortController for the segment about to present (for skip). */
|
|
21
32
|
beforeSegment?(ac: AbortController): void;
|
|
22
33
|
}
|
|
23
34
|
|
|
35
|
+
/** A planned move of the mode stack toward a target (or to base when `target` is null). Pure. */
|
|
36
|
+
export interface TransitionPlan {
|
|
37
|
+
/** Modes to exit, top-first (each is popped). */
|
|
38
|
+
exit: string[];
|
|
39
|
+
/** The level that becomes active after the exits, or null when none (same level, or unwind to
|
|
40
|
+
* base). `resumed` distinguishes returning to an existing parent from a fresh push. */
|
|
41
|
+
enter: { mode: string; resumed: boolean } | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pure: given the CURRENT bonus-mode stack (bottom→top) and the next segment's target mode
|
|
46
|
+
* (null = a base segment / round end), decide which levels to exit and whether a level enters.
|
|
47
|
+
*
|
|
48
|
+
* - target null → unwind everything (exit all, top-first).
|
|
49
|
+
* - target === top → same level, no transition.
|
|
50
|
+
* - target already deeper → RESUME: exit the levels above it; it re-activates (resumed).
|
|
51
|
+
* - target not on the stack → PUSH: a fresh level enters.
|
|
52
|
+
*
|
|
53
|
+
* The caller owns the actual stack array and applies the plan (pop per `exit`, push when
|
|
54
|
+
* `enter && !resumed`).
|
|
55
|
+
*/
|
|
56
|
+
export function planTransition(stack: readonly string[], target: string | null): TransitionPlan {
|
|
57
|
+
if (target == null) return { exit: [...stack].reverse(), enter: null };
|
|
58
|
+
if (stack.length > 0 && stack[stack.length - 1] === target) return { exit: [], enter: null };
|
|
59
|
+
const depth = stack.lastIndexOf(target);
|
|
60
|
+
if (depth >= 0)
|
|
61
|
+
return { exit: stack.slice(depth + 1).reverse(), enter: { mode: target, resumed: true } };
|
|
62
|
+
return { exit: [], enter: { mode: target, resumed: false } };
|
|
63
|
+
}
|
|
64
|
+
|
|
24
65
|
export async function runRound<T extends SlotSpinResultBase>(
|
|
25
66
|
deps: RunRoundDeps<T>,
|
|
26
67
|
action: string,
|
|
@@ -28,28 +69,48 @@ export async function runRound<T extends SlotSpinResultBase>(
|
|
|
28
69
|
deps.onSpinStart?.();
|
|
29
70
|
|
|
30
71
|
const ctxBet = deps.context(action).bet;
|
|
72
|
+
const modeOf = deps.modeOf ?? ((a: string) => a.toUpperCase());
|
|
73
|
+
const isBonus = deps.isBonusAction ?? (() => false);
|
|
31
74
|
|
|
32
|
-
const segment = async (
|
|
75
|
+
const segment = async (
|
|
76
|
+
a: string,
|
|
77
|
+
roundId: string | undefined,
|
|
78
|
+
): Promise<{ r: T; ctx: RenderContext }> => {
|
|
33
79
|
const ac = new AbortController();
|
|
34
80
|
deps.beforeSegment?.(ac);
|
|
35
81
|
const r = await deps.play(a, ctxBet, roundId);
|
|
82
|
+
// ctx carries the ROUND identity (built from the round action), stable across drained segments.
|
|
36
83
|
const ctx = { ...deps.context(action), signal: ac.signal } as RenderContext;
|
|
37
84
|
await deps.scene.onSpin(r, ctx);
|
|
38
85
|
deps.ack();
|
|
39
86
|
deps.afterPresent?.(r);
|
|
40
87
|
return { r, ctx };
|
|
41
88
|
};
|
|
42
|
-
let { r, ctx } = await segment(action, undefined);
|
|
43
89
|
|
|
44
|
-
|
|
90
|
+
// Active bonus-mode levels (bottom→top). Empty in the base game.
|
|
91
|
+
const stack: string[] = [];
|
|
92
|
+
// Emit the exits/enter to move the stack toward `nextAction` (null → unwind to base). Called
|
|
93
|
+
// BEFORE the target segment presents, with the CURRENT (triggering) r/ctx — mirrors the classic
|
|
94
|
+
// onEnterMode(trigger) timing so the host reads awarded spins off the segment that granted them.
|
|
95
|
+
const transition = async (nextAction: string | null, r: T, ctx: RenderContext): Promise<void> => {
|
|
96
|
+
const target = nextAction != null && isBonus(nextAction) ? modeOf(nextAction) : null;
|
|
97
|
+
const plan = planTransition(stack, target);
|
|
98
|
+
for (const mode of plan.exit) {
|
|
99
|
+
stack.pop();
|
|
100
|
+
await deps.onModeExit?.(mode, r, ctx);
|
|
101
|
+
}
|
|
102
|
+
if (plan.enter) {
|
|
103
|
+
if (!plan.enter.resumed) stack.push(plan.enter.mode);
|
|
104
|
+
await deps.onModeEnter?.(plan.enter.mode, r, ctx, plan.enter.resumed);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
let { r, ctx } = await segment(action, undefined);
|
|
45
109
|
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
46
110
|
const next = r.nextActions[0];
|
|
47
|
-
|
|
48
|
-
inMode = true;
|
|
49
|
-
await deps.onEnterMode?.(r, ctx);
|
|
50
|
-
}
|
|
111
|
+
await transition(next, r, ctx);
|
|
51
112
|
({ r, ctx } = await segment(next, r.roundId));
|
|
52
113
|
}
|
|
53
|
-
|
|
114
|
+
await transition(null, r, ctx); // round end: unwind any remaining levels to base
|
|
54
115
|
deps.onSpinEnd?.(r, ctx);
|
|
55
116
|
}
|
|
@@ -13,6 +13,10 @@ export interface RenderContext {
|
|
|
13
13
|
formatAmount(value: number): string;
|
|
14
14
|
/** LIVE turbo level (0 = off, 1..3 = escalating speed). Read at access. */
|
|
15
15
|
readonly turbo: number;
|
|
16
|
+
/** Only meaningful in `onEnterMode`: true when RETURNING to a suspended parent bonus after a
|
|
17
|
+
* nested sub-bonus finished (e.g. back to free spins after an adventure), false on a fresh
|
|
18
|
+
* entry. Lets a scene restore vs rebuild. Undefined outside `onEnterMode`. */
|
|
19
|
+
resumed?: boolean;
|
|
16
20
|
/** Aborted when the player skips this segment (double-tap). The scene's async pacing can race or
|
|
17
21
|
* cancel on it; on abort the scene must collapse to the segment's final visual state. */
|
|
18
22
|
signal: AbortSignal;
|
|
@@ -82,9 +86,13 @@ export interface SlotSceneController<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
82
86
|
onSpinStart(): void;
|
|
83
87
|
/** Render ONE segment (a spin or one free spin). Await your own pacing. */
|
|
84
88
|
onSpin(result: T, ctx: RenderContext): Promise<void>;
|
|
85
|
-
/** Fires when
|
|
89
|
+
/** Fires when a bonus LEVEL begins. With nested bonuses this fires once per level (free spins,
|
|
90
|
+
* then adventure, …) — check `ctx.mode` for which. `ctx.resumed` is true when returning to a
|
|
91
|
+
* suspended parent after a nested sub-bonus finished, so a scene can restore instead of rebuild.
|
|
92
|
+
* A single-bonus round fires it exactly once (as before). */
|
|
86
93
|
onEnterMode(result: T, ctx: RenderContext): Promise<void>;
|
|
87
|
-
/** Fires when
|
|
94
|
+
/** Fires when a bonus LEVEL ends — popping a nested sub-bonus back to its parent, or unwinding
|
|
95
|
+
* the last level back to BASE. `ctx.mode` is the level being left. Fires once per level. */
|
|
88
96
|
onExitMode(result: T, ctx: RenderContext): Promise<void>;
|
|
89
97
|
/** Fires once per round after the full drain (controls unlocked). */
|
|
90
98
|
onSpinEnd(result: T, ctx: RenderContext): void;
|