@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.
@@ -8,6 +8,7 @@ import type { CreateSlotGameOptions, SlotGameHandle } from './types';
8
8
  import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
9
9
  import type { ShellMode } from '@energy8platform/shell/pixi';
10
10
  import type { SceneApi, SlotSceneController } from './sceneController';
11
+ import type { FreeSpinsView } from './freeSpinsCounter';
11
12
 
12
13
  /**
13
14
  * One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
@@ -37,7 +38,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
37
38
  availableClose: false,
38
39
  title: shell.t('Something went wrong'),
39
40
  body: shell.t(message),
40
- actions: [{ title: shell.t('Reload'), on: () => { try { location.reload(); } catch { /* non-browser */ } } }],
41
+ actions: [
42
+ {
43
+ title: shell.t('Reload'),
44
+ on: () => {
45
+ try {
46
+ location.reload();
47
+ } catch {
48
+ /* non-browser */
49
+ }
50
+ },
51
+ },
52
+ ],
41
53
  });
42
54
  return;
43
55
  }
@@ -61,7 +73,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
61
73
  const launch = classifyStakeLaunch(location.href);
62
74
  if (launch === 'blocked') {
63
75
  fatal('Invalid game server address. Please relaunch the game from the lobby.');
64
- throw new Error('createSlotGame: refusing to run — Stake launch with a missing or invalid rgs_url');
76
+ throw new Error(
77
+ 'createSlotGame: refusing to run — Stake launch with a missing or invalid rgs_url',
78
+ );
65
79
  }
66
80
  isStakeNow = launch === 'stake';
67
81
  if (isStakeNow) {
@@ -149,7 +163,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
149
163
  // ACK the result AFTER the scene animates it (the scene calls host.ack()). On Stake this
150
164
  // triggers /wallet/end-round so a winning round settles post-animation instead of staying
151
165
  // open and blocking the next spin.
152
- ack: (raw) => game.platformSession!.playAck(raw as import('@energy8platform/platform-core').PlayResultData),
166
+ ack: (raw) =>
167
+ game.platformSession!.playAck(raw as import('@energy8platform/platform-core').PlayResultData),
153
168
  });
154
169
 
155
170
  if (opts.shell) {
@@ -187,7 +202,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
187
202
  try {
188
203
  const { lookupCurrency } = await import('@energy8platform/stake-bridge');
189
204
  currencyMeta = lookupCurrency(opts.model.spec.currency);
190
- } catch { /* stake-bridge not installed — resolveCurrency falls back to the code */ }
205
+ } catch {
206
+ /* stake-bridge not installed — resolveCurrency falls back to the code */
207
+ }
191
208
  }
192
209
  const runtime = {
193
210
  balance,
@@ -209,14 +226,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
209
226
  const cc = config?.currency as { code?: string; symbol?: string } | undefined;
210
227
  console.info(
211
228
  `[e8] currency → bridge.code=${cc?.code ?? '∅'} bridge.symbol=${cc?.symbol ?? '∅'} ` +
212
- `| spec=${opts.model.spec.currency ?? '∅'} ` +
213
- `| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`,
229
+ `| spec=${opts.model.spec.currency ?? '∅'} ` +
230
+ `| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`,
214
231
  );
215
232
  }
216
233
  // pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
217
234
  // scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
218
235
  // The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
219
- const pixiShellCfg: import('@energy8platform/shell/pixi').PixiShellConfig = { ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer };
236
+ const pixiShellCfg: import('@energy8platform/shell/pixi').PixiShellConfig = {
237
+ ...buildShellConfig(opts.shell, opts.model, runtime),
238
+ app: game.app,
239
+ parent: game.uiLayer,
240
+ };
220
241
  // The game may swap in its own shell (a custom renderer over the same core) via shellFactory;
221
242
  // default is the built-in Pixi shell. The host drives whichever it gets through the Shell contract.
222
243
  shell = (opts.shellFactory ?? createPixiShell)(pixiShellCfg);
