@energy8platform/game-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +1134 -0
  2. package/dist/animation.cjs.js +505 -0
  3. package/dist/animation.cjs.js.map +1 -0
  4. package/dist/animation.d.ts +235 -0
  5. package/dist/animation.esm.js +500 -0
  6. package/dist/animation.esm.js.map +1 -0
  7. package/dist/assets.cjs.js +148 -0
  8. package/dist/assets.cjs.js.map +1 -0
  9. package/dist/assets.d.ts +97 -0
  10. package/dist/assets.esm.js +146 -0
  11. package/dist/assets.esm.js.map +1 -0
  12. package/dist/audio.cjs.js +345 -0
  13. package/dist/audio.cjs.js.map +1 -0
  14. package/dist/audio.d.ts +135 -0
  15. package/dist/audio.esm.js +343 -0
  16. package/dist/audio.esm.js.map +1 -0
  17. package/dist/core.cjs.js +1832 -0
  18. package/dist/core.cjs.js.map +1 -0
  19. package/dist/core.d.ts +633 -0
  20. package/dist/core.esm.js +1827 -0
  21. package/dist/core.esm.js.map +1 -0
  22. package/dist/debug.cjs.js +298 -0
  23. package/dist/debug.cjs.js.map +1 -0
  24. package/dist/debug.d.ts +114 -0
  25. package/dist/debug.esm.js +295 -0
  26. package/dist/debug.esm.js.map +1 -0
  27. package/dist/index.cjs.js +3623 -0
  28. package/dist/index.cjs.js.map +1 -0
  29. package/dist/index.d.ts +1607 -0
  30. package/dist/index.esm.js +3598 -0
  31. package/dist/index.esm.js.map +1 -0
  32. package/dist/ui.cjs.js +1081 -0
  33. package/dist/ui.cjs.js.map +1 -0
  34. package/dist/ui.d.ts +387 -0
  35. package/dist/ui.esm.js +1072 -0
  36. package/dist/ui.esm.js.map +1 -0
  37. package/dist/vite.cjs.js +125 -0
  38. package/dist/vite.cjs.js.map +1 -0
  39. package/dist/vite.d.ts +42 -0
  40. package/dist/vite.esm.js +122 -0
  41. package/dist/vite.esm.js.map +1 -0
  42. package/package.json +107 -0
  43. package/src/animation/Easing.ts +116 -0
  44. package/src/animation/SpineHelper.ts +162 -0
  45. package/src/animation/Timeline.ts +138 -0
  46. package/src/animation/Tween.ts +225 -0
  47. package/src/animation/index.ts +4 -0
  48. package/src/assets/AssetManager.ts +174 -0
  49. package/src/assets/index.ts +2 -0
  50. package/src/audio/AudioManager.ts +366 -0
  51. package/src/audio/index.ts +1 -0
  52. package/src/core/EventEmitter.ts +47 -0
  53. package/src/core/GameApplication.ts +306 -0
  54. package/src/core/Scene.ts +48 -0
  55. package/src/core/SceneManager.ts +258 -0
  56. package/src/core/index.ts +4 -0
  57. package/src/debug/DevBridge.ts +259 -0
  58. package/src/debug/FPSOverlay.ts +102 -0
  59. package/src/debug/index.ts +3 -0
  60. package/src/index.ts +71 -0
  61. package/src/input/InputManager.ts +171 -0
  62. package/src/input/index.ts +1 -0
  63. package/src/loading/CSSPreloader.ts +155 -0
  64. package/src/loading/LoadingScene.ts +356 -0
  65. package/src/loading/index.ts +2 -0
  66. package/src/state/StateMachine.ts +228 -0
  67. package/src/state/index.ts +1 -0
  68. package/src/types.ts +218 -0
  69. package/src/ui/BalanceDisplay.ts +155 -0
  70. package/src/ui/Button.ts +199 -0
  71. package/src/ui/Label.ts +111 -0
  72. package/src/ui/Modal.ts +134 -0
  73. package/src/ui/Panel.ts +125 -0
  74. package/src/ui/ProgressBar.ts +121 -0
  75. package/src/ui/Toast.ts +124 -0
  76. package/src/ui/WinDisplay.ts +133 -0
  77. package/src/ui/index.ts +16 -0
  78. package/src/viewport/ViewportManager.ts +241 -0
  79. package/src/viewport/index.ts +1 -0
  80. package/src/vite/index.ts +153 -0
