@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/audio.cjs.js +15 -5
- package/dist/audio.cjs.js.map +1 -1
- package/dist/audio.d.ts +5 -0
- package/dist/audio.esm.js +15 -5
- package/dist/audio.esm.js.map +1 -1
- package/dist/core.cjs.js +46 -18
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +13 -1
- package/dist/core.esm.js +46 -18
- package/dist/core.esm.js.map +1 -1
- package/dist/host.cjs.js +333 -67
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +111 -26
- package/dist/host.esm.js +333 -67
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +46 -18
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.esm.js +46 -18
- package/dist/index.esm.js.map +1 -1
- package/dist/react.d.ts +13 -1
- package/package.json +3 -2
- package/src/audio/AudioManager.ts +17 -5
- package/src/core/GameApplication.ts +22 -5
- package/src/host/createSlotGame.ts +143 -23
- package/src/host/index.ts +4 -1
- package/src/host/overlayController.ts +81 -0
- package/src/host/pauseController.ts +21 -0
- package/src/host/runRound.ts +35 -43
- package/src/host/sceneAudio.ts +14 -0
- package/src/host/sceneController.ts +84 -19
- package/src/host/shellConfig.ts +11 -6
- package/src/host/skipGesture.ts +24 -0
- package/src/host/types.ts +5 -2
- package/src/viewport/ViewportManager.ts +19 -9
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.
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
303
|
-
this.scenes.setRoot(this.
|
|
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.
|
|
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/
|
|
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
|
-
* `
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
387
|
+
await scene.onEnterMode?.(trigger, ctx);
|
|
276
388
|
},
|
|
277
|
-
|
|
389
|
+
onExitMode: async (last, ctx) => {
|
|
278
390
|
inBonus = false;
|
|
279
|
-
await scene.
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
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 {
|
|
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
|
+
}
|