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