@volter/editor-model-play 0.5.189 → 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 +185 -0
- package/contributions/model-play.command.ts +37 -0
- package/contributions/model-play.service.tsx +108 -5
- package/package.json +3 -2
- package/src/camera-transition.ts +13 -3
- package/src/model-play.ts +253 -10
- package/src/play-log.ts +233 -0
- package/src/play-materials.ts +199 -0
- package/src/play-script.ts +357 -21
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,24 @@ 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';
|
|
85
|
+
import { beginModelPlayLog } from './play-log';
|
|
86
|
+
import { materialOverrides } from './play-materials';
|
|
42
87
|
interface PlayComposition {
|
|
43
88
|
readonly entries: readonly string[];
|
|
44
89
|
loadScript(): Promise<{ default?: unknown }>;
|
|
@@ -58,14 +103,97 @@ export interface ModelPlayContext {
|
|
|
58
103
|
readonly camera: THREE.Camera;
|
|
59
104
|
/** The keys held now, by `KeyboardEvent.code` (`ArrowUp`, `KeyW`, `Space`). */
|
|
60
105
|
readonly keys: ReadonlySet<string>;
|
|
106
|
+
/**
|
|
107
|
+
* Write one entry to the play log, stamped with the run's simulation time and frame
|
|
108
|
+
* (`play-log.ts`): a kind and the facts that explain it, JSON-serialisable and snapshotted
|
|
109
|
+
* now. Log transitions (a landing, a death and its cause, autoplay's choice), not every
|
|
110
|
+
* frame. Read back with `cyclotron play-log [--since <simT>] [--kind <k>] [--json]`
|
|
111
|
+
* or `editor.modelPlayLog({ since, kind })` in `eval`. Never throws, never changes the game,
|
|
112
|
+
* and does nothing once this script has been replaced or Play has stopped.
|
|
113
|
+
*
|
|
114
|
+
* play.log('death', { cause: 'lava', at: player.position, stage });
|
|
115
|
+
*/
|
|
116
|
+
log(kind: string, facts?: Record<string, unknown>): void;
|
|
117
|
+
/**
|
|
118
|
+
* Draw an object and the meshes under it in `color` (a `THREE.Color`, `'#2bff6b'`,
|
|
119
|
+
* `0x2bff6b`); an emitting surface glows in it too, an image texture is multiplied by it.
|
|
120
|
+
* `null` returns the authored colours. Other objects wearing the same Blender material keep
|
|
121
|
+
* theirs. An object is a name or one `find` answered.
|
|
122
|
+
*
|
|
123
|
+
* THE PRESENTED MATERIALS, which is why this exists: a mesh's `material` is an array with
|
|
124
|
+
* one shared `MeshPhysicalMaterial` per Blender material slot (a lone material only for an
|
|
125
|
+
* object without slots), so `material.color` is undefined, and editing an entry recolours
|
|
126
|
+
* every object wearing that Blender material. The presenter re-assigns those slots
|
|
127
|
+
* whenever it re-applies shading, `clone()` drops its shader hooks, and a node graph
|
|
128
|
+
* driving Base Color or Alpha ignores `color` and `opacity`. A tinted or faded object
|
|
129
|
+
* wears copies of its own slots, drawn from their constant inputs (a node graph's
|
|
130
|
+
* other inputs are not drawn while it does), and keeps them through a script reload. An
|
|
131
|
+
* object with a material the document cannot copy (one the script made) is left as it is,
|
|
132
|
+
* and the log says so once with a `tint-unsupported` entry.
|
|
133
|
+
*/
|
|
134
|
+
tint(object: THREE.Object3D | string, color: THREE.ColorRepresentation | null): void;
|
|
135
|
+
/** Fade an object and the meshes under it to `opacity` (0 to 1); `null` returns the
|
|
136
|
+
* authored opacity. Its colour is untouched, and the copies are `tint`'s. */
|
|
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>;
|
|
61
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;
|
|
62
163
|
|
|
63
164
|
export interface ModelPlayGame {
|
|
64
|
-
/** 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. */
|
|
65
168
|
update(deltaSeconds: number): void;
|
|
66
169
|
dispose?(): void;
|
|
67
170
|
}
|
|
68
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
|
+
|
|
69
197
|
/** The play script's project path for a model's `.blend`. */
|
|
70
198
|
export function playScriptPath(blend: string): string {
|
|
71
199
|
return blend.replace(/\.blend$/i, '') + '.play.ts';
|
|
@@ -107,14 +235,77 @@ export function runPlayScript(options: {
|
|
|
107
235
|
readonly container: HTMLElement;
|
|
108
236
|
readonly ready: () => void;
|
|
109
237
|
readonly returning: () => void;
|
|
238
|
+
readonly ownMaterial?: ((material: THREE.Material) => THREE.Material | null) | undefined;
|
|
110
239
|
}): () => void {
|
|
111
|
-
const { blend, root, camera, onFrame
|
|
240
|
+
const { blend, root, camera, onFrame } = options;
|
|
112
241
|
const modulePath = playScriptPath(blend);
|
|
242
|
+
// A fresh copy is a fresh run: its log starts empty, its clock at zero. Writes go through
|
|
243
|
+
// this run's handle, which is inert once the run has ended.
|
|
244
|
+
const run = beginModelPlayLog(options.documentId, modulePath);
|
|
245
|
+
const report = (phase: 'start' | 'update' | 'stop' | 'autoplay', title: string, error: unknown): void => {
|
|
246
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
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}`);
|
|
253
|
+
options.report(title, detail);
|
|
254
|
+
};
|
|
255
|
+
// Said once per object: a script that tints every frame would otherwise fill the log.
|
|
256
|
+
const unsupported = new WeakSet<THREE.Mesh>();
|
|
257
|
+
const materials = materialOverrides({
|
|
258
|
+
root,
|
|
259
|
+
ownMaterial: options.ownMaterial,
|
|
260
|
+
unsupported: (mesh, material) => {
|
|
261
|
+
if (unsupported.has(mesh)) return;
|
|
262
|
+
unsupported.add(mesh);
|
|
263
|
+
run.append('play', 'tint-unsupported', { object: mesh.name, material: material.name,
|
|
264
|
+
why: options.ownMaterial ? 'the material is not one the document presents' : 'this document lends no material copies' });
|
|
265
|
+
},
|
|
266
|
+
});
|
|
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 => {
|
|
269
|
+
if (typeof target !== 'string') return target;
|
|
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
|
+
}
|
|
275
|
+
return object;
|
|
276
|
+
};
|
|
113
277
|
const keys = new Set<string>();
|
|
114
278
|
const heldKeys = new Set<string>();
|
|
115
|
-
|
|
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
|
+
});
|
|
116
305
|
options.container.style.opacity = '0';
|
|
117
|
-
|
|
306
|
+
/** One script's context: its log, tint, opacity and bot do nothing once that script is gone
|
|
307
|
+
* (replaced, failed, or the run stopped), so a stale timer cannot reach a later one. */
|
|
308
|
+
const contextFor = (alive: Script): ModelPlayContext => ({
|
|
118
309
|
root,
|
|
119
310
|
find(name) {
|
|
120
311
|
const object = root.getObjectByName(name) ?? null;
|
|
@@ -126,7 +317,22 @@ export function runPlayScript(options: {
|
|
|
126
317
|
return camera();
|
|
127
318
|
},
|
|
128
319
|
keys,
|
|
129
|
-
|
|
320
|
+
log(kind, facts) { if (alive.value) run.append('script', kind, facts); },
|
|
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
|
+
},
|
|
334
|
+
});
|
|
335
|
+
const scripts = new WeakMap<ModelPlayGame, Script>();
|
|
130
336
|
let stopped = false;
|
|
131
337
|
let attempt = 0;
|
|
132
338
|
let game: ModelPlayGame | null = null;
|
|
@@ -153,8 +359,13 @@ export function runPlayScript(options: {
|
|
|
153
359
|
const dispose = (ending: ModelPlayGame | null, layers: PlayComposition | null): void => {
|
|
154
360
|
try { ending?.dispose?.(); }
|
|
155
361
|
catch (error) {
|
|
156
|
-
report(`${modulePath} failed while stopping`, error
|
|
157
|
-
} finally {
|
|
362
|
+
report('stop', `${modulePath} failed while stopping`, error);
|
|
363
|
+
} finally {
|
|
364
|
+
// After its own dispose, which may still log; nothing it scheduled may.
|
|
365
|
+
const alive = ending && scripts.get(ending);
|
|
366
|
+
if (alive) { alive.value = false; alive.bot = null; }
|
|
367
|
+
layers?.dispose();
|
|
368
|
+
}
|
|
158
369
|
};
|
|
159
370
|
const end = (): void => {
|
|
160
371
|
dispose(game, composition);
|
|
@@ -163,10 +374,12 @@ export function runPlayScript(options: {
|
|
|
163
374
|
if (startedAt !== null) endedAt = Date.now();
|
|
164
375
|
live.notifyChanged();
|
|
165
376
|
};
|
|
166
|
-
|
|
377
|
+
/** `reload` says why a replacement mounts, for the log's `script-reload`; null for Play's first start. */
|
|
378
|
+
const start = async (reload: { readonly reason: string; readonly path: string } | null): Promise<void> => {
|
|
167
379
|
const mine = ++attempt;
|
|
168
380
|
if (pending) { dispose(pending.game, pending.composition); pending = null; }
|
|
169
381
|
let nextComposition: PlayComposition | undefined;
|
|
382
|
+
const alive: Script = { value: true, bot: null, unknown: new Set() };
|
|
170
383
|
try {
|
|
171
384
|
if (mountLayers) {
|
|
172
385
|
const project = getCurrentProject();
|
|
@@ -191,26 +404,42 @@ export function runPlayScript(options: {
|
|
|
191
404
|
};
|
|
192
405
|
}
|
|
193
406
|
if (stopped || mine !== attempt) { nextComposition?.dispose(); return; }
|
|
194
|
-
|
|
407
|
+
// Before the replacement's default export runs, so what it logs follows this.
|
|
408
|
+
if (reload) run.append('play', 'script-reload', reload);
|
|
409
|
+
const next = await startGame(modulePath, contextFor(alive), nextComposition);
|
|
410
|
+
scripts.set(next, alive);
|
|
195
411
|
if (stopped || mine !== attempt) {
|
|
196
412
|
dispose(next, nextComposition ?? null);
|
|
197
413
|
return;
|
|
198
414
|
}
|
|
199
415
|
pending = { game: next, composition: nextComposition ?? null };
|
|
200
416
|
} catch (error) {
|
|
417
|
+
alive.value = false;
|
|
201
418
|
nextComposition?.dispose();
|
|
202
419
|
if (stopped || mine !== attempt) return;
|
|
203
|
-
report(`${modulePath} did not start`, error
|
|
420
|
+
report('start', `${modulePath} did not start`, error);
|
|
204
421
|
}
|
|
205
422
|
};
|
|
206
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;
|
|
207
427
|
let returning = false;
|
|
428
|
+
let stopReason = 'stop';
|
|
208
429
|
const stopRequest = registerModelPlayStop(options.documentId, (escape) => {
|
|
430
|
+
if (escape) stopReason = 'escape';
|
|
209
431
|
keys.clear();
|
|
210
432
|
heldKeys.clear();
|
|
211
433
|
if (firstFrame || transition.stop(escape)) finishModelPlay(options.documentId);
|
|
212
434
|
});
|
|
213
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
|
+
}
|
|
214
443
|
if (transition.leaving()) {
|
|
215
444
|
options.container.style.opacity = String(transition.hudOpacity());
|
|
216
445
|
if (!returning && transition.approachingEdit()) { returning = true; options.returning(); }
|
|
@@ -220,41 +449,137 @@ export function runPlayScript(options: {
|
|
|
220
449
|
if (!surfaceHoldsKeyboard()) { keys.clear(); heldKeys.clear(); }
|
|
221
450
|
else if (!transition.acceptingKeys()) keys.clear();
|
|
222
451
|
else for (const key of heldKeys) keys.add(key);
|
|
223
|
-
|
|
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;
|
|
224
516
|
if (pending) {
|
|
517
|
+
firstSlot = 1;
|
|
225
518
|
const next = pending;
|
|
226
519
|
pending = null;
|
|
227
520
|
try {
|
|
228
|
-
next.game
|
|
521
|
+
update(next.game, updates[0]!);
|
|
229
522
|
end();
|
|
230
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);
|
|
231
537
|
startedAt = Date.now();
|
|
232
538
|
endedAt = null;
|
|
233
539
|
composition = next.composition;
|
|
234
540
|
live.notifyChanged();
|
|
235
541
|
composition?.reveal();
|
|
236
|
-
replacementUpdated = true;
|
|
237
542
|
} catch (error) {
|
|
238
543
|
dispose(next.game, next.composition);
|
|
239
|
-
report(`${modulePath} did not start`, error
|
|
544
|
+
report('start', `${modulePath} did not start`, error);
|
|
240
545
|
}
|
|
241
546
|
}
|
|
242
|
-
if (game === null) return;
|
|
547
|
+
if (game === null) { advanceModelPlayClock(options.documentId, simulated, ran); keys.clear(); return; }
|
|
243
548
|
try {
|
|
244
|
-
|
|
549
|
+
for (let index = firstSlot; index < updates.length; index++) {
|
|
550
|
+
update(game, updates[index]!);
|
|
551
|
+
}
|
|
552
|
+
advanceModelPlayClock(options.documentId, simulated, ran);
|
|
553
|
+
ran = 0;
|
|
245
554
|
transition.frame(camera(), deltaSeconds);
|
|
246
555
|
options.container.style.opacity = String(transition.hudOpacity());
|
|
247
556
|
if (firstFrame) { firstFrame = false; options.ready(); }
|
|
248
557
|
} catch (error) {
|
|
558
|
+
advanceModelPlayClock(options.documentId, simulated, ran);
|
|
559
|
+
keys.clear();
|
|
249
560
|
end();
|
|
250
|
-
report(`${modulePath} failed`, error
|
|
561
|
+
report('update', `${modulePath} failed`, error);
|
|
251
562
|
return;
|
|
252
563
|
}
|
|
564
|
+
materials.frame();
|
|
253
565
|
keys.clear();
|
|
254
566
|
root.updateMatrixWorld(true);
|
|
255
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
|
+
};
|
|
256
578
|
const onKeyDown = (event: KeyboardEvent): void => {
|
|
257
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();
|
|
258
583
|
heldKeys.add(event.code);
|
|
259
584
|
if (transition.acceptingKeys()) keys.add(event.code);
|
|
260
585
|
};
|
|
@@ -262,7 +587,11 @@ export function runPlayScript(options: {
|
|
|
262
587
|
// Preserve a between-frame tap until one game update has observed it.
|
|
263
588
|
heldKeys.delete(event.code);
|
|
264
589
|
};
|
|
590
|
+
const onPointerDown = (event: PointerEvent): void => {
|
|
591
|
+
if (event.target instanceof Node && surface.contains(event.target)) takeover();
|
|
592
|
+
};
|
|
265
593
|
const onBlur = (): void => { keys.clear(); heldKeys.clear(); };
|
|
594
|
+
window.addEventListener('pointerdown', onPointerDown, true);
|
|
266
595
|
window.addEventListener('keydown', onKeyDown, true);
|
|
267
596
|
window.addEventListener('keyup', onKeyUp, true);
|
|
268
597
|
window.addEventListener('blur', onBlur);
|
|
@@ -272,26 +601,33 @@ export function runPlayScript(options: {
|
|
|
272
601
|
// would manufacture a mount failure while the old game is still alive.
|
|
273
602
|
// A missing dependency continues through the ordinary error-reporting path.
|
|
274
603
|
if (type === 'delete' && projectModuleChangeMatches(changed, modulePath)) {
|
|
604
|
+
stopReason = 'script-deleted';
|
|
275
605
|
finishModelPlay(options.documentId);
|
|
276
606
|
return;
|
|
277
607
|
}
|
|
278
608
|
// A composed HUD and script must remount together, including shared-store
|
|
279
609
|
// edits. A fresh epoch is threaded to both through the UI tool door.
|
|
280
610
|
const entries = [modulePath, ...retryEntries, ...(composition?.entries ?? []), ...(pending?.composition?.entries ?? [])];
|
|
281
|
-
if ([changed, ...(affected ?? [])].some(path => entries.some(entry => projectModuleChangeMatches(path, entry))))
|
|
611
|
+
if ([changed, ...(affected ?? [])].some(path => entries.some(entry => projectModuleChangeMatches(path, entry))))
|
|
612
|
+
void start({ reason: type === 'delete' ? 'dependency-deleted' : 'saved', path: changed });
|
|
282
613
|
});
|
|
283
|
-
void start();
|
|
614
|
+
void start(null);
|
|
284
615
|
return () => {
|
|
285
616
|
stopped = true;
|
|
286
617
|
unregisterLive();
|
|
287
618
|
stopRequest();
|
|
288
|
-
|
|
619
|
+
// A runner replaced by Restart leaves the play running for its successor.
|
|
620
|
+
if (current()) finishModelPlay(options.documentId);
|
|
289
621
|
stopFrames();
|
|
622
|
+
stopClock();
|
|
290
623
|
stopChanges();
|
|
624
|
+
window.removeEventListener('pointerdown', onPointerDown, true);
|
|
291
625
|
window.removeEventListener('keydown', onKeyDown, true);
|
|
292
626
|
window.removeEventListener('keyup', onKeyUp, true);
|
|
293
627
|
window.removeEventListener('blur', onBlur);
|
|
294
628
|
if (pending) { dispose(pending.game, pending.composition); pending = null; }
|
|
295
629
|
end();
|
|
630
|
+
materials.dispose();
|
|
631
|
+
run.end({ reason: stopReason });
|
|
296
632
|
};
|
|
297
633
|
}
|