@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.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Ticker, Assets, Container, Text, Application, Graphics, TextureSource } from 'pixi.js';
2
2
  import { createPlatformSession } from '@energy8platform/platform-core';
3
- import { buildLogoSVG, LOADER_BAR_MAX_WIDTH, createCSSPreloader, removeCSSPreloader } from '@energy8platform/platform-core/loading';
4
- import { socialize } from '@energy8platform/platform-core/shell';
3
+ import { waitCSSPreloaderTap, removeCSSPreloader, setCSSPreloaderProgress, createCSSPreloader } from '@energy8platform/platform-core/loading';
4
+ import { socialize, createI18n } from '@energy8platform/platform-core/shell';
5
5
  export { socialize } from '@energy8platform/platform-core/shell';
6
6
 
7
7
  // ─── Scale Modes ───────────────────────────────────────────
@@ -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) => {
@@ -2411,13 +2316,27 @@ async function createSlotGame(opts) {
2411
2316
  let currentBet = opts.model.spec.defaultBet ?? opts.model.spec.betLevels[0];
2412
2317
  // Build slotPlay FIRST — bindGameScene() needs it to be in scope.
2413
2318
  const { createSlotPlay, enrichRoundMeta } = await Promise.resolve().then(function () { return slotPlay; });
2319
+ // Injected once per controller scene the first time it becomes current (see `ensureCreated`).
2320
+ // `sceneApi` is assembled inside the shell block; until then injection is a no-op (a shell-less
2321
+ // launch never builds the api, so a controller scene simply never receives onCreate).
2322
+ let sceneApi = null;
2323
+ const createdScenes = new WeakSet();
2324
+ const ensureCreated = (s) => {
2325
+ if (!sceneApi || createdScenes.has(s))
2326
+ return;
2327
+ createdScenes.add(s);
2328
+ s.onCreate?.(sceneApi);
2329
+ };
2414
2330
  /** The current scene IFF it implements the SlotSceneController contract (duck-typed on
2415
- * `present`). The host drives the play loop against whichever scene is current. */
2331
+ * `onSpin`). The host drives the play loop against whichever scene is current. Injects the
2332
+ * SceneApi via onCreate the first time a controller scene is seen. */
2416
2333
  const gameScene = () => {
2417
2334
  const s = game.scenes.current?.scene;
2418
- return typeof s?.present === 'function'
2419
- ? s
2420
- : undefined;
2335
+ if (typeof s?.onSpin !== 'function')
2336
+ return undefined;
2337
+ const scene = s;
2338
+ ensureCreated(scene);
2339
+ return scene;
2421
2340
  };
2422
2341
  const { runRound } = await Promise.resolve().then(function () { return runRound$1; });
2423
2342
  const { createBalanceGate } = await Promise.resolve().then(function () { return balanceGate; });
@@ -2434,7 +2353,7 @@ async function createSlotGame(opts) {
2434
2353
  ack: (raw) => game.platformSession.playAck(raw),
2435
2354
  });
2436
2355
  if (opts.shell) {
2437
- const { createGameShell } = await import('@energy8platform/platform-core/shell');
2356
+ const { createPixiShell } = await import('@energy8platform/pixi-shell');
2438
2357
  const { buildShellConfig } = await Promise.resolve().then(function () { return shellConfig; });
2439
2358
  const { resolveReplayBonusId } = await Promise.resolve().then(function () { return replay; });
2440
2359
  const ps = game.platformSession;
@@ -2481,7 +2400,14 @@ async function createSlotGame(opts) {
2481
2400
  `| spec=${opts.model.spec.currency ?? '∅'} ` +
2482
2401
  `| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`);
2483
2402
  }
2484
- shell = createGameShell(buildShellConfig(opts.shell, opts.model, runtime));
2403
+ // pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
2404
+ // scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
2405
+ // The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
2406
+ shell = createPixiShell({ ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer });
2407
+ // Scope the bar to the slot scene: show only when a SlotSceneController scene is current
2408
+ // (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
2409
+ shell.setVisible(!!gameScene());
2410
+ game.scenes.on('change', () => shell.setVisible(!!gameScene()));
2485
2411
  // The gate tracks the live wallet (for the affordability guard) but only PAINTS the balance per
