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