@energy8platform/game-engine 0.2.0 → 0.3.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 (59) hide show
  1. package/README.md +318 -49
  2. package/dist/animation.cjs.js +191 -1
  3. package/dist/animation.cjs.js.map +1 -1
  4. package/dist/animation.d.ts +117 -1
  5. package/dist/animation.esm.js +192 -3
  6. package/dist/animation.esm.js.map +1 -1
  7. package/dist/audio.cjs.js +66 -16
  8. package/dist/audio.cjs.js.map +1 -1
  9. package/dist/audio.d.ts +4 -0
  10. package/dist/audio.esm.js +66 -16
  11. package/dist/audio.esm.js.map +1 -1
  12. package/dist/core.cjs.js +310 -84
  13. package/dist/core.cjs.js.map +1 -1
  14. package/dist/core.d.ts +60 -1
  15. package/dist/core.esm.js +311 -85
  16. package/dist/core.esm.js.map +1 -1
  17. package/dist/debug.cjs.js +36 -68
  18. package/dist/debug.cjs.js.map +1 -1
  19. package/dist/debug.d.ts +4 -6
  20. package/dist/debug.esm.js +36 -68
  21. package/dist/debug.esm.js.map +1 -1
  22. package/dist/index.cjs.js +1250 -251
  23. package/dist/index.cjs.js.map +1 -1
  24. package/dist/index.d.ts +386 -41
  25. package/dist/index.esm.js +1250 -254
  26. package/dist/index.esm.js.map +1 -1
  27. package/dist/ui.cjs.js +757 -1
  28. package/dist/ui.cjs.js.map +1 -1
  29. package/dist/ui.d.ts +208 -2
  30. package/dist/ui.esm.js +756 -2
  31. package/dist/ui.esm.js.map +1 -1
  32. package/dist/vite.cjs.js +65 -68
  33. package/dist/vite.cjs.js.map +1 -1
  34. package/dist/vite.d.ts +17 -23
  35. package/dist/vite.esm.js +66 -68
  36. package/dist/vite.esm.js.map +1 -1
  37. package/package.json +4 -5
  38. package/src/animation/SpriteAnimation.ts +210 -0
  39. package/src/animation/Tween.ts +27 -1
  40. package/src/animation/index.ts +2 -0
  41. package/src/audio/AudioManager.ts +64 -15
  42. package/src/core/EventEmitter.ts +7 -1
  43. package/src/core/GameApplication.ts +25 -7
  44. package/src/core/SceneManager.ts +3 -1
  45. package/src/debug/DevBridge.ts +49 -80
  46. package/src/index.ts +6 -0
  47. package/src/input/InputManager.ts +26 -0
  48. package/src/loading/CSSPreloader.ts +7 -33
  49. package/src/loading/LoadingScene.ts +17 -41
  50. package/src/loading/index.ts +1 -0
  51. package/src/loading/logo.ts +95 -0
  52. package/src/types.ts +4 -0
  53. package/src/ui/BalanceDisplay.ts +14 -0
  54. package/src/ui/Button.ts +1 -1
  55. package/src/ui/Layout.ts +364 -0
  56. package/src/ui/ScrollContainer.ts +557 -0
  57. package/src/ui/index.ts +4 -0
  58. package/src/viewport/ViewportManager.ts +2 -0
  59. package/src/vite/index.ts +83 -83
package/dist/index.cjs.js CHANGED
@@ -32,6 +32,9 @@ exports.TransitionType = void 0;
32
32
  /**
33
33
  * Minimal typed event emitter.
34
34
  * Used internally by GameApplication, SceneManager, AudioManager, etc.
35
+ *
36
+ * Supports `void` event types — events that carry no data can be emitted
37
+ * without arguments: `emitter.emit('eventName')`.
35
38
  */
36
39
  // eslint-disable-next-line @typescript-eslint/no-empty-object-type
