@energy8platform/game-engine 0.18.0 → 0.19.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;
@@ -1904,6 +1919,12 @@ class GameApplication extends EventEmitter {
1904
1919
  input;
1905
1920
  /** Viewport manager */
1906
1921
  viewport;
1922
+ /** Scaled world root (holds scenes). Transformed by the ViewportManager to fit the design
1923
+ * resolution; lives below the UI layer on app.stage. */
1924
+ worldRoot;
1925
+ /** Unscaled, screen-space UI layer. Sits above {@link worldRoot} and is NOT touched by the
1926
+ * viewport transform — children fill the real screen (e.g. the host's shell + overlay). */
1927
+ uiLayer;
1907
1928
  /** SDK instance (null in offline mode) */
1908
1929
  sdk = null;
1909
1930
  /** FPS overlay instance (only when debug: true) */
@@ -2083,20 +2104,27 @@ class GameApplication extends EventEmitter {
2083
2104
  this.audio = new AudioManager(this.config.audio);
2084
2105
  // Input Manager
2085
2106
  this.input = new InputManager(this.app.canvas);
2086
- // Viewport Manager
2107
+ // Stage layers: a scaled world root (scenes, transformed to design resolution by the
2108
+ // viewport) below an unscaled UI layer (screen space). app.stage itself stays identity.
2109
+ this.worldRoot = new pixi_js.Container();
2110
+ this.worldRoot.label = 'world';
2111
+ this.uiLayer = new pixi_js.Container();
2112
+ this.uiLayer.label = 'ui';
2113
+ this.app.stage.addChild(this.worldRoot, this.uiLayer);
2114
+ // Viewport Manager — scales worldRoot (NOT app.stage), so the UI layer is unscaled.
2087
2115
  this.viewport = new ViewportManager(this.app, this._container, {
2088
2116
  designWidth: this.config.designWidth,
2089
2117
  designHeight: this.config.designHeight,
2090
2118
  scaleMode: this.config.scaleMode,
2091
2119
  orientation: this.config.orientation,
2092
- });
2093
- // Wire SceneManager to the PixiJS stage
2094
- this.scenes.setRoot(this.app.stage);
2120
+ }, this.worldRoot);
2121
+ // Wire SceneManager to the scaled world root
2122
+ this.scenes.setRoot(this.worldRoot);
2095
2123
  this.scenes.setApp(this);
2096
2124
  // Wire viewport resize → scene manager + input manager
2097
2125
  this.viewport.on('resize', ({ width, height, scale }) => {
2098
2126
  this.scenes.resize(width, height);
2099
- this.input.setViewportTransform(scale, this.app.stage.x, this.app.stage.y);
2127
+ this.input.setViewportTransform(scale, this.worldRoot.x, this.worldRoot.y);
2100
2128
  this.emit('resize', { width, height });
2101
2129
  });