2486
2412
  // the HUD-timing rule: the debit is buffered during play→present and shown at afterPresent; the
2487
2413
  // async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
@@ -2490,8 +2416,52 @@ async function createSlotGame(opts) {
2490
2416
  ps?.on('balanceUpdate', (d) => { balanceGate.onBalance(d.balance); });
2491
2417
  // Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
2492
2418
  let currentTurbo = shell.state.turbo;
2493
- shell.on('turboChange', (level) => { currentTurbo = level; });
2419
+ shell.on('turboChange', (level) => { currentTurbo = level; gameScene()?.onTurboChanged?.(level); });
2420
+ // Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
2421
+ const skipEnabled = opts.skipGesture ?? true;
2422
+ // Shell settings → engine state. Sound/volume map onto the AudioManager.
2423
+ shell.on('settingChange', ({ key, value }) => {
2424
+ switch (key) {
2425
+ case 'sound':
2426
+ value ? game.audio.unmuteAll() : game.audio.muteAll();
2427
+ break;
2428
+ case 'master':
2429
+ game.audio.setMasterVolume(Number(value));
2430
+ break;
2431
+ case 'music':
2432
+ game.audio.setVolume('music', Number(value));
2433
+ break;
2434
+ case 'sfx':
2435
+ game.audio.setVolume('sfx', Number(value));
2436
+ break;
2437
+ }
2438
+ });
2439
+ // Overlay layer sits ABOVE the shell (the shell already mounted its root onto the uiLayer;
2440
+ // adding ours afterwards keeps it on top). It eats pointer events while open so shell controls
2441
+ // are unreachable. Mounted on the same unscaled UI layer; tracks viewport via game's 'resize'.
2442
+ const { createSceneAudio } = await Promise.resolve().then(function () { return sceneAudio; });
2443
+ const { createOverlayController } = await Promise.resolve().then(function () { return overlayController; });
2444
+ const overlayLayer = new Container();
2445
+ overlayLayer.label = 'overlay';
2446
+ game.uiLayer.addChild(overlayLayer);
2447
+ const overlayCtl = createOverlayController({
2448
+ parent: overlayLayer,
2449
+ size: () => ({ width: game.app.screen.width, height: game.app.screen.height }),
2450
+ });
2451
+ game.on('resize', ({ width, height }) => overlayCtl.resize(width, height));
2452
+ // Capabilities injected once per controller scene via onCreate (see `gameScene`/`ensureCreated`).
2453
+ sceneApi = {
2454
+ audio: createSceneAudio(game.audio),
2455
+ overlay: overlayCtl.overlay,
2456
+ shell: { get safeArea() { return shell.safeArea; } },
2457
+ formatAmount: (v) => shell.formatWin(v),
2458
+ get bet() { return currentBet; },
2459
+ get mode() { return opts.model.modeMap['spin'] ?? 'BASE'; },
2460
+ get turbo() { return currentTurbo; },
2461
+ };
2494
2462
  const roleOf = (action) => opts.model.spec.actions[action]?.role;
2463
+ // The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
2464
+ // attaches its own. So makeContext returns everything BUT `signal`.
2495
2465
  const makeContext = (action) => ({
2496
2466
  bet: currentBet,
2497
2467
  action,
@@ -2534,19 +2504,62 @@ async function createSlotGame(opts) {
2534
2504
  body: shell.t('Lost connection to the game server. Trying to reconnect…'),
2535
2505
  });
2536
2506
  });
