@volter/play 0.5.210 → 0.5.212

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.
@@ -0,0 +1,213 @@
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
+ * <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
6
+ * 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),
8
+ * `canvas` (its `<Canvas>` settings), `gradingExtra` (whether its grading holds effects beyond its view transform, which
9
+ * the view does not draw), `mountScene` and `viewTransform` (the runtime's, for this level). Only the level's names are
10
+ * written here; the grading's shaper and LUT are read from the grading module std's writer makes (std-dev's
11
+ * level-names.ts `writeGrading`).
12
+ *
13
+ * WHAT A VIEW DEPENDS ON is its module's whole import closure among the game's files (Vite's module graph: the level's
14
+ * components, its prefabs', each URL import, wherever they are), the modules whose import failed to resolve within it,
15
+ * and the files its source was written from (the grading's LUT among them). A file of those written, made or removed
16
+ * bumps the game's content version, invalidates the closure and is announced on the session's change channel
17
+ * (`volter:restart-required`) naming the levels it reached, which their views draw again. Each URL this module writes
18
+ * (a URL import's, the LUT's) names its file itself (`/@fs/<file>`, which only Vite answers, so the file served is the
19
+ * file imported) and carries the content version (`?volter-std-scene=`), so a new version is a new URL to the loaders'
20
+ * 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
+ * change on a page reload (`docs/adr/std-scene-view.md`, decision 5).
22
+ */
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { dirname, join, relative, sep } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { installedPackageDir } from '@volter/live/installed-package';
27
+ import { fsFileUrl } from '@volter/sdk/project-module-url';
28
+ import type { ProjectServingServices } from '@volter/sdk/session/project-serving';
29
+ import { normalizePath, type EnvironmentModuleGraph, type EnvironmentModuleNode, type Plugin } from 'vite';
30
+ import { camel, levelFiles, pascal } from '../src/std-level-files';
31
+
32
+ const PREFIX = '/@volter-std-scene/';
33
+ const VIRTUAL = '\0volter-std-scene:';
34
+ /** The Scene view's runtime (`./std-scene-runtime.js`), which a level's module imports by this name. */
35
+ const RUNTIME = 'volter-std-scene-runtime';
36
+ const RUNTIME_ID = '\0volter-std-scene-runtime';
37
+ /** The runtime's file, beside this module in play's `serving/` (this module runs from there or from `dist-node/`). */
38
+ const RUNTIME_FILE = join(dirname(fileURLToPath(import.meta.url)), '..', 'serving', 'std-scene-runtime.js');
39
+ const LEVEL = /^levels\/.+\.blend$/;
40
+ /** The query each URL the Scene module writes carries: the content version it was written at. */
41
+ 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
+ * imports, beside react, three and THROUGH's). */
44
+ const RUNTIME_PACKAGES: readonly string[] = ['@react-three/fiber', '@react-three/rapier'];
45
+
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). */
48
+ const THROUGH: ReadonlyMap<string, string> = new Map([['postprocessing', '@volter-ai/std/level/client']]);
49
+
50
+ /** The folder the package `name` names (a package, or a subpath of one) is installed in, as Node finds it from `root`. */
51
+ const installedFolder = (root: string, name: string): string | null =>
52
+ installedPackageDir(root, name.split('/').slice(0, name.startsWith('@') ? 2 : 1).join('/'));
53
+
54
+ /** The URL that serves `file` itself (`/@fs/`, which Vite alone answers), at the content version `version`. */
55
+ const fileUrl = (file: string, version: number): string => `${fsFileUrl(file)}?${MARK}=${version}`;
56
+
57
+ /** The view transform a level's grading module renders: its LUT file beside it and its shaper's range and exposure. */
58
+ interface ViewGrading {
59
+ readonly lut: string;
60
+ readonly min: number;
61
+ readonly max: number;
62
+ readonly exposure: number;
63
+ }
64
+
65
+ /** The module's source for the level whose `.blend` is `blend` (from the game's folder); each file it looks at, present
66
+ * or not, goes into `read`. */
67
+ function sceneModule(root: string, blend: string, read: Set<string>, version: number): string {
68
+ const files = levelFiles(blend);
69
+ const { base, dir } = files;
70
+ const level = `${pascal(base)}Level`, environment = `${pascal(base)}Environment`, canvas = `${camel(base)}Canvas`;
71
+ const exists = (at: string): boolean => {
72
+ read.add(normalizePath(join(root, at)));
73
+ return existsSync(join(root, at));
74
+ };
75
+ if (!exists(files.component) || !exists(files.canvas)) {
76
+ throw new Error(`${blend} has no exported level beside it (${files.component} and ${files.canvas}): save it, and std's level export writes them.`);
77
+ }
78
+ const hasEnvironment = exists(files.environment);
79
+ const source = exists(files.grading) ? readFileSync(join(root, files.grading), 'utf8') : null;
80
+ const shaper = source === null ? null : /<ViewShaper min=\{(-?[\d.]+)\} max=\{(-?[\d.]+)\}(?: exposure=\{(-?[\d.]+)\})? \/>/.exec(source);
81
+ const lut = source === null ? null : /import viewLut from '\.\/(.+?)\?url';/.exec(source);
82
+ const grading: ViewGrading | null = shaper !== null && lut !== null
83
+ ? { lut: join(root, dir, lut[1]!), min: Number(shaper[1]), max: Number(shaper[2]), exposure: Number(shaper[3] ?? 0) } : null;
84
+ // (the effects the composer holds beyond the shaper and the LUT: a converted level's contrast or saturation)
85
+ if (grading !== null) read.add(normalizePath(grading.lut));
86
+ const gradingExtra = source !== null && [...source.matchAll(/^\s+<([A-Z]\w*)\b/gm)]
87
+ .some((found) => found[1] !== 'EffectComposer' && found[1] !== 'ViewShaper' && found[1] !== 'LUT');
88
+ return [
89
+ `import { mountScene as mountLevel, viewTransform as levelTransform } from ${JSON.stringify(RUNTIME)};`,
90
+ `import { ${level} } from ${JSON.stringify(`/${files.component}`)};`,
91
+ ...(hasEnvironment ? [`import { ${environment} } from ${JSON.stringify(`/${files.environment}`)};`] : []),
92
+ `import { ${canvas} } from ${JSON.stringify(`/${files.canvas}`)};`,
93
+ // (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
94
+ // is its text as a module)
95
+ ...(grading === null ? [] : [`const viewLut = ${JSON.stringify(fileUrl(grading.lut, version))};`]),
96
+ `export const component = ${JSON.stringify(files.component)};`,
97
+ `export const canvas = ${canvas};`,
98
+ `export const gradingExtra = ${String(gradingExtra)};`,
99
+ `const level = { Level: ${level}, Environment: ${hasEnvironment ? environment : 'null'}, canvas: ${canvas} };`,
100
+ 'export const mountScene = (stage) => mountLevel(level, stage);',
101
+ grading === null
102
+ ? 'export const viewTransform = async () => null;'
103
+ : `export const viewTransform = () => levelTransform({ lut: viewLut, min: ${grading.min}, max: ${grading.max}, exposure: ${grading.exposure} });`,
104
+ '',
105
+ ].join('\n');
106
+ }
107
+
108
+ /** Whether a module's file is the game's own (in its folder, not a dependency's). */
109
+ function gameFile(root: string, file: string | null): file is string {
110
+ if (file === null) return false;
111
+ const at = relative(root, file);
112
+ return !at.startsWith('..') && !at.split(sep).includes('node_modules');
113
+ }
114
+
115
+ /** The game's modules a module imports, and theirs, all the way down: a level's components, its prefabs', each URL import. */
116
+ function importClosure(graph: EnvironmentModuleGraph, root: string, id: string): Set<EnvironmentModuleNode> {
117
+ const found = new Set<EnvironmentModuleNode>();
118
+ const visit = (node: EnvironmentModuleNode): void => {
119
+ for (const next of node.importedModules) {
120
+ if (found.has(next) || !gameFile(root, next.file)) continue;
121
+ found.add(next);
122
+ visit(next);
123
+ }
124
+ };
125
+ const start = graph.getModuleById(id);
126
+ if (start) visit(start);
127
+ return found;
128
+ }
129
+
130
+ export function stdScenePlugin(services: Pick<ProjectServingServices, 'currentProjectRoot'>): Plugin {
131
+ const stdGame = (): string | null => {
132
+ const root = services.currentProjectRoot();
133
+ return root && existsSync(join(root, 'std.json')) ? root : null;
134
+ };
135
+ /** Each level's latest Scene module, by its `.blend`: its id and the files its source was written from. */
136
+ const scenes = new Map<string, { readonly id: string; readonly read: ReadonlySet<string> }>();
137
+ /** 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. */
139
+ let version = Date.now();
140
+ return {
141
+ name: 'volter-play-std-scene',
142
+ 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,
144
+ // 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)
146
+ config() {
147
+ const root = stdGame();
148
+ if (root === null) return undefined;
149
+ const include = RUNTIME_PACKAGES.filter((name) => installedFolder(root, name) !== null);
150
+ return { optimizeDeps: { entries: ['levels/**/*.{ts,tsx}'], include } };
151
+ },
152
+ async resolveId(source, importer) {
153
+ const ours = importer?.startsWith(VIRTUAL) === true || importer === RUNTIME_ID;
154
+ if (source === RUNTIME && ours) return RUNTIME_ID;
155
+ const through = THROUGH.get(source);
156
+ if (through !== undefined && ours) {
157
+ // (from the owner's installed folder, not the module the owner resolves to: that can be Vite's prebundled copy,
158
+ // from whose folder the owner's own dependencies don't resolve)
159
+ const root = stdGame();
160
+ const owner = root === null ? null : installedFolder(root, through);
161
+ if (!owner) throw new Error(`${through} is not installed for the game's folder, so its ${source} is not at hand.`);
162
+ return this.resolve(source, join(owner, 'package.json'), { skipSelf: true });
163
+ }
164
+ if (!source.startsWith(PREFIX)) return null;
165
+ 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).`);
168
+ // (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}`;
170
+ },
171
+ load(id) {
172
+ if (id === RUNTIME_ID) return readFileSync(RUNTIME_FILE, 'utf8');
173
+ if (id.startsWith(VIRTUAL)) {
174
+ 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(/\?.*$/, '');
177
+ const read = new Set<string>();
178
+ scenes.set(blend, { id, read });
179
+ return sceneModule(root, blend, read, version);
180
+ }
181
+ const [file = id, query] = id.split('?');
182
+ const root = query === 'url' ? stdGame() : null;
183
+ if (!root || !gameFile(root, file)) return null;
184
+ return `export default ${JSON.stringify(fileUrl(file, version))};`;
185
+ },
186
+ // (a Scene view depends on its module's whole import closure among the game's files, the modules whose import failed
187
+ // to resolve within it and the files its source was written from. Any file of those written, made or removed bumps
188
+ // the content version, invalidates the closure, so each of its modules and URLs is served anew, and is announced on
189
+ // the session's change channel naming the levels it reached)
190
+ hotUpdate({ file, type, modules, server, timestamp }) {
191
+ if (this.environment.name !== 'client') return;
192
+ const root = stdGame();
193
+ if (!root) return;
194
+ const graph = this.environment.moduleGraph;
195
+ const failed = new Set(type === 'create' ? modules : []);
196
+ const reached = [...scenes].map(([blend, scene]) => ({ blend, scene, closure: importClosure(graph, root, scene.id) }))
197
+ .filter(({ scene, closure }) => {
198
+ const start = graph.getModuleById(scene.id);
199
+ return scene.read.has(file) || (start !== undefined && failed.has(start))
200
+ || [...closure].some((node) => node.file === file || failed.has(node));
201
+ });
202
+ if (reached.length === 0) return;
203
+ version += 1;
204
+ const seen = new Set<EnvironmentModuleNode>();
205
+ for (const { closure } of reached) for (const node of closure) graph.invalidateModule(node, seen, timestamp, true);
206
+ server.ws.send({
207
+ type: 'custom',
208
+ event: 'volter:restart-required',
209
+ data: { file, path: relative(root, file).replaceAll('\\', '/'), type, affected: reached.map(({ blend }) => blend) },
210
+ });
211
+ },
212
+ };
213
+ }
@@ -0,0 +1,33 @@
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.
5
+ */
6
+ import type { BlenderPageApi, DocumentPlayExtension } from '@volter/editor-blender/api';
7
+
8
+ let api: BlenderPageApi | null = null;
9
+ let modelPlay: DocumentPlayExtension | null = null;
10
+ const listeners = new Set<() => void>();
11
+
12
+ /** Kept by play's `activate`, and released (`null`) when it deactivates. */
13
+ export function keepEditorBlender(next: BlenderPageApi | null, registered: DocumentPlayExtension | null): void {
14
+ api = next;
15
+ modelPlay = registered;
16
+ for (const listener of listeners) listener();
17
+ }
18
+
19
+ /** editor-blender's API. play's modules run once its page half has activated, which is after editor-blender's. */
20
+ export function editorBlender(): BlenderPageApi {
21
+ if (!api) throw new Error("@volter/play is not active, so editor-blender's API is not at hand.");
22
+ return api;
23
+ }
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
+ }
@@ -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's Scene view draws in (`../contributions/std-scene.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
+ }