@volter/play 0.5.212 → 0.5.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A STD GAME'S LEVELS AS ITS DOCUMENTS: each level component, `levels/<Name>Level.tsx`, is a document of its own
3
+ * (`std-level`, drawn by `std-level.document.tsx` as the game draws it), named as its component is less `Level`, as a level is the
4
+ * document an engine opens. Its source is its record: its own code, or the `levels/<name>.blend` std's export writes
5
+ * it from, as a model's `.blend` is the source of the component the game places. The project opens on its main level.
6
+ */
7
+ import { z } from 'zod';
8
+ import { CODE_LEVEL, sceneLevelOf } from '../src/std-level-files';
9
+
10
+ export const finder = {
11
+ name: 'stdLevelDocuments',
12
+ // (paths only: whether a component is a Blender level's is read from the files beside it)
13
+ reads: 'files',
14
+ schema: z
15
+ .object({
16
+ finder: z.literal('stdLevelDocuments'),
17
+ include: z
18
+ .array(z.string().min(1))
19
+ .min(1)
20
+ .describe("Project-relative globs of the levels' files (`levels/**/*.tsx`, and `levels/**/*.blend` to know which components a Blender level writes)"),
21
+ })
22
+ .strict(),
23
+ run(_selection: { finder: 'stdLevelDocuments'; include: readonly string[] }, input: { files?: readonly string[] }) {
24
+ const files = input.files ?? [];
25
+ const entries = files
26
+ .filter((path) => CODE_LEVEL.test(path))
27
+ .map((path) => ({
28
+ id: `level:${path}`,
29
+ // (named as an engine names its levels: `MainLevel` is Main)
30
+ label: CODE_LEVEL.exec(path)?.[1]?.replace(/Level$/, '') ?? path,
31
+ kind: 'std-level',
32
+ region: null,
33
+ authorable: false,
34
+ reach: { kind: 'root-mount' as const },
35
+ // (the level's source: a Blender level's `.blend`, else its own code)
36
+ source: { path: sceneLevelOf(path, files) ?? path },
37
+ }));
38
+ // (the project opens on its level, as an engine opens its main level: the game's Main, else the level last worked
39
+ // on, the files coming newest first)
40
+ const opened = entries.find((entry) => entry.label === 'Main') ?? entries[0];
41
+ return { entries, notes: [] as string[], ...(opened === undefined ? {} : { default: opened.id }) };
42
+ },
43
+ } as const;
@@ -1,210 +1,92 @@
1
1
  /**
2
- * PLAY'S PAGE HALF (`contributes.volter.page`, `docs/adr/plugin-apis.md`): the model document's Play tool, registered
3
- * through editor-blender's API (`registerModelPlay`), which play declares in `extensionDependencies`: every Play runs in
4
- * the Blender model document's stage, and plays the project's volter-ai/std game, its own dev pages each a player's
5
- * (`../src/std-game.ts`).
2
+ * PLAY'S PAGE HALF (`contributes.volter.page`, `docs/adr/plugin-apis.md`): Play, the project's. It registers its Game
3
+ * panel through editor-blender's API (`registerPlay`, whose bottom area shows it), which play declares in
4
+ * `extensionDependencies`, and keeps the project's run (`../src/game-view.tsx`): Play runs the project's volter-ai/std
5
+ * game whatever document is in front, its own dev pages each a player's (`../src/std-game.ts`), in the Game view.
6
6
  */
7
- import { useSyncExternalStore } from 'react';
8
- import { Button, EditorIcon, editorIcons, MenuItem } from '@volter/sdk/widgets';
9
- import type { BlenderPageApi, DocumentPlayControlProps, DocumentPlayExtension } from '@volter/editor-blender/api';
7
+ import type { BlenderPageApi, PlayTool } from '@volter/editor-blender/api';
10
8
  import { registerVerbCommands, registerViewCommands } from '@volter/sdk/commands';
11
9
  import type { PluginContext } from '@volter/sdk/plugins';
12
- import {
13
- armModelPlayAutoplay,
14
- MODEL_PLAY_SPEEDS,
15
- modelPlayArm,
16
- modelPlayClock,
17
- modelPlayGeneration,
18
- modelPlaying,
19
- restartModelPlay,
20
- setModelPlayFailure,
21
- setModelPlaying,
22
- subscribeModelPlay,
23
- subscribeModelPlayClock,
24
- } from '../src/model-play';
25
- import {
26
- isStdGame,
27
- runStdGame,
28
- stdGameNow,
29
- setStdAutoplay,
30
- setStdPaused,
31
- setStdSpeed,
32
- setStdView,
33
- STD_DEV,
34
- stdAutoplay,
35
- stdRun,
36
- stepStd,
37
- subscribeStdGame,
38
- subscribeStdRun,
39
- } from '../src/std-game';
40
- import { beginModelPlayLog } from '../src/play-log';
41
10
  import { onProjectChange } from '@volter/sdk/active-project';
