@energy8platform/game-engine 0.18.0 → 0.19.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/react.d.ts CHANGED
@@ -1012,6 +1012,7 @@ declare class AudioManager {
1012
1012
  private _persist;
1013
1013
  private _storageKey;
1014
1014
  private _categories;
1015
+ private _masterGain;
1015
1016
  private _currentMusic;
1016
1017
  private _unlocked;
1017
1018
  private _unlockHandler;
@@ -1052,6 +1053,10 @@ declare class AudioManager {
1052
1053
  * Stop all sounds.
1053
1054
  */
1054
1055
  stopAll(): void;
1056
+ /** Global gain (0..1) folded into every category's effective volume. Driven by the shell's
1057
+ * 'master' settingChange. Does not affect the persisted per-category volumes. */
1058
+ setMasterVolume(volume: number): void;
1059
+ getMasterVolume(): number;
1055
1060
  /**
1056
1061
  * Set volume for a category.
1057
1062
  */
@@ -1254,6 +1259,7 @@ declare class ViewportManager extends EventEmitter<ViewportEvents> {
1254
1259
  private _app;
1255
1260
  private _container;
1256
1261
  private _config;
1262
+ private _target;
1257
1263
  private _resizeObserver;
1258
1264
  private _currentOrientation;
1259
1265
  private _currentWidth;
@@ -1261,7 +1267,7 @@ declare class ViewportManager extends EventEmitter<ViewportEvents> {
1261
1267
  private _currentScale;
1262
1268
  private _destroyed;
1263
1269
  private _resizeTimeout;
1264
- constructor(app: Application, container: HTMLElement, config: ViewportConfig);
1270
+ constructor(app: Application, container: HTMLElement, config: ViewportConfig, target?: Container);
1265
1271
  /** Current canvas width in game units */
1266
1272
  get width(): number;
1267
1273
  /** Current canvas height in game units */
@@ -1362,6 +1368,12 @@ declare class GameApplication extends EventEmitter<GameEngineEvents> {
1362
1368
  input: InputManager;
1363
1369
  /** Viewport manager */
1364
1370
  viewport: ViewportManager;
1371
+ /** Scaled world root (holds scenes). Transformed by the ViewportManager to fit the design
1372
+ * resolution; lives below the UI layer on app.stage. */
1373
+ worldRoot: Container;
1374
+ /** Unscaled, screen-space UI layer. Sits above {@link worldRoot} and is NOT touched by the
1375
+ * viewport transform — children fill the real screen (e.g. the host's shell + overlay). */
1376
+ uiLayer: Container;
1365
1377
  /** SDK instance (null in offline mode) */
1366
1378
  sdk: CasinoGameSDK | null;
1367
1379
  /** FPS overlay instance (only when debug: true) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/game-engine",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Universal casino game engine built on PixiJS v8 and @energy8platform/game-sdk",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs.js",
@@ -98,7 +98,8 @@
98
98
  "prepublishOnly": "npm run build"
99
99
  },
100
100
  "dependencies": {
101
- "@energy8platform/platform-core": "*"
101
+ "@energy8platform/platform-core": "*",
102
+ "@energy8platform/pixi-shell": "*"
102
103
  },
103
104
  "peerDependencies": {
104
105
  "@energy8platform/game-sdk": "^2.7.0",
@@ -35,6 +35,7 @@ export class AudioManager {
35
35
  private _persist: boolean;
36
36
  private _storageKey: string;
37
37
  private _categories: Record<AudioCategoryName, CategoryState>;
38
+ private _masterGain = 1.0;
38
39
  private _currentMusic: string | null = null;
39
40
  private _unlocked = false;
40
41
  private _unlockHandler: (() => void) | null = null;
@@ -105,7 +106,7 @@ export class AudioManager {
105
106
  if (this._globalMuted || this._categories[category].muted) return;
106
107
 
107
108
  const { sound } = this._soundModule;
108
- const vol = (options?.volume ?? 1) * this._categories[category].volume;
109
+ const vol = (options?.volume ?? 1) * this._categories[category].volume * this._masterGain;
109
110
 
110
111
  try {
111
112
  sound.play(alias, {
@@ -137,7 +138,7 @@ export class AudioManager {
137
138
  if (this._globalMuted || this._categories.music.muted) return;
138
139
 
139
140
  // Fade out the previous track
140
- this.fadeVolume(prevAlias, this._categories.music.volume, 0, fadeDuration, () => {
141
+ this.fadeVolume(prevAlias, this._categories.music.volume * this._masterGain, 0, fadeDuration, () => {
141
142
  try { sound.stop(prevAlias); } catch { /* ignore */ }
142
143
  });
143
144
 
@@ -147,7 +148,7 @@ export class AudioManager {
147
148
  volume: 0,
148
149
  loop: true,
149
150
  });
150
- this.fadeVolume(alias, 0, this._categories.music.volume, fadeDuration);
151
+ this.fadeVolume(alias, 0, this._categories.music.volume * this._masterGain, fadeDuration);
151
152
  } catch (e) {
152
153
  console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
153
154
  }
