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