@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/dist/host.cjs.js
CHANGED
|
@@ -753,6 +753,12 @@ class AudioManager {
|
|
|
753
753
|
_categories;
|
|
754
754
|
_masterGain = 1.0;
|
|
755
755
|
_currentMusic = null;
|
|
756
|
+
/** Duck factor (0..1) from duckMusic/unduckMusic. A presentation state, not a player setting. */
|
|
757
|
+
_musicDuck = 1;
|
|
758
|
+
/** Crossfade ramp (0..1) for the track that is fading IN. 1 whenever no fade is running. */
|
|
759
|
+
_musicFade = 1;
|
|
760
|
+
/** Generation counter so a superseded crossfade ramp stops writing over the new track's. */
|
|
761
|
+
_musicFadeToken = 0;
|
|
756
762
|
_unlocked = false;
|
|
757
763
|
_unlockHandler = null;
|
|
758
764
|
constructor(config) {
|
|
@@ -811,7 +817,10 @@ class AudioManager {
|
|
|
811
817
|
if (this._globalMuted || this._categories[category].muted)
|
|
812
818
|
return;
|
|
813
819
|
const { sound } = this._soundModule;
|
|
814
|
-
|
|
820
|
+
// The master gain lives on the GLOBAL bus (`sound.volumeAll`, see applyVolumes) and @pixi/sound
|
|
821
|
+
// already multiplies it in — folding it in here as well squared it, so a master of 0.5 played
|
|
822
|
+
// sfx at 0.25.
|
|
823
|
+
const vol = (options?.volume ?? 1) * this._categories[category].volume;
|
|
815
824
|
try {
|
|
816
825
|
sound.play(alias, {
|
|
817
826
|
volume: vol,
|
|
@@ -833,52 +842,50 @@ class AudioManager {
|
|
|
833
842
|
if (!this._initialized || !this._soundModule)
|
|
834
843
|
return;
|
|
835
844
|
const { sound } = this._soundModule;
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
});
|
|
849
|
-
// Start new track at zero volume, fade in
|
|
850
|
-
try {
|
|
851
|
-
sound.play(alias, {
|
|
852
|
-
volume: 0,
|
|
853
|
-
loop: true,
|
|
845
|
+
const prevAlias = this._currentMusic;
|
|
846
|
+
const crossfade = !!prevAlias && prevAlias !== alias && fadeDuration > 0;
|
|
847
|
+
// Retire the outgoing track. Its own SOUND-level volume is the only thing still pointing at it,
|
|
848
|
+
// so fading that to 0 is safe — nothing else writes it once `_currentMusic` has moved on.
|
|
849
|
+
if (prevAlias) {
|
|
850
|
+
if (crossfade) {
|
|
851
|
+
const from = this.soundVolumeOf(prevAlias);
|
|
852
|
+
this.fadeVolume(prevAlias, from, 0, fadeDuration, () => {
|
|
853
|
+
try {
|
|
854
|
+
sound.stop(prevAlias);
|
|
855
|
+
}
|
|
856
|
+
catch { /* ignore */ }
|
|
854
857
|
});
|
|
855
|
-
this.fadeVolume(alias, 0, this._categories.music.volume * this._masterGain, fadeDuration);
|
|
856
|
-
}
|
|
857
|
-
catch (e) {
|
|
858
|
-
console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
|
|
859
858
|
}
|
|
860
|
-
|
|
861
|
-
else {
|
|
862
|
-
// No crossfade — instant switch
|
|
863
|
-
if (this._currentMusic) {
|
|
859
|
+
else {
|
|
864
860
|
try {
|
|
865
|
-
sound.stop(
|
|
861
|
+
sound.stop(prevAlias);
|
|
866
862
|
}
|
|
867
863
|
catch { /* ignore */ }
|
|
868
864
|
}
|
|
869
|
-
this._currentMusic = alias;
|
|
870
|
-
if (this._globalMuted || this._categories.music.muted)
|
|
871
|
-
return;
|
|
872
|
-
try {
|
|
873
|
-
sound.play(alias, {
|
|
874
|
-
volume: this._categories.music.volume * this._masterGain,
|
|
875
|
-
loop: true,
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
catch (e) {
|
|
879
|
-
console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
|
|
880
|
-
}
|
|
881
865
|
}
|
|
866
|
+
this._currentMusic = alias;
|
|
867
|
+
this._musicFadeToken++; // any ramp still running belongs to a track we just replaced
|
|
868
|
+
// Deliberately started even while muted. Global mute is the @pixi/sound CONTEXT mute and a
|
|
869
|
+
// muted music category is a 0 term in `musicGain()` — both already make this inaudible, and
|
|
870
|
+
// both undo themselves the moment the player flips them back. Returning early here instead
|
|
871
|
+
// meant a track begun while muted never existed, so unmuting restored silence until some
|
|
872
|
+
// later mode change happened to switch tracks.
|
|
873
|
+
// The incoming track plays at INSTANCE volume 1 and carries its whole gain on the SOUND layer
|
|
874
|
+
// (`musicGain()`), which is the layer the slider, the duck and this fade all write. Splitting
|
|
875
|
+
// them across layers is what silenced every crossfade: the track was started at instance volume
|
|
876
|
+
// 0 and the ramp then moved the sound layer, whose product with 0 is 0 for the track's life.
|
|
877
|
+
// The gain is written BEFORE play() so the first frame is never at full volume.
|
|
878
|
+
this._musicFade = crossfade ? 0 : 1;
|
|
879
|
+
this.applyMusicGain();
|
|
880
|
+
try {
|
|
881
|
+
sound.play(alias, { volume: 1, loop: true });
|
|
882
|
+
}
|
|
883
|
+
catch (e) {
|
|
884
|
+
console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
if (crossfade)
|
|
888
|
+
this.rampMusicFade(fadeDuration);
|
|
882
889
|
}
|
|
883
890
|
/**
|
|
884
891
|
* Stop current music.
|
|
@@ -894,6 +901,10 @@ class AudioManager {
|
|
|
894
901
|
// ignore
|
|
895
902
|
}
|
|
896
903
|
this._currentMusic = null;
|
|
904
|
+
// Retire any running ramp and clear the fade term, so the next track does not inherit a
|
|
905
|
+
// half-finished crossfade and start silent.
|
|
906
|
+
this._musicFadeToken++;
|
|
907
|
+
this._musicFade = 1;
|
|
897
908
|
}
|
|
898
909
|
/**
|
|
899
910
|
* Stop all sounds.
|
|
@@ -919,6 +930,9 @@ class AudioManager {
|
|
|
919
930
|
*/
|
|
920
931
|
setVolume(category, volume) {
|
|
921
932
|
this._categories[category].volume = Math.max(0, Math.min(1, volume));
|
|
933
|
+
// applyVolumes() re-pushes the music gain, so moving the Music slider is heard on the track
|
|
934
|
+
// that is ALREADY playing — it used to take effect only at the next playMusic (a mode change).
|
|
935
|
+
// SFX need no push: play() reads the category volume fresh on every call.
|
|
922
936
|
this.applyVolumes();
|
|
923
937
|
this.saveState();
|
|
924
938
|
}
|
|
@@ -991,30 +1005,19 @@ class AudioManager {
|
|
|
991
1005
|
* @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal
|
|
992
1006
|
*/
|
|
993
1007
|
duckMusic(factor) {
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
}
|
|
1001
|
-
catch {
|
|
1002
|
-
// ignore
|
|
1003
|
-
}
|
|
1008
|
+
// Held as a FACTOR rather than written as a finished volume: the duck used to write
|
|
1009
|
+
// `category × factor` onto a track whose instance already carried the category volume, so it
|
|
1010
|
+
// ducked to category², and unducking restored category² instead of category. Keeping it as one
|
|
1011
|
+
// term of `musicGain()` also keeps the slider live while ducked.
|
|
1012
|
+
this._musicDuck = Math.max(0, Math.min(1, factor));
|
|
1013
|
+
this.applyMusicGain();
|
|
1004
1014
|
}
|
|
1005
1015
|
/**
|
|
1006
1016
|
* Restore music to normal volume after ducking.
|
|
1007
1017
|
*/
|
|
1008
1018
|
unduckMusic() {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
const { sound } = this._soundModule;
|
|
1012
|
-
try {
|
|
1013
|
-
sound.volume(this._currentMusic, this._categories.music.volume);
|
|
1014
|
-
}
|
|
1015
|
-
catch {
|
|
1016
|
-
// ignore
|
|
1017
|
-
}
|
|
1019
|
+
this._musicDuck = 1;
|
|
1020
|
+
this.applyMusicGain();
|
|
1018
1021
|
}
|
|
1019
1022
|
/**
|
|
1020
1023
|
* Destroy the audio manager and free resources.
|
|
@@ -1053,6 +1056,57 @@ class AudioManager {
|
|
|
1053
1056
|
};
|
|
1054
1057
|
requestAnimationFrame(tick);
|
|
1055
1058
|
}
|
|
1059
|
+
/**
|
|
1060
|
+
* The SOUND-layer gain for the running music track.
|
|
1061
|
+
*
|
|
1062
|
+
* @pixi/sound resolves a playing instance as `instance × sound × global` (WebAudioInstance.
|
|
1063
|
+
* refresh). Each of those three has exactly ONE owner here, which is what keeps the mixer honest:
|
|
1064
|
+
* global — the master gain (`applyVolumes`)
|
|
1065
|
+
* sound — music: this function. sfx: untouched, left at 1.
|
|
1066
|
+
* instance — sfx: the per-call volume × the sfx category. music: always 1.
|
|
1067
|
+
* Everything that can move music volume — the player's slider, the category mute, a big-win duck,
|
|
1068
|
+
* a crossfade — is a term below, so they compose instead of overwriting each other.
|
|
1069
|
+
*/
|
|
1070
|
+
musicGain() {
|
|
1071
|
+
const c = this._categories.music;
|
|
1072
|
+
return (c.muted ? 0 : 1) * c.volume * this._musicDuck * this._musicFade;
|
|
1073
|
+
}
|
|
1074
|
+
/** Push `musicGain()` at the current track. Safe before it starts playing and with none playing. */
|
|
1075
|
+
applyMusicGain() {
|
|
1076
|
+
if (!this._soundModule || !this._currentMusic)
|
|
1077
|
+
return;
|
|
1078
|
+
try {
|
|
1079
|
+
this._soundModule.sound.volume(this._currentMusic, this.musicGain());
|
|
1080
|
+
}
|
|
1081
|
+
catch {
|
|
1082
|
+
// ignore — alias not registered yet
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
/** Current SOUND-layer volume of `alias`, or 0 when it cannot be read. */
|
|
1086
|
+
soundVolumeOf(alias) {
|
|
1087
|
+
try {
|
|
1088
|
+
return Number(this._soundModule.sound.volume(alias)) || 0;
|
|
1089
|
+
}
|
|
1090
|
+
catch {
|
|
1091
|
+
return 0;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
/** Ramp the crossfade term 0 → 1 over `durationMs`, recomposing the gain each frame so a slider
|
|
1095
|
+
* drag or a duck landing mid-fade is honoured rather than overwritten when the fade ends. */
|
|
1096
|
+
rampMusicFade(durationMs) {
|
|
1097
|
+
const token = this._musicFadeToken;
|
|
1098
|
+
const start = Date.now();
|
|
1099
|
+
const tick = () => {
|
|
1100
|
+
if (token !== this._musicFadeToken)
|
|
1101
|
+
return; // a newer track owns the music now
|
|
1102
|
+
const t = Math.min((Date.now() - start) / durationMs, 1);
|
|
1103
|
+
this._musicFade = t;
|
|
1104
|
+
this.applyMusicGain();
|
|
1105
|
+
if (t < 1)
|
|
1106
|
+
requestAnimationFrame(tick);
|
|
1107
|
+
};
|
|
1108
|
+
requestAnimationFrame(tick);
|
|
1109
|
+
}
|
|
1056
1110
|
applyVolumes() {
|
|
1057
1111
|
if (!this._soundModule)
|
|
1058
1112
|
return;
|
|
@@ -1060,6 +1114,7 @@ class AudioManager {
|
|
|
1060
1114
|
// Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
|
|
1061
1115
|
// not by volumeAll — mixing both leaves mute un-undoable after reload.
|
|
1062
1116
|
sound.volumeAll = this._masterGain; // master multiplies the global bus
|
|
1117
|
+
this.applyMusicGain(); // category volume/mute reach the RUNNING track
|
|
1063
1118
|
}
|
|
1064
1119
|
setupMobileUnlock() {
|
|
1065
1120
|
if (this._unlocked)
|
|
@@ -1541,6 +1596,14 @@ class Scene {
|
|
|
1541
1596
|
* tap-to-start → `waitCSSPreloaderTap`, then fades it out via
|
|
1542
1597
|
* `removeCSSPreloader` before entering the game. One continuous overlay from
|
|
1543
1598
|
* boot to gameplay — no second logo, no mid-load flash.
|
|
1599
|
+
*
|
|
1600
|
+
* When the game supplied its own overlay (`loading.externalOverlay`, e.g.
|
|
1601
|
+
* Artube's `LoaderViewController`), this scene is also the HAND-OVER point: that
|
|
1602
|
+
* overlay covered the gap this scene's existence ends — the bundle download,
|
|
1603
|
+
* Pixi init and the SDK handshake, none of which the engine can paint over. The
|
|
1604
|
+
* first thing `onEnter` does is mount the preloader, wait for it to be painted,
|
|
1605
|
+
* and dismiss the game's overlay. Everything after that line is identical on
|
|
1606
|
+
* every platform.
|
|
1544
1607
|
*/
|
|
1545
1608
|
class LoadingScene extends Scene {
|
|
1546
1609
|
_engine;
|
|
@@ -1558,6 +1621,10 @@ class LoadingScene extends Scene {
|
|
|
1558
1621
|
this._targetScene = targetScene;
|
|
1559
1622
|
this._targetData = targetData;
|
|
1560
1623
|
this._config = engine.config.loading ?? {};
|
|
1624
|
+
// Take the screen from a game-supplied loading overlay, if there is one. Before any awaited
|
|
1625
|
+
// work: from here on the player is looking at OUR loading screen, and `_startTime` (which
|
|
1626
|
+
// `minDisplayTime` is measured from) must start when that becomes true.
|
|
1627
|
+
await this.takeOverFromExternalOverlay();
|
|
1561
1628
|
this._startTime = Date.now();
|
|
1562
1629
|
// Initialize asset manager
|
|
1563
1630
|
await this._engine.assets.init();
|
|
@@ -1604,8 +1671,10 @@ class LoadingScene extends Scene {
|
|
|
1604
1671
|
// Final snap to 100%
|
|
1605
1672
|
this._displayedProgress = 1;
|
|
1606
1673
|
this.updateLoaderBar(1);
|
|
1607
|
-
// Wait for the player's tap — resolves immediately when tapToStart is
|
|
1608
|
-
//
|
|
1674
|
+
// Wait for the player's tap — resolves immediately when tapToStart is false — then enter the
|
|
1675
|
+
// game. This is the preloader's gate and it reads the preloader's config, so it means the same
|
|
1676
|
+
// thing on every target: a game-supplied overlay has no say in it, and by now no part in the
|
|
1677
|
+
// screen either. It was dismissed at the hand-over above; the player is looking at ours.
|
|
1609
1678
|
await loading.waitCSSPreloaderTap();
|
|
1610
1679
|
await this.transitionToGame();
|
|
1611
1680
|
}
|
|
@@ -1624,6 +1693,59 @@ class LoadingScene extends Scene {
|
|
|
1624
1693
|
// (e.g. the scene was popped externally). Idempotent.
|
|
1625
1694
|
void loading.removeCSSPreloader(this.hostElement());
|
|
1626
1695
|
}
|
|
1696
|
+
// ─── Hand-over from a game-supplied overlay ────────────
|
|
1697
|
+
/**
|
|
1698
|
+
* Swap a game-supplied loading overlay for the engine's own loading screen.
|
|
1699
|
+
*
|
|
1700
|
+
* The overlay (Artube's) has been on screen since before this bundle was fetched, covering a gap
|
|
1701
|
+
* nothing of ours could. Its job ends here, at the first frame the engine paints; the player then
|
|
1702
|
+
* gets the game's own brand, progress bar and tap-to-start, exactly as on every other target.
|
|
1703
|
+
*
|
|
1704
|
+
* The order of the four steps is the whole design, and each is wrong on its own:
|
|
1705
|
+
*
|
|
1706
|
+
* 0. Wait out whatever the overlay is still owed on screen (`externalOverlayMinDisplayTime`,
|
|
1707
|
+
* default 1.5s, plus room for a phase crossfade already in flight). The gap this overlay
|
|
1708
|
+
* covers can be under half a second, which is not long enough for a partner's brand to
|
|
1709
|
+
* register. Waiting here — BEFORE mounting ours — rather than after is what keeps the two
|
|
1710
|
+
* screens' timelines from overlapping: our splash and brand floor start when the player can
|
|
1711
|
+
* actually see them, not behind someone else's overlay. On any boot slower than the floor
|
|
1712
|
+
* this step costs nothing, and on a non-Artube target it is not reached at all.
|
|
1713
|
+
* 1. Mount the preloader, opaque and full-bleed, while theirs is still up. Both are on screen
|
|
1714
|
+
* together for a few frames, so there is never a moment with neither, whatever happens next.
|
|
1715
|
+
* 2. Wait for that frame to actually be PAINTED — mounting only queues it. Dismissing theirs
|
|
1716
|
+
* before the paint is precisely the flash of bare background this ordering exists to avoid.
|
|
1717
|
+
* Two `requestAnimationFrame`s: the first callback runs before the frame it belongs to is
|
|
1718
|
+
* composited, the second after. Two frames is also enough for Pixi's own rAF-driven ticker
|
|
1719
|
+
* to have rendered this scene at least once, so "the loading scene has painted" is literally
|
|
1720
|
+
* true by the time step 3 runs.
|
|
1721
|
+
* 3. Only then dismiss theirs. Their `hideLoader()` plays a 0.3s fade and removes the element.
|
|
1722
|
+
* Not waiting for that fade is deliberate — it is an animation on someone else's element,
|
|
1723
|
+
* and blocking a boot on it would be a hang waiting to happen.
|
|
1724
|
+
*
|
|
1725
|
+
* Which of the two is visually on top is the host page's business, not ours, and it does NOT
|
|
1726
|
+
* change the guarantee. On a typical game page (`#game { position: fixed; inset: 0 }`) the fixed
|
|
1727
|
+
* container establishes a stacking context, so the preloader's z-index is scoped inside it and
|
|
1728
|
+
* Artube's `position: fixed; z-index: 9999` sits above — their fade then crossfades onto our
|
|
1729
|
+
* loading screen, which is what was observed live and looks right. On a page where ours wins
|
|
1730
|
+
* instead, their fade simply plays underneath, unseen. Either way the seam is covered, because
|
|
1731
|
+
* what step 2 buys is that OUR screen is already painted before theirs starts going away.
|
|
1732
|
+
*/
|
|
1733
|
+
async takeOverFromExternalOverlay() {
|
|
1734
|
+
if (!loading.hasExternalOverlay())
|
|
1735
|
+
return;
|
|
1736
|
+
await loading.externalOverlayHold();
|
|
1737
|
+
loading.createCSSPreloader(this.hostElement(), this._config);
|
|
1738
|
+
await this.nextPaint();
|
|
1739
|
+
loading.releaseExternalOverlay();
|
|
1740
|
+
}
|
|
1741
|
+
/** Resolves after the browser has composited at least one frame (see step 2 above). */
|
|
1742
|
+
nextPaint() {
|
|
1743
|
+
if (typeof requestAnimationFrame !== 'function')
|
|
1744
|
+
return Promise.resolve();
|
|
1745
|
+
return new Promise((resolve) => {
|
|
1746
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1627
1749
|
// ─── Progress ──────────────────────────────────────────
|
|
1628
1750
|
updateLoaderBar(progress) {
|
|
1629
1751
|
loading.setCSSPreloaderProgress(Math.max(0, Math.min(1, progress)));
|
|
@@ -1878,14 +2000,37 @@ class GameApplication extends EventEmitter {
|
|
|
1878
2000
|
return;
|
|
1879
2001
|
}
|
|
1880
2002
|
try {
|
|
2003
|
+
// 0. Adopt a game-supplied loading overlay (`loading.externalOverlay`) BEFORE anything that
|
|
2004
|
+
// can throw. Such an overlay is already on screen — Artube's is injected into index.html,
|
|
2005
|
+
// so it paints before this bundle is even fetched — and until the engine has adopted it,
|
|
2006
|
+
// the catch below has no way to take it down. A bad `container` selector (step 1) would
|
|
2007
|
+
// otherwise strand it on screen forever. It needs no container of ours.
|
|
2008
|
+
// Adoption is also where its minimum display time starts counting, which is why the
|
|
2009
|
+
// config value is handed over here rather than read at the hand-over: this is the
|
|
2010
|
+
// earliest moment the engine runs, and the overlay has been on screen since before it.
|
|
2011
|
+
const external = this.config.loading?.externalOverlay;
|
|
2012
|
+
if (external)
|
|
2013
|
+
loading.adoptExternalOverlay(external, this.config.loading?.externalOverlayMinDisplayTime);
|
|
1881
2014
|
// 1. Resolve container element
|
|
1882
2015
|
this._container = this.resolveContainer();
|
|
1883
|
-
// 2. Show CSS preloader immediately (before PixiJS)
|
|
1884
|
-
|
|
2016
|
+
// 2. Show the CSS preloader immediately (before PixiJS) — UNLESS a game-supplied overlay is
|
|
2017
|
+
// already covering the screen. In that case the preloader is mounted later, by LoadingScene
|
|
2018
|
+
// at its first frame, which is where the hand-over happens. Mounting it here instead would
|
|
2019
|
+
// put our brand over theirs for the whole of Pixi init and the SDK handshake, i.e. hand
|
|
2020
|
+
// over long before the gap the external overlay exists to cover has closed.
|
|
2021
|
+
if (!loading.hasExternalOverlay())
|
|
2022
|
+
loading.createCSSPreloader(this._container, this.config.loading);
|
|
1885
2023
|
// 3. Initialize PixiJS
|
|
1886
2024
|
await this.initPixi();
|
|
2025
|
+
// Milestones through the pre-first-frame gap, for a game-supplied overlay only (no-ops
|
|
2026
|
+
// otherwise, so the built-in preloader's behaviour is untouched). They are also what makes
|
|
2027
|
+
// Artube's loader crossfade from its dark partner phase to its branded one: that transition
|
|
2028
|
+
// fires on the first progress above zero, and without it the player would never see the
|
|
2029
|
+
// brand the loader exists to show. Values are honest weights of what remains, not a timer.
|
|
2030
|
+
loading.advanceExternalOverlay(0.35);
|
|
1887
2031
|
// 4. Initialize SDK (if enabled)
|
|
1888
2032
|
await this.initSDK();
|
|
2033
|
+
loading.advanceExternalOverlay(0.7);
|
|
1889
2034
|
// 4b. Mount the branded game shell after the SDK handshake (optional)
|
|
1890
2035
|
if (this.config.shell) {
|
|
1891
2036
|
const { createGameShell } = await import('@energy8platform/shell/html');
|
|
@@ -1895,10 +2040,17 @@ class GameApplication extends EventEmitter {
|
|
|
1895
2040
|
this.applySDKConfig();
|
|
1896
2041
|
// 6. Initialize sub-systems
|
|
1897
2042
|
this.initSubSystems();
|
|
2043
|
+
loading.advanceExternalOverlay(0.85);
|
|
1898
2044
|
this.emit('initialized');
|
|
1899
2045
|
// 7. Load assets. The CSS preloader stays on screen — LoadingScene drives
|
|
1900
2046
|
// its progress/tap and removes it before entering the game, so there's
|
|
1901
2047
|
// a single continuous overlay from boot to gameplay (no logo flash).
|
|
2048
|
+
//
|
|
2049
|
+
// With a game-supplied overlay the sequence has one extra step at the
|
|
2050
|
+
// front: LoadingScene MOUNTS the preloader, waits for its first painted
|
|
2051
|
+
// frame, and only then dismisses the external overlay. From that frame
|
|
2052
|
+
// on this path and every other are identical — same brand, same bar,
|
|
2053
|
+
// same tap-to-start.
|
|
1902
2054
|
await this.loadAssets(firstScene, sceneData);
|
|
1903
2055
|
this.emit('loaded');
|
|
1904
2056
|
// 8. Start the game loop
|
|
@@ -1907,9 +2059,13 @@ class GameApplication extends EventEmitter {
|
|
|
1907
2059
|
}
|
|
1908
2060
|
catch (err) {
|
|
1909
2061
|
console.error('[GameEngine] Failed to start:', err);
|
|
1910
|
-
// Tear down
|
|
1911
|
-
|
|
1912
|
-
|
|
2062
|
+
// Tear down both possible overlays so a failure strands neither brand frame. BOTH calls run:
|
|
2063
|
+
// a throw during the hand-over window can leave the preloader mounted AND the external
|
|
2064
|
+
// overlay still adopted, and each call is a no-op when there is nothing to remove. The
|
|
2065
|
+
// container may never have resolved (step 1 is inside this try), hence the `document.body`
|
|
2066
|
+
// fallback — the external overlay ignores the element entirely.
|
|
2067
|
+
loading.releaseExternalOverlay();
|
|
2068
|
+
void loading.removeCSSPreloader(this._container ?? document.body);
|
|
1913
2069
|
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
1914
2070
|
throw err;
|
|
1915
2071
|
}
|
|
@@ -2104,21 +2260,33 @@ class IntroScene extends Scene {
|
|
|
2104
2260
|
|
|
2105
2261
|
/**
|
|
2106
2262
|
* Pure: map host options to a GameApplicationConfig with sane defaults.
|
|
2107
|
-
* `isStakeNow`
|
|
2108
|
-
* stays a pure, renderer-free function).
|
|
2263
|
+
* `isStakeNow` / `isArtubeNow` are computed by the orchestrator (kept out of
|
|
2264
|
+
* here so this stays a pure, renderer-free function).
|
|
2265
|
+
*
|
|
2266
|
+
* Both host bridges run IN-PROCESS with the game, so either one means the SDK
|
|
2267
|
+
* must be in `devMode` — that is what makes it talk over the in-memory channel
|
|
2268
|
+
* the bridge listens on instead of postMessage-ing an outer host that isn't
|
|
2269
|
+
* there. (`dev` is the third, unrelated reason for the same flag: DevBridge.)
|
|
2109
2270
|
*/
|
|
2110
|
-
function buildAppConfig(opts, isStakeNow) {
|
|
2271
|
+
function buildAppConfig(opts, isStakeNow, isArtubeNow = false) {
|
|
2111
2272
|
return {
|
|
2112
2273
|
container: opts.container ?? '#game',
|
|
2113
2274
|
designWidth: opts.design?.width ?? 1920,
|
|
2114
2275
|
designHeight: opts.design?.height ?? 1080,
|
|
2115
2276
|
scaleMode: opts.scaleMode ?? ScaleMode.FILL,
|
|
2116
2277
|
orientation: opts.orientation ?? Orientation.ANY,
|
|
2117
|
-
|
|
2278
|
+
// MERGED, not replaced. `opts.loading ?? {…}` looked equivalent and was not: a game that
|
|
2279
|
+
// passes ANY loading option loses every default it did not restate, so `{ minDisplayTime: 900 }`
|
|
2280
|
+
// silently re-armed tap-to-start (the engine's own default for that flag is `true`, for
|
|
2281
|
+
// backwards compatibility with direct GameApplication users). The Artube target made it
|
|
2282
|
+
// visible — a game supplying only `externalOverlay` waited for a tap there and nowhere else,
|
|
2283
|
+
// i.e. the same source line behaved differently per platform. Spreading `opts.loading` last
|
|
2284
|
+
// keeps every explicit value winning while unset keys stay on the host's defaults.
|
|
2285
|
+
loading: { tapToStart: false, minDisplayTime: 600, ...opts.loading },
|
|
2118
2286
|
manifest: opts.manifest,
|
|
2119
2287
|
audio: opts.audio,
|
|
2120
2288
|
pixi: opts.pixi,
|
|
2121
|
-
sdk: { devMode: isStakeNow || (opts.dev ?? false) },
|
|
2289
|
+
sdk: { devMode: isStakeNow || isArtubeNow || (opts.dev ?? false) },
|
|
2122
2290
|
debug: opts.dev ?? false,
|
|
2123
2291
|
};
|
|
2124
2292
|
}
|
|
@@ -2247,7 +2415,7 @@ function installGlobalErrorHandlers(container, fatal = (m) => showFatalError(con
|
|
|
2247
2415
|
|
|
2248
2416
|
// packages/game-engine/src/host/createSlotGame.ts
|
|
2249
2417
|
/**
|
|
2250
|
-
* One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
|
|
2418
|
+
* One-call slot bootstrap: preboot → (optional Stake / Artube bridge) → GameApplication
|
|
2251
2419
|
* → register scene → start. Collapses the per-game main.ts boilerplate.
|
|
2252
2420
|
*
|
|
2253
2421
|
* Not unit-tested: GameApplication.init() drives Pixi, which hangs in headless
|
|
@@ -2262,6 +2430,25 @@ async function createSlotGame(opts) {
|
|
|
2262
2430
|
// Declared up front so `fatal` can route errors through the shell's own modal once it exists.
|
|
2263
2431
|
let shell = null;
|
|
2264
2432
|
const fatal = (message) => {
|
|
2433
|
+
// Take down a game-supplied loading overlay (`loading.externalOverlay`) first. Several fatal
|
|
2434
|
+
// paths below — a refused Artube launch, a bridge that cannot connect — happen BEFORE
|
|
2435
|
+
// `GameApplication.start()`, so its own error path never runs and nothing else would ever
|
|
2436
|
+
// dismiss the overlay. Artube's is already on screen from index.html at z-index 9999: leaving
|
|
2437
|
+
// it up means the player stares at a frozen loading screen, and a custom `onFatalError`
|
|
2438
|
+
// renderer would be hidden underneath it entirely.
|
|
2439
|
+
//
|
|
2440
|
+
// `releaseExternalOverlay` handles the case where the engine already adopted it (and keeps the
|
|
2441
|
+
// dismissal idempotent, which matters now that the normal hand-over also dismisses it). Its
|
|
2442
|
+
// `false` means the engine never got that far — those are exactly the pre-boot refusals, where
|
|
2443
|
+
// hiding the game's overlay directly is the only thing that can work.
|
|
2444
|
+
if (!loading.releaseExternalOverlay()) {
|
|
2445
|
+
try {
|
|
2446
|
+
opts.loading?.externalOverlay?.hideLoader();
|
|
2447
|
+
}
|
|
2448
|
+
catch {
|
|
2449
|
+
/* the overlay is the game's; a throw here must not swallow the error we came to report */
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2265
2452
|
if (opts.onFatalError)
|
|
2266
2453
|
return opts.onFatalError(message);
|
|
2267
2454
|
// Once the shell is up, use ITS branded modal (consistent chrome, social vocabulary, fit
|
|
@@ -2331,7 +2518,65 @@ async function createSlotGame(opts) {
|
|
|
2331
2518
|
}
|
|
2332
2519
|
}
|
|
2333
2520
|
}
|
|
2334
|
-
|
|
2521
|
+
let artubeBridge = null;
|
|
2522
|
+
let isArtubeNow = false;
|
|
2523
|
+
// `!isStakeNow`: both bridges install themselves in-process on the SAME SDK memory channel, so
|
|
2524
|
+
// whichever launch already claimed the game wins. (The two launch shapes are disjoint in practice
|
|
2525
|
+
// — Stake's marker is `sessionID`/`replay`, Artube's is `sessionId` — so this never fires; it just
|
|
2526
|
+
// makes the precedence explicit rather than leaving two bridges racing.)
|
|
2527
|
+
if (opts.artube && !isStakeNow) {
|
|
2528
|
+
// The GAME supplies the loader (see `ArtubeIntegration.load`): a bare
|
|
2529
|
+
// `import('@energy8platform/artube-bridge')` here would be resolved statically by every bundler
|
|
2530
|
+
// and would break — loudly or, under Vite, silently — every game that never installed the
|
|
2531
|
+
// package. Classifier and bridge come from the same module, so this is one load.
|
|
2532
|
+
let artube;
|
|
2533
|
+
try {
|
|
2534
|
+
artube = await opts.artube.load();
|
|
2535
|
+
}
|
|
2536
|
+
catch (err) {
|
|
2537
|
+
fatal('Could not start the game.');
|
|
2538
|
+
throw err;
|
|
2539
|
+
}
|
|
2540
|
+
// Security gate, the Artube counterpart of the Stake one above. Artube's only launch marker is
|
|
2541
|
+
// `sessionId`, and unlike Stake there is no attacker-suppliable server address to validate
|
|
2542
|
+
// (`apiBase` is the launch URL's own origin). What IS reachable is stripping the session: a URL
|
|
2543
|
+
// that carries `sessionId` with an empty/blank value claims a session it doesn't have, fails the
|
|
2544
|
+
// "is this Artube?" check, and would silently fall through to the offline/dev bridge — the
|
|
2545
|
+
// free-play hole. 'artube' = a real launch (load the bridge); 'offline' = no marker at all, a
|
|
2546
|
+
// genuine dev launch.
|
|
2547
|
+
//
|
|
2548
|
+
// What URL classification cannot catch: a marker removed ENTIRELY is indistinguishable from a
|
|
2549
|
+
// dev launch. In a production BUILD there is nothing to fall through to anyway — the DevBridge
|
|
2550
|
+
// bootstrapper is injected by a Vite plugin with `apply: 'serve'`, so no build carries one,
|
|
2551
|
+
// whatever BUILD_TARGET says. Under a plain `npm run dev` the bootstrapper HAS already started a
|
|
2552
|
+
// DevBridge before this code runs (it wraps the entry module), so there the protection is this
|
|
2553
|
+
// gate refusing to start the game — not the absence of a bridge.
|
|
2554
|
+
const launch = artube.classifyArtubeLaunch(location.href);
|
|
2555
|
+
if (launch === 'blocked') {
|
|
2556
|
+
fatal('Invalid game session. Please relaunch the game from the lobby.');
|
|
2557
|
+
throw new Error('createSlotGame: refusing to run — Artube launch with a missing or blank sessionId');
|
|
2558
|
+
}
|
|
2559
|
+
isArtubeNow = launch === 'artube';
|
|
2560
|
+
if (isArtubeNow) {
|
|
2561
|
+
try {
|
|
2562
|
+
artubeBridge = new artube.ArtubeBridge({
|
|
2563
|
+
// In-process over the SDK's MemoryChannel (see buildAppConfig's devMode).
|
|
2564
|
+
devMode: true,
|
|
2565
|
+
gameId: opts.model.spec.id,
|
|
2566
|
+
url: location.href,
|
|
2567
|
+
// Same-origin in production; both fields are dev/demo escape hatches (see ArtubeIntegration).
|
|
2568
|
+
...(opts.artube.apiBase ? { apiBase: opts.artube.apiBase } : {}),
|
|
2569
|
+
...(opts.artube.demoBalance != null ? { demoBalance: opts.artube.demoBalance } : {}),
|
|
2570
|
+
});
|
|
2571
|
+
await artubeBridge.ready();
|
|
2572
|
+
}
|
|
2573
|
+
catch (err) {
|
|
2574
|
+
fatal('Could not connect to the game server. Please reload.');
|
|
2575
|
+
throw err;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
const game = new GameApplication(buildAppConfig(opts, isStakeNow, isArtubeNow));
|
|
2335
2580
|
// Register EVERY scene up front so any of them can navigate to any other.
|
|
2336
2581
|
for (const { key, scene } of opts.scenes)
|
|
2337
2582
|
game.scenes.register(key, scene);
|
|
@@ -2396,6 +2641,8 @@ async function createSlotGame(opts) {
|
|
|
2396
2641
|
const { resolveReplayBonusId } = await Promise.resolve().then(function () { return replay; });
|
|
2397
2642
|
const ps = game.platformSession;
|
|
2398
2643
|
const balance = game.initData?.balance ?? 0;
|
|
2644
|
+
// Replay is a STAKE concept (a shared link that re-plays one recorded round). Artube has no
|
|
2645
|
+
// equivalent, so an Artube launch is always 'base' — nothing to mirror here.
|
|
2399
2646
|
const isReplay = !!stakeBridge?.isReplay;
|
|
2400
2647
|
const mode = isReplay ? 'replay' : 'base';
|
|
2401
2648
|
// initData.config carries the Stake bridge's currency/social/disclaimer surface (GameConfigData);
|
|
@@ -2410,17 +2657,26 @@ async function createSlotGame(opts) {
|
|
|
2410
2657
|
// carrier for the same value (INIT only ever has a session on a resume). Both are ignored when
|
|
2411
2658
|
// absent or 0 so an ordinary launch still starts on the default.
|
|
2412
2659
|
const resumedBet = config?.activeRound?.bet || initData?.session?.betAmount || undefined;
|
|
2660
|
+
// Artube states its per-session default bet as an INDEX into the platform's ladder; resolve it
|
|
2661
|
+
// against that SAME ladder so `runtime.defaultBet` is an amount on both platforms.
|
|
2662
|
+
const artubeDefaultBet = isArtubeNow
|
|
2663
|
+
? config?.betLevels?.[config?.artube?.defaultBetIndex ?? -1]
|
|
2664
|
+
: undefined;
|
|
2413
2665
|
const { resolveCurrency } = await Promise.resolve().then(function () { return shellConfig; });
|
|
2414
2666
|
// SINGLE source of truth for the symbol: the Stake bridge already puts a full CurrencyMetaData
|
|
2415
2667
|
// (symbol + placement) on initData.config.currency. In the non-stake/devBridge path that meta
|
|
2416
2668
|
// is absent and we only have the spec's currency CODE — resolve it through the SAME table
|
|
2417
2669
|
// (stake-bridge's lookupCurrency) so e.g. 'EUR' renders as '€', not the literal text "EUR".
|
|
2418
2670
|
// stake-bridge ships with every scaffold; if it's somehow absent we degrade to the code.
|
|
2671
|
+
// On Artube the session currency is the PLATFORM's (per player, and 'FUN' for demo sessions) and
|
|
2672
|
+
// arrives as a bare code on initData — there is no meta object. It outranks the spec's static
|
|
2673
|
+
// code, which would otherwise show every Artube player the spec's currency symbol.
|
|
2674
|
+
const currencyCode = (isArtubeNow ? initData?.currency : undefined) || opts.model.spec.currency;
|
|
2419
2675
|
let currencyMeta = config?.currency;
|
|
2420
|
-
if (!currencyMeta?.symbol &&
|
|
2676
|
+
if (!currencyMeta?.symbol && currencyCode) {
|
|
2421
2677
|
try {
|
|
2422
2678
|
const { lookupCurrency } = await import('@energy8platform/stake-bridge');
|
|
2423
|
-
currencyMeta = lookupCurrency(
|
|
2679
|
+
currencyMeta = lookupCurrency(currencyCode);
|
|
2424
2680
|
}
|
|
2425
2681
|
catch {
|
|
2426
2682
|
/* stake-bridge not installed — resolveCurrency falls back to the code */
|
|
@@ -2428,16 +2684,17 @@ async function createSlotGame(opts) {
|
|
|
2428
2684
|
}
|
|
2429
2685
|
const runtime = {
|
|
2430
2686
|
balance,
|
|
2431
|
-
currency: resolveCurrency(currencyMeta,
|
|
2687
|
+
currency: resolveCurrency(currencyMeta, currencyCode),
|
|
2432
2688
|
language: initData?.lang,
|
|
2433
2689
|
mode,
|
|
2434
2690
|
social: config?.socialMode,
|
|
2435
2691
|
disclaimerLines: config?.disclaimerLines,
|
|
2436
2692
|
jurisdiction: config?.jurisdiction,
|
|
2437
|
-
// Currency-specific ladder + per-currency default from /wallet/authenticate (Stake)
|
|
2438
|
-
// absent on dev/devBridge → buildShellConfig falls back to
|
|
2693
|
+
// Currency-specific ladder + per-currency default from /wallet/authenticate (Stake) or the
|
|
2694
|
+
// backend's `allowed_bets` (Artube); absent on dev/devBridge → buildShellConfig falls back to
|
|
2695
|
+
// the spec.
|
|
2439
2696
|
betLevels: config?.betLevels,
|
|
2440
|
-
defaultBet: resumedBet ?? config?.stake?.defaultBetLevel ?? config?.defaultBet,
|
|
2697
|
+
defaultBet: resumedBet ?? config?.stake?.defaultBetLevel ?? artubeDefaultBet ?? config?.defaultBet,
|
|
2441
2698
|
// Hard stake window; the bridge rejects anything outside it before /bet/play.
|
|
2442
2699
|
minBet: config?.stake?.minBet,
|
|
2443
2700
|
maxBet: config?.stake?.maxBet,
|
|
@@ -2446,9 +2703,13 @@ async function createSlotGame(opts) {
|
|
|
2446
2703
|
// spec's (EUR-shaped) ladder here would put the game on bets the wallet can't honour — every
|
|
2447
2704
|
// spin rejected on a high-denomination currency (ARS minBet 50), or silently mispriced. Fail
|
|
2448
2705
|
// where the cause is visible instead of at the first spin.
|
|
2449
|
-
|
|
2706
|
+
// Artube is the same requirement by a different route: the wire carries a bet INDEX, not an
|
|
2707
|
+
// amount, and the bridge maps the amount the game plays to the NEAREST rung of the platform's
|
|
2708
|
+
// ladder — so a spec-shaped ladder wouldn't be rejected, it would silently charge a different
|
|
2709
|
+
// price than the bar shows. Refuse there too.
|
|
2710
|
+
if ((isStakeNow || isArtubeNow) && !runtime.betLevels?.length) {
|
|
2450
2711
|
fatal('Could not load the bet levels for your currency. Please relaunch the game.');
|
|
2451
|
-
throw new Error(
|
|
2712
|
+
throw new Error(`createSlotGame: ${isStakeNow ? 'Stake' : 'Artube'} launch returned no config.betLevels — refusing to fall back to the spec ladder`);
|
|
2452
2713
|
}
|
|
2453
2714
|
if (opts.dev) {
|
|
2454
2715
|
// Dev-only diagnostic. Logged as PLAIN STRINGS (not collapsed objects) so the values are
|
|
@@ -2991,7 +3252,7 @@ async function createSlotGame(opts) {
|
|
|
2991
3252
|
});
|
|
2992
3253
|
}
|
|
2993
3254
|
}
|
|
2994
|
-
return { game, stakeBridge, shell };
|
|
3255
|
+
return { game, stakeBridge, artubeBridge, shell };
|
|
2995
3256
|
}
|
|
2996
3257
|
|
|
2997
3258
|
// packages/game-engine/src/host/shellConfig.ts
|