@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.js CHANGED
@@ -367,13 +367,16 @@ var Logger = class {
367
367
 
368
368
  // src/EngineContext.ts
369
369
  var ServiceKey = class {
370
- constructor(id) {
370
+ constructor(id, options) {
371
371
  this.id = id;
372
+ this.scope = options?.scope ?? "engine";
372
373
  }
373
374
  id;
374
375
  static {
375
376
  __name(this, "ServiceKey");
376
377
  }
378
+ /** Declared scope (engine or scene). Defaults to `"engine"`. */
379
+ scope;
377
380
  };
378
381
  var EngineContext = class {
379
382
  static {
@@ -419,6 +422,50 @@ var SystemSchedulerKey = new ServiceKey("systemScheduler");
419
422
  var ProcessSystemKey = new ServiceKey("processSystem");
420
423
  var AssetManagerKey = new ServiceKey("assetManager");
421
424
 
425
+ // src/SceneHooks.ts
426
+ var SceneHookRegistry = class {
427
+ static {
428
+ __name(this, "SceneHookRegistry");
429
+ }
430
+ hooks = [];
431
+ register(hooks) {
432
+ this.hooks.push(hooks);
433
+ return () => {
434
+ const idx = this.hooks.indexOf(hooks);
435
+ if (idx !== -1) this.hooks.splice(idx, 1);
436
+ };
437
+ }
438
+ /** Run all `beforeEnter` hooks serially. */
439
+ async runBeforeEnter(scene) {
440
+ for (const h of this.hooks) {
441
+ await h.beforeEnter?.(scene);
442
+ }
443
+ }
444
+ runAfterExit(scene) {
445
+ for (const h of this.hooks) {
446
+ try {
447
+ h.afterExit?.(scene);
448
+ } catch (err) {
449
+ const logger = scene.context.tryResolve(LoggerKey);
450
+ if (logger) {
451
+ logger.error("core", "Scene afterExit hook threw", {
452
+ scene: scene.name,
453
+ error: err
454
+ });
455
+ } else {
456
+ console.error(
457
+ `[yage] Scene afterExit hook threw for scene "${scene.name}":`,
458
+ err
459
+ );
460
+ }
461
+ }
462
+ }
463
+ }
464
+ };
465
+ var SceneHookRegistryKey = new ServiceKey(
466
+ "sceneHookRegistry"
467
+ );
468
+
422
469
  // src/EventToken.ts
423
470
  var EventToken = class {
424
471
  constructor(name) {
@@ -640,10 +687,11 @@ var Component = class {
640
687
  _cleanups;
641
688
  /**
642
689
  * Access the entity's scene. Throws if the entity is not in a scene.
643
- * Prefer this over `this.entity.scene!` in component methods.
690
+ * Prefer this over threading through `this.entity.scene` in component
691
+ * code.
644
692
  */
645
693
  get scene() {
646
- const scene = this.entity.scene;
694
+ const scene = this.entity.tryScene;
647
695
  if (!scene) {
648
696
  throw new Error(
649
697
  "Cannot access scene: entity is not attached to a scene."
@@ -658,20 +706,43 @@ var Component = class {
658
706
  get context() {
659
707
  return this.scene.context;
660
708
  }
661
- /** Resolve a service by key, cached after first lookup. */
709
+ /**
710
+ * Resolve a service by key, cached after first lookup. Scene-scoped values
711
+ * (registered via `scene._registerScoped`) take precedence over engine
712
+ * scope. A key declared with `scope: "scene"` that falls back to engine
713
+ * scope emits a one-shot dev warning — almost always signals a missed
714
+ * `beforeEnter` hook.
715
+ */
662
716
  use(key) {
663
717
  this._serviceCache ??= /* @__PURE__ */ new Map();
664
- let value = this._serviceCache.get(key.id);
665
- if (value === void 0) {
666
- value = this.context.resolve(key);
667
- this._serviceCache.set(key.id, value);
668
- }
718
+ const cached = this._serviceCache.get(key.id);
719
+ if (cached !== void 0) return cached;
720
+ const scene = this.entity.tryScene;
721
+ const scoped = scene?._resolveScoped(key);
722
+ if (scoped !== void 0) {
723
+ this._serviceCache.set(key.id, scoped);
724
+ return scoped;
725
+ }
726
+ const value = this.context.resolve(key);
727
+ if (key.scope === "scene") {
728
+ this._warnScopedFallback(key);
729
+ return value;
730
+ }
731
+ this._serviceCache.set(key.id, value);
669
732
  return value;
670
733
  }
734
+ _warnScopedFallback(key) {
735
+ const logger = this.context.tryResolve(LoggerKey);
736
+ logger?.warn(
737
+ "core",
738
+ `Scoped key "${key.id}" fell back to engine scope \u2014 did a plugin forget to register a beforeEnter hook?`,
739
+ { component: this.constructor.name }
740
+ );
741
+ }
671
742
  /**
672
743
  * Lazy proxy-based service resolution. Can be used at field-declaration time:
673
744
  * ```ts
674
- * readonly camera = this.service(CameraKey);
745
+ * readonly input = this.service(InputManagerKey);
675
746
  * ```
676
747
  * The actual resolution is deferred until first property access.
677
748
  */
@@ -928,8 +999,27 @@ var Entity = class {
928
999
  this.name = name ?? new.target.name ?? "Entity";
929
1000
  this.tags = new Set(tags);
930
1001
  }
931
- /** The scene this entity belongs to, or null. */
1002
+ /**
1003
+ * The scene this entity belongs to. Throws if the entity is not attached
1004
+ * to a scene — which in practice only happens before `scene.spawn` /
1005
+ * `addChild` wires it up, or after `destroy()` tears it down. Inside
1006
+ * lifecycle methods (`setup`, component `onAdd`, `update`, etc.) this is
1007
+ * always safe to access.
1008
+ *
1009
+ * For the rare case where you genuinely need to inspect whether an
1010
+ * entity has a scene (e.g. defensive code in systems iterating a query
1011
+ * result), use `tryScene` instead.
1012
+ */
932
1013
  get scene() {
1014
+ if (!this._scene) {
1015
+ throw new Error(
1016
+ `Entity "${this.name}" is not attached to a scene. Use \`tryScene\` if you need to check.`
1017
+ );
1018
+ }
1019
+ return this._scene;
1020
+ }
1021
+ /** The scene this entity belongs to, or `null` if detached. */
1022
+ get tryScene() {
933
1023
  return this._scene;
934
1024
  }
935
1025
  /** True if destroy() has been called. */
@@ -967,6 +1057,20 @@ var Entity = class {
967
1057
  this._scene._addExistingEntity(child);
968
1058
  }
969
1059
  }
1060
+ spawnChild(name, classOrBlueprint, params) {
1061
+ const scene = this.scene;
1062
+ if (this._children?.has(name)) {
1063
+ throw new Error(
1064
+ `Entity "${this.name}" already has a child named "${name}".`
1065
+ );
1066
+ }
1067
+ const child = classOrBlueprint === void 0 ? scene.spawn(name) : scene.spawn(
1068
+ classOrBlueprint,
1069
+ params
1070
+ );
1071
+ this.addChild(name, child);
1072
+ return child;
1073
+ }
970
1074
  /** Remove a named child. Returns the detached entity. */
971
1075
  removeChild(name) {
972
1076
  const child = this._children?.get(name);
@@ -1459,7 +1563,7 @@ var GameLoop = class {
1459
1563
  }
1460
1564
  /** Process one frame with the given dt in milliseconds. */
1461
1565
  tick(dtMs) {
1462
- if (!this.callbacks) return;
1566
+ if (!this.running || !this.callbacks) return;
1463
1567
  this._frameCount++;
1464
1568
  this.callbacks.earlyUpdate(dtMs);
1465
1569
  this.accumulator += dtMs;
@@ -1487,6 +1591,8 @@ var Scene = class {
1487
1591
  transparentBelow = false;
1488
1592
  /** Asset handles to load before onEnter(). Override in subclasses. */
1489
1593
  preload;
1594
+ /** Default transition used when this scene is the destination of a push/pop/replace. */
1595
+ defaultTransition;
1490
1596
  /** Manual pause flag. Set by game code to pause this scene regardless of stack position. */
1491
1597
  paused = false;
1492
1598
  /** Time scale multiplier for this scene. 1.0 = normal, 0.5 = half speed. Default: 1. */
@@ -1498,6 +1604,7 @@ var Scene = class {
1498
1604
  queryCache;
1499
1605
  bus;
1500
1606
  _entityEventHandlers;
1607
+ _scopedServices;
1501
1608
  /** Access the EngineContext. */
1502
1609
  get context() {
1503
1610
  return this._context;
@@ -1515,6 +1622,11 @@ var Scene = class {
1515
1622
  }
1516
1623
  return false;
1517
1624
  }
1625
+ /** Whether a scene transition is currently running. */
1626
+ get isTransitioning() {
1627
+ const sm = this._context?.tryResolve(SceneManagerKey);
1628
+ return sm?.isTransitioning ?? false;
1629
+ }
1518
1630
  /** Convenience accessor for the AssetManager. */
1519
1631
  get assets() {
1520
1632
  return this._context.resolve(AssetManagerKey);
@@ -1639,6 +1751,31 @@ var Scene = class {
1639
1751
  }
1640
1752
  }
1641
1753
  // ---- Internal methods ----
1754
+ /**
1755
+ * Register a scene-scoped service. Called from a plugin's `beforeEnter`
1756
+ * hook to make per-scene state (render tree, physics world) resolvable via
1757
+ * `Component.use(key)`.
1758
+ * @internal
1759
+ */
1760
+ _registerScoped(key, value) {
1761
+ this._scopedServices ??= /* @__PURE__ */ new Map();
1762
+ this._scopedServices.set(key.id, value);
1763
+ }
1764
+ /**
1765
+ * Resolve a scene-scoped service, or `undefined` if none was registered.
1766
+ * @internal
1767
+ */
1768
+ _resolveScoped(key) {
1769
+ return this._scopedServices?.get(key.id);
1770
+ }
1771
+ /**
1772
+ * Clear all scene-scoped services. Called by the SceneManager after
1773
+ * `afterExit` hooks run, so plugin cleanup code still sees scoped state.
1774
+ * @internal
1775
+ */
1776
+ _clearScopedServices() {
1777
+ this._scopedServices?.clear();
1778
+ }
1642
1779
  /**
1643
1780
  * Set the engine context. Called by SceneManager when the scene is pushed.
1644
1781
  * @internal
@@ -1690,6 +1827,138 @@ var Scene = class {
1690
1827
  }
1691
1828
  };
1692
1829
 
1830
+ // src/LoadingScene.ts
1831
+ var LoadingScene = class extends Scene {
1832
+ static {
1833
+ __name(this, "LoadingScene");
1834
+ }
1835
+ name = "loading";
1836
+ /**
1837
+ * Minimum wall-clock ms the scene stays visible before handing off.
1838
+ * Prevents flicker on cached loads. Default 0.
1839
+ */
1840
+ minDuration = 0;
1841
+ /** Transition used for the loading → target handoff. */
1842
+ transition;
1843
+ /**
1844
+ * When true (default), the handoff fires automatically after loading and
1845
+ * `minDuration`. Set false to gate it behind `continue()` — useful when
1846
+ * the loading scene also asks the player to press a key or click.
1847
+ */
1848
+ autoContinue = true;
1849
+ _progress = 0;
1850
+ _started = false;
1851
+ _active = true;
1852
+ _continueRequested = false;
1853
+ _continueGate;
1854
+ // Bumped on every `_run` attempt. `AssetManager.loadAll` uses `Promise.all`
1855
+ // under the hood, so individual loaders from a failed attempt can still
1856
+ // resolve and fire `onProgress` after the attempt rejects. Without this
1857
+ // guard, a retry kicked off from `onLoadError` would see stale progress
1858
+ // callbacks mutate `_progress` and emit `scene:loading:progress` events
1859
+ // attributed to the current attempt.
1860
+ _attempt = 0;
1861
+ /** Current load progress, 0 → 1. Updated as the AssetManager reports progress. */
1862
+ get progress() {
1863
+ return this._progress;
1864
+ }
1865
+ /**
1866
+ * Kick off asset loading. While a load is in flight, subsequent calls
1867
+ * are no-ops. After a load failure the guard is released, so calling
1868
+ * `startLoading()` from `onLoadError` (or from a retry button) kicks off
1869
+ * a fresh load against the same target.
1870
+ *
1871
+ * Usually called once from `onEnter` after spawning the loading UI:
1872
+ * ```ts
1873
+ * override onEnter() {
1874
+ * this.spawn(LoadingSceneProgressBar);
1875
+ * this.startLoading();
1876
+ * }
1877
+ * ```
1878
+ *
1879
+ * Deferring the call lets you gate the start of the load behind a
1880
+ * title screen, "press any key" prompt, intro animation, etc.
1881
+ */
1882
+ startLoading() {
1883
+ if (this._started) return;
1884
+ this._started = true;
1885
+ this._run().catch((err) => {
1886
+ if (!this._active) return;
1887
+ const logger = this.context.tryResolve(LoggerKey);
1888
+ if (logger) {
1889
+ logger.error("LoadingScene", "loading failed", { error: err });
1890
+ } else {
1891
+ console.error("[LoadingScene] loading failed:", err);
1892
+ }
1893
+ });
1894
+ }
1895
+ /**
1896
+ * Trigger the handoff to `target`. No-op if already called or if
1897
+ * `autoContinue` already fired it. If called before loading finishes,
1898
+ * the handoff runs as soon as loading + `minDuration` complete.
1899
+ */
1900
+ continue() {
1901
+ if (this._continueRequested) return;
1902
+ this._continueRequested = true;
1903
+ this._continueGate?.();
1904
+ }
1905
+ onExit() {
1906
+ this._active = false;
1907
+ this._continueGate?.();
1908
+ }
1909
+ async _run() {
1910
+ await new Promise((resolve) => setTimeout(resolve, 0));
1911
+ if (!this._active) return;
1912
+ const attempt = ++this._attempt;
1913
+ const target = typeof this.target === "function" ? this.target() : this.target;
1914
+ const startedAt = performance.now();
1915
+ const bus = this.context.resolve(EventBusKey);
1916
+ try {
1917
+ await this.assets.loadAll(target.preload ?? [], (ratio) => {
1918
+ if (!this._active || attempt !== this._attempt) return;
1919
+ this._progress = ratio;
1920
+ bus.emit("scene:loading:progress", { scene: this, ratio });
1921
+ });
1922
+ if (!this._active || attempt !== this._attempt) return;
1923
+ const elapsed = performance.now() - startedAt;
1924
+ const remaining = this.minDuration - elapsed;
1925
+ if (remaining > 0) {
1926
+ await new Promise((resolve) => setTimeout(resolve, remaining));
1927
+ if (!this._active || attempt !== this._attempt) return;
1928
+ }
1929
+ } catch (err) {
1930
+ if (!this._active || attempt !== this._attempt) return;
1931
+ const error = err instanceof Error ? err : new Error(String(err));
1932
+ this._started = false;
1933
+ this._attempt++;
1934
+ if (this.onLoadError) {
1935
+ await this.onLoadError(error);
1936
+ return;
1937
+ }
1938
+ throw error;
1939
+ }
1940
+ bus.emit("scene:loading:done", { scene: this });
1941
+ if (!this.autoContinue && !this._continueRequested) {
1942
+ await new Promise((resolve) => {
1943
+ this._continueGate = resolve;
1944
+ });
1945
+ if (!this._active || attempt !== this._attempt) return;
1946
+ }
1947
+ const scenes = this.context.resolve(SceneManagerKey);
1948
+ await scenes.replace(
1949
+ target,
1950
+ this.transition ? { transition: this.transition } : void 0
1951
+ );
1952
+ }
1953
+ };
1954
+
1955
+ // src/SceneTransition.ts
1956
+ function resolveTransition(callSite, destination) {
1957
+ if (callSite) return callSite;
1958
+ return destination?.defaultTransition;
1959
+ }
1960
+ __name(resolveTransition, "resolveTransition");
1961
+
1693
1962
  // src/SceneManager.ts
1694
1963
  var SceneManager = class {
1695
1964
  static {
@@ -1699,6 +1968,12 @@ var SceneManager = class {
1699
1968
  _context;
1700
1969
  bus;
1701
1970
  assetManager;
1971
+ hookRegistry;
1972
+ logger;
1973
+ _currentRun;
1974
+ _pendingChain = Promise.resolve();
1975
+ _mutationDepth = 0;
1976
+ _destroyed = false;
1702
1977
  /**
1703
1978
  * Set the engine context.
1704
1979
  * @internal
@@ -1707,6 +1982,8 @@ var SceneManager = class {
1707
1982
  this._context = context;
1708
1983
  this.bus = context.tryResolve(EventBusKey);
1709
1984
  this.assetManager = context.tryResolve(AssetManagerKey);
1985
+ this.hookRegistry = context.tryResolve(SceneHookRegistryKey);
1986
+ this.logger = context.tryResolve(LoggerKey);
1710
1987
  }
1711
1988
  /** The topmost (active) scene. */
1712
1989
  get active() {
@@ -1718,84 +1995,129 @@ var SceneManager = class {
1718
1995
  }
1719
1996
  /** All non-paused scenes in the stack, bottom to top. */
1720
1997
  get activeScenes() {
1721
- return this.stack.filter((s) => !s.isPaused);
1998
+ return this.stack.filter((scene) => !scene.isPaused);
1999
+ }
2000
+ /** Whether a scene transition is currently running. */
2001
+ get isTransitioning() {
2002
+ return this._currentRun !== void 0;
1722
2003
  }
1723
2004
  /**
1724
2005
  * Push a scene onto the stack. Scenes below may receive onPause().
1725
2006
  * If the scene declares a `preload` array, assets are loaded before onEnter().
1726
- * Await the returned promise when using preloaded scenes.
1727
2007
  */
1728
- push(scene) {
1729
- const wasPaused = new Map(this.stack.map((s) => [s, s.isPaused]));
1730
- scene._setContext(this._context);
1731
- this.stack.push(scene);
1732
- this._firePauseTransitions(wasPaused);
1733
- if (scene.preload?.length && this.assetManager) {
1734
- return this.assetManager.loadAll(scene.preload, scene.onProgress?.bind(scene)).then(() => {
1735
- scene.onEnter?.();
1736
- this.bus?.emit("scene:pushed", { scene });
1737
- });
1738
- }
1739
- scene.onEnter?.();
1740
- this.bus?.emit("scene:pushed", { scene });
1741
- return Promise.resolve();
2008
+ async push(scene, opts) {
2009
+ this._assertNotMutating("push");
2010
+ await this._enqueue(async () => {
2011
+ const fromScene = this.active;
2012
+ await this._pushScene(scene);
2013
+ const transition = resolveTransition(opts?.transition, scene);
2014
+ if (!transition) return;
2015
+ await this._runTransition("push", transition, fromScene, scene);
2016
+ });
1742
2017
  }
1743
2018
  /** Pop the top scene. Scenes below may receive onResume(). */
1744
- pop() {
1745
- const wasPaused = new Map(this.stack.map((s) => [s, s.isPaused]));
1746
- const removed = this.stack.pop();
1747
- if (!removed) return void 0;
1748
- removed.onExit?.();
1749
- removed._destroyAllEntities();
1750
- this._fireResumeTransitions(wasPaused);
1751
- this.bus?.emit("scene:popped", { scene: removed });
1752
- return removed;
2019
+ async pop(opts) {
2020
+ this._assertNotMutating("pop");
2021
+ return this._enqueue(async () => {
2022
+ if (this.stack.length === 0) return void 0;
2023
+ const fromScene = this.active;
2024
+ const destination = this.stack.length > 1 ? this.stack[this.stack.length - 2] : void 0;
2025
+ const transition = resolveTransition(opts?.transition, destination);
2026
+ if (transition) {
2027
+ await this._runTransition("pop", transition, fromScene, destination);
2028
+ }
2029
+ return this._popScene();
2030
+ });
1753
2031
  }
1754
2032
  /**
1755
- * Replace the top scene. Old scene receives onExit().
1756
- * New scene receives onEnter() (after preload, if declared).
2033
+ * Replace the top scene. Without a transition the old scene exits first,
2034
+ * then the new scene enters. With a transition the new scene is pushed
2035
+ * first, both scenes coexist for the transition duration, then the old
2036
+ * scene is removed at the end.
1757
2037
  */
1758
- replace(scene) {
1759
- const wasPaused = new Map(this.stack.map((s) => [s, s.isPaused]));
1760
- const old = this.stack.pop();
1761
- if (old) {
1762
- old.onExit?.();
1763
- old._destroyAllEntities();
1764
- }
1765
- scene._setContext(this._context);
1766
- this.stack.push(scene);
1767
- this._firePauseTransitions(wasPaused);
1768
- this._fireResumeTransitions(wasPaused);
1769
- if (scene.preload?.length && this.assetManager) {
1770
- return this.assetManager.loadAll(scene.preload, scene.onProgress?.bind(scene)).then(() => {
1771
- scene.onEnter?.();
1772
- if (old) {
1773
- this.bus?.emit("scene:replaced", {
1774
- oldScene: old,
1775
- newScene: scene
1776
- });
1777
- } else {
1778
- this.bus?.emit("scene:pushed", { scene });
2038
+ async replace(scene, opts) {
2039
+ this._assertNotMutating("replace");
2040
+ await this._enqueue(async () => {
2041
+ const transition = resolveTransition(opts?.transition, scene);
2042
+ if (!transition) {
2043
+ await this._replaceScene(scene);
2044
+ return;
2045
+ }
2046
+ const old = this.active;
2047
+ await this._pushScene(scene, true);
2048
+ await this._runTransition("replace", transition, old, scene);
2049
+ if (old) {
2050
+ this._removeScene(old, true);
2051
+ }
2052
+ this.bus?.emit("scene:replaced", {
2053
+ oldScene: old ?? scene,
2054
+ newScene: scene
2055
+ });
2056
+ });
2057
+ }
2058
+ /**
2059
+ * Pop every scene on the stack, top to bottom. Each receives onExit().
2060
+ * Queued like push/pop/replace — runs after any in-flight transition.
2061
+ * Use for "restart from menu"-style flows. Does not run transitions.
2062
+ */
2063
+ async popAll() {
2064
+ this._assertNotMutating("popAll");
2065
+ await this._enqueue(async () => {
2066
+ this._withMutationSync(() => {
2067
+ while (this.stack.length > 0) {
2068
+ const scene = this.stack.pop();
2069
+ if (!scene) break;
2070
+ this._teardownScene(scene);
2071
+ this.bus?.emit("scene:popped", { scene });
1779
2072
  }
1780
2073
  });
1781
- }
1782
- scene.onEnter?.();
1783
- if (old) {
1784
- this.bus?.emit("scene:replaced", { oldScene: old, newScene: scene });
1785
- } else {
1786
- this.bus?.emit("scene:pushed", { scene });
1787
- }
1788
- return Promise.resolve();
2074
+ });
1789
2075
  }
1790
- /** Clear all scenes. Each receives onExit() from top to bottom. */
1791
- clear() {
1792
- while (this.stack.length > 0) {
1793
- const scene = this.stack.pop();
1794
- if (!scene) break;
1795
- scene.onExit?.();
1796
- scene._destroyAllEntities();
1797
- this.bus?.emit("scene:popped", { scene });
1798
- }
2076
+ /**
2077
+ * Run the full scene-enter lifecycle (beforeEnter hooks, preload, onEnter)
2078
+ * for a scene that is NOT placed on the stack. Used by infrastructure
2079
+ * plugins like DebugPlugin that render a scene off-stack.
2080
+ * @internal
2081
+ */
2082
+ async _mountDetached(scene) {
2083
+ await this._withMutation(async () => {
2084
+ scene._setContext(this._context);
2085
+ await this.hookRegistry?.runBeforeEnter(scene);
2086
+ await this._preloadScene(scene);
2087
+ scene.onEnter?.();
2088
+ });
2089
+ }
2090
+ /**
2091
+ * Run the scene-exit lifecycle (onExit, entity destruction, afterExit
2092
+ * hooks, scoped-service clear) for a detached scene.
2093
+ * @internal
2094
+ */
2095
+ _unmountDetached(scene) {
2096
+ this._withMutationSync(() => {
2097
+ this._teardownScene(scene);
2098
+ });
2099
+ }
2100
+ /**
2101
+ * Mark the manager destroyed and synchronously tear down every scene.
2102
+ * Called by Engine.destroy(). Any queued async work short-circuits on
2103
+ * resume; in-flight transitions' pending promises are resolved via
2104
+ * _cleanupRun so they don't leak.
2105
+ * @internal
2106
+ */
2107
+ _destroy() {
2108
+ this._destroyed = true;
2109
+ if (this._currentRun) {
2110
+ this._cleanupRun(this._currentRun);
2111
+ }
2112
+ this._pendingChain = Promise.resolve();
2113
+ this._withMutationSync(() => {
2114
+ while (this.stack.length > 0) {
2115
+ const scene = this.stack.pop();
2116
+ if (!scene) break;
2117
+ this._teardownScene(scene);
2118
+ this.bus?.emit("scene:popped", { scene });
2119
+ }
2120
+ });
1799
2121
  }
1800
2122
  /**
1801
2123
  * Flush destroy queues for all active scenes.
@@ -1807,21 +2129,216 @@ var SceneManager = class {
1807
2129
  scene._flushDestroyQueue();
1808
2130
  }
1809
2131
  }
2132
+ /**
2133
+ * Advance the active transition by `dt` ms. Called by Engine's earlyUpdate
2134
+ * callback with raw (unscaled) wall-clock dt.
2135
+ * @internal
2136
+ */
2137
+ _tickTransition(dt) {
2138
+ const run = this._currentRun;
2139
+ if (!run) return;
2140
+ const remaining = run.transition.duration - run.elapsed;
2141
+ const consume = Math.min(dt, remaining);
2142
+ run.elapsed += consume;
2143
+ this._safeTick(run, consume);
2144
+ if (run.elapsed >= run.transition.duration) {
2145
+ this._cleanupRun(run);
2146
+ }
2147
+ }
2148
+ // ---- Private helpers ----
2149
+ _enqueue(work) {
2150
+ if (this._destroyed) return Promise.resolve(void 0);
2151
+ const next = this._pendingChain.then(async () => {
2152
+ if (this._destroyed) return void 0;
2153
+ return work();
2154
+ });
2155
+ this._pendingChain = next.then(
2156
+ () => void 0,
2157
+ () => void 0
2158
+ );
2159
+ return next;
2160
+ }
2161
+ async _pushScene(scene, suppressEvent = false) {
2162
+ const wasPaused = this._snapshotPauseStates();
2163
+ await this._withMutation(async () => {
2164
+ scene._setContext(this._context);
2165
+ await this.hookRegistry?.runBeforeEnter(scene);
2166
+ await this._preloadScene(scene);
2167
+ this.stack.push(scene);
2168
+ scene.onEnter?.();
2169
+ this._firePauseTransitions(wasPaused);
2170
+ if (!suppressEvent) {
2171
+ this.bus?.emit("scene:pushed", { scene });
2172
+ }
2173
+ });
2174
+ }
2175
+ _popScene(suppressEvent = false) {
2176
+ const wasPaused = this._snapshotPauseStates();
2177
+ return this._withMutationSync(() => {
2178
+ const removed = this.stack.pop();
2179
+ if (!removed) return void 0;
2180
+ this._teardownScene(removed);
2181
+ this._fireResumeTransitions(wasPaused);
2182
+ if (!suppressEvent) {
2183
+ this.bus?.emit("scene:popped", { scene: removed });
2184
+ }
2185
+ return removed;
2186
+ });
2187
+ }
2188
+ async _replaceScene(scene) {
2189
+ const wasPaused = this._snapshotPauseStates();
2190
+ await this._withMutation(async () => {
2191
+ scene._setContext(this._context);
2192
+ await this.hookRegistry?.runBeforeEnter(scene);
2193
+ await this._preloadScene(scene);
2194
+ const old = this.stack.pop();
2195
+ if (old) this._teardownScene(old);
2196
+ this.stack.push(scene);
2197
+ scene.onEnter?.();
2198
+ this._firePauseTransitions(wasPaused);
2199
+ this._fireResumeTransitions(wasPaused);
2200
+ this.bus?.emit("scene:replaced", {
2201
+ oldScene: old ?? scene,
2202
+ newScene: scene
2203
+ });
2204
+ });
2205
+ }
2206
+ _removeScene(scene, suppressEvent = false) {
2207
+ this._withMutationSync(() => {
2208
+ const idx = this.stack.indexOf(scene);
2209
+ if (idx === -1) return;
2210
+ const wasPaused = this._snapshotPauseStates();
2211
+ this.stack.splice(idx, 1);
2212
+ this._teardownScene(scene);
2213
+ this._firePauseTransitions(wasPaused);
2214
+ this._fireResumeTransitions(wasPaused);
2215
+ if (!suppressEvent) {
2216
+ this.bus?.emit("scene:popped", { scene });
2217
+ }
2218
+ });
2219
+ }
2220
+ async _preloadScene(scene) {
2221
+ if (!scene.preload?.length || !this.assetManager) return;
2222
+ await this.assetManager.loadAll(
2223
+ scene.preload,
2224
+ scene.onProgress?.bind(scene)
2225
+ );
2226
+ }
2227
+ _teardownScene(scene) {
2228
+ scene.onExit?.();
2229
+ scene._destroyAllEntities();
2230
+ this.hookRegistry?.runAfterExit(scene);
2231
+ scene._clearScopedServices();
2232
+ }
2233
+ async _runTransition(kind, transition, fromScene, toScene) {
2234
+ if (this._destroyed) return;
2235
+ let resolveRun;
2236
+ const promise = new Promise((resolve) => {
2237
+ resolveRun = resolve;
2238
+ });
2239
+ const run = {
2240
+ kind,
2241
+ transition,
2242
+ elapsed: 0,
2243
+ fromScene,
2244
+ toScene,
2245
+ resolve: resolveRun
2246
+ };
2247
+ this._currentRun = run;
2248
+ this.bus?.emit("scene:transition:started", {
2249
+ kind,
2250
+ fromScene,
2251
+ toScene
2252
+ });
2253
+ this._safeCall(run, "begin");
2254
+ if (!Number.isFinite(transition.duration) || transition.duration <= 0) {
2255
+ this._cleanupRun(run);
2256
+ return;
2257
+ }
2258
+ await promise;
2259
+ }
2260
+ _cleanupRun(run) {
2261
+ if (this._currentRun !== run) return;
2262
+ this._safeCall(run, "end");
2263
+ this._currentRun = void 0;
2264
+ this.bus?.emit("scene:transition:ended", {
2265
+ kind: run.kind,
2266
+ fromScene: run.fromScene,
2267
+ toScene: run.toScene
2268
+ });
2269
+ run.resolve();
2270
+ }
2271
+ _safeTick(run, dt) {
2272
+ try {
2273
+ run.transition.tick(dt, this._makeContext(run));
2274
+ } catch (err) {
2275
+ this.logger?.warn(
2276
+ "SceneManager",
2277
+ `Transition tick error: ${err instanceof Error ? err.message : String(err)}`
2278
+ );
2279
+ }
2280
+ }
2281
+ _safeCall(run, method) {
2282
+ try {
2283
+ run.transition[method]?.(this._makeContext(run));
2284
+ } catch (err) {
2285
+ this.logger?.warn(
2286
+ "SceneManager",
2287
+ `Transition ${method} error: ${err instanceof Error ? err.message : String(err)}`
2288
+ );
2289
+ }
2290
+ }
2291
+ _makeContext(run) {
2292
+ return {
2293
+ elapsed: run.elapsed,
2294
+ kind: run.kind,
2295
+ engineContext: this._context,
2296
+ fromScene: run.fromScene,
2297
+ toScene: run.toScene
2298
+ };
2299
+ }
2300
+ _snapshotPauseStates() {
2301
+ return new Map(
2302
+ this.stack.map((scene) => [scene, scene.isPaused])
2303
+ );
2304
+ }
2305
+ _assertNotMutating(method) {
2306
+ if (this._mutationDepth === 0) return;
2307
+ throw new Error(
2308
+ `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().`
2309
+ );
2310
+ }
2311
+ async _withMutation(work) {
2312
+ this._mutationDepth++;
2313
+ try {
2314
+ return await work();
2315
+ } finally {
2316
+ this._mutationDepth--;
2317
+ }
2318
+ }
2319
+ _withMutationSync(work) {
2320
+ this._mutationDepth++;
2321
+ try {
2322
+ return work();
2323
+ } finally {
2324
+ this._mutationDepth--;
2325
+ }
2326
+ }
1810
2327
  /** Fire onPause() for scenes that transitioned from not-paused to paused. */
1811
2328
  _firePauseTransitions(wasPaused) {
1812
- for (const s of this.stack) {
1813
- const was = wasPaused.get(s) ?? false;
1814
- if (s.isPaused && !was) {
1815
- s.onPause?.();
2329
+ for (const scene of this.stack) {
2330
+ const was = wasPaused.get(scene) ?? false;
2331
+ if (scene.isPaused && !was) {
2332
+ scene.onPause?.();
1816
2333
  }
1817
2334
  }
1818
2335
  }
1819
2336
  /** Fire onResume() for scenes that transitioned from paused to not-paused. */
1820
2337
  _fireResumeTransitions(wasPaused) {
1821
- for (const s of this.stack) {
1822
- const was = wasPaused.get(s) ?? false;
1823
- if (!s.isPaused && was) {
1824
- s.onResume?.();
2338
+ for (const scene of this.stack) {
2339
+ const was = wasPaused.get(scene) ?? false;
2340
+ if (!scene.isPaused && was) {
2341
+ scene.onResume?.();
1825
2342
  }
1826
2343
  }
1827
2344
  }
@@ -2658,6 +3175,7 @@ var Engine = class {
2658
3175
  scheduler;
2659
3176
  errorBoundary;
2660
3177
  queryCache;
3178
+ sceneHooks;
2661
3179
  /** The asset manager. */
2662
3180
  assets;
2663
3181
  plugins = /* @__PURE__ */ new Map();
@@ -2676,6 +3194,7 @@ var Engine = class {
2676
3194
  this.scheduler = new SystemScheduler();
2677
3195
  this.inspector = new Inspector(this);
2678
3196
  this.assets = new AssetManager();
3197
+ this.sceneHooks = new SceneHookRegistry();
2679
3198
  this.scheduler.setErrorBoundary(this.errorBoundary);
2680
3199
  this.context.register(EngineKey, this);
2681
3200
  this.context.register(EventBusKey, this.events);
@@ -2687,11 +3206,13 @@ var Engine = class {
2687
3206
  this.context.register(InspectorKey, this.inspector);
2688
3207
  this.context.register(SystemSchedulerKey, this.scheduler);
2689
3208
  this.context.register(AssetManagerKey, this.assets);
3209
+ this.context.register(SceneHookRegistryKey, this.sceneHooks);
2690
3210
  this.scenes._setContext(this.context);
2691
3211
  this.registerBuiltInSystems();
2692
3212
  this.loop.setCallbacks({
2693
3213
  earlyUpdate: /* @__PURE__ */ __name((dt) => {
2694
3214
  this.logger.setFrame(this.loop.frameCount);
3215
+ this.scenes._tickTransition(dt);
2695
3216
  this.scheduler.run("earlyUpdate" /* EarlyUpdate */, dt);
2696
3217
  }, "earlyUpdate"),
2697
3218
  fixedUpdate: /* @__PURE__ */ __name((dt) => this.scheduler.run("fixedUpdate" /* FixedUpdate */, dt), "fixedUpdate"),
@@ -2704,6 +3225,14 @@ var Engine = class {
2704
3225
  }, "endOfFrame")
2705
3226
  });
2706
3227
  }
3228
+ /**
3229
+ * Register scene lifecycle hooks. The returned function unregisters the
3230
+ * hooks. Infrastructure plugins (renderer, physics, debug) register hooks
3231
+ * in their `install` or `onStart` to set up and tear down per-scene state.
3232
+ */
3233
+ registerSceneHooks(hooks) {
3234
+ return this.sceneHooks.register(hooks);
3235
+ }
2707
3236
  /** Register a plugin. Must be called before start(). */
2708
3237
  use(plugin) {
2709
3238
  if (this.started) {
@@ -2739,7 +3268,7 @@ var Engine = class {
2739
3268
  };
2740
3269
  }
2741
3270
  for (const plugin of sorted) {
2742
- plugin.onStart?.();
3271
+ await plugin.onStart?.();
2743
3272
  }
2744
3273
  this.events.emit("engine:started", void 0);
2745
3274
  }
@@ -2747,7 +3276,7 @@ var Engine = class {
2747
3276
  destroy() {
2748
3277
  this.events.emit("engine:stopped", void 0);
2749
3278
  this.loop.stop();
2750
- this.scenes.clear();
3279
+ this.scenes._destroy();
2751
3280
  const allSystems = this.scheduler.getAllSystems();
2752
3281
  for (let i = allSystems.length - 1; i >= 0; i--) {
2753
3282
  allSystems[i].onUnregister?.();
@@ -2890,6 +3419,7 @@ export {
2890
3419
  Inspector,
2891
3420
  InspectorKey,
2892
3421
  KeyframeAnimator,
3422
+ LoadingScene,
2893
3423
  LogLevel,
2894
3424
  Logger,
2895
3425
  LoggerKey,
@@ -2905,6 +3435,8 @@ export {
2905
3435
  QueryResult,
2906
3436
  SERIALIZABLE_KEY,
2907
3437
  Scene,
3438
+ SceneHookRegistry,
3439
+ SceneHookRegistryKey,
2908
3440
  SceneManager,
2909
3441
  SceneManagerKey,
2910
3442
  Sequence,
@@ -2937,6 +3469,7 @@ export {
2937
3469
  getSerializableType,
2938
3470
  interpolate,
2939
3471
  isSerializable,
3472
+ resolveTransition,
2940
3473
  serializable,
2941
3474
  trait
2942
3475
  };