@energy8platform/game-engine 0.16.0 → 0.18.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 (73) hide show
  1. package/dist/core.cjs.js +62 -1
  2. package/dist/core.cjs.js.map +1 -1
  3. package/dist/core.d.ts +33 -2
  4. package/dist/core.esm.js +63 -3
  5. package/dist/core.esm.js.map +1 -1
  6. package/dist/debug.d.ts +1 -1
  7. package/dist/game-spec.cjs.js +13 -0
  8. package/dist/game-spec.cjs.js.map +1 -0
  9. package/dist/game-spec.d.ts +1 -0
  10. package/dist/game-spec.esm.js +2 -0
  11. package/dist/game-spec.esm.js.map +1 -0
  12. package/dist/host.cjs.js +3346 -0
  13. package/dist/host.cjs.js.map +1 -0
  14. package/dist/host.d.ts +915 -0
  15. package/dist/host.esm.js +3337 -0
  16. package/dist/host.esm.js.map +1 -0
  17. package/dist/index.cjs.js +13 -1
  18. package/dist/index.cjs.js.map +1 -1
  19. package/dist/index.d.ts +7 -2
  20. package/dist/index.esm.js +13 -1
  21. package/dist/index.esm.js.map +1 -1
  22. package/dist/react.cjs.js.map +1 -1
  23. package/dist/react.d.ts +6 -1
  24. package/dist/react.esm.js.map +1 -1
  25. package/dist/shell.cjs.js +19 -0
  26. package/dist/shell.cjs.js.map +1 -0
  27. package/dist/shell.d.ts +1 -0
  28. package/dist/shell.esm.js +2 -0
  29. package/dist/shell.esm.js.map +1 -0
  30. package/dist/slot.cjs.js +998 -0
  31. package/dist/slot.cjs.js.map +1 -0
  32. package/dist/slot.d.ts +333 -0
  33. package/dist/slot.esm.js +985 -0
  34. package/dist/slot.esm.js.map +1 -0
  35. package/package.json +26 -1
  36. package/src/core/GameApplication.ts +16 -1
  37. package/src/core/index.ts +2 -0
  38. package/src/debug/index.ts +1 -1
  39. package/src/game-spec/index.ts +1 -0
  40. package/src/host/autoplay.ts +78 -0
  41. package/src/host/balanceGate.ts +46 -0
  42. package/src/host/buildConfig.ts +28 -0
  43. package/src/host/createSlotGame.ts +423 -0
  44. package/src/host/fatalError.ts +104 -0
  45. package/src/host/freeSpinsCounter.ts +44 -0
  46. package/src/host/index.ts +18 -0
  47. package/src/host/playError.ts +64 -0
  48. package/src/host/preboot.ts +25 -0
  49. package/src/host/replay.ts +9 -0
  50. package/src/host/runRound.ts +63 -0
  51. package/src/host/sceneController.ts +31 -0
  52. package/src/host/sceneStart.ts +25 -0
  53. package/src/host/shellConfig.ts +379 -0
  54. package/src/host/slotPlay.ts +62 -0
  55. package/src/host/types.ts +71 -0
  56. package/src/index.ts +1 -1
  57. package/src/loading/index.ts +2 -0
  58. package/src/scenes/IntroScene.ts +66 -0
  59. package/src/shell/index.ts +20 -0
  60. package/src/slot/anim/CascadeController.ts +102 -0
  61. package/src/slot/anim/ReelSpinController.ts +81 -0
  62. package/src/slot/anim/easing-map.ts +14 -0
  63. package/src/slot/freeSpins/FreeSpinsSession.ts +40 -0
  64. package/src/slot/grid/AnimatedSymbol.ts +68 -0
  65. package/src/slot/grid/ReelGrid.ts +92 -0
  66. package/src/slot/grid/SymbolCell.ts +127 -0
  67. package/src/slot/grid/SymbolView.ts +13 -0
  68. package/src/slot/index.ts +21 -0
  69. package/src/slot/multiplier/MultiplierAccumulator.ts +29 -0
  70. package/src/slot/overlay/BigWinOverlay.ts +89 -0
  71. package/src/slot/overlay/CountUpDisplay.ts +56 -0
  72. package/src/slot/overlay/tiers.ts +29 -0
  73. package/src/types.ts +3 -0
