@volter/play 0.5.212 → 0.5.213

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.
@@ -15,8 +15,9 @@
15
15
  * - `POST /__editor/play/std/start` — `{ url }`: the game's dev server, started where none runs (`npm run dev --
16
16
  * --no-open`, in the project's folder, owned by this session and ended with it), found by the record std's dev server
17
17
  * writes in Vite's cacheDir (`node_modules/.vite/std-dev.json`: its address, its process and its endpoints' token).
18
- * - `GET /__editor/play/std/devices` and `POST /__editor/play/std/devices/<id>/control` — std's own endpoints
19
- * (`/__std/devices`, `/__std/devices/<id>/control`), asked with the record's token, which stays in this process.
18
+ * - `GET /__editor/play/std/devices`, `POST /__editor/play/std/devices/<id>/control` and `GET
19
+ * /__editor/play/std/scene` — std's own endpoints (`/__std/devices`, `/__std/devices/<id>/control`, and the game's
20
+ * running scene, `/__std/scene`), asked with the record's token, which stays in this process.
20
21
  * - `GET /__editor/play/std/goals` — `{ goals }`: the goals of the game's bot, its `autopilot/index.ts` program's
21
22
  * (`autopilot({ goals })`, std-driver's `program`), read by Node in the game's folder as std reads the game's
22
23
  * `autopilot/checks.ts` (std-dev's play-checks.ts: Node's own type stripping), each read a process of its own so an
@@ -24,9 +25,10 @@
24
25
  *
25
26
  * - `GET /__editor/play/std/level` — `{ blenderHalf }`: the level export's Blender half as the game installed it
26
27
  * (std-dev's `blender/blend-to-scene.py`), which the page runs in the document's Blender (`../src/std-level.ts`).
27
- * - `POST /__editor/play/std/level` `{ glb }` — `{ code, output }`: its Node half, `std level <glb> --description <its
28
- * .level.json>` in the game's folder, by the game's own std (std-dev's `bin/std.js`, under the session's Node), for a
29
- * level's glb or a prefab's (`prefabs/<name>/<name>.glb`, whose component std writes as `<Name>.tsx`).
28
+ * - `POST /__editor/play/std/level` `{ glb }` — `{ code, output }`: its Node half, `std level-scene <glb>` in the
29
+ * game's folder (from the glb and the `.level.json` the Blender half wrote beside it), by the game's own std
30
+ * (std-dev's `bin/std.js`, under the session's Node), for a level's glb or a prefab's (`prefabs/<name>/<name>.glb`,
31
+ * whose component std writes as `<Name>.tsx`).
30
32
  *
31
33
  * Every route is the machine owner's alone (`requireLocalOwner`), and a mutating one takes the editor's own origin
32
34
  * (the router's check on `/__editor`); a body is the JSON the router has parsed.
@@ -34,16 +36,17 @@
34
36
  * id, with the slot its address named (its trace's first line): how Play tells its own players' pages apart.
35
37
  * - `GET /__editor/play/std/trace?page=<page id>&from=<byte>` — `{ lines, next }`: the page's trace (`<page>.jsonl`
36
38
  * in that folder) from a byte on, whole lines only.
37
- * - `POST /__editor/play/std/play` `{ goal, bots? }` — std's own play of the game's bots to the goal's end
38
- * (`/__std/play`, std-dev's std-endpoints.ts) on the headless device, one bot unless asked, recorded in the run's
39
- * folder and checked by the goal's checks (`autopilot/checks.ts`): answered as it goes, one JSON object a line
40
- * (`application/x-ndjson`), each `{ line }` std says, then `{ end: { ok } }`. Its pages are std's, headless, so it
41
- * plays whether or not the editor is seen.
39
+ * - `POST /__editor/play/std/play` `{ goal, bots?, device? }` — std's own play of the game's bots to the goal's end
40
+ * (`/__std/play`, std-dev's std-endpoints.ts) on `device` (one of std's devices by its id: a player's page in the
41
+ * Game view, which std drives as it is) or, where none is named, std's headless device, one bot unless asked,
42
+ * recorded in the run's folder and checked by the goal's checks (`autopilot/checks.ts`): answered as it goes, one JSON
43
+ * object a line (`application/x-ndjson`), each `{ line }` std says, then `{ end: { ok } }`.
42
44
  * - `GET /__editor/play/std/capture?file=<page id>-capture-<n>.png` — a page's captured frame, the PNG std's receiver
43
45
  * wrote in that folder for a `capture` control.
44
- * - `GET /__editor/play/std/errors?server=<pid>&from=<line>` — `{ lines, next, server }`: the error output (stderr) of
45
- * the dev server this session started, from a line on (every line, where `server` names another), or with no `from`
46
- * none and where it ends: the room's own exceptions are Colyseus's to log there, never a page's trace.
46
+ * - `GET /__editor/play/std/output?server=<pid>&stdout=<line>&stderr=<line>` — `{ server, stdout, stderr }`, each
47
+ * stream's `{ lines, next }`: what the dev server this session started wrote on each, whole lines, from a line on
48
+ * (every line, where `server` names another), or with no lines asked none and where each ends: the room runs in that
49
+ * process, so its `console.log` is on stdout and the exceptions Colyseus logs on stderr, never in a page's trace.
47
50
  */
48
51
  import { spawn, type ChildProcess } from 'node:child_process';
49
52
  import { existsSync, readFileSync } from 'node:fs';
@@ -85,7 +88,7 @@ const isLevelGlb = (path: string): boolean =>
85
88
  /** The level export's Node half: its exit code and everything it printed, in order. */
86
89
  function runStdLevel(root: string, glb: string): Promise<{ readonly code: number | null; readonly output: string }> {
87
90
  return new Promise((resolve) => {
88
- const run = spawn(process.execPath, [join(stdDev(root), 'bin', 'std.js'), 'level', glb, '--description', glb.replace(/\.glb$/, '.level.json')], { cwd: root, windowsHide: true });
91
+ const run = spawn(process.execPath, [join(stdDev(root), 'bin', 'std.js'), 'level-scene', glb], { cwd: root, windowsHide: true });
89
92
  let output = '';
90
93
  run.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); });
91
94
  run.stderr.on('data', (chunk: Buffer) => { output += chunk.toString(); });
@@ -259,53 +262,75 @@ async function runningServer(root: string): Promise<RunningServer | null> {
259
262
  return alive(record.pid) ? record : null;
260
263
  }
261
264
 
262
- /** The most of a dev server's error output kept, in lines. */
263
- const ERRORS_KEPT = 500;
265
+ /** The most of each of a dev server's streams kept, in lines. */
266
+ const OUTPUT_KEPT = 500;
267
+
268
+ /** One of a dev server's output streams as lines (the first kept one being line `dropped`), the line not yet ended,
269
+ * and its decoder. Each stream is kept apart, so a room logging at every tick never pushes out an exception. */
270
+ interface OutputStream {
271
+ readonly lines: string[];
272
+ dropped: number;
273
+ partial: string;
274
+ readonly decoder: StringDecoder;
275
+ }
264
276
 
265
277
  /** The dev servers this session started, by project, with the last of what each said, why its launch failed, and its
266
- * error output (stderr, where Colyseus logs a room's exceptions and Vite its errors and warnings) as lines, the first
267
- * kept one being line `dropped`. */
278
+ * two streams: stdout, where a room's `console.log` and Vite's notices go, and stderr, where Colyseus logs a room's
279
+ * exceptions and Vite its errors and warnings. */
268
280
  interface OwnedServer {
269
281
  readonly child: ChildProcess;
270
282
  said: string;
271
283
  failed: string | null;
272
- readonly errors: string[];
273
- dropped: number;
274
- partial: string;
275
- readonly decoder: StringDecoder;
284
+ readonly streams: { readonly stdout: OutputStream; readonly stderr: OutputStream };
276
285
  }
277
286
 
278
287
  /** The most of a line kept before it ends (a progress line redrawn with `\r` never does). */
279
288
  const LINE_MOST = 4000;
280
289
  const owned = new Map<string, OwnedServer>();
281
290
 
282
- /** A chunk of a dev server's stderr taken into its lines, whole lines only (`null`, its output's end: the last one
283
- * too), terminal escapes left out. */
284
- function heardError(server: OwnedServer, chunk: Buffer | null): void {
285
- const text = chunk === null ? server.decoder.end() : server.decoder.write(chunk);
286
- const lines = stripVTControlCharacters(server.partial + text).split(/\r?\n/);
287
- server.partial = chunk === null ? '' : (lines.pop() ?? '').slice(-LINE_MOST);
288
- server.errors.push(...lines.filter((line, at) => chunk !== null || at < lines.length - 1 || line !== ''));
289
- const over = server.errors.length - ERRORS_KEPT;
291
+ /** A chunk of one of a dev server's streams taken into its lines, whole lines only (`null`, the stream's end: the last
292
+ * one too), terminal escapes left out. */
293
+ function heardOutput(stream: OutputStream, chunk: Buffer | null): void {
294
+ const text = chunk === null ? stream.decoder.end() : stream.decoder.write(chunk);
295
+ const lines = stripVTControlCharacters(stream.partial + text).split(/\r?\n/);
296
+ stream.partial = chunk === null ? '' : (lines.pop() ?? '').slice(-LINE_MOST);
297
+ stream.lines.push(...lines.filter((line, at) => chunk !== null || at < lines.length - 1 || line !== ''));
298
+ const over = stream.lines.length - OUTPUT_KEPT;
290
299
  if (over > 0) {
291
- server.errors.splice(0, over);
292
- server.dropped += over;
300
+ stream.lines.splice(0, over);
301
+ stream.dropped += over;
293
302
  }
294
303
  }
295
304
 
296
- /** The session's dev server's error output from line `from` of the server `server` (its process) on, every line of
297
- * another server's, or none and where it ends (`from` null: a run's mark); and the server and the line after them.
298
- * None where this session didn't start the game's dev server (one already running is its own terminal's). */
299
- function errorsFrom(root: string, server: number | null, from: number | null): {
300
- readonly lines: readonly string[]; readonly next: number; readonly server: number | null;
305
+ /** Where each of a dev server's streams has been read to; null, none asked of it (its end, a run's mark). */
306
+ interface StreamsFrom { readonly stdout: number | null; readonly stderr: number | null }
307
+
308
+ /** A stream's line as a query names it, or null where it names none. */
309
+ const lineOf = (asked: string | null): number | null => (asked === null ? null : Number(asked) || 0);
310
+
311
+ /** One stream's lines from line `from` on (none where `from` is null), and the line after them. */
312
+ function streamFrom(stream: OutputStream, from: number | null): { readonly lines: readonly string[]; readonly next: number } {
313
+ const next = stream.dropped + stream.lines.length;
314
+ return { lines: from === null ? [] : stream.lines.slice(Math.max(0, from - stream.dropped)), next };
315
+ }
316
+
317
+ /** The session's dev server's two streams, each from its line in `from` on where `server` is its process, every line
318
+ * of another server's, or none and where it ends (its line null: a run's mark); and the server. None where this
319
+ * session didn't start the game's dev server (one already running is its own terminal's). */
320
+ function outputFrom(root: string, server: number | null, from: StreamsFrom): {
321
+ readonly server: number | null;
322
+ readonly stdout: ReturnType<typeof streamFrom>;
323
+ readonly stderr: ReturnType<typeof streamFrom>;
301
324
  } {
302
325
  const mine = owned.get(root);
303
326
  const pid = mine?.child.pid ?? null;
304
- if (!mine || pid === null) return { lines: [], next: 0, server: null };
305
- const next = mine.dropped + mine.errors.length;
306
- if (from === null) return { lines: [], next, server: pid };
307
- const start = server === pid ? from : 0;
308
- return { lines: mine.errors.slice(Math.max(0, start - mine.dropped)), next, server: pid };
327
+ if (!mine || pid === null) return { server: null, stdout: { lines: [], next: 0 }, stderr: { lines: [], next: 0 } };
328
+ const start = (line: number | null): number | null => (line === null ? null : server === pid ? line : 0);
329
+ return {
330
+ server: pid,
331
+ stdout: streamFrom(mine.streams.stdout, start(from.stdout)),
332
+ stderr: streamFrom(mine.streams.stderr, start(from.stderr)),
333
+ };
309
334
  }
310
335
 
311
336
  /** Ends the dev servers this session started, each `npm run dev` with everything it started. */
@@ -323,13 +348,16 @@ async function startedServer(root: string): Promise<RunningServer> {
323
348
  const npm = npmProcess(['run', 'dev', '--', '--no-open'], root);
324
349
  // (a group of its own on POSIX, so its whole tree ends with it: `signalProcessTree`)
325
350
  const child = spawn(npm.program, npm.args, { cwd: root, detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
326
- mine = { child, said: '', failed: null, errors: [], dropped: 0, partial: '', decoder: new StringDecoder('utf8') };
351
+ const stream = (): OutputStream => ({ lines: [], dropped: 0, partial: '', decoder: new StringDecoder('utf8') });
352
+ mine = { child, said: '', failed: null, streams: { stdout: stream(), stderr: stream() } };
327
353
  const kept = mine;
328
354
  const hear = (chunk: Buffer): void => { kept.said = (kept.said + chunk.toString('utf8')).slice(-4000); };
329
355
  child.stdout?.on('data', hear);
330
356
  child.stderr?.on('data', hear);
331
- child.stderr?.on('data', (chunk: Buffer) => heardError(kept, chunk));
332
- child.stderr?.on('end', () => heardError(kept, null));
357
+ for (const name of ['stdout', 'stderr'] as const) {
358
+ child[name]?.on('data', (chunk: Buffer) => heardOutput(kept.streams[name], chunk));
359
+ child[name]?.on('end', () => heardOutput(kept.streams[name], null));
360
+ }
333
361
  child.on('error', (error) => { kept.failed = error.message; });
334
362
  owned.set(root, mine);
335
363
  }
@@ -399,9 +427,10 @@ const PLAY_MS = 15 * 60_000;
399
427
  * messages, then `{ end: { ok } }`), its messages passed on as they come, one JSON line each.
400
428
  */
401
429
  async function playGoal(root: string, body: unknown, response: ServerResponse): Promise<void> {
402
- const asked = body as { goal?: unknown; bots?: unknown } | undefined;
403
- if (typeof asked?.goal !== 'string' || asked.goal === '' || (asked.bots !== undefined && !(Number.isInteger(asked.bots) && Number(asked.bots) >= 1)))
404
- return answer(response, 400, { error: `A play is { goal, bots? }: a goal of the game's bot, and a whole number of bots; it was given ${JSON.stringify(body)}.` });
430
+ const asked = body as { goal?: unknown; bots?: unknown; device?: unknown } | undefined;
431
+ if (typeof asked?.goal !== 'string' || asked.goal === '' || (asked.bots !== undefined && !(Number.isInteger(asked.bots) && Number(asked.bots) >= 1))
432
+ || (asked.device !== undefined && (typeof asked.device !== 'string' || asked.device === '')))
433
+ return answer(response, 400, { error: `A play is { goal, bots?, device? }: a goal of the game's bot, a whole number of bots, and one of std's devices by its id; it was given ${JSON.stringify(body)}.` });
405
434
  const running = await runningServer(root);
