@energy8platform/game-engine 0.34.2 → 0.35.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 +114 -59
- package/dist/audio.cjs.js.map +1 -1
- package/dist/audio.d.ts +25 -0
- package/dist/audio.esm.js +114 -59
- package/dist/audio.esm.js.map +1 -1
- package/dist/core.cjs.js +222 -66
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +25 -0
- package/dist/core.esm.js +223 -67
- package/dist/core.esm.js.map +1 -1
- package/dist/flow.cjs.js +246 -0
- package/dist/flow.cjs.js.map +1 -1
- package/dist/flow.d.ts +192 -33
- package/dist/flow.esm.js +238 -1
- package/dist/flow.esm.js.map +1 -1
- package/dist/host.cjs.js +343 -82
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +82 -2
- package/dist/host.esm.js +344 -83
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +222 -66
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +72 -0
- package/dist/index.esm.js +223 -67
- package/dist/index.esm.js.map +1 -1
- package/dist/scene-devtools.cjs.js +529 -115
- package/dist/scene-devtools.cjs.js.map +1 -1
- package/dist/scene-devtools.d.ts +187 -34
- package/dist/scene-devtools.esm.js +529 -115
- package/dist/scene-devtools.esm.js.map +1 -1
- package/dist/scene.cjs.js +704 -46
- package/dist/scene.cjs.js.map +1 -1
- package/dist/scene.d.ts +228 -41
- package/dist/scene.esm.js +698 -47
- package/dist/scene.esm.js.map +1 -1
- package/package.json +2 -2
- package/src/audio/AudioManager.ts +111 -53
- package/src/core/GameApplication.ts +47 -5
- package/src/host/buildConfig.ts +17 -4
- package/src/host/createSlotGame.ts +114 -12
- package/src/host/index.ts +3 -0
- package/src/host/types.ts +58 -0
- package/src/loading/LoadingScene.ts +76 -2
- package/src/loading/index.ts +6 -0
- package/src/types.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@energy8platform/game-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Universal casino game engine built on PixiJS v8 and @energy8platform/game-sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs.js",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"prepublishOnly": "npm run build"
|
|
94
94
|
},
|
|
95
95
|
"dependencies": {
|
|
96
|
-
"@energy8platform/platform-core": ">=0.
|
|
96
|
+
"@energy8platform/platform-core": ">=0.31.0",
|
|
97
97
|
"@energy8platform/shell": ">=0.7.0"
|
|
98
98
|
},
|
|
99
99
|
"peerDependencies": {
|
|
@@ -37,6 +37,12 @@ export class AudioManager {
|
|
|
37
37
|
private _categories: Record<AudioCategoryName, CategoryState>;
|
|
38
38
|
private _masterGain = 1.0;
|
|
39
39
|
private _currentMusic: string | null = null;
|
|
40
|
+
/** Duck factor (0..1) from duckMusic/unduckMusic. A presentation state, not a player setting. */
|
|
41
|
+
private _musicDuck = 1;
|
|
42
|
+
/** Crossfade ramp (0..1) for the track that is fading IN. 1 whenever no fade is running. */
|
|
43
|
+
private _musicFade = 1;
|
|
44
|
+
/** Generation counter so a superseded crossfade ramp stops writing over the new track's. */
|
|
45
|
+
private _musicFadeToken = 0;
|
|
40
46
|
private _unlocked = false;
|
|
41
47
|
private _unlockHandler: (() => void) | null = null;
|
|
42
48
|
|
|
@@ -106,7 +112,10 @@ export class AudioManager {
|
|
|
106
112
|
if (this._globalMuted || this._categories[category].muted) return;
|
|
107
113
|
|
|
108
114
|
const { sound } = this._soundModule;
|
|
109
|
-
|
|
115
|
+
// The master gain lives on the GLOBAL bus (`sound.volumeAll`, see applyVolumes) and @pixi/sound
|
|
116
|
+
// already multiplies it in — folding it in here as well squared it, so a master of 0.5 played
|
|
117
|
+
// sfx at 0.25.
|
|
118
|
+
const vol = (options?.volume ?? 1) * this._categories[category].volume;
|
|
110
119
|
|
|
111
120
|
try {
|
|
112
121
|
sound.play(alias, {
|
|
@@ -129,47 +138,45 @@ export class AudioManager {
|
|
|
129
138
|
if (!this._initialized || !this._soundModule) return;
|
|
130
139
|
|
|
131
140
|
const { sound } = this._soundModule;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
try { sound.stop(prevAlias); } catch { /* ignore */ }
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// Start new track at zero volume, fade in
|
|
146
|
-
try {
|
|
147
|
-
sound.play(alias, {
|
|
148
|
-
volume: 0,
|
|
149
|
-
loop: true,
|
|
141
|
+
const prevAlias = this._currentMusic;
|
|
142
|
+
const crossfade = !!prevAlias && prevAlias !== alias && fadeDuration > 0;
|
|
143
|
+
|
|
144
|
+
// Retire the outgoing track. Its own SOUND-level volume is the only thing still pointing at it,
|
|
145
|
+
// so fading that to 0 is safe — nothing else writes it once `_currentMusic` has moved on.
|
|
146
|
+
if (prevAlias) {
|
|
147
|
+
if (crossfade) {
|
|
148
|
+
const from = this.soundVolumeOf(prevAlias);
|
|
149
|
+
this.fadeVolume(prevAlias, from, 0, fadeDuration, () => {
|
|
150
|
+
try { sound.stop(prevAlias); } catch { /* ignore */ }
|
|
150
151
|
});
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
|
|
154
|
-
}
|
|
155
|
-
} else {
|
|
156
|
-
// No crossfade — instant switch
|
|
157
|
-
if (this._currentMusic) {
|
|
158
|
-
try { sound.stop(this._currentMusic); } catch { /* ignore */ }
|
|
152
|
+
} else {
|
|
153
|
+
try { sound.stop(prevAlias); } catch { /* ignore */ }
|
|
159
154
|
}
|
|
155
|
+
}
|
|
160
156
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
157
|
+
this._currentMusic = alias;
|
|
158
|
+
this._musicFadeToken++; // any ramp still running belongs to a track we just replaced
|
|
159
|
+
|
|
160
|
+
// Deliberately started even while muted. Global mute is the @pixi/sound CONTEXT mute and a
|
|
161
|
+
// muted music category is a 0 term in `musicGain()` — both already make this inaudible, and
|
|
162
|
+
// both undo themselves the moment the player flips them back. Returning early here instead
|
|
163
|
+
// meant a track begun while muted never existed, so unmuting restored silence until some
|
|
164
|
+
// later mode change happened to switch tracks.
|
|
165
|
+
|
|
166
|
+
// The incoming track plays at INSTANCE volume 1 and carries its whole gain on the SOUND layer
|
|
167
|
+
// (`musicGain()`), which is the layer the slider, the duck and this fade all write. Splitting
|
|
168
|
+
// them across layers is what silenced every crossfade: the track was started at instance volume
|
|
169
|
+
// 0 and the ramp then moved the sound layer, whose product with 0 is 0 for the track's life.
|
|
170
|
+
// The gain is written BEFORE play() so the first frame is never at full volume.
|
|
171
|
+
this._musicFade = crossfade ? 0 : 1;
|
|
172
|
+
this.applyMusicGain();
|
|
173
|
+
try {
|
|
174
|
+
sound.play(alias, { volume: 1, loop: true });
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
|
|
177
|
+
return;
|
|
172
178
|
}
|
|
179
|
+
if (crossfade) this.rampMusicFade(fadeDuration);
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
/**
|
|
@@ -184,6 +191,10 @@ export class AudioManager {
|
|
|
184
191
|
// ignore
|
|
185
192
|
}
|
|
186
193
|
this._currentMusic = null;
|
|
194
|
+
// Retire any running ramp and clear the fade term, so the next track does not inherit a
|
|
195
|
+
// half-finished crossfade and start silent.
|
|
196
|
+
this._musicFadeToken++;
|
|
197
|
+
this._musicFade = 1;
|
|
187
198
|
}
|
|
188
199
|
|
|
189
200
|
/**
|
|
@@ -212,6 +223,9 @@ export class AudioManager {
|
|
|
212
223
|
*/
|
|
213
224
|
setVolume(category: AudioCategoryName, volume: number): void {
|
|
214
225
|
this._categories[category].volume = Math.max(0, Math.min(1, volume));
|
|
226
|
+
// applyVolumes() re-pushes the music gain, so moving the Music slider is heard on the track
|
|
227
|
+
// that is ALREADY playing — it used to take effect only at the next playMusic (a mode change).
|
|
228
|
+
// SFX need no push: play() reads the category volume fresh on every call.
|
|
215
229
|
this.applyVolumes();
|
|
216
230
|
this.saveState();
|
|
217
231
|
}
|
|
@@ -291,27 +305,20 @@ export class AudioManager {
|
|
|
291
305
|
* @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal
|
|
292
306
|
*/
|
|
293
307
|
duckMusic(factor: number): void {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
// ignore
|
|
301
|
-
}
|
|
308
|
+
// Held as a FACTOR rather than written as a finished volume: the duck used to write
|
|
309
|
+
// `category × factor` onto a track whose instance already carried the category volume, so it
|
|
310
|
+
// ducked to category², and unducking restored category² instead of category. Keeping it as one
|
|
311
|
+
// term of `musicGain()` also keeps the slider live while ducked.
|
|
312
|
+
this._musicDuck = Math.max(0, Math.min(1, factor));
|
|
313
|
+
this.applyMusicGain();
|
|
302
314
|
}
|
|
303
315
|
|
|
304
316
|
/**
|
|
305
317
|
* Restore music to normal volume after ducking.
|
|
306
318
|
*/
|
|
307
319
|
unduckMusic(): void {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
try {
|
|
311
|
-
sound.volume(this._currentMusic, this._categories.music.volume);
|
|
312
|
-
} catch {
|
|
313
|
-
// ignore
|
|
314
|
-
}
|
|
320
|
+
this._musicDuck = 1;
|
|
321
|
+
this.applyMusicGain();
|
|
315
322
|
}
|
|
316
323
|
|
|
317
324
|
/**
|
|
@@ -357,12 +364,63 @@ export class AudioManager {
|
|
|
357
364
|
requestAnimationFrame(tick);
|
|
358
365
|
}
|
|
359
366
|
|
|
367
|
+
/**
|
|
368
|
+
* The SOUND-layer gain for the running music track.
|
|
369
|
+
*
|
|
370
|
+
* @pixi/sound resolves a playing instance as `instance × sound × global` (WebAudioInstance.
|
|
371
|
+
* refresh). Each of those three has exactly ONE owner here, which is what keeps the mixer honest:
|
|
372
|
+
* global — the master gain (`applyVolumes`)
|
|
373
|
+
* sound — music: this function. sfx: untouched, left at 1.
|
|
374
|
+
* instance — sfx: the per-call volume × the sfx category. music: always 1.
|
|
375
|
+
* Everything that can move music volume — the player's slider, the category mute, a big-win duck,
|
|
376
|
+
* a crossfade — is a term below, so they compose instead of overwriting each other.
|
|
377
|
+
*/
|
|
378
|
+
private musicGain(): number {
|
|
379
|
+
const c = this._categories.music;
|
|
380
|
+
return (c.muted ? 0 : 1) * c.volume * this._musicDuck * this._musicFade;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Push `musicGain()` at the current track. Safe before it starts playing and with none playing. */
|
|
384
|
+
private applyMusicGain(): void {
|
|
385
|
+
if (!this._soundModule || !this._currentMusic) return;
|
|
386
|
+
try {
|
|
387
|
+
this._soundModule.sound.volume(this._currentMusic, this.musicGain());
|
|
388
|
+
} catch {
|
|
389
|
+
// ignore — alias not registered yet
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Current SOUND-layer volume of `alias`, or 0 when it cannot be read. */
|
|
394
|
+
private soundVolumeOf(alias: string): number {
|
|
395
|
+
try {
|
|
396
|
+
return Number(this._soundModule.sound.volume(alias)) || 0;
|
|
397
|
+
} catch {
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Ramp the crossfade term 0 → 1 over `durationMs`, recomposing the gain each frame so a slider
|
|
403
|
+
* drag or a duck landing mid-fade is honoured rather than overwritten when the fade ends. */
|
|
404
|
+
private rampMusicFade(durationMs: number): void {
|
|
405
|
+
const token = this._musicFadeToken;
|
|
406
|
+
const start = Date.now();
|
|
407
|
+
const tick = (): void => {
|
|
408
|
+
if (token !== this._musicFadeToken) return; // a newer track owns the music now
|
|
409
|
+
const t = Math.min((Date.now() - start) / durationMs, 1);
|
|
410
|
+
this._musicFade = t;
|
|
411
|
+
this.applyMusicGain();
|
|
412
|
+
if (t < 1) requestAnimationFrame(tick);
|
|
413
|
+
};
|
|
414
|
+
requestAnimationFrame(tick);
|
|
415
|
+
}
|
|
416
|
+
|
|
360
417
|
private applyVolumes(): void {
|
|
361
418
|
if (!this._soundModule) return;
|
|
362
419
|
const { sound } = this._soundModule;
|
|
363
420
|
// Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
|
|
364
421
|
// not by volumeAll — mixing both leaves mute un-undoable after reload.
|
|
365
422
|
sound.volumeAll = this._masterGain; // master multiplies the global bus
|
|
423
|
+
this.applyMusicGain(); // category volume/mute reach the RUNNING track
|
|
366
424
|
}
|
|
367
425
|
|
|
368
426
|
private setupMobileUnlock(): void {
|
|
@@ -11,7 +11,14 @@ import { AudioManager } from '../audio/AudioManager';
|
|
|
11
11
|
import { InputManager } from '../input/InputManager';
|
|
12
12
|
import { ViewportManager } from '../viewport/ViewportManager';
|
|
13
13
|
import { LoadingScene } from '../loading/LoadingScene';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createCSSPreloader,
|
|
16
|
+
removeCSSPreloader,
|
|
17
|
+
adoptExternalOverlay,
|
|
18
|
+
advanceExternalOverlay,
|
|
19
|
+
releaseExternalOverlay,
|
|
20
|
+
hasExternalOverlay,
|
|
21
|
+
} from '@energy8platform/platform-core/loading';
|
|
15
22
|
import { FPSOverlay } from '../debug/FPSOverlay';
|
|
16
23
|
|
|
17
24
|
/**
|
|
@@ -155,17 +162,40 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
|
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
try {
|
|
165
|
+
// 0. Adopt a game-supplied loading overlay (`loading.externalOverlay`) BEFORE anything that
|
|
166
|
+
// can throw. Such an overlay is already on screen — Artube's is injected into index.html,
|
|
167
|
+
// so it paints before this bundle is even fetched — and until the engine has adopted it,
|
|
168
|
+
// the catch below has no way to take it down. A bad `container` selector (step 1) would
|
|
169
|
+
// otherwise strand it on screen forever. It needs no container of ours.
|
|
170
|
+
// Adoption is also where its minimum display time starts counting, which is why the
|
|
171
|
+
// config value is handed over here rather than read at the hand-over: this is the
|
|
172
|
+
// earliest moment the engine runs, and the overlay has been on screen since before it.
|
|
173
|
+
const external = this.config.loading?.externalOverlay;
|
|
174
|
+
if (external)
|
|
175
|
+
adoptExternalOverlay(external, this.config.loading?.externalOverlayMinDisplayTime);
|
|
176
|
+
|
|
158
177
|
// 1. Resolve container element
|
|
159
178
|
this._container = this.resolveContainer();
|
|
160
179
|
|
|
161
|
-
// 2. Show CSS preloader immediately (before PixiJS)
|
|
162
|
-
|
|
180
|
+
// 2. Show the CSS preloader immediately (before PixiJS) — UNLESS a game-supplied overlay is
|
|
181
|
+
// already covering the screen. In that case the preloader is mounted later, by LoadingScene
|
|
182
|
+
// at its first frame, which is where the hand-over happens. Mounting it here instead would
|
|
183
|
+
// put our brand over theirs for the whole of Pixi init and the SDK handshake, i.e. hand
|
|
184
|
+
// over long before the gap the external overlay exists to cover has closed.
|
|
185
|
+
if (!hasExternalOverlay()) createCSSPreloader(this._container, this.config.loading);
|
|
163
186
|
|
|
164
187
|
// 3. Initialize PixiJS
|
|
165
188
|
await this.initPixi();
|
|
189
|
+
// Milestones through the pre-first-frame gap, for a game-supplied overlay only (no-ops
|
|
190
|
+
// otherwise, so the built-in preloader's behaviour is untouched). They are also what makes
|
|
191
|
+
// Artube's loader crossfade from its dark partner phase to its branded one: that transition
|
|
192
|
+
// fires on the first progress above zero, and without it the player would never see the
|
|
193
|
+
// brand the loader exists to show. Values are honest weights of what remains, not a timer.
|
|
194
|
+
advanceExternalOverlay(0.35);
|
|
166
195
|
|
|
167
196
|
// 4. Initialize SDK (if enabled)
|
|
168
197
|
await this.initSDK();
|
|
198
|
+
advanceExternalOverlay(0.7);
|
|
169
199
|
|
|
170
200
|
// 4b. Mount the branded game shell after the SDK handshake (optional)
|
|
171
201
|
if (this.config.shell) {
|
|
@@ -178,12 +208,19 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
|
|
|
178
208
|
|
|
179
209
|
// 6. Initialize sub-systems
|
|
180
210
|
this.initSubSystems();
|
|
211
|
+
advanceExternalOverlay(0.85);
|
|
181
212
|
|
|
182
213
|
this.emit('initialized');
|
|
183
214
|
|
|
184
215
|
// 7. Load assets. The CSS preloader stays on screen — LoadingScene drives
|
|
185
216
|
// its progress/tap and removes it before entering the game, so there's
|
|
186
217
|
// a single continuous overlay from boot to gameplay (no logo flash).
|
|
218
|
+
//
|
|
219
|
+
// With a game-supplied overlay the sequence has one extra step at the
|
|
220
|
+
// front: LoadingScene MOUNTS the preloader, waits for its first painted
|
|
221
|
+
// frame, and only then dismisses the external overlay. From that frame
|
|
222
|
+
// on this path and every other are identical — same brand, same bar,
|
|
223
|
+
// same tap-to-start.
|
|
187
224
|
await this.loadAssets(firstScene, sceneData);
|
|
188
225
|
|
|
189
226
|
this.emit('loaded');
|
|
@@ -193,8 +230,13 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
|
|
|
193
230
|
this.emit('started');
|
|
194
231
|
} catch (err) {
|
|
195
232
|
console.error('[GameEngine] Failed to start:', err);
|
|
196
|
-
// Tear down
|
|
197
|
-
|
|
233
|
+
// Tear down both possible overlays so a failure strands neither brand frame. BOTH calls run:
|
|
234
|
+
// a throw during the hand-over window can leave the preloader mounted AND the external
|
|
235
|
+
// overlay still adopted, and each call is a no-op when there is nothing to remove. The
|
|
236
|
+
// container may never have resolved (step 1 is inside this try), hence the `document.body`
|
|
237
|
+
// fallback — the external overlay ignores the element entirely.
|
|
238
|
+
releaseExternalOverlay();
|
|
239
|
+
void removeCSSPreloader(this._container ?? document.body);
|
|
198
240
|
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
199
241
|
throw err;
|
|
200
242
|
}
|
package/src/host/buildConfig.ts
CHANGED
|
@@ -6,12 +6,18 @@ import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-res
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Pure: map host options to a GameApplicationConfig with sane defaults.
|
|
9
|
-
* `isStakeNow`
|
|
10
|
-
* stays a pure, renderer-free function).
|
|
9
|
+
* `isStakeNow` / `isArtubeNow` are computed by the orchestrator (kept out of
|
|
10
|
+
* here so this stays a pure, renderer-free function).
|
|
11
|
+
*
|
|
12
|
+
* Both host bridges run IN-PROCESS with the game, so either one means the SDK
|
|
13
|
+
* must be in `devMode` — that is what makes it talk over the in-memory channel
|
|
14
|
+
* the bridge listens on instead of postMessage-ing an outer host that isn't
|
|
15
|
+
* there. (`dev` is the third, unrelated reason for the same flag: DevBridge.)
|
|
11
16
|
*/
|
|
12
17
|
export function buildAppConfig<T extends SlotSpinResultBase = SlotSpinResultBase>(
|
|
13
18
|
opts: CreateSlotGameOptions<T>,
|
|
14
19
|
isStakeNow: boolean,
|
|
20
|
+
isArtubeNow = false,
|
|
15
21
|
): GameApplicationConfig {
|
|
16
22
|
return {
|
|
17
23
|
container: opts.container ?? '#game',
|
|
@@ -19,11 +25,18 @@ export function buildAppConfig<T extends SlotSpinResultBase = SlotSpinResultBase
|
|
|
19
25
|
designHeight: opts.design?.height ?? 1080,
|
|
20
26
|
scaleMode: opts.scaleMode ?? ScaleMode.FILL,
|
|
21
27
|
orientation: opts.orientation ?? Orientation.ANY,
|
|
22
|
-
|
|
28
|
+
// MERGED, not replaced. `opts.loading ?? {…}` looked equivalent and was not: a game that
|
|
29
|
+
// passes ANY loading option loses every default it did not restate, so `{ minDisplayTime: 900 }`
|
|
30
|
+
// silently re-armed tap-to-start (the engine's own default for that flag is `true`, for
|
|
31
|
+
// backwards compatibility with direct GameApplication users). The Artube target made it
|
|
32
|
+
// visible — a game supplying only `externalOverlay` waited for a tap there and nowhere else,
|
|
33
|
+
// i.e. the same source line behaved differently per platform. Spreading `opts.loading` last
|
|
34
|
+
// keeps every explicit value winning while unset keys stay on the host's defaults.
|
|
35
|
+
loading: { tapToStart: false, minDisplayTime: 600, ...opts.loading },
|
|
23
36
|
manifest: opts.manifest,
|
|
24
37
|
audio: opts.audio,
|
|
25
38
|
pixi: opts.pixi,
|
|
26
|
-
sdk: { devMode: isStakeNow || (opts.dev ?? false) },
|
|
39
|
+
sdk: { devMode: isStakeNow || isArtubeNow || (opts.dev ?? false) },
|
|
27
40
|
debug: opts.dev ?? false,
|
|
28
41
|
};
|
|
29
42
|
}
|
|
@@ -5,13 +5,14 @@ import { buildAppConfig } from './buildConfig';
|
|
|
5
5
|
import { loadFonts, applyTextureDefaults, bootGuard } from './preboot';
|
|
6
6
|
import { showFatalError, installGlobalErrorHandlers } from './fatalError';
|
|
7
7
|
import type { CreateSlotGameOptions, SlotGameHandle } from './types';
|
|
8
|
+
import { releaseExternalOverlay } from '@energy8platform/platform-core/loading';
|
|
8
9
|
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
9
10
|
import type { ShellMode } from '@energy8platform/shell/pixi';
|
|
10
11
|
import type { SceneApi, SlotSceneController, RenderContext } from './sceneController';
|
|
11
12
|
import type { FreeSpinsView } from './freeSpinsCounter';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
|
|
15
|
+
* One-call slot bootstrap: preboot → (optional Stake / Artube bridge) → GameApplication
|
|
15
16
|
* → register scene → start. Collapses the per-game main.ts boilerplate.
|
|
16
17
|
*
|
|
17
18
|
* Not unit-tested: GameApplication.init() drives Pixi, which hangs in headless
|
|
@@ -29,6 +30,24 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
29
30
|
let shell: SlotGameHandle['shell'] = null;
|
|
30
31
|
|
|
31
32
|
const fatal = (message: string) => {
|
|
33
|
+
// Take down a game-supplied loading overlay (`loading.externalOverlay`) first. Several fatal
|
|
34
|
+
// paths below — a refused Artube launch, a bridge that cannot connect — happen BEFORE
|
|
35
|
+
// `GameApplication.start()`, so its own error path never runs and nothing else would ever
|
|
36
|
+
// dismiss the overlay. Artube's is already on screen from index.html at z-index 9999: leaving
|
|
37
|
+
// it up means the player stares at a frozen loading screen, and a custom `onFatalError`
|
|
38
|
+
// renderer would be hidden underneath it entirely.
|
|
39
|
+
//
|
|
40
|
+
// `releaseExternalOverlay` handles the case where the engine already adopted it (and keeps the
|
|
41
|
+
// dismissal idempotent, which matters now that the normal hand-over also dismisses it). Its
|
|
42
|
+
// `false` means the engine never got that far — those are exactly the pre-boot refusals, where
|
|
43
|
+
// hiding the game's overlay directly is the only thing that can work.
|
|
44
|
+
if (!releaseExternalOverlay()) {
|
|
45
|
+
try {
|
|
46
|
+
opts.loading?.externalOverlay?.hideLoader();
|
|
47
|
+
} catch {
|
|
48
|
+
/* the overlay is the game's; a throw here must not swallow the error we came to report */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
32
51
|
if (opts.onFatalError) return opts.onFatalError(message);
|
|
33
52
|
// Once the shell is up, use ITS branded modal (consistent chrome, social vocabulary, fit
|
|
34
53
|
// scaling) rather than the bare DOM fallback. Errors thrown before the shell boots (asset
|
|
@@ -100,7 +119,66 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
100
119
|
}
|
|
101
120
|
}
|
|
102
121
|
|
|
103
|
-
|
|
122
|
+
let artubeBridge: SlotGameHandle['artubeBridge'] = null;
|
|
123
|
+
let isArtubeNow = false;
|
|
124
|
+
// `!isStakeNow`: both bridges install themselves in-process on the SAME SDK memory channel, so
|
|
125
|
+
// whichever launch already claimed the game wins. (The two launch shapes are disjoint in practice
|
|
126
|
+
// — Stake's marker is `sessionID`/`replay`, Artube's is `sessionId` — so this never fires; it just
|
|
127
|
+
// makes the precedence explicit rather than leaving two bridges racing.)
|
|
128
|
+
if (opts.artube && !isStakeNow) {
|
|
129
|
+
// The GAME supplies the loader (see `ArtubeIntegration.load`): a bare
|
|
130
|
+
// `import('@energy8platform/artube-bridge')` here would be resolved statically by every bundler
|
|
131
|
+
// and would break — loudly or, under Vite, silently — every game that never installed the
|
|
132
|
+
// package. Classifier and bridge come from the same module, so this is one load.
|
|
133
|
+
let artube: import('./types').ArtubeModule;
|
|
134
|
+
try {
|
|
135
|
+
artube = await opts.artube.load();
|
|
136
|
+
} catch (err) {
|
|
137
|
+
fatal('Could not start the game.');
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
// Security gate, the Artube counterpart of the Stake one above. Artube's only launch marker is
|
|
141
|
+
// `sessionId`, and unlike Stake there is no attacker-suppliable server address to validate
|
|
142
|
+
// (`apiBase` is the launch URL's own origin). What IS reachable is stripping the session: a URL
|
|
143
|
+
// that carries `sessionId` with an empty/blank value claims a session it doesn't have, fails the
|
|
144
|
+
// "is this Artube?" check, and would silently fall through to the offline/dev bridge — the
|
|
145
|
+
// free-play hole. 'artube' = a real launch (load the bridge); 'offline' = no marker at all, a
|
|
146
|
+
// genuine dev launch.
|
|
147
|
+
//
|
|
148
|
+
// What URL classification cannot catch: a marker removed ENTIRELY is indistinguishable from a
|
|
149
|
+
// dev launch. In a production BUILD there is nothing to fall through to anyway — the DevBridge
|
|
150
|
+
// bootstrapper is injected by a Vite plugin with `apply: 'serve'`, so no build carries one,
|
|
151
|
+
// whatever BUILD_TARGET says. Under a plain `npm run dev` the bootstrapper HAS already started a
|
|
152
|
+
// DevBridge before this code runs (it wraps the entry module), so there the protection is this
|
|
153
|
+
// gate refusing to start the game — not the absence of a bridge.
|
|
154
|
+
const launch = artube.classifyArtubeLaunch(location.href);
|
|
155
|
+
if (launch === 'blocked') {
|
|
156
|
+
fatal('Invalid game session. Please relaunch the game from the lobby.');
|
|
157
|
+
throw new Error(
|
|
158
|
+
'createSlotGame: refusing to run — Artube launch with a missing or blank sessionId',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
isArtubeNow = launch === 'artube';
|
|
162
|
+
if (isArtubeNow) {
|
|
163
|
+
try {
|
|
164
|
+
artubeBridge = new artube.ArtubeBridge({
|
|
165
|
+
// In-process over the SDK's MemoryChannel (see buildAppConfig's devMode).
|
|
166
|
+
devMode: true,
|
|
167
|
+
gameId: opts.model.spec.id,
|
|
168
|
+
url: location.href,
|
|
169
|
+
// Same-origin in production; both fields are dev/demo escape hatches (see ArtubeIntegration).
|
|
170
|
+
...(opts.artube.apiBase ? { apiBase: opts.artube.apiBase } : {}),
|
|
171
|
+
...(opts.artube.demoBalance != null ? { demoBalance: opts.artube.demoBalance } : {}),
|
|
172
|
+
});
|
|
173
|
+
await artubeBridge.ready();
|
|
174
|
+
} catch (err) {
|
|
175
|
+
fatal('Could not connect to the game server. Please reload.');
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const game = new GameApplication(buildAppConfig(opts, isStakeNow, isArtubeNow));
|
|
104
182
|
|
|
105
183
|
// Register EVERY scene up front so any of them can navigate to any other.
|
|
106
184
|
for (const { key, scene } of opts.scenes) game.scenes.register(key, scene);
|
|
@@ -174,6 +252,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
174
252
|
|
|
175
253
|
const ps = game.platformSession;
|
|
176
254
|
const balance = (game.initData?.balance as number | undefined) ?? 0;
|
|
255
|
+
// Replay is a STAKE concept (a shared link that re-plays one recorded round). Artube has no
|
|
256
|
+
// equivalent, so an Artube launch is always 'base' — nothing to mirror here.
|
|
177
257
|
const isReplay = !!stakeBridge?.isReplay;
|
|
178
258
|
const mode: ShellMode = isReplay ? 'replay' : 'base';
|
|
179
259
|
// initData.config carries the Stake bridge's currency/social/disclaimer surface (GameConfigData);
|
|
@@ -189,9 +269,16 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
189
269
|
stake?: { defaultBetLevel?: number; minBet?: number; maxBet?: number };
|
|
190
270
|
/** Set by the Stake bridge when `/wallet/authenticate` returned a still-open round. */
|
|
191
271
|
activeRound?: { bet?: number; roundId?: string; mode?: string };
|
|
272
|
+
/** Artube's platform block. The default bet arrives as an INDEX into `config.betLevels`
|
|
273
|
+
* (the platform's `allowed_bets`), where Stake states an amount. */
|
|
274
|
+
artube?: { defaultBetIndex?: number };
|
|
192
275
|
};
|
|
193
|
-
/** Present only on a resume — the bridge synthesises it from the open round.
|
|
276
|
+
/** Present only on a resume — the bridge synthesises it from the open round. Both the Stake
|
|
277
|
+
* and the Artube bridge fill it the same way, so the resumed-bet path below is shared. */
|
|
194
278
|
session?: { betAmount?: number };
|
|
279
|
+
/** Session currency CODE. The Artube bridge's only currency surface (the platform picks it
|
|
280
|
+
* per session; demo sessions are 'FUN'); Stake sends full meta on `config.currency` instead. */
|
|
281
|
+
currency?: string;
|
|
195
282
|
lang?: string;
|
|
196
283
|
} | null;
|
|
197
284
|
const config = initData?.config;
|
|
@@ -203,33 +290,44 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
203
290
|
// carrier for the same value (INIT only ever has a session on a resume). Both are ignored when
|
|
204
291
|
// absent or 0 so an ordinary launch still starts on the default.
|
|
205
292
|
const resumedBet = config?.activeRound?.bet || initData?.session?.betAmount || undefined;
|
|
293
|
+
// Artube states its per-session default bet as an INDEX into the platform's ladder; resolve it
|
|
294
|
+
// against that SAME ladder so `runtime.defaultBet` is an amount on both platforms.
|
|
295
|
+
const artubeDefaultBet = isArtubeNow
|
|
296
|
+
? config?.betLevels?.[config?.artube?.defaultBetIndex ?? -1]
|
|
297
|
+
: undefined;
|
|
206
298
|
const { resolveCurrency } = await import('./shellConfig');
|
|
207
299
|
// SINGLE source of truth for the symbol: the Stake bridge already puts a full CurrencyMetaData
|
|
208
300
|
// (symbol + placement) on initData.config.currency. In the non-stake/devBridge path that meta
|
|
209
301
|
// is absent and we only have the spec's currency CODE — resolve it through the SAME table
|
|
210
302
|
// (stake-bridge's lookupCurrency) so e.g. 'EUR' renders as '€', not the literal text "EUR".
|
|
211
303
|
// stake-bridge ships with every scaffold; if it's somehow absent we degrade to the code.
|
|
304
|
+
// On Artube the session currency is the PLATFORM's (per player, and 'FUN' for demo sessions) and
|
|
305
|
+
// arrives as a bare code on initData — there is no meta object. It outranks the spec's static
|
|
306
|
+
// code, which would otherwise show every Artube player the spec's currency symbol.
|
|
307
|
+
const currencyCode = (isArtubeNow ? initData?.currency : undefined) || opts.model.spec.currency;
|
|
212
308
|
let currencyMeta = config?.currency;
|
|
213
|
-
if (!currencyMeta?.symbol &&
|
|
309
|
+
if (!currencyMeta?.symbol && currencyCode) {
|
|
214
310
|
try {
|
|
215
311
|
const { lookupCurrency } = await import('@energy8platform/stake-bridge');
|
|
216
|
-
currencyMeta = lookupCurrency(
|
|
312
|
+
currencyMeta = lookupCurrency(currencyCode);
|
|
217
313
|
} catch {
|
|
218
314
|
/* stake-bridge not installed — resolveCurrency falls back to the code */
|
|
219
315
|
}
|
|
220
316
|
}
|
|
221
317
|
const runtime = {
|
|
222
318
|
balance,
|
|
223
|
-
currency: resolveCurrency(currencyMeta,
|
|
319
|
+
currency: resolveCurrency(currencyMeta, currencyCode),
|
|
224
320
|
language: initData?.lang,
|
|
225
321
|
mode,
|
|
226
322
|
social: config?.socialMode,
|
|
227
323
|
disclaimerLines: config?.disclaimerLines,
|
|
228
324
|
jurisdiction: config?.jurisdiction,
|
|
229
|
-
// Currency-specific ladder + per-currency default from /wallet/authenticate (Stake)
|
|
230
|
-
// absent on dev/devBridge → buildShellConfig falls back to
|
|
325
|
+
// Currency-specific ladder + per-currency default from /wallet/authenticate (Stake) or the
|
|
326
|
+
// backend's `allowed_bets` (Artube); absent on dev/devBridge → buildShellConfig falls back to
|
|
327
|
+
// the spec.
|
|
231
328
|
betLevels: config?.betLevels,
|
|
232
|
-
defaultBet:
|
|
329
|
+
defaultBet:
|
|
330
|
+
resumedBet ?? config?.stake?.defaultBetLevel ?? artubeDefaultBet ?? config?.defaultBet,
|
|
233
331
|
// Hard stake window; the bridge rejects anything outside it before /bet/play.
|
|
234
332
|
minBet: config?.stake?.minBet,
|
|
235
333
|
maxBet: config?.stake?.maxBet,
|
|
@@ -238,10 +336,14 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
238
336
|
// spec's (EUR-shaped) ladder here would put the game on bets the wallet can't honour — every
|
|
239
337
|
// spin rejected on a high-denomination currency (ARS minBet 50), or silently mispriced. Fail
|
|
240
338
|
// where the cause is visible instead of at the first spin.
|
|
241
|
-
|
|
339
|
+
// Artube is the same requirement by a different route: the wire carries a bet INDEX, not an
|
|
340
|
+
// amount, and the bridge maps the amount the game plays to the NEAREST rung of the platform's
|
|
341
|
+
// ladder — so a spec-shaped ladder wouldn't be rejected, it would silently charge a different
|
|
342
|
+
// price than the bar shows. Refuse there too.
|
|
343
|
+
if ((isStakeNow || isArtubeNow) && !runtime.betLevels?.length) {
|
|
242
344
|
fatal('Could not load the bet levels for your currency. Please relaunch the game.');
|
|
243
345
|
throw new Error(
|
|
244
|
-
|
|
346
|
+
`createSlotGame: ${isStakeNow ? 'Stake' : 'Artube'} launch returned no config.betLevels — refusing to fall back to the spec ladder`,
|
|
245
347
|
);
|
|
246
348
|
}
|
|
247
349
|
if (opts.dev) {
|
|
@@ -811,5 +913,5 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
811
913
|
}
|
|
812
914
|
}
|
|
813
915
|
|
|
814
|
-
return { game, stakeBridge, shell };
|
|
916
|
+
return { game, stakeBridge, artubeBridge, shell };
|
|
815
917
|
}
|