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