42
11
  import { editorHost } from '@volter/sdk/host';
12
+ import type { ThreePageApi } from '@volter/editor-threejs/api';
13
+ import { setGameFailure, subscribeGame } from '../src/game-run';
14
+ import { keepEditSave, savedEdit } from '../src/game-controls';
15
+ import { type GameRunner, keepGameRuns } from '../src/game-view';
16
+ import { beginPlayLog } from '../src/play-log';
17
+ import { isStdGame, runStdGame, STD_DEV, stdGameNow, subscribeStdGame } from '../src/std-game';
43
18
  import { keepEditorBlender } from '../src/editor-blender';
44
19
  import { keepEditorThreejs } from '../src/editor-threejs';
45
- import type { ThreePageApi } from '@volter/editor-threejs/api';
46
20
  import { startStdLevels, stdLevelsSettled } from '../src/std-level';
47
- import { followSceneLevel, openStdScene } from '../src/std-scene-level';
48
- import { GamePanel, MODEL_PLAY_VIEW_VERBS } from './game-panel';
49
- import { modelPlayVerbs } from './model-play.verbs';
50
-
51
- function usePlaying(documentId: string | undefined): boolean {
52
- return useSyncExternalStore(subscribeModelPlay, () => documentId ? modelPlaying(documentId) : false, () => false);
53
- }
54
- // Kept because `DocumentPlayExtension` requires `Control` and `Menu` for a layout that draws Play in its header; Cyclotron's layout does not (its Play is the Game panel's).
55
- function Control({ documentId, onClose }: DocumentPlayControlProps) {
56
- const playing = usePlaying(documentId);
57
- return <Button size="compact" data-testid="model-play-button" aria-pressed={playing}
58
- disabled={!documentId} title={playing ? 'Stop playing (Escape)' : 'Play this level’s game'}
59
- onClick={() => { onClose(); if (documentId) setModelPlaying(documentId, !playing); }}>
60
- <EditorIcon icon={playing ? editorIcons.transport.stop : editorIcons.transport.play} />
61
- {playing ? 'Stop' : 'Play'}
62
- </Button>;
63
- }
64
- function Menu({ documentId, onClose }: DocumentPlayControlProps) {
65
- const playing = usePlaying(documentId);
66
- return <MenuItem data-testid="model-play" disabled={!documentId}
67
- onSelect={() => { onClose(); if (documentId) setModelPlaying(documentId, !playing); }}>
68
- {playing ? 'Stop' : 'Play'}
69
- </MenuItem>;
70
- }
71
-
72
- /** The last autoplay state read for a std game's run, and the state it was read over. */
73
- let autoplaySnapshot: { readonly key: string; readonly value: ReturnType<NonNullable<NonNullable<DocumentPlayExtension['transport']>['autoplay']>> } | null = null;
74
- /** A model document's level (`document:model:levels/arena.blend` is `levels/arena.blend`). */
75
- const levelOfDocument = (documentId: string): string => documentId.replace(/^document:model:/, '');
21
+ import { GamePanel, PLAY_VIEW_VERBS } from './game-panel';
22
+ import { playVerbs } from './play.verbs';
76
23
 
77
24
  const NOT_STD = 'This project is not a std game: it has no std.json, or no @volter-ai/std-dev among its packages (`cyclotron add-play` makes it one).';
78
25
 
79
- function hasScript(): boolean | null {
80
- return isStdGame() ?? null;
81
- }
82
-
83
26
  const EDITOR_BLENDER = 'volter.@volter/editor-blender';
84
27
  const EDITOR_THREEJS = 'volter.@volter/editor-threejs';
85
28
 
