@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/host.cjs.js CHANGED
@@ -735,6 +735,7 @@ class AudioManager {
735
735
  _persist;
736
736
  _storageKey;
737
737
  _categories;
738
+ _masterGain = 1.0;
738
739
  _currentMusic = null;
739
740
  _unlocked = false;
740
741
  _unlockHandler = null;
@@ -794,7 +795,7 @@ class AudioManager {
794
795
  if (this._globalMuted || this._categories[category].muted)
795
796
  return;
796
797
  const { sound } = this._soundModule;
797
- const vol = (options?.volume ?? 1) * this._categories[category].volume;
798
+ const vol = (options?.volume ?? 1) * this._categories[category].volume * this._masterGain;
798
799
  try {
799
800
  sound.play(alias, {
800
801
  volume: vol,
@@ -823,7 +824,7 @@ class AudioManager {
823
824
  if (this._globalMuted || this._categories.music.muted)
824
825
  return;
825
826
  // Fade out the previous track
826
- this.fadeVolume(prevAlias, this._categories.music.volume, 0, fadeDuration, () => {
827
+ this.fadeVolume(prevAlias, this._categories.music.volume * this._masterGain, 0, fadeDuration, () => {
827
828
  try {
828
829
  sound.stop(prevAlias);
829
830
  }
@@ -835,7 +836,7 @@ class AudioManager {
835
836
  volume: 0,
836
837
  loop: true,
837
838
  });
838
- this.fadeVolume(alias, 0, this._categories.music.volume, fadeDuration);
839
+ this.fadeVolume(alias, 0, this._categories.music.volume * this._masterGain, fadeDuration);
839
840
  }
840
841
  catch (e) {
841
842
  console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
@@ -854,7 +855,7 @@ class AudioManager {
854
855
  return;
855
856
  try {
856
857
  sound.play(alias, {
857
- volume: this._categories.music.volume,
858
+ volume: this._categories.music.volume * this._masterGain,
858
859
  loop: true,
859
860
  });
860
861
  }
@@ -888,6 +889,15 @@ class AudioManager {
888
889
  sound.stopAll();
889
890
  this._currentMusic = null;
890
891
  }
892
+ /** Global gain (0..1) folded into every category's effective volume. Driven by the shell's
893
+ * 'master' settingChange. Does not affect the persisted per-category volumes. */
894
+ setMasterVolume(volume) {
895
+ this._masterGain = Math.max(0, Math.min(1, volume));
896
+ this.applyVolumes();
897
+ }
898
+ getMasterVolume() {
899
+ return this._masterGain;
900
+ }
891
901
  /**
892
902
  * Set volume for a category.
893
903
  */
@@ -1033,7 +1043,7 @@ class AudioManager {
1033
1043
  const { sound } = this._soundModule;
1034
1044
  // Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
1035
1045
  // not by volumeAll — mixing both leaves mute un-undoable after reload.
1036
- sound.volumeAll = 1;
1046
+ sound.volumeAll = this._masterGain; // master multiplies the global bus
1037
1047
  }
1038
1048
  setupMobileUnlock() {
1039
1049
  if (this._unlocked)
@@ -1301,6 +1311,7 @@ class ViewportManager extends EventEmitter {
1301
1311
  _app;
1302
1312
  _container;
1303
1313
  _config;
1314
+ _target;
1304
1315
  _resizeObserver = null;
1305
1316
  _currentOrientation = Orientation.LANDSCAPE;
1306
1317
  _currentWidth = 0;
@@ -1308,11 +1319,15 @@ class ViewportManager extends EventEmitter {
1308
1319
  _currentScale = 1;
1309
1320
  _destroyed = false;
1310
1321
  _resizeTimeout = null;
1311
- constructor(app, container, config) {
1322
+ constructor(app, container, config, target) {
1312
1323
  super();
1313
1324
  this._app = app;
1314
1325
  this._container = container;
1315
1326
  this._config = config;
1327
+ // The container this manager scales/offsets. Defaults to app.stage for backward
1328
+ // compatibility; the engine passes a dedicated scaled world root so app.stage stays
1329
+ // identity (screen space) for unscaled UI layers.
1330
+ this._target = target ?? app.stage;
1316
1331
  this.setupObserver();
1317
1332
  }
1318
1333
  /** Current canvas width in game units */
@@ -1401,19 +1416,19 @@ class ViewportManager extends EventEmitter {
1401
1416
  const stageScale = scaleMode === ScaleMode.STRETCH
1402
1417
  ? Math.min(containerWidth / designWidth, containerHeight / designHeight)
1403
1418
  : scale;
1404
- this._app.stage.scale.set(stageScale);
1419
+ this._target.scale.set(stageScale);
1405
1420
  // Center the stage for FIT mode
1406
1421
  if (scaleMode === ScaleMode.FIT) {
1407
- this._app.stage.x = Math.round((containerWidth - designWidth * stageScale) / 2);
1408
- this._app.stage.y = Math.round((containerHeight - designHeight * stageScale) / 2);
1422
+ this._target.x = Math.round((containerWidth - designWidth * stageScale) / 2);
1423
+ this._target.y = Math.round((containerHeight - designHeight * stageScale) / 2);
1409
1424
  }
1410
1425
  else if (scaleMode === ScaleMode.FILL) {
1411
- this._app.stage.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
1412
- this._app.stage.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
1426
+ this._target.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
1427
+ this._target.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
1413
1428
  }
1414
1429
  else {
1415
- this._app.stage.x = 0;
1416
- this._app.stage.y = 0;
1430
+ this._target.x = 0;
1431
+ this._target.y = 0;
1417
1432
  }
1418
1433
  this._currentWidth = gameWidth;
1419
1434
  this._currentHeight = gameHeight;
@@ -1502,34 +1517,20 @@ class Scene {
1502
1517
  }
1503
1518
 
1504
1519
  /**
1505
- * Build the loading scene variant of the logo SVG.
1506
- * Uses unique IDs (prefixed with 'ls') to avoid collisions with CSSPreloader.
1507
- */
1508
- function buildLoadingLogoSVG() {
1509
- return loading.buildLogoSVG({
1510
- idPrefix: 'ls',
1511
- svgStyle: 'width:100%;height:auto;',
1512
- clipRectId: 'ge-loader-rect',
1513
- textId: 'ge-loader-pct',
1514
- textContent: '0%',
1515
- });
1516
- }
1517
- /**
1518
- * Built-in loading screen using the Energy8 SVG logo with animated loader bar.
1520
+ * Built-in loading screen.
1519
1521
  *
1520
- * Renders as an HTML overlay on top of the canvas for crisp SVG quality.
1521
- * The loader bar fill width is driven by asset loading progress.
1522
+ * It does NOT render its own overlay the CSS preloader created at boot
1523
+ * (`createPlatformSession`/`GameApplication.start`) stays on screen, and this
1524
+ * scene merely drives it: asset-load progress → `setCSSPreloaderProgress`,
1525
+ * tap-to-start → `waitCSSPreloaderTap`, then fades it out via
1526
+ * `removeCSSPreloader` before entering the game. One continuous overlay from
1527
+ * boot to gameplay — no second logo, no mid-load flash.
1522
1528
  */
1523
1529
  class LoadingScene extends Scene {
1524
1530
  _engine;
1525
1531
  _targetScene;
1526
1532
  _targetData;
1527
1533
  _config;
1528
- // HTML overlay
1529
- _overlay = null;
1530
- _loaderRect = null;
1531
- _percentEl = null;
1532
- _tapToStartEl = null;
1533
1534
  // State
1534
1535
  _displayedProgress = 0;
1535
1536
  _targetProgress = 0;
@@ -1542,8 +1543,6 @@ class LoadingScene extends Scene {
1542
1543
  this._targetData = targetData;
1543
1544
  this._config = engine.config.loading ?? {};
1544
1545
  this._startTime = Date.now();
1545
- // Create the HTML overlay with the SVG logo
1546
- this.createOverlay();
1547
1546
  // Initialize asset manager
1548
1547
  await this._engine.assets.init();
1549
1548
  // Initialize audio manager
@@ -1589,110 +1588,29 @@ class LoadingScene extends Scene {
1589
1588
  // Final snap to 100%
1590
1589
  this._displayedProgress = 1;
1591
1590
  this.updateLoaderBar(1);
1592
- // Show "Tap to Start" or transition directly
1593
- if (this._config.tapToStart !== false) {
1594
- await this.showTapToStart();
1595
- }
1596
- else {
1597
- await this.transitionToGame();
1598
- }
1591
+ // Wait for the player's tap resolves immediately when tapToStart is
1592
+ // false (the preloader honours that flag) — then enter the game.
1593
+ await loading.waitCSSPreloaderTap();
1594
+ await this.transitionToGame();
1599
1595
  }
1600
1596
  onUpdate(dt) {
1601
- // Smooth progress bar fill via HTML (during active loading)
1597
+ // Smooth progress bar fill (during active loading)
1602
1598
  if (!this._loadingComplete && this._displayedProgress < this._targetProgress) {
1603
1599
  this._displayedProgress = Math.min(this._displayedProgress + dt * 1.5, this._targetProgress);
1604
1600
  this.updateLoaderBar(this._displayedProgress);
1605
1601
  }
1606
1602
  }
1607
1603
  onResize(_width, _height) {
1608
- // Overlay is CSS-based, auto-resizes
1604
+ // The preloader overlay is CSS-based and auto-resizes.
1609
1605
  }
1610
1606
  onDestroy() {
1611
- this.removeOverlay();
1612
- }
1613
- // ─── HTML Overlay ──────────────────────────────────────
1614
- createOverlay() {
1615
- const bgColor = typeof this._config.backgroundColor === 'string'
1616
- ? this._config.backgroundColor
1617
- : typeof this._config.backgroundColor === 'number'
1618
- ? `#${this._config.backgroundColor.toString(16).padStart(6, '0')}`
1619
- : '#0a0a1a';
1620
- const bgGradient = this._config.backgroundGradient ??
1621
- `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
1622
- this._overlay = document.createElement('div');
1623
- this._overlay.id = '__ge-loading-overlay__';
1624
- this._overlay.innerHTML = `
1625
- <div class="ge-loading-content">
1626
- ${buildLoadingLogoSVG()}
1627
- </div>
1628
- `;
1629
- const style = document.createElement('style');
1630
- style.id = '__ge-loading-style__';
1631
- style.textContent = `
1632
- #__ge-loading-overlay__ {
1633
- position: absolute;
1634
- top: 0; left: 0;
1635
- width: 100%; height: 100%;
1636
- background: ${bgGradient};
1637
- display: flex;
1638
- align-items: center;
1639
- justify-content: center;
1640
- z-index: 9999;
1641
- transition: opacity 0.5s ease-out;
1642
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1643
- }
1644
- #__ge-loading-overlay__.ge-fade-out {
1645
- opacity: 0;
1646
- pointer-events: none;
1647
- }
1648
- .ge-loading-content {
1649
- display: flex;
1650
- flex-direction: column;
1651
- align-items: center;
1652
- width: 75%;
1653
- max-width: 650px;
1654
- }
1655
- .ge-loading-content svg {
1656
- filter: drop-shadow(0 0 40px rgba(121, 57, 194, 0.5));
1657
- cursor: default;
1658
- }
1659
-
1660
- .ge-svg-pulse {
1661
- animation: ge-tap-pulse 1.2s ease-in-out infinite;
1662
- }
1663
- @keyframes ge-tap-pulse {
1664
- 0%, 100% { opacity: 0.5; }
1665
- 50% { opacity: 1; }
1666
- }
1667
- `;
1668
- // Get the container that holds the canvas
1669
- const container = this._engine.app?.canvas?.parentElement;
1670
- if (container) {
1671
- container.style.position = container.style.position || 'relative';
1672
- container.appendChild(style);
1673
- container.appendChild(this._overlay);
1674
- }
1675
- // Cache the SVG loader rect for progress updates
1676
- this._loaderRect = this._overlay.querySelector('#ge-loader-rect');
1677
- this._percentEl = this._overlay.querySelector('#ge-loader-pct');
1678
- }
1679
- removeOverlay() {
1680
- this._overlay?.remove();
1681
- document.getElementById('__ge-loading-style__')?.remove();
1682
- this._overlay = null;
1683
- this._loaderRect = null;
1684
- this._percentEl = null;
1685
- this._tapToStartEl = null;
1607
+ // Defensive: ensure the preloader is gone even if we never transitioned
1608
+ // (e.g. the scene was popped externally). Idempotent.
1609
+ void loading.removeCSSPreloader(this.hostElement());
1686
1610
  }
1687
1611
  // ─── Progress ──────────────────────────────────────────
1688
1612
  updateLoaderBar(progress) {
1689
- if (this._loaderRect) {
1690
- this._loaderRect.setAttribute('width', String(loading.LOADER_BAR_MAX_WIDTH * progress));
1691
- }
1692
- if (this._percentEl) {
1693
- const pct = Math.round(progress * 100);
1694
- this._percentEl.textContent = `${pct}%`;
1695
- }
1613
+ loading.setCSSPreloaderProgress(Math.max(0, Math.min(1, progress)));
1696
1614
  }
1697
1615
  /**
1698
1616
  * Smoothly animate the displayed progress from its current value to `target`
@@ -1722,49 +1640,20 @@ class LoadingScene extends Scene {
1722
1640
  requestAnimationFrame(tick);
1723
1641
  });
1724
1642
  }
1725
- // ─── Tap to Start ─────────────────────────────────────
1726
- async showTapToStart() {
1727
- const tapText = this._config.tapToStartText ?? 'TAP TO START';
1728
- // Reuse the same SVG text element — replace percentage with tap text
1729
- if (this._percentEl) {
1730
- const el = this._percentEl;
1731
- el.textContent = tapText;
1732
- el.setAttribute('fill', '#ffffff');
1733
- el.classList.add('ge-svg-pulse');
1734
- this._tapToStartEl = el;
1735
- }
1736
- // Make overlay clickable
1737
- if (this._overlay) {
1738
- this._overlay.style.cursor = 'pointer';
1739
- }
1740
- // Wait for tap
1741
- return new Promise((resolve) => {
1742
- const handler = async () => {
1743
- this._overlay?.removeEventListener('click', handler);
1744
- await this.transitionToGame();
1745
- resolve();
1746
- };
1747
- // Listen on the full overlay for easier mobile tap
1748
- this._overlay?.addEventListener('click', handler);
1749
- });
1750
- }
1751
1643
  // ─── Transition ────────────────────────────────────────
1644
+ /** The DOM element hosting the canvas + preloader overlay. */
1645
+ hostElement() {
1646
+ return this._engine?.app?.canvas?.parentElement ?? document.body;
1647
+ }
1752
1648
  async transitionToGame() {
1753
- // Fade out the HTML overlay
1754
- if (this._overlay) {
1755
- this._overlay.classList.add('ge-fade-out');
1756
- await new Promise((resolve) => {
1757
- this._overlay.addEventListener('transitionend', () => resolve(), { once: true });
1758
- // Safety timeout
1759
- setTimeout(resolve, 600);
1760
- });
1761
- }
1762
- // Remove overlay
1763
- this.removeOverlay();
1649
+ // Fade out and remove the shared CSS preloader (resolves after the fade).
1650
+ await loading.removeCSSPreloader(this.hostElement());
1764
1651
  // Navigate to the target scene, always passing the engine reference
1765
1652
  await this._engine.scenes.goto(this._targetScene, {
1766
1653
  engine: this._engine,
1767
- ...(this._targetData && typeof this._targetData === 'object' ? this._targetData : { data: this._targetData }),
1654
+ ...(this._targetData && typeof this._targetData === 'object'
1655
+ ? this._targetData
1656
+ : { data: this._targetData }),
1768
1657
  });
1769
1658
  }
1770
1659
  }
@@ -1904,6 +1793,12 @@ class GameApplication extends EventEmitter {
1904
1793
  input;
1905
1794
  /** Viewport manager */
1906
1795
  viewport;
1796
+ /** Scaled world root (holds scenes). Transformed by the ViewportManager to fit the design
1797
+ * resolution; lives below the UI layer on app.stage. */
1798
+ worldRoot;
1799
+ /** Unscaled, screen-space UI layer. Sits above {@link worldRoot} and is NOT touched by the
1800
+ * viewport transform — children fill the real screen (e.g. the host's shell + overlay). */
1801
+ uiLayer;
1907
1802
  /** SDK instance (null in offline mode) */
1908
1803
  sdk = null;
1909
1804
  /** FPS overlay instance (only when debug: true) */
@@ -1985,17 +1880,20 @@ class GameApplication extends EventEmitter {
1985
1880
  // 6. Initialize sub-systems
1986
1881
  this.initSubSystems();
1987
1882
  this.emit('initialized');
1988
- // 7. Remove CSS preloader, show Canvas loading screen
1989
- loading.removeCSSPreloader(this._container);
1990
- // 8. Load assets with loading screen
1883
+ // 7. Load assets. The CSS preloader stays on screen — LoadingScene drives
1884
+ // its progress/tap and removes it before entering the game, so there's
1885
+ // a single continuous overlay from boot to gameplay (no logo flash).
1991
1886
  await this.loadAssets(firstScene, sceneData);
1992
1887
  this.emit('loaded');
1993
- // 9. Start the game loop
1888
+ // 8. Start the game loop
1994
1889
  this._running = true;
1995
1890
  this.emit('started');
1996
1891
  }
1997
1892
  catch (err) {
1998
1893
  console.error('[GameEngine] Failed to start:', err);
1894
+ // Tear down the preloader so a failure doesn't strand the brand frame.
1895
+ if (this._container)
1896
+ loading.removeCSSPreloader(this._container);
1999
1897
  this.emit('error', err instanceof Error ? err : new Error(String(err)));
2000
1898
  throw err;
2001
1899
  }
@@ -2083,20 +1981,27 @@ class GameApplication extends EventEmitter {
2083
1981
  this.audio = new AudioManager(this.config.audio);
2084
1982
  // Input Manager
2085
1983
  this.input = new InputManager(this.app.canvas);
2086
- // Viewport Manager
1984
+ // Stage layers: a scaled world root (scenes, transformed to design resolution by the
1985
+ // viewport) below an unscaled UI layer (screen space). app.stage itself stays identity.
1986
+ this.worldRoot = new pixi_js.Container();
1987
+ this.worldRoot.label = 'world';
1988
+ this.uiLayer = new pixi_js.Container();
1989
+ this.uiLayer.label = 'ui';
1990
+ this.app.stage.addChild(this.worldRoot, this.uiLayer);
1991
+ // Viewport Manager — scales worldRoot (NOT app.stage), so the UI layer is unscaled.
2087
1992
  this.viewport = new ViewportManager(this.app, this._container, {
2088
1993
  designWidth: this.config.designWidth,
2089
1994
  designHeight: this.config.designHeight,
2090
1995
  scaleMode: this.config.scaleMode,
2091
1996
  orientation: this.config.orientation,
2092
- });
2093
- // Wire SceneManager to the PixiJS stage
2094
- this.scenes.setRoot(this.app.stage);
1997
+ }, this.worldRoot);
1998
+ // Wire SceneManager to the scaled world root
1999
+ this.scenes.setRoot(this.worldRoot);
2095
2000
  this.scenes.setApp(this);
2096
2001
  // Wire viewport resize → scene manager + input manager
2097
2002
  this.viewport.on('resize', ({ width, height, scale }) => {
2098
2003
  this.scenes.resize(width, height);
2099
- this.input.setViewportTransform(scale, this.app.stage.x, this.app.stage.y);
2004
+ this.input.setViewportTransform(scale, this.worldRoot.x, this.worldRoot.y);
2100
2005
  this.emit('resize', { width, height });
2101
2006
  });
2102
2007
  this.viewport.on('orientationChange', (orientation) => {
@@ -2412,13 +2317,27 @@ async function createSlotGame(opts) {
2412
2317
  let currentBet = opts.model.spec.defaultBet ?? opts.model.spec.betLevels[0];
2413
2318
  // Build slotPlay FIRST — bindGameScene() needs it to be in scope.
2414
2319
  const { createSlotPlay, enrichRoundMeta } = await Promise.resolve().then(function () { return slotPlay; });
2320
+ // Injected once per controller scene the first time it becomes current (see `ensureCreated`).
2321
+ // `sceneApi` is assembled inside the shell block; until then injection is a no-op (a shell-less
2322
+ // launch never builds the api, so a controller scene simply never receives onCreate).
2323
+ let sceneApi = null;
2324
+ const createdScenes = new WeakSet();
2325
+ const ensureCreated = (s) => {
2326
+ if (!sceneApi || createdScenes.has(s))
2327
+ return;
2328
+ createdScenes.add(s);
2329
+ s.onCreate?.(sceneApi);
2330
+ };
2415
2331
  /** The current scene IFF it implements the SlotSceneController contract (duck-typed on
2416
- * `present`). The host drives the play loop against whichever scene is current. */
2332
+ * `onSpin`). The host drives the play loop against whichever scene is current. Injects the
2333
+ * SceneApi via onCreate the first time a controller scene is seen. */
2417
2334
  const gameScene = () => {
2418
2335
  const s = game.scenes.current?.scene;
2419
- return typeof s?.present === 'function'
2420
- ? s
2421
- : undefined;
2336
+ if (typeof s?.onSpin !== 'function')
2337
+ return undefined;
2338
+ const scene = s;
2339
+ ensureCreated(scene);
2340
+ return scene;
2422
2341
  };
2423
2342
  const { runRound } = await Promise.resolve().then(function () { return runRound$1; });
2424
2343
  const { createBalanceGate } = await Promise.resolve().then(function () { return balanceGate; });
@@ -2435,7 +2354,7 @@ async function createSlotGame(opts) {
2435
2354
  ack: (raw) => game.platformSession.playAck(raw),
2436
2355
  });
2437
2356
  if (opts.shell) {
2438
- const { createGameShell } = await import('@energy8platform/platform-core/shell');
2357
+ const { createPixiShell } = await import('@energy8platform/pixi-shell');
2439
2358
  const { buildShellConfig } = await Promise.resolve().then(function () { return shellConfig; });
2440
2359
  const { resolveReplayBonusId } = await Promise.resolve().then(function () { return replay; });
2441
2360
  const ps = game.platformSession;
@@ -2482,7 +2401,14 @@ async function createSlotGame(opts) {
2482
2401
  `| spec=${opts.model.spec.currency ?? '∅'} ` +
2483
2402
  `| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`);
2484
2403
  }
2485
- shell = createGameShell(buildShellConfig(opts.shell, opts.model, runtime));
2404
+ // pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
2405
+ // scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
2406
+ // The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
2407
+ shell = createPixiShell({ ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer });
2408
+ // Scope the bar to the slot scene: show only when a SlotSceneController scene is current
2409
+ // (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
2410
+ shell.setVisible(!!gameScene());
2411
+ game.scenes.on('change', () => shell.setVisible(!!gameScene()));
2486
2412
  // The gate tracks the live wallet (for the affordability guard) but only PAINTS the balance per
2487
2413
  // the HUD-timing rule: the debit is buffered during play→present and shown at afterPresent; the
2488
2414
  // async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
@@ -2491,8 +2417,52 @@ async function createSlotGame(opts) {
2491
2417
  ps?.on('balanceUpdate', (d) => { balanceGate.onBalance(d.balance); });
2492
2418
  // Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
2493
2419
  let currentTurbo = shell.state.turbo;
2494
- shell.on('turboChange', (level) => { currentTurbo = level; });
2420
+ shell.on('turboChange', (level) => { currentTurbo = level; gameScene()?.onTurboChanged?.(level); });
2421
+ // Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
2422
+ const skipEnabled = opts.skipGesture ?? true;
2423
+ // Shell settings → engine state. Sound/volume map onto the AudioManager.
2424
+ shell.on('settingChange', ({ key, value }) => {
2425
+ switch (key) {
2426
+ case 'sound':
2427
+ value ? game.audio.unmuteAll() : game.audio.muteAll();
2428
+ break;
2429
+ case 'master':
2430
+ game.audio.setMasterVolume(Number(value));
2431
+ break;
2432
+ case 'music':
2433
+ game.audio.setVolume('music', Number(value));
2434
+ break;
2435
+ case 'sfx':
2436
+ game.audio.setVolume('sfx', Number(value));
2437
+ break;
2438
+ }
2439
+ });
2440
+ // Overlay layer sits ABOVE the shell (the shell already mounted its root onto the uiLayer;
2441
+ // adding ours afterwards keeps it on top). It eats pointer events while open so shell controls
2442
+ // are unreachable. Mounted on the same unscaled UI layer; tracks viewport via game's 'resize'.
2443
+ const { createSceneAudio } = await Promise.resolve().then(function () { return sceneAudio; });
2444
+ const { createOverlayController } = await Promise.resolve().then(function () { return overlayController; });
2445
+ const overlayLayer = new pixi_js.Container();
2446
+ overlayLayer.label = 'overlay';
2447
+ game.uiLayer.addChild(overlayLayer);
2448
+ const overlayCtl = createOverlayController({
2449
+ parent: overlayLayer,
2450
+ size: () => ({ width: game.app.screen.width, height: game.app.screen.height }),
2451
+ });
2452
+ game.on('resize', ({ width, height }) => overlayCtl.resize(width, height));
2453
+ // Capabilities injected once per controller scene via onCreate (see `gameScene`/`ensureCreated`).
2454
+ sceneApi = {
2455
+ audio: createSceneAudio(game.audio),
2456
+ overlay: overlayCtl.overlay,
2457
+ shell: { get safeArea() { return shell.safeArea; } },
2458
+ formatAmount: (v) => shell.formatWin(v),
2459
+ get bet() { return currentBet; },
2460
+ get mode() { return opts.model.modeMap['spin'] ?? 'BASE'; },
2461
+ get turbo() { return currentTurbo; },
2462
+ };
2495
2463
  const roleOf = (action) => opts.model.spec.actions[action]?.role;
2464
+ // The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
2465
+ // attaches its own. So makeContext returns everything BUT `signal`.
2496
2466
  const makeContext = (action) => ({
2497
2467
  bet: currentBet,
2498
2468
  action,
@@ -2535,19 +2505,62 @@ async function createSlotGame(opts) {
2535
2505
  body: shell.t('Lost connection to the game server. Trying to reconnect…'),
2536
2506
  });
2537
2507
  });
2508
+ // Skip state: `currentSegmentAbort` is the controller for the segment presently animating;
2509
+ // `presenting` is true for the whole play→drain window (gates the double-tap detector so taps
2510
+ // only skip while a round is animating).
2511
+ let currentSegmentAbort = null;
2512
+ let presenting = false;
2513
+ // Double-tap skip: a double-tap on the play area aborts the current segment (the scene collapses
2514
+ // to its final visual via ctx.signal) and notifies the scene's onSkip. Gated by the shell's
2515
+ // skip-gesture setting (`skipEnabled`) and only active while a round is presenting.
2516
+ const { createDoubleTapSkip } = await Promise.resolve().then(function () { return skipGesture; });
2517
+ const skip = createDoubleTapSkip({
2518
+ enabled: () => skipEnabled,
2519
+ active: () => presenting,
2520
+ onSkip: () => { currentSegmentAbort?.abort(); gameScene()?.onSkip?.(); },
2521
+ });
2522
+ // Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
2523
+ // lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
2524
+ game.scenes.root.eventMode = 'static';
2525
+ game.scenes.root.on('pointertap', () => skip.tap(performance.now()));
2526
+ // Full auto-pause: on tab blur, freeze the ticker (stops tweens/onUpdate/in-flight onSpin),
2527
+ // duck music to silence, hold autoplay, and notify the scene. On focus, reverse it all.
2528
+ // `stopAutoplay` is reassigned in the base-mode block below — the closure reads it live.
2529
+ const { createPauseController } = await Promise.resolve().then(function () { return pauseController; });
2530
+ createPauseController({
2531
+ isHidden: () => typeof document !== 'undefined' && document.hidden,
2532
+ subscribe: (cb) => {
2533
+ if (typeof document === 'undefined')
2534
+ return () => { };
2535
+ document.addEventListener('visibilitychange', cb);
2536
+ return () => document.removeEventListener('visibilitychange', cb);
2537
+ },
2538
+ onHidden: () => {
2539
+ game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
2540
+ game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
2541
+ stopAutoplay(); // hold autoplay — don't start the next auto-round
2542
+ gameScene()?.onPause?.();
2543
+ },
2544
+ onVisible: () => {
2545
+ game.app.ticker.start();
2546
+ game.audio.unduckMusic();
2547
+ gameScene()?.onResume?.();
2548
+ },
2549
+ });
2538
2550
  /** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
2539
- * update only AFTER each present(), per the HUD-timing requirement. */
2551
+ * update only AFTER each onSpin(), per the HUD-timing requirement. */
2540
2552
  const playRound = (action) => {
2541
2553
  const scene = gameScene();
2542
2554
  if (!scene)
2543
2555
  return;
2544
2556
  // Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
2545
2557
  // (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
2546
- // the trigger segment (presented before onBonusEnter) doesn't count as a free spin.
2558
+ // the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
2547
2559
  let inBonus = false;
2548
2560
  let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
2549
2561
  const fsCounter = createFreeSpinsCounter();
2550
2562
  shell.setBusy(true); // block re-spin / spacebar while the round plays out
2563
+ presenting = true; // open the skip window for the whole play→drain
2551
2564
  // RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
2552
2565
  // animation has finished — returning void would reopen it instantly, over a running animation.
2553
2566
  return runRound({
@@ -2557,6 +2570,10 @@ async function createSlotGame(opts) {
2557
2570
  scene,
2558
2571
  context: makeContext,
2559
2572
  roleOf,
2573
+ // Hand the host the per-segment AbortController so a double-tap can skip the live segment.
2574
+ beforeSegment: (ac) => { currentSegmentAbort = ac; },
2575
+ onSpinStart: () => scene.onSpinStart?.(),
2576
+ onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
2560
2577
  afterPresent: (r) => {
2561
2578
  // WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
2562
2579
  // free-spins counter (totalWin) below, not the WIN readout.
@@ -2566,18 +2583,18 @@ async function createSlotGame(opts) {
2566
2583
  if (inBonus)
2567
2584
  shell.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
2568
2585
  },
2569
- onBonusEnter: async (trigger, ctx) => {
2586
+ onEnterMode: async (trigger, ctx) => {
2570
2587
  inBonus = true;
2571
2588
  shell.setMode('freeSpins');
2572
2589
  shell.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
2573
- await scene.onBonusEnter?.(trigger, ctx);
2590
+ await scene.onEnterMode?.(trigger, ctx);
2574
2591
  },
2575
- onBonusExit: async (last, ctx) => {
2592
+ onExitMode: async (last, ctx) => {
2576
2593
  inBonus = false;
2577
- await scene.onBonusExit?.(last, ctx);
2594
+ await scene.onExitMode?.(last, ctx);
2578
2595
  shell.setMode('base');
2579
2596
  },
2580
- }, action).catch(showPlayError).finally(() => shell.setBusy(false));
2597
+ }, action).catch(showPlayError).finally(() => { presenting = false; shell.setBusy(false); });
2581
2598
  };
2582
2599
  /**
2583
2600
  * Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
@@ -2590,7 +2607,12 @@ async function createSlotGame(opts) {
2590
2607
  const scene = gameScene();
2591
2608
  if (!scene || !ps)
2592
2609
  return;
2593
- const ctx = makeContext(firstRaw.action ?? 'spin');
2610
+ // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
2611
+ // never-aborted signal to satisfy onSpin's RenderContext.
2612
+ const ctx = {
2613
+ ...makeContext(firstRaw.action ?? 'spin'),
2614
+ signal: new AbortController().signal,
2615
+ };
2594
2616
  const fsView = (raw, totalWin) => {
2595
2617
  const s = raw.session;
2596
2618
  if (!s)
@@ -2613,7 +2635,7 @@ async function createSlotGame(opts) {
2613
2635
  shell.setMode('freeSpins');
2614
2636
  }
2615
2637
  if (animate)
2616
- await scene.present(r, ctx);
2638
+ await scene.onSpin(r, ctx);
2617
2639
  if (inBonus) {
2618
2640
  const v = fsView(raw, r.totalWin);
2619
2641
  if (v)
@@ -2661,7 +2683,7 @@ async function createSlotGame(opts) {
2661
2683
  return;
2662
2684
  void playRound(action);
2663
2685
  });
2664
- shell.on('betChange', (bet) => { currentBet = bet; });
2686
+ shell.on('betChange', (bet) => { currentBet = bet; gameScene()?.onBetChanged?.(bet); });
2665
2687
  shell.on('buyBonusSelect', ({ id }) => {
2666
2688
  if (!ensureAffordable(id))
2667
2689
  return;
@@ -2674,7 +2696,10 @@ async function createSlotGame(opts) {
2674
2696
  resolveAction: () => activeFeature ?? 'spin',
2675
2697
  canAfford: (a) => ensureAffordable(a),
2676
2698
  playRound: (a) => Promise.resolve(playRound(a)),
2677
- onState: (s) => shell.setAutoplay(s),
2699
+ onState: (s) => {
2700
+ shell.setAutoplay(s);
2701
+ gameScene()?.onAutoplayChanged?.({ running: s.active, remaining: s.remaining });
2702
+ },
2678
2703
  });
2679
2704
  stopAutoplay = () => autoplay$1.stop();
2680
2705
  shell.on('autoplayStart', (o) => autoplay$1.start(o?.remaining ?? 0));
@@ -2731,6 +2756,8 @@ async function createSlotGame(opts) {
2731
2756
  }
2732
2757
 
2733
2758
  // packages/game-engine/src/host/shellConfig.ts
2759
+ // `socialize` / `createI18n` are runtime helpers sourced from platform-core/shell;
2760
+ // pixi-shell re-exports only types so we import directly from platform-core.
2734
2761
  /**
2735
2762
  * Apply jurisdiction restrictions over the resolved shell features, in place. A restriction ALWAYS
2736
2763
  * wins over the author's intent (a forbidden control must stay off even if the game enabled it).
@@ -2785,8 +2812,9 @@ function stakeForAction(model, action, bet) {
2785
2812
  const cost = (model.spec.actions?.[action]?.cost ?? 1);
2786
2813
  return cost * bet;
2787
2814
  }
2788
- /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT). */
2789
- function toBonusOptions(model) {
2815
+ /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT).
2816
+ * @param t Optional translator applied to `title` and `description` before they enter the shell. */
2817
+ function toBonusOptions(model, t = (s) => s) {
2790
2818
  const out = [];
2791
2819
  for (const [key, action] of Object.entries(model.spec.actions)) {
2792
2820
  const role = action.role ?? 'base';
@@ -2795,15 +2823,18 @@ function toBonusOptions(model) {
2795
2823
  out.push({
2796
2824
  id: key,
2797
2825
  type: role === 'buy' ? 'bonus' : 'feature',
2798
- title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
2799
- description: action.description ?? '',
2826
+ title: t(action.title ?? key.replace(/_/g, ' ').toUpperCase()),
2827
+ description: t(action.description ?? ''),
2800
2828
  priceMultiplier: action.cost ?? (role === 'buy' ? 100 : 1),
2829
+ // Volatility (1–5 bolts) is part of the spec action SSOT; forward it so the buy card shows it.
2830
+ ...(action.volatility != null ? { volatility: action.volatility } : {}),
2801
2831
  });
2802
2832
  }
2803
2833
  return out;
2804
2834
  }
2805
- /** Build a paytable section from the model's derived paytable view (multipliers per symbol count). */
2806
- function paytableSection(model) {
2835
+ /** Build a paytable section from the model's derived paytable view (multipliers per symbol count).
2836
+ * @param t Optional translator applied to symbol names before they enter the shell. */
2837
+ function paytableSection(model, t = (s) => s) {
2807
2838
  const symbols = model.paytable?.symbols ?? [];
2808
2839
  const rows = [];
2809
2840
  for (const s of symbols) {
@@ -2813,11 +2844,13 @@ function paytableSection(model) {
2813
2844
  .sort((a, b) => Number(a.count) - Number(b.count));
2814
2845
  if (!wins.length)
2815
2846
  continue;
2816
- rows.push({ symbol: { text: s.name ?? s.id }, wins });
2847
+ rows.push({ symbol: { text: t(s.name ?? s.id) }, wins });
2817
2848
  }
2818
2849
  if (!rows.length)
2819
2850
  return null;
2820
- return { type: 'paytable', title: 'PAYTABLE', rows };
2851
+ // No literal title — the shell renders `s.title ?? host.t('Paytable')`, so the heading is
2852
+ // localized (matching how the modes/wins sections rely on the shell's translated fallback).
2853
+ return { type: 'paytable', rows };
2821
2854
  }
2822
2855
  /** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
2823
2856
  function winsSection(model) {
@@ -2859,14 +2892,15 @@ function orderDisclaimerLast(sections) {
2859
2892
  * gets a real info panel for free (paytable, win illustration, controls, and the Stake
2860
2893
  * disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
2861
2894
  * section identity (see `mergeGameInfo`), not wholesale-replaced.
2895
+ * @param t Optional translator applied to spec-derived player-facing strings (symbol names, mode titles, etc.).
2862
2896
  */
2863
- function defaultGameInfo(model, runtime) {
2897
+ function defaultGameInfo(model, runtime, t = (s) => s) {
2864
2898
  const sections = [];
2865
2899
  sections.push(winsSection(model));
2866
- const pay = paytableSection(model);
2900
+ const pay = paytableSection(model, t);
2867
2901
  if (pay)
2868
2902
  sections.push(pay);
2869
- const modes = modesSection(model);
2903
+ const modes = modesSection(model, t);
2870
2904
  if (modes)
2871
2905
  sections.push(modes);
2872
2906
  sections.push({ type: 'controls' });
@@ -2879,8 +2913,9 @@ function defaultGameInfo(model, runtime) {
2879
2913
  * (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
2880
2914
  * compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
2881
2915
  * mode once (in game.spec) and the info table can't drift. `free` actions are excluded (mathModes
2882
- * already drops them — free spins are part of a bonus, not a purchasable mode). */
2883
- function modesSection(model) {
2916
+ * already drops them — free spins are part of a bonus, not a purchasable mode).
2917
+ * @param t Optional translator applied to row `title` and `description` before they enter the shell. */
2918
+ function modesSection(model, t = (s) => s) {
2884
2919
  const modes = model.mathModes ?? [];
2885
2920
  if (!modes.length)
2886
2921
  return null;
@@ -2888,7 +2923,7 @@ function modesSection(model) {
2888
2923
  const action = model.spec.actions[m.action];
2889
2924
  const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
2890
2925
  const row = {
2891
- title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
2926
+ title: t(action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' '))),
2892
2927
  maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
2893
2928
  };
2894
2929
  // Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
@@ -2897,7 +2932,7 @@ function modesSection(model) {
2897
2932
  if (typeof m.rtp === 'number')
2898
2933
  row.rtp = Math.round(m.rtp * 1000) / 10; // 0.965 → 96.5 (%)
2899
2934
  if (action?.description)
2900
- row.description = action.description;
2935
+ row.description = t(action.description);
2901
2936
  return row;
2902
2937
  });
2903
2938
  return { type: 'modes', title: 'MODES', modes: rows };
@@ -2983,7 +3018,8 @@ function socializeBonusOptions(options, isSocial) {
2983
3018
  return options;
2984
3019
  return options.map((o) => ({ ...o, title: shell.socialize(o.title), description: shell.socialize(o.description) }));
2985
3020
  }
2986
- /** Pure: assemble a ShellConfig from the model + runtime context (currency/balance/language/mode). */
3021
+ /** Pure: assemble the shell config (sans mount target) from the model + runtime context
3022
+ * (currency/balance/language/mode). The host adds `app` at the call site. */
2987
3023
  function buildShellConfig(opts, model, runtime) {
2988
3024
  // Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
2989
3025
  const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
@@ -2993,18 +3029,17 @@ function buildShellConfig(opts, model, runtime) {
2993
3029
  // host); opts.currency still wins. Fall back to the spec code, then a neutral euro.
2994
3030
  const currency = opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
2995
3031
  const isSocial = runtime.social ?? false;
2996
- // Merge author sections over the host-derived defaults, THEN socialize the WHOLE merged set in
2997
- // social mode so restricted gambling vocabulary is rewritten in BOTH the built-in copy AND any
2998
- // author-supplied text (title + custom HTML). A game can no longer surface a forbidden word in
2999
- // social mode just because the author wrote it in their own info section. (Custom sections built
3000
- // from a raw DOM `node` can't be rewritten automatically author owns the node and can call the
3001
- // exported `socialize` from '@energy8platform/game-engine/host' on their own strings.)
3002
- // Author gameInfo may be a plain object or a `(t) => content` factory. `t` socializes when in
3003
- // social mode (identity otherwise) so authors can wrap copy explicitly; the full merged set is
3004
- // still socialized below as a safety net.
3005
- const t = isSocial ? shell.socialize : (text) => text;
3032
+ // Build the merged resolver: game i18n map + shell LOCALES (via createI18n), then socialize on
3033
+ // top for English social mode. For non-English languages, createI18n already handles the
3034
+ // translation lookup; social rewriting is English-only and applied separately after.
3035
+ // Author gameInfo may be a plain object or a `(t) => content` factory. `t` is the resolver so
3036
+ // authors can wrap player-facing copy explicitly; the full merged set is still socialized below
3037
+ // (section pass) as a safety net so restricted words can't slip through even if t() was missed.
3038
+ const { t } = shell.createI18n({ language: runtime.language ?? 'en', isSocial, messages: opts.i18n });
3006
3039
  const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
3007
- let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime), authored);
3040
+ // Pass t() through to spec-derived sections so symbol names, mode titles, and descriptions are
3041
+ // pre-translated before they reach the shell renderer.
3042
+ let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime, t), authored);
3008
3043
  // The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
3009
3044
  // wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
3010
3045
  if (isSocial) {
@@ -3014,8 +3049,8 @@ function buildShellConfig(opts, model, runtime) {
3014
3049
  }
3015
3050
  // The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
3016
3051
  gameInfo = { sections: orderDisclaimerLast(gameInfo.sections ?? []) };
3017
- // Buy-bonus cards: socialize the FINAL options (author override or spec-derived) in social mode.
3018
- const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model), isSocial);
3052
+ // Buy-bonus cards: apply t() to spec-derived options (pre-translate), then socialize for en+social.
3053
+ const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model, t), isSocial);
3019
3054
  // Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
3020
3055
  const features = {
3021
3056
  turbo: 0,
@@ -3026,7 +3061,6 @@ function buildShellConfig(opts, model, runtime) {
3026
3061
  };
3027
3062
  applyJurisdiction(features, runtime.jurisdiction);
3028
3063
  return {
3029
- mount: opts.mount ?? (typeof document !== 'undefined' ? document.body : undefined),
3030
3064
  language: runtime.language ?? 'en',
3031
3065
  isSocial,
3032
3066
  currency,
@@ -3135,39 +3169,32 @@ var slotPlay = /*#__PURE__*/Object.freeze({
3135
3169
  enrichRoundMeta: enrichRoundMeta
3136
3170
  });
3137
3171
 
3138
- /**
3139
- * Drive ONE round end-to-end: play the trigger, present it, ack; then drain the remaining segments
3140
- * (a bonus's free spins) by replaying nextActions[0] with the SAME roundId until the round reports
3141
- * `complete`. Fires `onBonusEnter` EXACTLY before the first free-role segment and `onBonusExit`
3142
- * after the last. A plain spin with no bonus is already `complete`, so the while-loop is a no-op.
3143
- *
3144
- * `ctx.bet` is captured once (bet can't change mid-round); `ctx.turbo` is a live getter so a
3145
- * mid-round toggle is honoured on the next segment.
3146
- */
3147
3172
  async function runRound(deps, action) {
3148
- const ctx = deps.context(action);
3149
- let r = await deps.play(action, ctx.bet);
3150
- await deps.scene.present(r, ctx);
3151
- deps.ack();
3152
- deps.afterPresent?.(r); // HUD readouts update AFTER the animation, not before
3153
- let inBonus = false;
3154
- while (!r.complete && r.nextActions && r.nextActions.length > 0) {
3155
- const next = r.nextActions[0];
3156
- if (!inBonus && deps.roleOf(next) === 'free') {
3157
- inBonus = true;
3158
- await deps.onBonusEnter?.(r, ctx);
3159
- }
3160
- // Snapshot the TRIGGER context per segment: { ... } freezes the live `turbo` getter into a data
3161
- // property (so a mid-round toggle is reflected on the NEXT segment), while action/mode/bet stay
3162
- // the round's (the trigger's) identity — a scene must see the same bonus identity all round.
3163
- const segCtx = { ...deps.context(action) };
3164
- r = await deps.play(next, ctx.bet, r.roundId);
3165
- await deps.scene.present(r, segCtx);
3173
+ deps.onSpinStart?.();
3174
+ const ctxBet = deps.context(action).bet;
3175
+ const segment = async (a, roundId) => {
3176
+ const ac = new AbortController();
3177
+ deps.beforeSegment?.(ac);
3178
+ const r = await deps.play(a, ctxBet, roundId);
3179
+ const ctx = { ...deps.context(action), signal: ac.signal };
3180
+ await deps.scene.onSpin(r, ctx);
3166
3181
  deps.ack();
3167
3182
  deps.afterPresent?.(r);
3183
+ return { r, ctx };
3184
+ };
3185
+ let { r, ctx } = await segment(action, undefined);
3186
+ let inMode = false;
3187
+ while (!r.complete && r.nextActions && r.nextActions.length > 0) {
3188
+ const next = r.nextActions[0];
3189
+ if (!inMode && deps.roleOf(next) === 'free') {
3190
+ inMode = true;
3191
+ await deps.onEnterMode?.(r, ctx);
3192
+ }
3193
+ ({ r, ctx } = await segment(next, r.roundId));
3168
3194
  }
3169
- if (inBonus)
3170
- await deps.onBonusExit?.(r, ctx);
3195
+ if (inMode)
3196
+ await deps.onExitMode?.(r, ctx);
3197
+ deps.onSpinEnd?.(r, ctx);
3171
3198
  }
3172
3199
 
3173
3200
  var runRound$1 = /*#__PURE__*/Object.freeze({
@@ -3274,6 +3301,129 @@ var playError = /*#__PURE__*/Object.freeze({
3274
3301
  resolvePlayError: resolvePlayError
3275
3302
  });
3276
3303
 
3304
+ /** Wrap the engine's AudioManager into the playback-only handle a scene receives. Volume/mute are
3305
+ * deliberately omitted — those are driven by the shell's settingChange → host. */
3306
+ function createSceneAudio(audio) {
3307
+ return {
3308
+ play: (alias, opts) => audio.play(alias, 'sfx', opts),
3309
+ playMusic: (alias, fadeMs) => audio.playMusic(alias, fadeMs),
3310
+ stopMusic: () => audio.stopMusic(),
3311
+ duck: (factor) => audio.duckMusic(factor),
3312
+ unduck: () => audio.unduckMusic(),
3313
+ };
3314
+ }
3315
+
3316
+ var sceneAudio = /*#__PURE__*/Object.freeze({
3317
+ __proto__: null,
3318
+ createSceneAudio: createSceneAudio
3319
+ });
3320
+
3321
+ function createOverlayController(deps) {
3322
+ let current = null;
3323
+ const teardown = () => {
3324
+ if (!current)
3325
+ return;
3326
+ if (current.timer)
3327
+ clearTimeout(current.timer);
3328
+ const { layer, resolve } = current;
3329
+ current = null;
3330
+ layer.removeFromParent();
3331
+ layer.destroy({ children: true });
3332
+ resolve();
3333
+ };
3334
+ const overlay = {
3335
+ show(opts) {
3336
+ if (current) {
3337
+ console.warn('[overlay] show() ignored — an overlay is already open');
3338
+ return Promise.reject(new Error('Overlay already open'));
3339
+ }
3340
+ const { width, height } = deps.size();
3341
+ const layer = new pixi_js.Container();
3342
+ layer.eventMode = 'static';
3343
+ // Pointer-eating + (optional) dim backdrop sized to the canvas.
3344
+ const hit = new pixi_js.Graphics().rect(0, 0, width, height).fill({
3345
+ color: 0x000000,
3346
+ alpha: opts.dim ?? 0.0001, // ~0 keeps it transparent but hit-testable
3347
+ });
3348
+ hit.eventMode = 'static';
3349
+ layer.addChild(hit);
3350
+ const content = new pixi_js.Container();
3351
+ layer.addChild(content);
3352
+ opts.build(content, { width, height });
3353
+ deps.parent.addChild(layer);
3354
+ return new Promise((resolve) => {
3355
+ const dimValue = opts.dim ?? 0.0001;
3356
+ current = { layer, resolve, timer: null, dim: dimValue };
3357
+ const closeOn = opts.closeOn ?? 'tap';
3358
+ if (closeOn === 'tap')
3359
+ hit.on('pointertap', teardown);
3360
+ if (typeof opts.autoCloseMs === 'number') {
3361
+ current.timer = setTimeout(teardown, opts.autoCloseMs);
3362
+ }
3363
+ });
3364
+ },
3365
+ close() { teardown(); },
3366
+ };
3367
+ return {
3368
+ overlay,
3369
+ resize(w, h) {
3370
+ if (!current)
3371
+ return;
3372
+ const hit = current.layer.getChildAt(0);
3373
+ hit.clear().rect(0, 0, w, h).fill({ color: 0x000000, alpha: current.dim });
3374
+ },
3375
+ destroy() { teardown(); },
3376
+ };
3377
+ }
3378
+
3379
+ var overlayController = /*#__PURE__*/Object.freeze({
3380
+ __proto__: null,
3381
+ createOverlayController: createOverlayController
3382
+ });
3383
+
3384
+ /** Pure double-tap recognizer. The host feeds it pointer `tap(now)` (e.g. performance.now()) and
3385
+ * supplies the enabled/active gates + the onSkip effect. */
3386
+ function createDoubleTapSkip(deps) {
3387
+ const threshold = deps.thresholdMs ?? 300;
3388
+ let last = -Infinity;
3389
+ return {
3390
+ tap(now) {
3391
+ const isDouble = now - last <= threshold;
3392
+ last = isDouble ? -Infinity : now; // consume the pair so a 3rd tap starts fresh
3393
+ if (isDouble && deps.enabled() && deps.active())
3394
+ deps.onSkip();
3395
+ },
3396
+ destroy() { last = -Infinity; },
3397
+ };
3398
+ }
3399
+
3400
+ var skipGesture = /*#__PURE__*/Object.freeze({
3401
+ __proto__: null,
3402
+ createDoubleTapSkip: createDoubleTapSkip
3403
+ });
3404
+
3405
+ /** Edge-triggers onHidden/onVisible from a visibility source. Effects (ticker/music/autoplay/scene)
3406
+ * are supplied by the host so this stays pure + testable. */
3407
+ function createPauseController(deps) {
3408
+ let paused = deps.isHidden();
3409
+ const unsub = deps.subscribe(() => {
3410
+ const hidden = deps.isHidden();
3411
+ if (hidden === paused)
3412
+ return;
3413
+ paused = hidden;
3414
+ if (hidden)
3415
+ deps.onHidden();
3416
+ else
3417
+ deps.onVisible();
3418
+ });
3419
+ return { destroy: () => unsub() };
3420
+ }
3421
+
3422
+ var pauseController = /*#__PURE__*/Object.freeze({
3423
+ __proto__: null,
3424
+ createPauseController: createPauseController
3425
+ });
3426
+
3277
3427
  function createAutoplayLoop(deps) {
3278
3428
  let active = false;
3279
3429
  let remaining = 0;