@energy8platform/game-engine 0.17.0 → 0.19.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/audio.cjs.js +15 -5
- package/dist/audio.cjs.js.map +1 -1
- package/dist/audio.d.ts +5 -0
- package/dist/audio.esm.js +15 -5
- package/dist/audio.esm.js.map +1 -1
- package/dist/core.cjs.js +108 -19
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +46 -3
- package/dist/core.esm.js +109 -21
- package/dist/core.esm.js.map +1 -1
- package/dist/game-spec.cjs.js +13 -0
- package/dist/game-spec.cjs.js.map +1 -0
- package/dist/game-spec.d.ts +1 -0
- package/dist/game-spec.esm.js +2 -0
- package/dist/game-spec.esm.js.map +1 -0
- package/dist/host.cjs.js +3612 -0
- package/dist/host.cjs.js.map +1 -0
- package/dist/host.d.ts +1000 -0
- package/dist/host.esm.js +3603 -0
- package/dist/host.esm.js.map +1 -0
- package/dist/index.cjs.js +59 -19
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +19 -2
- package/dist/index.esm.js +59 -19
- package/dist/index.esm.js.map +1 -1
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +19 -2
- package/dist/react.esm.js.map +1 -1
- package/dist/shell.cjs.js +19 -0
- package/dist/shell.cjs.js.map +1 -0
- package/dist/shell.d.ts +1 -0
- package/dist/shell.esm.js +2 -0
- package/dist/shell.esm.js.map +1 -0
- package/dist/slot.cjs.js +998 -0
- package/dist/slot.cjs.js.map +1 -0
- package/dist/slot.d.ts +333 -0
- package/dist/slot.esm.js +985 -0
- package/dist/slot.esm.js.map +1 -0
- package/package.json +28 -2
- package/src/audio/AudioManager.ts +17 -5
- package/src/core/GameApplication.ts +38 -6
- package/src/core/index.ts +2 -0
- package/src/game-spec/index.ts +1 -0
- package/src/host/autoplay.ts +78 -0
- package/src/host/balanceGate.ts +46 -0
- package/src/host/buildConfig.ts +28 -0
- package/src/host/createSlotGame.ts +543 -0
- package/src/host/fatalError.ts +104 -0
- package/src/host/freeSpinsCounter.ts +44 -0
- package/src/host/index.ts +21 -0
- package/src/host/overlayController.ts +81 -0
- package/src/host/pauseController.ts +21 -0
- package/src/host/playError.ts +64 -0
- package/src/host/preboot.ts +25 -0
- package/src/host/replay.ts +9 -0
- package/src/host/runRound.ts +55 -0
- package/src/host/sceneAudio.ts +14 -0
- package/src/host/sceneController.ts +96 -0
- package/src/host/sceneStart.ts +25 -0
- package/src/host/shellConfig.ts +384 -0
- package/src/host/skipGesture.ts +24 -0
- package/src/host/slotPlay.ts +62 -0
- package/src/host/types.ts +74 -0
- package/src/scenes/IntroScene.ts +66 -0
- package/src/shell/index.ts +20 -0
- package/src/slot/anim/CascadeController.ts +102 -0
- package/src/slot/anim/ReelSpinController.ts +81 -0
- package/src/slot/anim/easing-map.ts +14 -0
- package/src/slot/freeSpins/FreeSpinsSession.ts +40 -0
- package/src/slot/grid/AnimatedSymbol.ts +68 -0
- package/src/slot/grid/ReelGrid.ts +92 -0
- package/src/slot/grid/SymbolCell.ts +127 -0
- package/src/slot/grid/SymbolView.ts +13 -0
- package/src/slot/index.ts +21 -0
- package/src/slot/multiplier/MultiplierAccumulator.ts +29 -0
- package/src/slot/overlay/BigWinOverlay.ts +89 -0
- package/src/slot/overlay/CountUpDisplay.ts +56 -0
- package/src/slot/overlay/tiers.ts +29 -0
- package/src/types.ts +3 -0
- package/src/viewport/ViewportManager.ts +19 -9
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free-spins counter for the shell readout (current / total / totalWin), with RETRIGGER support.
|
|
3
|
+
*
|
|
4
|
+
* A bonus awards an initial pool of spins; a retrigger mid-bonus awards MORE. The full-event book
|
|
5
|
+
* already contains every segment (incl. retriggered spins), but the player-facing counter must grow
|
|
6
|
+
* dynamically: start at the awarded total, and each spin that awards extra bumps the total. The
|
|
7
|
+
* remaining spins the shell shows are `total - current`.
|
|
8
|
+
*
|
|
9
|
+
* Example (the canonical case): enter(10) → 0/10. After two spins → 2/10. The third spin retriggers
|
|
10
|
+
* +5 → 3/15 (i.e. 12 remaining). `awarded` per spin is the spins granted by THAT spin (0 normally,
|
|
11
|
+
* the retrigger amount on a retrigger). Pure + unit-testable; the host feeds it `result.freeSpins`.
|
|
12
|
+
*/
|
|
13
|
+
export interface FreeSpinsView {
|
|
14
|
+
/** Free spins played so far (1-based once spinning). */
|
|
15
|
+
current: number;
|
|
16
|
+
/** Total free spins awarded so far (initial + every retrigger). */
|
|
17
|
+
total: number;
|
|
18
|
+
/** Cumulative bonus win (the host passes the round's cumulative totalWin). */
|
|
19
|
+
totalWin: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FreeSpinsCounter {
|
|
23
|
+
/** Bonus start: seed the total with the trigger's awarded spins. Resets current + totalWin. */
|
|
24
|
+
enter(awarded: number): FreeSpinsView;
|
|
25
|
+
/** One free spin presented: count it, fold in any retrigger `awarded`, carry the cumulative win. */
|
|
26
|
+
spin(awarded: number, totalWin: number): FreeSpinsView;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createFreeSpinsCounter(): FreeSpinsCounter {
|
|
30
|
+
let total = 0;
|
|
31
|
+
let current = 0;
|
|
32
|
+
return {
|
|
33
|
+
enter(awarded: number): FreeSpinsView {
|
|
34
|
+
total = awarded;
|
|
35
|
+
current = 0;
|
|
36
|
+
return { current, total, totalWin: 0 };
|
|
37
|
+
},
|
|
38
|
+
spin(awarded: number, totalWin: number): FreeSpinsView {
|
|
39
|
+
current += 1;
|
|
40
|
+
total += awarded; // a retrigger grows the pool
|
|
41
|
+
return { current, total, totalWin };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// packages/game-engine/src/host/index.ts
|
|
2
|
+
export { createSlotGame } from './createSlotGame';
|
|
3
|
+
export type {
|
|
4
|
+
CreateSlotGameOptions,
|
|
5
|
+
SlotGameHandle,
|
|
6
|
+
StakeIntegration,
|
|
7
|
+
SceneRegistration,
|
|
8
|
+
SceneNavData,
|
|
9
|
+
SceneEntry,
|
|
10
|
+
} from './types';
|
|
11
|
+
export { buildShellConfig, stakeForAction } from './shellConfig';
|
|
12
|
+
export type { SlotShellOptions } from './shellConfig';
|
|
13
|
+
export { resolveReplayBonusId } from './replay';
|
|
14
|
+
export { resolveStartScene } from './sceneStart';
|
|
15
|
+
export type {
|
|
16
|
+
SlotSceneController, RenderContext, SceneApi, SceneAudio, SceneOverlay, SceneShell,
|
|
17
|
+
OverlayShowOptions, AutoplaySceneState,
|
|
18
|
+
} from './sceneController';
|
|
19
|
+
// Social-casino word-swap. The shell auto-socializes all gameInfo/buyBonus text in social mode;
|
|
20
|
+
// authors only need this to socialize strings they render themselves (e.g. inside a custom DOM node).
|
|
21
|
+
export { socialize } from '@energy8platform/platform-core/shell';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Container, Graphics } from 'pixi.js';
|
|
2
|
+
import type { SceneOverlay, OverlayShowOptions } from './sceneController';
|
|
3
|
+
|
|
4
|
+
interface OverlayDeps {
|
|
5
|
+
/** Container mounted above the shell on the stage. */
|
|
6
|
+
parent: Container;
|
|
7
|
+
/** Live canvas size getter (for the hit area + build size). */
|
|
8
|
+
size(): { width: number; height: number };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ActiveOverlay {
|
|
12
|
+
layer: Container;
|
|
13
|
+
resolve(): void;
|
|
14
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
15
|
+
dim: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createOverlayController(deps: OverlayDeps): {
|
|
19
|
+
overlay: SceneOverlay;
|
|
20
|
+
resize(w: number, h: number): void;
|
|
21
|
+
destroy(): void;
|
|
22
|
+
} {
|
|
23
|
+
let current: ActiveOverlay | null = null;
|
|
24
|
+
|
|
25
|
+
const teardown = (): void => {
|
|
26
|
+
if (!current) return;
|
|
27
|
+
if (current.timer) clearTimeout(current.timer);
|
|
28
|
+
const { layer, resolve } = current;
|
|
29
|
+
current = null;
|
|
30
|
+
layer.removeFromParent();
|
|
31
|
+
layer.destroy({ children: true });
|
|
32
|
+
resolve();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const overlay: SceneOverlay = {
|
|
36
|
+
show(opts: OverlayShowOptions): Promise<void> {
|
|
37
|
+
if (current) {
|
|
38
|
+
console.warn('[overlay] show() ignored — an overlay is already open');
|
|
39
|
+
return Promise.reject(new Error('Overlay already open'));
|
|
40
|
+
}
|
|
41
|
+
const { width, height } = deps.size();
|
|
42
|
+
const layer = new Container();
|
|
43
|
+
layer.eventMode = 'static';
|
|
44
|
+
|
|
45
|
+
// Pointer-eating + (optional) dim backdrop sized to the canvas.
|
|
46
|
+
const hit = new Graphics().rect(0, 0, width, height).fill({
|
|
47
|
+
color: 0x000000,
|
|
48
|
+
alpha: opts.dim ?? 0.0001, // ~0 keeps it transparent but hit-testable
|
|
49
|
+
});
|
|
50
|
+
hit.eventMode = 'static';
|
|
51
|
+
layer.addChild(hit);
|
|
52
|
+
|
|
53
|
+
const content = new Container();
|
|
54
|
+
layer.addChild(content);
|
|
55
|
+
opts.build(content, { width, height });
|
|
56
|
+
|
|
57
|
+
deps.parent.addChild(layer);
|
|
58
|
+
|
|
59
|
+
return new Promise<void>((resolve) => {
|
|
60
|
+
const dimValue = opts.dim ?? 0.0001;
|
|
61
|
+
current = { layer, resolve, timer: null, dim: dimValue };
|
|
62
|
+
const closeOn = opts.closeOn ?? 'tap';
|
|
63
|
+
if (closeOn === 'tap') hit.on('pointertap', teardown);
|
|
64
|
+
if (typeof opts.autoCloseMs === 'number') {
|
|
65
|
+
current.timer = setTimeout(teardown, opts.autoCloseMs);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
close(): void { teardown(); },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
overlay,
|
|
74
|
+
resize(w: number, h: number): void {
|
|
75
|
+
if (!current) return;
|
|
76
|
+
const hit = current.layer.getChildAt(0) as Graphics;
|
|
77
|
+
hit.clear().rect(0, 0, w, h).fill({ color: 0x000000, alpha: current.dim });
|
|
78
|
+
},
|
|
79
|
+
destroy(): void { teardown(); },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface PauseDeps {
|
|
2
|
+
isHidden(): boolean;
|
|
3
|
+
onHidden(): void;
|
|
4
|
+
onVisible(): void;
|
|
5
|
+
/** Register a change listener; return an unsubscribe fn. */
|
|
6
|
+
subscribe(cb: () => void): () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Edge-triggers onHidden/onVisible from a visibility source. Effects (ticker/music/autoplay/scene)
|
|
10
|
+
* are supplied by the host so this stays pure + testable. */
|
|
11
|
+
export function createPauseController(deps: PauseDeps): { destroy(): void } {
|
|
12
|
+
let paused = deps.isHidden();
|
|
13
|
+
const unsub = deps.subscribe(() => {
|
|
14
|
+
const hidden = deps.isHidden();
|
|
15
|
+
if (hidden === paused) return;
|
|
16
|
+
paused = hidden;
|
|
17
|
+
if (hidden) deps.onHidden();
|
|
18
|
+
else deps.onVisible();
|
|
19
|
+
});
|
|
20
|
+
return { destroy: () => unsub() };
|
|
21
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a play/settle error into a player-facing modal payload.
|
|
3
|
+
*
|
|
4
|
+
* The SDK rejects `play()` with an `SDKError` carrying a `.code` (e.g. `ACTIVE_SESSION_EXISTS`,
|
|
5
|
+
* `INSUFFICIENT_FUNDS`, `TIMEOUT`). The bridge ALSO emits a `connectionStateChanged: 'lost'` for
|
|
6
|
+
* some of these, which would otherwise surface a misleading "Reconnecting…" overlay while the real
|
|
7
|
+
* fix is "reload to resume". The host routes every play error through this classifier so the player
|
|
8
|
+
* sees the right message + action, and suppresses the connection overlay while a play-error modal
|
|
9
|
+
* is up.
|
|
10
|
+
*
|
|
11
|
+
* `reload: true` → the round must be recovered by reloading (an unfinished round blocks new plays);
|
|
12
|
+
* the modal offers a Reload button. Otherwise it's a dismissible OK.
|
|
13
|
+
*/
|
|
14
|
+
export interface PlayErrorView {
|
|
15
|
+
title: string;
|
|
16
|
+
body: string;
|
|
17
|
+
/** Offer a Reload action (the round can only be recovered by reloading). */
|
|
18
|
+
reload: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Pull a Stake/SDK error code off an unknown thrown value. */
|
|
22
|
+
export function errorCode(err: unknown): string | undefined {
|
|
23
|
+
const code = (err as { code?: unknown })?.code;
|
|
24
|
+
return typeof code === 'string' ? code : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolvePlayError(err: unknown): PlayErrorView {
|
|
28
|
+
const code = errorCode(err);
|
|
29
|
+
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
|
30
|
+
switch (code) {
|
|
31
|
+
case 'ACTIVE_SESSION_EXISTS':
|
|
32
|
+
return {
|
|
33
|
+
title: 'Round in progress',
|
|
34
|
+
body: 'You have an unfinished round. Reload to resume it.',
|
|
35
|
+
reload: true,
|
|
36
|
+
};
|
|
37
|
+
case 'NO_ACTIVE_SESSION':
|
|
38
|
+
return {
|
|
39
|
+
title: 'Round expired',
|
|
40
|
+
body: 'This round is no longer active. Reload to continue.',
|
|
41
|
+
reload: true,
|
|
42
|
+
};
|
|
43
|
+
case 'INSUFFICIENT_FUNDS':
|
|
44
|
+
return {
|
|
45
|
+
title: 'Insufficient balance',
|
|
46
|
+
body: 'You don’t have enough balance for this bet. Lower your bet or top up.',
|
|
47
|
+
reload: false,
|
|
48
|
+
};
|
|
49
|
+
case 'TIMEOUT':
|
|
50
|
+
return {
|
|
51
|
+
title: 'Connection timed out',
|
|
52
|
+
body: 'The game server did not respond in time. Please try again.',
|
|
53
|
+
reload: false,
|
|
54
|
+
};
|
|
55
|
+
default:
|
|
56
|
+
// Unknown code: surface the server message verbatim under a generic heading (never the
|
|
57
|
+
// connection overlay), so an operator can diagnose without a code change.
|
|
58
|
+
return {
|
|
59
|
+
title: 'Game error',
|
|
60
|
+
body: message || 'Something went wrong. Please reload the game.',
|
|
61
|
+
reload: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { TextureSource } from 'pixi.js';
|
|
2
|
+
|
|
3
|
+
/** Preload web fonts so Pixi text rasterizes with the right glyphs. Never throws. */
|
|
4
|
+
export async function loadFonts(specs?: string[]): Promise<void> {
|
|
5
|
+
if (!specs || specs.length === 0) return;
|
|
6
|
+
try {
|
|
7
|
+
await Promise.all(specs.map((s) => document.fonts.load(s)));
|
|
8
|
+
await document.fonts.ready;
|
|
9
|
+
} catch {
|
|
10
|
+
/* font CDN unreachable → fall back to system fonts */
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Smoother default downscaling for art-heavy slots. Pixel-art games omit this. */
|
|
15
|
+
export function applyTextureDefaults(): void {
|
|
16
|
+
TextureSource.defaultOptions.autoGenerateMipmaps = true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Idempotent double-boot guard. Returns true the first time, false thereafter. */
|
|
20
|
+
export function bootGuard(flag = '__e8SlotBooted__'): boolean {
|
|
21
|
+
const w = window as unknown as Record<string, boolean>;
|
|
22
|
+
if (w[flag]) return false;
|
|
23
|
+
w[flag] = true;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GameModel } from '@energy8platform/platform-core/game-spec';
|
|
2
|
+
|
|
3
|
+
/** Reverse the model's modeMap (Stake bet mode → SDK action key) for replay labelling/cost. */
|
|
4
|
+
export function resolveReplayBonusId(model: GameModel, stakeMode: string): string {
|
|
5
|
+
for (const [action, mode] of Object.entries(model.modeMap)) {
|
|
6
|
+
if (mode === stakeMode) return action;
|
|
7
|
+
}
|
|
8
|
+
return stakeMode;
|
|
9
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
2
|
+
import type { RenderContext, SlotSceneController } from './sceneController';
|
|
3
|
+
|
|
4
|
+
export interface RunRoundDeps<T extends SlotSpinResultBase> {
|
|
5
|
+
play(action: string, bet: number, roundId?: string): Promise<T>;
|
|
6
|
+
ack(): void;
|
|
7
|
+
scene: Pick<SlotSceneController<T>, 'onSpin'>;
|
|
8
|
+
/** Build the per-round render context (without signal — runRound injects it per segment). */
|
|
9
|
+
context(action: string): Omit<RenderContext, 'signal'> & { signal?: AbortSignal };
|
|
10
|
+
roleOf(action: string): string | undefined;
|
|
11
|
+
afterPresent?(result: T): void;
|
|
12
|
+
/** Once, before the first segment is played (player pressed spin). */
|
|
13
|
+
onSpinStart?(): void;
|
|
14
|
+
/** Once, after the full drain. */
|
|
15
|
+
onSpinEnd?(last: T, ctx: RenderContext): void;
|
|
16
|
+
/** Fires when entering a non-BASE mode (first free segment). */
|
|
17
|
+
onEnterMode?(trigger: T, ctx: RenderContext): Promise<void>;
|
|
18
|
+
/** Fires after the last segment of a mode. */
|
|
19
|
+
onExitMode?(last: T, ctx: RenderContext): Promise<void>;
|
|
20
|
+
/** Hands the host the AbortController for the segment about to present (for skip). */
|
|
21
|
+
beforeSegment?(ac: AbortController): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runRound<T extends SlotSpinResultBase>(
|
|
25
|
+
deps: RunRoundDeps<T>,
|
|
26
|
+
action: string,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
deps.onSpinStart?.();
|
|
29
|
+
|
|
30
|
+
const ctxBet = deps.context(action).bet;
|
|
31
|
+
|
|
32
|
+
const segment = async (a: string, roundId: string | undefined): Promise<{ r: T; ctx: RenderContext }> => {
|
|
33
|
+
const ac = new AbortController();
|
|
34
|
+
deps.beforeSegment?.(ac);
|
|
35
|
+
const r = await deps.play(a, ctxBet, roundId);
|
|
36
|
+
const ctx = { ...deps.context(action), signal: ac.signal } as RenderContext;
|
|
37
|
+
await deps.scene.onSpin(r, ctx);
|
|
38
|
+
deps.ack();
|
|
39
|
+
deps.afterPresent?.(r);
|
|
40
|
+
return { r, ctx };
|
|
41
|
+
};
|
|
42
|
+
let { r, ctx } = await segment(action, undefined);
|
|
43
|
+
|
|
44
|
+
let inMode = false;
|
|
45
|
+
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
46
|
+
const next = r.nextActions[0];
|
|
47
|
+
if (!inMode && deps.roleOf(next) === 'free') {
|
|
48
|
+
inMode = true;
|
|
49
|
+
await deps.onEnterMode?.(r, ctx);
|
|
50
|
+
}
|
|
51
|
+
({ r, ctx } = await segment(next, r.roundId));
|
|
52
|
+
}
|
|
53
|
+
if (inMode) await deps.onExitMode?.(r, ctx);
|
|
54
|
+
deps.onSpinEnd?.(r, ctx);
|
|
55
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AudioManager } from '../audio/AudioManager';
|
|
2
|
+
import type { SceneAudio } from './sceneController';
|
|
3
|
+
|
|
4
|
+
/** Wrap the engine's AudioManager into the playback-only handle a scene receives. Volume/mute are
|
|
5
|
+
* deliberately omitted — those are driven by the shell's settingChange → host. */
|
|
6
|
+
export function createSceneAudio(audio: AudioManager): SceneAudio {
|
|
7
|
+
return {
|
|
8
|
+
play: (alias, opts) => audio.play(alias, 'sfx', opts),
|
|
9
|
+
playMusic: (alias, fadeMs) => audio.playMusic(alias, fadeMs),
|
|
10
|
+
stopMusic: () => audio.stopMusic(),
|
|
11
|
+
duck: (factor) => audio.duckMusic(factor),
|
|
12
|
+
unduck: () => audio.unduckMusic(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Container } from 'pixi.js';
|
|
2
|
+
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
3
|
+
|
|
4
|
+
/** Everything a scene needs to render one segment. The host builds it per segment. */
|
|
5
|
+
export interface RenderContext {
|
|
6
|
+
/** Bet for this round (major units). Stable for the whole round. */
|
|
7
|
+
bet: number;
|
|
8
|
+
/** Trigger action in the game's own vocabulary ('spin' | 'ante' | 'buy_bonus' | …). */
|
|
9
|
+
action: string;
|
|
10
|
+
/** Stake bet-mode of the round ('BASE' | 'ANTE' | 'BONUS' | …). */
|
|
11
|
+
mode: string;
|
|
12
|
+
/** Currency-aware money formatter. */
|
|
13
|
+
formatAmount(value: number): string;
|
|
14
|
+
/** LIVE turbo level (0 = off, 1..3 = escalating speed). Read at access. */
|
|
15
|
+
readonly turbo: number;
|
|
16
|
+
/** Aborted when the player skips this segment (double-tap). The scene's async pacing can race or
|
|
17
|
+
* cancel on it; on abort the scene must collapse to the segment's final visual state. */
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Playback-only audio handle. Volume/mute are shell settings → host, never the scene. */
|
|
22
|
+
export interface SceneAudio {
|
|
23
|
+
play(alias: string, opts?: { volume?: number; loop?: boolean; speed?: number }): void;
|
|
24
|
+
playMusic(alias: string, fadeMs?: number): void;
|
|
25
|
+
stopMusic(): void;
|
|
26
|
+
duck(factor: number): void;
|
|
27
|
+
unduck(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OverlayShowOptions {
|
|
31
|
+
/** Draw the overlay content into `container` (sized to the canvas). */
|
|
32
|
+
build(container: Container, size: { width: number; height: number }): void;
|
|
33
|
+
/** Auto-close after N ms (combine with closeOn — whichever fires first). */
|
|
34
|
+
autoCloseMs?: number;
|
|
35
|
+
/** Dismiss on a single tap. Default 'tap'. Set false to require an explicit close(). */
|
|
36
|
+
closeOn?: 'tap' | false;
|
|
37
|
+
/** Optional host-drawn backdrop alpha (0..1). Default: none (game draws its own). */
|
|
38
|
+
dim?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Single host-owned layer above scene + shell. Eats pointer events so shell controls are
|
|
42
|
+
* unreachable while open. */
|
|
43
|
+
export interface SceneOverlay {
|
|
44
|
+
/** Resolves when the overlay closes. Rejects if one is already open. */
|
|
45
|
+
show(opts: OverlayShowOptions): Promise<void>;
|
|
46
|
+
close(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SceneShell {
|
|
50
|
+
/** Live insets (px). `bottom` = the shell bar height; read inside onResize. */
|
|
51
|
+
readonly safeArea: { top: number; right: number; bottom: number; left: number };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AutoplaySceneState {
|
|
55
|
+
running: boolean;
|
|
56
|
+
remaining: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Stable capabilities injected once via onCreate. */
|
|
60
|
+
export interface SceneApi {
|
|
61
|
+
audio: SceneAudio;
|
|
62
|
+
overlay: SceneOverlay;
|
|
63
|
+
shell: SceneShell;
|
|
64
|
+
formatAmount(value: number): string;
|
|
65
|
+
readonly bet: number;
|
|
66
|
+
readonly mode: string;
|
|
67
|
+
readonly turbo: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The contract a slot scene implements. The HOST owns the play→present→ack→drain loop and the
|
|
71
|
+
* shell; the scene only renders + reacts. The core spin-lifecycle hooks are REQUIRED (implement
|
|
72
|
+
* them — empty bodies are fine where a game has nothing to do); the incidental reactions below
|
|
73
|
+
* stay optional. */
|
|
74
|
+
export interface SlotSceneController<T extends SlotSpinResultBase = SlotSpinResultBase> {
|
|
75
|
+
/** Injected ONCE before the first round — capabilities, subscriptions, one-time setup. */
|
|
76
|
+
onCreate(api: SceneApi): void;
|
|
77
|
+
/** Fires once per round when the player presses spin (before the network result). */
|
|
78
|
+
onSpinStart(): void;
|
|
79
|
+
/** Render ONE segment (a spin or one free spin). Await your own pacing. */
|
|
80
|
+
onSpin(result: T, ctx: RenderContext): Promise<void>;
|
|
81
|
+
/** Fires when ctx.mode changes between segments (entering a non-BASE mode/bonus). */
|
|
82
|
+
onEnterMode(result: T, ctx: RenderContext): Promise<void>;
|
|
83
|
+
/** Fires when leaving a mode (back toward BASE). */
|
|
84
|
+
onExitMode(result: T, ctx: RenderContext): Promise<void>;
|
|
85
|
+
/** Fires once per round after the full drain (controls unlocked). */
|
|
86
|
+
onSpinEnd(result: T, ctx: RenderContext): void;
|
|
87
|
+
/** Shell events (may fire while idle) — optional. */
|
|
88
|
+
onBetChanged?(bet: number): void;
|
|
89
|
+
onTurboChanged?(level: number): void;
|
|
90
|
+
onAutoplayChanged?(state: AutoplaySceneState): void;
|
|
91
|
+
/** Double-tap skip during an active onSpin (gated by the skipGesture setting). */
|
|
92
|
+
onSkip?(): void;
|
|
93
|
+
/** Tab focus lost / regained. */
|
|
94
|
+
onPause?(): void;
|
|
95
|
+
onResume?(): void;
|
|
96
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SceneRegistration } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pick the scene to START with, given the registered scenes (in order) and the launch mode.
|
|
5
|
+
*
|
|
6
|
+
* Rules:
|
|
7
|
+
* - On a replay launch, scenes flagged `skipOnReplay` are NOT eligible to start (they stay
|
|
8
|
+
* registered for `goto`, they just aren't auto-started) — so a leading intro is skipped and
|
|
9
|
+
* the game scene starts directly.
|
|
10
|
+
* - An explicit `startScene` wins, but only if that scene is itself eligible; otherwise the first
|
|
11
|
+
* eligible scene wins.
|
|
12
|
+
* - Falls back to the first scene unconditionally if nothing is eligible (degenerate config).
|
|
13
|
+
*/
|
|
14
|
+
export function resolveStartScene(
|
|
15
|
+
scenes: SceneRegistration[],
|
|
16
|
+
isReplay: boolean,
|
|
17
|
+
explicitStart?: string,
|
|
18
|
+
): string {
|
|
19
|
+
const eligible = scenes.filter((s) => !(isReplay && s.skipOnReplay));
|
|
20
|
+
if (explicitStart) {
|
|
21
|
+
const ok = eligible.find((s) => s.key === explicitStart);
|
|
22
|
+
if (ok) return ok.key;
|
|
23
|
+
}
|
|
24
|
+
return eligible[0]?.key ?? scenes[0]?.key;
|
|
25
|
+
}
|