@energy8platform/game-engine 0.17.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.
Files changed (80) hide show
  1. package/dist/audio.cjs.js +15 -5
  2. package/dist/audio.cjs.js.map +1 -1
  3. package/dist/audio.d.ts +5 -0
  4. package/dist/audio.esm.js +15 -5
  5. package/dist/audio.esm.js.map +1 -1
  6. package/dist/core.cjs.js +108 -19
  7. package/dist/core.cjs.js.map +1 -1
  8. package/dist/core.d.ts +46 -3
  9. package/dist/core.esm.js +109 -21
  10. package/dist/core.esm.js.map +1 -1
  11. package/dist/game-spec.cjs.js +13 -0
  12. package/dist/game-spec.cjs.js.map +1 -0
  13. package/dist/game-spec.d.ts +1 -0
  14. package/dist/game-spec.esm.js +2 -0
  15. package/dist/game-spec.esm.js.map +1 -0
  16. package/dist/host.cjs.js +3612 -0
  17. package/dist/host.cjs.js.map +1 -0
  18. package/dist/host.d.ts +1000 -0
  19. package/dist/host.esm.js +3603 -0
  20. package/dist/host.esm.js.map +1 -0
  21. package/dist/index.cjs.js +59 -19
  22. package/dist/index.cjs.js.map +1 -1
  23. package/dist/index.d.ts +19 -2
  24. package/dist/index.esm.js +59 -19
  25. package/dist/index.esm.js.map +1 -1
  26. package/dist/react.cjs.js.map +1 -1
  27. package/dist/react.d.ts +19 -2
  28. package/dist/react.esm.js.map +1 -1
  29. package/dist/shell.cjs.js +19 -0
  30. package/dist/shell.cjs.js.map +1 -0
  31. package/dist/shell.d.ts +1 -0
  32. package/dist/shell.esm.js +2 -0
  33. package/dist/shell.esm.js.map +1 -0
  34. package/dist/slot.cjs.js +998 -0
  35. package/dist/slot.cjs.js.map +1 -0
  36. package/dist/slot.d.ts +333 -0
  37. package/dist/slot.esm.js +985 -0
  38. package/dist/slot.esm.js.map +1 -0
  39. package/package.json +28 -2
  40. package/src/audio/AudioManager.ts +17 -5
  41. package/src/core/GameApplication.ts +38 -6
  42. package/src/core/index.ts +2 -0
  43. package/src/game-spec/index.ts +1 -0
  44. package/src/host/autoplay.ts +78 -0
  45. package/src/host/balanceGate.ts +46 -0
  46. package/src/host/buildConfig.ts +28 -0
  47. package/src/host/createSlotGame.ts +543 -0
  48. package/src/host/fatalError.ts +104 -0
  49. package/src/host/freeSpinsCounter.ts +44 -0
  50. package/src/host/index.ts +21 -0
  51. package/src/host/overlayController.ts +81 -0
  52. package/src/host/pauseController.ts +21 -0
  53. package/src/host/playError.ts +64 -0
  54. package/src/host/preboot.ts +25 -0
  55. package/src/host/replay.ts +9 -0
  56. package/src/host/runRound.ts +55 -0
  57. package/src/host/sceneAudio.ts +14 -0
  58. package/src/host/sceneController.ts +96 -0
  59. package/src/host/sceneStart.ts +25 -0
  60. package/src/host/shellConfig.ts +384 -0
  61. package/src/host/skipGesture.ts +24 -0
  62. package/src/host/slotPlay.ts +62 -0
  63. package/src/host/types.ts +74 -0
  64. package/src/scenes/IntroScene.ts +66 -0
  65. package/src/shell/index.ts +20 -0
  66. package/src/slot/anim/CascadeController.ts +102 -0
  67. package/src/slot/anim/ReelSpinController.ts +81 -0
  68. package/src/slot/anim/easing-map.ts +14 -0
  69. package/src/slot/freeSpins/FreeSpinsSession.ts +40 -0
  70. package/src/slot/grid/AnimatedSymbol.ts +68 -0
  71. package/src/slot/grid/ReelGrid.ts +92 -0
  72. package/src/slot/grid/SymbolCell.ts +127 -0
  73. package/src/slot/grid/SymbolView.ts +13 -0
  74. package/src/slot/index.ts +21 -0
  75. package/src/slot/multiplier/MultiplierAccumulator.ts +29 -0
  76. package/src/slot/overlay/BigWinOverlay.ts +89 -0
  77. package/src/slot/overlay/CountUpDisplay.ts +56 -0
  78. package/src/slot/overlay/tiers.ts +29 -0
  79. package/src/types.ts +3 -0
  80. package/src/viewport/ViewportManager.ts +19 -9
