@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/core.esm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Ticker, Assets, Container, Text, Application, Graphics } 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
 
5
5
  // ─── Scale Modes ───────────────────────────────────────────
6
6
  var ScaleMode;
@@ -679,6 +679,12 @@ class AudioManager {
679
679
  _categories;
680
680
  _masterGain = 1.0;
681
681
  _currentMusic = null;
682
+ /** Duck factor (0..1) from duckMusic/unduckMusic. A presentation state, not a player setting. */
683
+ _musicDuck = 1;
684
+ /** Crossfade ramp (0..1) for the track that is fading IN. 1 whenever no fade is running. */
685
+ _musicFade = 1;
686
+ /** Generation counter so a superseded crossfade ramp stops writing over the new track's. */
687
+ _musicFadeToken = 0;
682
688
  _unlocked = false;
683
689
  _unlockHandler = null;
684
690
  constructor(config) {
@@ -737,7 +743,10 @@ class AudioManager {
737
743
  if (this._globalMuted || this._categories[category].muted)
738
744
  return;
739
745
  const { sound } = this._soundModule;
740
- const vol = (options?.volume ?? 1) * this._categories[category].volume * this._masterGain;
746
+ // The master gain lives on the GLOBAL bus (`sound.volumeAll`, see applyVolumes) and @pixi/sound
747
+ // already multiplies it in — folding it in here as well squared it, so a master of 0.5 played
748
+ // sfx at 0.25.
749
+ const vol = (options?.volume ?? 1) * this._categories[category].volume;
741
750
  try {
742
751
  sound.play(alias, {
743
752
  volume: vol,
@@ -759,52 +768,50 @@ class AudioManager {
759
768
  if (!this._initialized || !this._soundModule)
760
769
  return;
761
770
  const { sound } = this._soundModule;
762
- // Stop current music with fade-out, start new music with fade-in
763
- if (this._currentMusic && fadeDuration > 0) {
764
- const prevAlias = this._currentMusic;
765
- this._currentMusic = alias;
766
- if (this._globalMuted || this._categories.music.muted)
767
- return;
768
- // Fade out the previous track
769
- this.fadeVolume(prevAlias, this._categories.music.volume * this._masterGain, 0, fadeDuration, () => {
770
- try {
771
- sound.stop(prevAlias);
772
- }
773
- catch { /* ignore */ }
774
- });
775
- // Start new track at zero volume, fade in
776
- try {
777
- sound.play(alias, {
778
- volume: 0,
779
- loop: true,
771
+ const prevAlias = this._currentMusic;
772
+ const crossfade = !!prevAlias && prevAlias !== alias && fadeDuration > 0;
773
+ // Retire the outgoing track. Its own SOUND-level volume is the only thing still pointing at it,
774
+ // so fading that to 0 is safe — nothing else writes it once `_currentMusic` has moved on.
775
+ if (prevAlias) {
776
+ if (crossfade) {
777
+ const from = this.soundVolumeOf(prevAlias);
778
+ this.fadeVolume(prevAlias, from, 0, fadeDuration, () => {
779
+ try {
780
+ sound.stop(prevAlias);
781
+ }
782
+ catch { /* ignore */ }
780
783
  });
781
- this.fadeVolume(alias, 0, this._categories.music.volume * this._masterGain, fadeDuration);
782
- }
783
- catch (e) {
784
- console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
785
784
  }
786
- }
787
- else {
788
- // No crossfade — instant switch
789
- if (this._currentMusic) {
785
+ else {
790
786
  try {
791
- sound.stop(this._currentMusic);
787
+ sound.stop(prevAlias);
792
788
  }
793
789
  catch { /* ignore */ }
794
790
  }
795
- this._currentMusic = alias;
796
- if (this._globalMuted || this._categories.music.muted)
797
- return;
798
- try {
799
- sound.play(alias, {
800
- volume: this._categories.music.volume * this._masterGain,
801
- loop: true,
802
- });
803
- }
804
- catch (e) {
805
- console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
806
- }
807
791
  }
792
+ this._currentMusic = alias;
793
+ this._musicFadeToken++; // any ramp still running belongs to a track we just replaced
794
+ // Deliberately started even while muted. Global mute is the @pixi/sound CONTEXT mute and a
795
+ // muted music category is a 0 term in `musicGain()` — both already make this inaudible, and
796
+ // both undo themselves the moment the player flips them back. Returning early here instead
797
+ // meant a track begun while muted never existed, so unmuting restored silence until some
798
+ // later mode change happened to switch tracks.
799
+ // The incoming track plays at INSTANCE volume 1 and carries its whole gain on the SOUND layer
800
+ // (`musicGain()`), which is the layer the slider, the duck and this fade all write. Splitting
801
+ // them across layers is what silenced every crossfade: the track was started at instance volume
802
+ // 0 and the ramp then moved the sound layer, whose product with 0 is 0 for the track's life.
803
+ // The gain is written BEFORE play() so the first frame is never at full volume.
804
+ this._musicFade = crossfade ? 0 : 1;
805
+ this.applyMusicGain();
806
+ try {
807
+ sound.play(alias, { volume: 1, loop: true });
808
+ }
809
+ catch (e) {
810
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
811
+ return;
812
+ }
813
+ if (crossfade)
814
+ this.rampMusicFade(fadeDuration);
808
815
  }
809
816
  /**
810
817
  * Stop current music.
@@ -820,6 +827,10 @@ class AudioManager {
820
827
  // ignore
821
828
  }
822
829
  this._currentMusic = null;
830
+ // Retire any running ramp and clear the fade term, so the next track does not inherit a
831
+ // half-finished crossfade and start silent.
832
+ this._musicFadeToken++;
833
+ this._musicFade = 1;
823
834
  }
824
835
  /**
825
836
  * Stop all sounds.
@@ -845,6 +856,9 @@ class AudioManager {
845
856
  */
846
857
  setVolume(category, volume) {
847
858
  this._categories[category].volume = Math.max(0, Math.min(1, volume));
859
+ // applyVolumes() re-pushes the music gain, so moving the Music slider is heard on the track
860
+ // that is ALREADY playing — it used to take effect only at the next playMusic (a mode change).
861
+ // SFX need no push: play() reads the category volume fresh on every call.
848
862
  this.applyVolumes();
849
863
  this.saveState();
850
864
  }
@@ -917,30 +931,19 @@ class AudioManager {
917
931
  * @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal
918
932
  */
919
933
  duckMusic(factor) {
920
- if (!this._initialized || !this._soundModule || !this._currentMusic)
921
- return;
922
- const { sound } = this._soundModule;
923
- const vol = this._categories.music.volume * factor;
924
- try {
925
- sound.volume(this._currentMusic, vol);
926
- }
927
- catch {
928
- // ignore
929
- }
934
+ // Held as a FACTOR rather than written as a finished volume: the duck used to write
935
+ // `category × factor` onto a track whose instance already carried the category volume, so it
936
+ // ducked to category², and unducking restored category² instead of category. Keeping it as one
937
+ // term of `musicGain()` also keeps the slider live while ducked.
938
+ this._musicDuck = Math.max(0, Math.min(1, factor));
939
+ this.applyMusicGain();
930
940
  }
931
941
  /**
932
942
  * Restore music to normal volume after ducking.
933
943
  */
934
944
  unduckMusic() {
935
- if (!this._initialized || !this._soundModule || !this._currentMusic)
936
- return;
937
- const { sound } = this._soundModule;
938
- try {
939
- sound.volume(this._currentMusic, this._categories.music.volume);
940
- }
941
- catch {
942
- // ignore
943
- }
945
+ this._musicDuck = 1;
946
+ this.applyMusicGain();
944
947
  }
945
948
  /**
946
949
  * Destroy the audio manager and free resources.
@@ -979,6 +982,57 @@ class AudioManager {
979
982
  };
980
983
  requestAnimationFrame(tick);
981
984
  }
985
+ /**
986
+ * The SOUND-layer gain for the running music track.
987
+ *
988
+ * @pixi/sound resolves a playing instance as `instance × sound × global` (WebAudioInstance.
989
+ * refresh). Each of those three has exactly ONE owner here, which is what keeps the mixer honest:
990
+ * global — the master gain (`applyVolumes`)
991
+ * sound — music: this function. sfx: untouched, left at 1.
992
+ * instance — sfx: the per-call volume × the sfx category. music: always 1.
993
+ * Everything that can move music volume — the player's slider, the category mute, a big-win duck,
994
+ * a crossfade — is a term below, so they compose instead of overwriting each other.
995
+ */
996
+ musicGain() {
997
+ const c = this._categories.music;
998
+ return (c.muted ? 0 : 1) * c.volume * this._musicDuck * this._musicFade;
999
+ }
1000
+ /** Push `musicGain()` at the current track. Safe before it starts playing and with none playing. */
1001
+ applyMusicGain() {
1002
+ if (!this._soundModule || !this._currentMusic)
1003
+ return;
1004
+ try {
1005
+ this._soundModule.sound.volume(this._currentMusic, this.musicGain());
1006
+ }
1007
+ catch {
1008
+ // ignore — alias not registered yet
1009
+ }
1010
+ }
1011
+ /** Current SOUND-layer volume of `alias`, or 0 when it cannot be read. */
1012
+ soundVolumeOf(alias) {
1013
+ try {
1014
+ return Number(this._soundModule.sound.volume(alias)) || 0;
1015
+ }
1016
+ catch {
1017
+ return 0;
1018
+ }
1019
+ }
1020
+ /** Ramp the crossfade term 0 → 1 over `durationMs`, recomposing the gain each frame so a slider
1021
+ * drag or a duck landing mid-fade is honoured rather than overwritten when the fade ends. */
1022
+ rampMusicFade(durationMs) {
1023
+ const token = this._musicFadeToken;
1024
+ const start = Date.now();
1025
+ const tick = () => {
1026
+ if (token !== this._musicFadeToken)
1027
+ return; // a newer track owns the music now
1028
+ const t = Math.min((Date.now() - start) / durationMs, 1);
1029
+ this._musicFade = t;
1030
+ this.applyMusicGain();
1031
+ if (t < 1)
1032
+ requestAnimationFrame(tick);
1033
+ };
1034
+ requestAnimationFrame(tick);
1035
+ }
982
1036
  applyVolumes() {
983
1037
  if (!this._soundModule)
984
1038
  return;
@@ -986,6 +1040,7 @@ class AudioManager {
986
1040
  // Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
987
1041
  // not by volumeAll — mixing both leaves mute un-undoable after reload.
988
1042
  sound.volumeAll = this._masterGain; // master multiplies the global bus
1043
+ this.applyMusicGain(); // category volume/mute reach the RUNNING track
989
1044
  }
990
1045
  setupMobileUnlock() {
991
1046
  if (this._unlocked)
@@ -1467,6 +1522,14 @@ class Scene {
1467
1522
  * tap-to-start → `waitCSSPreloaderTap`, then fades it out via
1468
1523
  * `removeCSSPreloader` before entering the game. One continuous overlay from
1469
1524
  * boot to gameplay — no second logo, no mid-load flash.
1525
+ *
1526
+ * When the game supplied its own overlay (`loading.externalOverlay`, e.g.
1527
+ * Artube's `LoaderViewController`), this scene is also the HAND-OVER point: that
1528
+ * overlay covered the gap this scene's existence ends — the bundle download,
1529
+ * Pixi init and the SDK handshake, none of which the engine can paint over. The
1530
+ * first thing `onEnter` does is mount the preloader, wait for it to be painted,
1531
+ * and dismiss the game's overlay. Everything after that line is identical on
1532
+ * every platform.
1470
1533
  */
1471
1534
  class LoadingScene extends Scene {
1472
1535
  _engine;
@@ -1484,6 +1547,10 @@ class LoadingScene extends Scene {
1484
1547
  this._targetScene = targetScene;
1485
1548
  this._targetData = targetData;
1486
1549
  this._config = engine.config.loading ?? {};
1550
+ // Take the screen from a game-supplied loading overlay, if there is one. Before any awaited
1551
+ // work: from here on the player is looking at OUR loading screen, and `_startTime` (which
1552
+ // `minDisplayTime` is measured from) must start when that becomes true.
1553
+ await this.takeOverFromExternalOverlay();
1487
1554
  this._startTime = Date.now();
1488
1555
  // Initialize asset manager
1489
1556
  await this._engine.assets.init();
@@ -1530,8 +1597,10 @@ class LoadingScene extends Scene {
1530
1597
  // Final snap to 100%
1531
1598
  this._displayedProgress = 1;
1532
1599
  this.updateLoaderBar(1);
1533
- // Wait for the player's tap — resolves immediately when tapToStart is
1534
- // false (the preloader honours that flag) then enter the game.
1600
+ // Wait for the player's tap — resolves immediately when tapToStart is false — then enter the
1601
+ // game. This is the preloader's gate and it reads the preloader's config, so it means the same
1602
+ // thing on every target: a game-supplied overlay has no say in it, and by now no part in the
1603
+ // screen either. It was dismissed at the hand-over above; the player is looking at ours.
1535
1604
  await waitCSSPreloaderTap();
1536
1605
  await this.transitionToGame();
1537
1606
  }
@@ -1550,6 +1619,59 @@ class LoadingScene extends Scene {
1550
1619
  // (e.g. the scene was popped externally). Idempotent.
1551
1620
  void removeCSSPreloader(this.hostElement());
1552
1621
  }
1622
+ // ─── Hand-over from a game-supplied overlay ────────────
1623
+ /**
1624
+ * Swap a game-supplied loading overlay for the engine's own loading screen.
1625
+ *
1626
+ * The overlay (Artube's) has been on screen since before this bundle was fetched, covering a gap
1627
+ * nothing of ours could. Its job ends here, at the first frame the engine paints; the player then
1628
+ * gets the game's own brand, progress bar and tap-to-start, exactly as on every other target.
1629
+ *
1630
+ * The order of the four steps is the whole design, and each is wrong on its own:
1631
+ *
1632
+ * 0. Wait out whatever the overlay is still owed on screen (`externalOverlayMinDisplayTime`,
1633
+ * default 1.5s, plus room for a phase crossfade already in flight). The gap this overlay
1634
+ * covers can be under half a second, which is not long enough for a partner's brand to
1635
+ * register. Waiting here — BEFORE mounting ours — rather than after is what keeps the two
1636
+ * screens' timelines from overlapping: our splash and brand floor start when the player can
1637
+ * actually see them, not behind someone else's overlay. On any boot slower than the floor
1638
+ * this step costs nothing, and on a non-Artube target it is not reached at all.
1639
+ * 1. Mount the preloader, opaque and full-bleed, while theirs is still up. Both are on screen
1640
+ * together for a few frames, so there is never a moment with neither, whatever happens next.
1641
+ * 2. Wait for that frame to actually be PAINTED — mounting only queues it. Dismissing theirs
1642
+ * before the paint is precisely the flash of bare background this ordering exists to avoid.
1643
+ * Two `requestAnimationFrame`s: the first callback runs before the frame it belongs to is
1644
+ * composited, the second after. Two frames is also enough for Pixi's own rAF-driven ticker
1645
+ * to have rendered this scene at least once, so "the loading scene has painted" is literally
1646
+ * true by the time step 3 runs.
1647
+ * 3. Only then dismiss theirs. Their `hideLoader()` plays a 0.3s fade and removes the element.
1648
+ * Not waiting for that fade is deliberate — it is an animation on someone else's element,
1649
+ * and blocking a boot on it would be a hang waiting to happen.
1650
+ *
1651
+ * Which of the two is visually on top is the host page's business, not ours, and it does NOT
1652
+ * change the guarantee. On a typical game page (`#game { position: fixed; inset: 0 }`) the fixed
1653
+ * container establishes a stacking context, so the preloader's z-index is scoped inside it and
1654
+ * Artube's `position: fixed; z-index: 9999` sits above — their fade then crossfades onto our
1655
+ * loading screen, which is what was observed live and looks right. On a page where ours wins
1656
+ * instead, their fade simply plays underneath, unseen. Either way the seam is covered, because
1657
+ * what step 2 buys is that OUR screen is already painted before theirs starts going away.
1658
+ */
1659
+ async takeOverFromExternalOverlay() {
1660
+ if (!hasExternalOverlay())
1661
+ return;
1662
+ await externalOverlayHold();
1663
+ createCSSPreloader(this.hostElement(), this._config);
1664
+ await this.nextPaint();
1665
+ releaseExternalOverlay();
1666
+ }
1667
+ /** Resolves after the browser has composited at least one frame (see step 2 above). */
1668
+ nextPaint() {
1669
+ if (typeof requestAnimationFrame !== 'function')
1670
+ return Promise.resolve();
1671
+ return new Promise((resolve) => {
1672
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
1673
+ });
1674
+ }
1553
1675
  // ─── Progress ──────────────────────────────────────────
1554
1676
  updateLoaderBar(progress) {
1555
1677
  setCSSPreloaderProgress(Math.max(0, Math.min(1, progress)));
@@ -1804,14 +1926,37 @@ class GameApplication extends EventEmitter {
1804
1926
  return;
1805
1927
  }
1806
1928
  try {
1929
+ // 0. Adopt a game-supplied loading overlay (`loading.externalOverlay`) BEFORE anything that
1930
+ // can throw. Such an overlay is already on screen — Artube's is injected into index.html,
1931
+ // so it paints before this bundle is even fetched — and until the engine has adopted it,
1932
+ // the catch below has no way to take it down. A bad `container` selector (step 1) would
1933
+ // otherwise strand it on screen forever. It needs no container of ours.
1934
+ // Adoption is also where its minimum display time starts counting, which is why the
1935
+ // config value is handed over here rather than read at the hand-over: this is the
1936
+ // earliest moment the engine runs, and the overlay has been on screen since before it.
1937
+ const external = this.config.loading?.externalOverlay;
1938
+ if (external)
1939
+ adoptExternalOverlay(external, this.config.loading?.externalOverlayMinDisplayTime);
1807
1940
  // 1. Resolve container element
1808
1941
  this._container = this.resolveContainer();
1809
- // 2. Show CSS preloader immediately (before PixiJS)
1810
- createCSSPreloader(this._container, this.config.loading);
1942
+ // 2. Show the CSS preloader immediately (before PixiJS) — UNLESS a game-supplied overlay is
1943
+ // already covering the screen. In that case the preloader is mounted later, by LoadingScene
1944
+ // at its first frame, which is where the hand-over happens. Mounting it here instead would
1945
+ // put our brand over theirs for the whole of Pixi init and the SDK handshake, i.e. hand
1946
+ // over long before the gap the external overlay exists to cover has closed.
1947
+ if (!hasExternalOverlay())
1948
+ createCSSPreloader(this._container, this.config.loading);
1811
1949
  // 3. Initialize PixiJS
1812
1950
  await this.initPixi();
1951
+ // Milestones through the pre-first-frame gap, for a game-supplied overlay only (no-ops
1952
+ // otherwise, so the built-in preloader's behaviour is untouched). They are also what makes
1953
+ // Artube's loader crossfade from its dark partner phase to its branded one: that transition
1954
+ // fires on the first progress above zero, and without it the player would never see the
1955
+ // brand the loader exists to show. Values are honest weights of what remains, not a timer.
1956
+ advanceExternalOverlay(0.35);
1813
1957
  // 4. Initialize SDK (if enabled)
1814
1958
  await this.initSDK();
1959
+ advanceExternalOverlay(0.7);
1815
1960
  // 4b. Mount the branded game shell after the SDK handshake (optional)
1816
1961
  if (this.config.shell) {
1817
1962
  const { createGameShell } = await import('@energy8platform/shell/html');
@@ -1821,10 +1966,17 @@ class GameApplication extends EventEmitter {
1821
1966
  this.applySDKConfig();
1822
1967
  // 6. Initialize sub-systems
1823
1968
  this.initSubSystems();
1969
+ advanceExternalOverlay(0.85);
1824
1970
  this.emit('initialized');
1825
1971
  // 7. Load assets. The CSS preloader stays on screen — LoadingScene drives
1826
1972
  // its progress/tap and removes it before entering the game, so there's
1827
1973
  // a single continuous overlay from boot to gameplay (no logo flash).
1974
+ //
1975
+ // With a game-supplied overlay the sequence has one extra step at the
1976
+ // front: LoadingScene MOUNTS the preloader, waits for its first painted
1977
+ // frame, and only then dismisses the external overlay. From that frame
1978
+ // on this path and every other are identical — same brand, same bar,
1979
+ // same tap-to-start.
1828
1980
  await this.loadAssets(firstScene, sceneData);
1829
1981
  this.emit('loaded');
1830
1982
  // 8. Start the game loop
@@ -1833,9 +1985,13 @@ class GameApplication extends EventEmitter {
1833
1985
  }
1834
1986
  catch (err) {
1835
1987
  console.error('[GameEngine] Failed to start:', err);
1836
- // Tear down the preloader so a failure doesn't strand the brand frame.
1837
- if (this._container)
1838
- removeCSSPreloader(this._container);
1988
+ // Tear down both possible overlays so a failure strands neither brand frame. BOTH calls run:
1989
+ // a throw during the hand-over window can leave the preloader mounted AND the external
1990
+ // overlay still adopted, and each call is a no-op when there is nothing to remove. The
1991
+ // container may never have resolved (step 1 is inside this try), hence the `document.body`
1992
+ // fallback — the external overlay ignores the element entirely.
1993
+ releaseExternalOverlay();
1994
+ void removeCSSPreloader(this._container ?? document.body);
1839
1995
  this.emit('error', err instanceof Error ? err : new Error(String(err)));
1840
1996
  throw err;
1841
1997
  }