@volter/play 0.5.211 → 0.5.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,841 +0,0 @@
1
- /**
2
- * A MODEL'S PLAY SCRIPT — the project module that moves the detached copy while the document
3
- * plays (`model-play.ts`).
4
- *
5
- * It is an ordinary project file beside the `.blend`, as the bpy script that authored it is:
6
- * `src/models/canyon.blend` plays `src/models/canyon.play.ts`. Its default export is called once
7
- * with the play context and answers the game:
8
- *
9
- * export default (play: ModelPlayContext): ModelPlayGame => ({
10
- * update(dt) { play.find('Car')!.position.x += dt; },
11
- * });
12
- *
13
- * WHAT THE SCRIPT HOLDS IS THREE'S OWN GRAPH: `root` is the Model group the stage draws, one
14
- * `Object3D` per Blender object under it, named and parented as in the file. Inside `root`
15
- * the coordinates are Blender's (Z up); `root` itself carries the turn to three's Y up, so an
16
- * object's `getWorldPosition` is in the stage's space, where `camera` lives.
17
- *
18
- * Without UI layers, the tool imports through the live-module URL. When a UI
19
- * tool offers project roots, all entries share one project mount epoch.
20
- * A save in the mount's dependency graph prepares a replacement against the
21
- * same detached copy; its first successful update replaces the running mount.
22
- * The tool blends the camera from the editing pose for 0.8 seconds after
23
- * update, holding keys empty until arrival. Stop freezes the copy and blends
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.
53
- */
54
- import { editorHost } from '@volter/sdk/host';
55
- import { getCurrentProject } from '@volter/sdk/kit/active-project';
56
- import { surfaceAcceptsKey, surfaceHoldsKeyboard } from '@volter/sdk/kit/surface-keyboard';
57
- import {
58
- projectModuleChangeMatches,
59
- subscribeProjectModuleChange,
60
- } from '@volter/sdk/kit/project-module-changes';
61
- import {
62
- beginLiveModuleRevision,
63
- liveModuleImportUrl,
64
- } from '@volter/sdk/session/project-module-url';
65
- import type * as THREE from 'three';
66
- import { projectPlayLayers } from '@volter/sdk/kit/project-play-layers';
67
- import { beginProjectMountEpoch, projectEntryImportUrl } from '@volter/sdk/session/project-module-url';
68
- import { cameraTransition } from './camera-transition';
69
- import {
70
- advanceModelPlayClock,
71
- consumeModelPlayRestart,
72
- finishModelPlay,
73
- MODEL_PLAY_STEP_SECONDS,
74
- modelPlayAutoplay,
75
- modelPlayClock,
76
- modelPlayGeneration,
77
- noteModelPlayPerson,
78
- registerModelPlayStop,
79
- setModelPlayAutoplay,
80
- setModelPlayBotState,
81
- setModelPlayFailure,
82
- setModelPlayAutoplayAvailable,
83
- setModelPlayPaused,
84
- settleModelPlayAutoplay,
85
- subscribeModelPlayClock,
86
- takeModelPlayStep,
87
- } from './model-play';
88
- import { beginModelPlayLog } from './play-log';
89
- import type { DocumentPlayAnimation } from '@volter/sdk/kit/document-play-extension';
90
- import { materialOverrides } from './play-materials';
91
- interface PlayComposition {
92
- readonly entries: readonly string[];
93
- loadScript(): Promise<{ default?: unknown }>;
94
- reveal(): void;
95
- dispose(): void;
96
- }
97
-
98
- export interface ModelPlayContext {
99
- /** The Model group: the detached copy's root. */
100
- readonly root: THREE.Object3D;
101
- /** The object a Blender object's name presents as, ready to be moved by `position`,
102
- * `quaternion` and `scale`; null when the file has no such object. */
103
- find(name: string): THREE.Object3D | null;
104
- /** The camera the stage draws with. Navigation re-poses it before each call,
105
- * so a script states the whole pose every frame. The tool blends that pose
106
- * during entry and holds keys empty until the camera arrives. */
107
- readonly camera: THREE.Camera;
108
- /** The keys held now, by `KeyboardEvent.code` (`ArrowUp`, `KeyW`, `Space`). */
109
- readonly keys: ReadonlySet<string>;
110
- /**
111
- * Write one entry to the play log, stamped with the run's simulation time and frame
112
- * (`play-log.ts`): a kind and the facts that explain it, JSON-serialisable and snapshotted
113
- * now. Log transitions (a landing, a death and its cause, autoplay's choice), not every
114
- * frame. Read back with `cyclotron play-log [--since <simT>] [--kind <k>] [--json]`
115
- * or `editor.modelPlayLog({ since, kind })` in `eval`. Never throws, never changes the game,
116
- * and does nothing once this script has been replaced or Play has stopped.
117
- *
118
- * play.log('death', { cause: 'lava', at: player.position, stage });
119
- */
120
- log(kind: string, facts?: Record<string, unknown>): void;
121
- /**
122
- * Draw an object and the meshes under it in `color` (a `THREE.Color`, `'#2bff6b'`,
123
- * `0x2bff6b`); an emitting surface glows in it too, an image texture is multiplied by it.
124
- * `null` returns the authored colours. Other objects wearing the same Blender material keep
125
- * theirs. An object is a name or one `find` answered.
126
- *
127
- * THE PRESENTED MATERIALS, which is why this exists: a mesh's `material` is an array with
128
- * one shared `MeshPhysicalMaterial` per Blender material slot (a lone material only for an
129
- * object without slots), so `material.color` is undefined, and editing an entry recolours
130
- * every object wearing that Blender material. The presenter re-assigns those slots
131
- * whenever it re-applies shading, `clone()` drops its shader hooks, and a node graph
132
- * driving Base Color or Alpha ignores `color` and `opacity`. A tinted or faded object
133
- * wears copies of its own slots, drawn from their constant inputs (a node graph's
134
- * other inputs are not drawn while it does), and keeps them through a script reload. An
135
- * object with a material the document cannot copy (one the script made) is left as it is,
136
- * and the log says so once with a `tint-unsupported` entry.
137
- */
138
- tint(object: THREE.Object3D | string, color: THREE.ColorRepresentation | null): void;
139
- /** Fade an object and the meshes under it to `opacity` (0 to 1); `null` returns the
140
- * authored opacity. Its colour is untouched, and the copies are `tint`'s. */
141
- setOpacity(object: THREE.Object3D | string, opacity: number | null): void;
142
- /**
143
- * Offer this game's bot, as named BEHAVIOURS: what the bot sets out to do, each its own
144
- * controller. While the person (or an agent, `play autoplay on <behaviour>`) has autoplay on in
145
- * the Game panel, the chosen behaviour's controller is called before each `update` and the keys
146
- * it answers are held for that update, merged into `keys`. Offer the outcomes worth checking —
147
- * one that plays to win and one that loses on purpose — so a run tests an ending by saying so,
148
- * not by leaving the game alone. A bare function is the one behaviour `play`. Every run has a
149
- * limit in simulation seconds (`play autoplay on <behaviour> --for <seconds>`, 300 unless
150
- * given): reaching it turns autoplay off and pauses the game. Autoplay is off at every Play and
151
- * Restart, and a person's key or pointer in the game turns it off. One bot per script:
152
- * registering again replaces it, `null` withdraws it, and it goes with the script. Never bind
153
- * autoplay to a game key, and never start it from the script.
154
- *
155
- * play.autoplay({
156
- * win: ({ dt }) => (car.speed < 20 ? ['ArrowUp'] : []),
157
- * lose: () => ['ArrowLeft'], // drives off the track
158
- * });
159
- */
160
- autoplay(bot: ModelPlayAutoplayController | Readonly<Record<string, ModelPlayAutoplayController>> | null): void;
161
- /**
162
- * Set the action an object's armature plays, as Blender's `animation_data.action` does: any
163
- * action in the file, by name. `object` is the armature, its skinned mesh, or an object above or
164
- * below them, or its name. Each armature starts a run playing the action the file assigns it.
165
- * Setting another crossfades over `fade` seconds (0.2), repeats unless `loop: false` (which
166
- * holds the last frame), and plays at `speed`; setting the action already playing does nothing,
167
- * so a script may set it every update from the character's state. `null` fades it out. Each
168
- * change is an `action` entry in the play log; a name the file lacks is `action-unknown` once
169
- * and returns false.
170
- *
171
- * The armature plays as Blender plays it: its NLA tracks, then this action over them, then its
172
- * bone constraints, all as the file sets them (and as the Timeline shows them).
173
- *
174
- * play.setAction('Hero', moving ? 'Run' : 'Idle');
175
- */
176
- setAction(object: THREE.Object3D | string, action: string | null, options?: ModelPlayActionOptions): boolean;
177
- /**
178
- * Set one of the armature's NLA tracks, by name: the `action` it plays from now (a track the
179
- * file lacks is made, over the file's), its `influence` (0 to 1, reached over `fade`), or
180
- * `mute`. `null` gives the track back to the file. As in Blender, an action changes only the
181
- * bones it keys: an action keyed on the upper body, on a track over a running action, is an
182
- * upper body that aims while the legs run. Influences set every update blend poses between
183
- * authored ones (aim up, level and down by the aim's angle).
184
- *
185
- * play.setTrack('Hero', 'Aim', { action: 'Aim_Upper' });
186
- * play.setTrack('Hero', 'AimUp', { action: 'Aim_Up_Upper', influence: Math.max(0, pitch) });
187
- */
188
- setTrack(object: THREE.Object3D | string, track: string, options: ModelPlayTrackOptions | null): boolean;
189
- /**
190
- * Set one bone constraint the file gives the armature, by bone and constraint name: its
191
- * `influence`, or the `target` object it aims at. A constraint aims at the game's copy of its
192
- * file target, so moving that object in the game moves the aim with no call at all. A game
193
- * plays Damped Track; another type is refused with the reason.
194
- *
195
- * play.setConstraint('Hero', 'Head', 'Look', { influence: alert ? 1 : 0 });
196
- */
197
- setConstraint(object: THREE.Object3D | string, bone: string, constraint: string, options: ModelPlayConstraintOptions): boolean;
198
- /** The actions an object's armature can play, by name (empty without an armature). */
199
- actions(object: THREE.Object3D | string): readonly string[];
200
- }
201
-
202
- export interface ModelPlayActionOptions {
203
- readonly loop?: boolean;
204
- readonly fade?: number;
205
- readonly speed?: number;
206
- /** Start again from the first frame when this action is already playing. */
207
- readonly restart?: boolean;
208
- }
209
-
210
- export interface ModelPlayTrackOptions extends ModelPlayActionOptions {
211
- /** The action the track plays from now; absent, what the file gives it. */
212
- readonly action?: string;
213
- readonly influence?: number;
214
- readonly mute?: boolean;
215
- }
216
-
217
- export interface ModelPlayConstraintOptions {
218
- readonly influence?: number;
219
- /** The object it aims at (or its name) instead of the file's target. */
220
- readonly target?: THREE.Object3D | string | null;
221
- }
222
-
223
- /** What the bot is handed before each `update` it drives. */
224
- export interface ModelPlayAutoplayInput {
225
- /** The behaviour driving (`play autoplay on <behaviour>`), for a controller shared by several. */
226
- readonly behavior: string;
227
- /** The `dt` the coming `update` is handed. */
228
- readonly dt: number;
229
- /** Simulation seconds and the update's number, as that update's log entries carry them. */
230
- readonly simT: number;
231
- readonly tick: number;
232
- /** The keys the person holds, by `KeyboardEvent.code`; the bot's are merged with them. */
233
- readonly keys: ReadonlySet<string>;
234
- }
235
- /**
236
- * A game's bot: the keys (`KeyboardEvent.code`) it holds for the coming update — or those keys
237
- * with what it is doing now, `{ keys, state: 'heading to nest 2' }`. The state is the bot
238
- * explaining itself: the Game panel shows it beside the driver, `play state` reports it, and
239
- * each change is a `bot-state` entry in the play log. Say intentions and their reasons (a goal,
240
- * a target, why it is waiting), not per-frame numbers.
241
- */
242
- export type ModelPlayAutoplayController = (input: ModelPlayAutoplayInput) =>
243
- Iterable<string> | { readonly keys?: Iterable<string> | null; readonly state?: string | null } | null | undefined;
244
-
245
- /** The longest bot state kept: a line for the panel, not a report. */
246
- const BOT_STATE_CHARACTERS = 160;
247
-
248
- /** A controller's answer as its keys and its state (undefined: it said nothing about its state). */
249
- function botAnswer(answer: ReturnType<ModelPlayAutoplayController>): { keys: Iterable<string>; state: string | null | undefined } {
250
- if (answer === null || answer === undefined) return { keys: [], state: undefined };
251
- if (typeof (answer as Iterable<string>)[Symbol.iterator] === 'function') return { keys: answer as Iterable<string>, state: undefined };
252
- const { keys, state } = answer as { keys?: Iterable<string> | null; state?: string | null };
253
- const said = state === undefined ? undefined : state === null ? null : String(state).replace(/\s+/g, ' ').trim().slice(0, BOT_STATE_CHARACTERS) || null;
254
- return { keys: keys ?? [], state: said };
255
- }
256
-
257
- export interface ModelPlayGame {
258
- /** Once per drawn frame, with the simulation seconds since the last call (at most a tenth).
259
- * Not called while the game is paused; called once per Step; at a speed other than 1× the
260
- * seconds are scaled, and a fast frame may call it more than once. */
261
- update(deltaSeconds: number): void;
262
- dispose?(): void;
263
- }
264
-
265
- /** One script's lifetime: `value` until it is replaced, fails or the run stops; `bot` is what it
266
- * offered `play.autoplay`; `unknown` the object names it asked for that the model lacks, each
267
- * logged once — per script, so a typo that survives a reload is said again for the new one. */
268
- interface Script {
269
- value: boolean;
270
- /** The bot's behaviours by name, in the order offered; null without a bot. */
271
- bot: Readonly<Record<string, ModelPlayAutoplayController>> | null;
272
- readonly unknown: Set<string>;
273
- }
274
-
275
- /** The behaviour names a script's bot offers, in its order. */
276
- function behaviorsOf(script: Script | undefined): string[] {
277
- return script?.bot ? Object.keys(script.bot) : [];
278
- }
279
-
280
- /** `play.autoplay`'s argument as behaviours by name: a bare function is the one behaviour `play`. */
281
- function botBehaviors(bot: unknown): Readonly<Record<string, ModelPlayAutoplayController>> | null {
282
- if (bot === null) return null;
283
- if (typeof bot === 'function') return { play: bot as ModelPlayAutoplayController };
284
- if (typeof bot === 'object' && !Array.isArray(bot)) {
285
- const entries = Object.entries(bot as Record<string, unknown>);
286
- if (entries.length === 0) throw new Error('play.autoplay was given no behaviours: offer at least one, `{ win: (input) => keys }`.');
287
- for (const [name, controller] of entries) {
288
- if (!/^[A-Za-z][\w-]{0,39}$/.test(name)) throw new Error(`play.autoplay behaviour "${name}" is not a name: letters, digits, - and _, starting with a letter.`);
289
- if (typeof controller !== 'function') throw new Error(`play.autoplay behaviour "${name}" is not a function (the controller).`);
290
- }
291
- return Object.freeze({ ...(bot as Record<string, ModelPlayAutoplayController>) });
292
- }
293
- throw new Error('play.autoplay takes a function (the bot), its behaviours by name (`{ win, lose }`), or null.');
294
- }
295
-
296
- /** The longest `dt` one update is handed; longer scaled frames are split (`frameUpdates`). */
297
- const MAX_UPDATE_SECONDS = 0.1;
298
-
299
- /**
300
- * THE UPDATES THIS DRAWN FRAME RUNS, as the `dt` each is handed: none while paused, one nominal
301
- * frame for a Step, and otherwise the frame's seconds times the speed, split into equal parts of
302
- * at most {@link MAX_UPDATE_SECONDS}.
303
- */
304
- function frameUpdates(documentId: string, frameSeconds: number): number[] {
305
- const clock = modelPlayClock(documentId);
306
- if (clock.paused) return takeModelPlayStep(documentId) ? [MODEL_PLAY_STEP_SECONDS] : [];
307
- const scaled = frameSeconds * clock.speed;
308
- const parts = Math.max(1, Math.ceil(scaled / MAX_UPDATE_SECONDS - 1e-9));
309
- return Array.from({ length: parts }, () => scaled / parts);
310
- }
311
-
312
- /** The play script's project path for a model's `.blend`. */
313
- export function playScriptPath(blend: string): string {
314
- return blend.replace(/\.blend$/i, '') + '.play.ts';
315
- }
316
-
317
- function isGame(value: unknown): value is ModelPlayGame {
318
- return typeof value === 'object' && value !== null && typeof (value as ModelPlayGame).update === 'function';
319
- }
320
-
321
- async function startGame(modulePath: string, context: ModelPlayContext, composition?: PlayComposition): Promise<ModelPlayGame> {
322
- const project = getCurrentProject();
323
- if (!project) throw new Error('No project is open.');
324
- const namespace = composition ? await composition.loadScript() : (await import(
325
- /* @vite-ignore */ liveModuleImportUrl(project.rootPath, modulePath, beginLiveModuleRevision())
326
- )) as { default?: unknown };
327
- if (typeof namespace.default !== 'function')
328
- throw new Error(`${modulePath} has no default export to call: a play script default-exports (play) => ({ update(dt) }).`);
329
- const game: unknown = await (namespace.default as (play: ModelPlayContext) => unknown)(context);
330
- if (!isGame(game))
331
- throw new Error(`${modulePath}'s default export answered no game: it returns an object with update(dt).`);
332
- return game;
333
- }
334
-
335
- /**
336
- * Run a model's play script over `root` until the returned stop is called.
337
- *
338
- * `onFrame` is the stage's own frame hook (`ViewportStage.onFrame`), so the script runs after
339
- * the stage's navigation and before its draw. A script that throws stops being called and its
340
- * error is reported once; the next save starts it again.
341
- */
342
- export function runPlayScript(options: {
343
- readonly documentId: string;
344
- readonly blend: string;
345
- readonly root: THREE.Object3D;
346
- readonly camera: () => THREE.Camera;
347
- readonly editingCamera: () => THREE.Camera;
348
- readonly onFrame: (fn: (deltaSeconds: number) => void) => () => void;
349
- readonly report: (title: string, detail: string) => void;
350
- readonly container: HTMLElement;
351
- readonly ready: () => void;
352
- readonly returning: () => void;
353
- readonly ownMaterial?: ((material: THREE.Material) => THREE.Material | null) | undefined;
354
- /** The document's skins and clips bound to `root`; absent, characters stand in their exported pose. */
355
- readonly animation?: DocumentPlayAnimation | undefined;
356
- }): () => void {
357
- const { blend, root, camera, onFrame } = options;
358
- const options_ = options;
359
- const modulePath = playScriptPath(blend);
360
- // A fresh copy is a fresh run: its log starts empty, its clock at zero. Writes go through
361
- // this run's handle, which is inert once the run has ended.
362
- const run = beginModelPlayLog(options.documentId, modulePath);
363
- const report = (phase: 'start' | 'update' | 'stop' | 'autoplay', title: string, error: unknown): void => {
364
- const detail = error instanceof Error ? error.message : String(error);
365
- run.append('play', 'script-error', { phase, message: detail });
366
- // NO GAME RUNS NOW (a first start that failed, or the running game threw): the run still
367
- // plays, and a save retries it, but what is on screen is not a game. Said on the clock, so
368
- // the Game panel can say so and the document drops a Restart's cover (`failure`).
369
- if ((phase === 'start' || phase === 'update') && game === null && current())
370
- setModelPlayFailure(options.documentId, `${title}: ${detail}`);
371
- options.report(title, detail);
372
- };
373
- // Said once per object: a script that tints every frame would otherwise fill the log.
374
- const unsupported = new WeakSet<THREE.Mesh>();
375
- const materials = materialOverrides({
376
- root,
377
- ownMaterial: options.ownMaterial,
378
- unsupported: (mesh, material) => {
379
- if (unsupported.has(mesh)) return;
380
- unsupported.add(mesh);
381
- run.append('play', 'tint-unsupported', { object: mesh.name, material: material.name,
382
- why: options.ownMaterial ? 'the material is not one the document presents' : 'this document lends no material copies' });
383
- },
384
- });
385
- // An unknown name is the script's typo, not a reason to stop its game: said once per name, per script.
386
- const objectOf = (script: Script, target: THREE.Object3D | string, call: 'tint' | 'setOpacity'): THREE.Object3D | null => {
387
- if (typeof target !== 'string') return target;
388
- const object = root.getObjectByName(target) ?? null;
389
- if (!object && !script.unknown.has(target)) {
390
- script.unknown.add(target);
391
- run.append('play', 'tint-unknown-object', { object: target, call });
392
- }
393
- return object;
394
- };
395
- const keys = new Set<string>();
396
- const heldKeys = new Set<string>();
397
- // The run this runner belongs to. A Restart moves the document to the next generation, whose
398
- // own runner takes over; this one must then stand down without ending the play.
399
- const generation = modelPlayGeneration(options.documentId);
400
- const current = (): boolean => modelPlayGeneration(options.documentId) === generation;
401
- const restarted = consumeModelPlayRestart(options.documentId);
402
- const transition = cameraTransition(options.editingCamera(), { instant: restarted });
403
- // THE TRANSPORT'S CHANGES, IN THE LOG. Pause, resume and speed are the person's (or an
404
- // agent's) calls on `model-play.ts`, between frames; this run notes each as it lands. A run
405
- // that Restart has replaced notes nothing more — its successor's log has the restart.
406
- let seen = modelPlayClock(options.documentId);
407
- let seenBot = modelPlayAutoplay(options.documentId);
408
- if (restarted) run.append('play', 'play-restart', { speed: seen.speed });
409
- else if (seen.speed !== 1) run.append('play', 'speed', { speed: seen.speed });
410
- const stopClock = subscribeModelPlayClock(() => {
411
- const now = modelPlayClock(options.documentId);
412
- if (!current()) return;
413
- if (now.paused !== seen.paused) run.append('play', now.paused ? 'pause' : 'resume');
414
- if (now.speed !== seen.speed) run.append('play', 'speed', { speed: now.speed, from: seen.speed });
415
- seen = now;
416
- const bot = modelPlayAutoplay(options.documentId);
417
- if (bot.on !== seenBot.on || (bot.on && (bot.behavior !== seenBot.behavior || bot.since !== seenBot.since)))
418
- run.append('play', bot.on ? 'autoplay-on' : 'autoplay-off', bot.on ? { by: bot.by, behavior: bot.behavior, limit: bot.limit } : { by: bot.by, behavior: seenBot.behavior });
419
- // An arm dropped before it was taken: a takeover, or a script that offers no bot. (Stop's
420
- // reset has no `by`, and its `play-stop` says enough.)
421
- else if (seenBot.armed && !bot.armed && bot.by !== null) run.append('play', 'autoplay-off', { by: bot.by, armed: true, ...(bot.refused ? { why: bot.refused } : {}) });
422
- seenBot = bot;
423
- });
424
- options.container.style.opacity = '0';
425
- /** One script's context: its log, tint, opacity and bot do nothing once that script is gone
426
- * (replaced, failed, or the run stopped), so a stale timer cannot reach a later one. */
427
- /** The object an animation call names, and the document's animation; `action-unknown` once
428
- * per object and name when either is missing. */
429
- const animated = (alive: Script, object: THREE.Object3D | string, what: string | null, call: string) => {
430
- const name = typeof object === 'string' ? object : object.name;
431
- const unknown = (why: string): false => {
432
- const key = `${name}\u0000${call}\u0000${what}`;
433
- if (!alive.unknown.has(key)) { alive.unknown.add(key); run.append('play', 'action-unknown', { object: name, call, action: what, why }); }
434
- return false;
435
- };
436
- if (!alive.value) return { ok: false as const };
437
- const target = typeof object === 'string' ? root.getObjectByName(object) ?? null : object;
438
- if (!target) return { ok: unknown('the model has no such object') };
439
- if (!options_.animation) return { ok: unknown('this document lends no animation') };
440
- return { ok: true as const, target, animation: options_.animation, unknown };
441
- };
442
- /** What each NLA track was last set to, so the log says a change once. */
443
- const tracks = new Map<string, string>();
444
- const contextFor = (alive: Script): ModelPlayContext => ({
445
- root,
446
- find(name) {
447
- const object = root.getObjectByName(name) ?? null;
448
- // The presenter states each object's matrix outright; a script moves it by its parts.
449
- if (object !== null && object !== root) object.matrixAutoUpdate = true;
450
- return object;
451
- },
452
- get camera() {
453
- return camera();
454
- },
455
- keys,
456
- log(kind, facts) { if (alive.value) run.append('script', kind, facts); },
457
- tint(object, color) {
458
- const target = alive.value ? objectOf(alive, object, 'tint') : null;
459
- if (target) materials.tint(target, color);
460
- },
461
- setOpacity(object, opacity) {
462
- const target = alive.value ? objectOf(alive, object, 'setOpacity') : null;
463
- if (target) materials.setOpacity(target, opacity);
464
- },
465
- autoplay(bot) {
466
- const behaviors = botBehaviors(bot);
467
- if (alive.value) alive.bot = behaviors;
468
- },
469
- setAction(object, action, options) {
470
- const found = animated(alive, object, action, 'setAction');
471
- if (!found.ok) return false;
472
- const { target, animation } = found;
473
- const before = animation.playing(target);
474
- if (action === null) {
475
- animation.stop(target, options?.fade);
476
- if (before !== null) run.append('play', 'action', { object: target.name, action: null, from: before });
477
- return true;
478
- }
479
- const answer = animation.play(target, action, options);
480
- if (!answer.ok) return found.unknown(answer.why);
481
- if (before !== action || options?.restart) run.append('play', 'action', { armature: answer.armature, action, from: before });
482
- return true;
483
- },
484
- setTrack(object, track, options) {
485
- const found = animated(alive, object, track, 'setTrack');
486
- if (!found.ok) return false;
487
- const answer = found.animation.track(found.target, track, options);
488
- if (!answer.ok) return found.unknown(answer.why);
489
- // Influences change every update; the log keeps what the track plays and whether it is muted.
490
- const key = `${answer.armature}${track}`;
491
- const now = options === null ? 'file' : JSON.stringify([options.action ?? null, options.mute ?? false]);
492
- if (tracks.get(key) !== now) {
493
- tracks.set(key, now);
494
- run.append('play', 'track', { armature: answer.armature, track, ...(options === null ? { file: true } : { action: options.action ?? null, mute: options.mute ?? false }) });
495
- }
496
- return true;
497
- },
498
- setConstraint(object, bone, constraint, options) {
499
- const found = animated(alive, object, `${bone}/${constraint}`, 'setConstraint');
500
- if (!found.ok) return false;
501
- let aim: THREE.Object3D | null | undefined = undefined;
502
- if (typeof options.target === 'string') {
503
- aim = root.getObjectByName(options.target) ?? null;
504
- if (!aim) return found.unknown(`the model has no object "${options.target}" to aim at`);
505
- } else aim = options.target;
506
- const answer = found.animation.constraint(found.target, bone, constraint, {
507
- ...(options.influence === undefined ? {} : { influence: options.influence }),
508
- ...(aim === undefined ? {} : { target: aim }),
509
- });
510
- if (!answer.ok) return found.unknown(answer.why);
511
- if (aim !== undefined) run.append('play', 'constraint', { armature: answer.armature, bone, constraint, target: aim?.name ?? null });
512
- return true;
513
- },
514
- actions(object) {
515
- const target = typeof object === 'string' ? root.getObjectByName(object) ?? null : object;
516
- return target && options_.animation ? options_.animation.clips(target) : [];
517
- },
518
- });
519
- const scripts = new WeakMap<ModelPlayGame, Script>();
520
- let stopped = false;
521
- let attempt = 0;
522
- let game: ModelPlayGame | null = null;
523
- let composition: PlayComposition | null = null;
524
- let startedAt: number | null = null;
525
- let endedAt: number | null = null;
526
- const live = editorHost().live;
527
- const unregisterLive = live.register({
528
- id: `model-script:${options.documentId}`,
529
- mounted: () => !stopped && game !== null,
530
- // The live game owns the viewport through the camera return. Its update
531
- // loop is held during that transition; Edit begins when disposal completes.
532
- playing: () => !stopped && game !== null,
533
- stop: () => { if (!stopped) finishModelPlay(options.documentId); },
534
- instanceContainer: (id) => !id && !stopped && game !== null ? options.container : null,
535
- startedAt: () => startedAt,
536
- endedAt: () => endedAt,
537
- surface: () => 'three',
538
- });
539
- let pending: { game: ModelPlayGame; composition: PlayComposition | null } | null = null;
540
- // A failed mount/update still owns a dependency graph whose next save retries it.
541
- let retryEntries: readonly string[] = [];
542
- const mountLayers = projectPlayLayers();
543
- const dispose = (ending: ModelPlayGame | null, layers: PlayComposition | null): void => {
544
- try { ending?.dispose?.(); }
545
- catch (error) {
546
- report('stop', `${modulePath} failed while stopping`, error);
547
- } finally {
548
- // After its own dispose, which may still log; nothing it scheduled may.
549
- const alive = ending && scripts.get(ending);
550
- if (alive) { alive.value = false; alive.bot = null; }
551
- layers?.dispose();
552
- }
553
- };
554
- const end = (): void => {
555
- dispose(game, composition);
556
- game = null;
557
- composition = null;
558
- if (startedAt !== null) endedAt = Date.now();
559
- live.notifyChanged();
560
- };
561
- /** `reload` says why a replacement mounts, for the log's `script-reload`; null for Play's first start. */
562
- const start = async (reload: { readonly reason: string; readonly path: string } | null): Promise<void> => {
563
- const mine = ++attempt;
564
- if (pending) { dispose(pending.game, pending.composition); pending = null; }
565
- let nextComposition: PlayComposition | undefined;
566
- const alive: Script = { value: true, bot: null, unknown: new Set() };
567
- try {
568
- if (mountLayers) {
569
- const project = getCurrentProject();
570
- if (!project) throw new Error('No project is open.');
571
- const epoch = beginProjectMountEpoch();
572
- const container = document.createElement('div');
573
- container.dataset['mountEpoch'] = String(epoch);
574
- Object.assign(container.style, { position: 'absolute', inset: '0', visibility: 'hidden', pointerEvents: 'none' });
575
- options.container.appendChild(container);
576
- let layers;
577
- try {
578
- layers = await mountLayers({ projectRoot: project.rootPath, epoch, container,
579
- onEntries: (entries) => { if (!stopped && mine === attempt) retryEntries = [...entries]; },
580
- });
581
- }
582
- catch (error) { container.remove(); throw error; }
583
- nextComposition = {
584
- entries: layers.entries,
585
- loadScript: () => import(/* @vite-ignore */ projectEntryImportUrl(project.rootPath, modulePath, epoch)),
586
- reveal: () => { container.style.visibility = 'visible'; },
587
- dispose: () => { layers.dispose(); container.remove(); },
588
- };
589
- }
590
- if (stopped || mine !== attempt) { nextComposition?.dispose(); return; }
591
- // Before the replacement's default export runs, so what it logs follows this.
592
- if (reload) run.append('play', 'script-reload', reload);
593
- const next = await startGame(modulePath, contextFor(alive), nextComposition);
594
- scripts.set(next, alive);
595
- if (stopped || mine !== attempt) {
596
- dispose(next, nextComposition ?? null);
597
- return;
598
- }
599
- pending = { game: next, composition: nextComposition ?? null };
600
- } catch (error) {
601
- alive.value = false;
602
- nextComposition?.dispose();
603
- if (stopped || mine !== attempt) return;
604
- report('start', `${modulePath} did not start`, error);
605
- }
606
- };
607
- let firstFrame = true;
608
- /** Whether the last script to run its first update offered a bot (null before any did), so
609
- * `autoplay-unavailable` is said once per run, and again only after a bot came and went. */
610
- let offeredBot: boolean | null = null;
611
- let returning = false;
612
- let stopReason = 'stop';
613
- const stopRequest = registerModelPlayStop(options.documentId, (escape) => {
614
- if (escape) stopReason = 'escape';
615
- keys.clear();
616
- heldKeys.clear();
617
- if (firstFrame || transition.stop(escape)) finishModelPlay(options.documentId);
618
- });
619
- const stopFrames = onFrame((deltaSeconds) => {
620
- // REPLACED BY A RESTART, and not yet unmounted: the document keeps this copy on screen until
621
- // the new one has drawn, so it stands as it is — no update, no clock, the camera held.
622
- if (!current()) {
623
- if (game !== null) transition.hold(camera());
624
- keys.clear();
625
- return;
626
- }
627
- if (transition.leaving()) {
628
- options.container.style.opacity = String(transition.hudOpacity());
629
- if (!returning && transition.approachingEdit()) { returning = true; options.returning(); }
630
- if (transition.frame(camera(), deltaSeconds)) finishModelPlay(options.documentId);
631
- return;
632
- }
633
- if (!surfaceHoldsKeyboard()) { keys.clear(); heldKeys.clear(); }
634
- else if (!transition.acceptingKeys()) keys.clear();
635
- else for (const key of heldKeys) keys.add(key);
636
- // The panel's toggle is enabled by the bot the running script offers, as of the last frame.
637
- if (current()) setModelPlayAutoplayAvailable(options.documentId, game !== null ? behaviorsOf(scripts.get(game)) : []);
638
- const updates = frameUpdates(options.documentId, deltaSeconds);
639
- if (updates.length === 0) {
640
- // PAUSED: no update, so nothing states the camera; hold the pose the last frame drew.
641
- // A pending replacement waits too — its first update is a tick of the game's time.
642
- // A tap made while paused is dropped; a key still held is seen by the next step.
643
- if (game !== null) transition.hold(camera());
644
- keys.clear();
645
- return;
646
- }
647
- // Each update is counted — by the panel's clock and the log alike — as it is CALLED, so an
648
- // update that throws still took its tick, in both.
649
- let ran = 0;
650
- let simulated = 0;
651
- // THE BOT'S KEYS, per update: the person's keys of this frame and what the bot holds now. It
652
- // drives only once the camera has arrived, as a person's keys reach the game only then.
653
- const clock = modelPlayClock(options.documentId);
654
- const driving = current() && modelPlayAutoplay(options.documentId).on && transition.acceptingKeys();
655
- const person: ReadonlySet<string> = driving ? new Set(keys) : keys;
656
- /** `keys` back to the person's alone. Only ever after `drive` has added the bot's, when
657
- * `person` is a copy — never `keys` itself. */
658
- const restorePerson = (): void => {
659
- keys.clear();
660
- for (const key of person) keys.add(key);
661
- };
662
- /** Merge the bot's keys into `keys` for one update; true when it did, and the caller then
663
- * restores the person's keys after that update, however it ends. */
664
- const drive = (script: ModelPlayGame, dt: number): boolean => {
665
- // Asked again per update: the bot's own failure, a takeover, its limit, or
666
- // `play.autoplay(null)` ends it mid-frame.
667
- const now = modelPlayAutoplay(options.documentId);
668
- const behavior = driving && now.on ? now.behavior : null;
669
- const bot = behavior !== null ? scripts.get(script)?.bot?.[behavior] : undefined;
670
- if (!bot || behavior === null) return false;
671
- restorePerson();
672
- try {
673
- const answer = botAnswer(bot({ behavior, dt, simT: clock.time + simulated, tick: clock.tick + ran, keys: person }));
674
- for (const key of answer.keys) keys.add(String(key));
675
- if (answer.state !== undefined && answer.state !== now.state) {
676
- setModelPlayBotState(options.documentId, answer.state);
677
- run.append('play', 'bot-state', { behavior, state: answer.state });
678
- }
679
- return true;
680
- } catch (error) {
681
- restorePerson();
682
- setModelPlayAutoplay(options.documentId, false, 'script');
683
- report('autoplay', `${modulePath}'s autoplay failed`, error);
684
- return false;
685
- }
686
- };
687
- /** One update, bot-driven or not. The bot's keys hold for this update only: restored in a
688
- * `finally`, so neither a bot withdrawn mid-frame, nor an update that throws, nor a reloaded
689
- * script leaves them reading as held. */
690
- const update = (script: ModelPlayGame, dt: number): void => {
691
- tick(dt);
692
- const driven = drive(script, dt);
693
- try { script.update(dt); }
694
- finally { if (driven) restorePerson(); }
695
- };
696
- // A paused frame that runs an update is a Step; its entry carries the step's own tick.
697
- const stepping = modelPlayClock(options.documentId).paused;
698
- const tick = (dt: number): void => {
699
- // The characters' clips move on the game's clock, one update's `dt` at a time.
700
- options.animation?.update(dt);
701
- run.advance(dt);
702
- ran += 1;
703
- simulated += dt;
704
- if (stepping) run.append('play', 'step', { dt });
705
- };
706
- // A replacement's first update takes the frame's first slot whether it starts or throws; a
707
- // running game that a failed replacement leaves in place runs the rest of the frame.
708
- let firstSlot = 0;
709
- if (pending) {
710
- firstSlot = 1;
711
- const next = pending;
712
- pending = null;
713
- try {
714
- update(next.game, updates[0]!);
715
- end();
716
- game = next.game;
717
- // NOW THE SCRIPT HAS SAID WHETHER IT OFFERS A BOT (its default export and first update
718
- // are where `play.autoplay` is called): said before `running`, so no reader sees a running
719
- // game with its bot not yet counted, and an arm made while stopped is taken or dropped.
720
- const behaviors = behaviorsOf(scripts.get(next.game));
721
- const offered = behaviors.length > 0;
722
- if (!offered && offeredBot !== false)
723
- run.append('play', 'autoplay-unavailable', { why: 'the play script registers no bot with play.autoplay(controller)' });
724
- offeredBot = offered;
725
- // A KEY THE PERSON ALREADY HOLDS is a takeover the runner did not hear: it was pressed while
726
- // the stage was still preparing, before these listeners existed, and has only repeated
727
- // since. The person is driving, so an arm waiting for this update is dropped.
728
- if (heldKeys.size > 0 && modelPlayAutoplay(options.documentId).armed) takeover();
729
- settleModelPlayAutoplay(options.documentId, behaviors);
730
- setModelPlayFailure(options.documentId, null);
731
- startedAt = Date.now();
732
- endedAt = null;
733
- composition = next.composition;
734
- live.notifyChanged();
735
- composition?.reveal();
736
- } catch (error) {
737
- dispose(next.game, next.composition);
738
- report('start', `${modulePath} did not start`, error);
739
- }
740
- }
741
- if (game === null) { advanceModelPlayClock(options.documentId, simulated, ran); keys.clear(); return; }
742
- try {
743
- for (let index = firstSlot; index < updates.length; index++) {
744
- update(game, updates[index]!);
745
- }
746
- advanceModelPlayClock(options.documentId, simulated, ran);
747
- ran = 0;
748
- // THE RUN'S LIMIT: a bot still driving when its simulation seconds are spent is stopped and
749
- // the game held, so a game that never ends cannot keep a bot (and whoever waits on it) going.
750
- const bot = modelPlayAutoplay(options.documentId);
751
- if (current() && bot.on && bot.limit !== null && bot.since !== null) {
752
- const now = modelPlayClock(options.documentId).time;
753
- if (now - bot.since >= bot.limit) {
754
- run.append('play', 'autoplay-limit', { behavior: bot.behavior, limit: bot.limit, ran: now - bot.since, state: bot.state });
755
- setModelPlayAutoplay(options.documentId, false, 'limit');
756
- setModelPlayPaused(options.documentId, true);
757
- }
758
- }
759
- transition.frame(camera(), deltaSeconds);
760
- options.container.style.opacity = String(transition.hudOpacity());
761
- if (firstFrame) { firstFrame = false; options.ready(); }
762
- } catch (error) {
763
- advanceModelPlayClock(options.documentId, simulated, ran);
764
- keys.clear();
765
- end();
766
- report('update', `${modulePath} failed`, error);
767
- return;
768
- }
769
- materials.frame();
770
- keys.clear();
771
- root.updateMatrixWorld(true);
772
- });
773
- // THE PERSON ALWAYS WINS: a new key press the game would hear, or a pointer pressed anywhere
774
- // in the game's area (its HUD included), hands control back before the input is the game's.
775
- // Untrusted events count: `editor.document.key` and the probe's clicks are how an agent plays
776
- // by hand.
777
- const surface = options.container.parentElement ?? options.container;
778
- const takeover = (): void => {
779
- if (!current()) return;
780
- // Whether or not a bot was driving, the person is driving from here on in this run.
781
- noteModelPlayPerson(options.documentId);
782
- // An arm still waiting for the bot counts too: the person is driving before it could start.
783
- const now = modelPlayAutoplay(options.documentId);
784
- if (now.on || now.armed) setModelPlayAutoplay(options.documentId, false, 'takeover');
785
- };
786
- const onKeyDown = (event: KeyboardEvent): void => {
787
- if (!surfaceAcceptsKey(event)) return;
788
- // A repeat is a key already held, not a new press — unless this runner never saw it go down:
789
- // then it was pressed before the runner was listening, and that press was a takeover.
790
- if (!event.repeat || !heldKeys.has(event.code)) takeover();
791
- heldKeys.add(event.code);
792
- if (transition.acceptingKeys()) keys.add(event.code);
793
- };
794
- const onKeyUp = (event: KeyboardEvent): void => {
795
- // Preserve a between-frame tap until one game update has observed it.
796
- heldKeys.delete(event.code);
797
- };
798
- const onPointerDown = (event: PointerEvent): void => {
799
- if (event.target instanceof Node && surface.contains(event.target)) takeover();
800
- };
801
- const onBlur = (): void => { keys.clear(); heldKeys.clear(); };
802
- window.addEventListener('pointerdown', onPointerDown, true);
803
- window.addEventListener('keydown', onKeyDown, true);
804
- window.addEventListener('keyup', onKeyUp, true);
805
- window.addEventListener('blur', onBlur);
806
- const stopChanges = subscribeProjectModuleChange((changed, affected, type) => {
807
- // Removing this run's entry ends its lifetime. A rename can remove it
808
- // before the model document switches; importing that vanished entry
809
- // would manufacture a mount failure while the old game is still alive.
810
- // A missing dependency continues through the ordinary error-reporting path.
811
- if (type === 'delete' && projectModuleChangeMatches(changed, modulePath)) {
812
- stopReason = 'script-deleted';
813
- finishModelPlay(options.documentId);
814
- return;
815
- }
816
- // A composed HUD and script must remount together, including shared-store
817
- // edits. A fresh epoch is threaded to both through the UI tool door.
818
- const entries = [modulePath, ...retryEntries, ...(composition?.entries ?? []), ...(pending?.composition?.entries ?? [])];
819
- if ([changed, ...(affected ?? [])].some(path => entries.some(entry => projectModuleChangeMatches(path, entry))))
820
- void start({ reason: type === 'delete' ? 'dependency-deleted' : 'saved', path: changed });
821
- });
822
- void start(null);
823
- return () => {
824
- stopped = true;
825
- unregisterLive();
826
- stopRequest();
827
- // A runner replaced by Restart leaves the play running for its successor.
828
- if (current()) finishModelPlay(options.documentId);
829
- stopFrames();
830
- stopClock();
831
- stopChanges();
832
- window.removeEventListener('pointerdown', onPointerDown, true);
833
- window.removeEventListener('keydown', onKeyDown, true);
834
- window.removeEventListener('keyup', onKeyUp, true);
835
- window.removeEventListener('blur', onBlur);
836
- if (pending) { dispose(pending.game, pending.composition); pending = null; }
837
- end();
838
- materials.dispose();
839
- run.end({ reason: stopReason });
840
- };
841
- }