@@ -162,7 +163,7 @@ export class AudioManager {
162
163
 
163
164
  try {
164
165
  sound.play(alias, {
165
- volume: this._categories.music.volume,
166
+ volume: this._categories.music.volume * this._masterGain,
166
167
  loop: true,
167
168
  });
168
169
  } catch (e) {
@@ -195,6 +196,17 @@ export class AudioManager {
195
196
  this._currentMusic = null;
196
197
  }
197
198
 
199
+ /** Global gain (0..1) folded into every category's effective volume. Driven by the shell's
200
+ * 'master' settingChange. Does not affect the persisted per-category volumes. */
201
+ setMasterVolume(volume: number): void {
202
+ this._masterGain = Math.max(0, Math.min(1, volume));
203
+ this.applyVolumes();
204
+ }
205
+
206
+ getMasterVolume(): number {
207
+ return this._masterGain;
208
+ }
209
+
198
210
  /**
199
211
  * Set volume for a category.
200
212
  */
@@ -350,7 +362,7 @@ export class AudioManager {
350
362
  const { sound } = this._soundModule;
351
363
  // Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
352
364
  // not by volumeAll — mixing both leaves mute un-undoable after reload.
353
- sound.volumeAll = 1;
365
+ sound.volumeAll = this._masterGain; // master multiplies the global bus
354
366
  }
355
367
 
356
368
  private setupMobileUnlock(): void {
@@ -1,4 +1,4 @@
1
- import { Application, Assets, Ticker } from 'pixi.js';
1
+ import { Application, Assets, Container, Ticker } from 'pixi.js';
2
2
  import type { CasinoGameSDK } from '@energy8platform/game-sdk';
3
3
  import type { InitData, GameConfigData, SessionData } from '@energy8platform/game-sdk';
4
4
  import { createPlatformSession, type PlatformSession } from '@energy8platform/platform-core';
@@ -66,6 +66,14 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
66
66
  /** Viewport manager */
67
67
  public viewport!: ViewportManager;
68
68
 
69
+ /** Scaled world root (holds scenes). Transformed by the ViewportManager to fit the design
70
+ * resolution; lives below the UI layer on app.stage. */
71
+ public worldRoot!: Container;
72
+
73
+ /** Unscaled, screen-space UI layer. Sits above {@link worldRoot} and is NOT touched by the
74
+ * viewport transform — children fill the real screen (e.g. the host's shell + overlay). */
75
+ public uiLayer!: Container;
76
+
69
77
  /** SDK instance (null in offline mode) */
70
78
  public sdk: CasinoGameSDK | null = null;
71
79
 
@@ -287,7 +295,15 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
287
295
  // Input Manager
288
296
  this.input = new InputManager(this.app.canvas as HTMLCanvasElement);
289
297
 
290
- // Viewport Manager
298
+ // Stage layers: a scaled world root (scenes, transformed to design resolution by the
299
+ // viewport) below an unscaled UI layer (screen space). app.stage itself stays identity.
300
+ this.worldRoot = new Container();
301
+ this.worldRoot.label = 'world';
302
+ this.uiLayer = new Container();
303
+ this.uiLayer.label = 'ui';
304
+ this.app.stage.addChild(this.worldRoot, this.uiLayer);
305
+
306
+ // Viewport Manager — scales worldRoot (NOT app.stage), so the UI layer is unscaled.
291
307
  this.viewport = new ViewportManager(
292
308
  this.app,
293
309
  this._container!,
@@ -297,16 +313,17 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
297
313
  scaleMode: this.config.scaleMode!,
298
314
  orientation: this.config.orientation!,
299
315
  },
316
+ this.worldRoot,
300
317
  );
301
318
 
302
- // Wire SceneManager to the PixiJS stage
303
- this.scenes.setRoot(this.app.stage);
319
+ // Wire SceneManager to the scaled world root
320
+ this.scenes.setRoot(this.worldRoot);
304
321
  this.scenes.setApp(this);
305
322
 
306
323
  // Wire viewport resize → scene manager + input manager
307
324
  this.viewport.on('resize', ({ width, height, scale }) => {
308
325
  this.scenes.resize(width, height);
309
- this.input.setViewportTransform(scale, this.app.stage.x, this.app.stage.y);
326
+ this.input.setViewportTransform(scale, this.worldRoot.x, this.worldRoot.y);
310
327
  this.emit('resize', { width, height });
311
328
  });
312
329
 
@@ -1,11 +1,13 @@
1
1
  // packages/game-engine/src/host/createSlotGame.ts
2
+ import { Container } from 'pixi.js';
2
3
  import { GameApplication } from '../core';
3
4
  import { buildAppConfig } from './buildConfig';
4
5
  import { loadFonts, applyTextureDefaults, bootGuard } from './preboot';
5
6
  import { showFatalError, installGlobalErrorHandlers } from './fatalError';
6
7
  import type { CreateSlotGameOptions, SlotGameHandle } from './types';
7
8
  import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
8
- import type { ShellMode } from '@energy8platform/platform-core/shell';
9
+ import type { ShellMode } from '@energy8platform/pixi-shell';
10
+ import type { SceneApi, SlotSceneController } from './sceneController';
9
11
 
10
12
  /**
11
13
  * One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
@@ -102,15 +104,26 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
102
104
  // Build slotPlay FIRST — bindGameScene() needs it to be in scope.
103
105
  const { createSlotPlay, enrichRoundMeta } = await import('./slotPlay');
104
106
 
107
+ // Injected once per controller scene the first time it becomes current (see `ensureCreated`).
108
+ // `sceneApi` is assembled inside the shell block; until then injection is a no-op (a shell-less
109
+ // launch never builds the api, so a controller scene simply never receives onCreate).
110
+ let sceneApi: SceneApi | null = null;
111
+ const createdScenes = new WeakSet<object>();
112
+ const ensureCreated = (s: SlotSceneController<T>) => {
113
+ if (!sceneApi || createdScenes.has(s)) return;
114
+ createdScenes.add(s);
115
+ s.onCreate?.(sceneApi);
116
+ };
117
+
105
118
  /** The current scene IFF it implements the SlotSceneController contract (duck-typed on
106
- * `present`). The host drives the play loop against whichever scene is current. */
119
+ * `onSpin`). The host drives the play loop against whichever scene is current. Injects the
120
+ * SceneApi via onCreate the first time a controller scene is seen. */
107
121
  const gameScene = () => {
108
- const s = game.scenes.current?.scene as
109
- | Partial<import('./sceneController').SlotSceneController<T>>
110
- | undefined;
111
- return typeof s?.present === 'function'
112
- ? (s as import('./sceneController').SlotSceneController<T>)
113
- : undefined;
122
+ const s = game.scenes.current?.scene as Partial<SlotSceneController<T>> | undefined;
123
+ if (typeof s?.onSpin !== 'function') return undefined;
124
+ const scene = s as SlotSceneController<T>;
125
+ ensureCreated(scene);
126
+ return scene;
114
127
  };
115
128
 
116
129
  const { runRound } = await import('./runRound');
@@ -130,7 +143,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
130
143
  });
