@vgai/engine 0.2.0 → 0.4.0-canary.20260715.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/README.md +3 -1
- package/package.json +24 -4
- package/schemas/engine-api.json +124 -0
- package/schemas/engine-api.md +53 -0
- package/schemas/engine-capabilities.json +124 -0
- package/schemas/inputmap.schema.json +314 -0
- package/schemas/mat.schema.json +286 -0
- package/schemas/prefab.schema.json +10148 -0
- package/schemas/scn2d.schema.json +475 -0
- package/schemas/vgai-game.schema.json +383 -0
- package/schemas/vscn.schema.json +11007 -0
- package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
- package/src/adapter/authoring.ts +77 -0
- package/src/adapter/first-party-systems.ts +23 -34
- package/src/adapter/game-adapter.ts +8 -8
- package/src/adapter/host-context.ts +2 -4
- package/src/adapter/index.ts +4 -4
- package/src/adapter/system-adapter.ts +88 -22
- package/src/adapter/vgai-scene-game-adapter.ts +244 -194
- package/src/animation/anim-graph-types.ts +12 -43
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/camera-ownership.ts +467 -0
- package/src/animation/cinematic-cues.ts +451 -0
- package/src/animation/clip-map.ts +41 -0
- package/src/animation/gsap-registration.ts +184 -0
- package/src/animation/theatre-clock-binding.ts +111 -0
- package/src/animation/theatre-director.ts +347 -0
- package/src/animation/theatre-object-binding.ts +661 -0
- package/src/animation/xstate-animation-binding.ts +436 -0
- package/src/animation/xstate-animation-meta.ts +319 -0
- package/src/audio/index.ts +39 -7
- package/src/audio/tone-clock-binding.ts +98 -0
- package/src/audio/tone-context.ts +129 -0
- package/src/audio/tone-offline-render.ts +167 -0
- package/src/audio/wav-encode.ts +119 -0
- package/src/character/cloth-sim.ts +533 -0
- package/src/character/spring-chain.ts +307 -0
- package/src/core/game-loop.ts +57 -2
- package/src/core/seeded-random.ts +161 -0
- package/src/core/system-runner.ts +20 -3
- package/src/core/types.ts +50 -0
- package/src/data/data-asset.ts +167 -0
- package/src/data/data-check-core.ts +242 -0
- package/src/data/data-ref.ts +145 -0
- package/src/data/vite-plugin-data.ts +290 -0
- package/src/dev/performance-profiler.ts +213 -0
- package/src/dev/webgl-gpu-timer.ts +53 -0
- package/src/ecs/component-manager.ts +45 -12
- package/src/ecs/game-component.ts +95 -11
- package/src/humanoid/bake.operation.ts +326 -0
- package/src/humanoid/body.ts +663 -0
- package/src/humanoid/clips.ts +149 -0
- package/src/humanoid/compose.ts +209 -0
- package/src/humanoid/generate.ts +189 -0
- package/src/humanoid/index.ts +36 -0
- package/src/humanoid/schema.ts +108 -0
- package/src/humanoid/skeleton.ts +345 -0
- package/src/index.ts +48 -0
- package/src/input/input-manager.ts +1886 -33
- package/src/input/input-types.ts +158 -3
- package/src/input/prompt-labels.ts +122 -0
- package/src/input/rebind-controller.ts +105 -0
- package/src/input/schema.ts +206 -52
- package/src/manifest/index.ts +5 -5
- package/src/manifest/load.ts +125 -72
- package/src/manifest/schema.ts +362 -255
- package/src/react/game-state.tsx +135 -32
- package/src/react/root-adapter.tsx +49 -0
- package/src/react/unmanaged-root-detector.ts +66 -0
- package/src/react/use-data.ts +124 -0
- package/src/react/use-selection.tsx +135 -0
- package/src/runtime/create-runtime.ts +112 -273
- package/src/runtime/debug-bridge.ts +483 -0
- package/src/runtime/debug-registry.ts +856 -0
- package/src/runtime/game.ts +342 -93
- package/src/runtime/gameplay-rng-trap.ts +134 -0
- package/src/runtime/input-router.ts +7 -7
- package/src/runtime/mount-game.ts +40 -38
- package/src/runtime/mount-manifest.ts +169 -37
- package/src/runtime/render-audio-control.ts +168 -0
- package/src/runtime/render-control.ts +522 -0
- package/src/runtime/render-seed.ts +79 -0
- package/src/runtime/state-bridge.ts +24 -10
- package/src/runtime/types.ts +110 -33
- package/src/scene/asset-loaders.ts +10 -36
- package/src/scene/asset-paths.ts +0 -2
- package/src/scene/asset-ref-check.ts +248 -0
- package/src/scene/asset-registry.ts +22 -0
- package/src/scene/component-registry.ts +14 -3
- package/src/scene/defaults.ts +1 -0
- package/src/scene/light-camera-factory.ts +11 -3
- package/src/scene/parse.ts +133 -0
- package/src/scene/scene-apply.ts +55 -4
- package/src/scene/scene-loader.ts +91 -123
- package/src/scene/scene-types.ts +0 -1
- package/src/scene/schema/animation.ts +30 -79
- package/src/scene/schema/entity.ts +20 -0
- package/src/scene/schema/index.ts +2 -46
- package/src/scene/schema/light.ts +16 -1
- package/src/scene/schema/material.ts +96 -91
- package/src/scene/schema/scene-file.ts +1 -7
- package/src/scene/user-data.ts +22 -10
- package/src/setup/setup-renderer.ts +10 -3
- package/src/tools/define-tool.ts +191 -0
- package/src/world2d/authoring-2d.ts +17 -1
- package/src/world2d/collision-2d.ts +1 -1
- package/src/world2d/pixi-game-adapter.ts +19 -17
- package/src/world2d/scene2d-loader.ts +1 -0
- package/src/world2d/types.ts +8 -2
- package/src/animation/anim-graph.ts +0 -406
- package/src/animation/anim-system.ts +0 -28
- package/src/animation/property-track.ts +0 -178
- package/src/animation/schema.ts +0 -204
- package/src/audio/ambient.ts +0 -300
- package/src/audio/impacts.ts +0 -212
- package/src/audio/movement.ts +0 -140
- package/src/audio/musical.ts +0 -200
- package/src/audio/ui-sounds.ts +0 -171
- package/src/audio/vehicle.ts +0 -235
- package/src/audio/weapons.ts +0 -152
- package/src/runtime/scene-ui-bridge.ts +0 -86
- package/src/runtime/scene-ui-data.ts +0 -119
- package/src/scene/schema/ui.ts +0 -602
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Character primitive — spring-bone chains (hair, ponytails, cloth strips)
|
|
3
|
+
* on top of `@pixiv/three-vrm-springbone`'s raw API.
|
|
4
|
+
*
|
|
5
|
+
* This is a THIN COMPOSITION HELPER, not a wrapper (CLAUDE.md "use libraries
|
|
6
|
+
* directly"): every export here is a small function that assembles the
|
|
7
|
+
* library's own objects (`THREE.Bone`, `VRMSpringBoneJoint`,
|
|
8
|
+
* `VRMSpringBoneCollider`, `VRMSpringBoneManager`) — nothing from the
|
|
9
|
+
* library is re-exported, and callers still import `@pixiv/three-vrm-
|
|
10
|
+
* springbone` types directly for anything beyond what's here. Kept
|
|
11
|
+
* deliberately bare: composing a specific character (a caped viking, a
|
|
12
|
+
* ponytailed ranger) is ordinary calling code on top of these pieces, not a
|
|
13
|
+
* costume/preset system in the engine.
|
|
14
|
+
*
|
|
15
|
+
* Sharp edge this module exists partly to contain (see the mission brief /
|
|
16
|
+
* `cloth-hair-bench/spring-rig.ts`'s header for the full discovery):
|
|
17
|
+
* `VRMSpringBoneCollider` overrides `updateWorldMatrix` (the ON-DEMAND,
|
|
18
|
+
* manually-invoked method), NOT `updateMatrixWorld` (the one the renderer
|
|
19
|
+
* calls automatically) — `SpringColliderRig.reposition()` below calls
|
|
20
|
+
* `collider.updateWorldMatrix(true, false)` itself every tick so callers
|
|
21
|
+
* can't forget it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
VRMSpringBoneCollider,
|
|
26
|
+
type VRMSpringBoneColliderGroup,
|
|
27
|
+
VRMSpringBoneColliderShapeCapsule,
|
|
28
|
+
VRMSpringBoneColliderShapeSphere,
|
|
29
|
+
VRMSpringBoneJoint,
|
|
30
|
+
VRMSpringBoneManager,
|
|
31
|
+
} from '@pixiv/three-vrm-springbone';
|
|
32
|
+
import * as THREE from 'three';
|
|
33
|
+
|
|
34
|
+
const DEFAULT_GRAVITY_DIR = new THREE.Vector3(0, -1, 0);
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Bone chains
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export interface BoneChain {
|
|
41
|
+
/** Swinging bones, root (nearest the anchor) first. */
|
|
42
|
+
bones: THREE.Bone[];
|
|
43
|
+
/** A non-simulated leaf `Object3D` past the last bone — the spring joint
|
|
44
|
+
* "tail" target for the last bone in the chain. */
|
|
45
|
+
tip: THREE.Object3D;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** World-space offset from `parent`'s CURRENT world position -> the local
|
|
49
|
+
* position a new direct child of `parent` must have to sit there. Requires
|
|
50
|
+
* `parent.matrixWorld` to be current. Sidesteps ever reasoning about a
|
|
51
|
+
* bone's local rest-axis convention (arbitrary per-bone on an imported
|
|
52
|
+
* rig) — the caller only ever thinks in world-space directions. */
|
|
53
|
+
export function worldOffsetToLocalPos(
|
|
54
|
+
parent: THREE.Object3D,
|
|
55
|
+
worldDir: THREE.Vector3,
|
|
56
|
+
length: number,
|
|
57
|
+
): THREE.Vector3 {
|
|
58
|
+
const parentWorld = new THREE.Vector3();
|
|
59
|
+
parent.getWorldPosition(parentWorld);
|
|
60
|
+
const targetWorld = parentWorld.clone().addScaledVector(worldDir, length);
|
|
61
|
+
return parent.worldToLocal(targetWorld.clone());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Build a straight chain of `count` new `THREE.Bone`s under `parent`, each
|
|
65
|
+
* offset `segmentLength` further along the WORLD-space unit vector
|
|
66
|
+
* `worldDir` — the natural rest shape for hair/cloth hanging under gravity.
|
|
67
|
+
* Ends with a `tip` `Object3D` (one more segment past the last bone) so
|
|
68
|
+
* every bone has an explicit spring-joint child target. Can be anchored to
|
|
69
|
+
* either an existing rig bone (hair off a head) or any plain `Object3D`. */
|
|
70
|
+
export function buildBoneChain(
|
|
71
|
+
parent: THREE.Object3D,
|
|
72
|
+
namePrefix: string,
|
|
73
|
+
count: number,
|
|
74
|
+
segmentLength: number,
|
|
75
|
+
worldDir: THREE.Vector3,
|
|
76
|
+
): BoneChain {
|
|
77
|
+
const bones: THREE.Bone[] = [];
|
|
78
|
+
let cur: THREE.Object3D = parent;
|
|
79
|
+
cur.updateWorldMatrix(true, false);
|
|
80
|
+
for (let i = 0; i < count; i++) {
|
|
81
|
+
const localPos = worldOffsetToLocalPos(cur, worldDir, segmentLength);
|
|
82
|
+
const bone = new THREE.Bone();
|
|
83
|
+
bone.name = `${namePrefix}${i}`;
|
|
84
|
+
bone.position.copy(localPos);
|
|
85
|
+
cur.add(bone);
|
|
86
|
+
bone.updateWorldMatrix(true, false);
|
|
87
|
+
bones.push(bone);
|
|
88
|
+
cur = bone;
|
|
89
|
+
}
|
|
90
|
+
const tip = new THREE.Object3D();
|
|
91
|
+
tip.name = `${namePrefix}Tip`;
|
|
92
|
+
tip.position.copy(worldOffsetToLocalPos(cur, worldDir, segmentLength));
|
|
93
|
+
cur.add(tip);
|
|
94
|
+
return { bones, tip };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Spring joints
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export interface ChainJointSettings {
|
|
102
|
+
stiffness?: number;
|
|
103
|
+
gravityPower?: number;
|
|
104
|
+
gravityDir?: THREE.Vector3;
|
|
105
|
+
dragForce?: number;
|
|
106
|
+
hitRadius?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ChainJointHandle {
|
|
110
|
+
joints: VRMSpringBoneJoint[];
|
|
111
|
+
/** Deregisters every joint this chain added from `manager`. Does NOT
|
|
112
|
+
* detach the bones from the scene graph (the caller owns that). */
|
|
113
|
+
dispose(): void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Register one `VRMSpringBoneJoint` per bone in `chain` on `manager` (each
|
|
117
|
+
* targeting the next bone, the last targeting `chain.tip`). Does not call
|
|
118
|
+
* `manager.setInitState()` — batch that once after all chains for a
|
|
119
|
+
* manager are registered and the scene's world matrices are current (sharp
|
|
120
|
+
* edge: `VRMSpringBoneJoint` sets `bone.matrixAutoUpdate = false`, so
|
|
121
|
+
* `setInitState()` must run AFTER `scene.updateMatrixWorld(true)`). */
|
|
122
|
+
export function registerChainJoints(
|
|
123
|
+
manager: VRMSpringBoneManager,
|
|
124
|
+
chain: BoneChain,
|
|
125
|
+
colliderGroups: VRMSpringBoneColliderGroup[],
|
|
126
|
+
settings: ChainJointSettings = {},
|
|
127
|
+
): ChainJointHandle {
|
|
128
|
+
const joints: VRMSpringBoneJoint[] = [];
|
|
129
|
+
for (let i = 0; i < chain.bones.length; i++) {
|
|
130
|
+
const bone = chain.bones[i]!;
|
|
131
|
+
const child = chain.bones[i + 1] ?? chain.tip;
|
|
132
|
+
const joint = new VRMSpringBoneJoint(
|
|
133
|
+
bone,
|
|
134
|
+
child,
|
|
135
|
+
{
|
|
136
|
+
stiffness: settings.stiffness ?? 1.2,
|
|
137
|
+
gravityPower: settings.gravityPower ?? 0.9,
|
|
138
|
+
gravityDir: settings.gravityDir ?? DEFAULT_GRAVITY_DIR,
|
|
139
|
+
dragForce: settings.dragForce ?? 0.35,
|
|
140
|
+
hitRadius: settings.hitRadius ?? 0.02,
|
|
141
|
+
},
|
|
142
|
+
colliderGroups,
|
|
143
|
+
);
|
|
144
|
+
manager.addJoint(joint);
|
|
145
|
+
joints.push(joint);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
joints,
|
|
149
|
+
dispose(): void {
|
|
150
|
+
for (const joint of joints) manager.deleteJoint(joint);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Colliders
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export interface SphereColliderDef {
|
|
160
|
+
shape: 'sphere';
|
|
161
|
+
name: string;
|
|
162
|
+
/** The collider tracks this object's world position every `reposition()`. */
|
|
163
|
+
followBone: THREE.Object3D;
|
|
164
|
+
radius: number;
|
|
165
|
+
/** Extra WORLD-space offset added after sampling `followBone`'s position
|
|
166
|
+
* (e.g. nudging a head collider up from the neck joint). */
|
|
167
|
+
worldOffset?: THREE.Vector3;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface CapsuleColliderDef {
|
|
171
|
+
shape: 'capsule';
|
|
172
|
+
name: string;
|
|
173
|
+
followBone: THREE.Object3D;
|
|
174
|
+
radius: number;
|
|
175
|
+
/** Capsule tail, in the collider's own local space (world-axis-aligned —
|
|
176
|
+
* colliders are parented directly to the scene, see `buildSpringColliderRig`). */
|
|
177
|
+
tail: THREE.Vector3;
|
|
178
|
+
worldOffset?: THREE.Vector3;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type ColliderDef = SphereColliderDef | CapsuleColliderDef;
|
|
182
|
+
|
|
183
|
+
export interface SpringColliderRig {
|
|
184
|
+
group: VRMSpringBoneColliderGroup;
|
|
185
|
+
colliders: Map<string, VRMSpringBoneCollider>;
|
|
186
|
+
/** Reposition every collider from its `followBone`'s CURRENT world
|
|
187
|
+
* transform and refresh its `colliderMatrix` (see module header). Call
|
|
188
|
+
* once per tick, BEFORE `manager.update(dt)`. */
|
|
189
|
+
reposition(): void;
|
|
190
|
+
dispose(): void;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Build sphere/capsule colliders parented directly to `scene` (world
|
|
194
|
+
* space) — sidesteps reasoning about any bone's local rest-axis convention
|
|
195
|
+
* for the collider shape's offset/tail vectors. */
|
|
196
|
+
export function buildSpringColliderRig(scene: THREE.Scene, defs: ColliderDef[]): SpringColliderRig {
|
|
197
|
+
const colliders = new Map<string, VRMSpringBoneCollider>();
|
|
198
|
+
const followers: Array<{ def: ColliderDef; collider: VRMSpringBoneCollider }> = [];
|
|
199
|
+
|
|
200
|
+
for (const def of defs) {
|
|
201
|
+
const collider =
|
|
202
|
+
def.shape === 'sphere'
|
|
203
|
+
? new VRMSpringBoneCollider(new VRMSpringBoneColliderShapeSphere({ radius: def.radius }))
|
|
204
|
+
: new VRMSpringBoneCollider(
|
|
205
|
+
new VRMSpringBoneColliderShapeCapsule({
|
|
206
|
+
radius: def.radius,
|
|
207
|
+
offset: new THREE.Vector3(0, 0, 0),
|
|
208
|
+
tail: def.tail,
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
scene.add(collider);
|
|
212
|
+
colliders.set(def.name, collider);
|
|
213
|
+
followers.push({ def, collider });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const group: VRMSpringBoneColliderGroup = {
|
|
217
|
+
name: 'body',
|
|
218
|
+
colliders: [...colliders.values()],
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const worldTmp = new THREE.Vector3();
|
|
222
|
+
return {
|
|
223
|
+
group,
|
|
224
|
+
colliders,
|
|
225
|
+
reposition(): void {
|
|
226
|
+
for (const { def, collider } of followers) {
|
|
227
|
+
def.followBone.getWorldPosition(worldTmp);
|
|
228
|
+
collider.position.copy(worldTmp);
|
|
229
|
+
if (def.worldOffset) collider.position.add(def.worldOffset);
|
|
230
|
+
collider.updateWorldMatrix(true, false);
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
dispose(): void {
|
|
234
|
+
for (const collider of colliders.values()) scene.remove(collider);
|
|
235
|
+
colliders.clear();
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Composed chain rig (the common case: one chain, its own manager)
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
export interface SpringChainRigOptions {
|
|
245
|
+
parent: THREE.Object3D;
|
|
246
|
+
namePrefix: string;
|
|
247
|
+
count: number;
|
|
248
|
+
segmentLength: number;
|
|
249
|
+
worldDir: THREE.Vector3;
|
|
250
|
+
/** Colliders this chain collides against. Omit for a chain with no
|
|
251
|
+
* collision (e.g. a side strand tucked away from the body). */
|
|
252
|
+
colliderRig?: SpringColliderRig;
|
|
253
|
+
settings?: ChainJointSettings;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface SpringChainRig {
|
|
257
|
+
bones: THREE.Bone[];
|
|
258
|
+
tip: THREE.Object3D;
|
|
259
|
+
/** The manager owning this chain's joints — a "manager-compatible handle"
|
|
260
|
+
* a caller can also register additional joints on directly. */
|
|
261
|
+
manager: VRMSpringBoneManager;
|
|
262
|
+
/** Repositions `colliderRig` (if given) and steps the spring simulation.
|
|
263
|
+
* Handles the collider `updateWorldMatrix` footgun internally — callers
|
|
264
|
+
* never need to call `collider.updateWorldMatrix` themselves. */
|
|
265
|
+
tick(dt: number): void;
|
|
266
|
+
dispose(): void;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Compose `buildBoneChain` + `registerChainJoints` behind one call for the
|
|
270
|
+
* common single-chain case: builds the chain, gives it its OWN
|
|
271
|
+
* `VRMSpringBoneManager` (managers are cheap — one per independent chain
|
|
272
|
+
* keeps `dispose()`/`tick()` fully self-contained), registers its joints,
|
|
273
|
+
* and calls `setInitState()` once construction is complete.
|
|
274
|
+
*
|
|
275
|
+
* For MULTIPLE chains sharing one collider rig (e.g. a ponytail + two side
|
|
276
|
+
* strands all colliding against the same head), build each chain with its
|
|
277
|
+
* own `buildSpringChainRig` call (same `colliderRig` passed to each) and
|
|
278
|
+
* tick all of them — `colliderRig.reposition()` is idempotent per frame, so
|
|
279
|
+
* calling it once per chain tick is correct, just slightly redundant work
|
|
280
|
+
* (cheap: it only touches a handful of colliders). */
|
|
281
|
+
export function buildSpringChainRig(options: SpringChainRigOptions): SpringChainRig {
|
|
282
|
+
const chain = buildBoneChain(
|
|
283
|
+
options.parent,
|
|
284
|
+
options.namePrefix,
|
|
285
|
+
options.count,
|
|
286
|
+
options.segmentLength,
|
|
287
|
+
options.worldDir,
|
|
288
|
+
);
|
|
289
|
+
const manager = new VRMSpringBoneManager();
|
|
290
|
+
const colliderGroups = options.colliderRig ? [options.colliderRig.group] : [];
|
|
291
|
+
const jointHandle = registerChainJoints(manager, chain, colliderGroups, options.settings);
|
|
292
|
+
manager.setInitState();
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
bones: chain.bones,
|
|
296
|
+
tip: chain.tip,
|
|
297
|
+
manager,
|
|
298
|
+
tick(dt: number): void {
|
|
299
|
+
options.colliderRig?.reposition();
|
|
300
|
+
manager.update(dt);
|
|
301
|
+
},
|
|
302
|
+
dispose(): void {
|
|
303
|
+
jointHandle.dispose();
|
|
304
|
+
chain.bones[0]?.removeFromParent();
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
package/src/core/game-loop.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GameLoopConfig } from './types';
|
|
1
|
+
import type { GameLoopConfig, GameLoopLiveness } from './types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Fixed-timestep game loop with accumulator pattern.
|
|
@@ -18,6 +18,13 @@ export function createGameLoop(config: GameLoopConfig) {
|
|
|
18
18
|
const maxSubSteps = config.maxSubSteps ?? 8;
|
|
19
19
|
// Also the accumulator's hard ceiling (see the spiral-of-death guard below).
|
|
20
20
|
const maxAccumulator = fixedDt * maxSubSteps;
|
|
21
|
+
// I2 (render-control runtime mode, see `GameLoopConfig.externalDrive`'s
|
|
22
|
+
// doc comment): in this mode `start()` below deliberately skips both the
|
|
23
|
+
// `requestAnimationFrame` arm AND the `visibilitychange` listener install —
|
|
24
|
+
// no wall-clock timing source ever exists for this loop instance, so
|
|
25
|
+
// `config.update` can only ever be invoked by an external driver calling
|
|
26
|
+
// into the render-control seam, never by this file's own `frame()`.
|
|
27
|
+
const externalDrive = config.externalDrive ?? false;
|
|
21
28
|
|
|
22
29
|
let accumulator = 0;
|
|
23
30
|
let lastTime = 0;
|
|
@@ -87,12 +94,32 @@ export function createGameLoop(config: GameLoopConfig) {
|
|
|
87
94
|
hiddenPaused = false;
|
|
88
95
|
lastTime = performance.now();
|
|
89
96
|
accumulator = 0;
|
|
90
|
-
rafId = requestAnimationFrame(frame);
|
|
91
97
|
|
|
98
|
+
// External-drive mode (I2): never arm rAF and never install the
|
|
99
|
+
// visibilitychange auto-stop handler. `running` still flips `true`
|
|
100
|
+
// above (so `isRunning`/`stop()` behave normally for a caller that
|
|
101
|
+
// treats this loop as "started"), but nothing will ever call
|
|
102
|
+
// `frame()` — a headless/backgrounded capture page cannot have this
|
|
103
|
+
// loop silently killed by `document.hidden`, because there is no
|
|
104
|
+
// listener to fire in the first place.
|
|
105
|
+
if (externalDrive) return;
|
|
106
|
+
|
|
107
|
+
// A play session can be started while the editor tab is ALREADY
|
|
108
|
+
// hidden. In that case no future visibilitychange-to-hidden event will
|
|
109
|
+
// arrive, and arming rAF would leave `running=true` forever while the
|
|
110
|
+
// browser executes zero callbacks. Classify and park the loop before
|
|
111
|
+
// arming it so status is truthful and visibility restore can resume it.
|
|
92
112
|
if (!visibilityListenerAttached && typeof document !== 'undefined') {
|
|
93
113
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
94
114
|
visibilityListenerAttached = true;
|
|
95
115
|
}
|
|
116
|
+
if (typeof document !== 'undefined' && document.hidden) {
|
|
117
|
+
running = false;
|
|
118
|
+
hiddenPaused = true;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
rafId = requestAnimationFrame(frame);
|
|
96
123
|
},
|
|
97
124
|
|
|
98
125
|
stop() {
|
|
@@ -110,6 +137,22 @@ export function createGameLoop(config: GameLoopConfig) {
|
|
|
110
137
|
return running;
|
|
111
138
|
},
|
|
112
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Truthful liveness (issue #175 — see `GameLoopLiveness`'s doc comment
|
|
142
|
+
* in `core/types.ts` for the full contract). Unlike `isRunning` above,
|
|
143
|
+
* this DISTINGUISHES a hidden-tab pause from an actually-stopped loop:
|
|
144
|
+
* `hiddenPaused` is only ever set by `handleVisibilityChange` (T2.1's
|
|
145
|
+
* idle throttle) and cleared by `start()`/`stop()`/visibility-restore,
|
|
146
|
+
* so it can never be true at the same time `running` is true. An
|
|
147
|
+
* `externalDrive` loop never installs the visibility listener at all
|
|
148
|
+
* (see `start()` above), so `hiddenPaused` stays permanently `false` for
|
|
149
|
+
* it — it only ever reports `'running'` or `'stopped'`.
|
|
150
|
+
*/
|
|
151
|
+
get liveness(): GameLoopLiveness {
|
|
152
|
+
if (hiddenPaused) return 'hidden-paused';
|
|
153
|
+
return running ? 'running' : 'stopped';
|
|
154
|
+
},
|
|
155
|
+
|
|
113
156
|
set timeScale(value: number) {
|
|
114
157
|
const clamped = Math.min(8, Math.max(0, value));
|
|
115
158
|
if (clamped !== value) {
|
|
@@ -123,5 +166,17 @@ export function createGameLoop(config: GameLoopConfig) {
|
|
|
123
166
|
get timeScale() {
|
|
124
167
|
return timeScale;
|
|
125
168
|
},
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The fixed substep timestep (seconds) this loop's accumulator consumes
|
|
172
|
+
* per `config.update()` call (`config.fixedTimestep ?? 1/60`) — read by
|
|
173
|
+
* `Game.runTicks` (D15, `runtime/game.ts`) so a synchronous fast-forward
|
|
174
|
+
* burst drives `game.runFrame` with the SAME `fixedDt` this loop's own
|
|
175
|
+
* rAF-driven accumulator would have used, without exposing (or
|
|
176
|
+
* `runTicks` needing) any other internal loop state.
|
|
177
|
+
*/
|
|
178
|
+
get fixedDt() {
|
|
179
|
+
return fixedDt;
|
|
180
|
+
},
|
|
126
181
|
};
|
|
127
182
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D15 — the seeded-random core (`docs/D15-DETERMINISM-DESIGN.md` §2.a,
|
|
3
|
+
* T-D15.1). `ctx.random` is a game-scoped PRNG with NAMED STREAMS: calling
|
|
4
|
+
* the object itself (`ctx.random()`) draws from the `'gameplay'` stream;
|
|
5
|
+
* `ctx.random.stream('vfx')` (or any other name) derives an INDEPENDENT
|
|
6
|
+
* generator, so a cosmetic/VFX draw can never perturb the gameplay draw
|
|
7
|
+
* order — the classic replay-drift trap option B ("one game-scoped PRNG
|
|
8
|
+
* only") would fall into (see the design doc's §2.a option table).
|
|
9
|
+
*
|
|
10
|
+
* Each stream is seeded from `fnv1a(name) ^ rootSeed` — deterministic given
|
|
11
|
+
* the root seed, independent of draw order across streams (drawing from
|
|
12
|
+
* `'vfx'` never advances `'gameplay'`'s generator, since they are two
|
|
13
|
+
* separate mulberry32 instances). `reseed(seed)` re-derives every stream
|
|
14
|
+
* that has EVER been asked for via `.stream(name)` (including the implicit
|
|
15
|
+
* `'gameplay'` stream `ctx.random()` itself draws from) — future draws only:
|
|
16
|
+
* numbers already returned before a `reseed()` call are not (and cannot be)
|
|
17
|
+
* un-returned; this matches `play.seed.set`'s documented semantics (T-D15.6).
|
|
18
|
+
*
|
|
19
|
+
* Deliberately reuses {@link createMulberry32} FROM `runtime/render-seed.ts`
|
|
20
|
+
* (the render/capture door's own generator) rather than duplicating the
|
|
21
|
+
* algorithm — `render-seed.ts` stays a zero-import module itself (this file
|
|
22
|
+
* imports FROM it, never the reverse), so this is a dependency-clean
|
|
23
|
+
* direction: the render/capture door and the gameplay determinism door share
|
|
24
|
+
* one PRNG implementation without either depending on the other's door
|
|
25
|
+
* logic (§2.a: "Kept as-is for the render/capture door only" — the two doors
|
|
26
|
+
* stay orthogonal; only the generator function itself is shared).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createMulberry32 } from '../runtime/render-seed';
|
|
30
|
+
|
|
31
|
+
/** The name `ctx.random()` (called with no `.stream(...)`) draws from. */
|
|
32
|
+
export const GAMEPLAY_STREAM = 'gameplay';
|
|
33
|
+
|
|
34
|
+
/** The fixed fallback seed a `SeededRandom` boots with when no explicit seed
|
|
35
|
+
* is supplied (mirrors `render-seed.ts`'s own `DEFAULT_RENDER_SEED` in
|
|
36
|
+
* spirit — an arbitrary but fixed golden-ratio-derived constant, never
|
|
37
|
+
* wall-clock/`Math.random`-derived, so an UNDECLARED project's `ctx.random`
|
|
38
|
+
* is still perfectly reproducible on its own terms even though nothing in
|
|
39
|
+
* the manifest asked for that — it just isn't a documented CONTRACT until
|
|
40
|
+
* `determinism.seededRandom` is declared, per T4.1's "no dead field" rule). */
|
|
41
|
+
export const DEFAULT_SEEDED_RANDOM_SEED = 0x9e3779b9;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The `ctx.random` surface (`GameContext.random`, `runtime/types.ts`).
|
|
45
|
+
* Callable (draws from the `'gameplay'` stream), plus:
|
|
46
|
+
* - `stream(name)` — an independent named generator, `[0, 1)` floats, same
|
|
47
|
+
* call signature as `Math.random`/the bare `SeededRandom` call itself.
|
|
48
|
+
* - `reseed(seed)` — re-derive every stream ever requested so far (future
|
|
49
|
+
* draws only) from a NEW root seed.
|
|
50
|
+
* - `seed` — the CURRENT root seed (reflects the last `reseed()` call, if
|
|
51
|
+
* any) — a live getter, not a value snapshotted at construction.
|
|
52
|
+
*/
|
|
53
|
+
export interface SeededRandom {
|
|
54
|
+
(): number;
|
|
55
|
+
stream(name: string): () => number;
|
|
56
|
+
reseed(seed: number): void;
|
|
57
|
+
readonly seed: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** fnv1a-32, the standard non-cryptographic string hash — used only to mix a
|
|
61
|
+
* stream NAME into the root seed, never as a determinism primitive on its
|
|
62
|
+
* own (mulberry32 is still what actually produces the `[0,1)` sequence). */
|
|
63
|
+
function fnv1a32(str: string): number {
|
|
64
|
+
let hash = 0x811c9dc5;
|
|
65
|
+
for (let i = 0; i < str.length; i++) {
|
|
66
|
+
hash ^= str.charCodeAt(i);
|
|
67
|
+
hash = Math.imul(hash, 0x01000193);
|
|
68
|
+
}
|
|
69
|
+
return hash >>> 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface StreamBox {
|
|
73
|
+
/** Reassigned wholesale on `reseed()` — the wrapper function `.stream(name)`
|
|
74
|
+
* returns to callers stays the SAME identity forever; only what it reads
|
|
75
|
+
* from changes, which is what makes reseed observable through a handle a
|
|
76
|
+
* caller obtained long before the reseed call. */
|
|
77
|
+
gen: () => number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Construct a fresh, game-scoped seeded-random surface. `initialSeed` is
|
|
82
|
+
* coerced to an unsigned 32-bit integer the same way `render-seed.ts`'s
|
|
83
|
+
* `installDeterministicRandom` does (`>>> 0`), so any finite JS number is
|
|
84
|
+
* accepted.
|
|
85
|
+
*/
|
|
86
|
+
export function createSeededRandom(initialSeed: number): SeededRandom {
|
|
87
|
+
let rootSeed = initialSeed >>> 0;
|
|
88
|
+
const streams = new Map<string, StreamBox>();
|
|
89
|
+
|
|
90
|
+
function deriveStreamSeed(name: string): number {
|
|
91
|
+
return (fnv1a32(name) ^ rootSeed) >>> 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getBox(name: string): StreamBox {
|
|
95
|
+
let box = streams.get(name);
|
|
96
|
+
if (!box) {
|
|
97
|
+
box = { gen: createMulberry32(deriveStreamSeed(name)) };
|
|
98
|
+
streams.set(name, box);
|
|
99
|
+
}
|
|
100
|
+
return box;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function streamFn(name: string): () => number {
|
|
104
|
+
const box = getBox(name);
|
|
105
|
+
return () => box.gen();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const random = (() => getBox(GAMEPLAY_STREAM).gen()) as SeededRandom;
|
|
109
|
+
|
|
110
|
+
Object.defineProperties(random, {
|
|
111
|
+
stream: { value: streamFn, enumerable: true },
|
|
112
|
+
reseed: {
|
|
113
|
+
value: (seed: number) => {
|
|
114
|
+
rootSeed = seed >>> 0;
|
|
115
|
+
// Re-derive every stream ANYONE has ever asked for (including the
|
|
116
|
+
// implicit 'gameplay' stream, once `random()` or
|
|
117
|
+
// `.stream('gameplay')` has been called at least once) — a handle a
|
|
118
|
+
// caller stashed before this call keeps its identity but now reads
|
|
119
|
+
// from the freshly-seeded generator on its NEXT call (future draws
|
|
120
|
+
// only, per the design doc's `play.seed.set` semantics).
|
|
121
|
+
for (const [name, box] of streams) {
|
|
122
|
+
box.gen = createMulberry32(deriveStreamSeed(name));
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
enumerable: true,
|
|
126
|
+
},
|
|
127
|
+
seed: {
|
|
128
|
+
get: () => rootSeed,
|
|
129
|
+
enumerable: true,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return random;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Game-scoped registry — mirrors `runtime/debug-registry.ts`'s
|
|
138
|
+
// `registerDebugRegistry`/`getDebugRegistry` WeakMap pattern exactly, but
|
|
139
|
+
// keyed on a bare `object` (not `Game`) so this module never needs to import
|
|
140
|
+
// `runtime/game.ts` even as a type — `core/` stays independent of `runtime/`
|
|
141
|
+
// except for the one explicit, documented `render-seed.ts` reuse above.
|
|
142
|
+
// `createGame` (`runtime/game.ts`) is the one real registrant, passing itself
|
|
143
|
+
// (the `GameInternal` shell) as the key, exactly like it does for
|
|
144
|
+
// `registerDebugRegistry(gameInternal, debugRegistry)`.
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
const registryByOwner = new WeakMap<object, SeededRandom>();
|
|
148
|
+
|
|
149
|
+
/** Called once by `createGame`, right after both the seeded-random surface
|
|
150
|
+
* and the Game shell object exist — mirrors `registerDebugRegistry`. */
|
|
151
|
+
export function registerSeededRandom(owner: object, random: SeededRandom): void {
|
|
152
|
+
registryByOwner.set(owner, random);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The game-scoped `SeededRandom` backing every world's `ctx.random` — `null`
|
|
156
|
+
* for an owner built without one (there is always one for every real
|
|
157
|
+
* `createGame` call; `null` only for a hand-built `Game`-shaped stand-in a
|
|
158
|
+
* test constructs without going through `createGame`). */
|
|
159
|
+
export function getSeededRandom(owner: object): SeededRandom | null {
|
|
160
|
+
return registryByOwner.get(owner) ?? null;
|
|
161
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
PHASE_ORDER,
|
|
3
|
+
type SystemDef,
|
|
4
|
+
type SystemFn,
|
|
5
|
+
type SystemOptions,
|
|
6
|
+
type SystemPhaseName,
|
|
7
|
+
type SystemRunObserver,
|
|
8
|
+
} from './types';
|
|
2
9
|
|
|
3
10
|
/**
|
|
4
11
|
* Ordered system execution by named phase.
|
|
@@ -25,10 +32,12 @@ import { PHASE_ORDER, type SystemDef, type SystemFn, type SystemPhaseName } from
|
|
|
25
32
|
*
|
|
26
33
|
* Within each bucket, systems run in registration order.
|
|
27
34
|
*/
|
|
28
|
-
export function createSystemRunner() {
|
|
35
|
+
export function createSystemRunner(observer?: SystemRunObserver, scope = 'world') {
|
|
29
36
|
const systems = new Map<SystemPhaseName, SystemFn[]>();
|
|
30
37
|
const registered: SystemDef[] = [];
|
|
31
38
|
const componentTicks = new Map<SystemPhaseName, SystemFn | null>();
|
|
39
|
+
const labels = new Map<SystemFn, string>();
|
|
40
|
+
let anonymousId = 0;
|
|
32
41
|
|
|
33
42
|
// Initialize all phases with empty arrays / no component tick.
|
|
34
43
|
for (const phase of PHASE_ORDER) {
|
|
@@ -56,11 +65,16 @@ export function createSystemRunner() {
|
|
|
56
65
|
|
|
57
66
|
/** Run one system function, isolating a throw so it never wedges the frame. */
|
|
58
67
|
function runOne(fn: SystemFn, phase: SystemPhaseName, dt: number): void {
|
|
68
|
+
const label = labels.get(fn) ?? fn.name ?? `anonymous-${++anonymousId}`;
|
|
69
|
+
labels.set(fn, label);
|
|
70
|
+
observer?.beginSystem(scope, phase, label);
|
|
59
71
|
try {
|
|
60
72
|
fn(dt);
|
|
61
73
|
} catch (err) {
|
|
62
74
|
const label = fn.name ? `"${fn.name}"` : '(anonymous)';
|
|
63
75
|
console.error(`[system-runner] system ${label} in phase "${phase}" threw:`, err);
|
|
76
|
+
} finally {
|
|
77
|
+
observer?.endSystem(scope, phase, label);
|
|
64
78
|
}
|
|
65
79
|
}
|
|
66
80
|
|
|
@@ -69,12 +83,13 @@ export function createSystemRunner() {
|
|
|
69
83
|
* Register a bare system function in a specific phase.
|
|
70
84
|
* Systems within the same phase run in the order they were added.
|
|
71
85
|
*/
|
|
72
|
-
add(phase: SystemPhaseName, fn: SystemFn) {
|
|
86
|
+
add(phase: SystemPhaseName, fn: SystemFn, options?: SystemOptions) {
|
|
73
87
|
const list = systems.get(phase);
|
|
74
88
|
if (!list) {
|
|
75
89
|
throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
|
|
76
90
|
}
|
|
77
91
|
list.push(fn);
|
|
92
|
+
labels.set(fn, options?.name ?? fn.name ?? `anonymous-${++anonymousId}`);
|
|
78
93
|
},
|
|
79
94
|
|
|
80
95
|
/**
|
|
@@ -102,6 +117,7 @@ export function createSystemRunner() {
|
|
|
102
117
|
throw new Error(`Unknown phase: ${system.phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
|
|
103
118
|
}
|
|
104
119
|
list.push(system.update);
|
|
120
|
+
labels.set(system.update, system.name ?? system.update.name ?? `anonymous-${++anonymousId}`);
|
|
105
121
|
registered.push(system);
|
|
106
122
|
},
|
|
107
123
|
|
|
@@ -141,6 +157,7 @@ export function createSystemRunner() {
|
|
|
141
157
|
);
|
|
142
158
|
}
|
|
143
159
|
componentTicks.set(phase, fn);
|
|
160
|
+
labels.set(fn, `components.${phase}`);
|
|
144
161
|
},
|
|
145
162
|
|
|
146
163
|
/**
|