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