@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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Entity, ShardError, World } from '@aethervtt/shard-core';
|
|
2
|
+
import { type Overrides, type PrefabFile } from './format.js';
|
|
3
|
+
/**
|
|
4
|
+
* Every problem in a prefab file, each with a JSON pointer. `catalog: false` (importing) checks
|
|
5
|
+
* asset paths only when they're file-local (`#name`); scenes check the rest when they validate.
|
|
6
|
+
*/
|
|
7
|
+
export declare function validatePrefab(world: World, json: unknown, options?: {
|
|
8
|
+
id?: string;
|
|
9
|
+
catalog?: boolean;
|
|
10
|
+
}): ShardError[];
|
|
11
|
+
/**
|
|
12
|
+
* Flattens a variant onto its (resolved) base: root components merge field by field, children are
|
|
13
|
+
* added, overrides apply to the tree (paths inside nested instances land in their overrides).
|
|
14
|
+
*/
|
|
15
|
+
export declare function applyVariant(base: PrefabFile, variant: PrefabFile): PrefabFile;
|
|
16
|
+
/** `*.prefab.json`: a validated entity tree; variants are flattened onto their base here. */
|
|
17
|
+
export declare const PrefabImporter: import("@aethervtt/shard-assets").ImporterDef;
|
|
18
|
+
/**
|
|
19
|
+
* Registers a prefab from JSON without a file (tests, tools, generated content), under `path`.
|
|
20
|
+
* Registering the same path again replaces it: instances update as if the file was saved, and
|
|
21
|
+
* variants registered on top of it are re-resolved. Throws with every validation error.
|
|
22
|
+
*/
|
|
23
|
+
export declare function registerPrefab(world: World, path: string, json: unknown): void;
|
|
24
|
+
/** Registered prefab JSON as it was given (before variants are flattened), or undefined. */
|
|
25
|
+
export declare function registeredPrefab(world: World, path: string): PrefabFile | undefined;
|
|
26
|
+
/** File access for `applyToPrefab` (the project folder). */
|
|
27
|
+
export interface PrefabFiles {
|
|
28
|
+
readText(path: string): Promise<string>;
|
|
29
|
+
writeText(path: string, text: string): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* "Apply to prefab": writes an instance's overrides into its prefab (the file, re-imported; or the
|
|
33
|
+
* registered JSON), then clears the instance's overrides. Every instance picks up the change, and
|
|
34
|
+
* this one ends up with nothing left to override.
|
|
35
|
+
*/
|
|
36
|
+
export declare function applyToPrefab(world: World, entity: Entity, files?: PrefabFiles): Promise<{
|
|
37
|
+
prefab: string;
|
|
38
|
+
applied: Overrides;
|
|
39
|
+
file?: string;
|
|
40
|
+
}>;
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { assetServer, defineImporter } from "@aethervtt/shard-assets";
|
|
2
|
+
import {
|
|
3
|
+
defineSchema,
|
|
4
|
+
isPlainObject,
|
|
5
|
+
pointer,
|
|
6
|
+
ShardError,
|
|
7
|
+
World
|
|
8
|
+
} from "@aethervtt/shard-core";
|
|
9
|
+
import { PrefabAssets, PrefabInstance } from "./components.js";
|
|
10
|
+
import { PREFAB_VERSION } from "./format.js";
|
|
11
|
+
import { currentOverrides, mergeOverridesIntoPrefab, prefabOfInstance } from "./instances.js";
|
|
12
|
+
import {
|
|
13
|
+
ALIASES,
|
|
14
|
+
dedupeErrors,
|
|
15
|
+
flatten,
|
|
16
|
+
validateAssetsBlock,
|
|
17
|
+
validateTree
|
|
18
|
+
} from "./scene.js";
|
|
19
|
+
const PREFAB_FIELDS = [
|
|
20
|
+
"$schema",
|
|
21
|
+
"version",
|
|
22
|
+
"assets",
|
|
23
|
+
"root",
|
|
24
|
+
"extends",
|
|
25
|
+
"rootComponents",
|
|
26
|
+
"overrides",
|
|
27
|
+
"children"
|
|
28
|
+
];
|
|
29
|
+
const VARIANT_FIELDS = ["extends", "rootComponents", "overrides", "children"];
|
|
30
|
+
function validatePrefab(world, json, options = {}) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
if (!isPlainObject(json)) {
|
|
33
|
+
return [
|
|
34
|
+
new ShardError("prefab/invalid", "A prefab file must be a JSON object", {
|
|
35
|
+
path: "",
|
|
36
|
+
hint: 'Write { "version": 1, "root": { "name": "...", "components": {...}, "children": [...] } }, or a variant with "extends".'
|
|
37
|
+
})
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
const file = json;
|
|
41
|
+
if (file.version !== PREFAB_VERSION) {
|
|
42
|
+
errors.push(
|
|
43
|
+
new ShardError(
|
|
44
|
+
"prefab/unsupported-version",
|
|
45
|
+
`Prefab version ${JSON.stringify(file.version)} is not supported`,
|
|
46
|
+
{ path: "/version", hint: `Use "version": ${PREFAB_VERSION}.` }
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
for (const key of Object.keys(file)) {
|
|
51
|
+
if (!PREFAB_FIELDS.includes(key)) {
|
|
52
|
+
errors.push(
|
|
53
|
+
new ShardError("prefab/unknown-field", `Unknown prefab field "${key}"`, {
|
|
54
|
+
path: pointer("", key),
|
|
55
|
+
hint: 'A prefab has "root" (an entity), or "extends" plus "rootComponents", "overrides", and "children" (a variant).'
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
validateAssetsBlock(file.assets, errors);
|
|
61
|
+
const id = options.id ?? "prefab";
|
|
62
|
+
const catalog = options.catalog ?? true;
|
|
63
|
+
const isVariant = file.extends !== void 0;
|
|
64
|
+
if (isVariant && file.root !== void 0) {
|
|
65
|
+
errors.push(
|
|
66
|
+
new ShardError("prefab/invalid", 'A prefab has "root" or "extends", not both', {
|
|
67
|
+
path: "/root",
|
|
68
|
+
hint: 'A variant takes its root from its base: change it with "rootComponents" and "overrides".'
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!isVariant) {
|
|
73
|
+
for (const key of VARIANT_FIELDS) {
|
|
74
|
+
if (key !== "extends" && key in file) {
|
|
75
|
+
errors.push(
|
|
76
|
+
new ShardError("prefab/unknown-field", `Only variants (with "extends") have "${key}"`, {
|
|
77
|
+
path: pointer("", key),
|
|
78
|
+
hint: 'Write the change into "root" directly.'
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!isPlainObject(file.root)) {
|
|
84
|
+
errors.push(
|
|
85
|
+
new ShardError("prefab/invalid", 'A prefab needs a "root" entity (or "extends")', {
|
|
86
|
+
path: "/root",
|
|
87
|
+
hint: 'Give { "name": "ship", "components": {...}, "children": [...] }.'
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
return dedupeErrors(errors);
|
|
91
|
+
}
|
|
92
|
+
const flat = [
|
|
93
|
+
{ path: ".", parent: void 0, entity: file.root, pointer: "/root" }
|
|
94
|
+
];
|
|
95
|
+
if (Array.isArray(file.root.children))
|
|
96
|
+
flatten(file.root.children, "/root/children", void 0, flat);
|
|
97
|
+
else if (file.root.children !== void 0) {
|
|
98
|
+
errors.push(
|
|
99
|
+
new ShardError("schema/type-mismatch", '"children" must be an array', {
|
|
100
|
+
path: "/root/children"
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
validateTree(world, file, flat, errors, { id, catalog, root: true });
|
|
105
|
+
return dedupeErrors(errors);
|
|
106
|
+
}
|
|
107
|
+
const base = file.extends;
|
|
108
|
+
if (!isPlainObject(base) || typeof base.path !== "string" && typeof base.guid !== "string" || Object.keys(base).some((k) => k !== "path" && k !== "guid")) {
|
|
109
|
+
errors.push(
|
|
110
|
+
new ShardError("schema/type-mismatch", '"extends" must be a prefab reference', {
|
|
111
|
+
path: "/extends",
|
|
112
|
+
hint: 'Give { "path": "prefabs/ship.prefab.json" }.'
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (file.rootComponents !== void 0) {
|
|
117
|
+
if (!isPlainObject(file.rootComponents)) {
|
|
118
|
+
errors.push(
|
|
119
|
+
new ShardError("schema/type-mismatch", '"rootComponents" must be an object', {
|
|
120
|
+
path: "/rootComponents"
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
const root = {
|
|
125
|
+
path: ".",
|
|
126
|
+
parent: void 0,
|
|
127
|
+
entity: { name: "root", components: file.rootComponents },
|
|
128
|
+
pointer: ""
|
|
129
|
+
};
|
|
130
|
+
const before = errors.length;
|
|
131
|
+
validateTree(world, file, [root], errors, { id, catalog, root: true, inheritsAssets: true });
|
|
132
|
+
for (let i = before; i < errors.length; i++) {
|
|
133
|
+
const e = errors[i];
|
|
134
|
+
errors[i] = new ShardError(
|
|
135
|
+
e.code,
|
|
136
|
+
e.message.replace(" at /components", " at /rootComponents"),
|
|
137
|
+
{
|
|
138
|
+
path: e.path?.replace(/^\/components/, "/rootComponents"),
|
|
139
|
+
hint: e.hint
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (file.children !== void 0) {
|
|
146
|
+
if (!Array.isArray(file.children)) {
|
|
147
|
+
errors.push(
|
|
148
|
+
new ShardError("schema/type-mismatch", '"children" must be an array', {
|
|
149
|
+
path: "/children"
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
} else {
|
|
153
|
+
const flat = [];
|
|
154
|
+
flatten(file.children, "/children", void 0, flat);
|
|
155
|
+
validateTree(world, file, flat, errors, {
|
|
156
|
+
id,
|
|
157
|
+
catalog,
|
|
158
|
+
extraPaths: ["."],
|
|
159
|
+
inheritsAssets: true
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (file.overrides !== void 0 && !isPlainObject(file.overrides)) {
|
|
164
|
+
errors.push(
|
|
165
|
+
new ShardError("schema/type-mismatch", '"overrides" must be an object', {
|
|
166
|
+
path: "/overrides",
|
|
167
|
+
hint: 'Map paths to component patches: { "Exhaust": { "particles/ParticleSystem": { "timeScale": 2 } } }.'
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return dedupeErrors(errors);
|
|
172
|
+
}
|
|
173
|
+
function applyVariant(base, variant) {
|
|
174
|
+
const unknown = [];
|
|
175
|
+
const merged = mergeOverridesIntoPrefab(
|
|
176
|
+
{ version: PREFAB_VERSION, assets: base.assets, root: base.root },
|
|
177
|
+
variant.overrides ?? {},
|
|
178
|
+
(key) => unknown.push(key)
|
|
179
|
+
);
|
|
180
|
+
if (unknown.length > 0) {
|
|
181
|
+
throw new ShardError("prefab/unknown-path", `No entity at "${unknown[0]}" in the base prefab`, {
|
|
182
|
+
path: pointer("/overrides", unknown[0]),
|
|
183
|
+
hint: 'Override paths are relative to the root, e.g. "Hull" or "Hull/Cockpit".'
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const root = merged.root;
|
|
187
|
+
const components = { ...root.components ?? {} };
|
|
188
|
+
for (const [name, patch] of Object.entries(variant.rootComponents ?? {})) {
|
|
189
|
+
const own = { ...components[name] ?? {} };
|
|
190
|
+
for (const [alias, { field }] of Object.entries(ALIASES[name] ?? {})) {
|
|
191
|
+
if (field in patch) delete own[alias];
|
|
192
|
+
if (alias in patch) delete own[field];
|
|
193
|
+
}
|
|
194
|
+
components[name] = { ...own, ...patch };
|
|
195
|
+
}
|
|
196
|
+
const out = {
|
|
197
|
+
version: PREFAB_VERSION,
|
|
198
|
+
root: {
|
|
199
|
+
...root,
|
|
200
|
+
components,
|
|
201
|
+
...variant.children?.length || root.children ? { children: [...root.children ?? [], ...variant.children ?? []] } : {}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const assets = { ...base.assets ?? {}, ...variant.assets ?? {} };
|
|
205
|
+
if (Object.keys(assets).length > 0) out.assets = assets;
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
function referencedPaths(file) {
|
|
209
|
+
const out = /* @__PURE__ */ new Set();
|
|
210
|
+
const collect = (value) => {
|
|
211
|
+
if (Array.isArray(value)) value.forEach(collect);
|
|
212
|
+
else if (isPlainObject(value)) {
|
|
213
|
+
for (const [k, v] of Object.entries(value)) {
|
|
214
|
+
if (k === "path" && typeof v === "string") {
|
|
215
|
+
if (!v.startsWith("#") && !v.startsWith("procedural:")) out.add(v);
|
|
216
|
+
} else collect(v);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
collect(file.root);
|
|
221
|
+
return [...out];
|
|
222
|
+
}
|
|
223
|
+
function prefabRefs(json) {
|
|
224
|
+
const out = [];
|
|
225
|
+
if (typeof json.extends?.path === "string") out.push(json.extends.path);
|
|
226
|
+
const walk = (entities) => {
|
|
227
|
+
if (!Array.isArray(entities)) return;
|
|
228
|
+
for (const e of entities) {
|
|
229
|
+
const ref = e?.components?.["scene/PrefabInstance"]?.prefab;
|
|
230
|
+
if (isPlainObject(ref) && typeof ref.path === "string") out.push(ref.path);
|
|
231
|
+
walk(e?.children);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
walk(json.root ? [json.root] : []);
|
|
235
|
+
walk(json.children);
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
function parse(text, path) {
|
|
239
|
+
let json;
|
|
240
|
+
try {
|
|
241
|
+
json = JSON.parse(text);
|
|
242
|
+
} catch (cause) {
|
|
243
|
+
throw new ShardError("assets/import-failed", `${path} isn't valid JSON`, { path, cause });
|
|
244
|
+
}
|
|
245
|
+
if (isPlainObject(json) && "$schema" in json) {
|
|
246
|
+
const { $schema: _, ...rest } = json;
|
|
247
|
+
json = rest;
|
|
248
|
+
}
|
|
249
|
+
return json;
|
|
250
|
+
}
|
|
251
|
+
function invalid(path, errors) {
|
|
252
|
+
const first = errors[0];
|
|
253
|
+
return new ShardError(
|
|
254
|
+
first.code.startsWith("prefab/") ? first.code : "assets/import-failed",
|
|
255
|
+
`${path}: ${first.message}${errors.length > 1 ? ` (+${errors.length - 1} more)` : ""}`,
|
|
256
|
+
{ path: first.path, hint: first.hint, details: errors }
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
let bare;
|
|
260
|
+
async function resolvePrefab(path, json, read, chain) {
|
|
261
|
+
bare ??= new World();
|
|
262
|
+
const errors = validatePrefab(bare, json, { id: path, catalog: false });
|
|
263
|
+
if (errors.length > 0) throw invalid(path, errors);
|
|
264
|
+
const seen = [...chain, path];
|
|
265
|
+
const visit = async (file, stack) => {
|
|
266
|
+
for (const ref of prefabRefs(file)) {
|
|
267
|
+
const next = await read(ref);
|
|
268
|
+
if (stack.includes(next.path)) {
|
|
269
|
+
throw new ShardError(
|
|
270
|
+
"prefab/cycle",
|
|
271
|
+
`${seen[0]} contains or extends itself (${[...stack, next.path].join(" \u2192 ")})`,
|
|
272
|
+
{
|
|
273
|
+
path: seen[0],
|
|
274
|
+
hint: "A prefab can\u2019t contain or extend itself, directly or through others."
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
await visit(next.json, [...stack, next.path]);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
await visit(json, seen);
|
|
282
|
+
if (json.extends === void 0) return { file: json, bases: [] };
|
|
283
|
+
const base = await read(json.extends.path);
|
|
284
|
+
const resolved = await resolvePrefab(base.path, base.json, read, seen);
|
|
285
|
+
return { file: applyVariant(resolved.file, json), bases: [base.path, ...resolved.bases] };
|
|
286
|
+
}
|
|
287
|
+
function countEntities(root) {
|
|
288
|
+
const out = [];
|
|
289
|
+
const walk = (list, prefix) => {
|
|
290
|
+
for (const e of list ?? []) {
|
|
291
|
+
const path = prefix ? `${prefix}/${e.name}` : e.name;
|
|
292
|
+
out.push(path);
|
|
293
|
+
walk(e.children, path);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
walk(root?.children, "");
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
const NoSettings = defineSchema(
|
|
300
|
+
"scene/PrefabSettings",
|
|
301
|
+
{},
|
|
302
|
+
{ description: "None: the prefab is the file." }
|
|
303
|
+
);
|
|
304
|
+
const PrefabImporter = defineImporter({
|
|
305
|
+
name: "prefab",
|
|
306
|
+
version: 1,
|
|
307
|
+
extensions: [".prefab.json"],
|
|
308
|
+
settings: NoSettings,
|
|
309
|
+
async import(source, ctx) {
|
|
310
|
+
const json = parse(source.text(), source.path);
|
|
311
|
+
const decoder = new TextDecoder();
|
|
312
|
+
const read = async (p) => {
|
|
313
|
+
const full = ctx.resolve(p);
|
|
314
|
+
let bytes;
|
|
315
|
+
try {
|
|
316
|
+
bytes = await ctx.read(p);
|
|
317
|
+
} catch (cause) {
|
|
318
|
+
throw new ShardError(
|
|
319
|
+
"prefab/not-found",
|
|
320
|
+
`${source.path} refers to ${p}, which can't be read`,
|
|
321
|
+
{
|
|
322
|
+
path: p,
|
|
323
|
+
hint: 'Check the path: prefab paths start at an asset root, e.g. "prefabs/ship.prefab.json".',
|
|
324
|
+
cause
|
|
325
|
+
}
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return { path: full, json: parse(decoder.decode(bytes), full) };
|
|
329
|
+
};
|
|
330
|
+
const { file, bases } = await resolvePrefab(source.path, json, read, []);
|
|
331
|
+
const paths = countEntities(file.root);
|
|
332
|
+
const dependencies = referencedPaths(file);
|
|
333
|
+
return {
|
|
334
|
+
assets: [
|
|
335
|
+
{
|
|
336
|
+
label: "",
|
|
337
|
+
type: "Prefab",
|
|
338
|
+
json: file,
|
|
339
|
+
...dependencies.length ? { dependencies } : {},
|
|
340
|
+
info: {
|
|
341
|
+
root: file.root?.name ?? null,
|
|
342
|
+
entities: paths.length + 1,
|
|
343
|
+
tree: paths.slice(0, 200),
|
|
344
|
+
...bases.length ? { extends: bases } : {}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
]
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
const registered = /* @__PURE__ */ new WeakMap();
|
|
352
|
+
function registerPrefab(world, path, json) {
|
|
353
|
+
const file = json;
|
|
354
|
+
const errors = validatePrefab(world, json, { id: path, catalog: false });
|
|
355
|
+
if (errors.length > 0) {
|
|
356
|
+
throw new ShardError(
|
|
357
|
+
"prefab/invalid",
|
|
358
|
+
`Prefab "${path}" has ${errors.length} error${errors.length === 1 ? "" : "s"}; first: ${errors[0].message}`,
|
|
359
|
+
{ path: errors[0].path, hint: errors[0].hint, details: errors }
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
let files = registered.get(world);
|
|
363
|
+
if (!files) {
|
|
364
|
+
files = /* @__PURE__ */ new Map();
|
|
365
|
+
registered.set(world, files);
|
|
366
|
+
}
|
|
367
|
+
files.set(path, file);
|
|
368
|
+
const server = assetServer(world);
|
|
369
|
+
const resolve = (p, f, chain) => {
|
|
370
|
+
if (chain.includes(p)) {
|
|
371
|
+
throw new ShardError(
|
|
372
|
+
"prefab/cycle",
|
|
373
|
+
`${chain[0]} extends itself (${[...chain, p].join(" \u2192 ")})`,
|
|
374
|
+
{
|
|
375
|
+
path: chain[0],
|
|
376
|
+
hint: "A prefab can\u2019t contain or extend itself, directly or through others."
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (f.extends === void 0) return f;
|
|
381
|
+
const basePath = f.extends.path ?? server.entry({ guid: f.extends.guid })?.path ?? "";
|
|
382
|
+
const own = files.get(basePath);
|
|
383
|
+
const entry = server.entry(f.extends);
|
|
384
|
+
const base = own ? resolve(basePath, own, [...chain, p]) : entry ? world.resource(PrefabAssets).byGuid(entry.guid) : void 0;
|
|
385
|
+
if (!base) {
|
|
386
|
+
throw new ShardError("prefab/not-loaded", `${p} extends ${basePath}, which isn't loaded`, {
|
|
387
|
+
hint: "Register (or load) the base prefab first."
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return applyVariant(base, f);
|
|
391
|
+
};
|
|
392
|
+
const publish = (p, f) => {
|
|
393
|
+
const resolved = resolve(p, f, []);
|
|
394
|
+
server.updateVirtual(`mem:prefab:${p}`, p, "Prefab", () => resolved);
|
|
395
|
+
};
|
|
396
|
+
publish(path, file);
|
|
397
|
+
const dependsOn = (f, target, depth = 0) => {
|
|
398
|
+
const base = f.extends?.path;
|
|
399
|
+
if (!base || depth > 16) return false;
|
|
400
|
+
return base === target || files.has(base) && dependsOn(files.get(base), target, depth + 1);
|
|
401
|
+
};
|
|
402
|
+
for (const [p, f] of files) if (p !== path && dependsOn(f, path)) publish(p, f);
|
|
403
|
+
}
|
|
404
|
+
function registeredPrefab(world, path) {
|
|
405
|
+
return registered.get(world)?.get(path);
|
|
406
|
+
}
|
|
407
|
+
async function applyToPrefab(world, entity, files) {
|
|
408
|
+
const entry = prefabOfInstance(world, entity);
|
|
409
|
+
if (!entry) {
|
|
410
|
+
throw new ShardError(
|
|
411
|
+
"prefab/not-an-instance",
|
|
412
|
+
`Entity ${entity} isn't a spawned prefab instance`,
|
|
413
|
+
{
|
|
414
|
+
hint: 'Pass the entity that has scene/PrefabInstance (e.g. "player-ship"), once it has spawned.'
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const overrides = currentOverrides(world, entity) ?? {};
|
|
419
|
+
const own = registeredPrefab(world, entry.path);
|
|
420
|
+
const clear = () => world.set(entity, PrefabInstance, { overrides: {} });
|
|
421
|
+
if (own) {
|
|
422
|
+
clear();
|
|
423
|
+
registerPrefab(world, entry.path, mergeOverridesIntoPrefab(own, overrides));
|
|
424
|
+
return { prefab: entry.path, applied: overrides };
|
|
425
|
+
}
|
|
426
|
+
if (!entry.source || !files) {
|
|
427
|
+
throw new ShardError("prefab/read-only", `Can't write ${entry.path} from this host`, {
|
|
428
|
+
hint: "Apply from the CLI or Studio, which can write the project folder."
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const json = JSON.parse(await files.readText(entry.source));
|
|
432
|
+
const merged = mergeOverridesIntoPrefab(json, overrides);
|
|
433
|
+
await files.writeText(entry.source, `${JSON.stringify(merged, null, 2)}
|
|
434
|
+
`);
|
|
435
|
+
clear();
|
|
436
|
+
await assetServer(world).reimport(entry.source);
|
|
437
|
+
return { prefab: entry.path, applied: overrides, file: entry.source };
|
|
438
|
+
}
|
|
439
|
+
export {
|
|
440
|
+
PrefabImporter,
|
|
441
|
+
applyToPrefab,
|
|
442
|
+
applyVariant,
|
|
443
|
+
registerPrefab,
|
|
444
|
+
registeredPrefab,
|
|
445
|
+
validatePrefab
|
|
446
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ResolvedAsset, type World } from '@aethervtt/shard-core';
|
|
2
|
+
import { type Mesh } from '@aethervtt/shard-mesh';
|
|
3
|
+
type Factory = (params: Record<string, number>) => Mesh;
|
|
4
|
+
/** Procedural mesh sources, by name, with the parameters each accepts. */
|
|
5
|
+
export declare const PROCEDURAL_MESHES: Record<string, {
|
|
6
|
+
params: readonly string[];
|
|
7
|
+
create: Factory;
|
|
8
|
+
}>;
|
|
9
|
+
export interface ProceduralRef {
|
|
10
|
+
source: string;
|
|
11
|
+
params: Record<string, number>;
|
|
12
|
+
/** Canonical key: equal refs share one mesh (and instance together). */
|
|
13
|
+
key: string;
|
|
14
|
+
}
|
|
15
|
+
/** Parses `procedural:box?x=4&y=1` (or a name + params object). Throws with a hint on bad input. */
|
|
16
|
+
export declare function parseProcedural(source: string, params?: Record<string, unknown>, path?: string): ProceduralRef;
|
|
17
|
+
/**
|
|
18
|
+
* A kind of `procedural:` ref beyond the built-in meshes, e.g. project generators
|
|
19
|
+
* (`procedural:star-explorer/Rock?seed=3`). `spec` is everything after `procedural:`.
|
|
20
|
+
*/
|
|
21
|
+
export interface ProceduralSource {
|
|
22
|
+
/** Whether this source handles refs named `name` (the part before `?`). */
|
|
23
|
+
handles(name: string): boolean;
|
|
24
|
+
/** Checks a ref without making anything; throws a ShardError (with `path`) if it's invalid. */
|
|
25
|
+
check(spec: string, path?: string): {
|
|
26
|
+
type: string;
|
|
27
|
+
};
|
|
28
|
+
/** The asset for a ref, registered with the world's asset server (it may still be loading). */
|
|
29
|
+
resolve(world: World, spec: string): ResolvedAsset;
|
|
30
|
+
}
|
|
31
|
+
/** Adds a source of `procedural:` refs. Once per source. */
|
|
32
|
+
export declare function defineProceduralSource(source: ProceduralSource): ProceduralSource;
|
|
33
|
+
export declare function allProceduralSources(): readonly ProceduralSource[];
|
|
34
|
+
/** The source for a `procedural:` spec, if it isn't a built-in mesh. */
|
|
35
|
+
export declare function proceduralSourceFor(spec: string): ProceduralSource | undefined;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import {
|
|
3
|
+
box,
|
|
4
|
+
capsule,
|
|
5
|
+
cone,
|
|
6
|
+
cube,
|
|
7
|
+
cylinder,
|
|
8
|
+
plane,
|
|
9
|
+
sphere,
|
|
10
|
+
torus
|
|
11
|
+
} from "@aethervtt/shard-mesh";
|
|
12
|
+
const PROCEDURAL_MESHES = {
|
|
13
|
+
box: { params: ["x", "y", "z"], create: (p) => box(p) },
|
|
14
|
+
cube: { params: ["size"], create: (p) => cube(p) },
|
|
15
|
+
sphere: { params: ["radius", "segments", "rings"], create: (p) => sphere(p) },
|
|
16
|
+
plane: { params: ["size", "subdivisions"], create: (p) => plane(p) },
|
|
17
|
+
cylinder: { params: ["radius", "radiusTop", "height", "segments"], create: (p) => cylinder(p) },
|
|
18
|
+
cone: { params: ["radius", "height", "segments"], create: (p) => cone(p) },
|
|
19
|
+
capsule: { params: ["radius", "height", "segments", "rings"], create: (p) => capsule(p) },
|
|
20
|
+
torus: {
|
|
21
|
+
params: ["radius", "tube", "radialSegments", "tubularSegments"],
|
|
22
|
+
create: (p) => torus(p)
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
function parseProcedural(source, params = {}, path) {
|
|
26
|
+
let name = source;
|
|
27
|
+
const all = { ...params };
|
|
28
|
+
const q = source.indexOf("?");
|
|
29
|
+
if (q !== -1) {
|
|
30
|
+
name = source.slice(0, q);
|
|
31
|
+
for (const pair of source.slice(q + 1).split("&")) {
|
|
32
|
+
if (!pair) continue;
|
|
33
|
+
const [k, v] = pair.split("=");
|
|
34
|
+
all[k] = v === void 0 ? void 0 : Number(v);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const def = PROCEDURAL_MESHES[name];
|
|
38
|
+
if (!def) {
|
|
39
|
+
throw new ShardError("scene/unknown-procedural", `Unknown procedural mesh "${name}"`, {
|
|
40
|
+
path,
|
|
41
|
+
hint: `Use one of: ${Object.keys(PROCEDURAL_MESHES).join(", ")}.`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [k, v] of Object.entries(all)) {
|
|
46
|
+
if (!def.params.includes(k)) {
|
|
47
|
+
throw new ShardError("scene/unknown-procedural-param", `"${name}" has no parameter "${k}"`, {
|
|
48
|
+
path,
|
|
49
|
+
hint: `Parameters: ${def.params.join(", ")}.`
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
53
|
+
throw new ShardError(
|
|
54
|
+
"scene/invalid-procedural-param",
|
|
55
|
+
`"${name}" parameter "${k}" must be a number`,
|
|
56
|
+
{ path }
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
out[k] = v;
|
|
60
|
+
}
|
|
61
|
+
const key = `procedural:${name}?${Object.keys(out).sort().map((k) => `${k}=${out[k]}`).join("&")}`;
|
|
62
|
+
return { source: name, params: out, key };
|
|
63
|
+
}
|
|
64
|
+
const sources = [];
|
|
65
|
+
function defineProceduralSource(source) {
|
|
66
|
+
if (!sources.includes(source)) sources.push(source);
|
|
67
|
+
return source;
|
|
68
|
+
}
|
|
69
|
+
function allProceduralSources() {
|
|
70
|
+
return sources;
|
|
71
|
+
}
|
|
72
|
+
function proceduralSourceFor(spec) {
|
|
73
|
+
const q = spec.indexOf("?");
|
|
74
|
+
const name = q === -1 ? spec : spec.slice(0, q);
|
|
75
|
+
if (PROCEDURAL_MESHES[name]) return void 0;
|
|
76
|
+
return sources.find((s) => s.handles(name));
|
|
77
|
+
}
|
|
78
|
+
export {
|
|
79
|
+
PROCEDURAL_MESHES,
|
|
80
|
+
allProceduralSources,
|
|
81
|
+
defineProceduralSource,
|
|
82
|
+
parseProcedural,
|
|
83
|
+
proceduralSourceFor
|
|
84
|
+
};
|