@aethervtt/shard-protocol 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/index.d.ts +4 -0
- package/dist/index.js +18 -0
- package/dist/png.d.ts +9 -0
- package/dist/png.js +69 -0
- package/dist/preview.d.ts +10 -0
- package/dist/preview.js +249 -0
- package/dist/server.d.ts +59 -0
- package/dist/server.js +1086 -0
- package/dist/transport.d.ts +15 -0
- package/dist/transport.js +53 -0
- package/package.json +42 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
import { assetServer } from "@aethervtt/shard-assets";
|
|
2
|
+
import {
|
|
3
|
+
allComponents,
|
|
4
|
+
ChildOf,
|
|
5
|
+
Children,
|
|
6
|
+
defineSchema,
|
|
7
|
+
findComponent,
|
|
8
|
+
findResource,
|
|
9
|
+
isPlainObject,
|
|
10
|
+
Last,
|
|
11
|
+
PostUpdate,
|
|
12
|
+
ShardError,
|
|
13
|
+
t
|
|
14
|
+
} from "@aethervtt/shard-core";
|
|
15
|
+
import {
|
|
16
|
+
describeInput,
|
|
17
|
+
injectInput,
|
|
18
|
+
startRecording,
|
|
19
|
+
startReplay,
|
|
20
|
+
stopRecording
|
|
21
|
+
} from "@aethervtt/shard-input";
|
|
22
|
+
import {
|
|
23
|
+
captureBuffer,
|
|
24
|
+
captureShadowMap,
|
|
25
|
+
captureView,
|
|
26
|
+
DebugOverlays,
|
|
27
|
+
describeRender,
|
|
28
|
+
Gizmos,
|
|
29
|
+
Gpu,
|
|
30
|
+
isOverlayOn,
|
|
31
|
+
OffscreenTarget,
|
|
32
|
+
OVERLAYS,
|
|
33
|
+
overlayNames,
|
|
34
|
+
pick,
|
|
35
|
+
raycast,
|
|
36
|
+
Shaders,
|
|
37
|
+
setDebugView,
|
|
38
|
+
setOverlays,
|
|
39
|
+
Views,
|
|
40
|
+
Window
|
|
41
|
+
} from "@aethervtt/shard-render";
|
|
42
|
+
import {
|
|
43
|
+
AppControlResource,
|
|
44
|
+
LogResource,
|
|
45
|
+
Time
|
|
46
|
+
} from "@aethervtt/shard-runtime";
|
|
47
|
+
import {
|
|
48
|
+
applyToPrefab,
|
|
49
|
+
currentOverrides,
|
|
50
|
+
expandComponentAliases,
|
|
51
|
+
findEntityByPath,
|
|
52
|
+
instanceEntities,
|
|
53
|
+
loadPrefab,
|
|
54
|
+
loadScene,
|
|
55
|
+
pathOfEntity,
|
|
56
|
+
reloadScene,
|
|
57
|
+
SceneIndex,
|
|
58
|
+
saveScene,
|
|
59
|
+
spawnPrefab,
|
|
60
|
+
stringifyScene,
|
|
61
|
+
validateScene,
|
|
62
|
+
whenSceneReady,
|
|
63
|
+
worldSchemaContext
|
|
64
|
+
} from "@aethervtt/shard-scene";
|
|
65
|
+
import { Fonts, measureText } from "@aethervtt/shard-text";
|
|
66
|
+
import {
|
|
67
|
+
createPlacement,
|
|
68
|
+
FloatingOrigin,
|
|
69
|
+
Grid,
|
|
70
|
+
gridOf,
|
|
71
|
+
placeInGrid,
|
|
72
|
+
placementOf,
|
|
73
|
+
Transform,
|
|
74
|
+
worldPosition64
|
|
75
|
+
} from "@aethervtt/shard-transform";
|
|
76
|
+
import { encodePng, toBase64 } from "./png.js";
|
|
77
|
+
const CAPTURE_DEBUG_VIEWS = ["clusters", "cascades", "lod", "culling", "none"];
|
|
78
|
+
import { previewAsset } from "./preview.js";
|
|
79
|
+
const ERROR = { parse: -32700, invalid: -32600, notFound: -32601, params: -32602, shard: -32e3 };
|
|
80
|
+
function resolveEntity(world, ref) {
|
|
81
|
+
if (typeof ref === "number" && world.isAlive(ref)) return ref;
|
|
82
|
+
if (typeof ref === "string") {
|
|
83
|
+
const e = findEntityByPath(world, ref);
|
|
84
|
+
if (e !== void 0) return e;
|
|
85
|
+
}
|
|
86
|
+
throw new ShardError("protocol/unknown-entity", `No live entity ${JSON.stringify(ref)}`, {
|
|
87
|
+
hint: 'Pass an entity id from world.query, or a scene path like "ship/camera".'
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function requireComponent(name) {
|
|
91
|
+
const def = findComponent(name);
|
|
92
|
+
if (!def) {
|
|
93
|
+
throw new ShardError("protocol/unknown-component", `Unknown component "${name}"`, {
|
|
94
|
+
hint: "schema.list returns every component name."
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return def;
|
|
98
|
+
}
|
|
99
|
+
function toJson(value) {
|
|
100
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
101
|
+
return JSON.parse(
|
|
102
|
+
JSON.stringify(value, (_, v) => {
|
|
103
|
+
if (ArrayBuffer.isView(v)) return Array.from(v);
|
|
104
|
+
if (typeof v === "function") return void 0;
|
|
105
|
+
if (v instanceof Map) return Object.fromEntries(v);
|
|
106
|
+
if (v instanceof Set) return [...v];
|
|
107
|
+
if (v && typeof v === "object") {
|
|
108
|
+
if (seen.has(v)) return "[circular]";
|
|
109
|
+
seen.add(v);
|
|
110
|
+
}
|
|
111
|
+
return v;
|
|
112
|
+
}) ?? "null"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
function entityJson(world, entity, only) {
|
|
116
|
+
const components = {};
|
|
117
|
+
for (const def of world.componentsOf(entity)) {
|
|
118
|
+
if (!def.serializable && def !== ChildOf) continue;
|
|
119
|
+
if (only && !only.includes(def.name)) continue;
|
|
120
|
+
components[def.name] = def.serialize(world.get(entity, def));
|
|
121
|
+
}
|
|
122
|
+
const path = pathOfEntity(world, entity);
|
|
123
|
+
const out = path === void 0 ? { id: entity, components } : { id: entity, path, components };
|
|
124
|
+
if (!only && hasGrids(world) && world.has(entity, Transform))
|
|
125
|
+
out.worldPosition64 = [...worldPosition64(world, entity, new Float64Array(3))];
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
function hasGrids(world) {
|
|
129
|
+
return world.query({ with: [Grid] }).count() > 0;
|
|
130
|
+
}
|
|
131
|
+
function firstOf(world, def) {
|
|
132
|
+
return world.query({ with: [def] }).entities()[0];
|
|
133
|
+
}
|
|
134
|
+
function describeGrids(world) {
|
|
135
|
+
const place = createPlacement();
|
|
136
|
+
const refOf = (e) => pathOfEntity(world, e) ?? e;
|
|
137
|
+
const grids = world.query({ with: [Grid] }).entities().map((e) => {
|
|
138
|
+
placementOf(world, e, place, true);
|
|
139
|
+
const children = world.tryGet(e, Children)?.entities ?? [];
|
|
140
|
+
return {
|
|
141
|
+
grid: refOf(e),
|
|
142
|
+
cellSize: world.get(e, Grid).cellSize,
|
|
143
|
+
parent: place.grid < 0 ? null : refOf(place.grid),
|
|
144
|
+
cell: [...place.cell],
|
|
145
|
+
entities: children.filter((c) => c !== null).length
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
const originEntity = firstOf(world, FloatingOrigin);
|
|
149
|
+
let origin = null;
|
|
150
|
+
if (originEntity !== void 0) {
|
|
151
|
+
placementOf(world, originEntity, place, true);
|
|
152
|
+
origin = {
|
|
153
|
+
entity: refOf(originEntity),
|
|
154
|
+
grid: place.grid < 0 ? null : refOf(place.grid),
|
|
155
|
+
cell: [...place.cell]
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return { grids, origin };
|
|
159
|
+
}
|
|
160
|
+
function prepareComponents(world, input, base) {
|
|
161
|
+
const ctx = worldSchemaContext(world);
|
|
162
|
+
const out = [];
|
|
163
|
+
const errors = [];
|
|
164
|
+
for (const [name, raw] of Object.entries(input)) {
|
|
165
|
+
const def = requireComponent(name);
|
|
166
|
+
if (!isPlainObject(raw)) {
|
|
167
|
+
errors.push(
|
|
168
|
+
new ShardError("schema/type-mismatch", `"${name}" must be an object`, {
|
|
169
|
+
path: `${base}/${name}`
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const json = expandComponentAliases(name, raw);
|
|
175
|
+
const problems = def.validate(json, ctx);
|
|
176
|
+
for (const p of problems) {
|
|
177
|
+
errors.push(
|
|
178
|
+
new ShardError(p.code, p.message, {
|
|
179
|
+
path: `${base}/${name.replace("/", "~1")}${p.path ?? ""}`,
|
|
180
|
+
hint: p.hint
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (problems.length === 0) out.push([def, def.deserialize(json, ctx)]);
|
|
185
|
+
}
|
|
186
|
+
if (errors.length > 0) {
|
|
187
|
+
throw new ShardError(
|
|
188
|
+
"protocol/invalid-components",
|
|
189
|
+
`${errors.length} invalid value(s); first: ${errors[0].message}`,
|
|
190
|
+
{
|
|
191
|
+
path: errors[0].path,
|
|
192
|
+
hint: errors[0].hint,
|
|
193
|
+
details: errors
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
async function readJson(ctx, file) {
|
|
200
|
+
if (!ctx.options.platform)
|
|
201
|
+
throw new ShardError("protocol/no-files", "This host can't read files; pass JSON instead");
|
|
202
|
+
return JSON.parse(await ctx.options.platform.fs.readText(file));
|
|
203
|
+
}
|
|
204
|
+
function renderOnly(app) {
|
|
205
|
+
app.runSchedule(PostUpdate);
|
|
206
|
+
app.runSchedule(Last);
|
|
207
|
+
}
|
|
208
|
+
async function whenRenderReady(world, render) {
|
|
209
|
+
const gpu = world.tryResource(Gpu);
|
|
210
|
+
const shaders = world.tryResource(Shaders);
|
|
211
|
+
if (!gpu) return;
|
|
212
|
+
for (let i = 0; i < 20; i++) {
|
|
213
|
+
await render();
|
|
214
|
+
await shaders?.whenIdle();
|
|
215
|
+
await gpu.pipelines.whenIdle();
|
|
216
|
+
if (gpu.pipelines.skipped === 0 && gpu.pipelines.pending === 0) return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function checkOverlays(list) {
|
|
220
|
+
const names = list ?? [];
|
|
221
|
+
const known = overlayNames();
|
|
222
|
+
for (const name of names) {
|
|
223
|
+
if (!known.includes(name)) {
|
|
224
|
+
throw new ShardError("protocol/unknown-overlay", `Unknown overlay "${name}"`, {
|
|
225
|
+
hint: `Overlays: ${known.join(", ")}.`
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return names;
|
|
230
|
+
}
|
|
231
|
+
function applyOverlays(world, names, components, path) {
|
|
232
|
+
const o = world.resource(DebugOverlays);
|
|
233
|
+
const all = overlayNames();
|
|
234
|
+
const before = {
|
|
235
|
+
on: Object.fromEntries(all.map((k) => [k, isOverlayOn(o, k)])),
|
|
236
|
+
filter: { ...o.filter }
|
|
237
|
+
};
|
|
238
|
+
setOverlays(world, Object.fromEntries(all.map((k) => [k, names.includes(k)])), {
|
|
239
|
+
components,
|
|
240
|
+
path
|
|
241
|
+
});
|
|
242
|
+
return () => setOverlays(world, before.on, before.filter);
|
|
243
|
+
}
|
|
244
|
+
function hitJson(hit) {
|
|
245
|
+
return {
|
|
246
|
+
entity: hit.entity,
|
|
247
|
+
path: hit.path ?? null,
|
|
248
|
+
position: hit.position,
|
|
249
|
+
normal: hit.normal,
|
|
250
|
+
distance: hit.distance
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function nextFrame(app) {
|
|
254
|
+
return new Promise((resolve) => {
|
|
255
|
+
const off = app.onFrame(() => {
|
|
256
|
+
off();
|
|
257
|
+
resolve();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function depthImage(map) {
|
|
262
|
+
let lo = Number.POSITIVE_INFINITY;
|
|
263
|
+
let hi = 0;
|
|
264
|
+
for (let i = 0; i < map.data.length; i += 4) {
|
|
265
|
+
const d = map.data[i];
|
|
266
|
+
if (d > 0) {
|
|
267
|
+
if (d < lo) lo = d;
|
|
268
|
+
if (d > hi) hi = d;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const data = new Uint8Array(map.width * map.height * 4);
|
|
272
|
+
const span = hi > lo ? hi - lo : 1;
|
|
273
|
+
for (let i = 0; i < map.data.length; i += 4) {
|
|
274
|
+
const d = map.data[i];
|
|
275
|
+
const v = d > 0 ? Math.round(40 + (d - lo) / span * 215) : 0;
|
|
276
|
+
data[i] = data[i + 1] = data[i + 2] = v;
|
|
277
|
+
data[i + 3] = 255;
|
|
278
|
+
}
|
|
279
|
+
return { width: map.width, height: map.height, data };
|
|
280
|
+
}
|
|
281
|
+
function placementParams(world, entity, p) {
|
|
282
|
+
const position = p.position64;
|
|
283
|
+
const hasGrid = p.grid !== null && p.grid !== void 0;
|
|
284
|
+
if (!position || position.length === 0) {
|
|
285
|
+
if (hasGrid) {
|
|
286
|
+
throw new ShardError("protocol/invalid-position64", '"grid" needs "position64"', {
|
|
287
|
+
path: "/position64",
|
|
288
|
+
hint: 'Pass { "position64": [x, y, z], "grid": <grid> } together.'
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return void 0;
|
|
292
|
+
}
|
|
293
|
+
if (position.length !== 3 || !position.every((v) => Number.isFinite(v))) {
|
|
294
|
+
throw new ShardError("protocol/invalid-position64", '"position64" must be [x, y, z]', {
|
|
295
|
+
path: "/position64",
|
|
296
|
+
hint: "Three finite numbers in metres, e.g. [3.8e8, 0, 0]."
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
const grid = hasGrid ? resolveEntity(world, p.grid) : gridOf(world, entity);
|
|
300
|
+
if (grid === void 0 || !world.has(grid, Grid)) {
|
|
301
|
+
throw new ShardError(
|
|
302
|
+
"protocol/not-a-grid",
|
|
303
|
+
hasGrid ? `${JSON.stringify(p.grid)} is not a Grid` : "The entity is not in a grid",
|
|
304
|
+
{
|
|
305
|
+
path: "/grid",
|
|
306
|
+
hint: 'Pass "grid": the id or path of an entity with transform/Grid.'
|
|
307
|
+
}
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return { grid, position };
|
|
311
|
+
}
|
|
312
|
+
const s = (name, fields) => defineSchema(`protocol/${name}`, fields);
|
|
313
|
+
const entityRef = () => t.entity({ required: true, description: 'Entity id, or scene path like "ship/camera".' });
|
|
314
|
+
const none = s("None", {});
|
|
315
|
+
const METHODS = [
|
|
316
|
+
{
|
|
317
|
+
name: "app.describe",
|
|
318
|
+
description: "Plugins, schedules, and systems in run order; renderer and input state when present.",
|
|
319
|
+
params: none,
|
|
320
|
+
handler: ({ app, world }) => {
|
|
321
|
+
const out = { ...app.describe(), entities: world.entityCount };
|
|
322
|
+
if (world.tryResource(Views)) out.render = describeRender(world);
|
|
323
|
+
try {
|
|
324
|
+
out.input = describeInput(world);
|
|
325
|
+
} catch {
|
|
326
|
+
}
|
|
327
|
+
out.scenes = [...world.tryResource(SceneIndex)?.keys() ?? []];
|
|
328
|
+
if (hasGrids(world)) out.grids = describeGrids(world);
|
|
329
|
+
return toJson(out);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: "schema.list",
|
|
334
|
+
description: "Every defined component with its description and JSON Schema.",
|
|
335
|
+
params: none,
|
|
336
|
+
handler: () => allComponents().filter((d) => !d.name.startsWith("protocol/")).map((d) => ({
|
|
337
|
+
name: d.name,
|
|
338
|
+
description: d.description,
|
|
339
|
+
serializable: d.serializable,
|
|
340
|
+
requires: d.requires.map((r) => r.name)
|
|
341
|
+
}))
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: "schema.get",
|
|
345
|
+
description: "One component's JSON Schema (fields, types, ranges, presets, descriptions).",
|
|
346
|
+
params: s("SchemaGetParams", {
|
|
347
|
+
name: t.string({ required: true, description: 'Component name, e.g. "render/Camera3d".' })
|
|
348
|
+
}),
|
|
349
|
+
handler: (_, p) => requireComponent(p.name).jsonSchema()
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: "world.stats",
|
|
353
|
+
description: "Entity and archetype counts, and table memory.",
|
|
354
|
+
params: none,
|
|
355
|
+
handler: ({ world }) => world.stats()
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
name: "world.query",
|
|
359
|
+
description: "Entities that have every component in `with` and none in `without`, with their component values.",
|
|
360
|
+
params: s("QueryParams", {
|
|
361
|
+
with: t.list(t.string, { description: "Component names the entity must have." }),
|
|
362
|
+
without: t.list(t.string, { description: "Component names the entity must not have." }),
|
|
363
|
+
fields: t.list(t.string, { description: "Only include these components (default: all)." }),
|
|
364
|
+
limit: t.u32({ default: 50, min: 1, description: "Maximum entities returned." })
|
|
365
|
+
}),
|
|
366
|
+
handler: ({ world }, p) => {
|
|
367
|
+
const q = world.query({
|
|
368
|
+
with: p.with.map(requireComponent),
|
|
369
|
+
without: p.without.map(requireComponent)
|
|
370
|
+
});
|
|
371
|
+
const fields = p.fields.length > 0 ? p.fields : void 0;
|
|
372
|
+
const ids = q.entities().slice(0, p.limit);
|
|
373
|
+
return { total: q.count(), entities: ids.map((e) => entityJson(world, e, fields)) };
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: "entity.get",
|
|
378
|
+
description: "All components of one entity as JSON. With grids, also worldPosition64: its exact position relative to the floating origin.",
|
|
379
|
+
params: s("EntityGetParams", { entity: entityRef() }),
|
|
380
|
+
handler: ({ world }, p) => entityJson(world, resolveEntity(world, p.entity))
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: "entity.spawn",
|
|
384
|
+
description: "Spawns an entity from component JSON (validated; required components are added).",
|
|
385
|
+
params: s("SpawnParams", {
|
|
386
|
+
components: t.json({ description: "Component values by name, as in scene files." }),
|
|
387
|
+
parent: t.entity({ description: "Optional parent entity or path." })
|
|
388
|
+
}),
|
|
389
|
+
handler: ({ world }, p) => {
|
|
390
|
+
const inits = prepareComponents(
|
|
391
|
+
world,
|
|
392
|
+
p.components ?? {},
|
|
393
|
+
"/components"
|
|
394
|
+
);
|
|
395
|
+
if (p.parent !== null && p.parent !== void 0)
|
|
396
|
+
inits.push([ChildOf, { parent: resolveEntity(world, p.parent) }]);
|
|
397
|
+
const entity = world.spawn(...inits);
|
|
398
|
+
return entityJson(world, entity);
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: "entity.patch",
|
|
403
|
+
description: "Merges fields into components (adding missing ones). All values are validated before anything changes; null removes a component. position64 + grid places the entity at an exact f64 position in a grid (cell and translation computed for you).",
|
|
404
|
+
params: s("PatchParams", {
|
|
405
|
+
entity: entityRef(),
|
|
406
|
+
components: t.json({ description: "Partial values by component name." }),
|
|
407
|
+
position64: t.list(t.f64, {
|
|
408
|
+
description: "Exact position [x, y, z] in metres in `grid`'s frame; sets GridCell, Transform.translation, and the parent."
|
|
409
|
+
}),
|
|
410
|
+
grid: t.entity({
|
|
411
|
+
description: "Grid entity or path for position64 (default: the grid the entity is in)."
|
|
412
|
+
})
|
|
413
|
+
}),
|
|
414
|
+
handler: ({ world }, p) => {
|
|
415
|
+
const entity = resolveEntity(world, p.entity);
|
|
416
|
+
const place = placementParams(world, entity, p);
|
|
417
|
+
const patch = p.components ?? {};
|
|
418
|
+
const merged = {};
|
|
419
|
+
const removals = [];
|
|
420
|
+
for (const [name, value] of Object.entries(patch)) {
|
|
421
|
+
const def = requireComponent(name);
|
|
422
|
+
if (value === null) {
|
|
423
|
+
removals.push(def);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const current = world.has(entity, def) ? def.serialize(world.get(entity, def)) : {};
|
|
427
|
+
const partial = value;
|
|
428
|
+
const base = { ...current };
|
|
429
|
+
if (name === "core/Transform" && "rotationEuler" in partial) delete base.rotation;
|
|
430
|
+
merged[name] = { ...base, ...partial };
|
|
431
|
+
}
|
|
432
|
+
const prepared = prepareComponents(world, merged, "/components");
|
|
433
|
+
for (const [def, value] of prepared) {
|
|
434
|
+
if (world.has(entity, def)) world.set(entity, def, value);
|
|
435
|
+
else world.add(entity, def, value);
|
|
436
|
+
}
|
|
437
|
+
for (const def of removals) world.remove(entity, def);
|
|
438
|
+
if (place) placeInGrid(world, entity, place.grid, place.position);
|
|
439
|
+
return entityJson(world, entity);
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: "entity.despawn",
|
|
444
|
+
description: "Despawns an entity (and its children unless recursive is false).",
|
|
445
|
+
params: s("DespawnParams", { entity: entityRef(), recursive: t.bool({ default: true }) }),
|
|
446
|
+
handler: ({ world }, p) => {
|
|
447
|
+
const entity = resolveEntity(world, p.entity);
|
|
448
|
+
if (p.recursive) world.despawn(entity);
|
|
449
|
+
else world.despawnSingle(entity);
|
|
450
|
+
return { despawned: entity };
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
name: "resource.get",
|
|
455
|
+
description: "A resource's current value as JSON.",
|
|
456
|
+
params: s("ResourceGetParams", { name: t.string({ required: true }) }),
|
|
457
|
+
handler: ({ world }, p) => {
|
|
458
|
+
const def = findResource(p.name);
|
|
459
|
+
if (!def || !world.hasResource(def)) {
|
|
460
|
+
throw new ShardError(
|
|
461
|
+
"protocol/unknown-resource",
|
|
462
|
+
`Resource "${p.name}" is not in the world`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
return toJson(world.resource(def));
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
name: "resource.set",
|
|
470
|
+
description: "Merges fields into a plain-object resource (e.g. render/AmbientLight).",
|
|
471
|
+
params: s("ResourceSetParams", { name: t.string({ required: true }), value: t.json() }),
|
|
472
|
+
handler: ({ world }, p) => {
|
|
473
|
+
const def = findResource(p.name);
|
|
474
|
+
const current = def && world.tryResource(def);
|
|
475
|
+
if (!def || !isPlainObject(current)) {
|
|
476
|
+
throw new ShardError(
|
|
477
|
+
"protocol/unsettable-resource",
|
|
478
|
+
`Resource "${p.name}" can't be set from JSON`,
|
|
479
|
+
{
|
|
480
|
+
hint: "Only resources that are plain JSON objects can be set."
|
|
481
|
+
}
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (!isPlainObject(p.value))
|
|
485
|
+
throw new ShardError("schema/type-mismatch", '"value" must be an object', {
|
|
486
|
+
path: "/value"
|
|
487
|
+
});
|
|
488
|
+
Object.assign(current, p.value);
|
|
489
|
+
return toJson(current);
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
name: "time.pause",
|
|
494
|
+
description: "Pauses the game (rendering continues showing the last frame).",
|
|
495
|
+
params: none,
|
|
496
|
+
handler: ({ world }) => {
|
|
497
|
+
world.resource(AppControlResource).paused = true;
|
|
498
|
+
return { paused: true, frame: world.resource(Time).frame };
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
name: "time.resume",
|
|
503
|
+
description: "Resumes a paused game.",
|
|
504
|
+
params: none,
|
|
505
|
+
handler: ({ world }) => {
|
|
506
|
+
const control = world.resource(AppControlResource);
|
|
507
|
+
control.paused = false;
|
|
508
|
+
control.pausedByError = false;
|
|
509
|
+
return { paused: false, frame: world.resource(Time).frame };
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
name: "time.step",
|
|
514
|
+
description: "Pauses and runs exactly `frames` frames at the fixed timestep, then returns.",
|
|
515
|
+
params: s("StepParams", {
|
|
516
|
+
frames: t.u32({ default: 1, min: 1, max: 1e5, description: "Frames to run." })
|
|
517
|
+
}),
|
|
518
|
+
handler: async ({ app, world, options }, p) => {
|
|
519
|
+
const control = world.resource(AppControlResource);
|
|
520
|
+
const done = control.step(p.frames);
|
|
521
|
+
done.catch(() => {
|
|
522
|
+
});
|
|
523
|
+
if ((options.frames ?? "manual") === "manual") app.pump();
|
|
524
|
+
await done;
|
|
525
|
+
return { frame: world.resource(Time).frame, elapsed: world.resource(Time).elapsed };
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
name: "render.capture",
|
|
530
|
+
description: "A PNG of a camera view (default: the first camera). Renders the current state without advancing time.",
|
|
531
|
+
params: s("CaptureParams", {
|
|
532
|
+
camera: t.entity({ description: "Camera entity or path (default: first camera view)." }),
|
|
533
|
+
width: t.u32({
|
|
534
|
+
min: 0,
|
|
535
|
+
max: 4096,
|
|
536
|
+
description: "Resize the headless target first (0 = keep)."
|
|
537
|
+
}),
|
|
538
|
+
height: t.u32({ min: 0, max: 4096 }),
|
|
539
|
+
debug: t.string({
|
|
540
|
+
description: "A debug view instead of the image: 'clusters' (lights per cluster heat map), 'cascades' (cascades tinted by index), 'lod' (meshes tinted by LOD level: green, yellow, orange, red), 'shadow-map:<light>[:<layer>]' (a light's raw shadow map), or 'culling' (freezes what the camera culls with, and keeps it frozen for later captures while the camera moves; 'none' unfreezes)."
|
|
541
|
+
}),
|
|
542
|
+
overlays: t.list(t.string, {
|
|
543
|
+
description: `Debug overlays drawn into this capture only: ${OVERLAYS.join(", ")}, plus any a plugin registers (e.g. colliders). 'labels' writes each entity's scene path next to it; 'bounds' outlines what the renderer thinks is there.`
|
|
544
|
+
}),
|
|
545
|
+
filter: t.string({
|
|
546
|
+
description: 'Limit overlays to entities whose scene path starts with this, e.g. "ship/".'
|
|
547
|
+
}),
|
|
548
|
+
components: t.list(t.string, {
|
|
549
|
+
description: "Limit overlays to entities with all these components."
|
|
550
|
+
}),
|
|
551
|
+
buffer: t.string({
|
|
552
|
+
description: "Raw floats of a render buffer instead of the display image: 'hdr' (luminance in cd/m\xB2, before post-processing and tonemapping), 'post-hdr' (after the HDR effects, pre-exposed), 'depth', 'velocity' (screen motion in uv units, TAA and motion blur), 'ssao' (ambient occlusion), 'bloom' (the glow's first level), 'dof-half' (color and signed CoC in pixels), or another graph texture. Returned as base64 little-endian rgba32float."
|
|
553
|
+
})
|
|
554
|
+
}),
|
|
555
|
+
handler: async ({ app, world, options }, p) => {
|
|
556
|
+
const window = world.tryResource(Window);
|
|
557
|
+
if (window instanceof OffscreenTarget && p.width > 0 && p.height > 0) {
|
|
558
|
+
window.resize(p.width, p.height);
|
|
559
|
+
}
|
|
560
|
+
const view = p.camera !== null && p.camera !== void 0 ? `camera:${resolveEntity(world, p.camera)}` : void 0;
|
|
561
|
+
const renderNow = (options.frames ?? "manual") === "manual" || world.resource(AppControlResource).paused;
|
|
562
|
+
if (world.resource(Views).list.length === 0) renderOnly(app);
|
|
563
|
+
const name = view ?? world.resource(Views).list[0]?.name;
|
|
564
|
+
if (!name)
|
|
565
|
+
throw new ShardError(
|
|
566
|
+
"protocol/no-view",
|
|
567
|
+
"Nothing to capture: no camera renders to a target"
|
|
568
|
+
);
|
|
569
|
+
const debug = p.debug || "";
|
|
570
|
+
const cameraEntity = Number(name.slice("camera:".length));
|
|
571
|
+
if (debug.startsWith("shadow-map:")) {
|
|
572
|
+
await whenRenderReady(world, () => renderNow ? renderOnly(app) : nextFrame(app));
|
|
573
|
+
const [, lightRef, layer] = debug.split(":");
|
|
574
|
+
const light = resolveEntity(world, /^\d+$/.test(lightRef) ? Number(lightRef) : lightRef);
|
|
575
|
+
const map = await captureShadowMap(world, light, Number(layer ?? 0), cameraEntity);
|
|
576
|
+
const image = depthImage(map);
|
|
577
|
+
return {
|
|
578
|
+
mimeType: "image/png",
|
|
579
|
+
width: image.width,
|
|
580
|
+
height: image.height,
|
|
581
|
+
data: toBase64(await encodePng(image.data, image.width, image.height))
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
if (debug && !CAPTURE_DEBUG_VIEWS.includes(debug)) {
|
|
585
|
+
throw new ShardError("protocol/unknown-debug-view", `Unknown debug view "${debug}"`, {
|
|
586
|
+
hint: "Use 'clusters', 'cascades', 'lod', 'culling', 'none', or 'shadow-map:<light>'."
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
if (debug) setDebugView(world, cameraEntity, debug);
|
|
590
|
+
const restore = debug !== "" && debug !== "culling" && debug !== "none";
|
|
591
|
+
const overlays = checkOverlays(p.overlays);
|
|
592
|
+
const restoreOverlays = overlays.length > 0 ? applyOverlays(
|
|
593
|
+
world,
|
|
594
|
+
overlays,
|
|
595
|
+
p.components ?? [],
|
|
596
|
+
p.filter || ""
|
|
597
|
+
) : void 0;
|
|
598
|
+
try {
|
|
599
|
+
await whenRenderReady(world, () => renderNow ? renderOnly(app) : nextFrame(app));
|
|
600
|
+
if (p.buffer) {
|
|
601
|
+
const shot2 = captureBuffer(world, name, p.buffer);
|
|
602
|
+
if (renderNow) renderOnly(app);
|
|
603
|
+
else await nextFrame(app);
|
|
604
|
+
const b = await shot2;
|
|
605
|
+
return {
|
|
606
|
+
mimeType: "application/octet-stream",
|
|
607
|
+
format: "rgba32float",
|
|
608
|
+
source: b.format,
|
|
609
|
+
width: b.width,
|
|
610
|
+
height: b.height,
|
|
611
|
+
data: toBase64(new Uint8Array(b.data.buffer, b.data.byteOffset, b.data.byteLength))
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
const shot = captureView(world, name);
|
|
615
|
+
if (renderNow) renderOnly(app);
|
|
616
|
+
const image = await shot;
|
|
617
|
+
return {
|
|
618
|
+
mimeType: "image/png",
|
|
619
|
+
width: image.width,
|
|
620
|
+
height: image.height,
|
|
621
|
+
data: toBase64(await encodePng(image.data, image.width, image.height))
|
|
622
|
+
};
|
|
623
|
+
} finally {
|
|
624
|
+
if (restore) setDebugView(world, cameraEntity, "none");
|
|
625
|
+
restoreOverlays?.();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
name: "render.pick",
|
|
631
|
+
description: "What's under a pixel of a camera view (x, y from the top left, in the capture's pixels): the entity, its scene path, and the world position, normal, and distance of the surface. Meshes and world sprites are pickable. null when nothing is there.",
|
|
632
|
+
params: s("PickParams", {
|
|
633
|
+
camera: t.entity({ description: "Camera entity or path (default: the first camera view)." }),
|
|
634
|
+
x: t.f32({ required: true }),
|
|
635
|
+
y: t.f32({ required: true })
|
|
636
|
+
}),
|
|
637
|
+
handler: async ({ app, world, options }, p) => {
|
|
638
|
+
const manual = (options.frames ?? "manual") === "manual" || world.resource(AppControlResource).paused;
|
|
639
|
+
if (world.resource(Views).list.length === 0) renderOnly(app);
|
|
640
|
+
const camera = p.camera !== null && p.camera !== void 0 ? resolveEntity(world, p.camera) : void 0;
|
|
641
|
+
let done = false;
|
|
642
|
+
const result = pick(world, camera, p.x, p.y).finally(() => {
|
|
643
|
+
done = true;
|
|
644
|
+
});
|
|
645
|
+
for (let i = 0; i < 30 && !done; i++) {
|
|
646
|
+
if (manual) renderOnly(app);
|
|
647
|
+
else await nextFrame(app);
|
|
648
|
+
await world.tryResource(Gpu)?.pipelines.whenIdle();
|
|
649
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
650
|
+
}
|
|
651
|
+
const hit = await result;
|
|
652
|
+
return hit ? hitJson(hit) : null;
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
name: "world.raycast",
|
|
657
|
+
description: "Casts a ray against mesh entities on the CPU (bounds, then triangles): hits nearest first, each with entity, scene path, position, normal, and distance. Needs no GPU or frame.",
|
|
658
|
+
params: s("RaycastParams", {
|
|
659
|
+
origin: t.vec3({ required: true }),
|
|
660
|
+
direction: t.vec3({ required: true }),
|
|
661
|
+
maxDistance: t.f32({ min: 0, description: "Ignore hits past this (0: no limit)." }),
|
|
662
|
+
all: t.bool({ description: "Every entity along the ray, not just the nearest." })
|
|
663
|
+
}),
|
|
664
|
+
handler: ({ world }, p) => {
|
|
665
|
+
const hits = raycast(world, p.origin, p.direction, {
|
|
666
|
+
maxDistance: p.maxDistance || void 0,
|
|
667
|
+
all: p.all
|
|
668
|
+
});
|
|
669
|
+
return { hits: hits.map(hitJson) };
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
name: "debug.overlays",
|
|
674
|
+
description: `Shows debug overlays in every frame until changed (render.capture's "overlays" draws them into one capture instead). Pass the full set to show: ${OVERLAYS.join(", ")}, plus plugin overlays (colliders with physics); [] turns them off. Returns what's on.`,
|
|
675
|
+
params: s("OverlaysParams", {
|
|
676
|
+
overlays: t.list(t.string, { description: "Overlays to show; the rest turn off." }),
|
|
677
|
+
filter: t.string({ description: "Only entities whose scene path starts with this." }),
|
|
678
|
+
components: t.list(t.string, { description: "Only entities with all these components." })
|
|
679
|
+
}),
|
|
680
|
+
handler: ({ world }, p) => {
|
|
681
|
+
const names = checkOverlays(p.overlays);
|
|
682
|
+
applyOverlays(
|
|
683
|
+
world,
|
|
684
|
+
names,
|
|
685
|
+
p.components ?? [],
|
|
686
|
+
p.filter || ""
|
|
687
|
+
);
|
|
688
|
+
const o = world.resource(DebugOverlays);
|
|
689
|
+
return { overlays: overlayNames().filter((k) => isOverlayOn(o, k)), filter: o.filter };
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
name: "debug.gizmos",
|
|
694
|
+
description: "What gizmos drew last frame, as data: line segments (from, to, color, depthTest, width) and labels (position, text). Overlays count too.",
|
|
695
|
+
params: s("GizmosParams", {
|
|
696
|
+
limit: t.u32({ default: 200, max: 1e5, description: "Most line segments to list." })
|
|
697
|
+
}),
|
|
698
|
+
handler: ({ world }, p) => toJson(world.resource(Gizmos).describe(p.limit))
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
name: "render.describe",
|
|
702
|
+
description: "Render graph order, views, pipelines compiling, per-view stats, recent GPU errors.",
|
|
703
|
+
params: none,
|
|
704
|
+
handler: ({ world }) => toJson(describeRender(world))
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
name: "input.inject",
|
|
708
|
+
description: 'Queues input for the next frame: a key, a mouse button, or an action ("<map>.<action>").',
|
|
709
|
+
params: s("InjectParams", {
|
|
710
|
+
key: t.string({ description: 'KeyboardEvent.code, e.g. "KeyW".' }),
|
|
711
|
+
mouse: t.enum(["", "left", "middle", "right"]),
|
|
712
|
+
action: t.string({
|
|
713
|
+
description: 'Action as "<map>.<action>", e.g. "star-explorer/Controls.thrust".'
|
|
714
|
+
}),
|
|
715
|
+
pressed: t.bool({ default: true }),
|
|
716
|
+
value: t.f32({ description: "Analog value for actions (default 1 when pressed)." })
|
|
717
|
+
}),
|
|
718
|
+
handler: ({ world }, p) => {
|
|
719
|
+
if (p.action)
|
|
720
|
+
injectInput(world, {
|
|
721
|
+
action: p.action,
|
|
722
|
+
pressed: p.pressed,
|
|
723
|
+
value: p.value || void 0
|
|
724
|
+
});
|
|
725
|
+
else if (p.key) injectInput(world, { key: p.key, pressed: p.pressed });
|
|
726
|
+
else if (p.mouse)
|
|
727
|
+
injectInput(world, { mouse: p.mouse, pressed: p.pressed });
|
|
728
|
+
else
|
|
729
|
+
throw new ShardError("protocol/invalid-params", "Give one of key, mouse, or action", {
|
|
730
|
+
path: ""
|
|
731
|
+
});
|
|
732
|
+
return { queued: true };
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
name: "input.record",
|
|
737
|
+
description: "Starts recording input, or stops and returns the recording.",
|
|
738
|
+
params: s("RecordParams", { action: t.enum(["start", "stop"]) }),
|
|
739
|
+
handler: ({ world }, p) => p.action === "stop" ? { recording: stopRecording(world) } : (startRecording(world), { recording: true })
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: "input.replay",
|
|
743
|
+
description: "Replays a recording from the next frame, ignoring live input until it ends.",
|
|
744
|
+
params: s("ReplayParams", { recording: t.string({ required: true }) }),
|
|
745
|
+
handler: ({ world }, p) => {
|
|
746
|
+
startReplay(world, p.recording);
|
|
747
|
+
return { replaying: true };
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
name: "scene.validate",
|
|
752
|
+
description: "Every error in a scene (by file path or inline JSON), each with a JSON pointer.",
|
|
753
|
+
params: s("SceneValidateParams", { file: t.string(), json: t.json() }),
|
|
754
|
+
handler: async (ctx, p) => {
|
|
755
|
+
const json = p.file ? await readJson(ctx, p.file) : p.json;
|
|
756
|
+
const errors = validateScene(ctx.world, json);
|
|
757
|
+
return { valid: errors.length === 0, errors: errors.map((e) => e.toJSON()) };
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
name: "scene.load",
|
|
762
|
+
description: "Loads a scene (replacing a loaded scene with the same id). Returns entity paths and ids.",
|
|
763
|
+
params: s("SceneLoadParams", {
|
|
764
|
+
file: t.string(),
|
|
765
|
+
json: t.json(),
|
|
766
|
+
id: t.string({ description: "Defaults to the file path." }),
|
|
767
|
+
wait: t.bool({
|
|
768
|
+
default: true,
|
|
769
|
+
description: "Wait until every referenced asset has loaded or failed (default true)."
|
|
770
|
+
})
|
|
771
|
+
}),
|
|
772
|
+
handler: async (ctx, p) => {
|
|
773
|
+
const json = p.file ? await readJson(ctx, p.file) : p.json;
|
|
774
|
+
const id = p.id || p.file || "main";
|
|
775
|
+
const loaded = ctx.world.initResource(SceneIndex).has(id) ? reloadScene(ctx.world, id, json) : loadScene(ctx.world, json, { id });
|
|
776
|
+
if (p.wait !== false) await whenSceneReady(ctx.world, id);
|
|
777
|
+
const failed = assetServer(ctx.world).list({ state: "failed" }).map((e) => ({ path: e.path, error: e.error?.message }));
|
|
778
|
+
return {
|
|
779
|
+
id,
|
|
780
|
+
entities: Object.fromEntries(loaded.entities),
|
|
781
|
+
...failed.length > 0 ? { failedAssets: failed } : {}
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
name: "scene.save",
|
|
787
|
+
description: "Serializes a loaded scene; unchanged fields keep their authored form. Optionally writes the file.",
|
|
788
|
+
params: s("SceneSaveParams", { id: t.string({ required: true }), write: t.bool() }),
|
|
789
|
+
handler: async (ctx, p) => {
|
|
790
|
+
const file = saveScene(ctx.world, p.id);
|
|
791
|
+
if (p.write) {
|
|
792
|
+
if (!ctx.options.platform?.fs.writable)
|
|
793
|
+
throw new ShardError("protocol/no-files", "This host can't write files");
|
|
794
|
+
await ctx.options.platform.fs.writeText(p.id, stringifyScene(file));
|
|
795
|
+
}
|
|
796
|
+
return file;
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
name: "prefab.spawn",
|
|
801
|
+
description: "Spawns a prefab instance (loading the prefab first). Returns the root entity and its generated entities by path relative to the root.",
|
|
802
|
+
params: s("PrefabSpawnParams", {
|
|
803
|
+
prefab: t.string({
|
|
804
|
+
required: true,
|
|
805
|
+
description: 'Prefab path or guid, e.g. "prefabs/ship.prefab.json".'
|
|
806
|
+
}),
|
|
807
|
+
transform: t.json({
|
|
808
|
+
description: 'Root Transform fields, e.g. { "translation": [0, 5, 0] }.'
|
|
809
|
+
}),
|
|
810
|
+
overrides: t.json({
|
|
811
|
+
description: 'Changes to generated entities: { "Exhaust": { "particles/ParticleSystem": { "timeScale": 2 } } }.'
|
|
812
|
+
}),
|
|
813
|
+
parent: t.entity({ description: "Optional parent entity or path." })
|
|
814
|
+
}),
|
|
815
|
+
handler: async ({ world }, p) => {
|
|
816
|
+
await loadPrefab(world, p.prefab);
|
|
817
|
+
const transform = p.transform;
|
|
818
|
+
const root = spawnPrefab(world, p.prefab, {
|
|
819
|
+
...transform ? { transform } : {},
|
|
820
|
+
...p.overrides ? { overrides: p.overrides } : {},
|
|
821
|
+
...p.parent !== null && p.parent !== void 0 ? { parent: resolveEntity(world, p.parent) } : {}
|
|
822
|
+
});
|
|
823
|
+
return { root, paths: Object.fromEntries(instanceEntities(world, root)) };
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
name: "prefab.overrides",
|
|
828
|
+
description: "An instance's current differences from its prefab (or model), as the overrides a scene save would write.",
|
|
829
|
+
params: s("PrefabOverridesParams", { entity: entityRef() }),
|
|
830
|
+
handler: ({ world }, p) => {
|
|
831
|
+
const entity = resolveEntity(world, p.entity);
|
|
832
|
+
const overrides = currentOverrides(world, entity);
|
|
833
|
+
if (!overrides) {
|
|
834
|
+
throw new ShardError(
|
|
835
|
+
"prefab/not-an-instance",
|
|
836
|
+
`Entity ${JSON.stringify(p.entity)} isn't a spawned instance`,
|
|
837
|
+
{
|
|
838
|
+
hint: 'Pass the entity with scene/PrefabInstance or scene/SceneInstance, e.g. "player-ship".'
|
|
839
|
+
}
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
return { entity, overrides };
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
name: "prefab.apply",
|
|
847
|
+
description: "Apply to prefab: writes an instance's overrides into its prefab file and clears them. Every instance of the prefab picks up the change.",
|
|
848
|
+
params: s("PrefabApplyParams", { entity: entityRef() }),
|
|
849
|
+
handler: async ({ world, options }, p) => applyToPrefab(
|
|
850
|
+
world,
|
|
851
|
+
resolveEntity(world, p.entity),
|
|
852
|
+
options.platform?.fs.writable ? options.platform.fs : void 0
|
|
853
|
+
)
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
name: "asset.list",
|
|
857
|
+
description: "Assets in the catalog: path, type, and state (unloaded, loading, loaded, failed). Filter by type, path prefix, or state.",
|
|
858
|
+
params: s("AssetListParams", {
|
|
859
|
+
type: t.string({ description: 'e.g. "Mesh", "Material", "Texture".' }),
|
|
860
|
+
prefix: t.string({ description: 'Path prefix, e.g. "assets/ships/".' }),
|
|
861
|
+
state: t.string({ description: "unloaded, loading, loaded, or failed." }),
|
|
862
|
+
limit: t.u32({ default: 200 })
|
|
863
|
+
}),
|
|
864
|
+
handler: ({ world }, p) => {
|
|
865
|
+
const all = assetServer(world).list({
|
|
866
|
+
type: p.type || void 0,
|
|
867
|
+
prefix: p.prefix || void 0,
|
|
868
|
+
state: p.state || void 0
|
|
869
|
+
});
|
|
870
|
+
return {
|
|
871
|
+
total: all.length,
|
|
872
|
+
assets: all.slice(0, p.limit).map((e) => ({
|
|
873
|
+
path: e.path,
|
|
874
|
+
type: e.type,
|
|
875
|
+
state: e.state,
|
|
876
|
+
...e.error ? { error: e.error.message } : {}
|
|
877
|
+
}))
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
name: "asset.get",
|
|
883
|
+
description: "Everything about one asset: guid, type, state, importer and settings, dependencies and dependents, sub-assets, facts (counts, bounds), warnings, and the last error. Data assets include their value, and variants their $extends chain and which file set each field.",
|
|
884
|
+
params: s("AssetGetParams", {
|
|
885
|
+
asset: t.string({ required: true, description: "Asset path or guid." })
|
|
886
|
+
}),
|
|
887
|
+
handler: async ({ world }, p) => {
|
|
888
|
+
const assets = assetServer(world);
|
|
889
|
+
const info = toJson(assets.info(p.asset));
|
|
890
|
+
if (info.source && assets.importerOf(info.source)?.schema && info.artifact)
|
|
891
|
+
info.value = (await assets.artifact(p.asset)).json;
|
|
892
|
+
return info;
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
name: "asset.import",
|
|
897
|
+
description: 'Re-imports one source (optionally with new import settings, merged into its .meta), or scans every asset root when "asset" is omitted.',
|
|
898
|
+
params: s("AssetImportParams", {
|
|
899
|
+
asset: t.string({ description: "Source path or sub-asset path. Omit to scan everything." }),
|
|
900
|
+
settings: t.json({ description: 'Import settings to change, e.g. { "scale": 0.01 }.' }),
|
|
901
|
+
force: t.bool({ description: "Re-import even if nothing changed." })
|
|
902
|
+
}),
|
|
903
|
+
handler: async ({ world }, p) => {
|
|
904
|
+
const assets = assetServer(world);
|
|
905
|
+
if (!p.asset) return toJson(await assets.scan({ force: p.force === true }));
|
|
906
|
+
const settings = p.settings;
|
|
907
|
+
return toJson(await assets.reimport(p.asset, settings ? { settings } : {}));
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
name: "asset.move",
|
|
912
|
+
description: "Moves an asset source and its .meta, then rewrites references to it in scenes and data assets. Returns the files it rewrote.",
|
|
913
|
+
params: s("AssetMoveParams", {
|
|
914
|
+
from: t.string({ required: true }),
|
|
915
|
+
to: t.string({ required: true })
|
|
916
|
+
}),
|
|
917
|
+
handler: ({ world }, p) => assetServer(world).move(p.from, p.to)
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
name: "asset.preview",
|
|
921
|
+
description: "A PNG of an asset: a texture (top mip), a material on a sphere, a mesh or scene framed from its bounds, or a noise graph in grayscale (options: domain plane|sphere, seed, size, node to see an intermediate node). Renders in a private world; the game is untouched.",
|
|
922
|
+
params: s("AssetPreviewParams", {
|
|
923
|
+
asset: t.string({ required: true, description: "Asset path or guid." }),
|
|
924
|
+
width: t.u32({ default: 256, min: 16, max: 2048 }),
|
|
925
|
+
height: t.u32({ default: 256, min: 16, max: 2048 }),
|
|
926
|
+
options: t.json({
|
|
927
|
+
description: 'Type-specific options. NoiseGraph: { domain: "plane" | "sphere", seed, size, node }.'
|
|
928
|
+
})
|
|
929
|
+
}),
|
|
930
|
+
handler: async ({ world }, p) => {
|
|
931
|
+
const image = await previewAsset(
|
|
932
|
+
world,
|
|
933
|
+
p.asset,
|
|
934
|
+
p.width,
|
|
935
|
+
p.height,
|
|
936
|
+
p.options ?? void 0
|
|
937
|
+
);
|
|
938
|
+
const png = await encodePng(image.data, image.width, image.height);
|
|
939
|
+
return { width: image.width, height: image.height, data: toBase64(png) };
|
|
940
|
+
}
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
name: "text.measure",
|
|
944
|
+
description: "Lays out a string without rendering it: width, height, and line breaks, in the units of size (world units for Text, pixels for ScreenText). Use it to size a label before placing it.",
|
|
945
|
+
params: s("TextMeasureParams", {
|
|
946
|
+
font: t.string({ required: true, description: "Font asset path or guid." }),
|
|
947
|
+
value: t.string({ required: true }),
|
|
948
|
+
size: t.f32({ default: 1, min: 0 }),
|
|
949
|
+
maxWidth: t.f32({ min: 0, description: "Wrap width (0: none)." }),
|
|
950
|
+
lineHeight: t.f32({ default: 1.2 }),
|
|
951
|
+
align: t.enum(["left", "center", "right"])
|
|
952
|
+
}),
|
|
953
|
+
handler: async ({ world }, p) => {
|
|
954
|
+
const server = assetServer(world);
|
|
955
|
+
const ref = server.resolve(p.font);
|
|
956
|
+
if (ref) await server.load(ref.path ?? p.font);
|
|
957
|
+
const font = world.resource(Fonts).get(ref);
|
|
958
|
+
if (!font) {
|
|
959
|
+
throw new ShardError("text/not-loaded", `No font "${p.font}"`, {
|
|
960
|
+
hint: "asset.list shows fonts (type Font); pass a .ttf or .otf path."
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
const m = measureText(font, p.value, {
|
|
964
|
+
size: p.size,
|
|
965
|
+
maxWidth: p.maxWidth,
|
|
966
|
+
lineHeight: p.lineHeight,
|
|
967
|
+
align: ["left", "center", "right"][p.align] ?? "left"
|
|
968
|
+
});
|
|
969
|
+
const value = p.value;
|
|
970
|
+
return {
|
|
971
|
+
width: m.width,
|
|
972
|
+
height: m.height,
|
|
973
|
+
lines: m.lines.map((l) => ({ text: value.slice(l.start, l.end), width: l.width }))
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
name: "log.tail",
|
|
979
|
+
description: "Recent log entries (errors from systems, the GPU, and shaders land here).",
|
|
980
|
+
params: s("LogParams", {
|
|
981
|
+
count: t.u32({ default: 50, min: 1 }),
|
|
982
|
+
level: t.enum(["debug", "info", "warn", "error"])
|
|
983
|
+
}),
|
|
984
|
+
handler: ({ world }, p) => world.resource(LogResource).tail(p.count, p.level)
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
name: "errors.recent",
|
|
988
|
+
description: "Recent errors with code, path, and hint.",
|
|
989
|
+
params: s("ErrorsParams", { count: t.u32({ default: 20, min: 1 }) }),
|
|
990
|
+
handler: ({ world }, p) => world.resource(LogResource).errors(p.count)
|
|
991
|
+
}
|
|
992
|
+
];
|
|
993
|
+
function createProtocolServer(app, options = {}) {
|
|
994
|
+
const ctx = { app, world: app.world, options };
|
|
995
|
+
const byName = new Map([...METHODS, ...options.methods ?? []].map((m) => [m.name, m]));
|
|
996
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
997
|
+
const topics = /* @__PURE__ */ new Set();
|
|
998
|
+
const notify = (method, params) => {
|
|
999
|
+
for (const l of listeners) l({ jsonrpc: "2.0", method, params });
|
|
1000
|
+
};
|
|
1001
|
+
const offLog = app.world.resource(LogResource).subscribe((entry) => {
|
|
1002
|
+
if (topics.has("log")) notify("log", entry);
|
|
1003
|
+
if (topics.has("error") && entry.level === "error") notify("error", entry);
|
|
1004
|
+
});
|
|
1005
|
+
const offFrame = app.onFrame((frame) => {
|
|
1006
|
+
if (topics.has("frame")) notify("frame", { frame });
|
|
1007
|
+
});
|
|
1008
|
+
const subscribe = {
|
|
1009
|
+
name: "subscribe",
|
|
1010
|
+
description: 'Subscribes to notifications: "log", "error", "frame", "project" (reloads).',
|
|
1011
|
+
params: s("SubscribeParams", {
|
|
1012
|
+
topics: t.list(t.enum(["log", "error", "frame", "project"])),
|
|
1013
|
+
unsubscribe: t.bool()
|
|
1014
|
+
}),
|
|
1015
|
+
handler: (_, p) => {
|
|
1016
|
+
for (const topic of p.topics) {
|
|
1017
|
+
if (p.unsubscribe) topics.delete(topic);
|
|
1018
|
+
else topics.add(topic);
|
|
1019
|
+
}
|
|
1020
|
+
return { topics: [...topics] };
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
byName.set(subscribe.name, subscribe);
|
|
1024
|
+
const lookup = (name) => byName.get(name) ?? app.methods.find((m) => m.name === name);
|
|
1025
|
+
return {
|
|
1026
|
+
get methods() {
|
|
1027
|
+
return [...byName.values(), ...app.methods.filter((m) => !byName.has(m.name))];
|
|
1028
|
+
},
|
|
1029
|
+
async handle(request) {
|
|
1030
|
+
const id = request.id ?? null;
|
|
1031
|
+
const isNotification = request.id === void 0;
|
|
1032
|
+
const reply = (body) => isNotification ? void 0 : { jsonrpc: "2.0", id, ...body };
|
|
1033
|
+
if (request?.jsonrpc !== "2.0" || typeof request.method !== "string") {
|
|
1034
|
+
return reply({ error: { code: ERROR.invalid, message: "Invalid JSON-RPC request" } });
|
|
1035
|
+
}
|
|
1036
|
+
const method = lookup(request.method);
|
|
1037
|
+
if (!method) {
|
|
1038
|
+
return reply({
|
|
1039
|
+
error: {
|
|
1040
|
+
code: ERROR.notFound,
|
|
1041
|
+
message: `Unknown method "${request.method}"`,
|
|
1042
|
+
data: { methods: [...byName.keys(), ...app.methods.map((m) => m.name)] }
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
const raw = request.params ?? {};
|
|
1047
|
+
const problems = method.params.validate(raw);
|
|
1048
|
+
if (problems.length > 0) {
|
|
1049
|
+
const err = new ShardError(
|
|
1050
|
+
"protocol/invalid-params",
|
|
1051
|
+
`${problems.length} invalid parameter(s): ${problems[0].message}`,
|
|
1052
|
+
{
|
|
1053
|
+
path: problems[0].path,
|
|
1054
|
+
hint: problems[0].hint,
|
|
1055
|
+
details: problems
|
|
1056
|
+
}
|
|
1057
|
+
);
|
|
1058
|
+
return reply({ error: { code: ERROR.params, message: err.message, data: err.toJSON() } });
|
|
1059
|
+
}
|
|
1060
|
+
try {
|
|
1061
|
+
const params = method.params.deserialize(raw, worldSchemaContext(app.world));
|
|
1062
|
+
const result = await method.handler(ctx, params);
|
|
1063
|
+
return reply({ result: result ?? null });
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
const shard = err instanceof ShardError ? err : new ShardError("protocol/internal", err instanceof Error ? err.message : String(err));
|
|
1066
|
+
return reply({ error: { code: ERROR.shard, message: shard.message, data: shard.toJSON() } });
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
onNotification(listener) {
|
|
1070
|
+
listeners.add(listener);
|
|
1071
|
+
return () => listeners.delete(listener);
|
|
1072
|
+
},
|
|
1073
|
+
publish(topic, params) {
|
|
1074
|
+
if (topics.has(topic)) notify(topic, params);
|
|
1075
|
+
},
|
|
1076
|
+
close() {
|
|
1077
|
+
offLog();
|
|
1078
|
+
offFrame();
|
|
1079
|
+
listeners.clear();
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
export {
|
|
1084
|
+
METHODS,
|
|
1085
|
+
createProtocolServer
|
|
1086
|
+
};
|