131
144
 
132
145
  if (opts.shell) {
133
- const { createGameShell } = await import('@energy8platform/platform-core/shell');
146
+ const { createPixiShell } = await import('@energy8platform/pixi-shell');
134
147
  const { buildShellConfig } = await import('./shellConfig');
135
148
  const { resolveReplayBonusId } = await import('./replay');
136
149
 
@@ -190,7 +203,14 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
190
203
  `| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`,
191
204
  );
192
205
  }
193
- shell = createGameShell(buildShellConfig(opts.shell, opts.model, runtime));
206
+ // pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
207
+ // scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
208
+ // The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
209
+ shell = createPixiShell({ ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer });
210
+ // Scope the bar to the slot scene: show only when a SlotSceneController scene is current
211
+ // (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
212
+ shell.setVisible(!!gameScene());
213
+ game.scenes.on('change', () => shell!.setVisible(!!gameScene()));
194
214
  // The gate tracks the live wallet (for the affordability guard) but only PAINTS the balance per
195
215
  // the HUD-timing rule: the debit is buffered during play→present and shown at afterPresent; the
196
216
  // async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
@@ -200,10 +220,53 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
200
220
 
201
221
  // Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
202
222
  let currentTurbo = shell.state.turbo;
203
- shell.on('turboChange', (level: number) => { currentTurbo = level; });
223
+ shell.on('turboChange', (level: number) => { currentTurbo = level; gameScene()?.onTurboChanged?.(level); });
224
+ // Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
225
+ const skipEnabled = opts.skipGesture ?? true;
226
+
227
+ // Shell settings → engine state. Sound/volume map onto the AudioManager.
228
+ shell.on('settingChange', ({ key, value }: { key: string; value: unknown }) => {
229
+ switch (key) {
230
+ case 'sound': value ? game.audio.unmuteAll() : game.audio.muteAll(); break;
231
+ case 'master': game.audio.setMasterVolume(Number(value)); break;
232
+ case 'music': game.audio.setVolume('music', Number(value)); break;
233
+ case 'sfx': game.audio.setVolume('sfx', Number(value)); break;
234
+ }
235
+ });
236
+
237
+ // Overlay layer sits ABOVE the shell (the shell already mounted its root onto the uiLayer;
238
+ // adding ours afterwards keeps it on top). It eats pointer events while open so shell controls
239
+ // are unreachable. Mounted on the same unscaled UI layer; tracks viewport via game's 'resize'.
240
+ const { createSceneAudio } = await import('./sceneAudio');
241
+ const { createOverlayController } = await import('./overlayController');
242
+ const overlayLayer = new Container();
243
+ overlayLayer.label = 'overlay';
244
+ game.uiLayer.addChild(overlayLayer);
245
+ const overlayCtl = createOverlayController({
246
+ parent: overlayLayer,
247
+ size: () => ({ width: game.app.screen.width, height: game.app.screen.height }),
248
+ });
249
+ game.on('resize', ({ width, height }: { width: number; height: number }) =>
250
+ overlayCtl.resize(width, height),
251
+ );
252
+
253
+ // Capabilities injected once per controller scene via onCreate (see `gameScene`/`ensureCreated`).
254
+ sceneApi = {
255
+ audio: createSceneAudio(game.audio),
256
+ overlay: overlayCtl.overlay,
257
+ shell: { get safeArea() { return shell!.safeArea; } },
258
+ formatAmount: (v) => shell!.formatWin(v),
259
+ get bet() { return currentBet; },
260
+ get mode() { return opts.model.modeMap['spin'] ?? 'BASE'; },
261
+ get turbo() { return currentTurbo; },
262
+ };
204
263
 
