@aethervtt/shard-scene 0.0.1
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/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/components.d.ts +47 -0
- package/dist/components.js +93 -0
- package/dist/format.d.ts +53 -0
- package/dist/format.js +6 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +121 -0
- package/dist/instances.d.ts +120 -0
- package/dist/instances.js +1295 -0
- package/dist/prefab-file.d.ts +40 -0
- package/dist/prefab-file.js +446 -0
- package/dist/procedural.d.ts +36 -0
- package/dist/procedural.js +84 -0
- package/dist/scene.d.ts +93 -0
- package/dist/scene.js +736 -0
- package/dist/schema.d.ts +11 -0
- package/dist/schema.js +122 -0
- package/package.json +38 -0
package/dist/scene.js
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
import { assetServer } from "@aethervtt/shard-assets";
|
|
2
|
+
import {
|
|
3
|
+
ChildOf,
|
|
4
|
+
findComponent,
|
|
5
|
+
findResource,
|
|
6
|
+
isPlainObject,
|
|
7
|
+
pointer,
|
|
8
|
+
quat,
|
|
9
|
+
ShardError
|
|
10
|
+
} from "@aethervtt/shard-core";
|
|
11
|
+
import { Materials, Meshes, materialFromJson, validateMaterial } from "@aethervtt/shard-render";
|
|
12
|
+
import { FloatingOrigin, Grid, GridCell, Transform } from "@aethervtt/shard-transform";
|
|
13
|
+
import {
|
|
14
|
+
InstancePart,
|
|
15
|
+
PrefabInstance,
|
|
16
|
+
SceneIndex,
|
|
17
|
+
SceneInstance,
|
|
18
|
+
SceneMember
|
|
19
|
+
} from "./components.js";
|
|
20
|
+
import { SCENE_VERSION } from "./format.js";
|
|
21
|
+
import {
|
|
22
|
+
currentOverrides,
|
|
23
|
+
hookInstances,
|
|
24
|
+
instanceKindOf,
|
|
25
|
+
instanceKinds,
|
|
26
|
+
settleInstances,
|
|
27
|
+
validateInstance
|
|
28
|
+
} from "./instances.js";
|
|
29
|
+
import { PROCEDURAL_MESHES, parseProcedural, proceduralSourceFor } from "./procedural.js";
|
|
30
|
+
const ALIASES = {
|
|
31
|
+
"core/Transform": {
|
|
32
|
+
rotationEuler: {
|
|
33
|
+
field: "rotation",
|
|
34
|
+
// Degrees, applied X then Y then Z.
|
|
35
|
+
decode(json) {
|
|
36
|
+
const [x, y, z] = json.map((d) => d * Math.PI / 180);
|
|
37
|
+
return [...quat.fromEuler([0, 0, 0, 1], x, y, z)];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
function expandAliases(name, json, base, errors) {
|
|
43
|
+
const aliases = ALIASES[name];
|
|
44
|
+
if (!aliases) return json;
|
|
45
|
+
let out = json;
|
|
46
|
+
for (const [alias, { field, decode }] of Object.entries(aliases)) {
|
|
47
|
+
if (!(alias in json)) continue;
|
|
48
|
+
const value = json[alias];
|
|
49
|
+
if (field in json) {
|
|
50
|
+
errors?.push(
|
|
51
|
+
new ShardError("scene/conflicting-fields", `Both "${alias}" and "${field}" are set`, {
|
|
52
|
+
path: pointer(base, alias),
|
|
53
|
+
hint: `Use one: "${alias}" is the authoring form of "${field}".`
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
out = { ...out };
|
|
57
|
+
delete out[alias];
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(value) || value.length !== 3 || !value.every((v) => typeof v === "number" && Number.isFinite(v))) {
|
|
61
|
+
errors?.push(
|
|
62
|
+
new ShardError("schema/type-mismatch", `"${alias}" must be [x, y, z] in degrees`, {
|
|
63
|
+
path: pointer(base, alias)
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
out = { ...out };
|
|
69
|
+
delete out[alias];
|
|
70
|
+
out[field] = decode(value);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function resolveAssets(world, file, sceneId, mode, requested) {
|
|
75
|
+
const local = /* @__PURE__ */ new Map();
|
|
76
|
+
const server = assetServer(world);
|
|
77
|
+
const fromCatalog = (ref) => {
|
|
78
|
+
const entry = server.entry(ref);
|
|
79
|
+
if (!entry) return void 0;
|
|
80
|
+
if (mode === "create") {
|
|
81
|
+
server.request(entry.guid);
|
|
82
|
+
requested?.add(entry.guid);
|
|
83
|
+
}
|
|
84
|
+
return { guid: entry.guid, path: ref.path ?? entry.path, type: entry.type };
|
|
85
|
+
};
|
|
86
|
+
return (ref) => {
|
|
87
|
+
const path = ref.path;
|
|
88
|
+
if (path === void 0) {
|
|
89
|
+
const guid = ref.guid;
|
|
90
|
+
const known = fromCatalog(ref);
|
|
91
|
+
if (known) return known;
|
|
92
|
+
const type = guid.startsWith("mem:mesh") ? "Mesh" : guid.startsWith("mem:material") ? "Material" : void 0;
|
|
93
|
+
return type ? { guid, path: guid, type } : void 0;
|
|
94
|
+
}
|
|
95
|
+
if (path.startsWith("#")) {
|
|
96
|
+
const name = path.slice(1);
|
|
97
|
+
const cached = local.get(name);
|
|
98
|
+
if (cached) return cached;
|
|
99
|
+
const entry = file.assets?.[name];
|
|
100
|
+
if (!entry) return void 0;
|
|
101
|
+
if (mode === "check") return { guid: `pending:${path}`, path, type: entry.type };
|
|
102
|
+
let resolved;
|
|
103
|
+
if (entry.type === "Material") {
|
|
104
|
+
const store = world.tryResource(Materials);
|
|
105
|
+
if (!store) return void 0;
|
|
106
|
+
const ref2 = store.add(materialFromJson(entry.value), `${sceneId}${path}`);
|
|
107
|
+
resolved = { guid: ref2.guid, path, type: "Material" };
|
|
108
|
+
} else {
|
|
109
|
+
const proc = parseProcedural(entry.procedural, entry.params);
|
|
110
|
+
resolved = { ...procedural(world, proc.key, proc.source, proc.params), path };
|
|
111
|
+
}
|
|
112
|
+
local.set(name, resolved);
|
|
113
|
+
return resolved;
|
|
114
|
+
}
|
|
115
|
+
if (path.startsWith("procedural:")) {
|
|
116
|
+
const cached = local.get(path);
|
|
117
|
+
if (cached) return cached;
|
|
118
|
+
const spec = path.slice("procedural:".length);
|
|
119
|
+
const source = proceduralSourceFor(spec);
|
|
120
|
+
if (source) {
|
|
121
|
+
let resolved2;
|
|
122
|
+
if (mode === "check") {
|
|
123
|
+
try {
|
|
124
|
+
resolved2 = { guid: `pending:${path}`, path, type: source.check(spec).type };
|
|
125
|
+
} catch {
|
|
126
|
+
return void 0;
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
const made = source.resolve(world, spec);
|
|
130
|
+
server.request(made.guid);
|
|
131
|
+
requested?.add(made.guid);
|
|
132
|
+
resolved2 = { ...made, path };
|
|
133
|
+
}
|
|
134
|
+
local.set(path, resolved2);
|
|
135
|
+
return resolved2;
|
|
136
|
+
}
|
|
137
|
+
let proc;
|
|
138
|
+
try {
|
|
139
|
+
proc = parseProcedural(path.slice("procedural:".length));
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (mode === "check") return void 0;
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
const resolved = mode === "check" ? { guid: `pending:${proc.key}`, path, type: "Mesh" } : { ...procedural(world, proc.key, proc.source, proc.params), path };
|
|
145
|
+
local.set(path, resolved);
|
|
146
|
+
return resolved;
|
|
147
|
+
}
|
|
148
|
+
return fromCatalog(ref);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function procedural(world, key, source, params) {
|
|
152
|
+
if (!world.tryResource(Meshes)) {
|
|
153
|
+
throw new ShardError(
|
|
154
|
+
"scene/asset-unavailable",
|
|
155
|
+
"Procedural meshes need the render/forward plugin"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const entry = assetServer(world).virtual(
|
|
159
|
+
`proc:${key}`,
|
|
160
|
+
key,
|
|
161
|
+
"Mesh",
|
|
162
|
+
() => PROCEDURAL_MESHES[source].create(params)
|
|
163
|
+
);
|
|
164
|
+
return { guid: entry.guid, path: key, type: "Mesh" };
|
|
165
|
+
}
|
|
166
|
+
function worldSchemaContext(world) {
|
|
167
|
+
hookInstances(world);
|
|
168
|
+
const empty = { version: SCENE_VERSION, entities: [] };
|
|
169
|
+
return {
|
|
170
|
+
resolveEntity: (path) => findEntityByPath(world, path),
|
|
171
|
+
resolveAsset: resolveAssets(world, empty, "runtime", "create")
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function expandComponentAliases(name, json) {
|
|
175
|
+
const errors = [];
|
|
176
|
+
const out = expandAliases(name, json, "", errors);
|
|
177
|
+
if (errors.length > 0) throw errors[0];
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
function flatten(entities, base, parent, out) {
|
|
181
|
+
entities.forEach((entity, i) => {
|
|
182
|
+
const ptr = `${base}/${i}`;
|
|
183
|
+
const path = parent === void 0 ? entity.name : `${parent}/${entity.name}`;
|
|
184
|
+
out.push({ path, parent, entity, pointer: ptr });
|
|
185
|
+
if (Array.isArray(entity.children)) flatten(entity.children, `${ptr}/children`, path, out);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function rebase(err, base) {
|
|
189
|
+
return new ShardError(
|
|
190
|
+
err.code,
|
|
191
|
+
err.message.replace(/ at \/\S*/, ` at ${base}${err.path ?? ""}`),
|
|
192
|
+
{
|
|
193
|
+
path: `${base}${err.path ?? ""}`,
|
|
194
|
+
hint: err.hint
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
function componentDef(name) {
|
|
199
|
+
try {
|
|
200
|
+
return findComponent(name);
|
|
201
|
+
} catch {
|
|
202
|
+
return void 0;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function validateScene(world, json, options = {}) {
|
|
206
|
+
const errors = [];
|
|
207
|
+
if (!isPlainObject(json)) {
|
|
208
|
+
return [new ShardError("scene/invalid", "A scene file must be a JSON object", { path: "" })];
|
|
209
|
+
}
|
|
210
|
+
const file = json;
|
|
211
|
+
if (file.version !== SCENE_VERSION) {
|
|
212
|
+
errors.push(
|
|
213
|
+
new ShardError(
|
|
214
|
+
"scene/unsupported-version",
|
|
215
|
+
`Scene version ${JSON.stringify(file.version)} is not supported`,
|
|
216
|
+
{
|
|
217
|
+
path: "/version",
|
|
218
|
+
hint: `Use "version": ${SCENE_VERSION}.`
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
for (const key of Object.keys(file)) {
|
|
224
|
+
if (!["$schema", "version", "assets", "resources", "entities"].includes(key)) {
|
|
225
|
+
errors.push(
|
|
226
|
+
new ShardError("scene/unknown-field", `Unknown scene field "${key}"`, { path: `/${key}` })
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
validateAssetsBlock(file.assets, errors);
|
|
231
|
+
if (file.resources !== void 0) {
|
|
232
|
+
for (const [name, value] of Object.entries(file.resources ?? {})) {
|
|
233
|
+
if (!findResource(name)) {
|
|
234
|
+
errors.push(
|
|
235
|
+
new ShardError("scene/unknown-resource", `Unknown resource "${name}"`, {
|
|
236
|
+
path: pointer("/resources", name)
|
|
237
|
+
})
|
|
238
|
+
);
|
|
239
|
+
} else if (!isPlainObject(value)) {
|
|
240
|
+
errors.push(
|
|
241
|
+
new ShardError("schema/type-mismatch", `Resource "${name}" must be an object`, {
|
|
242
|
+
path: pointer("/resources", name)
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!Array.isArray(file.entities)) {
|
|
249
|
+
errors.push(
|
|
250
|
+
new ShardError("schema/type-mismatch", '"entities" must be an array', { path: "/entities" })
|
|
251
|
+
);
|
|
252
|
+
return errors;
|
|
253
|
+
}
|
|
254
|
+
const flat = [];
|
|
255
|
+
flatten(file.entities, "/entities", void 0, flat);
|
|
256
|
+
validateTree(world, file, flat, errors, { id: options.id ?? "main" });
|
|
257
|
+
return dedupeErrors(errors);
|
|
258
|
+
}
|
|
259
|
+
function validateAssetsBlock(assets, errors) {
|
|
260
|
+
if (assets === void 0) return;
|
|
261
|
+
if (!isPlainObject(assets)) {
|
|
262
|
+
errors.push(
|
|
263
|
+
new ShardError("schema/type-mismatch", '"assets" must be an object', { path: "/assets" })
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
for (const [name, entry] of Object.entries(assets)) {
|
|
268
|
+
const base = pointer("/assets", name);
|
|
269
|
+
if (entry?.type === "Material") {
|
|
270
|
+
for (const e of validateMaterial(entry.value)) errors.push(rebase(e, `${base}/value`));
|
|
271
|
+
} else if (entry?.type === "Mesh") {
|
|
272
|
+
try {
|
|
273
|
+
parseProcedural(entry.procedural, entry.params, base);
|
|
274
|
+
} catch (e) {
|
|
275
|
+
if (e instanceof ShardError) errors.push(e);
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
errors.push(
|
|
279
|
+
new ShardError(
|
|
280
|
+
"scene/invalid-asset",
|
|
281
|
+
`Asset "${name}" needs "type": "Material" or "Mesh"`,
|
|
282
|
+
{
|
|
283
|
+
path: base,
|
|
284
|
+
hint: 'Materials: { "type": "Material", "value": {...} }. Meshes: { "type": "Mesh", "procedural": "sphere", "params": {...} }.'
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function validateTree(world, file, flat, errors, options) {
|
|
292
|
+
const checkedProcedural = /* @__PURE__ */ new Set();
|
|
293
|
+
const catalog = options.catalog ?? true;
|
|
294
|
+
const paths = new Set(options.extraPaths);
|
|
295
|
+
const prefab = options.root || options.extraPaths?.includes(".");
|
|
296
|
+
for (const f of flat) {
|
|
297
|
+
if (typeof f.entity?.name !== "string" || f.entity.name === "" || f.entity.name.includes("/")) {
|
|
298
|
+
errors.push(
|
|
299
|
+
new ShardError("scene/invalid-name", 'Every entity needs a "name" (non-empty, no "/")', {
|
|
300
|
+
path: `${f.pointer}/name`
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (paths.has(f.path)) {
|
|
306
|
+
errors.push(
|
|
307
|
+
new ShardError(
|
|
308
|
+
"scene/duplicate-name",
|
|
309
|
+
`Two entities are named "${f.entity.name}" under the same parent`,
|
|
310
|
+
{
|
|
311
|
+
path: `${f.pointer}/name`,
|
|
312
|
+
hint: "Names must be unique among siblings; they form entity paths."
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
paths.add(f.path);
|
|
318
|
+
}
|
|
319
|
+
const ctx = {
|
|
320
|
+
resolveAsset: resolveAssets(world, file, options.id, "check"),
|
|
321
|
+
resolveEntity: (path) => paths.has(path) ? 0 : void 0
|
|
322
|
+
};
|
|
323
|
+
for (const f of flat) {
|
|
324
|
+
for (const key of Object.keys(f.entity ?? {})) {
|
|
325
|
+
if (!["name", "components", "children"].includes(key)) {
|
|
326
|
+
errors.push(
|
|
327
|
+
new ShardError("scene/unknown-field", `Unknown entity field "${key}"`, {
|
|
328
|
+
path: pointer(f.pointer, key)
|
|
329
|
+
})
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const components = f.entity?.components;
|
|
334
|
+
if (components === void 0) continue;
|
|
335
|
+
if (!isPlainObject(components)) {
|
|
336
|
+
errors.push(
|
|
337
|
+
new ShardError("schema/type-mismatch", '"components" must be an object', {
|
|
338
|
+
path: `${f.pointer}/components`
|
|
339
|
+
})
|
|
340
|
+
);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
for (const [name, raw] of Object.entries(components)) {
|
|
344
|
+
const base = pointer(`${f.pointer}/components`, name);
|
|
345
|
+
const def = componentDef(name);
|
|
346
|
+
if (!def) {
|
|
347
|
+
errors.push(
|
|
348
|
+
new ShardError("scene/unknown-component", `Unknown component "${name}"`, {
|
|
349
|
+
path: base,
|
|
350
|
+
hint: 'Use a registered name like "core/Transform" or "render/Camera3d" (see .agents/components.md).'
|
|
351
|
+
})
|
|
352
|
+
);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (!def.serializable || def === ChildOf) {
|
|
356
|
+
errors.push(
|
|
357
|
+
new ShardError("scene/derived-component", `"${name}" can't be written in a scene`, {
|
|
358
|
+
path: base,
|
|
359
|
+
hint: def === ChildOf ? 'Nest the entity under "children" instead.' : "It is computed by the engine each frame."
|
|
360
|
+
})
|
|
361
|
+
);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (!isPlainObject(raw)) {
|
|
365
|
+
errors.push(
|
|
366
|
+
new ShardError("schema/type-mismatch", `"${name}" must be an object`, { path: base })
|
|
367
|
+
);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const expanded = expandAliases(name, raw, base, errors);
|
|
371
|
+
const before = errors.length;
|
|
372
|
+
for (const e of def.validate(expanded, ctx)) {
|
|
373
|
+
if (!catalog && isCatalogMiss(e, expanded)) continue;
|
|
374
|
+
if (options.inheritsAssets && isLocalMiss(e, expanded)) continue;
|
|
375
|
+
errors.push(rebase(e, base));
|
|
376
|
+
}
|
|
377
|
+
for (const { name: field, field: type } of def.layout) {
|
|
378
|
+
const value = expanded[field];
|
|
379
|
+
if (type.kind === "entity" && typeof value === "string" && !paths.has(value)) {
|
|
380
|
+
errors.push(
|
|
381
|
+
new ShardError("scene/unknown-entity-path", `No entity at path "${value}"`, {
|
|
382
|
+
path: pointer(base, field),
|
|
383
|
+
hint: `Paths join names with "/" from the top of the ${prefab ? 'prefab (its root is ".")' : "scene"}, e.g. "${[...paths].find((p) => p !== ".") ?? "ship/camera"}".`
|
|
384
|
+
})
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
if (type.kind === "handle" && isPlainObject(value) && typeof value.path === "string") {
|
|
388
|
+
const p = value.path;
|
|
389
|
+
if (p.startsWith("procedural:") && !checkedProcedural.has(p)) {
|
|
390
|
+
try {
|
|
391
|
+
const spec = p.slice("procedural:".length);
|
|
392
|
+
const source = proceduralSourceFor(spec);
|
|
393
|
+
if (source) source.check(spec, pointer(base, field));
|
|
394
|
+
else parseProcedural(spec, {}, pointer(base, field));
|
|
395
|
+
checkedProcedural.add(p);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
if (e instanceof ShardError) errors.push(e);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if ((def === PrefabInstance || def === SceneInstance) && errors.length === before) {
|
|
403
|
+
validateInstance(world, def, expanded, base, f, errors, { catalog });
|
|
404
|
+
} else if (errors.length === before) {
|
|
405
|
+
instanceKindOf(def)?.validate?.(world, expanded, base, errors, { catalog });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
validateGrids(flat, errors, prefab === true);
|
|
410
|
+
}
|
|
411
|
+
function validateGrids(flat, errors, prefab) {
|
|
412
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
413
|
+
for (const f of flat) if (typeof f.path === "string") byPath.set(f.path, f);
|
|
414
|
+
const components = (f) => isPlainObject(f?.entity?.components) ? f.entity.components : void 0;
|
|
415
|
+
let origin;
|
|
416
|
+
for (const f of flat) {
|
|
417
|
+
const own = components(f);
|
|
418
|
+
if (!own) continue;
|
|
419
|
+
if (own[FloatingOrigin.name] !== void 0) {
|
|
420
|
+
if (origin === void 0) origin = f;
|
|
421
|
+
else {
|
|
422
|
+
errors.push(
|
|
423
|
+
new ShardError(
|
|
424
|
+
"transform/multiple-origins",
|
|
425
|
+
`"${f.path}" and "${origin.path}" both have FloatingOrigin`,
|
|
426
|
+
{
|
|
427
|
+
path: pointer(`${f.pointer}/components`, FloatingOrigin.name),
|
|
428
|
+
hint: "Keep one transform/FloatingOrigin per world, usually on the camera."
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (prefab && f.path === ".") continue;
|
|
435
|
+
const parentPath = f.parent ?? (prefab ? "." : void 0);
|
|
436
|
+
const parent = parentPath === void 0 ? void 0 : byPath.get(parentPath);
|
|
437
|
+
if (parentPath !== void 0 && parent === void 0) continue;
|
|
438
|
+
const up = components(parent);
|
|
439
|
+
if (up && instanceKinds().some((k) => up[k.def.name] !== void 0)) continue;
|
|
440
|
+
const grid = up?.[Grid.name];
|
|
441
|
+
const cell = own[GridCell.name];
|
|
442
|
+
if (grid === void 0) {
|
|
443
|
+
if (cell === void 0) continue;
|
|
444
|
+
errors.push(
|
|
445
|
+
new ShardError(
|
|
446
|
+
"transform/cell-outside-grid",
|
|
447
|
+
`"${f.path}" has a GridCell but its parent isn't a Grid`,
|
|
448
|
+
{
|
|
449
|
+
path: pointer(`${f.pointer}/components`, GridCell.name),
|
|
450
|
+
hint: "Nest the entity directly under an entity with transform/Grid, or remove its GridCell."
|
|
451
|
+
}
|
|
452
|
+
)
|
|
453
|
+
);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const transform = own[Transform.name];
|
|
457
|
+
const translation = isPlainObject(transform) ? transform.translation : void 0;
|
|
458
|
+
if (!Array.isArray(translation)) continue;
|
|
459
|
+
const size = isPlainObject(grid) ? grid.cellSize : void 0;
|
|
460
|
+
const cellSize = typeof size === "number" && size > 0 ? size : Grid.fields.cellSize.defaultValue();
|
|
461
|
+
if (translation.some((v) => typeof v === "number" && Math.abs(v) > cellSize)) {
|
|
462
|
+
errors.push(
|
|
463
|
+
new ShardError(
|
|
464
|
+
"transform/translation-outside-cell",
|
|
465
|
+
`"${f.path}" is more than one cell (${cellSize} m) from its cell's centre`,
|
|
466
|
+
{
|
|
467
|
+
path: pointer(pointer(`${f.pointer}/components`, Transform.name), "translation"),
|
|
468
|
+
hint: 'Split the position into transform/GridCell (whole cells) plus a translation under one cell, or place it with entity.patch { "position64": [x, y, z], "grid": <grid> }.'
|
|
469
|
+
}
|
|
470
|
+
)
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function isLocalMiss(e, json) {
|
|
476
|
+
return e.code === "schema/asset-not-found" && !isCatalogMiss(e, json);
|
|
477
|
+
}
|
|
478
|
+
function isCatalogMiss(e, json) {
|
|
479
|
+
if (e.code !== "schema/asset-not-found") return false;
|
|
480
|
+
let value = json;
|
|
481
|
+
for (const part of (e.path ?? "").split("/").slice(1)) {
|
|
482
|
+
const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
483
|
+
value = isPlainObject(value) || Array.isArray(value) ? value[key] : void 0;
|
|
484
|
+
}
|
|
485
|
+
const path = isPlainObject(value) ? value.path : void 0;
|
|
486
|
+
return typeof path !== "string" || !path.startsWith("#");
|
|
487
|
+
}
|
|
488
|
+
function dedupeErrors(errors) {
|
|
489
|
+
const specific = new Set(
|
|
490
|
+
errors.filter((e) => !e.code.startsWith("schema/") && e.code !== "scene/invalid").map((e) => e.path)
|
|
491
|
+
);
|
|
492
|
+
const seen = /* @__PURE__ */ new Set();
|
|
493
|
+
return errors.filter((e) => {
|
|
494
|
+
if (e.code === "schema/asset-not-found" && specific.has(e.path)) return false;
|
|
495
|
+
const key = `${e.code}|${e.path}`;
|
|
496
|
+
if (seen.has(key)) return false;
|
|
497
|
+
seen.add(key);
|
|
498
|
+
return true;
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
function loadScene(world, json, options = {}) {
|
|
502
|
+
const id = options.id ?? "main";
|
|
503
|
+
const index = world.initResource(SceneIndex);
|
|
504
|
+
if (index.has(id)) {
|
|
505
|
+
throw new ShardError("scene/already-loaded", `A scene with id "${id}" is already loaded`, {
|
|
506
|
+
hint: "Use reloadScene to replace it, or pass a different id."
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
const errors = validateScene(world, json, { id });
|
|
510
|
+
if (errors.length > 0) {
|
|
511
|
+
throw new ShardError(
|
|
512
|
+
"scene/invalid",
|
|
513
|
+
`Scene "${id}" has ${errors.length} error${errors.length === 1 ? "" : "s"}; first: ${errors[0].message}`,
|
|
514
|
+
{ path: errors[0].path, hint: errors[0].hint, details: errors }
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
const file = json;
|
|
518
|
+
const flat = [];
|
|
519
|
+
flatten(file.entities, "/entities", void 0, flat);
|
|
520
|
+
const entities = /* @__PURE__ */ new Map();
|
|
521
|
+
for (const f of flat) entities.set(f.path, world.reserveEntity());
|
|
522
|
+
const requested = /* @__PURE__ */ new Set();
|
|
523
|
+
const ctx = {
|
|
524
|
+
resolveAsset: resolveAssets(world, file, id, "create", requested),
|
|
525
|
+
resolveEntity: (path) => entities.get(path)
|
|
526
|
+
};
|
|
527
|
+
const scene = {
|
|
528
|
+
id,
|
|
529
|
+
file,
|
|
530
|
+
order: flat.map((f) => f.path),
|
|
531
|
+
entities,
|
|
532
|
+
authored: /* @__PURE__ */ new Map(),
|
|
533
|
+
loaded: /* @__PURE__ */ new Map(),
|
|
534
|
+
loadedResources: /* @__PURE__ */ new Map(),
|
|
535
|
+
assets: requested
|
|
536
|
+
};
|
|
537
|
+
for (const f of flat) {
|
|
538
|
+
const entity = entities.get(f.path);
|
|
539
|
+
const inits = [];
|
|
540
|
+
for (const [name, raw] of Object.entries(f.entity.components ?? {})) {
|
|
541
|
+
const def = findComponent(name);
|
|
542
|
+
inits.push([def, def.deserialize(expandAliases(name, raw, "", void 0), ctx)]);
|
|
543
|
+
}
|
|
544
|
+
if (f.parent !== void 0)
|
|
545
|
+
inits.push([ChildOf, { parent: entities.get(f.parent) }]);
|
|
546
|
+
inits.push([SceneMember, { scene: id, path: f.path }]);
|
|
547
|
+
world.spawnReserved(entity, inits);
|
|
548
|
+
scene.authored.set(entity, f.entity);
|
|
549
|
+
}
|
|
550
|
+
for (const entity of entities.values())
|
|
551
|
+
scene.loaded.set(entity, serializeComponents(world, entity));
|
|
552
|
+
for (const [name, value] of Object.entries(file.resources ?? {})) {
|
|
553
|
+
const def = findResource(name);
|
|
554
|
+
const current = world.tryResource(def);
|
|
555
|
+
if (isPlainObject(current)) Object.assign(current, value);
|
|
556
|
+
else world.insertResource(def, structuredCloneJson(value));
|
|
557
|
+
scene.loadedResources.set(name, toJson(world.resource(def)));
|
|
558
|
+
}
|
|
559
|
+
index.set(id, scene);
|
|
560
|
+
return { id, entities };
|
|
561
|
+
}
|
|
562
|
+
async function whenSceneReady(world, id) {
|
|
563
|
+
const scene = world.tryResource(SceneIndex)?.get(id);
|
|
564
|
+
if (!scene) throw new ShardError("scene/not-loaded", `No scene with id "${id}" is loaded`);
|
|
565
|
+
const server = assetServer(world);
|
|
566
|
+
let size = -1;
|
|
567
|
+
while (scene.assets.size !== size) {
|
|
568
|
+
size = scene.assets.size;
|
|
569
|
+
await server.whenSettled(scene.assets);
|
|
570
|
+
await settleInstances(world);
|
|
571
|
+
for (const wait of sceneWaiters) await wait(world, scene.id);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const sceneWaiters = [];
|
|
575
|
+
function addSceneReadyCheck(check) {
|
|
576
|
+
sceneWaiters.push(check);
|
|
577
|
+
}
|
|
578
|
+
function reloadScene(world, id, json) {
|
|
579
|
+
unloadScene(world, id, { collect: false });
|
|
580
|
+
const handle = loadScene(world, json, { id });
|
|
581
|
+
assetServer(world).collect();
|
|
582
|
+
return handle;
|
|
583
|
+
}
|
|
584
|
+
function unloadScene(world, id, options = {}) {
|
|
585
|
+
const index = world.initResource(SceneIndex);
|
|
586
|
+
const scene = index.get(id);
|
|
587
|
+
if (!scene) return;
|
|
588
|
+
for (const path of scene.order) {
|
|
589
|
+
const entity = scene.entities.get(path);
|
|
590
|
+
if (world.isAlive(entity)) world.despawn(entity);
|
|
591
|
+
}
|
|
592
|
+
index.delete(id);
|
|
593
|
+
if (options.collect ?? true) assetServer(world).collect();
|
|
594
|
+
}
|
|
595
|
+
function findEntityByPath(world, path) {
|
|
596
|
+
const index = world.tryResource(SceneIndex);
|
|
597
|
+
if (!index) return void 0;
|
|
598
|
+
const colon = path.indexOf(":");
|
|
599
|
+
if (colon !== -1) return index.get(path.slice(0, colon))?.entities.get(path.slice(colon + 1));
|
|
600
|
+
for (const scene of index.values()) {
|
|
601
|
+
const e = scene.entities.get(path);
|
|
602
|
+
if (e !== void 0 && world.isAlive(e)) return e;
|
|
603
|
+
}
|
|
604
|
+
return void 0;
|
|
605
|
+
}
|
|
606
|
+
function pathOfEntity(world, entity) {
|
|
607
|
+
return world.tryGet(entity, SceneMember)?.path;
|
|
608
|
+
}
|
|
609
|
+
function toJson(value) {
|
|
610
|
+
return JSON.parse(
|
|
611
|
+
JSON.stringify(
|
|
612
|
+
value,
|
|
613
|
+
(_, v) => ArrayBuffer.isView(v) ? Array.from(v) : v
|
|
614
|
+
)
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
function structuredCloneJson(value) {
|
|
618
|
+
return JSON.parse(JSON.stringify(value));
|
|
619
|
+
}
|
|
620
|
+
function equal(a, b) {
|
|
621
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
622
|
+
}
|
|
623
|
+
function serializeComponents(world, entity) {
|
|
624
|
+
const out = /* @__PURE__ */ new Map();
|
|
625
|
+
for (const def of world.componentsOf(entity)) {
|
|
626
|
+
if (!def.serializable || def === ChildOf) continue;
|
|
627
|
+
out.set(def.name, def.serialize(world.get(entity, def)));
|
|
628
|
+
}
|
|
629
|
+
return out;
|
|
630
|
+
}
|
|
631
|
+
function saveScene(world, id) {
|
|
632
|
+
const scene = world.initResource(SceneIndex).get(id);
|
|
633
|
+
if (!scene) throw new ShardError("scene/not-loaded", `No scene with id "${id}" is loaded`);
|
|
634
|
+
const writeEntity = (path) => {
|
|
635
|
+
const entity = scene.entities.get(path);
|
|
636
|
+
if (!world.isAlive(entity)) return void 0;
|
|
637
|
+
const authored = scene.authored.get(entity);
|
|
638
|
+
const loaded = scene.loaded.get(entity);
|
|
639
|
+
const current = serializeComponents(world, entity);
|
|
640
|
+
for (const { def } of instanceKinds()) {
|
|
641
|
+
const value = current.get(def.name);
|
|
642
|
+
if (value) value.overrides = currentOverrides(world, entity) ?? value.overrides;
|
|
643
|
+
}
|
|
644
|
+
const out2 = {};
|
|
645
|
+
for (const key of Object.keys(authored)) {
|
|
646
|
+
if (key === "name") out2.name = authored.name;
|
|
647
|
+
else if (key === "components")
|
|
648
|
+
out2.components = writeComponents(authored.components ?? {}, loaded, current);
|
|
649
|
+
else if (key === "children") {
|
|
650
|
+
out2.children = (authored.children ?? []).map((child) => writeEntity(`${path}/${child.name}`)).filter((c) => c !== void 0);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (!("components" in authored)) {
|
|
654
|
+
const added = writeComponents({}, loaded, current);
|
|
655
|
+
if (Object.keys(added).length > 0) out2.components = added;
|
|
656
|
+
}
|
|
657
|
+
return out2;
|
|
658
|
+
};
|
|
659
|
+
const writeComponents = (authored, loaded, current) => {
|
|
660
|
+
const out2 = {};
|
|
661
|
+
for (const [name, authoredJson] of Object.entries(authored)) {
|
|
662
|
+
const now = current.get(name);
|
|
663
|
+
if (!now) continue;
|
|
664
|
+
out2[name] = writeFields(name, authoredJson, loaded.get(name) ?? {}, now);
|
|
665
|
+
}
|
|
666
|
+
for (const [name, now] of current) {
|
|
667
|
+
if (name in authored || name === SceneMember.name || name === InstancePart.name) continue;
|
|
668
|
+
const before = loaded.get(name);
|
|
669
|
+
if (before && equal(before, now)) continue;
|
|
670
|
+
out2[name] = now;
|
|
671
|
+
}
|
|
672
|
+
return out2;
|
|
673
|
+
};
|
|
674
|
+
const writeFields = (name, authored, loaded, now) => {
|
|
675
|
+
const out2 = {};
|
|
676
|
+
const aliases = ALIASES[name] ?? {};
|
|
677
|
+
const covered = /* @__PURE__ */ new Set();
|
|
678
|
+
for (const [key, value] of Object.entries(authored)) {
|
|
679
|
+
const field = aliases[key]?.field ?? key;
|
|
680
|
+
covered.add(field);
|
|
681
|
+
if (equal(now[field], loaded[field])) out2[key] = value;
|
|
682
|
+
else out2[field] = now[field];
|
|
683
|
+
}
|
|
684
|
+
for (const field of Object.keys(now)) {
|
|
685
|
+
if (covered.has(field)) continue;
|
|
686
|
+
if (!equal(now[field], loaded[field])) out2[field] = now[field];
|
|
687
|
+
}
|
|
688
|
+
return out2;
|
|
689
|
+
};
|
|
690
|
+
const out = {};
|
|
691
|
+
for (const key of Object.keys(scene.file)) {
|
|
692
|
+
if (key === "entities") {
|
|
693
|
+
out.entities = scene.file.entities.map((e) => writeEntity(e.name)).filter((e) => e !== void 0);
|
|
694
|
+
} else if (key === "resources") {
|
|
695
|
+
const resources = {};
|
|
696
|
+
for (const [name, authored] of Object.entries(scene.file.resources ?? {})) {
|
|
697
|
+
const now = toJson(world.tryResource(findResource(name)));
|
|
698
|
+
resources[name] = equal(now, scene.loadedResources.get(name)) ? authored : now;
|
|
699
|
+
}
|
|
700
|
+
out.resources = resources;
|
|
701
|
+
} else {
|
|
702
|
+
out[key] = scene.file[key];
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return out;
|
|
706
|
+
}
|
|
707
|
+
function stringifyScene(file) {
|
|
708
|
+
return `${JSON.stringify(file, null, 2)}
|
|
709
|
+
`;
|
|
710
|
+
}
|
|
711
|
+
export {
|
|
712
|
+
ALIASES,
|
|
713
|
+
addSceneReadyCheck,
|
|
714
|
+
componentDef,
|
|
715
|
+
dedupeErrors,
|
|
716
|
+
equal,
|
|
717
|
+
expandAliases,
|
|
718
|
+
expandComponentAliases,
|
|
719
|
+
findEntityByPath,
|
|
720
|
+
flatten,
|
|
721
|
+
loadScene,
|
|
722
|
+
pathOfEntity,
|
|
723
|
+
rebase,
|
|
724
|
+
reloadScene,
|
|
725
|
+
resolveAssets,
|
|
726
|
+
saveScene,
|
|
727
|
+
serializeComponents,
|
|
728
|
+
stringifyScene,
|
|
729
|
+
toJson,
|
|
730
|
+
unloadScene,
|
|
731
|
+
validateAssetsBlock,
|
|
732
|
+
validateScene,
|
|
733
|
+
validateTree,
|
|
734
|
+
whenSceneReady,
|
|
735
|
+
worldSchemaContext
|
|
736
|
+
};
|