@volter/play 0.5.211 → 0.5.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
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 a level'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
+ }
34
+
35
+ /** A level's Blender file (`levels/<name>.blend`), whose export writes its component. */
36
+ export const BLEND_LEVEL = /^levels\/.+\.blend$/;
37
+
38
+ /** A level in code: `levels/<Name>Level.tsx`, its component the file's export of that name (std's starter's own level),
39
+ * where no level's Blender file in that folder writes it. */
40
+ export const CODE_LEVEL = /^levels\/(?:[^/]+\/)*([A-Z][A-Za-z0-9]*Level)\.tsx$/;
41
+
42
+ /** The level a level's document draws for a file of the game's (from its folder, among `files`, the game's file list): a
43
+ * level's Blender file as itself; a level's component as the Blender file that writes it where one does, else as a
44
+ * level in code; anything else none. */
45
+ export function sceneLevelOf(file: string, files: readonly string[]): string | null {
46
+ if (BLEND_LEVEL.test(file)) return file;
47
+ if (!CODE_LEVEL.test(file)) return null;
48
+ const writer = files.find((other) => BLEND_LEVEL.test(other) && levelFiles(other).component === file);
49
+ return writer ?? file;
50
+ }
@@ -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-scene <glb>`, runs in the game's folder
12
+ * (`../serving/std-dev.ts`, its `/level` route). std's dev server takes the level from there, its rooms reloaded by
13
+ * Colyseus's own dev mode. 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-scene ${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,68 @@
1
+ /**
2
+ * A STD GAME'S VIEW BAR, at the top of the Game view (`game-view.tsx`, `std-game.ts`): Scene (the current level,
3
+ * live), each Player N labelled "you" or "autopilot", and Grid (how many play is the Game panel's Players, beside Play). A press on it gives
4
+ * the 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 { 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-scene" label="Scene: the current level, live" pressed={view === 'scene'} wide onClick={() => setStdView('scene')}>
36
+ Scene
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>
49
+ );
50
+ }
51
+
52
+ export function StdGridTiles() {
53
+ const run = useRun();
54
+ if (!run || run.view !== 'grid') return null;
55
+ const { timelineGeometry: { TIMELINE_CHROME } } = editorBlender();
56
+ const count = run.players.length + 1;
57
+ const tiles = [{ label: 'Scene', open: 'scene' as const }, ...run.players.map((player, index) => ({ label: `Player ${player.number} · ${driver(run, player, index)}`, open: index }))];
58
+ return tiles.map(({ label, open }, tile) => {
59
+ const box = stdTile(tile, count);
60
+ return <button key={label} type="button" data-testid={`std-tile-${tile}`} title={`Open ${label}`} onClick={() => setStdView(open)}
61
+ 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',
62
+ border: `1px solid ${TIMELINE_CHROME.rule}`, display: 'flex', alignItems: 'flex-end', padding: 6 }}>
63
+ <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' }}>
64
+ {label}
65
+ </span>
66
+ </button>;
67
+ });
68
+ }
@@ -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
- }
@@ -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
- }