@@ -0,0 +1,1827 @@
1
+ import { Ticker, Assets, Container, Application } from 'pixi.js';
2
+ import { CasinoGameSDK } from '@energy8platform/game-sdk';
3
+
4
+ // ─── Scale Modes ───────────────────────────────────────────
5
+ var ScaleMode;
6
+ (function (ScaleMode) {
7
+ /** Fit inside container, maintain aspect ratio (letterbox/pillarbox) */
8
+ ScaleMode["FIT"] = "FIT";
9
+ /** Fill container, maintain aspect ratio (crop edges) */
10
+ ScaleMode["FILL"] = "FILL";
11
+ /** Stretch to fill (distorts) */
12
+ ScaleMode["STRETCH"] = "STRETCH";
13
+ })(ScaleMode || (ScaleMode = {}));
14
+ // ─── Orientation ───────────────────────────────────────────
15
+ var Orientation;
16
+ (function (Orientation) {
17
+ Orientation["LANDSCAPE"] = "landscape";
18
+ Orientation["PORTRAIT"] = "portrait";
19
+ Orientation["ANY"] = "any";
20
+ })(Orientation || (Orientation = {}));
21
+ // ─── Transition Types ──────────────────────────────────────
22
+ var TransitionType;
23
+ (function (TransitionType) {
24
+ TransitionType["NONE"] = "none";
25
+ TransitionType["FADE"] = "fade";
26
+ TransitionType["SLIDE_LEFT"] = "slide-left";
27
+ TransitionType["SLIDE_RIGHT"] = "slide-right";
28
+ })(TransitionType || (TransitionType = {}));
29
+
30
+ /**
31
+ * Minimal typed event emitter.
32
+ * Used internally by GameApplication, SceneManager, AudioManager, etc.
33
+ */
34
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
35
+ class EventEmitter {
36
+ listeners = new Map();
37
+ on(event, handler) {
38
+ if (!this.listeners.has(event)) {
39
+ this.listeners.set(event, new Set());
40
+ }
41
+ this.listeners.get(event).add(handler);
42
+ return this;
43
+ }
44
+ once(event, handler) {
45
+ const wrapper = (data) => {
46
+ this.off(event, wrapper);
47
+ handler(data);
48
+ };
49
+ return this.on(event, wrapper);
50
+ }
51
+ off(event, handler) {
52
+ this.listeners.get(event)?.delete(handler);
53
+ return this;
54
+ }
55
+ emit(event, data) {
56
+ const handlers = this.listeners.get(event);
57
+ if (handlers) {
58
+ for (const handler of handlers) {
59
+ handler(data);
60
+ }
61
+ }
62
+ }
63
+ removeAllListeners(event) {
64
+ if (event) {
65
+ this.listeners.delete(event);
66
+ }
67
+ else {
68
+ this.listeners.clear();
69
+ }
70
+ return this;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Collection of easing functions for use with Tween and Timeline.
76
+ *
77
+ * All functions take a progress value t (0..1) and return the eased value.
78
+ */
79
+ const Easing = {
80
+ easeOutQuad: (t) => t * (2 - t)};
81
+
82
+ /**
83
+ * Lightweight tween system integrated with PixiJS Ticker.
84
+ * Zero external dependencies — no GSAP required.
85
+ *
86
+ * All tweens return a Promise that resolves on completion.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * // Fade in a sprite
91
+ * await Tween.to(sprite, { alpha: 1, y: 100 }, 500, Easing.easeOutBack);
92
+ *
93
+ * // Move and wait
94
+ * await Tween.to(sprite, { x: 500 }, 300);
95
+ *
96
+ * // From a starting value
97
+ * await Tween.from(sprite, { scale: 0, alpha: 0 }, 400);
98
+ * ```
99
+ */
100
+ class Tween {
101
+ static _tweens = [];
102
+ static _tickerAdded = false;
103
+ /**
104
+ * Animate properties from current values to target values.
105
+ *
106
+ * @param target - Object to animate (Sprite, Container, etc.)
107
+ * @param props - Target property values
108
+ * @param duration - Duration in milliseconds
109
+ * @param easing - Easing function (default: easeOutQuad)
110
+ * @param onUpdate - Progress callback (0..1)
111
+ */
112
+ static to(target, props, duration, easing, onUpdate) {
113
+ return new Promise((resolve) => {
114
+ // Capture starting values
115
+ const from = {};
116
+ for (const key of Object.keys(props)) {
117
+ from[key] = Tween.getProperty(target, key);
118
+ }
119
+ const tween = {
120
+ target,
121
+ from,
122
+ to: { ...props },
123
+ duration: Math.max(1, duration),
124
+ easing: easing ?? Easing.easeOutQuad,
125
+ elapsed: 0,
126
+ delay: 0,
127
+ resolve,
128
+ onUpdate,
129
+ };
130
+ Tween._tweens.push(tween);
131
+ Tween.ensureTicker();
132
+ });
133
+ }
134
+ /**
135
+ * Animate properties from given values to current values.
136
+ */
137
+ static from(target, props, duration, easing, onUpdate) {
138
+ // Capture current values as "to"
139
+ const to = {};
140
+ for (const key of Object.keys(props)) {
141
+ to[key] = Tween.getProperty(target, key);
142
+ Tween.setProperty(target, key, props[key]);
143
+ }
144
+ return Tween.to(target, to, duration, easing, onUpdate);
145
+ }
146
+ /**
147
+ * Animate from one set of values to another.
148
+ */
149
+ static fromTo(target, fromProps, toProps, duration, easing, onUpdate) {
150
+ // Set starting values
151
+ for (const key of Object.keys(fromProps)) {
152
+ Tween.setProperty(target, key, fromProps[key]);
153
+ }
154
+ return Tween.to(target, toProps, duration, easing, onUpdate);
155
+ }
156
+ /**
157
+ * Wait for a given duration (useful in timelines).
158
+ */
159
+ static delay(ms) {
160
+ return new Promise((resolve) => setTimeout(resolve, ms));
161
+ }
162
+ /**
163
+ * Kill all tweens on a target.
164
+ */
165
+ static killTweensOf(target) {
166
+ Tween._tweens = Tween._tweens.filter((tw) => {
167
+ if (tw.target === target) {
168
+ tw.resolve();
169
+ return false;
170
+ }
171
+ return true;
172
+ });
173
+ }
174
+ /**
175
+ * Kill all active tweens.
176
+ */
177
+ static killAll() {
178
+ for (const tw of Tween._tweens) {
179
+ tw.resolve();
180
+ }
181
+ Tween._tweens.length = 0;
182
+ }
183
+ /** Number of active tweens */
184
+ static get activeTweens() {
185
+ return Tween._tweens.length;
186
+ }
187
+ // ─── Internal ──────────────────────────────────────────
188
+ static ensureTicker() {
189
+ if (Tween._tickerAdded)
190
+ return;
191
+ Tween._tickerAdded = true;
192
+ Ticker.shared.add(Tween.tick);
193
+ }
194
+ static tick = (ticker) => {
195
+ const dt = ticker.deltaMS;
196
+ const completed = [];
197
+ for (const tw of Tween._tweens) {
198
+ tw.elapsed += dt;
199
+ if (tw.elapsed < tw.delay)
200
+ continue;
201
+ const raw = Math.min((tw.elapsed - tw.delay) / tw.duration, 1);
202
+ const t = tw.easing(raw);
203
+ // Interpolate each property
204
+ for (const key of Object.keys(tw.to)) {
205
+ const start = tw.from[key];
206
+ const end = tw.to[key];
207
+ const value = start + (end - start) * t;
208
+ Tween.setProperty(tw.target, key, value);
209
+ }
210
+ tw.onUpdate?.(raw);
211
+ if (raw >= 1) {
212
+ completed.push(tw);
213
+ }
214
+ }
215
+ // Remove completed tweens
216
+ for (const tw of completed) {
217
+ const idx = Tween._tweens.indexOf(tw);
218
+ if (idx !== -1)
219
+ Tween._tweens.splice(idx, 1);
220
+ tw.resolve();
221
+ }
222
+ // Remove ticker when no active tweens
223
+ if (Tween._tweens.length === 0 && Tween._tickerAdded) {
224
+ Ticker.shared.remove(Tween.tick);
225
+ Tween._tickerAdded = false;
226
+ }
227
+ };
228
+ /**
229
+ * Get a potentially nested property (supports 'scale.x', 'position.y', etc.)
230
+ */
231
+ static getProperty(target, key) {
232
+ const parts = key.split('.');
233
+ let obj = target;
234
+ for (let i = 0; i < parts.length - 1; i++) {
235
+ obj = obj[parts[i]];
236
+ }
237
+ return obj[parts[parts.length - 1]] ?? 0;
238
+ }
239
+ /**
240
+ * Set a potentially nested property.
241
+ */
242
+ static setProperty(target, key, value) {
243
+ const parts = key.split('.');
244
+ let obj = target;
245
+ for (let i = 0; i < parts.length - 1; i++) {
246
+ obj = obj[parts[i]];
247
+ }
248
+ obj[parts[parts.length - 1]] = value;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Manages the scene stack and transitions between scenes.
254
+ *
255
+ * @example
256
+ * ```ts
257
+ * const scenes = new SceneManager(app.stage);
258
+ * scenes.register('loading', LoadingScene);
259
+ * scenes.register('game', GameScene);
260
+ * await scenes.goto('loading');
261
+ * ```
262
+ */
263
+ class SceneManager extends EventEmitter {
264
+ /** Root container that scenes are added to */
265
+ root;
266
+ registry = new Map();
267
+ stack = [];
268
+ _transitioning = false;
269
+ /** Current viewport dimensions — set by ViewportManager */
270
+ _width = 0;
271
+ _height = 0;
272
+ constructor(root) {
273
+ super();
274
+ if (root)
275
+ this.root = root;
276
+ }
277
+ /** @internal Set the root container (called by GameApplication after PixiJS init) */
278
+ setRoot(root) {
279
+ this.root = root;
280
+ }
281
+ /** Register a scene class by key */
282
+ register(key, ctor) {
283
+ this.registry.set(key, ctor);
284
+ return this;
285
+ }
286
+ /** Get the current (topmost) scene entry */
287
+ get current() {
288
+ return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null;
289
+ }
290
+ /** Get the current scene key */
291
+ get currentKey() {
292
+ return this.current?.key ?? null;
293
+ }
294
+ /** Whether a scene transition is in progress */
295
+ get isTransitioning() {
296
+ return this._transitioning;
297
+ }
298
+ /**
299
+ * Navigate to a scene, replacing the entire stack.
300
+ */
301
+ async goto(key, data, transition) {
302
+ const prevKey = this.currentKey;
303
+ // Exit all current scenes
304
+ while (this.stack.length > 0) {
305
+ await this.popInternal(false);
306
+ }
307
+ // Enter new scene
308
+ await this.pushInternal(key, data, transition);
309
+ this.emit('change', { from: prevKey, to: key });
310
+ }
311
+ /**
312
+ * Push a scene onto the stack (the previous scene stays underneath).
313
+ * Useful for overlays, modals, pause screens.
314
+ */
315
+ async push(key, data, transition) {
316
+ const prevKey = this.currentKey;
317
+ await this.pushInternal(key, data, transition);
318
+ this.emit('change', { from: prevKey, to: key });
319
+ }
320
+ /**
321
+ * Pop the top scene from the stack.
322
+ */
323
+ async pop(transition) {
324
+ if (this.stack.length <= 1) {
325
+ console.warn('[SceneManager] Cannot pop the last scene');
326
+ return;
327
+ }
328
+ const prevKey = this.currentKey;
329
+ await this.popInternal(true, transition);
330
+ this.emit('change', { from: prevKey, to: this.currentKey });
331
+ }
332
+ /**
333
+ * Replace the top scene with a new one.
334
+ */
335
+ async replace(key, data, transition) {
336
+ const prevKey = this.currentKey;
337
+ await this.popInternal(false);
338
+ await this.pushInternal(key, data, transition);
339
+ this.emit('change', { from: prevKey, to: key });
340
+ }
341
+ /**
342
+ * Called every frame by GameApplication.
343
+ */
344
+ update(dt) {
345
+ // Update only the top scene
346
+ this.current?.scene.onUpdate?.(dt);
347
+ }
348
+ /**
349
+ * Called on viewport resize.
350
+ */
351
+ resize(width, height) {
352
+ this._width = width;
353
+ this._height = height;
354
+ // Notify all scenes in the stack
355
+ for (const entry of this.stack) {
356
+ entry.scene.onResize?.(width, height);
357
+ }
358
+ }
359
+ /**
360
+ * Destroy all scenes and clear the manager.
361
+ */
362
+ destroy() {
363
+ for (const entry of this.stack) {
364
+ entry.scene.onDestroy?.();
365
+ entry.scene.container.destroy({ children: true });
366
+ }
367
+ this.stack.length = 0;
368
+ this.registry.clear();
369
+ this.removeAllListeners();
370
+ }
371
+ // ─── Internal ──────────────────────────────────────────
372
+ createScene(key) {
373
+ const Ctor = this.registry.get(key);
374
+ if (!Ctor) {
375
+ throw new Error(`[SceneManager] Scene "${key}" is not registered`);
376
+ }
377
+ return new Ctor();
378
+ }
379
+ async pushInternal(key, data, transition) {
380
+ this._transitioning = true;
381
+ const scene = this.createScene(key);
382
+ this.root.addChild(scene.container);
383
+ // Set initial size
384
+ if (this._width && this._height) {
385
+ scene.onResize?.(this._width, this._height);
386
+ }
387
+ // Transition in
388
+ await this.transitionIn(scene.container, transition);
389
+ await scene.onEnter?.(data);
390
+ this.stack.push({ scene, key });
391
+ this._transitioning = false;
392
+ }
393
+ async popInternal(showTransition, transition) {
394
+ const entry = this.stack.pop();
395
+ if (!entry)
396
+ return;
397
+ this._transitioning = true;
398
+ await entry.scene.onExit?.();
399
+ if (showTransition) {
400
+ await this.transitionOut(entry.scene.container, transition);
401
+ }
402
+ entry.scene.onDestroy?.();
403
+ entry.scene.container.destroy({ children: true });
404
+ this._transitioning = false;
405
+ }
406
+ async transitionIn(container, config) {
407
+ const type = config?.type ?? TransitionType.NONE;
408
+ const duration = config?.duration ?? 300;
409
+ if (type === TransitionType.NONE || duration <= 0)
410
+ return;
411
+ if (type === TransitionType.FADE) {
412
+ container.alpha = 0;
413
+ await Tween.to(container, { alpha: 1 }, duration, config?.easing);
414
+ }
415
+ else if (type === TransitionType.SLIDE_LEFT) {
416
+ container.x = this._width;
417
+ await Tween.to(container, { x: 0 }, duration, config?.easing);
418
+ }
419
+ else if (type === TransitionType.SLIDE_RIGHT) {
420
+ container.x = -this._width;
421
+ await Tween.to(container, { x: 0 }, duration, config?.easing);
422
+ }
423
+ }
424
+ async transitionOut(container, config) {
425
+ const type = config?.type ?? TransitionType.FADE;
426
+ const duration = config?.duration ?? 300;
427
+ if (type === TransitionType.NONE || duration <= 0)
428
+ return;
429
+ if (type === TransitionType.FADE) {
430
+ await Tween.to(container, { alpha: 0 }, duration, config?.easing);
431
+ }
432
+ else if (type === TransitionType.SLIDE_LEFT) {
433
+ await Tween.to(container, { x: -this._width }, duration, config?.easing);
434
+ }
435
+ else if (type === TransitionType.SLIDE_RIGHT) {
436
+ await Tween.to(container, { x: this._width }, duration, config?.easing);
437
+ }
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Manages game asset loading with progress tracking, bundle support, and
443
+ * automatic base path resolution from SDK's assetsUrl.
444
+ *
445
+ * Wraps PixiJS Assets API with a typed, game-oriented interface.
446
+ *
447
+ * @example
448
+ * ```ts
449
+ * const assets = new AssetManager('https://cdn.example.com/game/', manifest);
450
+ * await assets.init();
451
+ * await assets.loadBundle('preload', (p) => console.log(p));
452
+ * const texture = assets.get<Texture>('hero');
453
+ * ```
454
+ */
455
+ class AssetManager {
456
+ _initialized = false;
457
+ _basePath;
458
+ _manifest;
459
+ _loadedBundles = new Set();
460
+ constructor(basePath = '', manifest) {
461
+ this._basePath = basePath;
462
+ this._manifest = manifest ?? null;
463
+ }
464
+ /** Whether the asset system has been initialized */
465
+ get initialized() {
466
+ return this._initialized;
467
+ }
468
+ /** Base path for all assets (usually from SDK's assetsUrl) */
469
+ get basePath() {
470
+ return this._basePath;
471
+ }
472
+ /** Set of loaded bundle names */
473
+ get loadedBundles() {
474
+ return this._loadedBundles;
475
+ }
476
+ /**
477
+ * Initialize the asset system.
478
+ * Must be called before loading any assets.
479
+ */
480
+ async init() {
481
+ if (this._initialized)
482
+ return;
483
+ await Assets.init({
484
+ basePath: this._basePath || undefined,
485
+ texturePreference: {
486
+ resolution: Math.min(window.devicePixelRatio, 2),
487
+ format: ['webp', 'png'],
488
+ },
489
+ });
490
+ // Register bundles from manifest
491
+ if (this._manifest) {
492
+ for (const bundle of this._manifest.bundles) {
493
+ Assets.addBundle(bundle.name, bundle.assets.map((a) => ({
494
+ alias: a.alias,
495
+ src: a.src,
496
+ data: a.data,
497
+ })));
498
+ }
499
+ }
500
+ this._initialized = true;
501
+ }
502
+ /**
503
+ * Load a single bundle by name.
504
+ *
505
+ * @param name - Bundle name (must exist in the manifest)
506
+ * @param onProgress - Progress callback (0..1)
507
+ * @returns Loaded assets map
508
+ */
509
+ async loadBundle(name, onProgress) {
510
+ this.ensureInitialized();
511
+ const result = await Assets.loadBundle(name, onProgress);
512
+ this._loadedBundles.add(name);
513
+ return result;
514
+ }
515
+ /**
516
+ * Load multiple bundles simultaneously.
517
+ * Progress is aggregated across all bundles.
518
+ *
519
+ * @param names - Bundle names
520
+ * @param onProgress - Progress callback (0..1)
521
+ */
522
+ async loadBundles(names, onProgress) {
523
+ this.ensureInitialized();
524
+ const result = await Assets.loadBundle(names, onProgress);
525
+ for (const name of names) {
526
+ this._loadedBundles.add(name);
527
+ }
528
+ return result;
529
+ }
530
+ /**
531
+ * Load individual assets by URL or alias.
532
+ *
533
+ * @param urls - Asset URLs or aliases
534
+ * @param onProgress - Progress callback (0..1)
535
+ */
536
+ async load(urls, onProgress) {
537
+ this.ensureInitialized();
538
+ return Assets.load(urls, onProgress);
539
+ }
540
+ /**
541
+ * Get a loaded asset synchronously from cache.
542
+ *
543
+ * @param alias - Asset alias
544
+ * @throws if not loaded
545
+ */
546
+ get(alias) {
547
+ return Assets.get(alias);
548
+ }
549
+ /**
550
+ * Unload a bundle to free memory.
551
+ */
552
+ async unloadBundle(name) {
553
+ await Assets.unloadBundle(name);
554
+ this._loadedBundles.delete(name);
555
+ }
556
+ /**
557
+ * Start background loading a bundle (low-priority preload).
558
+ * Useful for loading bonus round assets while player is in base game.
559
+ */
560
+ async backgroundLoad(name) {
561
+ this.ensureInitialized();
562
+ await Assets.backgroundLoadBundle(name);
563
+ }
564
+ /**
565
+ * Get all bundle names from the manifest.
566
+ */
567
+ getBundleNames() {
568
+ return this._manifest?.bundles.map((b) => b.name) ?? [];
569
+ }
570
+ /**
571
+ * Check if a bundle is loaded.
572
+ */
573
+ isBundleLoaded(name) {
574
+ return this._loadedBundles.has(name);
575
+ }
576
+ ensureInitialized() {
577
+ if (!this._initialized) {
578
+ throw new Error('[AssetManager] Not initialized. Call init() first.');
579
+ }
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Manages all game audio: music, SFX, UI sounds, ambient.
585
+ *
586
+ * Optional dependency on @pixi/sound — if not installed, AudioManager
587
+ * operates as a silent no-op (graceful degradation).
588
+ *
589
+ * Features:
590
+ * - Per-category volume control (music, sfx, ui, ambient)
591
+ * - Music crossfade and looping
592
+ * - Mobile audio unlock on first interaction
593
+ * - Mute state persistence in localStorage
594
+ * - Global mute/unmute
595
+ *
596
+ * @example
597
+ * ```ts
598
+ * const audio = new AudioManager({ music: 0.5, sfx: 0.8 });
599
+ * await audio.init();
600
+ * audio.playMusic('bg-music');
601
+ * audio.play('spin-click', 'sfx');
602
+ * ```
603
+ */
604
+ class AudioManager {
605
+ _soundModule = null;
606
+ _initialized = false;
607
+ _globalMuted = false;
608
+ _persist;
609
+ _storageKey;
610
+ _categories;
611
+ _currentMusic = null;
612
+ _unlocked = false;
613
+ _unlockHandler = null;
614
+ constructor(config) {
615
+ this._persist = config?.persist ?? true;
616
+ this._storageKey = config?.storageKey ?? 'ge_audio';
617
+ this._categories = {
618
+ music: { volume: config?.music ?? 0.7, muted: false },
619
+ sfx: { volume: config?.sfx ?? 1.0, muted: false },
620
+ ui: { volume: config?.ui ?? 0.8, muted: false },
621
+ ambient: { volume: config?.ambient ?? 0.5, muted: false },
622
+ };
623
+ // Restore persisted state
624
+ if (this._persist) {
625
+ this.restoreState();
626
+ }
627
+ }
628
+ /** Whether the audio system is initialized */
629
+ get initialized() {
630
+ return this._initialized;
631
+ }
632
+ /** Whether audio is globally muted */
633
+ get muted() {
634
+ return this._globalMuted;
635
+ }
636
+ /**
637
+ * Initialize the audio system.
638
+ * Dynamically imports @pixi/sound to keep it optional.
639
+ */
640
+ async init() {
641
+ if (this._initialized)
642
+ return;
643
+ try {
644
+ this._soundModule = await import('@pixi/sound');
645
+ this._initialized = true;
646
+ this.applyVolumes();
647
+ this.setupMobileUnlock();
648
+ }
649
+ catch {
650
+ console.warn('[AudioManager] @pixi/sound not available. Audio disabled.');
651
+ this._initialized = false;
652
+ }
653
+ }
654
+ /**
655
+ * Play a sound effect.
656
+ *
657
+ * @param alias - Sound alias (must be loaded via AssetManager)
658
+ * @param category - Audio category (default: 'sfx')
659
+ * @param options - Additional play options
660
+ */
661
+ play(alias, category = 'sfx', options) {
662
+ if (!this._initialized || !this._soundModule)
663
+ return;
664
+ if (this._globalMuted || this._categories[category].muted)
665
+ return;
666
+ const { sound } = this._soundModule;
667
+ const vol = (options?.volume ?? 1) * this._categories[category].volume;
668
+ try {
669
+ sound.play(alias, {
670
+ volume: vol,
671
+ loop: options?.loop ?? false,
672
+ speed: options?.speed ?? 1,
673
+ });
674
+ }
675
+ catch (e) {
676
+ console.warn(`[AudioManager] Failed to play "${alias}":`, e);
677
+ }
678
+ }
679
+ /**
680
+ * Play background music with optional crossfade.
681
+ *
682
+ * @param alias - Music alias
683
+ * @param fadeDuration - Crossfade duration in ms (default: 500)
684
+ */
685
+ playMusic(alias, fadeDuration = 500) {
686
+ if (!this._initialized || !this._soundModule)
687
+ return;
688
+ const { sound } = this._soundModule;
689
+ // Stop current music
690
+ if (this._currentMusic) {
691
+ try {
692
+ sound.stop(this._currentMusic);
693
+ }
694
+ catch {
695
+ // ignore
696
+ }
697
+ }
698
+ this._currentMusic = alias;
699
+ if (this._globalMuted || this._categories.music.muted)
700
+ return;
701
+ try {
702
+ sound.play(alias, {
703
+ volume: this._categories.music.volume,
704
+ loop: true,
705
+ });
706
+ }
707
+ catch (e) {
708
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
709
+ }
710
+ }
711
+ /**
712
+ * Stop current music.
713
+ */
714
+ stopMusic() {
715
+ if (!this._initialized || !this._soundModule || !this._currentMusic)
716
+ return;
717
+ const { sound } = this._soundModule;
718
+ try {
719
+ sound.stop(this._currentMusic);
720
+ }
721
+ catch {
722
+ // ignore
723
+ }
724
+ this._currentMusic = null;
725
+ }
726
+ /**
727
+ * Stop all sounds.
728
+ */
729
+ stopAll() {
730
+ if (!this._initialized || !this._soundModule)
731
+ return;
732
+ const { sound } = this._soundModule;
733
+ sound.stopAll();
734
+ this._currentMusic = null;
735
+ }
736
+ /**
737
+ * Set volume for a category.
738
+ */
739
+ setVolume(category, volume) {
740
+ this._categories[category].volume = Math.max(0, Math.min(1, volume));
741
+ this.applyVolumes();
742
+ this.saveState();
743
+ }
744
+ /**
745
+ * Get volume for a category.
746
+ */
747
+ getVolume(category) {
748
+ return this._categories[category].volume;
749
+ }
750
+ /**
751
+ * Mute a specific category.
752
+ */
753
+ muteCategory(category) {
754
+ this._categories[category].muted = true;
755
+ this.applyVolumes();
756
+ this.saveState();
757
+ }
758
+ /**
759
+ * Unmute a specific category.
760
+ */
761
+ unmuteCategory(category) {
762
+ this._categories[category].muted = false;
763
+ this.applyVolumes();
764
+ this.saveState();
765
+ }
766
+ /**
767
+ * Toggle mute for a category.
768
+ */
769
+ toggleCategory(category) {
770
+ this._categories[category].muted = !this._categories[category].muted;
771
+ this.applyVolumes();
772
+ this.saveState();
773
+ return this._categories[category].muted;
774
+ }
775
+ /**
776
+ * Mute all audio globally.
777
+ */
778
+ muteAll() {
779
+ this._globalMuted = true;
780
+ if (this._soundModule) {
781
+ this._soundModule.sound.muteAll();
782
+ }
783
+ this.saveState();
784
+ }
785
+ /**
786
+ * Unmute all audio globally.
787
+ */
788
+ unmuteAll() {
789
+ this._globalMuted = false;
790
+ if (this._soundModule) {
791
+ this._soundModule.sound.unmuteAll();
792
+ }
793
+ this.saveState();
794
+ }
795
+ /**
796
+ * Toggle global mute.
797
+ */
798
+ toggleMute() {
799
+ if (this._globalMuted) {
800
+ this.unmuteAll();
801
+ }
802
+ else {
803
+ this.muteAll();
804
+ }
805
+ return this._globalMuted;
806
+ }
807
+ /**
808
+ * Duck music volume (e.g., during big win presentation).
809
+ *
810
+ * @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal
811
+ */
812
+ duckMusic(factor) {
813
+ if (!this._initialized || !this._soundModule || !this._currentMusic)
814
+ return;
815
+ const { sound } = this._soundModule;
816
+ const vol = this._categories.music.volume * factor;
817
+ try {
818
+ sound.volume(this._currentMusic, vol);
819
+ }
820
+ catch {
821
+ // ignore
822
+ }
823
+ }
824
+ /**
825
+ * Restore music to normal volume after ducking.
826
+ */
827
+ unduckMusic() {
828
+ if (!this._initialized || !this._soundModule || !this._currentMusic)
829
+ return;
830
+ const { sound } = this._soundModule;
831
+ try {
832
+ sound.volume(this._currentMusic, this._categories.music.volume);
833
+ }
834
+ catch {
835
+ // ignore
836
+ }
837
+ }
838
+ /**
839
+ * Destroy the audio manager and free resources.
840
+ */
841
+ destroy() {
842
+ this.stopAll();
843
+ this.removeMobileUnlock();
844
+ if (this._soundModule) {
845
+ this._soundModule.sound.removeAll();
846
+ }
847
+ this._initialized = false;
848
+ }
849
+ // ─── Private ───────────────────────────────────────────
850
+ applyVolumes() {
851
+ if (!this._soundModule)
852
+ return;
853
+ const { sound } = this._soundModule;
854
+ sound.volumeAll = this._globalMuted ? 0 : 1;
855
+ }
856
+ setupMobileUnlock() {
857
+ if (this._unlocked)
858
+ return;
859
+ this._unlockHandler = () => {
860
+ if (!this._soundModule)
861
+ return;
862
+ const { sound } = this._soundModule;
863
+ // Resume WebAudio context
864
+ if (sound.context?.audioContext?.state === 'suspended') {
865
+ sound.context.audioContext.resume();
866
+ }
867
+ this._unlocked = true;
868
+ this.removeMobileUnlock();
869
+ };
870
+ const events = ['touchstart', 'mousedown', 'pointerdown', 'keydown'];
871
+ for (const event of events) {
872
+ document.addEventListener(event, this._unlockHandler, { once: true });
873
+ }
874
+ }
875
+ removeMobileUnlock() {
876
+ if (!this._unlockHandler)
877
+ return;
878
+ const events = ['touchstart', 'mousedown', 'pointerdown', 'keydown'];
879
+ for (const event of events) {
880
+ document.removeEventListener(event, this._unlockHandler);
881
+ }
882
+ this._unlockHandler = null;
883
+ }
884
+ saveState() {
885
+ if (!this._persist)
886
+ return;
887
+ try {
888
+ const state = {
889
+ globalMuted: this._globalMuted,
890
+ categories: this._categories,
891
+ };
892
+ localStorage.setItem(this._storageKey, JSON.stringify(state));
893
+ }
894
+ catch {
895
+ // localStorage may not be available
896
+ }
897
+ }
898
+ restoreState() {
899
+ try {
900
+ const raw = localStorage.getItem(this._storageKey);
901
+ if (!raw)
902
+ return;
903
+ const state = JSON.parse(raw);
904
+ if (typeof state.globalMuted === 'boolean') {
905
+ this._globalMuted = state.globalMuted;
906
+ }
907
+ if (state.categories) {
908
+ for (const key of ['music', 'sfx', 'ui', 'ambient']) {
909
+ if (state.categories[key]) {
910
+ this._categories[key] = {
911
+ volume: state.categories[key].volume ?? this._categories[key].volume,
912
+ muted: state.categories[key].muted ?? false,
913
+ };
914
+ }
915
+ }
916
+ }
917
+ }
918
+ catch {
919
+ // ignore
920
+ }
921
+ }
922
+ }
923
+
924
+ /**
925
+ * Manages responsive scaling of the game canvas to fit its container.
926
+ *
927
+ * Supports three scale modes:
928
+ * - **FIT** — letterbox/pillarbox to maintain aspect ratio (industry standard)
929
+ * - **FILL** — fill container, crop edges
930
+ * - **STRETCH** — stretch to fill (distorts)
931
+ *
932
+ * Also handles:
933
+ * - Orientation detection (landscape/portrait)
934
+ * - Safe areas (mobile notch)
935
+ * - ResizeObserver for smooth container resizing
936
+ *
937
+ * @example
938
+ * ```ts
939
+ * const viewport = new ViewportManager(app, container, {
940
+ * designWidth: 1920,
941
+ * designHeight: 1080,
942
+ * scaleMode: ScaleMode.FIT,
943
+ * orientation: Orientation.LANDSCAPE,
944
+ * });
945
+ *
946
+ * viewport.on('resize', ({ width, height, scale }) => {
947
+ * console.log(`New size: ${width}x${height} @ ${scale}x`);
948
+ * });
949
+ * ```
950
+ */
951
+ class ViewportManager extends EventEmitter {
952
+ _app;
953
+ _container;
954
+ _config;
955
+ _resizeObserver = null;
956
+ _currentOrientation = Orientation.LANDSCAPE;
957
+ _currentWidth = 0;
958
+ _currentHeight = 0;
959
+ _currentScale = 1;
960
+ _destroyed = false;
961
+ _resizeTimeout = null;
962
+ constructor(app, container, config) {
963
+ super();
964
+ this._app = app;
965
+ this._container = container;
966
+ this._config = config;
967
+ this.setupObserver();
968
+ }
969
+ /** Current canvas width in game units */
970
+ get width() {
971
+ return this._currentWidth;
972
+ }
973
+ /** Current canvas height in game units */
974
+ get height() {
975
+ return this._currentHeight;
976
+ }
977
+ /** Current scale factor */
978
+ get scale() {
979
+ return this._currentScale;
980
+ }
981
+ /** Current orientation */
982
+ get orientation() {
983
+ return this._currentOrientation;
984
+ }
985
+ /** Design reference width */
986
+ get designWidth() {
987
+ return this._config.designWidth;
988
+ }
989
+ /** Design reference height */
990
+ get designHeight() {
991
+ return this._config.designHeight;
992
+ }
993
+ /**
994
+ * Force a resize calculation. Called automatically on container size change.
995
+ */
996
+ refresh() {
997
+ if (this._destroyed)
998
+ return;
999
+ const containerWidth = this._container.clientWidth || window.innerWidth;
1000
+ const containerHeight = this._container.clientHeight || window.innerHeight;
1001
+ if (containerWidth === 0 || containerHeight === 0)
1002
+ return;
1003
+ const { designWidth, designHeight, scaleMode } = this._config;
1004
+ const designRatio = designWidth / designHeight;
1005
+ const containerRatio = containerWidth / containerHeight;
1006
+ let gameWidth;
1007
+ let gameHeight;
1008
+ let scale;
1009
+ switch (scaleMode) {
1010
+ case ScaleMode.FIT: {
1011
+ if (containerRatio > designRatio) {
1012
+ // Container is wider → pillarbox
1013
+ scale = containerHeight / designHeight;
1014
+ gameWidth = designWidth;
1015
+ gameHeight = designHeight;
1016
+ }
1017
+ else {
1018
+ // Container is taller → letterbox
1019
+ scale = containerWidth / designWidth;
1020
+ gameWidth = designWidth;
1021
+ gameHeight = designHeight;
1022
+ }
1023
+ break;
1024
+ }
1025
+ case ScaleMode.FILL: {
1026
+ if (containerRatio > designRatio) {
1027
+ // Container is wider → crop top/bottom
1028
+ scale = containerWidth / designWidth;
1029
+ }
1030
+ else {
1031
+ // Container is taller → crop left/right
1032
+ scale = containerHeight / designHeight;
1033
+ }
1034
+ gameWidth = containerWidth / scale;
1035
+ gameHeight = containerHeight / scale;
1036
+ break;
1037
+ }
1038
+ case ScaleMode.STRETCH: {
1039
+ gameWidth = designWidth;
1040
+ gameHeight = designHeight;
1041
+ scale = 1; // stretch is handled by CSS
1042
+ break;
1043
+ }
1044
+ default:
1045
+ gameWidth = designWidth;
1046
+ gameHeight = designHeight;
1047
+ scale = 1;
1048
+ }
1049
+ // Resize the renderer
1050
+ this._app.renderer.resize(Math.round(containerWidth), Math.round(containerHeight));
1051
+ // Scale the stage
1052
+ const stageScale = scaleMode === ScaleMode.STRETCH
1053
+ ? Math.min(containerWidth / designWidth, containerHeight / designHeight)
1054
+ : scale;
1055
+ this._app.stage.scale.set(stageScale);
1056
+ // Center the stage for FIT mode
1057
+ if (scaleMode === ScaleMode.FIT) {
1058
+ this._app.stage.x = Math.round((containerWidth - designWidth * stageScale) / 2);
1059
+ this._app.stage.y = Math.round((containerHeight - designHeight * stageScale) / 2);
1060
+ }
1061
+ else if (scaleMode === ScaleMode.FILL) {
1062
+ this._app.stage.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
1063
+ this._app.stage.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
1064
+ }
1065
+ else {
1066
+ this._app.stage.x = 0;
1067
+ this._app.stage.y = 0;
1068
+ }
1069
+ this._currentWidth = gameWidth;
1070
+ this._currentHeight = gameHeight;
1071
+ this._currentScale = stageScale;
1072
+ // Check orientation
1073
+ const newOrientation = containerWidth >= containerHeight ? Orientation.LANDSCAPE : Orientation.PORTRAIT;
1074
+ if (newOrientation !== this._currentOrientation) {
1075
+ this._currentOrientation = newOrientation;
1076
+ this.emit('orientationChange', newOrientation);
1077
+ }
1078
+ this.emit('resize', {
1079
+ width: gameWidth,
1080
+ height: gameHeight,
1081
+ scale: stageScale,
1082
+ });
1083
+ }
1084
+ /**
1085
+ * Destroy the viewport manager.
1086
+ */
1087
+ destroy() {
1088
+ this._destroyed = true;
1089
+ this._resizeObserver?.disconnect();
1090
+ this._resizeObserver = null;
1091
+ if (this._resizeTimeout !== null) {
1092
+ clearTimeout(this._resizeTimeout);
1093
+ }
1094
+ this.removeAllListeners();
1095
+ }
1096
+ // ─── Private ───────────────────────────────────────────
1097
+ setupObserver() {
1098
+ if (typeof ResizeObserver !== 'undefined') {
1099
+ this._resizeObserver = new ResizeObserver(() => {
1100
+ this.debouncedRefresh();
1101
+ });
1102
+ this._resizeObserver.observe(this._container);
1103
+ }
1104
+ else {
1105
+ // Fallback for older browsers
1106
+ window.addEventListener('resize', this.onWindowResize);
1107
+ }
1108
+ }
1109
+ onWindowResize = () => {
1110
+ this.debouncedRefresh();
1111
+ };
1112
+ debouncedRefresh() {
1113
+ if (this._resizeTimeout !== null) {
1114
+ clearTimeout(this._resizeTimeout);
1115
+ }
1116
+ this._resizeTimeout = window.setTimeout(() => {
1117
+ this.refresh();
1118
+ this._resizeTimeout = null;
1119
+ }, 16); // ~1 frame
1120
+ }
1121
+ }
1122
+
1123
+ /**
1124
+ * Base class for all scenes.
1125
+ * Provides a root PixiJS Container and lifecycle hooks.
1126
+ *
1127
+ * @example
1128
+ * ```ts
1129
+ * class MenuScene extends Scene {
1130
+ * async onEnter() {
1131
+ * const bg = Sprite.from('menu-bg');
1132
+ * this.container.addChild(bg);
1133
+ * }
1134
+ *
1135
+ * onUpdate(dt: number) {
1136
+ * // per-frame logic
1137
+ * }
1138
+ *
1139
+ * onResize(width: number, height: number) {
1140
+ * // reposition UI
1141
+ * }
1142
+ * }
1143
+ * ```
1144
+ */
1145
+ class Scene {
1146
+ container;
1147
+ constructor() {
1148
+ this.container = new Container();
1149
+ this.container.label = this.constructor.name;
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Inline SVG logo with a loader bar (clip-animated for progress).
1155
+ * The clipPath rect width is set to 0 initially, expanded as loading progresses.
1156
+ */
1157
+ function buildLogoSVG() {
1158
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 200" fill="none" style="width:100%;height:auto;">
1159
+ <path d="m241 81.75h-19.28c-1.77 0-6.73 4.98-7.43 6.99l-4.36 12.22c-0.49 1.37 0.05 2.92 1.06 4.32-2.07 1.19-3.69 3.08-4.36 5.43l-3.25 10.41c-0.86 2.89 2.39 6.63 4.31 6.63h19.28c1.96 0 7.4-5.56 7.96-7.51l2.96-10.22c0.63-2.25 0.1-3.98-1.22-4.99 2.55-1.56 3.86-4.14 4.55-6.31l2.77-9.31c0.74-2.57-1.37-7.66-2.99-7.66zm-13.36 28.31-2.27 7.03h-8.28l2.58-8.28h8.28l-0.31 1.25zm4.06-16.97-2.11 6.7h-7.04l2.25-7.34h7.26l-0.36 0.64z" fill="url(#ls0)"/>
1160
+ <path d="m202.5 81.75-9.31 14.97-2.32-14.97h-11.82l4.32 25.15-0.57 4.91-8.64 26.44 15.31-12.76 5.63-16.48 19.96-27.26h-12.56z" fill="url(#ls1)"/>
1161
+ <path d="m174.2 81.75h-19.78l-5.75 5.16-10.79 33.2c-0.77 2.53 2.48 6.93 4.87 6.93h17.38c2.63 0 7.85-5.34 8.32-6.83l5.37-18.14h-15.17l-2.2 7.64h3.78l-2.25 7.2h-8.01l7.1-25.52h7.58l-1.48 8.4 12.78-5.98c1.28-0.63 1.97-3.99 1.61-6.61-0.36-2.34-1.64-5.45-3.36-5.45z" fill="url(#ls2)"/>
1162
+ <path d="m140.6 81.75h-70.6l-5.36 19.37-4.26-19.37h-46.76l2.95 5.88-10.58 39.28h26.84l2.95-9.52-15.63-0.13 2.55-8.34h8.74l8.47-9.81h-14.61l2.11-7.3h15.47l2.54-8.71 2.58 4.74-11.4 39.07h11.05l6.46-21.49 8.84 36.33 19.18-55.67-1.83-3.36 3.68 4.09-12.07 40.1h28.18l3.39-10.31h-17.01l2.67-8.03h9.98l7.58-9.52h-14.28l1.93-6.6h14.61l3.25-9.73 2.81 5.12-11.3 38.89h11.05l5.23-17.81h1.62l1.48 17.6h10.69l-1.48-16.81c4.75-1.28 7.52-5.9 8.64-9.81l2.95-11.3c0.86-2.73-1.43-6.85-3.3-6.85zm-9.8 17.3h-8.69l2.54-7.84h8.35l-2.2 7.84z" fill="url(#ls3)"/>
1163
+ <path d="m205.9 148.9h-122.6l-2.61-3.12h-32.4l-2.51 3.12h-1.59c-5.34 0-7.94 4.88-7.94 7.65v0.03c0 4.2 3.55 7.6 7.74 7.6h103.6l2.11 3.12h36.09l1.82-3.12h18.3c5.25 0 6.64-5.3 6.64-7.35v-0.25c0-4.23-2.9-7.68-6.64-7.68zm-0.7 12.83h-160.6c-3.69 0-6.11-2.58-6.11-5.47v-0.03c0-2.89 2.1-5.47 5.61-5.47h161.1c3.45 0 4.89 3.12 4.89 5.65v0.17c0 2.57-2.11 5.15-4.89 5.15z" fill="url(#ls4)"/>
1164
+ <clipPath id="ge-canvas-loader-clip">
1165
+ <rect id="ge-loader-rect" x="37" y="148" width="0" height="20"/>
1166
+ </clipPath>
1167
+ <path d="m204.5 152.6h-159.8c-2.78 0-4.45 1.69-4.45 3.99v0.11c0 2.04 1.42 3.43 3.64 3.43h160.6c2.88 0 3.67-2.07 3.67-3.43v-0.25c0-2.04-1.48-3.85-3.67-3.85z" fill="url(#ls5)" clip-path="url(#ge-canvas-loader-clip)"/>
1168
+ <text id="ge-loader-pct" x="125" y="196" text-anchor="middle" fill="rgba(255,255,255,0.7)" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="8" font-weight="600" letter-spacing="1.5">0%</text>
1169
+ <defs>
1170
+ <linearGradient id="ls0" x1="223.7" x2="223.7" y1="81.75" y2="127.8" gradientUnits="userSpaceOnUse">
1171
+ <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1172
+ </linearGradient>
1173
+ <linearGradient id="ls1" x1="194.6" x2="194.6" y1="81.75" y2="138.3" gradientUnits="userSpaceOnUse">
1174
+ <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1175
+ </linearGradient>
1176
+ <linearGradient id="ls2" x1="157.8" x2="157.8" y1="81.75" y2="127" gradientUnits="userSpaceOnUse">
1177
+ <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1178
+ </linearGradient>
1179
+ <linearGradient id="ls3" x1="79.96" x2="79.96" y1="81.75" y2="141.8" gradientUnits="userSpaceOnUse">
1180
+ <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1181
+ </linearGradient>
1182
+ <linearGradient id="ls4" x1="36.18" x2="212.5" y1="156.6" y2="156.6" gradientUnits="userSpaceOnUse">
1183
+ <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1184
+ </linearGradient>
1185
+ <linearGradient id="ls5" x1="40.27" x2="208.2" y1="156.4" y2="156.4" gradientUnits="userSpaceOnUse">
1186
+ <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1187
+ </linearGradient>
1188
+ </defs>
1189
+ </svg>`;
1190
+ }
1191
+ /** Max width of the loader bar in SVG units */
1192
+ const LOADER_BAR_MAX_WIDTH = 174;
1193
+ /**
1194
+ * Built-in loading screen using the Energy8 SVG logo with animated loader bar.
1195
+ *
1196
+ * Renders as an HTML overlay on top of the canvas for crisp SVG quality.
1197
+ * The loader bar fill width is driven by asset loading progress.
1198
+ */
1199
+ class LoadingScene extends Scene {
1200
+ _engine;
1201
+ _targetScene;
1202
+ _targetData;
1203
+ _config;
1204
+ // HTML overlay
1205
+ _overlay = null;
1206
+ _loaderRect = null;
1207
+ _percentEl = null;
1208
+ _tapToStartEl = null;
1209
+ // State
1210
+ _displayedProgress = 0;
1211
+ _targetProgress = 0;
1212
+ _loadingComplete = false;
1213
+ _startTime = 0;
1214
+ async onEnter(data) {
1215
+ const { engine, targetScene, targetData } = data;
1216
+ this._engine = engine;
1217
+ this._targetScene = targetScene;
1218
+ this._targetData = targetData;
1219
+ this._config = engine.config.loading ?? {};
1220
+ this._startTime = Date.now();
1221
+ // Create the HTML overlay with the SVG logo
1222
+ this.createOverlay();
1223
+ // Initialize asset manager
1224
+ await this._engine.assets.init();
1225
+ // Initialize audio manager
1226
+ await this._engine.audio.init();
1227
+ // Phase 1: Load preload bundle
1228
+ const bundles = this._engine.assets.getBundleNames();
1229
+ const hasPreload = bundles.includes('preload');
1230
+ if (hasPreload) {
1231
+ const preloadAssets = this._engine.config.manifest?.bundles?.find((b) => b.name === 'preload')?.assets;
1232
+ if (preloadAssets && preloadAssets.length > 0) {
1233
+ await this._engine.assets.loadBundle('preload', (p) => {
1234
+ this._targetProgress = p * 0.15;
1235
+ });
1236
+ }
1237
+ else {
1238
+ this._targetProgress = 0.15;
1239
+ }
1240
+ }
1241
+ // Phase 2: Load remaining bundles
1242
+ const remainingBundles = bundles.filter((b) => b !== 'preload' && !this._engine.assets.isBundleLoaded(b));
1243
+ if (remainingBundles.length > 0) {
1244
+ const hasAssets = remainingBundles.some((name) => {
1245
+ const bundle = this._engine.config.manifest?.bundles?.find((b) => b.name === name);
1246
+ return bundle?.assets && bundle.assets.length > 0;
1247
+ });
1248
+ if (hasAssets) {
1249
+ await this._engine.assets.loadBundles(remainingBundles, (p) => {
1250
+ this._targetProgress = 0.15 + p * 0.85;
1251
+ });
1252
+ }
1253
+ }
1254
+ this._targetProgress = 1;
1255
+ this._loadingComplete = true;
1256
+ // Enforce minimum display time: spread the remaining progress fill
1257
+ // over the remaining time so the bar fills smoothly, not abruptly
1258
+ const minTime = this._config.minDisplayTime ?? 1500;
1259
+ const elapsed = Date.now() - this._startTime;
1260
+ const remaining = Math.max(0, minTime - elapsed);
1261
+ if (remaining > 0) {
1262
+ // Distribute fill animation over the remaining time
1263
+ await this.animateProgressTo(1, remaining);
1264
+ }
1265
+ // Final snap to 100%
1266
+ this._displayedProgress = 1;
1267
+ this.updateLoaderBar(1);
1268
+ // Show "Tap to Start" or transition directly
1269
+ if (this._config.tapToStart !== false) {
1270
+ await this.showTapToStart();
1271
+ }
1272
+ else {
1273
+ await this.transitionToGame();
1274
+ }
1275
+ }
1276
+ onUpdate(dt) {
1277
+ // Smooth progress bar fill via HTML (during active loading)
1278
+ if (!this._loadingComplete && this._displayedProgress < this._targetProgress) {
1279
+ this._displayedProgress = Math.min(this._displayedProgress + dt * 1.5, this._targetProgress);
1280
+ this.updateLoaderBar(this._displayedProgress);
1281
+ }
1282
+ }
1283
+ onResize(_width, _height) {
1284
+ // Overlay is CSS-based, auto-resizes
1285
+ }
1286
+ onDestroy() {
1287
+ this.removeOverlay();
1288
+ }
1289
+ // ─── HTML Overlay ──────────────────────────────────────
1290
+ createOverlay() {
1291
+ const bgColor = typeof this._config.backgroundColor === 'string'
1292
+ ? this._config.backgroundColor
1293
+ : typeof this._config.backgroundColor === 'number'
1294
+ ? `#${this._config.backgroundColor.toString(16).padStart(6, '0')}`
1295
+ : '#0a0a1a';
1296
+ const bgGradient = this._config.backgroundGradient ??
1297
+ `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
1298
+ this._overlay = document.createElement('div');
1299
+ this._overlay.id = '__ge-loading-overlay__';
1300
+ this._overlay.innerHTML = `
1301
+ <div class="ge-loading-content">
1302
+ ${buildLogoSVG()}
1303
+ </div>
1304
+ `;
1305
+ const style = document.createElement('style');
1306
+ style.id = '__ge-loading-style__';
1307
+ style.textContent = `
1308
+ #__ge-loading-overlay__ {
1309
+ position: absolute;
1310
+ top: 0; left: 0;
1311
+ width: 100%; height: 100%;
1312
+ background: ${bgGradient};
1313
+ display: flex;
1314
+ align-items: center;
1315
+ justify-content: center;
1316
+ z-index: 9999;
1317
+ transition: opacity 0.5s ease-out;
1318
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1319
+ }
1320
+ #__ge-loading-overlay__.ge-fade-out {
1321
+ opacity: 0;
1322
+ pointer-events: none;
1323
+ }
1324
+ .ge-loading-content {
1325
+ display: flex;
1326
+ flex-direction: column;
1327
+ align-items: center;
1328
+ width: 75%;
1329
+ max-width: 650px;
1330
+ }
1331
+ .ge-loading-content svg {
1332
+ filter: drop-shadow(0 0 40px rgba(121, 57, 194, 0.5));
1333
+ cursor: default;
1334
+ }
1335
+
1336
+ .ge-svg-pulse {
1337
+ animation: ge-tap-pulse 1.2s ease-in-out infinite;
1338
+ }
1339
+ @keyframes ge-tap-pulse {
1340
+ 0%, 100% { opacity: 0.5; }
1341
+ 50% { opacity: 1; }
1342
+ }
1343
+ `;
1344
+ // Get the container that holds the canvas
1345
+ const container = this._engine.app?.canvas?.parentElement;
1346
+ if (container) {
1347
+ container.style.position = container.style.position || 'relative';
1348
+ container.appendChild(style);
1349
+ container.appendChild(this._overlay);
1350
+ }
1351
+ // Cache the SVG loader rect for progress updates
1352
+ this._loaderRect = this._overlay.querySelector('#ge-loader-rect');
1353
+ this._percentEl = this._overlay.querySelector('#ge-loader-pct');
1354
+ }
1355
+ removeOverlay() {
1356
+ this._overlay?.remove();
1357
+ document.getElementById('__ge-loading-style__')?.remove();
1358
+ this._overlay = null;
1359
+ this._loaderRect = null;
1360
+ this._percentEl = null;
1361
+ this._tapToStartEl = null;
1362
+ }
1363
+ // ─── Progress ──────────────────────────────────────────
1364
+ updateLoaderBar(progress) {
1365
+ if (this._loaderRect) {
1366
+ this._loaderRect.setAttribute('width', String(LOADER_BAR_MAX_WIDTH * progress));
1367
+ }
1368
+ if (this._percentEl) {
1369
+ const pct = Math.round(progress * 100);
1370
+ this._percentEl.textContent = `${pct}%`;
1371
+ }
1372
+ }
1373
+ /**
1374
+ * Smoothly animate the displayed progress from its current value to `target`
1375
+ * over `durationMs` using an easeOutCubic curve.
1376
+ */
1377
+ async animateProgressTo(target, durationMs) {
1378
+ const startVal = this._displayedProgress;
1379
+ const delta = target - startVal;
1380
+ if (delta <= 0 || durationMs <= 0)
1381
+ return;
1382
+ const startTime = Date.now();
1383
+ return new Promise((resolve) => {
1384
+ const tick = () => {
1385
+ const elapsed = Date.now() - startTime;
1386
+ const t = Math.min(elapsed / durationMs, 1);
1387
+ // easeOutCubic for a natural deceleration feel
1388
+ const eased = 1 - Math.pow(1 - t, 3);
1389
+ this._displayedProgress = startVal + delta * eased;
1390
+ this.updateLoaderBar(this._displayedProgress);
1391
+ if (t < 1) {
1392
+ requestAnimationFrame(tick);
1393
+ }
1394
+ else {
1395
+ resolve();
1396
+ }
1397
+ };
1398
+ requestAnimationFrame(tick);
1399
+ });
1400
+ }
1401
+ // ─── Tap to Start ─────────────────────────────────────
1402
+ async showTapToStart() {
1403
+ const tapText = this._config.tapToStartText ?? 'TAP TO START';
1404
+ // Reuse the same SVG text element — replace percentage with tap text
1405
+ if (this._percentEl) {
1406
+ const el = this._percentEl;
1407
+ el.textContent = tapText;
1408
+ el.setAttribute('fill', '#ffffff');
1409
+ el.classList.add('ge-svg-pulse');
1410
+ this._tapToStartEl = el;
1411
+ }
1412
+ // Make overlay clickable
1413
+ if (this._overlay) {
1414
+ this._overlay.style.cursor = 'pointer';
1415
+ }
1416
+ // Wait for tap
1417
+ return new Promise((resolve) => {
1418
+ const handler = async () => {
1419
+ this._overlay?.removeEventListener('click', handler);
1420
+ await this.transitionToGame();
1421
+ resolve();
1422
+ };
1423
+ // Listen on the full overlay for easier mobile tap
1424
+ this._overlay?.addEventListener('click', handler);
1425
+ });
1426
+ }
1427
+ // ─── Transition ────────────────────────────────────────
1428
+ async transitionToGame() {
1429
+ // Fade out the HTML overlay
1430
+ if (this._overlay) {
1431
+ this._overlay.classList.add('ge-fade-out');
1432
+ await new Promise((resolve) => {
1433
+ this._overlay.addEventListener('transitionend', () => resolve(), { once: true });
1434
+ // Safety timeout
1435
+ setTimeout(resolve, 600);
1436
+ });
1437
+ }
1438
+ // Remove overlay
1439
+ this.removeOverlay();
1440
+ // Navigate to the target scene
1441
+ await this._engine.scenes.goto(this._targetScene, this._targetData);
1442
+ }
1443
+ }
1444
+
1445
+ const PRELOADER_ID = '__ge-css-preloader__';
1446
+ /**
1447
+ * Inline SVG logo with animated loader bar.
1448
+ * The `#loader` path acts as the progress fill — animated via clipPath.
1449
+ */
1450
+ const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 200" fill="none" class="ge-logo-svg">
1451
+ <path d="m241 81.75h-19.28c-1.77 0-6.73 4.98-7.43 6.99l-4.36 12.22c-0.49 1.37 0.05 2.92 1.06 4.32-2.07 1.19-3.69 3.08-4.36 5.43l-3.25 10.41c-0.86 2.89 2.39 6.63 4.31 6.63h19.28c1.96 0 7.4-5.56 7.96-7.51l2.96-10.22c0.63-2.25 0.1-3.98-1.22-4.99 2.55-1.56 3.86-4.14 4.55-6.31l2.77-9.31c0.74-2.57-1.37-7.66-2.99-7.66zm-13.36 28.31-2.27 7.03h-8.28l2.58-8.28h8.28l-0.31 1.25zm4.06-16.97-2.11 6.7h-7.04l2.25-7.34h7.26l-0.36 0.64z" fill="url(#pl0)"/>
1452
+ <path d="m202.5 81.75-9.31 14.97-2.32-14.97h-11.82l4.32 25.15-0.57 4.91-8.64 26.44 15.31-12.76 5.63-16.48 19.96-27.26h-12.56z" fill="url(#pl1)"/>
1453
+ <path d="m174.2 81.75h-19.78l-5.75 5.16-10.79 33.2c-0.77 2.53 2.48 6.93 4.87 6.93h17.38c2.63 0 7.85-5.34 8.32-6.83l5.37-18.14h-15.17l-2.2 7.64h3.78l-2.25 7.2h-8.01l7.1-25.52h7.58l-1.48 8.4 12.78-5.98c1.28-0.63 1.97-3.99 1.61-6.61-0.36-2.34-1.64-5.45-3.36-5.45z" fill="url(#pl2)"/>
1454
+ <path d="m140.6 81.75h-70.6l-5.36 19.37-4.26-19.37h-46.76l2.95 5.88-10.58 39.28h26.84l2.95-9.52-15.63-0.13 2.55-8.34h8.74l8.47-9.81h-14.61l2.11-7.3h15.47l2.54-8.71 2.58 4.74-11.4 39.07h11.05l6.46-21.49 8.84 36.33 19.18-55.67-1.83-3.36 3.68 4.09-12.07 40.1h28.18l3.39-10.31h-17.01l2.67-8.03h9.98l7.58-9.52h-14.28l1.93-6.6h14.61l3.25-9.73 2.81 5.12-11.3 38.89h11.05l5.23-17.81h1.62l1.48 17.6h10.69l-1.48-16.81c4.75-1.28 7.52-5.9 8.64-9.81l2.95-11.3c0.86-2.73-1.43-6.85-3.3-6.85zm-9.8 17.3h-8.69l2.54-7.84h8.35l-2.2 7.84z" fill="url(#pl3)"/>
1455
+ <path d="m205.9 148.9h-122.6l-2.61-3.12h-32.4l-2.51 3.12h-1.59c-5.34 0-7.94 4.88-7.94 7.65v0.03c0 4.2 3.55 7.6 7.74 7.6h103.6l2.11 3.12h36.09l1.82-3.12h18.3c5.25 0 6.64-5.3 6.64-7.35v-0.25c0-4.23-2.9-7.68-6.64-7.68zm-0.7 12.83h-160.6c-3.69 0-6.11-2.58-6.11-5.47v-0.03c0-2.89 2.1-5.47 5.61-5.47h161.1c3.45 0 4.89 3.12 4.89 5.65v0.17c0 2.57-2.11 5.15-4.89 5.15z" fill="url(#pl4)"/>
1456
+ <!-- Loader fill with clip for progress animation -->
1457
+ <clipPath id="ge-loader-clip">
1458
+ <rect x="37" y="148" width="0" height="20" class="ge-clip-rect"/>
1459
+ </clipPath>
1460
+ <path d="m204.5 152.6h-159.8c-2.78 0-4.45 1.69-4.45 3.99v0.11c0 2.04 1.42 3.43 3.64 3.43h160.6c2.88 0 3.67-2.07 3.67-3.43v-0.25c0-2.04-1.48-3.85-3.67-3.85z" fill="url(#pl5)" clip-path="url(#ge-loader-clip)"/>
1461
+ <text x="125" y="196" text-anchor="middle" fill="rgba(255,255,255,0.6)" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="8" font-weight="600" letter-spacing="1.5" class="ge-preloader-svg-text">Loading...</text>
1462
+ <defs>
1463
+ <linearGradient id="pl0" x1="223.7" x2="223.7" y1="81.75" y2="127.8" gradientUnits="userSpaceOnUse">
1464
+ <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1465
+ </linearGradient>
1466
+ <linearGradient id="pl1" x1="194.6" x2="194.6" y1="81.75" y2="138.3" gradientUnits="userSpaceOnUse">
1467
+ <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1468
+ </linearGradient>
1469
+ <linearGradient id="pl2" x1="157.8" x2="157.8" y1="81.75" y2="127" gradientUnits="userSpaceOnUse">
1470
+ <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1471
+ </linearGradient>
1472
+ <linearGradient id="pl3" x1="79.96" x2="79.96" y1="81.75" y2="141.8" gradientUnits="userSpaceOnUse">
1473
+ <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1474
+ </linearGradient>
1475
+ <linearGradient id="pl4" x1="36.18" x2="212.5" y1="156.6" y2="156.6" gradientUnits="userSpaceOnUse">
1476
+ <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1477
+ </linearGradient>
1478
+ <linearGradient id="pl5" x1="40.27" x2="208.2" y1="156.4" y2="156.4" gradientUnits="userSpaceOnUse">
1479
+ <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1480
+ </linearGradient>
1481
+ </defs>
1482
+ </svg>`;
1483
+ /**
1484
+ * Creates a lightweight CSS-only preloader that appears instantly,
1485
+ * BEFORE PixiJS/WebGL is initialized.
1486
+ *
1487
+ * Displays the Energy8 logo SVG with an animated loader bar.
1488
+ */
1489
+ function createCSSPreloader(container, config) {
1490
+ if (document.getElementById(PRELOADER_ID))
1491
+ return;
1492
+ const bgColor = typeof config?.backgroundColor === 'string'
1493
+ ? config.backgroundColor
1494
+ : typeof config?.backgroundColor === 'number'
1495
+ ? `#${config.backgroundColor.toString(16).padStart(6, '0')}`
1496
+ : '#0a0a1a';
1497
+ const bgGradient = config?.backgroundGradient ?? `linear-gradient(135deg, ${bgColor} 0%, #1a1a3e 100%)`;
1498
+ const customHTML = config?.cssPreloaderHTML ?? '';
1499
+ const el = document.createElement('div');
1500
+ el.id = PRELOADER_ID;
1501
+ el.innerHTML = customHTML || `
1502
+ <div class="ge-preloader-content">
1503
+ ${LOGO_SVG}
1504
+ </div>
1505
+ `;
1506
+ const style = document.createElement('style');
1507
+ style.textContent = `
1508
+ #${PRELOADER_ID} {
1509
+ position: absolute;
1510
+ top: 0; left: 0;
1511
+ width: 100%; height: 100%;
1512
+ background: ${bgGradient};
1513
+ display: flex;
1514
+ align-items: center;
1515
+ justify-content: center;
1516
+ z-index: 10000;
1517
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1518
+ transition: opacity 0.4s ease-out;
1519
+ }
1520
+
1521
+ #${PRELOADER_ID}.ge-preloader-hidden {
1522
+ opacity: 0;
1523
+ pointer-events: none;
1524
+ }
1525
+
1526
+ .ge-preloader-content {
1527
+ display: flex;
1528
+ flex-direction: column;
1529
+ align-items: center;
1530
+ width: 80%;
1531
+ max-width: 700px;
1532
+ }
1533
+
1534
+ .ge-logo-svg {
1535
+ width: 100%;
1536
+ height: auto;
1537
+ filter: drop-shadow(0 0 30px rgba(121, 57, 194, 0.4));
1538
+ }
1539
+
1540
+ /* Animate the loader clip-rect to shimmer while waiting */
1541
+ .ge-clip-rect {
1542
+ animation: ge-loader-fill 2s ease-in-out infinite;
1543
+ }
1544
+
1545
+ @keyframes ge-loader-fill {
1546
+ 0% { width: 0; }
1547
+ 50% { width: 174; }
1548
+ 100% { width: 0; }
1549
+ }
1550
+
1551
+ /* Animate the SVG text opacity */
1552
+ .ge-preloader-svg-text {
1553
+ animation: ge-pulse 1.5s ease-in-out infinite;
1554
+ }
1555
+
1556
+ @keyframes ge-pulse {
1557
+ 0%, 100% { opacity: 0.4; }
1558
+ 50% { opacity: 1; }
1559
+ }
1560
+ `;
1561
+ container.style.position = container.style.position || 'relative';
1562
+ container.appendChild(style);
1563
+ container.appendChild(el);
1564
+ }
1565
+ /**
1566
+ * Remove the CSS preloader with a smooth fade-out transition.
1567
+ */
1568
+ function removeCSSPreloader(container) {
1569
+ const el = document.getElementById(PRELOADER_ID);
1570
+ if (!el)
1571
+ return;
1572
+ el.classList.add('ge-preloader-hidden');
1573
+ // Remove after transition
1574
+ el.addEventListener('transitionend', () => {
1575
+ el.remove();
1576
+ // Also remove the style element
1577
+ const styles = container.querySelectorAll('style');
1578
+ for (const style of styles) {
1579
+ if (style.textContent?.includes(PRELOADER_ID)) {
1580
+ style.remove();
1581
+ }
1582
+ }
1583
+ });
1584
+ }
1585
+
1586
+ /**
1587
+ * The main entry point for a game built on @energy8platform/game-engine.
1588
+ *
1589
+ * Orchestrates the full lifecycle:
1590
+ * 1. Create PixiJS Application
1591
+ * 2. Initialize SDK (or run offline)
1592
+ * 3. Show CSS preloader → Canvas loading screen with progress bar
1593
+ * 4. Load asset manifest
1594
+ * 5. Transition to the first game scene
1595
+ *
1596
+ * @example
1597
+ * ```ts
1598
+ * import { GameApplication, ScaleMode } from '@energy8platform/game-engine';
1599
+ * import { GameScene } from './scenes/GameScene';
1600
+ *
1601
+ * const game = new GameApplication({
1602
+ * container: '#game',
1603
+ * designWidth: 1920,
1604
+ * designHeight: 1080,
1605
+ * scaleMode: ScaleMode.FIT,
1606
+ * manifest: { bundles: [
1607
+ * { name: 'preload', assets: [{ alias: 'logo', src: 'logo.png' }] },
1608
+ * { name: 'game', assets: [{ alias: 'bg', src: 'background.png' }] },
1609
+ * ]},
1610
+ * loading: { tapToStart: true },
1611
+ * });
1612
+ *
1613
+ * game.scenes.register('game', GameScene);
1614
+ * await game.start('game');
1615
+ * ```
1616
+ */
1617
+ class GameApplication extends EventEmitter {
1618
+ // ─── Public references ──────────────────────────────────
1619
+ /** PixiJS Application instance */
1620
+ app;
1621
+ /** Scene manager */
1622
+ scenes;
1623
+ /** Asset manager */
1624
+ assets;
1625
+ /** Audio manager */
1626
+ audio;
1627
+ /** Viewport manager */
1628
+ viewport;
1629
+ /** SDK instance (null in offline mode) */
1630
+ sdk = null;
1631
+ /** Data received from SDK initialization */
1632
+ initData = null;
1633
+ /** Configuration */
1634
+ config;
1635
+ // ─── Private state ──────────────────────────────────────
1636
+ _running = false;
1637
+ _destroyed = false;
1638
+ _container = null;
1639
+ constructor(config = {}) {
1640
+ super();
1641
+ this.config = {
1642
+ designWidth: 1920,
1643
+ designHeight: 1080,
1644
+ scaleMode: ScaleMode.FIT,
1645
+ orientation: Orientation.ANY,
1646
+ debug: false,
1647
+ ...config,
1648
+ };
1649
+ // Create SceneManager early so scenes can be registered before start()
1650
+ this.scenes = new SceneManager();
1651
+ }
1652
+ // ─── Public getters ─────────────────────────────────────
1653
+ /** Current game config from SDK (or null in offline mode) */
1654
+ get gameConfig() {
1655
+ return this.initData?.config ?? null;
1656
+ }
1657
+ /** Current session data */
1658
+ get session() {
1659
+ return this.initData?.session ?? null;
1660
+ }
1661
+ /** Current balance */
1662
+ get balance() {
1663
+ return this.sdk?.balance ?? 0;
1664
+ }
1665
+ /** Current currency */
1666
+ get currency() {
1667
+ return this.sdk?.currency ?? 'USD';
1668
+ }
1669
+ /** Whether the engine is running */
1670
+ get isRunning() {
1671
+ return this._running;
1672
+ }
1673
+ // ─── Lifecycle ──────────────────────────────────────────
1674
+ /**
1675
+ * Start the game engine. This is the main entry point.
1676
+ *
1677
+ * @param firstScene - Key of the first scene to show after loading (must be registered)
1678
+ * @param sceneData - Optional data to pass to the first scene's onEnter
1679
+ */
1680
+ async start(firstScene, sceneData) {
1681
+ if (this._running) {
1682
+ console.warn('[GameEngine] Already running');
1683
+ return;
1684
+ }
1685
+ try {
1686
+ // 1. Resolve container element
1687
+ this._container = this.resolveContainer();
1688
+ // 2. Show CSS preloader immediately (before PixiJS)
1689
+ createCSSPreloader(this._container, this.config.loading);
1690
+ // 3. Initialize PixiJS
1691
+ await this.initPixi();
1692
+ // 4. Initialize SDK (if enabled)
1693
+ await this.initSDK();
1694
+ // 5. Merge design dimensions from SDK config
1695
+ this.applySDKConfig();
1696
+ // 6. Initialize sub-systems
1697
+ this.initSubSystems();
1698
+ this.emit('initialized', undefined);
1699
+ // 7. Remove CSS preloader, show Canvas loading screen
1700
+ removeCSSPreloader(this._container);
1701
+ // 8. Load assets with loading screen
1702
+ await this.loadAssets(firstScene, sceneData);
1703
+ // 9. Start the game loop
1704
+ this._running = true;
1705
+ this.emit('started', undefined);
1706
+ }
1707
+ catch (err) {
1708
+ console.error('[GameEngine] Failed to start:', err);
1709
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
1710
+ throw err;
1711
+ }
1712
+ }
1713
+ /**
1714
+ * Destroy the engine and free all resources.
1715
+ */
1716
+ destroy() {
1717
+ if (this._destroyed)
1718
+ return;
1719
+ this._destroyed = true;
1720
+ this._running = false;
1721
+ this.scenes?.destroy();
1722
+ this.audio?.destroy();
1723
+ this.viewport?.destroy();
1724
+ this.sdk?.destroy();
1725
+ this.app?.destroy(true, { children: true, texture: true });
1726
+ this.removeAllListeners();
1727
+ this.emit('destroyed', undefined);
1728
+ }
1729
+ // ─── Private initialization steps ──────────────────────
1730
+ resolveContainer() {
1731
+ if (typeof this.config.container === 'string') {
1732
+ const el = document.querySelector(this.config.container);
1733
+ if (!el)
1734
+ throw new Error(`[GameEngine] Container "${this.config.container}" not found`);
1735
+ return el;
1736
+ }
1737
+ return this.config.container ?? document.body;
1738
+ }
1739
+ async initPixi() {
1740
+ this.app = new Application();
1741
+ const pixiOpts = {
1742
+ background: typeof this.config.loading?.backgroundColor === 'number'
1743
+ ? this.config.loading.backgroundColor
1744
+ : 0x000000,
1745
+ antialias: true,
1746
+ resolution: Math.min(window.devicePixelRatio, 2),
1747
+ autoDensity: true,
1748
+ ...this.config.pixi,
1749
+ };
1750
+ await this.app.init(pixiOpts);
1751
+ // Append canvas to container
1752
+ this._container.appendChild(this.app.canvas);
1753
+ // Set canvas style
1754
+ this.app.canvas.style.display = 'block';
1755
+ this.app.canvas.style.width = '100%';
1756
+ this.app.canvas.style.height = '100%';
1757
+ }
1758
+ async initSDK() {
1759
+ if (this.config.sdk === false) {
1760
+ // Offline / development mode — no SDK
1761
+ this.initData = null;
1762
+ return;
1763
+ }
1764
+ const sdkOpts = typeof this.config.sdk === 'object' ? this.config.sdk : {};
1765
+ this.sdk = new CasinoGameSDK(sdkOpts);
1766
+ // Perform the handshake
1767
+ this.initData = await this.sdk.ready();
1768
+ // Forward SDK events
1769
+ this.sdk.on('error', (err) => {
1770
+ this.emit('error', err);
1771
+ });
1772
+ }
1773
+ applySDKConfig() {
1774
+ // If SDK provides viewport dimensions, use them as design reference
1775
+ if (this.initData?.config?.viewport) {
1776
+ const vp = this.initData.config.viewport;
1777
+ if (!this.config.designWidth)
1778
+ this.config.designWidth = vp.width;
1779
+ if (!this.config.designHeight)
1780
+ this.config.designHeight = vp.height;
1781
+ }
1782
+ }
1783
+ initSubSystems() {
1784
+ // Asset Manager
1785
+ const basePath = this.initData?.assetsUrl ?? '';
1786
+ this.assets = new AssetManager(basePath, this.config.manifest);
1787
+ // Audio Manager
1788
+ this.audio = new AudioManager(this.config.audio);
1789
+ // Viewport Manager
1790
+ this.viewport = new ViewportManager(this.app, this._container, {
1791
+ designWidth: this.config.designWidth,
1792
+ designHeight: this.config.designHeight,
1793
+ scaleMode: this.config.scaleMode,
1794
+ orientation: this.config.orientation,
1795
+ });
1796
+ // Wire SceneManager to the PixiJS stage
1797
+ this.scenes.setRoot(this.app.stage);
1798
+ // Wire viewport resize → scene manager
1799
+ this.viewport.on('resize', ({ width, height }) => {
1800
+ this.scenes.resize(width, height);
1801
+ this.emit('resize', { width, height });
1802
+ });
1803
+ this.viewport.on('orientationChange', (orientation) => {
1804
+ this.emit('orientationChange', orientation);
1805
+ });
1806
+ // Connect ticker → scene updates
1807
+ this.app.ticker.add((ticker) => {
1808
+ // Always update scenes (loading screen needs onUpdate before _running=true)
1809
+ this.scenes.update(ticker.deltaTime / 60); // convert to seconds
1810
+ });
1811
+ // Trigger initial resize
1812
+ this.viewport.refresh();
1813
+ }
1814
+ async loadAssets(firstScene, sceneData) {
1815
+ // Register built-in loading scene
1816
+ this.scenes.register('__loading__', LoadingScene);
1817
+ // Enter loading scene
1818
+ await this.scenes.goto('__loading__', {
1819
+ engine: this,
1820
+ targetScene: firstScene,
1821
+ targetData: sceneData,
1822
+ });
1823
+ }
1824
+ }
1825
+
1826
+ export { EventEmitter, GameApplication, Scene, SceneManager };
1827
+ //# sourceMappingURL=core.esm.js.map