2102
2130
  this.viewport.on('orientationChange', (orientation) => {
@@ -2412,13 +2440,27 @@ async function createSlotGame(opts) {
2412
2440
  let currentBet = opts.model.spec.defaultBet ?? opts.model.spec.betLevels[0];
2413
2441
  // Build slotPlay FIRST — bindGameScene() needs it to be in scope.
2414
2442
  const { createSlotPlay, enrichRoundMeta } = await Promise.resolve().then(function () { return slotPlay; });
2443
+ // Injected once per controller scene the first time it becomes current (see `ensureCreated`).
2444
+ // `sceneApi` is assembled inside the shell block; until then injection is a no-op (a shell-less
2445
+ // launch never builds the api, so a controller scene simply never receives onCreate).
2446
+ let sceneApi = null;
2447
+ const createdScenes = new WeakSet();
2448
+ const ensureCreated = (s) => {
2449
+ if (!sceneApi || createdScenes.has(s))
2450
+ return;
2451
+ createdScenes.add(s);
2452
+ s.onCreate?.(sceneApi);
2453
+ };
2415
2454
  /** 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. */
2455
+ * `onSpin`). The host drives the play loop against whichever scene is current. Injects the
2456
+ * SceneApi via onCreate the first time a controller scene is seen. */
2417
2457
  const gameScene = () => {
2418
2458
  const s = game.scenes.current?.scene;
2419
- return typeof s?.present === 'function'
2420
- ? s
2421
- : undefined;
2459
+ if (typeof s?.onSpin !== 'function')
2460
+ return undefined;
2461
+ const scene = s;
2462
+ ensureCreated(scene);
2463
+ return scene;
2422
2464
  };
2423
2465
  const { runRound } = await Promise.resolve().then(function () { return runRound$1; });
2424
2466
  const { createBalanceGate } = await Promise.resolve().then(function () { return balanceGate; });
@@ -2435,7 +2477,7 @@ async function createSlotGame(opts) {
2435
2477
  ack: (raw) => game.platformSession.playAck(raw),
2436
2478
  });
2437
2479
  if (opts.shell) {
2438
- const { createGameShell } = await import('@energy8platform/platform-core/shell');
2480
+ const { createPixiShell } = await import('@energy8platform/pixi-shell');
2439
2481
  const { buildShellConfig } = await Promise.resolve().then(function () { return shellConfig; });
2440
2482
  const { resolveReplayBonusId } = await Promise.resolve().then(function () { return replay; });
2441
2483
  const ps = game.platformSession;
@@ -2482,7 +2524,14 @@ async function createSlotGame(opts) {
2482
2524
  `| spec=${opts.model.spec.currency ?? '∅'} ` +
2483
2525
  `| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`);
2484
2526
  }
2485
- shell = createGameShell(buildShellConfig(opts.shell, opts.model, runtime));
2527
+ // pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
2528
+ // scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
2529
+ // The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
2530
+ shell = createPixiShell({ ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer });
2531
+ // Scope the bar to the slot scene: show only when a SlotSceneController scene is current
2532
+ // (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
2533
+ shell.setVisible(!!gameScene());
2534
+ game.scenes.on('change', () => shell.setVisible(!!gameScene()));
2486
2535
  // The gate tracks the live wallet (for the affordability guard) but only PAINTS the balance per
2487
2536
  // the HUD-timing rule: the debit is buffered during play→present and shown at afterPresent; the
2488
2537
  // async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
@@ -2491,8 +2540,52 @@ async function createSlotGame(opts) {
2491
2540
  ps?.on('balanceUpdate', (d) => { balanceGate.onBalance(d.balance); });
2492
2541
  // Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
2493
2542
  let currentTurbo = shell.state.turbo;
2494
- shell.on('turboChange', (level) => { currentTurbo = level; });
2543
+ shell.on('turboChange', (level) => { currentTurbo = level; gameScene()?.onTurboChanged?.(level); });
2544
+ // Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
2545
+ const skipEnabled = opts.skipGesture ?? true;
2546
+ // Shell settings → engine state. Sound/volume map onto the AudioManager.
2547
+ shell.on('settingChange', ({ key, value }) => {
2548
+ switch (key) {
2549
+ case 'sound':
2550
+ value ? game.audio.unmuteAll() : game.audio.muteAll();
2551
+ break;
2552
+ case 'master':
2553
+ game.audio.setMasterVolume(Number(value));
2554
+ break;
2555
+ case 'music':
2556
+ game.audio.setVolume('music', Number(value));
2557
+ break;
2558
+ case 'sfx':
2559
+ game.audio.setVolume('sfx', Number(value));
2560
+ break;
2561
+ }
2562
+ });
2563
+ // Overlay layer sits ABOVE the shell (the shell already mounted its root onto the uiLayer;
2564
+ // adding ours afterwards keeps it on top). It eats pointer events while open so shell controls
2565
+ // are unreachable. Mounted on the same unscaled UI layer; tracks viewport via game's 'resize'.
2566
+ const { createSceneAudio } = await Promise.resolve().then(function () { return sceneAudio; });
2567
+ const { createOverlayController } = await Promise.resolve().then(function () { return overlayController; });
2568
+ const overlayLayer = new pixi_js.Container();
2569
+ overlayLayer.label = 'overlay';
2570
+ game.uiLayer.addChild(overlayLayer);
2571
+ const overlayCtl = createOverlayController({
2572
+ parent: overlayLayer,
2573
+ size: () => ({ width: game.app.screen.width, height: game.app.screen.height }),
2574
+ });
2575
+ game.on('resize', ({ width, height }) => overlayCtl.resize(width, height));
2576
+ // Capabilities injected once per controller scene via onCreate (see `gameScene`/`ensureCreated`).
2577
+ sceneApi = {
2578
+ audio: createSceneAudio(game.audio),
2579
+ overlay: overlayCtl.overlay,
2580
+ shell: { get safeArea() { return shell.safeArea; } },
2581
+ formatAmount: (v) => shell.formatWin(v),
2582
+ get bet() { return currentBet; },
2583
+ get mode() { return opts.model.modeMap['spin'] ?? 'BASE'; },
2584
+ get turbo() { return currentTurbo; },
2585
+ };
2495
2586
  const roleOf = (action) => opts.model.spec.actions[action]?.role;
2587
+ // The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
2588
+ // attaches its own. So makeContext returns everything BUT `signal`.
2496
2589
  const makeContext = (action) => ({
2497
2590
  bet: currentBet,
2498
2591
  action,
@@ -2535,19 +2628,62 @@ async function createSlotGame(opts) {
2535
2628
  body: shell.t('Lost connection to the game server. Trying to reconnect…'),
2536
2629
  });
2537
2630
  });
2631
+ // Skip state: `currentSegmentAbort` is the controller for the segment presently animating;
2632
+ // `presenting` is true for the whole play→drain window (gates the double-tap detector so taps
2633
+ // only skip while a round is animating).
2634
+ let currentSegmentAbort = null;
2635
+ let presenting = false;
2636
+ // Double-tap skip: a double-tap on the play area aborts the current segment (the scene collapses
2637
+ // to its final visual via ctx.signal) and notifies the scene's onSkip. Gated by the shell's
2638
+ // skip-gesture setting (`skipEnabled`) and only active while a round is presenting.
2639
+ const { createDoubleTapSkip } = await Promise.resolve().then(function () { return skipGesture; });
2640
+ const skip = createDoubleTapSkip({
2641
+ enabled: () => skipEnabled,
2642
+ active: () => presenting,
2643
+ onSkip: () => { currentSegmentAbort?.abort(); gameScene()?.onSkip?.(); },
2644
+ });
2645
+ // Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
2646
+ // lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
2647
+ game.scenes.root.eventMode = 'static';
2648
+ game.scenes.root.on('pointertap', () => skip.tap(performance.now()));
2649
+ // Full auto-pause: on tab blur, freeze the ticker (stops tweens/onUpdate/in-flight onSpin),
2650
+ // duck music to silence, hold autoplay, and notify the scene. On focus, reverse it all.
2651
+ // `stopAutoplay` is reassigned in the base-mode block below — the closure reads it live.
2652
+ const { createPauseController } = await Promise.resolve().then(function () { return pauseController; });
2653
+ createPauseController({
2654
+ isHidden: () => typeof document !== 'undefined' && document.hidden,
2655
+ subscribe: (cb) => {
2656
+ if (typeof document === 'undefined')
2657
+ return () => { };
2658
+ document.addEventListener('visibilitychange', cb);
2659
+ return () => document.removeEventListener('visibilitychange', cb);
2660
+ },
2661
+ onHidden: () => {
2662
+ game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
2663
+ game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
2664
+ stopAutoplay(); // hold autoplay — don't start the next auto-round
2665
+ gameScene()?.onPause?.();
2666
+ },
2667
+ onVisible: () => {
2668
+ game.app.ticker.start();
2669
+ game.audio.unduckMusic();
2670
+ gameScene()?.onResume?.();
2671
+ },
2672
+ });
2538
2673
  /** 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. */
2674
+ * update only AFTER each onSpin(), per the HUD-timing requirement. */
2540
2675
  const playRound = (action) => {
2541
2676
  const scene = gameScene();
2542
2677
  if (!scene)
2543
2678
  return;
2544
2679
  // Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
2545
2680
  // (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.
2681
+ // the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
2547
2682
  let inBonus = false;
2548
2683
  let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
2549
2684
  const fsCounter = createFreeSpinsCounter();
2550
2685
  shell.setBusy(true); // block re-spin / spacebar while the round plays out
2686
+ presenting = true; // open the skip window for the whole play→drain
2551
2687
  // RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
2552
2688
  // animation has finished — returning void would reopen it instantly, over a running animation.
2553
2689
  return runRound({
@@ -2557,6 +2693,10 @@ async function createSlotGame(opts) {
2557
2693
  scene,
2558
2694
  context: makeContext,
2559
2695
  roleOf,
2696
+ // Hand the host the per-segment AbortController so a double-tap can skip the live segment.
2697
+ beforeSegment: (ac) => { currentSegmentAbort = ac; },
2698
+ onSpinStart: () => scene.onSpinStart?.(),
2699
+ onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
2560
2700
  afterPresent: (r) => {
2561
2701
  // WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
2562
2702
  // free-spins counter (totalWin) below, not the WIN readout.
@@ -2566,18 +2706,18 @@ async function createSlotGame(opts) {
2566
2706
  if (inBonus)
2567
2707
  shell.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
2568
2708
  },
2569
- onBonusEnter: async (trigger, ctx) => {
2709
+ onEnterMode: async (trigger, ctx) => {
2570
2710
  inBonus = true;
2571
2711
  shell.setMode('freeSpins');
2572
2712
  shell.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
2573
- await scene.onBonusEnter?.(trigger, ctx);
2713
+ await scene.onEnterMode?.(trigger, ctx);
2574
2714
  },
2575
- onBonusExit: async (last, ctx) => {
2715
+ onExitMode: async (last, ctx) => {
2576
2716
  inBonus = false;
2577
- await scene.onBonusExit?.(last, ctx);
2717
+ await scene.onExitMode?.(last, ctx);
2578
2718
  shell.setMode('base');
2579
2719
  },
2580
- }, action).catch(showPlayError).finally(() => shell.setBusy(false));
2720
+ }, action).catch(showPlayError).finally(() => { presenting = false; shell.setBusy(false); });
2581
2721
  };
2582
2722
  /**
2583
2723
  * Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
@@ -2590,7 +2730,12 @@ async function createSlotGame(opts) {
2590
2730
  const scene = gameScene();
2591
2731
  if (!scene || !ps)
2592
2732
  return;
2593
- const ctx = makeContext(firstRaw.action ?? 'spin');
2733
+ // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
2734
+ // never-aborted signal to satisfy onSpin's RenderContext.
2735
+ const ctx = {
2736
+ ...makeContext(firstRaw.action ?? 'spin'),
2737
+ signal: new AbortController().signal,
2738
+ };
2594
2739
  const fsView = (raw, totalWin) => {
2595
2740
  const s = raw.session;
2596
2741
  if (!s)
@@ -2613,7 +2758,7 @@ async function createSlotGame(opts) {
2613
2758
  shell.setMode('freeSpins');
2614
2759
  }
2615
2760
  if (animate)
2616
- await scene.present(r, ctx);
2761
+ await scene.onSpin(r, ctx);
2617
2762
  if (inBonus) {
2618
2763
  const v = fsView(raw, r.totalWin);
2619
2764
  if (v)
@@ -2661,7 +2806,7 @@ async function createSlotGame(opts) {
2661
2806
  return;
2662
2807
  void playRound(action);
2663
2808
  });
2664
- shell.on('betChange', (bet) => { currentBet = bet; });
2809
+ shell.on('betChange', (bet) => { currentBet = bet; gameScene()?.onBetChanged?.(bet); });
2665
2810
  shell.on('buyBonusSelect', ({ id }) => {
2666
2811
  if (!ensureAffordable(id))
2667
2812
  return;
@@ -2674,7 +2819,10 @@ async function createSlotGame(opts) {
2674
2819
  resolveAction: () => activeFeature ?? 'spin',
2675
2820
  canAfford: (a) => ensureAffordable(a),
2676
2821
  playRound: (a) => Promise.resolve(playRound(a)),
2677
- onState: (s) => shell.setAutoplay(s),
2822
+ onState: (s) => {
2823
+ shell.setAutoplay(s);
2824
+ gameScene()?.onAutoplayChanged?.({ running: s.active, remaining: s.remaining });
2825
+ },
2678
2826
  });
2679
2827
  stopAutoplay = () => autoplay$1.stop();
2680
2828
  shell.on('autoplayStart', (o) => autoplay$1.start(o?.remaining ?? 0));
@@ -2731,6 +2879,8 @@ async function createSlotGame(opts) {
2731
2879
  }
2732
2880
 
2733
2881
  // packages/game-engine/src/host/shellConfig.ts
2882
+ // `socialize` is a runtime helper that pixi-shell does NOT re-export (its index only re-exports
2883
+ // types), so it stays sourced from platform-core/shell; the shapes are structurally identical.
2734
2884
  /**
2735
2885
  * Apply jurisdiction restrictions over the resolved shell features, in place. A restriction ALWAYS
2736
2886
  * wins over the author's intent (a forbidden control must stay off even if the game enabled it).
@@ -2983,7 +3133,8 @@ function socializeBonusOptions(options, isSocial) {
2983
3133
  return options;
2984
3134
  return options.map((o) => ({ ...o, title: shell.socialize(o.title), description: shell.socialize(o.description) }));
2985
3135
  }
2986
- /** Pure: assemble a ShellConfig from the model + runtime context (currency/balance/language/mode). */
3136
+ /** Pure: assemble the shell config (sans mount target) from the model + runtime context
3137
+ * (currency/balance/language/mode). The host adds `app` at the call site. */
2987
3138
  function buildShellConfig(opts, model, runtime) {
2988
3139
  // Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
2989
3140
  const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
@@ -3026,7 +3177,6 @@ function buildShellConfig(opts, model, runtime) {
3026
3177
  };
3027
3178
  applyJurisdiction(features, runtime.jurisdiction);
3028
3179
  return {
3029
- mount: opts.mount ?? (typeof document !== 'undefined' ? document.body : undefined),
3030
3180
  language: runtime.language ?? 'en',
3031
3181
  isSocial,
3032
3182
  currency,
@@ -3135,39 +3285,32 @@ var slotPlay = /*#__PURE__*/Object.freeze({
3135
3285
  enrichRoundMeta: enrichRoundMeta
3136
3286
  });
3137
3287
 
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
3288
  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);
3289
+ deps.onSpinStart?.();
3290
+ const ctxBet = deps.context(action).bet;
3291
+ const segment = async (a, roundId) => {
3292
+ const ac = new AbortController();
3293
+ deps.beforeSegment?.(ac);
3294
+ const r = await deps.play(a, ctxBet, roundId);
3295
+ const ctx = { ...deps.context(action), signal: ac.signal };
3296
+ await deps.scene.onSpin(r, ctx);
3166
3297
  deps.ack();
3167
3298
  deps.afterPresent?.(r);
3299
+ return { r, ctx };
3300
+ };
3301
+ let { r, ctx } = await segment(action, undefined);
3302
+ let inMode = false;
3303
+ while (!r.complete && r.nextActions && r.nextActions.length > 0) {
3304
+ const next = r.nextActions[0];
3305
+ if (!inMode && deps.roleOf(next) === 'free') {
3306
+ inMode = true;
3307
+ await deps.onEnterMode?.(r, ctx);
3308
+ }
3309
+ ({ r, ctx } = await segment(next, r.roundId));
3168
3310
  }
3169
- if (inBonus)
3170
- await deps.onBonusExit?.(r, ctx);
3311
+ if (inMode)
3312
+ await deps.onExitMode?.(r, ctx);
3313
+ deps.onSpinEnd?.(r, ctx);
3171
3314
  }
3172
3315
 
3173
3316
  var runRound$1 = /*#__PURE__*/Object.freeze({
@@ -3274,6 +3417,129 @@ var playError = /*#__PURE__*/Object.freeze({
3274
3417
  resolvePlayError: resolvePlayError
3275
3418
  });
3276
3419
 
3420
+ /** Wrap the engine's AudioManager into the playback-only handle a scene receives. Volume/mute are
3421
+ * deliberately omitted — those are driven by the shell's settingChange → host. */
3422
+ function createSceneAudio(audio) {
3423
+ return {
3424
+ play: (alias, opts) => audio.play(alias, 'sfx', opts),
3425
+ playMusic: (alias, fadeMs) => audio.playMusic(alias, fadeMs),
3426
+ stopMusic: () => audio.stopMusic(),
3427
+ duck: (factor) => audio.duckMusic(factor),
3428
+ unduck: () => audio.unduckMusic(),
3429
+ };
3430
+ }
3431
+
3432
+ var sceneAudio = /*#__PURE__*/Object.freeze({
3433
+ __proto__: null,
3434
+ createSceneAudio: createSceneAudio
3435
+ });
3436
+
3437
+ function createOverlayController(deps) {
3438
+ let current = null;
3439
+ const teardown = () => {
3440
+ if (!current)
3441
+ return;
3442
+ if (current.timer)
3443
+ clearTimeout(current.timer);
3444
+ const { layer, resolve } = current;
3445
+ current = null;
3446
+ layer.removeFromParent();
3447
+ layer.destroy({ children: true });
3448
+ resolve();
3449
+ };
3450
+ const overlay = {
3451
+ show(opts) {
3452
+ if (current) {
3453
+ console.warn('[overlay] show() ignored — an overlay is already open');
3454
+ return Promise.reject(new Error('Overlay already open'));
3455
+ }
3456
+ const { width, height } = deps.size();
3457
+ const layer = new pixi_js.Container();
3458
+ layer.eventMode = 'static';
3459
+ // Pointer-eating + (optional) dim backdrop sized to the canvas.
3460
+ const hit = new pixi_js.Graphics().rect(0, 0, width, height).fill({
3461
+ color: 0x000000,
3462
+ alpha: opts.dim ?? 0.0001, // ~0 keeps it transparent but hit-testable
3463
+ });
3464
+ hit.eventMode = 'static';
3465
+ layer.addChild(hit);
3466
+ const content = new pixi_js.Container();
3467
+ layer.addChild(content);
3468
+ opts.build(content, { width, height });
3469
+ deps.parent.addChild(layer);
3470
+ return new Promise((resolve) => {
3471
+ const dimValue = opts.dim ?? 0.0001;
3472
+ current = { layer, resolve, timer: null, dim: dimValue };
3473
+ const closeOn = opts.closeOn ?? 'tap';
3474
+ if (closeOn === 'tap')
3475
+ hit.on('pointertap', teardown);
3476
+ if (typeof opts.autoCloseMs === 'number') {
3477
+ current.timer = setTimeout(teardown, opts.autoCloseMs);
3478
+ }
3479
+ });
3480
+ },
3481
+ close() { teardown(); },
3482
+ };
3483
+ return {
3484
+ overlay,
3485
+ resize(w, h) {
3486
+ if (!current)
3487
+ return;
3488
+ const hit = current.layer.getChildAt(0);
3489
+ hit.clear().rect(0, 0, w, h).fill({ color: 0x000000, alpha: current.dim });
3490
+ },
3491
+ destroy() { teardown(); },
3492
+ };
3493
+ }
3494
+
3495
+ var overlayController = /*#__PURE__*/Object.freeze({
3496
+ __proto__: null,
3497
+ createOverlayController: createOverlayController
3498
+ });
3499
+
3500
+ /** Pure double-tap recognizer. The host feeds it pointer `tap(now)` (e.g. performance.now()) and
3501
+ * supplies the enabled/active gates + the onSkip effect. */
3502
+ function createDoubleTapSkip(deps) {
3503
+ const threshold = deps.thresholdMs ?? 300;
3504
+ let last = -Infinity;
3505
+ return {
3506
+ tap(now) {
3507
+ const isDouble = now - last <= threshold;
3508
+ last = isDouble ? -Infinity : now; // consume the pair so a 3rd tap starts fresh
3509
+ if (isDouble && deps.enabled() && deps.active())
3510
+ deps.onSkip();
3511
+ },
3512
+ destroy() { last = -Infinity; },
3513
+ };
3514
+ }
3515
+
3516
+ var skipGesture = /*#__PURE__*/Object.freeze({
3517
+ __proto__: null,
3518
+ createDoubleTapSkip: createDoubleTapSkip
3519
+ });
3520
+
3521
+ /** Edge-triggers onHidden/onVisible from a visibility source. Effects (ticker/music/autoplay/scene)
3522
+ * are supplied by the host so this stays pure + testable. */
3523
+ function createPauseController(deps) {
3524
+ let paused = deps.isHidden();
3525
+ const unsub = deps.subscribe(() => {
3526
+ const hidden = deps.isHidden();
3527
+ if (hidden === paused)
3528
+ return;
3529
+ paused = hidden;
3530
+ if (hidden)
3531
+ deps.onHidden();
3532
+ else
3533
+ deps.onVisible();
3534
+ });
3535
+ return { destroy: () => unsub() };
3536
+ }
3537
+
3538
+ var pauseController = /*#__PURE__*/Object.freeze({
3539
+ __proto__: null,
3540
+ createPauseController: createPauseController
3541
+ });
3542
+
3277
3543
  function createAutoplayLoop(deps) {
3278
3544
  let active = false;
3279
3545
  let remaining = 0;