@vgai/engine 0.4.1 → 0.5.0-canary.20260719.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.
Files changed (103) hide show
  1. package/README.md +48 -15
  2. package/package.json +11 -25
  3. package/schemas/engine-capabilities.json +10 -10
  4. package/schemas/entity2d.schema.json +468 -0
  5. package/schemas/mat.schema.json +2 -33
  6. package/schemas/prefab.schema.json +16 -172
  7. package/schemas/scn2d.schema.json +42 -23
  8. package/schemas/vgai-game.schema.json +34 -0
  9. package/schemas/vscn.schema.json +16 -172
  10. package/src/adapter/authoring.ts +152 -2
  11. package/src/adapter/colyseus-networking-adapter.ts +35 -1
  12. package/src/adapter/first-party-systems.ts +7 -1
  13. package/src/adapter/game-adapter.ts +13 -0
  14. package/src/adapter/index.ts +25 -0
  15. package/src/adapter/rapier-physics-adapter.ts +55 -2
  16. package/src/adapter/system-adapter.ts +249 -2
  17. package/src/adapter/vgai-scene-game-adapter.ts +149 -25
  18. package/src/ai/navigation.ts +28 -0
  19. package/src/animation/clip-map.ts +1 -8
  20. package/src/animation/theatre-director.ts +50 -0
  21. package/src/animation/xstate-animation-binding.ts +6 -0
  22. package/src/audio/audio-introspection.ts +290 -0
  23. package/src/audio/tone-context.ts +46 -0
  24. package/src/dev/chrome-trace.ts +153 -0
  25. package/src/dev/performance-profiler.ts +93 -6
  26. package/src/dev/render-debug-adapter.ts +199 -0
  27. package/src/dev/render-memory.ts +243 -0
  28. package/src/dev/webgl-frame-capture.ts +424 -0
  29. package/src/ecs/component-manager.ts +43 -10
  30. package/src/ecs/game-component.ts +39 -10
  31. package/src/input/input-manager.ts +24 -19
  32. package/src/input/input-types.ts +1 -1
  33. package/src/loader.ts +7 -0
  34. package/src/manifest/load.ts +14 -0
  35. package/src/manifest/schema.ts +65 -0
  36. package/src/react/game-state.tsx +1 -1
  37. package/src/render/render-batch-system.ts +26 -12
  38. package/src/render/spark-renderer-lifecycle.ts +64 -0
  39. package/src/runtime/create-runtime.ts +33 -5
  40. package/src/runtime/debug-bridge.ts +5 -5
  41. package/src/runtime/game.ts +102 -20
  42. package/src/runtime/mount-manifest.ts +1 -1
  43. package/src/runtime/render-control.ts +121 -0
  44. package/src/runtime/types.ts +1 -1
  45. package/src/scene/asset-loaders.ts +77 -3
  46. package/src/scene/instance-mesh.ts +25 -0
  47. package/src/scene/material-factory.ts +4 -15
  48. package/src/scene/mesh-shadow.ts +18 -0
  49. package/src/scene/particles-factory.ts +59 -0
  50. package/src/scene/scene-loader.ts +41 -16
  51. package/src/scene/schema/instances.ts +1 -2
  52. package/src/scene/schema/material.ts +83 -94
  53. package/src/scene/schema/mesh.ts +76 -90
  54. package/src/scene/schema/scene-file.ts +1 -2
  55. package/src/scene/user-data.ts +30 -14
  56. package/src/setup/setup-renderer.ts +6 -1
  57. package/src/world2d/asset-paths2d.ts +44 -0
  58. package/src/world2d/collision-2d.ts +7 -14
  59. package/src/world2d/entity2d-asset.ts +22 -0
  60. package/src/world2d/index.ts +27 -2
  61. package/src/world2d/physics2d-transform.ts +173 -0
  62. package/src/world2d/physics2d-units.ts +10 -0
  63. package/src/world2d/pixi-game-adapter.ts +148 -36
  64. package/src/world2d/scene2d-identity.ts +49 -0
  65. package/src/world2d/scene2d-loader.ts +243 -119
  66. package/src/world2d/schema/entity2d.ts +51 -33
  67. package/src/world2d/schema/physics2d.ts +14 -3
  68. package/src/world2d/schema/sprite.ts +32 -4
  69. package/src/world2d/schema/tilemap.ts +26 -9
  70. package/src/world2d/transform-writer-2d.ts +29 -11
  71. package/src/world2d/types.ts +21 -8
  72. package/src/world3d-react/behavior.tsx +138 -0
  73. package/src/world3d-react/engine-bridge.ts +48 -0
  74. package/src/world3d-react/index.ts +44 -0
  75. package/src/world3d-react/r3f-adapter.tsx +303 -0
  76. package/src/world3d-react/world-context.ts +294 -0
  77. package/vendor/realism-effects/LICENSE.md +21 -0
  78. package/vendor/realism-effects/UPSTREAM.md +19 -0
  79. package/vendor/realism-effects/dist/index.cjs +3447 -0
  80. package/vendor/realism-effects/dist/index.d.ts +59 -0
  81. package/vendor/realism-effects/dist/index.js +3434 -0
  82. package/vendor/realism-effects/package.json +23 -0
  83. package/src/character/cloth-sim.ts +0 -533
  84. package/src/character/spring-chain.ts +0 -307
  85. package/src/humanoid/body.ts +0 -663
  86. package/src/humanoid/clips.ts +0 -149
  87. package/src/humanoid/compose.ts +0 -209
  88. package/src/humanoid/generate.ts +0 -189
  89. package/src/humanoid/index.ts +0 -36
  90. package/src/humanoid/schema.ts +0 -108
  91. package/src/humanoid/skeleton.ts +0 -345
  92. package/src/react/humanoid-bake.document.tsx +0 -337
  93. package/src/scene/geometries/index.ts +0 -7
  94. package/src/scene/geometries/terrain.ts +0 -42
  95. package/src/scene/geometry-registry.ts +0 -42
  96. package/src/scene/instance-registry.ts +0 -84
  97. package/src/scene/instancers/grid.ts +0 -38
  98. package/src/scene/instancers/index.ts +0 -7
  99. package/src/scene/material-registry.ts +0 -73
  100. package/src/scene/materials/index.ts +0 -7
  101. package/src/scene/materials/water.ts +0 -56
  102. package/src/world2d/components-2d.ts +0 -86
  103. package/tools/humanoid-bake.tool.ts +0 -274
