@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/audio.cjs.js +15 -5
- package/dist/audio.cjs.js.map +1 -1
- package/dist/audio.d.ts +5 -0
- package/dist/audio.esm.js +15 -5
- package/dist/audio.esm.js.map +1 -1
- package/dist/core.cjs.js +46 -18
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +13 -1
- package/dist/core.esm.js +46 -18
- package/dist/core.esm.js.map +1 -1
- package/dist/host.cjs.js +333 -67
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +111 -26
- package/dist/host.esm.js +333 -67
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +46 -18
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.esm.js +46 -18
- package/dist/index.esm.js.map +1 -1
- package/dist/react.d.ts +13 -1
- package/package.json +3 -2
- package/src/audio/AudioManager.ts +17 -5
- package/src/core/GameApplication.ts +22 -5
- package/src/host/createSlotGame.ts +143 -23
- package/src/host/index.ts +4 -1
- package/src/host/overlayController.ts +81 -0
- package/src/host/pauseController.ts +21 -0
- package/src/host/runRound.ts +35 -43
- package/src/host/sceneAudio.ts +14 -0
- package/src/host/sceneController.ts +84 -19
- package/src/host/shellConfig.ts +11 -6
- package/src/host/skipGesture.ts +24 -0
- package/src/host/types.ts +5 -2
- package/src/viewport/ViewportManager.ts +19 -9
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 =
|
|
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.
|
|
1419
|
+
this._target.scale.set(stageScale);
|
|
1405
1420
|
// Center the stage for FIT mode
|
|
1406
1421
|
if (scaleMode === ScaleMode.FIT) {
|
|
1407
|
-
this.
|
|
1408
|
-
this.
|
|
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.
|
|
1412
|
-
this.
|
|
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.
|
|
1416
|
-
this.
|
|
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
|
-
//
|
|
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
|
|
2094
|
-
this.scenes.setRoot(this.
|
|
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.
|
|
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
|
-
* `
|
|
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
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
2713
|
+
await scene.onEnterMode?.(trigger, ctx);
|
|
2574
2714
|
},
|
|
2575
|
-
|
|
2715
|
+
onExitMode: async (last, ctx) => {
|
|
2576
2716
|
inBonus = false;
|
|
2577
|
-
await scene.
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
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 (
|
|
3170
|
-
await deps.
|
|
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;
|