@vgai/engine 0.5.2 → 0.5.3
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/README.md +18 -11
- package/package.json +10 -6
- package/schemas/engine-api.json +1 -68
- package/schemas/engine-api.md +1 -32
- package/schemas/engine-capabilities.json +30 -42
- package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
- package/src/adapter/adapter-surface.ts +5 -5
- package/src/adapter/authoring.ts +168 -226
- package/src/adapter/colyseus-networking-adapter.ts +44 -5
- package/src/adapter/first-party-systems.ts +156 -42
- package/src/adapter/host-context.ts +177 -46
- package/src/adapter/index.ts +47 -51
- package/src/adapter/ingest/game-contract.ts +2 -2
- package/src/adapter/ingest/scene-capture.ts +18 -19
- package/src/adapter/ingest/structural-ids.ts +127 -0
- package/src/adapter/ingest/upstream-pin.ts +9 -12
- package/src/adapter/loop-gate-report.ts +11 -11
- package/src/adapter/rapier-physics-adapter.ts +27 -9
- package/src/adapter/root-adapter.ts +217 -0
- package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
- package/src/adapter/system-adapter.ts +80 -63
- package/src/ai/navigation.ts +1 -1
- package/src/animation/animation-clock.ts +1 -1
- package/src/animation/camera-ownership.ts +1 -2
- package/src/animation/cubic-spline-interpolant.ts +132 -0
- package/src/animation/theatre-clock-binding.ts +2 -2
- package/src/animation/theatre-object-binding.ts +4 -4
- package/src/animation/xstate-animation-binding.ts +75 -5
- package/src/{scene/schema → asset-formats}/camera.ts +2 -5
- package/src/{scene/schema → asset-formats}/collider.ts +2 -5
- package/src/asset-formats/index.ts +54 -0
- package/src/{scene/schema → asset-formats}/instances.ts +10 -6
- package/src/{scene/schema → asset-formats}/light.ts +3 -6
- package/src/{scene/schema → asset-formats}/material.ts +4 -7
- package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
- package/src/asset-formats/parse.ts +39 -0
- package/src/{scene/schema → asset-formats}/particles.ts +3 -6
- package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
- package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
- package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
- package/src/asset-parse-error.ts +33 -0
- package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
- package/src/assets.ts +1 -1
- package/src/audio/wav-encode.ts +9 -9
- package/src/canvas-react/engine-bridge.ts +59 -0
- package/src/canvas-react/index.ts +50 -0
- package/src/canvas-react/pixi-primitive.tsx +202 -0
- package/src/canvas-react/pixi-react-adapter.tsx +290 -0
- package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
- package/src/canvas-react/world-context.ts +328 -0
- package/src/core/frame-pacing.ts +100 -0
- package/src/core/game-loop.ts +50 -28
- package/src/core/seeded-random.ts +7 -7
- package/src/core/sim-clock.ts +388 -0
- package/src/core/system-runner.ts +17 -63
- package/src/core/types.ts +34 -15
- package/src/data/data-asset.ts +3 -3
- package/src/data/data-check-core.ts +6 -7
- package/src/data/data-ref.ts +11 -11
- package/src/data/vite-plugin-data.ts +10 -10
- package/src/{scene/defaults.ts → defaults.ts} +18 -40
- package/src/dev/render-debug-adapter.ts +1 -1
- package/src/dev/webgl-frame-capture.ts +1 -1
- package/src/ecs/scene-index.ts +439 -0
- package/src/ecs/scene-query.ts +43 -0
- package/src/{scene → ecs}/user-data.ts +17 -36
- package/src/index.ts +7 -9
- package/src/input/input-manager.ts +29 -32
- package/src/input/input-types.ts +2 -2
- package/src/input/schema.ts +5 -5
- package/src/loader.ts +57 -0
- package/src/manifest/editor-port.ts +69 -0
- package/src/manifest/filename.ts +49 -0
- package/src/manifest/index.ts +8 -2
- package/src/manifest/load-file.ts +11 -0
- package/src/manifest/load.ts +65 -77
- package/src/manifest/locate.ts +55 -0
- package/src/manifest/schema.ts +400 -233
- package/src/{scene → physics}/collider-dimensions.ts +3 -3
- package/src/physics/physics-registry.ts +1 -1
- package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
- package/src/pixi/index.ts +43 -0
- package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
- package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
- package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
- package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
- package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
- package/src/react/unmanaged-root-detector.ts +26 -1
- package/src/react/use-data.ts +5 -5
- package/src/react/use-selection.tsx +15 -42
- package/src/react/{game-state.tsx → world-state.tsx} +44 -46
- package/src/render/auto-batcher.ts +1 -2
- package/src/{scene → render}/instance-mesh.ts +1 -1
- package/src/{scene → render}/light-camera-factory.ts +14 -13
- package/src/render/lod.ts +17 -0
- package/src/{scene → render}/material-factory.ts +8 -7
- package/src/{scene → render}/particles-factory.ts +62 -12
- package/src/render/render-batch-system.ts +14 -41
- package/src/render/render-features.ts +1 -1
- package/src/render/render-settings.ts +1 -2
- package/src/render/spark-renderer-lifecycle.ts +1 -1
- package/src/runtime/create-runtime.ts +352 -463
- package/src/runtime/debug-bridge.ts +148 -93
- package/src/runtime/debug-registry.ts +79 -54
- package/src/runtime/dev-layers.ts +40 -0
- package/src/runtime/frame-selector-cache.ts +4 -4
- package/src/runtime/game.ts +618 -369
- package/src/runtime/gameplay-rng-trap.ts +6 -7
- package/src/runtime/input-router.ts +11 -11
- package/src/runtime/mount-game.ts +54 -55
- package/src/runtime/mount-manifest.ts +154 -150
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-audio-control.ts +64 -53
- package/src/runtime/render-control.ts +45 -51
- package/src/runtime/render-seed.ts +3 -4
- package/src/runtime/state-bridge.ts +17 -18
- package/src/runtime/types.ts +94 -61
- package/src/setup/setup-renderer.ts +11 -6
- package/src/world3d-react/engine-bridge.ts +46 -33
- package/src/world3d-react/index.ts +31 -26
- package/src/world3d-react/r3f-adapter.tsx +211 -96
- package/src/world3d-react/r3f-root-factory.tsx +91 -0
- package/src/world3d-react/renderer-config.ts +137 -0
- package/src/world3d-react/world-context.ts +141 -111
- package/schemas/entity2d.schema.json +0 -468
- package/schemas/prefab.schema.json +0 -9992
- package/schemas/scn2d.schema.json +0 -494
- package/schemas/vscn.schema.json +0 -10851
- package/src/adapter/game-adapter.ts +0 -164
- package/src/adapter/ingest/overlay-applier.ts +0 -207
- package/src/adapter/ingest/overlay-apply.ts +0 -168
- package/src/adapter/ingest/overlay-file.ts +0 -126
- package/src/adapter/ingest/overlay-report.ts +0 -176
- package/src/animation/gsap-registration.ts +0 -184
- package/src/audio/audio-introspection.ts +0 -290
- package/src/audio/index.ts +0 -39
- package/src/audio/tone-clock-binding.ts +0 -98
- package/src/audio/tone-context.ts +0 -175
- package/src/audio/tone-offline-render.ts +0 -167
- package/src/ecs/component-manager.ts +0 -814
- package/src/ecs/game-component.ts +0 -260
- package/src/ecs/hmr-swap-report.ts +0 -65
- package/src/physics/trigger-dispatch.ts +0 -97
- package/src/react/root-adapter.tsx +0 -49
- package/src/scene/asset-paths.ts +0 -121
- package/src/scene/asset-ref-check.ts +0 -248
- package/src/scene/component-registry.ts +0 -51
- package/src/scene/parse.ts +0 -204
- package/src/scene/scene-apply.ts +0 -407
- package/src/scene/scene-diff-schema.ts +0 -115
- package/src/scene/scene-diff-types.ts +0 -29
- package/src/scene/scene-loader.ts +0 -1526
- package/src/scene/scene-query.ts +0 -63
- package/src/scene/scene-types.ts +0 -33
- package/src/scene/scene-version.ts +0 -40
- package/src/scene/schema/animation.ts +0 -46
- package/src/scene/schema/audio.ts +0 -25
- package/src/scene/schema/entity-ref.ts +0 -78
- package/src/scene/schema/entity.ts +0 -189
- package/src/scene/schema/index.ts +0 -51
- package/src/scene/schema/joint.ts +0 -26
- package/src/scene/schema/physics.ts +0 -49
- package/src/scene/schema/scene-file.ts +0 -292
- package/src/scene/schema/shadow.ts +0 -24
- package/src/scene/schema/spline.ts +0 -21
- package/src/world2d/asset-paths2d.ts +0 -44
- package/src/world2d/capture-to-scene2d.ts +0 -52
- package/src/world2d/collision-2d.ts +0 -99
- package/src/world2d/entity2d-asset.ts +0 -22
- package/src/world2d/index.ts +0 -91
- package/src/world2d/physics2d-transform.ts +0 -173
- package/src/world2d/physics2d-units.ts +0 -10
- package/src/world2d/pixi-game-adapter.ts +0 -439
- package/src/world2d/pixi-surface.ts +0 -78
- package/src/world2d/scene2d-identity.ts +0 -49
- package/src/world2d/scene2d-loader.ts +0 -433
- package/src/world2d/schema/entity2d.ts +0 -163
- package/src/world2d/schema/physics2d.ts +0 -64
- package/src/world2d/schema/sprite.ts +0 -99
- package/src/world2d/schema/tilemap.ts +0 -39
- package/src/world2d/schema/tuples2d.ts +0 -25
- package/src/world2d/transform-writer-2d.ts +0 -42
- package/src/world2d/types.ts +0 -74
- package/src/world3d-react/behavior.tsx +0 -146
- /package/src/{scene → render}/mesh-shadow.ts +0 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SimClock` — P3, the sim-time scheduler (punchlist item P3, rung 4).
|
|
3
|
+
*
|
|
4
|
+
* `await` **is** the scheduler; JS already won that argument. The only thing
|
|
5
|
+
* missing from the engine was *clock binding*: a `delay(seconds)` that resolves
|
|
6
|
+
* on the fixed loop's own accumulator instead of on wall time, so a timer
|
|
7
|
+
* pauses when the game pauses, steps when the game steps, and reproduces
|
|
8
|
+
* exactly in an offline export. That, plus a timed-dispose helper for debris,
|
|
9
|
+
* is this whole module.
|
|
10
|
+
*
|
|
11
|
+
* ## This is NOT `AnimationClock`, and it never will be
|
|
12
|
+
*
|
|
13
|
+
* `animation/animation-clock.ts` is a seekable **cinematic** clock: it plays,
|
|
14
|
+
* pauses, loops, runs in reverse and is `seek()`ed to arbitrary times so
|
|
15
|
+
* Theatre/GSAP timelines and cue evaluators can be scrubbed. `SimClock` is the
|
|
16
|
+
* **monotonic gameplay clock**: it only ever moves forward, one fixed substep
|
|
17
|
+
* at a time, and there is no seek. Both live in one engine because they answer
|
|
18
|
+
* different questions — "where is the cinematic playhead" versus "how much
|
|
19
|
+
* gameplay time has actually elapsed".
|
|
20
|
+
*
|
|
21
|
+
* The reason they can never merge is a fact about Promises: **a Promise cannot
|
|
22
|
+
* un-resolve.** Scrub a seekable clock backwards past a `delay` that already
|
|
23
|
+
* fired and there is no correct behavior — you cannot un-await it. So `delay`
|
|
24
|
+
* binds to the clock that cannot rewind, and cinematic scrubbing stays with the
|
|
25
|
+
* clock that can.
|
|
26
|
+
*
|
|
27
|
+
* ## Two forms, because sync and async are genuinely different here
|
|
28
|
+
*
|
|
29
|
+
* `Game.runTicks` is **synchronous** (`runtime/game.ts`): it runs N substeps in
|
|
30
|
+
* one straight-line loop. A `delay` continuation is a microtask, so under
|
|
31
|
+
* `runTicks` — and under an offline export driven the same way — it does *not*
|
|
32
|
+
* interleave between ticks; it runs when the caller's stack unwinds, after the
|
|
33
|
+
* whole run. Under the ordinary rAF loop each frame yields, so the difference
|
|
34
|
+
* is invisible.
|
|
35
|
+
*
|
|
36
|
+
* Therefore:
|
|
37
|
+
*
|
|
38
|
+
* - `await clock.delay(n)` is the **ergonomic** form. Use it for gameplay.
|
|
39
|
+
* - `clock.after(n, fn)` is the **exact** form: `fn` is invoked inline, during
|
|
40
|
+
* the flush of the exact due tick, under every driver — rAF, `runTicks`,
|
|
41
|
+
* `step()`, offline export.
|
|
42
|
+
* - `clock.disposeAfter(obj, n)` is built on `after`, so debris removal is
|
|
43
|
+
* frame-exact in an offline export rather than "some time after the run".
|
|
44
|
+
*
|
|
45
|
+
* ## Firing rule (deterministic — pinned by `test/sim-clock.test.ts`)
|
|
46
|
+
*
|
|
47
|
+
* - The runtime calls `flush(simT)` immediately after it bumps `tick`/`simT`,
|
|
48
|
+
* inside the same `advanced` guard — so a paused or frozen world fires no
|
|
49
|
+
* timers at all, and `Game.play.step()` fires exactly the timers that one
|
|
50
|
+
* substep makes due.
|
|
51
|
+
* - A timer is due when `simT >= scheduledAt + seconds`.
|
|
52
|
+
* - Due timers run in due-time ascending order, insertion-sequence ascending
|
|
53
|
+
* on ties.
|
|
54
|
+
* - A timer scheduled *during* a flush is never due in that same flush, even
|
|
55
|
+
* at `seconds === 0`. That is what makes a self-rescheduling timer unable to
|
|
56
|
+
* hang the frame.
|
|
57
|
+
*
|
|
58
|
+
* ## Cancellation is `AbortSignal`
|
|
59
|
+
*
|
|
60
|
+
* The same rung-1 web primitive the rest of this program uses. An
|
|
61
|
+
* already-aborted signal makes `delay` reject immediately and `after` a no-op;
|
|
62
|
+
* rejection is a `DOMException` with `name === 'AbortError'`, matching `fetch`.
|
|
63
|
+
* Disposing the clock rejects every pending `delay` the same way — game code
|
|
64
|
+
* awaiting a delay is expected to tolerate rejection exactly as aborted `fetch`
|
|
65
|
+
* callers do.
|
|
66
|
+
*
|
|
67
|
+
* ## Ownership: the clock is GAME-scoped, and only the Game disposes it
|
|
68
|
+
*
|
|
69
|
+
* One clock per `Game`, shared by every world on it (`getSimClock(game)`); a
|
|
70
|
+
* mount with no Game shell builds a private, mount-local one instead. Whoever
|
|
71
|
+
* CREATED a clock disposes it: `GameInternal.dispose()` disposes the
|
|
72
|
+
* game-scoped one — `runtime/create-runtime.ts` calls that after every root has
|
|
73
|
+
* torn down — and a root adapter disposes only its own mount-local fallback.
|
|
74
|
+
* A per-root teardown must NEVER dispose the game-scoped clock: disposing one
|
|
75
|
+
* world's `mounted` is a supported way to end a sub-session while the Game
|
|
76
|
+
* keeps running, and doing so would freeze `now()`, reject every sibling
|
|
77
|
+
* world's pending `delay` and turn its later `after()` calls into silent
|
|
78
|
+
* no-ops. (`seededRandom` and `debugRegistry` are game-scoped the same way, and
|
|
79
|
+
* per-root teardown has never destroyed either — that asymmetry is what
|
|
80
|
+
* exposed the bug.)
|
|
81
|
+
*
|
|
82
|
+
* ## Cancelling your own timers is the GAME's job — warm restart will not
|
|
83
|
+
*
|
|
84
|
+
* A timer outlives the code that scheduled it unless something cancels it, and
|
|
85
|
+
* `hotReload` (warm restart) deliberately does not reach the game-scoped clock:
|
|
86
|
+
* it is per-ROOT, so cancelling here would kill a sibling root's live timers —
|
|
87
|
+
* the same ownership violation as above. There is deliberately no `reset()`.
|
|
88
|
+
*
|
|
89
|
+
* So a stale `after` closure surviving a warm restart is the same hazard class
|
|
90
|
+
* as a stale event listener, and it has the same owner and the same remedy: the
|
|
91
|
+
* game's own cleanup. `hotReload` runs the game's `dispose()` first for exactly
|
|
92
|
+
* this reason, and both `after` and `delay` take an `AbortSignal` so one
|
|
93
|
+
* controller cancels everything a `setup()` scheduled:
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* export async function setup(ctx: GameContext): Promise<GameCleanup> {
|
|
97
|
+
* const ac = new AbortController();
|
|
98
|
+
* ctx.clock.after(3, () => spawnWave(), { signal: ac.signal });
|
|
99
|
+
* return { dispose: () => ac.abort() }; // cancels timers AND listeners
|
|
100
|
+
* }
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* This is the engine-wide "Examples must clean up" contract (CLAUDE.md), not a
|
|
104
|
+
* special rule for timers.
|
|
105
|
+
*
|
|
106
|
+
* ## Deliberately absent
|
|
107
|
+
*
|
|
108
|
+
* - **No `every`/`repeat`/interval.** Nothing needs one, and `after`
|
|
109
|
+
* re-arming itself is three lines the game owns.
|
|
110
|
+
* - **No fiber kernel, no coroutine emulation, no `task.spawn`.** Calling an
|
|
111
|
+
* `async function` *is* `task.spawn` — the language already has it.
|
|
112
|
+
* - **No wall clock.** This module reads no system timer and creates no
|
|
113
|
+
* host timeout; every time value comes from the runtime's own accumulator.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
import type * as THREE from 'three';
|
|
117
|
+
|
|
118
|
+
/** Cancel handle returned by {@link SimClock.after}/{@link SimClock.disposeAfter}. */
|
|
119
|
+
export interface SimTimerHandle {
|
|
120
|
+
/** Cancel the timer if it has not fired yet. Idempotent. */
|
|
121
|
+
cancel(): void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Options accepted by every scheduling call. */
|
|
125
|
+
export interface SimScheduleOptions {
|
|
126
|
+
/** Cancel/reject when this signal aborts (already-aborted is honored too). */
|
|
127
|
+
readonly signal?: AbortSignal | undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The sim clock as GAME CODE sees it — the whole public surface. */
|
|
131
|
+
export interface SimClock {
|
|
132
|
+
/** Sim seconds elapsed: the fixed loop's own accumulator. Monotonic. */
|
|
133
|
+
now(): number;
|
|
134
|
+
/** Completed fixed substeps — the same counter `Game`'s `tick` carries. */
|
|
135
|
+
tickNow(): number;
|
|
136
|
+
/**
|
|
137
|
+
* Resolve after `seconds` of SIM time. The ergonomic form; see the module
|
|
138
|
+
* header for why it is not frame-exact under a synchronous driver.
|
|
139
|
+
* Rejects with an `AbortError` `DOMException` if `opts.signal` aborts (or is
|
|
140
|
+
* already aborted), or if the clock is disposed while it is pending.
|
|
141
|
+
*/
|
|
142
|
+
delay(seconds: number, opts?: SimScheduleOptions): Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* Invoke `fn` inline during the flush of the exact due tick — the exact
|
|
145
|
+
* form. A no-op if `opts.signal` is already aborted.
|
|
146
|
+
*/
|
|
147
|
+
after(seconds: number, fn: () => void, opts?: SimScheduleOptions): SimTimerHandle;
|
|
148
|
+
/**
|
|
149
|
+
* Debris: dispose `obj` after `seconds` of sim time. Built on {@link after},
|
|
150
|
+
* so removal lands on an exact tick. The disposal itself is supplied by the
|
|
151
|
+
* runtime (see {@link SimClockOptions.dispose}) — this module knows nothing
|
|
152
|
+
* about physics or rendering.
|
|
153
|
+
*/
|
|
154
|
+
disposeAfter(obj: THREE.Object3D, seconds: number): SimTimerHandle;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The runtime-facing half — NOT for game code. Split off the public
|
|
159
|
+
* {@link SimClock} the same way `runtime/game.ts` splits `GameInternal` off
|
|
160
|
+
* `Game`: `GameContext.clock` is typed as `SimClock`, so game code cannot
|
|
161
|
+
* reach `flush`/`dispose`.
|
|
162
|
+
*/
|
|
163
|
+
export interface SimClockInternal extends SimClock {
|
|
164
|
+
/**
|
|
165
|
+
* Advance to `simT` and fire everything now due. Called by `runFrameImpl`
|
|
166
|
+
* immediately after the `tick`/`simT` bump, inside the `advanced` guard.
|
|
167
|
+
* One call === one completed substep, which is why {@link SimClock.tickNow}
|
|
168
|
+
* can simply count them.
|
|
169
|
+
*/
|
|
170
|
+
flush(simT: number): void;
|
|
171
|
+
/**
|
|
172
|
+
* Drop every pending timer and reject every pending `delay`. Called by
|
|
173
|
+
* whoever CREATED this clock and by nobody else — `GameInternal.dispose()`
|
|
174
|
+
* for the game-scoped one, the owning mount for a mount-local fallback. See
|
|
175
|
+
* the module header's ownership section.
|
|
176
|
+
*/
|
|
177
|
+
dispose(): void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface SimClockOptions {
|
|
181
|
+
/**
|
|
182
|
+
* How {@link SimClock.disposeAfter} disposes an object. Supplied by the
|
|
183
|
+
* runtime, which is the only layer that knows about physics
|
|
184
|
+
* registries and shared geometry; the clock only knows *when*.
|
|
185
|
+
*/
|
|
186
|
+
readonly dispose: (obj: THREE.Object3D) => void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface SimTimer {
|
|
190
|
+
readonly dueAt: number;
|
|
191
|
+
readonly seq: number;
|
|
192
|
+
readonly fn: () => void;
|
|
193
|
+
cancelled: boolean;
|
|
194
|
+
detachAbort: (() => void) | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const NOOP_HANDLE: SimTimerHandle = { cancel() {} };
|
|
198
|
+
|
|
199
|
+
/** `fetch`-shaped abort rejection: a `DOMException` named `AbortError`. */
|
|
200
|
+
function abortError(message: string): DOMException {
|
|
201
|
+
return new DOMException(message, 'AbortError');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function assertSeconds(method: string, seconds: number): void {
|
|
205
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
206
|
+
throw new RangeError(
|
|
207
|
+
`SimClock.${method}: seconds must be a finite, non-negative number, got ${seconds}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build the one sim clock a `Game` owns. See the module header for the
|
|
214
|
+
* contract; `runtime/game.ts` is the only production caller.
|
|
215
|
+
*/
|
|
216
|
+
export function createSimClock(options: SimClockOptions): SimClockInternal {
|
|
217
|
+
const disposeObject = options.dispose;
|
|
218
|
+
|
|
219
|
+
let simT = 0;
|
|
220
|
+
let ticks = 0;
|
|
221
|
+
let seq = 0;
|
|
222
|
+
let disposed = false;
|
|
223
|
+
|
|
224
|
+
const pending = new Set<SimTimer>();
|
|
225
|
+
/** Rejectors for in-flight `delay`s, so `dispose()` can settle them all. */
|
|
226
|
+
const pendingDelays = new Set<(message: string) => void>();
|
|
227
|
+
|
|
228
|
+
function cancelTimer(timer: SimTimer): void {
|
|
229
|
+
if (timer.cancelled) return;
|
|
230
|
+
timer.cancelled = true;
|
|
231
|
+
pending.delete(timer);
|
|
232
|
+
timer.detachAbort?.();
|
|
233
|
+
timer.detachAbort = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function after(seconds: number, fn: () => void, opts?: SimScheduleOptions): SimTimerHandle {
|
|
237
|
+
assertSeconds('after', seconds);
|
|
238
|
+
const signal = opts?.signal;
|
|
239
|
+
// Already-aborted, or a clock that is gone: a no-op, never a throw.
|
|
240
|
+
if (disposed || signal?.aborted) return NOOP_HANDLE;
|
|
241
|
+
|
|
242
|
+
const timer: SimTimer = {
|
|
243
|
+
dueAt: simT + seconds,
|
|
244
|
+
seq: seq++,
|
|
245
|
+
fn,
|
|
246
|
+
cancelled: false,
|
|
247
|
+
detachAbort: null,
|
|
248
|
+
};
|
|
249
|
+
pending.add(timer);
|
|
250
|
+
|
|
251
|
+
if (signal) {
|
|
252
|
+
const onAbort = (): void => cancelTimer(timer);
|
|
253
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
254
|
+
timer.detachAbort = (): void => signal.removeEventListener('abort', onAbort);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
cancel(): void {
|
|
259
|
+
cancelTimer(timer);
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
now: (): number => simT,
|
|
266
|
+
tickNow: (): number => ticks,
|
|
267
|
+
after,
|
|
268
|
+
|
|
269
|
+
delay(seconds: number, opts?: SimScheduleOptions): Promise<void> {
|
|
270
|
+
assertSeconds('delay', seconds);
|
|
271
|
+
const signal = opts?.signal;
|
|
272
|
+
if (signal?.aborted) {
|
|
273
|
+
return Promise.reject(abortError('SimClock.delay: aborted before it was scheduled'));
|
|
274
|
+
}
|
|
275
|
+
if (disposed) {
|
|
276
|
+
return Promise.reject(abortError('SimClock.delay: the sim clock is disposed'));
|
|
277
|
+
}
|
|
278
|
+
return new Promise<void>((resolve, reject) => {
|
|
279
|
+
let handle: SimTimerHandle | null = null;
|
|
280
|
+
let detachAbort: (() => void) | null = null;
|
|
281
|
+
let settled = false;
|
|
282
|
+
|
|
283
|
+
const finish = (): void => {
|
|
284
|
+
settled = true;
|
|
285
|
+
pendingDelays.delete(rejectDelay);
|
|
286
|
+
detachAbort?.();
|
|
287
|
+
detachAbort = null;
|
|
288
|
+
};
|
|
289
|
+
function rejectDelay(message: string): void {
|
|
290
|
+
if (settled) return;
|
|
291
|
+
finish();
|
|
292
|
+
handle?.cancel();
|
|
293
|
+
reject(abortError(message));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
handle = after(seconds, () => {
|
|
297
|
+
if (settled) return;
|
|
298
|
+
finish();
|
|
299
|
+
resolve();
|
|
300
|
+
});
|
|
301
|
+
pendingDelays.add(rejectDelay);
|
|
302
|
+
|
|
303
|
+
if (signal) {
|
|
304
|
+
const onAbort = (): void => rejectDelay('SimClock.delay: aborted');
|
|
305
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
306
|
+
detachAbort = (): void => signal.removeEventListener('abort', onAbort);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
disposeAfter(obj: THREE.Object3D, seconds: number): SimTimerHandle {
|
|
312
|
+
assertSeconds('disposeAfter', seconds);
|
|
313
|
+
return after(seconds, () => disposeObject(obj));
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
flush(nextSimT: number): void {
|
|
317
|
+
if (disposed) return;
|
|
318
|
+
simT = nextSimT;
|
|
319
|
+
ticks++;
|
|
320
|
+
if (pending.size === 0) return;
|
|
321
|
+
|
|
322
|
+
// Snapshot BEFORE running anything: a timer scheduled by one of these
|
|
323
|
+
// callbacks lands in `pending` but not in this batch, so it can never be
|
|
324
|
+
// due in the same flush (module header, firing rule 4).
|
|
325
|
+
const batch: SimTimer[] = [];
|
|
326
|
+
for (const timer of pending) {
|
|
327
|
+
if (!timer.cancelled && timer.dueAt <= simT) batch.push(timer);
|
|
328
|
+
}
|
|
329
|
+
if (batch.length === 0) return;
|
|
330
|
+
batch.sort((a, b) => a.dueAt - b.dueAt || a.seq - b.seq);
|
|
331
|
+
|
|
332
|
+
for (const timer of batch) {
|
|
333
|
+
// A previously-fired callback may have cancelled this one.
|
|
334
|
+
if (timer.cancelled) continue;
|
|
335
|
+
cancelTimer(timer);
|
|
336
|
+
try {
|
|
337
|
+
timer.fn();
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// biome-ignore lint/suspicious/noConsole: loud degrade — one bad timer must not abort the frame's remaining timers (same isolation idiom as `runFrameImpl`'s per-world try/catch)
|
|
340
|
+
console.error('[sim-clock] timer callback threw:', err);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
dispose(): void {
|
|
346
|
+
if (disposed) return;
|
|
347
|
+
disposed = true;
|
|
348
|
+
for (const timer of pending) {
|
|
349
|
+
timer.detachAbort?.();
|
|
350
|
+
timer.detachAbort = null;
|
|
351
|
+
timer.cancelled = true;
|
|
352
|
+
}
|
|
353
|
+
pending.clear();
|
|
354
|
+
const rejectors = [...pendingDelays];
|
|
355
|
+
pendingDelays.clear();
|
|
356
|
+
for (const rejectDelay of rejectors) {
|
|
357
|
+
rejectDelay('SimClock: disposed while a delay was pending');
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Game-scoped registry — mirrors `core/seeded-random.ts`'s
|
|
365
|
+
// `registerSeededRandom`/`getSeededRandom` WeakMap pattern exactly, keyed on a
|
|
366
|
+
// bare `object` (not `Game`) so `core/` never imports `runtime/`. `createGame`
|
|
367
|
+
// is the one real registrant, passing the `GameInternal` shell as the key.
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
const clockByOwner = new WeakMap<object, SimClockInternal>();
|
|
371
|
+
|
|
372
|
+
/** Called once by `createGame`, right after the clock and the Game shell exist. */
|
|
373
|
+
export function registerSimClock(owner: object, clock: SimClockInternal): void {
|
|
374
|
+
clockByOwner.set(owner, clock);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The game-scoped clock backing every world's `ctx.clock` — `null` for an
|
|
378
|
+
* owner built without one (a hand-built `Game`-shaped stand-in that never went
|
|
379
|
+
* through `createGame`).
|
|
380
|
+
*
|
|
381
|
+
* Returns the INTERNAL view because this registry is engine-only — but a
|
|
382
|
+
* caller that RESOLVES a clock here is not its owner and must not call
|
|
383
|
+
* `dispose()` on it (module header, ownership). Game code never reaches it at
|
|
384
|
+
* all: `GameContext.clock` is typed as the public {@link SimClock}, which has
|
|
385
|
+
* neither `flush` nor `dispose`, the same way `Game` hides `GameInternal`. */
|
|
386
|
+
export function getSimClock(owner: object): SimClockInternal | null {
|
|
387
|
+
return clockByOwner.get(owner) ?? null;
|
|
388
|
+
}
|
|
@@ -12,22 +12,12 @@ import {
|
|
|
12
12
|
*
|
|
13
13
|
* Systems are registered with a phase name. When `run(dt)` is called, all
|
|
14
14
|
* phases execute in `PHASE_ORDER` via `runPhase(phase, dt)`. Within a phase,
|
|
15
|
-
* `runPhase` executes
|
|
16
|
-
* slice 2):
|
|
15
|
+
* `runPhase` executes two ordered buckets (T7.1 slice 2):
|
|
17
16
|
*
|
|
18
17
|
* 1. **engine** — everything `add()`/`register()`ed at or before the last
|
|
19
18
|
* `markEngineBoundary()` call, or EVERYTHING if no boundary has ever
|
|
20
|
-
* been marked
|
|
21
|
-
*
|
|
22
|
-
* 2. **componentTick** — the single per-phase slot set via
|
|
23
|
-
* `setComponentTick(phase, fn)`. Component ticks are engine
|
|
24
|
-
* infrastructure: they always run after every engine system and before
|
|
25
|
-
* every game system in that phase, REGARDLESS of when
|
|
26
|
-
* `setComponentTick` was called relative to other `add()` calls. This
|
|
27
|
-
* is what makes "engine systems run before component ticks" a
|
|
28
|
-
* structural guarantee rather than a registration-order convention
|
|
29
|
-
* (see `component-manager.ts`, which registers its tick this way).
|
|
30
|
-
* 3. **game** — everything `add()`/`register()`ed AFTER the last
|
|
19
|
+
* been marked.
|
|
20
|
+
* 2. **game** — everything `add()`/`register()`ed AFTER the last
|
|
31
21
|
* `markEngineBoundary()` call.
|
|
32
22
|
*
|
|
33
23
|
* Within each bucket, systems run in registration order.
|
|
@@ -35,14 +25,12 @@ import {
|
|
|
35
25
|
export function createSystemRunner(observer?: SystemRunObserver, scope = 'world') {
|
|
36
26
|
const systems = new Map<SystemPhaseName, SystemFn[]>();
|
|
37
27
|
const registered: SystemDef[] = [];
|
|
38
|
-
const componentTicks = new Map<SystemPhaseName, SystemFn | null>();
|
|
39
28
|
const labels = new Map<SystemFn, string>();
|
|
40
29
|
let anonymousId = 0;
|
|
41
30
|
|
|
42
|
-
// Initialize all phases with empty arrays
|
|
31
|
+
// Initialize all phases with empty arrays.
|
|
43
32
|
for (const phase of PHASE_ORDER) {
|
|
44
33
|
systems.set(phase, []);
|
|
45
|
-
componentTicks.set(phase, null);
|
|
46
34
|
}
|
|
47
35
|
|
|
48
36
|
// Membership bookkeeping for `markEngineBoundary()` (T7.2 review fix —
|
|
@@ -53,11 +41,7 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
|
|
|
53
41
|
// specific function/system was present at the last `markEngineBoundary()`
|
|
54
42
|
// call — membership survives arbitrary removal of any other entry,
|
|
55
43
|
// regardless of position. `boundaryMarked` is false until the first mark
|
|
56
|
-
// (mirrors the old `boundary === null`: everything is "engine" pre-mark
|
|
57
|
-
// the world2d-silo case, which never marks a boundary). Component-tick
|
|
58
|
-
// slots (`setComponentTick`) are NOT part of this bookkeeping at all — they
|
|
59
|
-
// are engine infrastructure and survive `removeAllNonEngine()`
|
|
60
|
-
// unconditionally.
|
|
44
|
+
// (mirrors the old `boundary === null`: everything is "engine" pre-mark).
|
|
61
45
|
let boundaryMarked = false;
|
|
62
46
|
const engineFns = new Map<SystemPhaseName, Set<SystemFn>>();
|
|
63
47
|
for (const phase of PHASE_ORDER) engineFns.set(phase, new Set());
|
|
@@ -137,29 +121,6 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
|
|
|
137
121
|
system.dispose?.();
|
|
138
122
|
},
|
|
139
123
|
|
|
140
|
-
/**
|
|
141
|
-
* Register the ONE component-tick function for a phase (T7.1 slice 2).
|
|
142
|
-
* Component ticks are engine infrastructure, not a bare `add()`ed
|
|
143
|
-
* system: they always execute after every engine-bucket system and
|
|
144
|
-
* before every game-bucket system in that phase (see `runPhase`),
|
|
145
|
-
* they are never touched by `removeAllNonEngine()`, and they do not
|
|
146
|
-
* count toward `markEngineBoundary()`'s bookkeeping. Throws if a tick
|
|
147
|
-
* is already set for this phase — at most one component manager may
|
|
148
|
-
* own a phase's tick slot.
|
|
149
|
-
*/
|
|
150
|
-
setComponentTick(phase: SystemPhaseName, fn: SystemFn) {
|
|
151
|
-
if (!componentTicks.has(phase)) {
|
|
152
|
-
throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
|
|
153
|
-
}
|
|
154
|
-
if (componentTicks.get(phase)) {
|
|
155
|
-
throw new Error(
|
|
156
|
-
`[system-runner] setComponentTick: phase "${phase}" already has a component tick registered`,
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
componentTicks.set(phase, fn);
|
|
160
|
-
labels.set(fn, `components.${phase}`);
|
|
161
|
-
},
|
|
162
|
-
|
|
163
124
|
/**
|
|
164
125
|
* Call init() on all registered lifecycle systems, in phase order.
|
|
165
126
|
* Call once after scene load, before the first game loop tick.
|
|
@@ -189,7 +150,7 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
|
|
|
189
150
|
},
|
|
190
151
|
|
|
191
152
|
/**
|
|
192
|
-
* Run ONE phase's
|
|
153
|
+
* Run ONE phase's two ordered buckets — engine, game
|
|
193
154
|
* (see the module doc comment). Each system call is isolated: a
|
|
194
155
|
* throwing system is loudly logged (never swallowed) but does not stop
|
|
195
156
|
* its siblings in the same bucket, a later bucket in this phase, or a
|
|
@@ -197,7 +158,7 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
|
|
|
197
158
|
*
|
|
198
159
|
* Public — the Game root's frame executor (`runtime/game.ts`) calls this
|
|
199
160
|
* directly per (phase, world); `run(dt)` below is just a loop over it,
|
|
200
|
-
* preserved for
|
|
161
|
+
* preserved for any direct caller.
|
|
201
162
|
*/
|
|
202
163
|
runPhase(phase: SystemPhaseName, dt: number) {
|
|
203
164
|
const list = systems.get(phase);
|
|
@@ -205,10 +166,10 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
|
|
|
205
166
|
throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
|
|
206
167
|
}
|
|
207
168
|
// Partition by MEMBERSHIP, not position: everything in this phase's
|
|
208
|
-
// `engineFns` set (or, if no boundary has ever been marked, everything
|
|
209
|
-
//
|
|
169
|
+
// `engineFns` set (or, if no boundary has ever been marked, everything)
|
|
170
|
+
// runs immediately, in list order, as it's
|
|
210
171
|
// encountered; everything else is queued into `gameFns` (also in list
|
|
211
|
-
// order) and run after
|
|
172
|
+
// order) and run after them. Because classification is
|
|
212
173
|
// per-function rather than "index < some remembered length", removing
|
|
213
174
|
// ANY entry via `remove()` — including a pre-boundary "engine" one —
|
|
214
175
|
// cannot shift a later game system into the engine bucket.
|
|
@@ -222,9 +183,6 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
|
|
|
222
183
|
}
|
|
223
184
|
}
|
|
224
185
|
|
|
225
|
-
const tick = componentTicks.get(phase);
|
|
226
|
-
if (tick) runOne(tick, phase, dt);
|
|
227
|
-
|
|
228
186
|
for (const fn of gameFns) {
|
|
229
187
|
runOne(fn, phase, dt);
|
|
230
188
|
}
|
|
@@ -245,8 +203,7 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
|
|
|
245
203
|
* Call once, right after mount finishes wiring engine-level systems and
|
|
246
204
|
* BEFORE any game (`setup()`/scene load) registers its own. `hotReload`
|
|
247
205
|
* calls `removeAllNonEngine()` on every restart, which only ever removes
|
|
248
|
-
* what was added after this mark.
|
|
249
|
-
* are unaffected — they are never part of this boundary.
|
|
206
|
+
* what was added after this mark.
|
|
250
207
|
*/
|
|
251
208
|
markEngineBoundary() {
|
|
252
209
|
for (const phase of PHASE_ORDER) {
|
|
@@ -262,14 +219,13 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
|
|
|
262
219
|
* call — both bare `add()`ed functions and lifecycle `register()`ed systems
|
|
263
220
|
* (the latter get `dispose()`d, mirroring `unregister()`). A no-op if no
|
|
264
221
|
* boundary has been marked. Idempotent: calling it again with nothing new
|
|
265
|
-
* registered since is a safe no-op.
|
|
266
|
-
* by this — they are engine infrastructure, not game content.
|
|
222
|
+
* registered since is a safe no-op.
|
|
267
223
|
*
|
|
268
224
|
* This is what makes a warm restart (`hotReload`) safe to call N times
|
|
269
225
|
* without accumulating duplicate systems/listeners — each restart's
|
|
270
226
|
* outgoing game systems are fully removed before the new one registers its
|
|
271
|
-
* own, while the engine systems (input/physics/render
|
|
272
|
-
*
|
|
227
|
+
* own, while the engine systems (input/physics/render/...) registered
|
|
228
|
+
* before the boundary are never touched.
|
|
273
229
|
*/
|
|
274
230
|
removeAllNonEngine() {
|
|
275
231
|
if (!boundaryMarked) return;
|
|
@@ -293,19 +249,17 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
|
|
|
293
249
|
},
|
|
294
250
|
|
|
295
251
|
/**
|
|
296
|
-
* Total system-function count, optionally scoped to one phase.
|
|
297
|
-
* component-tick slots (a set slot counts as 1 per phase it occupies).
|
|
252
|
+
* Total system-function count, optionally scoped to one phase.
|
|
298
253
|
* Test/introspection helper — used to assert a warm restart doesn't
|
|
299
254
|
* accumulate systems (see `removeAllNonEngine`).
|
|
300
255
|
*/
|
|
301
256
|
count(phase?: SystemPhaseName): number {
|
|
302
257
|
if (phase) {
|
|
303
|
-
return
|
|
258
|
+
return systems.get(phase)?.length ?? 0;
|
|
304
259
|
}
|
|
305
260
|
let total = 0;
|
|
306
|
-
for (const
|
|
261
|
+
for (const list of systems.values()) {
|
|
307
262
|
total += list.length;
|
|
308
|
-
if (componentTicks.get(p)) total += 1;
|
|
309
263
|
}
|
|
310
264
|
return total;
|
|
311
265
|
},
|
package/src/core/types.ts
CHANGED
|
@@ -60,24 +60,43 @@ export interface GameLoopConfig {
|
|
|
60
60
|
/** Max physics substeps per frame to prevent spiral of death (default: 8) */
|
|
61
61
|
maxSubSteps?: number;
|
|
62
62
|
/**
|
|
63
|
-
* Called once per consumed fixed substep
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
63
|
+
* Called once per consumed fixed substep — the SIM half. A real frame whose
|
|
64
|
+
* accumulator produces zero substeps calls this zero times, which is exactly
|
|
65
|
+
* why {@link GameLoopConfig.render} exists.
|
|
66
|
+
*
|
|
67
|
+
* Whether the `render`/`preRender` phases run inside this call is the
|
|
68
|
+
* CALLER's choice, not this loop's: a host that also passes `render` below
|
|
69
|
+
* asks `Game.runFrame` to skip them (`skipRenderPhases`), a host that does
|
|
70
|
+
* not keeps them here as the tail of `PHASE_ORDER`. See `game-loop.ts`'s
|
|
71
|
+
* module doc.
|
|
68
72
|
*/
|
|
69
73
|
update: (dt: number) => void;
|
|
70
74
|
/**
|
|
71
|
-
*
|
|
72
|
-
* §
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
75
|
+
* Called EXACTLY ONCE per real (rAF) frame — the PRESENTATION half
|
|
76
|
+
* (WO-P13, `docs/WORK.md` §2 P13; this is the callback whose absence the
|
|
77
|
+
* former D1 decision recorded).
|
|
78
|
+
*
|
|
79
|
+
* - `alpha` — `accumulator / fixedDt` after this frame's substeps were
|
|
80
|
+
* consumed, in `[0, 1]`: how far presentation sits past the last
|
|
81
|
+
* completed fixed state. `core/frame-pacing.ts` computes it.
|
|
82
|
+
* - `displayDt` — this display frame's own delta in seconds, clamped and
|
|
83
|
+
* `timeScale`d exactly like the sim's time. Integrate per-display-frame
|
|
84
|
+
* motion against this, never against `fixedTimestep`.
|
|
85
|
+
*
|
|
86
|
+
* Optional. Never invoked at all in {@link GameLoopConfig.externalDrive}
|
|
87
|
+
* mode, where `frame()` itself never runs.
|
|
88
|
+
*/
|
|
89
|
+
render?: (alpha: number, displayDt: number) => void;
|
|
90
|
+
/**
|
|
91
|
+
* External-drive mode (I2): when `true`, `start()` never arms a
|
|
92
|
+
* `requestAnimationFrame` chain and never installs the
|
|
93
|
+
* `visibilitychange`/`document.hidden` auto-stop handler — so a
|
|
94
|
+
* headless/backgrounded capture page cannot have its loop silently killed by
|
|
95
|
+
* the tab-hidden guard, and `config.update` is never invoked by
|
|
96
|
+
* wall-clock/RAF timing at all. The caller (a render harness) drives frames
|
|
97
|
+
* itself instead — see `runtime/render-control.ts`, which calls a world's
|
|
98
|
+
* phase hooks directly rather than going through this loop's `update`
|
|
99
|
+
* callback. Default `false` (normal wall-clock RAF playback, unchanged).
|
|
81
100
|
*/
|
|
82
101
|
externalDrive?: boolean;
|
|
83
102
|
}
|
package/src/data/data-asset.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Data assets — W1
|
|
2
|
+
* Data assets — W1 (§2.3 consumption API).
|
|
3
3
|
*
|
|
4
4
|
* A data asset is designer-tunable game data: a JSON file in the project's
|
|
5
5
|
* `src/data/`, governed by a Zod schema (`*.schema.ts`, engineer-owned) whose
|
|
@@ -71,7 +71,7 @@ export interface DataHandle<T> {
|
|
|
71
71
|
* the file, every bad path, and the fix (§2.3, §6.5 "errors teach").
|
|
72
72
|
*
|
|
73
73
|
* Schema fields should carry `.min/.max/.default/.describe` — the same
|
|
74
|
-
* annotations
|
|
74
|
+
* annotations every authored schema uses — so the emitted JSON Schema
|
|
75
75
|
* documents the file and (W2+) the editor renders real widgets.
|
|
76
76
|
*/
|
|
77
77
|
export function defineData<S extends z.ZodType>(
|
|
@@ -94,7 +94,7 @@ export function defineData<S extends z.ZodType>(
|
|
|
94
94
|
parsed = parseDataJson(schema, next, sourcePath);
|
|
95
95
|
} catch (err) {
|
|
96
96
|
// Keep the last good value alive — see the JSDoc on DataHandle.hotSwap.
|
|
97
|
-
// biome-ignore lint/suspicious/noConsole: deliberate, greppable — a rejected live-tune edit must be loud without crashing the running game (mirrors
|
|
97
|
+
// biome-ignore lint/suspicious/noConsole: deliberate, greppable — a rejected live-tune edit must be loud without crashing the running game (mirrors achieved-tier.ts's direct console use)
|
|
98
98
|
console.error(`[data] hot edit rejected, keeping previous values.\n${String(err)}`);
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Data-asset integrity core — the PURE half of W5 enforcement
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
8
|
-
* "what counts as dangling" and can never drift apart.
|
|
2
|
+
* Data-asset integrity core — the PURE half of W5 enforcement ("dangling
|
|
3
|
+
* `file#key` ref" / §6.7 build-path validation). No filesystem, no vite, no
|
|
4
|
+
* zod: plain parsed-JSON in, findings out, so BOTH consumers — `vgai
|
|
5
|
+
* doctor`'s data checks (`packages/editor/src/doctor/data-checks.ts`) and the
|
|
6
|
+
* build-time plugin (`./vite-plugin-data.ts`) — share one definition of "what
|
|
7
|
+
* is a ref" and "what counts as dangling" and can never drift apart.
|
|
9
8
|
*
|
|
10
9
|
* What is a ref (§2.2): a cross-asset reference is a plain string
|
|
11
10
|
* `"file#key"` — optionally with a field path, `"tuning#economy.slotGrowth"`
|