@energy8platform/game-engine 0.18.0 → 0.20.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/index.cjs.js CHANGED
@@ -736,6 +736,7 @@ class AudioManager {
736
736
  _persist;
737
737
  _storageKey;
738
738
  _categories;
739
+ _masterGain = 1.0;
739
740
  _currentMusic = null;
740
741
  _unlocked = false;
741
742
  _unlockHandler = null;
@@ -795,7 +796,7 @@ class AudioManager {
795
796
  if (this._globalMuted || this._categories[category].muted)
796
797
  return;
797
798
  const { sound } = this._soundModule;
798
- const vol = (options?.volume ?? 1) * this._categories[category].volume;
799
+ const vol = (options?.volume ?? 1) * this._categories[category].volume * this._masterGain;
799
800
  try {
800
801
  sound.play(alias, {
801
802
  volume: vol,
@@ -824,7 +825,7 @@ class AudioManager {
824
825
  if (this._globalMuted || this._categories.music.muted)
825
826
  return;
826
827
  // Fade out the previous track
827
- this.fadeVolume(prevAlias, this._categories.music.volume, 0, fadeDuration, () => {
828
+ this.fadeVolume(prevAlias, this._categories.music.volume * this._masterGain, 0, fadeDuration, () => {
828
829
  try {
829
830
  sound.stop(prevAlias);
830
831
  }
@@ -836,7 +837,7 @@ class AudioManager {
836
837
  volume: 0,
837
838
  loop: true,
838
839
  });
839
- this.fadeVolume(alias, 0, this._categories.music.volume, fadeDuration);
840
+ this.fadeVolume(alias, 0, this._categories.music.volume * this._masterGain, fadeDuration);
840
841
  }
841
842
  catch (e) {
842
843
  console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
@@ -855,7 +856,7 @@ class AudioManager {
855
856
  return;
856
857
  try {
857
858
  sound.play(alias, {
858
- volume: this._categories.music.volume,
859
+ volume: this._categories.music.volume * this._masterGain,
859
860
  loop: true,
860
861
  });
861
862
  }
@@ -889,6 +890,15 @@ class AudioManager {
889
890
  sound.stopAll();
890
891
  this._currentMusic = null;
891
892
  }
893
+ /** Global gain (0..1) folded into every category's effective volume. Driven by the shell's
894
+ * 'master' settingChange. Does not affect the persisted per-category volumes. */
895
+ setMasterVolume(volume) {
896
+ this._masterGain = Math.max(0, Math.min(1, volume));
897
+ this.applyVolumes();
898
+ }
899
+ getMasterVolume() {
900
+ return this._masterGain;
901
+ }
892
902
  /**
893
903
  * Set volume for a category.
894
904
  */
@@ -1034,7 +1044,7 @@ class AudioManager {
1034
1044
  const { sound } = this._soundModule;
1035
1045
  // Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
1036
1046
  // not by volumeAll — mixing both leaves mute un-undoable after reload.
1037
- sound.volumeAll = 1;
1047
+ sound.volumeAll = this._masterGain; // master multiplies the global bus
1038
1048
  }
1039
1049
  setupMobileUnlock() {
1040
1050
  if (this._unlocked)
@@ -1302,6 +1312,7 @@ class ViewportManager extends EventEmitter {
1302
1312
  _app;
1303
1313
  _container;
1304
1314
  _config;
1315
+ _target;
1305
1316
  _resizeObserver = null;
1306
1317
  _currentOrientation = exports.Orientation.LANDSCAPE;
1307
1318
  _currentWidth = 0;
@@ -1309,11 +1320,15 @@ class ViewportManager extends EventEmitter {
1309
1320
  _currentScale = 1;
1310
1321
  _destroyed = false;
1311
1322
  _resizeTimeout = null;
1312
- constructor(app, container, config) {
1323
+ constructor(app, container, config, target) {
1313
1324
  super();
1314
1325
  this._app = app;
1315
1326
  this._container = container;
1316
1327
  this._config = config;
1328
+ // The container this manager scales/offsets. Defaults to app.stage for backward
1329
+ // compatibility; the engine passes a dedicated scaled world root so app.stage stays
1330
+ // identity (screen space) for unscaled UI layers.
1331
+ this._target = target ?? app.stage;
1317
1332
  this.setupObserver();
1318
1333
  }
1319
1334
  /** Current canvas width in game units */
@@ -1402,19 +1417,19 @@ class ViewportManager extends EventEmitter {
1402
1417
  const stageScale = scaleMode === exports.ScaleMode.STRETCH
1403
1418
  ? Math.min(containerWidth / designWidth, containerHeight / designHeight)
1404
1419
  : scale;
1405
- this._app.stage.scale.set(stageScale);
1420
+ this._target.scale.set(stageScale);
1406
1421
  // Center the stage for FIT mode
1407
1422
  if (scaleMode === exports.ScaleMode.FIT) {
1408
- this._app.stage.x = Math.round((containerWidth - designWidth * stageScale) / 2);
1409
- this._app.stage.y = Math.round((containerHeight - designHeight * stageScale) / 2);
1423
+ this._target.x = Math.round((containerWidth - designWidth * stageScale) / 2);
1424
+ this._target.y = Math.round((containerHeight - designHeight * stageScale) / 2);
1410
1425
  }
1411
1426
  else if (scaleMode === exports.ScaleMode.FILL) {
1412
- this._app.stage.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
1413
- this._app.stage.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
1427
+ this._target.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
1428
+ this._target.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
1414
1429
  }
1415
1430
  else {
1416
- this._app.stage.x = 0;
1417
- this._app.stage.y = 0;
1431
+ this._target.x = 0;
1432
+ this._target.y = 0;
1418
1433
  }
1419
1434
  this._currentWidth = gameWidth;
1420
1435
  this._currentHeight = gameHeight;
@@ -1503,34 +1518,20 @@ class Scene {
1503
1518
  }
1504
1519
 
1505
1520
  /**
1506
- * Build the loading scene variant of the logo SVG.
1507
- * Uses unique IDs (prefixed with 'ls') to avoid collisions with CSSPreloader.
1508
- */
1509
- function buildLoadingLogoSVG() {
1510
- return loading.buildLogoSVG({
1511
- idPrefix: 'ls',
1512
- svgStyle: 'width:100%;height:auto;',
1513
- clipRectId: 'ge-loader-rect',
1514
- textId: 'ge-loader-pct',
1515
- textContent: '0%',
1516
- });
1517
- }
1518
- /**
1519
- * Built-in loading screen using the Energy8 SVG logo with animated loader bar.
1521
+ * Built-in loading screen.
1520
1522
  *
1521
- * Renders as an HTML overlay on top of the canvas for crisp SVG quality.
1522
- * The loader bar fill width is driven by asset loading progress.
1523
+ * It does NOT render its own overlay the CSS preloader created at boot
1524
+ * (`createPlatformSession`/`GameApplication.start`) stays on screen, and this
1525
+ * scene merely drives it: asset-load progress → `setCSSPreloaderProgress`,
1526
+ * tap-to-start → `waitCSSPreloaderTap`, then fades it out via
1527
+ * `removeCSSPreloader` before entering the game. One continuous overlay from
1528
+ * boot to gameplay — no second logo, no mid-load flash.
1523
1529
  */
1524
1530
  class LoadingScene extends Scene {
1525
1531
  _engine;
1526
1532
  _targetScene;
1527
1533
  _targetData;
1528
1534
  _config;
1529
- // HTML overlay
1530
- _overlay = null;
1531
- _loaderRect = null;
1532
- _percentEl = null;
1533
- _tapToStartEl = null;
1534
1535
  // State
1535
1536
  _displayedProgress = 0;
1536
1537
  _targetProgress = 0;
@@ -1543,8 +1544,6 @@ class LoadingScene extends Scene {
1543
1544
  this._targetData = targetData;
1544
1545
  this._config = engine.config.loading ?? {};
1545
1546
  this._startTime = Date.now();
1546
- // Create the HTML overlay with the SVG logo
1547
- this.createOverlay();
1548
1547
  // Initialize asset manager
1549
1548
  await this._engine.assets.init();
1550
1549
  // Initialize audio manager
@@ -1590,110 +1589,29 @@ class LoadingScene extends Scene {
1590
1589
  // Final snap to 100%
1591
1590
  this._displayedProgress = 1;
1592
1591
  this.updateLoaderBar(1);
1593
- // Show "Tap to Start" or transition directly
1594
- if (this._config.tapToStart !== false) {
1595
- await this.showTapToStart();
1596
- }
1597
- else {
1598
- await this.transitionToGame();
1599
- }
1592
+ // Wait for the player's tap resolves immediately when tapToStart is
1593
+ // false (the preloader honours that flag) — then enter the game.
1594
+ await loading.waitCSSPreloaderTap();
1595
+ await this.transitionToGame();
1600
1596
  }
1601
1597
  onUpdate(dt) {
1602
- // Smooth progress bar fill via HTML (during active loading)
1598
+ // Smooth progress bar fill (during active loading)
1603
1599
  if (!this._loadingComplete && this._displayedProgress < this._targetProgress) {
1604
1600
  this._displayedProgress = Math.min(this._displayedProgress + dt * 1.5, this._targetProgress);
1605
1601
  this.updateLoaderBar(this._displayedProgress);
1606
1602
  }
1607
1603
  }
1608
1604
  onResize(_width, _height) {
1609
- // Overlay is CSS-based, auto-resizes
1605
+ // The preloader overlay is CSS-based and auto-resizes.
1610
1606
  }
1611
1607
  onDestroy() {
1612
- this.removeOverlay();
1613
- }
1614
- // ─── HTML Overlay ──────────────────────────────────────
1615
- createOverlay() {
1616
- const bgColor = typeof this._config.backgroundColor === 'string'
1617
- ? this._config.backgroundColor
1618
- : typeof this._config.backgroundColor === 'number'
1619
- ? `#${this._config.backgroundColor.toString(16).padStart(6, '0')}`
1620
- : '#0a0a1a';
1621
- const bgGradient = this._config.backgroundGradient ??
1622
- `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
1623
- this._overlay = document.createElement('div');
1624
- this._overlay.id = '__ge-loading-overlay__';
1625
- this._overlay.innerHTML = `
1626
- <div class="ge-loading-content">
1627
- ${buildLoadingLogoSVG()}
1628
- </div>
1629
- `;
1630
- const style = document.createElement('style');
1631
- style.id = '__ge-loading-style__';
1632
- style.textContent = `
1633
- #__ge-loading-overlay__ {
1634
- position: absolute;
1635
- top: 0; left: 0;
1636
- width: 100%; height: 100%;
1637
- background: ${bgGradient};
1638
- display: flex;
1639
- align-items: center;
1640
- justify-content: center;
1641
- z-index: 9999;
1642
- transition: opacity 0.5s ease-out;
1643
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1644
- }
1645
- #__ge-loading-overlay__.ge-fade-out {
1646
- opacity: 0;
1647
- pointer-events: none;
1648
- }
1649
- .ge-loading-content {
1650
- display: flex;
1651
- flex-direction: column;
1652
- align-items: center;
1653
- width: 75%;
1654
- max-width: 650px;
1655
- }
1656
- .ge-loading-content svg {
1657
- filter: drop-shadow(0 0 40px rgba(121, 57, 194, 0.5));
1658
- cursor: default;
1659
- }
1660
-
1661
- .ge-svg-pulse {
1662
- animation: ge-tap-pulse 1.2s ease-in-out infinite;
1663
- }
1664
- @keyframes ge-tap-pulse {
1665
- 0%, 100% { opacity: 0.5; }
1666
- 50% { opacity: 1; }
1667
- }
1668
- `;
1669
- // Get the container that holds the canvas
1670
- const container = this._engine.app?.canvas?.parentElement;
1671
- if (container) {
1672
- container.style.position = container.style.position || 'relative';
1673
- container.appendChild(style);
1674
- container.appendChild(this._overlay);
1675
- }
1676
- // Cache the SVG loader rect for progress updates
1677
- this._loaderRect = this._overlay.querySelector('#ge-loader-rect');
1678
- this._percentEl = this._overlay.querySelector('#ge-loader-pct');
1679
- }
1680
- removeOverlay() {
1681
- this._overlay?.remove();
1682
- document.getElementById('__ge-loading-style__')?.remove();
1683
- this._overlay = null;
1684
- this._loaderRect = null;
1685
- this._percentEl = null;
1686
- this._tapToStartEl = null;
1608
+ // Defensive: ensure the preloader is gone even if we never transitioned
1609
+ // (e.g. the scene was popped externally). Idempotent.
1610
+ void loading.removeCSSPreloader(this.hostElement());
1687
1611
  }
1688
1612
  // ─── Progress ──────────────────────────────────────────
1689
1613
  updateLoaderBar(progress) {
1690
- if (this._loaderRect) {
1691
- this._loaderRect.setAttribute('width', String(loading.LOADER_BAR_MAX_WIDTH * progress));
1692
- }
1693
- if (this._percentEl) {
1694
- const pct = Math.round(progress * 100);
1695
- this._percentEl.textContent = `${pct}%`;
1696
- }
1614
+ loading.setCSSPreloaderProgress(Math.max(0, Math.min(1, progress)));
1697
1615
  }
1698
1616
  /**
1699
1617
  * Smoothly animate the displayed progress from its current value to `target`
@@ -1723,49 +1641,20 @@ class LoadingScene extends Scene {
1723
1641
  requestAnimationFrame(tick);
1724
1642
  });
1725
1643
  }
1726
- // ─── Tap to Start ─────────────────────────────────────
1727
- async showTapToStart() {
1728
- const tapText = this._config.tapToStartText ?? 'TAP TO START';
1729
- // Reuse the same SVG text element — replace percentage with tap text
1730
- if (this._percentEl) {
1731
- const el = this._percentEl;
1732
- el.textContent = tapText;
1733
- el.setAttribute('fill', '#ffffff');
1734
- el.classList.add('ge-svg-pulse');
1735
- this._tapToStartEl = el;
1736
- }
1737
- // Make overlay clickable
1738
- if (this._overlay) {
1739
- this._overlay.style.cursor = 'pointer';
1740
- }
1741
- // Wait for tap
1742
- return new Promise((resolve) => {
1743
- const handler = async () => {
1744
- this._overlay?.removeEventListener('click', handler);
1745
- await this.transitionToGame();
1746
- resolve();
1747
- };
1748
- // Listen on the full overlay for easier mobile tap
1749
- this._overlay?.addEventListener('click', handler);
1750
- });
1751
- }
1752
1644
  // ─── Transition ────────────────────────────────────────
1645
+ /** The DOM element hosting the canvas + preloader overlay. */
1646
+ hostElement() {
1647
+ return this._engine?.app?.canvas?.parentElement ?? document.body;
1648
+ }
1753
1649
  async transitionToGame() {
1754
- // Fade out the HTML overlay
1755
- if (this._overlay) {
1756
- this._overlay.classList.add('ge-fade-out');
1757
- await new Promise((resolve) => {
1758
- this._overlay.addEventListener('transitionend', () => resolve(), { once: true });
1759
- // Safety timeout
1760
- setTimeout(resolve, 600);
1761
- });
1762
- }
1763
- // Remove overlay
1764
- this.removeOverlay();
1650
+ // Fade out and remove the shared CSS preloader (resolves after the fade).
1651
+ await loading.removeCSSPreloader(this.hostElement());
1765
1652
  // Navigate to the target scene, always passing the engine reference
1766
1653
  await this._engine.scenes.goto(this._targetScene, {
1767
1654
  engine: this._engine,
1768
- ...(this._targetData && typeof this._targetData === 'object' ? this._targetData : { data: this._targetData }),
1655
+ ...(this._targetData && typeof this._targetData === 'object'
1656
+ ? this._targetData
1657
+ : { data: this._targetData }),
1769
1658
  });
1770
1659
  }
1771
1660
  }
@@ -1905,6 +1794,12 @@ class GameApplication extends EventEmitter {
1905
1794
  input;
1906
1795
  /** Viewport manager */
1907
1796
  viewport;
1797
+ /** Scaled world root (holds scenes). Transformed by the ViewportManager to fit the design
1798
+ * resolution; lives below the UI layer on app.stage. */
1799
+ worldRoot;
1800
+ /** Unscaled, screen-space UI layer. Sits above {@link worldRoot} and is NOT touched by the
1801
+ * viewport transform — children fill the real screen (e.g. the host's shell + overlay). */
1802
+ uiLayer;
1908
1803
  /** SDK instance (null in offline mode) */
1909
1804
  sdk = null;
1910
1805
  /** FPS overlay instance (only when debug: true) */
@@ -1986,17 +1881,20 @@ class GameApplication extends EventEmitter {
1986
1881
  // 6. Initialize sub-systems
1987
1882
  this.initSubSystems();
1988
1883
  this.emit('initialized');
1989
- // 7. Remove CSS preloader, show Canvas loading screen
1990
- loading.removeCSSPreloader(this._container);
1991
- // 8. Load assets with loading screen
1884
+ // 7. Load assets. The CSS preloader stays on screen — LoadingScene drives
1885
+ // its progress/tap and removes it before entering the game, so there's
1886
+ // a single continuous overlay from boot to gameplay (no logo flash).
1992
1887
  await this.loadAssets(firstScene, sceneData);
1993
1888
  this.emit('loaded');
1994
- // 9. Start the game loop
1889
+ // 8. Start the game loop
1995
1890
  this._running = true;
1996
1891
  this.emit('started');
1997
1892
  }
1998
1893
  catch (err) {
1999
1894
  console.error('[GameEngine] Failed to start:', err);
1895
+ // Tear down the preloader so a failure doesn't strand the brand frame.
1896
+ if (this._container)
1897
+ loading.removeCSSPreloader(this._container);
2000
1898
  this.emit('error', err instanceof Error ? err : new Error(String(err)));
2001
1899
  throw err;
2002
1900
  }
@@ -2084,20 +1982,27 @@ class GameApplication extends EventEmitter {
2084
1982
  this.audio = new AudioManager(this.config.audio);
2085
1983
  // Input Manager
2086
1984
  this.input = new InputManager(this.app.canvas);
2087
- // Viewport Manager
1985
+ // Stage layers: a scaled world root (scenes, transformed to design resolution by the
1986
+ // viewport) below an unscaled UI layer (screen space). app.stage itself stays identity.
1987
+ this.worldRoot = new pixi_js.Container();
1988
+ this.worldRoot.label = 'world';
1989
+ this.uiLayer = new pixi_js.Container();
1990
+ this.uiLayer.label = 'ui';
1991
+ this.app.stage.addChild(this.worldRoot, this.uiLayer);
1992
+ // Viewport Manager — scales worldRoot (NOT app.stage), so the UI layer is unscaled.
2088
1993
  this.viewport = new ViewportManager(this.app, this._container, {
2089
1994
  designWidth: this.config.designWidth,
2090
1995
  designHeight: this.config.designHeight,
2091
1996
  scaleMode: this.config.scaleMode,
2092
1997
  orientation: this.config.orientation,
2093
- });
2094
- // Wire SceneManager to the PixiJS stage
2095
- this.scenes.setRoot(this.app.stage);
1998
+ }, this.worldRoot);
1999
+ // Wire SceneManager to the scaled world root
2000
+ this.scenes.setRoot(this.worldRoot);
2096
2001
  this.scenes.setApp(this);
2097
2002
  // Wire viewport resize → scene manager + input manager
2098
2003
  this.viewport.on('resize', ({ width, height, scale }) => {
2099
2004
  this.scenes.resize(width, height);
2100
- this.input.setViewportTransform(scale, this.app.stage.x, this.app.stage.y);
2005
+ this.input.setViewportTransform(scale, this.worldRoot.x, this.worldRoot.y);
2101
2006
  this.emit('resize', { width, height });
2102
2007
  });
2103
2008
  this.viewport.on('orientationChange', (orientation) => {