@volter/editor-blender 0.1.129 → 0.1.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,764 @@
1
+ /**
2
+ * THE GAME PANEL — what the bottom area shows in GAME mode (`../src/play-mode.ts`), in place of
3
+ * the Timeline (owner, 2026-10-06: "In game mode, the bottom panel is replaced with a game
4
+ * version of something like that. Perhaps that's where the 'autoplay' controls and info will
5
+ * live instead").
6
+ *
7
+ * ## One Play, here
8
+ *
9
+ * The confusion it ends was two Plays on one screen: the header's (the play script) and the
10
+ * Timeline's (the file's animation). A mode now chooses which one exists. In Movie mode the
11
+ * Timeline's transport is the only one; in Game mode this panel's Play is the only one, and the
12
+ * Timeline is not mounted. The 3D viewport's header carries neither (`blender-header-menus.tsx`
13
+ * is its menus alone). Escape still stops a game, from the stage, as before.
14
+ *
15
+ * ## The Game | Movie switch is the bottom area's own
16
+ *
17
+ * Switching modes swaps this area and nothing else, so the switch stands at the leading edge of
18
+ * the area's header row, in place of its title — in this panel and in the Timeline alike
19
+ * ({@link PlayModeSwitch}), so the person switches back from whichever one is showing. It is
20
+ * drawn only when a Play tool is installed and a model is on screen; otherwise there is nothing
21
+ * to switch and the Timeline keeps its title.
22
+ *
23
+ * ## It is tall enough to read
24
+ *
25
+ * Blender's Timeline strip (`model.layout.ts`'s measured 0.0719 — 63 px of an 880-px column)
26
+ * showed this panel's play log as one clipped line. So Game mode asks the area for
27
+ * {@link GAME_PANEL_RATIO} (`setWorkspaceAreaRatio`, by the bottom area's document) and Movie
28
+ * mode gives Blender's back: each switch stands the area up at its mode's size, as a workspace
29
+ * switch does, and the sash stays the person's in between.
30
+ *
31
+ * ## What it drives is the Play tool's own run
32
+ *
33
+ * Every control here is a call on the Play tool's door (`DocumentPlayExtension` and its
34
+ * `transport`, `@volter/editor-sdk/kit/document-play-extension`), the same calls the runner
35
+ * reads every frame (`@volter/editor-model-play`'s `model-play.ts`), so Pause freezes the
36
+ * simulation itself — the `dt` the script is handed — and the clock drawn here is that
37
+ * simulation's: the seconds the script was given and the updates it ran. The verbs below are
38
+ * the same calls behind command ids (`volter.model-play.<verb>`), which is how an agent drives
39
+ * exactly what the person does: `cyclotron play pause`, or
40
+ * `await editor.command('volter.model-play.pause')` under `eval`.
41
+ *
42
+ * ## Autoplay is the editor's switch, and the person always wins
43
+ *
44
+ * A game offers its bot (`play.autoplay(controller)` in its play script); whether the bot drives
45
+ * is this panel's toggle (or `volter.model-play.autoplay`), never a game key, and it is off at
46
+ * every Play and Restart. A person's key or pointer in the game turns it off on the spot (the
47
+ * Play tool's runner does that), so the driver line reads "You're driving" and stays so until
48
+ * someone switches the bot on again. A game offers its bot only once its play script runs, so
49
+ * the panel SAYS when there is none to switch: stopped, "available once the game is running"
50
+ * (and pressing Autoplay then arms it for the next start); running, "No autoplay — this game
51
+ * doesn't provide a bot". `play state` carries the same reason as `autoplay.why`.
52
+ *
53
+ * ## The play log, live
54
+ *
55
+ * The right of the panel is the run's play log as it is written (the Play tool's `log` door):
56
+ * the newest entries of this document, newest at the bottom, filtered by kind. It follows the
57
+ * newest entry until the person scrolls up to read, and follows again once they scroll back to
58
+ * the bottom.
59
+ *
60
+ * ## It is drawn as the Timeline is
61
+ *
62
+ * Same area, same header band, the same widget table (`blender-timeline-geometry.ts`), so the
63
+ * bottom of the screen changes its job and not its family. The pressed speed cell takes the
64
+ * playhead's blue, which is Blender's own selected-widget colour in the same theme.
65
+ */
66
+ import { blenderSkin } from './blender-runtime-skin';
67
+ import { registerViewVerbs } from '@volter/editor-sdk/views';
68
+ import {
69
+ type DocumentPlayAutoplay,
70
+ type DocumentPlayClock,
71
+ type DocumentPlayExtension,
72
+ type DocumentPlayLog,
73
+ type DocumentPlayTransport,
74
+ documentPlayExtension,
75
+ subscribeDocumentPlayExtensions,
76
+ } from '@volter/editor-sdk/kit/document-play-extension';
77
+ import {
78
+ type MouseEvent,
79
+ type ReactNode,
80
+ useCallback,
81
+ useEffect,
82
+ useLayoutEffect,
83
+ useReducer,
84
+ useRef,
85
+ useState,
86
+ useSyncExternalStore,
87
+ } from 'react';
88
+ import {
89
+ type ModelPlayMode,
90
+ modelDocumentSource,
91
+ modelPlayMode,
92
+ playModeVersion,
93
+ servedModelDocument,
94
+ setChosenPlayMode,
95
+ subscribePlayMode,
96
+ } from '../src/play-mode';
97
+ import { HEADER_HEIGHT, TIMELINE_CHROME, TIMELINE_THEME } from './blender-timeline-geometry';
98
+
99
+ /**
100
+ * THE GAME PANEL'S SHARE OF THE COLUMN, asked of the bottom area in Game mode. A 1000-px window
101
+ * gives the centre column about 935 px, and 0.2 of it is 187: the 26-px header, the status
102
+ * band's padding, the log's own row (one widget, 20 px) and seven 12-px log lines at their
103
+ * 1.45 line height with room to spare. Taller windows show more lines; the sash still moves.
104
+ */
105
+ export const GAME_PANEL_RATIO = 0.2;
106
+
107
+ const STILL: DocumentPlayClock = { time: 0, tick: 0, paused: false, speed: 1 };
108
+ const NO_BOT: DocumentPlayAutoplay = { on: false, available: false, by: null };
109
+
110
+ function extension(): DocumentPlayExtension | null {
111
+ return documentPlayExtension('model');
112
+ }
113
+
114
+ /**
115
+ * FLIP A DOCUMENT BETWEEN GAME AND MOVIE, and leave nothing running that the new mode has no
116
+ * control for: a game playing when Movie is chosen is stopped (Movie has no Stop), and the
117
+ * file's animation playing when Game is chosen is paused (Game has no Timeline).
118
+ */
119
+ export function switchPlayMode(documentId: string, mode: ModelPlayMode): void {
120
+ if (mode === 'movie' && extension()?.playing(documentId)) extension()?.setPlaying(documentId, false);
121
+ if (mode === 'game' && blenderSkin.transport?.snapshot().playbackState === 'playing') blenderSkin.transport.pause();
122
+ setChosenPlayMode(documentId, mode);
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------------------------
126
+ // THE VERBS — `volter.model-play.<verb>`, the panel's controls behind command ids.
127
+ // ---------------------------------------------------------------------------------------------
128
+
129
+ /** Which document a verb acts on: the one it names, else the model document on screen. */
130
+ function verbDocument(args: Record<string, unknown> | undefined): string {
131
+ const named = args?.['document'];
132
+ if (typeof named === 'string' && named !== '') return named;
133
+ const served = servedModelDocument();
134
+ if (served === null) throw new Error('No model document is open; open one, or name it with `document`.');
135
+ return served;
136
+ }
137
+
138
+ function verbExtension(): DocumentPlayExtension & { transport: NonNullable<DocumentPlayExtension['transport']> } {
139
+ const found = extension();
140
+ if (found === null)
141
+ throw new Error('This project has no Play tool: `cyclotron add-play` adds @volter/editor-model-play.');
142
+ if (found.transport === undefined)
143
+ throw new Error('The installed Play tool has no pause, step, speed or restart; update @volter/editor-model-play.');
144
+ return found as DocumentPlayExtension & { transport: NonNullable<DocumentPlayExtension['transport']> };
145
+ }
146
+
147
+ /** THE ONE REPORT every verb answers with — what the panel draws, as data. A control's effect
148
+ * lands on the next drawn frame (a step runs there), so read `state` again to see it. */
149
+ function gameState(documentId: string): unknown {
150
+ const found = extension();
151
+ const blend = modelDocumentSource(documentId);
152
+ return {
153
+ document: documentId,
154
+ mode: modelPlayMode(documentId),
155
+ playing: found?.playing(documentId) ?? false,
156
+ script: blend === undefined || found?.scriptPath === undefined ? null : {
157
+ path: found.scriptPath(blend),
158
+ exists: found.hasScript?.(blend) ?? null,
159
+ },
160
+ clock: found?.transport?.clock(documentId) ?? STILL,
161
+ speeds: found?.transport?.speeds ?? [],
162
+ autoplay: autoplayState(documentId),
163
+ };
164
+ }
165
+
166
+ /** The bot's switch and who drives: `bot` while autoplay is on, `person` otherwise, null when
167
+ * nothing plays. `by` is who made the last change (`takeover`: a person's key or pointer);
168
+ * `why` says why there is no bot to switch, null when there is one. */
169
+ function autoplayState(documentId: string): unknown {
170
+ const found = extension();
171
+ const autoplay = found?.transport?.autoplay?.(documentId) ?? NO_BOT;
172
+ const playing = found?.playing(documentId) ?? false;
173
+ const why = autoplayWhy(playing, found?.transport?.clock(documentId) ?? STILL, autoplay);
174
+ return { ...autoplay, driver: playing ? (autoplay.on ? 'bot' : 'person') : null, why };
175
+ }
176
+
177
+ /**
178
+ * WHY THERE IS NO BOT TO SWITCH ON: `not-running` while stopped or still starting (a game offers
179
+ * its bot only once its play script runs), `no-bot` once it runs without one, null when it offers
180
+ * one. A tool that predates `running` counts a playing run without a failure as running.
181
+ */
182
+ function autoplayUnavailable(playing: boolean, clock: DocumentPlayClock, autoplay: DocumentPlayAutoplay): 'not-running' | 'no-bot' | null {
183
+ if (autoplay.available) return null;
184
+ return playing && (clock.running ?? !clock.failure) ? 'no-bot' : 'not-running';
185
+ }
186
+
187
+ /** {@link autoplayUnavailable} as the CLI says it. */
188
+ function autoplayWhy(playing: boolean, clock: DocumentPlayClock, autoplay: DocumentPlayAutoplay): string | null {
189
+ switch (autoplayUnavailable(playing, clock, autoplay)) {
190
+ case null: return null;
191
+ case 'no-bot': return 'No autoplay — this game doesn’t provide a bot: its play script registers none with play.autoplay(controller).';
192
+ case 'not-running': return !playing
193
+ ? 'Autoplay is available once the game is running, and nothing is playing; `play` starts it.'
194
+ : `Autoplay is available once the game is running, and it is not running yet${clock.failure ? `: ${clock.failure}` : '.'}`;
195
+ }
196
+ }
197
+
198
+ function requirePlaying(documentId: string, verb: string): void {
199
+ if (!verbExtension().playing(documentId))
200
+ throw new Error(`Nothing is playing in ${documentId}, so there is nothing to ${verb}; \`play\` starts it.`);
201
+ }
202
+
203
+ registerViewVerbs({
204
+ view: 'model-play',
205
+ title: 'Game',
206
+ verbs: [
207
+ { id: 'state', run: (args) => gameState(verbDocument(args)) },
208
+ {
209
+ id: 'mode',
210
+ run: (args) => {
211
+ const documentId = verbDocument(args);
212
+ const mode = args?.['mode'] ?? args?.['to'];
213
+ if (mode !== undefined) {
214
+ if (mode !== 'game' && mode !== 'movie') throw new Error(`mode is \`game\` or \`movie\`; got ${String(mode)}.`);
215
+ switchPlayMode(documentId, mode);
216
+ }
217
+ return gameState(documentId);
218
+ },
219
+ },
220
+ {
221
+ id: 'game-mode',
222
+ title: 'Game: Switch to Game Mode',
223
+ run: (args) => { const documentId = verbDocument(args); switchPlayMode(documentId, 'game'); return gameState(documentId); },
224
+ },
225
+ {
226
+ id: 'movie-mode',
227
+ title: 'Game: Switch to Movie Mode',
228
+ run: (args) => { const documentId = verbDocument(args); switchPlayMode(documentId, 'movie'); return gameState(documentId); },
229
+ },
230
+ {
231
+ // PLAY IS GAME MODE'S CONTROL, so asking for it puts the document in Game mode: the person
232
+ // then sees the panel that owns the run an agent started.
233
+ id: 'play',
234
+ title: 'Game: Play',
235
+ run: (args) => {
236
+ const documentId = verbDocument(args);
237
+ const found = verbExtension();
238
+ if (modelPlayMode(documentId) !== 'game') switchPlayMode(documentId, 'game');
239
+ if (!found.playing(documentId)) found.setPlaying(documentId, true);
240
+ return gameState(documentId);
241
+ },
242
+ },
243
+ {
244
+ id: 'stop',
245
+ title: 'Game: Stop',
246
+ run: (args) => {
247
+ const documentId = verbDocument(args);
248
+ verbExtension().setPlaying(documentId, false);
249
+ return gameState(documentId);
250
+ },
251
+ },
252
+ {
253
+ id: 'pause',
254
+ title: 'Game: Pause',
255
+ run: (args) => {
256
+ const documentId = verbDocument(args);
257
+ requirePlaying(documentId, 'pause');
258
+ verbExtension().transport.setPaused(documentId, true);
259
+ return gameState(documentId);
260
+ },
261
+ },
262
+ {
263
+ id: 'resume',
264
+ title: 'Game: Resume',
265
+ run: (args) => {
266
+ const documentId = verbDocument(args);
267
+ requirePlaying(documentId, 'resume');
268
+ verbExtension().transport.setPaused(documentId, false);
269
+ return gameState(documentId);
270
+ },
271
+ },
272
+ {
273
+ // `count` steps queue and run one per drawn frame, so each is seen.
274
+ id: 'step',
275
+ title: 'Game: Step One Tick',
276
+ run: (args) => {
277
+ const documentId = verbDocument(args);
278
+ requirePlaying(documentId, 'step');
279
+ const { transport } = verbExtension();
280
+ if (!transport.clock(documentId).paused) throw new Error('step runs one tick of a PAUSED game; `pause` first.');
281
+ const raw = args?.['count'];
282
+ const count = raw === undefined ? 1 : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
283
+ if (!Number.isInteger(count) || count < 1 || count > 600) throw new Error(`step's \`count\` is a whole number from 1 to 600; got ${JSON.stringify(raw)}.`);
284
+ for (let index = 0; index < count; index++) transport.step(documentId);
285
+ return gameState(documentId);
286
+ },
287
+ },
288
+ {
289
+ id: 'speed',
290
+ run: (args) => {
291
+ const documentId = verbDocument(args);
292
+ const { transport } = verbExtension();
293
+ const raw = args?.['speed'] ?? args?.['to'];
294
+ const speed = typeof raw === 'string' ? Number(raw.replace(/x$/i, '')) : Number(raw);
295
+ if (!transport.speeds.includes(speed))
296
+ throw new Error(`speed is one of ${transport.speeds.join(', ')} (simulation seconds per real second); got ${JSON.stringify(raw)}.`);
297
+ transport.setSpeed(documentId, speed);
298
+ return gameState(documentId);
299
+ },
300
+ },
301
+ {
302
+ // THE EDITOR'S AUTOPLAY SWITCH: on drives the game's own bot (`play.autoplay`), off hands
303
+ // the game back to the person. Off at every Play and Restart.
304
+ id: 'autoplay',
305
+ run: (args) => {
306
+ const documentId = verbDocument(args);
307
+ const raw = args?.['on'];
308
+ const on = raw === true || raw === 'on' || raw === 'true' ? true
309
+ : raw === false || raw === 'off' || raw === 'false' ? false : undefined;
310
+ if (on === undefined) throw new Error(`autoplay's \`on\` is true or false (\`play autoplay on|off\`); got ${String(raw)}.`);
311
+ const found = verbExtension();
312
+ const { transport } = found;
313
+ if (transport.setAutoplay === undefined)
314
+ throw new Error('The installed Play tool has no autoplay; update @volter/editor-model-play.');
315
+ // Refused with the reason, stopped or no bot, as `play state`'s `autoplay.why` gives it.
316
+ const why = on ? autoplayWhy(found.playing(documentId), transport.clock(documentId), transport.autoplay?.(documentId) ?? NO_BOT) : null;
317
+ if (why !== null) throw new Error(why);
318
+ transport.setAutoplay(documentId, on, 'cli');
319
+ return gameState(documentId);
320
+ },
321
+ },
322
+ {
323
+ id: 'restart',
324
+ title: 'Game: Restart',
325
+ run: (args) => {
326
+ const documentId = verbDocument(args);
327
+ const found = verbExtension();
328
+ if (modelPlayMode(documentId) !== 'game') switchPlayMode(documentId, 'game');
329
+ found.transport.restart(documentId);
330
+ return gameState(documentId);
331
+ },
332
+ },
333
+ ],
334
+ });
335
+
336
+ // ---------------------------------------------------------------------------------------------
337
+ // THE PANEL
338
+ // ---------------------------------------------------------------------------------------------
339
+
340
+ /** A panel button keeps the keyboard where it was. Focus left on a button would turn the
341
+ * player's next Space — a jump, in most games — into a second click on Pause. */
342
+ const keepFocus = (event: MouseEvent): void => event.preventDefault();
343
+
344
+ /** A 12-px mark in the widget's ink, the way the Timeline header draws its transport. */
345
+ function mark(path: string): ReactNode {
346
+ return (
347
+ <svg width={12} height={12} viewBox="0 0 12 12" aria-hidden="true">
348
+ <path d={path} fill={TIMELINE_CHROME.widgetText} />
349
+ </svg>
350
+ );
351
+ }
352
+ const MARKS = {
353
+ play: 'M3 2l7 4-7 4z',
354
+ stop: 'M2.5 2.5h7v7h-7z',
355
+ pause: 'M3 2h2v8H3zM7 2h2v8H7z',
356
+ step: 'M10 2H8.5v8H10zM2.5 2v8L7.5 6z',
357
+ restart: 'M6 1.5V0l3 2.25-3 2.25V3a3 3 0 1 0 3 3h1.5A4.5 4.5 0 1 1 6 1.5z',
358
+ } as const;
359
+
360
+ function GameButton({ label, testId, disabled, pressed, wide, onClick, children }: {
361
+ readonly label: string;
362
+ readonly testId: string;
363
+ readonly disabled?: boolean;
364
+ readonly pressed?: boolean;
365
+ readonly wide?: boolean;
366
+ readonly onClick: () => void;
367
+ readonly children: ReactNode;
368
+ }) {
369
+ const unit = TIMELINE_CHROME.unit;
370
+ return (
371
+ <button
372
+ type="button"
373
+ data-testid={testId}
374
+ title={label}
375
+ aria-label={label}
376
+ aria-pressed={pressed}
377
+ disabled={disabled}
378
+ onMouseDown={keepFocus}
379
+ onClick={onClick}
380
+ style={{
381
+ minWidth: unit,
382
+ height: unit,
383
+ padding: wide ? '0 6px' : 0,
384
+ border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
385
+ background: pressed ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widget,
386
+ color: TIMELINE_CHROME.widgetText,
387
+ font: 'inherit',
388
+ display: 'inline-flex',
389
+ alignItems: 'center',
390
+ justifyContent: 'center',
391
+ gap: 4,
392
+ cursor: disabled ? 'not-allowed' : 'pointer',
393
+ opacity: disabled ? 0.4 : 1,
394
+ }}
395
+ >
396
+ {children}
397
+ </button>
398
+ );
399
+ }
400
+
401
+ /**
402
+ * GAME | MOVIE, at the leading edge of the bottom area's header row, in the Game panel and the
403
+ * Timeline alike (the head of this file says why it is here). Two joined cells with the current
404
+ * one lit in the playhead's blue — the same widgets as the transport beside it, at the same
405
+ * height, so it reads as one of this row's controls and not a smaller one. It serves the model
406
+ * document the bottom area serves. With no Play tool installed, or no model on screen, there is
407
+ * nothing to switch, and the row keeps its `title` instead.
408
+ */
409
+ export function PlayModeSwitch({ title }: { readonly title: string }) {
410
+ useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
411
+ const found = useSyncExternalStore(subscribeDocumentPlayExtensions, extension, () => null);
412
+ const documentId = servedModelDocument();
413
+ if (found === null || documentId === null) return <span style={{ color: TIMELINE_CHROME.text }}>{title}</span>;
414
+ const mode = modelPlayMode(documentId);
415
+ const cell = (value: ModelPlayMode, label: string, tip: string) => (
416
+ <GameButton
417
+ testId={`model-play-mode-${value}`}
418
+ label={tip}
419
+ pressed={mode === value}
420
+ wide
421
+ onClick={() => switchPlayMode(documentId, value)}
422
+ >
423
+ {label}
424
+ </GameButton>
425
+ );
426
+ return (
427
+ <div role="group" aria-label="Game or Movie mode" data-testid="model-play-mode" style={{ display: 'flex' }}>
428
+ {cell('game', 'Game', 'Game mode: this area is the Game panel, with Play, pause, step, speed, restart and autoplay')}
429
+ {cell('movie', 'Movie', 'Movie mode: this area is the Timeline, playing the file’s animation')}
430
+ </div>
431
+ );
432
+ }
433
+
434
+ /** Simulation seconds as a clock reads them: `m:ss.ss`, hours only when there are some. */
435
+ function clockText(seconds: number): string {
436
+ const whole = Math.floor(seconds / 60);
437
+ const rest = (seconds - whole * 60).toFixed(2).padStart(5, '0');
438
+ if (whole < 60) return `${whole}:${rest}`;
439
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}:${rest}`;
440
+ }
441
+
442
+ function speedText(speed: number): string {
443
+ return `${speed}×`;
444
+ }
445
+
446
+ export function BlenderGamePanel() {
447
+ useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
448
+ const documentId = servedModelDocument();
449
+ const found = useSyncExternalStore(subscribeDocumentPlayExtensions, extension, () => null);
450
+ const transport = found?.transport;
451
+ const playing = documentId !== null && (found?.playing(documentId) ?? false);
452
+ const subscribeClock = useCallback(
453
+ (listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}),
454
+ [transport],
455
+ );
456
+ const clock = useSyncExternalStore(
457
+ subscribeClock,
458
+ () => (documentId !== null && transport ? transport.clock(documentId) : STILL),
459
+ () => STILL,
460
+ );
461
+ // Its changes are announced with the clock's.
462
+ const autoplay = useSyncExternalStore(
463
+ subscribeClock,
464
+ () => (documentId !== null && transport?.autoplay ? transport.autoplay(documentId) : NO_BOT),
465
+ () => NO_BOT,
466
+ );
467
+ const blend = documentId === null ? undefined : modelDocumentSource(documentId);
468
+ const scriptPath = blend === undefined ? null : (found?.scriptPath?.(blend) ?? null);
469
+ const scriptExists = blend === undefined ? null : (found?.hasScript?.(blend) ?? null);
470
+ const canPlay = documentId !== null && found !== null;
471
+ const status = documentId === null
472
+ ? 'No model document is open.'
473
+ : scriptExists === false && scriptPath !== null
474
+ ? `No play script yet — Play runs ${scriptPath}. \`cyclotron add-play\` adds an example.`
475
+ : playing && clock.failure
476
+ // The run plays but no game runs (`DocumentPlayClock.failure`): say so, not "Playing".
477
+ ? `Not running — ${clock.failure.replace(/\.$/, '')}. Restart (or save the script) to retry, or Stop.`
478
+ : playing
479
+ ? `${clock.paused ? 'Paused' : 'Playing'} ${scriptPath ?? ''}`.trim()
480
+ : scriptPath === null ? 'Stopped.' : `Stopped · Play runs ${scriptPath}`;
481
+ return (
482
+ <div
483
+ data-testid="blender-game-panel"
484
+ data-playing={playing || undefined}
485
+ data-paused={(playing && clock.paused) || undefined}
486
+ data-failed={(playing && Boolean(clock.failure)) || undefined}
487
+ style={{
488
+ // Filled from the host box, as the Timeline is and for the reason its own comment gives.
489
+ position: 'absolute',
490
+ inset: 0,
491
+ display: 'flex',
492
+ flexDirection: 'column',
493
+ overflow: 'hidden',
494
+ background: TIMELINE_THEME.back,
495
+ color: TIMELINE_CHROME.text,
496
+ font: '11px Inter, system-ui, sans-serif',
497
+ }}
498
+ >
499
+ <div
500
+ style={{
501
+ height: HEADER_HEIGHT,
502
+ flex: `0 0 ${HEADER_HEIGHT}px`,
503
+ display: 'flex',
504
+ alignItems: 'center',
505
+ gap: TIMELINE_CHROME.headerGap,
506
+ padding: TIMELINE_CHROME.headerPadding,
507
+ background: TIMELINE_THEME.header,
508
+ borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
509
+ }}
510
+ >
511
+ <PlayModeSwitch title="Game" />
512
+ {/* THE TRANSPORT IN THE MIDDLE, between two spacers, where the Timeline centres its own. */}
513
+ <div style={{ flex: 1 }} />
514
+ <div style={{ display: 'flex' }} role="group" aria-label="Game transport">
515
+ <GameButton
516
+ testId="model-play-button"
517
+ label={playing ? 'Stop playing (Escape)' : 'Play this model’s script'}
518
+ pressed={playing}
519
+ wide
520
+ disabled={!canPlay}
521
+ // The run's state NOW, not the render's: a game can stop between the two (Escape, an
522
+ // agent's `stop`, a failed script), and a toggle read from a stale render does nothing.
523
+ onClick={() => { if (documentId !== null && found) found.setPlaying(documentId, !found.playing(documentId)); }}
524
+ >
525
+ {mark(playing ? MARKS.stop : MARKS.play)}
526
+ {playing ? 'Stop' : 'Play'}
527
+ </GameButton>
528
+ {transport && (
529
+ <>
530
+ <GameButton
531
+ testId="model-play-pause"
532
+ label={clock.paused ? 'Resume' : 'Pause'}
533
+ pressed={playing && clock.paused}
534
+ disabled={!playing}
535
+ onClick={() => { if (documentId !== null) transport.setPaused(documentId, !transport.clock(documentId).paused); }}
536
+ >
537
+ {mark(clock.paused ? MARKS.play : MARKS.pause)}
538
+ </GameButton>
539
+ <GameButton
540
+ testId="model-play-step"
541
+ label="Step one tick (while paused)"
542
+ disabled={!playing || !clock.paused}
543
+ onClick={() => { if (documentId !== null) transport.step(documentId); }}
544
+ >
545
+ {mark(MARKS.step)}
546
+ </GameButton>
547
+ <GameButton
548
+ testId="model-play-restart"
549
+ label="Restart on a fresh copy of the model"
550
+ disabled={!playing}
551
+ onClick={() => { if (documentId !== null) transport.restart(documentId); }}
552
+ >
553
+ {mark(MARKS.restart)}
554
+ </GameButton>
555
+ </>
556
+ )}
557
+ </div>
558
+ {transport && (
559
+ <div style={{ display: 'flex' }} role="group" aria-label="Game speed">
560
+ {transport.speeds.map((speed) => (
561
+ <GameButton
562
+ key={speed}
563
+ testId={`model-play-speed-${speed}`}
564
+ label={`Speed ${speedText(speed)}`}
565
+ pressed={clock.speed === speed}
566
+ wide
567
+ disabled={documentId === null}
568
+ onClick={() => { if (documentId !== null) transport.setSpeed(documentId, speed); }}
569
+ >
570
+ {speedText(speed)}
571
+ </GameButton>
572
+ ))}
573
+ </div>
574
+ )}
575
+ <div style={{ flex: 1 }} />
576
+ {/* THE CLOCK IS THE SIMULATION'S: the seconds the script was handed and the updates it
577
+ ran. It stands still while paused and runs at the speed. */}
578
+ <span
579
+ data-testid="model-play-clock"
580
+ style={{ color: TIMELINE_CHROME.widgetText, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}
581
+ >
582
+ {clockText(clock.time)} · tick {clock.tick}
583
+ </span>
584
+ </div>
585
+ <div
586
+ style={{
587
+ flex: 1,
588
+ minHeight: 0,
589
+ display: 'flex',
590
+ alignItems: 'stretch',
591
+ gap: TIMELINE_CHROME.headerGap,
592
+ padding: TIMELINE_CHROME.statusPadding,
593
+ overflow: 'hidden',
594
+ }}
595
+ >
596
+ <div style={{ flex: '0 1 260px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
597
+ <span data-testid="model-play-status" title={status} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
598
+ {status}
599
+ </span>
600
+ {transport?.autoplay && documentId !== null && (
601
+ <AutoplayControl documentId={documentId} playing={playing} clock={clock} autoplay={autoplay} transport={transport} />
602
+ )}
603
+ </div>
604
+ {found?.log && documentId !== null && <PlayLogView documentId={documentId} log={found.log} />}
605
+ </div>
606
+ </div>
607
+ );
608
+ }
609
+
610
+ // ---------------------------------------------------------------------------------------------
611
+ // AUTOPLAY AND THE PLAY LOG
612
+ // ---------------------------------------------------------------------------------------------
613
+
614
+ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
615
+ readonly documentId: string;
616
+ readonly playing: boolean;
617
+ readonly clock: DocumentPlayClock;
618
+ readonly autoplay: DocumentPlayAutoplay;
619
+ readonly transport: DocumentPlayTransport;
620
+ }) {
621
+ const unavailable = autoplayUnavailable(playing, clock, autoplay);
622
+ const armed = autoplay.armed === true;
623
+ // STOPPED, the button ARMS: the next start switches the bot on once its script offers one.
624
+ const canArm = !playing && transport.armAutoplay !== undefined;
625
+ const label = unavailable === 'no-bot'
626
+ ? 'This game provides no bot: its play script registers none with play.autoplay(controller)'
627
+ : unavailable === 'not-running'
628
+ ? canArm
629
+ ? armed ? 'Autoplay is armed for the next Play; press to disarm' : 'Turn autoplay on as soon as the game is running (if it offers a bot)'
630
+ : 'Autoplay is available once the game is running'
631
+ : autoplay.on ? 'Stop autoplay and drive yourself' : 'Let the game’s bot drive (any key or click in the game takes over)';
632
+ // SAID, NOT ONLY HOVERED: why there is nothing to switch.
633
+ const note = unavailable === 'no-bot'
634
+ ? 'No autoplay — this game doesn’t provide a bot'
635
+ : unavailable === 'not-running'
636
+ ? armed ? 'Armed — turns on once the game is running' : 'Available once the game is running'
637
+ : null;
638
+ return (
639
+ <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap, minWidth: 0 }}>
640
+ {/* The tooltip lives on a wrapper too: a disabled button is the one that most needs it. */}
641
+ <span title={label} style={{ display: 'inline-flex' }}>
642
+ {/* KEEP THE KEYBOARD OFF THIS BUTTON (`GameButton`'s `keepFocus`). Left focused, a person
643
+ taking over with Space — a key the game hears, which turns autoplay off — would also
644
+ click this button and turn autoplay straight back on. */}
645
+ <GameButton
646
+ testId="model-play-autoplay"
647
+ label={label}
648
+ pressed={autoplay.on || armed}
649
+ wide
650
+ disabled={canArm ? false : !playing || !autoplay.available || transport.setAutoplay === undefined}
651
+ // The switch's state NOW, as the transport's buttons read theirs: a takeover can land
652
+ // between the render and the click.
653
+ onClick={() => {
654
+ const now = transport.autoplay?.(documentId) ?? NO_BOT;
655
+ if (!(extension()?.playing(documentId) ?? false)) transport.armAutoplay?.(documentId, !now.armed);
656
+ else transport.setAutoplay?.(documentId, !now.on, 'panel');
657
+ }}
658
+ >
659
+ Autoplay
660
+ </GameButton>
661
+ </span>
662
+ {note !== null ? (
663
+ <span data-testid="model-play-autoplay-note" data-reason={unavailable ?? undefined} style={{ color: TIMELINE_CHROME.widgetText, minWidth: 0 }}>
664
+ {note}
665
+ </span>
666
+ ) : playing && (
667
+ <span
668
+ data-testid="model-play-driver"
669
+ data-driver={autoplay.on ? 'bot' : 'person'}
670
+ style={{ color: autoplay.on ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap' }}
671
+ >
672
+ {autoplay.on ? 'Bot driving' : 'You’re driving'}
673
+ </span>
674
+ )}
675
+ </div>
676
+ );
677
+ }
678
+
679
+ /** Enough to read back what just happened; the whole log is `cyclotron play-log`. */
680
+ const LOG_LINES = 200;
681
+
682
+ function factsText(facts: Record<string, unknown> | undefined): string {
683
+ if (facts === undefined) return '';
684
+ try { return JSON.stringify(facts); } catch { return ''; }
685
+ }
686
+
687
+ function PlayLogView({ documentId, log }: { readonly documentId: string; readonly log: DocumentPlayLog }) {
688
+ const [kind, setKind] = useState('');
689
+ // Writes land several to a frame; the view redraws at most once per drawn frame.
690
+ const [, redraw] = useReducer((count: number) => count + 1, 0);
691
+ useEffect(() => {
692
+ let frame = 0;
693
+ const stop = log.subscribe(() => {
694
+ if (frame === 0) frame = requestAnimationFrame(() => { frame = 0; redraw(); });
695
+ });
696
+ return () => { stop(); cancelAnimationFrame(frame); };
697
+ }, [log]);
698
+ const tail = log.tail(documentId, LOG_LINES, kind === '' ? undefined : kind);
699
+ const list = useRef<HTMLDivElement>(null);
700
+ // Follows the newest entry until the person scrolls up; back at the bottom, it follows again.
701
+ const following = useRef(true);
702
+ useLayoutEffect(() => {
703
+ const element = list.current;
704
+ if (element && following.current) element.scrollTop = element.scrollHeight;
705
+ });
706
+ const newest = tail.entries.at(-1)?.seq;
707
+ return (
708
+ <div data-testid="model-play-log" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
709
+ <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap }}>
710
+ <span>Play log</span>
711
+ <select
712
+ data-testid="model-play-log-kind"
713
+ aria-label="Show entries of kind"
714
+ value={kind}
715
+ // Blurred once chosen, so the player's next arrow key moves the game, not this list.
716
+ onChange={(event) => { setKind(event.target.value); event.target.blur(); }}
717
+ style={{
718
+ height: TIMELINE_CHROME.unit,
719
+ background: TIMELINE_CHROME.widget,
720
+ color: TIMELINE_CHROME.widgetText,
721
+ border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
722
+ font: 'inherit',
723
+ }}
724
+ >
725
+ <option value="">All kinds</option>
726
+ {[...new Set([...tail.kinds, ...(kind === '' ? [] : [kind])])].map((one) => <option key={one} value={one}>{one}</option>)}
727
+ </select>
728
+ <span style={{ color: TIMELINE_CHROME.widgetText }}>
729
+ {tail.total === 0 ? 'Nothing logged yet' : `${tail.total} written${tail.total > LOG_LINES ? ` · newest ${LOG_LINES} shown` : ''}`}
730
+ </span>
731
+ </div>
732
+ <div
733
+ ref={list}
734
+ data-testid="model-play-log-entries"
735
+ data-newest={newest}
736
+ onScroll={(event) => {
737
+ const element = event.currentTarget;
738
+ following.current = element.scrollTop + element.clientHeight >= element.scrollHeight - 4;
739
+ }}
740
+ style={{
741
+ flex: 1,
742
+ minHeight: 0,
743
+ overflowY: 'auto',
744
+ font: '12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
745
+ background: TIMELINE_THEME.header,
746
+ border: `1px solid ${TIMELINE_CHROME.rule}`,
747
+ padding: '2px 6px',
748
+ }}
749
+ >
750
+ {tail.entries.map((entry) => (
751
+ <div
752
+ key={entry.seq}
753
+ data-kind={entry.kind}
754
+ style={{ display: 'flex', gap: 10, whiteSpace: 'nowrap', color: entry.source === 'play' ? TIMELINE_CHROME.widgetText : TIMELINE_CHROME.text }}
755
+ >
756
+ <span style={{ flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' }}>{clockText(entry.simT)}</span>
757
+ <span style={{ flex: '0 0 auto' }}>{entry.kind}</span>
758
+ <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }} title={factsText(entry.facts)}>{factsText(entry.facts)}</span>
759
+ </div>
760
+ ))}
761
+ </div>
762
+ </div>
763
+ );
764
+ }