@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.
- package/README.md +48 -15
- package/package.json +11 -25
- package/schemas/engine-capabilities.json +10 -10
- package/schemas/entity2d.schema.json +468 -0
- package/schemas/mat.schema.json +2 -33
- package/schemas/prefab.schema.json +16 -172
- package/schemas/scn2d.schema.json +42 -23
- package/schemas/vgai-game.schema.json +34 -0
- package/schemas/vscn.schema.json +16 -172
- package/src/adapter/authoring.ts +152 -2
- package/src/adapter/colyseus-networking-adapter.ts +35 -1
- package/src/adapter/first-party-systems.ts +7 -1
- package/src/adapter/game-adapter.ts +13 -0
- package/src/adapter/index.ts +25 -0
- package/src/adapter/rapier-physics-adapter.ts +55 -2
- package/src/adapter/system-adapter.ts +249 -2
- package/src/adapter/vgai-scene-game-adapter.ts +149 -25
- package/src/ai/navigation.ts +28 -0
- package/src/animation/clip-map.ts +1 -8
- package/src/animation/theatre-director.ts +50 -0
- package/src/animation/xstate-animation-binding.ts +6 -0
- package/src/audio/audio-introspection.ts +290 -0
- package/src/audio/tone-context.ts +46 -0
- package/src/dev/chrome-trace.ts +153 -0
- package/src/dev/performance-profiler.ts +93 -6
- package/src/dev/render-debug-adapter.ts +199 -0
- package/src/dev/render-memory.ts +243 -0
- package/src/dev/webgl-frame-capture.ts +424 -0
- package/src/ecs/component-manager.ts +43 -10
- package/src/ecs/game-component.ts +39 -10
- package/src/input/input-manager.ts +24 -19
- package/src/input/input-types.ts +1 -1
- package/src/loader.ts +7 -0
- package/src/manifest/load.ts +14 -0
- package/src/manifest/schema.ts +65 -0
- package/src/react/game-state.tsx +1 -1
- package/src/render/render-batch-system.ts +26 -12
- package/src/render/spark-renderer-lifecycle.ts +64 -0
- package/src/runtime/create-runtime.ts +33 -5
- package/src/runtime/debug-bridge.ts +5 -5
- package/src/runtime/game.ts +102 -20
- package/src/runtime/mount-manifest.ts +1 -1
- package/src/runtime/render-control.ts +121 -0
- package/src/runtime/types.ts +1 -1
- package/src/scene/asset-loaders.ts +77 -3
- package/src/scene/instance-mesh.ts +25 -0
- package/src/scene/material-factory.ts +4 -15
- package/src/scene/mesh-shadow.ts +18 -0
- package/src/scene/particles-factory.ts +59 -0
- package/src/scene/scene-loader.ts +41 -16
- package/src/scene/schema/instances.ts +1 -2
- package/src/scene/schema/material.ts +83 -94
- package/src/scene/schema/mesh.ts +76 -90
- package/src/scene/schema/scene-file.ts +1 -2
- package/src/scene/user-data.ts +30 -14
- package/src/setup/setup-renderer.ts +6 -1
- package/src/world2d/asset-paths2d.ts +44 -0
- package/src/world2d/collision-2d.ts +7 -14
- package/src/world2d/entity2d-asset.ts +22 -0
- package/src/world2d/index.ts +27 -2
- package/src/world2d/physics2d-transform.ts +173 -0
- package/src/world2d/physics2d-units.ts +10 -0
- package/src/world2d/pixi-game-adapter.ts +148 -36
- package/src/world2d/scene2d-identity.ts +49 -0
- package/src/world2d/scene2d-loader.ts +243 -119
- package/src/world2d/schema/entity2d.ts +51 -33
- package/src/world2d/schema/physics2d.ts +14 -3
- package/src/world2d/schema/sprite.ts +32 -4
- package/src/world2d/schema/tilemap.ts +26 -9
- package/src/world2d/transform-writer-2d.ts +29 -11
- package/src/world2d/types.ts +21 -8
- package/src/world3d-react/behavior.tsx +138 -0
- package/src/world3d-react/engine-bridge.ts +48 -0
- package/src/world3d-react/index.ts +44 -0
- package/src/world3d-react/r3f-adapter.tsx +303 -0
- package/src/world3d-react/world-context.ts +294 -0
- package/vendor/realism-effects/LICENSE.md +21 -0
- package/vendor/realism-effects/UPSTREAM.md +19 -0
- package/vendor/realism-effects/dist/index.cjs +3447 -0
- package/vendor/realism-effects/dist/index.d.ts +59 -0
- package/vendor/realism-effects/dist/index.js +3434 -0
- package/vendor/realism-effects/package.json +23 -0
- package/src/character/cloth-sim.ts +0 -533
- package/src/character/spring-chain.ts +0 -307
- package/src/humanoid/body.ts +0 -663
- package/src/humanoid/clips.ts +0 -149
- package/src/humanoid/compose.ts +0 -209
- package/src/humanoid/generate.ts +0 -189
- package/src/humanoid/index.ts +0 -36
- package/src/humanoid/schema.ts +0 -108
- package/src/humanoid/skeleton.ts +0 -345
- package/src/react/humanoid-bake.document.tsx +0 -337
- package/src/scene/geometries/index.ts +0 -7
- package/src/scene/geometries/terrain.ts +0 -42
- package/src/scene/geometry-registry.ts +0 -42
- package/src/scene/instance-registry.ts +0 -84
- package/src/scene/instancers/grid.ts +0 -38
- package/src/scene/instancers/index.ts +0 -7
- package/src/scene/material-registry.ts +0 -73
- package/src/scene/materials/index.ts +0 -7
- package/src/scene/materials/water.ts +0 -56
- package/src/world2d/components-2d.ts +0 -86
- 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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
function createTilemap(data: SceneTilemap): CompositeTilemap {
|
|
110
|
-
|
|
111
|
-
const textures = data.
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
127
|
-
if (
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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(
|
|
168
|
-
.setRotation(
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
292
|
-
roots.push(await spawn(
|
|
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:
|
|
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 —
|
|
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
|
-
'
|
|
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
|
-
//
|
|
129
|
-
//
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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('
|
|
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(
|
|
34
|
-
|
|
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()
|