@volter/play 0.5.212 → 0.5.214
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -51
- package/contributions/game-panel.tsx +228 -329
- package/contributions/levels.finder.ts +43 -0
- package/contributions/play.page.tsx +56 -174
- package/contributions/play.verbs.ts +74 -0
- package/contributions/{std-scene.document.tsx → std-level.document.tsx} +154 -22
- package/dist-node/serving.mjs +81 -39
- package/package.json +76 -54
- package/serving/index.ts +1 -1
- package/serving/std-dev.ts +86 -53
- package/serving/std-scene-runtime.js +128 -7
- package/serving/std-scene.ts +67 -24
- package/src/editor-blender.ts +4 -19
- package/src/editor-threejs.ts +1 -1
- package/src/game-controls.ts +111 -0
- package/src/game-run.ts +165 -0
- package/src/game-view.tsx +219 -0
- package/src/play-clock.ts +4 -4
- package/src/play-log.ts +57 -73
- package/src/std-controls.ts +7 -7
- package/src/std-export.ts +0 -8
- package/src/std-game-adapter.ts +10 -4
- package/src/std-game.ts +320 -191
- package/src/std-level-files.ts +18 -1
- package/src/std-level.ts +4 -4
- package/src/std-view-bar.tsx +7 -17
- package/contributions/model-play.verbs.ts +0 -44
- package/src/model-play.ts +0 -149
- package/src/std-scene-level.ts +0 -59
package/serving/std-dev.ts
CHANGED
|
@@ -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
|
|
19
|
-
* (`/__std/devices`, `/__std/devices/<id>/control
|
|
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
|
|
28
|
-
*
|
|
29
|
-
* level's glb or a prefab's (`prefabs/<name>/<name>.glb`,
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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/
|
|
45
|
-
* the dev server this session started
|
|
46
|
-
*
|
|
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
|
|
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
|
|
263
|
-
const
|
|
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
|
-
*
|
|
267
|
-
*
|
|
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
|
|
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
|
|
283
|
-
* too), terminal escapes left out. */
|
|
284
|
-
function
|
|
285
|
-
const text = chunk === null ?
|
|
286
|
-
const lines = stripVTControlCharacters(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const over =
|
|
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
|
-
|
|
292
|
-
|
|
300
|
+
stream.lines.splice(0, over);
|
|
301
|
+
stream.dropped += over;
|
|
293
302
|
}
|
|
294
303
|
}
|
|
295
304
|
|
|
296
|
-
/**
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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,
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
|
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 === '/
|
|
505
|
-
? Promise.resolve({ status: 200, body:
|
|
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.
|
|
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
|
|
61
|
-
* `<Physics paused debug={overlays}
|
|
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); `
|
|
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
|
-
|
|
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: () => {
|
|
207
|
+
unmount: () => {
|
|
208
|
+
for (const object of running.made) object.removeFromParent();
|
|
209
|
+
fiber.unmount();
|
|
210
|
+
},
|
|
90
211
|
};
|
|
91
212
|
}
|
|
92
213
|
|
package/serving/std-scene.ts
CHANGED
|
@@ -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
|
-
* <
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
|
@@ -20,37 +22,41 @@
|
|
|
20
22
|
* cache. A URL the game's code passes as a plain string, and a glTF's external files, keep their URL: they show a
|
|
21
23
|
* change on a page reload (`docs/adr/std-scene-view.md`, decision 5).
|
|
22
24
|
*/
|
|
23
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
24
26
|
import { dirname, join, relative, sep } from 'node:path';
|
|
25
27
|
import { fileURLToPath } from 'node:url';
|
|
26
28
|
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
|
|
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
|
|
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
|
|
47
|
-
*
|
|
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,40 @@ 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
|
+
|
|
134
|
+
/** The game's level modules (`levels/**` TypeScript), each as its path from the game's folder. */
|
|
135
|
+
function levelModules(root: string): string[] {
|
|
136
|
+
let found: string[];
|
|
137
|
+
try {
|
|
138
|
+
found = readdirSync(join(root, 'levels'), { recursive: true, encoding: 'utf8' });
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
return found
|
|
143
|
+
.filter((file) => /\.tsx?$/.test(file) && !file.split(sep).some((segment) => segment.startsWith('.') || segment === 'node_modules'))
|
|
144
|
+
.map((file) => `levels/${file.split(sep).join('/')}`)
|
|
145
|
+
.sort();
|
|
146
|
+
}
|
|
147
|
+
|
|
108
148
|
/** Whether a module's file is the game's own (in its folder, not a dependency's). */
|
|
109
149
|
function gameFile(root: string, file: string | null): file is string {
|
|
110
150
|
if (file === null) return false;
|
|
@@ -132,22 +172,24 @@ export function stdScenePlugin(services: Pick<ProjectServingServices, 'currentPr
|
|
|
132
172
|
const root = services.currentProjectRoot();
|
|
133
173
|
return root && existsSync(join(root, 'std.json')) ? root : null;
|
|
134
174
|
};
|
|
135
|
-
/** Each level's latest Scene module, by its
|
|
175
|
+
/** Each level's latest Scene module, by its level: its id and the files its source was written from. */
|
|
136
176
|
const scenes = new Map<string, { readonly id: string; readonly read: ReadonlySet<string> }>();
|
|
137
177
|
/** 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
|
|
178
|
+
* loads anew whatever a level's document depends on changed. */
|
|
139
179
|
let version = Date.now();
|
|
140
180
|
return {
|
|
141
181
|
name: 'volter-play-std-scene',
|
|
142
182
|
enforce: 'pre',
|
|
143
|
-
// (a std game's
|
|
183
|
+
// (a std game's level documents and its levels' modules prebundled as the session starts: found on the view's first open,
|
|
144
184
|
// Vite would optimize them again then, and the editor's page, which does not reload for it, would hold two copies of
|
|
145
|
-
// React. The levels' files are crawled, and the runtime's own packages are included where the game has them
|
|
185
|
+
// React. The levels' files are crawled, and the runtime's own packages are included where the game has them. The
|
|
186
|
+
// files are listed, never a pattern: one entry that is a pattern makes Vite glob the session's whole list, which
|
|
187
|
+
// skips every `node_modules`, and so every installed package's crawl entry, `globEntries` in its dependency scan)
|
|
146
188
|
config() {
|
|
147
189
|
const root = stdGame();
|
|
148
190
|
if (root === null) return undefined;
|
|
149
191
|
const include = RUNTIME_PACKAGES.filter((name) => installedFolder(root, name) !== null);
|
|
150
|
-
return { optimizeDeps: { entries:
|
|
192
|
+
return { optimizeDeps: { entries: levelModules(root), include } };
|
|
151
193
|
},
|
|
152
194
|
async resolveId(source, importer) {
|
|
153
195
|
const ours = importer?.startsWith(VIRTUAL) === true || importer === RUNTIME_ID;
|
|
@@ -163,27 +205,28 @@ export function stdScenePlugin(services: Pick<ProjectServingServices, 'currentPr
|
|
|
163
205
|
}
|
|
164
206
|
if (!source.startsWith(PREFIX)) return null;
|
|
165
207
|
const [path = '', query = ''] = source.slice(PREFIX.length).split('?');
|
|
166
|
-
const
|
|
167
|
-
if (!
|
|
208
|
+
const level = decodeURIComponent(path);
|
|
209
|
+
if (!BLEND_LEVEL.test(level) && !CODE_LEVEL.test(level))
|
|
210
|
+
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
211
|
// (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}${
|
|
212
|
+
return `${VIRTUAL}${level}?${query}`;
|
|
170
213
|
},
|
|
171
214
|
load(id) {
|
|
172
215
|
if (id === RUNTIME_ID) return readFileSync(RUNTIME_FILE, 'utf8');
|
|
173
216
|
if (id.startsWith(VIRTUAL)) {
|
|
174
217
|
const root = stdGame();
|
|
175
|
-
if (!root)
|
|
176
|
-
const
|
|
218
|
+
if (!root) return refused('The open project is not a std game (no std.json), so it has no level to draw.');
|
|
219
|
+
const level = id.slice(VIRTUAL.length).replace(/\?.*$/, '');
|
|
177
220
|
const read = new Set<string>();
|
|
178
|
-
scenes.set(
|
|
179
|
-
return sceneModule(root,
|
|
221
|
+
scenes.set(level, { id, read });
|
|
222
|
+
return CODE_LEVEL.test(level) ? codeSceneModule(root, level, read) : sceneModule(root, level, read, version);
|
|
180
223
|
}
|
|
181
224
|
const [file = id, query] = id.split('?');
|
|
182
225
|
const root = query === 'url' ? stdGame() : null;
|
|
183
226
|
if (!root || !gameFile(root, file)) return null;
|
|
184
227
|
return `export default ${JSON.stringify(fileUrl(file, version))};`;
|
|
185
228
|
},
|
|
186
|
-
// (a
|
|
229
|
+
// (a level's document depends on its module's whole import closure among the game's files, the modules whose import failed
|
|
187
230
|
// to resolve within it and the files its source was written from. Any file of those written, made or removed bumps
|
|
188
231
|
// the content version, invalidates the closure, so each of its modules and URLs is served anew, and is announced on
|
|
189
232
|
// the session's change channel naming the levels it reached)
|