@@ -0,0 +1,3603 @@
1
+ import { Ticker, Assets, Container, Text, Application, Graphics, TextureSource } from 'pixi.js';
2
+ import { createPlatformSession } from '@energy8platform/platform-core';
3
+ import { buildLogoSVG, LOADER_BAR_MAX_WIDTH, createCSSPreloader, removeCSSPreloader } from '@energy8platform/platform-core/loading';
4
+ import { socialize } from '@energy8platform/platform-core/shell';
5
+ export { socialize } from '@energy8platform/platform-core/shell';
6
+
7
+ // ─── Scale Modes ───────────────────────────────────────────
8
+ var ScaleMode;
9
+ (function (ScaleMode) {
10
+ /** Fit inside container, maintain aspect ratio (letterbox/pillarbox) */
11
+ ScaleMode["FIT"] = "FIT";
12
+ /** Fill container, maintain aspect ratio (crop edges) */
13
+ ScaleMode["FILL"] = "FILL";
14
+ /** Stretch to fill (distorts) */
15
+ ScaleMode["STRETCH"] = "STRETCH";
16
+ })(ScaleMode || (ScaleMode = {}));
17
+ // ─── Orientation ───────────────────────────────────────────
18
+ var Orientation;
19
+ (function (Orientation) {
20
+ Orientation["LANDSCAPE"] = "landscape";
21
+ Orientation["PORTRAIT"] = "portrait";
22
+ Orientation["ANY"] = "any";
23
+ })(Orientation || (Orientation = {}));
24
+ // ─── Transition Types ──────────────────────────────────────
25
+ var TransitionType;
26
+ (function (TransitionType) {
27
+ TransitionType["NONE"] = "none";
28
+ TransitionType["FADE"] = "fade";
29
+ TransitionType["SLIDE_LEFT"] = "slide-left";
30
+ TransitionType["SLIDE_RIGHT"] = "slide-right";
31
+ })(TransitionType || (TransitionType = {}));
32
+
33
+ /**
34
+ * Minimal typed event emitter.
35
+ * Used internally by GameApplication, SceneManager, AudioManager, etc.
36
+ *
37
+ * Supports `void` event types — events that carry no data can be emitted
38
+ * without arguments: `emitter.emit('eventName')`.
39
+ */
40
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
41
+ class EventEmitter {
42
+ listeners = new Map();
43
+ on(event, handler) {
44
+ if (!this.listeners.has(event)) {
45
+ this.listeners.set(event, new Set());
46
+ }
47
+ this.listeners.get(event).add(handler);
48
+ return this;
49
+ }
50
+ once(event, handler) {
51
+ const wrapper = (data) => {
52
+ this.off(event, wrapper);
53
+ handler(data);
54
+ };
55
+ return this.on(event, wrapper);
56
+ }
57
+ off(event, handler) {
58
+ this.listeners.get(event)?.delete(handler);
59
+ return this;
60
+ }
61
+ emit(...args) {
62
+ const [event, data] = args;
63
+ const handlers = this.listeners.get(event);
64
+ if (handlers) {
65
+ for (const handler of handlers) {
66
+ handler(data);
67
+ }
68
+ }
69
+ }
70
+ removeAllListeners(event) {
71
+ if (event) {
72
+ this.listeners.delete(event);
73
+ }
74
+ else {
75
+ this.listeners.clear();
76
+ }
77
+ return this;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Collection of easing functions for use with Tween and Timeline.
83
+ *
84
+ * All functions take a progress value t (0..1) and return the eased value.
85
+ */
86
+ const Easing = {
87
+ linear: (t) => t,
88
+ easeInQuad: (t) => t * t,
89
+ easeOutQuad: (t) => t * (2 - t),
90
+ easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
91
+ easeInCubic: (t) => t * t * t,
92
+ easeOutCubic: (t) => --t * t * t + 1,
93
+ easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
94
+ easeInQuart: (t) => t * t * t * t,
95
+ easeOutQuart: (t) => 1 - --t * t * t * t,
96
+ easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
97
+ easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
98
+ easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
99
+ easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
100
+ easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10)),
101
+ easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
102
+ easeInOutExpo: (t) => t === 0
103
+ ? 0
104
+ : t === 1
105
+ ? 1
106
+ : t < 0.5
107
+ ? Math.pow(2, 20 * t - 10) / 2
108
+ : (2 - Math.pow(2, -20 * t + 10)) / 2,
109
+ easeInBack: (t) => {
110
+ const c1 = 1.70158;
111
+ const c3 = c1 + 1;
112
+ return c3 * t * t * t - c1 * t * t;
113
+ },
114
+ easeOutBack: (t) => {
115
+ const c1 = 1.70158;
116
+ const c3 = c1 + 1;
117
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
118
+ },
119
+ easeInOutBack: (t) => {
120
+ const c1 = 1.70158;
121
+ const c2 = c1 * 1.525;
122
+ return t < 0.5
123
+ ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
124
+ : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
125
+ },
126
+ easeOutBounce: (t) => {
127
+ const n1 = 7.5625;
128
+ const d1 = 2.75;
129
+ if (t < 1 / d1)
130
+ return n1 * t * t;
131
+ if (t < 2 / d1)
132
+ return n1 * (t -= 1.5 / d1) * t + 0.75;
133
+ if (t < 2.5 / d1)
134
+ return n1 * (t -= 2.25 / d1) * t + 0.9375;
135
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
136
+ },
137
+ easeInBounce: (t) => 1 - Easing.easeOutBounce(1 - t),
138
+ easeInOutBounce: (t) => t < 0.5
139
+ ? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
140
+ : (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
141
+ easeOutElastic: (t) => {
142
+ const c4 = (2 * Math.PI) / 3;
143
+ return t === 0
144
+ ? 0
145
+ : t === 1
146
+ ? 1
147
+ : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
148
+ },
149
+ easeInElastic: (t) => {
150
+ const c4 = (2 * Math.PI) / 3;
151
+ return t === 0
152
+ ? 0
153
+ : t === 1
154
+ ? 1
155
+ : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4);
156
+ },
157
+ };
158
+
159
+ /**
160
+ * Lightweight tween system integrated with PixiJS Ticker.
161
+ * Zero external dependencies — no GSAP required.
162
+ *
163
+ * All tweens return a Promise that resolves on completion.
164
+ *
165
+ * @example
166
+ * ```ts
167
+ * // Fade in a sprite
168
+ * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
169
+ *
170
+ * // Move and wait
171
+ * await Tween.to(sprite, { x: 500 }, 300);
172
+ *
173
+ * // From a starting value
174
+ * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
175
+ * ```
176
+ */
177
+ class Tween {
178
+ static _tweens = [];
179
+ static _tickerAdded = false;
180
+ /**
181
+ * Animate properties from current values to target values.
182
+ *
183
+ * @param target - Object to animate (Sprite, Container, etc.)
184
+ * @param props - Target property values
185
+ * @param duration - Duration in milliseconds
186
+ * @param easing - Easing function (default: easeOutQuad)
187
+ * @param onUpdate - Progress callback (0..1)
188
+ */
189
+ static to(target, props, duration, easing, onUpdate) {
190
+ return new Promise((resolve) => {
191
+ // Capture starting values
192
+ const from = {};
193
+ for (const key of Object.keys(props)) {
194
+ from[key] = Tween.getProperty(target, key);
195
+ }
196
+ const tween = {
197
+ target,
198
+ from,
199
+ to: { ...props },
200
+ duration: Math.max(1, duration),
201
+ easing: easing ?? Easing.easeOutQuad,
202
+ elapsed: 0,
203
+ delay: 0,
204
+ resolve,
205
+ onUpdate,
206
+ };
207
+ Tween._tweens.push(tween);
208
+ Tween.ensureTicker();
209
+ });
210
+ }
211
+ /**
212
+ * Animate properties from given values to current values.
213
+ */
214
+ static from(target, props, duration, easing, onUpdate) {
215
+ // Capture current values as "to"
216
+ const to = {};
217
+ for (const key of Object.keys(props)) {
218
+ to[key] = Tween.getProperty(target, key);
219
+ Tween.setProperty(target, key, props[key]);
220
+ }
221
+ return Tween.to(target, to, duration, easing, onUpdate);
222
+ }
223
+ /**
224
+ * Animate from one set of values to another.
225
+ */
226
+ static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
227
+ // Set starting values
228
+ for (const key of Object.keys(fromProps)) {
229
+ Tween.setProperty(target, key, fromProps[key]);
230
+ }
231
+ return Tween.to(target, toProps, duration, easing, onUpdate);
232
+ }
233
+ /**
234
+ * Wait for a given duration (useful in timelines).
235
+ * Uses PixiJS Ticker for consistent timing with other tweens.
236
+ */
237
+ static delay(ms) {
238
+ return new Promise((resolve) => {
239
+ let elapsed = 0;
240
+ const onTick = (ticker) => {
241
+ elapsed += ticker.deltaMS;
242
+ if (elapsed >= ms) {
243
+ Ticker.shared.remove(onTick);
244
+ resolve();
245
+ }
246
+ };
247
+ Ticker.shared.add(onTick);
248
+ });
249
+ }
250
+ /**
251
+ * Kill all tweens on a target.
252
+ */
253
+ static killTweensOf(target) {
254
+ Tween._tweens = Tween._tweens.filter((tw) => {
255
+ if (tw.target === target) {
256
+ tw.resolve();
257
+ return false;
258
+ }
259
+ return true;
260
+ });
261
+ }
262
+ /**
263
+ * Kill all active tweens.
264
+ */
265
+ static killAll() {
266
+ for (const tw of Tween._tweens) {
267
+ tw.resolve();
268
+ }
269
+ Tween._tweens.length = 0;
270
+ }
271
+ /** Number of active tweens */
272
+ static get activeTweens() {
273
+ return Tween._tweens.length;
274
+ }
275
+ /**
276
+ * Reset the tween system — kill all tweens and remove the ticker.
277
+ * Useful for cleanup between game instances, tests, or hot-reload.
278
+ */
279
+ static reset() {
280
+ for (const tw of Tween._tweens) {
281
+ tw.resolve();
282
+ }
283
+ Tween._tweens.length = 0;
284
+ if (Tween._tickerAdded) {
285
+ Ticker.shared.remove(Tween.tick);
286
+ Tween._tickerAdded = false;
287
+ }
288
+ }
289
+ // ─── Internal ──────────────────────────────────────────
290
+ static ensureTicker() {
291
+ if (Tween._tickerAdded)
292
+ return;
293
+ Tween._tickerAdded = true;
294
+ Ticker.shared.add(Tween.tick);
295
+ }
296
+ static tick = (ticker) => {
297
+ const dt = ticker.deltaMS;
298
+ const completed = [];
299
+ for (const tw of Tween._tweens) {
300
+ tw.elapsed += dt;
301
+ if (tw.elapsed < tw.delay)
302
+ continue;
303
+ const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
304
+ const t = tw.easing(raw);
305
+ // Interpolate each property
306
+ for (const key of Object.keys(tw.to)) {
307
+ const start = tw.from[key];
308
+ const end = tw.to[key];
309
+ const value = start + (end - start) * t;
310
+ Tween.setProperty(tw.target, key, value);
311
+ }
312
+ tw.onUpdate?.(raw);
313
+ if (raw >= 1) {
314
+ completed.push(tw);
315
+ }
316
+ }
317
+ // Remove completed tweens
318
+ for (const tw of completed) {
319
+ const idx = Tween._tweens.indexOf(tw);
320
+ if (idx !== -1)
321
+ Tween._tweens.splice(idx, 1);
322
+ tw.resolve();
323
+ }
324
+ // Remove ticker when no active tweens
325
+ if (Tween._tweens.length === 0 && Tween._tickerAdded) {
326
+ Ticker.shared.remove(Tween.tick);
327
+ Tween._tickerAdded = false;
328
+ }
329
+ };
330
+ /**
331
+ * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
332
+ */
333
+ static getProperty(target, key) {
334
+ const parts = key.split('.');
335
+ let obj = target;
336
+ for (let i = 0; i < parts.length - 1; i++) {
337
+ obj = obj[parts[i]];
338
+ }
339
+ return obj[parts[parts.length - 1]] ?? 0;
340
+ }
341
+ /**
342
+ * Set a potentially nested property.
343
+ */
344
+ static setProperty(target, key, value) {
345
+ const parts = key.split('.');
346
+ let obj = target;
347
+ for (let i = 0; i < parts.length - 1; i++) {
348
+ obj = obj[parts[i]];
349
+ }
350
+ obj[parts[parts.length - 1]] = value;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Manages the scene stack and transitions between scenes.
356
+ *
357
+ * @example
358
+ * ```ts
359
+ * const scenes = new SceneManager(app.stage);
360
+ * scenes.register('loading', LoadingScene);
361
+ * scenes.register('game', GameScene);
362
+ * await scenes.goto('loading');
363
+ * ```
364
+ */
365
+ class SceneManager extends EventEmitter {
366
+ static MAX_TRANSITION_DEPTH = 10;
367
+ /** Root container that scenes are added to */
368
+ root;
369
+ registry = new Map();
370
+ stack = [];
371
+ _transitionDepth = 0;
372
+ /** Current viewport dimensions — set by ViewportManager */
373
+ _width = 0;
374
+ _height = 0;
375
+ /** @internal GameApplication reference — passed to scenes */
376
+ _app;
377
+ constructor(root) {
378
+ super();
379
+ if (root)
380
+ this.root = root;
381
+ }
382
+ /** @internal Set the root container (called by GameApplication after PixiJS init) */
383
+ setRoot(root) {
384
+ this.root = root;
385
+ }
386
+ /** @internal Set the app reference (called by GameApplication) */
387
+ setApp(app) {
388
+ this._app = app;
389
+ }
390
+ /** Register a scene class by key */
391
+ register(key, ctor) {
392
+ this.registry.set(key, ctor);
393
+ return this;
394
+ }
395
+ /** Get the current (topmost) scene entry */
396
+ get current() {
397
+ return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null;
398
+ }
399
+ /** Get the current scene key */
400
+ get currentKey() {
401
+ return this.current?.key ?? null;
402
+ }
403
+ /** Whether a scene transition is in progress */
404
+ get isTransitioning() {
405
+ return this._transitionDepth > 0;
406
+ }
407
+ /**
408
+ * Navigate to a scene, replacing the entire stack.
409
+ */
410
+ async goto(key, data, transition) {
411
+ if (this._transitionDepth >= SceneManager.MAX_TRANSITION_DEPTH) {
412
+ throw new Error('[SceneManager] Max transition depth exceeded — possible infinite loop');
413
+ }
414
+ const prevKey = this.currentKey;
415
+ // Exit all current scenes
416
+ while (this.stack.length > 0) {
417
+ await this.popInternal(false);
418
+ }
419
+ // Enter new scene
420
+ await this.pushInternal(key, data, transition);
421
+ this.emit('change', { from: prevKey, to: key });
422
+ }
423
+ /**
424
+ * Push a scene onto the stack (the previous scene stays underneath).
425
+ * Useful for overlays, modals, pause screens.
426
+ */
427
+ async push(key, data, transition) {
428
+ if (this._transitionDepth >= SceneManager.MAX_TRANSITION_DEPTH) {
429
+ throw new Error('[SceneManager] Max transition depth exceeded — possible infinite loop');
430
+ }
431
+ const prevKey = this.currentKey;
432
+ await this.pushInternal(key, data, transition);
433
+ this.emit('change', { from: prevKey, to: key });
434
+ }
435
+ /**
436
+ * Pop the top scene from the stack.
437
+ */
438
+ async pop(transition) {
439
+ if (this.stack.length <= 1) {
440
+ console.warn('[SceneManager] Cannot pop the last scene');
441
+ return;
442
+ }
443
+ if (this._transitionDepth >= SceneManager.MAX_TRANSITION_DEPTH) {
444
+ throw new Error('[SceneManager] Max transition depth exceeded — possible infinite loop');
445
+ }
446
+ const prevKey = this.currentKey;
447
+ await this.popInternal(true, transition);
448
+ this.emit('change', { from: prevKey, to: this.currentKey });
449
+ }
450
+ /**
451
+ * Replace the top scene with a new one.
452
+ */
453
+ async replace(key, data, transition) {
454
+ if (this._transitionDepth >= SceneManager.MAX_TRANSITION_DEPTH) {
455
+ throw new Error('[SceneManager] Max transition depth exceeded — possible infinite loop');
456
+ }
457
+ const prevKey = this.currentKey;
458
+ await this.popInternal(false);
459
+ await this.pushInternal(key, data, transition);
460
+ this.emit('change', { from: prevKey, to: key });
461
+ }
462
+ /**
463
+ * Called every frame by GameApplication.
464
+ */
465
+ update(dt) {
466
+ // Update only the top scene
467
+ this.current?.scene.onUpdate?.(dt);
468
+ }
469
+ /**
470
+ * Called on viewport resize.
471
+ */
472
+ resize(width, height) {
473
+ this._width = width;
474
+ this._height = height;
475
+ // Notify all scenes in the stack
476
+ for (const entry of this.stack) {
477
+ entry.scene.onResize?.(width, height);
478
+ }
479
+ }
480
+ /**
481
+ * Destroy all scenes and clear the manager.
482
+ */
483
+ destroy() {
484
+ for (const entry of this.stack) {
485
+ entry.scene.onDestroy?.();
486
+ entry.scene.container.destroy({ children: true });
487
+ }
488
+ this.stack.length = 0;
489
+ this.registry.clear();
490
+ this.removeAllListeners();
491
+ }
492
+ // ─── Internal ──────────────────────────────────────────
493
+ createScene(key) {
494
+ const Ctor = this.registry.get(key);
495
+ if (!Ctor) {
496
+ throw new Error(`[SceneManager] Scene "${key}" is not registered`);
497
+ }
498
+ const scene = new Ctor();
499
+ if (this._app) {
500
+ scene.__engineApp = this._app;
501
+ }
502
+ return scene;
503
+ }
504
+ async pushInternal(key, data, transition) {
505
+ this._transitionDepth++;
506
+ const scene = this.createScene(key);
507
+ this.root.addChild(scene.container);
508
+ // Set initial size
509
+ if (this._width && this._height) {
510
+ scene.onResize?.(this._width, this._height);
511
+ }
512
+ // Transition in
513
+ await this.transitionIn(scene.container, transition);
514
+ // Push to stack BEFORE onEnter so currentKey is correct during initialization
515
+ this.stack.push({ scene, key });
516
+ await scene.onEnter?.(data);
517
+ this._transitionDepth--;
518
+ }
519
+ async popInternal(showTransition, transition) {
520
+ const entry = this.stack.pop();
521
+ if (!entry)
522
+ return;
523
+ this._transitionDepth++;
524
+ await entry.scene.onExit?.();
525
+ if (showTransition) {
526
+ await this.transitionOut(entry.scene.container, transition);
527
+ }
528
+ entry.scene.onDestroy?.();
529
+ entry.scene.container.destroy({ children: true });
530
+ this._transitionDepth--;
531
+ }
532
+ async transitionIn(container, config) {
533
+ const type = config?.type ?? TransitionType.NONE;
534
+ const duration = config?.duration ?? 300;
535
+ if (type === TransitionType.NONE || duration <= 0)
536
+ return;
537
+ if (type === TransitionType.FADE) {
538
+ container.alpha = 0;
539
+ await Tween.to(container, { alpha: 1 }, duration, config?.easing);
540
+ }
541
+ else if (type === TransitionType.SLIDE_LEFT) {
542
+ container.x = this._width;
543
+ await Tween.to(container, { x: 0 }, duration, config?.easing);
544
+ }
545
+ else if (type === TransitionType.SLIDE_RIGHT) {
546
+ container.x = -this._width;
547
+ await Tween.to(container, { x: 0 }, duration, config?.easing);
548
+ }
549
+ }
550
+ async transitionOut(container, config) {
551
+ const type = config?.type ?? TransitionType.FADE;
552
+ const duration = config?.duration ?? 300;
553
+ if (type === TransitionType.NONE || duration <= 0)
554
+ return;
555
+ if (type === TransitionType.FADE) {
556
+ await Tween.to(container, { alpha: 0 }, duration, config?.easing);
557
+ }
558
+ else if (type === TransitionType.SLIDE_LEFT) {
559
+ await Tween.to(container, { x: -this._width }, duration, config?.easing);
560
+ }
561
+ else if (type === TransitionType.SLIDE_RIGHT) {
562
+ await Tween.to(container, { x: this._width }, duration, config?.easing);
563
+ }
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Manages game asset loading with progress tracking, bundle support, and
569
+ * automatic base path resolution from SDK's assetsUrl.
570
+ *
571
+ * Wraps PixiJS Assets API with a typed, game-oriented interface.
572
+ *
573
+ * @example
574
+ * ```ts
575
+ * const assets = new AssetManager('https://cdn.example.com/game/', manifest);
576
+ * await assets.init();
577
+ * await assets.loadBundle('preload', (p) => console.log(p));
578
+ * const texture = assets.get<Texture>('hero');
579
+ * ```
580
+ */
581
+ class AssetManager {
582
+ _initialized = false;
583
+ _basePath;
584
+ _manifest;
585
+ _loadedBundles = new Set();
586
+ constructor(basePath = '', manifest) {
587
+ this._basePath = basePath;
588
+ this._manifest = manifest ?? null;
589
+ }
590
+ /** Whether the asset system has been initialized */
591
+ get initialized() {
592
+ return this._initialized;
593
+ }
594
+ /** Base path for all assets (usually from SDK's assetsUrl) */
595
+ get basePath() {
596
+ return this._basePath;
597
+ }
598
+ /** Set of loaded bundle names */
599
+ get loadedBundles() {
600
+ return this._loadedBundles;
601
+ }
602
+ /**
603
+ * Initialize the asset system.
604
+ * Must be called before loading any assets.
605
+ */
606
+ async init() {
607
+ if (this._initialized)
608
+ return;
609
+ await Assets.init({
610
+ basePath: this._basePath || undefined,
611
+ texturePreference: {
612
+ resolution: Math.min(window.devicePixelRatio, 2),
613
+ format: ['webp', 'png'],
614
+ },
615
+ });
616
+ // Register bundles from manifest
617
+ if (this._manifest) {
618
+ for (const bundle of this._manifest.bundles) {
619
+ Assets.addBundle(bundle.name, bundle.assets.map((a) => ({
620
+ alias: a.alias,
621
+ src: a.src,
622
+ data: a.data,
623
+ })));
624
+ }
625
+ }
626
+ this._initialized = true;
627
+ }
628
+ /**
629
+ * Load a single bundle by name.
630
+ *
631
+ * @param name - Bundle name (must exist in the manifest)
632
+ * @param onProgress - Progress callback (0..1)
633
+ * @returns Loaded assets map
634
+ */
635
+ async loadBundle(name, onProgress) {
636
+ this.ensureInitialized();
637
+ const result = await Assets.loadBundle(name, onProgress);
638
+ this._loadedBundles.add(name);
639
+ return result;
640
+ }
641
+ /**
642
+ * Load multiple bundles simultaneously.
643
+ * Progress is aggregated across all bundles.
644
+ *
645
+ * @param names - Bundle names
646
+ * @param onProgress - Progress callback (0..1)
647
+ */
648
+ async loadBundles(names, onProgress) {
649
+ this.ensureInitialized();
650
+ const result = await Assets.loadBundle(names, onProgress);
651
+ for (const name of names) {
652
+ this._loadedBundles.add(name);
653
+ }
654
+ return result;
655
+ }
656
+ /**
657
+ * Load individual assets by URL or alias.
658
+ *
659
+ * @param urls - Asset URLs or aliases
660
+ * @param onProgress - Progress callback (0..1)
661
+ */
662
+ async load(urls, onProgress) {
663
+ this.ensureInitialized();
664
+ return Assets.load(urls, onProgress);
665
+ }
666
+ /**
667
+ * Get a loaded asset synchronously from cache.
668
+ *
669
+ * @param alias - Asset alias
670
+ * @throws if not loaded
671
+ */
672
+ get(alias) {
673
+ return Assets.get(alias);
674
+ }
675
+ /**
676
+ * Unload a bundle to free memory.
677
+ */
678
+ async unloadBundle(name) {
679
+ await Assets.unloadBundle(name);
680
+ this._loadedBundles.delete(name);
681
+ }
682
+ /**
683
+ * Start background loading a bundle (low-priority preload).
684
+ * Useful for loading bonus round assets while player is in base game.
685
+ */
686
+ async backgroundLoad(name) {
687
+ this.ensureInitialized();
688
+ await Assets.backgroundLoadBundle(name);
689
+ }
690
+ /**
691
+ * Get all bundle names from the manifest.
692
+ */
693
+ getBundleNames() {
694
+ return this._manifest?.bundles.map((b) => b.name) ?? [];
695
+ }
696
+ /**
697
+ * Check if a bundle is loaded.
698
+ */
699
+ isBundleLoaded(name) {
700
+ return this._loadedBundles.has(name);
701
+ }
702
+ ensureInitialized() {
703
+ if (!this._initialized) {
704
+ throw new Error('[AssetManager] Not initialized. Call init() first.');
705
+ }
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Manages all game audio: music, SFX, UI sounds, ambient.
711
+ *
712
+ * Optional dependency on @pixi/sound — if not installed, AudioManager
713
+ * operates as a silent no-op (graceful degradation).
714
+ *
715
+ * Features:
716
+ * - Per-category volume control (music, sfx, ui, ambient)
717
+ * - Music crossfade and looping
718
+ * - Mobile audio unlock on first interaction
719
+ * - Mute state persistence in localStorage
720
+ * - Global mute/unmute
721
+ *
722
+ * @example
723
+ * ```ts
724
+ * const audio = new AudioManager({ music: 0.5, sfx: 0.8 });
725
+ * await audio.init();
726
+ * audio.playMusic('bg-music');
727
+ * audio.play('spin-click', 'sfx');
728
+ * ```
729
+ */
730
+ class AudioManager {
731
+ _soundModule = null;
732
+ _initialized = false;
733
+ _globalMuted = false;
734
+ _persist;
735
+ _storageKey;
736
+ _categories;
737
+ _masterGain = 1.0;
738
+ _currentMusic = null;
739
+ _unlocked = false;
740
+ _unlockHandler = null;
741
+ constructor(config) {
742
+ this._persist = config?.persist ?? true;
743
+ this._storageKey = config?.storageKey ?? 'ge_audio';
744
+ this._categories = {
745
+ music: { volume: config?.music ?? 0.7, muted: false },
746
+ sfx: { volume: config?.sfx ?? 1.0, muted: false },
747
+ ui: { volume: config?.ui ?? 0.8, muted: false },
748
+ ambient: { volume: config?.ambient ?? 0.5, muted: false },
749
+ };
750
+ // Restore persisted state
751
+ if (this._persist) {
752
+ this.restoreState();
753
+ }
754
+ }
755
+ /** Whether the audio system is initialized */
756
+ get initialized() {
757
+ return this._initialized;
758
+ }
759
+ /** Whether audio is globally muted */
760
+ get muted() {
761
+ return this._globalMuted;
762
+ }
763
+ /**
764
+ * Initialize the audio system.
765
+ * Dynamically imports @pixi/sound to keep it optional.
766
+ */
767
+ async init() {
768
+ if (this._initialized)
769
+ return;
770
+ try {
771
+ this._soundModule = await import('@pixi/sound');
772
+ this._initialized = true;
773
+ this.applyVolumes();
774
+ if (this._globalMuted) {
775
+ this._soundModule.sound.muteAll();
776
+ }
777
+ this.setupMobileUnlock();
778
+ }
779
+ catch {
780
+ console.warn('[AudioManager] @pixi/sound not available. Audio disabled.');
781
+ this._initialized = false;
782
+ }
783
+ }
784
+ /**
785
+ * Play a sound effect.
786
+ *
787
+ * @param alias - Sound alias (must be loaded via AssetManager)
788
+ * @param category - Audio category (default: 'sfx')
789
+ * @param options - Additional play options
790
+ */
791
+ play(alias, category = 'sfx', options) {
792
+ if (!this._initialized || !this._soundModule)
793
+ return;
794
+ if (this._globalMuted || this._categories[category].muted)
795
+ return;
796
+ const { sound } = this._soundModule;
797
+ const vol = (options?.volume ?? 1) * this._categories[category].volume * this._masterGain;
798
+ try {
799
+ sound.play(alias, {
800
+ volume: vol,
801
+ loop: options?.loop ?? false,
802
+ speed: options?.speed ?? 1,
803
+ });
804
+ }
805
+ catch (e) {
806
+ console.warn(`[AudioManager] Failed to play "${alias}":`, e);
807
+ }
808
+ }
809
+ /**
810
+ * Play background music with optional crossfade.
811
+ *
812
+ * @param alias - Music alias
813
+ * @param fadeDuration - Crossfade duration in ms (default: 500)
814
+ */
815
+ playMusic(alias, fadeDuration = 500) {
816
+ if (!this._initialized || !this._soundModule)
817
+ return;
818
+ const { sound } = this._soundModule;
819
+ // Stop current music with fade-out, start new music with fade-in
820
+ if (this._currentMusic && fadeDuration > 0) {
821
+ const prevAlias = this._currentMusic;
822
+ this._currentMusic = alias;
823
+ if (this._globalMuted || this._categories.music.muted)
824
+ return;
825
+ // Fade out the previous track
826
+ this.fadeVolume(prevAlias, this._categories.music.volume * this._masterGain, 0, fadeDuration, () => {
827
+ try {
828
+ sound.stop(prevAlias);
829
+ }
830
+ catch { /* ignore */ }
831
+ });
832
+ // Start new track at zero volume, fade in
833
+ try {
834
+ sound.play(alias, {
835
+ volume: 0,
836
+ loop: true,
837
+ });
838
+ this.fadeVolume(alias, 0, this._categories.music.volume * this._masterGain, fadeDuration);
839
+ }
840
+ catch (e) {
841
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
842
+ }
843
+ }
844
+ else {
845
+ // No crossfade — instant switch
846
+ if (this._currentMusic) {
847
+ try {
848
+ sound.stop(this._currentMusic);
849
+ }
850
+ catch { /* ignore */ }
851
+ }
852
+ this._currentMusic = alias;
853
+ if (this._globalMuted || this._categories.music.muted)
854
+ return;
855
+ try {
856
+ sound.play(alias, {
857
+ volume: this._categories.music.volume * this._masterGain,
858
+ loop: true,
859
+ });
860
+ }
861
+ catch (e) {
862
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
863
+ }
864
+ }
865
+ }
866
+ /**
867
+ * Stop current music.
868
+ */
869
+ stopMusic() {
870
+ if (!this._initialized || !this._soundModule || !this._currentMusic)
871
+ return;
872
+ const { sound } = this._soundModule;
873
+ try {
874
+ sound.stop(this._currentMusic);
875
+ }
876
+ catch {
877
+ // ignore
878
+ }
879
+ this._currentMusic = null;
880
+ }
881
+ /**
882
+ * Stop all sounds.
883
+ */
884
+ stopAll() {
885
+ if (!this._initialized || !this._soundModule)
886
+ return;
887
+ const { sound } = this._soundModule;
888
+ sound.stopAll();
889
+ this._currentMusic = null;
890
+ }
891
+ /** Global gain (0..1) folded into every category's effective volume. Driven by the shell's
892
+ * 'master' settingChange. Does not affect the persisted per-category volumes. */
893
+ setMasterVolume(volume) {
894
+ this._masterGain = Math.max(0, Math.min(1, volume));
895
+ this.applyVolumes();
896
+ }
897
+ getMasterVolume() {
898
+ return this._masterGain;
899
+ }
900
+ /**
901
+ * Set volume for a category.
902
+ */
903
+ setVolume(category, volume) {
904
+ this._categories[category].volume = Math.max(0, Math.min(1, volume));
905
+ this.applyVolumes();
906
+ this.saveState();
907
+ }
908
+ /**
909
+ * Get volume for a category.
910
+ */
911
+ getVolume(category) {
912
+ return this._categories[category].volume;
913
+ }
914
+ /**
915
+ * Mute a specific category.
916
+ */
917
+ muteCategory(category) {
918
+ this._categories[category].muted = true;
919
+ this.applyVolumes();
920
+ this.saveState();
921
+ }
922
+ /**
923
+ * Unmute a specific category.
924
+ */
925
+ unmuteCategory(category) {
926
+ this._categories[category].muted = false;
927
+ this.applyVolumes();
928
+ this.saveState();
929
+ }
930
+ /**
931
+ * Toggle mute for a category.
932
+ */
933
+ toggleCategory(category) {
934
+ this._categories[category].muted = !this._categories[category].muted;
935
+ this.applyVolumes();
936
+ this.saveState();
937
+ return this._categories[category].muted;
938
+ }
939
+ /**
940
+ * Mute all audio globally.
941
+ */
942
+ muteAll() {
943
+ this._globalMuted = true;
944
+ if (this._soundModule) {
945
+ this._soundModule.sound.muteAll();
946
+ }
947
+ this.saveState();
948
+ }
949
+ /**
950
+ * Unmute all audio globally.
951
+ */
952
+ unmuteAll() {
953
+ this._globalMuted = false;
954
+ if (this._soundModule) {
955
+ this._soundModule.sound.unmuteAll();
956
+ }
957
+ this.saveState();
958
+ }
959
+ /**
960
+ * Toggle global mute.
961
+ */
962
+ toggleMute() {
963
+ if (this._globalMuted) {
964
+ this.unmuteAll();
965
+ }
966
+ else {
967
+ this.muteAll();
968
+ }
969
+ return this._globalMuted;
970
+ }
971
+ /**
972
+ * Duck music volume (e.g., during big win presentation).
973
+ *
974
+ * @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal
975
+ */
976
+ duckMusic(factor) {
977
+ if (!this._initialized || !this._soundModule || !this._currentMusic)
978
+ return;
979
+ const { sound } = this._soundModule;
980
+ const vol = this._categories.music.volume * factor;
981
+ try {
982
+ sound.volume(this._currentMusic, vol);
983
+ }
984
+ catch {
985
+ // ignore
986
+ }
987
+ }
988
+ /**
989
+ * Restore music to normal volume after ducking.
990
+ */
991
+ unduckMusic() {
992
+ if (!this._initialized || !this._soundModule || !this._currentMusic)
993
+ return;
994
+ const { sound } = this._soundModule;
995
+ try {
996
+ sound.volume(this._currentMusic, this._categories.music.volume);
997
+ }
998
+ catch {
999
+ // ignore
1000
+ }
1001
+ }
1002
+ /**
1003
+ * Destroy the audio manager and free resources.
1004
+ */
1005
+ destroy() {
1006
+ this.stopAll();
1007
+ this.removeMobileUnlock();
1008
+ if (this._soundModule) {
1009
+ this._soundModule.sound.removeAll();
1010
+ }
1011
+ this._initialized = false;
1012
+ }
1013
+ // ─── Private ───────────────────────────────────────────
1014
+ /**
1015
+ * Smoothly fade a sound's volume from `fromVol` to `toVol` over `durationMs`.
1016
+ */
1017
+ fadeVolume(alias, fromVol, toVol, durationMs, onComplete) {
1018
+ if (!this._soundModule)
1019
+ return;
1020
+ const { sound } = this._soundModule;
1021
+ const startTime = Date.now();
1022
+ const tick = () => {
1023
+ const elapsed = Date.now() - startTime;
1024
+ const t = Math.min(elapsed / durationMs, 1);
1025
+ const vol = fromVol + (toVol - fromVol) * t;
1026
+ try {
1027
+ sound.volume(alias, vol);
1028
+ }
1029
+ catch { /* ignore */ }
1030
+ if (t < 1) {
1031
+ requestAnimationFrame(tick);
1032
+ }
1033
+ else {
1034
+ onComplete?.();
1035
+ }
1036
+ };
1037
+ requestAnimationFrame(tick);
1038
+ }
1039
+ applyVolumes() {
1040
+ if (!this._soundModule)
1041
+ return;
1042
+ const { sound } = this._soundModule;
1043
+ // Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
1044
+ // not by volumeAll — mixing both leaves mute un-undoable after reload.
1045
+ sound.volumeAll = this._masterGain; // master multiplies the global bus
1046
+ }
1047
+ setupMobileUnlock() {
1048
+ if (this._unlocked)
1049
+ return;
1050
+ this._unlockHandler = () => {
1051
+ if (!this._soundModule)
1052
+ return;
1053
+ const { sound } = this._soundModule;
1054
+ // Resume WebAudio context
1055
+ if (sound.context?.audioContext?.state === 'suspended') {
1056
+ sound.context.audioContext.resume();
1057
+ }
1058
+ this._unlocked = true;
1059
+ this.removeMobileUnlock();
1060
+ };
1061
+ const events = ['touchstart', 'mousedown', 'pointerdown', 'keydown'];
1062
+ for (const event of events) {
1063
+ document.addEventListener(event, this._unlockHandler, { once: true });
1064
+ }
1065
+ }
1066
+ removeMobileUnlock() {
1067
+ if (!this._unlockHandler)
1068
+ return;
1069
+ const events = ['touchstart', 'mousedown', 'pointerdown', 'keydown'];
1070
+ for (const event of events) {
1071
+ document.removeEventListener(event, this._unlockHandler);
1072
+ }
1073
+ this._unlockHandler = null;
1074
+ }
1075
+ saveState() {
1076
+ if (!this._persist)
1077
+ return;
1078
+ try {
1079
+ const state = {
1080
+ globalMuted: this._globalMuted,
1081
+ categories: this._categories,
1082
+ };
1083
+ localStorage.setItem(this._storageKey, JSON.stringify(state));
1084
+ }
1085
+ catch {
1086
+ // localStorage may not be available
1087
+ }
1088
+ }
1089
+ restoreState() {
1090
+ try {
1091
+ const raw = localStorage.getItem(this._storageKey);
1092
+ if (!raw)
1093
+ return;
1094
+ const state = JSON.parse(raw);
1095
+ if (typeof state.globalMuted === 'boolean') {
1096
+ this._globalMuted = state.globalMuted;
1097
+ }
1098
+ if (state.categories) {
1099
+ for (const key of ['music', 'sfx', 'ui', 'ambient']) {
1100
+ if (state.categories[key]) {
1101
+ this._categories[key] = {
1102
+ volume: state.categories[key].volume ?? this._categories[key].volume,
1103
+ muted: state.categories[key].muted ?? false,
1104
+ };
1105
+ }
1106
+ }
1107
+ }
1108
+ }
1109
+ catch {
1110
+ // ignore
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ /**
1116
+ * Unified input manager for touch, mouse, and keyboard.
1117
+ *
1118
+ * Features:
1119
+ * - Unified pointer events (works with touch + mouse)
1120
+ * - Swipe gesture detection
1121
+ * - Keyboard input with isKeyDown state
1122
+ * - Input locking (block input during animations)
1123
+ *
1124
+ * @example
1125
+ * ```ts
1126
+ * const input = new InputManager(app.canvas);
1127
+ *
1128
+ * input.on('tap', ({ x, y }) => console.log('Tapped at', x, y));
1129
+ * input.on('swipe', ({ direction }) => console.log('Swiped', direction));
1130
+ * input.on('keydown', ({ key }) => {
1131
+ * if (key === ' ') spin();
1132
+ * });
1133
+ *
1134
+ * // Block input during animations
1135
+ * input.lock();
1136
+ * await playAnimation();
1137
+ * input.unlock();
1138
+ * ```
1139
+ */
1140
+ class InputManager extends EventEmitter {
1141
+ _canvas;
1142
+ _locked = false;
1143
+ _keysDown = new Set();
1144
+ _destroyed = false;
1145
+ // Viewport transform (set by ViewportManager via setViewportTransform)
1146
+ _viewportScale = 1;
1147
+ _viewportOffsetX = 0;
1148
+ _viewportOffsetY = 0;
1149
+ // Gesture tracking
1150
+ _pointerStart = null;
1151
+ _swipeThreshold = 50; // minimum distance in px
1152
+ _swipeMaxTime = 300; // max ms for swipe gesture
1153
+ constructor(canvas) {
1154
+ super();
1155
+ this._canvas = canvas;
1156
+ this.setupPointerEvents();
1157
+ this.setupKeyboardEvents();
1158
+ }
1159
+ /** Whether input is currently locked */
1160
+ get locked() {
1161
+ return this._locked;
1162
+ }
1163
+ /** Lock all input (e.g., during animations) */
1164
+ lock() {
1165
+ this._locked = true;
1166
+ }
1167
+ /** Unlock input */
1168
+ unlock() {
1169
+ this._locked = false;
1170
+ }
1171
+ /** Check if a key is currently pressed */
1172
+ isKeyDown(key) {
1173
+ return this._keysDown.has(key.toLowerCase());
1174
+ }
1175
+ /**
1176
+ * Update the viewport transform used for DOM→world coordinate mapping.
1177
+ * Called automatically by GameApplication when ViewportManager emits resize.
1178
+ */
1179
+ setViewportTransform(scale, offsetX, offsetY) {
1180
+ this._viewportScale = scale;
1181
+ this._viewportOffsetX = offsetX;
1182
+ this._viewportOffsetY = offsetY;
1183
+ }
1184
+ /**
1185
+ * Convert a DOM canvas position to game-world coordinates,
1186
+ * accounting for viewport scaling and offset.
1187
+ */
1188
+ getWorldPosition(canvasX, canvasY) {
1189
+ return {
1190
+ x: (canvasX - this._viewportOffsetX) / this._viewportScale,
1191
+ y: (canvasY - this._viewportOffsetY) / this._viewportScale,
1192
+ };
1193
+ }
1194
+ /** Destroy the input manager */
1195
+ destroy() {
1196
+ this._destroyed = true;
1197
+ this._canvas.removeEventListener('pointerdown', this.onPointerDown);
1198
+ this._canvas.removeEventListener('pointerup', this.onPointerUp);
1199
+ this._canvas.removeEventListener('pointermove', this.onPointerMove);
1200
+ document.removeEventListener('keydown', this.onKeyDown);
1201
+ document.removeEventListener('keyup', this.onKeyUp);
1202
+ this._keysDown.clear();
1203
+ this.removeAllListeners();
1204
+ }
1205
+ // ─── Private: Pointer ──────────────────────────────────
1206
+ setupPointerEvents() {
1207
+ this._canvas.addEventListener('pointerdown', this.onPointerDown);
1208
+ this._canvas.addEventListener('pointerup', this.onPointerUp);
1209
+ this._canvas.addEventListener('pointermove', this.onPointerMove);
1210
+ }
1211
+ onPointerDown = (e) => {
1212
+ if (this._locked || this._destroyed)
1213
+ return;
1214
+ const pos = this.getCanvasPosition(e);
1215
+ this._pointerStart = { ...pos, time: Date.now() };
1216
+ this.emit('press', pos);
1217
+ };
1218
+ onPointerUp = (e) => {
1219
+ if (this._locked || this._destroyed)
1220
+ return;
1221
+ const pos = this.getCanvasPosition(e);
1222
+ this.emit('release', pos);
1223
+ // Check for tap vs swipe
1224
+ if (this._pointerStart) {
1225
+ const dx = pos.x - this._pointerStart.x;
1226
+ const dy = pos.y - this._pointerStart.y;
1227
+ const dist = Math.sqrt(dx * dx + dy * dy);
1228
+ const elapsed = Date.now() - this._pointerStart.time;
1229
+ if (dist > this._swipeThreshold && elapsed < this._swipeMaxTime) {
1230
+ // Swipe detected
1231
+ const absDx = Math.abs(dx);
1232
+ const absDy = Math.abs(dy);
1233
+ let direction;
1234
+ if (absDx > absDy) {
1235
+ direction = dx > 0 ? 'right' : 'left';
1236
+ }
1237
+ else {
1238
+ direction = dy > 0 ? 'down' : 'up';
1239
+ }
1240
+ this.emit('swipe', { direction, velocity: dist / elapsed });
1241
+ }
1242
+ else if (dist < 10) {
1243
+ // Tap (minimal movement)
1244
+ this.emit('tap', pos);
1245
+ }
1246
+ }
1247
+ this._pointerStart = null;
1248
+ };
1249
+ onPointerMove = (e) => {
1250
+ if (this._locked || this._destroyed)
1251
+ return;
1252
+ this.emit('move', this.getCanvasPosition(e));
1253
+ };
1254
+ getCanvasPosition(e) {
1255
+ const rect = this._canvas.getBoundingClientRect();
1256
+ return {
1257
+ x: e.clientX - rect.left,
1258
+ y: e.clientY - rect.top,
1259
+ };
1260
+ }
1261
+ // ─── Private: Keyboard ─────────────────────────────────
1262
+ setupKeyboardEvents() {
1263
+ document.addEventListener('keydown', this.onKeyDown);
1264
+ document.addEventListener('keyup', this.onKeyUp);
1265
+ }
1266
+ onKeyDown = (e) => {
1267
+ if (this._locked || this._destroyed)
1268
+ return;
1269
+ this._keysDown.add(e.key.toLowerCase());
1270
+ this.emit('keydown', { key: e.key, code: e.code });
1271
+ };
1272
+ onKeyUp = (e) => {
1273
+ if (this._destroyed)
1274
+ return;
1275
+ this._keysDown.delete(e.key.toLowerCase());
1276
+ if (this._locked)
1277
+ return;
1278
+ this.emit('keyup', { key: e.key, code: e.code });
1279
+ };
1280
+ }
1281
+
1282
+ /**
1283
+ * Manages responsive scaling of the game canvas to fit its container.
1284
+ *
1285
+ * Supports three scale modes:
1286
+ * - **FIT** — letterbox/pillarbox to maintain aspect ratio (industry standard)
1287
+ * - **FILL** — fill container, crop edges
1288
+ * - **STRETCH** — stretch to fill (distorts)
1289
+ *
1290
+ * Also handles:
1291
+ * - Orientation detection (landscape/portrait)
1292
+ * - Safe areas (mobile notch)
1293
+ * - ResizeObserver for smooth container resizing
1294
+ *
1295
+ * @example
1296
+ * ```ts
1297
+ * const viewport = new ViewportManager(app, container, {
1298
+ * designWidth: 1920,
1299
+ * designHeight: 1080,
1300
+ * scaleMode: ScaleMode.FIT,
1301
+ * orientation: Orientation.LANDSCAPE,
1302
+ * });
1303
+ *
1304
+ * viewport.on('resize', ({ width, height, scale }) => {
1305
+ * console.log(`New size: ${width}x${height} @ ${scale}x`);
1306
+ * });
1307
+ * ```
1308
+ */
1309
+ class ViewportManager extends EventEmitter {
1310
+ _app;
1311
+ _container;
1312
+ _config;
1313
+ _target;
1314
+ _resizeObserver = null;
1315
+ _currentOrientation = Orientation.LANDSCAPE;
1316
+ _currentWidth = 0;
1317
+ _currentHeight = 0;
1318
+ _currentScale = 1;
1319
+ _destroyed = false;
1320
+ _resizeTimeout = null;
1321
+ constructor(app, container, config, target) {
1322
+ super();
1323
+ this._app = app;
1324
+ this._container = container;
1325
+ this._config = config;
1326
+ // The container this manager scales/offsets. Defaults to app.stage for backward
1327
+ // compatibility; the engine passes a dedicated scaled world root so app.stage stays
1328
+ // identity (screen space) for unscaled UI layers.
1329
+ this._target = target ?? app.stage;
1330
+ this.setupObserver();
1331
+ }
1332
+ /** Current canvas width in game units */
1333
+ get width() {
1334
+ return this._currentWidth;
1335
+ }
1336
+ /** Current canvas height in game units */
1337
+ get height() {
1338
+ return this._currentHeight;
1339
+ }
1340
+ /** Current scale factor */
1341
+ get scale() {
1342
+ return this._currentScale;
1343
+ }
1344
+ /** Current orientation */
1345
+ get orientation() {
1346
+ return this._currentOrientation;
1347
+ }
1348
+ /** Design reference width */
1349
+ get designWidth() {
1350
+ return this._config.designWidth;
1351
+ }
1352
+ /** Design reference height */
1353
+ get designHeight() {
1354
+ return this._config.designHeight;
1355
+ }
1356
+ /**
1357
+ * Force a resize calculation. Called automatically on container size change.
1358
+ */
1359
+ refresh() {
1360
+ if (this._destroyed)
1361
+ return;
1362
+ const containerWidth = this._container.clientWidth || window.innerWidth;
1363
+ const containerHeight = this._container.clientHeight || window.innerHeight;
1364
+ if (containerWidth === 0 || containerHeight === 0)
1365
+ return;
1366
+ const { designWidth, designHeight, scaleMode } = this._config;
1367
+ const designRatio = designWidth / designHeight;
1368
+ const containerRatio = containerWidth / containerHeight;
1369
+ let gameWidth;
1370
+ let gameHeight;
1371
+ let scale;
1372
+ switch (scaleMode) {
1373
+ case ScaleMode.FIT: {
1374
+ if (containerRatio > designRatio) {
1375
+ // Container is wider → pillarbox
1376
+ scale = containerHeight / designHeight;
1377
+ gameWidth = designWidth;
1378
+ gameHeight = designHeight;
1379
+ }
1380
+ else {
1381
+ // Container is taller → letterbox
1382
+ scale = containerWidth / designWidth;
1383
+ gameWidth = designWidth;
1384
+ gameHeight = designHeight;
1385
+ }
1386
+ break;
1387
+ }
1388
+ case ScaleMode.FILL: {
1389
+ if (containerRatio > designRatio) {
1390
+ // Container is wider → crop top/bottom
1391
+ scale = containerWidth / designWidth;
1392
+ }
1393
+ else {
1394
+ // Container is taller → crop left/right
1395
+ scale = containerHeight / designHeight;
1396
+ }
1397
+ gameWidth = containerWidth / scale;
1398
+ gameHeight = containerHeight / scale;
1399
+ break;
1400
+ }
1401
+ case ScaleMode.STRETCH: {
1402
+ gameWidth = designWidth;
1403
+ gameHeight = designHeight;
1404
+ scale = 1; // stretch is handled by CSS
1405
+ break;
1406
+ }
1407
+ default:
1408
+ gameWidth = designWidth;
1409
+ gameHeight = designHeight;
1410
+ scale = 1;
1411
+ }
1412
+ // Resize the renderer
1413
+ this._app.renderer.resize(Math.round(containerWidth), Math.round(containerHeight));
1414
+ // Scale the stage
1415
+ const stageScale = scaleMode === ScaleMode.STRETCH
1416
+ ? Math.min(containerWidth / designWidth, containerHeight / designHeight)
1417
+ : scale;
1418
+ this._target.scale.set(stageScale);
1419
+ // Center the stage for FIT mode
1420
+ if (scaleMode === ScaleMode.FIT) {
1421
+ this._target.x = Math.round((containerWidth - designWidth * stageScale) / 2);
1422
+ this._target.y = Math.round((containerHeight - designHeight * stageScale) / 2);
1423
+ }
1424
+ else if (scaleMode === ScaleMode.FILL) {
1425
+ this._target.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
1426
+ this._target.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
1427
+ }
1428
+ else {
1429
+ this._target.x = 0;
1430
+ this._target.y = 0;
1431
+ }
1432
+ this._currentWidth = gameWidth;
1433
+ this._currentHeight = gameHeight;
1434
+ this._currentScale = stageScale;
1435
+ // Check orientation
1436
+ const newOrientation = containerWidth >= containerHeight ? Orientation.LANDSCAPE : Orientation.PORTRAIT;
1437
+ if (newOrientation !== this._currentOrientation) {
1438
+ this._currentOrientation = newOrientation;
1439
+ this.emit('orientationChange', newOrientation);
1440
+ }
1441
+ this.emit('resize', {
1442
+ width: gameWidth,
1443
+ height: gameHeight,
1444
+ scale: stageScale,
1445
+ });
1446
+ }
1447
+ /**
1448
+ * Destroy the viewport manager.
1449
+ */
1450
+ destroy() {
1451
+ this._destroyed = true;
1452
+ this._resizeObserver?.disconnect();
1453
+ this._resizeObserver = null;
1454
+ // Remove fallback window resize listener if it was used
1455
+ window.removeEventListener('resize', this.onWindowResize);
1456
+ if (this._resizeTimeout !== null) {
1457
+ clearTimeout(this._resizeTimeout);
1458
+ }
1459
+ this.removeAllListeners();
1460
+ }
1461
+ // ─── Private ───────────────────────────────────────────
1462
+ setupObserver() {
1463
+ if (typeof ResizeObserver !== 'undefined') {
1464
+ this._resizeObserver = new ResizeObserver(() => {
1465
+ this.debouncedRefresh();
1466
+ });
1467
+ this._resizeObserver.observe(this._container);
1468
+ }
1469
+ else {
1470
+ // Fallback for older browsers
1471
+ window.addEventListener('resize', this.onWindowResize);
1472
+ }
1473
+ }
1474
+ onWindowResize = () => {
1475
+ this.debouncedRefresh();
1476
+ };
1477
+ debouncedRefresh() {
1478
+ if (this._resizeTimeout !== null) {
1479
+ clearTimeout(this._resizeTimeout);
1480
+ }
1481
+ this._resizeTimeout = window.setTimeout(() => {
1482
+ this.refresh();
1483
+ this._resizeTimeout = null;
1484
+ }, 16); // ~1 frame
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * Base class for all scenes.
1490
+ * Provides a root PixiJS Container and lifecycle hooks.
1491
+ *
1492
+ * @example
1493
+ * ```ts
1494
+ * class MenuScene extends Scene {
1495
+ * async onEnter() {
1496
+ * const bg = Sprite.from('menu-bg');
1497
+ * this.container.addChild(bg);
1498
+ * }
1499
+ *
1500
+ * onUpdate(dt: number) {
1501
+ * // per-frame logic
1502
+ * }
1503
+ *
1504
+ * onResize(width: number, height: number) {
1505
+ * // reposition UI
1506
+ * }
1507
+ * }
1508
+ * ```
1509
+ */
1510
+ class Scene {
1511
+ container;
1512
+ constructor() {
1513
+ this.container = new Container();
1514
+ this.container.label = this.constructor.name;
1515
+ }
1516
+ }
1517
+
1518
+ /**
1519
+ * Build the loading scene variant of the logo SVG.
1520
+ * Uses unique IDs (prefixed with 'ls') to avoid collisions with CSSPreloader.
1521
+ */
1522
+ function buildLoadingLogoSVG() {
1523
+ return buildLogoSVG({
1524
+ idPrefix: 'ls',
1525
+ svgStyle: 'width:100%;height:auto;',
1526
+ clipRectId: 'ge-loader-rect',
1527
+ textId: 'ge-loader-pct',
1528
+ textContent: '0%',
1529
+ });
1530
+ }
1531
+ /**
1532
+ * Built-in loading screen using the Energy8 SVG logo with animated loader bar.
1533
+ *
1534
+ * Renders as an HTML overlay on top of the canvas for crisp SVG quality.
1535
+ * The loader bar fill width is driven by asset loading progress.
1536
+ */
1537
+ class LoadingScene extends Scene {
1538
+ _engine;
1539
+ _targetScene;
1540
+ _targetData;
1541
+ _config;
1542
+ // HTML overlay
1543
+ _overlay = null;
1544
+ _loaderRect = null;
1545
+ _percentEl = null;
1546
+ _tapToStartEl = null;
1547
+ // State
1548
+ _displayedProgress = 0;
1549
+ _targetProgress = 0;
1550
+ _loadingComplete = false;
1551
+ _startTime = 0;
1552
+ async onEnter(data) {
1553
+ const { engine, targetScene, targetData } = data;
1554
+ this._engine = engine;
1555
+ this._targetScene = targetScene;
1556
+ this._targetData = targetData;
1557
+ this._config = engine.config.loading ?? {};
1558
+ this._startTime = Date.now();
1559
+ // Create the HTML overlay with the SVG logo
1560
+ this.createOverlay();
1561
+ // Initialize asset manager
1562
+ await this._engine.assets.init();
1563
+ // Initialize audio manager
1564
+ await this._engine.audio.init();
1565
+ // Phase 1: Load preload bundle
1566
+ const bundles = this._engine.assets.getBundleNames();
1567
+ const hasPreload = bundles.includes('preload');
1568
+ if (hasPreload) {
1569
+ const preloadAssets = this._engine.config.manifest?.bundles?.find((b) => b.name === 'preload')?.assets;
1570
+ if (preloadAssets && preloadAssets.length > 0) {
1571
+ await this._engine.assets.loadBundle('preload', (p) => {
1572
+ this._targetProgress = p * 0.15;
1573
+ });
1574
+ }
1575
+ else {
1576
+ this._targetProgress = 0.15;
1577
+ }
1578
+ }
1579
+ // Phase 2: Load remaining bundles
1580
+ const remainingBundles = bundles.filter((b) => b !== 'preload' && !this._engine.assets.isBundleLoaded(b));
1581
+ if (remainingBundles.length > 0) {
1582
+ const hasAssets = remainingBundles.some((name) => {
1583
+ const bundle = this._engine.config.manifest?.bundles?.find((b) => b.name === name);
1584
+ return bundle?.assets && bundle.assets.length > 0;
1585
+ });
1586
+ if (hasAssets) {
1587
+ await this._engine.assets.loadBundles(remainingBundles, (p) => {
1588
+ this._targetProgress = 0.15 + p * 0.85;
1589
+ });
1590
+ }
1591
+ }
1592
+ this._targetProgress = 1;
1593
+ this._loadingComplete = true;
1594
+ // Enforce minimum display time: spread the remaining progress fill
1595
+ // over the remaining time so the bar fills smoothly, not abruptly
1596
+ const minTime = this._config.minDisplayTime ?? 1500;
1597
+ const elapsed = Date.now() - this._startTime;
1598
+ const remaining = Math.max(0, minTime - elapsed);
1599
+ if (remaining > 0) {
1600
+ // Distribute fill animation over the remaining time
1601
+ await this.animateProgressTo(1, remaining);
1602
+ }
1603
+ // Final snap to 100%
1604
+ this._displayedProgress = 1;
1605
+ this.updateLoaderBar(1);
1606
+ // Show "Tap to Start" or transition directly
1607
+ if (this._config.tapToStart !== false) {
1608
+ await this.showTapToStart();
1609
+ }
1610
+ else {
1611
+ await this.transitionToGame();
1612
+ }
1613
+ }
1614
+ onUpdate(dt) {
1615
+ // Smooth progress bar fill via HTML (during active loading)
1616
+ if (!this._loadingComplete && this._displayedProgress < this._targetProgress) {
1617
+ this._displayedProgress = Math.min(this._displayedProgress + dt * 1.5, this._targetProgress);
1618
+ this.updateLoaderBar(this._displayedProgress);
1619
+ }
1620
+ }
1621
+ onResize(_width, _height) {
1622
+ // Overlay is CSS-based, auto-resizes
1623
+ }
1624
+ onDestroy() {
1625
+ this.removeOverlay();
1626
+ }
1627
+ // ─── HTML Overlay ──────────────────────────────────────
1628
+ createOverlay() {
1629
+ const bgColor = typeof this._config.backgroundColor === 'string'
1630
+ ? this._config.backgroundColor
1631
+ : typeof this._config.backgroundColor === 'number'
1632
+ ? `#${this._config.backgroundColor.toString(16).padStart(6, '0')}`
1633
+ : '#0a0a1a';
1634
+ const bgGradient = this._config.backgroundGradient ??
1635
+ `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
1636
+ this._overlay = document.createElement('div');
1637
+ this._overlay.id = '__ge-loading-overlay__';
1638
+ this._overlay.innerHTML = `
1639
+ <div class="ge-loading-content">
1640
+ ${buildLoadingLogoSVG()}
1641
+ </div>
1642
+ `;
1643
+ const style = document.createElement('style');
1644
+ style.id = '__ge-loading-style__';
1645
+ style.textContent = `
1646
+ #__ge-loading-overlay__ {
1647
+ position: absolute;
1648
+ top: 0; left: 0;
1649
+ width: 100%; height: 100%;
1650
+ background: ${bgGradient};
1651
+ display: flex;
1652
+ align-items: center;
1653
+ justify-content: center;
1654
+ z-index: 9999;
1655
+ transition: opacity 0.5s ease-out;
1656
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1657
+ }
1658
+ #__ge-loading-overlay__.ge-fade-out {
1659
+ opacity: 0;
1660
+ pointer-events: none;
1661
+ }
1662
+ .ge-loading-content {
1663
+ display: flex;
1664
+ flex-direction: column;
1665
+ align-items: center;
1666
+ width: 75%;
1667
+ max-width: 650px;
1668
+ }
1669
+ .ge-loading-content svg {
1670
+ filter: drop-shadow(0 0 40px rgba(121, 57, 194, 0.5));
1671
+ cursor: default;
1672
+ }
1673
+
1674
+ .ge-svg-pulse {
1675
+ animation: ge-tap-pulse 1.2s ease-in-out infinite;
1676
+ }
1677
+ @keyframes ge-tap-pulse {
1678
+ 0%, 100% { opacity: 0.5; }
1679
+ 50% { opacity: 1; }
1680
+ }
1681
+ `;
1682
+ // Get the container that holds the canvas
1683
+ const container = this._engine.app?.canvas?.parentElement;
1684
+ if (container) {
1685
+ container.style.position = container.style.position || 'relative';
1686
+ container.appendChild(style);
1687
+ container.appendChild(this._overlay);
1688
+ }
1689
+ // Cache the SVG loader rect for progress updates
1690
+ this._loaderRect = this._overlay.querySelector('#ge-loader-rect');
1691
+ this._percentEl = this._overlay.querySelector('#ge-loader-pct');
1692
+ }
1693
+ removeOverlay() {
1694
+ this._overlay?.remove();
1695
+ document.getElementById('__ge-loading-style__')?.remove();
1696
+ this._overlay = null;
1697
+ this._loaderRect = null;
1698
+ this._percentEl = null;
1699
+ this._tapToStartEl = null;
1700
+ }
1701
+ // ─── Progress ──────────────────────────────────────────
1702
+ updateLoaderBar(progress) {
1703
+ if (this._loaderRect) {
1704
+ this._loaderRect.setAttribute('width', String(LOADER_BAR_MAX_WIDTH * progress));
1705
+ }
1706
+ if (this._percentEl) {
1707
+ const pct = Math.round(progress * 100);
1708
+ this._percentEl.textContent = `${pct}%`;
1709
+ }
1710
+ }
1711
+ /**
1712
+ * Smoothly animate the displayed progress from its current value to `target`
1713
+ * over `durationMs` using an easeOutCubic curve.
1714
+ */
1715
+ async animateProgressTo(target, durationMs) {
1716
+ const startVal = this._displayedProgress;
1717
+ const delta = target - startVal;
1718
+ if (delta <= 0 || durationMs <= 0)
1719
+ return;
1720
+ const startTime = Date.now();
1721
+ return new Promise((resolve) => {
1722
+ const tick = () => {
1723
+ const elapsed = Date.now() - startTime;
1724
+ const t = Math.min(elapsed / durationMs, 1);
1725
+ // easeOutCubic for a natural deceleration feel
1726
+ const eased = 1 - Math.pow(1 - t, 3);
1727
+ this._displayedProgress = startVal + delta * eased;
1728
+ this.updateLoaderBar(this._displayedProgress);
1729
+ if (t < 1) {
1730
+ requestAnimationFrame(tick);
1731
+ }
1732
+ else {
1733
+ resolve();
1734
+ }
1735
+ };
1736
+ requestAnimationFrame(tick);
1737
+ });
1738
+ }
1739
+ // ─── Tap to Start ─────────────────────────────────────
1740
+ async showTapToStart() {
1741
+ const tapText = this._config.tapToStartText ?? 'TAP TO START';
1742
+ // Reuse the same SVG text element — replace percentage with tap text
1743
+ if (this._percentEl) {
1744
+ const el = this._percentEl;
1745
+ el.textContent = tapText;
1746
+ el.setAttribute('fill', '#ffffff');
1747
+ el.classList.add('ge-svg-pulse');
1748
+ this._tapToStartEl = el;
1749
+ }
1750
+ // Make overlay clickable
1751
+ if (this._overlay) {
1752
+ this._overlay.style.cursor = 'pointer';
1753
+ }
1754
+ // Wait for tap
1755
+ return new Promise((resolve) => {
1756
+ const handler = async () => {
1757
+ this._overlay?.removeEventListener('click', handler);
1758
+ await this.transitionToGame();
1759
+ resolve();
1760
+ };
1761
+ // Listen on the full overlay for easier mobile tap
1762
+ this._overlay?.addEventListener('click', handler);
1763
+ });
1764
+ }
1765
+ // ─── Transition ────────────────────────────────────────
1766
+ async transitionToGame() {
1767
+ // Fade out the HTML overlay
1768
+ if (this._overlay) {
1769
+ this._overlay.classList.add('ge-fade-out');
1770
+ await new Promise((resolve) => {
1771
+ this._overlay.addEventListener('transitionend', () => resolve(), { once: true });
1772
+ // Safety timeout
1773
+ setTimeout(resolve, 600);
1774
+ });
1775
+ }
1776
+ // Remove overlay
1777
+ this.removeOverlay();
1778
+ // Navigate to the target scene, always passing the engine reference
1779
+ await this._engine.scenes.goto(this._targetScene, {
1780
+ engine: this._engine,
1781
+ ...(this._targetData && typeof this._targetData === 'object' ? this._targetData : { data: this._targetData }),
1782
+ });
1783
+ }
1784
+ }
1785
+
1786
+ /**
1787
+ * FPS overlay for debugging performance.
1788
+ *
1789
+ * Shows FPS, frame time, and draw call count in the corner of the screen.
1790
+ *
1791
+ * @example
1792
+ * ```ts
1793
+ * const fps = new FPSOverlay(app);
1794
+ * fps.show();
1795
+ * ```
1796
+ */
1797
+ class FPSOverlay {
1798
+ _app;
1799
+ _container;
1800
+ _fpsText;
1801
+ _visible = false;
1802
+ _samples = [];
1803
+ _maxSamples = 60;
1804
+ _lastUpdate = 0;
1805
+ _tickFn = null;
1806
+ constructor(app) {
1807
+ this._app = app;
1808
+ this._container = new Container();
1809
+ this._container.label = 'FPSOverlay';
1810
+ this._container.zIndex = 99999;
1811
+ this._fpsText = new Text({
1812
+ text: 'FPS: --',
1813
+ style: {
1814
+ fontFamily: 'monospace',
1815
+ fontSize: 14,
1816
+ fill: 0x00ff00,
1817
+ stroke: { color: 0x000000, width: 2 },
1818
+ },
1819
+ });
1820
+ this._fpsText.x = 8;
1821
+ this._fpsText.y = 8;
1822
+ this._container.addChild(this._fpsText);
1823
+ }
1824
+ /** Show the FPS overlay */
1825
+ show() {
1826
+ if (this._visible)
1827
+ return;
1828
+ this._visible = true;
1829
+ this._app.stage.addChild(this._container);
1830
+ this._tickFn = (ticker) => {
1831
+ this._samples.push(ticker.FPS);
1832
+ if (this._samples.length > this._maxSamples) {
1833
+ this._samples.shift();
1834
+ }
1835
+ // Update display every ~500ms
1836
+ const now = Date.now();
1837
+ if (now - this._lastUpdate > 500) {
1838
+ const avg = this._samples.reduce((a, b) => a + b, 0) / this._samples.length;
1839
+ const min = Math.min(...this._samples);
1840
+ this._fpsText.text = [
1841
+ `FPS: ${Math.round(avg)} (min: ${Math.round(min)})`,
1842
+ `Frame: ${ticker.deltaMS.toFixed(1)}ms`,
1843
+ ].join('\n');
1844
+ this._lastUpdate = now;
1845
+ }
1846
+ };
1847
+ this._app.ticker.add(this._tickFn);
1848
+ }
1849
+ /** Hide the FPS overlay */
1850
+ hide() {
1851
+ if (!this._visible)
1852
+ return;
1853
+ this._visible = false;
1854
+ this._container.removeFromParent();
1855
+ if (this._tickFn) {
1856
+ this._app.ticker.remove(this._tickFn);
1857
+ this._tickFn = null;
1858
+ }
1859
+ }
1860
+ /** Toggle visibility */
1861
+ toggle() {
1862
+ if (this._visible) {
1863
+ this.hide();
1864
+ }
1865
+ else {
1866
+ this.show();
1867
+ }
1868
+ }
1869
+ /** Destroy the overlay */
1870
+ destroy() {
1871
+ this.hide();
1872
+ this._container.destroy({ children: true });
1873
+ }
1874
+ }
1875
+
1876
+ /**
1877
+ * The main entry point for a game built on @energy8platform/game-engine.
1878
+ *
1879
+ * Orchestrates the full lifecycle:
1880
+ * 1. Create PixiJS Application
1881
+ * 2. Initialize SDK (or run offline)
1882
+ * 3. Show CSS preloader → Canvas loading screen with progress bar
1883
+ * 4. Load asset manifest
1884
+ * 5. Transition to the first game scene
1885
+ *
1886
+ * @example
1887
+ * ```ts
1888
+ * import { GameApplication, ScaleMode } from '@energy8platform/game-engine';
1889
+ * import { GameScene } from './scenes/GameScene';
1890
+ *
1891
+ * const game = new GameApplication({
1892
+ * container: '#game',
1893
+ * designWidth: 1920,
1894
+ * designHeight: 1080,
1895
+ * scaleMode: ScaleMode.FIT,
1896
+ * manifest: { bundles: [
1897
+ * { name: 'preload', assets: [{ alias: 'logo', src: 'logo.png' }] },
1898
+ * { name: 'game', assets: [{ alias: 'bg', src: 'background.png' }] },
1899
+ * ]},
1900
+ * loading: { tapToStart: true },
1901
+ * });
1902
+ *
1903
+ * game.scenes.register('game', GameScene);
1904
+ * await game.start('game');
1905
+ * ```
1906
+ */
1907
+ class GameApplication extends EventEmitter {
1908
+ // ─── Public references ──────────────────────────────────
1909
+ /** PixiJS Application instance */
1910
+ app;
1911
+ /** Scene manager */
1912
+ scenes;
1913
+ /** Asset manager */
1914
+ assets;
1915
+ /** Audio manager */
1916
+ audio;
1917
+ /** Input manager */
1918
+ input;
1919
+ /** Viewport manager */
1920
+ viewport;
1921
+ /** Scaled world root (holds scenes). Transformed by the ViewportManager to fit the design
1922
+ * resolution; lives below the UI layer on app.stage. */
1923
+ worldRoot;
1924
+ /** Unscaled, screen-space UI layer. Sits above {@link worldRoot} and is NOT touched by the
1925
+ * viewport transform — children fill the real screen (e.g. the host's shell + overlay). */
1926
+ uiLayer;
1927
+ /** SDK instance (null in offline mode) */
1928
+ sdk = null;
1929
+ /** FPS overlay instance (only when debug: true) */
1930
+ fpsOverlay = null;
1931
+ /** Data received from SDK initialization */
1932
+ initData = null;
1933
+ /** Platform session (SDK + optional DevBridge). null until start() runs. */
1934
+ platformSession = null;
1935
+ /** Branded game shell (only when config.shell is set). */
1936
+ shell;
1937
+ /** Configuration */
1938
+ config;
1939
+ // ─── Private state ──────────────────────────────────────
1940
+ _running = false;
1941
+ _destroyed = false;
1942
+ _container = null;
1943
+ constructor(config = {}) {
1944
+ super();
1945
+ this.config = {
1946
+ designWidth: 1920,
1947
+ designHeight: 1080,
1948
+ scaleMode: ScaleMode.FIT,
1949
+ orientation: Orientation.ANY,
1950
+ debug: false,
1951
+ ...config,
1952
+ };
1953
+ // Create SceneManager early so scenes can be registered before start()
1954
+ this.scenes = new SceneManager();
1955
+ }
1956
+ // ─── Public getters ─────────────────────────────────────
1957
+ /** Current game config from SDK (or null in offline mode) */
1958
+ get gameConfig() {
1959
+ return this.initData?.config ?? null;
1960
+ }
1961
+ /** Current session data */
1962
+ get session() {
1963
+ return this.initData?.session ?? null;
1964
+ }
1965
+ /** Current balance */
1966
+ get balance() {
1967
+ return this.sdk?.balance ?? 0;
1968
+ }
1969
+ /** Current currency */
1970
+ get currency() {
1971
+ return this.sdk?.currency ?? 'USD';
1972
+ }
1973
+ /** Whether the engine is running */
1974
+ get isRunning() {
1975
+ return this._running;
1976
+ }
1977
+ // ─── Lifecycle ──────────────────────────────────────────
1978
+ /**
1979
+ * Start the game engine. This is the main entry point.
1980
+ *
1981
+ * @param firstScene - Key of the first scene to show after loading (must be registered)
1982
+ * @param sceneData - Optional data to pass to the first scene's onEnter
1983
+ */
1984
+ async start(firstScene, sceneData) {
1985
+ if (this._running) {
1986
+ console.warn('[GameEngine] Already running');
1987
+ return;
1988
+ }
1989
+ try {
1990
+ // 1. Resolve container element
1991
+ this._container = this.resolveContainer();
1992
+ // 2. Show CSS preloader immediately (before PixiJS)
1993
+ createCSSPreloader(this._container, this.config.loading);
1994
+ // 3. Initialize PixiJS
1995
+ await this.initPixi();
1996
+ // 4. Initialize SDK (if enabled)
1997
+ await this.initSDK();
1998
+ // 4b. Mount the branded game shell after the SDK handshake (optional)
1999
+ if (this.config.shell) {
2000
+ const { createGameShell } = await import('@energy8platform/platform-core/shell');
2001
+ this.shell = createGameShell(this.config.shell);
2002
+ }
2003
+ // 5. Merge design dimensions from SDK config
2004
+ this.applySDKConfig();
2005
+ // 6. Initialize sub-systems
2006
+ this.initSubSystems();
2007
+ this.emit('initialized');
2008
+ // 7. Remove CSS preloader, show Canvas loading screen
2009
+ removeCSSPreloader(this._container);
2010
+ // 8. Load assets with loading screen
2011
+ await this.loadAssets(firstScene, sceneData);
2012
+ this.emit('loaded');
2013
+ // 9. Start the game loop
2014
+ this._running = true;
2015
+ this.emit('started');
2016
+ }
2017
+ catch (err) {
2018
+ console.error('[GameEngine] Failed to start:', err);
2019
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
2020
+ throw err;
2021
+ }
2022
+ }
2023
+ /**
2024
+ * Destroy the engine and free all resources.
2025
+ */
2026
+ async destroy() {
2027
+ if (this._destroyed)
2028
+ return;
2029
+ this._destroyed = true;
2030
+ this._running = false;
2031
+ if (this.shell) {
2032
+ const { removeGameShell } = await import('@energy8platform/platform-core/shell');
2033
+ await removeGameShell();
2034
+ this.shell = undefined;
2035
+ }
2036
+ this.scenes?.destroy();
2037
+ this.input?.destroy();
2038
+ this.audio?.destroy();
2039
+ this.viewport?.destroy();
2040
+ this.platformSession?.destroy();
2041
+ this.app?.destroy(true, { children: true, texture: true });
2042
+ this.emit('destroyed');
2043
+ this.removeAllListeners();
2044
+ }
2045
+ // ─── Private initialization steps ──────────────────────
2046
+ resolveContainer() {
2047
+ if (typeof this.config.container === 'string') {
2048
+ const el = document.querySelector(this.config.container);
2049
+ if (!el)
2050
+ throw new Error(`[GameEngine] Container "${this.config.container}" not found`);
2051
+ return el;
2052
+ }
2053
+ return this.config.container ?? document.body;
2054
+ }
2055
+ async initPixi() {
2056
+ this.app = new Application();
2057
+ const pixiOpts = {
2058
+ preference: 'webgl',
2059
+ background: typeof this.config.loading?.backgroundColor === 'number'
2060
+ ? this.config.loading.backgroundColor
2061
+ : 0x000000,
2062
+ antialias: true,
2063
+ resolution: Math.min(window.devicePixelRatio, 2),
2064
+ autoDensity: true,
2065
+ ...this.config.pixi,
2066
+ };
2067
+ await this.app.init(pixiOpts);
2068
+ // Append canvas to container
2069
+ this._container.appendChild(this.app.canvas);
2070
+ // Set canvas style
2071
+ this.app.canvas.style.display = 'block';
2072
+ this.app.canvas.style.width = '100%';
2073
+ this.app.canvas.style.height = '100%';
2074
+ }
2075
+ async initSDK() {
2076
+ // Delegate the SDK handshake (and any optional in-process DevBridge
2077
+ // wiring) to platform-core. The session forwards SDK events upward.
2078
+ this.platformSession = await createPlatformSession({ sdk: this.config.sdk });
2079
+ this.sdk = this.platformSession.sdk;
2080
+ this.initData = this.platformSession.initData;
2081
+ this.platformSession.on('error', (err) => {
2082
+ this.emit('error', err);
2083
+ });
2084
+ this.platformSession.on('balanceUpdate', (data) => {
2085
+ this.emit('balanceUpdate', data);
2086
+ });
2087
+ }
2088
+ applySDKConfig() {
2089
+ // If SDK provides viewport dimensions, use them as design reference
2090
+ if (this.initData?.config?.viewport) {
2091
+ const vp = this.initData.config.viewport;
2092
+ if (!this.config.designWidth)
2093
+ this.config.designWidth = vp.width;
2094
+ if (!this.config.designHeight)
2095
+ this.config.designHeight = vp.height;
2096
+ }
2097
+ }
2098
+ initSubSystems() {
2099
+ // Asset Manager
2100
+ const basePath = this.initData?.assetsUrl ?? '';
2101
+ this.assets = new AssetManager(basePath, this.config.manifest);
2102
+ // Audio Manager
2103
+ this.audio = new AudioManager(this.config.audio);
2104
+ // Input Manager
2105
+ this.input = new InputManager(this.app.canvas);
2106
+ // Stage layers: a scaled world root (scenes, transformed to design resolution by the
2107
+ // viewport) below an unscaled UI layer (screen space). app.stage itself stays identity.
2108
+ this.worldRoot = new Container();
2109
+ this.worldRoot.label = 'world';
2110
+ this.uiLayer = new Container();
2111
+ this.uiLayer.label = 'ui';
2112
+ this.app.stage.addChild(this.worldRoot, this.uiLayer);
2113
+ // Viewport Manager — scales worldRoot (NOT app.stage), so the UI layer is unscaled.
2114
+ this.viewport = new ViewportManager(this.app, this._container, {
2115
+ designWidth: this.config.designWidth,
2116
+ designHeight: this.config.designHeight,
2117
+ scaleMode: this.config.scaleMode,
2118
+ orientation: this.config.orientation,
2119
+ }, this.worldRoot);
2120
+ // Wire SceneManager to the scaled world root
2121
+ this.scenes.setRoot(this.worldRoot);
2122
+ this.scenes.setApp(this);
2123
+ // Wire viewport resize → scene manager + input manager
2124
+ this.viewport.on('resize', ({ width, height, scale }) => {
2125
+ this.scenes.resize(width, height);
2126
+ this.input.setViewportTransform(scale, this.worldRoot.x, this.worldRoot.y);
2127
+ this.emit('resize', { width, height });
2128
+ });
2129
+ this.viewport.on('orientationChange', (orientation) => {
2130
+ this.emit('orientationChange', orientation);
2131
+ });
2132
+ // Wire scene changes → engine event
2133
+ this.scenes.on('change', ({ from, to }) => {
2134
+ this.emit('sceneChange', { from, to });
2135
+ });
2136
+ // Connect ticker → scene updates
2137
+ this.app.ticker.add((ticker) => {
2138
+ // Always update scenes (loading screen needs onUpdate before _running=true)
2139
+ this.scenes.update(ticker.deltaTime / 60); // convert to seconds
2140
+ });
2141
+ // Trigger initial resize
2142
+ this.viewport.refresh();
2143
+ // Enable FPS overlay in debug mode
2144
+ if (this.config.debug) {
2145
+ this.fpsOverlay = new FPSOverlay(this.app);
2146
+ this.fpsOverlay.show();
2147
+ }
2148
+ }
2149
+ async loadAssets(firstScene, sceneData) {
2150
+ // Register built-in loading scene
2151
+ this.scenes.register('__loading__', LoadingScene);
2152
+ // Enter loading scene
2153
+ await this.scenes.goto('__loading__', {
2154
+ engine: this,
2155
+ targetScene: firstScene,
2156
+ targetData: sceneData,
2157
+ });
2158
+ }
2159
+ }
2160
+
2161
+ // packages/game-engine/src/scenes/IntroScene.ts
2162
+ /**
2163
+ * Reusable splash scene: shows a title (or logo) + "tap to start", then advances.
2164
+ *
2165
+ * The host no longer special-cases the intro. Navigation works like every other
2166
+ * scene: the host injects `goto(key)` into this scene's start data. On tap this
2167
+ * scene calls `onStart` if the game supplied one, otherwise `goto(next ?? 'game')`.
2168
+ * (The built-in can't know the game's scene key generically, so it falls back to
2169
+ * the conventional 'game' key — override via `next`. Scaffold-generated intros
2170
+ * skip this primitive and call `goto('game')` directly.)
2171
+ */
2172
+ class IntroScene extends Scene {
2173
+ layer;
2174
+ async onEnter(data) {
2175
+ const cfg = (data ?? {});
2176
+ const start = () => cfg.onStart ? cfg.onStart() : cfg.goto?.(cfg.next ?? 'game');
2177
+ const layer = new Container();
2178
+ this.layer = layer;
2179
+ this.container.addChild(layer);
2180
+ const title = new Text({
2181
+ text: cfg.title ?? 'PLAY',
2182
+ style: { fill: 0xffffff, fontSize: 96, fontFamily: 'Inter', align: 'center' },
2183
+ });
2184
+ title.anchor.set(0.5);
2185
+ title.position.set(960, 460);
2186
+ layer.addChild(title);
2187
+ if (cfg.tapToStart !== false) {
2188
+ const hint = new Text({
2189
+ text: 'Tap to start',
2190
+ style: { fill: 0xffd24a, fontSize: 36, fontFamily: 'Inter' },
2191
+ });
2192
+ hint.anchor.set(0.5);
2193
+ hint.position.set(960, 600);
2194
+ layer.addChild(hint);
2195
+ }
2196
+ // full-screen tap target
2197
+ const hit = new Graphics().rect(0, 0, 1920, 1080).fill({ color: 0x000000, alpha: 0.001 });
2198
+ hit.eventMode = 'static';
2199
+ hit.cursor = 'pointer';
2200
+ hit.once('pointerdown', () => start());
2201
+ layer.addChild(hit);
2202
+ }
2203
+ onExit() {
2204
+ this.layer?.destroy({ children: true });
2205
+ this.layer = undefined;
2206
+ }
2207
+ }
2208
+
2209
+ /**
2210
+ * Pure: map host options to a GameApplicationConfig with sane defaults.
2211
+ * `isStakeNow` is computed by the orchestrator (kept out of here so this
2212
+ * stays a pure, renderer-free function).
2213
+ */
2214
+ function buildAppConfig(opts, isStakeNow) {
2215
+ return {
2216
+ container: opts.container ?? '#game',
2217
+ designWidth: opts.design?.width ?? 1920,
2218
+ designHeight: opts.design?.height ?? 1080,
2219
+ scaleMode: opts.scaleMode ?? ScaleMode.FILL,
2220
+ orientation: opts.orientation ?? Orientation.ANY,
2221
+ loading: opts.loading ?? { tapToStart: false, minDisplayTime: 600 },
2222
+ manifest: opts.manifest,
2223
+ audio: opts.audio,
2224
+ pixi: opts.pixi,
2225
+ sdk: { devMode: isStakeNow || (opts.dev ?? false) },
2226
+ debug: opts.dev ?? false,
2227
+ };
2228
+ }
2229
+
2230
+ /** Preload web fonts so Pixi text rasterizes with the right glyphs. Never throws. */
2231
+ async function loadFonts(specs) {
2232
+ if (!specs || specs.length === 0)
2233
+ return;
2234
+ try {
2235
+ await Promise.all(specs.map((s) => document.fonts.load(s)));
2236
+ await document.fonts.ready;
2237
+ }
2238
+ catch {
2239
+ /* font CDN unreachable → fall back to system fonts */
2240
+ }
2241
+ }
2242
+ /** Smoother default downscaling for art-heavy slots. Pixel-art games omit this. */
2243
+ function applyTextureDefaults() {
2244
+ TextureSource.defaultOptions.autoGenerateMipmaps = true;
2245
+ }
2246
+ /** Idempotent double-boot guard. Returns true the first time, false thereafter. */
2247
+ function bootGuard(flag = '__e8SlotBooted__') {
2248
+ const w = window;
2249
+ if (w[flag])
2250
+ return false;
2251
+ w[flag] = true;
2252
+ return true;
2253
+ }
2254
+
2255
+ // packages/game-engine/src/host/fatalError.ts
2256
+ /** Marker id so the modal is idempotent (first one wins; later calls replace its message). */
2257
+ const FATAL_ID = 'e8-fatal-error';
2258
+ /** Pure: extract a human-readable message from any thrown value / event reason. */
2259
+ function fatalMessage(input) {
2260
+ if (input == null)
2261
+ return 'Something went wrong.';
2262
+ if (typeof input === 'string')
2263
+ return input;
2264
+ if (input instanceof Error)
2265
+ return input.message || input.name || 'Something went wrong.';
2266
+ const anyIn = input;
2267
+ if (typeof anyIn.message === 'string' && anyIn.message)
2268
+ return anyIn.message;
2269
+ if (anyIn.reason != null)
2270
+ return fatalMessage(anyIn.reason);
2271
+ try {
2272
+ return String(input);
2273
+ }
2274
+ catch {
2275
+ return 'Something went wrong.';
2276
+ }
2277
+ }
2278
+ /** Pure: build the modal overlay element (error text + a Reload button). */
2279
+ function buildFatalErrorModal(message, onReload) {
2280
+ const overlay = document.createElement('div');
2281
+ overlay.id = FATAL_ID;
2282
+ overlay.setAttribute('role', 'alertdialog');
2283
+ overlay.style.cssText =
2284
+ 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' +
2285
+ 'background:rgba(10,5,4,0.92);z-index:99999;font-family:system-ui,sans-serif;padding:24px';
2286
+ const card = document.createElement('div');
2287
+ card.style.cssText =
2288
+ 'max-width:420px;width:100%;background:#1a0f0a;border:1px solid #5a3a1e;border-radius:12px;' +
2289
+ 'padding:28px 24px;text-align:center;box-shadow:0 12px 40px rgba(0,0,0,0.6)';
2290
+ const text = document.createElement('div');
2291
+ text.className = 'e8-fatal-message';
2292
+ text.style.cssText = 'color:#f0c98a;font:600 17px/1.4 system-ui,sans-serif;margin-bottom:22px';
2293
+ text.textContent = message;
2294
+ const button = document.createElement('button');
2295
+ button.type = 'button';
2296
+ button.className = 'e8-fatal-reload';
2297
+ button.textContent = 'Reload';
2298
+ button.style.cssText =
2299
+ 'cursor:pointer;border:none;border-radius:8px;padding:12px 28px;font:600 15px system-ui,sans-serif;' +
2300
+ 'color:#1a0f0a;background:#f0c98a';
2301
+ button.addEventListener('click', onReload);
2302
+ card.appendChild(text);
2303
+ card.appendChild(button);
2304
+ overlay.appendChild(card);
2305
+ return overlay;
2306
+ }
2307
+ /**
2308
+ * Render a blocking fatal-error modal with a Reload button. Idempotent: if a modal is already
2309
+ * shown, its message is replaced instead of stacking a second overlay.
2310
+ */
2311
+ function showFatalError(container, message) {
2312
+ if (typeof document === 'undefined')
2313
+ return;
2314
+ const host = typeof container === 'string'
2315
+ ? document.querySelector(container) ?? document.body
2316
+ : container;
2317
+ const existing = document.getElementById(FATAL_ID);
2318
+ if (existing) {
2319
+ const msg = existing.querySelector('.e8-fatal-message');
2320
+ if (msg)
2321
+ msg.textContent = message;
2322
+ return;
2323
+ }
2324
+ const overlay = buildFatalErrorModal(message, () => {
2325
+ try {
2326
+ location.reload();
2327
+ }
2328
+ catch {
2329
+ /* no-op in non-browser environments */
2330
+ }
2331
+ });
2332
+ host.appendChild(overlay);
2333
+ }
2334
+ /**
2335
+ * Install global handlers so ANY uncaught error or unhandled promise rejection surfaces the
2336
+ * fatal modal (game devs don't have to handle errors themselves). `fatal` defaults to the
2337
+ * built-in modal targeting `container`. Returns a disposer that removes the listeners.
2338
+ */
2339
+ function installGlobalErrorHandlers(container, fatal = (m) => showFatalError(container, m)) {
2340
+ if (typeof window === 'undefined')
2341
+ return () => { };
2342
+ const onError = (e) => fatal(fatalMessage(e.error ?? e.message));
2343
+ const onRejection = (e) => fatal(fatalMessage(e.reason));
2344
+ window.addEventListener('error', onError);
2345
+ window.addEventListener('unhandledrejection', onRejection);
2346
+ return () => {
2347
+ window.removeEventListener('error', onError);
2348
+ window.removeEventListener('unhandledrejection', onRejection);
2349
+ };
2350
+ }
2351
+
2352
+ // packages/game-engine/src/host/createSlotGame.ts
2353
+ /**
2354
+ * One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
2355
+ * → register scene → start. Collapses the per-game main.ts boilerplate.
2356
+ *
2357
+ * Not unit-tested: GameApplication.init() drives Pixi, which hangs in headless
2358
+ * environments. The pure helpers it sequences are unit-tested individually.
2359
+ */
2360
+ async function createSlotGame(opts) {
2361
+ if (!bootGuard())
2362
+ throw new Error('createSlotGame() called more than once');
2363
+ if (opts.textureDefaults)
2364
+ applyTextureDefaults();
2365
+ await loadFonts(opts.fonts);
2366
+ // Declared up front so `fatal` can route errors through the shell's own modal once it exists.
2367
+ let shell = null;
2368
+ const fatal = (message) => {
2369
+ if (opts.onFatalError)
2370
+ return opts.onFatalError(message);
2371
+ // Once the shell is up, use ITS branded modal (consistent chrome, social vocabulary, fit
2372
+ // scaling) rather than the bare DOM fallback. Errors thrown before the shell boots (asset
2373
+ // load, SDK handshake) still get the standalone overlay.
2374
+ if (shell) {
2375
+ shell.openModal({
2376
+ availableClose: false,
2377
+ title: shell.t('Something went wrong'),
2378
+ body: shell.t(message),
2379
+ actions: [{ title: shell.t('Reload'), on: () => { try {
2380
+ location.reload();
2381
+ }
2382
+ catch { /* non-browser */ } } }],
2383
+ });
2384
+ return;
2385
+ }
2386
+ showFatalError(opts.container ?? '#game', message);
2387
+ };
2388
+ // Global safety net: surface ANY uncaught error / unhandled rejection (e.g. an
2389
+ // `Uncaught (in promise) SDKError` on spin) through the same fatal modal so games
2390
+ // don't have to handle errors themselves. Honours the onFatalError override.
2391
+ installGlobalErrorHandlers(opts.container ?? '#game', fatal);
2392
+ let stakeBridge = null;
2393
+ let isStakeNow = false;
2394
+ if (opts.stake) {
2395
+ const { isStakeLaunch } = await import('@energy8platform/stake-bridge/detect');
2396
+ isStakeNow = isStakeLaunch(location.href);
2397
+ if (isStakeNow) {
2398
+ try {
2399
+ const { StakeBridge } = await import('@energy8platform/stake-bridge');
2400
+ stakeBridge = new StakeBridge({
2401
+ devMode: true,
2402
+ // In the dev harness the iframe is served over http and the dev-RGS
2403
+ // lives at the same (http) origin; force the matching scheme so
2404
+ // RGSClient can reach it. Prod (https) is unaffected.
2405
+ protocol: location.protocol === 'http:' ? 'http' : 'https',
2406
+ adapter: opts.stake.adapter,
2407
+ modeMap: opts.model.modeMap,
2408
+ gameId: opts.model.spec.id,
2409
+ url: location.href,
2410
+ });
2411
+ await stakeBridge.ready();
2412
+ }
2413
+ catch (err) {
2414
+ fatal('Could not connect to the game server. Please reload.');
2415
+ throw err;
2416
+ }
2417
+ }
2418
+ }
2419
+ const game = new GameApplication(buildAppConfig(opts, isStakeNow));
2420
+ // Register EVERY scene up front so any of them can navigate to any other.
2421
+ for (const { key, scene } of opts.scenes)
2422
+ game.scenes.register(key, scene);
2423
+ // Navigation injected into the start data of every scene: a scene reads `goto`
2424
+ // from its `onEnter(data)` and calls it to switch scenes (intro → game, etc.).
2425
+ const goto = (key, data) => {
2426
+ void game.scenes.goto(key, { ...data, goto });
2427
+ };
2428
+ // Pick the start scene from the ordered list + launch mode: a replay launch skips any leading
2429
+ // `skipOnReplay` scene (the intro) and starts directly on the game scene.
2430
+ const { resolveStartScene } = await Promise.resolve().then(function () { return sceneStart; });
2431
+ const startScene = resolveStartScene(opts.scenes, !!stakeBridge?.isReplay, opts.startScene);
2432
+ try {
2433
+ await game.start(startScene, { ...opts.startData, goto });
2434
+ }
2435
+ catch (err) {
2436
+ fatal('Could not start the game.');
2437
+ throw err;
2438
+ }
2439
+ let currentBet = opts.model.spec.defaultBet ?? opts.model.spec.betLevels[0];
2440
+ // Build slotPlay FIRST — bindGameScene() needs it to be in scope.
2441
+ const { createSlotPlay, enrichRoundMeta } = await Promise.resolve().then(function () { return slotPlay; });
2442
+ // Injected once per controller scene the first time it becomes current (see `ensureCreated`).
2443
+ // `sceneApi` is assembled inside the shell block; until then injection is a no-op (a shell-less
2444
+ // launch never builds the api, so a controller scene simply never receives onCreate).
2445
+ let sceneApi = null;
2446
+ const createdScenes = new WeakSet();
2447
+ const ensureCreated = (s) => {
2448
+ if (!sceneApi || createdScenes.has(s))
2449
+ return;
2450
+ createdScenes.add(s);
2451
+ s.onCreate?.(sceneApi);
2452
+ };
2453
+ /** The current scene IFF it implements the SlotSceneController contract (duck-typed on
2454
+ * `onSpin`). The host drives the play loop against whichever scene is current. Injects the
2455
+ * SceneApi via onCreate the first time a controller scene is seen. */
2456
+ const gameScene = () => {
2457
+ const s = game.scenes.current?.scene;
2458
+ if (typeof s?.onSpin !== 'function')
2459
+ return undefined;
2460
+ const scene = s;
2461
+ ensureCreated(scene);
2462
+ return scene;
2463
+ };
2464
+ const { runRound } = await Promise.resolve().then(function () { return runRound$1; });
2465
+ const { createBalanceGate } = await Promise.resolve().then(function () { return balanceGate; });
2466
+ const { createFreeSpinsCounter } = await Promise.resolve().then(function () { return freeSpinsCounter; });
2467
+ const { resolvePlayError } = await Promise.resolve().then(function () { return playError; });
2468
+ // slotPlay references shell via closure — define it after shell is assigned below.
2469
+ // We use a late-binding wrapper so the closure captures the variable, not null.
2470
+ const slotPlay$1 = createSlotPlay({
2471
+ play: (p) => game.platformSession.play(p),
2472
+ normalize: opts.normalize,
2473
+ // ACK the result AFTER the scene animates it (the scene calls host.ack()). On Stake this
2474
+ // triggers /wallet/end-round so a winning round settles post-animation instead of staying
2475
+ // open and blocking the next spin.
2476
+ ack: (raw) => game.platformSession.playAck(raw),
2477
+ });
2478
+ if (opts.shell) {
2479
+ const { createPixiShell } = await import('@energy8platform/pixi-shell');
2480
+ const { buildShellConfig } = await Promise.resolve().then(function () { return shellConfig; });
2481
+ const { resolveReplayBonusId } = await Promise.resolve().then(function () { return replay; });
2482
+ const ps = game.platformSession;
2483
+ const balance = game.initData?.balance ?? 0;
2484
+ const isReplay = !!stakeBridge?.isReplay;
2485
+ const mode = isReplay ? 'replay' : 'base';
2486
+ // initData.config carries the Stake bridge's currency/social/disclaimer surface (GameConfigData);
2487
+ // all are absent in non-stake/dev launches → graceful fallbacks downstream.
2488
+ const initData = game.initData;
2489
+ const config = initData?.config;
2490
+ const { resolveCurrency } = await Promise.resolve().then(function () { return shellConfig; });
2491
+ // SINGLE source of truth for the symbol: the Stake bridge already puts a full CurrencyMetaData
2492
+ // (symbol + placement) on initData.config.currency. In the non-stake/devBridge path that meta
2493
+ // is absent and we only have the spec's currency CODE — resolve it through the SAME table
2494
+ // (stake-bridge's lookupCurrency) so e.g. 'EUR' renders as '€', not the literal text "EUR".
2495
+ // stake-bridge ships with every scaffold; if it's somehow absent we degrade to the code.
2496
+ let currencyMeta = config?.currency;
2497
+ if (!currencyMeta?.symbol && opts.model.spec.currency) {
2498
+ try {
2499
+ const { lookupCurrency } = await import('@energy8platform/stake-bridge');
2500
+ currencyMeta = lookupCurrency(opts.model.spec.currency);
2501
+ }
2502
+ catch { /* stake-bridge not installed — resolveCurrency falls back to the code */ }
2503
+ }
2504
+ const runtime = {
2505
+ balance,
2506
+ currency: resolveCurrency(currencyMeta, opts.model.spec.currency),
2507
+ language: initData?.lang,
2508
+ mode,
2509
+ social: config?.socialMode,
2510
+ disclaimerLines: config?.disclaimerLines,
2511
+ jurisdiction: config?.jurisdiction,
2512
+ // Currency-specific ladder + per-currency default from /wallet/authenticate (Stake);
2513
+ // absent on dev/devBridge → buildShellConfig falls back to the spec.
2514
+ betLevels: config?.betLevels,
2515
+ defaultBet: config?.stake?.defaultBetLevel ?? config?.defaultBet,
2516
+ };
2517
+ if (opts.dev) {
2518
+ // Dev-only diagnostic. Logged as PLAIN STRINGS (not collapsed objects) so the values are
2519
+ // readable in the console without expanding. If the shown symbol is a bare code ("EUR")
2520
+ // instead of a glyph ("€"), paste this whole line.
2521
+ const cc = config?.currency;
2522
+ console.info(`[e8] currency → bridge.code=${cc?.code ?? '∅'} bridge.symbol=${cc?.symbol ?? '∅'} ` +
2523
+ `| spec=${opts.model.spec.currency ?? '∅'} ` +
2524
+ `| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`);
2525
+ }
2526
+ // pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
2527
+ // scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
2528
+ // The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
2529
+ shell = createPixiShell({ ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer });
2530
+ // Scope the bar to the slot scene: show only when a SlotSceneController scene is current
2531
+ // (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
2532
+ shell.setVisible(!!gameScene());
2533
+ game.scenes.on('change', () => shell.setVisible(!!gameScene()));
2534
+ // The gate tracks the live wallet (for the affordability guard) but only PAINTS the balance per
2535
+ // the HUD-timing rule: the debit is buffered during play→present and shown at afterPresent; the
2536
+ // async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
2537
+ // is the single source for both the displayed balance and `ensureAffordable`.
2538
+ const balanceGate = createBalanceGate((b) => shell.setBalance(b), balance);
2539
+ ps?.on('balanceUpdate', (d) => { balanceGate.onBalance(d.balance); });
2540
+ // Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
2541
+ let currentTurbo = shell.state.turbo;
2542
+ shell.on('turboChange', (level) => { currentTurbo = level; gameScene()?.onTurboChanged?.(level); });
2543
+ // Double-tap-to-skip is a game-level option (default on), set once via createSlotGame({ skipGesture }).
2544
+ const skipEnabled = opts.skipGesture ?? true;
2545
+ // Shell settings → engine state. Sound/volume map onto the AudioManager.
2546
+ shell.on('settingChange', ({ key, value }) => {
2547
+ switch (key) {
2548
+ case 'sound':
2549
+ value ? game.audio.unmuteAll() : game.audio.muteAll();
2550
+ break;
2551
+ case 'master':
2552
+ game.audio.setMasterVolume(Number(value));
2553
+ break;
2554
+ case 'music':
2555
+ game.audio.setVolume('music', Number(value));
2556
+ break;
2557
+ case 'sfx':
2558
+ game.audio.setVolume('sfx', Number(value));
2559
+ break;
2560
+ }
2561
+ });
2562
+ // Overlay layer sits ABOVE the shell (the shell already mounted its root onto the uiLayer;
2563
+ // adding ours afterwards keeps it on top). It eats pointer events while open so shell controls
2564
+ // are unreachable. Mounted on the same unscaled UI layer; tracks viewport via game's 'resize'.
2565
+ const { createSceneAudio } = await Promise.resolve().then(function () { return sceneAudio; });
2566
+ const { createOverlayController } = await Promise.resolve().then(function () { return overlayController; });
2567
+ const overlayLayer = new Container();
2568
+ overlayLayer.label = 'overlay';
2569
+ game.uiLayer.addChild(overlayLayer);
2570
+ const overlayCtl = createOverlayController({
2571
+ parent: overlayLayer,
2572
+ size: () => ({ width: game.app.screen.width, height: game.app.screen.height }),
2573
+ });
2574
+ game.on('resize', ({ width, height }) => overlayCtl.resize(width, height));
2575
+ // Capabilities injected once per controller scene via onCreate (see `gameScene`/`ensureCreated`).
2576
+ sceneApi = {
2577
+ audio: createSceneAudio(game.audio),
2578
+ overlay: overlayCtl.overlay,
2579
+ shell: { get safeArea() { return shell.safeArea; } },
2580
+ formatAmount: (v) => shell.formatWin(v),
2581
+ get bet() { return currentBet; },
2582
+ get mode() { return opts.model.modeMap['spin'] ?? 'BASE'; },
2583
+ get turbo() { return currentTurbo; },
2584
+ };
2585
+ const roleOf = (action) => opts.model.spec.actions[action]?.role;
2586
+ // The signal-less context. runRound injects a per-segment `signal` (for skip); resumeDrain
2587
+ // attaches its own. So makeContext returns everything BUT `signal`.
2588
+ const makeContext = (action) => ({
2589
+ bet: currentBet,
2590
+ action,
2591
+ mode: opts.model.modeMap[action] ?? action.toUpperCase(),
2592
+ formatAmount: (v) => shell.formatWin(v),
2593
+ get turbo() { return currentTurbo; },
2594
+ });
2595
+ // Play-error + connection handling. A play rejection is classified into a player-facing modal
2596
+ // (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
2597
+ // overlay is suppressed while a play-error modal owns the screen.
2598
+ let playErrorOpen = false;
2599
+ let stopAutoplay = () => { }; // wired to the autoplay loop once it's created (below)
2600
+ const showPlayError = (err) => {
2601
+ stopAutoplay(); // a play error halts an autoplay run (the .catch swallows, so stop explicitly)
2602
+ const v = resolvePlayError(err);
2603
+ playErrorOpen = true;
2604
+ shell.openModal({
2605
+ availableClose: !v.reload,
2606
+ title: shell.t(v.title),
2607
+ body: shell.t(v.body),
2608
+ actions: v.reload
2609
+ ? [{ title: shell.t('Reload'), on: () => { try {
2610
+ window.location.reload();
2611
+ }
2612
+ catch { /* non-browser */ } } }]
2613
+ : [{ title: shell.t('OK'), on: () => { playErrorOpen = false; } }],
2614
+ });
2615
+ };
2616
+ ps?.on('connectionStateChanged', (s) => {
2617
+ if (s.status === 'restored') {
2618
+ if (!playErrorOpen)
2619
+ shell.closeModal();
2620
+ return;
2621
+ }
2622
+ if (playErrorOpen)
2623
+ return; // a play-error modal owns the screen — don't mask it with "reconnecting"
2624
+ shell.openModal({
2625
+ availableClose: false,
2626
+ title: shell.t('Reconnecting…'),
2627
+ body: shell.t('Lost connection to the game server. Trying to reconnect…'),
2628
+ });
2629
+ });
2630
+ // Skip state: `currentSegmentAbort` is the controller for the segment presently animating;
2631
+ // `presenting` is true for the whole play→drain window (gates the double-tap detector so taps
2632
+ // only skip while a round is animating).
2633
+ let currentSegmentAbort = null;
2634
+ let presenting = false;
2635
+ // Double-tap skip: a double-tap on the play area aborts the current segment (the scene collapses
2636
+ // to its final visual via ctx.signal) and notifies the scene's onSkip. Gated by the shell's
2637
+ // skip-gesture setting (`skipEnabled`) and only active while a round is presenting.
2638
+ const { createDoubleTapSkip } = await Promise.resolve().then(function () { return skipGesture; });
2639
+ const skip = createDoubleTapSkip({
2640
+ enabled: () => skipEnabled,
2641
+ active: () => presenting,
2642
+ onSkip: () => { currentSegmentAbort?.abort(); gameScene()?.onSkip?.(); },
2643
+ });
2644
+ // Listen for taps on the scene root (game.worldRoot — the scaled scene container). The shell
2645
+ // lives on the sibling uiLayer, so its bar taps never reach worldRoot — taps here are the play area.
2646
+ game.scenes.root.eventMode = 'static';
2647
+ game.scenes.root.on('pointertap', () => skip.tap(performance.now()));
2648
+ // Full auto-pause: on tab blur, freeze the ticker (stops tweens/onUpdate/in-flight onSpin),
2649
+ // duck music to silence, hold autoplay, and notify the scene. On focus, reverse it all.
2650
+ // `stopAutoplay` is reassigned in the base-mode block below — the closure reads it live.
2651
+ const { createPauseController } = await Promise.resolve().then(function () { return pauseController; });
2652
+ createPauseController({
2653
+ isHidden: () => typeof document !== 'undefined' && document.hidden,
2654
+ subscribe: (cb) => {
2655
+ if (typeof document === 'undefined')
2656
+ return () => { };
2657
+ document.addEventListener('visibilitychange', cb);
2658
+ return () => document.removeEventListener('visibilitychange', cb);
2659
+ },
2660
+ onHidden: () => {
2661
+ game.app.ticker.stop(); // freezes tweens, onUpdate, in-flight onSpin animation
2662
+ game.audio.duckMusic(0); // silence music (ducked to 0; restored on resume)
2663
+ stopAutoplay(); // hold autoplay — don't start the next auto-round
2664
+ gameScene()?.onPause?.();
2665
+ },
2666
+ onVisible: () => {
2667
+ game.app.ticker.start();
2668
+ game.audio.unduckMusic();
2669
+ gameScene()?.onResume?.();
2670
+ },
2671
+ });
2672
+ /** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
2673
+ * update only AFTER each onSpin(), per the HUD-timing requirement. */
2674
+ const playRound = (action) => {
2675
+ const scene = gameScene();
2676
+ if (!scene)
2677
+ return;
2678
+ // Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
2679
+ // (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
2680
+ // the trigger segment (rendered by onSpin before onEnterMode) doesn't count as a free spin.
2681
+ let inBonus = false;
2682
+ let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
2683
+ const fsCounter = createFreeSpinsCounter();
2684
+ shell.setBusy(true); // block re-spin / spacebar while the round plays out
2685
+ presenting = true; // open the skip window for the whole play→drain
2686
+ // RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
2687
+ // animation has finished — returning void would reopen it instantly, over a running animation.
2688
+ return runRound({
2689
+ // Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
2690
+ play: (a, b, rid) => { balanceGate.beginPlay(); return slotPlay$1.play(a, b, rid); },
2691
+ ack: slotPlay$1.ack,
2692
+ scene,
2693
+ context: makeContext,
2694
+ roleOf,
2695
+ // Hand the host the per-segment AbortController so a double-tap can skip the live segment.
2696
+ beforeSegment: (ac) => { currentSegmentAbort = ac; },
2697
+ onSpinStart: () => scene.onSpinStart?.(),
2698
+ onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
2699
+ afterPresent: (r) => {
2700
+ // WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
2701
+ // free-spins counter (totalWin) below, not the WIN readout.
2702
+ shell.setWin(r.totalWin - prevWin);
2703
+ prevWin = r.totalWin;
2704
+ balanceGate.afterPresent();
2705
+ if (inBonus)
2706
+ shell.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
2707
+ },
2708
+ onEnterMode: async (trigger, ctx) => {
2709
+ inBonus = true;
2710
+ shell.setMode('freeSpins');
2711
+ shell.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
2712
+ await scene.onEnterMode?.(trigger, ctx);
2713
+ },
2714
+ onExitMode: async (last, ctx) => {
2715
+ inBonus = false;
2716
+ await scene.onExitMode?.(last, ctx);
2717
+ shell.setMode('base');
2718
+ },
2719
+ }, action).catch(showPlayError).finally(() => { presenting = false; shell.setBusy(false); });
2720
+ };
2721
+ /**
2722
+ * Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
2723
+ * the bonus start (Continue animates each; Finish fast-forwards without animation), reaching the
2724
+ * final ack so /wallet/end-round credits the win — fixing the old resume that presented one
2725
+ * snapshot and never settled. The original trigger is gone on reload, so the FS counter here uses
2726
+ * the bridge's session counts; FS mode is entered/exited around the drain.
2727
+ */
2728
+ const resumeDrain = async (firstRaw, animate) => {
2729
+ const scene = gameScene();
2730
+ if (!scene || !ps)
2731
+ return;
2732
+ // A recovered drain isn't skippable (no live skip gesture wired to it), so it gets a stable,
2733
+ // never-aborted signal to satisfy onSpin's RenderContext.
2734
+ const ctx = {
2735
+ ...makeContext(firstRaw.action ?? 'spin'),
2736
+ signal: new AbortController().signal,
2737
+ };
2738
+ const fsView = (raw, totalWin) => {
2739
+ const s = raw.session;
2740
+ if (!s)
2741
+ return null;
2742
+ // The bridge session counts ALL segments incl. the trigger (segment 0); the free-spins
2743
+ // counter is over FREE spins only, so drop the one trigger segment → 1/10, not 2/11.
2744
+ const played = s.spinsPlayed ?? 0;
2745
+ const current = Math.max(0, played - 1);
2746
+ const total = Math.max(0, played + (s.spinsRemaining ?? 0) - 1);
2747
+ return { current, total, totalWin };
2748
+ };
2749
+ let raw = firstRaw;
2750
+ let r = enrichRoundMeta(opts.normalize(raw), raw);
2751
+ let inBonus = false;
2752
+ let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
2753
+ const applySegment = async () => {
2754
+ // A recovered open round with remaining segments is a bonus → show FS mode + counter.
2755
+ if (!inBonus && !r.complete) {
2756
+ inBonus = true;
2757
+ shell.setMode('freeSpins');
2758
+ }
2759
+ if (animate)
2760
+ await scene.onSpin(r, ctx);
2761
+ if (inBonus) {
2762
+ const v = fsView(raw, r.totalWin);
2763
+ if (v)
2764
+ shell.setFreeSpins(v);
2765
+ }
2766
+ shell.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
2767
+ prevWin = r.totalWin;
2768
+ ps.playAck(raw); // settles via /wallet/end-round on the FINAL segment
2769
+ };
2770
+ shell.setBusy(true); // block input while the recovered round drains
2771
+ try {
2772
+ await applySegment();
2773
+ while (!r.complete && r.nextActions && r.nextActions.length > 0) {
2774
+ raw = (await ps.play({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId }));
2775
+ r = enrichRoundMeta(opts.normalize(raw), raw);
2776
+ await applySegment();
2777
+ }
2778
+ if (inBonus)
2779
+ shell.setMode('base');
2780
+ }
2781
+ finally {
2782
+ shell.setBusy(false);
2783
+ }
2784
+ };
2785
+ if (mode === 'base') {
2786
+ let activeFeature = null;
2787
+ shell.on('featureActivate', ({ id }) => { activeFeature = id; });
2788
+ shell.on('featureDeactivate', ({ id: _id }) => { activeFeature = null; });
2789
+ const { stakeForAction } = await Promise.resolve().then(function () { return shellConfig; });
2790
+ // Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
2791
+ const ensureAffordable = (action) => {
2792
+ if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9)
2793
+ return true;
2794
+ shell.openModal({
2795
+ availableClose: true,
2796
+ title: shell.t('Insufficient balance'),
2797
+ body: shell.t('You don’t have enough balance for this bet. Lower your bet or top up.'),
2798
+ actions: [{ title: shell.t('OK') }],
2799
+ });
2800
+ return false;
2801
+ };
2802
+ shell.on('spin', () => {
2803
+ const action = activeFeature ?? 'spin';
2804
+ if (!ensureAffordable(action))
2805
+ return;
2806
+ void playRound(action);
2807
+ });
2808
+ shell.on('betChange', (bet) => { currentBet = bet; gameScene()?.onBetChanged?.(bet); });
2809
+ shell.on('buyBonusSelect', ({ id }) => {
2810
+ if (!ensureAffordable(id))
2811
+ return;
2812
+ void playRound(id);
2813
+ });
2814
+ // Autoplay: the shell owns the picker/confirm/STOP/counter/lockout (all driven by state.autoplay);
2815
+ // the host just runs the loop and pushes the per-spin remaining back via setAutoplay.
2816
+ const { createAutoplayLoop } = await Promise.resolve().then(function () { return autoplay; });
2817
+ const autoplay$1 = createAutoplayLoop({
2818
+ resolveAction: () => activeFeature ?? 'spin',
2819
+ canAfford: (a) => ensureAffordable(a),
2820
+ playRound: (a) => Promise.resolve(playRound(a)),
2821
+ onState: (s) => {
2822
+ shell.setAutoplay(s);
2823
+ gameScene()?.onAutoplayChanged?.({ running: s.active, remaining: s.remaining });
2824
+ },
2825
+ });
2826
+ stopAutoplay = () => autoplay$1.stop();
2827
+ shell.on('autoplayStart', (o) => autoplay$1.start(o?.remaining ?? 0));
2828
+ shell.on('autoplayStop', () => autoplay$1.stop());
2829
+ // Resume offer: when the game scene is (or becomes) current on a reload, ask the host whether
2830
+ // a round is still open. If so, offer Continue (replay its animation, then settle) or Finish
2831
+ // (settle now). Settlement is the same playAck path a normal spin uses. Runs at most once.
2832
+ let resumeOffered = false;
2833
+ const offerResume = async () => {
2834
+ if (resumeOffered || !shell || !gameScene())
2835
+ return;
2836
+ resumeOffered = true;
2837
+ let snap = null;
2838
+ try {
2839
+ snap = await ps?.getState() ?? null;
2840
+ }
2841
+ catch {
2842
+ snap = null;
2843
+ }
2844
+ if (!snap)
2845
+ return;
2846
+ shell.openModal({
2847
+ availableClose: false,
2848
+ title: shell.t('Unfinished round'),
2849
+ body: shell.t('You have an unfinished round. Continue it or finish it now?'),
2850
+ actions: [
2851
+ // Continue: replay the round from the start with animation, then settle.
2852
+ { title: shell.t('Continue'), on: () => { void resumeDrain(snap, true); } },
2853
+ // Finish: fast-forward the remaining segments (no animation) to settle the win now.
2854
+ { title: shell.t('Finish'), on: () => { void resumeDrain(snap, false); } },
2855
+ ],
2856
+ });
2857
+ };
2858
+ game.scenes.on('change', () => { void offerResume(); });
2859
+ void offerResume();
2860
+ }
2861
+ else {
2862
+ const stakeMode = stakeBridge?.replayMode ?? 'BASE';
2863
+ const bonusId = resolveReplayBonusId(opts.model, stakeMode);
2864
+ // The replayed round's OWN bet + payout (fetched up front per Stake rules), not the spec's
2865
+ // default bet — otherwise the replay modal always shows bet 1.
2866
+ const replayBet = stakeBridge?.replayBet || currentBet;
2867
+ currentBet = replayBet;
2868
+ // onReplay only spins — the shell reopens the modal after it resolves; never call openReplay inside onReplay (double-open).
2869
+ shell.openReplay({
2870
+ bonusId,
2871
+ bet: replayBet,
2872
+ payoutMultiplier: stakeBridge?.replayPayoutMultiplier ?? 0,
2873
+ onReplay: () => playRound(bonusId),
2874
+ });
2875
+ }
2876
+ }
2877
+ return { game, stakeBridge, shell };
2878
+ }
2879
+
2880
+ // packages/game-engine/src/host/shellConfig.ts
2881
+ // `socialize` is a runtime helper that pixi-shell does NOT re-export (its index only re-exports
2882
+ // types), so it stays sourced from platform-core/shell; the shapes are structurally identical.
2883
+ /**
2884
+ * Apply jurisdiction restrictions over the resolved shell features, in place. A restriction ALWAYS
2885
+ * wins over the author's intent (a forbidden control must stay off even if the game enabled it).
2886
+ */
2887
+ function applyJurisdiction(features, j) {
2888
+ if (!j)
2889
+ return;
2890
+ if (j.disabledTurbo)
2891
+ features.turbo = 0;
2892
+ else if (j.disabledSuperTurbo && features.turbo > 1)
2893
+ features.turbo = 1;
2894
+ if (j.disabledSpacebar)
2895
+ features.spacebar = false;
2896
+ if (j.disabledAutoplay)
2897
+ features.autoplay = null;
2898
+ if (j.disabledBuyFeature)
2899
+ features.buyBonus = false;
2900
+ }
2901
+ /**
2902
+ * Resolve the shell `CurrencyConfig` from the SAME data the Stake bridge uses — the
2903
+ * `CurrencyMetaData` it puts on `initData.config.currency` (symbol + placement from
2904
+ * `symbolAfter`). No second symbol table lives here.
2905
+ *
2906
+ * Fallback chain (dev/devBridge with no Stake meta): `initData.config.currency`
2907
+ * → the spec's currency `code` (neutral `{ symbol: code, position: 'left' }`)
2908
+ * → `{ symbol: '€', position: 'left' }`.
2909
+ */
2910
+ /** Extra precision for WIN / TOTAL-WIN readouts so small-bet wins (e.g. 0.0041 on a 0.01 bet) are
2911
+ * not rounded away to 0.00. Balance / bet stay at the currency's own decimals (`minDecimals`). */
2912
+ const WIN_MAX_DECIMALS = 4;
2913
+ /** Attach decimals: `minDecimals` (balance/bet/prices, fixed) = the currency's decimals; `maxDecimals`
2914
+ * (win/total-win, variable, trailing zeros trimmed) = up to WIN_MAX_DECIMALS — but only when the
2915
+ * currency actually has fraction digits (a 0-decimal currency like JPY keeps wins integer). */
2916
+ function withDecimals(base, decimals) {
2917
+ return {
2918
+ ...base,
2919
+ minDecimals: decimals,
2920
+ maxDecimals: decimals > 0 ? Math.max(decimals, WIN_MAX_DECIMALS) : 0,
2921
+ };
2922
+ }
2923
+ function resolveCurrency(meta, specCurrency) {
2924
+ const hasMeta = !!(meta && meta.symbol);
2925
+ // Single expression, no early-return branches (the bundler was treeshaking the meta branch away).
2926
+ const symbol = hasMeta ? meta.symbol : (specCurrency || '€');
2927
+ const position = hasMeta && meta.symbolAfter ? 'right' : 'left';
2928
+ const decimals = hasMeta && typeof meta.decimals === 'number' ? meta.decimals : 2;
2929
+ return withDecimals({ symbol, position }, decimals);
2930
+ }
2931
+ /** Total stake for an action = bet × the action's cost multiplier (1 for a base spin; e.g. 100 for
2932
+ * a buy bonus). The host uses this to block a play the balance can't cover. */
2933
+ function stakeForAction(model, action, bet) {
2934
+ const cost = (model.spec.actions?.[action]?.cost ?? 1);
2935
+ return cost * bet;
2936
+ }
2937
+ /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT). */
2938
+ function toBonusOptions(model) {
2939
+ const out = [];
2940
+ for (const [key, action] of Object.entries(model.spec.actions)) {
2941
+ const role = action.role ?? 'base';
2942
+ if (role !== 'buy' && role !== 'feature')
2943
+ continue;
2944
+ out.push({
2945
+ id: key,
2946
+ type: role === 'buy' ? 'bonus' : 'feature',
2947
+ title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
2948
+ description: action.description ?? '',
2949
+ priceMultiplier: action.cost ?? (role === 'buy' ? 100 : 1),
2950
+ });
2951
+ }
2952
+ return out;
2953
+ }
2954
+ /** Build a paytable section from the model's derived paytable view (multipliers per symbol count). */
2955
+ function paytableSection(model) {
2956
+ const symbols = model.paytable?.symbols ?? [];
2957
+ const rows = [];
2958
+ for (const s of symbols) {
2959
+ const wins = Object.entries(s.pay ?? {})
2960
+ .map(([count, multiplier]) => ({ count: String(count), multiplier: Number(multiplier) }))
2961
+ .filter((w) => Number.isFinite(w.multiplier) && w.multiplier > 0)
2962
+ .sort((a, b) => Number(a.count) - Number(b.count));
2963
+ if (!wins.length)
2964
+ continue;
2965
+ rows.push({ symbol: { text: s.name ?? s.id }, wins });
2966
+ }
2967
+ if (!rows.length)
2968
+ return null;
2969
+ return { type: 'paytable', title: 'PAYTABLE', rows };
2970
+ }
2971
+ /** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
2972
+ function winsSection(model) {
2973
+ const { cols, rows } = model.spec.grid;
2974
+ const grid = { cols, rows };
2975
+ switch (model.spec.mechanic) {
2976
+ case 'cluster':
2977
+ return { type: 'wins', kind: 'cluster', minCount: 5, grid };
2978
+ case 'ways':
2979
+ return { type: 'wins', kind: 'ways', grid };
2980
+ default:
2981
+ return { type: 'wins', kind: 'anywhere', minCount: 3, grid };
2982
+ }
2983
+ }
2984
+ /** Title of the legal disclaimer section — used to build it and to exempt it from socialization. */
2985
+ const DISCLAIMER_TITLE = 'DISCLAIMER';
2986
+ /** A disclaimer section from initData's disclaimer lines; null when none supplied. */
2987
+ function disclaimerSection(lines) {
2988
+ const clean = (lines ?? []).map((l) => l.trim()).filter(Boolean);
2989
+ if (!clean.length)
2990
+ return null;
2991
+ const html = clean.map((l) => `<p>${l}</p>`).join('');
2992
+ return { type: 'custom', title: DISCLAIMER_TITLE, html };
2993
+ }
2994
+ /** The legal disclaimer must be shown verbatim — this identifies it so socialization skips it. */
2995
+ function isDisclaimerSection(s) {
2996
+ return s.type === 'custom' && s.title === DISCLAIMER_TITLE;
2997
+ }
2998
+ /** Move the legal disclaimer to the very END of the section list — it must always render last,
2999
+ * regardless of where an author merge or an extra section would otherwise place it. */
3000
+ function orderDisclaimerLast(sections) {
3001
+ const disclaimer = sections.filter(isDisclaimerSection);
3002
+ if (!disclaimer.length)
3003
+ return sections;
3004
+ return [...sections.filter((s) => !isDisclaimerSection(s)), ...disclaimer];
3005
+ }
3006
+ /**
3007
+ * Pure: derive a maximal default GameInfoContent from the model + runtime so every game
3008
+ * gets a real info panel for free (paytable, win illustration, controls, and the Stake
3009
+ * disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
3010
+ * section identity (see `mergeGameInfo`), not wholesale-replaced.
3011
+ */
3012
+ function defaultGameInfo(model, runtime) {
3013
+ const sections = [];
3014
+ sections.push(winsSection(model));
3015
+ const pay = paytableSection(model);
3016
+ if (pay)
3017
+ sections.push(pay);
3018
+ const modes = modesSection(model);
3019
+ if (modes)
3020
+ sections.push(modes);
3021
+ sections.push({ type: 'controls' });
3022
+ const disclaimer = disclaimerSection(runtime.disclaimerLines);
3023
+ if (disclaimer)
3024
+ sections.push(disclaimer);
3025
+ return { sections };
3026
+ }
3027
+ /** Per-mode info table (BASE / ANTE / each buy tier) derived from the spec's modes — the SAME SSOT
3028
+ * (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
3029
+ * compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
3030
+ * mode once (in game.spec) and the info table can't drift. `free` actions are excluded (mathModes
3031
+ * already drops them — free spins are part of a bonus, not a purchasable mode). */
3032
+ function modesSection(model) {
3033
+ const modes = model.mathModes ?? [];
3034
+ if (!modes.length)
3035
+ return null;
3036
+ const rows = modes.map((m) => {
3037
+ const action = model.spec.actions[m.action];
3038
+ const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
3039
+ const row = {
3040
+ title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
3041
+ maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
3042
+ };
3043
+ // Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
3044
+ if (m.costMultiplier && m.costMultiplier !== 1)
3045
+ row.price = `${m.costMultiplier}×`;
3046
+ if (typeof m.rtp === 'number')
3047
+ row.rtp = Math.round(m.rtp * 1000) / 10; // 0.965 → 96.5 (%)
3048
+ if (action?.description)
3049
+ row.description = action.description;
3050
+ return row;
3051
+ });
3052
+ return { type: 'modes', title: 'MODES', modes: rows };
3053
+ }
3054
+ /** Identity key for merge. `wins` is keyed by `kind` (different mechanics coexist). `custom` has
3055
+ * no structural discriminant and several can coexist (MAX WIN, DISCLAIMER, …) so it is keyed by
3056
+ * its `title` (an author `custom` with a matching title replaces that derived block; a new title
3057
+ * is added). Every other type is a singleton keyed by `type`. */
3058
+ function sectionKey(s) {
3059
+ if (s.type === 'wins')
3060
+ return `wins:${s.kind}`;
3061
+ if (s.type === 'custom')
3062
+ return `custom:${s.title ?? ''}`;
3063
+ return s.type;
3064
+ }
3065
+ /**
3066
+ * Merge author `gameInfo` over the host-derived set by section identity: an author section
3067
+ * REPLACES the derived section of the same identity (same `type`, or same `wins` `kind`); a new
3068
+ * identity is APPENDED (after the derived ones, in author order); derived sections without an
3069
+ * author override are KEPT. `override` undefined → the pure derived set.
3070
+ */
3071
+ function mergeGameInfo(derived, override) {
3072
+ if (!override)
3073
+ return derived;
3074
+ const authorByKey = new Map();
3075
+ for (const s of override.sections ?? [])
3076
+ authorByKey.set(sectionKey(s), s);
3077
+ const out = [];
3078
+ const used = new Set();
3079
+ // Keep derived order; swap in the author's version where identities collide.
3080
+ for (const s of derived.sections ?? []) {
3081
+ const k = sectionKey(s);
3082
+ const replacement = authorByKey.get(k);
3083
+ if (replacement) {
3084
+ out.push(replacement);
3085
+ used.add(k);
3086
+ }
3087
+ else
3088
+ out.push(s);
3089
+ }
3090
+ // Append author sections whose identity wasn't in the derived set, in author order.
3091
+ for (const s of override.sections ?? []) {
3092
+ const k = sectionKey(s);
3093
+ if (!used.has(k)) {
3094
+ out.push(s);
3095
+ used.add(k);
3096
+ }
3097
+ }
3098
+ return { sections: out };
3099
+ }
3100
+ /** Run a section's player-facing text through `socialize`. Applied to the full MERGED set
3101
+ * (host-derived + author) in social mode. Covers section titles, custom HTML, and PAYTABLE row
3102
+ * symbol labels — the paytable's symbol text comes straight from the gameSpec's `symbols[].name`,
3103
+ * so a forbidden word in a spec symbol name is rewritten here too. A `node`-based custom section is
3104
+ * returned untouched — its DOM is author-owned and not introspected. */
3105
+ function socializeSection(s) {
3106
+ const next = { ...s };
3107
+ if ('title' in next && typeof next.title === 'string') {
3108
+ next.title = socialize(next.title);
3109
+ }
3110
+ if (next.type === 'custom' && typeof next.html === 'string') {
3111
+ next.html = socialize(next.html);
3112
+ }
3113
+ if (next.type === 'paytable' && Array.isArray(next.rows)) {
3114
+ next.rows = next.rows.map((r) => typeof r.symbol?.text === 'string'
3115
+ ? { ...r, symbol: { ...r.symbol, text: socialize(r.symbol.text) } }
3116
+ : r);
3117
+ }
3118
+ if (next.type === 'modes' && Array.isArray(next.modes)) {
3119
+ next.modes = next.modes.map((m) => ({
3120
+ ...m,
3121
+ title: socialize(m.title),
3122
+ ...(m.description ? { description: socialize(m.description) } : {}),
3123
+ }));
3124
+ }
3125
+ return next;
3126
+ }
3127
+ /** Socialize buy-bonus card copy (title/description) when in social mode; a no-op otherwise.
3128
+ * Applied to the final option set (author override or spec-derived) so forbidden words in author
3129
+ * card copy are rewritten too. */
3130
+ function socializeBonusOptions(options, isSocial) {
3131
+ if (!isSocial)
3132
+ return options;
3133
+ return options.map((o) => ({ ...o, title: socialize(o.title), description: socialize(o.description) }));
3134
+ }
3135
+ /** Pure: assemble the shell config (sans mount target) from the model + runtime context
3136
+ * (currency/balance/language/mode). The host adds `app` at the call site. */
3137
+ function buildShellConfig(opts, model, runtime) {
3138
+ // Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
3139
+ const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
3140
+ // Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
3141
+ const defaultBet = runtime.defaultBet ?? model.spec.defaultBet ?? betLevels[0];
3142
+ // runtime.currency is the resolved CurrencyConfig (derived from initData.config.currency by the
3143
+ // host); opts.currency still wins. Fall back to the spec code, then a neutral euro.
3144
+ const currency = opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
3145
+ const isSocial = runtime.social ?? false;
3146
+ // Merge author sections over the host-derived defaults, THEN socialize the WHOLE merged set in
3147
+ // social mode — so restricted gambling vocabulary is rewritten in BOTH the built-in copy AND any
3148
+ // author-supplied text (title + custom HTML). A game can no longer surface a forbidden word in
3149
+ // social mode just because the author wrote it in their own info section. (Custom sections built
3150
+ // from a raw DOM `node` can't be rewritten automatically — author owns the node and can call the
3151
+ // exported `socialize` from '@energy8platform/game-engine/host' on their own strings.)
3152
+ // Author gameInfo may be a plain object or a `(t) => content` factory. `t` socializes when in
3153
+ // social mode (identity otherwise) so authors can wrap copy explicitly; the full merged set is
3154
+ // still socialized below as a safety net.
3155
+ const t = isSocial ? socialize : (text) => text;
3156
+ const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
3157
+ let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime), authored);
3158
+ // The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
3159
+ // wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
3160
+ if (isSocial) {
3161
+ gameInfo = {
3162
+ sections: (gameInfo.sections ?? []).map((s) => (isDisclaimerSection(s) ? s : socializeSection(s))),
3163
+ };
3164
+ }
3165
+ // The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
3166
+ gameInfo = { sections: orderDisclaimerLast(gameInfo.sections ?? []) };
3167
+ // Buy-bonus cards: socialize the FINAL options (author override or spec-derived) in social mode.
3168
+ const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model), isSocial);
3169
+ // Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
3170
+ const features = {
3171
+ turbo: 0,
3172
+ spacebar: true,
3173
+ autoplay: {},
3174
+ buyBonus,
3175
+ ...(opts.features ?? {}),
3176
+ };
3177
+ applyJurisdiction(features, runtime.jurisdiction);
3178
+ return {
3179
+ language: runtime.language ?? 'en',
3180
+ isSocial,
3181
+ currency,
3182
+ gameInfo,
3183
+ availableBets: [...betLevels],
3184
+ defaultBet,
3185
+ currentBet: defaultBet,
3186
+ balance: runtime.balance,
3187
+ win: 0,
3188
+ mode: runtime.mode,
3189
+ features,
3190
+ };
3191
+ }
3192
+
3193
+ var shellConfig = /*#__PURE__*/Object.freeze({
3194
+ __proto__: null,
3195
+ applyJurisdiction: applyJurisdiction,
3196
+ buildShellConfig: buildShellConfig,
3197
+ defaultGameInfo: defaultGameInfo,
3198
+ mergeGameInfo: mergeGameInfo,
3199
+ resolveCurrency: resolveCurrency,
3200
+ stakeForAction: stakeForAction,
3201
+ toBonusOptions: toBonusOptions
3202
+ });
3203
+
3204
+ /** Reverse the model's modeMap (Stake bet mode → SDK action key) for replay labelling/cost. */
3205
+ function resolveReplayBonusId(model, stakeMode) {
3206
+ for (const [action, mode] of Object.entries(model.modeMap)) {
3207
+ if (mode === stakeMode)
3208
+ return action;
3209
+ }
3210
+ return stakeMode;
3211
+ }
3212
+
3213
+ var replay = /*#__PURE__*/Object.freeze({
3214
+ __proto__: null,
3215
+ resolveReplayBonusId: resolveReplayBonusId
3216
+ });
3217
+
3218
+ /**
3219
+ * Pick the scene to START with, given the registered scenes (in order) and the launch mode.
3220
+ *
3221
+ * Rules:
3222
+ * - On a replay launch, scenes flagged `skipOnReplay` are NOT eligible to start (they stay
3223
+ * registered for `goto`, they just aren't auto-started) — so a leading intro is skipped and
3224
+ * the game scene starts directly.
3225
+ * - An explicit `startScene` wins, but only if that scene is itself eligible; otherwise the first
3226
+ * eligible scene wins.
3227
+ * - Falls back to the first scene unconditionally if nothing is eligible (degenerate config).
3228
+ */
3229
+ function resolveStartScene(scenes, isReplay, explicitStart) {
3230
+ const eligible = scenes.filter((s) => !(isReplay && s.skipOnReplay));
3231
+ if (explicitStart) {
3232
+ const ok = eligible.find((s) => s.key === explicitStart);
3233
+ if (ok)
3234
+ return ok.key;
3235
+ }
3236
+ return eligible[0]?.key ?? scenes[0]?.key;
3237
+ }
3238
+
3239
+ var sceneStart = /*#__PURE__*/Object.freeze({
3240
+ __proto__: null,
3241
+ resolveStartScene: resolveStartScene
3242
+ });
3243
+
3244
+ // packages/game-engine/src/host/index.ts
3245
+
3246
+ /**
3247
+ * Enrich a normalized result with round-continuation metadata (roundId / nextActions / complete)
3248
+ * read from the raw play result, so a caller can drain the remaining segments of a multi-segment
3249
+ * round by replaying the SAME roundId. The game's normalizer stays focused on render data. Shared by
3250
+ * `createSlotPlay` (normal play) and the host's resume path (draining a recovered open round).
3251
+ */
3252
+ function enrichRoundMeta(result, raw) {
3253
+ const meta = (raw ?? {});
3254
+ result.roundId = meta.roundId;
3255
+ result.nextActions = meta.nextActions;
3256
+ // A round is complete when there is no open session, or the session reports completed. The host
3257
+ // sets a session on every segment, so this is `session.completed` in practice.
3258
+ result.complete = !meta.session || meta.session.completed === true;
3259
+ return result;
3260
+ }
3261
+ /** Build the host play/ack pair. Host-agnostic wiring; unit-testable. The returned `play` stashes
3262
+ * the raw host result so the matching `ack()` can forward it to `deps.ack` (PlatformSession.playAck)
3263
+ * once the scene has finished animating. Plays are sequential (awaited), so a single stash is safe. */
3264
+ function createSlotPlay(deps) {
3265
+ let lastRaw = null;
3266
+ return {
3267
+ play: async (action, bet, roundId) => {
3268
+ const raw = await deps.play({ action, bet, roundId });
3269
+ lastRaw = raw;
3270
+ const result = enrichRoundMeta(deps.normalize(raw), raw);
3271
+ deps.onWin?.(result.totalWin);
3272
+ return result;
3273
+ },
3274
+ ack: () => {
3275
+ if (lastRaw != null)
3276
+ deps.ack?.(lastRaw);
3277
+ },
3278
+ };
3279
+ }
3280
+
3281
+ var slotPlay = /*#__PURE__*/Object.freeze({
3282
+ __proto__: null,
3283
+ createSlotPlay: createSlotPlay,
3284
+ enrichRoundMeta: enrichRoundMeta
3285
+ });
3286
+
3287
+ async function runRound(deps, action) {
3288
+ deps.onSpinStart?.();
3289
+ const ctxBet = deps.context(action).bet;
3290
+ const segment = async (a, roundId) => {
3291
+ const ac = new AbortController();
3292
+ deps.beforeSegment?.(ac);
3293
+ const r = await deps.play(a, ctxBet, roundId);
3294
+ const ctx = { ...deps.context(action), signal: ac.signal };
3295
+ await deps.scene.onSpin(r, ctx);
3296
+ deps.ack();
3297
+ deps.afterPresent?.(r);
3298
+ return { r, ctx };
3299
+ };
3300
+ let { r, ctx } = await segment(action, undefined);
3301
+ let inMode = false;
3302
+ while (!r.complete && r.nextActions && r.nextActions.length > 0) {
3303
+ const next = r.nextActions[0];
3304
+ if (!inMode && deps.roleOf(next) === 'free') {
3305
+ inMode = true;
3306
+ await deps.onEnterMode?.(r, ctx);
3307
+ }
3308
+ ({ r, ctx } = await segment(next, r.roundId));
3309
+ }
3310
+ if (inMode)
3311
+ await deps.onExitMode?.(r, ctx);
3312
+ deps.onSpinEnd?.(r, ctx);
3313
+ }
3314
+
3315
+ var runRound$1 = /*#__PURE__*/Object.freeze({
3316
+ __proto__: null,
3317
+ runRound: runRound
3318
+ });
3319
+
3320
+ function createBalanceGate(paint, initial = 0) {
3321
+ let latest = initial;
3322
+ let suppressed = false;
3323
+ return {
3324
+ onBalance(amount) {
3325
+ latest = amount;
3326
+ if (!suppressed)
3327
+ paint(amount);
3328
+ },
3329
+ beginPlay() {
3330
+ suppressed = true;
3331
+ },
3332
+ afterPresent() {
3333
+ paint(latest);
3334
+ suppressed = false;
3335
+ },
3336
+ get balance() {
3337
+ return latest;
3338
+ },
3339
+ };
3340
+ }
3341
+
3342
+ var balanceGate = /*#__PURE__*/Object.freeze({
3343
+ __proto__: null,
3344
+ createBalanceGate: createBalanceGate
3345
+ });
3346
+
3347
+ function createFreeSpinsCounter() {
3348
+ let total = 0;
3349
+ let current = 0;
3350
+ return {
3351
+ enter(awarded) {
3352
+ total = awarded;
3353
+ current = 0;
3354
+ return { current, total, totalWin: 0 };
3355
+ },
3356
+ spin(awarded, totalWin) {
3357
+ current += 1;
3358
+ total += awarded; // a retrigger grows the pool
3359
+ return { current, total, totalWin };
3360
+ },
3361
+ };
3362
+ }
3363
+
3364
+ var freeSpinsCounter = /*#__PURE__*/Object.freeze({
3365
+ __proto__: null,
3366
+ createFreeSpinsCounter: createFreeSpinsCounter
3367
+ });
3368
+
3369
+ /** Pull a Stake/SDK error code off an unknown thrown value. */
3370
+ function errorCode(err) {
3371
+ const code = err?.code;
3372
+ return typeof code === 'string' ? code : undefined;
3373
+ }
3374
+ function resolvePlayError(err) {
3375
+ const code = errorCode(err);
3376
+ const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
3377
+ switch (code) {
3378
+ case 'ACTIVE_SESSION_EXISTS':
3379
+ return {
3380
+ title: 'Round in progress',
3381
+ body: 'You have an unfinished round. Reload to resume it.',
3382
+ reload: true,
3383
+ };
3384
+ case 'NO_ACTIVE_SESSION':
3385
+ return {
3386
+ title: 'Round expired',
3387
+ body: 'This round is no longer active. Reload to continue.',
3388
+ reload: true,
3389
+ };
3390
+ case 'INSUFFICIENT_FUNDS':
3391
+ return {
3392
+ title: 'Insufficient balance',
3393
+ body: 'You don’t have enough balance for this bet. Lower your bet or top up.',
3394
+ reload: false,
3395
+ };
3396
+ case 'TIMEOUT':
3397
+ return {
3398
+ title: 'Connection timed out',
3399
+ body: 'The game server did not respond in time. Please try again.',
3400
+ reload: false,
3401
+ };
3402
+ default:
3403
+ // Unknown code: surface the server message verbatim under a generic heading (never the
3404
+ // connection overlay), so an operator can diagnose without a code change.
3405
+ return {
3406
+ title: 'Game error',
3407
+ body: message || 'Something went wrong. Please reload the game.',
3408
+ reload: true,
3409
+ };
3410
+ }
3411
+ }
3412
+
3413
+ var playError = /*#__PURE__*/Object.freeze({
3414
+ __proto__: null,
3415
+ errorCode: errorCode,
3416
+ resolvePlayError: resolvePlayError
3417
+ });
3418
+
3419
+ /** Wrap the engine's AudioManager into the playback-only handle a scene receives. Volume/mute are
3420
+ * deliberately omitted — those are driven by the shell's settingChange → host. */
3421
+ function createSceneAudio(audio) {
3422
+ return {
3423
+ play: (alias, opts) => audio.play(alias, 'sfx', opts),
3424
+ playMusic: (alias, fadeMs) => audio.playMusic(alias, fadeMs),
3425
+ stopMusic: () => audio.stopMusic(),
3426
+ duck: (factor) => audio.duckMusic(factor),
3427
+ unduck: () => audio.unduckMusic(),
3428
+ };
3429
+ }
3430
+
3431
+ var sceneAudio = /*#__PURE__*/Object.freeze({
3432
+ __proto__: null,
3433
+ createSceneAudio: createSceneAudio
3434
+ });
3435
+
3436
+ function createOverlayController(deps) {
3437
+ let current = null;
3438
+ const teardown = () => {
3439
+ if (!current)
3440
+ return;
3441
+ if (current.timer)
3442
+ clearTimeout(current.timer);
3443
+ const { layer, resolve } = current;
3444
+ current = null;
3445
+ layer.removeFromParent();
3446
+ layer.destroy({ children: true });
3447
+ resolve();
3448
+ };
3449
+ const overlay = {
3450
+ show(opts) {
3451
+ if (current) {
3452
+ console.warn('[overlay] show() ignored — an overlay is already open');
3453
+ return Promise.reject(new Error('Overlay already open'));
3454
+ }
3455
+ const { width, height } = deps.size();
3456
+ const layer = new Container();
3457
+ layer.eventMode = 'static';
3458
+ // Pointer-eating + (optional) dim backdrop sized to the canvas.
3459
+ const hit = new Graphics().rect(0, 0, width, height).fill({
3460
+ color: 0x000000,
3461
+ alpha: opts.dim ?? 0.0001, // ~0 keeps it transparent but hit-testable
3462
+ });
3463
+ hit.eventMode = 'static';
3464
+ layer.addChild(hit);
3465
+ const content = new Container();
3466
+ layer.addChild(content);
3467
+ opts.build(content, { width, height });
3468
+ deps.parent.addChild(layer);
3469
+ return new Promise((resolve) => {
3470
+ const dimValue = opts.dim ?? 0.0001;
3471
+ current = { layer, resolve, timer: null, dim: dimValue };
3472
+ const closeOn = opts.closeOn ?? 'tap';
3473
+ if (closeOn === 'tap')
3474
+ hit.on('pointertap', teardown);
3475
+ if (typeof opts.autoCloseMs === 'number') {
3476
+ current.timer = setTimeout(teardown, opts.autoCloseMs);
3477
+ }
3478
+ });
3479
+ },
3480
+ close() { teardown(); },
3481
+ };
3482
+ return {
3483
+ overlay,
3484
+ resize(w, h) {
3485
+ if (!current)
3486
+ return;
3487
+ const hit = current.layer.getChildAt(0);
3488
+ hit.clear().rect(0, 0, w, h).fill({ color: 0x000000, alpha: current.dim });
3489
+ },
3490
+ destroy() { teardown(); },
3491
+ };
3492
+ }
3493
+
3494
+ var overlayController = /*#__PURE__*/Object.freeze({
3495
+ __proto__: null,
3496
+ createOverlayController: createOverlayController
3497
+ });
3498
+
3499
+ /** Pure double-tap recognizer. The host feeds it pointer `tap(now)` (e.g. performance.now()) and
3500
+ * supplies the enabled/active gates + the onSkip effect. */
3501
+ function createDoubleTapSkip(deps) {
3502
+ const threshold = deps.thresholdMs ?? 300;
3503
+ let last = -Infinity;
3504
+ return {
3505
+ tap(now) {
3506
+ const isDouble = now - last <= threshold;
3507
+ last = isDouble ? -Infinity : now; // consume the pair so a 3rd tap starts fresh
3508
+ if (isDouble && deps.enabled() && deps.active())
3509
+ deps.onSkip();
3510
+ },
3511
+ destroy() { last = -Infinity; },
3512
+ };
3513
+ }
3514
+
3515
+ var skipGesture = /*#__PURE__*/Object.freeze({
3516
+ __proto__: null,
3517
+ createDoubleTapSkip: createDoubleTapSkip
3518
+ });
3519
+
3520
+ /** Edge-triggers onHidden/onVisible from a visibility source. Effects (ticker/music/autoplay/scene)
3521
+ * are supplied by the host so this stays pure + testable. */
3522
+ function createPauseController(deps) {
3523
+ let paused = deps.isHidden();
3524
+ const unsub = deps.subscribe(() => {
3525
+ const hidden = deps.isHidden();
3526
+ if (hidden === paused)
3527
+ return;
3528
+ paused = hidden;
3529
+ if (hidden)
3530
+ deps.onHidden();
3531
+ else
3532
+ deps.onVisible();
3533
+ });
3534
+ return { destroy: () => unsub() };
3535
+ }
3536
+
3537
+ var pauseController = /*#__PURE__*/Object.freeze({
3538
+ __proto__: null,
3539
+ createPauseController: createPauseController
3540
+ });
3541
+
3542
+ function createAutoplayLoop(deps) {
3543
+ let active = false;
3544
+ let remaining = 0;
3545
+ let running = false; // guards against a second concurrent loop
3546
+ const stop = () => {
3547
+ if (!active && remaining === 0)
3548
+ return;
3549
+ active = false;
3550
+ remaining = 0;
3551
+ deps.onState({ active: false, remaining: 0 });
3552
+ };
3553
+ async function loop() {
3554
+ if (running)
3555
+ return;
3556
+ running = true;
3557
+ try {
3558
+ while (active && remaining > 0) {
3559
+ const action = deps.resolveAction();
3560
+ if (!deps.canAfford(action)) {
3561
+ stop();
3562
+ return;
3563
+ }
3564
+ // Decrement at spin START (so the spin in flight is `total − remaining`), then play it out.
3565
+ remaining -= 1;
3566
+ deps.onState({ active: true, remaining });
3567
+ try {
3568
+ await deps.playRound(action);
3569
+ }
3570
+ catch {
3571
+ stop(); // a play error already surfaced its own modal — just halt the run
3572
+ return;
3573
+ }
3574
+ }
3575
+ if (active)
3576
+ stop(); // ran the budget out
3577
+ }
3578
+ finally {
3579
+ running = false;
3580
+ }
3581
+ }
3582
+ return {
3583
+ start(count) {
3584
+ if (active || running || count <= 0)
3585
+ return;
3586
+ active = true;
3587
+ remaining = count;
3588
+ deps.onState({ active: true, remaining });
3589
+ void loop();
3590
+ },
3591
+ stop,
3592
+ get active() { return active; },
3593
+ get remaining() { return remaining; },
3594
+ };
3595
+ }
3596
+
3597
+ var autoplay = /*#__PURE__*/Object.freeze({
3598
+ __proto__: null,
3599
+ createAutoplayLoop: createAutoplayLoop
3600
+ });
3601
+
3602
+ export { buildShellConfig, createSlotGame, resolveReplayBonusId, resolveStartScene, stakeForAction };
3603
+ //# sourceMappingURL=host.esm.js.map