@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.
- package/NOTICE +5 -5
- package/README.md +125 -172
- package/contributions/game-panel.tsx +908 -0
- package/contributions/model-play.verbs.ts +44 -0
- package/contributions/play.page.tsx +211 -0
- package/contributions/std-scene.document.tsx +345 -0
- package/dist-node/serving.mjs +664 -0
- package/package.json +257 -12
- package/serving/index.ts +13 -0
- package/serving/std-dev.ts +514 -0
- package/serving/std-scene-runtime.js +119 -0
- package/serving/std-scene.ts +213 -0
- package/src/editor-blender.ts +33 -0
- package/src/editor-threejs.ts +19 -0
- package/src/model-play.ts +60 -287
- package/src/play-clock.ts +62 -0
- package/src/play-log.ts +77 -62
- package/src/std-controls.ts +128 -0
- package/src/std-export.ts +128 -0
- package/src/std-game-adapter.ts +20 -0
- package/src/std-game.ts +841 -0
- package/src/std-level-files.ts +33 -0
- package/src/std-level.ts +107 -0
- package/src/std-scene-level.ts +59 -0
- package/src/std-view-bar.tsx +78 -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/play-materials.ts +0 -199
- package/src/play-script.ts +0 -841
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MODEL PLAY VERBS of the session wire (`@volter/sdk/commands`): each the Code-OSS command `volter.<verb>`, registered
|
|
3
|
+
* by play's page half (`play.page.tsx`), with its budget on its `contributes.commands` entry.
|
|
4
|
+
*
|
|
5
|
+
* `model-play-log`, the read behind `cyclotron play-log` and `editor.contributedVerb('model-play-log')` in `eval`, answers one model
|
|
6
|
+
* document's play log (`../src/play-log.ts`) — `documentId`'s, or the active Play's when omitted; the current run's, or
|
|
7
|
+
* the last one's after Stop — filtered by `since` (game seconds, inclusive) and `kind`. A read only: no play gate,
|
|
8
|
+
* since an empty log is the honest answer when nothing has played.
|
|
9
|
+
*
|
|
10
|
+
* `model-play`, the agent's tool for Play (declared in this package's `contributes.languageModelTools`), runs the Game
|
|
11
|
+
* panel's own view verbs, the ones `volter.model-play.<action>` runs (`game-panel.tsx`), so an agent drives the run the
|
|
12
|
+
* person sees and answers with the game's state.
|
|
13
|
+
*/
|
|
14
|
+
import type { VerbHandler } from '@volter/sdk/commands';
|
|
15
|
+
import { executeCommand } from '@volter/sdk/editor-commands';
|
|
16
|
+
import { readModelPlayLog } from '../src/play-log';
|
|
17
|
+
|
|
18
|
+
const ACTIONS = ['state', 'play', 'stop', 'pause', 'resume', 'restart'] as const;
|
|
19
|
+
|
|
20
|
+
export const modelPlayVerbs: Readonly<Record<string, VerbHandler>> = {
|
|
21
|
+
'model-play': async (command) => {
|
|
22
|
+
const action = command['action'];
|
|
23
|
+
if (typeof action !== 'string' || !(ACTIONS as readonly string[]).includes(action))
|
|
24
|
+
return { ok: false, error: `model-play's action is one of ${ACTIONS.join(', ')}; it was given ${JSON.stringify(action)}.` };
|
|
25
|
+
return { ok: true, data: { result: (await executeCommand(`volter.model-play.${action}`, {})) ?? null } };
|
|
26
|
+
},
|
|
27
|
+
'model-play-log': (command) => {
|
|
28
|
+
const since = command['since'];
|
|
29
|
+
const kind = command['kind'];
|
|
30
|
+
const documentId = command['documentId'];
|
|
31
|
+
if (documentId !== undefined && typeof documentId !== 'string')
|
|
32
|
+
return { ok: false, error: `model-play-log's documentId is a model document id, a string; it was given ${JSON.stringify(documentId)}.` };
|
|
33
|
+
if (since !== undefined && (typeof since !== 'number' || !Number.isFinite(since)))
|
|
34
|
+
return { ok: false, error: `model-play-log's since is game seconds, a number; it was given ${JSON.stringify(since)}.` };
|
|
35
|
+
if (kind !== undefined && typeof kind !== 'string')
|
|
36
|
+
return { ok: false, error: `model-play-log's kind is an entry kind, a string; it was given ${JSON.stringify(kind)}.` };
|
|
37
|
+
const reading = readModelPlayLog({
|
|
38
|
+
...(documentId === undefined ? {} : { documentId }),
|
|
39
|
+
...(since === undefined ? {} : { since }),
|
|
40
|
+
...(kind === undefined ? {} : { kind }),
|
|
41
|
+
});
|
|
42
|
+
return { ok: true, data: { ...reading } };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
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`).
|
|
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';
|
|
10
|
+
import { registerVerbCommands, registerViewCommands } from '@volter/sdk/commands';
|
|
11
|
+
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
|
+
import { onProjectChange } from '@volter/sdk/active-project';
|
|
42
|
+
import { editorHost } from '@volter/sdk/host';
|
|
43
|
+
import { keepEditorBlender } from '../src/editor-blender';
|
|
44
|
+
import { keepEditorThreejs } from '../src/editor-threejs';
|
|
45
|
+
import type { ThreePageApi } from '@volter/editor-threejs/api';
|
|
46
|
+
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:/, '');
|
|
76
|
+
|
|
77
|
+
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
|
+
|
|
79
|
+
function hasScript(): boolean | null {
|
|
80
|
+
return isStdGame() ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const EDITOR_BLENDER = 'volter.@volter/editor-blender';
|
|
84
|
+
const EDITOR_THREEJS = 'volter.@volter/editor-threejs';
|
|
85
|
+
|
|
86
|
+
export function activate(context: PluginContext) {
|
|
87
|
+
// Declared in `extensionDependencies`, so it is active before this runs.
|
|
88
|
+
const blender = context.extensions.getExtension<BlenderPageApi>(EDITOR_BLENDER)?.exports;
|
|
89
|
+
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))
|
|
100
|
+
.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
|
+
},
|
|
116
|
+
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),
|
|
185
|
+
},
|
|
186
|
+
scriptPath: () => STD_DEV,
|
|
187
|
+
hasScript,
|
|
188
|
+
};
|
|
189
|
+
keepEditorBlender(blender, modelPlay);
|
|
190
|
+
// (a std level's Scene view draws in editor-threejs's viewport, `std-scene.document.tsx`; declared in `extensionDependencies`)
|
|
191
|
+
const three = context.extensions.getExtension<ThreePageApi>(EDITOR_THREEJS)?.exports;
|
|
192
|
+
if (!three) throw new Error(`${EDITOR_THREEJS} answered no API.`);
|
|
193
|
+
keepEditorThreejs(three);
|
|
194
|
+
const unregister = blender.registerModelPlay(modelPlay);
|
|
195
|
+
// A std game's levels exported on save, and its prefabs a linked asset library (`std-level.ts`).
|
|
196
|
+
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
|
+
// 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);
|
|
202
|
+
context.subscriptions.push({
|
|
203
|
+
dispose() {
|
|
204
|
+
unregister();
|
|
205
|
+
stopStdLevels();
|
|
206
|
+
stopSceneLevel();
|
|
207
|
+
keepEditorBlender(null, null);
|
|
208
|
+
keepEditorThreejs(null);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
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
|
|
6
|
+
* the stage's own renderer and camera, the stage drawing the picture, the level's `useFrame`s run on the stage's frame,
|
|
7
|
+
* and its environment is the document's World (editor-threejs's `documentWorld`). The level follows the stage the
|
|
8
|
+
* document has now: an attachment the stage makes again (after a lost context) is bound anew.
|
|
9
|
+
*
|
|
10
|
+
* - The level's `<Canvas>` settings are the view's presentation: Rendered shading lit by the scene alone, its backdrop
|
|
11
|
+
* the scene's own (the level's sky), its tone mapping the canvas's; its shadows are the renderer's.
|
|
12
|
+
* - Its view transform (std's shaper and baked LUT) is the stage's display transform; a grading with effects beyond it
|
|
13
|
+
* is named in the banner, as the view does not draw them.
|
|
14
|
+
* - The overlays: colliders and sensors as react-three-rapier's debug lines of the paused world, and each std marker (an
|
|
15
|
+
* object whose `userData.behaviours` names its behaviours: a spawn point, a seat, a prompt) as a marker and its name,
|
|
16
|
+
* 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
|
+
*/
|
|
20
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
21
|
+
import * as THREE from 'three';
|
|
22
|
+
import type { DocumentDisplayTransform, ViewportStage } from '@volter/editor-threejs/api';
|
|
23
|
+
import type { ToolContributionProps } from '@volter/sdk/contributions';
|
|
24
|
+
import { projectModuleChangeMatches, subscribeProjectModuleChange } from '@volter/sdk/project-module-changes';
|
|
25
|
+
import { beginLiveModuleRevision } from '@volter/sdk/project-module-url';
|
|
26
|
+
import type { PresentationLayer } from '@volter/sdk/viewport-presentation';
|
|
27
|
+
import { editorThreejs } from '../src/editor-threejs';
|
|
28
|
+
import { sceneLevel, sceneLevelVersion, STD_SCENE_DOCUMENT, subscribeSceneLevel } from '../src/std-scene-level';
|
|
29
|
+
|
|
30
|
+
export const point = 'workspace.document';
|
|
31
|
+
export const title = 'Scene';
|
|
32
|
+
|
|
33
|
+
/** The level's module, as `../serving/std-scene.ts` writes it. */
|
|
34
|
+
interface StdSceneModule {
|
|
35
|
+
readonly component: string;
|
|
36
|
+
readonly canvas: { readonly gl?: { readonly toneMapping?: THREE.ToneMapping; readonly toneMappingExposure?: number } };
|
|
37
|
+
readonly gradingExtra: boolean;
|
|
38
|
+
mountScene(args: {
|
|
39
|
+
readonly renderer: THREE.WebGLRenderer;
|
|
40
|
+
readonly world: THREE.Scene;
|
|
41
|
+
readonly camera: THREE.Camera;
|
|
42
|
+
readonly root: THREE.Group;
|
|
43
|
+
readonly onError: (error: unknown) => void;
|
|
44
|
+
}): Promise<MountedScene>;
|
|
45
|
+
viewTransform(): Promise<(DocumentDisplayTransform & { dispose(): void }) | null>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface MountedScene {
|
|
49
|
+
draw(overlays: boolean): void;
|
|
50
|
+
advance(seconds: number): void;
|
|
51
|
+
unmount(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The viewport's tone mappers, by three's constant: fiber's own default, a canvas stating none, is ACES. */
|
|
55
|
+
const TONE_MAPPERS = new Map<THREE.ToneMapping, 'none' | 'aces' | 'agx'>([
|
|
56
|
+
[THREE.NoToneMapping, 'none'],
|
|
57
|
+
[THREE.ACESFilmicToneMapping, 'aces'],
|
|
58
|
+
[THREE.AgXToneMapping, 'agx'],
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
62
|
+
|
|
63
|
+
/** The presentation that draws a level as its canvas does; a string where the canvas asks what the viewport lacks. */
|
|
64
|
+
function canvasPresentation(canvas: StdSceneModule['canvas']): PresentationLayer | string {
|
|
65
|
+
const toneMapping = canvas.gl?.toneMapping ?? THREE.ACESFilmicToneMapping;
|
|
66
|
+
const mapper = TONE_MAPPERS.get(toneMapping);
|
|
67
|
+
if (mapper === undefined) return `The level's canvas tone-maps with three's mapper ${toneMapping}, which the viewport does not draw (it draws none, ACES and AgX).`;
|
|
68
|
+
return {
|
|
69
|
+
drawMode: 'rendered',
|
|
70
|
+
all: {
|
|
71
|
+
lighting: { source: 'scene', auto: null, tone: { mapper, exposure: canvas.gl?.toneMappingExposure ?? 1 } },
|
|
72
|
+
backdrop: { source: 'scene' },
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const MARKER_COLOURS: Readonly<Record<string, number>> = { spawn: 0x4ade80, seat: 0x60a5fa, prompt: 0xfacc15 };
|
|
78
|
+
|
|
79
|
+
/** A marker's sign on the helper layer: a diamond at the object's origin and its name and behaviours above it. */
|
|
80
|
+
function markerSign(object: THREE.Object3D, behaviours: readonly string[]): THREE.Object3D {
|
|
81
|
+
const colour = MARKER_COLOURS[behaviours[0] ?? ''] ?? 0xf472b6;
|
|
82
|
+
const sign = new THREE.Group();
|
|
83
|
+
const diamond = new THREE.LineSegments(
|
|
84
|
+
new THREE.EdgesGeometry(new THREE.OctahedronGeometry(0.3)),
|
|
85
|
+
new THREE.LineBasicMaterial({ color: colour, depthTest: false, transparent: true }),
|
|
86
|
+
);
|
|
87
|
+
diamond.renderOrder = 999;
|
|
88
|
+
const text = `${object.name || '(unnamed)'} · ${behaviours.join(', ')}`;
|
|
89
|
+
const canvas = document.createElement('canvas');
|
|
90
|
+
const context = canvas.getContext('2d')!;
|
|
91
|
+
context.font = '28px Inter, system-ui, sans-serif';
|
|
92
|
+
canvas.width = Math.ceil(context.measureText(text).width) + 24;
|
|
93
|
+
canvas.height = 44;
|
|
94
|
+
context.font = '28px Inter, system-ui, sans-serif';
|
|
95
|
+
context.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
|
96
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
97
|
+
context.fillStyle = `#${colour.toString(16).padStart(6, '0')}`;
|
|
98
|
+
context.fillText(text, 12, 31);
|
|
99
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
100
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
101
|
+
const label = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false, transparent: true }));
|
|
102
|
+
label.scale.set((0.4 * canvas.width) / canvas.height, 0.4, 1);
|
|
103
|
+
label.position.y = 0.6;
|
|
104
|
+
label.renderOrder = 999;
|
|
105
|
+
sign.add(diamond, label);
|
|
106
|
+
return sign;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function disposeSigns(signs: THREE.Object3D): void {
|
|
110
|
+
signs.traverse((object) => {
|
|
111
|
+
if (object instanceof THREE.LineSegments) { object.geometry.dispose(); (object.material as THREE.Material).dispose(); }
|
|
112
|
+
if (object instanceof THREE.Sprite) { object.material.map?.dispose(); object.material.dispose(); }
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The std markers under `root`: each object whose `userData.behaviours` names any. */
|
|
117
|
+
function markersOf(root: THREE.Object3D): { readonly object: THREE.Object3D; readonly behaviours: readonly string[] }[] {
|
|
118
|
+
const found: { object: THREE.Object3D; behaviours: readonly string[] }[] = [];
|
|
119
|
+
root.traverse((object) => {
|
|
120
|
+
const behaviours = (object.userData as { behaviours?: unknown }).behaviours;
|
|
121
|
+
if (behaviours !== null && typeof behaviours === 'object' && Object.keys(behaviours).length > 0) found.push({ object, behaviours: Object.keys(behaviours) });
|
|
122
|
+
});
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The marker signs of `root`, kept on the stage's helper layer at each marker's place, made again as the markers change. */
|
|
127
|
+
function followMarkers(stage: ViewportStage, root: THREE.Object3D, shown: () => boolean): () => void {
|
|
128
|
+
let signature = '';
|
|
129
|
+
let signs: THREE.Group | null = null;
|
|
130
|
+
let objects: THREE.Object3D[] = [];
|
|
131
|
+
const stop = stage.onFrame(() => {
|
|
132
|
+
if (!shown()) {
|
|
133
|
+
if (signs) { stage.setHelper('std-markers', null); disposeSigns(signs); signs = null; signature = ''; }
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const markers = markersOf(root);
|
|
137
|
+
const next = markers.map(({ object, behaviours }) => `${object.uuid}:${behaviours.join(',')}`).join('|');
|
|
138
|
+
if (next !== signature) {
|
|
139
|
+
signature = next;
|
|
140
|
+
if (signs) disposeSigns(signs);
|
|
141
|
+
signs = new THREE.Group();
|
|
142
|
+
signs.name = 'std markers';
|
|
143
|
+
for (const { object, behaviours } of markers) signs.add(markerSign(object, behaviours));
|
|
144
|
+
objects = markers.map(({ object }) => object);
|
|
145
|
+
stage.setHelper('std-markers', signs);
|
|
146
|
+
}
|
|
147
|
+
objects.forEach((object, index) => signs!.children[index]!.position.setFromMatrixPosition(object.matrixWorld));
|
|
148
|
+
});
|
|
149
|
+
return () => {
|
|
150
|
+
stop();
|
|
151
|
+
stage.setHelper('std-markers', null);
|
|
152
|
+
if (signs) disposeSigns(signs);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** What a binding of the level to one stage reads and reports. */
|
|
157
|
+
interface StageBinding {
|
|
158
|
+
readonly documentId: string;
|
|
159
|
+
readonly module: StdSceneModule;
|
|
160
|
+
readonly transform: DocumentDisplayTransform | null;
|
|
161
|
+
readonly root: THREE.Group;
|
|
162
|
+
readonly overlays: () => boolean;
|
|
163
|
+
readonly mounted: (scene: MountedScene, live: boolean) => void;
|
|
164
|
+
readonly framed: () => void;
|
|
165
|
+
readonly fail: (error: unknown) => void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Each canvas's fiber lifetimes, one after another. Fiber keeps one root per canvas, and a root made on a canvas whose
|
|
170
|
+
* root is closing takes that root over; so a binding makes its root only once the binding before it on that canvas has
|
|
171
|
+
* ended its own, and the root a binding made is the one it alone ends.
|
|
172
|
+
*/
|
|
173
|
+
const canvasTurns = new WeakMap<HTMLCanvasElement, Promise<void>>();
|
|
174
|
+
function onCanvas(canvas: HTMLCanvasElement, turn: () => Promise<void>): void {
|
|
175
|
+
const next = (canvasTurns.get(canvas) ?? Promise.resolve()).then(turn);
|
|
176
|
+
canvasTurns.set(canvas, next.catch(() => {}));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The level drawn on one stage attachment, as one generation: a fiber root on the attachment's renderer, its frames on
|
|
181
|
+
* the stage's frame, its markers and its view transform. The answer ends the generation; each of its async steps asks
|
|
182
|
+
* whether it is still current before it draws or installs anything, and the root it made it ends itself, after its
|
|
183
|
+
* last step. An attachment the stage makes again (a lost context) has another renderer, and is another generation.
|
|
184
|
+
*/
|
|
185
|
+
function bindStage(stage: ViewportStage, binding: StageBinding): () => void {
|
|
186
|
+
const { documentId, module, transform, root } = binding;
|
|
187
|
+
const three = editorThreejs();
|
|
188
|
+
const world = three.documentWorld(root);
|
|
189
|
+
if (world === null) {
|
|
190
|
+
binding.fail(new Error("The Scene view's root stands in no document scene of its stage."));
|
|
191
|
+
return () => {};
|
|
192
|
+
}
|
|
193
|
+
const rig = stage.rig();
|
|
194
|
+
let current = true;
|
|
195
|
+
let transformed = false;
|
|
196
|
+
let ended!: () => void;
|
|
197
|
+
const end = new Promise<void>((resolve) => { ended = resolve; });
|
|
198
|
+
const hooks: (() => void)[] = [];
|
|
199
|
+
onCanvas(rig.renderer.domElement, async () => {
|
|
200
|
+
if (!current) return;
|
|
201
|
+
let scene: MountedScene;
|
|
202
|
+
try {
|
|
203
|
+
scene = await module.mountScene({ renderer: rig.renderer, world, camera: rig.camera, root, onError: binding.fail });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
binding.fail(error);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// (from here this generation holds the canvas's root, and ends it below, whatever comes)
|
|
209
|
+
if (current) {
|
|
210
|
+
scene.draw(binding.overlays());
|
|
211
|
+
binding.mounted(scene, true);
|
|
212
|
+
hooks.push(stage.onFrame(() => {
|
|
213
|
+
scene.advance(performance.now() / 1000);
|
|
214
|
+
// (the stage's session stands after its rig: the transform and the framing wait for it)
|
|
215
|
+
if (transform !== null && !transformed) transformed = three.setDisplayTransform(documentId, transform);
|
|
216
|
+
binding.framed();
|
|
217
|
+
}));
|
|
218
|
+
hooks.push(followMarkers(stage, root, binding.overlays));
|
|
219
|
+
}
|
|
220
|
+
await end;
|
|
221
|
+
binding.mounted(scene, false);
|
|
222
|
+
scene.unmount();
|
|
223
|
+
});
|
|
224
|
+
return () => {
|
|
225
|
+
current = false;
|
|
226
|
+
for (const hook of hooks.splice(0).reverse()) hook();
|
|
227
|
+
if (transformed) three.setDisplayTransform(documentId, null);
|
|
228
|
+
ended();
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
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();
|
|
236
|
+
const { Object3DDocumentViewport, frameAll } = editorThreejs();
|
|
237
|
+
const root = useMemo(() => {
|
|
238
|
+
const group = new THREE.Group();
|
|
239
|
+
group.name = 'Level';
|
|
240
|
+
return group;
|
|
241
|
+
}, []);
|
|
242
|
+
const build = useCallback(() => ({ root, dispose() {} }), [root]);
|
|
243
|
+
const [revision, setRevision] = useState(() => beginLiveModuleRevision());
|
|
244
|
+
const [overlays, setOverlays] = useState(true);
|
|
245
|
+
const overlaysRef = useRef(overlays);
|
|
246
|
+
overlaysRef.current = overlays;
|
|
247
|
+
const [drawn, setDrawn] = useState<{ readonly presentation: PresentationLayer; readonly component: string } | null>(null);
|
|
248
|
+
const [notes, setNotes] = useState<{ readonly error: string | null; readonly gradingExtra: boolean }>({ error: null, gradingExtra: false });
|
|
249
|
+
const mounted = useRef<MountedScene | null>(null);
|
|
250
|
+
/** The level the camera was framed on, once its content first stood. */
|
|
251
|
+
const framed = useRef<string | null>(null);
|
|
252
|
+
|
|
253
|
+
// (the level drawn again as anything its module depends on is written, made or removed: play's serving half reads
|
|
254
|
+
// that from the session's module graph and names the levels a change reached, `../serving/std-scene.ts`)
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (level === null) return;
|
|
257
|
+
return subscribeProjectModuleChange((_changed, affected) => {
|
|
258
|
+
if (affected?.some((path) => projectModuleChangeMatches(path, level))) setRevision(beginLiveModuleRevision());
|
|
259
|
+
});
|
|
260
|
+
}, [level]);
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (level === null) return;
|
|
264
|
+
const three = editorThreejs();
|
|
265
|
+
let disposed = false;
|
|
266
|
+
let transform: (DocumentDisplayTransform & { dispose(): void }) | null = null;
|
|
267
|
+
let stopStages: (() => void) | null = null;
|
|
268
|
+
let bound: THREE.WebGLRenderer | null = null;
|
|
269
|
+
let unbind: (() => void) | null = null;
|
|
270
|
+
const fail = (error: unknown): void => { if (!disposed) setNotes((now) => ({ ...now, error: `${level}: ${message(error)}` })); };
|
|
271
|
+
void (async () => {
|
|
272
|
+
// (`import` is Vite's mark for a module import: without it a path ending `.blend` is not one Vite transforms, and
|
|
273
|
+
// the request answers 404)
|
|
274
|
+
const module = (await import(/* @vite-ignore */ `/@volter-std-scene/${level}?import&v=${revision}`)) as StdSceneModule;
|
|
275
|
+
if (disposed) return;
|
|
276
|
+
const look = canvasPresentation(module.canvas);
|
|
277
|
+
if (typeof look === 'string') { fail(new Error(look)); return; }
|
|
278
|
+
setDrawn((now) => (now?.component === module.component && JSON.stringify(now.presentation) === JSON.stringify(look) ? now : { presentation: look, component: module.component }));
|
|
279
|
+
setNotes({ error: null, gradingExtra: module.gradingExtra });
|
|
280
|
+
const made = await module.viewTransform();
|
|
281
|
+
if (disposed) { made?.dispose(); return; }
|
|
282
|
+
transform = made;
|
|
283
|
+
const binding: StageBinding = {
|
|
284
|
+
documentId, module, transform: made, root, fail,
|
|
285
|
+
overlays: () => overlaysRef.current,
|
|
286
|
+
mounted: (scene, live) => {
|
|
287
|
+
if (live) mounted.current = scene;
|
|
288
|
+
else if (mounted.current === scene) mounted.current = null;
|
|
289
|
+
},
|
|
290
|
+
// (framed once a level, as its content first stands: a draw again keeps the camera)
|
|
291
|
+
framed: () => {
|
|
292
|
+
if (framed.current !== level && !new THREE.Box3().setFromObject(root).isEmpty() && frameAll(documentId)) framed.current = level;
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
// (the level follows the stage attachment the document has now, known by its renderer: the stage doors are made
|
|
296
|
+
// again as any stage binds or goes, its attachment made again after a lost context has another renderer)
|
|
297
|
+
const follow = (): void => {
|
|
298
|
+
const stage = three.stage(documentId);
|
|
299
|
+
const renderer = stage?.rig().renderer ?? null;
|
|
300
|
+
if (renderer === bound) return;
|
|
301
|
+
unbind?.();
|
|
302
|
+
unbind = null;
|
|
303
|
+
bound = renderer;
|
|
304
|
+
if (stage) unbind = bindStage(stage, binding);
|
|
305
|
+
};
|
|
306
|
+
stopStages = three.onStages(follow);
|
|
307
|
+
follow();
|
|
308
|
+
})().catch(fail);
|
|
309
|
+
return () => {
|
|
310
|
+
disposed = true;
|
|
311
|
+
stopStages?.();
|
|
312
|
+
unbind?.();
|
|
313
|
+
transform?.dispose();
|
|
314
|
+
};
|
|
315
|
+
}, [documentId, frameAll, level, revision, root]);
|
|
316
|
+
|
|
317
|
+
useEffect(() => { mounted.current?.draw(overlays); }, [overlays]);
|
|
318
|
+
|
|
319
|
+
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/<name>.blend), and its Scene view draws it.
|
|
322
|
+
</div>;
|
|
323
|
+
}
|
|
324
|
+
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
|
+
return (
|
|
326
|
+
// (filled from the host box, as the editor's other documents are: in an editor group's auto-height content box
|
|
327
|
+
// `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>
|
|
331
|
+
<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)} />
|
|
333
|
+
Overlays (colliders, sensors, markers)
|
|
334
|
+
</label>
|
|
335
|
+
</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>}
|
|
337
|
+
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
|
|
338
|
+
{drawn !== null && (
|
|
339
|
+
<Object3DDocumentViewport documentId={documentId} sourcePath={drawn.component} displayName={`Scene: ${level}`} build={build}
|
|
340
|
+
stageKind="std-scene" presentation={drawn.presentation} />
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|