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