37
40
  class EventEmitter {
@@ -54,7 +57,8 @@ class EventEmitter {
54
57
  this.listeners.get(event)?.delete(handler);
55
58
  return this;
56
59
  }
57
- emit(event, data) {
60
+ emit(...args) {
61
+ const [event, data] = args;
58
62
  const handlers = this.listeners.get(event);
59
63
  if (handlers) {
60
64
  for (const handler of handlers) {
@@ -227,9 +231,20 @@ class Tween {
227
231
  }
228
232
  /**
229
233
  * Wait for a given duration (useful in timelines).
234
+ * Uses PixiJS Ticker for consistent timing with other tweens.
230
235
  */
231
236
  static delay(ms) {
232
- return new Promise((resolve) => setTimeout(resolve, ms));
237
+ return new Promise((resolve) => {
238
+ let elapsed = 0;
239
+ const onTick = (ticker) => {
240
+ elapsed += ticker.deltaMS;
241
+ if (elapsed >= ms) {
242
+ pixi_js.Ticker.shared.remove(onTick);
243
+ resolve();
244
+ }
245
+ };
246
+ pixi_js.Ticker.shared.add(onTick);
247
+ });
233
248
  }
234
249
  /**
235
250
  * Kill all tweens on a target.
@@ -256,6 +271,20 @@ class Tween {
256
271
  static get activeTweens() {
257
272
  return Tween._tweens.length;
258
273
  }
274
+ /**
275
+ * Reset the tween system — kill all tweens and remove the ticker.
276
+ * Useful for cleanup between game instances, tests, or hot-reload.
277
+ */
278
+ static reset() {
279
+ for (const tw of Tween._tweens) {
280
+ tw.resolve();
281
+ }
282
+ Tween._tweens.length = 0;
283
+ if (Tween._tickerAdded) {
284
+ pixi_js.Ticker.shared.remove(Tween.tick);
285
+ Tween._tickerAdded = false;
286
+ }
287
+ }
259
288
  // ─── Internal ──────────────────────────────────────────
260
289
  static ensureTicker() {
261
290
  if (Tween._tickerAdded)
@@ -458,8 +487,9 @@ class SceneManager extends EventEmitter {
458
487
  }
459
488
  // Transition in
460
489
  await this.transitionIn(scene.container, transition);
461
- await scene.onEnter?.(data);
490
+ // Push to stack BEFORE onEnter so currentKey is correct during initialization
462
491
  this.stack.push({ scene, key });
492
+ await scene.onEnter?.(data);
463
493
  this._transitioning = false;
464
494
  }
465
495
  async popInternal(showTransition, transition) {
@@ -758,26 +788,51 @@ class AudioManager {
758
788
  if (!this._initialized || !this._soundModule)
759
789
  return;
760
790
  const { sound } = this._soundModule;
761
- // Stop current music
762
- if (this._currentMusic) {
791
+ // Stop current music with fade-out, start new music with fade-in
792
+ if (this._currentMusic && fadeDuration > 0) {
793
+ const prevAlias = this._currentMusic;
794
+ this._currentMusic = alias;
795
+ if (this._globalMuted || this._categories.music.muted)
796
+ return;
797
+ // Fade out the previous track
798
+ this.fadeVolume(prevAlias, this._categories.music.volume, 0, fadeDuration, () => {
799
+ try {
800
+ sound.stop(prevAlias);
801
+ }
802
+ catch { /* ignore */ }
803
+ });
804
+ // Start new track at zero volume, fade in
763
805
  try {
764
- sound.stop(this._currentMusic);
806
+ sound.play(alias, {
807
+ volume: 0,
808
+ loop: true,
809
+ });
810
+ this.fadeVolume(alias, 0, this._categories.music.volume, fadeDuration);
765
811
  }
766
- catch {
767
- // ignore
812
+ catch (e) {
813
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
768
814
  }
769
815
  }
770
- this._currentMusic = alias;
771
- if (this._globalMuted || this._categories.music.muted)
772
- return;
773
- try {
774
- sound.play(alias, {
775
- volume: this._categories.music.volume,
776
- loop: true,
777
- });
778
- }
779
- catch (e) {
780
- console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
816
+ else {
817
+ // No crossfade — instant switch
818
+ if (this._currentMusic) {
819
+ try {
820
+ sound.stop(this._currentMusic);
821
+ }
822
+ catch { /* ignore */ }
823
+ }
824
+ this._currentMusic = alias;
825
+ if (this._globalMuted || this._categories.music.muted)
826
+ return;
827
+ try {
828
+ sound.play(alias, {
829
+ volume: this._categories.music.volume,
830
+ loop: true,
831
+ });
832
+ }
833
+ catch (e) {
834
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
835
+ }
781
836
  }
782
837
  }
783
838
  /**
@@ -919,6 +974,31 @@ class AudioManager {
919
974
  this._initialized = false;
920
975
  }
921
976
  // ─── Private ───────────────────────────────────────────
977
+ /**
978
+ * Smoothly fade a sound's volume from `fromVol` to `toVol` over `durationMs`.
979
+ */
980
+ fadeVolume(alias, fromVol, toVol, durationMs, onComplete) {
981
+ if (!this._soundModule)
982
+ return;
983
+ const { sound } = this._soundModule;
984
+ const startTime = Date.now();
985
+ const tick = () => {
986
+ const elapsed = Date.now() - startTime;
987
+ const t = Math.min(elapsed / durationMs, 1);
988
+ const vol = fromVol + (toVol - fromVol) * t;
989
+ try {
990
+ sound.volume(alias, vol);
991
+ }
992
+ catch { /* ignore */ }
993
+ if (t < 1) {
994
+ requestAnimationFrame(tick);
995
+ }
996
+ else {
997
+ onComplete?.();
998
+ }
999
+ };
1000
+ requestAnimationFrame(tick);
1001
+ }
922
1002
  applyVolumes() {
923
1003
  if (!this._soundModule)
924
1004
  return;
@@ -1023,6 +1103,10 @@ class InputManager extends EventEmitter {
1023
1103
  _locked = false;
1024
1104
  _keysDown = new Set();
1025
1105
  _destroyed = false;
1106
+ // Viewport transform (set by ViewportManager via setViewportTransform)
1107
+ _viewportScale = 1;
1108
+ _viewportOffsetX = 0;
1109
+ _viewportOffsetY = 0;
1026
1110
  // Gesture tracking
1027
1111
  _pointerStart = null;
1028
1112
  _swipeThreshold = 50; // minimum distance in px
@@ -1049,6 +1133,25 @@ class InputManager extends EventEmitter {
1049
1133
  isKeyDown(key) {
1050
1134
  return this._keysDown.has(key.toLowerCase());
1051
1135
  }
1136
+ /**
1137
+ * Update the viewport transform used for DOM→world coordinate mapping.
1138
+ * Called automatically by GameApplication when ViewportManager emits resize.
1139
+ */
1140
+ setViewportTransform(scale, offsetX, offsetY) {
1141
+ this._viewportScale = scale;
1142
+ this._viewportOffsetX = offsetX;
1143
+ this._viewportOffsetY = offsetY;
1144
+ }
1145
+ /**
1146
+ * Convert a DOM canvas position to game-world coordinates,
1147
+ * accounting for viewport scaling and offset.
1148
+ */
1149
+ getWorldPosition(canvasX, canvasY) {
1150
+ return {
1151
+ x: (canvasX - this._viewportOffsetX) / this._viewportScale,
1152
+ y: (canvasY - this._viewportOffsetY) / this._viewportScale,
1153
+ };
1154
+ }
1052
1155
  /** Destroy the input manager */
1053
1156
  destroy() {
1054
1157
  this._destroyed = true;
@@ -1304,6 +1407,8 @@ class ViewportManager extends EventEmitter {
1304
1407
  this._destroyed = true;
1305
1408
  this._resizeObserver?.disconnect();
1306
1409
  this._resizeObserver = null;
1410
+ // Remove fallback window resize listener if it was used
1411
+ window.removeEventListener('resize', this.onWindowResize);
1307
1412
  if (this._resizeTimeout !== null) {
1308
1413
  clearTimeout(this._resizeTimeout);
1309
1414
  }
@@ -1367,45 +1472,87 @@ class Scene {
1367
1472
  }
1368
1473
 
1369
1474
  /**
1370
- * Inline SVG logo with a loader bar (clip-animated for progress).
1371
- * The clipPath rect width is set to 0 initially, expanded as loading progresses.
1475
+ * Shared Energy8 SVG logo with an embedded loader bar.
1476
+ *
1477
+ * The loader bar fill is controlled via a `<clipPath>` whose `<rect>` width
1478
+ * is animatable. Different consumers customise gradient IDs and the clip
1479
+ * element's ID/class to avoid collisions when both CSSPreloader and
1480
+ * LoadingScene appear in the same DOM.
1372
1481
  */
1373
- function buildLogoSVG() {
1374
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 200" fill="none" style="width:100%;height:auto;">
1375
- <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)"/>
1376
- <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)"/>
1377
- <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)"/>
1378
- <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)"/>
1379
- <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)"/>
1380
- <clipPath id="ge-canvas-loader-clip">
1381
- <rect id="ge-loader-rect" x="37" y="148" width="0" height="20"/>
1382
- </clipPath>
1383
- <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)"/>
1384
- <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>
1385
- <defs>
1386
- <linearGradient id="ls0" x1="223.7" x2="223.7" y1="81.75" y2="127.8" gradientUnits="userSpaceOnUse">
1482
+ /** SVG path data for the Energy8 wordmark — reused across loaders */
1483
+ const WORDMARK_PATHS = `
1484
+ <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(#GID0)"/>
1485
+ <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(#GID1)"/>
1486
+ <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(#GID2)"/>
1487
+ <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(#GID3)"/>
1488
+ <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(#GID4)"/>`;
1489
+ /** Gradient definitions template (gradient IDs are replaced per-consumer) */
1490
+ const GRADIENT_DEFS = `
1491
+ <linearGradient id="GID0" x1="223.7" x2="223.7" y1="81.75" y2="127.8" gradientUnits="userSpaceOnUse">
1387
1492
  <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1388
1493
  </linearGradient>
1389
- <linearGradient id="ls1" x1="194.6" x2="194.6" y1="81.75" y2="138.3" gradientUnits="userSpaceOnUse">
1494
+ <linearGradient id="GID1" x1="194.6" x2="194.6" y1="81.75" y2="138.3" gradientUnits="userSpaceOnUse">
1390
1495
  <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1391
1496
  </linearGradient>
1392
- <linearGradient id="ls2" x1="157.8" x2="157.8" y1="81.75" y2="127" gradientUnits="userSpaceOnUse">
1497
+ <linearGradient id="GID2" x1="157.8" x2="157.8" y1="81.75" y2="127" gradientUnits="userSpaceOnUse">
1393
1498
  <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1394
1499
  </linearGradient>
1395
- <linearGradient id="ls3" x1="79.96" x2="79.96" y1="81.75" y2="141.8" gradientUnits="userSpaceOnUse">
1500
+ <linearGradient id="GID3" x1="79.96" x2="79.96" y1="81.75" y2="141.8" gradientUnits="userSpaceOnUse">
1396
1501
  <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1397
1502
  </linearGradient>
1398
- <linearGradient id="ls4" x1="36.18" x2="212.5" y1="156.6" y2="156.6" gradientUnits="userSpaceOnUse">
1503
+ <linearGradient id="GID4" x1="36.18" x2="212.5" y1="156.6" y2="156.6" gradientUnits="userSpaceOnUse">
1399
1504
  <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1400
1505
  </linearGradient>
1401
- <linearGradient id="ls5" x1="40.27" x2="208.2" y1="156.4" y2="156.4" gradientUnits="userSpaceOnUse">
1506
+ <linearGradient id="GID5" x1="40.27" x2="208.2" y1="156.4" y2="156.4" gradientUnits="userSpaceOnUse">
1402
1507
  <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1403
- </linearGradient>
1508
+ </linearGradient>`;
1509
+ /** Max width of the loader bar in SVG units */
1510
+ const LOADER_BAR_MAX_WIDTH = 174;
1511
+ /**
1512
+ * Build the Energy8 SVG logo with a loader bar, using unique IDs.
1513
+ *
1514
+ * @param opts - Configuration to avoid element ID collisions
1515
+ * @returns SVG markup string
1516
+ */
1517
+ function buildLogoSVG(opts) {
1518
+ const { idPrefix, svgClass, svgStyle, clipRectClass, clipRectId, textId, textContent, textClass } = opts;
1519
+ // Replace gradient ID placeholders with prefixed versions
1520
+ const paths = WORDMARK_PATHS.replace(/GID(\d)/g, `${idPrefix}$1`);
1521
+ const defs = GRADIENT_DEFS.replace(/GID(\d)/g, `${idPrefix}$1`);
1522
+ const clipId = `${idPrefix}-loader-clip`;
1523
+ const fillGradientId = `${idPrefix}5`;
1524
+ const classAttr = svgClass ? ` class="${svgClass}"` : '';
1525
+ const styleAttr = svgStyle ? ` style="${svgStyle}"` : '';
1526
+ const rectClassAttr = clipRectClass ? ` class="${clipRectClass}"` : '';
1527
+ const rectIdAttr = clipRectId ? ` id="${clipRectId}"` : '';
1528
+ const txtIdAttr = textId ? ` id="${textId}"` : '';
1529
+ const txtClassAttr = textClass ? ` class="${textClass}"` : '';
1530
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 200" fill="none"${classAttr}${styleAttr}>
1531
+ ${paths}
1532
+ <clipPath id="${clipId}">
1533
+ <rect${rectIdAttr} x="37" y="148" width="0" height="20"${rectClassAttr}/>
1534
+ </clipPath>
1535
+ <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(#${fillGradientId})" clip-path="url(#${clipId})"/>
1536
+ <text${txtIdAttr} 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"${txtClassAttr}>${textContent ?? 'Loading...'}</text>
1537
+ <defs>
1538
+ ${defs}
1404
1539
  </defs>
1405
1540
  </svg>`;
1406
1541
  }
1407
- /** Max width of the loader bar in SVG units */
1408
- const LOADER_BAR_MAX_WIDTH = 174;
1542
+
1543
+ /**
1544
+ * Build the loading scene variant of the logo SVG.
1545
+ * Uses unique IDs (prefixed with 'ls') to avoid collisions with CSSPreloader.
1546
+ */
1547
+ function buildLoadingLogoSVG() {
1548
+ return buildLogoSVG({
1549
+ idPrefix: 'ls',
1550
+ svgStyle: 'width:100%;height:auto;',
1551
+ clipRectId: 'ge-loader-rect',
1552
+ textId: 'ge-loader-pct',
1553
+ textContent: '0%',
1554
+ });
1555
+ }
1409
1556
  /**
1410
1557
  * Built-in loading screen using the Energy8 SVG logo with animated loader bar.
1411
1558
  *
@@ -1515,7 +1662,7 @@ class LoadingScene extends Scene {
1515
1662
  this._overlay.id = '__ge-loading-overlay__';
1516
1663
  this._overlay.innerHTML = `
1517
1664
  <div class="ge-loading-content">
1518
- ${buildLogoSVG()}
1665
+ ${buildLoadingLogoSVG()}
1519
1666
  </div>
1520
1667
  `;
1521
1668
  const style = document.createElement('style');
@@ -1653,8 +1800,11 @@ class LoadingScene extends Scene {
1653
1800
  }
1654
1801
  // Remove overlay
1655
1802
  this.removeOverlay();
1656
- // Navigate to the target scene
1657
- await this._engine.scenes.goto(this._targetScene, this._targetData);
1803
+ // Navigate to the target scene, always passing the engine reference
1804
+ await this._engine.scenes.goto(this._targetScene, {
1805
+ engine: this._engine,
1806
+ ...(this._targetData && typeof this._targetData === 'object' ? this._targetData : { data: this._targetData }),
1807
+ });
1658
1808
  }
1659
1809
  }
1660
1810
 
@@ -1663,39 +1813,12 @@ const PRELOADER_ID = '__ge-css-preloader__';
1663
1813
  * Inline SVG logo with animated loader bar.
1664
1814
  * The `#loader` path acts as the progress fill — animated via clipPath.
1665
1815
  */
1666
- const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 200" fill="none" class="ge-logo-svg">
1667
- <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)"/>
1668
- <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)"/>
1669
- <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)"/>
1670
- <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)"/>
1671
- <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)"/>
1672
- <!-- Loader fill with clip for progress animation -->
1673
- <clipPath id="ge-loader-clip">
1674
- <rect x="37" y="148" width="0" height="20" class="ge-clip-rect"/>
1675
- </clipPath>
1676
- <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)"/>
1677
- <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>
1678
- <defs>
1679
- <linearGradient id="pl0" x1="223.7" x2="223.7" y1="81.75" y2="127.8" gradientUnits="userSpaceOnUse">
1680
- <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1681
- </linearGradient>
1682
- <linearGradient id="pl1" x1="194.6" x2="194.6" y1="81.75" y2="138.3" gradientUnits="userSpaceOnUse">
1683
- <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1684
- </linearGradient>
1685
- <linearGradient id="pl2" x1="157.8" x2="157.8" y1="81.75" y2="127" gradientUnits="userSpaceOnUse">
1686
- <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1687
- </linearGradient>
1688
- <linearGradient id="pl3" x1="79.96" x2="79.96" y1="81.75" y2="141.8" gradientUnits="userSpaceOnUse">
1689
- <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1690
- </linearGradient>
1691
- <linearGradient id="pl4" x1="36.18" x2="212.5" y1="156.6" y2="156.6" gradientUnits="userSpaceOnUse">
1692
- <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1693
- </linearGradient>
1694
- <linearGradient id="pl5" x1="40.27" x2="208.2" y1="156.4" y2="156.4" gradientUnits="userSpaceOnUse">
1695
- <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1696
- </linearGradient>
1697
- </defs>
1698
- </svg>`;
1816
+ const LOGO_SVG = buildLogoSVG({
1817
+ idPrefix: 'pl',
1818
+ svgClass: 'ge-logo-svg',
1819
+ clipRectClass: 'ge-clip-rect',
1820
+ textClass: 'ge-preloader-svg-text',
1821
+ });
1699
1822
  /**
1700
1823
  * Creates a lightweight CSS-only preloader that appears instantly,
1701
1824
  * BEFORE PixiJS/WebGL is initialized.
@@ -1799,6 +1922,96 @@ function removeCSSPreloader(container) {
1799
1922
  });
1800
1923
  }
1801
1924
 
1925
+ /**
1926
+ * FPS overlay for debugging performance.
1927
+ *
1928
+ * Shows FPS, frame time, and draw call count in the corner of the screen.
1929
+ *
1930
+ * @example
1931
+ * ```ts
1932
+ * const fps = new FPSOverlay(app);
1933
+ * fps.show();
1934
+ * ```
1935
+ */
1936
+ class FPSOverlay {
1937
+ _app;
1938
+ _container;
1939
+ _fpsText;
1940
+ _visible = false;
1941
+ _samples = [];
1942
+ _maxSamples = 60;
1943
+ _lastUpdate = 0;
1944
+ _tickFn = null;
1945
+ constructor(app) {
1946
+ this._app = app;
1947
+ this._container = new pixi_js.Container();
1948
+ this._container.label = 'FPSOverlay';
1949
+ this._container.zIndex = 99999;
1950
+ this._fpsText = new pixi_js.Text({
1951
+ text: 'FPS: --',
1952
+ style: {
1953
+ fontFamily: 'monospace',
1954
+ fontSize: 14,
1955
+ fill: 0x00ff00,
1956
+ stroke: { color: 0x000000, width: 2 },
1957
+ },
1958
+ });
1959
+ this._fpsText.x = 8;
1960
+ this._fpsText.y = 8;
1961
+ this._container.addChild(this._fpsText);
1962
+ }
1963
+ /** Show the FPS overlay */
1964
+ show() {
1965
+ if (this._visible)
1966
+ return;
1967
+ this._visible = true;
1968
+ this._app.stage.addChild(this._container);
1969
+ this._tickFn = (ticker) => {
1970
+ this._samples.push(ticker.FPS);
1971
+ if (this._samples.length > this._maxSamples) {
1972
+ this._samples.shift();
1973
+ }
1974
+ // Update display every ~500ms
1975
+ const now = Date.now();
1976
+ if (now - this._lastUpdate > 500) {
1977
+ const avg = this._samples.reduce((a, b) => a + b, 0) / this._samples.length;
1978
+ const min = Math.min(...this._samples);
1979
+ this._fpsText.text = [
1980
+ `FPS: ${Math.round(avg)} (min: ${Math.round(min)})`,
1981
+ `Frame: ${ticker.deltaMS.toFixed(1)}ms`,
1982
+ ].join('\n');
1983
+ this._lastUpdate = now;
1984
+ }
1985
+ };
1986
+ this._app.ticker.add(this._tickFn);
1987
+ }
1988
+ /** Hide the FPS overlay */
1989
+ hide() {
1990
+ if (!this._visible)
1991
+ return;
1992
+ this._visible = false;
1993
+ this._container.removeFromParent();
1994
+ if (this._tickFn) {
1995
+ this._app.ticker.remove(this._tickFn);
1996
+ this._tickFn = null;
1997
+ }
1998
+ }
1999
+ /** Toggle visibility */
2000
+ toggle() {
2001
+ if (this._visible) {
2002
+ this.hide();
2003
+ }
2004
+ else {
2005
+ this.show();
2006
+ }
2007
+ }
2008
+ /** Destroy the overlay */
2009
+ destroy() {
2010
+ this.hide();
2011
+ this._container.destroy({ children: true });
2012
+ }
2013
+ }
2014
+
1802
2015
  /**
1803
2016
  * The main entry point for a game built on @energy8platform/game-engine.
1804
2017
  *
@@ -1846,6 +2059,8 @@ class GameApplication extends EventEmitter {
1846
2059
  viewport;
1847
2060
  /** SDK instance (null in offline mode) */
1848
2061
  sdk = null;
2062
+ /** FPS overlay instance (only when debug: true) */
2063
+ fpsOverlay = null;
1849
2064
  /** Data received from SDK initialization */
1850
2065
  initData = null;
1851
2066
  /** Configuration */
@@ -1913,14 +2128,15 @@ class GameApplication extends EventEmitter {
1913
2128
  this.applySDKConfig();
1914
2129
  // 6. Initialize sub-systems
1915
2130
  this.initSubSystems();
1916
- this.emit('initialized', undefined);
2131
+ this.emit('initialized');
1917
2132
  // 7. Remove CSS preloader, show Canvas loading screen
1918
2133
  removeCSSPreloader(this._container);
1919
2134
  // 8. Load assets with loading screen
1920
2135
  await this.loadAssets(firstScene, sceneData);
2136
+ this.emit('loaded');
1921
2137
  // 9. Start the game loop
1922
2138
  this._running = true;
1923
- this.emit('started', undefined);
2139
+ this.emit('started');
1924
2140
  }
1925
2141
  catch (err) {
1926
2142
  console.error('[GameEngine] Failed to start:', err);
@@ -1942,8 +2158,8 @@ class GameApplication extends EventEmitter {
1942
2158
  this.viewport?.destroy();
1943
2159
  this.sdk?.destroy();
1944
2160
  this.app?.destroy(true, { children: true, texture: true });
2161
+ this.emit('destroyed');
1945
2162
  this.removeAllListeners();
1946
- this.emit('destroyed', undefined);
1947
2163
  }
1948
2164
  // ─── Private initialization steps ──────────────────────
1949
2165
  resolveContainer() {
@@ -2016,14 +2232,19 @@ class GameApplication extends EventEmitter {
2016
2232
  });
2017
2233
  // Wire SceneManager to the PixiJS stage
2018
2234
  this.scenes.setRoot(this.app.stage);
2019
- // Wire viewport resize → scene manager
2020
- this.viewport.on('resize', ({ width, height }) => {
2235
+ // Wire viewport resize → scene manager + input manager
2236
+ this.viewport.on('resize', ({ width, height, scale }) => {
2021
2237
  this.scenes.resize(width, height);
2238
+ this.input.setViewportTransform(scale, this.app.stage.x, this.app.stage.y);
2022
2239
  this.emit('resize', { width, height });
2023
2240
  });
2024
2241
  this.viewport.on('orientationChange', (orientation) => {
2025
2242
  this.emit('orientationChange', orientation);
2026
2243
  });
2244
+ // Wire scene changes → engine event
2245
+ this.scenes.on('change', ({ from, to }) => {
2246
+ this.emit('sceneChange', { from, to });
2247
+ });
2027
2248
  // Connect ticker → scene updates
2028
2249
  this.app.ticker.add((ticker) => {
2029
2250
  // Always update scenes (loading screen needs onUpdate before _running=true)
@@ -2031,6 +2252,11 @@ class GameApplication extends EventEmitter {
2031
2252
  });
2032
2253
  // Trigger initial resize
2033
2254
  this.viewport.refresh();
2255
+ // Enable FPS overlay in debug mode
2256
+ if (this.config.debug) {
2257
+ this.fpsOverlay = new FPSOverlay(this.app);
2258
+ this.fpsOverlay.show();
2259
+ }
2034
2260
  }
2035
2261
  async loadAssets(firstScene, sceneData) {
2036
2262
  // Register built-in loading scene
@@ -2490,6 +2716,170 @@ class SpineHelper {
2490
2716
  }
2491
2717
  }
2492
2718
 
2719
+ /**
2720
+ * Helper for creating frame-based animations from spritesheets.
2721
+ *
2722
+ * Wraps PixiJS `AnimatedSprite` with a convenient API for
2723
+ * common iGaming effects: coin showers, symbol animations,
2724
+ * sparkle trails, win celebrations.
2725
+ *
2726
+ * Cheaper than Spine for simple frame sequences.
2727
+ *
2728
+ * @example
2729
+ * ```ts
2730
+ * // From an array of textures
2731
+ * const coinAnim = SpriteAnimation.create(coinTextures, {
2732
+ * fps: 30,
2733
+ * loop: true,
2734
+ * });
2735
+ * scene.addChild(coinAnim);
2736
+ *
2737
+ * // From a spritesheet with a naming pattern
2738
+ * const sheet = Assets.get('effects');
2739
+ * const sparkle = SpriteAnimation.fromSpritesheet(sheet, 'sparkle_');
2740
+ * sparkle.play();
2741
+ *
2742
+ * // From a numbered range
2743
+ * const explosion = SpriteAnimation.fromRange(sheet, 'explosion_{i}', 0, 24, {
2744
+ * fps: 60,
2745
+ * loop: false,
2746
+ * onComplete: () => explosion.destroy(),
2747
+ * });
2748
+ * ```
2749
+ */
2750
+ class SpriteAnimation {
2751
+ /**
2752
+ * Create an animated sprite from an array of textures.
2753
+ *
2754
+ * @param textures - Array of PixiJS Textures
2755
+ * @param config - Animation options
2756
+ * @returns Configured AnimatedSprite
2757
+ */
2758
+ static create(textures, config = {}) {
2759
+ const sprite = new pixi_js.AnimatedSprite(textures);
2760
+ // Configure
2761
+ sprite.animationSpeed = (config.fps ?? 24) / 60; // PixiJS uses speed relative to 60fps ticker
2762
+ sprite.loop = config.loop ?? true;
2763
+ // Anchor
2764
+ if (config.anchor !== undefined) {
2765
+ if (typeof config.anchor === 'number') {
2766
+ sprite.anchor.set(config.anchor);
2767
+ }
2768
+ else {
2769
+ sprite.anchor.set(config.anchor.x, config.anchor.y);
2770
+ }
2771
+ }
2772
+ else {
2773
+ sprite.anchor.set(0.5);
2774
+ }
2775
+ // Complete callback
2776
+ if (config.onComplete) {
2777
+ sprite.onComplete = config.onComplete;
2778
+ }
2779
+ // Auto-play
2780
+ if (config.autoPlay !== false) {
2781
+ sprite.play();
2782
+ }
2783
+ return sprite;
2784
+ }
2785
+ /**
2786
+ * Create an animated sprite from a spritesheet using a name prefix.
2787
+ *
2788
+ * Collects all textures whose keys start with `prefix`, sorted alphabetically.
2789
+ *
2790
+ * @param sheet - PixiJS Spritesheet instance
2791
+ * @param prefix - Texture name prefix (e.g., 'coin_')
2792
+ * @param config - Animation options
2793
+ * @returns Configured AnimatedSprite
2794
+ */
2795
+ static fromSpritesheet(sheet, prefix, config = {}) {
2796
+ const textures = SpriteAnimation.getTexturesByPrefix(sheet, prefix);
2797
+ if (textures.length === 0) {
2798
+ console.warn(`[SpriteAnimation] No textures found with prefix "${prefix}"`);
2799
+ }
2800
+ return SpriteAnimation.create(textures, config);
2801
+ }
2802
+ /**
2803
+ * Create an animated sprite from a numbered range of frames.
2804
+ *
2805
+ * The `pattern` string should contain `{i}` as a placeholder for the frame number.
2806
+ * Numbers are zero-padded to match the length of `start`.
2807
+ *
2808
+ * @param sheet - PixiJS Spritesheet instance
2809
+ * @param pattern - Frame name pattern, e.g. 'explosion_{i}'
2810
+ * @param start - Start frame index (inclusive)
2811
+ * @param end - End frame index (inclusive)
2812
+ * @param config - Animation options
2813
+ * @returns Configured AnimatedSprite
2814
+ */
2815
+ static fromRange(sheet, pattern, start, end, config = {}) {
2816
+ const textures = [];
2817
+ const padLength = String(end).length;
2818
+ for (let i = start; i <= end; i++) {
2819
+ const name = pattern.replace('{i}', String(i).padStart(padLength, '0'));
2820
+ const texture = sheet.textures[name];
2821
+ if (texture) {
2822
+ textures.push(texture);
2823
+ }
2824
+ else {
2825
+ console.warn(`[SpriteAnimation] Missing frame: "${name}"`);
2826
+ }
2827
+ }
2828
+ if (textures.length === 0) {
2829
+ console.warn(`[SpriteAnimation] No textures found for pattern "${pattern}" [${start}..${end}]`);
2830
+ }
2831
+ return SpriteAnimation.create(textures, config);
2832
+ }
2833
+ /**
2834
+ * Create an AnimatedSprite from texture aliases (loaded via AssetManager).
2835
+ *
2836
+ * @param aliases - Array of texture aliases
2837
+ * @param config - Animation options
2838
+ * @returns Configured AnimatedSprite
2839
+ */
2840
+ static fromAliases(aliases, config = {}) {
2841
+ const textures = aliases.map((alias) => {
2842
+ const tex = pixi_js.Texture.from(alias);
2843
+ return tex;
2844
+ });
2845
+ return SpriteAnimation.create(textures, config);
2846
+ }
2847
+ /**
2848
+ * Play a one-shot animation and auto-destroy when complete.
2849
+ * Useful for fire-and-forget effects like coin bursts.
2850
+ *
2851
+ * @param textures - Array of textures
2852
+ * @param config - Animation options (loop will be forced to false)
2853
+ * @returns Promise that resolves when animation completes
2854
+ */
2855
+ static playOnce(textures, config = {}) {
2856
+ const finished = new Promise((resolve) => {
2857
+ config = {
2858
+ ...config,
2859
+ loop: false,
2860
+ onComplete: () => {
2861
+ config.onComplete?.();
2862
+ sprite.destroy();
2863
+ resolve();
2864
+ },
2865
+ };
2866
+ });
2867
+ const sprite = SpriteAnimation.create(textures, config);
2868
+ return { sprite, finished };
2869
+ }
2870
+ // ─── Utility ───────────────────────────────────────────
2871
+ /**
2872
+ * Get all textures from a spritesheet that start with a given prefix.
2873
+ * Results are sorted alphabetically by key.
2874
+ */
2875
+ static getTexturesByPrefix(sheet, prefix) {
2876
+ const keys = Object.keys(sheet.textures)
2877
+ .filter((k) => k.startsWith(prefix))
2878
+ .sort();
2879
+ return keys.map((k) => sheet.textures[k]);
2880
+ }
2881
+ }
2882
+
2493
2883
  const DEFAULT_COLORS = {
2494
2884
  normal: 0xffd700,
2495
2885
  hover: 0xffe44d,
@@ -2936,6 +3326,7 @@ class BalanceDisplay extends pixi_js.Container {
2936
3326
  _currentValue = 0;
2937
3327
  _displayedValue = 0;
2938
3328
  _animating = false;
3329
+ _animationCancelled = false;
2939
3330
  constructor(config = {}) {
2940
3331
  super();
2941
3332
  this._config = {
@@ -2997,11 +3388,22 @@ class BalanceDisplay extends pixi_js.Container {
2997
3388
  this.updateDisplay();
2998
3389
  }
2999
3390
  async animateValue(from, to) {
3391
+ // Cancel any ongoing animation
3392
+ if (this._animating) {
3393
+ this._animationCancelled = true;
3394
+ }
3000
3395
  this._animating = true;
3396
+ this._animationCancelled = false;
3001
3397
  const duration = this._config.animationDuration;
3002
3398
  const startTime = Date.now();
3003
3399
  return new Promise((resolve) => {
3004
3400
  const tick = () => {
3401
+ // If cancelled by a newer animation, stop immediately
3402
+ if (this._animationCancelled) {
3403
+ this._animating = false;
3404
+ resolve();
3405
+ return;
3406
+ }
3005
3407
  const elapsed = Date.now() - startTime;
3006
3408
  const t = Math.min(elapsed / duration, 1);
3007
3409
  const eased = Easing.easeOutCubic(t);
@@ -3310,15 +3712,732 @@ class Toast extends pixi_js.Container {
3310
3712
  }
3311
3713
  }
3312
3714
 
3313
- const DEFAULT_CONFIG = {
3314
- balance: 10000,
3315
- currency: 'USD',
3316
- gameConfig: {
3317
- id: 'dev-game',
3318
- type: 'slot',
3319
- version: '1.0.0',
3320
- viewport: { width: 1920, height: 1080 },
3321
- betLevels: [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50],
3715
+ /**
3716
+ * Responsive layout container that automatically arranges its children.
3717
+ *
3718
+ * Supports horizontal, vertical, grid, and wrap layout modes with
3719
+ * alignment, padding, gap, and viewport-anchor positioning.
3720
+ * Breakpoints allow different layouts for different screen sizes.
3721
+ *
3722
+ * @example
3723
+ * ```ts
3724
+ * const toolbar = new Layout({
3725
+ * direction: 'horizontal',
3726
+ * gap: 20,
3727
+ * alignment: 'center',
3728
+ * anchor: 'bottom-center',
3729
+ * padding: 16,
3730
+ * breakpoints: {
3731
+ * 768: { direction: 'vertical', gap: 10 },
3732
+ * },
3733
+ * });
3734
+ *
3735
+ * toolbar.addItem(spinButton);
3736
+ * toolbar.addItem(betLabel);
3737
+ * scene.container.addChild(toolbar);
3738
+ *
3739
+ * // On resize, update layout position relative to viewport
3740
+ * toolbar.updateViewport(width, height);
3741
+ * ```
3742
+ */
3743
+ class Layout extends pixi_js.Container {
3744
+ _config;
3745
+ _padding;
3746
+ _anchor;
3747
+ _maxWidth;
3748
+ _breakpoints;
3749
+ _content;
3750
+ _items = [];
3751
+ _viewportWidth = 0;
3752
+ _viewportHeight = 0;
3753
+ constructor(config = {}) {
3754
+ super();
3755
+ this._config = {
3756
+ direction: config.direction ?? 'vertical',
3757
+ gap: config.gap ?? 0,
3758
+ alignment: config.alignment ?? 'start',
3759
+ autoLayout: config.autoLayout ?? true,
3760
+ columns: config.columns ?? 2,
3761
+ };
3762
+ this._padding = Layout.normalizePadding(config.padding ?? 0);
3763
+ this._anchor = config.anchor ?? 'top-left';
3764
+ this._maxWidth = config.maxWidth ?? Infinity;
3765
+ // Sort breakpoints by width ascending for correct resolution
3766
+ this._breakpoints = config.breakpoints
3767
+ ? Object.entries(config.breakpoints)
3768
+ .map(([w, cfg]) => [Number(w), cfg])
3769
+ .sort((a, b) => a[0] - b[0])
3770
+ : [];
3771
+ this._content = new pixi_js.Container();
3772
+ this.addChild(this._content);
3773
+ }
3774
+ /** Add an item to the layout */
3775
+ addItem(child) {
3776
+ this._items.push(child);
3777
+ this._content.addChild(child);
3778
+ if (this._config.autoLayout)
3779
+ this.layout();
3780
+ return this;
3781
+ }
3782
+ /** Remove an item from the layout */
3783
+ removeItem(child) {
3784
+ const idx = this._items.indexOf(child);
3785
+ if (idx !== -1) {
3786
+ this._items.splice(idx, 1);
3787
+ this._content.removeChild(child);
3788
+ if (this._config.autoLayout)
3789
+ this.layout();
3790
+ }
3791
+ return this;
3792
+ }
3793
+ /** Remove all items */
3794
+ clearItems() {
3795
+ for (const item of this._items) {
3796
+ this._content.removeChild(item);
3797
+ }
3798
+ this._items.length = 0;
3799
+ if (this._config.autoLayout)
3800
+ this.layout();
3801
+ return this;
3802
+ }
3803
+ /** Get all layout items */
3804
+ get items() {
3805
+ return this._items;
3806
+ }
3807
+ /**
3808
+ * Update the viewport size and recalculate layout.
3809
+ * Should be called from `Scene.onResize()`.
3810
+ */
3811
+ updateViewport(width, height) {
3812
+ this._viewportWidth = width;
3813
+ this._viewportHeight = height;
3814
+ this.layout();
3815
+ }
3816
+ /**
3817
+ * Recalculate layout positions of all children.
3818
+ */
3819
+ layout() {
3820
+ if (this._items.length === 0)
3821
+ return;
3822
+ // Resolve effective config (apply breakpoint overrides)
3823
+ const effective = this.resolveConfig();
3824
+ const gap = effective.gap ?? this._config.gap;
3825
+ const direction = effective.direction ?? this._config.direction;
3826
+ const alignment = effective.alignment ?? this._config.alignment;
3827
+ const columns = effective.columns ?? this._config.columns;
3828
+ const padding = effective.padding !== undefined
3829
+ ? Layout.normalizePadding(effective.padding)
3830
+ : this._padding;
3831
+ const maxWidth = effective.maxWidth ?? this._maxWidth;
3832
+ const [pt, pr, pb, pl] = padding;
3833
+ switch (direction) {
3834
+ case 'horizontal':
3835
+ this.layoutLinear('x', 'y', gap, alignment, pl, pt);
3836
+ break;
3837
+ case 'vertical':
3838
+ this.layoutLinear('y', 'x', gap, alignment, pt, pl);
3839
+ break;
3840
+ case 'grid':
3841
+ this.layoutGrid(columns, gap, alignment, pl, pt);
3842
+ break;
3843
+ case 'wrap':
3844
+ this.layoutWrap(maxWidth - pl - pr, gap, alignment, pl, pt);
3845
+ break;
3846
+ }
3847
+ // Apply anchor positioning relative to viewport
3848
+ this.applyAnchor(effective.anchor ?? this._anchor);
3849
+ }
3850
+ // ─── Private layout helpers ────────────────────────────
3851
+ layoutLinear(mainAxis, crossAxis, gap, alignment, mainOffset, crossOffset) {
3852
+ let pos = mainOffset;
3853
+ const sizes = this._items.map(item => this.getItemSize(item));
3854
+ const maxCross = Math.max(...sizes.map(s => (crossAxis === 'x' ? s.width : s.height)));
3855
+ for (let i = 0; i < this._items.length; i++) {
3856
+ const item = this._items[i];
3857
+ const size = sizes[i];
3858
+ item[mainAxis] = pos;
3859
+ // Cross-axis alignment
3860
+ const itemCross = crossAxis === 'x' ? size.width : size.height;
3861
+ switch (alignment) {
3862
+ case 'start':
3863
+ item[crossAxis] = crossOffset;
3864
+ break;
3865
+ case 'center':
3866
+ item[crossAxis] = crossOffset + (maxCross - itemCross) / 2;
3867
+ break;
3868
+ case 'end':
3869
+ item[crossAxis] = crossOffset + maxCross - itemCross;
3870
+ break;
3871
+ case 'stretch':
3872
+ item[crossAxis] = crossOffset;
3873
+ // Note: stretch doesn't resize children — that's up to the item
3874
+ break;
3875
+ }
3876
+ const mainSize = mainAxis === 'x' ? size.width : size.height;
3877
+ pos += mainSize + gap;
3878
+ }
3879
+ }
3880
+ layoutGrid(columns, gap, alignment, offsetX, offsetY) {
3881
+ const sizes = this._items.map(item => this.getItemSize(item));
3882
+ const maxItemWidth = Math.max(...sizes.map(s => s.width));
3883
+ const maxItemHeight = Math.max(...sizes.map(s => s.height));
3884
+ const cellW = maxItemWidth + gap;
3885
+ const cellH = maxItemHeight + gap;
3886
+ for (let i = 0; i < this._items.length; i++) {
3887
+ const item = this._items[i];
3888
+ const col = i % columns;
3889
+ const row = Math.floor(i / columns);
3890
+ const size = sizes[i];
3891
+ // X alignment within cell
3892
+ switch (alignment) {
3893
+ case 'center':
3894
+ item.x = offsetX + col * cellW + (maxItemWidth - size.width) / 2;
3895
+ break;
3896
+ case 'end':
3897
+ item.x = offsetX + col * cellW + maxItemWidth - size.width;
3898
+ break;
3899
+ default:
3900
+ item.x = offsetX + col * cellW;
3901
+ }
3902
+ item.y = offsetY + row * cellH;
3903
+ }
3904
+ }
3905
+ layoutWrap(maxWidth, gap, alignment, offsetX, offsetY) {
3906
+ let x = offsetX;
3907
+ let y = offsetY;
3908
+ let rowHeight = 0;
3909
+ const sizes = this._items.map(item => this.getItemSize(item));
3910
+ for (let i = 0; i < this._items.length; i++) {
3911
+ const item = this._items[i];
3912
+ const size = sizes[i];
3913
+ // Check if item fits in current row
3914
+ if (x + size.width > maxWidth + offsetX && x > offsetX) {
3915
+ // Wrap to next row
3916
+ x = offsetX;
3917
+ y += rowHeight + gap;
3918
+ rowHeight = 0;
3919
+ }
3920
+ item.x = x;
3921
+ item.y = y;
3922
+ x += size.width + gap;
3923
+ rowHeight = Math.max(rowHeight, size.height);
3924
+ }
3925
+ }
3926
+ applyAnchor(anchor) {
3927
+ if (this._viewportWidth === 0 || this._viewportHeight === 0)
3928
+ return;
3929
+ const bounds = this._content.getBounds();
3930
+ const contentW = bounds.width;
3931
+ const contentH = bounds.height;
3932
+ const vw = this._viewportWidth;
3933
+ const vh = this._viewportHeight;
3934
+ let anchorX = 0;
3935
+ let anchorY = 0;
3936
+ // Horizontal
3937
+ if (anchor.includes('left')) {
3938
+ anchorX = 0;
3939
+ }
3940
+ else if (anchor.includes('right')) {
3941
+ anchorX = vw - contentW;
3942
+ }
3943
+ else {
3944
+ // center
3945
+ anchorX = (vw - contentW) / 2;
3946
+ }
3947
+ // Vertical
3948
+ if (anchor.startsWith('top')) {
3949
+ anchorY = 0;
3950
+ }
3951
+ else if (anchor.startsWith('bottom')) {
3952
+ anchorY = vh - contentH;
3953
+ }
3954
+ else {
3955
+ // center
3956
+ anchorY = (vh - contentH) / 2;
3957
+ }
3958
+ // Compensate for content's local bounds offset
3959
+ this.x = anchorX - bounds.x;
3960
+ this.y = anchorY - bounds.y;
3961
+ }
3962
+ resolveConfig() {
3963
+ if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
3964
+ return {};
3965
+ }
3966
+ // Find the largest breakpoint that's ≤ current viewport width
3967
+ let resolved = {};
3968
+ for (const [maxWidth, overrides] of this._breakpoints) {
3969
+ if (this._viewportWidth <= maxWidth) {
3970
+ resolved = overrides;
3971
+ break;
3972
+ }
3973
+ }
3974
+ return resolved;
3975
+ }
3976
+ getItemSize(item) {
3977
+ const bounds = item.getBounds();
3978
+ return { width: bounds.width, height: bounds.height };
3979
+ }
3980
+ static normalizePadding(padding) {
3981
+ if (typeof padding === 'number') {
3982
+ return [padding, padding, padding, padding];
3983
+ }
3984
+ return padding;
3985
+ }
3986
+ }
3987
+
3988
+ /**
3989
+ * Scrollable container with touch/drag, mouse wheel, inertia, and optional scrollbar.
3990
+ *
3991
+ * Perfect for paytables, settings panels, bet history, and any scrollable content
3992
+ * that doesn't fit on screen.
3993
+ *
3994
+ * @example
3995
+ * ```ts
3996
+ * const scroll = new ScrollContainer({
3997
+ * width: 600,
3998
+ * height: 400,
3999
+ * direction: 'vertical',
4000
+ * showScrollbar: true,
4001
+ * elasticity: 0.3,
4002
+ * });
4003
+ *
4004
+ * // Add content taller than 400px
4005
+ * const list = new Container();
4006
+ * for (let i = 0; i < 50; i++) {
4007
+ * const row = createRow(i);
4008
+ * row.y = i * 40;
4009
+ * list.addChild(row);
4010
+ * }
4011
+ * scroll.setContent(list);
4012
+ *
4013
+ * scene.container.addChild(scroll);
4014
+ * ```
4015
+ */
4016
+ class ScrollContainer extends pixi_js.Container {
4017
+ _config;
4018
+ _viewport;
4019
+ _content = null;
4020
+ _mask;
4021
+ _bg;
4022
+ _scrollbarV = null;
4023
+ _scrollbarH = null;
4024
+ _scrollbarFadeTimeout = null;
4025
+ // Scroll state
4026
+ _scrollX = 0;
4027
+ _scrollY = 0;
4028
+ _velocityX = 0;
4029
+ _velocityY = 0;
4030
+ _isDragging = false;
4031
+ _dragStart = { x: 0, y: 0 };
4032
+ _scrollStart = { x: 0, y: 0 };
4033
+ _lastDragPos = { x: 0, y: 0 };
4034
+ _lastDragTime = 0;
4035
+ _isAnimating = false;
4036
+ _animationFrame = null;
4037
+ constructor(config) {
4038
+ super();
4039
+ this._config = {
4040
+ width: config.width,
4041
+ height: config.height,
4042
+ direction: config.direction ?? 'vertical',
4043
+ showScrollbar: config.showScrollbar ?? true,
4044
+ scrollbarWidth: config.scrollbarWidth ?? 6,
4045
+ scrollbarColor: config.scrollbarColor ?? 0xffffff,
4046
+ scrollbarAlpha: config.scrollbarAlpha ?? 0.4,
4047
+ elasticity: config.elasticity ?? 0.3,
4048
+ inertia: config.inertia ?? 0.92,
4049
+ snapSize: config.snapSize ?? 0,
4050
+ borderRadius: config.borderRadius ?? 0,
4051
+ };
4052
+ // Background
4053
+ this._bg = new pixi_js.Graphics();
4054
+ if (config.backgroundColor !== undefined) {
4055
+ this._bg.roundRect(0, 0, config.width, config.height, this._config.borderRadius)
4056
+ .fill({ color: config.backgroundColor, alpha: config.backgroundAlpha ?? 1 });
4057
+ }
4058
+ this.addChild(this._bg);
4059
+ // Viewport (masked area)
4060
+ this._viewport = new pixi_js.Container();
4061
+ this.addChild(this._viewport);
4062
+ // Mask
4063
+ this._mask = new pixi_js.Graphics();
4064
+ this._mask.roundRect(0, 0, config.width, config.height, this._config.borderRadius)
4065
+ .fill(0xffffff);
4066
+ this.addChild(this._mask);
4067
+ this._viewport.mask = this._mask;
4068
+ // Scrollbars
4069
+ if (this._config.showScrollbar) {
4070
+ if (this._config.direction !== 'horizontal') {
4071
+ this._scrollbarV = new pixi_js.Graphics();
4072
+ this._scrollbarV.alpha = 0;
4073
+ this.addChild(this._scrollbarV);
4074
+ }
4075
+ if (this._config.direction !== 'vertical') {
4076
+ this._scrollbarH = new pixi_js.Graphics();
4077
+ this._scrollbarH.alpha = 0;
4078
+ this.addChild(this._scrollbarH);
4079
+ }
4080
+ }
4081
+ // Interaction
4082
+ this.eventMode = 'static';
4083
+ this.cursor = 'grab';
4084
+ this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
4085
+ this.on('pointerdown', this.onPointerDown);
4086
+ this.on('pointermove', this.onPointerMove);
4087
+ this.on('pointerup', this.onPointerUp);
4088
+ this.on('pointerupoutside', this.onPointerUp);
4089
+ this.on('wheel', this.onWheel);
4090
+ }
4091
+ /** Set scrollable content. Replaces any existing content. */
4092
+ setContent(content) {
4093
+ if (this._content) {
4094
+ this._viewport.removeChild(this._content);
4095
+ }
4096
+ this._content = content;
4097
+ this._viewport.addChild(content);
4098
+ this._scrollX = 0;
4099
+ this._scrollY = 0;
4100
+ this.applyScroll();
4101
+ }
4102
+ /** Get the content container */
4103
+ get content() {
4104
+ return this._content;
4105
+ }
4106
+ /** Scroll to a specific position (in content coordinates) */
4107
+ scrollTo(x, y, animate = true) {
4108
+ if (!animate) {
4109
+ this._scrollX = x;
4110
+ this._scrollY = y;
4111
+ this.clampScroll();
4112
+ this.applyScroll();
4113
+ return;
4114
+ }
4115
+ this.animateScrollTo(x, y);
4116
+ }
4117
+ /** Scroll to make a specific item/child visible */
4118
+ scrollToItem(index) {
4119
+ if (this._config.snapSize > 0) {
4120
+ const pos = index * this._config.snapSize;
4121
+ if (this._config.direction === 'horizontal') {
4122
+ this.scrollTo(pos, this._scrollY);
4123
+ }
4124
+ else {
4125
+ this.scrollTo(this._scrollX, pos);
4126
+ }
4127
+ }
4128
+ }
4129
+ /** Current scroll position */
4130
+ get scrollPosition() {
4131
+ return { x: this._scrollX, y: this._scrollY };
4132
+ }
4133
+ /** Resize the scroll viewport */
4134
+ resize(width, height) {
4135
+ this._config.width = width;
4136
+ this._config.height = height;
4137
+ // Redraw mask and background
4138
+ this._mask.clear();
4139
+ this._mask.roundRect(0, 0, width, height, this._config.borderRadius).fill(0xffffff);
4140
+ this._bg.clear();
4141
+ this.hitArea = { contains: (x, y) => x >= 0 && x <= width && y >= 0 && y <= height };
4142
+ this.clampScroll();
4143
+ this.applyScroll();
4144
+ }
4145
+ /** Destroy and clean up */
4146
+ destroy(options) {
4147
+ this.stopAnimation();
4148
+ if (this._scrollbarFadeTimeout !== null) {
4149
+ clearTimeout(this._scrollbarFadeTimeout);
4150
+ }
4151
+ this.off('pointerdown', this.onPointerDown);
4152
+ this.off('pointermove', this.onPointerMove);
4153
+ this.off('pointerup', this.onPointerUp);
4154
+ this.off('pointerupoutside', this.onPointerUp);
4155
+ this.off('wheel', this.onWheel);
4156
+ super.destroy(options);
4157
+ }
4158
+ // ─── Scroll mechanics ─────────────────────────────────
4159
+ get contentWidth() {
4160
+ if (!this._content)
4161
+ return 0;
4162
+ const bounds = this._content.getBounds();
4163
+ return bounds.width;
4164
+ }
4165
+ get contentHeight() {
4166
+ if (!this._content)
4167
+ return 0;
4168
+ const bounds = this._content.getBounds();
4169
+ return bounds.height;
4170
+ }
4171
+ get maxScrollX() {
4172
+ return Math.max(0, this.contentWidth - this._config.width);
4173
+ }
4174
+ get maxScrollY() {
4175
+ return Math.max(0, this.contentHeight - this._config.height);
4176
+ }
4177
+ canScrollX() {
4178
+ return this._config.direction === 'horizontal' || this._config.direction === 'both';
4179
+ }
4180
+ canScrollY() {
4181
+ return this._config.direction === 'vertical' || this._config.direction === 'both';
4182
+ }
4183
+ clampScroll() {
4184
+ if (this.canScrollX()) {
4185
+ this._scrollX = Math.max(0, Math.min(this._scrollX, this.maxScrollX));
4186
+ }
4187
+ else {
4188
+ this._scrollX = 0;
4189
+ }
4190
+ if (this.canScrollY()) {
4191
+ this._scrollY = Math.max(0, Math.min(this._scrollY, this.maxScrollY));
4192
+ }
4193
+ else {
4194
+ this._scrollY = 0;
4195
+ }
4196
+ }
4197
+ applyScroll() {
4198
+ if (!this._content)
4199
+ return;
4200
+ this._content.x = -this._scrollX;
4201
+ this._content.y = -this._scrollY;
4202
+ this.updateScrollbars();
4203
+ }
4204
+ // ─── Input handlers ────────────────────────────────────
4205
+ onPointerDown = (e) => {
4206
+ this._isDragging = true;
4207
+ this._isAnimating = false;
4208
+ this.stopAnimation();
4209
+ this.cursor = 'grabbing';
4210
+ const local = e.getLocalPosition(this);
4211
+ this._dragStart = { x: local.x, y: local.y };
4212
+ this._scrollStart = { x: this._scrollX, y: this._scrollY };
4213
+ this._lastDragPos = { x: local.x, y: local.y };
4214
+ this._lastDragTime = Date.now();
4215
+ this._velocityX = 0;
4216
+ this._velocityY = 0;
4217
+ this.showScrollbars();
4218
+ };
4219
+ onPointerMove = (e) => {
4220
+ if (!this._isDragging)
4221
+ return;
4222
+ const local = e.getLocalPosition(this);
4223
+ const dx = local.x - this._dragStart.x;
4224
+ const dy = local.y - this._dragStart.y;
4225
+ const now = Date.now();
4226
+ const dt = Math.max(1, now - this._lastDragTime);
4227
+ // Calculate velocity for inertia
4228
+ this._velocityX = (local.x - this._lastDragPos.x) / dt * 16; // normalize to ~60fps
4229
+ this._velocityY = (local.y - this._lastDragPos.y) / dt * 16;
4230
+ this._lastDragPos = { x: local.x, y: local.y };
4231
+ this._lastDragTime = now;
4232
+ // Apply scroll with elasticity for overscroll
4233
+ let newX = this._scrollStart.x - dx;
4234
+ let newY = this._scrollStart.y - dy;
4235
+ const elasticity = this._config.elasticity;
4236
+ if (this.canScrollX()) {
4237
+ if (newX < 0)
4238
+ newX *= elasticity;
4239
+ else if (newX > this.maxScrollX)
4240
+ newX = this.maxScrollX + (newX - this.maxScrollX) * elasticity;
4241
+ this._scrollX = newX;
4242
+ }
4243
+ if (this.canScrollY()) {
4244
+ if (newY < 0)
4245
+ newY *= elasticity;
4246
+ else if (newY > this.maxScrollY)
4247
+ newY = this.maxScrollY + (newY - this.maxScrollY) * elasticity;
4248
+ this._scrollY = newY;
4249
+ }
4250
+ this.applyScroll();
4251
+ };
4252
+ onPointerUp = () => {
4253
+ if (!this._isDragging)
4254
+ return;
4255
+ this._isDragging = false;
4256
+ this.cursor = 'grab';
4257
+ // Start inertia
4258
+ if (Math.abs(this._velocityX) > 0.5 || Math.abs(this._velocityY) > 0.5) {
4259
+ this.startInertia();
4260
+ }
4261
+ else {
4262
+ this.snapAndBounce();
4263
+ }
4264
+ };
4265
+ onWheel = (e) => {
4266
+ e.preventDefault?.();
4267
+ const delta = e.deltaY ?? 0;
4268
+ const deltaX = e.deltaX ?? 0;
4269
+ if (this.canScrollY()) {
4270
+ this._scrollY += delta * 0.5;
4271
+ }
4272
+ if (this.canScrollX()) {
4273
+ this._scrollX += deltaX * 0.5;
4274
+ }
4275
+ this.clampScroll();
4276
+ this.applyScroll();
4277
+ this.showScrollbars();
4278
+ this.scheduleScrollbarFade();
4279
+ };
4280
+ // ─── Inertia & snap ───────────────────────────────────
4281
+ startInertia() {
4282
+ this._isAnimating = true;
4283
+ const tick = () => {
4284
+ if (!this._isAnimating)
4285
+ return;
4286
+ this._velocityX *= this._config.inertia;
4287
+ this._velocityY *= this._config.inertia;
4288
+ if (this.canScrollX())
4289
+ this._scrollX -= this._velocityX;
4290
+ if (this.canScrollY())
4291
+ this._scrollY -= this._velocityY;
4292
+ // Bounce back if overscrolled
4293
+ let bounced = false;
4294
+ if (this.canScrollX()) {
4295
+ if (this._scrollX < 0) {
4296
+ this._scrollX *= 0.8;
4297
+ bounced = true;
4298
+ }
4299
+ else if (this._scrollX > this.maxScrollX) {
4300
+ this._scrollX = this.maxScrollX + (this._scrollX - this.maxScrollX) * 0.8;
4301
+ bounced = true;
4302
+ }
4303
+ }
4304
+ if (this.canScrollY()) {
4305
+ if (this._scrollY < 0) {
4306
+ this._scrollY *= 0.8;
4307
+ bounced = true;
4308
+ }
4309
+ else if (this._scrollY > this.maxScrollY) {
4310
+ this._scrollY = this.maxScrollY + (this._scrollY - this.maxScrollY) * 0.8;
4311
+ bounced = true;
4312
+ }
4313
+ }
4314
+ this.applyScroll();
4315
+ const speed = Math.abs(this._velocityX) + Math.abs(this._velocityY);
4316
+ if (speed < 0.1 && !bounced) {
4317
+ this._isAnimating = false;
4318
+ this.snapAndBounce();
4319
+ return;
4320
+ }
4321
+ this._animationFrame = requestAnimationFrame(tick);
4322
+ };
4323
+ this._animationFrame = requestAnimationFrame(tick);
4324
+ }
4325
+ snapAndBounce() {
4326
+ // Clamp first
4327
+ let targetX = Math.max(0, Math.min(this._scrollX, this.maxScrollX));
4328
+ let targetY = Math.max(0, Math.min(this._scrollY, this.maxScrollY));
4329
+ // Snap
4330
+ if (this._config.snapSize > 0) {
4331
+ if (this.canScrollY()) {
4332
+ targetY = Math.round(targetY / this._config.snapSize) * this._config.snapSize;
4333
+ targetY = Math.max(0, Math.min(targetY, this.maxScrollY));
4334
+ }
4335
+ if (this.canScrollX()) {
4336
+ targetX = Math.round(targetX / this._config.snapSize) * this._config.snapSize;
4337
+ targetX = Math.max(0, Math.min(targetX, this.maxScrollX));
4338
+ }
4339
+ }
4340
+ if (Math.abs(targetX - this._scrollX) < 0.5 && Math.abs(targetY - this._scrollY) < 0.5) {
4341
+ this._scrollX = targetX;
4342
+ this._scrollY = targetY;
4343
+ this.applyScroll();
4344
+ this.scheduleScrollbarFade();
4345
+ return;
4346
+ }
4347
+ this.animateScrollTo(targetX, targetY);
4348
+ }
4349
+ animateScrollTo(targetX, targetY) {
4350
+ this._isAnimating = true;
4351
+ const startX = this._scrollX;
4352
+ const startY = this._scrollY;
4353
+ const startTime = Date.now();
4354
+ const duration = 300;
4355
+ const tick = () => {
4356
+ if (!this._isAnimating)
4357
+ return;
4358
+ const elapsed = Date.now() - startTime;
4359
+ const t = Math.min(elapsed / duration, 1);
4360
+ // easeOutCubic
4361
+ const eased = 1 - Math.pow(1 - t, 3);
4362
+ this._scrollX = startX + (targetX - startX) * eased;
4363
+ this._scrollY = startY + (targetY - startY) * eased;
4364
+ this.applyScroll();
4365
+ if (t < 1) {
4366
+ this._animationFrame = requestAnimationFrame(tick);
4367
+ }
4368
+ else {
4369
+ this._isAnimating = false;
4370
+ this.scheduleScrollbarFade();
4371
+ }
4372
+ };
4373
+ this._animationFrame = requestAnimationFrame(tick);
4374
+ }
4375
+ stopAnimation() {
4376
+ this._isAnimating = false;
4377
+ if (this._animationFrame !== null) {
4378
+ cancelAnimationFrame(this._animationFrame);
4379
+ this._animationFrame = null;
4380
+ }
4381
+ }
4382
+ // ─── Scrollbars ────────────────────────────────────────
4383
+ updateScrollbars() {
4384
+ const { width, height, scrollbarWidth, scrollbarColor, scrollbarAlpha } = this._config;
4385
+ if (this._scrollbarV && this.canScrollY() && this.contentHeight > height) {
4386
+ const ratio = height / this.contentHeight;
4387
+ const barH = Math.max(20, height * ratio);
4388
+ const barY = (this._scrollY / this.maxScrollY) * (height - barH);
4389
+ this._scrollbarV.clear();
4390
+ this._scrollbarV.roundRect(width - scrollbarWidth - 2, Math.max(0, barY), scrollbarWidth, barH, scrollbarWidth / 2).fill({ color: scrollbarColor, alpha: scrollbarAlpha });
4391
+ }
4392
+ if (this._scrollbarH && this.canScrollX() && this.contentWidth > width) {
4393
+ const ratio = width / this.contentWidth;
4394
+ const barW = Math.max(20, width * ratio);
4395
+ const barX = (this._scrollX / this.maxScrollX) * (width - barW);
4396
+ this._scrollbarH.clear();
4397
+ this._scrollbarH.roundRect(Math.max(0, barX), height - scrollbarWidth - 2, barW, scrollbarWidth, scrollbarWidth / 2).fill({ color: scrollbarColor, alpha: scrollbarAlpha });
4398
+ }
4399
+ }
4400
+ showScrollbars() {
4401
+ if (this._scrollbarV)
4402
+ this._scrollbarV.alpha = 1;
4403
+ if (this._scrollbarH)
4404
+ this._scrollbarH.alpha = 1;
4405
+ }
4406
+ scheduleScrollbarFade() {
4407
+ if (this._scrollbarFadeTimeout !== null) {
4408
+ clearTimeout(this._scrollbarFadeTimeout);
4409
+ }
4410
+ this._scrollbarFadeTimeout = window.setTimeout(() => {
4411
+ this.fadeScrollbars();
4412
+ }, 1000);
4413
+ }
4414
+ fadeScrollbars() {
4415
+ const duration = 300;
4416
+ const startTime = Date.now();
4417
+ const startAlphaV = this._scrollbarV?.alpha ?? 0;
4418
+ const startAlphaH = this._scrollbarH?.alpha ?? 0;
4419
+ const tick = () => {
4420
+ const t = Math.min((Date.now() - startTime) / duration, 1);
4421
+ if (this._scrollbarV)
4422
+ this._scrollbarV.alpha = startAlphaV * (1 - t);
4423
+ if (this._scrollbarH)
4424
+ this._scrollbarH.alpha = startAlphaH * (1 - t);
4425
+ if (t < 1)
4426
+ requestAnimationFrame(tick);
4427
+ };
4428
+ requestAnimationFrame(tick);
4429
+ }
4430
+ }
4431
+
4432
+ const DEFAULT_CONFIG = {
4433
+ balance: 10000,
4434
+ currency: 'USD',
4435
+ gameConfig: {
4436
+ id: 'dev-game',
4437
+ type: 'slot',
4438
+ version: '1.0.0',
4439
+ viewport: { width: 1920, height: 1080 },
4440
+ betLevels: [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50],
3322
4441
  },
3323
4442
  assetsUrl: '/assets/',
3324
4443
  session: null,
@@ -3329,8 +4448,9 @@ const DEFAULT_CONFIG = {
3329
4448
  /**
3330
4449
  * Mock host bridge for local development.
3331
4450
  *
3332
- * Intercepts postMessage communication from the SDK and responds
3333
- * with mock data, simulating a real casino host environment.
4451
+ * Uses the SDK's `Bridge` class in `devMode` to communicate with
4452
+ * `CasinoGameSDK` via a shared in-memory `MemoryChannel`, removing
4453
+ * the need for postMessage and iframes.
3334
4454
  *
3335
4455
  * This allows games to be developed and tested without a real backend.
3336
4456
  *
@@ -3358,8 +4478,7 @@ class DevBridge {
3358
4478
  _config;
3359
4479
  _balance;
3360
4480
  _roundCounter = 0;
3361
- _listening = false;
3362
- _handler = null;
4481
+ _bridge = null;
3363
4482
  constructor(config = {}) {
3364
4483
  this._config = { ...DEFAULT_CONFIG, ...config };
3365
4484
  this._balance = this._config.balance;
@@ -3370,24 +4489,38 @@ class DevBridge {
3370
4489
  }
3371
4490
  /** Start listening for SDK messages */
3372
4491
  start() {
3373
- if (this._listening)
4492
+ if (this._bridge)
3374
4493
  return;
3375
- this._handler = (e) => {
3376
- this.handleMessage(e);
3377
- };
3378
- window.addEventListener('message', this._handler);
3379
- this._listening = true;
4494
+ console.debug('[DevBridge] Starting with config:', this._config);
4495
+ this._bridge = new gameSdk.Bridge({ devMode: true, debug: this._config.debug });
4496
+ this._bridge.on('GAME_READY', (_payload, id) => {
4497
+ this.handleGameReady(id);
4498
+ });
4499
+ this._bridge.on('PLAY_REQUEST', (payload, id) => {
4500
+ this.handlePlayRequest(payload, id);
4501
+ });
4502
+ this._bridge.on('PLAY_RESULT_ACK', (payload) => {
4503
+ this.handlePlayAck(payload);
4504
+ });
4505
+ this._bridge.on('GET_BALANCE', (_payload, id) => {
4506
+ this.handleGetBalance(id);
4507
+ });
4508
+ this._bridge.on('GET_STATE', (_payload, id) => {
4509
+ this.handleGetState(id);
4510
+ });
4511
+ this._bridge.on('OPEN_DEPOSIT', () => {
4512
+ this.handleOpenDeposit();
4513
+ });
3380
4514
  if (this._config.debug) {
3381
- console.log('[DevBridge] Started — listening for SDK messages');
4515
+ console.log('[DevBridge] Started — listening via Bridge (devMode)');
3382
4516
  }
3383
4517
  }
3384
4518
  /** Stop listening */
3385
4519
  stop() {
3386
- if (this._handler) {
3387
- window.removeEventListener('message', this._handler);
3388
- this._handler = null;
4520
+ if (this._bridge) {
4521
+ this._bridge.destroy();
4522
+ this._bridge = null;
3389
4523
  }
3390
- this._listening = false;
3391
4524
  if (this._config.debug) {
3392
4525
  console.log('[DevBridge] Stopped');
3393
4526
  }
@@ -3395,47 +4528,13 @@ class DevBridge {
3395
4528
  /** Set mock balance */
3396
4529
  setBalance(balance) {
3397
4530
  this._balance = balance;
3398
- // Send balance update
3399
- this.sendMessage('BALANCE_UPDATE', { balance: this._balance });
4531
+ this._bridge?.send('BALANCE_UPDATE', { balance: this._balance });
3400
4532
  }
3401
4533
  /** Destroy the dev bridge */
3402
4534
  destroy() {
3403
4535
  this.stop();
3404
4536
  }
3405
4537
  // ─── Message Handling ──────────────────────────────────
3406
- handleMessage(e) {
3407
- const data = e.data;
3408
- // Only process bridge messages
3409
- if (!data || data.__casino_bridge !== true)
3410
- return;
3411
- if (this._config.debug) {
3412
- console.log('[DevBridge] ←', data.type, data.payload);
3413
- }
3414
- switch (data.type) {
3415
- case 'GAME_READY':
3416
- this.handleGameReady(data.id);
3417
- break;
3418
- case 'PLAY_REQUEST':
3419
- this.handlePlayRequest(data.payload, data.id);
3420
- break;
3421
- case 'PLAY_RESULT_ACK':
3422
- this.handlePlayAck(data.payload, data.id);
3423
- break;
3424
- case 'GET_BALANCE':
3425
- this.handleGetBalance(data.id);
3426
- break;
3427
- case 'GET_STATE':
3428
- this.handleGetState(data.id);
3429
- break;
3430
- case 'OPEN_DEPOSIT':
3431
- this.handleOpenDeposit();
3432
- break;
3433
- default:
3434
- if (this._config.debug) {
3435
- console.log('[DevBridge] Unknown message type:', data.type);
3436
- }
3437
- }
3438
- }
3439
4538
  handleGameReady(id) {
3440
4539
  const initData = {
3441
4540
  balance: this._balance,
@@ -3468,13 +4567,13 @@ class DevBridge {
3468
4567
  };
3469
4568
  this.delayedSend('PLAY_RESULT', result, id);
3470
4569
  }
3471
- handlePlayAck(_payload, _id) {
4570
+ handlePlayAck(_payload) {
3472
4571
  if (this._config.debug) {
3473
4572
  console.log('[DevBridge] Play acknowledged');
3474
4573
  }
3475
4574
  }
3476
4575
  handleGetBalance(id) {
3477
- this.delayedSend('BALANCE_RESPONSE', { balance: this._balance }, id);
4576
+ this.delayedSend('BALANCE_UPDATE', { balance: this._balance }, id);
3478
4577
  }
3479
4578
  handleGetState(id) {
3480
4579
  this.delayedSend('STATE_RESPONSE', this._config.session, id);
@@ -3484,120 +4583,17 @@ class DevBridge {
3484
4583
  console.log('[DevBridge] 💰 Open deposit requested (mock: adding 1000)');
3485
4584
  }
3486
4585
  this._balance += 1000;
3487
- this.sendMessage('BALANCE_UPDATE', { balance: this._balance });
4586
+ this._bridge?.send('BALANCE_UPDATE', { balance: this._balance });
3488
4587
  }
3489
4588
  // ─── Communication ─────────────────────────────────────
3490
4589
  delayedSend(type, payload, id) {
3491
4590
  const delay = this._config.networkDelay;
3492
4591
  if (delay > 0) {
3493
- setTimeout(() => this.sendMessage(type, payload, id), delay);
4592
+ setTimeout(() => this._bridge?.send(type, payload, id), delay);
3494
4593
  }
3495
4594
  else {
3496
- this.sendMessage(type, payload, id);
3497
- }
3498
- }
3499
- sendMessage(type, payload, id) {
3500
- const message = {
3501
- __casino_bridge: true,
3502
- type,
3503
- payload,
3504
- id,
3505
- };
3506
- if (this._config.debug) {
3507
- console.log('[DevBridge] →', type, payload);
4595
+ this._bridge?.send(type, payload, id);
3508
4596
  }
3509
- // Post to the same window (SDK listens on window)
3510
- window.postMessage(message, '*');
3511
- }
3512
- }
3513
-
3514
- /**
3515
- * FPS overlay for debugging performance.
3516
- *
3517
- * Shows FPS, frame time, and draw call count in the corner of the screen.
3518
- *
3519
- * @example
3520
- * ```ts
3521
- * const fps = new FPSOverlay(app);
3522
- * fps.show();
3523
- * ```
3524
- */
3525
- class FPSOverlay {
3526
- _app;
3527
- _container;
3528
- _fpsText;
3529
- _visible = false;
3530
- _samples = [];
3531
- _maxSamples = 60;
3532
- _lastUpdate = 0;
3533
- _tickFn = null;
3534
- constructor(app) {
3535
- this._app = app;
3536
- this._container = new pixi_js.Container();
3537
- this._container.label = 'FPSOverlay';
3538
- this._container.zIndex = 99999;
3539
- this._fpsText = new pixi_js.Text({
3540
- text: 'FPS: --',
3541
- style: {
3542
- fontFamily: 'monospace',
3543
- fontSize: 14,
3544
- fill: 0x00ff00,
3545
- stroke: { color: 0x000000, width: 2 },
3546
- },
3547
- });
3548
- this._fpsText.x = 8;
3549
- this._fpsText.y = 8;
3550
- this._container.addChild(this._fpsText);
3551
- }
3552
- /** Show the FPS overlay */
3553
- show() {
3554
- if (this._visible)
3555
- return;
3556
- this._visible = true;
3557
- this._app.stage.addChild(this._container);
3558
- this._tickFn = (ticker) => {
3559
- this._samples.push(ticker.FPS);
3560
- if (this._samples.length > this._maxSamples) {
3561
- this._samples.shift();
3562
- }
3563
- // Update display every ~500ms
3564
- const now = Date.now();
3565
- if (now - this._lastUpdate > 500) {
3566
- const avg = this._samples.reduce((a, b) => a + b, 0) / this._samples.length;
3567
- const min = Math.min(...this._samples);
3568
- this._fpsText.text = [
3569
- `FPS: ${Math.round(avg)} (min: ${Math.round(min)})`,
3570
- `Frame: ${ticker.deltaMS.toFixed(1)}ms`,
3571
- ].join('\n');
3572
- this._lastUpdate = now;
3573
- }
3574
- };
3575
- this._app.ticker.add(this._tickFn);
3576
- }
3577
- /** Hide the FPS overlay */
3578
- hide() {
3579
- if (!this._visible)
3580
- return;
3581
- this._visible = false;
3582
- this._container.removeFromParent();
3583
- if (this._tickFn) {
3584
- this._app.ticker.remove(this._tickFn);
3585
- this._tickFn = null;
3586
- }
3587
- }
3588
- /** Toggle visibility */
3589
- toggle() {
3590
- if (this._visible) {
3591
- this.hide();
3592
- }
3593
- else {
3594
- this.show();
3595
- }
3596
- }
3597
- /** Destroy the overlay */
3598
- destroy() {
3599
- this.hide();
3600
- this._container.destroy({ children: true });
3601
4597
  }
3602
4598
  }
3603
4599
 
@@ -3612,13 +4608,16 @@ exports.FPSOverlay = FPSOverlay;
3612
4608
  exports.GameApplication = GameApplication;
3613
4609
  exports.InputManager = InputManager;
3614
4610
  exports.Label = Label;
4611
+ exports.Layout = Layout;
3615
4612
  exports.LoadingScene = LoadingScene;
3616
4613
  exports.Modal = Modal;
3617
4614
  exports.Panel = Panel;
3618
4615
  exports.ProgressBar = ProgressBar;
3619
4616
  exports.Scene = Scene;
3620
4617
  exports.SceneManager = SceneManager;
4618
+ exports.ScrollContainer = ScrollContainer;
3621
4619
  exports.SpineHelper = SpineHelper;
4620
+ exports.SpriteAnimation = SpriteAnimation;
3622
4621
  exports.StateMachine = StateMachine;
3623
4622
  exports.Timeline = Timeline;
3624
4623
  exports.Toast = Toast;