@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,543 @@
|
|
|
1
|
+
// packages/game-engine/src/host/createSlotGame.ts
|
|
2
|
+
import { Container } from 'pixi.js';
|
|
3
|
+
import { GameApplication } from '../core';
|
|
4
|
+
import { buildAppConfig } from './buildConfig';
|
|
5
|
+
import { loadFonts, applyTextureDefaults, bootGuard } from './preboot';
|
|
6
|
+
import { showFatalError, installGlobalErrorHandlers } from './fatalError';
|
|
7
|
+
import type { CreateSlotGameOptions, SlotGameHandle } from './types';
|
|
8
|
+
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
9
|
+
import type { ShellMode } from '@energy8platform/pixi-shell';
|
|
10
|
+
import type { SceneApi, SlotSceneController } from './sceneController';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
|
|
14
|
+
* → register scene → start. Collapses the per-game main.ts boilerplate.
|
|
15
|
+
*
|
|
16
|
+
* Not unit-tested: GameApplication.init() drives Pixi, which hangs in headless
|
|
17
|
+
* environments. The pure helpers it sequences are unit-tested individually.
|
|
18
|
+
*/
|
|
19
|
+
export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResultBase>(
|
|
20
|
+
opts: CreateSlotGameOptions<T>,
|
|
21
|
+
): Promise<SlotGameHandle> {
|
|
22
|
+
if (!bootGuard()) throw new Error('createSlotGame() called more than once');
|
|
23
|
+
|
|
24
|
+
if (opts.textureDefaults) applyTextureDefaults();
|
|
25
|
+
await loadFonts(opts.fonts);
|
|
26
|
+
|
|
27
|
+
// Declared up front so `fatal` can route errors through the shell's own modal once it exists.
|
|
28
|
+
let shell: SlotGameHandle['shell'] = null;
|
|
29
|
+
|
|
30
|
+
const fatal = (message: string) => {
|
|
31
|
+
if (opts.onFatalError) return opts.onFatalError(message);
|
|
32
|
+
// Once the shell is up, use ITS branded modal (consistent chrome, social vocabulary, fit
|
|
33
|
+
// scaling) rather than the bare DOM fallback. Errors thrown before the shell boots (asset
|
|
34
|
+
// load, SDK handshake) still get the standalone overlay.
|
|
35
|
+
if (shell) {
|
|
36
|
+
shell.openModal({
|
|
37
|
+
availableClose: false,
|
|
38
|
+
title: shell.t('Something went wrong'),
|
|
39
|
+
body: shell.t(message),
|
|
40
|
+
actions: [{ title: shell.t('Reload'), on: () => { try { location.reload(); } catch { /* non-browser */ } } }],
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
showFatalError(opts.container ?? '#game', message);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Global safety net: surface ANY uncaught error / unhandled rejection (e.g. an
|
|
48
|
+
// `Uncaught (in promise) SDKError` on spin) through the same fatal modal so games
|
|
49
|
+
// don't have to handle errors themselves. Honours the onFatalError override.
|
|
50
|
+
installGlobalErrorHandlers(opts.container ?? '#game', fatal);
|
|
51
|
+
|
|
52
|
+
let stakeBridge: SlotGameHandle['stakeBridge'] = null;
|
|
53
|
+
let isStakeNow = false;
|
|
54
|
+
if (opts.stake) {
|
|
55
|
+
const { isStakeLaunch } = await import('@energy8platform/stake-bridge/detect');
|
|
56
|
+
isStakeNow = isStakeLaunch(location.href);
|
|
57
|
+
if (isStakeNow) {
|
|
58
|
+
try {
|
|
59
|
+
const { StakeBridge } = await import('@energy8platform/stake-bridge');
|
|
60
|
+
stakeBridge = new StakeBridge({
|
|
61
|
+
devMode: true,
|
|
62
|
+
// In the dev harness the iframe is served over http and the dev-RGS
|
|
63
|
+
// lives at the same (http) origin; force the matching scheme so
|
|
64
|
+
// RGSClient can reach it. Prod (https) is unaffected.
|
|
65
|
+
protocol: location.protocol === 'http:' ? 'http' : 'https',
|
|
66
|
+
adapter: opts.stake.adapter,
|
|
67
|
+
modeMap: opts.model.modeMap,
|
|
68
|
+
gameId: opts.model.spec.id,
|
|
69
|
+
url: location.href,
|
|
70
|
+
});
|
|
71
|
+
await stakeBridge.ready();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
fatal('Could not connect to the game server. Please reload.');
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const game = new GameApplication(buildAppConfig(opts, isStakeNow));
|
|
80
|
+
|
|
81
|
+
// Register EVERY scene up front so any of them can navigate to any other.
|
|
82
|
+
for (const { key, scene } of opts.scenes) game.scenes.register(key, scene);
|
|
83
|
+
|
|
84
|
+
// Navigation injected into the start data of every scene: a scene reads `goto`
|
|
85
|
+
// from its `onEnter(data)` and calls it to switch scenes (intro → game, etc.).
|
|
86
|
+
const goto = (key: string, data?: unknown) => {
|
|
87
|
+
void game.scenes.goto(key, { ...(data as object), goto });
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Pick the start scene from the ordered list + launch mode: a replay launch skips any leading
|
|
91
|
+
// `skipOnReplay` scene (the intro) and starts directly on the game scene.
|
|
92
|
+
const { resolveStartScene } = await import('./sceneStart');
|
|
93
|
+
const startScene = resolveStartScene(opts.scenes, !!stakeBridge?.isReplay, opts.startScene);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await game.start(startScene, { ...(opts.startData as object), goto });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
fatal('Could not start the game.');
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let currentBet = opts.model.spec.defaultBet ?? opts.model.spec.betLevels[0];
|
|
103
|
+
|
|
104
|
+
// Build slotPlay FIRST — bindGameScene() needs it to be in scope.
|
|
105
|
+
const { createSlotPlay, enrichRoundMeta } = await import('./slotPlay');
|
|
106
|
+
|
|
107
|
+
// Injected once per controller scene the first time it becomes current (see `ensureCreated`).
|
|
108
|
+
// `sceneApi` is assembled inside the shell block; until then injection is a no-op (a shell-less
|
|
109
|
+
// launch never builds the api, so a controller scene simply never receives onCreate).
|
|
110
|
+
let sceneApi: SceneApi | null = null;
|
|
111
|
+
const createdScenes = new WeakSet<object>();
|
|
112
|
+
const ensureCreated = (s: SlotSceneController<T>) => {
|
|
113
|
+
if (!sceneApi || createdScenes.has(s)) return;
|
|
114
|
+
createdScenes.add(s);
|
|
115
|
+
s.onCreate?.(sceneApi);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The current scene IFF it implements the SlotSceneController contract (duck-typed on
|
|
119
|
+
* `onSpin`). The host drives the play loop against whichever scene is current. Injects the
|
|
120
|
+
* SceneApi via onCreate the first time a controller scene is seen. */
|
|
121
|
+
const gameScene = () => {
|
|
122
|
+
const s = game.scenes.current?.scene as Partial<SlotSceneController<T>> | undefined;
|
|
123
|
+
if (typeof s?.onSpin !== 'function') return undefined;
|
|
124
|
+
const scene = s as SlotSceneController<T>;
|
|
125
|
+
ensureCreated(scene);
|
|
126
|
+
return scene;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const { runRound } = await import('./runRound');
|
|
130
|
+
const { createBalanceGate } = await import('./balanceGate');
|
|
131
|
+
const { createFreeSpinsCounter } = await import('./freeSpinsCounter');
|
|
132
|
+
const { resolvePlayError } = await import('./playError');
|
|
133
|
+
|
|
134
|
+
// slotPlay references shell via closure — define it after shell is assigned below.
|
|
135
|
+
// We use a late-binding wrapper so the closure captures the variable, not null.
|
|
136
|
+
const slotPlay = createSlotPlay<T>({
|
|
137
|
+
play: (p) => game.platformSession!.play(p),
|
|
138
|
+
normalize: opts.normalize,
|
|
139
|
+
// ACK the result AFTER the scene animates it (the scene calls host.ack()). On Stake this
|
|
140
|
+
// triggers /wallet/end-round so a winning round settles post-animation instead of staying
|
|
141
|
+
// open and blocking the next spin.
|
|
142
|
+
ack: (raw) => game.platformSession!.playAck(raw as import('@energy8platform/platform-core').PlayResultData),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (opts.shell) {
|
|
146
|
+
const { createPixiShell } = await import('@energy8platform/pixi-shell');
|
|
147
|
+
const { buildShellConfig } = await import('./shellConfig');
|
|
148
|
+
const { resolveReplayBonusId } = await import('./replay');
|
|
149
|
+
|
|
150
|
+
const ps = game.platformSession;
|
|
151
|
+
const balance = (game.initData?.balance as number | undefined) ?? 0;
|
|
152
|
+
const isReplay = !!stakeBridge?.isReplay;
|
|
153
|
+
const mode: ShellMode = isReplay ? 'replay' : 'base';
|
|
154
|
+
// initData.config carries the Stake bridge's currency/social/disclaimer surface (GameConfigData);
|
|
155
|
+
// all are absent in non-stake/dev launches → graceful fallbacks downstream.
|
|
156
|
+
const initData = game.initData as {
|
|
157
|
+
config?: {
|
|
158
|
+
socialMode?: boolean;
|
|
159
|
+
disclaimerLines?: string[];
|
|
160
|
+
currency?: { code: string; symbol: string; decimals: number; symbolAfter?: boolean };
|
|
161
|
+
jurisdiction?: import('./shellConfig').JurisdictionRestrictions;
|
|
162
|
+
betLevels?: number[];
|
|
163
|
+
defaultBet?: number;
|
|
164
|
+
stake?: { defaultBetLevel?: number };
|
|
165
|
+
};
|
|
166
|
+
lang?: string;
|
|
167
|
+
} | null;
|
|
168
|
+
const config = initData?.config;
|
|
169
|
+
const { resolveCurrency } = await import('./shellConfig');
|
|
170
|
+
// SINGLE source of truth for the symbol: the Stake bridge already puts a full CurrencyMetaData
|
|
171
|
+
// (symbol + placement) on initData.config.currency. In the non-stake/devBridge path that meta
|
|
172
|
+
// is absent and we only have the spec's currency CODE — resolve it through the SAME table
|
|
173
|
+
// (stake-bridge's lookupCurrency) so e.g. 'EUR' renders as '€', not the literal text "EUR".
|
|
174
|
+
// stake-bridge ships with every scaffold; if it's somehow absent we degrade to the code.
|
|
175
|
+
let currencyMeta = config?.currency;
|
|
176
|
+
if (!currencyMeta?.symbol && opts.model.spec.currency) {
|
|
177
|
+
try {
|
|
178
|
+
const { lookupCurrency } = await import('@energy8platform/stake-bridge');
|
|
179
|
+
currencyMeta = lookupCurrency(opts.model.spec.currency);
|
|
180
|
+
} catch { /* stake-bridge not installed — resolveCurrency falls back to the code */ }
|
|
181
|
+
}
|
|
182
|
+
const runtime = {
|
|
183
|
+
balance,
|
|
184
|
+
currency: resolveCurrency(currencyMeta, opts.model.spec.currency),
|
|
185
|
+
language: initData?.lang,
|
|
186
|
+
mode,
|
|
187
|
+
social: config?.socialMode,
|
|
188
|
+
disclaimerLines: config?.disclaimerLines,
|
|
189
|
+
jurisdiction: config?.jurisdiction,
|
|
190
|
+
// Currency-specific ladder + per-currency default from /wallet/authenticate (Stake);
|
|
191
|
+
// absent on dev/devBridge → buildShellConfig falls back to the spec.
|
|
192
|
+
betLevels: config?.betLevels,
|
|
193
|
+
defaultBet: config?.stake?.defaultBetLevel ?? config?.defaultBet,
|
|
194
|
+
};
|
|
195
|
+
if (opts.dev) {
|
|
196
|
+
// Dev-only diagnostic. Logged as PLAIN STRINGS (not collapsed objects) so the values are
|
|
197
|
+
// readable in the console without expanding. If the shown symbol is a bare code ("EUR")
|
|
198
|
+
// instead of a glyph ("€"), paste this whole line.
|
|
199
|
+
const cc = config?.currency as { code?: string; symbol?: string } | undefined;
|
|
200
|
+
console.info(
|
|
201
|
+
`[e8] currency → bridge.code=${cc?.code ?? '∅'} bridge.symbol=${cc?.symbol ?? '∅'} ` +
|
|
202
|
+
`| spec=${opts.model.spec.currency ?? '∅'} ` +
|
|
203
|
+
`| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
// pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
|
|
207
|
+
// scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
|
|
208
|
+
// The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
|
|
209
|
+
shell = createPixiShell({ ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer });
|
|
210
|
+
// Scope the bar to the slot scene: show only when a SlotSceneController scene is current
|
|
211
|
+
// (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
|
|
212
|
+
shell.setVisible(!!gameScene());
|
|
213
|
+
game.scenes.on('change', () => shell!.setVisible(!!gameScene()));
|
|
214
|
+
// The gate tracks the live wallet (for the affordability guard) but only PAINTS the balance per
|
|
215
|
+
// the HUD-timing rule: the debit is buffered during play→present and shown at afterPresent; the
|
|
216
|
+
// async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
|
|
217
|
+
// is the single source for both the displayed balance and `ensureAffordable`.
|
|
218
|
+
const balanceGate = createBalanceGate((b) => shell!.setBalance(b), balance);
|
|
219
|
+
ps?.on('balanceUpdate', (d: { balance: number }) => { balanceGate.onBalance(d.balance); });
|
|
220
|
+
|
|
221
|
+
// Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
|
|
222
|
+
let currentTurbo = shell.state.turbo;
|
|
223
|
+
shell.on('turboChange', (level: number) => { currentTurbo = level; gameScene()?.onTurboChanged?.(level); });
|
|
224
|
+
// Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
|
|
225
|
+
const skipEnabled = opts.skipGesture ?? true;
|
|
226
|
+
|
|
227
|
+
// Shell settings → engine state. Sound/volume map onto the AudioManager.
|
|
228
|
+
shell.on('settingChange', ({ key, value }: { key: string; value: unknown }) => {
|
|
229
|
+
switch (key) {
|
|
230
|
+
case 'sound': value ? game.audio.unmuteAll() : game.audio.muteAll(); break;
|
|
231
|
+
case 'master': game.audio.setMasterVolume(Number(value)); break;
|
|
232
|
+
case 'music': game.audio.setVolume('music', Number(value)); break;
|
|
233
|
+
case 'sfx': game.audio.setVolume('sfx', Number(value)); break;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Overlay layer sits ABOVE the shell (the shell already mounted its root onto the uiLayer;
|
|
238
|
+
// adding ours afterwards keeps it on top). It eats pointer events while open so shell controls
|
|
239
|
+
// are unreachable. Mounted on the same unscaled UI layer; tracks viewport via game's 'resize'.
|
|
240
|
+
const { createSceneAudio } = await import('./sceneAudio');
|
|
241
|
+
const { createOverlayController } = await import('./overlayController');
|
|
242
|
+
const overlayLayer = new Container();
|
|
243
|
+
overlayLayer.label = 'overlay';
|
|
244
|
+
game.uiLayer.addChild(overlayLayer);
|
|
245
|
+
const overlayCtl = createOverlayController({
|
|
246
|
+
parent: overlayLayer,
|
|
247
|
+
size: () => ({ width: game.app.screen.width, height: game.app.screen.height }),
|
|
248
|
+
});
|
|
249
|
+
game.on('resize', ({ width, height }: { width: number; height: number }) =>
|
|
250
|
+
overlayCtl.resize(width, height),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// Capabilities injected once per controller scene via onCreate (see `gameScene`/`ensureCreated`).
|
|
254
|
+
sceneApi = {
|
|
255
|
+
audio: createSceneAudio(game.audio),
|
|
256
|
+
overlay: overlayCtl.overlay,
|
|
257
|
+
shell: { get safeArea() { return shell!.safeArea; } },
|
|
258
|
+
formatAmount: (v) => shell!.formatWin(v),
|
|
259
|
+
get bet() { return currentBet; },
|
|
260
|
+
get mode() { return opts.model.modeMap['spin'] ?? 'BASE'; },
|
|
261
|
+
get turbo() { return currentTurbo; },
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const roleOf = (action: string) => opts.model.spec.actions[action]?.role;
|
|
265
|
+
// The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
|
|
266
|
+
// attaches its own. So makeContext returns everything BUT `signal`.
|
|
267
|
+
const makeContext = (
|
|
268
|
+
action: string,
|
|
269
|
+
): Omit<import('./sceneController').RenderContext, 'signal'> => ({
|
|
270
|
+
bet: currentBet,
|
|
271
|
+
action,
|
|
272
|
+
mode: opts.model.modeMap[action] ?? action.toUpperCase(),
|
|
273
|
+
formatAmount: (v) => shell!.formatWin(v),
|
|
274
|
+
get turbo() { return currentTurbo; },
|
|
275
|
+
});
|
|
276
|
+
// Play-error + connection handling. A play rejection is classified into a player-facing modal
|
|
277
|
+
// (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
|
|
278
|
+
// overlay is suppressed while a play-error modal owns the screen.
|
|
279
|
+
let playErrorOpen = false;
|
|
280
|
+
let stopAutoplay: () => void = () => {}; // wired to the autoplay loop once it's created (below)
|
|
281
|
+
const showPlayError = (err: unknown): void => {
|
|
282
|
+
stopAutoplay(); // a play error halts an autoplay run (the .catch swallows, so stop explicitly)
|
|
283
|
+
const v = resolvePlayError(err);
|
|
284
|
+
playErrorOpen = true;
|
|
285
|
+
shell!.openModal({
|
|
286
|
+
availableClose: !v.reload,
|
|
287
|
+
title: shell!.t(v.title),
|
|
288
|
+
body: shell!.t(v.body),
|
|
289
|
+
actions: v.reload
|
|
290
|
+
? [{ title: shell!.t('Reload'), on: () => { try { window.location.reload(); } catch { /* non-browser */ } } }]
|
|
291
|
+
: [{ title: shell!.t('OK'), on: () => { playErrorOpen = false; } }],
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
ps?.on('connectionStateChanged', (s: { status: string }) => {
|
|
295
|
+
if (s.status === 'restored') { if (!playErrorOpen) shell!.closeModal(); return; }
|
|
296
|
+
if (playErrorOpen) return; // a play-error modal owns the screen — don't mask it with "reconnecting"
|
|
297
|
+
shell!.openModal({
|
|
298
|
+
availableClose: false,
|
|
299
|
+
title: shell!.t('Reconnecting…'),
|
|
300
|
+
body: shell!.t('Lost connection to the game server. Trying to reconnect…'),
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Skip state: `currentSegmentAbort` is the controller for the segment presently animating;
|
|
305
|
+
// `presenting` is true for the whole play→drain window (gates the double-tap detector so taps
|
|
306
|
+
// only skip while a round is animating).
|
|
307
|
+
let currentSegmentAbort: AbortController | null = null;
|
|
308
|
+
let presenting = false;
|
|
309
|
+
|
|
310
|
+
// Double-tap skip: a double-tap on the play area aborts the current segment (the scene collapses
|
|
311
|
+
// to its final visual via ctx.signal) and notifies the scene's onSkip. Gated by the shell's
|
|
312
|
+
// skip-gesture setting (`skipEnabled`) and only active while a round is presenting.
|
|
313
|
+
const { createDoubleTapSkip } = await import('./skipGesture');
|
|
314
|
+
const skip = createDoubleTapSkip({
|
|
315
|
+
enabled: () => skipEnabled,
|
|
316
|
+
active: () => presenting,
|
|
317
|
+
onSkip: () => { currentSegmentAbort?.abort(); gameScene()?.onSkip?.(); },
|
|
318
|
+
});
|
|
319
|
+
// Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
|
|
320
|
+
// lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
|
|
321
|
+
game.scenes.root.eventMode = 'static';
|
|
322
|
+
game.scenes.root.on('pointertap', () => skip.tap(performance.now()));
|
|
323
|
+
|
|
324
|
+
// Full auto-pause: on tab blur, freeze the ticker (stops tweens/onUpdate/in-flight onSpin),
|
|
325
|
+
// duck music to silence, hold autoplay, and notify the scene. On focus, reverse it all.
|
|
326
|
+
// `stopAutoplay` is reassigned in the base-mode block below — the closure reads it live.
|
|
327
|
+
const { createPauseController } = await import('./pauseController');
|
|
328
|
+
createPauseController({
|
|
329
|
+
isHidden: () => typeof document !== 'undefined' && document.hidden,
|
|
330
|
+
subscribe: (cb) => {
|
|
331
|
+
if (typeof document === 'undefined') return () => {};
|
|
332
|
+
document.addEventListener('visibilitychange', cb);
|
|
333
|
+
return () => document.removeEventListener('visibilitychange', cb);
|
|
334
|
+
},
|
|
335
|
+
onHidden: () => {
|
|
336
|
+
game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
|
|
337
|
+
game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
|
|
338
|
+
stopAutoplay(); // hold autoplay — don't start the next auto-round
|
|
339
|
+
gameScene()?.onPause?.();
|
|
340
|
+
},
|
|
341
|
+
onVisible: () => {
|
|
342
|
+
game.app.ticker.start();
|
|
343
|
+
game.audio.unduckMusic();
|
|
344
|
+
gameScene()?.onResume?.();
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
/** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
|
|
349
|
+
* update only AFTER each onSpin(), per the HUD-timing requirement. */
|
|
350
|
+
const playRound = (action: string) => {
|
|
351
|
+
const scene = gameScene();
|
|
352
|
+
if (!scene) return;
|
|
353
|
+
// Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
|
|
354
|
+
// (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
|
|
355
|
+
// the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
|
|
356
|
+
let inBonus = false;
|
|
357
|
+
let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
|
|
358
|
+
const fsCounter = createFreeSpinsCounter();
|
|
359
|
+
shell!.setBusy(true); // block re-spin / spacebar while the round plays out
|
|
360
|
+
presenting = true; // open the skip window for the whole play→drain
|
|
361
|
+
// RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
|
|
362
|
+
// animation has finished — returning void would reopen it instantly, over a running animation.
|
|
363
|
+
return runRound<T>(
|
|
364
|
+
{
|
|
365
|
+
// Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
|
|
366
|
+
play: (a, b, rid) => { balanceGate.beginPlay(); return slotPlay.play(a, b, rid); },
|
|
367
|
+
ack: slotPlay.ack,
|
|
368
|
+
scene,
|
|
369
|
+
context: makeContext,
|
|
370
|
+
roleOf,
|
|
371
|
+
// Hand the host the per-segment AbortController so a double-tap can skip the live segment.
|
|
372
|
+
beforeSegment: (ac) => { currentSegmentAbort = ac; },
|
|
373
|
+
onSpinStart: () => scene.onSpinStart?.(),
|
|
374
|
+
onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
|
|
375
|
+
afterPresent: (r) => {
|
|
376
|
+
// WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
|
|
377
|
+
// free-spins counter (totalWin) below, not the WIN readout.
|
|
378
|
+
shell!.setWin(r.totalWin - prevWin);
|
|
379
|
+
prevWin = r.totalWin;
|
|
380
|
+
balanceGate.afterPresent();
|
|
381
|
+
if (inBonus) shell!.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
|
|
382
|
+
},
|
|
383
|
+
onEnterMode: async (trigger, ctx) => {
|
|
384
|
+
inBonus = true;
|
|
385
|
+
shell!.setMode('freeSpins');
|
|
386
|
+
shell!.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
|
|
387
|
+
await scene.onEnterMode?.(trigger, ctx);
|
|
388
|
+
},
|
|
389
|
+
onExitMode: async (last, ctx) => {
|
|
390
|
+
inBonus = false;
|
|
391
|
+
await scene.onExitMode?.(last, ctx);
|
|
392
|
+
shell!.setMode('base');
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
action,
|
|
396
|
+
).catch(showPlayError).finally(() => { presenting = false; shell!.setBusy(false); });
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
|
|
401
|
+
* the bonus start (Continue animates each; Finish fast-forwards without animation), reaching the
|
|
402
|
+
* final ack so /wallet/end-round credits the win — fixing the old resume that presented one
|
|
403
|
+
* snapshot and never settled. The original trigger is gone on reload, so the FS counter here uses
|
|
404
|
+
* the bridge's session counts; FS mode is entered/exited around the drain.
|
|
405
|
+
*/
|
|
406
|
+
const resumeDrain = async (
|
|
407
|
+
firstRaw: import('@energy8platform/platform-core').PlayResultData,
|
|
408
|
+
animate: boolean,
|
|
409
|
+
): Promise<void> => {
|
|
410
|
+
const scene = gameScene();
|
|
411
|
+
if (!scene || !ps) return;
|
|
412
|
+
// A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
|
|
413
|
+
// never-aborted signal to satisfy onSpin's RenderContext.
|
|
414
|
+
const ctx: import('./sceneController').RenderContext = {
|
|
415
|
+
...makeContext((firstRaw as { action?: string }).action ?? 'spin'),
|
|
416
|
+
signal: new AbortController().signal,
|
|
417
|
+
};
|
|
418
|
+
const fsView = (raw: unknown, totalWin: number) => {
|
|
419
|
+
const s = (raw as { session?: { spinsPlayed?: number; spinsRemaining?: number } }).session;
|
|
420
|
+
if (!s) return null;
|
|
421
|
+
// The bridge session counts ALL segments incl. the trigger (segment 0); the free-spins
|
|
422
|
+
// counter is over FREE spins only, so drop the one trigger segment → 1/10, not 2/11.
|
|
423
|
+
const played = s.spinsPlayed ?? 0;
|
|
424
|
+
const current = Math.max(0, played - 1);
|
|
425
|
+
const total = Math.max(0, played + (s.spinsRemaining ?? 0) - 1);
|
|
426
|
+
return { current, total, totalWin };
|
|
427
|
+
};
|
|
428
|
+
let raw = firstRaw;
|
|
429
|
+
let r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
430
|
+
let inBonus = false;
|
|
431
|
+
let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
|
|
432
|
+
const applySegment = async (): Promise<void> => {
|
|
433
|
+
// A recovered open round with remaining segments is a bonus → show FS mode + counter.
|
|
434
|
+
if (!inBonus && !r.complete) { inBonus = true; shell!.setMode('freeSpins'); }
|
|
435
|
+
if (animate) await scene.onSpin(r, ctx);
|
|
436
|
+
if (inBonus) { const v = fsView(raw, r.totalWin); if (v) shell!.setFreeSpins(v); }
|
|
437
|
+
shell!.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
|
|
438
|
+
prevWin = r.totalWin;
|
|
439
|
+
ps!.playAck(raw); // settles via /wallet/end-round on the FINAL segment
|
|
440
|
+
};
|
|
441
|
+
shell!.setBusy(true); // block input while the recovered round drains
|
|
442
|
+
try {
|
|
443
|
+
await applySegment();
|
|
444
|
+
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
445
|
+
raw = (await ps.play({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId })) as
|
|
446
|
+
import('@energy8platform/platform-core').PlayResultData;
|
|
447
|
+
r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
448
|
+
await applySegment();
|
|
449
|
+
}
|
|
450
|
+
if (inBonus) shell!.setMode('base');
|
|
451
|
+
} finally {
|
|
452
|
+
shell!.setBusy(false);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
if (mode === 'base') {
|
|
457
|
+
let activeFeature: string | null = null;
|
|
458
|
+
shell.on('featureActivate', ({ id }: { id: string }) => { activeFeature = id; });
|
|
459
|
+
shell.on('featureDeactivate', ({ id: _id }: { id: string }) => { activeFeature = null; });
|
|
460
|
+
|
|
461
|
+
const { stakeForAction } = await import('./shellConfig');
|
|
462
|
+
// Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
|
|
463
|
+
const ensureAffordable = (action: string): boolean => {
|
|
464
|
+
if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9) return true;
|
|
465
|
+
shell!.openModal({
|
|
466
|
+
availableClose: true,
|
|
467
|
+
title: shell!.t('Insufficient balance'),
|
|
468
|
+
body: shell!.t('You don’t have enough balance for this bet. Lower your bet or top up.'),
|
|
469
|
+
actions: [{ title: shell!.t('OK') }],
|
|
470
|
+
});
|
|
471
|
+
return false;
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
shell.on('spin', () => {
|
|
475
|
+
const action = activeFeature ?? 'spin';
|
|
476
|
+
if (!ensureAffordable(action)) return;
|
|
477
|
+
void playRound(action);
|
|
478
|
+
});
|
|
479
|
+
shell.on('betChange', (bet: number) => { currentBet = bet; gameScene()?.onBetChanged?.(bet); });
|
|
480
|
+
shell.on('buyBonusSelect', ({ id }: { id: string }) => {
|
|
481
|
+
if (!ensureAffordable(id)) return;
|
|
482
|
+
void playRound(id);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Autoplay: the shell owns the picker/confirm/STOP/counter/lockout (all driven by state.autoplay);
|
|
486
|
+
// the host just runs the loop and pushes the per-spin remaining back via setAutoplay.
|
|
487
|
+
const { createAutoplayLoop } = await import('./autoplay');
|
|
488
|
+
const autoplay = createAutoplayLoop({
|
|
489
|
+
resolveAction: () => activeFeature ?? 'spin',
|
|
490
|
+
canAfford: (a) => ensureAffordable(a),
|
|
491
|
+
playRound: (a) => Promise.resolve(playRound(a)),
|
|
492
|
+
onState: (s) => {
|
|
493
|
+
shell!.setAutoplay(s);
|
|
494
|
+
gameScene()?.onAutoplayChanged?.({ running: s.active, remaining: s.remaining });
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
stopAutoplay = () => autoplay.stop();
|
|
498
|
+
shell.on('autoplayStart', (o: { remaining?: number }) => autoplay.start(o?.remaining ?? 0));
|
|
499
|
+
shell.on('autoplayStop', () => autoplay.stop());
|
|
500
|
+
|
|
501
|
+
// Resume offer: when the game scene is (or becomes) current on a reload, ask the host whether
|
|
502
|
+
// a round is still open. If so, offer Continue (replay its animation, then settle) or Finish
|
|
503
|
+
// (settle now). Settlement is the same playAck path a normal spin uses. Runs at most once.
|
|
504
|
+
let resumeOffered = false;
|
|
505
|
+
const offerResume = async () => {
|
|
506
|
+
if (resumeOffered || !shell || !gameScene()) return;
|
|
507
|
+
resumeOffered = true;
|
|
508
|
+
let snap: import('@energy8platform/platform-core').PlayResultData | null = null;
|
|
509
|
+
try { snap = await ps?.getState() ?? null; } catch { snap = null; }
|
|
510
|
+
if (!snap) return;
|
|
511
|
+
shell.openModal({
|
|
512
|
+
availableClose: false,
|
|
513
|
+
title: shell.t('Unfinished round'),
|
|
514
|
+
body: shell.t('You have an unfinished round. Continue it or finish it now?'),
|
|
515
|
+
actions: [
|
|
516
|
+
// Continue: replay the round from the start with animation, then settle.
|
|
517
|
+
{ title: shell.t('Continue'), on: () => { void resumeDrain(snap!, true); } },
|
|
518
|
+
// Finish: fast-forward the remaining segments (no animation) to settle the win now.
|
|
519
|
+
{ title: shell.t('Finish'), on: () => { void resumeDrain(snap!, false); } },
|
|
520
|
+
],
|
|
521
|
+
});
|
|
522
|
+
};
|
|
523
|
+
game.scenes.on('change', () => { void offerResume(); });
|
|
524
|
+
void offerResume();
|
|
525
|
+
} else {
|
|
526
|
+
const stakeMode = stakeBridge?.replayMode ?? 'BASE';
|
|
527
|
+
const bonusId = resolveReplayBonusId(opts.model, stakeMode);
|
|
528
|
+
// The replayed round's OWN bet + payout (fetched up front per Stake rules), not the spec's
|
|
529
|
+
// default bet — otherwise the replay modal always shows bet 1.
|
|
530
|
+
const replayBet = stakeBridge?.replayBet || currentBet;
|
|
531
|
+
currentBet = replayBet;
|
|
532
|
+
// onReplay only spins — the shell reopens the modal after it resolves; never call openReplay inside onReplay (double-open).
|
|
533
|
+
shell.openReplay({
|
|
534
|
+
bonusId,
|
|
535
|
+
bet: replayBet,
|
|
536
|
+
payoutMultiplier: stakeBridge?.replayPayoutMultiplier ?? 0,
|
|
537
|
+
onReplay: () => playRound(bonusId),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { game, stakeBridge, shell };
|
|
543
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// packages/game-engine/src/host/fatalError.ts
|
|
2
|
+
|
|
3
|
+
/** Marker id so the modal is idempotent (first one wins; later calls replace its message). */
|
|
4
|
+
const FATAL_ID = 'e8-fatal-error';
|
|
5
|
+
|
|
6
|
+
/** Pure: extract a human-readable message from any thrown value / event reason. */
|
|
7
|
+
export function fatalMessage(input: unknown): string {
|
|
8
|
+
if (input == null) return 'Something went wrong.';
|
|
9
|
+
if (typeof input === 'string') return input;
|
|
10
|
+
if (input instanceof Error) return input.message || input.name || 'Something went wrong.';
|
|
11
|
+
const anyIn = input as { message?: unknown; reason?: unknown };
|
|
12
|
+
if (typeof anyIn.message === 'string' && anyIn.message) return anyIn.message;
|
|
13
|
+
if (anyIn.reason != null) return fatalMessage(anyIn.reason);
|
|
14
|
+
try {
|
|
15
|
+
return String(input);
|
|
16
|
+
} catch {
|
|
17
|
+
return 'Something went wrong.';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Pure: build the modal overlay element (error text + a Reload button). */
|
|
22
|
+
export function buildFatalErrorModal(message: string, onReload: () => void): HTMLElement {
|
|
23
|
+
const overlay = document.createElement('div');
|
|
24
|
+
overlay.id = FATAL_ID;
|
|
25
|
+
overlay.setAttribute('role', 'alertdialog');
|
|
26
|
+
overlay.style.cssText =
|
|
27
|
+
'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' +
|
|
28
|
+
'background:rgba(10,5,4,0.92);z-index:99999;font-family:system-ui,sans-serif;padding:24px';
|
|
29
|
+
|
|
30
|
+
const card = document.createElement('div');
|
|
31
|
+
card.style.cssText =
|
|
32
|
+
'max-width:420px;width:100%;background:#1a0f0a;border:1px solid #5a3a1e;border-radius:12px;' +
|
|
33
|
+
'padding:28px 24px;text-align:center;box-shadow:0 12px 40px rgba(0,0,0,0.6)';
|
|
34
|
+
|
|
35
|
+
const text = document.createElement('div');
|
|
36
|
+
text.className = 'e8-fatal-message';
|
|
37
|
+
text.style.cssText = 'color:#f0c98a;font:600 17px/1.4 system-ui,sans-serif;margin-bottom:22px';
|
|
38
|
+
text.textContent = message;
|
|
39
|
+
|
|
40
|
+
const button = document.createElement('button');
|
|
41
|
+
button.type = 'button';
|
|
42
|
+
button.className = 'e8-fatal-reload';
|
|
43
|
+
button.textContent = 'Reload';
|
|
44
|
+
button.style.cssText =
|
|
45
|
+
'cursor:pointer;border:none;border-radius:8px;padding:12px 28px;font:600 15px system-ui,sans-serif;' +
|
|
46
|
+
'color:#1a0f0a;background:#f0c98a';
|
|
47
|
+
button.addEventListener('click', onReload);
|
|
48
|
+
|
|
49
|
+
card.appendChild(text);
|
|
50
|
+
card.appendChild(button);
|
|
51
|
+
overlay.appendChild(card);
|
|
52
|
+
return overlay;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render a blocking fatal-error modal with a Reload button. Idempotent: if a modal is already
|
|
57
|
+
* shown, its message is replaced instead of stacking a second overlay.
|
|
58
|
+
*/
|
|
59
|
+
export function showFatalError(container: HTMLElement | string, message: string): void {
|
|
60
|
+
if (typeof document === 'undefined') return;
|
|
61
|
+
const host =
|
|
62
|
+
typeof container === 'string'
|
|
63
|
+
? document.querySelector<HTMLElement>(container) ?? document.body
|
|
64
|
+
: container;
|
|
65
|
+
|
|
66
|
+
const existing = document.getElementById(FATAL_ID);
|
|
67
|
+
if (existing) {
|
|
68
|
+
const msg = existing.querySelector<HTMLElement>('.e8-fatal-message');
|
|
69
|
+
if (msg) msg.textContent = message;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const overlay = buildFatalErrorModal(message, () => {
|
|
74
|
+
try {
|
|
75
|
+
location.reload();
|
|
76
|
+
} catch {
|
|
77
|
+
/* no-op in non-browser environments */
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
host.appendChild(overlay);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Install global handlers so ANY uncaught error or unhandled promise rejection surfaces the
|
|
85
|
+
* fatal modal (game devs don't have to handle errors themselves). `fatal` defaults to the
|
|
86
|
+
* built-in modal targeting `container`. Returns a disposer that removes the listeners.
|
|
87
|
+
*/
|
|
88
|
+
export function installGlobalErrorHandlers(
|
|
89
|
+
container: HTMLElement | string,
|
|
90
|
+
fatal: (message: string) => void = (m) => showFatalError(container, m),
|
|
91
|
+
): () => void {
|
|
92
|
+
if (typeof window === 'undefined') return () => {};
|
|
93
|
+
|
|
94
|
+
const onError = (e: ErrorEvent) => fatal(fatalMessage(e.error ?? e.message));
|
|
95
|
+
const onRejection = (e: PromiseRejectionEvent) => fatal(fatalMessage(e.reason));
|
|
96
|
+
|
|
97
|
+
window.addEventListener('error', onError);
|
|
98
|
+
window.addEventListener('unhandledrejection', onRejection);
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
window.removeEventListener('error', onError);
|
|
102
|
+
window.removeEventListener('unhandledrejection', onRejection);
|
|
103
|
+
};
|
|
104
|
+
}
|