2507
+ // Skip state: `currentSegmentAbort` is the controller for the segment presently animating;
2508
+ // `presenting` is true for the whole play→drain window (gates the double-tap detector so taps
2509
+ // only skip while a round is animating).
2510
+ let currentSegmentAbort = null;
2511
+ let presenting = false;
2512
+ // Double-tap skip: a double-tap on the play area aborts the current segment (the scene collapses
2513
+ // to its final visual via ctx.signal) and notifies the scene's onSkip. Gated by the shell's
2514
+ // skip-gesture setting (`skipEnabled`) and only active while a round is presenting.
2515
+ const { createDoubleTapSkip } = await Promise.resolve().then(function () { return skipGesture; });
2516
+ const skip = createDoubleTapSkip({
2517
+ enabled: () => skipEnabled,
2518
+ active: () => presenting,
2519
+ onSkip: () => { currentSegmentAbort?.abort(); gameScene()?.onSkip?.(); },
2520
+ });
2521
+ // Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
2522
+ // lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
2523
+ game.scenes.root.eventMode = 'static';
2524
+ game.scenes.root.on('pointertap', () => skip.tap(performance.now()));
2525
+ // Full auto-pause: on tab blur, freeze the ticker (stops tweens/onUpdate/in-flight onSpin),
2526
+ // duck music to silence, hold autoplay, and notify the scene. On focus, reverse it all.
2527
+ // `stopAutoplay` is reassigned in the base-mode block below — the closure reads it live.
2528
+ const { createPauseController } = await Promise.resolve().then(function () { return pauseController; });
2529
+ createPauseController({
2530
+ isHidden: () => typeof document !== 'undefined' && document.hidden,
2531
+ subscribe: (cb) => {
2532
+ if (typeof document === 'undefined')
2533
+ return () => { };
2534
+ document.addEventListener('visibilitychange', cb);
2535
+ return () => document.removeEventListener('visibilitychange', cb);
2536
+ },
2537
+ onHidden: () => {
2538
+ game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
2539
+ game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
2540
+ stopAutoplay(); // hold autoplay — don't start the next auto-round
2541
+ gameScene()?.onPause?.();
2542
+ },
2543
+ onVisible: () => {
2544
+ game.app.ticker.start();
2545
+ game.audio.unduckMusic();
2546
+ gameScene()?.onResume?.();
2547
+ },
2548
+ });
2537
2549
  /** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
2538
- * update only AFTER each present(), per the HUD-timing requirement. */
2550
+ * update only AFTER each onSpin(), per the HUD-timing requirement. */
2539
2551
  const playRound = (action) => {
2540
2552
  const scene = gameScene();
2541
2553
  if (!scene)
2542
2554
  return;
2543
2555
  // Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
2544
2556
  // (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
2545
- // the trigger segment (presented before onBonusEnter) doesn't count as a free spin.
2557
+ // the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
2546
2558
  let inBonus = false;
2547
2559
  let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
2548
2560
  const fsCounter = createFreeSpinsCounter();
2549
2561
  shell.setBusy(true); // block re-spin / spacebar while the round plays out
2562
+ presenting = true; // open the skip window for the whole play→drain
2550
2563
  // RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
2551
2564
  // animation has finished — returning void would reopen it instantly, over a running animation.
2552
2565
  return runRound({
@@ -2556,6 +2569,10 @@ async function createSlotGame(opts) {
2556
2569
  scene,
2557
2570
  context: makeContext,
2558
2571
  roleOf,
2572
+ // Hand the host the per-segment AbortController so a double-tap can skip the live segment.
2573
+ beforeSegment: (ac) => { currentSegmentAbort = ac; },
2574
+ onSpinStart: () => scene.onSpinStart?.(),
2575
+ onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
2559
2576
  afterPresent: (r) => {
2560
2577
  // WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
2561
2578
  // free-spins counter (totalWin) below, not the WIN readout.
@@ -2565,18 +2582,18 @@ async function createSlotGame(opts) {
2565
2582
  if (inBonus)
2566
2583
  shell.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
2567
2584
  },
2568
- onBonusEnter: async (trigger, ctx) => {
2585
+ onEnterMode: async (trigger, ctx) => {
2569
2586
  inBonus = true;
2570
2587
  shell.setMode('freeSpins');
2571
2588
  shell.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
2572
- await scene.onBonusEnter?.(trigger, ctx);
2589
+ await scene.onEnterMode?.(trigger, ctx);
2573
2590
  },
2574
- onBonusExit: async (last, ctx) => {
2591
+ onExitMode: async (last, ctx) => {
2575
2592
  inBonus = false;
2576
- await scene.onBonusExit?.(last, ctx);
2593
+ await scene.onExitMode?.(last, ctx);
2577
2594
  shell.setMode('base');
2578
2595
  },
2579
- }, action).catch(showPlayError).finally(() => shell.setBusy(false));
2596
+ }, action).catch(showPlayError).finally(() => { presenting = false; shell.setBusy(false); });
2580
2597
  };
2581
2598
  /**
2582
2599
  * Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
@@ -2589,7 +2606,12 @@ async function createSlotGame(opts) {
2589
2606
  const scene = gameScene();
2590
2607
  if (!scene || !ps)
2591
2608
  return;
2592
- const ctx = makeContext(firstRaw.action ?? 'spin');
2609
+ // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
2610
+ // never-aborted signal to satisfy onSpin's RenderContext.
2611
+ const ctx = {
2612
+ ...makeContext(firstRaw.action ?? 'spin'),
2613
+ signal: new AbortController().signal,
2614
+ };
2593
2615
  const fsView = (raw, totalWin) => {
2594
2616
  const s = raw.session;
2595
2617
  if (!s)
@@ -2612,7 +2634,7 @@ async function createSlotGame(opts) {
2612
2634
  shell.setMode('freeSpins');
2613
2635
  }
2614
2636
  if (animate)
2615
- await scene.present(r, ctx);
2637
+ await scene.onSpin(r, ctx);
2616
2638
  if (inBonus) {
2617
2639
  const v = fsView(raw, r.totalWin);
2618
2640
  if (v)
@@ -2660,7 +2682,7 @@ async function createSlotGame(opts) {
2660
2682
  return;
2661
2683
  void playRound(action);
2662
2684
  });
2663
- shell.on('betChange', (bet) => { currentBet = bet; });
2685
+ shell.on('betChange', (bet) => { currentBet = bet; gameScene()?.onBetChanged?.(bet); });
2664
2686
  shell.on('buyBonusSelect', ({ id }) => {
2665
2687
  if (!ensureAffordable(id))
2666
2688
  return;
@@ -2673,7 +2695,10 @@ async function createSlotGame(opts) {
2673
2695
  resolveAction: () => activeFeature ?? 'spin',
2674
2696
  canAfford: (a) => ensureAffordable(a),
2675
2697
  playRound: (a) => Promise.resolve(playRound(a)),
2676
- onState: (s) => shell.setAutoplay(s),
2698
+ onState: (s) => {
2699
+ shell.setAutoplay(s);
2700
+ gameScene()?.onAutoplayChanged?.({ running: s.active, remaining: s.remaining });
2701
+ },
2677
2702
  });
2678
2703
  stopAutoplay = () => autoplay$1.stop();
2679
2704
  shell.on('autoplayStart', (o) => autoplay$1.start(o?.remaining ?? 0));
@@ -2730,6 +2755,8 @@ async function createSlotGame(opts) {
2730
2755
  }
2731
2756
 
2732
2757
  // packages/game-engine/src/host/shellConfig.ts
2758
+ // `socialize` / `createI18n` are runtime helpers sourced from platform-core/shell;
2759
+ // pixi-shell re-exports only types so we import directly from platform-core.
2733
2760
  /**
2734
2761
  * Apply jurisdiction restrictions over the resolved shell features, in place. A restriction ALWAYS
2735
2762
  * wins over the author's intent (a forbidden control must stay off even if the game enabled it).
@@ -2784,8 +2811,9 @@ function stakeForAction(model, action, bet) {
2784
2811
  const cost = (model.spec.actions?.[action]?.cost ?? 1);
2785
2812
  return cost * bet;
2786
2813
  }
2787
- /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT). */
2788
- function toBonusOptions(model) {
2814
+ /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT).
2815
+ * @param t Optional translator applied to `title` and `description` before they enter the shell. */
2816
+ function toBonusOptions(model, t = (s) => s) {
2789
2817
  const out = [];
2790
2818
  for (const [key, action] of Object.entries(model.spec.actions)) {
2791
2819
  const role = action.role ?? 'base';
@@ -2794,15 +2822,18 @@ function toBonusOptions(model) {
2794
2822
  out.push({
2795
2823
  id: key,
2796
2824
  type: role === 'buy' ? 'bonus' : 'feature',
2797
- title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
2798
- description: action.description ?? '',
2825
+ title: t(action.title ?? key.replace(/_/g, ' ').toUpperCase()),
2826
+ description: t(action.description ?? ''),
2799
2827
  priceMultiplier: action.cost ?? (role === 'buy' ? 100 : 1),
2828
+ // Volatility (1–5 bolts) is part of the spec action SSOT; forward it so the buy card shows it.
2829
+ ...(action.volatility != null ? { volatility: action.volatility } : {}),
2800
2830
  });
2801
2831
  }
2802
2832
  return out;
2803
2833
  }
2804
- /** Build a paytable section from the model's derived paytable view (multipliers per symbol count). */
2805
- function paytableSection(model) {
2834
+ /** Build a paytable section from the model's derived paytable view (multipliers per symbol count).
2835
+ * @param t Optional translator applied to symbol names before they enter the shell. */
2836
+ function paytableSection(model, t = (s) => s) {
2806
2837
  const symbols = model.paytable?.symbols ?? [];
2807
2838
  const rows = [];
2808
2839
  for (const s of symbols) {
@@ -2812,11 +2843,13 @@ function paytableSection(model) {
2812
2843
  .sort((a, b) => Number(a.count) - Number(b.count));
2813
2844
  if (!wins.length)
2814
2845
  continue;
2815
- rows.push({ symbol: { text: s.name ?? s.id }, wins });
2846
+ rows.push({ symbol: { text: t(s.name ?? s.id) }, wins });
2816
2847
  }
2817
2848
  if (!rows.length)
2818
2849
  return null;
2819
- return { type: 'paytable', title: 'PAYTABLE', rows };
2850
+ // No literal title — the shell renders `s.title ?? host.t('Paytable')`, so the heading is
2851
+ // localized (matching how the modes/wins sections rely on the shell's translated fallback).
2852
+ return { type: 'paytable', rows };
2820
2853
  }
2821
2854
  /** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
2822
2855
  function winsSection(model) {
@@ -2858,14 +2891,15 @@ function orderDisclaimerLast(sections) {
2858
2891
  * gets a real info panel for free (paytable, win illustration, controls, and the Stake
2859
2892
  * disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
2860
2893
  * section identity (see `mergeGameInfo`), not wholesale-replaced.
2894
+ * @param t Optional translator applied to spec-derived player-facing strings (symbol names, mode titles, etc.).
2861
2895
  */
2862
- function defaultGameInfo(model, runtime) {
2896
+ function defaultGameInfo(model, runtime, t = (s) => s) {
2863
2897
  const sections = [];
2864
2898
  sections.push(winsSection(model));
2865
- const pay = paytableSection(model);
2899
+ const pay = paytableSection(model, t);
2866
2900
  if (pay)
2867
2901
  sections.push(pay);
2868
- const modes = modesSection(model);
2902
+ const modes = modesSection(model, t);
2869
2903
  if (modes)
2870
2904
  sections.push(modes);
2871
2905
  sections.push({ type: 'controls' });
@@ -2878,8 +2912,9 @@ function defaultGameInfo(model, runtime) {
2878
2912
  * (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
2879
2913
  * compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
2880
2914
  * mode once (in game.spec) and the info table can't drift. `free` actions are excluded (mathModes
2881
- * already drops them — free spins are part of a bonus, not a purchasable mode). */
2882
- function modesSection(model) {
2915
+ * already drops them — free spins are part of a bonus, not a purchasable mode).
2916
+ * @param t Optional translator applied to row `title` and `description` before they enter the shell. */
2917
+ function modesSection(model, t = (s) => s) {
2883
2918
  const modes = model.mathModes ?? [];
2884
2919
  if (!modes.length)
2885
2920
  return null;
@@ -2887,7 +2922,7 @@ function modesSection(model) {
2887
2922
  const action = model.spec.actions[m.action];
2888
2923
  const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
2889
2924
  const row = {
2890
- title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
2925
+ title: t(action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' '))),
2891
2926
  maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
2892
2927
  };
2893
2928
  // Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
@@ -2896,7 +2931,7 @@ function modesSection(model) {
2896
2931
  if (typeof m.rtp === 'number')
2897
2932
  row.rtp = Math.round(m.rtp * 1000) / 10; // 0.965 → 96.5 (%)
2898
2933
  if (action?.description)
2899
- row.description = action.description;
2934
+ row.description = t(action.description);
2900
2935
  return row;
2901
2936
  });
2902
2937
  return { type: 'modes', title: 'MODES', modes: rows };
@@ -2982,7 +3017,8 @@ function socializeBonusOptions(options, isSocial) {
2982
3017
  return options;
2983
3018
  return options.map((o) => ({ ...o, title: socialize(o.title), description: socialize(o.description) }));
2984
3019
  }
2985
- /** Pure: assemble a ShellConfig from the model + runtime context (currency/balance/language/mode). */
3020
+ /** Pure: assemble the shell config (sans mount target) from the model + runtime context
3021
+ * (currency/balance/language/mode). The host adds `app` at the call site. */
2986
3022
  function buildShellConfig(opts, model, runtime) {
2987
3023
  // Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
2988
3024
  const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
@@ -2992,18 +3028,17 @@ function buildShellConfig(opts, model, runtime) {
2992
3028
  // host); opts.currency still wins. Fall back to the spec code, then a neutral euro.
2993
3029
  const currency = opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
2994
3030
  const isSocial = runtime.social ?? false;
2995
- // Merge author sections over the host-derived defaults, THEN socialize the WHOLE merged set in
2996
- // social mode so restricted gambling vocabulary is rewritten in BOTH the built-in copy AND any
2997
- // author-supplied text (title + custom HTML). A game can no longer surface a forbidden word in
2998
- // social mode just because the author wrote it in their own info section. (Custom sections built
2999
- // from a raw DOM `node` can't be rewritten automatically author owns the node and can call the
3000
- // exported `socialize` from '@energy8platform/game-engine/host' on their own strings.)
3001
- // Author gameInfo may be a plain object or a `(t) => content` factory. `t` socializes when in
3002
- // social mode (identity otherwise) so authors can wrap copy explicitly; the full merged set is
3003
- // still socialized below as a safety net.
3004
- const t = isSocial ? socialize : (text) => text;
3031
+ // Build the merged resolver: game i18n map + shell LOCALES (via createI18n), then socialize on
3032
+ // top for English social mode. For non-English languages, createI18n already handles the
3033
+ // translation lookup; social rewriting is English-only and applied separately after.
3034
+ // Author gameInfo may be a plain object or a `(t) => content` factory. `t` is the resolver so
3035
+ // authors can wrap player-facing copy explicitly; the full merged set is still socialized below
3036
+ // (section pass) as a safety net so restricted words can't slip through even if t() was missed.
3037
+ const { t } = createI18n({ language: runtime.language ?? 'en', isSocial, messages: opts.i18n });
3005
3038
  const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
3006
- let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime), authored);
3039
+ // Pass t() through to spec-derived sections so symbol names, mode titles, and descriptions are
3040
+ // pre-translated before they reach the shell renderer.
3041
+ let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime, t), authored);
3007
3042
  // The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
3008
3043
  // wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
3009
3044
  if (isSocial) {
@@ -3013,8 +3048,8 @@ function buildShellConfig(opts, model, runtime) {
3013
3048
  }
3014
3049
  // The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
3015
3050
  gameInfo = { sections: orderDisclaimerLast(gameInfo.sections ?? []) };
3016
- // Buy-bonus cards: socialize the FINAL options (author override or spec-derived) in social mode.
3017
- const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model), isSocial);
3051
+ // Buy-bonus cards: apply t() to spec-derived options (pre-translate), then socialize for en+social.
3052
+ const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model, t), isSocial);
3018
3053
  // Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
3019
3054
  const features = {
3020
3055
  turbo: 0,
@@ -3025,7 +3060,6 @@ function buildShellConfig(opts, model, runtime) {
3025
3060
  };
3026
3061
  applyJurisdiction(features, runtime.jurisdiction);
3027
3062
  return {
3028
- mount: opts.mount ?? (typeof document !== 'undefined' ? document.body : undefined),
3029
3063
  language: runtime.language ?? 'en',
3030
3064
  isSocial,
3031
3065
  currency,
@@ -3134,39 +3168,32 @@ var slotPlay = /*#__PURE__*/Object.freeze({
3134
3168
  enrichRoundMeta: enrichRoundMeta
3135
3169
  });
3136
3170
 
3137
- /**
3138
- * Drive ONE round end-to-end: play the trigger, present it, ack; then drain the remaining segments
3139
- * (a bonus's free spins) by replaying nextActions[0] with the SAME roundId until the round reports
3140
- * `complete`. Fires `onBonusEnter` EXACTLY before the first free-role segment and `onBonusExit`
3141
- * after the last. A plain spin with no bonus is already `complete`, so the while-loop is a no-op.
3142
- *
3143
- * `ctx.bet` is captured once (bet can't change mid-round); `ctx.turbo` is a live getter so a
3144
- * mid-round toggle is honoured on the next segment.
3145
- */
3146
3171
  async function runRound(deps, action) {
3147
- const ctx = deps.context(action);
3148
- let r = await deps.play(action, ctx.bet);
3149
- await deps.scene.present(r, ctx);
3150
- deps.ack();
3151
- deps.afterPresent?.(r); // HUD readouts update AFTER the animation, not before
3152
- let inBonus = false;
3153
- while (!r.complete && r.nextActions && r.nextActions.length > 0) {
3154
- const next = r.nextActions[0];
3155
- if (!inBonus && deps.roleOf(next) === 'free') {
3156
- inBonus = true;
3157
- await deps.onBonusEnter?.(r, ctx);
3158
- }
3159
- // Snapshot the TRIGGER context per segment: { ... } freezes the live `turbo` getter into a data
3160
- // property (so a mid-round toggle is reflected on the NEXT segment), while action/mode/bet stay
3161
- // the round's (the trigger's) identity — a scene must see the same bonus identity all round.
3162
- const segCtx = { ...deps.context(action) };
3163
- r = await deps.play(next, ctx.bet, r.roundId);
3164
- await deps.scene.present(r, segCtx);
3172
+ deps.onSpinStart?.();
3173
+ const ctxBet = deps.context(action).bet;
3174
+ const segment = async (a, roundId) => {
3175
+ const ac = new AbortController();
3176
+ deps.beforeSegment?.(ac);
3177
+ const r = await deps.play(a, ctxBet, roundId);
3178
+ const ctx = { ...deps.context(action), signal: ac.signal };
3179
+ await deps.scene.onSpin(r, ctx);
3165
3180
  deps.ack();
3166
3181
  deps.afterPresent?.(r);
3182
+ return { r, ctx };
3183
+ };
3184
+ let { r, ctx } = await segment(action, undefined);
3185
+ let inMode = false;
3186
+ while (!r.complete && r.nextActions && r.nextActions.length > 0) {
3187
+ const next = r.nextActions[0];
3188
+ if (!inMode && deps.roleOf(next) === 'free') {
3189
+ inMode = true;
3190
+ await deps.onEnterMode?.(r, ctx);
3191
+ }
3192
+ ({ r, ctx } = await segment(next, r.roundId));
3167
3193
  }
3168
- if (inBonus)
3169
- await deps.onBonusExit?.(r, ctx);
3194
+ if (inMode)
3195
+ await deps.onExitMode?.(r, ctx);
3196
+ deps.onSpinEnd?.(r, ctx);
3170
3197
  }
3171
3198
 
3172
3199
  var runRound$1 = /*#__PURE__*/Object.freeze({
@@ -3273,6 +3300,129 @@ var playError = /*#__PURE__*/Object.freeze({
3273
3300
  resolvePlayError: resolvePlayError
3274
3301
  });
3275
3302
 
3303
+ /** Wrap the engine's AudioManager into the playback-only handle a scene receives. Volume/mute are
3304
+ * deliberately omitted — those are driven by the shell's settingChange → host. */
3305
+ function createSceneAudio(audio) {
3306
+ return {
3307
+ play: (alias, opts) => audio.play(alias, 'sfx', opts),
3308
+ playMusic: (alias, fadeMs) => audio.playMusic(alias, fadeMs),
3309
+ stopMusic: () => audio.stopMusic(),
3310
+ duck: (factor) => audio.duckMusic(factor),
3311
+ unduck: () => audio.unduckMusic(),
3312
+ };
3313
+ }
3314
+
3315
+ var sceneAudio = /*#__PURE__*/Object.freeze({
3316
+ __proto__: null,
3317
+ createSceneAudio: createSceneAudio
3318
+ });
3319
+
3320
+ function createOverlayController(deps) {
3321
+ let current = null;
3322
+ const teardown = () => {
3323
+ if (!current)
3324
+ return;
3325
+ if (current.timer)
3326
+ clearTimeout(current.timer);
3327
+ const { layer, resolve } = current;
3328
+ current = null;
3329
+ layer.removeFromParent();
3330
+ layer.destroy({ children: true });
3331
+ resolve();
3332
+ };
3333
+ const overlay = {
3334
+ show(opts) {
3335
+ if (current) {
3336
+ console.warn('[overlay] show() ignored — an overlay is already open');
3337
+ return Promise.reject(new Error('Overlay already open'));
3338
+ }
3339
+ const { width, height } = deps.size();
3340
+ const layer = new Container();
3341
+ layer.eventMode = 'static';
3342
+ // Pointer-eating + (optional) dim backdrop sized to the canvas.
3343
+ const hit = new Graphics().rect(0, 0, width, height).fill({
3344
+ color: 0x000000,
3345
+ alpha: opts.dim ?? 0.0001, // ~0 keeps it transparent but hit-testable
3346
+ });
3347
+ hit.eventMode = 'static';
3348
+ layer.addChild(hit);
3349
+ const content = new Container();
3350
+ layer.addChild(content);
3351
+ opts.build(content, { width, height });
3352
+ deps.parent.addChild(layer);
3353
+ return new Promise((resolve) => {
3354
+ const dimValue = opts.dim ?? 0.0001;
3355
+ current = { layer, resolve, timer: null, dim: dimValue };
3356
+ const closeOn = opts.closeOn ?? 'tap';
3357
+ if (closeOn === 'tap')
3358
+ hit.on('pointertap', teardown);
3359
+ if (typeof opts.autoCloseMs === 'number') {
3360
+ current.timer = setTimeout(teardown, opts.autoCloseMs);
3361
+ }
3362
+ });
3363
+ },
3364
+ close() { teardown(); },
3365
+ };
3366
+ return {
3367
+ overlay,
3368
+ resize(w, h) {
3369
+ if (!current)
3370
+ return;
3371
+ const hit = current.layer.getChildAt(0);
3372
+ hit.clear().rect(0, 0, w, h).fill({ color: 0x000000, alpha: current.dim });
3373
+ },
3374
+ destroy() { teardown(); },
3375
+ };
3376
+ }
3377
+
3378
+ var overlayController = /*#__PURE__*/Object.freeze({
3379
+ __proto__: null,
3380
+ createOverlayController: createOverlayController
3381
+ });
3382
+
3383
+ /** Pure double-tap recognizer. The host feeds it pointer `tap(now)` (e.g. performance.now()) and
3384
+ * supplies the enabled/active gates + the onSkip effect. */
3385
+ function createDoubleTapSkip(deps) {
3386
+ const threshold = deps.thresholdMs ?? 300;
3387
+ let last = -Infinity;
3388
+ return {
3389
+ tap(now) {
3390
+ const isDouble = now - last <= threshold;
3391
+ last = isDouble ? -Infinity : now; // consume the pair so a 3rd tap starts fresh
3392
+ if (isDouble && deps.enabled() && deps.active())
3393
+ deps.onSkip();
3394
+ },
3395
+ destroy() { last = -Infinity; },
3396
+ };
3397
+ }
3398
+
3399
+ var skipGesture = /*#__PURE__*/Object.freeze({
3400
+ __proto__: null,
3401
+ createDoubleTapSkip: createDoubleTapSkip
3402
+ });
3403
+
3404
+ /** Edge-triggers onHidden/onVisible from a visibility source. Effects (ticker/music/autoplay/scene)
3405
+ * are supplied by the host so this stays pure + testable. */
3406
+ function createPauseController(deps) {
3407
+ let paused = deps.isHidden();
3408
+ const unsub = deps.subscribe(() => {
3409
+ const hidden = deps.isHidden();
3410
+ if (hidden === paused)
3411
+ return;
3412
+ paused = hidden;
3413
+ if (hidden)
3414
+ deps.onHidden();
3415
+ else
3416
+ deps.onVisible();
3417
+ });
3418
+ return { destroy: () => unsub() };
3419
+ }
3420
+
3421
+ var pauseController = /*#__PURE__*/Object.freeze({
3422
+ __proto__: null,
3423
+ createPauseController: createPauseController
3424
+ });
3425
+
3276
3426
  function createAutoplayLoop(deps) {
3277
3427
  let active = false;
3278
3428
  let remaining = 0;