@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/core.cjs.js CHANGED
@@ -664,6 +664,7 @@ class AudioManager {
664
664
  _persist;
665
665
  _storageKey;
666
666
  _categories;
667
+ _masterGain = 1.0;
667
668
  _currentMusic = null;
668
669
  _unlocked = false;
669
670
  _unlockHandler = null;
@@ -723,7 +724,7 @@ class AudioManager {
723
724
  if (this._globalMuted || this._categories[category].muted)
724
725
  return;
725
726
  const { sound } = this._soundModule;
726
- const vol = (options?.volume ?? 1) * this._categories[category].volume;
727
+ const vol = (options?.volume ?? 1) * this._categories[category].volume * this._masterGain;
727
728
  try {
728
729
  sound.play(alias, {
729
730
  volume: vol,
@@ -752,7 +753,7 @@ class AudioManager {
752
753
  if (this._globalMuted || this._categories.music.muted)
753
754
  return;
754
755
  // Fade out the previous track
755
- this.fadeVolume(prevAlias, this._categories.music.volume, 0, fadeDuration, () => {
756
+ this.fadeVolume(prevAlias, this._categories.music.volume * this._masterGain, 0, fadeDuration, () => {
756
757
  try {
757
758
  sound.stop(prevAlias);
758
759
  }
@@ -764,7 +765,7 @@ class AudioManager {
764
765
  volume: 0,
765
766
  loop: true,
766
767
  });
767
- this.fadeVolume(alias, 0, this._categories.music.volume, fadeDuration);
768
+ this.fadeVolume(alias, 0, this._categories.music.volume * this._masterGain, fadeDuration);
768
769
  }
769
770
  catch (e) {
770
771
  console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
@@ -783,7 +784,7 @@ class AudioManager {
783
784
  return;
784
785
  try {
785
786
  sound.play(alias, {
786
- volume: this._categories.music.volume,
787
+ volume: this._categories.music.volume * this._masterGain,
787
788
  loop: true,
788
789
  });
789
790
  }
@@ -817,6 +818,15 @@ class AudioManager {
817
818
  sound.stopAll();
818
819
  this._currentMusic = null;
819
820
  }
821
+ /** Global gain (0..1) folded into every category's effective volume. Driven by the shell's
822
+ * 'master' settingChange. Does not affect the persisted per-category volumes. */
823
+ setMasterVolume(volume) {
824
+ this._masterGain = Math.max(0, Math.min(1, volume));
825
+ this.applyVolumes();
826
+ }
827
+ getMasterVolume() {
828
+ return this._masterGain;
829
+ }
820
830
  /**
821
831
  * Set volume for a category.
822
832
  */
@@ -962,7 +972,7 @@ class AudioManager {
962
972
  const { sound } = this._soundModule;
963
973
  // Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
964
974
  // not by volumeAll — mixing both leaves mute un-undoable after reload.
965
- sound.volumeAll = 1;
975
+ sound.volumeAll = this._masterGain; // master multiplies the global bus
966
976
  }
967
977
  setupMobileUnlock() {
968
978
  if (this._unlocked)
@@ -1230,6 +1240,7 @@ class ViewportManager extends EventEmitter {
1230
1240
  _app;
1231
1241
  _container;
1232
1242
  _config;
1243
+ _target;
1233
1244
  _resizeObserver = null;
1234
1245
  _currentOrientation = Orientation.LANDSCAPE;
1235
1246
  _currentWidth = 0;
@@ -1237,11 +1248,15 @@ class ViewportManager extends EventEmitter {
1237
1248
  _currentScale = 1;
1238
1249
  _destroyed = false;
1239
1250
  _resizeTimeout = null;
1240
- constructor(app, container, config) {
1251
+ constructor(app, container, config, target) {
1241
1252
  super();
1242
1253
  this._app = app;
1243
1254
  this._container = container;
1244
1255
  this._config = config;
1256
+ // The container this manager scales/offsets. Defaults to app.stage for backward
1257
+ // compatibility; the engine passes a dedicated scaled world root so app.stage stays
1258
+ // identity (screen space) for unscaled UI layers.
1259
+ this._target = target ?? app.stage;
1245
1260
  this.setupObserver();
1246
1261
  }
1247
1262
  /** Current canvas width in game units */
@@ -1330,19 +1345,19 @@ class ViewportManager extends EventEmitter {
1330
1345
  const stageScale = scaleMode === ScaleMode.STRETCH
1331
1346
  ? Math.min(containerWidth / designWidth, containerHeight / designHeight)
1332
1347
  : scale;
1333
- this._app.stage.scale.set(stageScale);
1348
+ this._target.scale.set(stageScale);
1334
1349
  // Center the stage for FIT mode
1335
1350
  if (scaleMode === ScaleMode.FIT) {
1336
- this._app.stage.x = Math.round((containerWidth - designWidth * stageScale) / 2);
1337
- this._app.stage.y = Math.round((containerHeight - designHeight * stageScale) / 2);
1351
+ this._target.x = Math.round((containerWidth - designWidth * stageScale) / 2);
1352
+ this._target.y = Math.round((containerHeight - designHeight * stageScale) / 2);
1338
1353
  }
1339
1354
  else if (scaleMode === ScaleMode.FILL) {
1340
- this._app.stage.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
1341
- this._app.stage.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
1355
+ this._target.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
1356
+ this._target.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
1342
1357
  }
1343
1358
  else {
1344
- this._app.stage.x = 0;
1345
- this._app.stage.y = 0;
1359
+ this._target.x = 0;
1360
+ this._target.y = 0;
1346
1361
  }
1347
1362
  this._currentWidth = gameWidth;
1348
1363
  this._currentHeight = gameHeight;
@@ -1431,34 +1446,20 @@ class Scene {
1431
1446
  }
1432
1447
 
1433
1448
  /**
1434
- * Build the loading scene variant of the logo SVG.
1435
- * Uses unique IDs (prefixed with 'ls') to avoid collisions with CSSPreloader.
1436
- */
1437
- function buildLoadingLogoSVG() {
1438
- return loading.buildLogoSVG({
1439
- idPrefix: 'ls',
1440
- svgStyle: 'width:100%;height:auto;',
1441
- clipRectId: 'ge-loader-rect',
1442
- textId: 'ge-loader-pct',
1443
- textContent: '0%',
1444
- });
1445
- }
1446
- /**
1447
- * Built-in loading screen using the Energy8 SVG logo with animated loader bar.
1449
+ * Built-in loading screen.
1448
1450
  *
1449
- * Renders as an HTML overlay on top of the canvas for crisp SVG quality.
1450
- * The loader bar fill width is driven by asset loading progress.
1451
+ * It does NOT render its own overlay the CSS preloader created at boot
1452
+ * (`createPlatformSession`/`GameApplication.start`) stays on screen, and this
1453
+ * scene merely drives it: asset-load progress → `setCSSPreloaderProgress`,
1454
+ * tap-to-start → `waitCSSPreloaderTap`, then fades it out via
1455
+ * `removeCSSPreloader` before entering the game. One continuous overlay from
1456
+ * boot to gameplay — no second logo, no mid-load flash.
1451
1457
  */
1452
1458
  class LoadingScene extends Scene {
1453
1459
  _engine;
1454
1460
  _targetScene;
1455
1461
  _targetData;
1456
1462
  _config;
1457
- // HTML overlay
1458
- _overlay = null;
1459
- _loaderRect = null;
1460
- _percentEl = null;
1461
- _tapToStartEl = null;
1462
1463
  // State
1463
1464
  _displayedProgress = 0;
1464
1465
  _targetProgress = 0;
@@ -1471,8 +1472,6 @@ class LoadingScene extends Scene {
1471
1472
  this._targetData = targetData;
1472
1473
  this._config = engine.config.loading ?? {};
1473
1474
  this._startTime = Date.now();
1474
- // Create the HTML overlay with the SVG logo
1475
- this.createOverlay();
1476
1475
  // Initialize asset manager
1477
1476
  await this._engine.assets.init();
1478
1477
  // Initialize audio manager
@@ -1518,110 +1517,29 @@ class LoadingScene extends Scene {
1518
1517
  // Final snap to 100%
1519
1518
  this._displayedProgress = 1;
1520
1519
  this.updateLoaderBar(1);
1521
- // Show "Tap to Start" or transition directly
1522
- if (this._config.tapToStart !== false) {
1523
- await this.showTapToStart();
1524
- }
1525
- else {
1526
- await this.transitionToGame();
1527
- }
1520
+ // Wait for the player's tap resolves immediately when tapToStart is
1521
+ // false (the preloader honours that flag) — then enter the game.
1522
+ await loading.waitCSSPreloaderTap();
1523
+ await this.transitionToGame();
1528
1524
  }
1529
1525
  onUpdate(dt) {
1530
- // Smooth progress bar fill via HTML (during active loading)
1526
+ // Smooth progress bar fill (during active loading)
1531
1527
  if (!this._loadingComplete && this._displayedProgress < this._targetProgress) {
1532
1528
  this._displayedProgress = Math.min(this._displayedProgress + dt * 1.5, this._targetProgress);
1533
1529
  this.updateLoaderBar(this._displayedProgress);
1534
1530
  }
1535
1531
  }
1536
1532
  onResize(_width, _height) {
1537
- // Overlay is CSS-based, auto-resizes
1533
+ // The preloader overlay is CSS-based and auto-resizes.
1538
1534
  }
1539
1535
  onDestroy() {
1540
- this.removeOverlay();
1541
- }
1542
- // ─── HTML Overlay ──────────────────────────────────────
1543
- createOverlay() {
1544
- const bgColor = typeof this._config.backgroundColor === 'string'
1545
- ? this._config.backgroundColor
1546
- : typeof this._config.backgroundColor === 'number'
1547
- ? `#${this._config.backgroundColor.toString(16).padStart(6, '0')}`
1548
- : '#0a0a1a';
1549
- const bgGradient = this._config.backgroundGradient ??
1550
- `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
1551
- this._overlay = document.createElement('div');
1552
- this._overlay.id = '__ge-loading-overlay__';
1553
- this._overlay.innerHTML = `
1554
- <div class="ge-loading-content">
1555
- ${buildLoadingLogoSVG()}
1556
- </div>
1557
- `;
1558
- const style = document.createElement('style');
1559
- style.id = '__ge-loading-style__';
1560
- style.textContent = `
1561
- #__ge-loading-overlay__ {
1562
- position: absolute;
1563
- top: 0; left: 0;
1564
- width: 100%; height: 100%;
1565
- background: ${bgGradient};
1566
- display: flex;
1567
- align-items: center;
1568
- justify-content: center;
1569
- z-index: 9999;
1570
- transition: opacity 0.5s ease-out;
1571
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1572
- }
1573
- #__ge-loading-overlay__.ge-fade-out {
1574
- opacity: 0;
1575
- pointer-events: none;
1576
- }
1577
- .ge-loading-content {
1578
- display: flex;
1579
- flex-direction: column;
1580
- align-items: center;
1581
- width: 75%;
1582
- max-width: 650px;
1583
- }
1584
- .ge-loading-content svg {
1585
- filter: drop-shadow(0 0 40px rgba(121, 57, 194, 0.5));
1586
- cursor: default;
1587
- }
1588
-
1589
- .ge-svg-pulse {
1590
- animation: ge-tap-pulse 1.2s ease-in-out infinite;
1591
- }
1592
- @keyframes ge-tap-pulse {
1593
- 0%, 100% { opacity: 0.5; }
1594
- 50% { opacity: 1; }
1595
- }
1596
- `;
1597
- // Get the container that holds the canvas
1598
- const container = this._engine.app?.canvas?.parentElement;
1599
- if (container) {
1600
- container.style.position = container.style.position || 'relative';
1601
- container.appendChild(style);
1602
- container.appendChild(this._overlay);
1603
- }
1604
- // Cache the SVG loader rect for progress updates
1605
- this._loaderRect = this._overlay.querySelector('#ge-loader-rect');
1606
- this._percentEl = this._overlay.querySelector('#ge-loader-pct');
1607
- }
1608
- removeOverlay() {
1609
- this._overlay?.remove();
1610
- document.getElementById('__ge-loading-style__')?.remove();
1611
- this._overlay = null;
1612
- this._loaderRect = null;
1613
- this._percentEl = null;
1614
- this._tapToStartEl = null;
1536
+ // Defensive: ensure the preloader is gone even if we never transitioned
1537
+ // (e.g. the scene was popped externally). Idempotent.
1538
+ void loading.removeCSSPreloader(this.hostElement());
1615
1539
  }
1616
1540
  // ─── Progress ──────────────────────────────────────────
1617
1541
  updateLoaderBar(progress) {
1618
- if (this._loaderRect) {
1619
- this._loaderRect.setAttribute('width', String(loading.LOADER_BAR_MAX_WIDTH * progress));
1620
- }
1621
- if (this._percentEl) {
1622
- const pct = Math.round(progress * 100);
1623
- this._percentEl.textContent = `${pct}%`;
1624
- }
1542
+ loading.setCSSPreloaderProgress(Math.max(0, Math.min(1, progress)));
1625
1543
  }
1626
1544
  /**
1627
1545
  * Smoothly animate the displayed progress from its current value to `target`
@@ -1651,49 +1569,20 @@ class LoadingScene extends Scene {
1651
1569
  requestAnimationFrame(tick);
1652
1570
  });
1653
1571
  }
1654
- // ─── Tap to Start ─────────────────────────────────────
1655
- async showTapToStart() {
1656
- const tapText = this._config.tapToStartText ?? 'TAP TO START';
1657
- // Reuse the same SVG text element — replace percentage with tap text
1658
- if (this._percentEl) {
1659
- const el = this._percentEl;
1660
- el.textContent = tapText;
1661
- el.setAttribute('fill', '#ffffff');
1662
- el.classList.add('ge-svg-pulse');
1663
- this._tapToStartEl = el;
1664
- }
1665
- // Make overlay clickable
1666
- if (this._overlay) {
1667
- this._overlay.style.cursor = 'pointer';
1668
- }
1669
- // Wait for tap
1670
- return new Promise((resolve) => {
1671
- const handler = async () => {
1672
- this._overlay?.removeEventListener('click', handler);
1673
- await this.transitionToGame();
1674
- resolve();
1675
- };
1676
- // Listen on the full overlay for easier mobile tap
1677
- this._overlay?.addEventListener('click', handler);
1678
- });
1679
- }
1680
1572
  // ─── Transition ────────────────────────────────────────
1573
+ /** The DOM element hosting the canvas + preloader overlay. */
1574
+ hostElement() {
1575
+ return this._engine?.app?.canvas?.parentElement ?? document.body;
1576
+ }
1681
1577
  async transitionToGame() {
1682
- // Fade out the HTML overlay
1683
- if (this._overlay) {
1684
- this._overlay.classList.add('ge-fade-out');
1685
- await new Promise((resolve) => {
1686
- this._overlay.addEventListener('transitionend', () => resolve(), { once: true });
1687
- // Safety timeout
1688
- setTimeout(resolve, 600);
1689
- });
1690
- }
1691
- // Remove overlay
1692
- this.removeOverlay();
1578
+ // Fade out and remove the shared CSS preloader (resolves after the fade).
1579
+ await loading.removeCSSPreloader(this.hostElement());
1693
1580
  // Navigate to the target scene, always passing the engine reference
1694
1581
  await this._engine.scenes.goto(this._targetScene, {
1695
1582
  engine: this._engine,
1696
- ...(this._targetData && typeof this._targetData === 'object' ? this._targetData : { data: this._targetData }),
1583
+ ...(this._targetData && typeof this._targetData === 'object'
1584
+ ? this._targetData
1585
+ : { data: this._targetData }),
1697
1586
  });
1698
1587
  }
1699
1588
  }
@@ -1833,6 +1722,12 @@ class GameApplication extends EventEmitter {
1833
1722
  input;
1834
1723
  /** Viewport manager */
1835
1724
  viewport;
1725
+ /** Scaled world root (holds scenes). Transformed by the ViewportManager to fit the design
1726
+ * resolution; lives below the UI layer on app.stage. */
1727
+ worldRoot;
1728
+ /** Unscaled, screen-space UI layer. Sits above {@link worldRoot} and is NOT touched by the
1729
+ * viewport transform — children fill the real screen (e.g. the host's shell + overlay). */
1730
+ uiLayer;
1836
1731
  /** SDK instance (null in offline mode) */
1837
1732
  sdk = null;
1838
1733
  /** FPS overlay instance (only when debug: true) */
@@ -1914,17 +1809,20 @@ class GameApplication extends EventEmitter {
1914
1809
  // 6. Initialize sub-systems
1915
1810
  this.initSubSystems();
1916
1811
  this.emit('initialized');
1917
- // 7. Remove CSS preloader, show Canvas loading screen
1918
- loading.removeCSSPreloader(this._container);
1919
- // 8. Load assets with loading screen
1812
+ // 7. Load assets. The CSS preloader stays on screen — LoadingScene drives
1813
+ // its progress/tap and removes it before entering the game, so there's
1814
+ // a single continuous overlay from boot to gameplay (no logo flash).
1920
1815
  await this.loadAssets(firstScene, sceneData);
1921
1816
  this.emit('loaded');
1922
- // 9. Start the game loop
1817
+ // 8. Start the game loop
1923
1818
  this._running = true;
1924
1819
  this.emit('started');
1925
1820
  }
1926
1821
  catch (err) {
1927
1822
  console.error('[GameEngine] Failed to start:', err);
1823
+ // Tear down the preloader so a failure doesn't strand the brand frame.
1824
+ if (this._container)
1825
+ loading.removeCSSPreloader(this._container);
1928
1826
  this.emit('error', err instanceof Error ? err : new Error(String(err)));
1929
1827
  throw err;
1930
1828
  }
@@ -2012,20 +1910,27 @@ class GameApplication extends EventEmitter {
2012
1910
  this.audio = new AudioManager(this.config.audio);
2013
1911
  // Input Manager
2014
1912
  this.input = new InputManager(this.app.canvas);
2015
- // Viewport Manager
1913
+ // Stage layers: a scaled world root (scenes, transformed to design resolution by the
1914
+ // viewport) below an unscaled UI layer (screen space). app.stage itself stays identity.
1915
+ this.worldRoot = new pixi_js.Container();
1916
+ this.worldRoot.label = 'world';
1917
+ this.uiLayer = new pixi_js.Container();
1918
+ this.uiLayer.label = 'ui';
1919
+ this.app.stage.addChild(this.worldRoot, this.uiLayer);
1920
+ // Viewport Manager — scales worldRoot (NOT app.stage), so the UI layer is unscaled.
2016
1921
  this.viewport = new ViewportManager(this.app, this._container, {
2017
1922
  designWidth: this.config.designWidth,
2018
1923
  designHeight: this.config.designHeight,
2019
1924
  scaleMode: this.config.scaleMode,
2020
1925
  orientation: this.config.orientation,
2021
- });
2022
- // Wire SceneManager to the PixiJS stage
2023
- this.scenes.setRoot(this.app.stage);
1926
+ }, this.worldRoot);
1927
+ // Wire SceneManager to the scaled world root
1928
+ this.scenes.setRoot(this.worldRoot);
2024
1929
  this.scenes.setApp(this);
2025
1930
  // Wire viewport resize → scene manager + input manager
2026
1931
  this.viewport.on('resize', ({ width, height, scale }) => {
2027
1932
  this.scenes.resize(width, height);
2028
- this.input.setViewportTransform(scale, this.app.stage.x, this.app.stage.y);
1933
+ this.input.setViewportTransform(scale, this.worldRoot.x, this.worldRoot.y);
2029
1934
  this.emit('resize', { width, height });
2030
1935
  });
2031
1936
  this.viewport.on('orientationChange', (orientation) => {