205
264
  const roleOf = (action: string) => opts.model.spec.actions[action]?.role;
206
- const makeContext = (action: string): import('./sceneController').RenderContext => ({
265
+ // The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
266
+ // attaches its own. So makeContext returns everything BUT `signal`.
267
+ const makeContext = (
268
+ action: string,
269
+ ): Omit<import('./sceneController').RenderContext, 'signal'> => ({
207
270
  bet: currentBet,
208
271
  action,
209
272
  mode: opts.model.modeMap[action] ?? action.toUpperCase(),
@@ -238,18 +301,63 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
238
301
  });
239
302
  });
240
303
 
304
+ // Skip state: `currentSegmentAbort` is the controller for the segment presently animating;
305
+ // `presenting` is true for the whole play→drain window (gates the double-tap detector so taps
306
+ // only skip while a round is animating).
307
+ let currentSegmentAbort: AbortController | null = null;
308
+ let presenting = false;
309
+
310
+ // Double-tap skip: a double-tap on the play area aborts the current segment (the scene collapses
311
+ // to its final visual via ctx.signal) and notifies the scene's onSkip. Gated by the shell's
312
+ // skip-gesture setting (`skipEnabled`) and only active while a round is presenting.
313
+ const { createDoubleTapSkip } = await import('./skipGesture');
314
+ const skip = createDoubleTapSkip({
315
+ enabled: () => skipEnabled,
316
+ active: () => presenting,
317
+ onSkip: () => { currentSegmentAbort?.abort(); gameScene()?.onSkip?.(); },
318
+ });
319
+ // Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
320
+ // lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
321
+ game.scenes.root.eventMode = 'static';
322
+ game.scenes.root.on('pointertap', () => skip.tap(performance.now()));
323
+
324
+ // Full auto-pause: on tab blur, freeze the ticker (stops tweens/onUpdate/in-flight onSpin),
325
+ // duck music to silence, hold autoplay, and notify the scene. On focus, reverse it all.
326
+ // `stopAutoplay` is reassigned in the base-mode block below — the closure reads it live.
327
+ const { createPauseController } = await import('./pauseController');
328
+ createPauseController({
329
+ isHidden: () => typeof document !== 'undefined' && document.hidden,
330
+ subscribe: (cb) => {
331
+ if (typeof document === 'undefined') return () => {};
332
+ document.addEventListener('visibilitychange', cb);
333
+ return () => document.removeEventListener('visibilitychange', cb);
334
+ },
335
+ onHidden: () => {
336
+ game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
337
+ game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
338
+ stopAutoplay(); // hold autoplay — don't start the next auto-round
339
+ gameScene()?.onPause?.();
340
+ },
341
+ onVisible: () => {
342
+ game.app.ticker.start();
343
+ game.audio.unduckMusic();
344
+ gameScene()?.onResume?.();
345
+ },
346
+ });
347
+
241
348
  /** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
242
- * update only AFTER each present(), per the HUD-timing requirement. */
349
+ * update only AFTER each onSpin(), per the HUD-timing requirement. */
243
350
  const playRound = (action: string) => {
244
351
  const scene = gameScene();
245
352
  if (!scene) return;
246
353
  // Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
247
354
  // (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
248
- // the trigger segment (presented before onBonusEnter) doesn't count as a free spin.
355
+ // the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
249
356
  let inBonus = false;
250
357
  let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
251
358
  const fsCounter = createFreeSpinsCounter();
252
359
  shell!.setBusy(true); // block re-spin / spacebar while the round plays out
360
+ presenting = true; // open the skip window for the whole play→drain
253
361
  // RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
254
362
  // animation has finished — returning void would reopen it instantly, over a running animation.
255
363
  return runRound<T>(
@@ -260,6 +368,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
260
368
  scene,
261
369
  context: makeContext,
262
370
  roleOf,
371
+ // Hand the host the per-segment AbortController so a double-tap can skip the live segment.
372
+ beforeSegment: (ac) => { currentSegmentAbort = ac; },
373
+ onSpinStart: () => scene.onSpinStart?.(),
374
+ onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
263
375
  afterPresent: (r) => {
264
376
  // WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
265
377
  // free-spins counter (totalWin) below, not the WIN readout.
@@ -268,20 +380,20 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
268
380
  balanceGate.afterPresent();
269
381
  if (inBonus) shell!.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
270
382
  },
271
- onBonusEnter: async (trigger, ctx) => {
383
+ onEnterMode: async (trigger, ctx) => {
272
384
  inBonus = true;
273
385
  shell!.setMode('freeSpins');
274
386
  shell!.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
275
- await scene.onBonusEnter?.(trigger, ctx);
387
+ await scene.onEnterMode?.(trigger, ctx);
276
388
  },
277
- onBonusExit: async (last, ctx) => {
389
+ onExitMode: async (last, ctx) => {
278
390
  inBonus = false;
279
- await scene.onBonusExit?.(last, ctx);
391
+ await scene.onExitMode?.(last, ctx);
280
392
  shell!.setMode('base');
281
393
  },
282
394
  },
283
395
  action,
284
- ).catch(showPlayError).finally(() => shell!.setBusy(false));
396
+ ).catch(showPlayError).finally(() => { presenting = false; shell!.setBusy(false); });
285
397
  };
