@volter/play 0.5.211 → 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.
- package/NOTICE +5 -5
- package/README.md +156 -172
- package/contributions/game-panel.tsx +807 -0
- package/contributions/levels.finder.ts +43 -0
- package/contributions/play.page.tsx +93 -0
- package/contributions/play.verbs.ts +74 -0
- package/contributions/std-level.document.tsx +477 -0
- package/dist-node/serving.mjs +695 -0
- package/package.json +277 -10
- package/serving/index.ts +13 -0
- package/serving/std-dev.ts +547 -0
- package/serving/std-scene-runtime.js +240 -0
- package/serving/std-scene.ts +240 -0
- package/src/editor-blender.ts +18 -0
- package/src/editor-threejs.ts +19 -0
- 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 +62 -0
- package/src/play-log.ts +113 -114
- package/src/std-controls.ts +128 -0
- package/src/std-export.ts +120 -0
- package/src/std-game-adapter.ts +26 -0
- package/src/std-game.ts +967 -0
- package/src/std-level-files.ts +50 -0
- package/src/std-level.ts +107 -0
- package/src/std-view-bar.tsx +68 -0
- package/contributions/model-play.command.ts +0 -37
- package/contributions/model-play.service.tsx +0 -152
- package/src/camera-transition.ts +0 -87
- package/src/model-play.ts +0 -376
- package/src/play-materials.ts +0 -199
- package/src/play-script.ts +0 -841
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SCENE VIEW'S RUNTIME, served from the std game's own module graph (`./std-scene.ts`, the id
|
|
3
|
+
* `\0volter-std-scene-runtime`): its imports resolve as the game's, so the fiber root, react-three-rapier and std's
|
|
4
|
+
* effects are the game's own instances, the very ones the level's components use. A level's module (`./std-scene.ts`'s
|
|
5
|
+
* `sceneModule`) names its files and hands them here. It is a browser module, not part of play's page bundle, because
|
|
6
|
+
* play's page carries none of the game's libraries.
|
|
7
|
+
*/
|
|
8
|
+
import { Component, createElement as h } from 'react';
|
|
9
|
+
import * as THREE from 'three';
|
|
10
|
+
import { advance, createPortal, createRoot, extend, useFrame } from '@react-three/fiber';
|
|
11
|
+
import { Physics, useRapier } from '@react-three/rapier';
|
|
12
|
+
import { EffectPass, LUT3DEffect } from 'postprocessing';
|
|
13
|
+
import { LUTCubeLoader } from 'three/addons/loaders/LUTCubeLoader.js';
|
|
14
|
+
import { ViewShaperEffect } from '@volter-ai/std/level/client';
|
|
15
|
+
|
|
16
|
+
// (a root made by createRoot knows no three.js element until it is told, as <Canvas> tells its own)
|
|
17
|
+
extend(THREE);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* THE TREES' LOADS STAY THE GAME'S. The level's components load through fiber's `useLoader`, whose loaders and cache are
|
|
21
|
+
* fiber's own, shared by every fiber root on the page; nothing here changes them. A file the level's modules import by
|
|
22
|
+
* URL (`?url`: the glb, the sky, the terrain) carries the game's content version (`./std-scene.ts`), so a new version
|
|
23
|
+
* is a new URL and a new cache entry. A URL the game's code passes as a plain string, and a glTF's external files, keep
|
|
24
|
+
* their URL: they show a change on a page reload (`docs/adr/std-scene-view.md`, decision 5).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** A frame subscriber of priority 1 takes the render over from fiber, so the stage's own draw is the only one. */
|
|
28
|
+
function StageDraws() {
|
|
29
|
+
useFrame(() => {}, 1);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
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
|
+
|
|
146
|
+
/** What the level throws as it renders or loads, the view's to show. */
|
|
147
|
+
class Failed extends Component {
|
|
148
|
+
state = { error: null };
|
|
149
|
+
static getDerivedStateFromError(error) { return { error }; }
|
|
150
|
+
componentDidCatch(error) { this.props.onError(error); }
|
|
151
|
+
render() { return this.state.error === null ? this.props.children : null; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The stage's renderer as fiber holds it. Fiber's unmount releases a renderer it was given (9.8.2's `releaseRenderer`:
|
|
156
|
+
* for a renderer it did not make, it still calls `forceContextLoss`); the stage's renderer and its context are the
|
|
157
|
+
* stage's, so fiber holds it through this view, whose `forceContextLoss` and `dispose` leave it to the stage. Every
|
|
158
|
+
* other member is the renderer's own.
|
|
159
|
+
*/
|
|
160
|
+
function lent(renderer) {
|
|
161
|
+
return new Proxy(renderer, {
|
|
162
|
+
get(target, key) {
|
|
163
|
+
if (key === 'forceContextLoss' || key === 'dispose') return () => {};
|
|
164
|
+
const value = Reflect.get(target, key, target);
|
|
165
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
166
|
+
},
|
|
167
|
+
set: (target, key, value) => Reflect.set(target, key, value, target),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A fiber root for `level` on the stage's renderer and camera, its children portalled into the document: the level's
|
|
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.
|
|
178
|
+
*/
|
|
179
|
+
export async function mountScene(level, { renderer, world, camera, root, onError }) {
|
|
180
|
+
const { Level, Environment, canvas } = level;
|
|
181
|
+
const fiber = createRoot(renderer.domElement);
|
|
182
|
+
const size = renderer.getSize(new THREE.Vector2());
|
|
183
|
+
const { width, height } = renderer.domElement.style;
|
|
184
|
+
try {
|
|
185
|
+
await fiber.configure({ gl: lent(renderer), camera, frameloop: 'never', shadows: canvas.shadows ?? false, flat: true,
|
|
186
|
+
dpr: renderer.getPixelRatio(), size: { width: size.x, height: size.y, top: 0, left: 0 } });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
fiber.unmount();
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
// (fiber sizes the canvas's style in pixels; the stage sizes it by its box)
|
|
192
|
+
Object.assign(renderer.domElement.style, { width, height });
|
|
193
|
+
let store = null;
|
|
194
|
+
const running = { root, pending: [], made: new Set(), moved: new Map() };
|
|
195
|
+
const draw = (shown) => {
|
|
196
|
+
store = fiber.render(h(Failed, { onError }, h(StageDraws),
|
|
197
|
+
Environment === null ? null : createPortal(h(Environment), world),
|
|
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)));
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
draw,
|
|
204
|
+
running: (changes) => { running.pending.push(changes); },
|
|
205
|
+
// (this root's frame alone: another view's level is its own root)
|
|
206
|
+
advance: (seconds) => { if (store !== null) advance(seconds, true, store.getState()); },
|
|
207
|
+
unmount: () => {
|
|
208
|
+
for (const object of running.made) object.removeFromParent();
|
|
209
|
+
fiber.unmount();
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A level's view transform as the stage's display transform: std's `ViewShaperEffect`, then postprocessing's
|
|
216
|
+
* `LUT3DEffect` on the LUT std baked, read by three's `LUTCubeLoader` as std reads it. Its `dispose` frees the pass, its
|
|
217
|
+
* effects and the LUT.
|
|
218
|
+
*/
|
|
219
|
+
export async function viewTransform({ lut: url, min, max, exposure }) {
|
|
220
|
+
const lut = (await new LUTCubeLoader().loadAsync(url)).texture3D;
|
|
221
|
+
const shaper = new ViewShaperEffect(min, max, exposure);
|
|
222
|
+
const look = new LUT3DEffect(lut, { tetrahedralInterpolation: true });
|
|
223
|
+
look.inputColorSpace = THREE.LinearSRGBColorSpace;
|
|
224
|
+
const pass = new EffectPass(new THREE.PerspectiveCamera(), shaper, look);
|
|
225
|
+
let initialized = false;
|
|
226
|
+
return {
|
|
227
|
+
render(renderer, input, output) {
|
|
228
|
+
if (!initialized) {
|
|
229
|
+
pass.initialize(renderer, false, THREE.HalfFloatType);
|
|
230
|
+
initialized = true;
|
|
231
|
+
}
|
|
232
|
+
pass.renderToScreen = output === null;
|
|
233
|
+
pass.render(renderer, input, output, 0, false);
|
|
234
|
+
},
|
|
235
|
+
dispose() {
|
|
236
|
+
pass.dispose();
|
|
237
|
+
lut.dispose();
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
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
|
+
* <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
|
|
8
|
+
* same graph so that the game's own React Three Fiber, react-three-rapier, postprocessing and std draw it. It exports
|
|
9
|
+
* what the level's document (`../contributions/std-level.document.tsx`) reads: `component` (the level's component's file),
|
|
10
|
+
* `canvas` (its `<Canvas>` settings), `gradingExtra` (whether its grading holds effects beyond its view transform, which
|
|
11
|
+
* the view does not draw), `mountScene` and `viewTransform` (the runtime's, for this level). Only the level's names are
|
|
12
|
+
* written here; the grading's shaper and LUT are read from the grading module std's writer makes (std-dev's
|
|
13
|
+
* level-names.ts `writeGrading`).
|
|
14
|
+
*
|
|
15
|
+
* WHAT A VIEW DEPENDS ON is its module's whole import closure among the game's files (Vite's module graph: the level's
|
|
16
|
+
* components, its prefabs', each URL import, wherever they are), the modules whose import failed to resolve within it,
|
|
17
|
+
* and the files its source was written from (the grading's LUT among them). A file of those written, made or removed
|
|
18
|
+
* bumps the game's content version, invalidates the closure and is announced on the session's change channel
|
|
19
|
+
* (`volter:restart-required`) naming the levels it reached, which their views draw again. Each URL this module writes
|
|
20
|
+
* (a URL import's, the LUT's) names its file itself (`/@fs/<file>`, which only Vite answers, so the file served is the
|
|
21
|
+
* file imported) and carries the content version (`?volter-std-scene=`), so a new version is a new URL to the loaders'
|
|
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
|
|
23
|
+
* change on a page reload (`docs/adr/std-scene-view.md`, decision 5).
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { installedPackageDir } from '@volter/live/installed-package';
|
|
29
|
+
import { fsFileUrl } from '@volter/sdk/project-module-url';
|
|
30
|
+
import type { ProjectServingServices } from '@volter/sdk/session/project-serving';
|
|
31
|
+
import { normalizePath, type EnvironmentModuleGraph, type EnvironmentModuleNode, type Plugin } from 'vite';
|
|
32
|
+
import { BLEND_LEVEL, camel, CODE_LEVEL, levelFiles, pascal } from '../src/std-level-files';
|
|
33
|
+
|
|
34
|
+
const PREFIX = '/@volter-std-scene/';
|
|
35
|
+
const VIRTUAL = '\0volter-std-scene:';
|
|
36
|
+
/** The level document's runtime (`./std-scene-runtime.js`), which a level's module imports by this name. */
|
|
37
|
+
const RUNTIME = 'volter-std-scene-runtime';
|
|
38
|
+
const RUNTIME_ID = '\0volter-std-scene-runtime';
|
|
39
|
+
/** The runtime's file, beside this module in play's `serving/` (this module runs from there or from `dist-node/`). */
|
|
40
|
+
const RUNTIME_FILE = join(dirname(fileURLToPath(import.meta.url)), '..', 'serving', 'std-scene-runtime.js');
|
|
41
|
+
/** The query each URL the Scene module writes carries: the content version it was written at. */
|
|
42
|
+
const MARK = 'volter-std-scene';
|
|
43
|
+
/** The packages the level document's runtime imports by name that the game installs itself (`std-scene-runtime.js`'s
|
|
44
|
+
* imports, beside react, three and THROUGH's). */
|
|
45
|
+
const RUNTIME_PACKAGES: readonly string[] = ['@react-three/fiber', '@react-three/rapier'];
|
|
46
|
+
|
|
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). */
|
|
49
|
+
const THROUGH: ReadonlyMap<string, string> = new Map([['postprocessing', '@volter-ai/std/level/client']]);
|
|
50
|
+
|
|
51
|
+
/** The folder the package `name` names (a package, or a subpath of one) is installed in, as Node finds it from `root`. */
|
|
52
|
+
const installedFolder = (root: string, name: string): string | null =>
|
|
53
|
+
installedPackageDir(root, name.split('/').slice(0, name.startsWith('@') ? 2 : 1).join('/'));
|
|
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
|
+
|
|
60
|
+
/** The URL that serves `file` itself (`/@fs/`, which Vite alone answers), at the content version `version`. */
|
|
61
|
+
const fileUrl = (file: string, version: number): string => `${fsFileUrl(file)}?${MARK}=${version}`;
|
|
62
|
+
|
|
63
|
+
/** The view transform a level's grading module renders: its LUT file beside it and its shaper's range and exposure. */
|
|
64
|
+
interface ViewGrading {
|
|
65
|
+
readonly lut: string;
|
|
66
|
+
readonly min: number;
|
|
67
|
+
readonly max: number;
|
|
68
|
+
readonly exposure: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The module's source for the level whose `.blend` is `blend` (from the game's folder); each file it looks at, present
|
|
72
|
+
* or not, goes into `read`. */
|
|
73
|
+
function sceneModule(root: string, blend: string, read: Set<string>, version: number): string {
|
|
74
|
+
const files = levelFiles(blend);
|
|
75
|
+
const { base, dir } = files;
|
|
76
|
+
const level = `${pascal(base)}Level`, environment = `${pascal(base)}Environment`, canvas = `${camel(base)}Canvas`;
|
|
77
|
+
const exists = (at: string): boolean => {
|
|
78
|
+
read.add(normalizePath(join(root, at)));
|
|
79
|
+
return existsSync(join(root, at));
|
|
80
|
+
};
|
|
81
|
+
if (!exists(files.component) || !exists(files.canvas)) {
|
|
82
|
+
throw new Error(`${blend} has no exported level beside it (${files.component} and ${files.canvas}): save it, and std's level export writes them.`);
|
|
83
|
+
}
|
|
84
|
+
const hasEnvironment = exists(files.environment);
|
|
85
|
+
const source = exists(files.grading) ? readFileSync(join(root, files.grading), 'utf8') : null;
|
|
86
|
+
const shaper = source === null ? null : /<ViewShaper min=\{(-?[\d.]+)\} max=\{(-?[\d.]+)\}(?: exposure=\{(-?[\d.]+)\})? \/>/.exec(source);
|
|
87
|
+
const lut = source === null ? null : /import viewLut from '\.\/(.+?)\?url';/.exec(source);
|
|
88
|
+
const grading: ViewGrading | null = shaper !== null && lut !== null
|
|
89
|
+
? { lut: join(root, dir, lut[1]!), min: Number(shaper[1]), max: Number(shaper[2]), exposure: Number(shaper[3] ?? 0) } : null;
|
|
90
|
+
// (the effects the composer holds beyond the shaper and the LUT: a converted level's contrast or saturation)
|
|
91
|
+
if (grading !== null) read.add(normalizePath(grading.lut));
|
|
92
|
+
const gradingExtra = source !== null && [...source.matchAll(/^\s+<([A-Z]\w*)\b/gm)]
|
|
93
|
+
.some((found) => found[1] !== 'EffectComposer' && found[1] !== 'ViewShaper' && found[1] !== 'LUT');
|
|
94
|
+
return [
|
|
95
|
+
`import { mountScene as mountLevel, viewTransform as levelTransform } from ${JSON.stringify(RUNTIME)};`,
|
|
96
|
+
`import { ${level} } from ${JSON.stringify(`/${files.component}`)};`,
|
|
97
|
+
...(hasEnvironment ? [`import { ${environment} } from ${JSON.stringify(`/${files.environment}`)};`] : []),
|
|
98
|
+
`import { ${canvas} } from ${JSON.stringify(`/${files.canvas}`)};`,
|
|
99
|
+
// (the LUT's address, which its loader fetches as it is: an import of a `.cube` is no asset to Vite, whose import of it
|
|
100
|
+
// is its text as a module)
|
|
101
|
+
...(grading === null ? [] : [`const viewLut = ${JSON.stringify(fileUrl(grading.lut, version))};`]),
|
|
102
|
+
`export const component = ${JSON.stringify(files.component)};`,
|
|
103
|
+
`export const canvas = ${canvas};`,
|
|
104
|
+
`export const gradingExtra = ${String(gradingExtra)};`,
|
|
105
|
+
`const level = { Level: ${level}, Environment: ${hasEnvironment ? environment : 'null'}, canvas: ${canvas} };`,
|
|
106
|
+
'export const mountScene = (stage) => mountLevel(level, stage);',
|
|
107
|
+
grading === null
|
|
108
|
+
? 'export const viewTransform = async () => null;'
|
|
109
|
+
: `export const viewTransform = () => levelTransform({ lut: viewLut, min: ${grading.min}, max: ${grading.max}, exposure: ${grading.exposure} });`,
|
|
110
|
+
'',
|
|
111
|
+
].join('\n');
|
|
112
|
+
}
|
|
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
|
+
/** Whether a module's file is the game's own (in its folder, not a dependency's). */
|
|
135
|
+
function gameFile(root: string, file: string | null): file is string {
|
|
136
|
+
if (file === null) return false;
|
|
137
|
+
const at = relative(root, file);
|
|
138
|
+
return !at.startsWith('..') && !at.split(sep).includes('node_modules');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The game's modules a module imports, and theirs, all the way down: a level's components, its prefabs', each URL import. */
|
|
142
|
+
function importClosure(graph: EnvironmentModuleGraph, root: string, id: string): Set<EnvironmentModuleNode> {
|
|
143
|
+
const found = new Set<EnvironmentModuleNode>();
|
|
144
|
+
const visit = (node: EnvironmentModuleNode): void => {
|
|
145
|
+
for (const next of node.importedModules) {
|
|
146
|
+
if (found.has(next) || !gameFile(root, next.file)) continue;
|
|
147
|
+
found.add(next);
|
|
148
|
+
visit(next);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const start = graph.getModuleById(id);
|
|
152
|
+
if (start) visit(start);
|
|
153
|
+
return found;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function stdScenePlugin(services: Pick<ProjectServingServices, 'currentProjectRoot'>): Plugin {
|
|
157
|
+
const stdGame = (): string | null => {
|
|
158
|
+
const root = services.currentProjectRoot();
|
|
159
|
+
return root && existsSync(join(root, 'std.json')) ? root : null;
|
|
160
|
+
};
|
|
161
|
+
/** Each level's latest Scene module, by its level: its id and the files its source was written from. */
|
|
162
|
+
const scenes = new Map<string, { readonly id: string; readonly read: ReadonlySet<string> }>();
|
|
163
|
+
/** The game's content version, which every URL a game's module imports carries: a loader's cache keyed by the URL
|
|
164
|
+
* loads anew whatever a level's document depends on changed. */
|
|
165
|
+
let version = Date.now();
|
|
166
|
+
return {
|
|
167
|
+
name: 'volter-play-std-scene',
|
|
168
|
+
enforce: 'pre',
|
|
169
|
+
// (a std game's level documents and its levels' modules prebundled as the session starts: found on the view's first open,
|
|
170
|
+
// Vite would optimize them again then, and the editor's page, which does not reload for it, would hold two copies of
|
|
171
|
+
// React. The levels' files are crawled, and the runtime's own packages are included where the game has them)
|
|
172
|
+
config() {
|
|
173
|
+
const root = stdGame();
|
|
174
|
+
if (root === null) return undefined;
|
|
175
|
+
const include = RUNTIME_PACKAGES.filter((name) => installedFolder(root, name) !== null);
|
|
176
|
+
return { optimizeDeps: { entries: ['levels/**/*.{ts,tsx}'], include } };
|
|
177
|
+
},
|
|
178
|
+
async resolveId(source, importer) {
|
|
179
|
+
const ours = importer?.startsWith(VIRTUAL) === true || importer === RUNTIME_ID;
|
|
180
|
+
if (source === RUNTIME && ours) return RUNTIME_ID;
|
|
181
|
+
const through = THROUGH.get(source);
|
|
182
|
+
if (through !== undefined && ours) {
|
|
183
|
+
// (from the owner's installed folder, not the module the owner resolves to: that can be Vite's prebundled copy,
|
|
184
|
+
// from whose folder the owner's own dependencies don't resolve)
|
|
185
|
+
const root = stdGame();
|
|
186
|
+
const owner = root === null ? null : installedFolder(root, through);
|
|
187
|
+
if (!owner) throw new Error(`${through} is not installed for the game's folder, so its ${source} is not at hand.`);
|
|
188
|
+
return this.resolve(source, join(owner, 'package.json'), { skipSelf: true });
|
|
189
|
+
}
|
|
190
|
+
if (!source.startsWith(PREFIX)) return null;
|
|
191
|
+
const [path = '', query = ''] = source.slice(PREFIX.length).split('?');
|
|
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).`);
|
|
195
|
+
// (each of the view's imports is a module of its own, written from the level's files as they are then)
|
|
196
|
+
return `${VIRTUAL}${level}?${query}`;
|
|
197
|
+
},
|
|
198
|
+
load(id) {
|
|
199
|
+
if (id === RUNTIME_ID) return readFileSync(RUNTIME_FILE, 'utf8');
|
|
200
|
+
if (id.startsWith(VIRTUAL)) {
|
|
201
|
+
const root = stdGame();
|
|
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(/\?.*$/, '');
|
|
204
|
+
const read = new Set<string>();
|
|
205
|
+
scenes.set(level, { id, read });
|
|
206
|
+
return CODE_LEVEL.test(level) ? codeSceneModule(root, level, read) : sceneModule(root, level, read, version);
|
|
207
|
+
}
|
|
208
|
+
const [file = id, query] = id.split('?');
|
|
209
|
+
const root = query === 'url' ? stdGame() : null;
|
|
210
|
+
if (!root || !gameFile(root, file)) return null;
|
|
211
|
+
return `export default ${JSON.stringify(fileUrl(file, version))};`;
|
|
212
|
+
},
|
|
213
|
+
// (a level's document depends on its module's whole import closure among the game's files, the modules whose import failed
|
|
214
|
+
// to resolve within it and the files its source was written from. Any file of those written, made or removed bumps
|
|
215
|
+
// the content version, invalidates the closure, so each of its modules and URLs is served anew, and is announced on
|
|
216
|
+
// the session's change channel naming the levels it reached)
|
|
217
|
+
hotUpdate({ file, type, modules, server, timestamp }) {
|
|
218
|
+
if (this.environment.name !== 'client') return;
|
|
219
|
+
const root = stdGame();
|
|
220
|
+
if (!root) return;
|
|
221
|
+
const graph = this.environment.moduleGraph;
|
|
222
|
+
const failed = new Set(type === 'create' ? modules : []);
|
|
223
|
+
const reached = [...scenes].map(([blend, scene]) => ({ blend, scene, closure: importClosure(graph, root, scene.id) }))
|
|
224
|
+
.filter(({ scene, closure }) => {
|
|
225
|
+
const start = graph.getModuleById(scene.id);
|
|
226
|
+
return scene.read.has(file) || (start !== undefined && failed.has(start))
|
|
227
|
+
|| [...closure].some((node) => node.file === file || failed.has(node));
|
|
228
|
+
});
|
|
229
|
+
if (reached.length === 0) return;
|
|
230
|
+
version += 1;
|
|
231
|
+
const seen = new Set<EnvironmentModuleNode>();
|
|
232
|
+
for (const { closure } of reached) for (const node of closure) graph.invalidateModule(node, seen, timestamp, true);
|
|
233
|
+
server.ws.send({
|
|
234
|
+
type: 'custom',
|
|
235
|
+
event: 'volter:restart-required',
|
|
236
|
+
data: { file, path: relative(root, file).replaceAll('\\', '/'), type, affected: reached.map(({ blend }) => blend) },
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
4
|
+
*/
|
|
5
|
+
import type { BlenderPageApi } from '@volter/editor-blender/api';
|
|
6
|
+
|
|
7
|
+
let api: BlenderPageApi | null = null;
|
|
8
|
+
|
|
9
|
+
/** Kept by play's `activate`, and released (`null`) when it deactivates. */
|
|
10
|
+
export function keepEditorBlender(next: BlenderPageApi | null): void {
|
|
11
|
+
api = next;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** editor-blender's API. play's modules run once its page half has activated, which is after editor-blender's. */
|
|
15
|
+
export function editorBlender(): BlenderPageApi {
|
|
16
|
+
if (!api) throw new Error("@volter/play is not active, so editor-blender's API is not at hand.");
|
|
17
|
+
return api;
|
|
18
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
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 in code is drawn in (`../contributions/std-level.document.tsx`). play reads it here rather than importing
|
|
4
|
+
* editor-threejs's modules: its API is the one instance editor-threejs's own `activate` returned.
|
|
5
|
+
*/
|
|
6
|
+
import type { ThreePageApi } from '@volter/editor-threejs/api';
|
|
7
|
+
|
|
8
|
+
let api: ThreePageApi | null = null;
|
|
9
|
+
|
|
10
|
+
/** Kept by play's `activate`, and released (`null`) when it deactivates. */
|
|
11
|
+
export function keepEditorThreejs(next: ThreePageApi | null): void {
|
|
12
|
+
api = next;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** editor-threejs's API. play's modules run once its page half has activated, which is after editor-threejs's. */
|
|
16
|
+
export function editorThreejs(): ThreePageApi {
|
|
17
|
+
if (!api) throw new Error("@volter/play is not active, so editor-threejs's API is not at hand.");
|
|
18
|
+
return api;
|
|
19
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GAME'S CONTROLS, which the Game panel draws (`../contributions/game-panel.tsx`) and its verbs run behind command
|
|
3
|
+
* ids (`volter.play.<verb>`): Play and Stop, pause, step, speed, Restart and Autoplay, each a call on the run
|
|
4
|
+
* (`game-run.ts`) or std's own control said to the game's pages (`std-game.ts`, `std-controls.ts`).
|
|
5
|
+
*
|
|
6
|
+
* PLAY SAVES THE EDIT FIRST: Play (and Restart) save the `.blend` Blender holds where it changed (a level's or a
|
|
7
|
+
* prefab's, whichever was edited last), which runs std's level export (`std-level.ts`), and the run's pages load once
|
|
8
|
+
* that export has run, so the game plays the current edit (`savedEdit`). A save that fails is said in the play log, and
|
|
9
|
+
* the game plays the level as last exported.
|
|
10
|
+
*
|
|
11
|
+
* AUTOPLAY IS THE EDITOR'S SWITCH, and the person always wins: std's own play of one of the game's goals, recorded and
|
|
12
|
+
* checked, on the player in view while the Game view is on screen and out of view otherwise (`std-game.ts`'s
|
|
13
|
+
* `setStdAutoplay`); a person's own press in the player's page takes it back, which ends the play. Stopped, it is
|
|
14
|
+
* armed for the next start.
|
|
15
|
+
*/
|
|
16
|
+
import { armGameAutoplay, gameArm, gamePlaying, restartGame, setGamePlaying, subscribeGameClock } from './game-run';
|
|
17
|
+
import { setStdAutoplay, setStdPaused, setStdSpeed, stdAutoplay, stdRun, stepStd, subscribeStdRun } from './std-game';
|
|
18
|
+
|
|
19
|
+
/** Autoplay as the panel reads it: on or off, whether it can be switched on and why not, armed
|
|
20
|
+
* while stopped, the goals the game's bot offers and the one it plays, and how std's own play of a goal stands
|
|
21
|
+
* (`<goal>: playing`, `achieved`, or `failed — why`, `headless ` before it where it plays out of view). */
|
|
22
|
+
export interface GameAutoplay {
|
|
23
|
+
/** Whether std's play of a goal plays now, wherever it plays (Off ends it). */
|
|
24
|
+
readonly on: boolean;
|
|
25
|
+
/** Whether that play drives the player in view, on its page. */
|
|
26
|
+
readonly driving?: boolean;
|
|
27
|
+
readonly available: boolean;
|
|
28
|
+
readonly unavailable?: string | null;
|
|
29
|
+
readonly armed?: boolean;
|
|
30
|
+
readonly behaviors?: readonly string[];
|
|
31
|
+
readonly behavior?: string | null;
|
|
32
|
+
readonly state?: string | null;
|
|
33
|
+
/** std's play's report (`build/runs/<run>/report-<n>.json`), once std has written it. */
|
|
34
|
+
readonly report?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The save Play runs first (play's page half keeps it, `play.page.tsx`), and the last one Play asked for. */
|
|
38
|
+
let saveEdit: (() => Promise<string | null>) | null = null;
|
|
39
|
+
let saving: Promise<string | null> = Promise.resolve(null);
|
|
40
|
+
|
|
41
|
+
/** Kept by play's page half while it is active: what saves the edit Blender holds, answering why it failed, if it
|
|
42
|
+
* did. */
|
|
43
|
+
export function keepEditSave(save: (() => Promise<string | null>) | null): void {
|
|
44
|
+
saveEdit = save;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The save the run's start waits for, answering why it failed (null: saved, or nothing to save). */
|
|
48
|
+
export const savedEdit = (): Promise<string | null> => saving;
|
|
49
|
+
|
|
50
|
+
function save(): void {
|
|
51
|
+
saving = saveEdit?.() ?? Promise.resolve(null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Play (the edit saved first) or Stop. */
|
|
55
|
+
export function playGame(on: boolean): void {
|
|
56
|
+
if (on && !gamePlaying()) save();
|
|
57
|
+
setGamePlaying(on);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Restart: the edit saved, every player's page loaded afresh; a game not playing simply starts. */
|
|
61
|
+
export function restartPlay(): void {
|
|
62
|
+
if (!gamePlaying()) { playGame(true); return; }
|
|
63
|
+
save();
|
|
64
|
+
restartGame();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const pauseGame = (paused: boolean): Promise<void> => setStdPaused(paused).then(() => {});
|
|
68
|
+
export const stepGame = (frames = 1): Promise<void> => stepStd(frames).then(() => {});
|
|
69
|
+
export const setGameSpeedOf = (speed: number): Promise<void> => setStdSpeed(speed);
|
|
70
|
+
|
|
71
|
+
/** Every change of the clock or the run, which Autoplay's state follows. */
|
|
72
|
+
export function subscribeGameControls(listener: () => void): () => void {
|
|
73
|
+
const stopClock = subscribeGameClock(listener), stopRun = subscribeStdRun(listener);
|
|
74
|
+
return () => { stopClock(); stopRun(); };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The last autoplay state read, and the state it was read over: one snapshot until it changes. */
|
|
78
|
+
let snapshot: { readonly key: string; readonly value: GameAutoplay } | null = null;
|
|
79
|
+
|
|
80
|
+
export function gameAutoplay(): GameAutoplay {
|
|
81
|
+
const arm = gameArm();
|
|
82
|
+
const running = stdRun() !== null;
|
|
83
|
+
const { on, driving, goal, goals, unavailable, played } = stdAutoplay();
|
|
84
|
+
const value: GameAutoplay = {
|
|
85
|
+
on: running && on,
|
|
86
|
+
driving: running && driving,
|
|
87
|
+
available: running ? on || unavailable === null : arm === null && goals.length > 0,
|
|
88
|
+
unavailable: running && !on ? unavailable : null,
|
|
89
|
+
armed: arm !== null,
|
|
90
|
+
behaviors: goals,
|
|
91
|
+
behavior: arm !== null ? arm.goal ?? goal : goal,
|
|
92
|
+
state: played === null ? null
|
|
93
|
+
: `${played.device === 'headless' ? 'headless ' : ''}${played.goal}: ${played.state === 'failed' ? `failed — ${played.why ?? 'std did not say why'}` : played.state}`
|
|
94
|
+
+ (played.checks === null ? '' : ` · ${played.checks}`),
|
|
95
|
+
report: played?.report ?? null,
|
|
96
|
+
};
|
|
97
|
+
const key = JSON.stringify(value);
|
|
98
|
+
if (snapshot?.key !== key) snapshot = { key, value };
|
|
99
|
+
return snapshot.value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Switch the bot on (the game playing) or off, with `goal` (the game's own when absent). */
|
|
103
|
+
export function setGameAutoplay(on: boolean, by: 'panel' | 'cli', goal?: string): Promise<void> {
|
|
104
|
+
if (!stdRun()) throw new Error('Nothing is playing; `play` starts it, then `play autoplay on`.');
|
|
105
|
+
return setStdAutoplay(on, by, goal);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Arm (or disarm) Autoplay for the next start, while stopped. */
|
|
109
|
+
export function armAutoplay(armed: boolean, goal?: string): void {
|
|
110
|
+
armGameAutoplay(armed, goal ?? null);
|
|
111
|
+
}
|