@volter/editor-model-play 0.5.190 → 0.5.191
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/README.md +125 -11
- package/contributions/model-play.command.ts +1 -1
- package/contributions/model-play.service.tsx +106 -4
- package/package.json +2 -2
- package/src/camera-transition.ts +13 -3
- package/src/model-play.ts +253 -10
- package/src/play-log.ts +54 -4
- package/src/play-materials.ts +33 -7
- package/src/play-script.ts +272 -22
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Volter Model Play
|
|
2
2
|
|
|
3
|
-
Play scripts on detached model documents.
|
|
3
|
+
Play scripts on detached model documents. Play (the Game panel's, in Cyclotron) runs
|
|
4
4
|
`src/models/<name>.play.ts` beside `src/models/<name>.blend` on a detached copy
|
|
5
5
|
of the model; the model, its selection and its history stand as they were, and
|
|
6
6
|
Stop returns to them. The script's default export is called once with the play
|
|
@@ -29,26 +29,35 @@ export default (play: ModelPlayContext): ModelPlayGame => {
|
|
|
29
29
|
|
|
30
30
|
`play.log(kind, facts?)` writes one entry, stamped with `simT` (seconds of
|
|
31
31
|
simulation since Play started, the sum of the `dt`s given to `update`) and
|
|
32
|
-
`tick` (the update in progress). Facts are snapshotted as JSON when logged.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
`tick` (the update in progress). Facts are snapshotted as JSON when logged. `simT` and `tick` are the Game panel's
|
|
33
|
+
clock: they stand still while paused and run at the speed (see Time below). The
|
|
34
|
+
runner adds `play-start`, `play-restart` (`{ speed }`, right after a Restart's
|
|
35
|
+
`play-start`), `script-reload` (`{ reason: 'saved' | 'dependency-deleted', path }`),
|
|
36
|
+
`script-error` (`{ phase, message }`), `tint-unsupported` (`{ object, material, why }`),
|
|
37
|
+
`tint-unknown-object` (`{ object, call }`, once per name: `tint` or `setOpacity` was given a
|
|
38
|
+
name the model has none of, and did nothing), `autoplay-on` / `autoplay-off`
|
|
39
|
+
(`{ by: 'panel' | 'cli' | 'takeover' | 'script' }`, see Autoplay below),
|
|
40
|
+
`autoplay-unavailable` (`{ why }`, when a script's first update has run and it registered no
|
|
41
|
+
bot; once per run, and again only if a bot came and went),
|
|
42
|
+
`pause`, `resume`, `step` (`{ dt }`, one per stepped update), `speed`
|
|
43
|
+
(`{ speed, from }` on a change; `{ speed }` at a start that is not 1×) and
|
|
44
|
+
`play-stop` (`{ reason }`). Log transitions rather than every frame. Logging
|
|
36
45
|
never throws and never changes timing or state; a script's `log`, `tint` and
|
|
37
46
|
`setOpacity` do nothing once it has been replaced or Play has stopped.
|
|
38
47
|
|
|
39
48
|
Each model document keeps its own log; a read takes the active Play's unless it
|
|
40
49
|
names a document. The newest 5000 entries are kept (older ones are counted as
|
|
41
50
|
`dropped`), each entry's facts up to 2048 characters of JSON. Play from a fresh
|
|
42
|
-
copy
|
|
51
|
+
copy, and Restart, empty the document's log; a script reload does not, and the log stays readable after Stop until the
|
|
43
52
|
next Play. `console.log` does not reach the session's console feed (only
|
|
44
53
|
warnings and errors do); the play log does.
|
|
45
54
|
|
|
46
55
|
```sh
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
cyclotron play-log # every kept entry, one per line
|
|
57
|
+
cyclotron play-log --kind death # one kind
|
|
58
|
+
cyclotron play-log --since 12.5 --json # entries at or after simT 12.5, as JSON
|
|
59
|
+
cyclotron play-log --document <id> # another model document's log
|
|
60
|
+
cyclotron eval "(await editor.modelPlayLog({ kind: 'death' })).entries"
|
|
52
61
|
```
|
|
53
62
|
|
|
54
63
|
## Recolouring the copy
|
|
@@ -69,3 +78,108 @@ object wears copies of its own slots that draw the material's constant inputs
|
|
|
69
78
|
across a script reload. An object with a material the document cannot copy
|
|
70
79
|
(one the script made itself) is left as it is, and the log says so once with
|
|
71
80
|
`tint-unsupported`.
|
|
81
|
+
|
|
82
|
+
## Autoplay
|
|
83
|
+
|
|
84
|
+
A game offers its bot; the editor decides whether it drives.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
play.autoplay(({ dt, simT, tick, keys }) => {
|
|
88
|
+
// the game's own decision, from its own state
|
|
89
|
+
return car.speed < 20 ? ['ArrowUp'] : [];
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The controller is a plain function. While autoplay is on it is called before each `update`
|
|
94
|
+
with that update's `dt`, `simT` and `tick` (the stamps that update's log entries carry) and
|
|
95
|
+
`keys`, the keys the person holds; it returns the `KeyboardEvent.code` keys the bot holds for
|
|
96
|
+
that update (any iterable, or nothing). The runner merges them into `play.keys`, so the bot
|
|
97
|
+
drives through the script's own input code exactly as a person does. One bot per script:
|
|
98
|
+
registering again replaces it, `play.autoplay(null)` withdraws it, and it goes with the script
|
|
99
|
+
on a reload or Stop. The bot is called only inside the editor's Play runner; a game run
|
|
100
|
+
anywhere else never drives itself.
|
|
101
|
+
|
|
102
|
+
Whether it drives is the editor's:
|
|
103
|
+
|
|
104
|
+
- Autoplay is **off** whenever Play starts or restarts.
|
|
105
|
+
- Only the Game panel's **Autoplay** toggle, `cyclotron play autoplay on|off` or
|
|
106
|
+
`await editor.command('volter.model-play.autoplay', { on: true })` turn it on (or off).
|
|
107
|
+
- **A bot exists only while the game runs**: `play.autoplay` is called by the play script, so
|
|
108
|
+
a stopped game (or one still starting) has none, and neither has a running script that never
|
|
109
|
+
registers one. The panel says which, in words beside the toggle — "Available once the game is
|
|
110
|
+
running" or "No autoplay — this game doesn't provide a bot" — and `on` is refused with the
|
|
111
|
+
same reason, which `play state` also gives as `autoplay.why`.
|
|
112
|
+
- **Arming.** Pressed while stopped, the panel's Autoplay arms the next start: autoplay turns
|
|
113
|
+
on (`by: 'panel'`) as soon as that run's script has run its first update with a bot, and the
|
|
114
|
+
arm is dropped if it offers none. A person's key or click in the game before then drops it
|
|
115
|
+
too (`autoplay-off` with `{ by: 'takeover', armed: true }`): the person always wins. Only an
|
|
116
|
+
explicit arm carries over; Stop, and closing the model, drop it.
|
|
117
|
+
- **The person always wins.** A new key press the game would hear, or a pointer pressed in the
|
|
118
|
+
game's area (the HUD included), turns autoplay off before that key reaches `play.keys`, and
|
|
119
|
+
the panel reads "You're driving" until someone switches the bot on again. Synthetic keys and
|
|
120
|
+
clicks (`editor.document.key`, `click`) count as a person's: they are how an agent plays by
|
|
121
|
+
hand. A key repeat is not a new press; keys typed in a text field, or while the game's surface
|
|
122
|
+
does not hold the keyboard, are not the game's and do not take over.
|
|
123
|
+
- A bot that throws turns autoplay off (`by: 'script'`, with a `script-error` of phase
|
|
124
|
+
`autoplay`), as does a reload whose script offers no bot.
|
|
125
|
+
|
|
126
|
+
Each change is a play-log entry, `autoplay-on` or `autoplay-off`, with `by`.
|
|
127
|
+
|
|
128
|
+
## Time
|
|
129
|
+
|
|
130
|
+
`update(dt)` is the only clock a play script is given, and the editor decides it:
|
|
131
|
+
|
|
132
|
+
| Control | What the script sees |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| Playing at 1× | One `update` per drawn frame, `dt` = the frame's seconds (at most 0.1). |
|
|
135
|
+
| Speed 0.25× – 4× | `dt` scaled by the speed. A scaled frame longer than 0.1 s is split into equal updates, so no single `dt` exceeds 0.1. |
|
|
136
|
+
| Pause | No `update` at all. The copy, the camera and the HUD hold the last frame. |
|
|
137
|
+
| Step (while paused) | One `update` with `dt` = 1/60. |
|
|
138
|
+
| Restart | The run begins again on a fresh detached copy, the clock at zero, without the camera's fly-in. |
|
|
139
|
+
|
|
140
|
+
A script that reads the page's own clock (`performance.now()`, `Date.now()`) instead of
|
|
141
|
+
summing `dt` is outside the reach of pause and speed.
|
|
142
|
+
|
|
143
|
+
The run's clock — simulation time (the sum of the `dt`s handed to the script) and tick (the
|
|
144
|
+
number of updates) — is kept per document beside whether it plays (`src/model-play.ts`).
|
|
145
|
+
Speed is kept for the page's life; pause and the clock reset on every Play.
|
|
146
|
+
When the run plays but no game runs — the script failed to start, or threw — the clock's
|
|
147
|
+
`failure` says why (the Game panel shows it in place of "Playing"); a save of the script
|
|
148
|
+
retries, and the next running game clears it.
|
|
149
|
+
|
|
150
|
+
## Controls
|
|
151
|
+
|
|
152
|
+
The tool registers the `model` document Play extension
|
|
153
|
+
(`@volter/editor-sdk/kit/document-play-extension`). Beside Play and Stop it offers:
|
|
154
|
+
|
|
155
|
+
- `transport` — `setPaused`, `step`, `setSpeed`, `restart`, the `clock` and its own
|
|
156
|
+
subscription, the offered `speeds`, and the restart `generation` the document keys its
|
|
157
|
+
detached copy on.
|
|
158
|
+
- `transport.autoplay(documentId)` and `transport.setAutoplay(documentId, on, by)` — the bot's
|
|
159
|
+
switch: `{ on, available, by, armed }`, announced through `subscribeClock`;
|
|
160
|
+
`transport.armAutoplay(documentId, armed)` arms it, while stopped, for the next start.
|
|
161
|
+
- `log` — `tail(documentId, last, kind?)` (the newest entries and every kind the run wrote) and
|
|
162
|
+
`subscribe`, which the Game panel draws live.
|
|
163
|
+
- `scriptPath(sourcePath)` and `hasScript(sourcePath)` — where a model's play script goes and
|
|
164
|
+
whether it exists, so a layout can open a model with a script as a game.
|
|
165
|
+
|
|
166
|
+
In Cyclotron these are drawn by the Game panel (`@volter/editor-blender`): the Game /
|
|
167
|
+
Movie switch at the left of the bottom area's header puts it there in place of the Timeline. The panel's controls
|
|
168
|
+
are also commands, `volter.model-play.<verb>`, so an agent drives the same run the person
|
|
169
|
+
sees:
|
|
170
|
+
|
|
171
|
+
| Command | Arguments |
|
|
172
|
+
| --- | --- |
|
|
173
|
+
| `volter.model-play.state` | — |
|
|
174
|
+
| `volter.model-play.play` / `stop` | — (`play` also switches the document to Game mode) |
|
|
175
|
+
| `volter.model-play.pause` / `resume` | — |
|
|
176
|
+
| `volter.model-play.step` | `{ count?: 1–600 }`, while paused |
|
|
177
|
+
| `volter.model-play.speed` | `{ speed: 0.25 \| 0.5 \| 1 \| 2 \| 4 }` |
|
|
178
|
+
| `volter.model-play.restart` | — |
|
|
179
|
+
| `volter.model-play.mode` | `{ mode?: 'game' \| 'movie' }` |
|
|
180
|
+
| `volter.model-play.autoplay` | `{ on: boolean }` — the game's bot drives, or the person does |
|
|
181
|
+
|
|
182
|
+
Each takes an optional `document` (the model document's id) and otherwise acts on the model
|
|
183
|
+
document on screen; each answers with the panel's state. From the shell:
|
|
184
|
+
`cyclotron play pause`; under `eval`:
|
|
185
|
+
`await editor.command('volter.model-play.speed', { speed: 0.5 })`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE MODEL PLAY VERB of the session wire (`@volter/editor-sdk/commands`, a
|
|
3
3
|
* `workspace.command` contribution): `model-play-log`, the read behind
|
|
4
|
-
* `
|
|
4
|
+
* `cyclotron play-log` and `editor.modelPlayLog()` in `eval`.
|
|
5
5
|
*
|
|
6
6
|
* It answers one model document's play log (`../src/play-log.ts`) — `documentId`'s, or the
|
|
7
7
|
* active Play's when omitted; the current run's, or the last one's after Stop — filtered by
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
/** Model script tool, available to every product through project packages. */
|
|
2
2
|
import { useSyncExternalStore } from 'react';
|
|
3
3
|
import { Button, EditorIcon, editorIcons, MenuItem } from '@volter/editor-sdk/widgets';
|
|
4
|
+
import { editorHost } from '@volter/editor-sdk/host';
|
|
4
5
|
import { registerDocumentPlayExtension, type DocumentPlayControlProps } from '@volter/editor-sdk/kit/document-play-extension';
|
|
5
|
-
import {
|
|
6
|
-
|
|
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';
|
|
7
26
|
import type * as THREE from 'three';
|
|
8
27
|
import { getCurrentProject, onProjectChange } from '@volter/editor-sdk/kit/active-project';
|
|
9
28
|
|
|
@@ -11,6 +30,7 @@ export const point = 'workspace.service';
|
|
|
11
30
|
function usePlaying(documentId: string | undefined): boolean {
|
|
12
31
|
return useSyncExternalStore(subscribeModelPlay, () => documentId ? modelPlaying(documentId) : false, () => false);
|
|
13
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).
|
|
14
34
|
function Control({ documentId, onClose }: DocumentPlayControlProps) {
|
|
15
35
|
const playing = usePlaying(documentId);
|
|
16
36
|
return <Button size="compact" data-testid="model-play-button" aria-pressed={playing}
|
|
@@ -27,12 +47,64 @@ function Menu({ documentId, onClose }: DocumentPlayControlProps) {
|
|
|
27
47
|
{playing ? 'Stop' : 'Play'}
|
|
28
48
|
</MenuItem>;
|
|
29
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* WHETHER A MODEL HAS A PLAY SCRIPT — `src/models/track.blend` has one when
|
|
53
|
+
* `src/models/track.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
|
+
|
|
30
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(); });
|
|
31
102
|
const unregister = registerDocumentPlayExtension('model', {
|
|
32
103
|
Control, Menu, playing: modelPlaying, setPlaying: setModelPlaying, escape: escapeModelPlay,
|
|
33
104
|
subscribe(listener) {
|
|
34
105
|
const stopPlay = subscribeModelPlay(listener), stopProject = onProjectChange(listener);
|
|
35
|
-
|
|
106
|
+
scriptListeners.add(listener);
|
|
107
|
+
return () => { stopPlay(); stopProject(); scriptListeners.delete(listener); };
|
|
36
108
|
},
|
|
37
109
|
aspectRatio() {
|
|
38
110
|
const size = getCurrentProject()?.config.resolution;
|
|
@@ -45,6 +117,36 @@ export function start(): () => void {
|
|
|
45
117
|
camera: stage.camera as () => THREE.Camera, editingCamera: stage.editingCamera as () => THREE.Camera,
|
|
46
118
|
ownMaterial: stage.ownMaterial as ((material: THREE.Material) => THREE.Material | null) | undefined });
|
|
47
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,
|
|
48
146
|
});
|
|
49
|
-
return
|
|
147
|
+
return () => {
|
|
148
|
+
unregister();
|
|
149
|
+
stopProject();
|
|
150
|
+
stopWatching();
|
|
151
|
+
};
|
|
50
152
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/editor-model-play",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.191",
|
|
4
4
|
"author": "Volter AI, Inc.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
]
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@volter/editor-sdk": "0.5.
|
|
31
|
+
"@volter/editor-sdk": "0.5.191"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"react": "^19.0.0",
|
package/src/camera-transition.ts
CHANGED
|
@@ -14,10 +14,14 @@ type Pose = ReturnType<typeof pose>;
|
|
|
14
14
|
/** The tool blends AFTER the script has stated its complete camera pose.
|
|
15
15
|
* Projection matrices also interpolate, preserving an orthographic editing
|
|
16
16
|
* view exactly at the handoff to a perspective game. Neither editing camera
|
|
17
|
-
* nor its orbit target is ever written.
|
|
18
|
-
|
|
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 }) {
|
|
19
23
|
const editing = pose(editingCamera);
|
|
20
|
-
let phase: 'entering' | 'playing' | 'leaving' = 'entering';
|
|
24
|
+
let phase: 'entering' | 'playing' | 'leaving' = options?.instant ? 'playing' : 'entering';
|
|
21
25
|
let elapsed = 0;
|
|
22
26
|
let last = editing;
|
|
23
27
|
let leavingFrom = editing;
|
|
@@ -52,6 +56,12 @@ export function cameraTransition(editingCamera: THREE.Camera) {
|
|
|
52
56
|
? Math.min(1, elapsed / duration)
|
|
53
57
|
: Math.max(0, 1 - elapsed / (duration - 0.16)),
|
|
54
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
|
+
},
|
|
55
65
|
/** Escape completes an existing blend; otherwise begin the return. */
|
|
56
66
|
stop(escape: boolean): boolean {
|
|
57
67
|
if (escape && phase === 'entering') { phase = 'playing'; return false; }
|
package/src/model-play.ts
CHANGED
|
@@ -1,25 +1,125 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WHETHER A MODEL DOCUMENT IS PLAYING (the
|
|
2
|
+
* WHETHER A MODEL DOCUMENT IS PLAYING (Play in the Game panel, `blender-game-panel.tsx`; drawn by
|
|
3
3
|
* `blender-runtime.document.tsx`). Playing, the document's area shows a detached copy of the
|
|
4
4
|
* model (`BlenderRuntimeView.detach`) that the project's play script moves
|
|
5
5
|
* (`play-script.ts`); the model, its selection and its history stand as they were, and Stop
|
|
6
6
|
* returns to them. Kept for the page's life and never stored: a reload opens the model, not a
|
|
7
7
|
* game.
|
|
8
|
+
*
|
|
9
|
+
* AND HOW ITS CLOCK RUNS. Pause, step, speed and restart are state of the RUN, so they are kept
|
|
10
|
+
* here beside "is it playing", and the runner (`play-script.ts`) reads them on every frame: the
|
|
11
|
+
* `dt` a script's `update` receives is the only time a play script is given, so holding it is
|
|
12
|
+
* what freezes the game and scaling it is what speeds the game up. A script that reads the
|
|
13
|
+
* page's own clock (`performance.now()`, `Date.now()`) instead is outside that reach.
|
|
14
|
+
*
|
|
15
|
+
* AND WHO DRIVES. Autoplay is the editor's switch, not the game's: a script only offers a bot
|
|
16
|
+
* (`play.autoplay(controller)`, `play-script.ts`), and whether that bot drives is kept here. It
|
|
17
|
+
* is off whenever a run begins — Play, Restart, Stop — and only the Game panel's toggle or its
|
|
18
|
+
* verb (`volter.model-play.autoplay`) turns it on. The one carry-over is an ARM: a person may
|
|
19
|
+
* press Autoplay while stopped (there is no bot yet — a script offers it only once it runs), and
|
|
20
|
+
* the next start turns autoplay on the moment its script has run its first update with a bot, or
|
|
21
|
+
* drops the arm if it offers none. The runner turns it off the moment a person
|
|
22
|
+
* presses a key or touches the game (`takeover`), or when the running script stops offering a
|
|
23
|
+
* bot (`script`). Its changes are announced through the clock's subscription, which the panel
|
|
24
|
+
* and the runner already hold.
|
|
8
25
|
*/
|
|
9
26
|
const listeners = new Set<() => void>();
|
|
27
|
+
const clockListeners = new Set<() => void>();
|
|
10
28
|
const playing = new Set<string>();
|
|
11
|
-
const stops = new Map<string, (escape: boolean) => void>();
|
|
29
|
+
const stops = new Map<string, { readonly stop: (escape: boolean) => void; readonly generation: number }>();
|
|
30
|
+
|
|
31
|
+
/** The speeds the transport offers, slowest first (simulation seconds per real second). */
|
|
32
|
+
export const MODEL_PLAY_SPEEDS: readonly number[] = [0.25, 0.5, 1, 2, 4];
|
|
33
|
+
/** The `dt` one Step hands the game: one nominal 60 Hz frame, whatever the speed. */
|
|
34
|
+
export const MODEL_PLAY_STEP_SECONDS = 1 / 60;
|
|
35
|
+
|
|
36
|
+
export interface ModelPlayClock {
|
|
37
|
+
readonly time: number;
|
|
38
|
+
readonly tick: number;
|
|
39
|
+
readonly paused: boolean;
|
|
40
|
+
readonly speed: number;
|
|
41
|
+
/** The run is playing but no game runs: the script failed to start, or threw (`play-script.ts`). */
|
|
42
|
+
readonly failure: string | null;
|
|
43
|
+
/** A game has started in this run and runs (`DocumentPlayClock.running`). */
|
|
44
|
+
readonly running: boolean;
|
|
45
|
+
}
|
|
46
|
+
const STILL: ModelPlayClock = { time: 0, tick: 0, paused: false, speed: 1, failure: null, running: false };
|
|
47
|
+
/** Replaced, never mutated, so a snapshot read by `useSyncExternalStore` changes identity
|
|
48
|
+
* exactly when it changes value. */
|
|
49
|
+
const clocks = new Map<string, ModelPlayClock>();
|
|
50
|
+
/** Steps asked for while paused and not yet run; the runner takes one per drawn frame. */
|
|
51
|
+
const steps = new Map<string, number>();
|
|
52
|
+
/** Bumped by Restart; the document keys its detached copy on it (`DocumentPlayTransport`). */
|
|
53
|
+
const generations = new Map<string, number>();
|
|
54
|
+
/** Documents whose next run was begun by Restart, and so enters without the camera's blend. */
|
|
55
|
+
const restarted = new Set<string>();
|
|
56
|
+
|
|
57
|
+
/** Who last switched autoplay: the panel's toggle, a verb (CLI or `eval`), a person's input
|
|
58
|
+
* in the game, or the running script no longer offering a bot (replaced, failed, or its bot
|
|
59
|
+
* threw). */
|
|
60
|
+
export type ModelPlayAutoplayBy = 'panel' | 'cli' | 'takeover' | 'script';
|
|
61
|
+
export interface ModelPlayAutoplay {
|
|
62
|
+
/** The bot drives: its keys are merged into the keys the script reads. */
|
|
63
|
+
readonly on: boolean;
|
|
64
|
+
/** The running script registered a bot with `play.autoplay`. */
|
|
65
|
+
readonly available: boolean;
|
|
66
|
+
readonly by: ModelPlayAutoplayBy | null;
|
|
67
|
+
/** Pressed while stopped: the next start turns autoplay on once its script offers a bot. */
|
|
68
|
+
readonly armed: boolean;
|
|
69
|
+
}
|
|
70
|
+
const NO_BOT: ModelPlayAutoplay = { on: false, available: false, by: null, armed: false };
|
|
71
|
+
const ARMED: ModelPlayAutoplay = { ...NO_BOT, armed: true };
|
|
72
|
+
/** Replaced, never mutated, as the clocks are. Absent is {@link NO_BOT}: a new run's state. */
|
|
73
|
+
const autoplays = new Map<string, ModelPlayAutoplay>();
|
|
74
|
+
|
|
75
|
+
/** A new run's autoplay: off, no bot yet. Play keeps an arm made while stopped; Stop drops it. */
|
|
76
|
+
function resetAutoplay(documentId: string, keepArm: boolean): void {
|
|
77
|
+
if (keepArm && modelPlayAutoplay(documentId).armed) autoplays.set(documentId, ARMED);
|
|
78
|
+
else autoplays.delete(documentId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function publish(): void {
|
|
82
|
+
for (const listener of [...listeners]) listener();
|
|
83
|
+
}
|
|
84
|
+
function publishClock(): void {
|
|
85
|
+
for (const listener of [...clockListeners]) listener();
|
|
86
|
+
}
|
|
87
|
+
function setClock(documentId: string, next: Partial<ModelPlayClock>): void {
|
|
88
|
+
clocks.set(documentId, { ...modelPlayClock(documentId), ...next });
|
|
89
|
+
publishClock();
|
|
90
|
+
}
|
|
12
91
|
|
|
13
92
|
export function modelPlaying(documentId: string): boolean {
|
|
14
93
|
return playing.has(documentId);
|
|
15
94
|
}
|
|
16
95
|
|
|
17
96
|
export function setModelPlaying(documentId: string, value: boolean): void {
|
|
18
|
-
if (playing.has(documentId) === value)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
97
|
+
if (playing.has(documentId) === value) {
|
|
98
|
+
// Stop with nothing playing is how a closing document lets go of its play: an arm made while
|
|
99
|
+
// stopped goes with it, or reopening the model and pressing Play would still take it.
|
|
100
|
+
if (!value && modelPlayAutoplay(documentId).armed) { resetAutoplay(documentId, false); publishClock(); }
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const stop = value ? null : currentStop(documentId);
|
|
104
|
+
if (stop) { stop(false); return; }
|
|
105
|
+
if (value) {
|
|
106
|
+
playing.add(documentId);
|
|
107
|
+
// A new run starts its clock at zero and running; the speed is the person's and stays.
|
|
108
|
+
steps.delete(documentId);
|
|
109
|
+
resetAutoplay(documentId, true);
|
|
110
|
+
setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
|
|
111
|
+
} else {
|
|
112
|
+
playing.delete(documentId);
|
|
113
|
+
restarted.delete(documentId);
|
|
114
|
+
steps.delete(documentId);
|
|
115
|
+
resetAutoplay(documentId, false);
|
|
116
|
+
// The clock keeps the stopped run's time and tick, so the panel still says how far it got.
|
|
117
|
+
// Re-issued even unchanged, after `playing` has changed: every way a game stops (Stop,
|
|
118
|
+
// Escape, a deleted script, a mode switch, an agent's `stop`) ends here, and a reader of
|
|
119
|
+
// the clock alone must see the run end too.
|
|
120
|
+
setClock(documentId, { paused: false, failure: null, running: false });
|
|
121
|
+
}
|
|
122
|
+
publish();
|
|
23
123
|
}
|
|
24
124
|
|
|
25
125
|
export function finishModelPlay(documentId: string): void {
|
|
@@ -28,14 +128,36 @@ export function finishModelPlay(documentId: string): void {
|
|
|
28
128
|
}
|
|
29
129
|
|
|
30
130
|
export function escapeModelPlay(documentId: string): void {
|
|
31
|
-
const stop =
|
|
131
|
+
const stop = currentStop(documentId);
|
|
32
132
|
if (stop) stop(true);
|
|
33
133
|
else finishModelPlay(documentId);
|
|
34
134
|
}
|
|
35
135
|
|
|
136
|
+
/**
|
|
137
|
+
* A RUNNER'S STOP, BOUND TO ITS GENERATION. Stop and Escape go to the runner of the CURRENT
|
|
138
|
+
* generation, which blends the camera back first; a stop a Restart has superseded is never
|
|
139
|
+
* asked, because that runner stands still from the moment its generation passes
|
|
140
|
+
* (`play-script.ts`) and would never finish the blend — the stop would be lost. With no current
|
|
141
|
+
* runner (a restarted game still preparing) the play simply ends.
|
|
142
|
+
*/
|
|
36
143
|
export function registerModelPlayStop(documentId: string, stop: (escape: boolean) => void): () => void {
|
|
37
|
-
|
|
38
|
-
|
|
144
|
+
const entry = { stop, generation: modelPlayGeneration(documentId) };
|
|
145
|
+
stops.set(documentId, entry);
|
|
146
|
+
return () => { if (stops.get(documentId) === entry) stops.delete(documentId); };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function currentStop(documentId: string): ((escape: boolean) => void) | null {
|
|
150
|
+
const entry = stops.get(documentId);
|
|
151
|
+
return entry !== undefined && entry.generation === modelPlayGeneration(documentId) ? entry.stop : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The runner's report that no game runs although the run plays (`null` once one does, which
|
|
155
|
+
* is also the moment the run is `running`). */
|
|
156
|
+
export function setModelPlayFailure(documentId: string, failure: string | null): void {
|
|
157
|
+
const clock = modelPlayClock(documentId);
|
|
158
|
+
const running = failure === null;
|
|
159
|
+
if (!playing.has(documentId) || (clock.failure === failure && clock.running === running)) return;
|
|
160
|
+
setClock(documentId, { failure, running });
|
|
39
161
|
}
|
|
40
162
|
|
|
41
163
|
export function subscribeModelPlay(listener: () => void): () => void {
|
|
@@ -44,3 +166,124 @@ export function subscribeModelPlay(listener: () => void): () => void {
|
|
|
44
166
|
listeners.delete(listener);
|
|
45
167
|
};
|
|
46
168
|
}
|
|
169
|
+
|
|
170
|
+
export function modelPlayClock(documentId: string): ModelPlayClock {
|
|
171
|
+
return clocks.get(documentId) ?? STILL;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function subscribeModelPlayClock(listener: () => void): () => void {
|
|
175
|
+
clockListeners.add(listener);
|
|
176
|
+
return () => {
|
|
177
|
+
clockListeners.delete(listener);
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Hold or release the game's updates. Only a playing document has updates to hold. */
|
|
182
|
+
export function setModelPlayPaused(documentId: string, paused: boolean): void {
|
|
183
|
+
if (!playing.has(documentId) || modelPlayClock(documentId).paused === paused) return;
|
|
184
|
+
if (!paused) steps.delete(documentId);
|
|
185
|
+
setClock(documentId, { paused });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One update while paused. Steps asked for faster than frames are drawn queue, one per frame,
|
|
189
|
+
* so each one is seen. */
|
|
190
|
+
export function stepModelPlay(documentId: string): void {
|
|
191
|
+
if (!playing.has(documentId) || !modelPlayClock(documentId).paused) return;
|
|
192
|
+
steps.set(documentId, (steps.get(documentId) ?? 0) + 1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The runner's half of {@link stepModelPlay}: whether this frame runs a step. */
|
|
196
|
+
export function takeModelPlayStep(documentId: string): boolean {
|
|
197
|
+
const pending = steps.get(documentId) ?? 0;
|
|
198
|
+
if (pending === 0) return false;
|
|
199
|
+
if (pending === 1) steps.delete(documentId);
|
|
200
|
+
else steps.set(documentId, pending - 1);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function setModelPlaySpeed(documentId: string, speed: number): void {
|
|
205
|
+
if (!MODEL_PLAY_SPEEDS.includes(speed))
|
|
206
|
+
throw new Error(`Play speed ${speed} is not one the transport offers: ${MODEL_PLAY_SPEEDS.join(', ')}.`);
|
|
207
|
+
if (modelPlayClock(documentId).speed !== speed) setClock(documentId, { speed });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The runner's report of the updates it ran this frame. */
|
|
211
|
+
export function advanceModelPlayClock(documentId: string, seconds: number, ticks: number): void {
|
|
212
|
+
if (ticks === 0) return;
|
|
213
|
+
const clock = modelPlayClock(documentId);
|
|
214
|
+
setClock(documentId, { time: clock.time + seconds, tick: clock.tick + ticks });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function modelPlayGeneration(documentId: string): number {
|
|
218
|
+
return generations.get(documentId) ?? 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* BEGIN THE RUN AGAIN ON A FRESH COPY. Playing stays true throughout — nothing returns to the
|
|
223
|
+
* model — and the generation moves, which is what makes the document detach a new copy and mount
|
|
224
|
+
* a new runner over it; the old runner sees it is no longer the current generation and stands
|
|
225
|
+
* down without ending the play. A document that is not playing simply starts.
|
|
226
|
+
*/
|
|
227
|
+
export function restartModelPlay(documentId: string): void {
|
|
228
|
+
if (!playing.has(documentId)) { setModelPlaying(documentId, true); return; }
|
|
229
|
+
generations.set(documentId, modelPlayGeneration(documentId) + 1);
|
|
230
|
+
restarted.add(documentId);
|
|
231
|
+
steps.delete(documentId);
|
|
232
|
+
// An arm not yet taken (the game was still starting) carries to the fresh run.
|
|
233
|
+
resetAutoplay(documentId, true);
|
|
234
|
+
setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
|
|
235
|
+
publish();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether the run starting now was begun by Restart (asked once, by that run's runner). */
|
|
239
|
+
export function consumeModelPlayRestart(documentId: string): boolean {
|
|
240
|
+
return restarted.delete(documentId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function modelPlayAutoplay(documentId: string): ModelPlayAutoplay {
|
|
244
|
+
return autoplays.get(documentId) ?? NO_BOT;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function setAutoplay(documentId: string, next: Partial<ModelPlayAutoplay>): void {
|
|
248
|
+
autoplays.set(documentId, { ...modelPlayAutoplay(documentId), ...next });
|
|
249
|
+
publishClock();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Switch the running script's bot on or off. On asks for a playing document whose script
|
|
253
|
+
* registered a bot; off always succeeds, and also drops an arm not yet taken — a person who
|
|
254
|
+
* takes over while the game is still starting is driving, and the arm must not override them. */
|
|
255
|
+
export function setModelPlayAutoplay(documentId: string, on: boolean, by: ModelPlayAutoplayBy): void {
|
|
256
|
+
const now = modelPlayAutoplay(documentId);
|
|
257
|
+
if (on && !playing.has(documentId))
|
|
258
|
+
throw new Error(`Autoplay is available once the game is running, and nothing is playing in ${documentId}; \`play\` starts it, then \`play autoplay on\`.`);
|
|
259
|
+
const clock = modelPlayClock(documentId);
|
|
260
|
+
if (on && !now.available)
|
|
261
|
+
throw new Error(clock.running
|
|
262
|
+
? 'No autoplay: this game doesn’t provide a bot — its play script registers none with `play.autoplay(controller)`.'
|
|
263
|
+
: `Autoplay is available once the game is running, and it is not running yet${clock.failure ? ` (${clock.failure})` : ''}.`);
|
|
264
|
+
if (on ? !now.on : now.on || now.armed) setAutoplay(documentId, on ? { on, by } : { on, by, armed: false });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button
|
|
268
|
+
* before Play. Disarming always succeeds. */
|
|
269
|
+
export function armModelPlayAutoplay(documentId: string, armed: boolean): void {
|
|
270
|
+
if (armed && playing.has(documentId))
|
|
271
|
+
throw new Error(`${documentId} is already playing; switch autoplay on instead of arming it.`);
|
|
272
|
+
if (modelPlayAutoplay(documentId).armed !== armed) setAutoplay(documentId, { armed });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The runner's report that a script has run its first update, offering a bot or not. An arm is
|
|
276
|
+
* taken here: on with a bot, dropped without one. */
|
|
277
|
+
export function settleModelPlayAutoplay(documentId: string, offered: boolean): void {
|
|
278
|
+
const now = modelPlayAutoplay(documentId);
|
|
279
|
+
if (!playing.has(documentId)) return;
|
|
280
|
+
if (!now.armed) { setModelPlayAutoplayAvailable(documentId, offered); return; }
|
|
281
|
+
setAutoplay(documentId, offered ? { available: true, armed: false, on: true, by: 'panel' } : { available: false, armed: false, by: 'script' });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The runner's report of whether the running script offers a bot. Losing it turns autoplay off. */
|
|
285
|
+
export function setModelPlayAutoplayAvailable(documentId: string, available: boolean): void {
|
|
286
|
+
const now = modelPlayAutoplay(documentId);
|
|
287
|
+
if (now.available === available || !playing.has(documentId)) return;
|
|
288
|
+
setAutoplay(documentId, available || !now.on ? { available } : { available, on: false, by: 'script' });
|
|
289
|
+
}
|
package/src/play-log.ts
CHANGED
|
@@ -4,15 +4,21 @@
|
|
|
4
4
|
* The game editor's play log is the receipt of a run (`play-*.jsonl`, `log-format.ts`); this
|
|
5
5
|
* is Model Play's. A script calls `play.log(kind, facts)` (`play-script.ts`) on the
|
|
6
6
|
* transitions that explain a run — a jump, a landing, a death and its cause, autoplay's
|
|
7
|
-
* choice — and an agent reads them back with `
|
|
7
|
+
* choice — and an agent reads them back with `cyclotron play-log` or
|
|
8
8
|
* `editor.modelPlayLog()` in `eval`. `console.log` cannot do this job: the session's
|
|
9
9
|
* console feed keeps warnings and errors only.
|
|
10
10
|
*
|
|
11
11
|
* Every entry carries `simT` (the seconds of simulation since Play started: the sum of the
|
|
12
12
|
* `dt`s the runner has handed to `update`) and `tick` (the number of the update in
|
|
13
13
|
* progress; 0 while the script's default export runs), the same two stamps the game
|
|
14
|
-
* editor's entries carry.
|
|
15
|
-
* `play-
|
|
14
|
+
* editor's entries carry. They are the Game panel's clock too: the runner advances both at
|
|
15
|
+
* the same call (`play-script.ts`), so they stand still while paused and run at the speed. The
|
|
16
|
+
* runner adds its own lifecycle entries (`source: 'play'`): `play-start`, `play-restart` (a
|
|
17
|
+
* Restart's fresh run, right after its `play-start`), `script-reload` with its reason,
|
|
18
|
+
* `script-error`, `tint-unsupported`, `tint-unknown-object`, `pause`, `resume`, `step` (one per
|
|
19
|
+
* stepped update, with its `dt`), `speed` (on a change, and at a start that is not 1×),
|
|
20
|
+
* `autoplay-on` and `autoplay-off` (each with `by`: `panel`, `cli`, `takeover`, `script`),
|
|
21
|
+
* `autoplay-unavailable` (a script ran its first update without offering a bot),
|
|
16
22
|
* `play-stop`.
|
|
17
23
|
*
|
|
18
24
|
* ONE LOG PER MODEL DOCUMENT, each its document's latest run. Two documents playing at once
|
|
@@ -28,6 +34,9 @@
|
|
|
28
34
|
* at most {@link MODEL_PLAY_LOG_FACTS_CHARS} characters. It is never stored: a reload of the
|
|
29
35
|
* page forgets it, as it forgets the game.
|
|
30
36
|
*
|
|
37
|
+
* THE GAME PANEL DRAWS IT LIVE: {@link subscribeModelPlayLog} hears every write, and
|
|
38
|
+
* {@link tailModelPlayLog} reads the newest entries of one document without copying the ring.
|
|
39
|
+
*
|
|
31
40
|
* Logging is a read of the script's own values: it never throws into the script and touches
|
|
32
41
|
* neither the clock nor the copy.
|
|
33
42
|
*/
|
|
@@ -90,6 +99,8 @@ interface Run {
|
|
|
90
99
|
tick: number;
|
|
91
100
|
readonly ring: ModelPlayLogEntry[];
|
|
92
101
|
total: number;
|
|
102
|
+
/** Every kind this run has written, for the panel's filter. */
|
|
103
|
+
readonly kinds: Set<string>;
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
/** The runner's door onto its own run's log: inert once the run has ended. */
|
|
@@ -106,6 +117,21 @@ export interface ModelPlayRun {
|
|
|
106
117
|
const key = Symbol.for('volter.model-play-log');
|
|
107
118
|
const page = globalThis as typeof globalThis & { [key]?: Map<string, Run> };
|
|
108
119
|
const runs: Map<string, Run> = page[key] ??= new Map();
|
|
120
|
+
const listenersKey = Symbol.for('volter.model-play-log.listeners');
|
|
121
|
+
const pageListeners = globalThis as typeof globalThis & { [listenersKey]?: Set<() => void> };
|
|
122
|
+
const listeners: Set<() => void> = pageListeners[listenersKey] ??= new Set();
|
|
123
|
+
|
|
124
|
+
function publish(): void {
|
|
125
|
+
for (const listener of [...listeners]) {
|
|
126
|
+
try { listener(); } catch { /* A reader's failure is not the writer's. */ }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Hear every write to any document's log (several may land in one frame; coalesce). */
|
|
131
|
+
export function subscribeModelPlayLog(listener: () => void): () => void {
|
|
132
|
+
listeners.add(listener);
|
|
133
|
+
return () => { listeners.delete(listener); };
|
|
134
|
+
}
|
|
109
135
|
|
|
110
136
|
function keptFacts(facts: unknown): Record<string, unknown> | undefined {
|
|
111
137
|
if (facts === undefined || facts === null) return undefined;
|
|
@@ -128,13 +154,15 @@ function append(run: Run, source: ModelPlayLogEntry['source'], kind: string, fac
|
|
|
128
154
|
};
|
|
129
155
|
run.ring[run.total % MODEL_PLAY_LOG_CAPACITY] = entry;
|
|
130
156
|
run.total += 1;
|
|
157
|
+
run.kinds.add(entry.kind);
|
|
131
158
|
} catch { /* A log that cannot be written is not the script's failure. */ }
|
|
159
|
+
publish();
|
|
132
160
|
}
|
|
133
161
|
|
|
134
162
|
/** Play started from a fresh copy: the document's log replaced by an empty one at zero,
|
|
135
163
|
* opened by `play-start`. */
|
|
136
164
|
export function beginModelPlayLog(documentId: string, script: string): ModelPlayRun {
|
|
137
|
-
const run: Run = { documentId, script, startedAt: Date.now(), playing: true, simT: 0, tick: 0, ring: [], total: 0 };
|
|
165
|
+
const run: Run = { documentId, script, startedAt: Date.now(), playing: true, simT: 0, tick: 0, ring: [], total: 0, kinds: new Set() };
|
|
138
166
|
// Re-inserted, so the map's order is the order Plays started.
|
|
139
167
|
runs.delete(documentId);
|
|
140
168
|
runs.set(documentId, run);
|
|
@@ -181,3 +209,25 @@ export function readModelPlayLog(query: ModelPlayLogQuery = {}): ModelPlayLogRea
|
|
|
181
209
|
simT: run.simT, tick: run.tick, capacity: MODEL_PLAY_LOG_CAPACITY, total: run.total, dropped, entries,
|
|
182
210
|
};
|
|
183
211
|
}
|
|
212
|
+
|
|
213
|
+
export interface ModelPlayLogTail {
|
|
214
|
+
/** Entries the document's latest run has written; 0 when it has no log. */
|
|
215
|
+
readonly total: number;
|
|
216
|
+
/** Every kind that run has written, in the order first seen. */
|
|
217
|
+
readonly kinds: readonly string[];
|
|
218
|
+
/** The newest kept entries (of the asked kind), at most `last`, oldest first. */
|
|
219
|
+
readonly entries: readonly ModelPlayLogEntry[];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The newest `last` entries of one document's log, walked from the newest end. */
|
|
223
|
+
export function tailModelPlayLog(documentId: string, last: number, kind?: string): ModelPlayLogTail {
|
|
224
|
+
const run = runs.get(documentId);
|
|
225
|
+
if (!run) return { total: 0, kinds: [], entries: [] };
|
|
226
|
+
const oldest = Math.max(0, run.total - MODEL_PLAY_LOG_CAPACITY);
|
|
227
|
+
const entries: ModelPlayLogEntry[] = [];
|
|
228
|
+
for (let seq = run.total - 1; seq >= oldest && entries.length < last; seq--) {
|
|
229
|
+
const entry = run.ring[seq % MODEL_PLAY_LOG_CAPACITY]!;
|
|
230
|
+
if (kind === undefined || entry.kind === kind) entries.push(entry);
|
|
231
|
+
}
|
|
232
|
+
return { total: run.total, kinds: [...run.kinds], entries: entries.reverse() };
|
|
233
|
+
}
|
package/src/play-materials.ts
CHANGED
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
* The presenter's slots are re-read whenever they may have moved: an object whose slots the
|
|
26
26
|
* presenter has put back is given copies of the slots it now has, both each frame and before
|
|
27
27
|
* any change, so clearing returns the presenter's current slots, never a stale set. An object
|
|
28
|
-
* the script removed from the copy is let go on the next frame.
|
|
28
|
+
* the script removed from the copy is let go on the next frame. That is heard, not searched
|
|
29
|
+
* for: each overridden mesh and its parents up to the copy's root report their own removal
|
|
30
|
+
* (three's `removed` event, which `remove`, `add` elsewhere and `attach` all dispatch), and only
|
|
31
|
+
* a mesh one of them reported is walked up to see whether it is still in the copy.
|
|
29
32
|
*/
|
|
30
33
|
import type * as THREE from 'three';
|
|
31
34
|
|
|
@@ -39,6 +42,11 @@ interface Override {
|
|
|
39
42
|
copies: Colored[];
|
|
40
43
|
color: THREE.ColorRepresentation | null;
|
|
41
44
|
opacity: number | null;
|
|
45
|
+
/** The mesh and its parents below the copy's root, each told to report its removal. */
|
|
46
|
+
watched: THREE.Object3D[];
|
|
47
|
+
/** One of them was removed from its parent since the last frame. */
|
|
48
|
+
moved: boolean;
|
|
49
|
+
readonly onRemoved: () => void;
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
const slotsOf = (slots: Slots): THREE.Material[] => Array.isArray(slots) ? slots : [slots];
|
|
@@ -71,6 +79,20 @@ export function materialOverrides(options: {
|
|
|
71
79
|
for (const copy of override.copies) copy.dispose();
|
|
72
80
|
override.copies = [];
|
|
73
81
|
};
|
|
82
|
+
const unwatch = (override: Override): void => {
|
|
83
|
+
for (const object of override.watched) object.removeEventListener('removed', override.onRemoved);
|
|
84
|
+
override.watched = [];
|
|
85
|
+
};
|
|
86
|
+
/** Hear the mesh or a parent leave; a mesh not under the copy's root is let go next frame. */
|
|
87
|
+
const watch = (override: Override): void => {
|
|
88
|
+
unwatch(override);
|
|
89
|
+
let at: THREE.Object3D | null = override.mesh;
|
|
90
|
+
for (; at && at !== root; at = at.parent) {
|
|
91
|
+
at.addEventListener('removed', override.onRemoved);
|
|
92
|
+
override.watched.push(at);
|
|
93
|
+
}
|
|
94
|
+
override.moved = at !== root;
|
|
95
|
+
};
|
|
74
96
|
/** Wear copies of the object's slots; false (and nothing worn) when one cannot be copied. */
|
|
75
97
|
const dress = (override: Override): boolean => {
|
|
76
98
|
release(override);
|
|
@@ -116,6 +138,7 @@ export function materialOverrides(options: {
|
|
|
116
138
|
const forget = (override: Override): void => {
|
|
117
139
|
if (override.mesh.material === override.shown) override.mesh.material = override.authored;
|
|
118
140
|
release(override);
|
|
141
|
+
unwatch(override);
|
|
119
142
|
overrides.delete(override.mesh);
|
|
120
143
|
};
|
|
121
144
|
/** The presenter re-assigned the object's slots since it last wore copies: adopt its slots
|
|
@@ -126,6 +149,7 @@ export function materialOverrides(options: {
|
|
|
126
149
|
override.shown = override.mesh.material;
|
|
127
150
|
if (dress(override)) return true;
|
|
128
151
|
release(override);
|
|
152
|
+
unwatch(override);
|
|
129
153
|
overrides.delete(override.mesh);
|
|
130
154
|
return false;
|
|
131
155
|
};
|
|
@@ -137,21 +161,19 @@ export function materialOverrides(options: {
|
|
|
137
161
|
let override = overrides.get(mesh);
|
|
138
162
|
if (override && !resync(override)) override = undefined;
|
|
139
163
|
if (!override) {
|
|
140
|
-
const fresh: Override = { mesh, authored: mesh.material, shown: mesh.material, copies: [], color: null, opacity: null
|
|
164
|
+
const fresh: Override = { mesh, authored: mesh.material, shown: mesh.material, copies: [], color: null, opacity: null,
|
|
165
|
+
watched: [], moved: false, onRemoved: () => { fresh.moved = true; } };
|
|
141
166
|
edit(fresh);
|
|
142
167
|
if (fresh.color === null && fresh.opacity === null) return;
|
|
143
168
|
if (!dress(fresh)) return;
|
|
144
169
|
overrides.set(mesh, fresh);
|
|
170
|
+
watch(fresh);
|
|
145
171
|
override = fresh;
|
|
146
172
|
} else edit(override);
|
|
147
173
|
if (override.color === null && override.opacity === null) forget(override);
|
|
148
174
|
else paint(override);
|
|
149
175
|
});
|
|
150
176
|
};
|
|
151
|
-
const inCopy = (object: THREE.Object3D): boolean => {
|
|
152
|
-
for (let at: THREE.Object3D | null = object; at; at = at.parent) if (at === root) return true;
|
|
153
|
-
return false;
|
|
154
|
-
};
|
|
155
177
|
return {
|
|
156
178
|
tint(target, color) { change(target, (override) => { override.color = color; }); },
|
|
157
179
|
setOpacity(target, opacity) {
|
|
@@ -161,7 +183,11 @@ export function materialOverrides(options: {
|
|
|
161
183
|
},
|
|
162
184
|
frame() {
|
|
163
185
|
for (const override of [...overrides.values()]) {
|
|
164
|
-
if (
|
|
186
|
+
if (override.moved) {
|
|
187
|
+
// Re-walked only after a reported removal; one put back under the copy is watched anew.
|
|
188
|
+
watch(override);
|
|
189
|
+
if (override.moved) { forget(override); continue; }
|
|
190
|
+
}
|
|
165
191
|
if (override.mesh.material !== override.shown && resync(override)) paint(override);
|
|
166
192
|
}
|
|
167
193
|
},
|
package/src/play-script.ts
CHANGED
|
@@ -22,6 +22,34 @@
|
|
|
22
22
|
* The tool blends the camera from the editing pose for 0.8 seconds after
|
|
23
23
|
* update, holding keys empty until arrival. Stop freezes the copy and blends
|
|
24
24
|
* back before disposing it; Escape during either blend completes that blend.
|
|
25
|
+
*
|
|
26
|
+
* THE GAME'S TIME IS THE `dt` IT IS HANDED, and the runner decides it (`model-play.ts` keeps
|
|
27
|
+
* the run's clock): paused, `update` is not called at all and the camera holds the last drawn
|
|
28
|
+
* pose; a Step is one `update` of one nominal frame; a speed scales the frame's seconds, and a
|
|
29
|
+
* scaled frame longer than a tenth of a second is run as several updates so the promise below —
|
|
30
|
+
* at most a tenth per call — holds at 4× too. The camera's own blends run on the page's time,
|
|
31
|
+
* because they are the editor's motion and not the game's. Restart is the document detaching a
|
|
32
|
+
* fresh copy for a new runner (`restartModelPlay`); this runner, no longer the current
|
|
33
|
+
* generation, stands down without ending the play.
|
|
34
|
+
*
|
|
35
|
+
* THE PLAY LOG KEEPS THE SAME CLOCK (`play-log.ts`): the run's log is advanced by exactly the
|
|
36
|
+
* `dt` of each `update` the panel's clock counts, at the same call, so an entry's `simT` and
|
|
37
|
+
* `tick` are the numbers the Game panel shows. A Restart is a fresh copy and so a fresh log,
|
|
38
|
+
* opened by `play-start` and then `play-restart`; pause, resume, each step and a speed change
|
|
39
|
+
* are lifecycle entries of their own.
|
|
40
|
+
*
|
|
41
|
+
* AUTOPLAY IS THE GAME'S BOT AND THE EDITOR'S SWITCH. A script offers one bot,
|
|
42
|
+
* `play.autoplay(controller)`: a plain function the runner calls before each `update` while
|
|
43
|
+
* autoplay is on, handed that update's `dt`, `simT`, `tick` and the keys the person holds, and
|
|
44
|
+
* answering the keys the bot holds. The runner merges those into `keys` for that update, so the
|
|
45
|
+
* bot drives through the script's own input code, exactly as a person's keys do. Whether it
|
|
46
|
+
* drives is not the script's to say (`model-play.ts`): off at every Play and Restart, on only
|
|
47
|
+
* from the Game panel or its verb, and off again the moment a person presses a key the game
|
|
48
|
+
* would hear or presses a pointer in the game — before that key reaches `keys`. A synthetic key
|
|
49
|
+
* (`editor.document.key`) is dispatched as a DOM event like a person's and is handled as one;
|
|
50
|
+
* the bot's own keys never pass through the DOM, so they cannot take over from themselves. The
|
|
51
|
+
* controller is called only here, inside the editor's runner: a game run anywhere else never
|
|
52
|
+
* drives itself.
|
|
25
53
|
*/
|
|
26
54
|
import { editorHost } from '@volter/editor-sdk/host';
|
|
27
55
|
import { getCurrentProject } from '@volter/editor-sdk/kit/active-project';
|
|
@@ -38,7 +66,22 @@ import type * as THREE from 'three';
|
|
|
38
66
|
import { projectPlayLayers } from '@volter/editor-sdk/kit/project-play-layers';
|
|
39
67
|
import { beginProjectMountEpoch, projectEntryImportUrl } from '@volter/editor-sdk/session/project-module-url';
|
|
40
68
|
import { cameraTransition } from './camera-transition';
|
|
41
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
advanceModelPlayClock,
|
|
71
|
+
consumeModelPlayRestart,
|
|
72
|
+
finishModelPlay,
|
|
73
|
+
MODEL_PLAY_STEP_SECONDS,
|
|
74
|
+
modelPlayAutoplay,
|
|
75
|
+
modelPlayClock,
|
|
76
|
+
modelPlayGeneration,
|
|
77
|
+
registerModelPlayStop,
|
|
78
|
+
setModelPlayAutoplay,
|
|
79
|
+
setModelPlayFailure,
|
|
80
|
+
setModelPlayAutoplayAvailable,
|
|
81
|
+
settleModelPlayAutoplay,
|
|
82
|
+
subscribeModelPlayClock,
|
|
83
|
+
takeModelPlayStep,
|
|
84
|
+
} from './model-play';
|
|
42
85
|
import { beginModelPlayLog } from './play-log';
|
|
43
86
|
import { materialOverrides } from './play-materials';
|
|
44
87
|
interface PlayComposition {
|
|
@@ -64,7 +107,7 @@ export interface ModelPlayContext {
|
|
|
64
107
|
* Write one entry to the play log, stamped with the run's simulation time and frame
|
|
65
108
|
* (`play-log.ts`): a kind and the facts that explain it, JSON-serialisable and snapshotted
|
|
66
109
|
* now. Log transitions (a landing, a death and its cause, autoplay's choice), not every
|
|
67
|
-
* frame. Read back with `
|
|
110
|
+
* frame. Read back with `cyclotron play-log [--since <simT>] [--kind <k>] [--json]`
|
|
68
111
|
* or `editor.modelPlayLog({ since, kind })` in `eval`. Never throws, never changes the game,
|
|
69
112
|
* and does nothing once this script has been replaced or Play has stopped.
|
|
70
113
|
*
|
|
@@ -92,14 +135,65 @@ export interface ModelPlayContext {
|
|
|
92
135
|
/** Fade an object and the meshes under it to `opacity` (0 to 1); `null` returns the
|
|
93
136
|
* authored opacity. Its colour is untouched, and the copies are `tint`'s. */
|
|
94
137
|
setOpacity(object: THREE.Object3D | string, opacity: number | null): void;
|
|
138
|
+
/**
|
|
139
|
+
* Offer this game's bot. While the person (or an agent, `play autoplay on`) has autoplay on in
|
|
140
|
+
* the Game panel, `controller` is called before each `update` and the keys it answers are held
|
|
141
|
+
* for that update, merged into `keys`. Autoplay is off at every Play and Restart, and a
|
|
142
|
+
* person's key or pointer in the game turns it off. One bot per script: registering again
|
|
143
|
+
* replaces it, `null` withdraws it, and it goes with the script. Never bind autoplay to a game
|
|
144
|
+
* key, and never start it from the script.
|
|
145
|
+
*
|
|
146
|
+
* play.autoplay(({ dt }) => (car.speed < 20 ? ['ArrowUp'] : []));
|
|
147
|
+
*/
|
|
148
|
+
autoplay(controller: ModelPlayAutoplayController | null): void;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What the bot is handed before each `update` it drives. */
|
|
152
|
+
export interface ModelPlayAutoplayInput {
|
|
153
|
+
/** The `dt` the coming `update` is handed. */
|
|
154
|
+
readonly dt: number;
|
|
155
|
+
/** Simulation seconds and the update's number, as that update's log entries carry them. */
|
|
156
|
+
readonly simT: number;
|
|
157
|
+
readonly tick: number;
|
|
158
|
+
/** The keys the person holds, by `KeyboardEvent.code`; the bot's are merged with them. */
|
|
159
|
+
readonly keys: ReadonlySet<string>;
|
|
95
160
|
}
|
|
161
|
+
/** A game's bot: the keys (`KeyboardEvent.code`) it holds for the coming update. */
|
|
162
|
+
export type ModelPlayAutoplayController = (input: ModelPlayAutoplayInput) => Iterable<string> | null | undefined;
|
|
96
163
|
|
|
97
164
|
export interface ModelPlayGame {
|
|
98
|
-
/** Once per drawn frame, with the seconds since the last
|
|
165
|
+
/** Once per drawn frame, with the simulation seconds since the last call (at most a tenth).
|
|
166
|
+
* Not called while the game is paused; called once per Step; at a speed other than 1× the
|
|
167
|
+
* seconds are scaled, and a fast frame may call it more than once. */
|
|
99
168
|
update(deltaSeconds: number): void;
|
|
100
169
|
dispose?(): void;
|
|
101
170
|
}
|
|
102
171
|
|
|
172
|
+
/** One script's lifetime: `value` until it is replaced, fails or the run stops; `bot` is what it
|
|
173
|
+
* offered `play.autoplay`; `unknown` the object names it asked for that the model lacks, each
|
|
174
|
+
* logged once — per script, so a typo that survives a reload is said again for the new one. */
|
|
175
|
+
interface Script {
|
|
176
|
+
value: boolean;
|
|
177
|
+
bot: ModelPlayAutoplayController | null;
|
|
178
|
+
readonly unknown: Set<string>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The longest `dt` one update is handed; longer scaled frames are split (`frameUpdates`). */
|
|
182
|
+
const MAX_UPDATE_SECONDS = 0.1;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* THE UPDATES THIS DRAWN FRAME RUNS, as the `dt` each is handed: none while paused, one nominal
|
|
186
|
+
* frame for a Step, and otherwise the frame's seconds times the speed, split into equal parts of
|
|
187
|
+
* at most {@link MAX_UPDATE_SECONDS}.
|
|
188
|
+
*/
|
|
189
|
+
function frameUpdates(documentId: string, frameSeconds: number): number[] {
|
|
190
|
+
const clock = modelPlayClock(documentId);
|
|
191
|
+
if (clock.paused) return takeModelPlayStep(documentId) ? [MODEL_PLAY_STEP_SECONDS] : [];
|
|
192
|
+
const scaled = frameSeconds * clock.speed;
|
|
193
|
+
const parts = Math.max(1, Math.ceil(scaled / MAX_UPDATE_SECONDS - 1e-9));
|
|
194
|
+
return Array.from({ length: parts }, () => scaled / parts);
|
|
195
|
+
}
|
|
196
|
+
|
|
103
197
|
/** The play script's project path for a model's `.blend`. */
|
|
104
198
|
export function playScriptPath(blend: string): string {
|
|
105
199
|
return blend.replace(/\.blend$/i, '') + '.play.ts';
|
|
@@ -148,9 +242,14 @@ export function runPlayScript(options: {
|
|
|
148
242
|
// A fresh copy is a fresh run: its log starts empty, its clock at zero. Writes go through
|
|
149
243
|
// this run's handle, which is inert once the run has ended.
|
|
150
244
|
const run = beginModelPlayLog(options.documentId, modulePath);
|
|
151
|
-
const report = (phase: 'start' | 'update' | 'stop', title: string, error: unknown): void => {
|
|
245
|
+
const report = (phase: 'start' | 'update' | 'stop' | 'autoplay', title: string, error: unknown): void => {
|
|
152
246
|
const detail = error instanceof Error ? error.message : String(error);
|
|
153
247
|
run.append('play', 'script-error', { phase, message: detail });
|
|
248
|
+
// NO GAME RUNS NOW (a first start that failed, or the running game threw): the run still
|
|
249
|
+
// plays, and a save retries it, but what is on screen is not a game. Said on the clock, so
|
|
250
|
+
// the Game panel can say so and the document drops a Restart's cover (`failure`).
|
|
251
|
+
if ((phase === 'start' || phase === 'update') && game === null && current())
|
|
252
|
+
setModelPlayFailure(options.documentId, `${title}: ${detail}`);
|
|
154
253
|
options.report(title, detail);
|
|
155
254
|
};
|
|
156
255
|
// Said once per object: a script that tints every frame would otherwise fill the log.
|
|
@@ -165,19 +264,48 @@ export function runPlayScript(options: {
|
|
|
165
264
|
why: options.ownMaterial ? 'the material is not one the document presents' : 'this document lends no material copies' });
|
|
166
265
|
},
|
|
167
266
|
});
|
|
168
|
-
|
|
267
|
+
// An unknown name is the script's typo, not a reason to stop its game: said once per name, per script.
|
|
268
|
+
const objectOf = (script: Script, target: THREE.Object3D | string, call: 'tint' | 'setOpacity'): THREE.Object3D | null => {
|
|
169
269
|
if (typeof target !== 'string') return target;
|
|
170
|
-
const object = root.getObjectByName(target);
|
|
171
|
-
if (!object
|
|
270
|
+
const object = root.getObjectByName(target) ?? null;
|
|
271
|
+
if (!object && !script.unknown.has(target)) {
|
|
272
|
+
script.unknown.add(target);
|
|
273
|
+
run.append('play', 'tint-unknown-object', { object: target, call });
|
|
274
|
+
}
|
|
172
275
|
return object;
|
|
173
276
|
};
|
|
174
277
|
const keys = new Set<string>();
|
|
175
278
|
const heldKeys = new Set<string>();
|
|
176
|
-
|
|
279
|
+
// The run this runner belongs to. A Restart moves the document to the next generation, whose
|
|
280
|
+
// own runner takes over; this one must then stand down without ending the play.
|
|
281
|
+
const generation = modelPlayGeneration(options.documentId);
|
|
282
|
+
const current = (): boolean => modelPlayGeneration(options.documentId) === generation;
|
|
283
|
+
const restarted = consumeModelPlayRestart(options.documentId);
|
|
284
|
+
const transition = cameraTransition(options.editingCamera(), { instant: restarted });
|
|
285
|
+
// THE TRANSPORT'S CHANGES, IN THE LOG. Pause, resume and speed are the person's (or an
|
|
286
|
+
// agent's) calls on `model-play.ts`, between frames; this run notes each as it lands. A run
|
|
287
|
+
// that Restart has replaced notes nothing more — its successor's log has the restart.
|
|
288
|
+
let seen = modelPlayClock(options.documentId);
|
|
289
|
+
let seenBot = modelPlayAutoplay(options.documentId);
|
|
290
|
+
if (restarted) run.append('play', 'play-restart', { speed: seen.speed });
|
|
291
|
+
else if (seen.speed !== 1) run.append('play', 'speed', { speed: seen.speed });
|
|
292
|
+
const stopClock = subscribeModelPlayClock(() => {
|
|
293
|
+
const now = modelPlayClock(options.documentId);
|
|
294
|
+
if (!current()) return;
|
|
295
|
+
if (now.paused !== seen.paused) run.append('play', now.paused ? 'pause' : 'resume');
|
|
296
|
+
if (now.speed !== seen.speed) run.append('play', 'speed', { speed: now.speed, from: seen.speed });
|
|
297
|
+
seen = now;
|
|
298
|
+
const bot = modelPlayAutoplay(options.documentId);
|
|
299
|
+
if (bot.on !== seenBot.on) run.append('play', bot.on ? 'autoplay-on' : 'autoplay-off', { by: bot.by });
|
|
300
|
+
// An arm dropped before it was taken: a takeover, or a script that offers no bot. (Stop's
|
|
301
|
+
// reset has no `by`, and its `play-stop` says enough.)
|
|
302
|
+
else if (seenBot.armed && !bot.armed && bot.by !== null) run.append('play', 'autoplay-off', { by: bot.by, armed: true });
|
|
303
|
+
seenBot = bot;
|
|
304
|
+
});
|
|
177
305
|
options.container.style.opacity = '0';
|
|
178
|
-
/** One script's context: its log, tint and
|
|
306
|
+
/** One script's context: its log, tint, opacity and bot do nothing once that script is gone
|
|
179
307
|
* (replaced, failed, or the run stopped), so a stale timer cannot reach a later one. */
|
|
180
|
-
const contextFor = (alive:
|
|
308
|
+
const contextFor = (alive: Script): ModelPlayContext => ({
|
|
181
309
|
root,
|
|
182
310
|
find(name) {
|
|
183
311
|
const object = root.getObjectByName(name) ?? null;
|
|
@@ -190,10 +318,21 @@ export function runPlayScript(options: {
|
|
|
190
318
|
},
|
|
191
319
|
keys,
|
|
192
320
|
log(kind, facts) { if (alive.value) run.append('script', kind, facts); },
|
|
193
|
-
tint(object, color) {
|
|
194
|
-
|
|
321
|
+
tint(object, color) {
|
|
322
|
+
const target = alive.value ? objectOf(alive, object, 'tint') : null;
|
|
323
|
+
if (target) materials.tint(target, color);
|
|
324
|
+
},
|
|
325
|
+
setOpacity(object, opacity) {
|
|
326
|
+
const target = alive.value ? objectOf(alive, object, 'setOpacity') : null;
|
|
327
|
+
if (target) materials.setOpacity(target, opacity);
|
|
328
|
+
},
|
|
329
|
+
autoplay(controller) {
|
|
330
|
+
if (controller !== null && typeof controller !== 'function')
|
|
331
|
+
throw new Error('play.autoplay takes a function (the bot) or null.');
|
|
332
|
+
if (alive.value) alive.bot = controller;
|
|
333
|
+
},
|
|
195
334
|
});
|
|
196
|
-
const scripts = new WeakMap<ModelPlayGame,
|
|
335
|
+
const scripts = new WeakMap<ModelPlayGame, Script>();
|
|
197
336
|
let stopped = false;
|
|
198
337
|
let attempt = 0;
|
|
199
338
|
let game: ModelPlayGame | null = null;
|
|
@@ -224,7 +363,7 @@ export function runPlayScript(options: {
|
|
|
224
363
|
} finally {
|
|
225
364
|
// After its own dispose, which may still log; nothing it scheduled may.
|
|
226
365
|
const alive = ending && scripts.get(ending);
|
|
227
|
-
if (alive) alive.value = false;
|
|
366
|
+
if (alive) { alive.value = false; alive.bot = null; }
|
|
228
367
|
layers?.dispose();
|
|
229
368
|
}
|
|
230
369
|
};
|
|
@@ -240,7 +379,7 @@ export function runPlayScript(options: {
|
|
|
240
379
|
const mine = ++attempt;
|
|
241
380
|
if (pending) { dispose(pending.game, pending.composition); pending = null; }
|
|
242
381
|
let nextComposition: PlayComposition | undefined;
|
|
243
|
-
const alive = { value: true };
|
|
382
|
+
const alive: Script = { value: true, bot: null, unknown: new Set() };
|
|
244
383
|
try {
|
|
245
384
|
if (mountLayers) {
|
|
246
385
|
const project = getCurrentProject();
|
|
@@ -282,6 +421,9 @@ export function runPlayScript(options: {
|
|
|
282
421
|
}
|
|
283
422
|
};
|
|
284
423
|
let firstFrame = true;
|
|
424
|
+
/** Whether the last script to run its first update offered a bot (null before any did), so
|
|
425
|
+
* `autoplay-unavailable` is said once per run, and again only after a bot came and went. */
|
|
426
|
+
let offeredBot: boolean | null = null;
|
|
285
427
|
let returning = false;
|
|
286
428
|
let stopReason = 'stop';
|
|
287
429
|
const stopRequest = registerModelPlayStop(options.documentId, (escape) => {
|
|
@@ -291,6 +433,13 @@ export function runPlayScript(options: {
|
|
|
291
433
|
if (firstFrame || transition.stop(escape)) finishModelPlay(options.documentId);
|
|
292
434
|
});
|
|
293
435
|
const stopFrames = onFrame((deltaSeconds) => {
|
|
436
|
+
// REPLACED BY A RESTART, and not yet unmounted: the document keeps this copy on screen until
|
|
437
|
+
// the new one has drawn, so it stands as it is — no update, no clock, the camera held.
|
|
438
|
+
if (!current()) {
|
|
439
|
+
if (game !== null) transition.hold(camera());
|
|
440
|
+
keys.clear();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
294
443
|
if (transition.leaving()) {
|
|
295
444
|
options.container.style.opacity = String(transition.hudOpacity());
|
|
296
445
|
if (!returning && transition.approachingEdit()) { returning = true; options.returning(); }
|
|
@@ -300,33 +449,114 @@ export function runPlayScript(options: {
|
|
|
300
449
|
if (!surfaceHoldsKeyboard()) { keys.clear(); heldKeys.clear(); }
|
|
301
450
|
else if (!transition.acceptingKeys()) keys.clear();
|
|
302
451
|
else for (const key of heldKeys) keys.add(key);
|
|
303
|
-
|
|
304
|
-
if (
|
|
452
|
+
// The panel's toggle is enabled by the bot the running script offers, as of the last frame.
|
|
453
|
+
if (current()) setModelPlayAutoplayAvailable(options.documentId, game !== null && scripts.get(game)?.bot != null);
|
|
454
|
+
const updates = frameUpdates(options.documentId, deltaSeconds);
|
|
455
|
+
if (updates.length === 0) {
|
|
456
|
+
// PAUSED: no update, so nothing states the camera; hold the pose the last frame drew.
|
|
457
|
+
// A pending replacement waits too — its first update is a tick of the game's time.
|
|
458
|
+
// A tap made while paused is dropped; a key still held is seen by the next step.
|
|
459
|
+
if (game !== null) transition.hold(camera());
|
|
460
|
+
keys.clear();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Each update is counted — by the panel's clock and the log alike — as it is CALLED, so an
|
|
464
|
+
// update that throws still took its tick, in both.
|
|
465
|
+
let ran = 0;
|
|
466
|
+
let simulated = 0;
|
|
467
|
+
// THE BOT'S KEYS, per update: the person's keys of this frame and what the bot holds now. It
|
|
468
|
+
// drives only once the camera has arrived, as a person's keys reach the game only then.
|
|
469
|
+
const clock = modelPlayClock(options.documentId);
|
|
470
|
+
const driving = current() && modelPlayAutoplay(options.documentId).on && transition.acceptingKeys();
|
|
471
|
+
const person: ReadonlySet<string> = driving ? new Set(keys) : keys;
|
|
472
|
+
/** `keys` back to the person's alone. Only ever after `drive` has added the bot's, when
|
|
473
|
+
* `person` is a copy — never `keys` itself. */
|
|
474
|
+
const restorePerson = (): void => {
|
|
475
|
+
keys.clear();
|
|
476
|
+
for (const key of person) keys.add(key);
|
|
477
|
+
};
|
|
478
|
+
/** Merge the bot's keys into `keys` for one update; true when it did, and the caller then
|
|
479
|
+
* restores the person's keys after that update, however it ends. */
|
|
480
|
+
const drive = (script: ModelPlayGame, dt: number): boolean => {
|
|
481
|
+
const bot = driving ? scripts.get(script)?.bot : null;
|
|
482
|
+
// Asked again per update: the bot's own failure, a takeover, or `play.autoplay(null)`
|
|
483
|
+
// ends it mid-frame.
|
|
484
|
+
if (!bot || !modelPlayAutoplay(options.documentId).on) return false;
|
|
485
|
+
restorePerson();
|
|
486
|
+
try {
|
|
487
|
+
for (const key of bot({ dt, simT: clock.time + simulated, tick: clock.tick + ran, keys: person }) ?? []) keys.add(String(key));
|
|
488
|
+
return true;
|
|
489
|
+
} catch (error) {
|
|
490
|
+
restorePerson();
|
|
491
|
+
setModelPlayAutoplay(options.documentId, false, 'script');
|
|
492
|
+
report('autoplay', `${modulePath}'s autoplay failed`, error);
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
/** One update, bot-driven or not. The bot's keys hold for this update only: restored in a
|
|
497
|
+
* `finally`, so neither a bot withdrawn mid-frame, nor an update that throws, nor a reloaded
|
|
498
|
+
* script leaves them reading as held. */
|
|
499
|
+
const update = (script: ModelPlayGame, dt: number): void => {
|
|
500
|
+
tick(dt);
|
|
501
|
+
const driven = drive(script, dt);
|
|
502
|
+
try { script.update(dt); }
|
|
503
|
+
finally { if (driven) restorePerson(); }
|
|
504
|
+
};
|
|
505
|
+
// A paused frame that runs an update is a Step; its entry carries the step's own tick.
|
|
506
|
+
const stepping = modelPlayClock(options.documentId).paused;
|
|
507
|
+
const tick = (dt: number): void => {
|
|
508
|
+
run.advance(dt);
|
|
509
|
+
ran += 1;
|
|
510
|
+
simulated += dt;
|
|
511
|
+
if (stepping) run.append('play', 'step', { dt });
|
|
512
|
+
};
|
|
513
|
+
// A replacement's first update takes the frame's first slot whether it starts or throws; a
|
|
514
|
+
// running game that a failed replacement leaves in place runs the rest of the frame.
|
|
515
|
+
let firstSlot = 0;
|
|
305
516
|
if (pending) {
|
|
517
|
+
firstSlot = 1;
|
|
306
518
|
const next = pending;
|
|
307
519
|
pending = null;
|
|
308
520
|
try {
|
|
309
|
-
next.game
|
|
521
|
+
update(next.game, updates[0]!);
|
|
310
522
|
end();
|
|
311
523
|
game = next.game;
|
|
524
|
+
// NOW THE SCRIPT HAS SAID WHETHER IT OFFERS A BOT (its default export and first update
|
|
525
|
+
// are where `play.autoplay` is called): said before `running`, so no reader sees a running
|
|
526
|
+
// game with its bot not yet counted, and an arm made while stopped is taken or dropped.
|
|
527
|
+
const offered = scripts.get(next.game)?.bot != null;
|
|
528
|
+
if (!offered && offeredBot !== false)
|
|
529
|
+
run.append('play', 'autoplay-unavailable', { why: 'the play script registers no bot with play.autoplay(controller)' });
|
|
530
|
+
offeredBot = offered;
|
|
531
|
+
// A KEY THE PERSON ALREADY HOLDS is a takeover the runner did not hear: it was pressed while
|
|
532
|
+
// the stage was still preparing, before these listeners existed, and has only repeated
|
|
533
|
+
// since. The person is driving, so an arm waiting for this update is dropped.
|
|
534
|
+
if (heldKeys.size > 0 && modelPlayAutoplay(options.documentId).armed) takeover();
|
|
535
|
+
settleModelPlayAutoplay(options.documentId, offered);
|
|
536
|
+
setModelPlayFailure(options.documentId, null);
|
|
312
537
|
startedAt = Date.now();
|
|
313
538
|
endedAt = null;
|
|
314
539
|
composition = next.composition;
|
|
315
540
|
live.notifyChanged();
|
|
316
541
|
composition?.reveal();
|
|
317
|
-
replacementUpdated = true;
|
|
318
542
|
} catch (error) {
|
|
319
543
|
dispose(next.game, next.composition);
|
|
320
544
|
report('start', `${modulePath} did not start`, error);
|
|
321
545
|
}
|
|
322
546
|
}
|
|
323
|
-
if (game === null) return;
|
|
547
|
+
if (game === null) { advanceModelPlayClock(options.documentId, simulated, ran); keys.clear(); return; }
|
|
324
548
|
try {
|
|
325
|
-
|
|
549
|
+
for (let index = firstSlot; index < updates.length; index++) {
|
|
550
|
+
update(game, updates[index]!);
|
|
551
|
+
}
|
|
552
|
+
advanceModelPlayClock(options.documentId, simulated, ran);
|
|
553
|
+
ran = 0;
|
|
326
554
|
transition.frame(camera(), deltaSeconds);
|
|
327
555
|
options.container.style.opacity = String(transition.hudOpacity());
|
|
328
556
|
if (firstFrame) { firstFrame = false; options.ready(); }
|
|
329
557
|
} catch (error) {
|
|
558
|
+
advanceModelPlayClock(options.documentId, simulated, ran);
|
|
559
|
+
keys.clear();
|
|
330
560
|
end();
|
|
331
561
|
report('update', `${modulePath} failed`, error);
|
|
332
562
|
return;
|
|
@@ -335,8 +565,21 @@ export function runPlayScript(options: {
|
|
|
335
565
|
keys.clear();
|
|
336
566
|
root.updateMatrixWorld(true);
|
|
337
567
|
});
|
|
568
|
+
// THE PERSON ALWAYS WINS: a new key press the game would hear, or a pointer pressed anywhere
|
|
569
|
+
// in the game's area (its HUD included), hands control back before the input is the game's.
|
|
570
|
+
// Untrusted events count: `editor.document.key` and the probe's clicks are how an agent plays
|
|
571
|
+
// by hand.
|
|
572
|
+
const surface = options.container.parentElement ?? options.container;
|
|
573
|
+
const takeover = (): void => {
|
|
574
|
+
// An arm still waiting for the bot counts too: the person is driving before it could start.
|
|
575
|
+
const now = modelPlayAutoplay(options.documentId);
|
|
576
|
+
if (current() && (now.on || now.armed)) setModelPlayAutoplay(options.documentId, false, 'takeover');
|
|
577
|
+
};
|
|
338
578
|
const onKeyDown = (event: KeyboardEvent): void => {
|
|
339
579
|
if (!surfaceAcceptsKey(event)) return;
|
|
580
|
+
// A repeat is a key already held, not a new press — unless this runner never saw it go down:
|
|
581
|
+
// then it was pressed before the runner was listening, and that press was a takeover.
|
|
582
|
+
if (!event.repeat || !heldKeys.has(event.code)) takeover();
|
|
340
583
|
heldKeys.add(event.code);
|
|
341
584
|
if (transition.acceptingKeys()) keys.add(event.code);
|
|
342
585
|
};
|
|
@@ -344,7 +587,11 @@ export function runPlayScript(options: {
|
|
|
344
587
|
// Preserve a between-frame tap until one game update has observed it.
|
|
345
588
|
heldKeys.delete(event.code);
|
|
346
589
|
};
|
|
590
|
+
const onPointerDown = (event: PointerEvent): void => {
|
|
591
|
+
if (event.target instanceof Node && surface.contains(event.target)) takeover();
|
|
592
|
+
};
|
|
347
593
|
const onBlur = (): void => { keys.clear(); heldKeys.clear(); };
|
|
594
|
+
window.addEventListener('pointerdown', onPointerDown, true);
|
|
348
595
|
window.addEventListener('keydown', onKeyDown, true);
|
|
349
596
|
window.addEventListener('keyup', onKeyUp, true);
|
|
350
597
|
window.addEventListener('blur', onBlur);
|
|
@@ -369,9 +616,12 @@ export function runPlayScript(options: {
|
|
|
369
616
|
stopped = true;
|
|
370
617
|
unregisterLive();
|
|
371
618
|
stopRequest();
|
|
372
|
-
|
|
619
|
+
// A runner replaced by Restart leaves the play running for its successor.
|
|
620
|
+
if (current()) finishModelPlay(options.documentId);
|
|
373
621
|
stopFrames();
|
|
622
|
+
stopClock();
|
|
374
623
|
stopChanges();
|
|
624
|
+
window.removeEventListener('pointerdown', onPointerDown, true);
|
|
375
625
|
window.removeEventListener('keydown', onKeyDown, true);
|
|
376
626
|
window.removeEventListener('keyup', onKeyUp, true);
|
|
377
627
|
window.removeEventListener('blur', onBlur);
|