@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/core.esm.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Ticker, Assets, Container, Application } from 'pixi.js';
1
+ import { Ticker, Assets, Container, Text, Application } from 'pixi.js';
2
2
  import { CasinoGameSDK } from '@energy8platform/game-sdk';
3
3
 
4
4
  // ─── Scale Modes ───────────────────────────────────────────
@@ -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) {
@@ -155,9 +159,20 @@ class Tween {
155
159
  }
156
160
  /**
157
161
  * Wait for a given duration (useful in timelines).
162
+ * Uses PixiJS Ticker for consistent timing with other tweens.
158
163
  */
159
164
  static delay(ms) {
160
- return new Promise((resolve) => setTimeout(resolve, ms));
165
+ return new Promise((resolve) => {
166
+ let elapsed = 0;
167
+ const onTick = (ticker) => {
168
+ elapsed += ticker.deltaMS;
169
+ if (elapsed >= ms) {
170
+ Ticker.shared.remove(onTick);
171
+ resolve();
172
+ }
173
+ };
174
+ Ticker.shared.add(onTick);
175
+ });
161
176
  }
162
177
  /**
163
178
  * Kill all tweens on a target.
@@ -184,6 +199,20 @@ class Tween {
184
199
  static get activeTweens() {
185
200
  return Tween._tweens.length;
186
201
  }
202
+ /**
203
+ * Reset the tween system — kill all tweens and remove the ticker.
204
+ * Useful for cleanup between game instances, tests, or hot-reload.
205
+ */
206
+ static reset() {
207
+ for (const tw of Tween._tweens) {
208
+ tw.resolve();
209
+ }
210
+ Tween._tweens.length = 0;
211
+ if (Tween._tickerAdded) {
212
+ Ticker.shared.remove(Tween.tick);
213
+ Tween._tickerAdded = false;
214
+ }
215
+ }
187
216
  // ─── Internal ──────────────────────────────────────────
188
217
  static ensureTicker() {
189
218
  if (Tween._tickerAdded)
@@ -386,8 +415,9 @@ class SceneManager extends EventEmitter {
386
415
  }
387
416
  // Transition in
388
417
  await this.transitionIn(scene.container, transition);
389
- await scene.onEnter?.(data);
418
+ // Push to stack BEFORE onEnter so currentKey is correct during initialization
390
419
  this.stack.push({ scene, key });
420
+ await scene.onEnter?.(data);
391
421
  this._transitioning = false;
392
422
  }
393
423
  async popInternal(showTransition, transition) {
@@ -686,26 +716,51 @@ class AudioManager {
686
716
  if (!this._initialized || !this._soundModule)
687
717
  return;
688
718
  const { sound } = this._soundModule;
689
- // Stop current music
690
- if (this._currentMusic) {
719
+ // Stop current music with fade-out, start new music with fade-in
720
+ if (this._currentMusic && fadeDuration > 0) {
721
+ const prevAlias = this._currentMusic;
722
+ this._currentMusic = alias;
723
+ if (this._globalMuted || this._categories.music.muted)
724
+ return;
725
+ // Fade out the previous track
726
+ this.fadeVolume(prevAlias, this._categories.music.volume, 0, fadeDuration, () => {
727
+ try {
728
+ sound.stop(prevAlias);
729
+ }
730
+ catch { /* ignore */ }
731
+ });
732
+ // Start new track at zero volume, fade in
691
733
  try {
692
- sound.stop(this._currentMusic);
734
+ sound.play(alias, {
735
+ volume: 0,
736
+ loop: true,
737
+ });
738
+ this.fadeVolume(alias, 0, this._categories.music.volume, fadeDuration);
693
739
  }
694
- catch {
695
- // ignore
740
+ catch (e) {
741
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
696
742
  }
697
743
  }
698
- this._currentMusic = alias;
699
- if (this._globalMuted || this._categories.music.muted)
700
- return;
701
- try {
702
- sound.play(alias, {
703
- volume: this._categories.music.volume,
704
- loop: true,
705
- });
706
- }
707
- catch (e) {
708
- console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
744
+ else {
745
+ // No crossfade — instant switch
746
+ if (this._currentMusic) {
747
+ try {
748
+ sound.stop(this._currentMusic);
749
+ }
750
+ catch { /* ignore */ }
751
+ }
752
+ this._currentMusic = alias;
753
+ if (this._globalMuted || this._categories.music.muted)
754
+ return;
755
+ try {
756
+ sound.play(alias, {
757
+ volume: this._categories.music.volume,
758
+ loop: true,
759
+ });
760
+ }
761
+ catch (e) {
762
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
763
+ }
709
764
  }
710
765
  }
711
766
  /**
@@ -847,6 +902,31 @@ class AudioManager {
847
902
  this._initialized = false;
848
903
  }
849
904
  // ─── Private ───────────────────────────────────────────
905
+ /**
906
+ * Smoothly fade a sound's volume from `fromVol` to `toVol` over `durationMs`.
907
+ */
908
+ fadeVolume(alias, fromVol, toVol, durationMs, onComplete) {
909
+ if (!this._soundModule)
910
+ return;
911
+ const { sound } = this._soundModule;
912
+ const startTime = Date.now();
913
+ const tick = () => {
914
+ const elapsed = Date.now() - startTime;
915
+ const t = Math.min(elapsed / durationMs, 1);
916
+ const vol = fromVol + (toVol - fromVol) * t;
917
+ try {
918
+ sound.volume(alias, vol);
919
+ }
920
+ catch { /* ignore */ }
921
+ if (t < 1) {
922
+ requestAnimationFrame(tick);
923
+ }
924
+ else {
925
+ onComplete?.();
926
+ }
927
+ };
928
+ requestAnimationFrame(tick);
929
+ }
850
930
  applyVolumes() {
851
931
  if (!this._soundModule)
852
932
  return;
@@ -951,6 +1031,10 @@ class InputManager extends EventEmitter {
951
1031
  _locked = false;
952
1032
  _keysDown = new Set();
953
1033
  _destroyed = false;
1034
+ // Viewport transform (set by ViewportManager via setViewportTransform)
1035
+ _viewportScale = 1;
1036
+ _viewportOffsetX = 0;
1037
+ _viewportOffsetY = 0;
954
1038
  // Gesture tracking
955
1039
  _pointerStart = null;
956
1040
  _swipeThreshold = 50; // minimum distance in px
@@ -977,6 +1061,25 @@ class InputManager extends EventEmitter {
977
1061
  isKeyDown(key) {
978
1062
  return this._keysDown.has(key.toLowerCase());
979
1063
  }
1064
+ /**
1065
+ * Update the viewport transform used for DOM→world coordinate mapping.
1066
+ * Called automatically by GameApplication when ViewportManager emits resize.
1067
+ */
1068
+ setViewportTransform(scale, offsetX, offsetY) {
1069
+ this._viewportScale = scale;
1070
+ this._viewportOffsetX = offsetX;
1071
+ this._viewportOffsetY = offsetY;
1072
+ }
1073
+ /**
1074
+ * Convert a DOM canvas position to game-world coordinates,
1075
+ * accounting for viewport scaling and offset.
1076
+ */
1077
+ getWorldPosition(canvasX, canvasY) {
1078
+ return {
1079
+ x: (canvasX - this._viewportOffsetX) / this._viewportScale,
1080
+ y: (canvasY - this._viewportOffsetY) / this._viewportScale,
1081
+ };
1082
+ }
980
1083
  /** Destroy the input manager */
981
1084
  destroy() {
982
1085
  this._destroyed = true;
@@ -1232,6 +1335,8 @@ class ViewportManager extends EventEmitter {
1232
1335
  this._destroyed = true;
1233
1336
  this._resizeObserver?.disconnect();
1234
1337
  this._resizeObserver = null;
1338
+ // Remove fallback window resize listener if it was used
1339
+ window.removeEventListener('resize', this.onWindowResize);
1235
1340
  if (this._resizeTimeout !== null) {
1236
1341
  clearTimeout(this._resizeTimeout);
1237
1342
  }
@@ -1295,45 +1400,87 @@ class Scene {
1295
1400
  }
1296
1401
 
1297
1402
  /**
1298
- * Inline SVG logo with a loader bar (clip-animated for progress).
1299
- * The clipPath rect width is set to 0 initially, expanded as loading progresses.
1403
+ * Shared Energy8 SVG logo with an embedded loader bar.
1404
+ *
1405
+ * The loader bar fill is controlled via a `<clipPath>` whose `<rect>` width
1406
+ * is animatable. Different consumers customise gradient IDs and the clip
1407
+ * element's ID/class to avoid collisions when both CSSPreloader and
1408
+ * LoadingScene appear in the same DOM.
1300
1409
  */
1301
- function buildLogoSVG() {
1302
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 200" fill="none" style="width:100%;height:auto;">
1303
- <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)"/>
1304
- <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)"/>
1305
- <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)"/>
1306
- <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)"/>
1307
- <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)"/>
1308
- <clipPath id="ge-canvas-loader-clip">
1309
- <rect id="ge-loader-rect" x="37" y="148" width="0" height="20"/>
1310
- </clipPath>
1311
- <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)"/>
1312
- <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>
1313
- <defs>
1314
- <linearGradient id="ls0" x1="223.7" x2="223.7" y1="81.75" y2="127.8" gradientUnits="userSpaceOnUse">
1410
+ /** SVG path data for the Energy8 wordmark — reused across loaders */
1411
+ const WORDMARK_PATHS = `
1412
+ <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)"/>
1413
+ <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)"/>
1414
+ <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)"/>
1415
+ <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)"/>
1416
+ <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)"/>`;
1417
+ /** Gradient definitions template (gradient IDs are replaced per-consumer) */
1418
+ const GRADIENT_DEFS = `
1419
+ <linearGradient id="GID0" x1="223.7" x2="223.7" y1="81.75" y2="127.8" gradientUnits="userSpaceOnUse">
1315
1420
  <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1316
1421
  </linearGradient>
1317
- <linearGradient id="ls1" x1="194.6" x2="194.6" y1="81.75" y2="138.3" gradientUnits="userSpaceOnUse">
1422
+ <linearGradient id="GID1" x1="194.6" x2="194.6" y1="81.75" y2="138.3" gradientUnits="userSpaceOnUse">
1318
1423
  <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1319
1424
  </linearGradient>
1320
- <linearGradient id="ls2" x1="157.8" x2="157.8" y1="81.75" y2="127" gradientUnits="userSpaceOnUse">
1425
+ <linearGradient id="GID2" x1="157.8" x2="157.8" y1="81.75" y2="127" gradientUnits="userSpaceOnUse">
1321
1426
  <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1322
1427
  </linearGradient>
1323
- <linearGradient id="ls3" x1="79.96" x2="79.96" y1="81.75" y2="141.8" gradientUnits="userSpaceOnUse">
1428
+ <linearGradient id="GID3" x1="79.96" x2="79.96" y1="81.75" y2="141.8" gradientUnits="userSpaceOnUse">
1324
1429
  <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1325
1430
  </linearGradient>
1326
- <linearGradient id="ls4" x1="36.18" x2="212.5" y1="156.6" y2="156.6" gradientUnits="userSpaceOnUse">
1431
+ <linearGradient id="GID4" x1="36.18" x2="212.5" y1="156.6" y2="156.6" gradientUnits="userSpaceOnUse">
1327
1432
  <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1328
1433
  </linearGradient>
1329
- <linearGradient id="ls5" x1="40.27" x2="208.2" y1="156.4" y2="156.4" gradientUnits="userSpaceOnUse">
1434
+ <linearGradient id="GID5" x1="40.27" x2="208.2" y1="156.4" y2="156.4" gradientUnits="userSpaceOnUse">
1330
1435
  <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1331
- </linearGradient>
1436
+ </linearGradient>`;
1437
+ /** Max width of the loader bar in SVG units */
1438
+ const LOADER_BAR_MAX_WIDTH = 174;
1439
+ /**
1440
+ * Build the Energy8 SVG logo with a loader bar, using unique IDs.
1441
+ *
1442
+ * @param opts - Configuration to avoid element ID collisions
1443
+ * @returns SVG markup string
1444
+ */
1445
+ function buildLogoSVG(opts) {
1446
+ const { idPrefix, svgClass, svgStyle, clipRectClass, clipRectId, textId, textContent, textClass } = opts;
1447
+ // Replace gradient ID placeholders with prefixed versions
1448
+ const paths = WORDMARK_PATHS.replace(/GID(\d)/g, `${idPrefix}$1`);
1449
+ const defs = GRADIENT_DEFS.replace(/GID(\d)/g, `${idPrefix}$1`);
1450
+ const clipId = `${idPrefix}-loader-clip`;
1451
+ const fillGradientId = `${idPrefix}5`;
1452
+ const classAttr = svgClass ? ` class="${svgClass}"` : '';
1453
+ const styleAttr = svgStyle ? ` style="${svgStyle}"` : '';
1454
+ const rectClassAttr = clipRectClass ? ` class="${clipRectClass}"` : '';
1455
+ const rectIdAttr = clipRectId ? ` id="${clipRectId}"` : '';
1456
+ const txtIdAttr = textId ? ` id="${textId}"` : '';
1457
+ const txtClassAttr = textClass ? ` class="${textClass}"` : '';
1458
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 200" fill="none"${classAttr}${styleAttr}>
1459
+ ${paths}
1460
+ <clipPath id="${clipId}">
1461
+ <rect${rectIdAttr} x="37" y="148" width="0" height="20"${rectClassAttr}/>
1462
+ </clipPath>
1463
+ <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})"/>
1464
+ <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>
1465
+ <defs>
1466
+ ${defs}
1332
1467
  </defs>
1333
1468
  </svg>`;
1334
1469
  }
1335
- /** Max width of the loader bar in SVG units */
1336
- const LOADER_BAR_MAX_WIDTH = 174;
1470
+
1471
+ /**
1472
+ * Build the loading scene variant of the logo SVG.
1473
+ * Uses unique IDs (prefixed with 'ls') to avoid collisions with CSSPreloader.
1474
+ */
1475
+ function buildLoadingLogoSVG() {
1476
+ return buildLogoSVG({
1477
+ idPrefix: 'ls',
1478
+ svgStyle: 'width:100%;height:auto;',
1479
+ clipRectId: 'ge-loader-rect',
1480
+ textId: 'ge-loader-pct',
1481
+ textContent: '0%',
1482
+ });
1483
+ }
1337
1484
  /**
1338
1485
  * Built-in loading screen using the Energy8 SVG logo with animated loader bar.
1339
1486
  *
@@ -1443,7 +1590,7 @@ class LoadingScene extends Scene {
1443
1590
  this._overlay.id = '__ge-loading-overlay__';
1444
1591
  this._overlay.innerHTML = `
1445
1592
  <div class="ge-loading-content">
1446
- ${buildLogoSVG()}
1593
+ ${buildLoadingLogoSVG()}
1447
1594
  </div>
1448
1595
  `;
1449
1596
  const style = document.createElement('style');
@@ -1581,8 +1728,11 @@ class LoadingScene extends Scene {
1581
1728
  }
1582
1729
  // Remove overlay
1583
1730
  this.removeOverlay();
1584
- // Navigate to the target scene
1585
- await this._engine.scenes.goto(this._targetScene, this._targetData);
1731
+ // Navigate to the target scene, always passing the engine reference
1732
+ await this._engine.scenes.goto(this._targetScene, {
1733
+ engine: this._engine,
1734
+ ...(this._targetData && typeof this._targetData === 'object' ? this._targetData : { data: this._targetData }),
1735
+ });
1586
1736
  }
1587
1737
  }
1588
1738
 
@@ -1591,39 +1741,12 @@ const PRELOADER_ID = '__ge-css-preloader__';
1591
1741
  * Inline SVG logo with animated loader bar.
1592
1742
  * The `#loader` path acts as the progress fill — animated via clipPath.
1593
1743
  */
1594
- const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 200" fill="none" class="ge-logo-svg">
1595
- <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)"/>
1596
- <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)"/>
1597
- <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)"/>
1598
- <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)"/>
1599
- <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)"/>
1600
- <!-- Loader fill with clip for progress animation -->
1601
- <clipPath id="ge-loader-clip">
1602
- <rect x="37" y="148" width="0" height="20" class="ge-clip-rect"/>
1603
- </clipPath>
1604
- <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)"/>
1605
- <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>
1606
- <defs>
1607
- <linearGradient id="pl0" x1="223.7" x2="223.7" y1="81.75" y2="127.8" gradientUnits="userSpaceOnUse">
1608
- <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1609
- </linearGradient>
1610
- <linearGradient id="pl1" x1="194.6" x2="194.6" y1="81.75" y2="138.3" gradientUnits="userSpaceOnUse">
1611
- <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1612
- </linearGradient>
1613
- <linearGradient id="pl2" x1="157.8" x2="157.8" y1="81.75" y2="127" gradientUnits="userSpaceOnUse">
1614
- <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1615
- </linearGradient>
1616
- <linearGradient id="pl3" x1="79.96" x2="79.96" y1="81.75" y2="141.8" gradientUnits="userSpaceOnUse">
1617
- <stop stop-color="#663BA6"/><stop stop-color="#7939C2" offset=".349"/><stop stop-color="#8A2FC0" offset=".6615"/><stop stop-color="#791BA3" offset="1"/>
1618
- </linearGradient>
1619
- <linearGradient id="pl4" x1="36.18" x2="212.5" y1="156.6" y2="156.6" gradientUnits="userSpaceOnUse">
1620
- <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1621
- </linearGradient>
1622
- <linearGradient id="pl5" x1="40.27" x2="208.2" y1="156.4" y2="156.4" gradientUnits="userSpaceOnUse">
1623
- <stop stop-color="#316FB0"/><stop stop-color="#1FCDE6" offset=".5"/><stop stop-color="#29FEE7" offset="1"/>
1624
- </linearGradient>
1625
- </defs>
1626
- </svg>`;
1744
+ const LOGO_SVG = buildLogoSVG({
1745
+ idPrefix: 'pl',
1746
+ svgClass: 'ge-logo-svg',
1747
+ clipRectClass: 'ge-clip-rect',
1748
+ textClass: 'ge-preloader-svg-text',
1749
+ });
1627
1750
  /**
1628
1751
  * Creates a lightweight CSS-only preloader that appears instantly,
1629
1752
  * BEFORE PixiJS/WebGL is initialized.
@@ -1727,6 +1850,96 @@ function removeCSSPreloader(container) {
1727
1850
  });
1728
1851
  }
1729
1852
 
1853
+ /**
1854
+ * FPS overlay for debugging performance.
1855
+ *
1856
+ * Shows FPS, frame time, and draw call count in the corner of the screen.
1857
+ *
1858
+ * @example
1859
+ * ```ts
1860
+ * const fps = new FPSOverlay(app);
1861
+ * fps.show();
1862
+ * ```
1863
+ */
1864
+ class FPSOverlay {
1865
+ _app;
1866
+ _container;
1867
+ _fpsText;
1868
+ _visible = false;
1869
+ _samples = [];
1870
+ _maxSamples = 60;
1871
+ _lastUpdate = 0;
1872
+ _tickFn = null;
1873
+ constructor(app) {
1874
+ this._app = app;
1875
+ this._container = new Container();
1876
+ this._container.label = 'FPSOverlay';
1877
+ this._container.zIndex = 99999;
1878
+ this._fpsText = new Text({
1879
+ text: 'FPS: --',
1880
+ style: {
1881
+ fontFamily: 'monospace',
1882
+ fontSize: 14,
1883
+ fill: 0x00ff00,
1884
+ stroke: { color: 0x000000, width: 2 },
1885
+ },
1886
+ });
1887
+ this._fpsText.x = 8;
1888
+ this._fpsText.y = 8;
1889
+ this._container.addChild(this._fpsText);
1890
+ }
1891
+ /** Show the FPS overlay */
1892
+ show() {
1893
+ if (this._visible)
1894
+ return;
1895
+ this._visible = true;
1896
+ this._app.stage.addChild(this._container);
1897
+ this._tickFn = (ticker) => {
1898
+ this._samples.push(ticker.FPS);
1899
+ if (this._samples.length > this._maxSamples) {
1900
+ this._samples.shift();
1901
+ }
1902
+ // Update display every ~500ms
1903
+ const now = Date.now();
1904
+ if (now - this._lastUpdate > 500) {
1905
+ const avg = this._samples.reduce((a, b) => a + b, 0) / this._samples.length;
1906
+ const min = Math.min(...this._samples);
1907
+ this._fpsText.text = [
1908
+ `FPS: ${Math.round(avg)} (min: ${Math.round(min)})`,
1909
+ `Frame: ${ticker.deltaMS.toFixed(1)}ms`,
1910
+ ].join('\n');
1911
+ this._lastUpdate = now;
1912
+ }
1913
+ };
1914
+ this._app.ticker.add(this._tickFn);
1915
+ }
1916
+ /** Hide the FPS overlay */
1917
+ hide() {
1918
+ if (!this._visible)
1919
+ return;
1920
+ this._visible = false;
1921
+ this._container.removeFromParent();
1922
+ if (this._tickFn) {
1923
+ this._app.ticker.remove(this._tickFn);
1924
+ this._tickFn = null;
1925
+ }
1926
+ }
1927
+ /** Toggle visibility */
1928
+ toggle() {
1929
+ if (this._visible) {
1930
+ this.hide();
1931
+ }
1932
+ else {
1933
+ this.show();
1934
+ }
1935
+ }
1936
+ /** Destroy the overlay */
1937
+ destroy() {
1938
+ this.hide();
1939
+ this._container.destroy({ children: true });
1940
+ }
1941
+ }
1942
+
1730
1943
  /**
1731
1944
  * The main entry point for a game built on @energy8platform/game-engine.
1732
1945
  *
@@ -1774,6 +1987,8 @@ class GameApplication extends EventEmitter {
1774
1987
  viewport;
1775
1988
  /** SDK instance (null in offline mode) */
1776
1989
  sdk = null;
1990
+ /** FPS overlay instance (only when debug: true) */
1991
+ fpsOverlay = null;
1777
1992
  /** Data received from SDK initialization */
1778
1993
  initData = null;
1779
1994
  /** Configuration */
@@ -1841,14 +2056,15 @@ class GameApplication extends EventEmitter {
1841
2056
  this.applySDKConfig();
1842
2057
  // 6. Initialize sub-systems
1843
2058
  this.initSubSystems();
1844
- this.emit('initialized', undefined);
2059
+ this.emit('initialized');
1845
2060
  // 7. Remove CSS preloader, show Canvas loading screen
1846
2061
  removeCSSPreloader(this._container);
1847
2062
  // 8. Load assets with loading screen
1848
2063
  await this.loadAssets(firstScene, sceneData);
2064
+ this.emit('loaded');
1849
2065
  // 9. Start the game loop
1850
2066
  this._running = true;
1851
- this.emit('started', undefined);
2067
+ this.emit('started');
1852
2068
  }
1853
2069
  catch (err) {
1854
2070
  console.error('[GameEngine] Failed to start:', err);
@@ -1870,8 +2086,8 @@ class GameApplication extends EventEmitter {
1870
2086
  this.viewport?.destroy();
1871
2087
  this.sdk?.destroy();
1872
2088
  this.app?.destroy(true, { children: true, texture: true });
2089
+ this.emit('destroyed');
1873
2090
  this.removeAllListeners();
1874
- this.emit('destroyed', undefined);
1875
2091
  }
1876
2092
  // ─── Private initialization steps ──────────────────────
1877
2093
  resolveContainer() {
@@ -1944,14 +2160,19 @@ class GameApplication extends EventEmitter {
1944
2160
  });
1945
2161
  // Wire SceneManager to the PixiJS stage
1946
2162
  this.scenes.setRoot(this.app.stage);
1947
- // Wire viewport resize → scene manager
1948
- this.viewport.on('resize', ({ width, height }) => {
2163
+ // Wire viewport resize → scene manager + input manager
2164
+ this.viewport.on('resize', ({ width, height, scale }) => {
1949
2165
  this.scenes.resize(width, height);
2166
+ this.input.setViewportTransform(scale, this.app.stage.x, this.app.stage.y);
1950
2167
  this.emit('resize', { width, height });
1951
2168
  });
1952
2169
  this.viewport.on('orientationChange', (orientation) => {
1953
2170
  this.emit('orientationChange', orientation);
1954
2171
  });
2172
+ // Wire scene changes → engine event
2173
+ this.scenes.on('change', ({ from, to }) => {
2174
+ this.emit('sceneChange', { from, to });
2175
+ });
1955
2176
  // Connect ticker → scene updates
1956
2177
  this.app.ticker.add((ticker) => {
1957
2178
  // Always update scenes (loading screen needs onUpdate before _running=true)
@@ -1959,6 +2180,11 @@ class GameApplication extends EventEmitter {
1959
2180
  });
1960
2181
  // Trigger initial resize
1961
2182
  this.viewport.refresh();
2183
+ // Enable FPS overlay in debug mode
2184
+ if (this.config.debug) {
2185
+ this.fpsOverlay = new FPSOverlay(this.app);
2186
+ this.fpsOverlay.show();
2187
+ }
1962
2188
  }
1963
2189
  async loadAssets(firstScene, sceneData) {
1964
2190
  // Register built-in loading scene