29
+ /** The game run in the Game view's stage, once the edit Play saved first has saved (and exported). */
30
+ const runGame: GameRunner = (stage) => {
31
+ const failed = (title: string, failure: string) => {
32
+ const run = beginPlayLog(STD_DEV);
33
+ run.append('play', 'script-error', { phase: 'stage', message: `${title}: ${failure}` });
34
+ run.end({ reason: 'stage-failed' });
35
+ setGameFailure(`${title}: ${failure}`);
36
+ stage.report(title, failure);
37
+ };
38
+ const saved = savedEdit().then((failure) => {
39
+ if (failure !== null) editorHost().console.warn(`Play's save failed, so the game plays its last export: ${failure}`, 'play');
40
+ });
41
+ // (asked now: a project add-play has just made a std game plays without a reload)
42
+ let stopped = false, stop: (() => void) | null = null;
43
+ void stdGameNow().then((std) => {
44
+ if (stopped) return;
45
+ if (std) stop = runStdGame(stage, failed, saved);
46
+ else failed('The game could not be started', NOT_STD);
47
+ });
48
+ return () => { stopped = true; stop?.(); };
49
+ };
50
+
86
51
  export function activate(context: PluginContext) {
87
52
  // Declared in `extensionDependencies`, so it is active before this runs.
88
53
  const blender = context.extensions.getExtension<BlenderPageApi>(EDITOR_BLENDER)?.exports;
89
54
  if (!blender) throw new Error(`${EDITOR_BLENDER} answered no API.`);
90
- /**
91
- * PLAY SAVES THE LEVEL FIRST: Play (and Restart) save the open `.blend` where it changed, which runs std's level
92
- * export (`../src/std-level.ts`), and the run's pages load once that export has run, so the game plays the current
93
- * edit. A save that fails is said in the play log, and the game plays the level as last exported. Play on a prefab's
94
- * `.blend` plays the game, its levels as its client draws them, the prefab saved and exported first (the Game panel
95
- * says so).
96
- */
97
- const saves = new Map<string, Promise<string | null>>();
98
- const saveLevel = (documentId: string): void => {
99
- saves.set(documentId, blender.modelDocument.flush(levelOfDocument(documentId))
55
+ // (the edit Play saves first: the `.blend` Blender holds, whichever document is in front, and std's export of it)
56
+ keepEditSave(() => {
57
+ const held = blender.modelDocument.held();
58
+ return (held === null ? Promise.resolve() : blender.modelDocument.flush(held))
100
59
  .then(() => stdLevelsSettled())
101
- .then(() => null, (error: unknown) => error instanceof Error ? error.message : String(error)));
102
- };
103
- const setPlaying = (documentId: string, value: boolean): void => {
104
- if (value && !modelPlaying(documentId)) saveLevel(documentId);
105
- setModelPlaying(documentId, value);
106
- };
107
- const modelPlay: DocumentPlayExtension = {
108
- // (the Game panel, which the Blender document's bottom area shows in Game mode, `game-panel.tsx`)
109
- Control, Menu, Panel: GamePanel, playing: modelPlaying, setPlaying,
110
- // (Escape opens Edit first, its views' way out; from Edit it stops)
111
- escape(documentId) {
112
- const std = stdRun(documentId);
113
- if (std && std.view !== 'edit') setStdView('edit');
114
- else setModelPlaying(documentId, false);
115
- },
60
+ .then(() => null, (error: unknown) => error instanceof Error ? error.message : String(error));
61
+ });
62
+ const tool: PlayTool = {
63
+ Panel: GamePanel,
64
+ hasGame: () => isStdGame() ?? null,
116
65
  subscribe(listener) {
117
- const stopPlay = subscribeModelPlay(listener), stopProject = onProjectChange(listener), stopStd = subscribeStdGame(listener);
118
- return () => { stopPlay(); stopProject(); stopStd(); };
119
- },
120
- // (the players' pages fit themselves to the stage, which the view bar's tiles and the document's own area share)
121
- aspectRatio: () => null,
122
- run(stage) {
123
- const failed = (title: string, failure: string) => {
124
- const run = beginModelPlayLog(stage.documentId, STD_DEV);
125
- run.append('play', 'script-error', { phase: 'stage', message: `${title}: ${failure}` });
126
- run.end({ reason: 'stage-failed' });
127
- setModelPlayFailure(stage.documentId, `${title}: ${failure}`);
128
- stage.report(title, failure);
129
- };
130
- const saved = (saves.get(stage.documentId) ?? Promise.resolve(null)).then((failure) => {
131
- saves.delete(stage.documentId);
132
- if (failure !== null) editorHost().console.warn(`Play's save of ${stage.sourcePath} failed, so the game plays its last export: ${failure}`, 'model-play');
133
- });
134
- // (asked now: a project add-play has just made a std game plays without a reload)
135
- let stopped = false, stop: (() => void) | null = null;
136
- void stdGameNow().then((std) => {
137
- if (stopped) return;
138
- if (std) stop = runStdGame(stage, failed, saved);
139
- else failed('The game could not be started', NOT_STD);
140
- });
141
- return () => { stopped = true; stop?.(); };
142
- },
143
- // THE RUN'S TRANSPORT (`model-play.ts`), each control std's own (`../src/std-controls.ts`).
144
- transport: {
145
- speeds: MODEL_PLAY_SPEEDS,
146
- clock: modelPlayClock,
147
- // (Autoplay is the viewed player's bot, announced with its run)
148
- subscribeClock(listener) {
149
- const stopClock = subscribeModelPlayClock(listener), stopStd = subscribeStdRun(listener);
150
- return () => { stopClock(); stopStd(); };
151
- },
152
- setPaused: (documentId, paused) => setStdPaused(documentId, paused).then(() => {}),
153
- step: (documentId, frames = 1) => stepStd(documentId, frames).then(() => {}),
154
- setSpeed: setStdSpeed,
155
- restart: (documentId) => {
156
- if (modelPlaying(documentId)) saveLevel(documentId);
157
- restartModelPlay(documentId);
158
- },
159
- generation: modelPlayGeneration,
160
- autoplay: (documentId) => {
161
- const arm = modelPlayArm(documentId);
162
- const running = stdRun(documentId) !== null;
163
- const { on, goal, goals, unavailable, headless } = stdAutoplay();
164
- const value = {
165
- on: running && on,
166
- available: running ? unavailable === null : arm === null && goals.length > 0,
167
- unavailable: running ? unavailable : null,
168
- by: null,
169
- armed: arm !== null,
170
- behaviors: goals,
171
- behavior: arm !== null ? arm.goal ?? goal : goal,
172
- state: headless === null ? null
173
- : `${headless.goal}: ${headless.state === 'failed' ? `failed — ${headless.why ?? 'std did not say why'}` : headless.state}`,
174
- };
175
- // (one snapshot until its state changes: the Game panel reads it as an external store)
176
- const key = JSON.stringify(value);
177
- if (autoplaySnapshot?.key !== key) autoplaySnapshot = { key, value };
178
- return autoplaySnapshot.value;
179
- },
180
- setAutoplay: (documentId, on, by, goal) => {
181
- if (!stdRun(documentId)) throw new Error(`Nothing is playing in ${documentId}; \`play\` starts it, then \`play autoplay on\`.`);
182
- return setStdAutoplay(on, by, goal);
183
- },
184
- armAutoplay: (documentId, armed, goal) => armModelPlayAutoplay(documentId, armed, goal ?? null),
66
+ const stopGame = subscribeGame(listener), stopProject = onProjectChange(listener), stopStd = subscribeStdGame(listener);
67
+ return () => { stopGame(); stopProject(); stopStd(); };
185
68
  },
186
- scriptPath: () => STD_DEV,
187
- hasScript,
188
69
  };
189
- keepEditorBlender(blender, modelPlay);
190
- // (a std level's Scene view draws in editor-threejs's viewport, `std-scene.document.tsx`; declared in `extensionDependencies`)
70
+ keepEditorBlender(blender);
71
+ // (a std level in code is drawn in editor-threejs's viewport, `std-level.document.tsx`; declared in `extensionDependencies`)
191
72
  const three = context.extensions.getExtension<ThreePageApi>(EDITOR_THREEJS)?.exports;
192
73
  if (!three) throw new Error(`${EDITOR_THREEJS} answered no API.`);
193
74
  keepEditorThreejs(three);
194
- const unregister = blender.registerModelPlay(modelPlay);
75
+ const unregister = blender.registerPlay(tool);
76
+ // (the project's run: Play opens the Game view and runs the game there, whatever document is in front)
77
+ const stopGameRuns = keepGameRuns(runGame);
195
78
  // A std game's levels exported on save, and its prefabs a linked asset library (`std-level.ts`).
196
79
  const stopStdLevels = startStdLevels(blender);
197
- // The Scene view draws the std level whose Blender document was active last (`std-scene-level.ts`).
198
- const stopSceneLevel = followSceneLevel();
199
80
  // The session verbs and the Game panel's verbs, as the Code-OSS commands package.json declares.
200
- registerVerbCommands(context, { ...modelPlayVerbs, 'std-scene': openStdScene });
201
- registerViewCommands(context, 'model-play', MODEL_PLAY_VIEW_VERBS);
81
+ registerVerbCommands(context, playVerbs);
82
+ registerViewCommands(context, 'play', PLAY_VIEW_VERBS);
202
83
  context.subscriptions.push({
203
84
  dispose() {
85
+ stopGameRuns();
204
86
  unregister();
205
87
  stopStdLevels();
206
- stopSceneLevel();
207
- keepEditorBlender(null, null);
88
+ keepEditSave(null);
89
+ keepEditorBlender(null);
208
90
  keepEditorThreejs(null);
209
91
  },
210
92
  });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * THE PLAY VERBS of the session wire (`@volter/sdk/commands`): each the Code-OSS command `volter.<verb>`, registered by
3
+ * play's page half (`play.page.tsx`), with its budget on its `contributes.commands` entry.
4
+ *
5
+ * `play-log`, the read behind `cyclotron play-log` and `editor.contributedVerb('play-log')` in `eval`, answers the game's
6
+ * play log (`../src/play-log.ts`) — the current run's, or the last one's after Stop — filtered by `since` (game seconds,
7
+ * inclusive) and `kind`. A read only: no play gate, since an empty log is the honest answer when nothing has played.
8
+ *
9
+ * `play`, the agent's tool for Play (declared in this package's `contributes.languageModelTools`), runs the Game panel's
10
+ * own view verbs, the ones `volter.play.<action>` runs (`game-panel.tsx`), so an agent drives the run the person sees
11
+ * and answers with the game's state. Its `play` and `restart` answer as `cyclotron play play` does: once a player's page
12
+ * has joined the run, or with why the game didn't start (`started`), never at "playing" alone.
13
+ */
14
+ import type { VerbHandler } from '@volter/sdk/commands';
15
+ import { executeCommand } from '@volter/sdk/editor-commands';
16
+ import { readPlayLog } from '../src/play-log';
17
+ import { gameClock, gamePlaying, subscribeGame, subscribeGameClock } from '../src/game-run';
18
+
19
+ const ACTIONS = ['state', 'play', 'stop', 'pause', 'resume', 'restart'] as const;
20
+
21
+ /** The actions that start a run, which answer once it runs. */
22
+ const STARTS: ReadonlySet<string> = new Set(['play', 'restart']);
23
+
24
+ /**
25
+ * THE RUN STARTED, OR WHY NOT: resolved once a player's page has joined it (`GameClock.running`), rejected with the
26
+ * run's failure or with Play switched off first, as the CLI's `play` reads the run. No deadline of its own: the run
27
+ * starts on drawn frames, which a hidden tab doesn't draw, so a healthy start can take as long as the tab stays hidden;
28
+ * a relayed call (`eval`, the limited view) ends at `volter.play`'s budget, its `timeoutMs`.
29
+ */
30
+ function started(): Promise<void> {
31
+ return new Promise((resolve, reject) => {
32
+ const stops: (() => void)[] = [];
33
+ const settle = (): void => {
34
+ const clock = gameClock();
35
+ const failure = clock.failure !== null && clock.failure !== '' ? clock.failure : null;
36
+ if (failure === null && gamePlaying() && !clock.running) return;
37
+ for (const stop of stops) stop();
38
+ if (failure !== null) reject(new Error(`Play did not start: ${failure}. The play log has the entry (play-log, kind script-error).`));
39
+ else if (!gamePlaying()) reject(new Error('Play switched itself off before a game started.'));
40
+ else resolve();
41
+ };
42
+ stops.push(subscribeGame(settle), subscribeGameClock(settle));
43
+ settle();
44
+ });
45
+ }
46
+
47
+ export const playVerbs: Readonly<Record<string, VerbHandler>> = {
48
+ play: async (command) => {
49
+ const action = command['action'];
50
+ if (typeof action !== 'string' || !(ACTIONS as readonly string[]).includes(action))
51
+ return { ok: false, error: `play's action is one of ${ACTIONS.join(', ')}; it was given ${JSON.stringify(action)}.` };
52
+ const answer = await executeCommand(`volter.play.${action}`, {});
53
+ if (!STARTS.has(action)) return { ok: true, data: { result: answer ?? null } };
54
+ try {
55
+ await started();
56
+ } catch (error) {
57
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
58
+ }
59
+ return { ok: true, data: { result: (await executeCommand('volter.play.state', {})) ?? null } };
60
+ },
61
+ 'play-log': (command) => {
62
+ const since = command['since'];
63
+ const kind = command['kind'];
64
+ if (since !== undefined && (typeof since !== 'number' || !Number.isFinite(since)))
65
+ return { ok: false, error: `play-log's since is game seconds, a number; it was given ${JSON.stringify(since)}.` };
66
+ if (kind !== undefined && typeof kind !== 'string')
67
+ return { ok: false, error: `play-log's kind is an entry kind, a string; it was given ${JSON.stringify(kind)}.` };
68
+ const reading = readPlayLog({
69
+ ...(since === undefined ? {} : { since }),
70
+ ...(kind === undefined ? {} : { kind }),
71
+ });
72
+ return { ok: true, data: { ...reading } };
73
+ },
74
+ };
@@ -1,8 +1,10 @@
1
1
  /**
2
- * A STD GAME'S SCENE VIEW (`docs/adr/std-scene-view.md`): the level whose Blender document was active last
3
- * (`../src/std-scene-level.ts`), drawn as the game draws it, by its own generated component and the game's own React Three
4
- * Fiber, react-three-rapier and std, in editor-threejs's viewport (`ThreePageApi`): orbit, pick, frame, the Hierarchy.
5
- * The level's module is `/@volter-std-scene/<levels/name.blend>` (`../serving/std-scene.ts`); its fiber root draws on
2
+ * A STD GAME'S LEVEL AS ITS DOCUMENT (`docs/adr/std-scene-view.md`): `levels/<Name>Level.tsx`, a document of the
3
+ * project's table (`levels.finder.ts`) whose source is its code or the `.blend` std's export writes it from, drawn as
4
+ * the game draws it, by its own component and the game's own React
5
+ * Three Fiber, react-three-rapier and std, in editor-threejs's viewport (`ThreePageApi`): orbit, pick, frame, the
6
+ * Hierarchy.
7
+ * The level's module is `/@volter-std-scene/<level>` (`../serving/std-scene.ts`); its fiber root draws on
6
8
  * the stage's own renderer and camera, the stage drawing the picture, the level's `useFrame`s run on the stage's frame,
7
9
  * and its environment is the document's World (editor-threejs's `documentWorld`). The level follows the stage the
8
10
  * document has now: an attachment the stage makes again (after a lost context) is bound anew.
@@ -14,8 +16,9 @@
14
16
  * - The overlays: colliders and sensors as react-three-rapier's debug lines of the paused world, and each std marker (an
15
17
  * object whose `userData.behaviours` names its behaviours: a spawn point, a seat, a prompt) as a marker and its name,
16
18
  * on the stage's helper layer (`std-markers`). The toolbar's Overlays shows or hides them.
17
- * - The `.blend` is the source of record: the view edits nothing. Each save exports the level again (`../src/std-level.ts`)
18
- * and the view draws it again as anything its module depends on changes, the camera kept.
19
+ * - The level's source is its record, a Blender level's `.blend` or a level's code: the view edits nothing. A Blender
20
+ * level's save exports it again (`../src/std-level.ts`), a code level's file is its own, and the view draws it again
21
+ * as anything its module depends on changes (a model a code level places among them), the camera kept.
19
22
  */
20
23
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
21
24
  import * as THREE from 'three';
@@ -25,10 +28,13 @@ import { projectModuleChangeMatches, subscribeProjectModuleChange } from '@volte
25
28
  import { beginLiveModuleRevision } from '@volter/sdk/project-module-url';
26
29
  import type { PresentationLayer } from '@volter/sdk/viewport-presentation';
27
30
  import { editorThreejs } from '../src/editor-threejs';
28
- import { sceneLevel, sceneLevelVersion, STD_SCENE_DOCUMENT, subscribeSceneLevel } from '../src/std-scene-level';
31
+ import { askPlay } from '../src/std-controls';
32
+ import { stdRun, stdRunVersion, subscribeStdRun } from '../src/std-game';
29
33
 
30
34
  export const point = 'workspace.document';
31
- export const title = 'Scene';
35
+ export const title = 'Level';
36
+ /** The editor of the table's `std-level` documents: a level in code (`levels.finder.ts`). */
37
+ export const documentKind = 'std-level';
32
38
 
33
39
  /** The level's module, as `../serving/std-scene.ts` writes it. */
34
40
  interface StdSceneModule {
@@ -48,6 +54,8 @@ interface StdSceneModule {
48
54
  interface MountedScene {
49
55
  draw(overlays: boolean): void;
50
56
  advance(seconds: number): void;
57
+ /** A change of the game's running scene, put on this copy of the level at its next frame ({@link useRunningScene}). */
58
+ running(read: SceneRead): void;
51
59
  unmount(): void;
52
60
  }
53
61
 
@@ -187,7 +195,7 @@ function bindStage(stage: ViewportStage, binding: StageBinding): () => void {
187
195
  const three = editorThreejs();
188
196
  const world = three.documentWorld(root);
189
197
  if (world === null) {
190
- binding.fail(new Error("The Scene view's root stands in no document scene of its stage."));
198
+ binding.fail(new Error("The level's root stands in no document scene of its stage."));
191
199
  return () => {};
192
200
  }
193
201
  const rig = stage.rig();
@@ -229,10 +237,42 @@ function bindStage(stage: ViewportStage, binding: StageBinding): () => void {
229
237
  };
230
238
  }
231
239
 
232
- export default function StdSceneView({ documentId: given }: ToolContributionProps) {
233
- const documentId = given ?? `tool:${STD_SCENE_DOCUMENT}`;
234
- useSyncExternalStore(subscribeSceneLevel, sceneLevelVersion, sceneLevelVersion);
235
- const level = sceneLevel();
240
+ export default function StdLevelDocument({ documentId: given, document, active }: ToolContributionProps) {
241
+ const level = document?.source?.path ?? null;
242
+ const documentId = given ?? `level:${level ?? ''}`;
243
+ // (the level in front last is the current level, which the Game view's Scene shows live)
244
+ useEffect(() => (active !== false && level !== null ? noteCurrentLevel(level, document?.label ?? level) : undefined), [active, level, document?.label]);
245
+ return <StdLevelView documentId={documentId} level={level} label={document?.label ?? level} live={false} />;
246
+ }
247
+
248
+ /** THE CURRENT LEVEL: the level document in front last, which the Game view's Scene draws live; null before any. */
249
+ let currentLevel: { readonly path: string; readonly label: string } | null = null;
250
+ const currentListeners = new Set<() => void>();
251
+ function noteCurrentLevel(path: string, label: string): undefined {
252
+ if (currentLevel?.path !== path || currentLevel.label !== label) {
253
+ currentLevel = { path, label };
254
+ for (const listener of [...currentListeners]) listener();
255
+ }
256
+ return undefined;
257
+ }
258
+ export const currentStdLevel = (): { readonly path: string; readonly label: string } | null => currentLevel;
259
+ export function subscribeCurrentStdLevel(listener: () => void): () => void {
260
+ currentListeners.add(listener);
261
+ return () => { currentListeners.delete(listener); };
262
+ }
263
+
264
+ /**
265
+ * A LEVEL'S VIEW: the level drawn as the game draws it, as authored (its document), or LIVE (the Game view's Scene,
266
+ * `../src/game-view.tsx`), the game's running scene put on this view's own copy of the level ({@link useRunningScene}).
267
+ * The same view, code and overlays either way.
268
+ */
269
+ export function StdLevelView({ documentId, level, label, live }: {
270
+ readonly documentId: string;
271
+ readonly level: string | null;
272
+ readonly label: string | null;
273
+ /** Live, it reads the running scene while the game runs, drawn or not: its copy of the level is the Hierarchy's. */
274
+ readonly live: boolean;
275
+ }) {
236
276
  const { Object3DDocumentViewport, frameAll } = editorThreejs();
237
277
  const root = useMemo(() => {
238
278
  const group = new THREE.Group();
@@ -316,30 +356,122 @@ export default function StdSceneView({ documentId: given }: ToolContributionProp
316
356
 
317
357
  useEffect(() => { mounted.current?.draw(overlays); }, [overlays]);
318
358
 
359
+ useRunningScene(live, mounted, useCallback(() => setRevision(beginLiveModuleRevision()), []));
360
+
319
361
  if (level === null) {
320
- return <div data-testid="std-scene-empty" style={{ padding: 16, font: '12px Inter, system-ui, sans-serif' }}>
321
- No std level to draw: open a level's Blender file (levels/&lt;name&gt;.blend), and its Scene view draws it.
362
+ return <div data-testid="std-level-empty" style={{ padding: 16, font: '12px Inter, system-ui, sans-serif' }}>
363
+ This document names no level: a level in code is levels/&lt;Name&gt;Level.tsx.
322
364
  </div>;
323
365
  }
324
366
  const banner = notes.error ?? (notes.gradingExtra ? "The level's grading holds effects beyond its view transform, which this view does not draw: the game shows them." : null);
325
367
  return (
326
368
  // (filled from the host box, as the editor's other documents are: in an editor group's auto-height content box
327
369
  // `height: 100%` resolves to the content's height, which left the stage a strip)
328
- <div data-testid="std-scene" style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
329
- <div role="toolbar" aria-label="Scene" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '2px 8px', font: '11px Inter, system-ui, sans-serif' }}>
330
- <span data-testid="std-scene-level">{level}</span>
370
+ <div data-testid="std-level" style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
371
+ <div role="toolbar" aria-label="Level" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '2px 8px', font: '11px Inter, system-ui, sans-serif' }}>
372
+ <span data-testid="std-level-path">{level}</span>
331
373
  <label style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
332
- <input type="checkbox" data-testid="std-scene-overlays" checked={overlays} onChange={(event) => setOverlays(event.currentTarget.checked)} />
374
+ <input type="checkbox" data-testid="std-level-overlays" checked={overlays} onChange={(event) => setOverlays(event.currentTarget.checked)} />
333
375
  Overlays (colliders, sensors, markers)
334
376
  </label>
335
377
  </div>
336
- {banner !== null && <div role={notes.error ? 'alert' : 'status'} data-testid="std-scene-banner" style={{ padding: '4px 8px', font: '11px Inter, system-ui, sans-serif', background: notes.error ? 'rgba(220, 38, 38, 0.25)' : 'rgba(250, 204, 21, 0.2)' }}>{banner}</div>}
378
+ {banner !== null && <div role={notes.error ? 'alert' : 'status'} data-testid="std-level-banner" style={{ padding: '4px 8px', font: '11px Inter, system-ui, sans-serif', background: notes.error ? 'rgba(220, 38, 38, 0.25)' : 'rgba(250, 204, 21, 0.2)' }}>{banner}</div>}
337
379
  <div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
338
380
  {drawn !== null && (
339
- <Object3DDocumentViewport documentId={documentId} sourcePath={drawn.component} displayName={`Scene: ${level}`} build={build}
340
- stageKind="std-scene" presentation={drawn.presentation} />
381
+ <Object3DDocumentViewport documentId={documentId} sourcePath={drawn.component} displayName={label ?? level} build={build}
382
+ stageKind={documentKind} presentation={drawn.presentation} />
341
383
  )}
342
384
  </div>
343
385
  </div>
344
386
  );
345
387
  }
388
+
389
+ /** One object of the game's running scene as std reads it (room/shared's scene-reading.ts). */
390
+ interface SceneObject {
391
+ readonly path: string;
392
+ readonly type: string;
393
+ readonly x: number; readonly y: number; readonly z: number;
394
+ readonly qx: number; readonly qy: number; readonly qz: number; readonly qw: number;
395
+ readonly sx: number; readonly sy: number; readonly sz: number;
396
+ readonly visible: boolean;
397
+ }
398
+
399
+ /** What changed in the game's running scene since an earlier reading (`GET /__std/scene`), and where it runs: `room:<id>`
400
+ * for the world the server runs, else the device whose page renders it. */
401
+ interface SceneRead {
402
+ readonly of: string;
403
+ readonly scene: string;
404
+ readonly at: number;
405
+ readonly whole: boolean;
406
+ readonly objects: readonly SceneObject[];
407
+ readonly gone: readonly string[];
408
+ }
409
+
410
+ /** A reading of the running scene, or none (`of: null`) where it runs nowhere yet. */
411
+ type RunningScene = SceneRead | { readonly of: null };
412
+
413
+ /** How often the running scene is read again while the game runs (ms): one reading at a time. */
414
+ const SCENE_ASK_MS = 50;
415
+
416
+ /**
417
+ * A LIVE LEVEL VIEW IS THE PLAYING WORLD while the game runs (the Game view's Scene), as an engine's scene view is
418
+ * during Play: the game's running scene, read where it runs (std-dev's `/__std/scene`, through Play's serving half),
419
+ * each change put on the view's own copy of the level by the runtime (`../serving/std-scene-runtime.js`'s
420
+ * `SceneNodes`), so its picking and overlays are the level view's own. The scene read is the world's authority: the one
421
+ * the game's server runs, where it runs one, else the one the viewed player's page renders (the first player's while the
422
+ * view is Scene or Grid), a game of one player's among them. Each reading names the one held, so a new scene, or another
423
+ * place it runs, answers whole, and so does the first reading of each copy of the level (a run's start, an edit's
424
+ * remount), which holds none. When the game stops, the level is drawn again as authored.
425
+ */
426
+ function useRunningScene(live: boolean, mounted: { readonly current: MountedScene | null }, again: () => void): void {
427
+ useSyncExternalStore(subscribeStdRun, stdRunVersion, stdRunVersion);
428
+ const running = live && (stdRun()?.url ?? null) !== null;
429
+ const againRef = useRef(again);
430
+ againRef.current = again;
431
+ // (the run over, the level drawn again as authored)
432
+ useEffect(() => {
433
+ if (!running) return undefined;
434
+ return () => { againRef.current(); };
435
+ }, [running]);
436
+ useEffect(() => {
437
+ if (!running) return;
438
+ let ended = false;
439
+ let timer: ReturnType<typeof setTimeout> | undefined;
440
+ /** The reading held: the scene read last, and its `at`. */
441
+ let last: { readonly scene: string; readonly at: number } = { scene: '', at: 0 };
442
+ let fed: MountedScene | null = null;
443
+ // (the viewed player's page, or the first player's: where a game the server runs no world for plays)
444
+ const viewedDevice = (): string | null => {
445
+ const run = stdRun();
446
+ const player = run === null ? undefined : typeof run.view === 'number' ? run.players[run.view] : run.players[0];
447
+ return player?.device ?? null;
448
+ };
449
+ // (the reading held, named to whichever place is read: a place that runs another scene answers it whole)
450
+ const held = (): string => `scene=${encodeURIComponent(last.scene)}&since=${last.at}&`;
451
+ const readScene = async (): Promise<RunningScene> => {
452
+ const server = await askPlay<RunningScene>(`/scene?${held()}`);
453
+ if (server.of !== null) return server;
454
+ const device = viewedDevice();
455
+ if (device === null) return server;
456
+ return askPlay<RunningScene>(`/scene?${held()}of=${encodeURIComponent(device)}`);
457
+ };
458
+ void (async () => {
459
+ while (!ended) {
460
+ const copy = mounted.current;
461
+ // (a new copy holds no scene, which std answers whole: what the game removed before it is hidden on it too)
462
+ if (copy !== fed) { fed = copy; last = { scene: '', at: 0 }; }
463
+ const read = copy === null ? null : await readScene().catch(() => null);
464
+ if (ended) return;
465
+ if (read !== null && read.of !== null && copy !== null && copy === mounted.current) {
466
+ last = { scene: read.scene, at: read.at };
467
+ copy.running(read);
468
+ }
469
+ await new Promise<void>((done) => { timer = setTimeout(done, SCENE_ASK_MS); });
470
+ }
471
+ })();
472
+ return () => {
473
+ ended = true;
474
+ clearTimeout(timer);
475
+ };
476
+ }, [mounted, running]);
477
+ }