@yagejs/core 0.1.0 → 0.2.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.
package/dist/index.cjs CHANGED
@@ -85,6 +85,7 @@ __export(index_exports, {
85
85
  Inspector: () => Inspector,
86
86
  InspectorKey: () => InspectorKey,
87
87
  KeyframeAnimator: () => KeyframeAnimator,
88
+ LoadingScene: () => LoadingScene,
88
89
  LogLevel: () => LogLevel,
89
90
  Logger: () => Logger,
90
91
  LoggerKey: () => LoggerKey,
@@ -100,6 +101,8 @@ __export(index_exports, {
100
101
  QueryResult: () => QueryResult,
101
102
  SERIALIZABLE_KEY: () => SERIALIZABLE_KEY,
102
103
  Scene: () => Scene,
104
+ SceneHookRegistry: () => SceneHookRegistry,
105
+ SceneHookRegistryKey: () => SceneHookRegistryKey,
103
106
  SceneManager: () => SceneManager,
104
107
  SceneManagerKey: () => SceneManagerKey,
105
108
  Sequence: () => Sequence,
@@ -132,6 +135,7 @@ __export(index_exports, {
132
135
  getSerializableType: () => getSerializableType,
133
136
  interpolate: () => interpolate,
134
137
  isSerializable: () => isSerializable,
138
+ resolveTransition: () => resolveTransition,
135
139
  serializable: () => serializable,
136
140
  trait: () => trait
137
141
  });
@@ -458,13 +462,16 @@ var Logger = class {
458
462
 
459
463
  // src/EngineContext.ts
460
464
  var ServiceKey = class {
461
- constructor(id) {
465
+ constructor(id, options) {
462
466
  this.id = id;
467
+ this.scope = options?.scope ?? "engine";
463
468
  }
464
469
  id;
465
470
  static {
466
471
  __name(this, "ServiceKey");
467
472
  }
473
+ /** Declared scope (engine or scene). Defaults to `"engine"`. */
474
+ scope;
468
475
  };
469
476
  var EngineContext = class {
470
477
  static {
@@ -510,6 +517,50 @@ var SystemSchedulerKey = new ServiceKey("systemScheduler");
510
517
  var ProcessSystemKey = new ServiceKey("processSystem");
511
518
  var AssetManagerKey = new ServiceKey("assetManager");
512
519
 
520
+ // src/SceneHooks.ts
521
+ var SceneHookRegistry = class {
522
+ static {
523
+ __name(this, "SceneHookRegistry");
524
+ }
525
+ hooks = [];
526
+ register(hooks) {
527
+ this.hooks.push(hooks);
528
+ return () => {
529
+ const idx = this.hooks.indexOf(hooks);
530
+ if (idx !== -1) this.hooks.splice(idx, 1);
531
+ };
532
+ }
533
+ /** Run all `beforeEnter` hooks serially. */
534
+ async runBeforeEnter(scene) {
535
+ for (const h of this.hooks) {
536
+ await h.beforeEnter?.(scene);
537
+ }
538
+ }
539
+ runAfterExit(scene) {
540
+ for (const h of this.hooks) {
541
+ try {
542
+ h.afterExit?.(scene);
543
+ } catch (err) {
544
+ const logger = scene.context.tryResolve(LoggerKey);
545
+ if (logger) {
546
+ logger.error("core", "Scene afterExit hook threw", {
547
+ scene: scene.name,
548
+ error: err
549
+ });
550
+ } else {
551
+ console.error(
552
+ `[yage] Scene afterExit hook threw for scene "${scene.name}":`,
553
+ err
554
+ );
555
+ }
556
+ }
557
+ }
558
+ }
559
+ };
560
+ var SceneHookRegistryKey = new ServiceKey(
561
+ "sceneHookRegistry"
562
+ );
563
+
513
564
  // src/EventToken.ts
514
565
  var EventToken = class {
515
566
  constructor(name) {
@@ -731,10 +782,11 @@ var Component = class {
731
782
  _cleanups;
732
783
  /**
733
784
  * Access the entity's scene. Throws if the entity is not in a scene.
734
- * Prefer this over `this.entity.scene!` in component methods.
785
+ * Prefer this over threading through `this.entity.scene` in component
786
+ * code.
735
787
  */
736
788
  get scene() {
737
- const scene = this.entity.scene;
789
+ const scene = this.entity.tryScene;
738
790
  if (!scene) {
739
791
  throw new Error(
740
792
  "Cannot access scene: entity is not attached to a scene."
@@ -749,20 +801,43 @@ var Component = class {
749
801
  get context() {
750
802
  return this.scene.context;
751
803
  }
752
- /** Resolve a service by key, cached after first lookup. */
804
+ /**
805
+ * Resolve a service by key, cached after first lookup. Scene-scoped values
806
+ * (registered via `scene._registerScoped`) take precedence over engine
807
+ * scope. A key declared with `scope: "scene"` that falls back to engine
808
+ * scope emits a one-shot dev warning — almost always signals a missed
809
+ * `beforeEnter` hook.
810
+ */
753
811
  use(key) {
754
812
  this._serviceCache ??= /* @__PURE__ */ new Map();
755
- let value = this._serviceCache.get(key.id);
756
- if (value === void 0) {
757
- value = this.context.resolve(key);
758
- this._serviceCache.set(key.id, value);
759
- }
813
+ const cached = this._serviceCache.get(key.id);
814
+ if (cached !== void 0) return cached;
815
+ const scene = this.entity.tryScene;
816
+ const scoped = scene?._resolveScoped(key);
817
+ if (scoped !== void 0) {
818
+ this._serviceCache.set(key.id, scoped);
819
+ return scoped;
820
+ }
821
+ const value = this.context.resolve(key);
822
+ if (key.scope === "scene") {
823
+ this._warnScopedFallback(key);
824
+ return value;
825
+ }
826
+ this._serviceCache.set(key.id, value);
760
827
  return value;
761
828
  }
829
+ _warnScopedFallback(key) {
830
+ const logger = this.context.tryResolve(LoggerKey);
831
+ logger?.warn(
832
+ "core",
833
+ `Scoped key "${key.id}" fell back to engine scope \u2014 did a plugin forget to register a beforeEnter hook?`,
834
+ { component: this.constructor.name }
835
+ );
836
+ }
762
837
  /**
763
838
  * Lazy proxy-based service resolution. Can be used at field-declaration time:
764
839
  * ```ts
765
- * readonly camera = this.service(CameraKey);
840
+ * readonly input = this.service(InputManagerKey);
766
841
  * ```
767
842
  * The actual resolution is deferred until first property access.
768
843
  */
@@ -1019,8 +1094,27 @@ var Entity = class {
1019
1094
  this.name = name ?? new.target.name ?? "Entity";
1020
1095
  this.tags = new Set(tags);
1021
1096
  }
1022
- /** The scene this entity belongs to, or null. */
1097
+ /**
1098
+ * The scene this entity belongs to. Throws if the entity is not attached
1099
+ * to a scene — which in practice only happens before `scene.spawn` /
1100
+ * `addChild` wires it up, or after `destroy()` tears it down. Inside
1101
+ * lifecycle methods (`setup`, component `onAdd`, `update`, etc.) this is
1102
+ * always safe to access.
1103
+ *
1104
+ * For the rare case where you genuinely need to inspect whether an
1105
+ * entity has a scene (e.g. defensive code in systems iterating a query
1106
+ * result), use `tryScene` instead.
1107
+ */
1023
1108
  get scene() {
1109
+ if (!this._scene) {
1110
+ throw new Error(
1111
+ `Entity "${this.name}" is not attached to a scene. Use \`tryScene\` if you need to check.`
1112
+ );
1113
+ }
1114
+ return this._scene;
1115
+ }
1116
+ /** The scene this entity belongs to, or `null` if detached. */
1117
+ get tryScene() {
1024
1118
  return this._scene;
1025
1119
  }
1026
1120
  /** True if destroy() has been called. */
@@ -1058,6 +1152,20 @@ var Entity = class {
1058
1152
  this._scene._addExistingEntity(child);
1059
1153
  }
1060
1154
  }
1155
+ spawnChild(name, classOrBlueprint, params) {
1156
+ const scene = this.scene;
1157
+ if (this._children?.has(name)) {
1158
+ throw new Error(
1159
+ `Entity "${this.name}" already has a child named "${name}".`
1160
+ );
1161
+ }
1162
+ const child = classOrBlueprint === void 0 ? scene.spawn(name) : scene.spawn(
1163
+ classOrBlueprint,
1164
+ params
1165
+ );
1166
+ this.addChild(name, child);
1167
+ return child;
1168
+ }
1061
1169
  /** Remove a named child. Returns the detached entity. */
1062
1170
  removeChild(name) {
1063
1171
  const child = this._children?.get(name);
@@ -1550,7 +1658,7 @@ var GameLoop = class {
1550
1658
  }
1551
1659
  /** Process one frame with the given dt in milliseconds. */
1552
1660
  tick(dtMs) {
1553
- if (!this.callbacks) return;
1661
+ if (!this.running || !this.callbacks) return;
1554
1662
  this._frameCount++;
1555
1663
  this.callbacks.earlyUpdate(dtMs);
1556
1664
  this.accumulator += dtMs;
@@ -1578,6 +1686,8 @@ var Scene = class {
1578
1686
  transparentBelow = false;
1579
1687
  /** Asset handles to load before onEnter(). Override in subclasses. */
1580
1688
  preload;
1689
+ /** Default transition used when this scene is the destination of a push/pop/replace. */
1690
+ defaultTransition;
1581
1691
  /** Manual pause flag. Set by game code to pause this scene regardless of stack position. */
1582
1692
  paused = false;
1583
1693
  /** Time scale multiplier for this scene. 1.0 = normal, 0.5 = half speed. Default: 1. */
@@ -1589,6 +1699,7 @@ var Scene = class {
1589
1699
  queryCache;
1590
1700
  bus;
1591
1701
  _entityEventHandlers;
1702
+ _scopedServices;
1592
1703
  /** Access the EngineContext. */
1593
1704
  get context() {
1594
1705
  return this._context;
@@ -1606,6 +1717,11 @@ var Scene = class {
1606
1717
  }
1607
1718
  return false;
1608
1719
  }
1720
+ /** Whether a scene transition is currently running. */
1721
+ get isTransitioning() {
1722
+ const sm = this._context?.tryResolve(SceneManagerKey);
1723
+ return sm?.isTransitioning ?? false;
1724
+ }
1609
1725
  /** Convenience accessor for the AssetManager. */
1610
1726
  get assets() {
1611
1727
  return this._context.resolve(AssetManagerKey);
@@ -1730,6 +1846,31 @@ var Scene = class {
1730
1846
  }
1731
1847
  }
1732
1848
  // ---- Internal methods ----
1849
+ /**
1850
+ * Register a scene-scoped service. Called from a plugin's `beforeEnter`
1851
+ * hook to make per-scene state (render tree, physics world) resolvable via
1852
+ * `Component.use(key)`.
1853
+ * @internal
1854
+ */
1855
+ _registerScoped(key, value) {
1856
+ this._scopedServices ??= /* @__PURE__ */ new Map();
1857
+ this._scopedServices.set(key.id, value);
1858
+ }
1859
+ /**
1860
+ * Resolve a scene-scoped service, or `undefined` if none was registered.
1861
+ * @internal
1862
+ */
1863
+ _resolveScoped(key) {
1864
+ return this._scopedServices?.get(key.id);
1865
+ }
1866
+ /**
1867
+ * Clear all scene-scoped services. Called by the SceneManager after
1868
+ * `afterExit` hooks run, so plugin cleanup code still sees scoped state.
1869
+ * @internal
1870
+ */
1871
+ _clearScopedServices() {
1872
+ this._scopedServices?.clear();
1873
+ }
1733
1874
  /**
1734
1875
  * Set the engine context. Called by SceneManager when the scene is pushed.
1735
1876
  * @internal
@@ -1781,6 +1922,138 @@ var Scene = class {
1781
1922
  }
1782
1923
  };
1783
1924
 
1925
+ // src/LoadingScene.ts
1926
+ var LoadingScene = class extends Scene {
1927
+ static {
1928
+ __name(this, "LoadingScene");
1929
+ }
1930
+ name = "loading";
1931
+ /**
1932
+ * Minimum wall-clock ms the scene stays visible before handing off.
1933
+ * Prevents flicker on cached loads. Default 0.
1934
+ */
1935
+ minDuration = 0;
1936
+ /** Transition used for the loading → target handoff. */
1937
+ transition;
1938
+ /**
1939
+ * When true (default), the handoff fires automatically after loading and
1940
+ * `minDuration`. Set false to gate it behind `continue()` — useful when
1941
+ * the loading scene also asks the player to press a key or click.
1942
+ */
1943
+ autoContinue = true;
1944
+ _progress = 0;
1945
+ _started = false;
1946
+ _active = true;
1947
+ _continueRequested = false;
1948
+ _continueGate;
1949
+ // Bumped on every `_run` attempt. `AssetManager.loadAll` uses `Promise.all`
1950
+ // under the hood, so individual loaders from a failed attempt can still
1951
+ // resolve and fire `onProgress` after the attempt rejects. Without this
1952
+ // guard, a retry kicked off from `onLoadError` would see stale progress
1953
+ // callbacks mutate `_progress` and emit `scene:loading:progress` events
1954
+ // attributed to the current attempt.
1955
+ _attempt = 0;
1956
+ /** Current load progress, 0 → 1. Updated as the AssetManager reports progress. */
1957
+ get progress() {
1958
+ return this._progress;
1959
+ }
1960
+ /**
1961
+ * Kick off asset loading. While a load is in flight, subsequent calls
1962
+ * are no-ops. After a load failure the guard is released, so calling
1963
+ * `startLoading()` from `onLoadError` (or from a retry button) kicks off
1964
+ * a fresh load against the same target.
1965
+ *
1966
+ * Usually called once from `onEnter` after spawning the loading UI:
1967
+ * ```ts
1968
+ * override onEnter() {
1969
+ * this.spawn(LoadingSceneProgressBar);
1970
+ * this.startLoading();
1971
+ * }
1972
+ * ```
1973
+ *
1974
+ * Deferring the call lets you gate the start of the load behind a
1975
+ * title screen, "press any key" prompt, intro animation, etc.
1976
+ */
1977
+ startLoading() {
1978
+ if (this._started) return;
1979
+ this._started = true;
1980
+ this._run().catch((err) => {
1981
+ if (!this._active) return;
1982
+ const logger = this.context.tryResolve(LoggerKey);
1983
+ if (logger) {
1984
+ logger.error("LoadingScene", "loading failed", { error: err });
1985
+ } else {
1986
+ console.error("[LoadingScene] loading failed:", err);
1987
+ }
1988
+ });
1989
+ }
1990
+ /**
1991
+ * Trigger the handoff to `target`. No-op if already called or if
1992
+ * `autoContinue` already fired it. If called before loading finishes,
1993
+ * the handoff runs as soon as loading + `minDuration` complete.
1994
+ */
1995
+ continue() {
1996
+ if (this._continueRequested) return;
1997
+ this._continueRequested = true;
1998
+ this._continueGate?.();
1999
+ }
2000
+ onExit() {
2001
+ this._active = false;
2002
+ this._continueGate?.();
2003
+ }
2004
+ async _run() {
2005
+ await new Promise((resolve) => setTimeout(resolve, 0));
2006
+ if (!this._active) return;
2007
+ const attempt = ++this._attempt;
2008
+ const target = typeof this.target === "function" ? this.target() : this.target;
2009
+ const startedAt = performance.now();
2010
+ const bus = this.context.resolve(EventBusKey);
2011
+ try {
2012
+ await this.assets.loadAll(target.preload ?? [], (ratio) => {
2013
+ if (!this._active || attempt !== this._attempt) return;
2014
+ this._progress = ratio;
2015
+ bus.emit("scene:loading:progress", { scene: this, ratio });
2016
+ });
2017
+ if (!this._active || attempt !== this._attempt) return;
2018
+ const elapsed = performance.now() - startedAt;
2019
+ const remaining = this.minDuration - elapsed;
2020
+ if (remaining > 0) {
2021
+ await new Promise((resolve) => setTimeout(resolve, remaining));
2022
+ if (!this._active || attempt !== this._attempt) return;
2023
+ }
2024
+ } catch (err) {
2025
+ if (!this._active || attempt !== this._attempt) return;
2026
+ const error = err instanceof Error ? err : new Error(String(err));
2027
+ this._started = false;
2028
+ this._attempt++;
2029
+ if (this.onLoadError) {
2030
+ await this.onLoadError(error);
2031
+ return;
2032
+ }
2033
+ throw error;
2034
+ }
2035
+ bus.emit("scene:loading:done", { scene: this });
2036
+ if (!this.autoContinue && !this._continueRequested) {
2037
+ await new Promise((resolve) => {
2038
+ this._continueGate = resolve;
2039
+ });
2040
+ if (!this._active || attempt !== this._attempt) return;
2041
+ }
2042
+ const scenes = this.context.resolve(SceneManagerKey);
2043
+ await scenes.replace(
2044
+ target,
2045
+ this.transition ? { transition: this.transition } : void 0
2046
+ );
2047
+ }
2048
+ };
2049
+
2050
+ // src/SceneTransition.ts
2051
+ function resolveTransition(callSite, destination) {
2052
+ if (callSite) return callSite;
2053
+ return destination?.defaultTransition;
2054
+ }
2055
+ __name(resolveTransition, "resolveTransition");
2056
+
1784
2057
  // src/SceneManager.ts
1785
2058
  var SceneManager = class {
1786
2059
  static {
@@ -1790,6 +2063,12 @@ var SceneManager = class {
1790
2063
  _context;
1791
2064
  bus;
1792
2065
  assetManager;
2066
+ hookRegistry;
2067
+ logger;
2068
+ _currentRun;
2069
+ _pendingChain = Promise.resolve();
2070
+ _mutationDepth = 0;
2071
+ _destroyed = false;
1793
2072
  /**
1794
2073
  * Set the engine context.
1795
2074
  * @internal
@@ -1798,6 +2077,8 @@ var SceneManager = class {
1798
2077
  this._context = context;
1799
2078
  this.bus = context.tryResolve(EventBusKey);
1800
2079
  this.assetManager = context.tryResolve(AssetManagerKey);
2080
+ this.hookRegistry = context.tryResolve(SceneHookRegistryKey);
2081
+ this.logger = context.tryResolve(LoggerKey);
1801
2082
  }
1802
2083
  /** The topmost (active) scene. */
1803
2084
  get active() {
@@ -1809,84 +2090,129 @@ var SceneManager = class {
1809
2090
  }
1810
2091
  /** All non-paused scenes in the stack, bottom to top. */
1811
2092
  get activeScenes() {
1812
- return this.stack.filter((s) => !s.isPaused);
2093
+ return this.stack.filter((scene) => !scene.isPaused);
2094
+ }
2095
+ /** Whether a scene transition is currently running. */
2096
+ get isTransitioning() {
2097
+ return this._currentRun !== void 0;
1813
2098
  }
1814
2099
  /**
1815
2100
  * Push a scene onto the stack. Scenes below may receive onPause().
1816
2101
  * If the scene declares a `preload` array, assets are loaded before onEnter().
1817
- * Await the returned promise when using preloaded scenes.
1818
2102
  */
1819
- push(scene) {
1820
- const wasPaused = new Map(this.stack.map((s) => [s, s.isPaused]));
1821
- scene._setContext(this._context);
1822
- this.stack.push(scene);
1823
- this._firePauseTransitions(wasPaused);
1824
- if (scene.preload?.length && this.assetManager) {
1825
- return this.assetManager.loadAll(scene.preload, scene.onProgress?.bind(scene)).then(() => {
1826
- scene.onEnter?.();
1827
- this.bus?.emit("scene:pushed", { scene });
1828
- });
1829
- }
1830
- scene.onEnter?.();
1831
- this.bus?.emit("scene:pushed", { scene });
1832
- return Promise.resolve();
2103
+ async push(scene, opts) {
2104
+ this._assertNotMutating("push");
2105
+ await this._enqueue(async () => {
2106
+ const fromScene = this.active;
2107
+ await this._pushScene(scene);
2108
+ const transition = resolveTransition(opts?.transition, scene);
2109
+ if (!transition) return;
2110
+ await this._runTransition("push", transition, fromScene, scene);
2111
+ });
1833
2112
  }
1834
2113
  /** Pop the top scene. Scenes below may receive onResume(). */
1835
- pop() {
1836
- const wasPaused = new Map(this.stack.map((s) => [s, s.isPaused]));
1837
- const removed = this.stack.pop();
1838
- if (!removed) return void 0;
1839
- removed.onExit?.();
1840
- removed._destroyAllEntities();
1841
- this._fireResumeTransitions(wasPaused);
1842
- this.bus?.emit("scene:popped", { scene: removed });
1843
- return removed;
2114
+ async pop(opts) {
2115
+ this._assertNotMutating("pop");
2116
+ return this._enqueue(async () => {
2117
+ if (this.stack.length === 0) return void 0;
2118
+ const fromScene = this.active;
2119
+ const destination = this.stack.length > 1 ? this.stack[this.stack.length - 2] : void 0;
2120
+ const transition = resolveTransition(opts?.transition, destination);
2121
+ if (transition) {
2122
+ await this._runTransition("pop", transition, fromScene, destination);
2123
+ }
2124
+ return this._popScene();
2125
+ });
1844
2126
  }
1845
2127
  /**
1846
- * Replace the top scene. Old scene receives onExit().
1847
- * New scene receives onEnter() (after preload, if declared).
2128
+ * Replace the top scene. Without a transition the old scene exits first,
2129
+ * then the new scene enters. With a transition the new scene is pushed
2130
+ * first, both scenes coexist for the transition duration, then the old
2131
+ * scene is removed at the end.
1848
2132
  */
1849
- replace(scene) {
1850
- const wasPaused = new Map(this.stack.map((s) => [s, s.isPaused]));
1851
- const old = this.stack.pop();
1852
- if (old) {
1853
- old.onExit?.();
1854
- old._destroyAllEntities();
1855
- }
1856
- scene._setContext(this._context);
1857
- this.stack.push(scene);
1858
- this._firePauseTransitions(wasPaused);
1859
- this._fireResumeTransitions(wasPaused);
1860
- if (scene.preload?.length && this.assetManager) {
1861
- return this.assetManager.loadAll(scene.preload, scene.onProgress?.bind(scene)).then(() => {
1862
- scene.onEnter?.();
1863
- if (old) {
1864
- this.bus?.emit("scene:replaced", {
1865
- oldScene: old,
1866
- newScene: scene
1867
- });
1868
- } else {
1869
- this.bus?.emit("scene:pushed", { scene });
2133
+ async replace(scene, opts) {
2134
+ this._assertNotMutating("replace");
2135
+ await this._enqueue(async () => {
2136
+ const transition = resolveTransition(opts?.transition, scene);
2137
+ if (!transition) {
2138
+ await this._replaceScene(scene);
2139
+ return;
2140
+ }
2141
+ const old = this.active;
2142
+ await this._pushScene(scene, true);
2143
+ await this._runTransition("replace", transition, old, scene);
2144
+ if (old) {
2145
+ this._removeScene(old, true);
2146
+ }
2147
+ this.bus?.emit("scene:replaced", {
2148
+ oldScene: old ?? scene,
2149
+ newScene: scene
2150
+ });
2151
+ });
2152
+ }
2153
+ /**
2154
+ * Pop every scene on the stack, top to bottom. Each receives onExit().
2155
+ * Queued like push/pop/replace — runs after any in-flight transition.
2156
+ * Use for "restart from menu"-style flows. Does not run transitions.
2157
+ */
2158
+ async popAll() {
2159
+ this._assertNotMutating("popAll");
2160
+ await this._enqueue(async () => {
2161
+ this._withMutationSync(() => {
2162
+ while (this.stack.length > 0) {
2163
+ const scene = this.stack.pop();
2164
+ if (!scene) break;
2165
+ this._teardownScene(scene);
2166
+ this.bus?.emit("scene:popped", { scene });
1870
2167
  }
1871
2168
  });
1872
- }
1873
- scene.onEnter?.();
1874
- if (old) {
1875
- this.bus?.emit("scene:replaced", { oldScene: old, newScene: scene });
1876
- } else {
1877
- this.bus?.emit("scene:pushed", { scene });
1878
- }
1879
- return Promise.resolve();
2169
+ });
1880
2170
  }
1881
- /** Clear all scenes. Each receives onExit() from top to bottom. */
1882
- clear() {
1883
- while (this.stack.length > 0) {
1884
- const scene = this.stack.pop();
1885
- if (!scene) break;
1886
- scene.onExit?.();
1887
- scene._destroyAllEntities();
1888
- this.bus?.emit("scene:popped", { scene });
1889
- }
2171
+ /**
2172
+ * Run the full scene-enter lifecycle (beforeEnter hooks, preload, onEnter)
2173
+ * for a scene that is NOT placed on the stack. Used by infrastructure
2174
+ * plugins like DebugPlugin that render a scene off-stack.
2175
+ * @internal
2176
+ */
2177
+ async _mountDetached(scene) {
2178
+ await this._withMutation(async () => {
2179
+ scene._setContext(this._context);
2180
+ await this.hookRegistry?.runBeforeEnter(scene);
2181
+ await this._preloadScene(scene);
2182
+ scene.onEnter?.();
2183
+ });
2184
+ }
2185
+ /**
2186
+ * Run the scene-exit lifecycle (onExit, entity destruction, afterExit
2187
+ * hooks, scoped-service clear) for a detached scene.
2188
+ * @internal
2189
+ */
2190
+ _unmountDetached(scene) {
2191
+ this._withMutationSync(() => {
2192
+ this._teardownScene(scene);
2193
+ });
2194
+ }
2195
+ /**
2196
+ * Mark the manager destroyed and synchronously tear down every scene.
2197
+ * Called by Engine.destroy(). Any queued async work short-circuits on
2198
+ * resume; in-flight transitions' pending promises are resolved via
2199
+ * _cleanupRun so they don't leak.
2200
+ * @internal
2201
+ */
2202
+ _destroy() {
2203
+ this._destroyed = true;
2204
+ if (this._currentRun) {
2205
+ this._cleanupRun(this._currentRun);
2206
+ }
2207
+ this._pendingChain = Promise.resolve();
2208
+ this._withMutationSync(() => {
2209
+ while (this.stack.length > 0) {
2210
+ const scene = this.stack.pop();
2211
+ if (!scene) break;
2212
+ this._teardownScene(scene);
2213
+ this.bus?.emit("scene:popped", { scene });
2214
+ }
2215
+ });
1890
2216
  }
1891
2217
  /**
1892
2218
  * Flush destroy queues for all active scenes.
@@ -1898,21 +2224,216 @@ var SceneManager = class {
1898
2224
  scene._flushDestroyQueue();
1899
2225
  }
1900
2226
  }
2227
+ /**
2228
+ * Advance the active transition by `dt` ms. Called by Engine's earlyUpdate
2229
+ * callback with raw (unscaled) wall-clock dt.
2230
+ * @internal
2231
+ */
2232
+ _tickTransition(dt) {
2233
+ const run = this._currentRun;
2234
+ if (!run) return;
2235
+ const remaining = run.transition.duration - run.elapsed;
2236
+ const consume = Math.min(dt, remaining);
2237
+ run.elapsed += consume;
2238
+ this._safeTick(run, consume);
2239
+ if (run.elapsed >= run.transition.duration) {
2240
+ this._cleanupRun(run);
2241
+ }
2242
+ }
2243
+ // ---- Private helpers ----
2244
+ _enqueue(work) {
2245
+ if (this._destroyed) return Promise.resolve(void 0);
2246
+ const next = this._pendingChain.then(async () => {
2247
+ if (this._destroyed) return void 0;
2248
+ return work();
2249
+ });
2250
+ this._pendingChain = next.then(
2251
+ () => void 0,
2252
+ () => void 0
2253
+ );
2254
+ return next;
2255
+ }
2256
+ async _pushScene(scene, suppressEvent = false) {
2257
+ const wasPaused = this._snapshotPauseStates();
2258
+ await this._withMutation(async () => {
2259
+ scene._setContext(this._context);
2260
+ await this.hookRegistry?.runBeforeEnter(scene);
2261
+ await this._preloadScene(scene);
2262
+ this.stack.push(scene);
2263
+ scene.onEnter?.();
2264
+ this._firePauseTransitions(wasPaused);
2265
+ if (!suppressEvent) {
2266
+ this.bus?.emit("scene:pushed", { scene });
2267
+ }
2268
+ });
2269
+ }
2270
+ _popScene(suppressEvent = false) {
2271
+ const wasPaused = this._snapshotPauseStates();
2272
+ return this._withMutationSync(() => {
2273
+ const removed = this.stack.pop();
2274
+ if (!removed) return void 0;
2275
+ this._teardownScene(removed);
2276
+ this._fireResumeTransitions(wasPaused);
2277
+ if (!suppressEvent) {
2278
+ this.bus?.emit("scene:popped", { scene: removed });
2279
+ }
2280
+ return removed;
2281
+ });
2282
+ }
2283
+ async _replaceScene(scene) {
2284
+ const wasPaused = this._snapshotPauseStates();
2285
+ await this._withMutation(async () => {
2286
+ scene._setContext(this._context);
2287
+ await this.hookRegistry?.runBeforeEnter(scene);
2288
+ await this._preloadScene(scene);
2289
+ const old = this.stack.pop();
2290
+ if (old) this._teardownScene(old);
2291
+ this.stack.push(scene);
2292
+ scene.onEnter?.();
2293
+ this._firePauseTransitions(wasPaused);
2294
+ this._fireResumeTransitions(wasPaused);
2295
+ this.bus?.emit("scene:replaced", {
2296
+ oldScene: old ?? scene,
2297
+ newScene: scene
2298
+ });
2299
+ });
2300
+ }
2301
+ _removeScene(scene, suppressEvent = false) {
2302
+ this._withMutationSync(() => {
2303
+ const idx = this.stack.indexOf(scene);
2304
+ if (idx === -1) return;
2305
+ const wasPaused = this._snapshotPauseStates();
2306
+ this.stack.splice(idx, 1);
2307
+ this._teardownScene(scene);
2308
+ this._firePauseTransitions(wasPaused);
2309
+ this._fireResumeTransitions(wasPaused);
2310
+ if (!suppressEvent) {
2311
+ this.bus?.emit("scene:popped", { scene });
2312
+ }
2313
+ });
2314
+ }
2315
+ async _preloadScene(scene) {
2316
+ if (!scene.preload?.length || !this.assetManager) return;
2317
+ await this.assetManager.loadAll(
2318
+ scene.preload,
2319
+ scene.onProgress?.bind(scene)
2320
+ );
2321
+ }
2322
+ _teardownScene(scene) {
2323
+ scene.onExit?.();
2324
+ scene._destroyAllEntities();
2325
+ this.hookRegistry?.runAfterExit(scene);
2326
+ scene._clearScopedServices();
2327
+ }
2328
+ async _runTransition(kind, transition, fromScene, toScene) {
2329
+ if (this._destroyed) return;
2330
+ let resolveRun;
2331
+ const promise = new Promise((resolve) => {
2332
+ resolveRun = resolve;
2333
+ });
2334
+ const run = {
2335
+ kind,
2336
+ transition,
2337
+ elapsed: 0,
2338
+ fromScene,
2339
+ toScene,
2340
+ resolve: resolveRun
2341
+ };
2342
+ this._currentRun = run;
2343
+ this.bus?.emit("scene:transition:started", {
2344
+ kind,
2345
+ fromScene,
2346
+ toScene
2347
+ });
2348
+ this._safeCall(run, "begin");
2349
+ if (!Number.isFinite(transition.duration) || transition.duration <= 0) {
2350
+ this._cleanupRun(run);
2351
+ return;
2352
+ }
2353
+ await promise;
2354
+ }
2355
+ _cleanupRun(run) {
2356
+ if (this._currentRun !== run) return;
2357
+ this._safeCall(run, "end");
2358
+ this._currentRun = void 0;
2359
+ this.bus?.emit("scene:transition:ended", {
2360
+ kind: run.kind,
2361
+ fromScene: run.fromScene,
2362
+ toScene: run.toScene
2363
+ });
2364
+ run.resolve();
2365
+ }
2366
+ _safeTick(run, dt) {
2367
+ try {
2368
+ run.transition.tick(dt, this._makeContext(run));
2369
+ } catch (err) {
2370
+ this.logger?.warn(
2371
+ "SceneManager",
2372
+ `Transition tick error: ${err instanceof Error ? err.message : String(err)}`
2373
+ );
2374
+ }
2375
+ }
2376
+ _safeCall(run, method) {
2377
+ try {
2378
+ run.transition[method]?.(this._makeContext(run));
2379
+ } catch (err) {
2380
+ this.logger?.warn(
2381
+ "SceneManager",
2382
+ `Transition ${method} error: ${err instanceof Error ? err.message : String(err)}`
2383
+ );
2384
+ }
2385
+ }
2386
+ _makeContext(run) {
2387
+ return {
2388
+ elapsed: run.elapsed,
2389
+ kind: run.kind,
2390
+ engineContext: this._context,
2391
+ fromScene: run.fromScene,
2392
+ toScene: run.toScene
2393
+ };
2394
+ }
2395
+ _snapshotPauseStates() {
2396
+ return new Map(
2397
+ this.stack.map((scene) => [scene, scene.isPaused])
2398
+ );
2399
+ }
2400
+ _assertNotMutating(method) {
2401
+ if (this._mutationDepth === 0) return;
2402
+ throw new Error(
2403
+ `SceneManager.${method}() called reentrantly from a scene lifecycle hook (onEnter/onExit/onPause/onResume or a beforeEnter/afterExit hook). Defer the call outside the hook, e.g. via queueMicrotask() or from a component update().`
2404
+ );
2405
+ }
2406
+ async _withMutation(work) {
2407
+ this._mutationDepth++;
2408
+ try {
2409
+ return await work();
2410
+ } finally {
2411
+ this._mutationDepth--;
2412
+ }
2413
+ }
2414
+ _withMutationSync(work) {
2415
+ this._mutationDepth++;
2416
+ try {
2417
+ return work();
2418
+ } finally {
2419
+ this._mutationDepth--;
2420
+ }
2421
+ }
1901
2422
  /** Fire onPause() for scenes that transitioned from not-paused to paused. */
1902
2423
  _firePauseTransitions(wasPaused) {
1903
- for (const s of this.stack) {
1904
- const was = wasPaused.get(s) ?? false;
1905
- if (s.isPaused && !was) {
1906
- s.onPause?.();
2424
+ for (const scene of this.stack) {
2425
+ const was = wasPaused.get(scene) ?? false;
2426
+ if (scene.isPaused && !was) {
2427
+ scene.onPause?.();
1907
2428
  }
1908
2429
  }
1909
2430
  }
1910
2431
  /** Fire onResume() for scenes that transitioned from paused to not-paused. */
1911
2432
  _fireResumeTransitions(wasPaused) {
1912
- for (const s of this.stack) {
1913
- const was = wasPaused.get(s) ?? false;
1914
- if (!s.isPaused && was) {
1915
- s.onResume?.();
2433
+ for (const scene of this.stack) {
2434
+ const was = wasPaused.get(scene) ?? false;
2435
+ if (!scene.isPaused && was) {
2436
+ scene.onResume?.();
1916
2437
  }
1917
2438
  }
1918
2439
  }
@@ -2749,6 +3270,7 @@ var Engine = class {
2749
3270
  scheduler;
2750
3271
  errorBoundary;
2751
3272
  queryCache;
3273
+ sceneHooks;
2752
3274
  /** The asset manager. */
2753
3275
  assets;
2754
3276
  plugins = /* @__PURE__ */ new Map();
@@ -2767,6 +3289,7 @@ var Engine = class {
2767
3289
  this.scheduler = new SystemScheduler();
2768
3290
  this.inspector = new Inspector(this);
2769
3291
  this.assets = new AssetManager();
3292
+ this.sceneHooks = new SceneHookRegistry();
2770
3293
  this.scheduler.setErrorBoundary(this.errorBoundary);
2771
3294
  this.context.register(EngineKey, this);
2772
3295
  this.context.register(EventBusKey, this.events);
@@ -2778,11 +3301,13 @@ var Engine = class {
2778
3301
  this.context.register(InspectorKey, this.inspector);
2779
3302
  this.context.register(SystemSchedulerKey, this.scheduler);
2780
3303
  this.context.register(AssetManagerKey, this.assets);
3304
+ this.context.register(SceneHookRegistryKey, this.sceneHooks);
2781
3305
  this.scenes._setContext(this.context);
2782
3306
  this.registerBuiltInSystems();
2783
3307
  this.loop.setCallbacks({
2784
3308
  earlyUpdate: /* @__PURE__ */ __name((dt) => {
2785
3309
  this.logger.setFrame(this.loop.frameCount);
3310
+ this.scenes._tickTransition(dt);
2786
3311
  this.scheduler.run("earlyUpdate" /* EarlyUpdate */, dt);
2787
3312
  }, "earlyUpdate"),
2788
3313
  fixedUpdate: /* @__PURE__ */ __name((dt) => this.scheduler.run("fixedUpdate" /* FixedUpdate */, dt), "fixedUpdate"),
@@ -2795,6 +3320,14 @@ var Engine = class {
2795
3320
  }, "endOfFrame")
2796
3321
  });
2797
3322
  }
3323
+ /**
3324
+ * Register scene lifecycle hooks. The returned function unregisters the
3325
+ * hooks. Infrastructure plugins (renderer, physics, debug) register hooks
3326
+ * in their `install` or `onStart` to set up and tear down per-scene state.
3327
+ */
3328
+ registerSceneHooks(hooks) {
3329
+ return this.sceneHooks.register(hooks);
3330
+ }
2798
3331
  /** Register a plugin. Must be called before start(). */
2799
3332
  use(plugin) {
2800
3333
  if (this.started) {
@@ -2830,7 +3363,7 @@ var Engine = class {
2830
3363
  };
2831
3364
  }
2832
3365
  for (const plugin of sorted) {
2833
- plugin.onStart?.();
3366
+ await plugin.onStart?.();
2834
3367
  }
2835
3368
  this.events.emit("engine:started", void 0);
2836
3369
  }
@@ -2838,7 +3371,7 @@ var Engine = class {
2838
3371
  destroy() {
2839
3372
  this.events.emit("engine:stopped", void 0);
2840
3373
  this.loop.stop();
2841
- this.scenes.clear();
3374
+ this.scenes._destroy();
2842
3375
  const allSystems = this.scheduler.getAllSystems();
2843
3376
  for (let i = allSystems.length - 1; i >= 0; i--) {
2844
3377
  allSystems[i].onUnregister?.();
@@ -2982,6 +3515,7 @@ var VERSION = "0.0.0";
2982
3515
  Inspector,
2983
3516
  InspectorKey,
2984
3517
  KeyframeAnimator,
3518
+ LoadingScene,
2985
3519
  LogLevel,
2986
3520
  Logger,
2987
3521
  LoggerKey,
@@ -2997,6 +3531,8 @@ var VERSION = "0.0.0";
2997
3531
  QueryResult,
2998
3532
  SERIALIZABLE_KEY,
2999
3533
  Scene,
3534
+ SceneHookRegistry,
3535
+ SceneHookRegistryKey,
3000
3536
  SceneManager,
3001
3537
  SceneManagerKey,
3002
3538
  Sequence,
@@ -3029,6 +3565,7 @@ var VERSION = "0.0.0";
3029
3565
  getSerializableType,
3030
3566
  interpolate,
3031
3567
  isSerializable,
3568
+ resolveTransition,
3032
3569
  serializable,
3033
3570
  trait
3034
3571
  });