@@ -229,21 +250,34 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
229
250
  // async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
230
251
  // is the single source for both the displayed balance and `ensureAffordable`.
231
252
  const balanceGate = createBalanceGate((b) => shell!.setBalance(b), balance);
232
- ps?.on('balanceUpdate', (d: { balance: number }) => { balanceGate.onBalance(d.balance); });
253
+ ps?.on('balanceUpdate', (d: { balance: number }) => {
254
+ balanceGate.onBalance(d.balance);
255
+ });
233
256
 
234
257
  // Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
235
258
  let currentTurbo = shell.state.turbo;
236
- shell.on('turboChange', (level: number) => { currentTurbo = level; gameScene()?.onTurboChanged?.(level); });
259
+ shell.on('turboChange', (level: number) => {
260
+ currentTurbo = level;
261
+ gameScene()?.onTurboChanged?.(level);
262
+ });
237
263
  // Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
238
264
  const skipEnabled = opts.skipGesture ?? true;
239
265
 
240
266
  // Shell settings → engine state. Sound/volume map onto the AudioManager.
241
267
  shell.on('settingChange', ({ key, value }: { key: string; value: unknown }) => {
242
268
  switch (key) {
243
- case 'sound': value ? game.audio.unmuteAll() : game.audio.muteAll(); break;
244
- case 'master': game.audio.setMasterVolume(Number(value)); break;
245
- case 'music': game.audio.setVolume('music', Number(value)); break;
246
- case 'sfx': game.audio.setVolume('sfx', Number(value)); break;
269
+ case 'sound':
270
+ value ? game.audio.unmuteAll() : game.audio.muteAll();
271
+ break;
272
+ case 'master':
273
+ game.audio.setMasterVolume(Number(value));
274
+ break;
275
+ case 'music':
276
+ game.audio.setVolume('music', Number(value));
277
+ break;
278
+ case 'sfx':
279
+ game.audio.setVolume('sfx', Number(value));
280
+ break;
247
281
  }
248
282
  });
249
283
 
@@ -267,11 +301,21 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
267
301
  sceneApi = {
268
302
  audio: createSceneAudio(game.audio),
269
303
  overlay: overlayCtl.overlay,
270
- shell: { get safeArea() { return shell!.safeArea; } },
304
+ shell: {
305
+ get safeArea() {
306
+ return shell!.safeArea;
307
+ },
308
+ },
271
309
  formatAmount: (v) => shell!.formatWin(v),
272
- get bet() { return currentBet; },
273
- get mode() { return opts.model.modeMap['spin'] ?? 'BASE'; },
274
- get turbo() { return currentTurbo; },
310
+ get bet() {
311
+ return currentBet;
312
+ },
313
+ get mode() {
314
+ return opts.model.modeMap['spin'] ?? 'BASE';
315
+ },
316
+ get turbo() {
317
+ return currentTurbo;
318
+ },
275
319
  };
276
320
 
277
321
  const roleOf = (action: string) => opts.model.spec.actions[action]?.role;
@@ -284,7 +328,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
284
328
  action,
285
329
  mode: opts.model.modeMap[action] ?? action.toUpperCase(),
286
330
  formatAmount: (v) => shell!.formatWin(v),
287
- get turbo() { return currentTurbo; },
331
+ get turbo() {
332
+ return currentTurbo;
333
+ },
288
334
  });
289
335
  // Play-error + connection handling. A play rejection is classified into a player-facing modal
290
336
  // (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
@@ -300,12 +346,33 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
300
346
  title: shell!.t(v.title),
301
347
  body: shell!.t(v.body),
302
348
  actions: v.reload
303
- ? [{ title: shell!.t('Reload'), on: () => { try { window.location.reload(); } catch { /* non-browser */ } } }]
304
- : [{ title: shell!.t('OK'), on: () => { playErrorOpen = false; } }],
349
+ ? [
350
+ {
351
+ title: shell!.t('Reload'),
352
+ on: () => {
353
+ try {
354
+ window.location.reload();
355
+ } catch {
356
+ /* non-browser */
357
+ }
358
+ },
359
+ },
360
+ ]
361
+ : [
362
+ {
363
+ title: shell!.t('OK'),
364
+ on: () => {
365
+ playErrorOpen = false;
366
+ },
367
+ },
368
+ ],
305
369
  });
