@yagejs/core 0.1.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.
package/dist/index.js CHANGED
@@ -170,14 +170,52 @@ var Vec2 = class _Vec2 {
170
170
  static lerp(a, b, t) {
171
171
  return new _Vec2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
172
172
  }
173
+ /** Move current toward target by at most maxDelta without overshooting. */
174
+ static moveTowards(current, target, maxDelta) {
175
+ const dx = target.x - current.x;
176
+ const dy = target.y - current.y;
177
+ const distanceSq = dx * dx + dy * dy;
178
+ if (distanceSq < EPSILON * EPSILON) {
179
+ return new _Vec2(target.x, target.y);
180
+ }
181
+ if (maxDelta <= 0) {
182
+ return new _Vec2(current.x, current.y);
183
+ }
184
+ const distance = Math.sqrt(distanceSq);
185
+ if (distance <= maxDelta) {
186
+ return new _Vec2(target.x, target.y);
187
+ }
188
+ const scale = maxDelta / distance;
189
+ return new _Vec2(current.x + dx * scale, current.y + dy * scale);
190
+ }
173
191
  };
174
192
 
175
193
  // src/MathUtils.ts
194
+ var TAU = Math.PI * 2;
195
+ var MIN_SMOOTH_TIME = 1e-4;
196
+ function normalizeAngle(radians) {
197
+ const wrapped = ((radians + Math.PI) % TAU + TAU) % TAU - Math.PI;
198
+ return wrapped === -Math.PI && radians > 0 ? Math.PI : wrapped;
199
+ }
200
+ __name(normalizeAngle, "normalizeAngle");
176
201
  var MathUtils = {
177
202
  /** Linear interpolation between a and b. */
178
203
  lerp(a, b, t) {
179
204
  return a + (b - a) * t;
180
205
  },
206
+ /** Return the clamped interpolation factor that produces v between a and b. */
207
+ inverseLerp(a, b, v) {
208
+ if (a === b) return 0;
209
+ return MathUtils.clamp((v - a) / (b - a), 0, 1);
210
+ },
211
+ /** Interpolate between angles in radians along the shortest path. */
212
+ lerpAngle(a, b, t) {
213
+ return normalizeAngle(a + MathUtils.shortestAngleBetween(a, b) * t);
214
+ },
215
+ /** Signed shortest angular delta from a to b, in radians. */
216
+ shortestAngleBetween(a, b) {
217
+ return normalizeAngle(b - a);
218
+ },
181
219
  /** Clamp a value between min and max. */
182
220
  clamp(value, min, max) {
183
221
  return Math.max(min, Math.min(max, value));
@@ -187,6 +225,12 @@ var MathUtils = {
187
225
  const t = (value - inMin) / (inMax - inMin);
188
226
  return outMin + (outMax - outMin) * t;
189
227
  },
228
+ /** Bounce t between 0 and length. */
229
+ pingPong(t, length) {
230
+ if (length <= 0) return 0;
231
+ const wrapped = MathUtils.wrap(t, 0, length * 2);
232
+ return length - Math.abs(wrapped - length);
233
+ },
190
234
  /** Random float in [min, max). */
191
235
  randomRange(min, max) {
192
236
  return min + Math.random() * (max - min);
@@ -210,6 +254,34 @@ var MathUtils = {
210
254
  }
211
255
  return Math.max(current - step, target);
212
256
  },
257
+ /**
258
+ * Smoothly damp current toward target without overshooting.
259
+ * Pass the returned velocity back into the next call.
260
+ */
261
+ smoothDamp(current, target, velocity, smoothTime, deltaTime, maxSpeed = Infinity) {
262
+ if (deltaTime <= 0) {
263
+ return { value: current, velocity };
264
+ }
265
+ const safeSmoothTime = Math.max(MIN_SMOOTH_TIME, smoothTime);
266
+ const omega = 2 / safeSmoothTime;
267
+ const x = omega * deltaTime;
268
+ const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
269
+ const originalTarget = target;
270
+ const maxChange = maxSpeed * safeSmoothTime;
271
+ const change = MathUtils.clamp(current - target, -maxChange, maxChange);
272
+ const adjustedTarget = current - change;
273
+ const temp = (velocity + omega * change) * deltaTime;
274
+ const nextVelocity = (velocity - omega * temp) * exp;
275
+ let value = adjustedTarget + (change + temp) * exp;
276
+ let resultVelocity = nextVelocity;
277
+ const targetIsAboveCurrent = originalTarget - current > 0;
278
+ const valuePassedTarget = targetIsAboveCurrent ? value > originalTarget : value < originalTarget;
279
+ if (valuePassedTarget) {
280
+ value = originalTarget;
281
+ resultVelocity = 0;
282
+ }
283
+ return { value, velocity: resultVelocity };
284
+ },
213
285
  /** Wrap value into the range [min, max). */
214
286
  wrap(value, min, max) {
215
287
  const range = max - min;
@@ -367,13 +439,16 @@ var Logger = class {
367
439
 
368
440
  // src/EngineContext.ts
369
441
  var ServiceKey = class {
370
- constructor(id) {
442
+ constructor(id, options) {
371
443
  this.id = id;
444
+ this.scope = options?.scope ?? "engine";
372
445
  }
373
446
  id;
374
447
  static {
375
448
  __name(this, "ServiceKey");
376
449
  }
450
+ /** Declared scope (engine or scene). Defaults to `"engine"`. */
451
+ scope;
377
452
  };
378
453
  var EngineContext = class {
379
454
  static {
@@ -419,6 +494,50 @@ var SystemSchedulerKey = new ServiceKey("systemScheduler");
419
494
  var ProcessSystemKey = new ServiceKey("processSystem");
420
495
  var AssetManagerKey = new ServiceKey("assetManager");
421
496
 
497
+ // src/SceneHooks.ts
498
+ var SceneHookRegistry = class {
499
+ static {
500
+ __name(this, "SceneHookRegistry");
501
+ }
502
+ hooks = [];
503
+ register(hooks) {
504
+ this.hooks.push(hooks);
505
+ return () => {
506
+ const idx = this.hooks.indexOf(hooks);
507
+ if (idx !== -1) this.hooks.splice(idx, 1);
508
+ };
509
+ }
510
+ /** Run all `beforeEnter` hooks serially. */
511
+ async runBeforeEnter(scene) {
512
+ for (const h of this.hooks) {
513
+ await h.beforeEnter?.(scene);
514
+ }
515
+ }
516
+ runAfterExit(scene) {
517
+ for (const h of this.hooks) {
518
+ try {
519
+ h.afterExit?.(scene);
520
+ } catch (err) {
521
+ const logger = scene.context.tryResolve(LoggerKey);
522
+ if (logger) {
523
+ logger.error("core", "Scene afterExit hook threw", {
524
+ scene: scene.name,
525
+ error: err
526
+ });
527
+ } else {
528
+ console.error(
529
+ `[yage] Scene afterExit hook threw for scene "${scene.name}":`,
530
+ err
531
+ );
532
+ }
533
+ }
534
+ }
535
+ }
536
+ };
537
+ var SceneHookRegistryKey = new ServiceKey(
538
+ "sceneHookRegistry"
539
+ );
540
+
422
541
  // src/EventToken.ts
423
542
  var EventToken = class {
424
543
  constructor(name) {
@@ -640,10 +759,11 @@ var Component = class {
640
759
  _cleanups;
641
760
  /**
642
761
  * Access the entity's scene. Throws if the entity is not in a scene.
643
- * Prefer this over `this.entity.scene!` in component methods.
762
+ * Prefer this over threading through `this.entity.scene` in component
763
+ * code.
644
764
  */
645
765
  get scene() {
646
- const scene = this.entity.scene;
766
+ const scene = this.entity.tryScene;
647
767
  if (!scene) {
648
768
  throw new Error(
649
769
  "Cannot access scene: entity is not attached to a scene."
@@ -658,20 +778,43 @@ var Component = class {
658
778
  get context() {
659
779
  return this.scene.context;
660
780
  }
661
- /** Resolve a service by key, cached after first lookup. */
781
+ /**
782
+ * Resolve a service by key, cached after first lookup. Scene-scoped values
783
+ * (registered via `scene._registerScoped`) take precedence over engine
784
+ * scope. A key declared with `scope: "scene"` that falls back to engine
785
+ * scope emits a one-shot dev warning — almost always signals a missed
786
+ * `beforeEnter` hook.
787
+ */
662
788
  use(key) {
663
789
  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
- }
790
+ const cached = this._serviceCache.get(key.id);
791
+ if (cached !== void 0) return cached;
792
+ const scene = this.entity.tryScene;
793
+ const scoped = scene?._resolveScoped(key);
794
+ if (scoped !== void 0) {
795
+ this._serviceCache.set(key.id, scoped);
796
+ return scoped;
797
+ }
798
+ const value = this.context.resolve(key);
799
+ if (key.scope === "scene") {
800
+ this._warnScopedFallback(key);
801
+ return value;
802
+ }
803
+ this._serviceCache.set(key.id, value);
669
804
  return value;
670
805
  }
806
+ _warnScopedFallback(key) {
807
+ const logger = this.context.tryResolve(LoggerKey);
808
+ logger?.warn(
809
+ "core",
810
+ `Scoped key "${key.id}" fell back to engine scope \u2014 did a plugin forget to register a beforeEnter hook?`,
811
+ { component: this.constructor.name }
812
+ );
813
+ }
671
814
  /**
672
815
  * Lazy proxy-based service resolution. Can be used at field-declaration time:
673
816
  * ```ts
674
- * readonly camera = this.service(CameraKey);
817
+ * readonly input = this.service(InputManagerKey);
675
818
  * ```
676
819
  * The actual resolution is deferred until first property access.
677
820
  */
@@ -928,8 +1071,27 @@ var Entity = class {
928
1071
  this.name = name ?? new.target.name ?? "Entity";
929
1072
  this.tags = new Set(tags);
930
1073
  }
931
- /** The scene this entity belongs to, or null. */
1074
+ /**
1075
+ * The scene this entity belongs to. Throws if the entity is not attached
1076
+ * to a scene — which in practice only happens before `scene.spawn` /
1077
+ * `addChild` wires it up, or after `destroy()` tears it down. Inside
1078
+ * lifecycle methods (`setup`, component `onAdd`, `update`, etc.) this is
1079
+ * always safe to access.
1080
+ *
1081
+ * For the rare case where you genuinely need to inspect whether an
1082
+ * entity has a scene (e.g. defensive code in systems iterating a query
1083
+ * result), use `tryScene` instead.
1084
+ */
932
1085
  get scene() {
1086
+ if (!this._scene) {
1087
+ throw new Error(
1088
+ `Entity "${this.name}" is not attached to a scene. Use \`tryScene\` if you need to check.`
1089
+ );
1090
+ }
1091
+ return this._scene;
1092
+ }
1093
+ /** The scene this entity belongs to, or `null` if detached. */
1094
+ get tryScene() {
933
1095
  return this._scene;
934
1096
  }
935
1097
  /** True if destroy() has been called. */
@@ -967,6 +1129,20 @@ var Entity = class {
967
1129
  this._scene._addExistingEntity(child);
968
1130
  }
969
1131
  }
1132
+ spawnChild(name, classOrBlueprint, params) {
1133
+ const scene = this.scene;
1134
+ if (this._children?.has(name)) {
1135
+ throw new Error(
1136
+ `Entity "${this.name}" already has a child named "${name}".`
1137
+ );
1138
+ }
1139
+ const child = classOrBlueprint === void 0 ? scene.spawn(name) : scene.spawn(
1140
+ classOrBlueprint,
1141
+ params
1142
+ );
1143
+ this.addChild(name, child);
1144
+ return child;
1145
+ }
970
1146
  /** Remove a named child. Returns the detached entity. */
971
1147
  removeChild(name) {
972
1148
  const child = this._children?.get(name);
@@ -1459,7 +1635,7 @@ var GameLoop = class {
1459
1635
  }
1460
1636
  /** Process one frame with the given dt in milliseconds. */
1461
1637
  tick(dtMs) {
1462
- if (!this.callbacks) return;
1638
+ if (!this.running || !this.callbacks) return;
1463
1639
  this._frameCount++;
1464
1640
  this.callbacks.earlyUpdate(dtMs);
1465
1641
  this.accumulator += dtMs;
@@ -1487,6 +1663,8 @@ var Scene = class {
1487
1663
  transparentBelow = false;
1488
1664
  /** Asset handles to load before onEnter(). Override in subclasses. */
1489
1665
  preload;
1666
+ /** Default transition used when this scene is the destination of a push/pop/replace. */
1667
+ defaultTransition;
1490
1668
  /** Manual pause flag. Set by game code to pause this scene regardless of stack position. */
1491
1669
  paused = false;
1492
1670
  /** Time scale multiplier for this scene. 1.0 = normal, 0.5 = half speed. Default: 1. */
@@ -1498,6 +1676,7 @@ var Scene = class {
1498
1676
  queryCache;
1499
1677
  bus;
1500
1678
  _entityEventHandlers;
1679
+ _scopedServices;
1501
1680
  /** Access the EngineContext. */
1502
1681
  get context() {
1503
1682
  return this._context;
@@ -1515,6 +1694,11 @@ var Scene = class {
1515
1694
  }
1516
1695
  return false;
1517
1696
  }
1697
+ /** Whether a scene transition is currently running. */
1698
+ get isTransitioning() {
1699
+ const sm = this._context?.tryResolve(SceneManagerKey);
1700
+ return sm?.isTransitioning ?? false;
1701
+ }
1518
1702
  /** Convenience accessor for the AssetManager. */
1519
1703
  get assets() {
1520
1704
  return this._context.resolve(AssetManagerKey);
@@ -1639,6 +1823,31 @@ var Scene = class {
1639
1823
  }
1640
1824
  }
1641
1825
  // ---- Internal methods ----
1826
+ /**
1827
+ * Register a scene-scoped service. Called from a plugin's `beforeEnter`
1828
+ * hook to make per-scene state (render tree, physics world) resolvable via
1829
+ * `Component.use(key)`.
1830
+ * @internal
1831
+ */
1832
+ _registerScoped(key, value) {
1833
+ this._scopedServices ??= /* @__PURE__ */ new Map();
1834
+ this._scopedServices.set(key.id, value);
1835
+ }
1836
+ /**
1837
+ * Resolve a scene-scoped service, or `undefined` if none was registered.
1838
+ * @internal
1839
+ */
1840
+ _resolveScoped(key) {
1841
+ return this._scopedServices?.get(key.id);
1842
+ }
1843
+ /**
1844
+ * Clear all scene-scoped services. Called by the SceneManager after
1845
+ * `afterExit` hooks run, so plugin cleanup code still sees scoped state.
1846
+ * @internal
1847
+ */
1848
+ _clearScopedServices() {
1849
+ this._scopedServices?.clear();
1850
+ }
1642
1851
  /**
1643
1852
  * Set the engine context. Called by SceneManager when the scene is pushed.
1644
1853
  * @internal
@@ -1690,6 +1899,138 @@ var Scene = class {
1690
1899
  }
1691
1900
  };
1692
1901
 
1902
+ // src/LoadingScene.ts
1903
+ var LoadingScene = class extends Scene {
1904
+ static {
1905
+ __name(this, "LoadingScene");
1906
+ }
1907
+ name = "loading";
1908
+ /**
1909
+ * Minimum wall-clock ms the scene stays visible before handing off.
1910
+ * Prevents flicker on cached loads. Default 0.
1911
+ */
1912
+ minDuration = 0;
1913
+ /** Transition used for the loading → target handoff. */
1914
+ transition;
1915
+ /**
1916
+ * When true (default), the handoff fires automatically after loading and
1917
+ * `minDuration`. Set false to gate it behind `continue()` — useful when
1918
+ * the loading scene also asks the player to press a key or click.
1919
+ */
1920
+ autoContinue = true;
1921
+ _progress = 0;
1922
+ _started = false;
1923
+ _active = true;
1924
+ _continueRequested = false;
1925
+ _continueGate;
1926
+ // Bumped on every `_run` attempt. `AssetManager.loadAll` uses `Promise.all`
1927
+ // under the hood, so individual loaders from a failed attempt can still
1928
+ // resolve and fire `onProgress` after the attempt rejects. Without this
1929
+ // guard, a retry kicked off from `onLoadError` would see stale progress
1930
+ // callbacks mutate `_progress` and emit `scene:loading:progress` events
1931
+ // attributed to the current attempt.
1932
+ _attempt = 0;
1933
+ /** Current load progress, 0 → 1. Updated as the AssetManager reports progress. */
1934
+ get progress() {
1935
+ return this._progress;
1936
+ }
1937
+ /**
1938
+ * Kick off asset loading. While a load is in flight, subsequent calls
1939
+ * are no-ops. After a load failure the guard is released, so calling
1940
+ * `startLoading()` from `onLoadError` (or from a retry button) kicks off
1941
+ * a fresh load against the same target.
1942
+ *
1943
+ * Usually called once from `onEnter` after spawning the loading UI:
1944
+ * ```ts
1945
+ * override onEnter() {
1946
+ * this.spawn(LoadingSceneProgressBar);
1947
+ * this.startLoading();
1948
+ * }
1949
+ * ```
1950
+ *
1951
+ * Deferring the call lets you gate the start of the load behind a
1952
+ * title screen, "press any key" prompt, intro animation, etc.
1953
+ */
1954
+ startLoading() {
1955
+ if (this._started) return;
1956
+ this._started = true;
1957
+ this._run().catch((err) => {
1958
+ if (!this._active) return;
1959
+ const logger = this.context.tryResolve(LoggerKey);
1960
+ if (logger) {
1961
+ logger.error("LoadingScene", "loading failed", { error: err });
1962
+ } else {
1963
+ console.error("[LoadingScene] loading failed:", err);
1964
+ }
1965
+ });
1966
+ }
1967
+ /**
1968
+ * Trigger the handoff to `target`. No-op if already called or if
1969
+ * `autoContinue` already fired it. If called before loading finishes,
1970
+ * the handoff runs as soon as loading + `minDuration` complete.
1971
+ */
1972
+ continue() {
1973
+ if (this._continueRequested) return;
1974
+ this._continueRequested = true;
1975
+ this._continueGate?.();
1976
+ }
1977
+ onExit() {
1978
+ this._active = false;
1979
+ this._continueGate?.();
1980
+ }
1981
+ async _run() {
1982
+ await new Promise((resolve) => setTimeout(resolve, 0));
1983
+ if (!this._active) return;
1984
+ const attempt = ++this._attempt;
1985
+ const target = typeof this.target === "function" ? this.target() : this.target;
1986
+ const startedAt = performance.now();
1987
+ const bus = this.context.resolve(EventBusKey);
1988
+ try {
1989
+ await this.assets.loadAll(target.preload ?? [], (ratio) => {
1990
+ if (!this._active || attempt !== this._attempt) return;
1991
+ this._progress = ratio;
1992
+ bus.emit("scene:loading:progress", { scene: this, ratio });
1993
+ });
1994
+ if (!this._active || attempt !== this._attempt) return;
1995
+ const elapsed = performance.now() - startedAt;
1996
+ const remaining = this.minDuration - elapsed;
1997
+ if (remaining > 0) {
1998
+ await new Promise((resolve) => setTimeout(resolve, remaining));
1999
+ if (!this._active || attempt !== this._attempt) return;
2000
+ }
2001
+ } catch (err) {
2002
+ if (!this._active || attempt !== this._attempt) return;
2003
+ const error = err instanceof Error ? err : new Error(String(err));
2004
+ this._started = false;
2005
+ this._attempt++;
2006
+ if (this.onLoadError) {
2007
+ await this.onLoadError(error);
2008
+ return;
2009
+ }
2010
+ throw error;
2011
+ }
2012
+ bus.emit("scene:loading:done", { scene: this });
2013
+ if (!this.autoContinue && !this._continueRequested) {
2014
+ await new Promise((resolve) => {
2015
+ this._continueGate = resolve;
2016
+ });
2017
+ if (!this._active || attempt !== this._attempt) return;
2018
+ }
2019
+ const scenes = this.context.resolve(SceneManagerKey);
2020
+ await scenes.replace(
2021
+ target,
2022
+ this.transition ? { transition: this.transition } : void 0
2023
+ );
2024
+ }
2025
+ };
2026
+
2027
+ // src/SceneTransition.ts
2028
+ function resolveTransition(callSite, destination) {
2029
+ if (callSite) return callSite;
2030
+ return destination?.defaultTransition;
2031
+ }
2032
+ __name(resolveTransition, "resolveTransition");
2033
+
1693
2034
  // src/SceneManager.ts
1694
2035
  var SceneManager = class {
1695
2036
  static {
@@ -1699,6 +2040,35 @@ var SceneManager = class {
1699
2040
  _context;
1700
2041
  bus;
1701
2042
  assetManager;
2043
+ hookRegistry;
2044
+ logger;
2045
+ _currentRun;
2046
+ _pendingChain = Promise.resolve();
2047
+ _mutationDepth = 0;
2048
+ _destroyed = false;
2049
+ _autoPauseOnBlur = false;
2050
+ _isBlurred = false;
2051
+ _visibilityPausedScenes = /* @__PURE__ */ new Set();
2052
+ _visibilityListenerCleanup;
2053
+ /**
2054
+ * Pause all non-paused scenes when `document.hidden` becomes `true`; restore
2055
+ * them on focus. Default: `false`. Only scenes paused by this mechanism are
2056
+ * restored — user-paused scenes (manual `scene.paused = true` or `pauseBelow`
2057
+ * cascade) are never touched.
2058
+ */
2059
+ get autoPauseOnBlur() {
2060
+ return this._autoPauseOnBlur;
2061
+ }
2062
+ set autoPauseOnBlur(value) {
2063
+ if (this._autoPauseOnBlur === value) return;
2064
+ this._autoPauseOnBlur = value;
2065
+ if (!this._isBlurred) return;
2066
+ if (value) {
2067
+ this._applyBlurPause();
2068
+ } else if (this._visibilityPausedScenes.size > 0) {
2069
+ this._restoreBlurPause();
2070
+ }
2071
+ }
1702
2072
  /**
1703
2073
  * Set the engine context.
1704
2074
  * @internal
@@ -1707,6 +2077,42 @@ var SceneManager = class {
1707
2077
  this._context = context;
1708
2078
  this.bus = context.tryResolve(EventBusKey);
1709
2079
  this.assetManager = context.tryResolve(AssetManagerKey);
2080
+ this.hookRegistry = context.tryResolve(SceneHookRegistryKey);
2081
+ this.logger = context.tryResolve(LoggerKey);
2082
+ if (this._visibilityListenerCleanup || typeof document === "undefined") {
2083
+ return;
2084
+ }
2085
+ const onVisibilityChange = /* @__PURE__ */ __name(() => {
2086
+ this._handleVisibilityChange(document.hidden);
2087
+ }, "onVisibilityChange");
2088
+ document.addEventListener("visibilitychange", onVisibilityChange);
2089
+ this._visibilityListenerCleanup = () => document.removeEventListener("visibilitychange", onVisibilityChange);
2090
+ }
2091
+ /**
2092
+ * React to a visibility change. Parameterised on `hidden` so unit tests can
2093
+ * drive it without a real `document`.
2094
+ * @internal
2095
+ */
2096
+ _handleVisibilityChange(hidden) {
2097
+ if (hidden && !this._isBlurred) {
2098
+ this._isBlurred = true;
2099
+ if (this._autoPauseOnBlur) this._applyBlurPause();
2100
+ } else if (!hidden && this._isBlurred) {
2101
+ this._isBlurred = false;
2102
+ if (this._visibilityPausedScenes.size > 0) this._restoreBlurPause();
2103
+ }
2104
+ }
2105
+ _applyBlurPause() {
2106
+ for (const scene of this.activeScenes) {
2107
+ scene.paused = true;
2108
+ this._visibilityPausedScenes.add(scene);
2109
+ }
2110
+ }
2111
+ _restoreBlurPause() {
2112
+ for (const scene of this._visibilityPausedScenes) {
2113
+ scene.paused = false;
2114
+ }
2115
+ this._visibilityPausedScenes.clear();
1710
2116
  }
1711
2117
  /** The topmost (active) scene. */
1712
2118
  get active() {
@@ -1718,84 +2124,132 @@ var SceneManager = class {
1718
2124
  }
1719
2125
  /** All non-paused scenes in the stack, bottom to top. */
1720
2126
  get activeScenes() {
1721
- return this.stack.filter((s) => !s.isPaused);
2127
+ return this.stack.filter((scene) => !scene.isPaused);
2128
+ }
2129
+ /** Whether a scene transition is currently running. */
2130
+ get isTransitioning() {
2131
+ return this._currentRun !== void 0;
1722
2132
  }
1723
2133
  /**
1724
2134
  * Push a scene onto the stack. Scenes below may receive onPause().
1725
2135
  * If the scene declares a `preload` array, assets are loaded before onEnter().
1726
- * Await the returned promise when using preloaded scenes.
1727
2136
  */
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();
2137
+ async push(scene, opts) {
2138
+ this._assertNotMutating("push");
2139
+ await this._enqueue(async () => {
2140
+ const fromScene = this.active;
2141
+ await this._pushScene(scene);
2142
+ const transition = resolveTransition(opts?.transition, scene);
2143
+ if (!transition) return;
2144
+ await this._runTransition("push", transition, fromScene, scene);
2145
+ });
1742
2146
  }
1743
2147
  /** 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;
2148
+ async pop(opts) {
2149
+ this._assertNotMutating("pop");
2150
+ return this._enqueue(async () => {
2151
+ if (this.stack.length === 0) return void 0;
2152
+ const fromScene = this.active;
2153
+ const destination = this.stack.length > 1 ? this.stack[this.stack.length - 2] : void 0;
2154
+ const transition = resolveTransition(opts?.transition, destination);
2155
+ if (transition) {
2156
+ await this._runTransition("pop", transition, fromScene, destination);
2157
+ }
2158
+ return this._popScene();
2159
+ });
1753
2160
  }
1754
2161
  /**
1755
- * Replace the top scene. Old scene receives onExit().
1756
- * New scene receives onEnter() (after preload, if declared).
2162
+ * Replace the top scene. Without a transition the old scene exits first,
2163
+ * then the new scene enters. With a transition the new scene is pushed
2164
+ * first, both scenes coexist for the transition duration, then the old
2165
+ * scene is removed at the end.
1757
2166
  */
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 });
2167
+ async replace(scene, opts) {
2168
+ this._assertNotMutating("replace");
2169
+ await this._enqueue(async () => {
2170
+ const transition = resolveTransition(opts?.transition, scene);
2171
+ if (!transition) {
2172
+ await this._replaceScene(scene);
2173
+ return;
2174
+ }
2175
+ const old = this.active;
2176
+ await this._pushScene(scene, true);
2177
+ await this._runTransition("replace", transition, old, scene);
2178
+ if (old) {
2179
+ this._removeScene(old, true);
2180
+ }
2181
+ this.bus?.emit("scene:replaced", {
2182
+ oldScene: old ?? scene,
2183
+ newScene: scene
2184
+ });
2185
+ });
2186
+ }
2187
+ /**
2188
+ * Pop every scene on the stack, top to bottom. Each receives onExit().
2189
+ * Queued like push/pop/replace — runs after any in-flight transition.
2190
+ * Use for "restart from menu"-style flows. Does not run transitions.
2191
+ */
2192
+ async popAll() {
2193
+ this._assertNotMutating("popAll");
2194
+ await this._enqueue(async () => {
2195
+ this._withMutationSync(() => {
2196
+ while (this.stack.length > 0) {
2197
+ const scene = this.stack.pop();
2198
+ if (!scene) break;
2199
+ this._teardownScene(scene);
2200
+ this.bus?.emit("scene:popped", { scene });
1779
2201
  }
1780
2202
  });
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();
2203
+ });
1789
2204
  }
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
- }
2205
+ /**
2206
+ * Run the full scene-enter lifecycle (beforeEnter hooks, preload, onEnter)
2207
+ * for a scene that is NOT placed on the stack. Used by infrastructure
2208
+ * plugins like DebugPlugin that render a scene off-stack.
2209
+ * @internal
2210
+ */
2211
+ async _mountDetached(scene) {
2212
+ await this._withMutation(async () => {
2213
+ scene._setContext(this._context);
2214
+ await this.hookRegistry?.runBeforeEnter(scene);
2215
+ await this._preloadScene(scene);
2216
+ scene.onEnter?.();
2217
+ });
2218
+ }
2219
+ /**
2220
+ * Run the scene-exit lifecycle (onExit, entity destruction, afterExit
2221
+ * hooks, scoped-service clear) for a detached scene.
2222
+ * @internal
2223
+ */
2224
+ _unmountDetached(scene) {
2225
+ this._withMutationSync(() => {
2226
+ this._teardownScene(scene);
2227
+ });
2228
+ }
2229
+ /**
2230
+ * Mark the manager destroyed and synchronously tear down every scene.
2231
+ * Called by Engine.destroy(). Any queued async work short-circuits on
2232
+ * resume; in-flight transitions' pending promises are resolved via
2233
+ * _cleanupRun so they don't leak.
2234
+ * @internal
2235
+ */
2236
+ _destroy() {
2237
+ this._destroyed = true;
2238
+ if (this._currentRun) {
2239
+ this._cleanupRun(this._currentRun);
2240
+ }
2241
+ this._pendingChain = Promise.resolve();
2242
+ this._visibilityListenerCleanup?.();
2243
+ this._visibilityListenerCleanup = void 0;
2244
+ this._visibilityPausedScenes.clear();
2245
+ this._withMutationSync(() => {
2246
+ while (this.stack.length > 0) {
2247
+ const scene = this.stack.pop();
2248
+ if (!scene) break;
2249
+ this._teardownScene(scene);
2250
+ this.bus?.emit("scene:popped", { scene });
2251
+ }
2252
+ });
1799
2253
  }
1800
2254
  /**
1801
2255
  * Flush destroy queues for all active scenes.
@@ -1807,21 +2261,217 @@ var SceneManager = class {
1807
2261
  scene._flushDestroyQueue();
1808
2262
  }
1809
2263
  }
2264
+ /**
2265
+ * Advance the active transition by `dt` ms. Called by Engine's earlyUpdate
2266
+ * callback with raw (unscaled) wall-clock dt.
2267
+ * @internal
2268
+ */
2269
+ _tickTransition(dt) {
2270
+ const run = this._currentRun;
2271
+ if (!run) return;
2272
+ const remaining = run.transition.duration - run.elapsed;
2273
+ const consume = Math.min(dt, remaining);
2274
+ run.elapsed += consume;
2275
+ this._safeTick(run, consume);
2276
+ if (run.elapsed >= run.transition.duration) {
2277
+ this._cleanupRun(run);
2278
+ }
2279
+ }
2280
+ // ---- Private helpers ----
2281
+ _enqueue(work) {
2282
+ if (this._destroyed) return Promise.resolve(void 0);
2283
+ const next = this._pendingChain.then(async () => {
2284
+ if (this._destroyed) return void 0;
2285
+ return work();
2286
+ });
2287
+ this._pendingChain = next.then(
2288
+ () => void 0,
2289
+ () => void 0
2290
+ );
2291
+ return next;
2292
+ }
2293
+ async _pushScene(scene, suppressEvent = false) {
2294
+ const wasPaused = this._snapshotPauseStates();
2295
+ await this._withMutation(async () => {
2296
+ scene._setContext(this._context);
2297
+ await this.hookRegistry?.runBeforeEnter(scene);
2298
+ await this._preloadScene(scene);
2299
+ this.stack.push(scene);
2300
+ scene.onEnter?.();
2301
+ this._firePauseTransitions(wasPaused);
2302
+ if (!suppressEvent) {
2303
+ this.bus?.emit("scene:pushed", { scene });
2304
+ }
2305
+ });
2306
+ }
2307
+ _popScene(suppressEvent = false) {
2308
+ const wasPaused = this._snapshotPauseStates();
2309
+ return this._withMutationSync(() => {
2310
+ const removed = this.stack.pop();
2311
+ if (!removed) return void 0;
2312
+ this._teardownScene(removed);
2313
+ this._fireResumeTransitions(wasPaused);
2314
+ if (!suppressEvent) {
2315
+ this.bus?.emit("scene:popped", { scene: removed });
2316
+ }
2317
+ return removed;
2318
+ });
2319
+ }
2320
+ async _replaceScene(scene) {
2321
+ const wasPaused = this._snapshotPauseStates();
2322
+ await this._withMutation(async () => {
2323
+ scene._setContext(this._context);
2324
+ await this.hookRegistry?.runBeforeEnter(scene);
2325
+ await this._preloadScene(scene);
2326
+ const old = this.stack.pop();
2327
+ if (old) this._teardownScene(old);
2328
+ this.stack.push(scene);
2329
+ scene.onEnter?.();
2330
+ this._firePauseTransitions(wasPaused);
2331
+ this._fireResumeTransitions(wasPaused);
2332
+ this.bus?.emit("scene:replaced", {
2333
+ oldScene: old ?? scene,
2334
+ newScene: scene
2335
+ });
2336
+ });
2337
+ }
2338
+ _removeScene(scene, suppressEvent = false) {
2339
+ this._withMutationSync(() => {
2340
+ const idx = this.stack.indexOf(scene);
2341
+ if (idx === -1) return;
2342
+ const wasPaused = this._snapshotPauseStates();
2343
+ this.stack.splice(idx, 1);
2344
+ this._teardownScene(scene);
2345
+ this._firePauseTransitions(wasPaused);
2346
+ this._fireResumeTransitions(wasPaused);
2347
+ if (!suppressEvent) {
2348
+ this.bus?.emit("scene:popped", { scene });
2349
+ }
2350
+ });
2351
+ }
2352
+ async _preloadScene(scene) {
2353
+ if (!scene.preload?.length || !this.assetManager) return;
2354
+ await this.assetManager.loadAll(
2355
+ scene.preload,
2356
+ scene.onProgress?.bind(scene)
2357
+ );
2358
+ }
2359
+ _teardownScene(scene) {
2360
+ scene.onExit?.();
2361
+ scene._destroyAllEntities();
2362
+ this.hookRegistry?.runAfterExit(scene);
2363
+ scene._clearScopedServices();
2364
+ this._visibilityPausedScenes.delete(scene);
2365
+ }
2366
+ async _runTransition(kind, transition, fromScene, toScene) {
2367
+ if (this._destroyed) return;
2368
+ let resolveRun;
2369
+ const promise = new Promise((resolve) => {
2370
+ resolveRun = resolve;
2371
+ });
2372
+ const run = {
2373
+ kind,
2374
+ transition,
2375
+ elapsed: 0,
2376
+ fromScene,
2377
+ toScene,
2378
+ resolve: resolveRun
2379
+ };
2380
+ this._currentRun = run;
2381
+ this.bus?.emit("scene:transition:started", {
2382
+ kind,
2383
+ fromScene,
2384
+ toScene
2385
+ });
2386
+ this._safeCall(run, "begin");
2387
+ if (!Number.isFinite(transition.duration) || transition.duration <= 0) {
2388
+ this._cleanupRun(run);
2389
+ return;
2390
+ }
2391
+ await promise;
2392
+ }
2393
+ _cleanupRun(run) {
2394
+ if (this._currentRun !== run) return;
2395
+ this._safeCall(run, "end");
2396
+ this._currentRun = void 0;
2397
+ this.bus?.emit("scene:transition:ended", {
2398
+ kind: run.kind,
2399
+ fromScene: run.fromScene,
2400
+ toScene: run.toScene
2401
+ });
2402
+ run.resolve();
2403
+ }
2404
+ _safeTick(run, dt) {
2405
+ try {
2406
+ run.transition.tick(dt, this._makeContext(run));
2407
+ } catch (err) {
2408
+ this.logger?.warn(
2409
+ "SceneManager",
2410
+ `Transition tick error: ${err instanceof Error ? err.message : String(err)}`
2411
+ );
2412
+ }
2413
+ }
2414
+ _safeCall(run, method) {
2415
+ try {
2416
+ run.transition[method]?.(this._makeContext(run));
2417
+ } catch (err) {
2418
+ this.logger?.warn(
2419
+ "SceneManager",
2420
+ `Transition ${method} error: ${err instanceof Error ? err.message : String(err)}`
2421
+ );
2422
+ }
2423
+ }
2424
+ _makeContext(run) {
2425
+ return {
2426
+ elapsed: run.elapsed,
2427
+ kind: run.kind,
2428
+ engineContext: this._context,
2429
+ fromScene: run.fromScene,
2430
+ toScene: run.toScene
2431
+ };
2432
+ }
2433
+ _snapshotPauseStates() {
2434
+ return new Map(
2435
+ this.stack.map((scene) => [scene, scene.isPaused])
2436
+ );
2437
+ }
2438
+ _assertNotMutating(method) {
2439
+ if (this._mutationDepth === 0) return;
2440
+ throw new Error(
2441
+ `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().`
2442
+ );
2443
+ }
2444
+ async _withMutation(work) {
2445
+ this._mutationDepth++;
2446
+ try {
2447
+ return await work();
2448
+ } finally {
2449
+ this._mutationDepth--;
2450
+ }
2451
+ }
2452
+ _withMutationSync(work) {
2453
+ this._mutationDepth++;
2454
+ try {
2455
+ return work();
2456
+ } finally {
2457
+ this._mutationDepth--;
2458
+ }
2459
+ }
1810
2460
  /** Fire onPause() for scenes that transitioned from not-paused to paused. */
1811
2461
  _firePauseTransitions(wasPaused) {
1812
- for (const s of this.stack) {
1813
- const was = wasPaused.get(s) ?? false;
1814
- if (s.isPaused && !was) {
1815
- s.onPause?.();
2462
+ for (const scene of this.stack) {
2463
+ const was = wasPaused.get(scene) ?? false;
2464
+ if (scene.isPaused && !was) {
2465
+ scene.onPause?.();
1816
2466
  }
1817
2467
  }
1818
2468
  }
1819
2469
  /** Fire onResume() for scenes that transitioned from paused to not-paused. */
1820
2470
  _fireResumeTransitions(wasPaused) {
1821
- for (const s of this.stack) {
1822
- const was = wasPaused.get(s) ?? false;
1823
- if (!s.isPaused && was) {
1824
- s.onResume?.();
2471
+ for (const scene of this.stack) {
2472
+ const was = wasPaused.get(scene) ?? false;
2473
+ if (!scene.isPaused && was) {
2474
+ scene.onResume?.();
1825
2475
  }
1826
2476
  }
1827
2477
  }
@@ -2658,6 +3308,7 @@ var Engine = class {
2658
3308
  scheduler;
2659
3309
  errorBoundary;
2660
3310
  queryCache;
3311
+ sceneHooks;
2661
3312
  /** The asset manager. */
2662
3313
  assets;
2663
3314
  plugins = /* @__PURE__ */ new Map();
@@ -2676,6 +3327,7 @@ var Engine = class {
2676
3327
  this.scheduler = new SystemScheduler();
2677
3328
  this.inspector = new Inspector(this);
2678
3329
  this.assets = new AssetManager();
3330
+ this.sceneHooks = new SceneHookRegistry();
2679
3331
  this.scheduler.setErrorBoundary(this.errorBoundary);
2680
3332
  this.context.register(EngineKey, this);
2681
3333
  this.context.register(EventBusKey, this.events);
@@ -2687,11 +3339,13 @@ var Engine = class {
2687
3339
  this.context.register(InspectorKey, this.inspector);
2688
3340
  this.context.register(SystemSchedulerKey, this.scheduler);
2689
3341
  this.context.register(AssetManagerKey, this.assets);
3342
+ this.context.register(SceneHookRegistryKey, this.sceneHooks);
2690
3343
  this.scenes._setContext(this.context);
2691
3344
  this.registerBuiltInSystems();
2692
3345
  this.loop.setCallbacks({
2693
3346
  earlyUpdate: /* @__PURE__ */ __name((dt) => {
2694
3347
  this.logger.setFrame(this.loop.frameCount);
3348
+ this.scenes._tickTransition(dt);
2695
3349
  this.scheduler.run("earlyUpdate" /* EarlyUpdate */, dt);
2696
3350
  }, "earlyUpdate"),
2697
3351
  fixedUpdate: /* @__PURE__ */ __name((dt) => this.scheduler.run("fixedUpdate" /* FixedUpdate */, dt), "fixedUpdate"),
@@ -2704,6 +3358,14 @@ var Engine = class {
2704
3358
  }, "endOfFrame")
2705
3359
  });
2706
3360
  }
3361
+ /**
3362
+ * Register scene lifecycle hooks. The returned function unregisters the
3363
+ * hooks. Infrastructure plugins (renderer, physics, debug) register hooks
3364
+ * in their `install` or `onStart` to set up and tear down per-scene state.
3365
+ */
3366
+ registerSceneHooks(hooks) {
3367
+ return this.sceneHooks.register(hooks);
3368
+ }
2707
3369
  /** Register a plugin. Must be called before start(). */
2708
3370
  use(plugin) {
2709
3371
  if (this.started) {
@@ -2739,7 +3401,7 @@ var Engine = class {
2739
3401
  };
2740
3402
  }
2741
3403
  for (const plugin of sorted) {
2742
- plugin.onStart?.();
3404
+ await plugin.onStart?.();
2743
3405
  }
2744
3406
  this.events.emit("engine:started", void 0);
2745
3407
  }
@@ -2747,7 +3409,7 @@ var Engine = class {
2747
3409
  destroy() {
2748
3410
  this.events.emit("engine:stopped", void 0);
2749
3411
  this.loop.stop();
2750
- this.scenes.clear();
3412
+ this.scenes._destroy();
2751
3413
  const allSystems = this.scheduler.getAllSystems();
2752
3414
  for (let i = allSystems.length - 1; i >= 0; i--) {
2753
3415
  allSystems[i].onUnregister?.();
@@ -2821,6 +3483,11 @@ var Engine = class {
2821
3483
  }
2822
3484
  };
2823
3485
 
3486
+ // src/RendererAdapter.ts
3487
+ var RendererAdapterKey = new ServiceKey(
3488
+ "rendererAdapter"
3489
+ );
3490
+
2824
3491
  // src/test-utils.ts
2825
3492
  var _TestScene = class extends Scene {
2826
3493
  static {
@@ -2890,6 +3557,7 @@ export {
2890
3557
  Inspector,
2891
3558
  InspectorKey,
2892
3559
  KeyframeAnimator,
3560
+ LoadingScene,
2893
3561
  LogLevel,
2894
3562
  Logger,
2895
3563
  LoggerKey,
@@ -2903,8 +3571,11 @@ export {
2903
3571
  QueryCache,
2904
3572
  QueryCacheKey,
2905
3573
  QueryResult,
3574
+ RendererAdapterKey,
2906
3575
  SERIALIZABLE_KEY,
2907
3576
  Scene,
3577
+ SceneHookRegistry,
3578
+ SceneHookRegistryKey,
2908
3579
  SceneManager,
2909
3580
  SceneManagerKey,
2910
3581
  Sequence,
@@ -2937,6 +3608,7 @@ export {
2937
3608
  getSerializableType,
2938
3609
  interpolate,
2939
3610
  isSerializable,
3611
+ resolveTransition,
2940
3612
  serializable,
2941
3613
  trait
2942
3614
  };