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