@energy8platform/game-engine 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/host.cjs.js +142 -35
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +42 -0
- package/dist/host.esm.js +142 -35
- package/dist/host.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/host/buildConfig.ts +3 -2
- package/src/host/createSlotGame.ts +189 -43
- package/src/host/types.ts +24 -1
package/dist/host.cjs.js
CHANGED
|
@@ -2270,10 +2270,19 @@ async function createSlotGame(opts) {
|
|
|
2270
2270
|
availableClose: false,
|
|
2271
2271
|
title: shell.t('Something went wrong'),
|
|
2272
2272
|
body: shell.t(message),
|
|
2273
|
-
actions: [
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2273
|
+
actions: [
|
|
2274
|
+
{
|
|
2275
|
+
title: shell.t('Reload'),
|
|
2276
|
+
on: () => {
|
|
2277
|
+
try {
|
|
2278
|
+
location.reload();
|
|
2279
|
+
}
|
|
2280
|
+
catch {
|
|
2281
|
+
/* non-browser */
|
|
2282
|
+
}
|
|
2283
|
+
},
|
|
2284
|
+
},
|
|
2285
|
+
],
|
|
2277
2286
|
});
|
|
2278
2287
|
return;
|
|
2279
2288
|
}
|
|
@@ -2403,7 +2412,9 @@ async function createSlotGame(opts) {
|
|
|
2403
2412
|
const { lookupCurrency } = await import('@energy8platform/stake-bridge');
|
|
2404
2413
|
currencyMeta = lookupCurrency(opts.model.spec.currency);
|
|
2405
2414
|
}
|
|
2406
|
-
catch {
|
|
2415
|
+
catch {
|
|
2416
|
+
/* stake-bridge not installed — resolveCurrency falls back to the code */
|
|
2417
|
+
}
|
|
2407
2418
|
}
|
|
2408
2419
|
const runtime = {
|
|
2409
2420
|
balance,
|
|
@@ -2430,7 +2441,11 @@ async function createSlotGame(opts) {
|
|
|
2430
2441
|
// pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
|
|
2431
2442
|
// scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
|
|
2432
2443
|
// The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
|
|
2433
|
-
const pixiShellCfg = {
|
|
2444
|
+
const pixiShellCfg = {
|
|
2445
|
+
...buildShellConfig(opts.shell, opts.model, runtime),
|
|
2446
|
+
app: game.app,
|
|
2447
|
+
parent: game.uiLayer,
|
|
2448
|
+
};
|
|
2434
2449
|
// The game may swap in its own shell (a custom renderer over the same core) via shellFactory;
|
|
2435
2450
|
// default is the built-in Pixi shell. The host drives whichever it gets through the Shell contract.
|
|
2436
2451
|
shell = (opts.shellFactory ?? createPixiShell)(pixiShellCfg);
|
|
@@ -2443,10 +2458,15 @@ async function createSlotGame(opts) {
|
|
|
2443
2458
|
// async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
|
|
2444
2459
|
// is the single source for both the displayed balance and `ensureAffordable`.
|
|
2445
2460
|
const balanceGate = createBalanceGate((b) => shell.setBalance(b), balance);
|
|
2446
|
-
ps?.on('balanceUpdate', (d) => {
|
|
2461
|
+
ps?.on('balanceUpdate', (d) => {
|
|
2462
|
+
balanceGate.onBalance(d.balance);
|
|
2463
|
+
});
|
|
2447
2464
|
// Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
|
|
2448
2465
|
let currentTurbo = shell.state.turbo;
|
|
2449
|
-
shell.on('turboChange', (level) => {
|
|
2466
|
+
shell.on('turboChange', (level) => {
|
|
2467
|
+
currentTurbo = level;
|
|
2468
|
+
gameScene()?.onTurboChanged?.(level);
|
|
2469
|
+
});
|
|
2450
2470
|
// Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
|
|
2451
2471
|
const skipEnabled = opts.skipGesture ?? true;
|
|
2452
2472
|
// Shell settings → engine state. Sound/volume map onto the AudioManager.
|
|
@@ -2483,11 +2503,21 @@ async function createSlotGame(opts) {
|
|
|
2483
2503
|
sceneApi = {
|
|
2484
2504
|
audio: createSceneAudio(game.audio),
|
|
2485
2505
|
overlay: overlayCtl.overlay,
|
|
2486
|
-
shell: {
|
|
2506
|
+
shell: {
|
|
2507
|
+
get safeArea() {
|
|
2508
|
+
return shell.safeArea;
|
|
2509
|
+
},
|
|
2510
|
+
},
|
|
2487
2511
|
formatAmount: (v) => shell.formatWin(v),
|
|
2488
|
-
get bet() {
|
|
2489
|
-
|
|
2490
|
-
|
|
2512
|
+
get bet() {
|
|
2513
|
+
return currentBet;
|
|
2514
|
+
},
|
|
2515
|
+
get mode() {
|
|
2516
|
+
return opts.model.modeMap['spin'] ?? 'BASE';
|
|
2517
|
+
},
|
|
2518
|
+
get turbo() {
|
|
2519
|
+
return currentTurbo;
|
|
2520
|
+
},
|
|
2491
2521
|
};
|
|
2492
2522
|
const roleOf = (action) => opts.model.spec.actions[action]?.role;
|
|
2493
2523
|
// The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
|
|
@@ -2497,7 +2527,9 @@ async function createSlotGame(opts) {
|
|
|
2497
2527
|
action,
|
|
2498
2528
|
mode: opts.model.modeMap[action] ?? action.toUpperCase(),
|
|
2499
2529
|
formatAmount: (v) => shell.formatWin(v),
|
|
2500
|
-
get turbo() {
|
|
2530
|
+
get turbo() {
|
|
2531
|
+
return currentTurbo;
|
|
2532
|
+
},
|
|
2501
2533
|
});
|
|
2502
2534
|
// Play-error + connection handling. A play rejection is classified into a player-facing modal
|
|
2503
2535
|
// (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
|
|
@@ -2513,11 +2545,27 @@ async function createSlotGame(opts) {
|
|
|
2513
2545
|
title: shell.t(v.title),
|
|
2514
2546
|
body: shell.t(v.body),
|
|
2515
2547
|
actions: v.reload
|
|
2516
|
-
? [
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2548
|
+
? [
|
|
2549
|
+
{
|
|
2550
|
+
title: shell.t('Reload'),
|
|
2551
|
+
on: () => {
|
|
2552
|
+
try {
|
|
2553
|
+
window.location.reload();
|
|
2554
|
+
}
|
|
2555
|
+
catch {
|
|
2556
|
+
/* non-browser */
|
|
2557
|
+
}
|
|
2558
|
+
},
|
|
2559
|
+
},
|
|
2560
|
+
]
|
|
2561
|
+
: [
|
|
2562
|
+
{
|
|
2563
|
+
title: shell.t('OK'),
|
|
2564
|
+
on: () => {
|
|
2565
|
+
playErrorOpen = false;
|
|
2566
|
+
},
|
|
2567
|
+
},
|
|
2568
|
+
],
|
|
2521
2569
|
});
|
|
2522
2570
|
};
|
|
2523
2571
|
ps?.on('connectionStateChanged', (s) => {
|
|
@@ -2546,7 +2594,10 @@ async function createSlotGame(opts) {
|
|
|
2546
2594
|
const skip = createDoubleTapSkip({
|
|
2547
2595
|
enabled: () => skipEnabled,
|
|
2548
2596
|
active: () => presenting,
|
|
2549
|
-
onSkip: () => {
|
|
2597
|
+
onSkip: () => {
|
|
2598
|
+
currentSegmentAbort?.abort();
|
|
2599
|
+
gameScene()?.onSkip?.();
|
|
2600
|
+
},
|
|
2550
2601
|
});
|
|
2551
2602
|
// Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
|
|
2552
2603
|
// lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
|
|
@@ -2576,6 +2627,27 @@ async function createSlotGame(opts) {
|
|
|
2576
2627
|
gameScene()?.onResume?.();
|
|
2577
2628
|
},
|
|
2578
2629
|
});
|
|
2630
|
+
/** Push a bonus segment's readout to the shell bar. Default (no `opts.bonus`) = the free-spins
|
|
2631
|
+
* counter (label 'Free spins', value current/total). With `opts.bonus`, the game supplies the
|
|
2632
|
+
* label + value string (adventure / hold-and-spin / respins) and we drive the generic 'bonus'
|
|
2633
|
+
* hero instead — the shell stays free of any per-game bonus concept. */
|
|
2634
|
+
const applyBonusReadout = (result, view, mode) => {
|
|
2635
|
+
const b = opts.bonus;
|
|
2636
|
+
if (!b) {
|
|
2637
|
+
shell.setFreeSpins(view);
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
const label = typeof b.label === 'function' ? b.label(mode) : (b.label ?? 'Free spins');
|
|
2641
|
+
const value = b.readout
|
|
2642
|
+
? b.readout(result, { view, mode })
|
|
2643
|
+
: view.current == null
|
|
2644
|
+
? String(view.total)
|
|
2645
|
+
: `${view.current} / ${view.total}`;
|
|
2646
|
+
shell.setBonus({ label, value, totalWin: view.totalWin });
|
|
2647
|
+
};
|
|
2648
|
+
/** The mode entered via setMode for a bonus — 'bonus' when the game customises the readout,
|
|
2649
|
+
* else 'freeSpins' (the back-compat default the shell already renders). */
|
|
2650
|
+
const bonusShellMode = opts.bonus ? 'bonus' : 'freeSpins';
|
|
2579
2651
|
/** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
|
|
2580
2652
|
* update only AFTER each onSpin(), per the HUD-timing requirement. */
|
|
2581
2653
|
const playRound = (action) => {
|
|
@@ -2586,6 +2658,7 @@ async function createSlotGame(opts) {
|
|
|
2586
2658
|
// (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
|
|
2587
2659
|
// the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
|
|
2588
2660
|
let inBonus = false;
|
|
2661
|
+
let bonusMode = ''; // the mode string captured on bonus-enter, reused for each settled spin
|
|
2589
2662
|
let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
|
|
2590
2663
|
const fsCounter = createFreeSpinsCounter();
|
|
2591
2664
|
shell.setBusy(true); // block re-spin / spacebar while the round plays out
|
|
@@ -2594,13 +2667,18 @@ async function createSlotGame(opts) {
|
|
|
2594
2667
|
// animation has finished — returning void would reopen it instantly, over a running animation.
|
|
2595
2668
|
return runRound({
|
|
2596
2669
|
// Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
|
|
2597
|
-
play: (a, b, rid) => {
|
|
2670
|
+
play: (a, b, rid) => {
|
|
2671
|
+
balanceGate.beginPlay();
|
|
2672
|
+
return slotPlay$1.play(a, b, rid);
|
|
2673
|
+
},
|
|
2598
2674
|
ack: slotPlay$1.ack,
|
|
2599
2675
|
scene,
|
|
2600
2676
|
context: makeContext,
|
|
2601
2677
|
roleOf,
|
|
2602
2678
|
// Hand the host the per-segment AbortController so a double-tap can skip the live segment.
|
|
2603
|
-
beforeSegment: (ac) => {
|
|
2679
|
+
beforeSegment: (ac) => {
|
|
2680
|
+
currentSegmentAbort = ac;
|
|
2681
|
+
},
|
|
2604
2682
|
onSpinStart: () => scene.onSpinStart?.(),
|
|
2605
2683
|
onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
|
|
2606
2684
|
afterPresent: (r) => {
|
|
@@ -2610,12 +2688,13 @@ async function createSlotGame(opts) {
|
|
|
2610
2688
|
prevWin = r.totalWin;
|
|
2611
2689
|
balanceGate.afterPresent();
|
|
2612
2690
|
if (inBonus)
|
|
2613
|
-
|
|
2691
|
+
applyBonusReadout(r, fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin), bonusMode);
|
|
2614
2692
|
},
|
|
2615
2693
|
onEnterMode: async (trigger, ctx) => {
|
|
2616
2694
|
inBonus = true;
|
|
2617
|
-
|
|
2618
|
-
shell.
|
|
2695
|
+
bonusMode = ctx.mode;
|
|
2696
|
+
shell.setMode(bonusShellMode);
|
|
2697
|
+
applyBonusReadout(trigger, fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0), bonusMode);
|
|
2619
2698
|
await scene.onEnterMode?.(trigger, ctx);
|
|
2620
2699
|
},
|
|
2621
2700
|
onExitMode: async (last, ctx) => {
|
|
@@ -2623,7 +2702,12 @@ async function createSlotGame(opts) {
|
|
|
2623
2702
|
await scene.onExitMode?.(last, ctx);
|
|
2624
2703
|
shell.setMode('base');
|
|
2625
2704
|
},
|
|
2626
|
-
}, action)
|
|
2705
|
+
}, action)
|
|
2706
|
+
.catch(showPlayError)
|
|
2707
|
+
.finally(() => {
|
|
2708
|
+
presenting = false;
|
|
2709
|
+
shell.setBusy(false);
|
|
2710
|
+
});
|
|
2627
2711
|
};
|
|
2628
2712
|
/**
|
|
2629
2713
|
* Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
|
|
@@ -2661,14 +2745,14 @@ async function createSlotGame(opts) {
|
|
|
2661
2745
|
// A recovered open round with remaining segments is a bonus → show FS mode + counter.
|
|
2662
2746
|
if (!inBonus && !r.complete) {
|
|
2663
2747
|
inBonus = true;
|
|
2664
|
-
shell.setMode(
|
|
2748
|
+
shell.setMode(bonusShellMode);
|
|
2665
2749
|
}
|
|
2666
2750
|
if (animate)
|
|
2667
2751
|
await scene.onSpin(r, ctx);
|
|
2668
2752
|
if (inBonus) {
|
|
2669
2753
|
const v = fsView(raw, r.totalWin);
|
|
2670
2754
|
if (v)
|
|
2671
|
-
|
|
2755
|
+
applyBonusReadout(r, v, ctx.mode);
|
|
2672
2756
|
}
|
|
2673
2757
|
shell.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
|
|
2674
2758
|
prevWin = r.totalWin;
|
|
@@ -2678,7 +2762,11 @@ async function createSlotGame(opts) {
|
|
|
2678
2762
|
try {
|
|
2679
2763
|
await applySegment();
|
|
2680
2764
|
while (!r.complete && r.nextActions && r.nextActions.length > 0) {
|
|
2681
|
-
raw = (await ps.play({
|
|
2765
|
+
raw = (await ps.play({
|
|
2766
|
+
action: r.nextActions[0],
|
|
2767
|
+
bet: ctx.bet,
|
|
2768
|
+
roundId: r.roundId,
|
|
2769
|
+
}));
|
|
2682
2770
|
r = enrichRoundMeta(opts.normalize(raw), raw);
|
|
2683
2771
|
await applySegment();
|
|
2684
2772
|
}
|
|
@@ -2691,8 +2779,12 @@ async function createSlotGame(opts) {
|
|
|
2691
2779
|
};
|
|
2692
2780
|
if (mode === 'base') {
|
|
2693
2781
|
let activeFeature = null;
|
|
2694
|
-
shell.on('featureActivate', ({ id }) => {
|
|
2695
|
-
|
|
2782
|
+
shell.on('featureActivate', ({ id }) => {
|
|
2783
|
+
activeFeature = id;
|
|
2784
|
+
});
|
|
2785
|
+
shell.on('featureDeactivate', ({ id: _id }) => {
|
|
2786
|
+
activeFeature = null;
|
|
2787
|
+
});
|
|
2696
2788
|
const { stakeForAction } = await Promise.resolve().then(function () { return shellConfig; });
|
|
2697
2789
|
// Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
|
|
2698
2790
|
const ensureAffordable = (action) => {
|
|
@@ -2712,7 +2804,10 @@ async function createSlotGame(opts) {
|
|
|
2712
2804
|
return;
|
|
2713
2805
|
void playRound(action);
|
|
2714
2806
|
});
|
|
2715
|
-
shell.on('betChange', (bet) => {
|
|
2807
|
+
shell.on('betChange', (bet) => {
|
|
2808
|
+
currentBet = bet;
|
|
2809
|
+
gameScene()?.onBetChanged?.(bet);
|
|
2810
|
+
});
|
|
2716
2811
|
shell.on('buyBonusSelect', ({ id }) => {
|
|
2717
2812
|
if (!ensureAffordable(id))
|
|
2718
2813
|
return;
|
|
@@ -2743,7 +2838,7 @@ async function createSlotGame(opts) {
|
|
|
2743
2838
|
resumeOffered = true;
|
|
2744
2839
|
let snap = null;
|
|
2745
2840
|
try {
|
|
2746
|
-
snap = await ps?.getState() ?? null;
|
|
2841
|
+
snap = (await ps?.getState()) ?? null;
|
|
2747
2842
|
}
|
|
2748
2843
|
catch {
|
|
2749
2844
|
snap = null;
|
|
@@ -2756,13 +2851,25 @@ async function createSlotGame(opts) {
|
|
|
2756
2851
|
body: shell.t('You have an unfinished round. Continue it or finish it now?'),
|
|
2757
2852
|
actions: [
|
|
2758
2853
|
// Continue: replay the round from the start with animation, then settle.
|
|
2759
|
-
{
|
|
2854
|
+
{
|
|
2855
|
+
title: shell.t('Continue'),
|
|
2856
|
+
on: () => {
|
|
2857
|
+
void resumeDrain(snap, true);
|
|
2858
|
+
},
|
|
2859
|
+
},
|
|
2760
2860
|
// Finish: fast-forward the remaining segments (no animation) to settle the win now.
|
|
2761
|
-
{
|
|
2861
|
+
{
|
|
2862
|
+
title: shell.t('Finish'),
|
|
2863
|
+
on: () => {
|
|
2864
|
+
void resumeDrain(snap, false);
|
|
2865
|
+
},
|
|
2866
|
+
},
|
|
2762
2867
|
],
|
|
2763
2868
|
});
|
|
2764
2869
|
};
|
|
2765
|
-
game.scenes.on('change', () => {
|
|
2870
|
+
game.scenes.on('change', () => {
|
|
2871
|
+
void offerResume();
|
|
2872
|
+
});
|
|
2766
2873
|
void offerResume();
|
|
2767
2874
|
}
|
|
2768
2875
|
else {
|