@@ -1,5 +1,6 @@
1
1
  import type RAPIER from '@dimforge/rapier2d-compat';
2
2
  import { CompositeTilemap } from '@pixi/tilemap';
3
+ import type { Ticker } from 'pixi.js';
3
4
  import {
4
5
  AnimatedSprite,
5
6
  Assets,
@@ -12,6 +13,12 @@ import {
12
13
  } from 'pixi.js';
13
14
  import type { ComponentManager } from '../ecs/component-manager';
14
15
  import { SceneParseError } from '../scene/parse';
16
+ import {
17
+ assertMovingPhysicsParent2D,
18
+ computePhysicsPose2D,
19
+ scaleCollider2D,
20
+ } from './physics2d-transform';
21
+ import { resolveScene2DEntityIds } from './scene2d-identity';
15
22
  import { type Entity2D, type Scene2DFile, Scene2DSchema } from './schema/entity2d';
16
23
  import type { SceneSprite, SceneText2D } from './schema/sprite';
17
24
  import type { SceneTilemap } from './schema/tilemap';
@@ -24,37 +31,39 @@ const BLEND: Record<string, 'normal' | 'add' | 'multiply' | 'screen'> = {
24
31
  screen: 'screen',
25
32
  };
26
33
 
34
+ // Pixi implements Sprite width/height by mutating native scale. Keep that
35
+ // implementation scale separate from authored transform2d.scale so the visual
36
+ // and its pixel-authored collider compose the same way.
37
+ const spriteBaseScales = new WeakMap<Container, readonly [number, number]>();
38
+ const tilemapTextures = new WeakMap<CompositeTilemap, readonly Texture[]>();
39
+
40
+ /** Native atlas sub-textures retained for source-backed tile painting. */
41
+ export function getSceneTilemapTextures2D(display: Container): readonly Texture[] | null {
42
+ return display instanceof CompositeTilemap ? (tilemapTextures.get(display) ?? null) : null;
43
+ }
44
+
45
+ /** Refresh the native Sprite sizing scale after an authoring operation writes
46
+ * `display.width`/`height` from new source dimensions. The next authored
47
+ * transform application multiplies this base by transform2d.scale exactly once. */
48
+ export function captureSceneSpriteBaseScale2D(
49
+ display: Container,
50
+ axes: { x: boolean; y: boolean } = { x: true, y: true },
51
+ authoredScale: readonly [number, number] = [1, 1],
52
+ ): void {
53
+ const previous = spriteBaseScales.get(display) ?? [display.scale.x, display.scale.y];
54
+ const authoredSignX = authoredScale[0] < 0 ? -1 : 1;
55
+ const authoredSignY = authoredScale[1] < 0 ? -1 : 1;
56
+ spriteBaseScales.set(display, [
57
+ axes.x ? display.scale.x * authoredSignX : previous[0],
58
+ axes.y ? display.scale.y * authoredSignY : previous[1],
59
+ ]);
60
+ }
61
+
27
62
  function hexToNum(hex: string | undefined, fallback: number): number {
28
63
  if (!hex) return fallback;
29
64
  return Number.parseInt(hex.replace('#', ''), 16);
30
65
  }
31
66
 
32
- /** Generate N asset-light frame textures (a moving accent dot) for an AnimatedSprite. */
33
- function generateFrames(count: number, w: number, h: number, tint: string): Texture[] {
34
- const frames: Texture[] = [];
35
- for (let i = 0; i < count; i++) {
36
- const canvas = document.createElement('canvas');
37
- canvas.width = w;
38
- canvas.height = h;
39
- const g = canvas.getContext('2d')!;
40
- g.fillStyle = tint;
41
- g.fillRect(0, 0, w, h);
42
- g.fillStyle = '#10101a';
43
- const t = (i / count) * Math.PI * 2;
44
- g.beginPath();
45
- g.arc(
46
- w / 2 + Math.cos(t) * w * 0.25,
47
- h / 2 + Math.sin(t) * h * 0.25,
48
- Math.min(w, h) * 0.18,
49
- 0,
50
- Math.PI * 2,
51
- );
52
- g.fill();
53
- frames.push(Texture.from(canvas));
54
- }
55
- return frames;
56
- }
57
-
58
67
  function applyFilter(display: Sprite | AnimatedSprite, filter: string | undefined): void {
59
68
  if (!filter || filter === 'none') return;
60
69
  if (filter === 'blur') display.filters = [new BlurFilter({ strength: 6 })];
@@ -65,36 +74,30 @@ function applyFilter(display: Sprite | AnimatedSprite, filter: string | undefine
65
74
  }
66
75
  }
67
76
 
68
- /** Instantiate a PIXI.Sprite / AnimatedSprite from authored `sprite` data. */
69
- async function createSprite(data: SceneSprite): Promise<Sprite | AnimatedSprite> {
70
- let display: Sprite | AnimatedSprite;
71
- if (data.animationFrames && data.animationFrames > 1) {
72
- const frames = generateFrames(
73
- data.animationFrames,
74
- data.width ?? 40,
75
- data.height ?? 40,
76
- data.tint ?? '#ffffff',
77
- );
78
- const anim = new AnimatedSprite(frames);
79
- anim.animationSpeed = (data.fps ?? 8) / 60;
80
- anim.play();
81
- display = anim;
82
- } else {
83
- let texture: Texture = Texture.WHITE;
84
- if (data.texture) {
85
- const loaded = await Assets.load(data.texture);
86
- texture =
87
- data.frame && loaded?.textures?.[data.frame]
88
- ? loaded.textures[data.frame]
89
- : (loaded as Texture);
90
- }
91
- display = new Sprite(texture);
77
+ type LoadedSpriteAsset = Texture & {
78
+ animations?: Record<string, Texture[]>;
79
+ textures?: Record<string, Texture>;
80
+ };
81
+
82
+ function createAnimatedSprite(data: SceneSprite, loaded: LoadedSpriteAsset): AnimatedSprite {
83
+ const frames = loaded.animations?.[data.animation!];
84
+ if (!frames?.length) {
85
+ throw new Error(`Spritesheet "${data.texture}" does not contain animation "${data.animation}"`);
92
86
  }
87
+ const display = new AnimatedSprite({ autoUpdate: false, textures: frames });
88
+ display.animationSpeed = (data.fps ?? 8) / 60;
89
+ display.loop = data.loop ?? true;
90
+ display.play();
91
+ return display;
92
+ }
93
+
94
+ function applySpriteAppearance(display: Sprite | AnimatedSprite, data: SceneSprite): void {
93
95
  const [ax, ay] = data.anchor ?? [0.5, 0.5];
94
96
  display.anchor.set(ax, ay);
95
97
  if (data.width !== undefined) display.width = data.width;
96
98
  if (data.height !== undefined) display.height = data.height;
97
- if (!data.animationFrames) display.tint = hexToNum(data.tint, 0xffffff);
99
+ captureSceneSpriteBaseScale2D(display);
100
+ display.tint = hexToNum(data.tint, 0xffffff);
98
101
  if (data.alpha !== undefined) display.alpha = data.alpha;
99
102
  if (data.blendMode) display.blendMode = BLEND[data.blendMode] ?? 'normal';
100
103
  if (data.interactive) {
@@ -102,29 +105,51 @@ async function createSprite(data: SceneSprite): Promise<Sprite | AnimatedSprite>
102
105
  display.cursor = 'pointer';
103
106
  }
104
107
  applyFilter(display, data.filter);
108
+ }
109
+
110
+ function resolveStaticSpriteTexture(
111
+ data: SceneSprite,
112
+ loaded: LoadedSpriteAsset | undefined,
113
+ ): Texture {
114
+ if (!loaded) return Texture.WHITE;
115
+ if (!data.frame) return loaded as Texture;
116
+ const frame = loaded.textures?.[data.frame];
117
+ if (!frame) {
118
+ throw new Error(`Spritesheet "${data.texture}" does not contain frame "${data.frame}"`);
119
+ }
120
+ return frame;
121
+ }
122
+
123
+ /** Instantiate a PIXI.Sprite / AnimatedSprite from authored `sprite` data. */
124
+ async function createSprite(data: SceneSprite): Promise<Sprite | AnimatedSprite> {
125
+ const loaded = data.texture
126
+ ? ((await Assets.load(data.texture)) as LoadedSpriteAsset)
127
+ : undefined;
128
+ const display = data.animation
129
+ ? createAnimatedSprite(data, loaded!)
130
+ : new Sprite(resolveStaticSpriteTexture(data, loaded));
131
+ applySpriteAppearance(display, data);
105
132
  return display;
106
133
  }
107
134
 
108
- /** Instantiate a @pixi/tilemap CompositeTilemap from authored `tilemap` data. */
109
- function createTilemap(data: SceneTilemap): CompositeTilemap {
110
- // Generate one tile texture per palette color (asset-light).
111
- const textures = data.palette.map((color) => {
112
- const c = document.createElement('canvas');
113
- c.width = data.tileSize;
114
- c.height = data.tileSize;
115
- const g = c.getContext('2d')!;
116
- g.fillStyle = color;
117
- g.fillRect(0, 0, data.tileSize, data.tileSize);
118
- g.strokeStyle = 'rgba(0,0,0,0.18)';
119
- g.strokeRect(0.5, 0.5, data.tileSize - 1, data.tileSize - 1);
120
- return Texture.from(c);
135
+ /** Instantiate a @pixi/tilemap CompositeTilemap from a native Pixi atlas. */
136
+ async function createTilemap(data: SceneTilemap): Promise<CompositeTilemap> {
137
+ const loaded = (await Assets.load(data.texture)) as LoadedSpriteAsset;
138
+ const textures = data.frames.map((frame) => {
139
+ const texture = loaded.textures?.[frame];
140
+ if (!texture) {
141
+ throw new Error(`Spritesheet "${data.texture}" does not contain tile frame "${frame}"`);
142
+ }
143
+ return texture;
121
144
  });
122
145
  const tilemap = new CompositeTilemap();
146
+ tilemapTextures.set(tilemap, textures);
123
147
  for (let y = 0; y < data.tiles.length; y++) {
124
148
  const row = data.tiles[y]!;
125
149
  for (let x = 0; x < row.length; x++) {
126
- const tex = textures[row[x]!];
127
- if (tex) tilemap.tile(tex, x * data.tileSize, y * data.tileSize);
150
+ const index = row[x]!;
151
+ if (index === -1) continue;
152
+ tilemap.tile(textures[index]!, x * data.tileSize, y * data.tileSize);
128
153
  }
129
154
  }
130
155
  return tilemap;
@@ -155,28 +180,62 @@ function bodyDesc(rapier2d: typeof RAPIER, type: string): RAPIER.RigidBodyDesc {
155
180
  }
156
181
  }
157
182
 
158
- /** Create the Rapier 2D body + collider for an entity, register it, return the body. */
159
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive physics2d-descriptor builder; each branch maps an optional authored field to a Rapier setter
160
- function createPhysics2D(ctx: World2DContext, entity: Entity2D, display: Container): void {
183
+ interface MovingPhysicsAncestor2D {
184
+ bodyType: string;
185
+ name: string;
186
+ }
187
+
188
+ function entityLabel(entity: Entity2D): string {
189
+ return entity.id ? `${entity.name} (id: "${entity.id}")` : entity.name;
190
+ }
191
+
192
+ function isMovingBodyType(bodyType: string): boolean {
193
+ return bodyType !== 'fixed';
194
+ }
195
+
196
+ /** Create the Rapier 2D body + collider for an entity and register it. */
197
+ function createPhysics2D(
198
+ ctx: World2DContext,
199
+ entity: Entity2D,
200
+ display: Container,
201
+ movingAncestor: MovingPhysicsAncestor2D | undefined,
202
+ ): void {
161
203
  const p = entity.physics2d;
162
204
  if (!p) return;
163
205
  const { rapier2d, rapier2dWorld, physics2d } = ctx;
164
- const pos = entity.transform2d?.position ?? [0, 0];
206
+ const label = entityLabel(entity);
207
+ if (movingAncestor) {
208
+ throw new Error(
209
+ `world2d physics entity "${label}" has a "${p.bodyType}" body nested under ` +
210
+ `"${movingAncestor.name}", which has a moving "${movingAncestor.bodyType}" body. ` +
211
+ 'Rapier has no parent/child body relationship, so a moving ancestor cannot carry ' +
212
+ 'another physics body. Move the nested body out of that hierarchy or make the ' +
213
+ 'ancestor fixed.',
214
+ );
215
+ }
216
+ if (isMovingBodyType(p.bodyType)) assertMovingPhysicsParent2D(display, ctx.stage, label);
217
+ const pose = computePhysicsPose2D(entity, display, ctx.stage, label);
218
+ const colliderShape = scaleCollider2D(p.collider, pose.scale, label);
165
219
 
166
220
  const rbDesc = bodyDesc(rapier2d, p.bodyType)
167
- .setTranslation(pos[0], pos[1])
168
- .setRotation(entity.transform2d?.rotation ?? 0);
221
+ .setTranslation(pose.position[0], pose.position[1])
222
+ .setRotation(pose.rotation);
169
223
  if (p.linearDamping !== undefined) rbDesc.setLinearDamping(p.linearDamping);
170
224
  if (p.gravityScale !== undefined) rbDesc.setGravityScale(p.gravityScale);
171
225
  if (p.lockRotations) rbDesc.lockRotations();
172
226
  const body = rapier2dWorld.createRigidBody(rbDesc);
173
227
 
174
228
  let colDesc: RAPIER.ColliderDesc;
175
- const c = p.collider;
176
- if (c.shape === 'cuboid')
177
- colDesc = rapier2d.ColliderDesc.cuboid(c.halfExtents[0], c.halfExtents[1]);
178
- else if (c.shape === 'ball') colDesc = rapier2d.ColliderDesc.ball(c.radius);
179
- else colDesc = rapier2d.ColliderDesc.capsule(c.halfHeight, c.radius);
229
+ if (colliderShape.shape === 'cuboid') {
230
+ colDesc = rapier2d.ColliderDesc.cuboid(
231
+ colliderShape.halfExtents[0],
232
+ colliderShape.halfExtents[1],
233
+ );
234
+ } else if (colliderShape.shape === 'ball') {
235
+ colDesc = rapier2d.ColliderDesc.ball(colliderShape.radius);
236
+ } else {
237
+ colDesc = rapier2d.ColliderDesc.capsule(colliderShape.halfHeight, colliderShape.radius);
238
+ }
180
239
 
181
240
  if (p.density !== undefined) colDesc.setDensity(p.density);
182
241
  if (p.friction !== undefined) colDesc.setFriction(p.friction);
@@ -214,89 +273,98 @@ function applyComponents2D(
214
273
  }
215
274
  }
216
275
 
217
- /** Apply a 2D transform to a display object. */
218
- function applyTransform2D(display: Container, entity: Entity2D): void {
276
+ /** Apply authored entity state to its live Pixi object. Exported so the source
277
+ * authoring adapter uses exactly the loader's Sprite-size/scale composition. */
278
+ export function applySceneEntityTransform2D(display: Container, entity: Entity2D): void {
219
279
  const t = entity.transform2d;
220
280
  if (t?.position) display.position.set(t.position[0], t.position[1]);
221
281
  if (t?.rotation !== undefined) display.rotation = t.rotation;
222
- if (t?.scale) display.scale.set(t.scale[0], t.scale[1]);
282
+ const authoredScale = t?.scale ?? [1, 1];
283
+ let baseScale = spriteBaseScales.get(display);
284
+ if (!baseScale) {
285
+ baseScale = entity.sprite ? [display.scale.x, display.scale.y] : [1, 1];
286
+ if (entity.sprite) spriteBaseScales.set(display, baseScale);
287
+ }
288
+ display.scale.set(baseScale[0] * authoredScale[0], baseScale[1] * authoredScale[1]);
223
289
  const pivot = t?.pivot ?? entity.pivot;
224
290
  if (pivot) display.pivot.set(pivot[0], pivot[1]);
225
291
  if (entity.visible === false) display.visible = false;
226
292
  }
227
293
 
228
- let _autoId = 0;
229
-
230
294
  /** A loaded world2d scene instance: the root display objects + an update/dispose. */
231
295
  export interface Scene2DInstance {
232
296
  roots: Container[];
233
297
  /** id → display object map for editor/lookup (the 2D analog of the loader's entity map). */
234
298
  byId: Map<string, Container>;
299
+ /** Advance authored AnimatedSprites through Pixi's native update method from the engine loop. */
300
+ updateAnimations(dt: number): void;
235
301
  dispose(): void;
236
302
  }
237
303
 
238
- /**
239
- * Load a world2d scene from authored data — the 2D analog of `loadSceneFromData`.
240
- * Three passes: (1) spawn all entities (recursing into children), building an
241
- * id→display map and creating Rapier 2D bodies; (2) [no joints in 2D yet];
242
- * (3) `componentManager.initAll()` once everything exists.
243
- *
244
- * Re-validates `data` even though the fetch path (`fetchScene2DFile` in
245
- * `packages/editor/src/adapter-resolver.ts`) already validates before calling
246
- * in — not every caller goes through that path (test-constructed
247
- * `PixiSceneConfig.sceneData` literals reach here unvalidated), so a malformed
248
- * payload still throws a `SceneParseError` naming the file, not a raw
249
- * `ZodError` (T4.6 convention, closing §7.1-14). `sourceName` is optional
250
- * because not every caller knows the originating file path; callers that do
251
- * (or gain the ability to) should thread it through for a better message.
252
- */
253
- export async function loadScene2DFromData(
254
- data: Scene2DFile,
255
- ctx: World2DContext,
256
- registry: Component2DRegistry,
257
- sourceName?: string,
304
+ type SpawnHook = (
305
+ entity: Entity2D,
306
+ display: Container,
307
+ movingAncestor: MovingPhysicsAncestor2D | undefined,
308
+ ) => void;
309
+
310
+ async function spawnScene2DVisuals(
311
+ scene: Scene2DFile,
312
+ stage: Container,
313
+ onSpawn?: SpawnHook,
258
314
  ): Promise<Scene2DInstance> {
259
- const result = Scene2DSchema.safeParse(data);
260
- if (!result.success) throw new SceneParseError(result.error.issues, sourceName);
261
- const scene = result.data;
262
315
  const byId = new Map<string, Container>();
316
+ const animatedSprites: AnimatedSprite[] = [];
263
317
  const roots: Container[] = [];
318
+ const ids = resolveScene2DEntityIds(scene);
264
319
 
265
- async function spawn(entity: Entity2D, parent: Container): Promise<Container> {
320
+ async function spawn(
321
+ entity: Entity2D,
322
+ parent: Container,
323
+ path: readonly number[],
324
+ movingAncestor?: MovingPhysicsAncestor2D,
325
+ ): Promise<Container> {
266
326
  let display: Container;
267
327
  if (entity.sprite) display = await createSprite(entity.sprite);
268
328
  else if (entity.text) display = createText(entity.text);
269
- else if (entity.tilemap) display = createTilemap(entity.tilemap);
329
+ else if (entity.tilemap) display = await createTilemap(entity.tilemap);
270
330
  else display = new Container();
331
+ if (display instanceof AnimatedSprite) animatedSprites.push(display);
271
332
 
272
333
  display.label = entity.name;
273
- const id = entity.id ?? `e2d_${_autoId++}`;
334
+ // Authored ids are canonical. The path fallback is deterministic across
335
+ // edit-mode reloads, unlike the old process-global counter, while keeping
336
+ // id optional for existing source documents.
337
+ const id = ids.get(entity)!;
274
338
  (display as Container & { __entityId?: string }).__entityId = id;
275
339
  byId.set(id, display);
276
340
 
277
- applyTransform2D(display, entity);
341
+ applySceneEntityTransform2D(display, entity);
278
342
  parent.addChild(display);
279
-
280
- // Physics bodies live in world space (flat under the world container); the
281
- // transform-writer drives their pose. Components attach to the display.
282
- createPhysics2D(ctx, entity, display);
283
- applyComponents2D(registry, display, entity.components, ctx.components);
343
+ onSpawn?.(entity, display, movingAncestor);
284
344
 
285
345
  if (entity.children) {
286
- for (const child of entity.children) await spawn(child, display);
346
+ const childMovingAncestor =
347
+ entity.physics2d && isMovingBodyType(entity.physics2d.bodyType)
348
+ ? { bodyType: entity.physics2d.bodyType, name: entityLabel(entity) }
349
+ : movingAncestor;
350
+ for (let index = 0; index < entity.children.length; index++) {
351
+ await spawn(entity.children[index]!, display, [...path, index], childMovingAncestor);
352
+ }
287
353
  }
288
354
  return display;
289
355
  }
290
356
 
291
- for (const entity of scene.entities) {
292
- roots.push(await spawn(entity, ctx.stage));
357
+ for (let index = 0; index < scene.entities.length; index++) {
358
+ roots.push(await spawn(scene.entities[index]!, stage, [index]));
293
359
  }
294
360
 
295
- await ctx.components.initAll();
296
-
297
361
  return {
298
362
  roots,
299
363
  byId,
364
+ updateAnimations(dt) {
365
+ const ticker = { deltaTime: dt * 60 } as Ticker;
366
+ for (const display of animatedSprites) display.update(ticker);
367
+ },
300
368
  dispose() {
301
369
  for (const root of roots) {
302
370
  root.removeFromParent();
@@ -304,6 +372,62 @@ export async function loadScene2DFromData(
304
372
  }
305
373
  roots.length = 0;
306
374
  byId.clear();
375
+ animatedSprites.length = 0;
307
376
  },
308
377
  };
309
378
  }
379
+
380
+ /**
381
+ * Instantiate only authored Pixi display objects. This is the editor's
382
+ * stopped-mode preview seam: no component construction/init, Rapier bodies,
383
+ * input manager, systems, or gameplay loop are created.
384
+ */
385
+ export async function loadScene2DVisualsFromData(
386
+ data: Scene2DFile,
387
+ stage: Container,
388
+ sourceName?: string,
389
+ ): Promise<Scene2DInstance> {
390
+ const result = Scene2DSchema.safeParse(data);
391
+ if (!result.success) throw new SceneParseError(result.error.issues, sourceName);
392
+ return spawnScene2DVisuals(result.data, stage);
393
+ }
394
+
395
+ /**
396
+ * Load a world2d scene from authored data — the 2D analog of `loadSceneFromData`.
397
+ * Three passes: (1) spawn all entities (recursing into children), building an
398
+ * id→display map and creating Rapier 2D bodies; (2) [no joints in 2D yet];
399
+ * (3) `componentManager.initAll()` once everything exists.
400
+ *
401
+ * Re-validates `data` even though the fetch path (`fetchScene2DFile` in
402
+ * `packages/editor/src/adapter-resolver.ts`) already validates before calling
403
+ * in — not every caller goes through that path (test-constructed
404
+ * `PixiSceneConfig.sceneData` literals reach here unvalidated), so a malformed
405
+ * payload still throws a `SceneParseError` naming the file, not a raw
406
+ * `ZodError` (T4.6 convention, closing §7.1-14). `sourceName` is optional
407
+ * because not every caller knows the originating file path; callers that do
408
+ * (or gain the ability to) should thread it through for a better message.
409
+ */
410
+ export async function loadScene2DFromData(
411
+ data: Scene2DFile,
412
+ ctx: World2DContext,
413
+ registry: Component2DRegistry,
414
+ sourceName?: string,
415
+ ): Promise<Scene2DInstance> {
416
+ const result = Scene2DSchema.safeParse(data);
417
+ if (!result.success) throw new SceneParseError(result.error.issues, sourceName);
418
+ const scene = result.data;
419
+ const instance = await spawnScene2DVisuals(
420
+ scene,
421
+ ctx.stage,
422
+ (entity, display, movingAncestor) => {
423
+ // Rapier bodies live in world space. The loader composes the Pixi parent
424
+ // chain at spawn; the post-physics writer converts the simulated pose back
425
+ // to parent-local display coordinates.
426
+ createPhysics2D(ctx, entity, display, movingAncestor);
427
+ applyComponents2D(registry, display, entity.components, ctx.components);
428
+ },
429
+ );
430
+
431
+ await ctx.components.initAll();
432
+ return instance;
433
+ }
@@ -36,10 +36,7 @@ const Entity2DBase = z.object({
36
36
  locked: z
37
37
  .boolean()
38
38
  .optional()
39
- .describe(
40
- 'Prevent selection and editing in the scene editor. REJECTED at parse (T4.1): declared ' +
41
- 'but has no runtime reader — authoring this field throws. Do not author.',
42
- ),
39
+ .describe('Prevent selection and editing in the native 2D scene editor'),
43
40
  tags: z
44
41
  .array(z.string())
45
42
  .optional()
@@ -66,11 +63,9 @@ export const Entity2DSchema: z.ZodType<Entity2D> = z.lazy(() =>
66
63
  Entity2DBase.extend({
67
64
  children: z.array(Entity2DSchema).optional().describe('Child entities'),
68
65
  })
69
- // T4.1: three fields are authored/typed but have no runtime reader in
66
+ // T4.1: two fields are authored/typed but have no runtime reader in
70
67
  // `scene2d-loader.ts`'s `spawn()` (unlike their 3D `SceneEntity` siblings,
71
- // which ARE read — editor selection-locking for `locked`, `scene-query.ts`
72
- // for `tags`):
73
- // - `locked` — spawn() never checks it (no 2D editor selection-lock yet).
68
+ // which ARE read — `scene-query.ts` for `tags`):
74
69
  // - `tags` — never copied onto userData; no 2D equivalent of scene-query.ts.
75
70
  // - `surface: 'world3d'` — spawn() always instantiates as world2d
76
71
  // regardless of this field's value, so a 'world3d' entity is silently
@@ -78,15 +73,6 @@ export const Entity2DSchema: z.ZodType<Entity2D> = z.lazy(() =>
78
73
  // harmless no-op and is not flagged.)
79
74
  // Loud load errors until readers exist — see T4.1.
80
75
  .superRefine((entity, ctx) => {
81
- if (entity.locked !== undefined) {
82
- ctx.addIssue({
83
- code: z.ZodIssueCode.custom,
84
- message:
85
- `world2d entity "${entity.name}": \`locked\` is authored but not implemented — ` +
86
- 'scene2d-loader.ts has no 2D editor selection-lock reader yet. See T4.1.',
87
- path: ['locked'],
88
- });
89
- }
90
76
  if (entity.tags !== undefined) {
91
77
  ctx.addIssue({
92
78
  code: z.ZodIssueCode.custom,
@@ -116,30 +102,62 @@ export const Scene2DSchema = z
116
102
  name: z
117
103
  .string()
118
104
  .optional()
105
+ .describe('Scene name shown in the native 2D document hierarchy and inspector'),
106
+ surface: z.literal('world2d').optional().describe("Render surface. Always 'world2d'"),
107
+ gravity: Vec2Schema.optional().describe(
108
+ 'World gravity [x, y] in px/s^2. Default [0, 9.8 * pixelsPerMeter] ([0, 980] at the default scale)',
109
+ ),
110
+ pixelsPerMeter: z
111
+ .number()
112
+ .positive()
113
+ .optional()
119
114
  .describe(
120
- 'Scene name. REJECTED at parse (T4.1): declared but has no runtime reader — authoring ' +
121
- 'this field throws. Do not author.',
115
+ 'Authored pixels per physics meter. Configures native Rapier World.lengthUnit and scales default gravity. Default 100',
122
116
  ),
123
- surface: z.literal('world2d').optional().describe("Render surface. Always 'world2d'"),
124
- gravity: Vec2Schema.optional().describe('World gravity [x, y] in px/s^2. Default [0, 980]'),
125
117
  background: z.string().optional().describe("Background color hex string. Default '#10101a'"),
126
118
  entities: z.array(Entity2DSchema).describe('Root entities of the world2d scene'),
127
119
  })
128
- // T4.1: `name` is authored/typed but has no runtime reader anywhere (unlike
129
- // `SceneFile.name`/`PrefabFile.name`, which the editor reads via
130
- // `editor-store.ts`'s `sceneName` getter/setter) — no 2D scene-name display
131
- // exists yet. Loud load error until a reader exists — see T4.1.
120
+ // Duplicate authored ids are rejected at the document boundary so source
121
+ // history and editor selection always address one stable native Container.
132
122
  .superRefine((scene, ctx) => {
133
- if (scene.name !== undefined) {
134
- ctx.addIssue({
135
- code: z.ZodIssueCode.custom,
136
- message:
137
- '`name` is authored but not implemented — no runtime reader displays/consumes a ' +
138
- 'world2d scene name yet (unlike SceneFile.name). See T4.1.',
139
- path: ['name'],
123
+ const authoredIds = new Map<string, Array<string | number>>();
124
+ const visit = (entities: Entity2D[], parentPath: Array<string | number>): void => {
125
+ entities.forEach((entity, index) => {
126
+ const entityPath = [...parentPath, index];
127
+ if (entity.id !== undefined) {
128
+ const idPath = [...entityPath, 'id'];
129
+ const firstPath = authoredIds.get(entity.id);
130
+ if (firstPath) {
131
+ ctx.addIssue({
132
+ code: z.ZodIssueCode.custom,
133
+ message:
134
+ `Duplicate world2d entity id "${entity.id}"; first authored at ` +
135
+ firstPath.join('.'),
136
+ path: idPath,
137
+ });
138
+ } else {
139
+ authoredIds.set(entity.id, idPath);
140
+ }
141
+ }
142
+ if (entity.children) visit(entity.children, [...entityPath, 'children']);
140
143
  });
141
- }
144
+ };
145
+ visit(scene.entities, ['entities']);
142
146
  })
143
147
  .describe('A world2d scene (PixiJS render surface)');
144
148
 
145
149
  export type Scene2DFile = z.infer<typeof Scene2DSchema>;
150
+
151
+ /** A copy-on-instantiate reusable native 2D entity subtree. This is a
152
+ * separate format from Three's `.prefab.json`: its root is an `Entity2D`, and
153
+ * insertion assigns fresh scene identities instead of creating a linked
154
+ * override layer. */
155
+ export const Entity2DAssetSchema = z
156
+ .object({
157
+ version: z.literal(1).describe('2D entity asset format version'),
158
+ name: z.string().describe('Reusable 2D entity asset display name'),
159
+ root: Entity2DSchema.describe('Native Entity2D subtree copied into a Scene2D document'),
160
+ })
161
+ .describe('A reusable native 2D entity asset (.entity2d.json)');
162
+
163
+ export type Entity2DAsset = z.infer<typeof Entity2DAssetSchema>;
@@ -5,7 +5,7 @@ import { Vec2Schema } from './tuples2d';
5
5
  export const Collider2DSchema = z
6
6
  .discriminatedUnion('shape', [
7
7
  z.object({
8
- shape: z.literal('cuboid').describe('Axis-aligned box collider'),
8
+ shape: z.literal('cuboid').describe('Box collider aligned to the rigid-body rotation'),
9
9
  halfExtents: Vec2Schema.describe('Half-width and half-height [hx, hy] in pixels'),
10
10
  }),
11
11
  z.object({
@@ -30,8 +30,19 @@ export const Physics2DSchema = z
30
30
  .object({
31
31
  bodyType: z
32
32
  .enum(['dynamic', 'fixed', 'kinematicVelocity', 'kinematicPosition'])
33
- .describe('Rapier 2D rigid-body type'),
34
- collider: Collider2DSchema.describe('Collider shape attached to the body'),
33
+ .describe(
34
+ 'Rapier 2D rigid-body type. A body of any type nested below a dynamic or kinematic ' +
35
+ 'body is rejected: Rapier has no parent/child body relationship. Nesting below a ' +
36
+ 'fixed or physics-less transformed parent is supported for fixed bodies. Dynamic ' +
37
+ 'and kinematic bodies additionally require a conformal parent chain (uniform scale ' +
38
+ 'plus rotation/reflection), because later body rotation cannot round-trip through ' +
39
+ 'non-uniform parent scale without shearing the Pixi visual.',
40
+ ),
41
+ collider: Collider2DSchema.describe(
42
+ 'Collider shape attached to the body. Its pose and dimensions use the composed parent ' +
43
+ 'transform at spawn. Cuboids support non-uniform scale; ball/capsule colliders reject ' +
44
+ 'non-uniform scale because Rapier cannot represent an ellipse.',
45
+ ),
35
46
  sensor: z
36
47
  .boolean()
37
48
  .optional()