@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.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,
@@ -98,8 +99,11 @@ __export(index_exports, {
98
99
  QueryCache: () => QueryCache,
99
100
  QueryCacheKey: () => QueryCacheKey,
100
101
  QueryResult: () => QueryResult,
102
+ RendererAdapterKey: () => RendererAdapterKey,
101
103
  SERIALIZABLE_KEY: () => SERIALIZABLE_KEY,
102
104
  Scene: () => Scene,
105
+ SceneHookRegistry: () => SceneHookRegistry,
106
+ SceneHookRegistryKey: () => SceneHookRegistryKey,
103
107
  SceneManager: () => SceneManager,
104
108
  SceneManagerKey: () => SceneManagerKey,
105
109
  Sequence: () => Sequence,
@@ -132,6 +136,7 @@ __export(index_exports, {
132
136
  getSerializableType: () => getSerializableType,
133
137
  interpolate: () => interpolate,
134
138
  isSerializable: () => isSerializable,
139
+ resolveTransition: () => resolveTransition,
135
140
  serializable: () => serializable,
136
141
  trait: () => trait
137
142
  });
@@ -261,14 +266,52 @@ var Vec2 = class _Vec2 {
261
266
  static lerp(a, b, t) {
262
267
  return new _Vec2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
263
268
  }
269
+ /** Move current toward target by at most maxDelta without overshooting. */
270
+ static moveTowards(current, target, maxDelta) {
271
+ const dx = target.x - current.x;
272
+ const dy = target.y - current.y;
273
+ const distanceSq = dx * dx + dy * dy;
274
+ if (distanceSq < EPSILON * EPSILON) {
275
+ return new _Vec2(target.x, target.y);
276
+ }
277
+ if (maxDelta <= 0) {
278
+ return new _Vec2(current.x, current.y);
279
+ }
280
+ const distance = Math.sqrt(distanceSq);
281
+ if (distance <= maxDelta) {
282
+ return new _Vec2(target.x, target.y);
283
+ }
284
+ const scale = maxDelta / distance;
285
+ return new _Vec2(current.x + dx * scale, current.y + dy * scale);
286
+ }
264
287
  };
265
288
 
266
289
  // src/MathUtils.ts
290
+ var TAU = Math.PI * 2;
291
+ var MIN_SMOOTH_TIME = 1e-4;
292
+ function normalizeAngle(radians) {
293
+ const wrapped = ((radians + Math.PI) % TAU + TAU) % TAU - Math.PI;
294
+ return wrapped === -Math.PI && radians > 0 ? Math.PI : wrapped;
295
+ }
296
+ __name(normalizeAngle, "normalizeAngle");
267
297
  var MathUtils = {
268
298
  /** Linear interpolation between a and b. */
269
299
  lerp(a, b, t) {
270
300
  return a + (b - a) * t;
271
301
  },
302
+ /** Return the clamped interpolation factor that produces v between a and b. */
303
+ inverseLerp(a, b, v) {
304
+ if (a === b) return 0;
305
+ return MathUtils.clamp((v - a) / (b - a), 0, 1);
306
+ },
307
+ /** Interpolate between angles in radians along the shortest path. */
308
+ lerpAngle(a, b, t) {
309
+ return normalizeAngle(a + MathUtils.shortestAngleBetween(a, b) * t);
310
+ },
311
+ /** Signed shortest angular delta from a to b, in radians. */
312
+ shortestAngleBetween(a, b) {
313
+ return normalizeAngle(b - a);
314
+ },
272
315
  /** Clamp a value between min and max. */
273
316
  clamp(value, min, max) {
274
317
  return Math.max(min, Math.min(max, value));
@@ -278,6 +321,12 @@ var MathUtils = {
278
321
  const t = (value - inMin) / (inMax - inMin);
279
322
  return outMin + (outMax - outMin) * t;
280
323
  },
324
+ /** Bounce t between 0 and length. */
325
+ pingPong(t, length) {
326
+ if (length <= 0) return 0;
327
+ const wrapped = MathUtils.wrap(t, 0, length * 2);
328
+ return length - Math.abs(wrapped - length);
329
+ },
281
330
  /** Random float in [min, max). */
282
331
  randomRange(min, max) {
283
332
  return min + Math.random() * (max - min);
@@ -301,6 +350,34 @@ var MathUtils = {
301
350
  }
302
351
  return Math.max(current - step, target);
303
352
  },
353
+ /**
354
+ * Smoothly damp current toward target without overshooting.
355
+ * Pass the returned velocity back into the next call.
356
+ */
357
+ smoothDamp(current, target, velocity, smoothTime, deltaTime, maxSpeed = Infinity) {
358
+ if (deltaTime <= 0) {
359
+ return { value: current, velocity };
360
+ }
361
+ const safeSmoothTime = Math.max(MIN_SMOOTH_TIME, smoothTime);
362
+ const omega = 2 / safeSmoothTime;
363
+ const x = omega * deltaTime;
364
+ const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
365
+ const originalTarget = target;
366
+ const maxChange = maxSpeed * safeSmoothTime;
367
+ const change = MathUtils.clamp(current - target, -maxChange, maxChange);
368
+ const adjustedTarget = current - change;
369
+ const temp = (velocity + omega * change) * deltaTime;
370
+ const nextVelocity = (velocity - omega * temp) * exp;
371
+ let value = adjustedTarget + (change + temp) * exp;
372
+ let resultVelocity = nextVelocity;
373
+ const targetIsAboveCurrent = originalTarget - current > 0;
374
+ const valuePassedTarget = targetIsAboveCurrent ? value > originalTarget : value < originalTarget;
375
+ if (valuePassedTarget) {
376
+ value = originalTarget;
377
+ resultVelocity = 0;
378
+ }
379
+ return { value, velocity: resultVelocity };
380
+ },
304
381
  /** Wrap value into the range [min, max). */
305
382
  wrap(value, min, max) {
306
383
  const range = max - min;
@@ -458,13 +535,16 @@ var Logger = class {
458
535
 
459
536
  // src/EngineContext.ts
460
537
  var ServiceKey = class {
461
- constructor(id) {
538
+ constructor(id, options) {
462
539
  this.id = id;
540
+ this.scope = options?.scope ?? "engine";
463
541
  }
464
542
  id;
465
543
  static {
466
544
  __name(this, "ServiceKey");
467
545
  }
546
+ /** Declared scope (engine or scene). Defaults to `"engine"`. */
547
+ scope;
468
548
  };
469
549
  var EngineContext = class {
470
550
  static {
@@ -510,6 +590,50 @@ var SystemSchedulerKey = new ServiceKey("systemScheduler");
510
590
  var ProcessSystemKey = new ServiceKey("processSystem");
511
591
  var AssetManagerKey = new ServiceKey("assetManager");
512
592
 
593
+ // src/SceneHooks.ts
594
+ var SceneHookRegistry = class {
595
+ static {
596
+ __name(this, "SceneHookRegistry");
597
+ }
598
+ hooks = [];
599
+ register(hooks) {
600
+ this.hooks.push(hooks);
601
+ return () => {
602
+ const idx = this.hooks.indexOf(hooks);
603
+ if (idx !== -1) this.hooks.splice(idx, 1);
604
+ };
605
+ }
606
+ /** Run all `beforeEnter` hooks serially. */
607
+ async runBeforeEnter(scene) {
608
+ for (const h of this.hooks) {
609
+ await h.beforeEnter?.(scene);
610
+ }
611
+ }
612
+ runAfterExit(scene) {
613
+ for (const h of this.hooks) {
614
+ try {
615
+ h.afterExit?.(scene);
616
+ } catch (err) {
617
+ const logger = scene.context.tryResolve(LoggerKey);
618
+ if (logger) {
619
+ logger.error("core", "Scene afterExit hook threw", {
620
+ scene: scene.name,
621
+ error: err
622
+ });
623
+ } else {
624
+ console.error(
625
+ `[yage] Scene afterExit hook threw for scene "${scene.name}":`,
626
+ err
627
+ );
628
+ }
629
+ }
630
+ }
631
+ }
632
+ };
633
+ var SceneHookRegistryKey = new ServiceKey(
634
+ "sceneHookRegistry"
635
+ );
636
+
513
637
  // src/EventToken.ts
514
638
  var EventToken = class {
515
639
  constructor(name) {
@@ -731,10 +855,11 @@ var Component = class {
731
855
  _cleanups;
732
856
  /**
733
857
  * Access the entity's scene. Throws if the entity is not in a scene.
734
- * Prefer this over `this.entity.scene!` in component methods.
858
+ * Prefer this over threading through `this.entity.scene` in component
859
+ * code.
735
860
  */
736
861
  get scene() {
737
- const scene = this.entity.scene;
862
+ const scene = this.entity.tryScene;
738
863
  if (!scene) {
739
864
  throw new Error(
740
865
  "Cannot access scene: entity is not attached to a scene."
@@ -749,20 +874,43 @@ var Component = class {
749
874
  get context() {
750
875
  return this.scene.context;
751
876
  }
752
- /** Resolve a service by key, cached after first lookup. */
877
+ /**
878
+ * Resolve a service by key, cached after first lookup. Scene-scoped values
879
+ * (registered via `scene._registerScoped`) take precedence over engine
880
+ * scope. A key declared with `scope: "scene"` that falls back to engine
881
+ * scope emits a one-shot dev warning — almost always signals a missed
882
+ * `beforeEnter` hook.
883
+ */
753
884
  use(key) {
754
885
  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
- }
886
+ const cached = this._serviceCache.get(key.id);
887
+ if (cached !== void 0) return cached;
888
+ const scene = this.entity.tryScene;
889
+ const scoped = scene?._resolveScoped(key);
890
+ if (scoped !== void 0) {
891
+ this._serviceCache.set(key.id, scoped);
892
+ return scoped;
893
+ }
894
+ const value = this.context.resolve(key);
895
+ if (key.scope === "scene") {
896
+ this._warnScopedFallback(key);
897
+ return value;
898
+ }
899
+ this._serviceCache.set(key.id, value);
760
900
  return value;
761
901
  }
902
+ _warnScopedFallback(key) {
903
+ const logger = this.context.tryResolve(LoggerKey);
904
+ logger?.warn(
905
+ "core",
906
+ `Scoped key "${key.id}" fell back to engine scope \u2014 did a plugin forget to register a beforeEnter hook?`,
907
+ { component: this.constructor.name }
908
+ );
909
+ }
762
910
  /**
763
911
  * Lazy proxy-based service resolution. Can be used at field-declaration time:
764
912
  * ```ts
765
- * readonly camera = this.service(CameraKey);
913
+ * readonly input = this.service(InputManagerKey);
766
914
  * ```
767
915
  * The actual resolution is deferred until first property access.
768
916
  */
@@ -1019,8 +1167,27 @@ var Entity = class {
1019
1167
  this.name = name ?? new.target.name ?? "Entity";
1020
1168
  this.tags = new Set(tags);
1021
1169
  }
1022
- /** The scene this entity belongs to, or null. */
1170
+ /**
1171
+ * The scene this entity belongs to. Throws if the entity is not attached
1172
+ * to a scene — which in practice only happens before `scene.spawn` /
1173
+ * `addChild` wires it up, or after `destroy()` tears it down. Inside
1174
+ * lifecycle methods (`setup`, component `onAdd`, `update`, etc.) this is
1175
+ * always safe to access.
1176
+ *
1177
+ * For the rare case where you genuinely need to inspect whether an
1178
+ * entity has a scene (e.g. defensive code in systems iterating a query
1179
+ * result), use `tryScene` instead.
1180
+ */
1023
1181
  get scene() {
1182
+ if (!this._scene) {
1183
+ throw new Error(
1184
+ `Entity "${this.name}" is not attached to a scene. Use \`tryScene\` if you need to check.`
1185
+ );
1186
+ }
1187
+ return this._scene;
1188
+ }
1189
+ /** The scene this entity belongs to, or `null` if detached. */
1190
+ get tryScene() {
1024
1191
  return this._scene;
1025
1192
  }
1026
1193
  /** True if destroy() has been called. */
@@ -1058,6 +1225,20 @@ var Entity = class {
1058
1225
  this._scene._addExistingEntity(child);
1059
1226
  }
1060
1227
  }
1228
+ spawnChild(name, classOrBlueprint, params) {
1229
+ const scene = this.scene;
1230
+ if (this._children?.has(name)) {
1231
+ throw new Error(
1232
+ `Entity "${this.name}" already has a child named "${name}".`
1233
+ );
1234
+ }
1235
+ const child = classOrBlueprint === void 0 ? scene.spawn(name) : scene.spawn(
1236
+ classOrBlueprint,
1237
+ params
1238
+ );
1239
+ this.addChild(name, child);
1240
+ return child;
1241
+ }
1061
1242
  /** Remove a named child. Returns the detached entity. */
1062
1243
  removeChild(name) {
1063
1244
  const child = this._children?.get(name);
@@ -1550,7 +1731,7 @@ var GameLoop = class {
1550
1731
  }
1551
1732
  /** Process one frame with the given dt in milliseconds. */
1552
1733
  tick(dtMs) {
1553
- if (!this.callbacks) return;
1734
+ if (!this.running || !this.callbacks) return;
1554
1735
  this._frameCount++;
1555
1736
  this.callbacks.earlyUpdate(dtMs);
1556
1737
  this.accumulator += dtMs;
@@ -1578,6 +1759,8 @@ var Scene = class {
1578
1759
  transparentBelow = false;
1579
1760
  /** Asset handles to load before onEnter(). Override in subclasses. */
1580
1761
  preload;
1762
+ /** Default transition used when this scene is the destination of a push/pop/replace. */
1763
+ defaultTransition;
1581
1764
  /** Manual pause flag. Set by game code to pause this scene regardless of stack position. */
1582
1765
  paused = false;
1583
1766
  /** Time scale multiplier for this scene. 1.0 = normal, 0.5 = half speed. Default: 1. */
@@ -1589,6 +1772,7 @@ var Scene = class {
1589
1772
  queryCache;
1590
1773
  bus;
1591
1774
  _entityEventHandlers;
1775
+ _scopedServices;
1592
1776
  /** Access the EngineContext. */
1593
1777
  get context() {
1594
1778
  return this._context;
@@ -1606,6 +1790,11 @@ var Scene = class {
1606
1790
  }
1607
1791
  return false;
1608
1792
  }
1793
+ /** Whether a scene transition is currently running. */
1794
+ get isTransitioning() {
1795
+ const sm = this._context?.tryResolve(SceneManagerKey);
1796
+ return sm?.isTransitioning ?? false;
1797
+ }
1609
1798
  /** Convenience accessor for the AssetManager. */
1610
1799
  get assets() {
1611
1800
  return this._context.resolve(AssetManagerKey);
@@ -1730,6 +1919,31 @@ var Scene = class {
1730
1919
  }
1731
1920
  }
1732
1921
  // ---- Internal methods ----
1922
+ /**
1923
+ * Register a scene-scoped service. Called from a plugin's `beforeEnter`
1924
+ * hook to make per-scene state (render tree, physics world) resolvable via
1925
+ * `Component.use(key)`.
1926
+ * @internal
1927
+ */
1928
+ _registerScoped(key, value) {
1929
+ this._scopedServices ??= /* @__PURE__ */ new Map();
1930
+ this._scopedServices.set(key.id, value);
1931
+ }
1932
+ /**
1933
+ * Resolve a scene-scoped service, or `undefined` if none was registered.
1934
+ * @internal
1935
+ */
1936
+ _resolveScoped(key) {
1937
+ return this._scopedServices?.get(key.id);
1938
+ }
1939
+ /**
1940
+ * Clear all scene-scoped services. Called by the SceneManager after
1941
+ * `afterExit` hooks run, so plugin cleanup code still sees scoped state.
1942
+ * @internal
1943
+ */
1944
+ _clearScopedServices() {
1945
+ this._scopedServices?.clear();
1946
+ }
1733
1947
  /**
1734
1948
  * Set the engine context. Called by SceneManager when the scene is pushed.
1735
1949
  * @internal
@@ -1781,6 +1995,138 @@ var Scene = class {
1781
1995
  }
1782
1996
  };
1783
1997
 
1998
+ // src/LoadingScene.ts
1999
+ var LoadingScene = class extends Scene {
2000
+ static {
2001
+ __name(this, "LoadingScene");
2002
+ }
2003
+ name = "loading";
2004
+ /**
2005
+ * Minimum wall-clock ms the scene stays visible before handing off.
2006
+ * Prevents flicker on cached loads. Default 0.
2007
+ */
2008
+ minDuration = 0;
2009
+ /** Transition used for the loading → target handoff. */
2010
+ transition;
2011
+ /**
2012
+ * When true (default), the handoff fires automatically after loading and
2013
+ * `minDuration`. Set false to gate it behind `continue()` — useful when
2014
+ * the loading scene also asks the player to press a key or click.
2015
+ */
2016
+ autoContinue = true;
2017
+ _progress = 0;
2018
+ _started = false;
2019
+ _active = true;
2020
+ _continueRequested = false;
2021
+ _continueGate;
2022
+ // Bumped on every `_run` attempt. `AssetManager.loadAll` uses `Promise.all`
2023
+ // under the hood, so individual loaders from a failed attempt can still
2024
+ // resolve and fire `onProgress` after the attempt rejects. Without this
2025
+ // guard, a retry kicked off from `onLoadError` would see stale progress
2026
+ // callbacks mutate `_progress` and emit `scene:loading:progress` events
2027
+ // attributed to the current attempt.
2028
+ _attempt = 0;
2029
+ /** Current load progress, 0 → 1. Updated as the AssetManager reports progress. */
2030
+ get progress() {
2031
+ return this._progress;
2032
+ }
2033
+ /**
2034
+ * Kick off asset loading. While a load is in flight, subsequent calls
2035
+ * are no-ops. After a load failure the guard is released, so calling
2036
+ * `startLoading()` from `onLoadError` (or from a retry button) kicks off
2037
+ * a fresh load against the same target.
2038
+ *
2039
+ * Usually called once from `onEnter` after spawning the loading UI:
2040
+ * ```ts
2041
+ * override onEnter() {
2042
+ * this.spawn(LoadingSceneProgressBar);
2043
+ * this.startLoading();
2044
+ * }
2045
+ * ```
2046
+ *
2047
+ * Deferring the call lets you gate the start of the load behind a
2048
+ * title screen, "press any key" prompt, intro animation, etc.
2049
+ */
2050
+ startLoading() {
2051
+ if (this._started) return;
2052
+ this._started = true;
2053
+ this._run().catch((err) => {
2054
+ if (!this._active) return;
2055
+ const logger = this.context.tryResolve(LoggerKey);
2056
+ if (logger) {
2057
+ logger.error("LoadingScene", "loading failed", { error: err });
2058
+ } else {
2059
+ console.error("[LoadingScene] loading failed:", err);
2060
+ }
2061
+ });
2062
+ }
2063
+ /**
2064
+ * Trigger the handoff to `target`. No-op if already called or if
2065
+ * `autoContinue` already fired it. If called before loading finishes,
2066
+ * the handoff runs as soon as loading + `minDuration` complete.
2067
+ */
2068
+ continue() {
2069
+ if (this._continueRequested) return;
2070
+ this._continueRequested = true;
2071
+ this._continueGate?.();
2072
+ }
2073
+ onExit() {
2074
+ this._active = false;
2075
+ this._continueGate?.();
2076
+ }
2077
+ async _run() {
2078
+ await new Promise((resolve) => setTimeout(resolve, 0));
2079
+ if (!this._active) return;
2080
+ const attempt = ++this._attempt;
2081
+ const target = typeof this.target === "function" ? this.target() : this.target;
2082
+ const startedAt = performance.now();
2083
+ const bus = this.context.resolve(EventBusKey);
2084
+ try {
2085
+ await this.assets.loadAll(target.preload ?? [], (ratio) => {
2086
+ if (!this._active || attempt !== this._attempt) return;
2087
+ this._progress = ratio;
2088
+ bus.emit("scene:loading:progress", { scene: this, ratio });
2089
+ });
2090
+ if (!this._active || attempt !== this._attempt) return;
2091
+ const elapsed = performance.now() - startedAt;
2092
+ const remaining = this.minDuration - elapsed;
2093
+ if (remaining > 0) {
2094
+ await new Promise((resolve) => setTimeout(resolve, remaining));
2095
+ if (!this._active || attempt !== this._attempt) return;
2096
+ }
2097
+ } catch (err) {
2098
+ if (!this._active || attempt !== this._attempt) return;
2099
+ const error = err instanceof Error ? err : new Error(String(err));
2100
+ this._started = false;
2101
+ this._attempt++;
2102
+ if (this.onLoadError) {
2103
+ await this.onLoadError(error);
2104
+ return;
2105
+ }
2106
+ throw error;
2107
+ }
2108
+ bus.emit("scene:loading:done", { scene: this });
2109
+ if (!this.autoContinue && !this._continueRequested) {
2110
+ await new Promise((resolve) => {
2111
+ this._continueGate = resolve;
2112
+ });
2113
+ if (!this._active || attempt !== this._attempt) return;
2114
+ }
2115
+ const scenes = this.context.resolve(SceneManagerKey);
2116
+ await scenes.replace(
2117
+ target,
2118
+ this.transition ? { transition: this.transition } : void 0
2119
+ );
2120
+ }
2121
+ };
2122
+
2123
+ // src/SceneTransition.ts
2124
+ function resolveTransition(callSite, destination) {
2125
+ if (callSite) return callSite;
2126
+ return destination?.defaultTransition;
2127
+ }
2128
+ __name(resolveTransition, "resolveTransition");
2129
+
1784
2130
  // src/SceneManager.ts
1785
2131
  var SceneManager = class {
1786
2132
  static {
@@ -1790,6 +2136,35 @@ var SceneManager = class {
1790
2136
  _context;
1791
2137
  bus;
1792
2138
  assetManager;
2139
+ hookRegistry;
2140
+ logger;
2141
+ _currentRun;
2142
+ _pendingChain = Promise.resolve();
2143
+ _mutationDepth = 0;
2144
+ _destroyed = false;
2145
+ _autoPauseOnBlur = false;
2146
+ _isBlurred = false;
2147
+ _visibilityPausedScenes = /* @__PURE__ */ new Set();
2148
+ _visibilityListenerCleanup;
2149
+ /**
2150
+ * Pause all non-paused scenes when `document.hidden` becomes `true`; restore
2151
+ * them on focus. Default: `false`. Only scenes paused by this mechanism are
2152
+ * restored — user-paused scenes (manual `scene.paused = true` or `pauseBelow`
2153
+ * cascade) are never touched.
2154
+ */
2155
+ get autoPauseOnBlur() {
2156
+ return this._autoPauseOnBlur;
2157
+ }
2158
+ set autoPauseOnBlur(value) {
2159
+ if (this._autoPauseOnBlur === value) return;
2160
+ this._autoPauseOnBlur = value;
2161
+ if (!this._isBlurred) return;
2162
+ if (value) {
2163
+ this._applyBlurPause();
2164
+ } else if (this._visibilityPausedScenes.size > 0) {
2165
+ this._restoreBlurPause();
2166
+ }
2167
+ }
1793
2168
  /**
1794
2169
  * Set the engine context.
1795
2170
  * @internal
@@ -1798,6 +2173,42 @@ var SceneManager = class {
1798
2173
  this._context = context;
1799
2174
  this.bus = context.tryResolve(EventBusKey);
1800
2175
  this.assetManager = context.tryResolve(AssetManagerKey);
2176
+ this.hookRegistry = context.tryResolve(SceneHookRegistryKey);
2177
+ this.logger = context.tryResolve(LoggerKey);
2178
+ if (this._visibilityListenerCleanup || typeof document === "undefined") {
2179
+ return;
2180
+ }
2181
+ const onVisibilityChange = /* @__PURE__ */ __name(() => {
2182
+ this._handleVisibilityChange(document.hidden);
2183
+ }, "onVisibilityChange");
2184
+ document.addEventListener("visibilitychange", onVisibilityChange);
2185
+ this._visibilityListenerCleanup = () => document.removeEventListener("visibilitychange", onVisibilityChange);
2186
+ }
2187
+ /**
2188
+ * React to a visibility change. Parameterised on `hidden` so unit tests can
2189
+ * drive it without a real `document`.
2190
+ * @internal
2191
+ */
2192
+ _handleVisibilityChange(hidden) {
2193
+ if (hidden && !this._isBlurred) {
2194
+ this._isBlurred = true;
2195
+ if (this._autoPauseOnBlur) this._applyBlurPause();
2196
+ } else if (!hidden && this._isBlurred) {
2197
+ this._isBlurred = false;
2198
+ if (this._visibilityPausedScenes.size > 0) this._restoreBlurPause();
2199
+ }
2200
+ }
2201
+ _applyBlurPause() {
2202
+ for (const scene of this.activeScenes) {
2203
+ scene.paused = true;
2204
+ this._visibilityPausedScenes.add(scene);
2205
+ }
2206
+ }
2207
+ _restoreBlurPause() {
2208
+ for (const scene of this._visibilityPausedScenes) {
2209
+ scene.paused = false;
2210
+ }
2211
+ this._visibilityPausedScenes.clear();
1801
2212
  }
1802
2213
  /** The topmost (active) scene. */
1803
2214
  get active() {
@@ -1809,84 +2220,132 @@ var SceneManager = class {
1809
2220
  }
1810
2221
  /** All non-paused scenes in the stack, bottom to top. */
1811
2222
  get activeScenes() {
1812
- return this.stack.filter((s) => !s.isPaused);
2223
+ return this.stack.filter((scene) => !scene.isPaused);
2224
+ }
2225
+ /** Whether a scene transition is currently running. */
2226
+ get isTransitioning() {
2227
+ return this._currentRun !== void 0;
1813
2228
  }
1814
2229
  /**
1815
2230
  * Push a scene onto the stack. Scenes below may receive onPause().
1816
2231
  * If the scene declares a `preload` array, assets are loaded before onEnter().
1817
- * Await the returned promise when using preloaded scenes.
1818
2232
  */
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();
2233
+ async push(scene, opts) {
2234
+ this._assertNotMutating("push");
2235
+ await this._enqueue(async () => {
2236
+ const fromScene = this.active;
2237
+ await this._pushScene(scene);
2238
+ const transition = resolveTransition(opts?.transition, scene);
2239
+ if (!transition) return;
2240
+ await this._runTransition("push", transition, fromScene, scene);
2241
+ });
1833
2242
  }
1834
2243
  /** 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;
2244
+ async pop(opts) {
2245
+ this._assertNotMutating("pop");
2246
+ return this._enqueue(async () => {
2247
+ if (this.stack.length === 0) return void 0;
2248
+ const fromScene = this.active;
2249
+ const destination = this.stack.length > 1 ? this.stack[this.stack.length - 2] : void 0;
2250
+ const transition = resolveTransition(opts?.transition, destination);
2251
+ if (transition) {
2252
+ await this._runTransition("pop", transition, fromScene, destination);
2253
+ }
2254
+ return this._popScene();
2255
+ });
1844
2256
  }
1845
2257
  /**
1846
- * Replace the top scene. Old scene receives onExit().
1847
- * New scene receives onEnter() (after preload, if declared).
2258
+ * Replace the top scene. Without a transition the old scene exits first,
2259
+ * then the new scene enters. With a transition the new scene is pushed
2260
+ * first, both scenes coexist for the transition duration, then the old
2261
+ * scene is removed at the end.
1848
2262
  */
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 });
2263
+ async replace(scene, opts) {
2264
+ this._assertNotMutating("replace");
2265
+ await this._enqueue(async () => {
2266
+ const transition = resolveTransition(opts?.transition, scene);
2267
+ if (!transition) {
2268
+ await this._replaceScene(scene);
2269
+ return;
2270
+ }
2271
+ const old = this.active;
2272
+ await this._pushScene(scene, true);
2273
+ await this._runTransition("replace", transition, old, scene);
2274
+ if (old) {
2275
+ this._removeScene(old, true);
2276
+ }
2277
+ this.bus?.emit("scene:replaced", {
2278
+ oldScene: old ?? scene,
2279
+ newScene: scene
2280
+ });
2281
+ });
2282
+ }
2283
+ /**
2284
+ * Pop every scene on the stack, top to bottom. Each receives onExit().
2285
+ * Queued like push/pop/replace — runs after any in-flight transition.
2286
+ * Use for "restart from menu"-style flows. Does not run transitions.
2287
+ */
2288
+ async popAll() {
2289
+ this._assertNotMutating("popAll");
2290
+ await this._enqueue(async () => {
2291
+ this._withMutationSync(() => {
2292
+ while (this.stack.length > 0) {
2293
+ const scene = this.stack.pop();
2294
+ if (!scene) break;
2295
+ this._teardownScene(scene);
2296
+ this.bus?.emit("scene:popped", { scene });
1870
2297
  }
1871
2298
  });
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();
2299
+ });
1880
2300
  }
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
- }
2301
+ /**
2302
+ * Run the full scene-enter lifecycle (beforeEnter hooks, preload, onEnter)
2303
+ * for a scene that is NOT placed on the stack. Used by infrastructure
2304
+ * plugins like DebugPlugin that render a scene off-stack.
2305
+ * @internal
2306
+ */
2307
+ async _mountDetached(scene) {
2308
+ await this._withMutation(async () => {
2309
+ scene._setContext(this._context);
2310
+ await this.hookRegistry?.runBeforeEnter(scene);
2311
+ await this._preloadScene(scene);
2312
+ scene.onEnter?.();
2313
+ });
2314
+ }
2315
+ /**
2316
+ * Run the scene-exit lifecycle (onExit, entity destruction, afterExit
2317
+ * hooks, scoped-service clear) for a detached scene.
2318
+ * @internal
2319
+ */
2320
+ _unmountDetached(scene) {
2321
+ this._withMutationSync(() => {
2322
+ this._teardownScene(scene);
2323
+ });
2324
+ }
2325
+ /**
2326
+ * Mark the manager destroyed and synchronously tear down every scene.
2327
+ * Called by Engine.destroy(). Any queued async work short-circuits on
2328
+ * resume; in-flight transitions' pending promises are resolved via
2329
+ * _cleanupRun so they don't leak.
2330
+ * @internal
2331
+ */
2332
+ _destroy() {
2333
+ this._destroyed = true;
2334
+ if (this._currentRun) {
2335
+ this._cleanupRun(this._currentRun);
2336
+ }
2337
+ this._pendingChain = Promise.resolve();
2338
+ this._visibilityListenerCleanup?.();
2339
+ this._visibilityListenerCleanup = void 0;
2340
+ this._visibilityPausedScenes.clear();
2341
+ this._withMutationSync(() => {
2342
+ while (this.stack.length > 0) {
2343
+ const scene = this.stack.pop();
2344
+ if (!scene) break;
2345
+ this._teardownScene(scene);
2346
+ this.bus?.emit("scene:popped", { scene });
2347
+ }
2348
+ });
1890
2349
  }
1891
2350
  /**
1892
2351
  * Flush destroy queues for all active scenes.
@@ -1898,21 +2357,217 @@ var SceneManager = class {
1898
2357
  scene._flushDestroyQueue();
1899
2358
  }
1900
2359
  }
2360
+ /**
2361
+ * Advance the active transition by `dt` ms. Called by Engine's earlyUpdate
2362
+ * callback with raw (unscaled) wall-clock dt.
2363
+ * @internal
2364
+ */
2365
+ _tickTransition(dt) {
2366
+ const run = this._currentRun;
2367
+ if (!run) return;
2368
+ const remaining = run.transition.duration - run.elapsed;
2369
+ const consume = Math.min(dt, remaining);
2370
+ run.elapsed += consume;
2371
+ this._safeTick(run, consume);
2372
+ if (run.elapsed >= run.transition.duration) {
2373
+ this._cleanupRun(run);
2374
+ }
2375
+ }
2376
+ // ---- Private helpers ----
2377
+ _enqueue(work) {
2378
+ if (this._destroyed) return Promise.resolve(void 0);
2379
+ const next = this._pendingChain.then(async () => {
2380
+ if (this._destroyed) return void 0;
2381
+ return work();
2382
+ });
2383
+ this._pendingChain = next.then(
2384
+ () => void 0,
2385
+ () => void 0
2386
+ );
2387
+ return next;
2388
+ }
2389
+ async _pushScene(scene, suppressEvent = false) {
2390
+ const wasPaused = this._snapshotPauseStates();
2391
+ await this._withMutation(async () => {
2392
+ scene._setContext(this._context);
2393
+ await this.hookRegistry?.runBeforeEnter(scene);
2394
+ await this._preloadScene(scene);
2395
+ this.stack.push(scene);
2396
+ scene.onEnter?.();
2397
+ this._firePauseTransitions(wasPaused);
2398
+ if (!suppressEvent) {
2399
+ this.bus?.emit("scene:pushed", { scene });
2400
+ }
2401
+ });
2402
+ }
2403
+ _popScene(suppressEvent = false) {
2404
+ const wasPaused = this._snapshotPauseStates();
2405
+ return this._withMutationSync(() => {
2406
+ const removed = this.stack.pop();
2407
+ if (!removed) return void 0;
2408
+ this._teardownScene(removed);
2409
+ this._fireResumeTransitions(wasPaused);
2410
+ if (!suppressEvent) {
2411
+ this.bus?.emit("scene:popped", { scene: removed });
2412
+ }
2413
+ return removed;
2414
+ });
2415
+ }
2416
+ async _replaceScene(scene) {
2417
+ const wasPaused = this._snapshotPauseStates();
2418
+ await this._withMutation(async () => {
2419
+ scene._setContext(this._context);
2420
+ await this.hookRegistry?.runBeforeEnter(scene);
2421
+ await this._preloadScene(scene);
2422
+ const old = this.stack.pop();
2423
+ if (old) this._teardownScene(old);
2424
+ this.stack.push(scene);
2425
+ scene.onEnter?.();
2426
+ this._firePauseTransitions(wasPaused);
2427
+ this._fireResumeTransitions(wasPaused);
2428
+ this.bus?.emit("scene:replaced", {
2429
+ oldScene: old ?? scene,
2430
+ newScene: scene
2431
+ });
2432
+ });
2433
+ }
2434
+ _removeScene(scene, suppressEvent = false) {
2435
+ this._withMutationSync(() => {
2436
+ const idx = this.stack.indexOf(scene);
2437
+ if (idx === -1) return;
2438
+ const wasPaused = this._snapshotPauseStates();
2439
+ this.stack.splice(idx, 1);
2440
+ this._teardownScene(scene);
2441
+ this._firePauseTransitions(wasPaused);
2442
+ this._fireResumeTransitions(wasPaused);
2443
+ if (!suppressEvent) {
2444
+ this.bus?.emit("scene:popped", { scene });
2445
+ }
2446
+ });
2447
+ }
2448
+ async _preloadScene(scene) {
2449
+ if (!scene.preload?.length || !this.assetManager) return;
2450
+ await this.assetManager.loadAll(
2451
+ scene.preload,
2452
+ scene.onProgress?.bind(scene)
2453
+ );
2454
+ }
2455
+ _teardownScene(scene) {
2456
+ scene.onExit?.();
2457
+ scene._destroyAllEntities();
2458
+ this.hookRegistry?.runAfterExit(scene);
2459
+ scene._clearScopedServices();
2460
+ this._visibilityPausedScenes.delete(scene);
2461
+ }
2462
+ async _runTransition(kind, transition, fromScene, toScene) {
2463
+ if (this._destroyed) return;
2464
+ let resolveRun;
2465
+ const promise = new Promise((resolve) => {
2466
+ resolveRun = resolve;
2467
+ });
2468
+ const run = {
2469
+ kind,
2470
+ transition,
2471
+ elapsed: 0,
2472
+ fromScene,
2473
+ toScene,
2474
+ resolve: resolveRun
2475
+ };
2476
+ this._currentRun = run;
2477
+ this.bus?.emit("scene:transition:started", {
2478
+ kind,
2479
+ fromScene,
2480
+ toScene
2481
+ });
2482
+ this._safeCall(run, "begin");
2483
+ if (!Number.isFinite(transition.duration) || transition.duration <= 0) {
2484
+ this._cleanupRun(run);
2485
+ return;
2486
+ }
2487
+ await promise;
2488
+ }
2489
+ _cleanupRun(run) {
2490
+ if (this._currentRun !== run) return;
2491
+ this._safeCall(run, "end");
2492
+ this._currentRun = void 0;
2493
+ this.bus?.emit("scene:transition:ended", {
2494
+ kind: run.kind,
2495
+ fromScene: run.fromScene,
2496
+ toScene: run.toScene
2497
+ });
2498
+ run.resolve();
2499
+ }
2500
+ _safeTick(run, dt) {
2501
+ try {
2502
+ run.transition.tick(dt, this._makeContext(run));
2503
+ } catch (err) {
2504
+ this.logger?.warn(
2505
+ "SceneManager",
2506
+ `Transition tick error: ${err instanceof Error ? err.message : String(err)}`
2507
+ );
2508
+ }
2509
+ }
2510
+ _safeCall(run, method) {
2511
+ try {
2512
+ run.transition[method]?.(this._makeContext(run));
2513
+ } catch (err) {
2514
+ this.logger?.warn(
2515
+ "SceneManager",
2516
+ `Transition ${method} error: ${err instanceof Error ? err.message : String(err)}`
2517
+ );
2518
+ }
2519
+ }
2520
+ _makeContext(run) {
2521
+ return {
2522
+ elapsed: run.elapsed,
2523
+ kind: run.kind,
2524
+ engineContext: this._context,
2525
+ fromScene: run.fromScene,
2526
+ toScene: run.toScene
2527
+ };
2528
+ }
2529
+ _snapshotPauseStates() {
2530
+ return new Map(
2531
+ this.stack.map((scene) => [scene, scene.isPaused])
2532
+ );
2533
+ }
2534
+ _assertNotMutating(method) {
2535
+ if (this._mutationDepth === 0) return;
2536
+ throw new Error(
2537
+ `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().`
2538
+ );
2539
+ }
2540
+ async _withMutation(work) {
2541
+ this._mutationDepth++;
2542
+ try {
2543
+ return await work();
2544
+ } finally {
2545
+ this._mutationDepth--;
2546
+ }
2547
+ }
2548
+ _withMutationSync(work) {
2549
+ this._mutationDepth++;
2550
+ try {
2551
+ return work();
2552
+ } finally {
2553
+ this._mutationDepth--;
2554
+ }
2555
+ }
1901
2556
  /** Fire onPause() for scenes that transitioned from not-paused to paused. */
1902
2557
  _firePauseTransitions(wasPaused) {
1903
- for (const s of this.stack) {
1904
- const was = wasPaused.get(s) ?? false;
1905
- if (s.isPaused && !was) {
1906
- s.onPause?.();
2558
+ for (const scene of this.stack) {
2559
+ const was = wasPaused.get(scene) ?? false;
2560
+ if (scene.isPaused && !was) {
2561
+ scene.onPause?.();
1907
2562
  }
1908
2563
  }
1909
2564
  }
1910
2565
  /** Fire onResume() for scenes that transitioned from paused to not-paused. */
1911
2566
  _fireResumeTransitions(wasPaused) {
1912
- for (const s of this.stack) {
1913
- const was = wasPaused.get(s) ?? false;
1914
- if (!s.isPaused && was) {
1915
- s.onResume?.();
2567
+ for (const scene of this.stack) {
2568
+ const was = wasPaused.get(scene) ?? false;
2569
+ if (!scene.isPaused && was) {
2570
+ scene.onResume?.();
1916
2571
  }
1917
2572
  }
1918
2573
  }
@@ -2749,6 +3404,7 @@ var Engine = class {
2749
3404
  scheduler;
2750
3405
  errorBoundary;
2751
3406
  queryCache;
3407
+ sceneHooks;
2752
3408
  /** The asset manager. */
2753
3409
  assets;
2754
3410
  plugins = /* @__PURE__ */ new Map();
@@ -2767,6 +3423,7 @@ var Engine = class {
2767
3423
  this.scheduler = new SystemScheduler();
2768
3424
  this.inspector = new Inspector(this);
2769
3425
  this.assets = new AssetManager();
3426
+ this.sceneHooks = new SceneHookRegistry();
2770
3427
  this.scheduler.setErrorBoundary(this.errorBoundary);
2771
3428
  this.context.register(EngineKey, this);
2772
3429
  this.context.register(EventBusKey, this.events);
@@ -2778,11 +3435,13 @@ var Engine = class {
2778
3435
  this.context.register(InspectorKey, this.inspector);
2779
3436
  this.context.register(SystemSchedulerKey, this.scheduler);
2780
3437
  this.context.register(AssetManagerKey, this.assets);
3438
+ this.context.register(SceneHookRegistryKey, this.sceneHooks);
2781
3439
  this.scenes._setContext(this.context);
2782
3440
  this.registerBuiltInSystems();
2783
3441
  this.loop.setCallbacks({
2784
3442
  earlyUpdate: /* @__PURE__ */ __name((dt) => {
2785
3443
  this.logger.setFrame(this.loop.frameCount);
3444
+ this.scenes._tickTransition(dt);
2786
3445
  this.scheduler.run("earlyUpdate" /* EarlyUpdate */, dt);
2787
3446
  }, "earlyUpdate"),
2788
3447
  fixedUpdate: /* @__PURE__ */ __name((dt) => this.scheduler.run("fixedUpdate" /* FixedUpdate */, dt), "fixedUpdate"),
@@ -2795,6 +3454,14 @@ var Engine = class {
2795
3454
  }, "endOfFrame")
2796
3455
  });
2797
3456
  }
3457
+ /**
3458
+ * Register scene lifecycle hooks. The returned function unregisters the
3459
+ * hooks. Infrastructure plugins (renderer, physics, debug) register hooks
3460
+ * in their `install` or `onStart` to set up and tear down per-scene state.
3461
+ */
3462
+ registerSceneHooks(hooks) {
3463
+ return this.sceneHooks.register(hooks);
3464
+ }
2798
3465
  /** Register a plugin. Must be called before start(). */
2799
3466
  use(plugin) {
2800
3467
  if (this.started) {
@@ -2830,7 +3497,7 @@ var Engine = class {
2830
3497
  };
2831
3498
  }
2832
3499
  for (const plugin of sorted) {
2833
- plugin.onStart?.();
3500
+ await plugin.onStart?.();
2834
3501
  }
2835
3502
  this.events.emit("engine:started", void 0);
2836
3503
  }
@@ -2838,7 +3505,7 @@ var Engine = class {
2838
3505
  destroy() {
2839
3506
  this.events.emit("engine:stopped", void 0);
2840
3507
  this.loop.stop();
2841
- this.scenes.clear();
3508
+ this.scenes._destroy();
2842
3509
  const allSystems = this.scheduler.getAllSystems();
2843
3510
  for (let i = allSystems.length - 1; i >= 0; i--) {
2844
3511
  allSystems[i].onUnregister?.();
@@ -2912,6 +3579,11 @@ var Engine = class {
2912
3579
  }
2913
3580
  };
2914
3581
 
3582
+ // src/RendererAdapter.ts
3583
+ var RendererAdapterKey = new ServiceKey(
3584
+ "rendererAdapter"
3585
+ );
3586
+
2915
3587
  // src/test-utils.ts
2916
3588
  var _TestScene = class extends Scene {
2917
3589
  static {
@@ -2982,6 +3654,7 @@ var VERSION = "0.0.0";
2982
3654
  Inspector,
2983
3655
  InspectorKey,
2984
3656
  KeyframeAnimator,
3657
+ LoadingScene,
2985
3658
  LogLevel,
2986
3659
  Logger,
2987
3660
  LoggerKey,
@@ -2995,8 +3668,11 @@ var VERSION = "0.0.0";
2995
3668
  QueryCache,
2996
3669
  QueryCacheKey,
2997
3670
  QueryResult,
3671
+ RendererAdapterKey,
2998
3672
  SERIALIZABLE_KEY,
2999
3673
  Scene,
3674
+ SceneHookRegistry,
3675
+ SceneHookRegistryKey,
3000
3676
  SceneManager,
3001
3677
  SceneManagerKey,
3002
3678
  Sequence,
@@ -3029,6 +3705,7 @@ var VERSION = "0.0.0";
3029
3705
  getSerializableType,
3030
3706
  interpolate,
3031
3707
  isSerializable,
3708
+ resolveTransition,
3032
3709
  serializable,
3033
3710
  trait
3034
3711
  });