306
370
  };
307
371
  ps?.on('connectionStateChanged', (s: { status: string }) => {
308
- if (s.status === 'restored') { if (!playErrorOpen) shell!.closeModal(); return; }
372
+ if (s.status === 'restored') {
373
+ if (!playErrorOpen) shell!.closeModal();
374
+ return;
375
+ }
309
376
  if (playErrorOpen) return; // a play-error modal owns the screen — don't mask it with "reconnecting"
310
377
  shell!.openModal({
311
378
  availableClose: false,
@@ -327,7 +394,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
327
394
  const skip = createDoubleTapSkip({
328
395
  enabled: () => skipEnabled,
329
396
  active: () => presenting,
330
- onSkip: () => { currentSegmentAbort?.abort(); gameScene()?.onSkip?.(); },
397
+ onSkip: () => {
398
+ currentSegmentAbort?.abort();
399
+ gameScene()?.onSkip?.();
400
+ },
331
401
  });
332
402
  // Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
333
403
  // lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
@@ -346,9 +416,9 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
346
416
  return () => document.removeEventListener('visibilitychange', cb);
347
417
  },
348
418
  onHidden: () => {
349
- game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
350
- game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
351
- stopAutoplay(); // hold autoplay — don't start the next auto-round
419
+ game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
420
+ game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
421
+ stopAutoplay(); // hold autoplay — don't start the next auto-round
352
422
  gameScene()?.onPause?.();
353
423
  },
354
424
  onVisible: () => {
@@ -358,6 +428,28 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
358
428
  },
359
429
  });
360
430
 
431
+ /** Push a bonus segment's readout to the shell bar. Default (no `opts.bonus`) = the free-spins
432
+ * counter (label 'Free spins', value current/total). With `opts.bonus`, the game supplies the
433
+ * label + value string (adventure / hold-and-spin / respins) and we drive the generic 'bonus'
434
+ * hero instead — the shell stays free of any per-game bonus concept. */
435
+ const applyBonusReadout = (result: T, view: FreeSpinsView, mode: string): void => {
436
+ const b = opts.bonus;
437
+ if (!b) {
438
+ shell!.setFreeSpins(view);
439
+ return;
440
+ }
441
+ const label = typeof b.label === 'function' ? b.label(mode) : (b.label ?? 'Free spins');
442
+ const value = b.readout
443
+ ? b.readout(result, { view, mode })
444
+ : view.current == null
445
+ ? String(view.total)
446
+ : `${view.current} / ${view.total}`;
447
+ shell!.setBonus({ label, value, totalWin: view.totalWin });
448
+ };
449
+ /** The mode entered via setMode for a bonus — 'bonus' when the game customises the readout,
450
+ * else 'freeSpins' (the back-compat default the shell already renders). */
451
+ const bonusShellMode: ShellMode = opts.bonus ? 'bonus' : 'freeSpins';
452
+
361
453
  /** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
362
454
  * update only AFTER each onSpin(), per the HUD-timing requirement. */
363
455
  const playRound = (action: string) => {
@@ -367,6 +459,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
367
459
  // (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
368
460
  // the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
369
461
  let inBonus = false;
462
+ let bonusMode = ''; // the mode string captured on bonus-enter, reused for each settled spin
370
463
  let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
371
464
  const fsCounter = createFreeSpinsCounter();
372
465
  shell!.setBusy(true); // block re-spin / spacebar while the round plays out
@@ -376,13 +469,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
376
469
  return runRound<T>(
377
470
  {
378
471
  // Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
379
- play: (a, b, rid) => { balanceGate.beginPlay(); return slotPlay.play(a, b, rid); },
472
+ play: (a, b, rid) => {
473
+ balanceGate.beginPlay();
474
+ return slotPlay.play(a, b, rid);
475
+ },
380
476
  ack: slotPlay.ack,
381
477
  scene,
382
478
  context: makeContext,
383
479
  roleOf,
384
480
  // Hand the host the per-segment AbortController so a double-tap can skip the live segment.
385
- beforeSegment: (ac) => { currentSegmentAbort = ac; },
481
+ beforeSegment: (ac) => {
482
+ currentSegmentAbort = ac;
483
+ },
386
484
  onSpinStart: () => scene.onSpinStart?.(),
387
485
  onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
388
486
  afterPresent: (r) => {
@@ -391,12 +489,22 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
391
489
  shell!.setWin(r.totalWin - prevWin);
392
490
  prevWin = r.totalWin;
393
491
  balanceGate.afterPresent();
394
- if (inBonus) shell!.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
492
+ if (inBonus)
493
+ applyBonusReadout(
494
+ r,
495
+ fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin),
496
+ bonusMode,
497
+ );
395
498
  },
396
499
  onEnterMode: async (trigger, ctx) => {
397
500
  inBonus = true;
398
- shell!.setMode('freeSpins');
399
- shell!.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
501
+ bonusMode = ctx.mode;
502
+ shell!.setMode(bonusShellMode);
503
+ applyBonusReadout(
504
+ trigger,
505
+ fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0),
506
+ bonusMode,
507
+ );
400
508
  await scene.onEnterMode?.(trigger, ctx);
401
509
  },
402
510
  onExitMode: async (last, ctx) => {
@@ -406,7 +514,12 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
406
514
  },
407
515
  },
408
516
  action,
409
- ).catch(showPlayError).finally(() => { presenting = false; shell!.setBusy(false); });
517
+ )
518
+ .catch(showPlayError)
519
+ .finally(() => {
520
+ presenting = false;
521
+ shell!.setBusy(false);
522
+ });
410
523
  };
