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