@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.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: [{ title: shell.t('Reload'), on: () => { try {
2273
- location.reload();
2274
- }
2275
- catch { /* non-browser */ } } }],
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 { /* stake-bridge not installed — resolveCurrency falls back to the code */ }
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 = { ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer };
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) => { balanceGate.onBalance(d.balance); });
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) => { currentTurbo = level; gameScene()?.onTurboChanged?.(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: { get safeArea() { return shell.safeArea; } },
2505
+ shell: {
2506
+ get safeArea() {
2507
+ return shell.safeArea;
2508
+ },
2509
+ },
2486
2510
  formatAmount: (v) => shell.formatWin(v),
2487
- get bet() { return currentBet; },
2488
- get mode() { return opts.model.modeMap['spin'] ?? 'BASE'; },
2489
- get turbo() { return currentTurbo; },
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() { return currentTurbo; },
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
- ? [{ title: shell.t('Reload'), on: () => { try {
2516
- window.location.reload();
2517
- }
2518
- catch { /* non-browser */ } } }]
2519
- : [{ title: shell.t('OK'), on: () => { playErrorOpen = false; } }],
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: () => { currentSegmentAbort?.abort(); gameScene()?.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) => { balanceGate.beginPlay(); return slotPlay$1.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) => { currentSegmentAbort = 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
- shell.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
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
- shell.setMode('freeSpins');
2617
- shell.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
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).catch(showPlayError).finally(() => { presenting = false; shell.setBusy(false); });
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('freeSpins');
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
- shell.setFreeSpins(v);
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({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId }));
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 }) => { activeFeature = id; });
2694
- shell.on('featureDeactivate', ({ id: _id }) => { activeFeature = null; });
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) => { currentBet = bet; gameScene()?.onBetChanged?.(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
- { title: shell.t('Continue'), on: () => { void resumeDrain(snap, true); } },
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
- { title: shell.t('Finish'), on: () => { void resumeDrain(snap, false); } },
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', () => { void offerResume(); });
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
  };