411
524
 
412
525
  /**
@@ -444,9 +557,15 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
444
557
  let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
445
558
  const applySegment = async (): Promise<void> => {
446
559
  // A recovered open round with remaining segments is a bonus → show FS mode + counter.
447
- if (!inBonus && !r.complete) { inBonus = true; shell!.setMode('freeSpins'); }
560
+ if (!inBonus && !r.complete) {
561
+ inBonus = true;
562
+ shell!.setMode(bonusShellMode);
563
+ }
448
564
  if (animate) await scene.onSpin(r, ctx);
449
- if (inBonus) { const v = fsView(raw, r.totalWin); if (v) shell!.setFreeSpins(v); }
565
+ if (inBonus) {
566
+ const v = fsView(raw, r.totalWin);
567
+ if (v) applyBonusReadout(r, v, ctx.mode);
568
+ }
450
569
  shell!.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
451
570
  prevWin = r.totalWin;
452
571
  ps!.playAck(raw); // settles via /wallet/end-round on the FINAL segment
@@ -455,8 +574,11 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
455
574
  try {
456
575
  await applySegment();
457
576
  while (!r.complete && r.nextActions && r.nextActions.length > 0) {
458
- raw = (await ps.play({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId })) as
459
- import('@energy8platform/platform-core').PlayResultData;
577
+ raw = (await ps.play({
578
+ action: r.nextActions[0],
579
+ bet: ctx.bet,
580
+ roundId: r.roundId,
581
+ })) as import('@energy8platform/platform-core').PlayResultData;
460
582
  r = enrichRoundMeta(opts.normalize(raw), raw);
461
583
  await applySegment();
462
584
  }
@@ -468,13 +590,18 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
468
590
 
469
591
  if (mode === 'base') {
470
592
  let activeFeature: string | null = null;
471
- shell.on('featureActivate', ({ id }: { id: string }) => { activeFeature = id; });
472
- shell.on('featureDeactivate', ({ id: _id }: { id: string }) => { activeFeature = null; });
593
+ shell.on('featureActivate', ({ id }: { id: string }) => {
594
+ activeFeature = id;
595
+ });
596
+ shell.on('featureDeactivate', ({ id: _id }: { id: string }) => {
597
+ activeFeature = null;
598
+ });
473
599
 
474
600
  const { stakeForAction } = await import('./shellConfig');
475
601
  // Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
476
602
  const ensureAffordable = (action: string): boolean => {
477
- if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9) return true;
603
+ if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9)
604
+ return true;
478
605
  shell!.openModal({
479
606
  availableClose: true,
480
607
  title: shell!.t('Insufficient balance'),
@@ -489,7 +616,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
489
616
  if (!ensureAffordable(action)) return;
490
617
  void playRound(action);
491
618
  });
492
- shell.on('betChange', (bet: number) => { currentBet = bet; gameScene()?.onBetChanged?.(bet); });
619
+ shell.on('betChange', (bet: number) => {
620
+ currentBet = bet;
621
+ gameScene()?.onBetChanged?.(bet);
622
+ });
493
623
  shell.on('buyBonusSelect', ({ id }: { id: string }) => {
494
624
  if (!ensureAffordable(id)) return;
495
625
  void playRound(id);
@@ -519,7 +649,11 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
519
649
  if (resumeOffered || !shell || !gameScene()) return;
520
650
  resumeOffered = true;
521
651
  let snap: import('@energy8platform/platform-core').PlayResultData | null = null;
522
- try { snap = await ps?.getState() ?? null; } catch { snap = null; }
652
+ try {
653
+ snap = (await ps?.getState()) ?? null;
654
+ } catch {
655
+ snap = null;
656
+ }
523
657
  if (!snap) return;
524
658
  shell.openModal({
525
659
  availableClose: false,
@@ -527,13 +661,25 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
527
661
  body: shell.t('You have an unfinished round. Continue it or finish it now?'),
528
662
  actions: [
529
663
  // Continue: replay the round from the start with animation, then settle.
530
- { title: shell.t('Continue'), on: () => { void resumeDrain(snap!, true); } },
664
+ {
665
+ title: shell.t('Continue'),
666
+ on: () => {
667
+ void resumeDrain(snap!, true);
668
+ },
669
+ },
531
670
  // Finish: fast-forward the remaining segments (no animation) to settle the win now.
532
- { title: shell.t('Finish'), on: () => { void resumeDrain(snap!, false); } },
671
+ {
672
+ title: shell.t('Finish'),
673
+ on: () => {
674
+ void resumeDrain(snap!, false);
675
+ },
676
+ },
533
677
  ],
534
678
  });
535
679
  };
536
- game.scenes.on('change', () => { void offerResume(); });
680
+ game.scenes.on('change', () => {
681
+ void offerResume();
682
+ });
537
683
  void offerResume();
538
684
  } else {
539
685
  const stakeMode = stakeBridge?.replayMode ?? 'BASE';
@@ -10,6 +10,9 @@ interface OverlayDeps {
10
10
 
11
11
  interface ActiveOverlay {
12
12
  layer: Container;
13
+ /** The scene-owned content container from `build` — re-laid-out on resize. */
14
+ content: Container;
15
+ onResize: OverlayShowOptions['onResize'];
13
16
  resolve(): void;