286
398
 
287
399
  /**
@@ -297,7 +409,12 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
297
409
  ): Promise<void> => {
298
410
  const scene = gameScene();
299
411
  if (!scene || !ps) return;
300
- const ctx = makeContext((firstRaw as { action?: string }).action ?? 'spin');
412
+ // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
413
+ // never-aborted signal to satisfy onSpin's RenderContext.
414
+ const ctx: import('./sceneController').RenderContext = {
415
+ ...makeContext((firstRaw as { action?: string }).action ?? 'spin'),
416
+ signal: new AbortController().signal,
417
+ };
301
418
  const fsView = (raw: unknown, totalWin: number) => {
302
419
  const s = (raw as { session?: { spinsPlayed?: number; spinsRemaining?: number } }).session;
303
420
  if (!s) return null;
@@ -315,7 +432,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
315
432
  const applySegment = async (): Promise<void> => {
316
433
  // A recovered open round with remaining segments is a bonus → show FS mode + counter.
317
434
  if (!inBonus && !r.complete) { inBonus = true; shell!.setMode('freeSpins'); }
318
- if (animate) await scene.present(r, ctx);
435
+ if (animate) await scene.onSpin(r, ctx);
319
436
  if (inBonus) { const v = fsView(raw, r.totalWin); if (v) shell!.setFreeSpins(v); }
320
437
  shell!.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
321
438
  prevWin = r.totalWin;
@@ -359,7 +476,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
359
476
  if (!ensureAffordable(action)) return;
360
477
  void playRound(action);
361
478
  });
362
- shell.on('betChange', (bet: number) => { currentBet = bet; });
479
+ shell.on('betChange', (bet: number) => { currentBet = bet; gameScene()?.onBetChanged?.(bet); });
363
480
  shell.on('buyBonusSelect', ({ id }: { id: string }) => {
364
481
  if (!ensureAffordable(id)) return;
365
482
  void playRound(id);
@@ -372,7 +489,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
372
489
  resolveAction: () => activeFeature ?? 'spin',
373
490
  canAfford: (a) => ensureAffordable(a),
374
491
  playRound: (a) => Promise.resolve(playRound(a)),
375
- onState: (s) => shell!.setAutoplay(s),
492
+ onState: (s) => {
493
+ shell!.setAutoplay(s);
494
+ gameScene()?.onAutoplayChanged?.({ running: s.active, remaining: s.remaining });
495
+ },
376
496
  });
377
497
  stopAutoplay = () => autoplay.stop();
378
498
  shell.on('autoplayStart', (o: { remaining?: number }) => autoplay.start(o?.remaining ?? 0));
package/src/host/index.ts CHANGED
@@ -12,7 +12,10 @@ export { buildShellConfig, stakeForAction } from './shellConfig';
12
12
  export type { SlotShellOptions } from './shellConfig';
13
13
  export { resolveReplayBonusId } from './replay';
14
14
  export { resolveStartScene } from './sceneStart';
15
- export type { SlotSceneController, RenderContext } from './sceneController';
15
+ export type {
16
+ SlotSceneController, RenderContext, SceneApi, SceneAudio, SceneOverlay, SceneShell,
17
+ OverlayShowOptions, AutoplaySceneState,
18
+ } from './sceneController';
16
19
  // Social-casino word-swap. The shell auto-socializes all gameInfo/buyBonus text in social mode;
17
20
  // authors only need this to socialize strings they render themselves (e.g. inside a custom DOM node).
18
21
  export { socialize } from '@energy8platform/platform-core/shell';
@@ -0,0 +1,81 @@
1
+ import { Container, Graphics } from 'pixi.js';
2
+ import type { SceneOverlay, OverlayShowOptions } from './sceneController';
3
+
4
+ interface OverlayDeps {
5
+ /** Container mounted above the shell on the stage. */
6
+ parent: Container;
7
+ /** Live canvas size getter (for the hit area + build size). */
8
+ size(): { width: number; height: number };
9
+ }
10
+
11
+ interface ActiveOverlay {
12
+ layer: Container;
13
+ resolve(): void;
14
+ timer: ReturnType<typeof setTimeout> | null;
15
+ dim: number;
16
+ }
17
+
18
+ export function createOverlayController(deps: OverlayDeps): {
19
+ overlay: SceneOverlay;
20
+ resize(w: number, h: number): void;
21
+ destroy(): void;
22
+ } {
23
+ let current: ActiveOverlay | null = null;
24
+
25
+ const teardown = (): void => {
26
+ if (!current) return;
27
+ if (current.timer) clearTimeout(current.timer);
28
+ const { layer, resolve } = current;
29
+ current = null;
30
+ layer.removeFromParent();
31
+ layer.destroy({ children: true });
32
+ resolve();
33
+ };
34
+
35
+ const overlay: SceneOverlay = {
36
+ show(opts: OverlayShowOptions): Promise<void> {
37
+ if (current) {
38
+ console.warn('[overlay] show() ignored — an overlay is already open');
39
+ return Promise.reject(new Error('Overlay already open'));
40
+ }
41
+ const { width, height } = deps.size();
42
+ const layer = new Container();
43
+ layer.eventMode = 'static';
44
+
45
+ // Pointer-eating + (optional) dim backdrop sized to the canvas.
46
+ const hit = new Graphics().rect(0, 0, width, height).fill({
47
+ color: 0x000000,
48
+ alpha: opts.dim ?? 0.0001, // ~0 keeps it transparent but hit-testable
49
+ });
50
+ hit.eventMode = 'static';
51
+ layer.addChild(hit);
52
+
53
+ const content = new Container();
54
+ layer.addChild(content);
55
+ opts.build(content, { width, height });
56
+
57
+ deps.parent.addChild(layer);
58
+
59
+ return new Promise<void>((resolve) => {
60
+ const dimValue = opts.dim ?? 0.0001;
61
+ current = { layer, resolve, timer: null, dim: dimValue };
62
+ const closeOn = opts.closeOn ?? 'tap';
63
+ if (closeOn === 'tap') hit.on('pointertap', teardown);
64
+ if (typeof opts.autoCloseMs === 'number') {
65
+ current.timer = setTimeout(teardown, opts.autoCloseMs);
66
+ }
67
+ });
68
+ },
69
+ close(): void { teardown(); },
70
+ };
71
+
72
+ return {
73
+ overlay,
74
+ resize(w: number, h: number): void {
75
+ if (!current) return;
76
+ const hit = current.layer.getChildAt(0) as Graphics;
77
+ hit.clear().rect(0, 0, w, h).fill({ color: 0x000000, alpha: current.dim });
78
+ },
79
+ destroy(): void { teardown(); },
80
+ };
81
+ }
@@ -0,0 +1,21 @@
1
+ interface PauseDeps {
2
+ isHidden(): boolean;
3
+ onHidden(): void;
4
+ onVisible(): void;
5
+ /** Register a change listener; return an unsubscribe fn. */
6
+ subscribe(cb: () => void): () => void;
7
+ }
8
+
9
+ /** Edge-triggers onHidden/onVisible from a visibility source. Effects (ticker/music/autoplay/scene)
10
+ * are supplied by the host so this stays pure + testable. */
11
+ export function createPauseController(deps: PauseDeps): { destroy(): void } {
12
+ let paused = deps.isHidden();
13
+ const unsub = deps.subscribe(() => {
14
+ const hidden = deps.isHidden();
15
+ if (hidden === paused) return;
16
+ paused = hidden;
17
+ if (hidden) deps.onHidden();
18
+ else deps.onVisible();
19
+ });
20
+ return { destroy: () => unsub() };
21
+ }