@@ -0,0 +1,3346 @@
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
+ _currentMusic = null;
739
+ _unlocked = false;
740
+ _unlockHandler = null;
741
+ constructor(config) {
742
+ this._persist = config?.persist ?? true;
743
+ this._storageKey = config?.storageKey ?? 'ge_audio';
744
+ this._categories = {
745
+ music: { volume: config?.music ?? 0.7, muted: false },
746
+ sfx: { volume: config?.sfx ?? 1.0, muted: false },
747
+ ui: { volume: config?.ui ?? 0.8, muted: false },
748
+ ambient: { volume: config?.ambient ?? 0.5, muted: false },
749
+ };
750
+ // Restore persisted state
751
+ if (this._persist) {
752
+ this.restoreState();
753
+ }
754
+ }
755
+ /** Whether the audio system is initialized */
756
+ get initialized() {
757
+ return this._initialized;
758
+ }
759
+ /** Whether audio is globally muted */
760
+ get muted() {
761
+ return this._globalMuted;
762
+ }
763
+ /**
764
+ * Initialize the audio system.
765
+ * Dynamically imports @pixi/sound to keep it optional.
766
+ */
767
+ async init() {
768
+ if (this._initialized)
769
+ return;
770
+ try {
771
+ this._soundModule = await import('@pixi/sound');
772
+ this._initialized = true;
773
+ this.applyVolumes();
774
+ if (this._globalMuted) {
775
+ this._soundModule.sound.muteAll();
776
+ }
777
+ this.setupMobileUnlock();
778
+ }
779
+ catch {
780
+ console.warn('[AudioManager] @pixi/sound not available. Audio disabled.');
781
+ this._initialized = false;
782
+ }
783
+ }
784
+ /**
785
+ * Play a sound effect.
786
+ *
787
+ * @param alias - Sound alias (must be loaded via AssetManager)
788
+ * @param category - Audio category (default: 'sfx')
789
+ * @param options - Additional play options
790
+ */
791
+ play(alias, category = 'sfx', options) {
792
+ if (!this._initialized || !this._soundModule)
793
+ return;
794
+ if (this._globalMuted || this._categories[category].muted)
795
+ return;
796
+ const { sound } = this._soundModule;
797
+ const vol = (options?.volume ?? 1) * this._categories[category].volume;
798
+ try {
799
+ sound.play(alias, {
800
+ volume: vol,
801
+ loop: options?.loop ?? false,
802
+ speed: options?.speed ?? 1,
803
+ });
804
+ }
805
+ catch (e) {
806
+ console.warn(`[AudioManager] Failed to play "${alias}":`, e);
807
+ }
808
+ }
809
+ /**
810
+ * Play background music with optional crossfade.
811
+ *
812
+ * @param alias - Music alias
813
+ * @param fadeDuration - Crossfade duration in ms (default: 500)
814
+ */
815
+ playMusic(alias, fadeDuration = 500) {
816
+ if (!this._initialized || !this._soundModule)
817
+ return;
818
+ const { sound } = this._soundModule;
819
+ // Stop current music with fade-out, start new music with fade-in
820
+ if (this._currentMusic && fadeDuration > 0) {
821
+ const prevAlias = this._currentMusic;
822
+ this._currentMusic = alias;
823
+ if (this._globalMuted || this._categories.music.muted)
824
+ return;
825
+ // Fade out the previous track
826
+ this.fadeVolume(prevAlias, this._categories.music.volume, 0, fadeDuration, () => {
827
+ try {
828
+ sound.stop(prevAlias);
829
+ }
830
+ catch { /* ignore */ }
831
+ });
832
+ // Start new track at zero volume, fade in
833
+ try {
834
+ sound.play(alias, {
835
+ volume: 0,
836
+ loop: true,
837
+ });
838
+ this.fadeVolume(alias, 0, this._categories.music.volume, fadeDuration);
839
+ }
840
+ catch (e) {
841
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
842
+ }
843
+ }
844
+ else {
845
+ // No crossfade — instant switch
846
+ if (this._currentMusic) {
847
+ try {
848
+ sound.stop(this._currentMusic);
849
+ }
850
+ catch { /* ignore */ }
851
+ }
852
+ this._currentMusic = alias;
853
+ if (this._globalMuted || this._categories.music.muted)
854
+ return;
855
+ try {
856
+ sound.play(alias, {
857
+ volume: this._categories.music.volume,
858
+ loop: true,
859
+ });
860
+ }
861
+ catch (e) {
862
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
863
+ }
864
+ }
865
+ }
866
+ /**
867
+ * Stop current music.
868
+ */
869
+ stopMusic() {
870
+ if (!this._initialized || !this._soundModule || !this._currentMusic)
871
+ return;
872
+ const { sound } = this._soundModule;
873
+ try {
874
+ sound.stop(this._currentMusic);
875
+ }
876
+ catch {
877
+ // ignore
878
+ }
879
+ this._currentMusic = null;
880
+ }
881
+ /**
882
+ * Stop all sounds.
883
+ */
884
+ stopAll() {
885
+ if (!this._initialized || !this._soundModule)
886
+ return;
887
+ const { sound } = this._soundModule;
888
+ sound.stopAll();
889
+ this._currentMusic = null;
890
+ }
891
+ /**
892
+ * Set volume for a category.
893
+ */
894
+ setVolume(category, volume) {
895
+ this._categories[category].volume = Math.max(0, Math.min(1, volume));
896
+ this.applyVolumes();
897
+ this.saveState();
898
+ }
899
+ /**
900
+ * Get volume for a category.
901
+ */
902
+ getVolume(category) {
903
+ return this._categories[category].volume;
904
+ }
905
+ /**
906
+ * Mute a specific category.
907
+ */
908
+ muteCategory(category) {
909
+ this._categories[category].muted = true;
910
+ this.applyVolumes();
911
+ this.saveState();
912
+ }
913
+ /**
914
+ * Unmute a specific category.
915
+ */
916
+ unmuteCategory(category) {
917
+ this._categories[category].muted = false;
918
+ this.applyVolumes();
919
+ this.saveState();
920
+ }
921
+ /**
922
+ * Toggle mute for a category.
923
+ */
924
+ toggleCategory(category) {
925
+ this._categories[category].muted = !this._categories[category].muted;
926
+ this.applyVolumes();
927
+ this.saveState();
928
+ return this._categories[category].muted;
929
+ }
930
+ /**
931
+ * Mute all audio globally.
932
+ */
933
+ muteAll() {
934
+ this._globalMuted = true;
935
+ if (this._soundModule) {
936
+ this._soundModule.sound.muteAll();
937
+ }
938
+ this.saveState();
939
+ }
940
+ /**
941
+ * Unmute all audio globally.
942
+ */
943
+ unmuteAll() {
944
+ this._globalMuted = false;
945
+ if (this._soundModule) {
946
+ this._soundModule.sound.unmuteAll();
947
+ }
948
+ this.saveState();
949
+ }
950
+ /**
951
+ * Toggle global mute.
952
+ */
953
+ toggleMute() {
954
+ if (this._globalMuted) {
955
+ this.unmuteAll();
956
+ }
957
+ else {
958
+ this.muteAll();
959
+ }
960
+ return this._globalMuted;
961
+ }
962
+ /**
963
+ * Duck music volume (e.g., during big win presentation).
964
+ *
965
+ * @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal
966
+ */
967
+ duckMusic(factor) {
968
+ if (!this._initialized || !this._soundModule || !this._currentMusic)
969
+ return;
970
+ const { sound } = this._soundModule;
971
+ const vol = this._categories.music.volume * factor;
972
+ try {
973
+ sound.volume(this._currentMusic, vol);
974
+ }
975
+ catch {
976
+ // ignore
977
+ }
978
+ }
979
+ /**
980
+ * Restore music to normal volume after ducking.
981
+ */
982
+ unduckMusic() {
983
+ if (!this._initialized || !this._soundModule || !this._currentMusic)
984
+ return;
985
+ const { sound } = this._soundModule;
986
+ try {
987
+ sound.volume(this._currentMusic, this._categories.music.volume);
988
+ }
989
+ catch {
990
+ // ignore
991
+ }
992
+ }
993
+ /**
994
+ * Destroy the audio manager and free resources.
995
+ */
996
+ destroy() {
997
+ this.stopAll();
998
+ this.removeMobileUnlock();
999
+ if (this._soundModule) {
1000
+ this._soundModule.sound.removeAll();
1001
+ }
1002
+ this._initialized = false;
1003
+ }
1004
+ // ─── Private ───────────────────────────────────────────
1005
+ /**
1006
+ * Smoothly fade a sound's volume from `fromVol` to `toVol` over `durationMs`.
1007
+ */
1008
+ fadeVolume(alias, fromVol, toVol, durationMs, onComplete) {
1009
+ if (!this._soundModule)
1010
+ return;
1011
+ const { sound } = this._soundModule;
1012
+ const startTime = Date.now();
1013
+ const tick = () => {
1014
+ const elapsed = Date.now() - startTime;
1015
+ const t = Math.min(elapsed / durationMs, 1);
1016
+ const vol = fromVol + (toVol - fromVol) * t;
1017
+ try {
1018
+ sound.volume(alias, vol);
1019
+ }
1020
+ catch { /* ignore */ }
1021
+ if (t < 1) {
1022
+ requestAnimationFrame(tick);
1023
+ }
1024
+ else {
1025
+ onComplete?.();
1026
+ }
1027
+ };
1028
+ requestAnimationFrame(tick);
1029
+ }
1030
+ applyVolumes() {
1031
+ if (!this._soundModule)
1032
+ return;
1033
+ const { sound } = this._soundModule;
1034
+ // Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
1035
+ // not by volumeAll — mixing both leaves mute un-undoable after reload.
1036
+ sound.volumeAll = 1;
1037
+ }
1038
+ setupMobileUnlock() {
1039
+ if (this._unlocked)
1040
+ return;
1041
+ this._unlockHandler = () => {
1042
+ if (!this._soundModule)
1043
+ return;
1044
+ const { sound } = this._soundModule;
1045
+ // Resume WebAudio context
1046
+ if (sound.context?.audioContext?.state === 'suspended') {
1047
+ sound.context.audioContext.resume();
1048
+ }
1049
+ this._unlocked = true;
1050
+ this.removeMobileUnlock();
1051
+ };
1052
+ const events = ['touchstart', 'mousedown', 'pointerdown', 'keydown'];
1053
+ for (const event of events) {
1054
+ document.addEventListener(event, this._unlockHandler, { once: true });
1055
+ }
1056
+ }
1057
+ removeMobileUnlock() {
1058
+ if (!this._unlockHandler)
1059
+ return;
1060
+ const events = ['touchstart', 'mousedown', 'pointerdown', 'keydown'];
1061
+ for (const event of events) {
1062
+ document.removeEventListener(event, this._unlockHandler);
1063
+ }
1064
+ this._unlockHandler = null;
1065
+ }
1066
+ saveState() {
1067
+ if (!this._persist)
1068
+ return;
1069
+ try {
1070
+ const state = {
1071
+ globalMuted: this._globalMuted,
1072
+ categories: this._categories,
1073
+ };
1074
+ localStorage.setItem(this._storageKey, JSON.stringify(state));
1075
+ }
1076
+ catch {
1077
+ // localStorage may not be available
1078
+ }
1079
+ }
1080
+ restoreState() {
1081
+ try {
1082
+ const raw = localStorage.getItem(this._storageKey);
1083
+ if (!raw)
1084
+ return;
1085
+ const state = JSON.parse(raw);
1086
+ if (typeof state.globalMuted === 'boolean') {
1087
+ this._globalMuted = state.globalMuted;
1088
+ }
1089
+ if (state.categories) {
1090
+ for (const key of ['music', 'sfx', 'ui', 'ambient']) {
1091
+ if (state.categories[key]) {
1092
+ this._categories[key] = {
1093
+ volume: state.categories[key].volume ?? this._categories[key].volume,
1094
+ muted: state.categories[key].muted ?? false,
1095
+ };
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+ catch {
1101
+ // ignore
1102
+ }
1103
+ }
1104
+ }
1105
+
1106
+ /**
1107
+ * Unified input manager for touch, mouse, and keyboard.
1108
+ *
1109
+ * Features:
1110
+ * - Unified pointer events (works with touch + mouse)
1111
+ * - Swipe gesture detection
1112
+ * - Keyboard input with isKeyDown state
1113
+ * - Input locking (block input during animations)
1114
+ *
1115
+ * @example
1116
+ * ```ts
1117
+ * const input = new InputManager(app.canvas);
1118
+ *
1119
+ * input.on('tap', ({ x, y }) => console.log('Tapped at', x, y));
1120
+ * input.on('swipe', ({ direction }) => console.log('Swiped', direction));
1121
+ * input.on('keydown', ({ key }) => {
1122
+ * if (key === ' ') spin();
1123
+ * });
1124
+ *
1125
+ * // Block input during animations
1126
+ * input.lock();
1127
+ * await playAnimation();
1128
+ * input.unlock();
1129
+ * ```
1130
+ */
1131
+ class InputManager extends EventEmitter {
1132
+ _canvas;
1133
+ _locked = false;
1134
+ _keysDown = new Set();
1135
+ _destroyed = false;
1136
+ // Viewport transform (set by ViewportManager via setViewportTransform)
1137
+ _viewportScale = 1;
1138
+ _viewportOffsetX = 0;
1139
+ _viewportOffsetY = 0;
1140
+ // Gesture tracking
1141
+ _pointerStart = null;
1142
+ _swipeThreshold = 50; // minimum distance in px
1143
+ _swipeMaxTime = 300; // max ms for swipe gesture
1144
+ constructor(canvas) {
1145
+ super();
1146
+ this._canvas = canvas;
1147
+ this.setupPointerEvents();
1148
+ this.setupKeyboardEvents();
1149
+ }
1150
+ /** Whether input is currently locked */
1151
+ get locked() {
1152
+ return this._locked;
1153
+ }
1154
+ /** Lock all input (e.g., during animations) */
1155
+ lock() {
1156
+ this._locked = true;
1157
+ }
1158
+ /** Unlock input */
1159
+ unlock() {
1160
+ this._locked = false;
1161
+ }
1162
+ /** Check if a key is currently pressed */
1163
+ isKeyDown(key) {
1164
+ return this._keysDown.has(key.toLowerCase());
1165
+ }
1166
+ /**
1167
+ * Update the viewport transform used for DOM→world coordinate mapping.
1168
+ * Called automatically by GameApplication when ViewportManager emits resize.
1169
+ */
1170
+ setViewportTransform(scale, offsetX, offsetY) {
1171
+ this._viewportScale = scale;
1172
+ this._viewportOffsetX = offsetX;
1173
+ this._viewportOffsetY = offsetY;
1174
+ }
1175
+ /**
1176
+ * Convert a DOM canvas position to game-world coordinates,
1177
+ * accounting for viewport scaling and offset.
1178
+ */
1179
+ getWorldPosition(canvasX, canvasY) {
1180
+ return {
1181
+ x: (canvasX - this._viewportOffsetX) / this._viewportScale,
1182
+ y: (canvasY - this._viewportOffsetY) / this._viewportScale,
1183
+ };
1184
+ }
1185
+ /** Destroy the input manager */
1186
+ destroy() {
1187
+ this._destroyed = true;
1188
+ this._canvas.removeEventListener('pointerdown', this.onPointerDown);
1189
+ this._canvas.removeEventListener('pointerup', this.onPointerUp);
1190
+ this._canvas.removeEventListener('pointermove', this.onPointerMove);
1191
+ document.removeEventListener('keydown', this.onKeyDown);
1192
+ document.removeEventListener('keyup', this.onKeyUp);
1193
+ this._keysDown.clear();
1194
+ this.removeAllListeners();
1195
+ }
1196
+ // ─── Private: Pointer ──────────────────────────────────
1197
+ setupPointerEvents() {
1198
+ this._canvas.addEventListener('pointerdown', this.onPointerDown);
1199
+ this._canvas.addEventListener('pointerup', this.onPointerUp);
1200
+ this._canvas.addEventListener('pointermove', this.onPointerMove);
1201
+ }
1202
+ onPointerDown = (e) => {
1203
+ if (this._locked || this._destroyed)
1204
+ return;
1205
+ const pos = this.getCanvasPosition(e);
1206
+ this._pointerStart = { ...pos, time: Date.now() };
1207
+ this.emit('press', pos);
1208
+ };
1209
+ onPointerUp = (e) => {
1210
+ if (this._locked || this._destroyed)
1211
+ return;
1212
+ const pos = this.getCanvasPosition(e);
1213
+ this.emit('release', pos);
1214
+ // Check for tap vs swipe
1215
+ if (this._pointerStart) {
1216
+ const dx = pos.x - this._pointerStart.x;
1217
+ const dy = pos.y - this._pointerStart.y;
1218
+ const dist = Math.sqrt(dx * dx + dy * dy);
1219
+ const elapsed = Date.now() - this._pointerStart.time;
1220
+ if (dist > this._swipeThreshold && elapsed < this._swipeMaxTime) {
1221
+ // Swipe detected
1222
+ const absDx = Math.abs(dx);
1223
+ const absDy = Math.abs(dy);
1224
+ let direction;
1225
+ if (absDx > absDy) {
1226
+ direction = dx > 0 ? 'right' : 'left';
1227
+ }
1228
+ else {
1229
+ direction = dy > 0 ? 'down' : 'up';
1230
+ }
1231
+ this.emit('swipe', { direction, velocity: dist / elapsed });
1232
+ }
1233
+ else if (dist < 10) {
1234
+ // Tap (minimal movement)
1235
+ this.emit('tap', pos);
1236
+ }
1237
+ }
1238
+ this._pointerStart = null;
1239
+ };
1240
+ onPointerMove = (e) => {
1241
+ if (this._locked || this._destroyed)
1242
+ return;
1243
+ this.emit('move', this.getCanvasPosition(e));
1244
+ };
1245
+ getCanvasPosition(e) {
1246
+ const rect = this._canvas.getBoundingClientRect();
1247
+ return {
1248
+ x: e.clientX - rect.left,
1249
+ y: e.clientY - rect.top,
1250
+ };
1251
+ }
1252
+ // ─── Private: Keyboard ─────────────────────────────────
1253
+ setupKeyboardEvents() {
1254
+ document.addEventListener('keydown', this.onKeyDown);
1255
+ document.addEventListener('keyup', this.onKeyUp);
1256
+ }
1257
+ onKeyDown = (e) => {
1258
+ if (this._locked || this._destroyed)
1259
+ return;
1260
+ this._keysDown.add(e.key.toLowerCase());
1261
+ this.emit('keydown', { key: e.key, code: e.code });
1262
+ };
1263
+ onKeyUp = (e) => {
1264
+ if (this._destroyed)
1265
+ return;
1266
+ this._keysDown.delete(e.key.toLowerCase());
1267
+ if (this._locked)
1268
+ return;
1269
+ this.emit('keyup', { key: e.key, code: e.code });
1270
+ };
1271
+ }
1272
+
1273
+ /**
1274
+ * Manages responsive scaling of the game canvas to fit its container.
1275
+ *
1276
+ * Supports three scale modes:
1277
+ * - **FIT** — letterbox/pillarbox to maintain aspect ratio (industry standard)
1278
+ * - **FILL** — fill container, crop edges
1279
+ * - **STRETCH** — stretch to fill (distorts)
1280
+ *
1281
+ * Also handles:
1282
+ * - Orientation detection (landscape/portrait)
1283
+ * - Safe areas (mobile notch)
1284
+ * - ResizeObserver for smooth container resizing
1285
+ *
1286
+ * @example
1287
+ * ```ts
1288
+ * const viewport = new ViewportManager(app, container, {
1289
+ * designWidth: 1920,
1290
+ * designHeight: 1080,
1291
+ * scaleMode: ScaleMode.FIT,
1292
+ * orientation: Orientation.LANDSCAPE,
1293
+ * });
1294
+ *
1295
+ * viewport.on('resize', ({ width, height, scale }) => {
1296
+ * console.log(`New size: ${width}x${height} @ ${scale}x`);
1297
+ * });
1298
+ * ```
1299
+ */
1300
+ class ViewportManager extends EventEmitter {
1301
+ _app;
1302
+ _container;
1303
+ _config;
1304
+ _resizeObserver = null;
1305
+ _currentOrientation = Orientation.LANDSCAPE;
1306
+ _currentWidth = 0;
1307
+ _currentHeight = 0;
1308
+ _currentScale = 1;
1309
+ _destroyed = false;
1310
+ _resizeTimeout = null;
1311
+ constructor(app, container, config) {
1312
+ super();
1313
+ this._app = app;
1314
+ this._container = container;
1315
+ this._config = config;
1316
+ this.setupObserver();
1317
+ }
1318
+ /** Current canvas width in game units */
1319
+ get width() {
1320
+ return this._currentWidth;
1321
+ }
1322
+ /** Current canvas height in game units */
1323
+ get height() {
1324
+ return this._currentHeight;
1325
+ }
1326
+ /** Current scale factor */
1327
+ get scale() {
1328
+ return this._currentScale;
1329
+ }
1330
+ /** Current orientation */
1331
+ get orientation() {
1332
+ return this._currentOrientation;
1333
+ }
1334
+ /** Design reference width */
1335
+ get designWidth() {
1336
+ return this._config.designWidth;
1337
+ }
1338
+ /** Design reference height */
1339
+ get designHeight() {
1340
+ return this._config.designHeight;
1341
+ }
1342
+ /**
1343
+ * Force a resize calculation. Called automatically on container size change.
1344
+ */
1345
+ refresh() {
1346
+ if (this._destroyed)
1347
+ return;
1348
+ const containerWidth = this._container.clientWidth || window.innerWidth;
1349
+ const containerHeight = this._container.clientHeight || window.innerHeight;
1350
+ if (containerWidth === 0 || containerHeight === 0)
1351
+ return;
1352
+ const { designWidth, designHeight, scaleMode } = this._config;
1353
+ const designRatio = designWidth / designHeight;
1354
+ const containerRatio = containerWidth / containerHeight;
1355
+ let gameWidth;
1356
+ let gameHeight;
1357
+ let scale;
1358
+ switch (scaleMode) {
1359
+ case ScaleMode.FIT: {
1360
+ if (containerRatio > designRatio) {
1361
+ // Container is wider → pillarbox
1362
+ scale = containerHeight / designHeight;
1363
+ gameWidth = designWidth;
1364
+ gameHeight = designHeight;
1365
+ }
1366
+ else {
1367
+ // Container is taller → letterbox
1368
+ scale = containerWidth / designWidth;
1369
+ gameWidth = designWidth;
1370
+ gameHeight = designHeight;
1371
+ }
1372
+ break;
1373
+ }
1374
+ case ScaleMode.FILL: {
1375
+ if (containerRatio > designRatio) {
1376
+ // Container is wider → crop top/bottom
1377
+ scale = containerWidth / designWidth;
1378
+ }
1379
+ else {
1380
+ // Container is taller → crop left/right
1381
+ scale = containerHeight / designHeight;
1382
+ }
1383
+ gameWidth = containerWidth / scale;
1384
+ gameHeight = containerHeight / scale;
1385
+ break;
1386
+ }
1387
+ case ScaleMode.STRETCH: {
1388
+ gameWidth = designWidth;
1389
+ gameHeight = designHeight;
1390
+ scale = 1; // stretch is handled by CSS
1391
+ break;
1392
+ }
1393
+ default:
1394
+ gameWidth = designWidth;
1395
+ gameHeight = designHeight;
1396
+ scale = 1;
1397
+ }
1398
+ // Resize the renderer
1399
+ this._app.renderer.resize(Math.round(containerWidth), Math.round(containerHeight));
1400
+ // Scale the stage
1401
+ const stageScale = scaleMode === ScaleMode.STRETCH
1402
+ ? Math.min(containerWidth / designWidth, containerHeight / designHeight)
1403
+ : scale;
1404
+ this._app.stage.scale.set(stageScale);
1405
+ // Center the stage for FIT mode
1406
+ if (scaleMode === ScaleMode.FIT) {
1407
+ this._app.stage.x = Math.round((containerWidth - designWidth * stageScale) / 2);
1408
+ this._app.stage.y = Math.round((containerHeight - designHeight * stageScale) / 2);
1409
+ }
1410
+ else if (scaleMode === ScaleMode.FILL) {
1411
+ this._app.stage.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
1412
+ this._app.stage.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
1413
+ }
1414
+ else {
1415
+ this._app.stage.x = 0;
1416
+ this._app.stage.y = 0;
1417
+ }
1418
+ this._currentWidth = gameWidth;
1419
+ this._currentHeight = gameHeight;
1420
+ this._currentScale = stageScale;
1421
+ // Check orientation
1422
+ const newOrientation = containerWidth >= containerHeight ? Orientation.LANDSCAPE : Orientation.PORTRAIT;
1423
+ if (newOrientation !== this._currentOrientation) {
1424
+ this._currentOrientation = newOrientation;
1425
+ this.emit('orientationChange', newOrientation);
1426
+ }
1427
+ this.emit('resize', {
1428
+ width: gameWidth,
1429
+ height: gameHeight,
1430
+ scale: stageScale,
1431
+ });
1432
+ }
1433
+ /**
1434
+ * Destroy the viewport manager.
1435
+ */
1436
+ destroy() {
1437
+ this._destroyed = true;
1438
+ this._resizeObserver?.disconnect();
1439
+ this._resizeObserver = null;
1440
+ // Remove fallback window resize listener if it was used
1441
+ window.removeEventListener('resize', this.onWindowResize);
1442
+ if (this._resizeTimeout !== null) {
1443
+ clearTimeout(this._resizeTimeout);
1444
+ }
1445
+ this.removeAllListeners();
1446
+ }
1447
+ // ─── Private ───────────────────────────────────────────
1448
+ setupObserver() {
1449
+ if (typeof ResizeObserver !== 'undefined') {
1450
+ this._resizeObserver = new ResizeObserver(() => {
1451
+ this.debouncedRefresh();
1452
+ });
1453
+ this._resizeObserver.observe(this._container);
1454
+ }
1455
+ else {
1456
+ // Fallback for older browsers
1457
+ window.addEventListener('resize', this.onWindowResize);
1458
+ }
1459
+ }
1460
+ onWindowResize = () => {
1461
+ this.debouncedRefresh();
1462
+ };
1463
+ debouncedRefresh() {
1464
+ if (this._resizeTimeout !== null) {
1465
+ clearTimeout(this._resizeTimeout);
1466
+ }
1467
+ this._resizeTimeout = window.setTimeout(() => {
1468
+ this.refresh();
1469
+ this._resizeTimeout = null;
1470
+ }, 16); // ~1 frame
1471
+ }
1472
+ }
1473
+
1474
+ /**
1475
+ * Base class for all scenes.
1476
+ * Provides a root PixiJS Container and lifecycle hooks.
1477
+ *
1478
+ * @example
1479
+ * ```ts
1480
+ * class MenuScene extends Scene {
1481
+ * async onEnter() {
1482
+ * const bg = Sprite.from('menu-bg');
1483
+ * this.container.addChild(bg);
1484
+ * }
1485
+ *
1486
+ * onUpdate(dt: number) {
1487
+ * // per-frame logic
1488
+ * }
1489
+ *
1490
+ * onResize(width: number, height: number) {
1491
+ * // reposition UI
1492
+ * }
1493
+ * }
1494
+ * ```
1495
+ */
1496
+ class Scene {
1497
+ container;
1498
+ constructor() {
1499
+ this.container = new pixi_js.Container();
1500
+ this.container.label = this.constructor.name;
1501
+ }
1502
+ }
1503
+
1504
+ /**
1505
+ * Build the loading scene variant of the logo SVG.
1506
+ * Uses unique IDs (prefixed with 'ls') to avoid collisions with CSSPreloader.
1507
+ */
1508
+ function buildLoadingLogoSVG() {
1509
+ return loading.buildLogoSVG({
1510
+ idPrefix: 'ls',
1511
+ svgStyle: 'width:100%;height:auto;',
1512
+ clipRectId: 'ge-loader-rect',
1513
+ textId: 'ge-loader-pct',
1514
+ textContent: '0%',
1515
+ });
1516
+ }
1517
+ /**
1518
+ * Built-in loading screen using the Energy8 SVG logo with animated loader bar.
1519
+ *
1520
+ * Renders as an HTML overlay on top of the canvas for crisp SVG quality.
1521
+ * The loader bar fill width is driven by asset loading progress.
1522
+ */
1523
+ class LoadingScene extends Scene {
1524
+ _engine;
1525
+ _targetScene;
1526
+ _targetData;
1527
+ _config;
1528
+ // HTML overlay
1529
+ _overlay = null;
1530
+ _loaderRect = null;
1531
+ _percentEl = null;
1532
+ _tapToStartEl = null;
1533
+ // State
1534
+ _displayedProgress = 0;
1535
+ _targetProgress = 0;
1536
+ _loadingComplete = false;
1537
+ _startTime = 0;
1538
+ async onEnter(data) {
1539
+ const { engine, targetScene, targetData } = data;
1540
+ this._engine = engine;
1541
+ this._targetScene = targetScene;
1542
+ this._targetData = targetData;
1543
+ this._config = engine.config.loading ?? {};
1544
+ this._startTime = Date.now();
1545
+ // Create the HTML overlay with the SVG logo
1546
+ this.createOverlay();
1547
+ // Initialize asset manager
1548
+ await this._engine.assets.init();
1549
+ // Initialize audio manager
1550
+ await this._engine.audio.init();
1551
+ // Phase 1: Load preload bundle
1552
+ const bundles = this._engine.assets.getBundleNames();
1553
+ const hasPreload = bundles.includes('preload');
1554
+ if (hasPreload) {
1555
+ const preloadAssets = this._engine.config.manifest?.bundles?.find((b) => b.name === 'preload')?.assets;
1556
+ if (preloadAssets && preloadAssets.length > 0) {
1557
+ await this._engine.assets.loadBundle('preload', (p) => {
1558
+ this._targetProgress = p * 0.15;
1559
+ });
1560
+ }
1561
+ else {
1562
+ this._targetProgress = 0.15;
1563
+ }
1564
+ }
1565
+ // Phase 2: Load remaining bundles
1566
+ const remainingBundles = bundles.filter((b) => b !== 'preload' && !this._engine.assets.isBundleLoaded(b));
1567
+ if (remainingBundles.length > 0) {
1568
+ const hasAssets = remainingBundles.some((name) => {
1569
+ const bundle = this._engine.config.manifest?.bundles?.find((b) => b.name === name);
1570
+ return bundle?.assets && bundle.assets.length > 0;
1571
+ });
1572
+ if (hasAssets) {
1573
+ await this._engine.assets.loadBundles(remainingBundles, (p) => {
1574
+ this._targetProgress = 0.15 + p * 0.85;
1575
+ });
1576
+ }
1577
+ }
1578
+ this._targetProgress = 1;
1579
+ this._loadingComplete = true;
1580
+ // Enforce minimum display time: spread the remaining progress fill
1581
+ // over the remaining time so the bar fills smoothly, not abruptly
1582
+ const minTime = this._config.minDisplayTime ?? 1500;
1583
+ const elapsed = Date.now() - this._startTime;
1584
+ const remaining = Math.max(0, minTime - elapsed);
1585
+ if (remaining > 0) {
1586
+ // Distribute fill animation over the remaining time
1587
+ await this.animateProgressTo(1, remaining);
1588
+ }
1589
+ // Final snap to 100%
1590
+ this._displayedProgress = 1;
1591
+ this.updateLoaderBar(1);
1592
+ // Show "Tap to Start" or transition directly
1593
+ if (this._config.tapToStart !== false) {
1594
+ await this.showTapToStart();
1595
+ }
1596
+ else {
1597
+ await this.transitionToGame();
1598
+ }
1599
+ }
1600
+ onUpdate(dt) {
1601
+ // Smooth progress bar fill via HTML (during active loading)
1602
+ if (!this._loadingComplete && this._displayedProgress < this._targetProgress) {
1603
+ this._displayedProgress = Math.min(this._displayedProgress + dt * 1.5, this._targetProgress);
1604
+ this.updateLoaderBar(this._displayedProgress);
1605
+ }
1606
+ }
1607
+ onResize(_width, _height) {
1608
+ // Overlay is CSS-based, auto-resizes
1609
+ }
1610
+ onDestroy() {
1611
+ this.removeOverlay();
1612
+ }
1613
+ // ─── HTML Overlay ──────────────────────────────────────
1614
+ createOverlay() {
1615
+ const bgColor = typeof this._config.backgroundColor === 'string'
1616
+ ? this._config.backgroundColor
1617
+ : typeof this._config.backgroundColor === 'number'
1618
+ ? `#${this._config.backgroundColor.toString(16).padStart(6, '0')}`
1619
+ : '#0a0a1a';
1620
+ const bgGradient = this._config.backgroundGradient ??
1621
+ `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
1622
+ this._overlay = document.createElement('div');
1623
+ this._overlay.id = '__ge-loading-overlay__';
1624
+ this._overlay.innerHTML = `
1625
+ <div class="ge-loading-content">
1626
+ ${buildLoadingLogoSVG()}
1627
+ </div>
1628
+ `;
1629
+ const style = document.createElement('style');
1630
+ style.id = '__ge-loading-style__';
1631
+ style.textContent = `
1632
+ #__ge-loading-overlay__ {
1633
+ position: absolute;
1634
+ top: 0; left: 0;
1635
+ width: 100%; height: 100%;
1636
+ background: ${bgGradient};
1637
+ display: flex;
1638
+ align-items: center;
1639
+ justify-content: center;
1640
+ z-index: 9999;
1641
+ transition: opacity 0.5s ease-out;
1642
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1643
+ }
1644
+ #__ge-loading-overlay__.ge-fade-out {
1645
+ opacity: 0;
1646
+ pointer-events: none;
1647
+ }
1648
+ .ge-loading-content {
1649
+ display: flex;
1650
+ flex-direction: column;
1651
+ align-items: center;
1652
+ width: 75%;
1653
+ max-width: 650px;
1654
+ }
1655
+ .ge-loading-content svg {
1656
+ filter: drop-shadow(0 0 40px rgba(121, 57, 194, 0.5));
1657
+ cursor: default;
1658
+ }
1659
+
1660
+ .ge-svg-pulse {
1661
+ animation: ge-tap-pulse 1.2s ease-in-out infinite;
1662
+ }
1663
+ @keyframes ge-tap-pulse {
1664
+ 0%, 100% { opacity: 0.5; }
1665
+ 50% { opacity: 1; }
1666
+ }
1667
+ `;
1668
+ // Get the container that holds the canvas
1669
+ const container = this._engine.app?.canvas?.parentElement;
1670
+ if (container) {
1671
+ container.style.position = container.style.position || 'relative';
1672
+ container.appendChild(style);
1673
+ container.appendChild(this._overlay);
1674
+ }
1675
+ // Cache the SVG loader rect for progress updates
1676
+ this._loaderRect = this._overlay.querySelector('#ge-loader-rect');
1677
+ this._percentEl = this._overlay.querySelector('#ge-loader-pct');
1678
+ }
1679
+ removeOverlay() {
1680
+ this._overlay?.remove();
1681
+ document.getElementById('__ge-loading-style__')?.remove();
1682
+ this._overlay = null;
1683
+ this._loaderRect = null;
1684
+ this._percentEl = null;
1685
+ this._tapToStartEl = null;
1686
+ }
1687
+ // ─── Progress ──────────────────────────────────────────
1688
+ updateLoaderBar(progress) {
1689
+ if (this._loaderRect) {
1690
+ this._loaderRect.setAttribute('width', String(loading.LOADER_BAR_MAX_WIDTH * progress));
1691
+ }
1692
+ if (this._percentEl) {
1693
+ const pct = Math.round(progress * 100);
1694
+ this._percentEl.textContent = `${pct}%`;
1695
+ }
1696
+ }
1697
+ /**
1698
+ * Smoothly animate the displayed progress from its current value to `target`
1699
+ * over `durationMs` using an easeOutCubic curve.
1700
+ */
1701
+ async animateProgressTo(target, durationMs) {
1702
+ const startVal = this._displayedProgress;
1703
+ const delta = target - startVal;
1704
+ if (delta <= 0 || durationMs <= 0)
1705
+ return;
1706
+ const startTime = Date.now();
1707
+ return new Promise((resolve) => {
1708
+ const tick = () => {
1709
+ const elapsed = Date.now() - startTime;
1710
+ const t = Math.min(elapsed / durationMs, 1);
1711
+ // easeOutCubic for a natural deceleration feel
1712
+ const eased = 1 - Math.pow(1 - t, 3);
1713
+ this._displayedProgress = startVal + delta * eased;
1714
+ this.updateLoaderBar(this._displayedProgress);
1715
+ if (t < 1) {
1716
+ requestAnimationFrame(tick);
1717
+ }
1718
+ else {
1719
+ resolve();
1720
+ }
1721
+ };
1722
+ requestAnimationFrame(tick);
1723
+ });
1724
+ }
1725
+ // ─── Tap to Start ─────────────────────────────────────
1726
+ async showTapToStart() {
1727
+ const tapText = this._config.tapToStartText ?? 'TAP TO START';
1728
+ // Reuse the same SVG text element — replace percentage with tap text
1729
+ if (this._percentEl) {
1730
+ const el = this._percentEl;
1731
+ el.textContent = tapText;
1732
+ el.setAttribute('fill', '#ffffff');
1733
+ el.classList.add('ge-svg-pulse');
1734
+ this._tapToStartEl = el;
1735
+ }
1736
+ // Make overlay clickable
1737
+ if (this._overlay) {
1738
+ this._overlay.style.cursor = 'pointer';
1739
+ }
1740
+ // Wait for tap
1741
+ return new Promise((resolve) => {
1742
+ const handler = async () => {
1743
+ this._overlay?.removeEventListener('click', handler);
1744
+ await this.transitionToGame();
1745
+ resolve();
1746
+ };
1747
+ // Listen on the full overlay for easier mobile tap
1748
+ this._overlay?.addEventListener('click', handler);
1749
+ });
1750
+ }
1751
+ // ─── Transition ────────────────────────────────────────
1752
+ async transitionToGame() {
1753
+ // Fade out the HTML overlay
1754
+ if (this._overlay) {
1755
+ this._overlay.classList.add('ge-fade-out');
1756
+ await new Promise((resolve) => {
1757
+ this._overlay.addEventListener('transitionend', () => resolve(), { once: true });
1758
+ // Safety timeout
1759
+ setTimeout(resolve, 600);
1760
+ });
1761
+ }
1762
+ // Remove overlay
1763
+ this.removeOverlay();
1764
+ // Navigate to the target scene, always passing the engine reference
1765
+ await this._engine.scenes.goto(this._targetScene, {
1766
+ engine: this._engine,
1767
+ ...(this._targetData && typeof this._targetData === 'object' ? this._targetData : { data: this._targetData }),
1768
+ });
1769
+ }
1770
+ }
1771
+
1772
+ /**
1773
+ * FPS overlay for debugging performance.
1774
+ *
1775
+ * Shows FPS, frame time, and draw call count in the corner of the screen.
1776
+ *
1777
+ * @example
1778
+ * ```ts
1779
+ * const fps = new FPSOverlay(app);
1780
+ * fps.show();
1781
+ * ```
1782
+ */
1783
+ class FPSOverlay {
1784
+ _app;
1785
+ _container;
1786
+ _fpsText;
1787
+ _visible = false;
1788
+ _samples = [];
1789
+ _maxSamples = 60;
1790
+ _lastUpdate = 0;
1791
+ _tickFn = null;
1792
+ constructor(app) {
1793
+ this._app = app;
1794
+ this._container = new pixi_js.Container();
1795
+ this._container.label = 'FPSOverlay';
1796
+ this._container.zIndex = 99999;
1797
+ this._fpsText = new pixi_js.Text({
1798
+ text: 'FPS: --',
1799
+ style: {
1800
+ fontFamily: 'monospace',
1801
+ fontSize: 14,
1802
+ fill: 0x00ff00,
1803
+ stroke: { color: 0x000000, width: 2 },
1804
+ },
1805
+ });
1806
+ this._fpsText.x = 8;
1807
+ this._fpsText.y = 8;
1808
+ this._container.addChild(this._fpsText);
1809
+ }
1810
+ /** Show the FPS overlay */
1811
+ show() {
1812
+ if (this._visible)
1813
+ return;
1814
+ this._visible = true;
1815
+ this._app.stage.addChild(this._container);
1816
+ this._tickFn = (ticker) => {
1817
+ this._samples.push(ticker.FPS);
1818
+ if (this._samples.length > this._maxSamples) {
1819
+ this._samples.shift();
1820
+ }
1821
+ // Update display every ~500ms
1822
+ const now = Date.now();
1823
+ if (now - this._lastUpdate > 500) {
1824
+ const avg = this._samples.reduce((a, b) => a + b, 0) / this._samples.length;
1825
+ const min = Math.min(...this._samples);
1826
+ this._fpsText.text = [
1827
+ `FPS: ${Math.round(avg)} (min: ${Math.round(min)})`,
1828
+ `Frame: ${ticker.deltaMS.toFixed(1)}ms`,
1829
+ ].join('\n');
1830
+ this._lastUpdate = now;
1831
+ }
1832
+ };
1833
+ this._app.ticker.add(this._tickFn);
1834
+ }
1835
+ /** Hide the FPS overlay */
1836
+ hide() {
1837
+ if (!this._visible)
1838
+ return;
1839
+ this._visible = false;
1840
+ this._container.removeFromParent();
1841
+ if (this._tickFn) {
1842
+ this._app.ticker.remove(this._tickFn);
1843
+ this._tickFn = null;
1844
+ }
1845
+ }
1846
+ /** Toggle visibility */
1847
+ toggle() {
1848
+ if (this._visible) {
1849
+ this.hide();
1850
+ }
1851
+ else {
1852
+ this.show();
1853
+ }
1854
+ }
1855
+ /** Destroy the overlay */
1856
+ destroy() {
1857
+ this.hide();
1858
+ this._container.destroy({ children: true });
1859
+ }
1860
+ }
1861
+
1862
+ /**
1863
+ * The main entry point for a game built on @energy8platform/game-engine.
1864
+ *
1865
+ * Orchestrates the full lifecycle:
1866
+ * 1. Create PixiJS Application
1867
+ * 2. Initialize SDK (or run offline)
1868
+ * 3. Show CSS preloader → Canvas loading screen with progress bar
1869
+ * 4. Load asset manifest
1870
+ * 5. Transition to the first game scene
1871
+ *
1872
+ * @example
1873
+ * ```ts
1874
+ * import { GameApplication, ScaleMode } from '@energy8platform/game-engine';
1875
+ * import { GameScene } from './scenes/GameScene';
1876
+ *
1877
+ * const game = new GameApplication({
1878
+ * container: '#game',
1879
+ * designWidth: 1920,
1880
+ * designHeight: 1080,
1881
+ * scaleMode: ScaleMode.FIT,
1882
+ * manifest: { bundles: [
1883
+ * { name: 'preload', assets: [{ alias: 'logo', src: 'logo.png' }] },
1884
+ * { name: 'game', assets: [{ alias: 'bg', src: 'background.png' }] },
1885
+ * ]},
1886
+ * loading: { tapToStart: true },
1887
+ * });
1888
+ *
1889
+ * game.scenes.register('game', GameScene);
1890
+ * await game.start('game');
1891
+ * ```
1892
+ */
1893
+ class GameApplication extends EventEmitter {
1894
+ // ─── Public references ──────────────────────────────────
1895
+ /** PixiJS Application instance */
1896
+ app;
1897
+ /** Scene manager */
1898
+ scenes;
1899
+ /** Asset manager */
1900
+ assets;
1901
+ /** Audio manager */
1902
+ audio;
1903
+ /** Input manager */
1904
+ input;
1905
+ /** Viewport manager */
1906
+ viewport;
1907
+ /** SDK instance (null in offline mode) */
1908
+ sdk = null;
1909
+ /** FPS overlay instance (only when debug: true) */
1910
+ fpsOverlay = null;
1911
+ /** Data received from SDK initialization */
1912
+ initData = null;
1913
+ /** Platform session (SDK + optional DevBridge). null until start() runs. */
1914
+ platformSession = null;
1915
+ /** Branded game shell (only when config.shell is set). */
1916
+ shell;
1917
+ /** Configuration */
1918
+ config;
1919
+ // ─── Private state ──────────────────────────────────────
1920
+ _running = false;
1921
+ _destroyed = false;
1922
+ _container = null;
1923
+ constructor(config = {}) {
1924
+ super();
1925
+ this.config = {
1926
+ designWidth: 1920,
1927
+ designHeight: 1080,
1928
+ scaleMode: ScaleMode.FIT,
1929
+ orientation: Orientation.ANY,
1930
+ debug: false,
1931
+ ...config,
1932
+ };
1933
+ // Create SceneManager early so scenes can be registered before start()
1934
+ this.scenes = new SceneManager();
1935
+ }
1936
+ // ─── Public getters ─────────────────────────────────────
1937
+ /** Current game config from SDK (or null in offline mode) */
1938
+ get gameConfig() {
1939
+ return this.initData?.config ?? null;
1940
+ }
1941
+ /** Current session data */
1942
+ get session() {
1943
+ return this.initData?.session ?? null;
1944
+ }
1945
+ /** Current balance */
1946
+ get balance() {
1947
+ return this.sdk?.balance ?? 0;
1948
+ }
1949
+ /** Current currency */
1950
+ get currency() {
1951
+ return this.sdk?.currency ?? 'USD';
1952
+ }
1953
+ /** Whether the engine is running */
1954
+ get isRunning() {
1955
+ return this._running;
1956
+ }
1957
+ // ─── Lifecycle ──────────────────────────────────────────
1958
+ /**
1959
+ * Start the game engine. This is the main entry point.
1960
+ *
1961
+ * @param firstScene - Key of the first scene to show after loading (must be registered)
1962
+ * @param sceneData - Optional data to pass to the first scene's onEnter
1963
+ */
1964
+ async start(firstScene, sceneData) {
1965
+ if (this._running) {
1966
+ console.warn('[GameEngine] Already running');
1967
+ return;
1968
+ }
1969
+ try {
1970
+ // 1. Resolve container element
1971
+ this._container = this.resolveContainer();
1972
+ // 2. Show CSS preloader immediately (before PixiJS)
1973
+ loading.createCSSPreloader(this._container, this.config.loading);
1974
+ // 3. Initialize PixiJS
1975
+ await this.initPixi();
1976
+ // 4. Initialize SDK (if enabled)
1977
+ await this.initSDK();
1978
+ // 4b. Mount the branded game shell after the SDK handshake (optional)
1979
+ if (this.config.shell) {
1980
+ const { createGameShell } = await import('@energy8platform/platform-core/shell');
1981
+ this.shell = createGameShell(this.config.shell);
1982
+ }
1983
+ // 5. Merge design dimensions from SDK config
1984
+ this.applySDKConfig();
1985
+ // 6. Initialize sub-systems
1986
+ this.initSubSystems();
1987
+ this.emit('initialized');
1988
+ // 7. Remove CSS preloader, show Canvas loading screen
1989
+ loading.removeCSSPreloader(this._container);
1990
+ // 8. Load assets with loading screen
1991
+ await this.loadAssets(firstScene, sceneData);
1992
+ this.emit('loaded');
1993
+ // 9. Start the game loop
1994
+ this._running = true;
1995
+ this.emit('started');
1996
+ }
1997
+ catch (err) {
1998
+ console.error('[GameEngine] Failed to start:', err);
1999
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
2000
+ throw err;
2001
+ }
2002
+ }
2003
+ /**
2004
+ * Destroy the engine and free all resources.
2005
+ */
2006
+ async destroy() {
2007
+ if (this._destroyed)
2008
+ return;
2009
+ this._destroyed = true;
2010
+ this._running = false;
2011
+ if (this.shell) {
2012
+ const { removeGameShell } = await import('@energy8platform/platform-core/shell');
2013
+ await removeGameShell();
2014
+ this.shell = undefined;
2015
+ }
2016
+ this.scenes?.destroy();
2017
+ this.input?.destroy();
2018
+ this.audio?.destroy();
2019
+ this.viewport?.destroy();
2020
+ this.platformSession?.destroy();
2021
+ this.app?.destroy(true, { children: true, texture: true });
2022
+ this.emit('destroyed');
2023
+ this.removeAllListeners();
2024
+ }
2025
+ // ─── Private initialization steps ──────────────────────
2026
+ resolveContainer() {
2027
+ if (typeof this.config.container === 'string') {
2028
+ const el = document.querySelector(this.config.container);
2029
+ if (!el)
2030
+ throw new Error(`[GameEngine] Container "${this.config.container}" not found`);
2031
+ return el;
2032
+ }
2033
+ return this.config.container ?? document.body;
2034
+ }
2035
+ async initPixi() {
2036
+ this.app = new pixi_js.Application();
2037
+ const pixiOpts = {
2038
+ preference: 'webgl',
2039
+ background: typeof this.config.loading?.backgroundColor === 'number'
2040
+ ? this.config.loading.backgroundColor
2041
+ : 0x000000,
2042
+ antialias: true,
2043
+ resolution: Math.min(window.devicePixelRatio, 2),
2044
+ autoDensity: true,
2045
+ ...this.config.pixi,
2046
+ };
2047
+ await this.app.init(pixiOpts);
2048
+ // Append canvas to container
2049
+ this._container.appendChild(this.app.canvas);
2050
+ // Set canvas style
2051
+ this.app.canvas.style.display = 'block';
2052
+ this.app.canvas.style.width = '100%';
2053
+ this.app.canvas.style.height = '100%';
2054
+ }
2055
+ async initSDK() {
2056
+ // Delegate the SDK handshake (and any optional in-process DevBridge
2057
+ // wiring) to platform-core. The session forwards SDK events upward.
2058
+ this.platformSession = await platformCore.createPlatformSession({ sdk: this.config.sdk });
2059
+ this.sdk = this.platformSession.sdk;
2060
+ this.initData = this.platformSession.initData;
2061
+ this.platformSession.on('error', (err) => {
2062
+ this.emit('error', err);
2063
+ });
2064
+ this.platformSession.on('balanceUpdate', (data) => {
2065
+ this.emit('balanceUpdate', data);
2066
+ });
2067
+ }
2068
+ applySDKConfig() {
2069
+ // If SDK provides viewport dimensions, use them as design reference
2070
+ if (this.initData?.config?.viewport) {
2071
+ const vp = this.initData.config.viewport;
2072
+ if (!this.config.designWidth)
2073
+ this.config.designWidth = vp.width;
2074
+ if (!this.config.designHeight)
2075
+ this.config.designHeight = vp.height;
2076
+ }
2077
+ }
2078
+ initSubSystems() {
2079
+ // Asset Manager
2080
+ const basePath = this.initData?.assetsUrl ?? '';
2081
+ this.assets = new AssetManager(basePath, this.config.manifest);
2082
+ // Audio Manager
2083
+ this.audio = new AudioManager(this.config.audio);
2084
+ // Input Manager
2085
+ this.input = new InputManager(this.app.canvas);
2086
+ // Viewport Manager
2087
+ this.viewport = new ViewportManager(this.app, this._container, {
2088
+ designWidth: this.config.designWidth,
2089
+ designHeight: this.config.designHeight,
2090
+ scaleMode: this.config.scaleMode,
2091
+ orientation: this.config.orientation,
2092
+ });
2093
+ // Wire SceneManager to the PixiJS stage
2094
+ this.scenes.setRoot(this.app.stage);
2095
+ this.scenes.setApp(this);
2096
+ // Wire viewport resize → scene manager + input manager
2097
+ this.viewport.on('resize', ({ width, height, scale }) => {
2098
+ this.scenes.resize(width, height);
2099
+ this.input.setViewportTransform(scale, this.app.stage.x, this.app.stage.y);
2100
+ this.emit('resize', { width, height });
2101
+ });
2102
+ this.viewport.on('orientationChange', (orientation) => {
2103
+ this.emit('orientationChange', orientation);
2104
+ });
2105
+ // Wire scene changes → engine event
2106
+ this.scenes.on('change', ({ from, to }) => {
2107
+ this.emit('sceneChange', { from, to });
2108
+ });
2109
+ // Connect ticker → scene updates
2110
+ this.app.ticker.add((ticker) => {
2111
+ // Always update scenes (loading screen needs onUpdate before _running=true)
2112
+ this.scenes.update(ticker.deltaTime / 60); // convert to seconds
2113
+ });
2114
+ // Trigger initial resize
2115
+ this.viewport.refresh();
2116
+ // Enable FPS overlay in debug mode
2117
+ if (this.config.debug) {
2118
+ this.fpsOverlay = new FPSOverlay(this.app);
2119
+ this.fpsOverlay.show();
2120
+ }
2121
+ }
2122
+ async loadAssets(firstScene, sceneData) {
2123
+ // Register built-in loading scene
2124
+ this.scenes.register('__loading__', LoadingScene);
2125
+ // Enter loading scene
2126
+ await this.scenes.goto('__loading__', {
2127
+ engine: this,
2128
+ targetScene: firstScene,
2129
+ targetData: sceneData,
2130
+ });
2131
+ }
2132
+ }
2133
+
2134
+ // packages/game-engine/src/scenes/IntroScene.ts
2135
+ /**
2136
+ * Reusable splash scene: shows a title (or logo) + "tap to start", then advances.
2137
+ *
2138
+ * The host no longer special-cases the intro. Navigation works like every other
2139
+ * scene: the host injects `goto(key)` into this scene's start data. On tap this
2140
+ * scene calls `onStart` if the game supplied one, otherwise `goto(next ?? 'game')`.
2141
+ * (The built-in can't know the game's scene key generically, so it falls back to
2142
+ * the conventional 'game' key — override via `next`. Scaffold-generated intros
2143
+ * skip this primitive and call `goto('game')` directly.)
2144
+ */
2145
+ class IntroScene extends Scene {
2146
+ layer;
2147
+ async onEnter(data) {
2148
+ const cfg = (data ?? {});
2149
+ const start = () => cfg.onStart ? cfg.onStart() : cfg.goto?.(cfg.next ?? 'game');
2150
+ const layer = new pixi_js.Container();
2151
+ this.layer = layer;
2152
+ this.container.addChild(layer);
2153
+ const title = new pixi_js.Text({
2154
+ text: cfg.title ?? 'PLAY',
2155
+ style: { fill: 0xffffff, fontSize: 96, fontFamily: 'Inter', align: 'center' },
2156
+ });
2157
+ title.anchor.set(0.5);
2158
+ title.position.set(960, 460);
2159
+ layer.addChild(title);
2160
+ if (cfg.tapToStart !== false) {
2161
+ const hint = new pixi_js.Text({
2162
+ text: 'Tap to start',
2163
+ style: { fill: 0xffd24a, fontSize: 36, fontFamily: 'Inter' },
2164
+ });
2165
+ hint.anchor.set(0.5);
2166
+ hint.position.set(960, 600);
2167
+ layer.addChild(hint);
2168
+ }
2169
+ // full-screen tap target
2170
+ const hit = new pixi_js.Graphics().rect(0, 0, 1920, 1080).fill({ color: 0x000000, alpha: 0.001 });
2171
+ hit.eventMode = 'static';
2172
+ hit.cursor = 'pointer';
2173
+ hit.once('pointerdown', () => start());
2174
+ layer.addChild(hit);
2175
+ }
2176
+ onExit() {
2177
+ this.layer?.destroy({ children: true });
2178
+ this.layer = undefined;
2179
+ }
2180
+ }
2181
+
2182
+ /**
2183
+ * Pure: map host options to a GameApplicationConfig with sane defaults.
2184
+ * `isStakeNow` is computed by the orchestrator (kept out of here so this
2185
+ * stays a pure, renderer-free function).
2186
+ */
2187
+ function buildAppConfig(opts, isStakeNow) {
2188
+ return {
2189
+ container: opts.container ?? '#game',
2190
+ designWidth: opts.design?.width ?? 1920,
2191
+ designHeight: opts.design?.height ?? 1080,
2192
+ scaleMode: opts.scaleMode ?? ScaleMode.FILL,
2193
+ orientation: opts.orientation ?? Orientation.ANY,
2194
+ loading: opts.loading ?? { tapToStart: false, minDisplayTime: 600 },
2195
+ manifest: opts.manifest,
2196
+ audio: opts.audio,
2197
+ pixi: opts.pixi,
2198
+ sdk: { devMode: isStakeNow || (opts.dev ?? false) },
2199
+ debug: opts.dev ?? false,
2200
+ };
2201
+ }
2202
+
2203
+ /** Preload web fonts so Pixi text rasterizes with the right glyphs. Never throws. */
2204
+ async function loadFonts(specs) {
2205
+ if (!specs || specs.length === 0)
2206
+ return;
2207
+ try {
2208
+ await Promise.all(specs.map((s) => document.fonts.load(s)));
2209
+ await document.fonts.ready;
2210
+ }
2211
+ catch {
2212
+ /* font CDN unreachable → fall back to system fonts */
2213
+ }
2214
+ }
2215
+ /** Smoother default downscaling for art-heavy slots. Pixel-art games omit this. */
2216
+ function applyTextureDefaults() {
2217
+ pixi_js.TextureSource.defaultOptions.autoGenerateMipmaps = true;
2218
+ }
2219
+ /** Idempotent double-boot guard. Returns true the first time, false thereafter. */
2220
+ function bootGuard(flag = '__e8SlotBooted__') {
2221
+ const w = window;
2222
+ if (w[flag])
2223
+ return false;
2224
+ w[flag] = true;
2225
+ return true;
2226
+ }
2227
+
2228
+ // packages/game-engine/src/host/fatalError.ts
2229
+ /** Marker id so the modal is idempotent (first one wins; later calls replace its message). */
2230
+ const FATAL_ID = 'e8-fatal-error';
2231
+ /** Pure: extract a human-readable message from any thrown value / event reason. */
2232
+ function fatalMessage(input) {
2233
+ if (input == null)
2234
+ return 'Something went wrong.';
2235
+ if (typeof input === 'string')
2236
+ return input;
2237
+ if (input instanceof Error)
2238
+ return input.message || input.name || 'Something went wrong.';
2239
+ const anyIn = input;
2240
+ if (typeof anyIn.message === 'string' && anyIn.message)
2241
+ return anyIn.message;
2242
+ if (anyIn.reason != null)
2243
+ return fatalMessage(anyIn.reason);
2244
+ try {
2245
+ return String(input);
2246
+ }
2247
+ catch {
2248
+ return 'Something went wrong.';
2249
+ }
2250
+ }
2251
+ /** Pure: build the modal overlay element (error text + a Reload button). */
2252
+ function buildFatalErrorModal(message, onReload) {
2253
+ const overlay = document.createElement('div');
2254
+ overlay.id = FATAL_ID;
2255
+ overlay.setAttribute('role', 'alertdialog');
2256
+ overlay.style.cssText =
2257
+ 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' +
2258
+ 'background:rgba(10,5,4,0.92);z-index:99999;font-family:system-ui,sans-serif;padding:24px';
2259
+ const card = document.createElement('div');
2260
+ card.style.cssText =
2261
+ 'max-width:420px;width:100%;background:#1a0f0a;border:1px solid #5a3a1e;border-radius:12px;' +
2262
+ 'padding:28px 24px;text-align:center;box-shadow:0 12px 40px rgba(0,0,0,0.6)';
2263
+ const text = document.createElement('div');
2264
+ text.className = 'e8-fatal-message';
2265
+ text.style.cssText = 'color:#f0c98a;font:600 17px/1.4 system-ui,sans-serif;margin-bottom:22px';
2266
+ text.textContent = message;
2267
+ const button = document.createElement('button');
2268
+ button.type = 'button';
2269
+ button.className = 'e8-fatal-reload';
2270
+ button.textContent = 'Reload';
2271
+ button.style.cssText =
2272
+ 'cursor:pointer;border:none;border-radius:8px;padding:12px 28px;font:600 15px system-ui,sans-serif;' +
2273
+ 'color:#1a0f0a;background:#f0c98a';
2274
+ button.addEventListener('click', onReload);
2275
+ card.appendChild(text);
2276
+ card.appendChild(button);
2277
+ overlay.appendChild(card);
2278
+ return overlay;
2279
+ }
2280
+ /**
2281
+ * Render a blocking fatal-error modal with a Reload button. Idempotent: if a modal is already
2282
+ * shown, its message is replaced instead of stacking a second overlay.
2283
+ */
2284
+ function showFatalError(container, message) {
2285
+ if (typeof document === 'undefined')
2286
+ return;
2287
+ const host = typeof container === 'string'
2288
+ ? document.querySelector(container) ?? document.body
2289
+ : container;
2290
+ const existing = document.getElementById(FATAL_ID);
2291
+ if (existing) {
2292
+ const msg = existing.querySelector('.e8-fatal-message');
2293
+ if (msg)
2294
+ msg.textContent = message;
2295
+ return;
2296
+ }
2297
+ const overlay = buildFatalErrorModal(message, () => {
2298
+ try {
2299
+ location.reload();
2300
+ }
2301
+ catch {
2302
+ /* no-op in non-browser environments */
2303
+ }
2304
+ });
2305
+ host.appendChild(overlay);
2306
+ }
2307
+ /**
2308
+ * Install global handlers so ANY uncaught error or unhandled promise rejection surfaces the
2309
+ * fatal modal (game devs don't have to handle errors themselves). `fatal` defaults to the
2310
+ * built-in modal targeting `container`. Returns a disposer that removes the listeners.
2311
+ */
2312
+ function installGlobalErrorHandlers(container, fatal = (m) => showFatalError(container, m)) {
2313
+ if (typeof window === 'undefined')
2314
+ return () => { };
2315
+ const onError = (e) => fatal(fatalMessage(e.error ?? e.message));
2316
+ const onRejection = (e) => fatal(fatalMessage(e.reason));
2317
+ window.addEventListener('error', onError);
2318
+ window.addEventListener('unhandledrejection', onRejection);
2319
+ return () => {
2320
+ window.removeEventListener('error', onError);
2321
+ window.removeEventListener('unhandledrejection', onRejection);
2322
+ };
2323
+ }
2324
+
2325
+ // packages/game-engine/src/host/createSlotGame.ts
2326
+ /**
2327
+ * One-call slot bootstrap: preboot → (optional Stake bridge) → GameApplication
2328
+ * → register scene → start. Collapses the per-game main.ts boilerplate.
2329
+ *
2330
+ * Not unit-tested: GameApplication.init() drives Pixi, which hangs in headless
2331
+ * environments. The pure helpers it sequences are unit-tested individually.
2332
+ */
2333
+ async function createSlotGame(opts) {
2334
+ if (!bootGuard())
2335
+ throw new Error('createSlotGame() called more than once');
2336
+ if (opts.textureDefaults)
2337
+ applyTextureDefaults();
2338
+ await loadFonts(opts.fonts);
2339
+ // Declared up front so `fatal` can route errors through the shell's own modal once it exists.
2340
+ let shell = null;
2341
+ const fatal = (message) => {
2342
+ if (opts.onFatalError)
2343
+ return opts.onFatalError(message);
2344
+ // Once the shell is up, use ITS branded modal (consistent chrome, social vocabulary, fit
2345
+ // scaling) rather than the bare DOM fallback. Errors thrown before the shell boots (asset
2346
+ // load, SDK handshake) still get the standalone overlay.
2347
+ if (shell) {
2348
+ shell.openModal({
2349
+ availableClose: false,
2350
+ title: shell.t('Something went wrong'),
2351
+ body: shell.t(message),
2352
+ actions: [{ title: shell.t('Reload'), on: () => { try {
2353
+ location.reload();
2354
+ }
2355
+ catch { /* non-browser */ } } }],
2356
+ });
2357
+ return;
2358
+ }
2359
+ showFatalError(opts.container ?? '#game', message);
2360
+ };
2361
+ // Global safety net: surface ANY uncaught error / unhandled rejection (e.g. an
2362
+ // `Uncaught (in promise) SDKError` on spin) through the same fatal modal so games
2363
+ // don't have to handle errors themselves. Honours the onFatalError override.
2364
+ installGlobalErrorHandlers(opts.container ?? '#game', fatal);
2365
+ let stakeBridge = null;
2366
+ let isStakeNow = false;
2367
+ if (opts.stake) {
2368
+ const { isStakeLaunch } = await import('@energy8platform/stake-bridge/detect');
2369
+ isStakeNow = isStakeLaunch(location.href);
2370
+ if (isStakeNow) {
2371
+ try {
2372
+ const { StakeBridge } = await import('@energy8platform/stake-bridge');
2373
+ stakeBridge = new StakeBridge({
2374
+ devMode: true,
2375
+ // In the dev harness the iframe is served over http and the dev-RGS
2376
+ // lives at the same (http) origin; force the matching scheme so
2377
+ // RGSClient can reach it. Prod (https) is unaffected.
2378
+ protocol: location.protocol === 'http:' ? 'http' : 'https',
2379
+ adapter: opts.stake.adapter,
2380
+ modeMap: opts.model.modeMap,
2381
+ gameId: opts.model.spec.id,
2382
+ url: location.href,
2383
+ });
2384
+ await stakeBridge.ready();
2385
+ }
2386
+ catch (err) {
2387
+ fatal('Could not connect to the game server. Please reload.');
2388
+ throw err;
2389
+ }
2390
+ }
2391
+ }
2392
+ const game = new GameApplication(buildAppConfig(opts, isStakeNow));
2393
+ // Register EVERY scene up front so any of them can navigate to any other.
2394
+ for (const { key, scene } of opts.scenes)
2395
+ game.scenes.register(key, scene);
2396
+ // Navigation injected into the start data of every scene: a scene reads `goto`
2397
+ // from its `onEnter(data)` and calls it to switch scenes (intro → game, etc.).
2398
+ const goto = (key, data) => {
2399
+ void game.scenes.goto(key, { ...data, goto });
2400
+ };
2401
+ // Pick the start scene from the ordered list + launch mode: a replay launch skips any leading
2402
+ // `skipOnReplay` scene (the intro) and starts directly on the game scene.
2403
+ const { resolveStartScene } = await Promise.resolve().then(function () { return sceneStart; });
2404
+ const startScene = resolveStartScene(opts.scenes, !!stakeBridge?.isReplay, opts.startScene);
2405
+ try {
2406
+ await game.start(startScene, { ...opts.startData, goto });
2407
+ }
2408
+ catch (err) {
2409
+ fatal('Could not start the game.');
2410
+ throw err;
2411
+ }
2412
+ let currentBet = opts.model.spec.defaultBet ?? opts.model.spec.betLevels[0];
2413
+ // Build slotPlay FIRST — bindGameScene() needs it to be in scope.
2414
+ const { createSlotPlay, enrichRoundMeta } = await Promise.resolve().then(function () { return slotPlay; });
2415
+ /** The current scene IFF it implements the SlotSceneController contract (duck-typed on
2416
+ * `present`). The host drives the play loop against whichever scene is current. */
2417
+ const gameScene = () => {
2418
+ const s = game.scenes.current?.scene;
2419
+ return typeof s?.present === 'function'
2420
+ ? s
2421
+ : undefined;
2422
+ };
2423
+ const { runRound } = await Promise.resolve().then(function () { return runRound$1; });
2424
+ const { createBalanceGate } = await Promise.resolve().then(function () { return balanceGate; });
2425
+ const { createFreeSpinsCounter } = await Promise.resolve().then(function () { return freeSpinsCounter; });
2426
+ const { resolvePlayError } = await Promise.resolve().then(function () { return playError; });
2427
+ // slotPlay references shell via closure — define it after shell is assigned below.
2428
+ // We use a late-binding wrapper so the closure captures the variable, not null.
2429
+ const slotPlay$1 = createSlotPlay({
2430
+ play: (p) => game.platformSession.play(p),
2431
+ normalize: opts.normalize,
2432
+ // ACK the result AFTER the scene animates it (the scene calls host.ack()). On Stake this
2433
+ // triggers /wallet/end-round so a winning round settles post-animation instead of staying
2434
+ // open and blocking the next spin.
2435
+ ack: (raw) => game.platformSession.playAck(raw),
2436
+ });
2437
+ if (opts.shell) {
2438
+ const { createGameShell } = await import('@energy8platform/platform-core/shell');
2439
+ const { buildShellConfig } = await Promise.resolve().then(function () { return shellConfig; });
2440
+ const { resolveReplayBonusId } = await Promise.resolve().then(function () { return replay; });
2441
+ const ps = game.platformSession;
2442
+ const balance = game.initData?.balance ?? 0;
2443
+ const isReplay = !!stakeBridge?.isReplay;
2444
+ const mode = isReplay ? 'replay' : 'base';
2445
+ // initData.config carries the Stake bridge's currency/social/disclaimer surface (GameConfigData);
2446
+ // all are absent in non-stake/dev launches → graceful fallbacks downstream.
2447
+ const initData = game.initData;
2448
+ const config = initData?.config;
2449
+ const { resolveCurrency } = await Promise.resolve().then(function () { return shellConfig; });
2450
+ // SINGLE source of truth for the symbol: the Stake bridge already puts a full CurrencyMetaData
2451
+ // (symbol + placement) on initData.config.currency. In the non-stake/devBridge path that meta
2452
+ // is absent and we only have the spec's currency CODE — resolve it through the SAME table
2453
+ // (stake-bridge's lookupCurrency) so e.g. 'EUR' renders as '€', not the literal text "EUR".
2454
+ // stake-bridge ships with every scaffold; if it's somehow absent we degrade to the code.
2455
+ let currencyMeta = config?.currency;
2456
+ if (!currencyMeta?.symbol && opts.model.spec.currency) {
2457
+ try {
2458
+ const { lookupCurrency } = await import('@energy8platform/stake-bridge');
2459
+ currencyMeta = lookupCurrency(opts.model.spec.currency);
2460
+ }
2461
+ catch { /* stake-bridge not installed — resolveCurrency falls back to the code */ }
2462
+ }
2463
+ const runtime = {
2464
+ balance,
2465
+ currency: resolveCurrency(currencyMeta, opts.model.spec.currency),
2466
+ language: initData?.lang,
2467
+ mode,
2468
+ social: config?.socialMode,
2469
+ disclaimerLines: config?.disclaimerLines,
2470
+ jurisdiction: config?.jurisdiction,
2471
+ // Currency-specific ladder + per-currency default from /wallet/authenticate (Stake);
2472
+ // absent on dev/devBridge → buildShellConfig falls back to the spec.
2473
+ betLevels: config?.betLevels,
2474
+ defaultBet: config?.stake?.defaultBetLevel ?? config?.defaultBet,
2475
+ };
2476
+ if (opts.dev) {
2477
+ // Dev-only diagnostic. Logged as PLAIN STRINGS (not collapsed objects) so the values are
2478
+ // readable in the console without expanding. If the shown symbol is a bare code ("EUR")
2479
+ // instead of a glyph ("€"), paste this whole line.
2480
+ const cc = config?.currency;
2481
+ console.info(`[e8] currency → bridge.code=${cc?.code ?? '∅'} bridge.symbol=${cc?.symbol ?? '∅'} ` +
2482
+ `| spec=${opts.model.spec.currency ?? '∅'} ` +
2483
+ `| RESOLVED.symbol=${runtime.currency?.symbol ?? '∅'} pos=${runtime.currency?.position ?? '∅'}`);
2484
+ }
2485
+ shell = createGameShell(buildShellConfig(opts.shell, opts.model, runtime));
2486
+ // The gate tracks the live wallet (for the affordability guard) but only PAINTS the balance per
2487
+ // the HUD-timing rule: the debit is buffered during play→present and shown at afterPresent; the
2488
+ // async win credit (/wallet/end-round, after the final ack) paints when it lands. `balanceGate`
2489
+ // is the single source for both the displayed balance and `ensureAffordable`.
2490
+ const balanceGate = createBalanceGate((b) => shell.setBalance(b), balance);
2491
+ ps?.on('balanceUpdate', (d) => { balanceGate.onBalance(d.balance); });
2492
+ // Live turbo level (0..3) — read fresh on each ctx.turbo access so a mid-round toggle is honoured.
2493
+ let currentTurbo = shell.state.turbo;
2494
+ shell.on('turboChange', (level) => { currentTurbo = level; });
2495
+ const roleOf = (action) => opts.model.spec.actions[action]?.role;
2496
+ const makeContext = (action) => ({
2497
+ bet: currentBet,
2498
+ action,
2499
+ mode: opts.model.modeMap[action] ?? action.toUpperCase(),
2500
+ formatAmount: (v) => shell.formatWin(v),
2501
+ get turbo() { return currentTurbo; },
2502
+ });
2503
+ // Play-error + connection handling. A play rejection is classified into a player-facing modal
2504
+ // (ACTIVE_SESSION_EXISTS → Reload, etc.) instead of a misleading reconnect overlay; the reconnect
2505
+ // overlay is suppressed while a play-error modal owns the screen.
2506
+ let playErrorOpen = false;
2507
+ let stopAutoplay = () => { }; // wired to the autoplay loop once it's created (below)
2508
+ const showPlayError = (err) => {
2509
+ stopAutoplay(); // a play error halts an autoplay run (the .catch swallows, so stop explicitly)
2510
+ const v = resolvePlayError(err);
2511
+ playErrorOpen = true;
2512
+ shell.openModal({
2513
+ availableClose: !v.reload,
2514
+ title: shell.t(v.title),
2515
+ body: shell.t(v.body),
2516
+ actions: v.reload
2517
+ ? [{ title: shell.t('Reload'), on: () => { try {
2518
+ window.location.reload();
2519
+ }
2520
+ catch { /* non-browser */ } } }]
2521
+ : [{ title: shell.t('OK'), on: () => { playErrorOpen = false; } }],
2522
+ });
2523
+ };
2524
+ ps?.on('connectionStateChanged', (s) => {
2525
+ if (s.status === 'restored') {
2526
+ if (!playErrorOpen)
2527
+ shell.closeModal();
2528
+ return;
2529
+ }
2530
+ if (playErrorOpen)
2531
+ return; // a play-error modal owns the screen — don't mask it with "reconnecting"
2532
+ shell.openModal({
2533
+ availableClose: false,
2534
+ title: shell.t('Reconnecting…'),
2535
+ body: shell.t('Lost connection to the game server. Trying to reconnect…'),
2536
+ });
2537
+ });
2538
+ /** Drive a full round (trigger + drain) against the current scene. HUD readouts (win + balance)
2539
+ * update only AFTER each present(), per the HUD-timing requirement. */
2540
+ const playRound = (action) => {
2541
+ const scene = gameScene();
2542
+ if (!scene)
2543
+ return;
2544
+ // Per-round free-spins state: the shell enters FS mode on bonus-enter and shows current/total
2545
+ // (growing on retriggers) + cumulative win per spin. `inBonus` gates the per-spin counter so
2546
+ // the trigger segment (presented before onBonusEnter) doesn't count as a free spin.
2547
+ let inBonus = false;
2548
+ let prevWin = 0; // cumulative win up to the previous segment — the WIN readout shows the delta
2549
+ const fsCounter = createFreeSpinsCounter();
2550
+ shell.setBusy(true); // block re-spin / spacebar while the round plays out
2551
+ // RETURN the promise: the replay modal awaits onReplay() and only reopens once the round's
2552
+ // animation has finished — returning void would reopen it instantly, over a running animation.
2553
+ return runRound({
2554
+ // Suppress the debit paint from play() until this segment's afterPresent (HUD timing).
2555
+ play: (a, b, rid) => { balanceGate.beginPlay(); return slotPlay$1.play(a, b, rid); },
2556
+ ack: slotPlay$1.ack,
2557
+ scene,
2558
+ context: makeContext,
2559
+ roleOf,
2560
+ afterPresent: (r) => {
2561
+ // WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
2562
+ // free-spins counter (totalWin) below, not the WIN readout.
2563
+ shell.setWin(r.totalWin - prevWin);
2564
+ prevWin = r.totalWin;
2565
+ balanceGate.afterPresent();
2566
+ if (inBonus)
2567
+ shell.setFreeSpins(fsCounter.spin(r.freeSpins?.awarded ?? 0, r.totalWin));
2568
+ },
2569
+ onBonusEnter: async (trigger, ctx) => {
2570
+ inBonus = true;
2571
+ shell.setMode('freeSpins');
2572
+ shell.setFreeSpins(fsCounter.enter(trigger.freeSpins?.awarded ?? trigger.freeSpins?.total ?? 0));
2573
+ await scene.onBonusEnter?.(trigger, ctx);
2574
+ },
2575
+ onBonusExit: async (last, ctx) => {
2576
+ inBonus = false;
2577
+ await scene.onBonusExit?.(last, ctx);
2578
+ shell.setMode('base');
2579
+ },
2580
+ }, action).catch(showPlayError).finally(() => shell.setBusy(false));
2581
+ };
2582
+ /**
2583
+ * Drain a recovered open round to completion and settle it. Plays EVERY remaining segment from
2584
+ * the bonus start (Continue animates each; Finish fast-forwards without animation), reaching the
2585
+ * final ack so /wallet/end-round credits the win — fixing the old resume that presented one
2586
+ * snapshot and never settled. The original trigger is gone on reload, so the FS counter here uses
2587
+ * the bridge's session counts; FS mode is entered/exited around the drain.
2588
+ */
2589
+ const resumeDrain = async (firstRaw, animate) => {
2590
+ const scene = gameScene();
2591
+ if (!scene || !ps)
2592
+ return;
2593
+ const ctx = makeContext(firstRaw.action ?? 'spin');
2594
+ const fsView = (raw, totalWin) => {
2595
+ const s = raw.session;
2596
+ if (!s)
2597
+ return null;
2598
+ // The bridge session counts ALL segments incl. the trigger (segment 0); the free-spins
2599
+ // counter is over FREE spins only, so drop the one trigger segment → 1/10, not 2/11.
2600
+ const played = s.spinsPlayed ?? 0;
2601
+ const current = Math.max(0, played - 1);
2602
+ const total = Math.max(0, played + (s.spinsRemaining ?? 0) - 1);
2603
+ return { current, total, totalWin };
2604
+ };
2605
+ let raw = firstRaw;
2606
+ let r = enrichRoundMeta(opts.normalize(raw), raw);
2607
+ let inBonus = false;
2608
+ let prevWin = 0; // cumulative win up to the previous segment — WIN readout shows the delta
2609
+ const applySegment = async () => {
2610
+ // A recovered open round with remaining segments is a bonus → show FS mode + counter.
2611
+ if (!inBonus && !r.complete) {
2612
+ inBonus = true;
2613
+ shell.setMode('freeSpins');
2614
+ }
2615
+ if (animate)
2616
+ await scene.present(r, ctx);
2617
+ if (inBonus) {
2618
+ const v = fsView(raw, r.totalWin);
2619
+ if (v)
2620
+ shell.setFreeSpins(v);
2621
+ }
2622
+ shell.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
2623
+ prevWin = r.totalWin;
2624
+ ps.playAck(raw); // settles via /wallet/end-round on the FINAL segment
2625
+ };
2626
+ shell.setBusy(true); // block input while the recovered round drains
2627
+ try {
2628
+ await applySegment();
2629
+ while (!r.complete && r.nextActions && r.nextActions.length > 0) {
2630
+ raw = (await ps.play({ action: r.nextActions[0], bet: ctx.bet, roundId: r.roundId }));
2631
+ r = enrichRoundMeta(opts.normalize(raw), raw);
2632
+ await applySegment();
2633
+ }
2634
+ if (inBonus)
2635
+ shell.setMode('base');
2636
+ }
2637
+ finally {
2638
+ shell.setBusy(false);
2639
+ }
2640
+ };
2641
+ if (mode === 'base') {
2642
+ let activeFeature = null;
2643
+ shell.on('featureActivate', ({ id }) => { activeFeature = id; });
2644
+ shell.on('featureDeactivate', ({ id: _id }) => { activeFeature = null; });
2645
+ const { stakeForAction } = await Promise.resolve().then(function () { return shellConfig; });
2646
+ // Guard a play: if the stake exceeds the balance, show a shell modal and DON'T play.
2647
+ const ensureAffordable = (action) => {
2648
+ if (stakeForAction(opts.model, action, currentBet) <= balanceGate.balance + 1e-9)
2649
+ return true;
2650
+ shell.openModal({
2651
+ availableClose: true,
2652
+ title: shell.t('Insufficient balance'),
2653
+ body: shell.t('You don’t have enough balance for this bet. Lower your bet or top up.'),
2654
+ actions: [{ title: shell.t('OK') }],
2655
+ });
2656
+ return false;
2657
+ };
2658
+ shell.on('spin', () => {
2659
+ const action = activeFeature ?? 'spin';
2660
+ if (!ensureAffordable(action))
2661
+ return;
2662
+ void playRound(action);
2663
+ });
2664
+ shell.on('betChange', (bet) => { currentBet = bet; });
2665
+ shell.on('buyBonusSelect', ({ id }) => {
2666
+ if (!ensureAffordable(id))
2667
+ return;
2668
+ void playRound(id);
2669
+ });
2670
+ // Autoplay: the shell owns the picker/confirm/STOP/counter/lockout (all driven by state.autoplay);
2671
+ // the host just runs the loop and pushes the per-spin remaining back via setAutoplay.
2672
+ const { createAutoplayLoop } = await Promise.resolve().then(function () { return autoplay; });
2673
+ const autoplay$1 = createAutoplayLoop({
2674
+ resolveAction: () => activeFeature ?? 'spin',
2675
+ canAfford: (a) => ensureAffordable(a),
2676
+ playRound: (a) => Promise.resolve(playRound(a)),
2677
+ onState: (s) => shell.setAutoplay(s),
2678
+ });
2679
+ stopAutoplay = () => autoplay$1.stop();
2680
+ shell.on('autoplayStart', (o) => autoplay$1.start(o?.remaining ?? 0));
2681
+ shell.on('autoplayStop', () => autoplay$1.stop());
2682
+ // Resume offer: when the game scene is (or becomes) current on a reload, ask the host whether
2683
+ // a round is still open. If so, offer Continue (replay its animation, then settle) or Finish
2684
+ // (settle now). Settlement is the same playAck path a normal spin uses. Runs at most once.
2685
+ let resumeOffered = false;
2686
+ const offerResume = async () => {
2687
+ if (resumeOffered || !shell || !gameScene())
2688
+ return;
2689
+ resumeOffered = true;
2690
+ let snap = null;
2691
+ try {
2692
+ snap = await ps?.getState() ?? null;
2693
+ }
2694
+ catch {
2695
+ snap = null;
2696
+ }
2697
+ if (!snap)
2698
+ return;
2699
+ shell.openModal({
2700
+ availableClose: false,
2701
+ title: shell.t('Unfinished round'),
2702
+ body: shell.t('You have an unfinished round. Continue it or finish it now?'),
2703
+ actions: [
2704
+ // Continue: replay the round from the start with animation, then settle.
2705
+ { title: shell.t('Continue'), on: () => { void resumeDrain(snap, true); } },
2706
+ // Finish: fast-forward the remaining segments (no animation) to settle the win now.
2707
+ { title: shell.t('Finish'), on: () => { void resumeDrain(snap, false); } },
2708
+ ],
2709
+ });
2710
+ };
2711
+ game.scenes.on('change', () => { void offerResume(); });
2712
+ void offerResume();
2713
+ }
2714
+ else {
2715
+ const stakeMode = stakeBridge?.replayMode ?? 'BASE';
2716
+ const bonusId = resolveReplayBonusId(opts.model, stakeMode);
2717
+ // The replayed round's OWN bet + payout (fetched up front per Stake rules), not the spec's
2718
+ // default bet — otherwise the replay modal always shows bet 1.
2719
+ const replayBet = stakeBridge?.replayBet || currentBet;
2720
+ currentBet = replayBet;
2721
+ // onReplay only spins — the shell reopens the modal after it resolves; never call openReplay inside onReplay (double-open).
2722
+ shell.openReplay({
2723
+ bonusId,
2724
+ bet: replayBet,
2725
+ payoutMultiplier: stakeBridge?.replayPayoutMultiplier ?? 0,
2726
+ onReplay: () => playRound(bonusId),
2727
+ });
2728
+ }
2729
+ }
2730
+ return { game, stakeBridge, shell };
2731
+ }
2732
+
2733
+ // packages/game-engine/src/host/shellConfig.ts
2734
+ /**
2735
+ * Apply jurisdiction restrictions over the resolved shell features, in place. A restriction ALWAYS
2736
+ * wins over the author's intent (a forbidden control must stay off even if the game enabled it).
2737
+ */
2738
+ function applyJurisdiction(features, j) {
2739
+ if (!j)
2740
+ return;
2741
+ if (j.disabledTurbo)
2742
+ features.turbo = 0;
2743
+ else if (j.disabledSuperTurbo && features.turbo > 1)
2744
+ features.turbo = 1;
2745
+ if (j.disabledSpacebar)
2746
+ features.spacebar = false;
2747
+ if (j.disabledAutoplay)
2748
+ features.autoplay = null;
2749
+ if (j.disabledBuyFeature)
2750
+ features.buyBonus = false;
2751
+ }
2752
+ /**
2753
+ * Resolve the shell `CurrencyConfig` from the SAME data the Stake bridge uses — the
2754
+ * `CurrencyMetaData` it puts on `initData.config.currency` (symbol + placement from
2755
+ * `symbolAfter`). No second symbol table lives here.
2756
+ *
2757
+ * Fallback chain (dev/devBridge with no Stake meta): `initData.config.currency`
2758
+ * → the spec's currency `code` (neutral `{ symbol: code, position: 'left' }`)
2759
+ * → `{ symbol: '€', position: 'left' }`.
2760
+ */
2761
+ /** Extra precision for WIN / TOTAL-WIN readouts so small-bet wins (e.g. 0.0041 on a 0.01 bet) are
2762
+ * not rounded away to 0.00. Balance / bet stay at the currency's own decimals (`minDecimals`). */
2763
+ const WIN_MAX_DECIMALS = 4;
2764
+ /** Attach decimals: `minDecimals` (balance/bet/prices, fixed) = the currency's decimals; `maxDecimals`
2765
+ * (win/total-win, variable, trailing zeros trimmed) = up to WIN_MAX_DECIMALS — but only when the
2766
+ * currency actually has fraction digits (a 0-decimal currency like JPY keeps wins integer). */
2767
+ function withDecimals(base, decimals) {
2768
+ return {
2769
+ ...base,
2770
+ minDecimals: decimals,
2771
+ maxDecimals: decimals > 0 ? Math.max(decimals, WIN_MAX_DECIMALS) : 0,
2772
+ };
2773
+ }
2774
+ function resolveCurrency(meta, specCurrency) {
2775
+ const hasMeta = !!(meta && meta.symbol);
2776
+ // Single expression, no early-return branches (the bundler was treeshaking the meta branch away).
2777
+ const symbol = hasMeta ? meta.symbol : (specCurrency || '€');
2778
+ const position = hasMeta && meta.symbolAfter ? 'right' : 'left';
2779
+ const decimals = hasMeta && typeof meta.decimals === 'number' ? meta.decimals : 2;
2780
+ return withDecimals({ symbol, position }, decimals);
2781
+ }
2782
+ /** Total stake for an action = bet × the action's cost multiplier (1 for a base spin; e.g. 100 for
2783
+ * a buy bonus). The host uses this to block a play the balance can't cover. */
2784
+ function stakeForAction(model, action, bet) {
2785
+ const cost = (model.spec.actions?.[action]?.cost ?? 1);
2786
+ return cost * bet;
2787
+ }
2788
+ /** Derive shell buy cards + ante toggles from the spec's buy/feature actions (SSOT). */
2789
+ function toBonusOptions(model) {
2790
+ const out = [];
2791
+ for (const [key, action] of Object.entries(model.spec.actions)) {
2792
+ const role = action.role ?? 'base';
2793
+ if (role !== 'buy' && role !== 'feature')
2794
+ continue;
2795
+ out.push({
2796
+ id: key,
2797
+ type: role === 'buy' ? 'bonus' : 'feature',
2798
+ title: action.title ?? key.replace(/_/g, ' ').toUpperCase(),
2799
+ description: action.description ?? '',
2800
+ priceMultiplier: action.cost ?? (role === 'buy' ? 100 : 1),
2801
+ });
2802
+ }
2803
+ return out;
2804
+ }
2805
+ /** Build a paytable section from the model's derived paytable view (multipliers per symbol count). */
2806
+ function paytableSection(model) {
2807
+ const symbols = model.paytable?.symbols ?? [];
2808
+ const rows = [];
2809
+ for (const s of symbols) {
2810
+ const wins = Object.entries(s.pay ?? {})
2811
+ .map(([count, multiplier]) => ({ count: String(count), multiplier: Number(multiplier) }))
2812
+ .filter((w) => Number.isFinite(w.multiplier) && w.multiplier > 0)
2813
+ .sort((a, b) => Number(a.count) - Number(b.count));
2814
+ if (!wins.length)
2815
+ continue;
2816
+ rows.push({ symbol: { text: s.name ?? s.id }, wins });
2817
+ }
2818
+ if (!rows.length)
2819
+ return null;
2820
+ return { type: 'paytable', title: 'PAYTABLE', rows };
2821
+ }
2822
+ /** Build a "wins" illustration section sized to the grid; `kind` follows the spec mechanic hint. */
2823
+ function winsSection(model) {
2824
+ const { cols, rows } = model.spec.grid;
2825
+ const grid = { cols, rows };
2826
+ switch (model.spec.mechanic) {
2827
+ case 'cluster':
2828
+ return { type: 'wins', kind: 'cluster', minCount: 5, grid };
2829
+ case 'ways':
2830
+ return { type: 'wins', kind: 'ways', grid };
2831
+ default:
2832
+ return { type: 'wins', kind: 'anywhere', minCount: 3, grid };
2833
+ }
2834
+ }
2835
+ /** Title of the legal disclaimer section — used to build it and to exempt it from socialization. */
2836
+ const DISCLAIMER_TITLE = 'DISCLAIMER';
2837
+ /** A disclaimer section from initData's disclaimer lines; null when none supplied. */
2838
+ function disclaimerSection(lines) {
2839
+ const clean = (lines ?? []).map((l) => l.trim()).filter(Boolean);
2840
+ if (!clean.length)
2841
+ return null;
2842
+ const html = clean.map((l) => `<p>${l}</p>`).join('');
2843
+ return { type: 'custom', title: DISCLAIMER_TITLE, html };
2844
+ }
2845
+ /** The legal disclaimer must be shown verbatim — this identifies it so socialization skips it. */
2846
+ function isDisclaimerSection(s) {
2847
+ return s.type === 'custom' && s.title === DISCLAIMER_TITLE;
2848
+ }
2849
+ /** Move the legal disclaimer to the very END of the section list — it must always render last,
2850
+ * regardless of where an author merge or an extra section would otherwise place it. */
2851
+ function orderDisclaimerLast(sections) {
2852
+ const disclaimer = sections.filter(isDisclaimerSection);
2853
+ if (!disclaimer.length)
2854
+ return sections;
2855
+ return [...sections.filter((s) => !isDisclaimerSection(s)), ...disclaimer];
2856
+ }
2857
+ /**
2858
+ * Pure: derive a maximal default GameInfoContent from the model + runtime so every game
2859
+ * gets a real info panel for free (paytable, win illustration, controls, and the Stake
2860
+ * disclaimer when present). Author-supplied `opts.gameInfo` is MERGED over this set by
2861
+ * section identity (see `mergeGameInfo`), not wholesale-replaced.
2862
+ */
2863
+ function defaultGameInfo(model, runtime) {
2864
+ const sections = [];
2865
+ sections.push(winsSection(model));
2866
+ const pay = paytableSection(model);
2867
+ if (pay)
2868
+ sections.push(pay);
2869
+ const modes = modesSection(model);
2870
+ if (modes)
2871
+ sections.push(modes);
2872
+ sections.push({ type: 'controls' });
2873
+ const disclaimer = disclaimerSection(runtime.disclaimerLines);
2874
+ if (disclaimer)
2875
+ sections.push(disclaimer);
2876
+ return { sections };
2877
+ }
2878
+ /** Per-mode info table (BASE / ANTE / each buy tier) derived from the spec's modes — the SAME SSOT
2879
+ * (`model.mathModes` + `spec.actions`) that drives the buy cards and the math pipeline. Stake
2880
+ * compliance requires Cost / RTP / Max Win per mode; deriving it here means the author declares a
2881
+ * mode once (in game.spec) and the info table can't drift. `free` actions are excluded (mathModes
2882
+ * already drops them — free spins are part of a bonus, not a purchasable mode). */
2883
+ function modesSection(model) {
2884
+ const modes = model.mathModes ?? [];
2885
+ if (!modes.length)
2886
+ return null;
2887
+ const rows = modes.map((m) => {
2888
+ const action = model.spec.actions[m.action];
2889
+ const isBase = (action?.role ?? 'base') === 'base' || m.mode === 'BASE';
2890
+ const row = {
2891
+ title: action?.title ?? (isBase ? 'Base game' : m.mode.replace(/_/g, ' ')),
2892
+ maxWin: `${m.maxWin.toLocaleString('en-US')}×`,
2893
+ };
2894
+ // Cost is a bet-multiplier; a base spin (1×) reads as no premium, so only show it for buys/features.
2895
+ if (m.costMultiplier && m.costMultiplier !== 1)
2896
+ row.price = `${m.costMultiplier}×`;
2897
+ if (typeof m.rtp === 'number')
2898
+ row.rtp = Math.round(m.rtp * 1000) / 10; // 0.965 → 96.5 (%)
2899
+ if (action?.description)
2900
+ row.description = action.description;
2901
+ return row;
2902
+ });
2903
+ return { type: 'modes', title: 'MODES', modes: rows };
2904
+ }
2905
+ /** Identity key for merge. `wins` is keyed by `kind` (different mechanics coexist). `custom` has
2906
+ * no structural discriminant and several can coexist (MAX WIN, DISCLAIMER, …) so it is keyed by
2907
+ * its `title` (an author `custom` with a matching title replaces that derived block; a new title
2908
+ * is added). Every other type is a singleton keyed by `type`. */
2909
+ function sectionKey(s) {
2910
+ if (s.type === 'wins')
2911
+ return `wins:${s.kind}`;
2912
+ if (s.type === 'custom')
2913
+ return `custom:${s.title ?? ''}`;
2914
+ return s.type;
2915
+ }
2916
+ /**
2917
+ * Merge author `gameInfo` over the host-derived set by section identity: an author section
2918
+ * REPLACES the derived section of the same identity (same `type`, or same `wins` `kind`); a new
2919
+ * identity is APPENDED (after the derived ones, in author order); derived sections without an
2920
+ * author override are KEPT. `override` undefined → the pure derived set.
2921
+ */
2922
+ function mergeGameInfo(derived, override) {
2923
+ if (!override)
2924
+ return derived;
2925
+ const authorByKey = new Map();
2926
+ for (const s of override.sections ?? [])
2927
+ authorByKey.set(sectionKey(s), s);
2928
+ const out = [];
2929
+ const used = new Set();
2930
+ // Keep derived order; swap in the author's version where identities collide.
2931
+ for (const s of derived.sections ?? []) {
2932
+ const k = sectionKey(s);
2933
+ const replacement = authorByKey.get(k);
2934
+ if (replacement) {
2935
+ out.push(replacement);
2936
+ used.add(k);
2937
+ }
2938
+ else
2939
+ out.push(s);
2940
+ }
2941
+ // Append author sections whose identity wasn't in the derived set, in author order.
2942
+ for (const s of override.sections ?? []) {
2943
+ const k = sectionKey(s);
2944
+ if (!used.has(k)) {
2945
+ out.push(s);
2946
+ used.add(k);
2947
+ }
2948
+ }
2949
+ return { sections: out };
2950
+ }
2951
+ /** Run a section's player-facing text through `socialize`. Applied to the full MERGED set
2952
+ * (host-derived + author) in social mode. Covers section titles, custom HTML, and PAYTABLE row
2953
+ * symbol labels — the paytable's symbol text comes straight from the gameSpec's `symbols[].name`,
2954
+ * so a forbidden word in a spec symbol name is rewritten here too. A `node`-based custom section is
2955
+ * returned untouched — its DOM is author-owned and not introspected. */
2956
+ function socializeSection(s) {
2957
+ const next = { ...s };
2958
+ if ('title' in next && typeof next.title === 'string') {
2959
+ next.title = shell.socialize(next.title);
2960
+ }
2961
+ if (next.type === 'custom' && typeof next.html === 'string') {
2962
+ next.html = shell.socialize(next.html);
2963
+ }
2964
+ if (next.type === 'paytable' && Array.isArray(next.rows)) {
2965
+ next.rows = next.rows.map((r) => typeof r.symbol?.text === 'string'
2966
+ ? { ...r, symbol: { ...r.symbol, text: shell.socialize(r.symbol.text) } }
2967
+ : r);
2968
+ }
2969
+ if (next.type === 'modes' && Array.isArray(next.modes)) {
2970
+ next.modes = next.modes.map((m) => ({
2971
+ ...m,
2972
+ title: shell.socialize(m.title),
2973
+ ...(m.description ? { description: shell.socialize(m.description) } : {}),
2974
+ }));
2975
+ }
2976
+ return next;
2977
+ }
2978
+ /** Socialize buy-bonus card copy (title/description) when in social mode; a no-op otherwise.
2979
+ * Applied to the final option set (author override or spec-derived) so forbidden words in author
2980
+ * card copy are rewritten too. */
2981
+ function socializeBonusOptions(options, isSocial) {
2982
+ if (!isSocial)
2983
+ return options;
2984
+ return options.map((o) => ({ ...o, title: shell.socialize(o.title), description: shell.socialize(o.description) }));
2985
+ }
2986
+ /** Pure: assemble a ShellConfig from the model + runtime context (currency/balance/language/mode). */
2987
+ function buildShellConfig(opts, model, runtime) {
2988
+ // Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
2989
+ const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
2990
+ // Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
2991
+ const defaultBet = runtime.defaultBet ?? model.spec.defaultBet ?? betLevels[0];
2992
+ // runtime.currency is the resolved CurrencyConfig (derived from initData.config.currency by the
2993
+ // host); opts.currency still wins. Fall back to the spec code, then a neutral euro.
2994
+ const currency = opts.currency ?? runtime.currency ?? resolveCurrency(null, model.spec.currency);
2995
+ const isSocial = runtime.social ?? false;
2996
+ // Merge author sections over the host-derived defaults, THEN socialize the WHOLE merged set in
2997
+ // social mode — so restricted gambling vocabulary is rewritten in BOTH the built-in copy AND any
2998
+ // author-supplied text (title + custom HTML). A game can no longer surface a forbidden word in
2999
+ // social mode just because the author wrote it in their own info section. (Custom sections built
3000
+ // from a raw DOM `node` can't be rewritten automatically — author owns the node and can call the
3001
+ // exported `socialize` from '@energy8platform/game-engine/host' on their own strings.)
3002
+ // Author gameInfo may be a plain object or a `(t) => content` factory. `t` socializes when in
3003
+ // social mode (identity otherwise) so authors can wrap copy explicitly; the full merged set is
3004
+ // still socialized below as a safety net.
3005
+ const t = isSocial ? shell.socialize : (text) => text;
3006
+ const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
3007
+ let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime), authored);
3008
+ // The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
3009
+ // wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
3010
+ if (isSocial) {
3011
+ gameInfo = {
3012
+ sections: (gameInfo.sections ?? []).map((s) => (isDisclaimerSection(s) ? s : socializeSection(s))),
3013
+ };
3014
+ }
3015
+ // The legal DISCLAIMER always renders LAST — author-merged or extra sections never push below it.
3016
+ gameInfo = { sections: orderDisclaimerLast(gameInfo.sections ?? []) };
3017
+ // Buy-bonus cards: socialize the FINAL options (author override or spec-derived) in social mode.
3018
+ const buyBonus = socializeBonusOptions(opts.buyBonus ?? toBonusOptions(model), isSocial);
3019
+ // Features: defaults, then author overrides, THEN jurisdiction restrictions (a restriction wins).
3020
+ const features = {
3021
+ turbo: 0,
3022
+ spacebar: true,
3023
+ autoplay: {},
3024
+ buyBonus,
3025
+ ...(opts.features ?? {}),
3026
+ };
3027
+ applyJurisdiction(features, runtime.jurisdiction);
3028
+ return {
3029
+ mount: opts.mount ?? (typeof document !== 'undefined' ? document.body : undefined),
3030
+ language: runtime.language ?? 'en',
3031
+ isSocial,
3032
+ currency,
3033
+ gameInfo,
3034
+ availableBets: [...betLevels],
3035
+ defaultBet,
3036
+ currentBet: defaultBet,
3037
+ balance: runtime.balance,
3038
+ win: 0,
3039
+ mode: runtime.mode,
3040
+ features,
3041
+ };
3042
+ }
3043
+
3044
+ var shellConfig = /*#__PURE__*/Object.freeze({
3045
+ __proto__: null,
3046
+ applyJurisdiction: applyJurisdiction,
3047
+ buildShellConfig: buildShellConfig,
3048
+ defaultGameInfo: defaultGameInfo,
3049
+ mergeGameInfo: mergeGameInfo,
3050
+ resolveCurrency: resolveCurrency,
3051
+ stakeForAction: stakeForAction,
3052
+ toBonusOptions: toBonusOptions
3053
+ });
3054
+
3055
+ /** Reverse the model's modeMap (Stake bet mode → SDK action key) for replay labelling/cost. */
3056
+ function resolveReplayBonusId(model, stakeMode) {
3057
+ for (const [action, mode] of Object.entries(model.modeMap)) {
3058
+ if (mode === stakeMode)
3059
+ return action;
3060
+ }
3061
+ return stakeMode;
3062
+ }
3063
+
3064
+ var replay = /*#__PURE__*/Object.freeze({
3065
+ __proto__: null,
3066
+ resolveReplayBonusId: resolveReplayBonusId
3067
+ });
3068
+
3069
+ /**
3070
+ * Pick the scene to START with, given the registered scenes (in order) and the launch mode.
3071
+ *
3072
+ * Rules:
3073
+ * - On a replay launch, scenes flagged `skipOnReplay` are NOT eligible to start (they stay
3074
+ * registered for `goto`, they just aren't auto-started) — so a leading intro is skipped and
3075
+ * the game scene starts directly.
3076
+ * - An explicit `startScene` wins, but only if that scene is itself eligible; otherwise the first
3077
+ * eligible scene wins.
3078
+ * - Falls back to the first scene unconditionally if nothing is eligible (degenerate config).
3079
+ */
3080
+ function resolveStartScene(scenes, isReplay, explicitStart) {
3081
+ const eligible = scenes.filter((s) => !(isReplay && s.skipOnReplay));
3082
+ if (explicitStart) {
3083
+ const ok = eligible.find((s) => s.key === explicitStart);
3084
+ if (ok)
3085
+ return ok.key;
3086
+ }
3087
+ return eligible[0]?.key ?? scenes[0]?.key;
3088
+ }
3089
+
3090
+ var sceneStart = /*#__PURE__*/Object.freeze({
3091
+ __proto__: null,
3092
+ resolveStartScene: resolveStartScene
3093
+ });
3094
+
3095
+ // packages/game-engine/src/host/index.ts
3096
+
3097
+ /**
3098
+ * Enrich a normalized result with round-continuation metadata (roundId / nextActions / complete)
3099
+ * read from the raw play result, so a caller can drain the remaining segments of a multi-segment
3100
+ * round by replaying the SAME roundId. The game's normalizer stays focused on render data. Shared by
3101
+ * `createSlotPlay` (normal play) and the host's resume path (draining a recovered open round).
3102
+ */
3103
+ function enrichRoundMeta(result, raw) {
3104
+ const meta = (raw ?? {});
3105
+ result.roundId = meta.roundId;
3106
+ result.nextActions = meta.nextActions;
3107
+ // A round is complete when there is no open session, or the session reports completed. The host
3108
+ // sets a session on every segment, so this is `session.completed` in practice.
3109
+ result.complete = !meta.session || meta.session.completed === true;
3110
+ return result;
3111
+ }
3112
+ /** Build the host play/ack pair. Host-agnostic wiring; unit-testable. The returned `play` stashes
3113
+ * the raw host result so the matching `ack()` can forward it to `deps.ack` (PlatformSession.playAck)
3114
+ * once the scene has finished animating. Plays are sequential (awaited), so a single stash is safe. */
3115
+ function createSlotPlay(deps) {
3116
+ let lastRaw = null;
3117
+ return {
3118
+ play: async (action, bet, roundId) => {
3119
+ const raw = await deps.play({ action, bet, roundId });
3120
+ lastRaw = raw;
3121
+ const result = enrichRoundMeta(deps.normalize(raw), raw);
3122
+ deps.onWin?.(result.totalWin);
3123
+ return result;
3124
+ },
3125
+ ack: () => {
3126
+ if (lastRaw != null)
3127
+ deps.ack?.(lastRaw);
3128
+ },
3129
+ };
3130
+ }
3131
+
3132
+ var slotPlay = /*#__PURE__*/Object.freeze({
3133
+ __proto__: null,
3134
+ createSlotPlay: createSlotPlay,
3135
+ enrichRoundMeta: enrichRoundMeta
3136
+ });
3137
+
3138
+ /**
3139
+ * Drive ONE round end-to-end: play the trigger, present it, ack; then drain the remaining segments
3140
+ * (a bonus's free spins) by replaying nextActions[0] with the SAME roundId until the round reports
3141
+ * `complete`. Fires `onBonusEnter` EXACTLY before the first free-role segment and `onBonusExit`
3142
+ * after the last. A plain spin with no bonus is already `complete`, so the while-loop is a no-op.
3143
+ *
3144
+ * `ctx.bet` is captured once (bet can't change mid-round); `ctx.turbo` is a live getter so a
3145
+ * mid-round toggle is honoured on the next segment.
3146
+ */
3147
+ async function runRound(deps, action) {
3148
+ const ctx = deps.context(action);
3149
+ let r = await deps.play(action, ctx.bet);
3150
+ await deps.scene.present(r, ctx);
3151
+ deps.ack();
3152
+ deps.afterPresent?.(r); // HUD readouts update AFTER the animation, not before
3153
+ let inBonus = false;
3154
+ while (!r.complete && r.nextActions && r.nextActions.length > 0) {
3155
+ const next = r.nextActions[0];
3156
+ if (!inBonus && deps.roleOf(next) === 'free') {
3157
+ inBonus = true;
3158
+ await deps.onBonusEnter?.(r, ctx);
3159
+ }
3160
+ // Snapshot the TRIGGER context per segment: { ... } freezes the live `turbo` getter into a data
3161
+ // property (so a mid-round toggle is reflected on the NEXT segment), while action/mode/bet stay
3162
+ // the round's (the trigger's) identity — a scene must see the same bonus identity all round.
3163
+ const segCtx = { ...deps.context(action) };
3164
+ r = await deps.play(next, ctx.bet, r.roundId);
3165
+ await deps.scene.present(r, segCtx);
3166
+ deps.ack();
3167
+ deps.afterPresent?.(r);
3168
+ }
3169
+ if (inBonus)
3170
+ await deps.onBonusExit?.(r, ctx);
3171
+ }
3172
+
3173
+ var runRound$1 = /*#__PURE__*/Object.freeze({
3174
+ __proto__: null,
3175
+ runRound: runRound
3176
+ });
3177
+
3178
+ function createBalanceGate(paint, initial = 0) {
3179
+ let latest = initial;
3180
+ let suppressed = false;
3181
+ return {
3182
+ onBalance(amount) {
3183
+ latest = amount;
3184
+ if (!suppressed)
3185
+ paint(amount);
3186
+ },
3187
+ beginPlay() {
3188
+ suppressed = true;
3189
+ },
3190
+ afterPresent() {
3191
+ paint(latest);
3192
+ suppressed = false;
3193
+ },
3194
+ get balance() {
3195
+ return latest;
3196
+ },
3197
+ };
3198
+ }
3199
+
3200
+ var balanceGate = /*#__PURE__*/Object.freeze({
3201
+ __proto__: null,
3202
+ createBalanceGate: createBalanceGate
3203
+ });
3204
+
3205
+ function createFreeSpinsCounter() {
3206
+ let total = 0;
3207
+ let current = 0;
3208
+ return {
3209
+ enter(awarded) {
3210
+ total = awarded;
3211
+ current = 0;
3212
+ return { current, total, totalWin: 0 };
3213
+ },
3214
+ spin(awarded, totalWin) {
3215
+ current += 1;
3216
+ total += awarded; // a retrigger grows the pool
3217
+ return { current, total, totalWin };
3218
+ },
3219
+ };
3220
+ }
3221
+
3222
+ var freeSpinsCounter = /*#__PURE__*/Object.freeze({
3223
+ __proto__: null,
3224
+ createFreeSpinsCounter: createFreeSpinsCounter
3225
+ });
3226
+
3227
+ /** Pull a Stake/SDK error code off an unknown thrown value. */
3228
+ function errorCode(err) {
3229
+ const code = err?.code;
3230
+ return typeof code === 'string' ? code : undefined;
3231
+ }
3232
+ function resolvePlayError(err) {
3233
+ const code = errorCode(err);
3234
+ const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
3235
+ switch (code) {
3236
+ case 'ACTIVE_SESSION_EXISTS':
3237
+ return {
3238
+ title: 'Round in progress',
3239
+ body: 'You have an unfinished round. Reload to resume it.',
3240
+ reload: true,
3241
+ };
3242
+ case 'NO_ACTIVE_SESSION':
3243
+ return {
3244
+ title: 'Round expired',
3245
+ body: 'This round is no longer active. Reload to continue.',
3246
+ reload: true,
3247
+ };
3248
+ case 'INSUFFICIENT_FUNDS':
3249
+ return {
3250
+ title: 'Insufficient balance',
3251
+ body: 'You don’t have enough balance for this bet. Lower your bet or top up.',
3252
+ reload: false,
3253
+ };
3254
+ case 'TIMEOUT':
3255
+ return {
3256
+ title: 'Connection timed out',
3257
+ body: 'The game server did not respond in time. Please try again.',
3258
+ reload: false,
3259
+ };
3260
+ default:
3261
+ // Unknown code: surface the server message verbatim under a generic heading (never the
3262
+ // connection overlay), so an operator can diagnose without a code change.
3263
+ return {
3264
+ title: 'Game error',
3265
+ body: message || 'Something went wrong. Please reload the game.',
3266
+ reload: true,
3267
+ };
3268
+ }
3269
+ }
3270
+
3271
+ var playError = /*#__PURE__*/Object.freeze({
3272
+ __proto__: null,
3273
+ errorCode: errorCode,
3274
+ resolvePlayError: resolvePlayError
3275
+ });
3276
+
3277
+ function createAutoplayLoop(deps) {
3278
+ let active = false;
3279
+ let remaining = 0;
3280
+ let running = false; // guards against a second concurrent loop
3281
+ const stop = () => {
3282
+ if (!active && remaining === 0)
3283
+ return;
3284
+ active = false;
3285
+ remaining = 0;
3286
+ deps.onState({ active: false, remaining: 0 });
3287
+ };
3288
+ async function loop() {
3289
+ if (running)
3290
+ return;
3291
+ running = true;
3292
+ try {
3293
+ while (active && remaining > 0) {
3294
+ const action = deps.resolveAction();
3295
+ if (!deps.canAfford(action)) {
3296
+ stop();
3297
+ return;
3298
+ }
3299
+ // Decrement at spin START (so the spin in flight is `total − remaining`), then play it out.
3300
+ remaining -= 1;
3301
+ deps.onState({ active: true, remaining });
3302
+ try {
3303
+ await deps.playRound(action);
3304
+ }
3305
+ catch {
3306
+ stop(); // a play error already surfaced its own modal — just halt the run
3307
+ return;
3308
+ }
3309
+ }
3310
+ if (active)
3311
+ stop(); // ran the budget out
3312
+ }
3313
+ finally {
3314
+ running = false;
3315
+ }
3316
+ }
3317
+ return {
3318
+ start(count) {
3319
+ if (active || running || count <= 0)
3320
+ return;
3321
+ active = true;
3322
+ remaining = count;
3323
+ deps.onState({ active: true, remaining });
3324
+ void loop();
3325
+ },
3326
+ stop,
3327
+ get active() { return active; },
3328
+ get remaining() { return remaining; },
3329
+ };
3330
+ }
3331
+
3332
+ var autoplay = /*#__PURE__*/Object.freeze({
3333
+ __proto__: null,
3334
+ createAutoplayLoop: createAutoplayLoop
3335
+ });
3336
+
3337
+ Object.defineProperty(exports, "socialize", {
3338
+ enumerable: true,
3339
+ get: function () { return shell.socialize; }
3340
+ });
3341
+ exports.buildShellConfig = buildShellConfig;
3342
+ exports.createSlotGame = createSlotGame;
3343
+ exports.resolveReplayBonusId = resolveReplayBonusId;
3344
+ exports.resolveStartScene = resolveStartScene;
3345
+ exports.stakeForAction = stakeForAction;
3346
+ //# sourceMappingURL=host.cjs.js.map