@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,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A STD LEVEL'S GENERATED FILES, named from its `.blend` as std's level export names them (std-dev's level-names.ts
|
|
3
|
+
* `levelNames`, beside `writeGrading`): the files the Scene view's module is written from (`../serving/std-scene.ts`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** std's PascalCase and camelCase of a level's base name (std-dev's level-names.ts `pascal` and `camel`). */
|
|
7
|
+
export const pascal = (name: string): string =>
|
|
8
|
+
name.replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_m, _s, c: string) => c.toUpperCase()).replace(/^[0-9]/, (d) => `L${d}`);
|
|
9
|
+
export const camel = (name: string): string => {
|
|
10
|
+
const p = pascal(name);
|
|
11
|
+
return p.charAt(0).toLowerCase() + p.slice(1);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A level's generated files, from the game's folder, for its `.blend` (`levels/<name>.blend`). */
|
|
15
|
+
export function levelFiles(blend: string): {
|
|
16
|
+
readonly base: string;
|
|
17
|
+
readonly dir: string;
|
|
18
|
+
readonly component: string;
|
|
19
|
+
readonly environment: string;
|
|
20
|
+
readonly canvas: string;
|
|
21
|
+
readonly grading: string;
|
|
22
|
+
} {
|
|
23
|
+
const dir = blend.slice(0, blend.lastIndexOf('/'));
|
|
24
|
+
const base = blend.slice(dir.length + 1).replace(/\.blend$/, '');
|
|
25
|
+
return {
|
|
26
|
+
base,
|
|
27
|
+
dir,
|
|
28
|
+
component: `${dir}/${pascal(base)}Level.tsx`,
|
|
29
|
+
environment: `${dir}/${pascal(base)}Environment.tsx`,
|
|
30
|
+
canvas: `${dir}/${base}.canvas.ts`,
|
|
31
|
+
grading: `${dir}/${base}.grading.tsx`,
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/std-level.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A STD GAME IN CYCLOTRON'S BLENDER. Where the project is a std game (`std-game.ts`'s `isStdGame`):
|
|
3
|
+
*
|
|
4
|
+
* - EACH SAVE OF A LEVEL'S `.blend` (`levels/**\/*.blend`) OR A PREFAB'S (`prefabs/<name>/<name>.blend`, std's) RUNS ITS
|
|
5
|
+
* EXPORT, its two halves (std's guide, "Levels": a prefab's export writes its component, `prefabs/<name>/<Name>.tsx`,
|
|
6
|
+
* which a level that places it needs first; each save exports, so a prefab saved is exported before the level that
|
|
7
|
+
* places it is next saved). The
|
|
8
|
+
* Blender half, std-dev's `blend-to-scene.py`, is the save's own script (editor-blender's `modelDocument.setSaveScript`):
|
|
9
|
+
* it runs in the save's turn, on the very scene that save wrote, before anything else reaches that Blender, and the
|
|
10
|
+
* save's answer carries the files it wrote (`levels/<name>.glb` and what goes beside it). Then, one save after another,
|
|
11
|
+
* those files are carried into the project and the Node half, `std level <glb> --description <json>`, runs in the
|
|
12
|
+
* game's folder (`../serving/std-dev.ts`, its `/level` route). std's dev server takes the level from there by its own
|
|
13
|
+
* hot reload (std-dev's `level-hot.ts`). A save is a person's Save, the workbench's autosave a second after an edit (a
|
|
14
|
+
* viewport drag writes Blender once, at its release), an export's flush or the save that retires a document.
|
|
15
|
+
* - THE GAME'S `prefabs/` IS A BLENDER ASSET LIBRARY WHOSE ASSETS ARE LINKED, not appended ("Prefabs": Blender's
|
|
16
|
+
* link-or-append is the library's import method, which std cannot set).
|
|
17
|
+
*
|
|
18
|
+
* - PRESSING PLAY SAVES THE LEVEL FIRST (`../contributions/play.page.tsx`), and the game's pages load once that
|
|
19
|
+
* save's export has run ({@link stdLevelsSettled}), so the game plays the current edit.
|
|
20
|
+
*
|
|
21
|
+
* What each half printed is the console's (`std-level`), and a half that failed is an error there.
|
|
22
|
+
*/
|
|
23
|
+
import type { BlenderPageApi, ModelDocumentSaved } from '@volter/editor-blender/api';
|
|
24
|
+
import { getCurrentProject, onProjectChange } from '@volter/sdk/active-project';
|
|
25
|
+
import { editorHost } from '@volter/sdk/host';
|
|
26
|
+
import { isStdGame, subscribeStdGame } from './std-game';
|
|
27
|
+
|
|
28
|
+
const ROUTE = '/__editor/play/std/level';
|
|
29
|
+
const SOURCE = 'std-level';
|
|
30
|
+
/** A level's `.blend`, or a prefab's in its own folder: what std's export takes. */
|
|
31
|
+
const EXPORTED = /^(levels\/.+|prefabs\/([^/]+)\/\2)\.blend$/;
|
|
32
|
+
|
|
33
|
+
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
34
|
+
|
|
35
|
+
/** One save's carry and Node half after another: each Node half reads and takes away the description its save
|
|
36
|
+
* wrote. */
|
|
37
|
+
let tail: Promise<void> = Promise.resolve();
|
|
38
|
+
/** Once every save's export heard so far has run (or failed, which the console says). */
|
|
39
|
+
export const stdLevelsSettled = (): Promise<void> => tail;
|
|
40
|
+
const glbOf = (blend: string): string => blend.replace(/\.blend$/, '.glb');
|
|
41
|
+
|
|
42
|
+
/** The Blender half's source, as the game has it installed now (read for each save). */
|
|
43
|
+
async function blenderHalf(): Promise<string> {
|
|
44
|
+
const answer = await fetch(ROUTE);
|
|
45
|
+
const said = (await answer.json()) as { blenderHalf?: string; error?: string };
|
|
46
|
+
if (!answer.ok || typeof said.blenderHalf !== 'string') throw new Error(said.error ?? `${ROUTE} answered HTTP ${answer.status}`);
|
|
47
|
+
return said.blenderHalf;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One save's export, after its Blender half ran in the save: its files carried, then the Node half. */
|
|
51
|
+
async function finishExport(saved: ModelDocumentSaved): Promise<void> {
|
|
52
|
+
const ledger = editorHost().console;
|
|
53
|
+
const { blend, script } = saved;
|
|
54
|
+
if (script === null) return;
|
|
55
|
+
const glb = glbOf(blend);
|
|
56
|
+
for (const line of script.unstaged) ledger.warn(`std's export of ${blend} ran on Blender's earlier copy of a project file: ${line}`, SOURCE);
|
|
57
|
+
if (script.output.trim()) ledger.log(script.output.trimEnd(), SOURCE);
|
|
58
|
+
if (script.error !== null) {
|
|
59
|
+
ledger.error(`std's export of ${blend} failed in Blender (blend-to-scene.py): ${script.error}`, SOURCE);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await saved.carry();
|
|
63
|
+
const answer = await fetch(ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ glb }) });
|
|
64
|
+
const node = (await answer.json()) as { code?: number | null; output?: string; error?: string };
|
|
65
|
+
if (!answer.ok) throw new Error(node.error ?? `${ROUTE} answered HTTP ${answer.status}`);
|
|
66
|
+
if (node.output?.trim()) ledger.log(node.output.trimEnd(), SOURCE);
|
|
67
|
+
if (node.code !== 0) ledger.error(`std's export of ${blend} failed in Node (std level ${glb}): its exit was ${String(node.code)}`, SOURCE);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Start following the project's saves; the answer stops it and withdraws the save script and the prefabs library. */
|
|
71
|
+
export function startStdLevels(blender: BlenderPageApi): () => void {
|
|
72
|
+
/** The project looked at (null before the first look), and whether it is a std game, as last answered. */
|
|
73
|
+
let project: string | undefined | null = null;
|
|
74
|
+
let std = false;
|
|
75
|
+
let withdraw: (() => void) | null = null;
|
|
76
|
+
// (each answer of whether the project is a std game is looked at: another project, or this one's manifest changed)
|
|
77
|
+
const look = (): void => {
|
|
78
|
+
const root = getCurrentProject()?.rootPath;
|
|
79
|
+
const answer = isStdGame();
|
|
80
|
+
if (answer === undefined || (root === project && answer === std)) return;
|
|
81
|
+
project = root;
|
|
82
|
+
std = answer;
|
|
83
|
+
withdraw?.();
|
|
84
|
+
withdraw = null;
|
|
85
|
+
if (!std) return;
|
|
86
|
+
const stopScript = blender.modelDocument.setSaveScript((blend) =>
|
|
87
|
+
(EXPORTED.test(blend) ? blenderHalf().then((source) => ({ source, args: [glbOf(blend)] })) : null));
|
|
88
|
+
const stopLibrary = blender.setAssetLibrary({ name: 'Prefabs', directory: 'prefabs', importMethod: 'LINK' });
|
|
89
|
+
withdraw = () => { stopScript(); stopLibrary(); };
|
|
90
|
+
};
|
|
91
|
+
const stopSaves = blender.modelDocument.subscribeSaved((saved) => {
|
|
92
|
+
if (saved.script === null) return;
|
|
93
|
+
const done = tail.then(() => finishExport(saved)).catch((error: unknown) =>
|
|
94
|
+
editorHost().console.error(`std's export of ${saved.blend} did not run: ${message(error)}`, SOURCE));
|
|
95
|
+
tail = done;
|
|
96
|
+
return done;
|
|
97
|
+
});
|
|
98
|
+
const stopProject = onProjectChange(look);
|
|
99
|
+
const stopStd = subscribeStdGame(look);
|
|
100
|
+
look();
|
|
101
|
+
return () => {
|
|
102
|
+
stopSaves();
|
|
103
|
+
stopProject();
|
|
104
|
+
stopStd();
|
|
105
|
+
withdraw?.();
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LEVEL A STD GAME'S SCENE VIEW DRAWS (`../contributions/std-scene.document.tsx`): the std level whose Blender
|
|
3
|
+
* document was active last (`levels/<name>.blend`), or the one `volter.std-scene` named as it opened the view. Another
|
|
4
|
+
* project forgets it.
|
|
5
|
+
*/
|
|
6
|
+
import type { EditorCommandMessage, EditorCommandResult } from '@volter/sdk/commands';
|
|
7
|
+
import { onProjectChange } from '@volter/sdk/active-project';
|
|
8
|
+
import { editorHost } from '@volter/sdk/host';
|
|
9
|
+
import { isStdGame, subscribeStdGame } from './std-game';
|
|
10
|
+
|
|
11
|
+
/** The Scene view's document contribution. */
|
|
12
|
+
export const STD_SCENE_DOCUMENT = 'std-scene.document';
|
|
13
|
+
const LEVEL = /^levels\/.+\.blend$/;
|
|
14
|
+
const LEVEL_DOCUMENT = /^document:model:(levels\/.+\.blend)$/;
|
|
15
|
+
|
|
16
|
+
let level: string | null = null;
|
|
17
|
+
let version = 0;
|
|
18
|
+
const listeners = new Set<() => void>();
|
|
19
|
+
const publish = (next: string | null): void => {
|
|
20
|
+
if (next === level) return;
|
|
21
|
+
level = next;
|
|
22
|
+
version += 1;
|
|
23
|
+
for (const listener of [...listeners]) listener();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const sceneLevel = (): string | null => level;
|
|
27
|
+
export const sceneLevelVersion = (): number => version;
|
|
28
|
+
export function subscribeSceneLevel(listener: () => void): () => void {
|
|
29
|
+
listeners.add(listener);
|
|
30
|
+
return () => { listeners.delete(listener); };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Follow the active document: a std level's Blender document makes its level the Scene view's. */
|
|
34
|
+
export function followSceneLevel(): () => void {
|
|
35
|
+
const look = (): void => {
|
|
36
|
+
const found = LEVEL_DOCUMENT.exec(editorHost().documents.activeId() ?? '')?.[1];
|
|
37
|
+
if (found !== undefined && isStdGame() === true) publish(found);
|
|
38
|
+
};
|
|
39
|
+
const stopDocuments = editorHost().documents.subscribe(look);
|
|
40
|
+
// (whether the project is a std game is answered later than its first document opens)
|
|
41
|
+
const stopStd = subscribeStdGame(look);
|
|
42
|
+
const stopProject = onProjectChange(() => publish(null));
|
|
43
|
+
look();
|
|
44
|
+
return () => { stopDocuments(); stopStd(); stopProject(); };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `volter.std-scene { level? }`: open the Scene view on `level` (a `levels/<name>.blend`), or on the level it draws. */
|
|
48
|
+
export async function openStdScene(command: EditorCommandMessage): Promise<EditorCommandResult> {
|
|
49
|
+
if (isStdGame() !== true) return { ok: false, error: 'The open project is not a std game (no std.json), so it has no Scene view.' };
|
|
50
|
+
const asked = command['level'];
|
|
51
|
+
if (asked !== undefined && (typeof asked !== 'string' || !LEVEL.test(asked))) {
|
|
52
|
+
return { ok: false, error: `std-scene opens a level's Blender file, levels/<name>.blend; it was given ${JSON.stringify(asked)}.` };
|
|
53
|
+
}
|
|
54
|
+
const at = asked ?? level;
|
|
55
|
+
if (at === null) return { ok: false, error: "No std level is open: open a level's .blend (levels/<name>.blend), or name one as { level }." };
|
|
56
|
+
publish(at);
|
|
57
|
+
if (!(await editorHost().workspace.open({ kind: 'tool', id: STD_SCENE_DOCUMENT }))) return { ok: false, error: `${STD_SCENE_DOCUMENT} is not registered.` };
|
|
58
|
+
return { ok: true, data: { level: at } };
|
|
59
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A STD GAME'S VIEW BAR, at the top of the play stage's overlay over the viewport (`std-game.ts`): Edit, each Player N
|
|
3
|
+
* labelled "you" or "autopilot", Grid, and Players [-][+], each one a client joining or leaving. A press on it gives the
|
|
4
|
+
* keyboard back to the editor, so the view keys work again while a player's page held it. And the grid's tiles, each
|
|
5
|
+
* a button that opens its view. Drawn with the play area's own widget (`PlayAreaButton`).
|
|
6
|
+
*/
|
|
7
|
+
import { useSyncExternalStore } from 'react';
|
|
8
|
+
import { editorBlender } from './editor-blender';
|
|
9
|
+
import { addStdPlayer, removeStdPlayer, setStdView, stdRun, stdRunVersion, stdTile, subscribeStdRun, type StdPlayer, type StdRun } from './std-game';
|
|
10
|
+
|
|
11
|
+
function useRun(): StdRun | null {
|
|
12
|
+
useSyncExternalStore(subscribeStdRun, stdRunVersion, stdRunVersion);
|
|
13
|
+
return stdRun();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Who drives a player, as its button says it. */
|
|
17
|
+
function driver(run: StdRun, player: StdPlayer, index: number): string {
|
|
18
|
+
if (player.device === null) return 'joining';
|
|
19
|
+
if (player.autopilot) return 'autopilot';
|
|
20
|
+
return run.view === index ? 'you' : 'idle';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function StdViewBar() {
|
|
24
|
+
const run = useRun();
|
|
25
|
+
if (!run) return null;
|
|
26
|
+
const { PlayAreaButton, timelineGeometry: { TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
|
|
27
|
+
const { players, view } = run;
|
|
28
|
+
return (
|
|
29
|
+
<div role="toolbar" aria-label="Views" data-testid="std-view-bar" tabIndex={-1}
|
|
30
|
+
onPointerDown={(event) => event.currentTarget.focus()}
|
|
31
|
+
style={{ outline: 'none', margin: '4px auto', width: 'max-content', display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap,
|
|
32
|
+
padding: '2px 6px', pointerEvents: 'auto', background: TIMELINE_THEME.header, color: TIMELINE_CHROME.text,
|
|
33
|
+
border: `1px solid ${TIMELINE_CHROME.rule}`, borderRadius: TIMELINE_CHROME.widgetRadius, font: '11px Inter, system-ui, sans-serif' }}>
|
|
34
|
+
<div style={{ display: 'flex' }} role="group" aria-label="View">
|
|
35
|
+
<PlayAreaButton testId="std-view-edit" label="Edit the level while the game runs (Escape)" pressed={view === 'edit'} wide onClick={() => setStdView('edit')}>
|
|
36
|
+
Edit
|
|
37
|
+
</PlayAreaButton>
|
|
38
|
+
{players.map((player, index) => (
|
|
39
|
+
<PlayAreaButton key={player.slot} testId={`std-view-player-${player.number}`} wide pressed={view === index}
|
|
40
|
+
label={`Player ${player.number}, ${driver(run, player, index)}${index < 4 ? ` (${index + 1})` : ''}`} onClick={() => setStdView(index)}>
|
|
41
|
+
{`Player ${player.number} · ${driver(run, player, index)}`}
|
|
42
|
+
</PlayAreaButton>
|
|
43
|
+
))}
|
|
44
|
+
<PlayAreaButton testId="std-view-grid" label="Grid: every view tiled (Tab cycles the views)" pressed={view === 'grid'} wide onClick={() => setStdView('grid')}>
|
|
45
|
+
Grid
|
|
46
|
+
</PlayAreaButton>
|
|
47
|
+
</div>
|
|
48
|
+
<div style={{ display: 'flex', alignItems: 'center' }} role="group" aria-label="Players">
|
|
49
|
+
<span style={{ padding: '0 4px' }}>Players</span>
|
|
50
|
+
<PlayAreaButton testId="std-players-remove" label="One player fewer: the last one's client leaves" disabled={players.length <= 1} onClick={removeStdPlayer}>
|
|
51
|
+
−
|
|
52
|
+
</PlayAreaButton>
|
|
53
|
+
<span data-testid="std-players-count" style={{ minWidth: 14, textAlign: 'center' }}>{players.length}</span>
|
|
54
|
+
<PlayAreaButton testId="std-players-add" label="One more player: a client joins" disabled={run.url === null} onClick={addStdPlayer}>
|
|
55
|
+
+
|
|
56
|
+
</PlayAreaButton>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function StdGridTiles() {
|
|
63
|
+
const run = useRun();
|
|
64
|
+
if (!run || run.view !== 'grid') return null;
|
|
65
|
+
const { timelineGeometry: { TIMELINE_CHROME } } = editorBlender();
|
|
66
|
+
const count = run.players.length + 1;
|
|
67
|
+
const tiles = [{ label: 'Edit', open: 'edit' as const }, ...run.players.map((player, index) => ({ label: `Player ${player.number} · ${driver(run, player, index)}`, open: index }))];
|
|
68
|
+
return tiles.map(({ label, open }, tile) => {
|
|
69
|
+
const box = stdTile(tile, count);
|
|
70
|
+
return <button key={label} type="button" data-testid={`std-tile-${tile}`} title={`Open ${label}`} onClick={() => setStdView(open)}
|
|
71
|
+
style={{ position: 'absolute', left: `${box.left * 100}%`, top: `${box.top * 100}%`, width: `${box.width * 100}%`, height: `${box.height * 100}%`, pointerEvents: 'auto', background: 'transparent', cursor: 'pointer',
|
|
72
|
+
border: `1px solid ${TIMELINE_CHROME.rule}`, display: 'flex', alignItems: 'flex-end', padding: 6 }}>
|
|
73
|
+
<span style={{ background: 'rgba(0, 0, 0, 0.6)', color: TIMELINE_CHROME.widgetText, padding: '2px 6px', borderRadius: TIMELINE_CHROME.widgetRadius, font: '11px Inter, system-ui, sans-serif' }}>
|
|
74
|
+
{label}
|
|
75
|
+
</span>
|
|
76
|
+
</button>;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE MODEL PLAY VERB of the session wire (`@volter/sdk/commands`, a
|
|
3
|
-
* `workspace.command` contribution): `model-play-log`, the read behind
|
|
4
|
-
* `cyclotron play-log` and `editor.modelPlayLog()` in `eval`.
|
|
5
|
-
*
|
|
6
|
-
* It answers one model document's play log (`../src/play-log.ts`) — `documentId`'s, or the
|
|
7
|
-
* active Play's when omitted; the current run's, or the last one's after Stop — filtered by
|
|
8
|
-
* `since` (simulation seconds, inclusive) and `kind`. A read
|
|
9
|
-
* only: no play gate, since an empty log is the honest answer when nothing has played.
|
|
10
|
-
*/
|
|
11
|
-
import type { CommandContribution } from '@volter/sdk/commands';
|
|
12
|
-
import { readModelPlayLog } from '../src/play-log';
|
|
13
|
-
|
|
14
|
-
export const point = 'workspace.command';
|
|
15
|
-
|
|
16
|
-
export const commands: CommandContribution['commands'] = {
|
|
17
|
-
'model-play-log': {
|
|
18
|
-
derivedRefresh: 'none',
|
|
19
|
-
handle: (command) => {
|
|
20
|
-
const since = command['since'];
|
|
21
|
-
const kind = command['kind'];
|
|
22
|
-
const documentId = command['documentId'];
|
|
23
|
-
if (documentId !== undefined && typeof documentId !== 'string')
|
|
24
|
-
return { ok: false, error: `model-play-log's documentId is a model document id, a string; it was given ${JSON.stringify(documentId)}.` };
|
|
25
|
-
if (since !== undefined && (typeof since !== 'number' || !Number.isFinite(since)))
|
|
26
|
-
return { ok: false, error: `model-play-log's since is simulation seconds, a number; it was given ${JSON.stringify(since)}.` };
|
|
27
|
-
if (kind !== undefined && typeof kind !== 'string')
|
|
28
|
-
return { ok: false, error: `model-play-log's kind is an entry kind, a string; it was given ${JSON.stringify(kind)}.` };
|
|
29
|
-
const reading = readModelPlayLog({
|
|
30
|
-
...(documentId === undefined ? {} : { documentId }),
|
|
31
|
-
...(since === undefined ? {} : { since }),
|
|
32
|
-
...(kind === undefined ? {} : { kind }),
|
|
33
|
-
});
|
|
34
|
-
return { ok: true, data: { ...reading } };
|
|
35
|
-
},
|
|
36
|
-
},
|
|
37
|
-
};
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
/** Model script tool, available to every product through project packages. */
|
|
2
|
-
import { useSyncExternalStore } from 'react';
|
|
3
|
-
import { Button, EditorIcon, editorIcons, MenuItem } from '@volter/sdk/widgets';
|
|
4
|
-
import { editorHost } from '@volter/sdk/host';
|
|
5
|
-
import { registerDocumentPlayExtension, type DocumentPlayControlProps } from '@volter/sdk/kit/document-play-extension';
|
|
6
|
-
import {
|
|
7
|
-
armModelPlayAutoplay,
|
|
8
|
-
escapeModelPlay,
|
|
9
|
-
MODEL_PLAY_SPEEDS,
|
|
10
|
-
modelPlayAutoplay,
|
|
11
|
-
modelPlayClock,
|
|
12
|
-
modelPlayGeneration,
|
|
13
|
-
modelPlaying,
|
|
14
|
-
restartModelPlay,
|
|
15
|
-
setModelPlayAutoplay,
|
|
16
|
-
setModelPlayFailure,
|
|
17
|
-
setModelPlayPaused,
|
|
18
|
-
setModelPlaying,
|
|
19
|
-
setModelPlaySpeed,
|
|
20
|
-
stepModelPlay,
|
|
21
|
-
subscribeModelPlay,
|
|
22
|
-
subscribeModelPlayClock,
|
|
23
|
-
} from '../src/model-play';
|
|
24
|
-
import { playScriptPath, runPlayScript } from '../src/play-script';
|
|
25
|
-
import { beginModelPlayLog, subscribeModelPlayLog, tailModelPlayLog } from '../src/play-log';
|
|
26
|
-
import type * as THREE from 'three';
|
|
27
|
-
import { getCurrentProject, onProjectChange } from '@volter/sdk/kit/active-project';
|
|
28
|
-
|
|
29
|
-
export const point = 'workspace.service';
|
|
30
|
-
function usePlaying(documentId: string | undefined): boolean {
|
|
31
|
-
return useSyncExternalStore(subscribeModelPlay, () => documentId ? modelPlaying(documentId) : false, () => false);
|
|
32
|
-
}
|
|
33
|
-
// Kept because `DocumentPlayExtension` requires `Control` and `Menu` for a layout that draws Play in its header; Cyclotron's layout no longer does (its Play is the Game panel's).
|
|
34
|
-
function Control({ documentId, onClose }: DocumentPlayControlProps) {
|
|
35
|
-
const playing = usePlaying(documentId);
|
|
36
|
-
return <Button size="compact" data-testid="model-play-button" aria-pressed={playing}
|
|
37
|
-
disabled={!documentId} title={playing ? 'Stop playing (Escape)' : 'Play this model’s script'}
|
|
38
|
-
onClick={() => { onClose(); if (documentId) setModelPlaying(documentId, !playing); }}>
|
|
39
|
-
<EditorIcon icon={playing ? editorIcons.transport.stop : editorIcons.transport.play} />
|
|
40
|
-
{playing ? 'Stop' : 'Play'}
|
|
41
|
-
</Button>;
|
|
42
|
-
}
|
|
43
|
-
function Menu({ documentId, onClose }: DocumentPlayControlProps) {
|
|
44
|
-
const playing = usePlaying(documentId);
|
|
45
|
-
return <MenuItem data-testid="model-play" disabled={!documentId}
|
|
46
|
-
onSelect={() => { onClose(); if (documentId) setModelPlaying(documentId, !playing); }}>
|
|
47
|
-
{playing ? 'Stop' : 'Play'}
|
|
48
|
-
</MenuItem>;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* WHETHER A MODEL HAS A PLAY SCRIPT — `src/models/canyon.blend` has one when
|
|
53
|
-
* `src/models/canyon.play.ts` exists (`playScriptPath`), the same file Play imports. Asked once
|
|
54
|
-
* per path through the project's files door and then kept, corrected by that door's change
|
|
55
|
-
* events, so a layout can open a model with a script as a game and one without as a model. A
|
|
56
|
-
* project switch forgets every answer.
|
|
57
|
-
*/
|
|
58
|
-
const scripts = new Map<string, boolean>();
|
|
59
|
-
const looking = new Set<string>();
|
|
60
|
-
const scriptListeners = new Set<() => void>();
|
|
61
|
-
function publishScripts(): void {
|
|
62
|
-
for (const listener of [...scriptListeners]) listener();
|
|
63
|
-
}
|
|
64
|
-
function projectPath(path: string): string {
|
|
65
|
-
return path.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
66
|
-
}
|
|
67
|
-
function hasScript(sourcePath: string): boolean | null {
|
|
68
|
-
const path = projectPath(playScriptPath(sourcePath));
|
|
69
|
-
const known = scripts.get(path);
|
|
70
|
-
if (known !== undefined) return known;
|
|
71
|
-
if (!looking.has(path)) {
|
|
72
|
-
looking.add(path);
|
|
73
|
-
const project = getCurrentProject()?.rootPath;
|
|
74
|
-
void editorHost().files.exists(path).then(exists => exists, () => false).then(exists => {
|
|
75
|
-
looking.delete(path);
|
|
76
|
-
// An answer about the previous project is no answer about this one.
|
|
77
|
-
if (getCurrentProject()?.rootPath !== project) return;
|
|
78
|
-
scripts.set(path, exists);
|
|
79
|
-
publishScripts();
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function start(): () => void {
|
|
86
|
-
const stopWatching = (() => {
|
|
87
|
-
try {
|
|
88
|
-
return editorHost().files.watch((event) => {
|
|
89
|
-
const path = projectPath(event.path);
|
|
90
|
-
if (!scripts.has(path) && !path.endsWith('.play.ts')) return;
|
|
91
|
-
const exists = event.type !== 'remove';
|
|
92
|
-
if (scripts.get(path) === exists) return;
|
|
93
|
-
scripts.set(path, exists);
|
|
94
|
-
publishScripts();
|
|
95
|
-
});
|
|
96
|
-
} catch {
|
|
97
|
-
// A host without a files door still plays; the answer is then read once per path.
|
|
98
|
-
return () => {};
|
|
99
|
-
}
|
|
100
|
-
})();
|
|
101
|
-
const stopProject = onProjectChange(() => { scripts.clear(); publishScripts(); });
|
|
102
|
-
const unregister = registerDocumentPlayExtension('model', {
|
|
103
|
-
Control, Menu, playing: modelPlaying, setPlaying: setModelPlaying, escape: escapeModelPlay,
|
|
104
|
-
subscribe(listener) {
|
|
105
|
-
const stopPlay = subscribeModelPlay(listener), stopProject = onProjectChange(listener);
|
|
106
|
-
scriptListeners.add(listener);
|
|
107
|
-
return () => { stopPlay(); stopProject(); scriptListeners.delete(listener); };
|
|
108
|
-
},
|
|
109
|
-
aspectRatio() {
|
|
110
|
-
const size = getCurrentProject()?.config.resolution;
|
|
111
|
-
const ratio = size ? size.width / size.height : null;
|
|
112
|
-
return ratio !== null && Number.isFinite(ratio) && ratio > 0 ? ratio : null;
|
|
113
|
-
},
|
|
114
|
-
run(stage) {
|
|
115
|
-
// The document kind lends native scene objects; this tool owns their Three types.
|
|
116
|
-
return runPlayScript({ ...stage, blend: stage.sourcePath, root: stage.root as THREE.Object3D,
|
|
117
|
-
camera: stage.camera as () => THREE.Camera, editingCamera: stage.editingCamera as () => THREE.Camera,
|
|
118
|
-
ownMaterial: stage.ownMaterial as ((material: THREE.Material) => THREE.Material | null) | undefined });
|
|
119
|
-
},
|
|
120
|
-
// THE RUN'S TRANSPORT (`model-play.ts`): the runner reads all of it each frame.
|
|
121
|
-
transport: {
|
|
122
|
-
speeds: MODEL_PLAY_SPEEDS,
|
|
123
|
-
clock: modelPlayClock,
|
|
124
|
-
subscribeClock: subscribeModelPlayClock,
|
|
125
|
-
setPaused: setModelPlayPaused,
|
|
126
|
-
step: stepModelPlay,
|
|
127
|
-
setSpeed: setModelPlaySpeed,
|
|
128
|
-
restart: restartModelPlay,
|
|
129
|
-
generation: modelPlayGeneration,
|
|
130
|
-
autoplay: modelPlayAutoplay,
|
|
131
|
-
setAutoplay: setModelPlayAutoplay,
|
|
132
|
-
armAutoplay: armModelPlayAutoplay,
|
|
133
|
-
// NO STAGE, SO NO RUNNER, and until 2026-10-06 the document turned Play off and wrote the
|
|
134
|
-
// reason to the console: Play flicked on and off with nothing said where anyone looks.
|
|
135
|
-
// Now the run stands with its failure — the Game panel draws it, the log records it.
|
|
136
|
-
fail(documentId, sourcePath, failure) {
|
|
137
|
-
const run = beginModelPlayLog(documentId, playScriptPath(sourcePath));
|
|
138
|
-
run.append('play', 'script-error', { phase: 'stage', message: failure });
|
|
139
|
-
run.end({ reason: 'stage-failed' });
|
|
140
|
-
setModelPlayFailure(documentId, failure);
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
log: { tail: tailModelPlayLog, subscribe: subscribeModelPlayLog },
|
|
144
|
-
scriptPath: playScriptPath,
|
|
145
|
-
hasScript,
|
|
146
|
-
});
|
|
147
|
-
return () => {
|
|
148
|
-
unregister();
|
|
149
|
-
stopProject();
|
|
150
|
-
stopWatching();
|
|
151
|
-
};
|
|
152
|
-
}
|
package/src/camera-transition.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import * as THREE from 'three';
|
|
2
|
-
|
|
3
|
-
function pose(camera: THREE.Camera) {
|
|
4
|
-
camera.updateWorldMatrix(true, false);
|
|
5
|
-
return {
|
|
6
|
-
position: camera.getWorldPosition(new THREE.Vector3()),
|
|
7
|
-
quaternion: camera.getWorldQuaternion(new THREE.Quaternion()),
|
|
8
|
-
projection: camera.projectionMatrix.clone(),
|
|
9
|
-
fov: (camera as THREE.PerspectiveCamera).fov,
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
|
-
type Pose = ReturnType<typeof pose>;
|
|
13
|
-
|
|
14
|
-
/** The tool blends AFTER the script has stated its complete camera pose.
|
|
15
|
-
* Projection matrices also interpolate, preserving an orthographic editing
|
|
16
|
-
* view exactly at the handoff to a perspective game. Neither editing camera
|
|
17
|
-
* nor its orbit target is ever written.
|
|
18
|
-
*
|
|
19
|
-
* `instant` enters already arrived: a Restart replaces a game that was on screen a frame ago,
|
|
20
|
-
* and flying in again from the editing pose would show the model between two games. The return
|
|
21
|
-
* on Stop still blends. */
|
|
22
|
-
export function cameraTransition(editingCamera: THREE.Camera, options?: { readonly instant?: boolean }) {
|
|
23
|
-
const editing = pose(editingCamera);
|
|
24
|
-
let phase: 'entering' | 'playing' | 'leaving' = options?.instant ? 'playing' : 'entering';
|
|
25
|
-
let elapsed = 0;
|
|
26
|
-
let last = editing;
|
|
27
|
-
let leavingFrom = editing;
|
|
28
|
-
const duration = 0.8;
|
|
29
|
-
const position = new THREE.Vector3();
|
|
30
|
-
const quaternion = new THREE.Quaternion();
|
|
31
|
-
function blend(camera: THREE.Camera, from: Pose, to: Pose, t: number): void {
|
|
32
|
-
const eased = t * t * (3 - 2 * t);
|
|
33
|
-
position.lerpVectors(from.position, to.position, eased);
|
|
34
|
-
quaternion.slerpQuaternions(from.quaternion, to.quaternion, eased);
|
|
35
|
-
camera.position.copy(position);
|
|
36
|
-
camera.quaternion.copy(quaternion);
|
|
37
|
-
if (camera.parent) {
|
|
38
|
-
camera.parent.worldToLocal(camera.position);
|
|
39
|
-
const parentRotation = camera.parent.getWorldQuaternion(new THREE.Quaternion());
|
|
40
|
-
camera.quaternion.premultiply(parentRotation.invert());
|
|
41
|
-
}
|
|
42
|
-
if (Number.isFinite(from.fov) && Number.isFinite(to.fov)) {
|
|
43
|
-
(camera as THREE.PerspectiveCamera).fov = THREE.MathUtils.lerp(from.fov, to.fov, eased);
|
|
44
|
-
}
|
|
45
|
-
for (let i = 0; i < 16; i++) {
|
|
46
|
-
camera.projectionMatrix.elements[i] = THREE.MathUtils.lerp(from.projection.elements[i]!, to.projection.elements[i]!, eased);
|
|
47
|
-
}
|
|
48
|
-
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
|
|
49
|
-
camera.updateMatrixWorld(true);
|
|
50
|
-
last = pose(camera);
|
|
51
|
-
}
|
|
52
|
-
return {
|
|
53
|
-
acceptingKeys: () => phase === 'playing',
|
|
54
|
-
leaving: () => phase === 'leaving',
|
|
55
|
-
hudOpacity: () => phase === 'playing' ? 1 : phase === 'entering'
|
|
56
|
-
? Math.min(1, elapsed / duration)
|
|
57
|
-
: Math.max(0, 1 - elapsed / (duration - 0.16)),
|
|
58
|
-
approachingEdit: () => phase === 'leaving' && elapsed >= duration - 0.16,
|
|
59
|
-
/** A PAUSED frame: put the camera back where the last drawn frame had it. Nothing else
|
|
60
|
-
* states the pose while the game's update is held, and the stage's navigation runs before
|
|
61
|
-
* this hook each frame; the blend's own clock stands still with the game's. */
|
|
62
|
-
hold(camera: THREE.Camera): void {
|
|
63
|
-
blend(camera, last, last, 1);
|
|
64
|
-
},
|
|
65
|
-
/** Escape completes an existing blend; otherwise begin the return. */
|
|
66
|
-
stop(escape: boolean): boolean {
|
|
67
|
-
if (escape && phase === 'entering') { phase = 'playing'; return false; }
|
|
68
|
-
if (escape && phase === 'leaving') return true;
|
|
69
|
-
if (phase !== 'leaving') {
|
|
70
|
-
leavingFrom = last;
|
|
71
|
-
elapsed = 0;
|
|
72
|
-
phase = 'leaving';
|
|
73
|
-
}
|
|
74
|
-
return false;
|
|
75
|
-
},
|
|
76
|
-
frame(camera: THREE.Camera, dt: number): boolean {
|
|
77
|
-
if (phase === 'playing') { last = pose(camera); return false; }
|
|
78
|
-
const t = Math.min(1, elapsed / duration);
|
|
79
|
-
blend(camera, phase === 'entering' ? editing : leavingFrom, phase === 'entering' ? pose(camera) : editing, t);
|
|
80
|
-
elapsed += dt;
|
|
81
|
-
if (t < 1) return false;
|
|
82
|
-
if (phase === 'leaving') return true;
|
|
83
|
-
phase = 'playing';
|
|
84
|
-
return false;
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
}
|