14
17
  timer: ReturnType<typeof setTimeout> | null;
15
18
  dim: number;
@@ -58,7 +61,7 @@ export function createOverlayController(deps: OverlayDeps): {
58
61
 
59
62
  return new Promise<void>((resolve) => {
60
63
  const dimValue = opts.dim ?? 0.0001;
61
- current = { layer, resolve, timer: null, dim: dimValue };
64
+ current = { layer, content, onResize: opts.onResize, resolve, timer: null, dim: dimValue };
62
65
  const closeOn = opts.closeOn ?? 'tap';
63
66
  if (closeOn === 'tap') hit.on('pointertap', teardown);
64
67
  if (typeof opts.autoCloseMs === 'number') {
@@ -75,6 +78,7 @@ export function createOverlayController(deps: OverlayDeps): {
75
78
  if (!current) return;
76
79
  const hit = current.layer.getChildAt(0) as Graphics;
77
80
  hit.clear().rect(0, 0, w, h).fill({ color: 0x000000, alpha: current.dim });
81
+ current.onResize?.(current.content, { width: w, height: h });
78
82
  },
79
83
  destroy(): void { teardown(); },
80
84
  };
@@ -30,6 +30,10 @@ export interface SceneAudio {
30
30
  export interface OverlayShowOptions {
31
31
  /** Draw the overlay content into `container` (sized to the canvas). */
32
32
  build(container: Container, size: { width: number; height: number }): void;
33
+ /** Re-layout the same `container` when the canvas resizes while the overlay is open.
34
+ * Receives the content container from `build` and the new size. Optional — omit for
35
+ * overlays that self-center or don't care about resize. */
36
+ onResize?(container: Container, size: { width: number; height: number }): void;
33
37
  /** Auto-close after N ms (combine with closeOn — whichever fires first). */
34
38
  autoCloseMs?: number;
35
39
  /** Dismiss on a single tap. Default 'tap'. Set false to require an explicit close(). */
package/src/host/types.ts CHANGED
@@ -7,7 +7,27 @@ import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../t
7
7
  import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
8
8
  import type { GameApplication } from '../core';
9
9
  import type { SlotShellOptions } from './shellConfig';
10
- import type { SlotSpinResultBase, SlotResultNormalizer } from '@energy8platform/platform-core/slot-result';
10
+ import type {
11
+ SlotSpinResultBase,
12
+ SlotResultNormalizer,
13
+ } from '@energy8platform/platform-core/slot-result';
14
+ import type { FreeSpinsView } from './freeSpinsCounter';
15
+
16
+ /** Turns a bonus segment into the bar readout for games whose bonus ISN'T a plain free-spins
17
+ * counter (adventure, hold-and-spin, respins). The shell shows a host-driven hero + Total Win in
18
+ * ANY bonus; this only customises the label + counter VALUE. Omit `bonus` entirely and the host
19
+ * falls back to the free-spins default (label 'Free spins', value current/total, retrigger-aware). */
20
+ export interface BonusReadoutConfig<T extends SlotSpinResultBase = SlotSpinResultBase> {
21
+ /** Bar label (localized by the shell, so a game i18n entry is honoured). A string, or a function
22
+ * of the current mode (e.g. `m => m === 'ADVENTURE' ? 'Adventure' : 'Free spins'`).
23
+ * Default: 'Free spins'. */
24
+ label?: string | ((mode: string) => string);
25
+ /** Format the counter VALUE string from the settled segment. `view` is the host's default
26
+ * free-spins counter (current/total, retrigger-aware) — use it for the common case, or ignore it
27
+ * and read your own fields off `result` (respins left, coins collected, a multiplier).
28
+ * Default: `view.current == null ? String(view.total) : `${view.current} / ${view.total}``. */
29
+ readout?: (result: T, ctx: { view: FreeSpinsView; mode: string }) => string;
30
+ }
11
31
 
12
32
  export interface StakeIntegration {
13
33
  /** The game's BookAdapter (or its module). modeMap + gameId come from the model. */
@@ -58,6 +78,9 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
58
78
  dev?: boolean;
59
79
  stake?: StakeIntegration;
60
80
  shell?: SlotShellOptions;
81
+ /** Customise the bonus bar readout for games whose bonus isn't plain free spins (adventure,
82
+ * hold-and-spin, respins). Omit for the free-spins default. See `BonusReadoutConfig`. */
83
+ bonus?: BonusReadoutConfig<T>;
61
84
  /** Override how the control-bar shell is built. The host resolves the full shell config (theme,
62
85
  * features, gameInfo, currency, balance) and the Pixi mount (`app`/`parent`) and hands it to this
63
86
  * factory; return any `Shell` — e.g. `createShell({ renderer: new MyRenderer(...), ...config })`