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