406
435
  if (!running) return answer(response, 409, { error: "The game's dev server is not running: press Play." });
407
436
  response.statusCode = 200;
@@ -419,7 +448,7 @@ async function playGoal(root: string, body: unknown, response: ServerResponse):
419
448
  if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close();
420
449
  };
421
450
  const limit = setTimeout(() => end(false, `Play ended std's play of ${asked.goal}: it ran ${PLAY_MS / 60_000} minutes.`), PLAY_MS);
422
- socket.addEventListener('open', () => socket.send(JSON.stringify({ goal: asked.goal, bots: asked.bots ?? 1, device: 'headless' })));
451
+ socket.addEventListener('open', () => socket.send(JSON.stringify({ goal: asked.goal, bots: asked.bots ?? 1, device: asked.device ?? 'headless' })));
423
452
  socket.addEventListener('message', (event) => {
424
453
  const message = JSON.parse(String(event.data)) as { line?: unknown; end?: { ok?: unknown } };
425
454
  if (message.end !== undefined) end(message.end.ok === true);
@@ -443,7 +472,9 @@ function answer(response: ServerResponse, status: number, value: unknown): void
443
472
  async function askStd(root: string, path: string, init?: RequestInit): Promise<{ readonly status: number; readonly body: unknown }> {
444
473
  const running = await runningServer(root);
445
474
  if (!running) return { status: 409, body: { error: "The game's dev server is not running: press Play." } };
446
- const response = await fetch(`${running.url}${path}?token=${running.token}`, init);
475
+ const at = new URL(path, running.url);
476
+ at.searchParams.set('token', running.token);
477
+ const response = await fetch(at, init);
447
478
  return { status: response.status, body: await response.json() };
448
479
  }
449
480
 
@@ -492,6 +523,8 @@ export function stdDevPlugin(services: Pick<ProjectServingServices, 'currentProj
492
523
  url.pathname === '/' && request.method === 'GET' ? Promise.resolve({ status: 200, body: stdGame(root) })
493
524
  : url.pathname === '/start' && request.method === 'POST' ? startedServer(root).then(({ url: at }) => ({ status: 200, body: { url: at } }))
494
525
  : url.pathname === '/devices' && request.method === 'GET' ? askStd(root, '/__std/devices')
526
+ // (the game's running scene, read where it runs: the level document draws the playing world from it)
527
+ : url.pathname === '/scene' && request.method === 'GET' ? askStd(root, `/__std/scene${url.search}`)
495
528
  : control !== undefined && request.method === 'POST'
496
529
  ? askStd(root, `/__std/devices/${encodeURIComponent(decodeURIComponent(control))}/control`,
497
530
  { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(bodyOf(request)) })
@@ -501,10 +534,10 @@ export function stdDevPlugin(services: Pick<ProjectServingServices, 'currentProj
501
534
  : url.pathname === '/pages' && request.method === 'GET' ? pageSlots(root).then((body) => ({ status: 200, body }))
502
535
  : url.pathname === '/trace' && request.method === 'GET' && PAGE_ID.test(url.searchParams.get('page') ?? '')
503
536
  ? traceFrom(root, url.searchParams.get('page')!, Number(url.searchParams.get('from') ?? 0)).then((body) => ({ status: 200, body }))
504
- : url.pathname === '/errors' && request.method === 'GET'
505
- ? Promise.resolve({ status: 200, body: errorsFrom(root,
537
+ : url.pathname === '/output' && request.method === 'GET'
538
+ ? Promise.resolve({ status: 200, body: outputFrom(root,
506
539
  url.searchParams.has('server') ? Number(url.searchParams.get('server')) : null,
507
- url.searchParams.has('from') ? Number(url.searchParams.get('from')) || 0 : null) })
540
+ { stdout: lineOf(url.searchParams.get('stdout')), stderr: lineOf(url.searchParams.get('stderr')) }) })
508
541
  : Promise.resolve({ status: 404, body: { error: `${STD_ROUTE}${url.pathname} is not a route of Play's` } });
509
542
  asked.then(({ status, body }) => answer(response, status, body),
510
543
  (error: unknown) => answer(response, 500, { error: error instanceof Error ? error.message : String(error) }));
@@ -8,7 +8,7 @@
8
8
  import { Component, createElement as h } from 'react';
9
9
  import * as THREE from 'three';
10
10
  import { advance, createPortal, createRoot, extend, useFrame } from '@react-three/fiber';
11
- import { Physics } from '@react-three/rapier';
11
+ import { Physics, useRapier } from '@react-three/rapier';
12
12
  import { EffectPass, LUT3DEffect } from 'postprocessing';
13
13
  import { LUTCubeLoader } from 'three/addons/loaders/LUTCubeLoader.js';
14
14
  import { ViewShaperEffect } from '@volter-ai/std/level/client';
@@ -30,6 +30,119 @@ function StageDraws() {
30
30
  return null;
31
31
  }
32
32
 
33
+ /** An object's children, each with its name in a running scene's path (std's "The running scene": its name, else its
34
+ * three type, siblings of one name numbered `~2`, `~3` in their parent's order). */
35
+ function namedChildren(object) {
36
+ const seen = new Map();
37
+ return object.children.map((child) => {
38
+ const own = child.name === '' ? child.type : child.name;
39
+ const n = (seen.get(own) ?? 0) + 1;
40
+ seen.set(own, n);
41
+ return [child, n === 1 ? own : `${own}~${n}`];
42
+ });
43
+ }
44
+
45
+ /** The object at a running scene's path below `root`, where there is one. */
46
+ function objectBelow(root, path) {
47
+ let at = root;
48
+ for (const name of path.split('/')) {
49
+ at = namedChildren(at).find(([, own]) => own === name)?.[0];
50
+ if (at === undefined) return undefined;
51
+ }
52
+ return at;
53
+ }
54
+
55
+ const place = new THREE.Vector3();
56
+ const turn = new THREE.Quaternion();
57
+ const size = new THREE.Vector3();
58
+
59
+ /** A running scene's object put on this copy's: its local transform and visibility, and the body it carries where it
60
+ * has one, where the object now stands in the world (the copy's physics is paused, so its collider lines follow the
61
+ * object only so), without waking it. */
62
+ function putNode(object, node, body) {
63
+ object.position.set(node.x, node.y, node.z);
64
+ object.quaternion.set(node.qx, node.qy, node.qz, node.qw);
65
+ object.scale.set(node.sx, node.sy, node.sz);
66
+ object.visible = node.visible;
67
+ if (body === undefined) return;
68
+ object.updateWorldMatrix(true, false);
69
+ object.matrixWorld.decompose(place, turn, size);
70
+ body.setTranslation(place, false);
71
+ body.setRotation(turn, false);
72
+ }
73
+
74
+ /** Each object below `root` with its path, into `into`. */
75
+ function pathsBelow(root, above, into) {
76
+ for (const [child, name] of namedChildren(root)) {
77
+ const path = above === '' ? name : `${above}/${name}`;
78
+ into.set(path, child);
79
+ pathsBelow(child, path, into);
80
+ }
81
+ }
82
+
83
+ /** Whether an object stands as a node has it. */
84
+ const standsAt = (object, node) =>
85
+ object.visible === node.visible &&
86
+ object.position.x === node.x && object.position.y === node.y && object.position.z === node.z &&
87
+ object.quaternion.x === node.qx && object.quaternion.y === node.qy && object.quaternion.z === node.qz && object.quaternion.w === node.qw &&
88
+ object.scale.x === node.sx && object.scale.y === node.sy && object.scale.z === node.sz;
89
+
90
+ /**
91
+ * THE RUNNING SCENE ON THE LEVEL, while the game plays: each change std reads of the game's running scene (std-dev's
92
+ * `/__std/scene`: the world its server runs, or the scene a player's page renders), every object by its path below that
93
+ * scene, put on this root's own copy of the level as a page puts the room's nodes on its own (`putNode`, the object's
94
+ * body too), so the scene view is the playing world, as an engine's scene view is during Play. As std's own
95
+ * page does with the room's moved objects (`useRoomScene`), the nodes the running scene changed are put again every
96
+ * frame, after the level's own frame callbacks (a priority after theirs), each found by its path: what the running
97
+ * scene moved stands where it has it; an object it holds as the copy draws it is left to the level. An object of the
98
+ * running scene's this copy has none of (a character, an object the game adds) stands as an empty of its name and type
99
+ * at its place; an object gone from it is hidden here, or let go where it was the running scene's alone. A whole answer
100
+ * names every object there is: any other is gone.
101
+ */
102
+ function SceneNodes({ running }) {
103
+ const { rigidBodyStates } = useRapier();
104
+ useFrame(() => {
105
+ const leave = (path, object) => {
106
+ running.moved.delete(path);
107
+ if (running.made.has(object)) { object.removeFromParent(); running.made.delete(object); }
108
+ else object.visible = false;
109
+ };
110
+ for (const { whole, objects, gone } of running.pending.splice(0)) {
111
+ if (whole) {
112
+ const named = new Set(objects.map((node) => node.path));
113
+ const here = new Map();
114
+ pathsBelow(running.root, '', here);
115
+ for (const [path, object] of here) if (!named.has(path) && object.parent !== null) leave(path, object);
116
+ }
117
+ for (const node of objects) {
118
+ const object = objectBelow(running.root, node.path);
119
+ if (object === undefined) {
120
+ const at = node.path.lastIndexOf('/');
121
+ const parent = at < 0 ? running.root : objectBelow(running.root, node.path.slice(0, at));
122
+ if (parent === undefined) continue;
123
+ const made = node.type === 'Group' ? new THREE.Group() : new THREE.Object3D();
124
+ made.name = node.path.slice(at + 1).replace(/~\d+$/, '');
125
+ parent.add(made);
126
+ running.made.add(made);
127
+ running.moved.set(node.path, node);
128
+ } else if (running.moved.has(node.path) || !standsAt(object, node)) running.moved.set(node.path, node);
129
+ }
130
+ for (const path of gone) {
131
+ const object = objectBelow(running.root, path);
132
+ if (object !== undefined) leave(path, object);
133
+ }
134
+ }
135
+ if (running.moved.size === 0) return;
136
+ const bodies = new Map();
137
+ rigidBodyStates.forEach((state) => { bodies.set(state.object, state.rigidBody); });
138
+ for (const [path, node] of running.moved) {
139
+ const object = objectBelow(running.root, path);
140
+ if (object !== undefined) putNode(object, node, bodies.get(object));
141
+ }
142
+ }, 1);
143
+ return null;
144
+ }
145
+
33
146
  /** What the level throws as it renders or loads, the view's to show. */
34
147
  class Failed extends Component {
35
148
  state = { error: null };
@@ -57,10 +170,11 @@ function lent(renderer) {
57
170
 
58
171
  /**
59
172
  * A fiber root for `level` on the stage's renderer and camera, its children portalled into the document: the level's
60
- * environment into `world`, the document's own scene, whose sky, environment light and fog the stage draws by, and
61
- * `<Physics paused debug={overlays}><Level /></Physics>` into `root`. Fiber keeps a scene of its own, as its unmount
62
- * disposes what that scene holds. It draws nothing until `draw` is asked; `advance` runs a frame of the level's own
63
- * `useFrame`s (the stage draws the picture); `unmount` ends it.
173
+ * environment into `world`, the document's own scene, whose sky, environment light and fog the stage draws by;
174
+ * `<Physics paused debug={overlays}>` there too, its collider lines beside the level; and in it `<Level />` into
175
+ * `root`, which holds the level alone. Fiber keeps a scene of its own, as its unmount disposes what that scene holds. It draws nothing until `draw` is asked; `advance` runs a frame of the level's own
176
+ * `useFrame`s (the stage draws the picture); `running` takes a change of the game's running scene ({@link SceneNodes}),
177
+ * put on the next frame; `unmount` ends it.
64
178
  */
65
179
  export async function mountScene(level, { renderer, world, camera, root, onError }) {
66
180
  const { Level, Environment, canvas } = level;
@@ -77,16 +191,23 @@ export async function mountScene(level, { renderer, world, camera, root, onError
77
191
  // (fiber sizes the canvas's style in pixels; the stage sizes it by its box)
78
192
  Object.assign(renderer.domElement.style, { width, height });
79
193
  let store = null;
194
+ const running = { root, pending: [], made: new Set(), moved: new Map() };
80
195
  const draw = (shown) => {
81
196
  store = fiber.render(h(Failed, { onError }, h(StageDraws),
82
197
  Environment === null ? null : createPortal(h(Environment), world),
83
- createPortal(h(Physics, { paused: true, debug: shown }, h(Level)), root)));
198
+ // (the physics world in the document's World, its collider lines there, and the level alone in the root: the
199
+ // root's objects are the level's, by the same paths as the game's scene)
200
+ createPortal(h(Physics, { paused: true, debug: shown }, createPortal(h(Level), root), h(SceneNodes, { running })), world)));
84
201
  };
85
202
  return {
86
203
  draw,
204
+ running: (changes) => { running.pending.push(changes); },
87
205
  // (this root's frame alone: another view's level is its own root)
88
206
  advance: (seconds) => { if (store !== null) advance(seconds, true, store.getState()); },
89
- unmount: () => { fiber.unmount(); },
207
+ unmount: () => {
208
+ for (const object of running.made) object.removeFromParent();
209
+ fiber.unmount();
210
+ },
90
211
  };
91
212
  }
92
213
 
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * A STD LEVEL'S SCENE AS A MODULE OF THE GAME'S OWN GRAPH (`docs/adr/std-scene-view.md`, decision 2). `/@volter-std-scene/
3
- * <levels/name.blend>` is a module the session's Vite serves from the game's folder, so its imports resolve as the game's
4
- * own. It names the level's generated files (std's level export writes them beside the `.blend`, named by std's rule:
5
- * `../src/std-level-files.ts`) and hands them to the Scene view's runtime (`./std-scene-runtime.js`), served from the
3
+ * <level>` is a module the session's Vite serves from the game's folder, so its imports resolve as the game's own. A
4
+ * level is a Blender file's (`levels/<name>.blend`), whose module names the level's generated files (std's level export
5
+ * writes them beside the `.blend`, named by std's rule: `../src/std-level-files.ts`), or a level in code
6
+ * (`levels/<Name>Level.tsx`), whose module names that file's component alone, the level lighting itself. Either hands
7
+ * them to the level document's runtime (`./std-scene-runtime.js`), served from the
6
8
  * same graph so that the game's own React Three Fiber, react-three-rapier, postprocessing and std draw it. It exports
7
- * what the Scene view (`../contributions/std-scene.document.tsx`) reads: `component` (the level's component's file),
9
+ * what the level's document (`../contributions/std-level.document.tsx`) reads: `component` (the level's component's file),
8
10
  * `canvas` (its `<Canvas>` settings), `gradingExtra` (whether its grading holds effects beyond its view transform, which
9
11
  * the view does not draw), `mountScene` and `viewTransform` (the runtime's, for this level). Only the level's names are
10
12
  * written here; the grading's shaper and LUT are read from the grading module std's writer makes (std-dev's
@@ -27,30 +29,34 @@ import { installedPackageDir } from '@volter/live/installed-package';
27
29
  import { fsFileUrl } from '@volter/sdk/project-module-url';
28
30
  import type { ProjectServingServices } from '@volter/sdk/session/project-serving';
29
31
  import { normalizePath, type EnvironmentModuleGraph, type EnvironmentModuleNode, type Plugin } from 'vite';
30
- import { camel, levelFiles, pascal } from '../src/std-level-files';
32
+ import { BLEND_LEVEL, camel, CODE_LEVEL, levelFiles, pascal } from '../src/std-level-files';
31
33
 
32
34
  const PREFIX = '/@volter-std-scene/';
33
35
  const VIRTUAL = '\0volter-std-scene:';
34
- /** The Scene view's runtime (`./std-scene-runtime.js`), which a level's module imports by this name. */
36
+ /** The level document's runtime (`./std-scene-runtime.js`), which a level's module imports by this name. */
35
37
  const RUNTIME = 'volter-std-scene-runtime';
36
38
  const RUNTIME_ID = '\0volter-std-scene-runtime';
37
39
  /** The runtime's file, beside this module in play's `serving/` (this module runs from there or from `dist-node/`). */
38
40
  const RUNTIME_FILE = join(dirname(fileURLToPath(import.meta.url)), '..', 'serving', 'std-scene-runtime.js');
39
- const LEVEL = /^levels\/.+\.blend$/;
40
41
  /** The query each URL the Scene module writes carries: the content version it was written at. */
41
42
  const MARK = 'volter-std-scene';
42
- /** The packages the Scene view's runtime imports by name that the game installs itself (`std-scene-runtime.js`'s
43
+ /** The packages the level document's runtime imports by name that the game installs itself (`std-scene-runtime.js`'s
43
44
  * imports, beside react, three and THROUGH's). */
44
45
  const RUNTIME_PACKAGES: readonly string[] = ['@react-three/fiber', '@react-three/rapier'];
45
46
 
46
- /** The runtime's imports the game has only through one of its dependencies, each resolved from that dependency, so it
47
- * is the very instance the dependency uses: std's postprocessing (whose `Effect` std's `ViewShaperEffect` extends). */
47
+ /** The runtime's imports that must be the very instance one of the game's dependencies uses, each resolved from that
48
+ * dependency whatever the game also declares: std's postprocessing (whose `Effect` std's `ViewShaperEffect` extends). */
48
49
  const THROUGH: ReadonlyMap<string, string> = new Map([['postprocessing', '@volter-ai/std/level/client']]);
49
50
 
50
51
  /** The folder the package `name` names (a package, or a subpath of one) is installed in, as Node finds it from `root`. */
51
52
  const installedFolder = (root: string, name: string): string | null =>
52
53
  installedPackageDir(root, name.split('/').slice(0, name.startsWith('@') ? 2 : 1).join('/'));
53
54
 
55
+ /** A level's module that refuses: it throws `why` as it runs, so the document's import of it fails with that message,
56
+ * which the level's view shows (a load that throws reaches the page as a failed fetch, its reason in the server's log
57
+ * alone). */
58
+ const refused = (why: string): string => `throw new Error(${JSON.stringify(why)});`;
59
+
54
60
  /** The URL that serves `file` itself (`/@fs/`, which Vite alone answers), at the content version `version`. */
55
61
  const fileUrl = (file: string, version: number): string => `${fsFileUrl(file)}?${MARK}=${version}`;
56
62
 
@@ -105,6 +111,26 @@ function sceneModule(root: string, blend: string, read: Set<string>, version: nu
105
111
  ].join('\n');
106
112
  }
107
113
 
114
+ /** The module's source for a level in code (`levels/<Name>Level.tsx`, from the game's folder): its own component, with
115
+ * no environment, canvas settings or grading of std's export, as the level lights itself, its lights' shadows drawn
116
+ * as std's starter's `<Canvas shadows="percentage">` draws them; the file goes into `read`. */
117
+ function codeSceneModule(root: string, file: string, read: Set<string>): string {
118
+ const component = CODE_LEVEL.exec(file)![1]!;
119
+ read.add(normalizePath(join(root, file)));
120
+ if (!existsSync(join(root, file))) throw new Error(`${file} is not in the game: a level in code is levels/<Name>Level.tsx.`);
121
+ return [
122
+ `import { mountScene as mountLevel } from ${JSON.stringify(RUNTIME)};`,
123
+ `import { ${component} } from ${JSON.stringify(`/${file}`)};`,
124
+ `export const component = ${JSON.stringify(file)};`,
125
+ "export const canvas = { shadows: 'percentage' };",
126
+ 'export const gradingExtra = false;',
127
+ `const level = { Level: ${component}, Environment: null, canvas: { shadows: 'percentage' } };`,
128
+ 'export const mountScene = (stage) => mountLevel(level, stage);',
129
+ 'export const viewTransform = async () => null;',
130
+ '',
131
+ ].join('\n');
132
+ }
133
+
108
134
  /** Whether a module's file is the game's own (in its folder, not a dependency's). */
109
135
  function gameFile(root: string, file: string | null): file is string {
110
136
  if (file === null) return false;
@@ -132,15 +158,15 @@ export function stdScenePlugin(services: Pick<ProjectServingServices, 'currentPr
132
158
  const root = services.currentProjectRoot();
133
159
  return root && existsSync(join(root, 'std.json')) ? root : null;
134
160
  };
135
- /** Each level's latest Scene module, by its `.blend`: its id and the files its source was written from. */
161
+ /** Each level's latest Scene module, by its level: its id and the files its source was written from. */
136
162
  const scenes = new Map<string, { readonly id: string; readonly read: ReadonlySet<string> }>();
137
163
  /** The game's content version, which every URL a game's module imports carries: a loader's cache keyed by the URL
138
- * loads anew whatever a Scene view depends on changed. */
164
+ * loads anew whatever a level's document depends on changed. */
139
165
  let version = Date.now();
140
166
  return {
141
167
  name: 'volter-play-std-scene',
142
168
  enforce: 'pre',
143
- // (a std game's Scene view and its levels' modules prebundled as the session starts: found on the view's first open,
169
+ // (a std game's level documents and its levels' modules prebundled as the session starts: found on the view's first open,
144
170
  // Vite would optimize them again then, and the editor's page, which does not reload for it, would hold two copies of
145
171
  // React. The levels' files are crawled, and the runtime's own packages are included where the game has them)
146
172
  config() {
@@ -163,27 +189,28 @@ export function stdScenePlugin(services: Pick<ProjectServingServices, 'currentPr
163
189
  }
164
190
  if (!source.startsWith(PREFIX)) return null;
165
191
  const [path = '', query = ''] = source.slice(PREFIX.length).split('?');
166
- const blend = decodeURIComponent(path);
167
- if (!LEVEL.test(blend)) throw new Error(`${blend} is not a std level's Blender file (levels/<name>.blend).`);
192
+ const level = decodeURIComponent(path);
193
+ if (!BLEND_LEVEL.test(level) && !CODE_LEVEL.test(level))
194
+ throw new Error(`${level} is not a std level: a level's Blender file (levels/<name>.blend) or a level in code (levels/<Name>Level.tsx).`);
168
195
  // (each of the view's imports is a module of its own, written from the level's files as they are then)
169
- return `${VIRTUAL}${blend}?${query}`;
196
+ return `${VIRTUAL}${level}?${query}`;
170
197
  },
171
198
  load(id) {
172
199
  if (id === RUNTIME_ID) return readFileSync(RUNTIME_FILE, 'utf8');
173
200
  if (id.startsWith(VIRTUAL)) {
174
201
  const root = stdGame();
175
- if (!root) throw new Error('The open project is not a std game (no std.json), so it has no level to draw.');
176
- const blend = id.slice(VIRTUAL.length).replace(/\?.*$/, '');
202
+ if (!root) return refused('The open project is not a std game (no std.json), so it has no level to draw.');
203
+ const level = id.slice(VIRTUAL.length).replace(/\?.*$/, '');
177
204
  const read = new Set<string>();
178
- scenes.set(blend, { id, read });
179
- return sceneModule(root, blend, read, version);
205
+ scenes.set(level, { id, read });
206
+ return CODE_LEVEL.test(level) ? codeSceneModule(root, level, read) : sceneModule(root, level, read, version);
180
207
  }
181
208
  const [file = id, query] = id.split('?');
182
209
  const root = query === 'url' ? stdGame() : null;
183
210
  if (!root || !gameFile(root, file)) return null;
184
211
  return `export default ${JSON.stringify(fileUrl(file, version))};`;
185
212
  },
186
- // (a Scene view depends on its module's whole import closure among the game's files, the modules whose import failed
213
+ // (a level's document depends on its module's whole import closure among the game's files, the modules whose import failed
187
214
  // to resolve within it and the files its source was written from. Any file of those written, made or removed bumps
188
215
  // the content version, invalidates the closure, so each of its modules and URLs is served anew, and is announced on
189
216
  // the session's change channel naming the levels it reached)
@@ -1,19 +1,14 @@
1
1
  /**
2
- * EDITOR-BLENDER'S API, AS PLAY'S PAGE HALF KEPT IT (`../contributions/play.page.tsx`), and the model
3
- * Play that page half registered through it. play's modules read them here rather than importing
4
- * editor-blender's modules: its API is the one instance editor-blender's own `activate` returned.
2
+ * EDITOR-BLENDER'S API, AS PLAY'S PAGE HALF KEPT IT (`../contributions/play.page.tsx`). play's modules read it here
3
+ * rather than importing editor-blender's modules: its API is the one instance editor-blender's own `activate` returned.
5
4
  */
6
- import type { BlenderPageApi, DocumentPlayExtension } from '@volter/editor-blender/api';
5
+ import type { BlenderPageApi } from '@volter/editor-blender/api';
7
6
 
8
7
  let api: BlenderPageApi | null = null;
9
- let modelPlay: DocumentPlayExtension | null = null;
10
- const listeners = new Set<() => void>();
11
8
 
12
9
  /** Kept by play's `activate`, and released (`null`) when it deactivates. */
13
- export function keepEditorBlender(next: BlenderPageApi | null, registered: DocumentPlayExtension | null): void {
10
+ export function keepEditorBlender(next: BlenderPageApi | null): void {
14
11
  api = next;
15
- modelPlay = registered;
16
- for (const listener of listeners) listener();
17
12
  }
18
13
 
19
14
  /** editor-blender's API. play's modules run once its page half has activated, which is after editor-blender's. */
@@ -21,13 +16,3 @@ export function editorBlender(): BlenderPageApi {
21
16
  if (!api) throw new Error("@volter/play is not active, so editor-blender's API is not at hand.");
22
17
  return api;
23
18
  }
24
-
25
- /** The model Play play registered, or `null` while it is not. */
26
- export function registeredModelPlay(): DocumentPlayExtension | null {
27
- return modelPlay;
28
- }
29
-
30
- export function subscribeRegisteredModelPlay(listener: () => void): () => void {
31
- listeners.add(listener);
32
- return () => { listeners.delete(listener); };
33
- }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * EDITOR-THREEJS'S API, AS PLAY'S PAGE HALF KEPT IT (`../contributions/play.page.tsx`): the shared viewport and its
3
- * stages, which a std level's Scene view draws in (`../contributions/std-scene.document.tsx`). play reads it here rather than importing
3
+ * stages, which a std level in code is drawn in (`../contributions/std-level.document.tsx`). play reads it here rather than importing
4
4
  * editor-threejs's modules: its API is the one instance editor-threejs's own `activate` returned.
5
5
  */
6
6
  import type { ThreePageApi } from '@volter/editor-threejs/api';