@volter/play 0.5.211 → 0.5.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,807 @@
1
+ /**
2
+ * THE GAME PANEL — Play, the project's, in editor-blender's bottom area: under every document, and below a model in its
3
+ * Game mode, where its Animation mode shows Blender's Timeline instead (owner, 2026-10-06: "In game mode, the bottom
4
+ * panel is replaced with a game version of something like that. Perhaps that's where the 'autoplay' controls and info
5
+ * will live instead").
6
+ *
7
+ * ## Where it is
8
+ *
9
+ * play registers it through editor-blender's API (`registerPlay`, `play.page.tsx`); the area's own Game | Animation
10
+ * switch below a model and its size stay the area's. It draws with the area's button and the Timeline's widget table.
11
+ * The game itself shows in the Game view (`../src/game-view.tsx`), which Play opens.
12
+ *
13
+ * ## What it drives is the game's run
14
+ *
15
+ * Every control here is a call on the run (`../src/game-run.ts`, `../src/game-controls.ts`), each std's own control said
16
+ * to the game's pages (`../src/std-controls.ts`): Pause holds the game's own clock, and the clock drawn here is the one
17
+ * std answered, the game's seconds and frames. The verbs below are the same calls behind command ids
18
+ * (`volter.play.<verb>`), which is how an agent drives exactly what the person does: `cyclotron play pause`, or
19
+ * `await editor.command('volter.play.pause')` under `eval`.
20
+ *
21
+ * ## Autoplay is the editor's switch, and the person always wins
22
+ *
23
+ * A std game offers its bot's goals (its `autopilot/` program's); whether the bot drives the
24
+ * player in view, and with which goal, is this panel's toggle (or `volter.play.autoplay`).
25
+ * A person's own key or click in the player's page takes it back on the spot (std's page does
26
+ * that), so the driver line reads "You're driving". Stopped, pressing Autoplay arms it for the
27
+ * next start. Where it cannot be switched on, the panel SAYS why, and `play state` carries the
28
+ * same reason as `autoplay.why`.
29
+ *
30
+ * ## The play log, live
31
+ *
32
+ * The right of the panel is the run's play log as it is written (`../src/play-log.ts`): its newest entries, newest at
33
+ * the bottom, filtered by kind. It follows the
34
+ * newest entry until the person scrolls up to read, and follows again once they scroll back to
35
+ * the bottom.
36
+ *
37
+ * ## It is drawn as the Timeline is
38
+ *
39
+ * Same area, same header band, the same widget table (`blender-timeline-geometry.ts`), so the
40
+ * bottom of the screen changes its job and not its family. The pressed speed cell takes the
41
+ * playhead's blue, which is Blender's own selected-widget colour in the same theme.
42
+ */
43
+ import type { ViewVerb } from '@volter/sdk/commands';
44
+ import {
45
+ type ReactNode,
46
+ useCallback,
47
+ useEffect,
48
+ useLayoutEffect,
49
+ useMemo,
50
+ useReducer,
51
+ useRef,
52
+ useState,
53
+ useSyncExternalStore,
54
+ } from 'react';
55
+ import { editorBlender } from '../src/editor-blender';
56
+ import { CLOCK_TEXT_INTERVAL_MS, type ClockState, clockStateReader, throttledSubscription } from '../src/play-clock';
57
+ import { getCurrentProject, onProjectChange } from '@volter/sdk/active-project';
58
+ import { editorHost } from '@volter/sdk/host';
59
+ import { GAME_SPEEDS, gameClock, type GameClock, gamePlaying, subscribeGame, subscribeGameClock } from '../src/game-run';
60
+ import {
61
+ armAutoplay,
62
+ type GameAutoplay,
63
+ gameAutoplay,
64
+ pauseGame,
65
+ playGame,
66
+ restartPlay,
67
+ setGameAutoplay,
68
+ setGameSpeedOf,
69
+ stepGame,
70
+ subscribeGameControls,
71
+ } from '../src/game-controls';
72
+ import { captureStdView, isStdGame, MOST_PLAYERS, setStdPlayers, STD_DEV, stdPlayersAsked, stepStdPlayers, stdRun, stdRunVersion, subscribeStdGame, subscribeStdRun } from '../src/std-game';
73
+ import { EXPORT_LABELS, EXPORT_TARGETS, isExportTarget, onStdExport, startStdExport, stdExport, stdTargets, type ExportTarget, type StdExport } from '../src/std-export';
74
+ import { subscribePlayLog, tailPlayLog } from '../src/play-log';
75
+ import { showGameView } from '../src/game-view';
76
+
77
+ /** Said where the project is not a std game, whose Play has nothing to run. */
78
+ const NOT_STD = 'This project is not a std game (no std.json): `cyclotron add-play` makes it one.';
79
+
80
+ /** The bottom area's mode and the model document it serves: editor-blender's, through its API. */
81
+ const playMode = () => editorBlender().playMode;
82
+
83
+ // ---------------------------------------------------------------------------------------------
84
+ // THE VERBS — `volter.play.<verb>`, the panel's controls behind command ids.
85
+ // ---------------------------------------------------------------------------------------------
86
+
87
+ /** The model document a mode verb acts on: the one in front, whose bottom area is its Game or Animation. */
88
+ function modelInFront(): string {
89
+ const served = playMode().servedModelDocument();
90
+ if (served === null) throw new Error('No model document is in front: Game | Animation is a model\'s bottom area.');
91
+ return served;
92
+ }
93
+
94
+ /** THE ONE REPORT every verb answers with — what the panel draws, as data. A control's effect
95
+ * lands on the next drawn frame (a step runs there), so read `state` again to see it. */
96
+ function gameState(): unknown {
97
+ const model = playMode().servedModelDocument();
98
+ return {
99
+ // (a model in front has Game or Animation; any other document, the Game panel)
100
+ mode: model === null ? 'game' : playMode().modelPlayMode(model),
101
+ playing: gamePlaying(),
102
+ script: { path: STD_DEV, exists: isStdGame() ?? null },
103
+ clock: gameClock(),
104
+ speeds: GAME_SPEEDS,
105
+ autoplay: autoplayState(),
106
+ // (a std game's: how many players Play starts, and how many have joined the running game)
107
+ ...(isStdGame() === true ? { players: { asked: stdPlayersAsked(), joined: stdRun()?.players.length ?? 0 } } : {}),
108
+ export: stdExport(),
109
+ };
110
+ }
111
+
112
+ /** Who drives the player in view: the bot while std's play drives its page, else the person. */
113
+ function driverOf(playing: boolean, autoplay: GameAutoplay): 'bot' | 'person' | null {
114
+ if (!playing) return null;
115
+ return autoplay.driving === true ? 'bot' : 'person';
116
+ }
117
+
118
+ /** The bot's switch and who drives (`driverOf`), null when nothing plays; `why` says why the bot cannot be switched
119
+ * on, null when it can. */
120
+ function autoplayState(): unknown {
121
+ const autoplay = gameAutoplay();
122
+ const playing = gamePlaying();
123
+ const why = autoplayWhy(playing, gameClock(), autoplay);
124
+ return { ...autoplay, driver: driverOf(playing, autoplay), why };
125
+ }
126
+
127
+ /**
128
+ * WHY THE BOT CANNOT BE SWITCHED ON: `not-running` while stopped or still starting (a player's page must have joined),
129
+ * `game` once it runs and the run says why (`autoplay.unavailable`: no goals, no player in view), null when it can.
130
+ */
131
+ function autoplayUnavailable(playing: boolean, clock: Pick<GameClock, 'failure' | 'running'>, autoplay: GameAutoplay): 'not-running' | 'game' | null {
132
+ if (playing && clock.running && autoplay.available) return null;
133
+ if (!playing || !clock.running) return 'not-running';
134
+ return 'game';
135
+ }
136
+
137
+ /** {@link autoplayUnavailable} as the CLI says it. */
138
+ function autoplayWhy(playing: boolean, clock: Pick<GameClock, 'failure' | 'running'>, autoplay: GameAutoplay): string | null {
139
+ switch (autoplayUnavailable(playing, clock, autoplay)) {
140
+ case null: return null;
141
+ case 'game': return autoplay.unavailable ?? 'No autoplay for the player in view.';
142
+ case 'not-running': return !playing
143
+ ? 'Autoplay is available once the game is running, and nothing is playing; `play` starts it.'
144
+ : `Autoplay is available once the game is running, and it is not running yet${clock.failure ? `: ${clock.failure}` : '.'}`;
145
+ }
146
+ }
147
+
148
+ function requirePlaying(verb: string): void {
149
+ if (!gamePlaying()) throw new Error(`Nothing is playing, so there is nothing to ${verb}; \`play\` starts it.`);
150
+ }
151
+
152
+ /** The Game panel's verbs: the Code-OSS commands `volter.play.<verb>`, registered by play's page half. */
153
+ export const PLAY_VIEW_VERBS: readonly ViewVerb[] = [
154
+ { id: 'state', run: () => gameState() },
155
+ {
156
+ id: 'mode',
157
+ run: (args) => {
158
+ const mode = args?.['mode'] ?? args?.['to'];
159
+ if (mode !== undefined) {
160
+ const asked = playMode().playModeOf(mode);
161
+ if (asked === null) throw new Error(`mode is \`game\` or \`animation\`; got ${String(mode)}.`);
162
+ editorBlender().switchPlayMode(modelInFront(), asked);
163
+ }
164
+ return gameState();
165
+ },
166
+ },
167
+ {
168
+ // HOW MANY PLAY: a std game's player count, the project's setting (`volter.play.players`); a running game's players
169
+ // join or leave to match it.
170
+ id: 'players',
171
+ run: async (args) => {
172
+ const raw = args?.['players'] ?? args?.['count'];
173
+ if (raw !== undefined) {
174
+ if (isStdGame() !== true) throw new Error('players is a std game\'s player count; this project is not a std game.');
175
+ const count = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
176
+ if (!Number.isInteger(count) || count < 1 || count > 8) throw new Error(`players is a whole number from 1 to 8; got ${JSON.stringify(raw)}.`);
177
+ // (answered once the setting is in force)
178
+ await setStdPlayers(count);
179
+ }
180
+ return gameState();
181
+ },
182
+ },
183
+ {
184
+ id: 'game-mode',
185
+ run: () => { editorBlender().switchPlayMode(modelInFront(), 'game'); return gameState(); },
186
+ },
187
+ {
188
+ id: 'animation-mode',
189
+ run: () => { editorBlender().switchPlayMode(modelInFront(), 'animation'); return gameState(); },
190
+ },
191
+ {
192
+ // (the game played in the Game view, brought to the front where it already plays, as Play opens it)
193
+ id: 'play',
194
+ run: () => {
195
+ if (!gamePlaying()) playGame(true);
196
+ else showGameView();
197
+ return gameState();
198
+ },
199
+ },
200
+ { id: 'stop', run: () => { playGame(false); return gameState(); } },
201
+ {
202
+ id: 'pause',
203
+ run: async () => {
204
+ requirePlaying('pause');
205
+ await pauseGame(true);
206
+ return gameState();
207
+ },
208
+ },
209
+ {
210
+ id: 'resume',
211
+ run: async () => {
212
+ requirePlaying('resume');
213
+ await pauseGame(false);
214
+ return gameState();
215
+ },
216
+ },
217
+ {
218
+ // `count` frames of 1/60 s, which std runs (pausing the game first) and answers.
219
+ id: 'step',
220
+ run: async (args) => {
221
+ requirePlaying('step');
222
+ const raw = args?.['count'];
223
+ const count = raw === undefined ? 1 : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
224
+ if (!Number.isInteger(count) || count < 1 || count > 600) throw new Error(`step's \`count\` is a whole number of frames from 1 to 600; got ${JSON.stringify(raw)}.`);
225
+ await stepGame(count);
226
+ return gameState();
227
+ },
228
+ },
229
+ {
230
+ id: 'speed',
231
+ run: async (args) => {
232
+ const raw = args?.['speed'] ?? args?.['to'];
233
+ const speed = typeof raw === 'string' ? Number(raw.replace(/x$/i, '')) : Number(raw);
234
+ if (!GAME_SPEEDS.includes(speed))
235
+ throw new Error(`speed is one of ${GAME_SPEEDS.join(', ')} (game seconds per real second); got ${JSON.stringify(raw)}.`);
236
+ await setGameSpeedOf(speed);
237
+ return gameState();
238
+ },
239
+ },
240
+ {
241
+ // THE EDITOR'S AUTOPLAY SWITCH: on plays one of the game's goals (`goal`, the game's own when absent), off ends
242
+ // that play, handing a page it drives back to the person. From here (an agent's `cli`) as from the panel, the goal is std's own play,
243
+ // recorded and checked, on the viewed player's page while the Game view is on screen and on std's headless device
244
+ // while it is not (`setStdAutoplay`): it answers once the play has started, and `play state`'s `autoplay.state`
245
+ // and the play log say how it ends.
246
+ id: 'autoplay',
247
+ run: async (args) => {
248
+ const raw = args?.['on'];
249
+ const on = raw === true || raw === 'on' || raw === 'true' ? true
250
+ : raw === false || raw === 'off' || raw === 'false' ? false : undefined;
251
+ if (on === undefined) throw new Error(`autoplay's \`on\` is true or false (\`play autoplay on [<goal>]|off\`); got ${String(raw)}.`);
252
+ const goal = typeof args?.['goal'] === 'string' && args['goal'] !== '' ? args['goal'] : undefined;
253
+ // (refused while nothing plays; the run says its own reasons, a headless play needing no player's page)
254
+ if (on && !gamePlaying()) throw new Error(autoplayWhy(false, gameClock(), gameAutoplay()) ?? 'Nothing is playing.');
255
+ await setGameAutoplay(on, 'cli', on ? goal : undefined);
256
+ return gameState();
257
+ },
258
+ },
259
+ {
260
+ id: 'restart',
261
+ run: () => {
262
+ restartPlay();
263
+ return gameState();
264
+ },
265
+ },
266
+ {
267
+ // THE GAME EXPORTED, as the panel's Export menu does it, the project's and so no document's: `target` web, roblox or
268
+ // unreal; an engine the game doesn't target refused unless `add` (true) makes it one first, as the menu's "Add
269
+ // Roblox and export" does. It answers once the export has started with the export and the game's targets; the
270
+ // panel says how it stands and ends, and the console holds each line std printed. With no `target`, the export as it
271
+ // stands.
272
+ id: 'export',
273
+ run: async (args) => {
274
+ const target = args?.['target'];
275
+ const add = args?.['add'];
276
+ if (add !== undefined && typeof add !== 'boolean') throw new Error(`export's \`add\` is true or false; got ${JSON.stringify(add)}.`);
277
+ if (target !== undefined) {
278
+ if (!isExportTarget(target)) throw new Error(`export's \`target\` is one of ${EXPORT_TARGETS.join(', ')}; got ${JSON.stringify(target)}.`);
279
+ const targets = await stdTargets();
280
+ if (target !== 'web' && !targets.includes(target) && add !== true)
281
+ throw new Error(`${EXPORT_LABELS[target]} isn't a target of this game: export it with \`add: true\` to add it first (std target add ${target}), only where the person asked for ${EXPORT_LABELS[target]}.`);
282
+ startStdExport(target, add === true);
283
+ }
284
+ return { export: stdExport(), targets: await stdTargets() };
285
+ },
286
+ },
287
+ ];
288
+
289
+ // ---------------------------------------------------------------------------------------------
290
+ // THE PANEL
291
+ // ---------------------------------------------------------------------------------------------
292
+
293
+ /** A 12-px mark in the widget's ink, the way the Timeline header draws its transport. */
294
+ function mark(path: string): ReactNode {
295
+ const { TIMELINE_CHROME } = editorBlender().timelineGeometry;
296
+ return (
297
+ <svg width={12} height={12} viewBox="0 0 12 12" aria-hidden="true">
298
+ <path d={path} fill={TIMELINE_CHROME.widgetText} />
299
+ </svg>
300
+ );
301
+ }
302
+ const MARKS = {
303
+ play: 'M3 2l7 4-7 4z',
304
+ stop: 'M2.5 2.5h7v7h-7z',
305
+ pause: 'M3 2h2v8H3zM7 2h2v8H7z',
306
+ step: 'M10 2H8.5v8H10zM2.5 2v8L7.5 6z',
307
+ 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',
308
+ capture: 'M4 2l1-1h2l1 1h2.5v8h-9V2zM6 8.5a2.25 2.25 0 1 0 0-4.5 2.25 2.25 0 0 0 0 4.5z',
309
+ } as const;
310
+
311
+ /** Simulation seconds as a clock reads them: `m:ss.ss`, hours only when there are some. */
312
+ function clockText(seconds: number): string {
313
+ const whole = Math.floor(seconds / 60);
314
+ const rest = (seconds - whole * 60).toFixed(2).padStart(5, '0');
315
+ if (whole < 60) return `${whole}:${rest}`;
316
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}:${rest}`;
317
+ }
318
+
319
+ /** The clock as std last answered it, redrawn at most every {@link CLOCK_TEXT_INTERVAL_MS} ms and always after the
320
+ * last change (`../src/play-clock.ts`): the game's seconds and frames where it is paused, which a step moves exactly;
321
+ * running, its speed, the time being the game's own between answers. */
322
+ function PlayClockText() {
323
+ const { TIMELINE_CHROME } = editorBlender().timelineGeometry;
324
+ const subscribe = useMemo(() => throttledSubscription(subscribeGameClock, CLOCK_TEXT_INTERVAL_MS), []);
325
+ const text = useSyncExternalStore(
326
+ subscribe,
327
+ () => {
328
+ const clock = gameClock();
329
+ const playing = gamePlaying();
330
+ return !playing ? `stopped · ${speedText(clock.speed)}` : clock.paused ? `${clockText(clock.time)} · frame ${clock.tick}` : `running · ${speedText(clock.speed)}`;
331
+ },
332
+ () => `${clockText(0)} · frame 0`,
333
+ );
334
+ return (
335
+ <span
336
+ data-testid="play-clock"
337
+ style={{ color: TIMELINE_CHROME.widgetText, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}
338
+ >
339
+ {text}
340
+ </span>
341
+ );
342
+ }
343
+
344
+ function speedText(speed: number): string {
345
+ return `${speed}×`;
346
+ }
347
+
348
+ /** A control's refusal, said in the console under its name. */
349
+ const said = (control: string) => (error: unknown): void =>
350
+ editorHost().console.error(`${control}: ${error instanceof Error ? error.message : String(error)}`, 'play');
351
+
352
+ export function GamePanel() {
353
+ const { PlayAreaButton, PlayModeSwitch, timelineGeometry: { HEADER_HEIGHT, TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
354
+ const { subscribePlayMode, playModeVersion } = playMode();
355
+ useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
356
+ // (the project's run, whatever document is in front)
357
+ const playing = useSyncExternalStore(subscribeGame, gamePlaying, () => false);
358
+ // THE CLOCK'S STATE, not its time (`../src/play-clock.ts`): the panel re-renders when a game is
359
+ // paused, sped up, fails or starts running, never once a frame; its time is {@link PlayClockText}'s.
360
+ const readClockState = useMemo(() => clockStateReader(gameClock), []);
361
+ const clock = useSyncExternalStore(subscribeGameClock, readClockState, readClockState);
362
+ // Whether the project is a std game, which Play plays (undefined while the session is asked).
363
+ const std = useSyncExternalStore(subscribeStdGame, isStdGame, () => undefined);
364
+ const autoplay = useSyncExternalStore(subscribeGameControls, gameAutoplay, gameAutoplay);
365
+ const scriptPath = STD_DEV;
366
+ // THE GAME'S NEWEST ERROR, a player's page's (`std-game.ts`): said where the person looks, as the console says each.
367
+ useSyncExternalStore(subscribeStdRun, stdRunVersion, stdRunVersion);
368
+ const asked = stdPlayersAsked();
369
+ const thrown = playing ? stdRun()?.lastError ?? null : null;
370
+ // (std's own play of a goal, where it stands: `win: achieved`, `lose: failed — why`)
371
+ const headlessLine = playing && autoplay.state ? ` · ${autoplay.state}` : '';
372
+ const status = std === false
373
+ ? NOT_STD
374
+ : playing && clock.failure
375
+ // The run plays but no game runs (`GameClock.failure`): say so, not "Playing".
376
+ ? `Not running — ${clock.failure.replace(/\.$/, '')}. Restart (or save the game) to retry, or Stop.${headlessLine}`
377
+ : playing && thrown !== null
378
+ ? `Playing — the game threw: ${thrown}${headlessLine}`
379
+ : playing
380
+ ? `${clock.paused ? 'Paused' : 'Playing'} ${scriptPath}` + headlessLine
381
+ : `Stopped · Play runs ${scriptPath}`;
382
+ return (
383
+ <div
384
+ data-testid="game-panel"
385
+ data-playing={playing || undefined}
386
+ data-paused={(playing && clock.paused) || undefined}
387
+ data-failed={(playing && Boolean(clock.failure)) || undefined}
388
+ style={{
389
+ // Filled from the host box, as the Timeline is and for the reason its own comment gives.
390
+ position: 'absolute',
391
+ inset: 0,
392
+ display: 'flex',
393
+ flexDirection: 'column',
394
+ overflow: 'hidden',
395
+ background: TIMELINE_THEME.back,
396
+ color: TIMELINE_CHROME.text,
397
+ font: '11px Inter, system-ui, sans-serif',
398
+ }}
399
+ >
400
+ <div
401
+ style={{
402
+ height: HEADER_HEIGHT,
403
+ flex: `0 0 ${HEADER_HEIGHT}px`,
404
+ display: 'flex',
405
+ alignItems: 'center',
406
+ gap: TIMELINE_CHROME.headerGap,
407
+ padding: TIMELINE_CHROME.headerPadding,
408
+ background: TIMELINE_THEME.header,
409
+ borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
410
+ }}
411
+ >
412
+ <PlayModeSwitch title="Game" />
413
+ {/* THE TRANSPORT IN THE MIDDLE, between two spacers, where the Timeline centres its own. */}
414
+ <div style={{ flex: 1 }} />
415
+ <div style={{ display: 'flex' }} role="group" aria-label="Game transport">
416
+ <PlayAreaButton
417
+ testId="play-button"
418
+ label={playing ? 'Stop the game' : 'Play the game'}
419
+ pressed={playing}
420
+ wide
421
+ // The run's state NOW, not the render's: a game can stop between the two (an agent's `stop`, the Game view
422
+ // closed, a failed script), and a toggle read from a stale render does nothing.
423
+ onClick={() => { playGame(!gamePlaying()); }}
424
+ >
425
+ {mark(playing ? MARKS.stop : MARKS.play)}
426
+ {playing ? 'Stop' : 'Play'}
427
+ </PlayAreaButton>
428
+ <PlayAreaButton
429
+ testId="play-pause"
430
+ label={clock.paused ? 'Resume' : 'Pause'}
431
+ pressed={playing && clock.paused}
432
+ disabled={!playing}
433
+ onClick={() => { void pauseGame(!gameClock().paused).catch(said('Pause')); }}
434
+ >
435
+ {mark(clock.paused ? MARKS.play : MARKS.pause)}
436
+ </PlayAreaButton>
437
+ <PlayAreaButton
438
+ testId="play-step"
439
+ label="Step one frame (pauses first)"
440
+ disabled={!playing}
441
+ onClick={() => { void stepGame().catch(said('Step')); }}
442
+ >
443
+ {mark(MARKS.step)}
444
+ </PlayAreaButton>
445
+ <PlayAreaButton
446
+ testId="play-restart"
447
+ label="Restart: the level saved, every player's page loaded again"
448
+ disabled={!playing}
449
+ onClick={() => { restartPlay(); }}
450
+ >
451
+ {mark(MARKS.restart)}
452
+ </PlayAreaButton>
453
+ <PlayAreaButton
454
+ testId="play-capture"
455
+ label="Capture the viewed player's next frame (std's receiver writes it; the log names the file)"
456
+ disabled={!playing}
457
+ onClick={() => { void captureStdView().catch(said('Capture')); }}
458
+ >
459
+ {mark(MARKS.capture)}
460
+ </PlayAreaButton>
461
+ </div>
462
+ {std === true && (
463
+ // HOW MANY PLAY, beside Play as an engine's Play settings are: set before a run, and a running game's
464
+ // players join or leave to match. A refused write is said on the console.
465
+ <div style={{ display: 'flex', alignItems: 'center' }} role="group" aria-label="Players">
466
+ <span style={{ padding: '0 4px' }}>Players</span>
467
+ <PlayAreaButton testId="std-players-remove" label="One player fewer (a running game's last player leaves)" disabled={asked <= 1} onClick={() => { stepStdPlayers(-1).catch(() => {}); }}>
468
+ −
469
+ </PlayAreaButton>
470
+ <span data-testid="std-players-count" style={{ minWidth: 14, textAlign: 'center' }}>{asked}</span>
471
+ <PlayAreaButton testId="std-players-add" label="One more player (a running game's new player joins)" disabled={asked >= MOST_PLAYERS} onClick={() => { stepStdPlayers(1).catch(() => {}); }}>
472
+ +
473
+ </PlayAreaButton>
474
+ </div>
475
+ )}
476
+ <div style={{ display: 'flex' }} role="group" aria-label="Game speed">
477
+ {GAME_SPEEDS.map((speed) => (
478
+ <PlayAreaButton
479
+ key={speed}
480
+ testId={`play-speed-${speed}`}
481
+ label={`Speed ${speedText(speed)}`}
482
+ pressed={clock.speed === speed}
483
+ wide
484
+ onClick={() => { void setGameSpeedOf(speed).catch(said('Speed')); }}
485
+ >
486
+ {speedText(speed)}
487
+ </PlayAreaButton>
488
+ ))}
489
+ </div>
490
+ <div style={{ flex: 1 }} />
491
+ {std === true && <ExportControl />}
492
+ {/* THE CLOCK IS THE GAME'S, as std answered it: its seconds and frames, still while paused. */}
493
+ <PlayClockText />
494
+ </div>
495
+ <div
496
+ style={{
497
+ flex: 1,
498
+ minHeight: 0,
499
+ display: 'flex',
500
+ alignItems: 'stretch',
501
+ gap: TIMELINE_CHROME.headerGap,
502
+ padding: TIMELINE_CHROME.statusPadding,
503
+ overflow: 'hidden',
504
+ }}
505
+ >
506
+ <div style={{ flex: '0 1 260px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
507
+ <span data-testid="play-status" title={status} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
508
+ {status}
509
+ </span>
510
+ <AutoplayControl playing={playing} clock={clock} autoplay={autoplay} />
511
+ {std === true && <ExportNote />}
512
+ </div>
513
+ <PlayLogView />
514
+ </div>
515
+ </div>
516
+ );
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------------------------
520
+ // EXPORT
521
+ // ---------------------------------------------------------------------------------------------
522
+
523
+ /**
524
+ * EXPORT, as an engine's editor builds for a platform when it is chosen: a button that opens its menu, the web (`std
525
+ * build`), or an engine, "Add Roblox and export" where it is not yet one of the game's targets (`std target add`), so a
526
+ * game is built for an engine only once someone asks for it. A menu, never a select: a stray key on a closed select
527
+ * changes its value, and a choice here installs packages. Its targets are read afresh as it opens and as an export
528
+ * ends; while they are unknown, an engine is named plainly and the session decides.
529
+ */
530
+ function ExportControl() {
531
+ const { PlayAreaButton, timelineGeometry: { TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
532
+ const current = useSyncExternalStore(onStdExport, stdExport, () => null);
533
+ const [targets, setTargets] = useState<readonly ExportTarget[] | null>(null);
534
+ const [open, setOpen] = useState(false);
535
+ const menu = useRef<HTMLDivElement>(null);
536
+ const look = useCallback(() => { void stdTargets().then(setTargets, () => setTargets(null)); }, []);
537
+ const running = current?.running === true;
538
+ // (read again as an export ends and as another project opens: a project's targets are its own)
539
+ const project = useSyncExternalStore(onProjectChange, () => getCurrentProject()?.rootPath ?? null, () => null);
540
+ useEffect(look, [look, running, project]);
541
+ // (closed by a press outside it or Escape: its buttons keep the keyboard where it was, as the panel's do, so no blur
542
+ // comes to close it)
543
+ useEffect(() => {
544
+ if (!open) return undefined;
545
+ const outside = (event: PointerEvent): void => { if (!menu.current?.contains(event.target as Node)) setOpen(false); };
546
+ // (Escape closes the menu alone)
547
+ const escape = (event: KeyboardEvent): void => {
548
+ if (event.key !== 'Escape') return;
549
+ event.preventDefault();
550
+ event.stopImmediatePropagation();
551
+ setOpen(false);
552
+ };
553
+ window.addEventListener('pointerdown', outside, true);
554
+ window.addEventListener('keydown', escape, true);
555
+ return () => {
556
+ window.removeEventListener('pointerdown', outside, true);
557
+ window.removeEventListener('keydown', escape, true);
558
+ };
559
+ }, [open]);
560
+ const adds = (target: ExportTarget): boolean => target !== 'web' && targets !== null && !targets.includes(target);
561
+ const choose = (target: ExportTarget): void => {
562
+ setOpen(false);
563
+ try { startStdExport(target, adds(target)); } catch (error) { said('Export')(error); }
564
+ };
565
+ return (
566
+ <div ref={menu} style={{ position: 'relative', display: 'inline-flex' }}>
567
+ <PlayAreaButton
568
+ testId="play-export"
569
+ label={running && current !== null ? `Exporting to ${EXPORT_LABELS[current.target]}` : 'Export the game: to the web, or to an engine'}
570
+ pressed={open}
571
+ wide
572
+ disabled={running}
573
+ onClick={() => { if (!open) look(); setOpen(!open); }}
574
+ >
575
+ {running ? 'Exporting…' : 'Export'}
576
+ </PlayAreaButton>
577
+ {open && (
578
+ <div
579
+ role="menu"
580
+ aria-label="Export to"
581
+ style={{
582
+ position: 'absolute', right: 0, top: '100%', marginTop: 4, zIndex: 1,
583
+ display: 'flex', flexDirection: 'column', minWidth: 170,
584
+ background: TIMELINE_THEME.header, border: `1px solid ${TIMELINE_CHROME.rule}`, borderRadius: TIMELINE_CHROME.widgetRadius,
585
+ }}
586
+ >
587
+ {EXPORT_TARGETS.map((target) => (
588
+ <button
589
+ key={target}
590
+ type="button"
591
+ role="menuitem"
592
+ data-testid={`play-export-${target}`}
593
+ onMouseDown={(event) => event.preventDefault()}
594
+ onClick={() => choose(target)}
595
+ style={{ background: 'none', border: 'none', color: TIMELINE_CHROME.text, font: 'inherit', textAlign: 'left', padding: '4px 8px', cursor: 'pointer' }}
596
+ >
597
+ {adds(target) ? `Add ${EXPORT_LABELS[target]} and export` : EXPORT_LABELS[target]}
598
+ </button>
599
+ ))}
600
+ </div>
601
+ )}
602
+ </div>
603
+ );
604
+ }
605
+
606
+ /** THE EXPORT, SAID where the person looks: running with std's last line, or how it ended and where the export is. */
607
+ function ExportNote() {
608
+ const { timelineGeometry: { TIMELINE_CHROME } } = editorBlender();
609
+ const current = useSyncExternalStore(onStdExport, stdExport, () => null);
610
+ if (current === null) return null;
611
+ const label = EXPORT_LABELS[current.target];
612
+ const text = exportText(current, label);
613
+ return (
614
+ <span
615
+ data-testid="play-export-note"
616
+ data-export={current.running ? 'running' : current.ok === true ? 'exported' : 'failed'}
617
+ title={text}
618
+ style={{ color: TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
619
+ >
620
+ {text}
621
+ </span>
622
+ );
623
+ }
624
+
625
+ function exportText(current: StdExport, label: string): string {
626
+ if (current.running) return `Exporting to ${label}${current.line === null ? '…' : ` · ${current.line}`}`;
627
+ if (current.ok === true) return `Exported to ${label}: ${current.out ?? ''}${current.added ? ` · ${label} added as a target` : ''}`;
628
+ return `Export to ${label} failed${current.line === null ? '' : `: ${current.line}`} (the console has every line)`;
629
+ }
630
+
631
+ // ---------------------------------------------------------------------------------------------
632
+ // AUTOPLAY AND THE PLAY LOG
633
+ // ---------------------------------------------------------------------------------------------
634
+
635
+ function AutoplayControl({ playing, clock, autoplay }: {
636
+ readonly playing: boolean;
637
+ readonly clock: ClockState;
638
+ readonly autoplay: GameAutoplay;
639
+ }) {
640
+ const { PlayAreaButton, timelineGeometry: { TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
641
+ const unavailable = autoplayUnavailable(playing, clock, autoplay);
642
+ const armed = autoplay.armed === true;
643
+ // STOPPED, the button ARMS: the next start switches the bot on once its player joins.
644
+ const canArm = !playing;
645
+ const label = unavailable === 'game'
646
+ ? autoplay.unavailable ?? 'No autoplay for the player in view'
647
+ : unavailable === 'not-running'
648
+ ? canArm
649
+ ? armed ? 'Autoplay is armed for the next Play; press to disarm' : 'Let the game’s bot drive your player as soon as it joins'
650
+ : 'Autoplay is available once a player has joined'
651
+ : autoplay.on ? (autoplay.driving === true ? 'Stop autoplay and drive yourself' : 'Stop the bot’s play of its goal')
652
+ : 'Let the game’s bot drive your player (any key or click in its page takes over)';
653
+ // SAID, NOT ONLY HOVERED: why there is nothing to switch.
654
+ const note = unavailable === 'game'
655
+ ? autoplay.unavailable ?? 'No autoplay for the player in view'
656
+ : unavailable === 'not-running'
657
+ ? armed ? 'Armed — turns on once your player joins' : 'Available once a player has joined'
658
+ : null;
659
+ // THE GOAL TO PLAY, when the game's bot offers several: chosen here, as `play autoplay on <goal>` names it. Shown
660
+ // with the one driving (or armed) selected, else the game's own.
661
+ const goals = autoplay.behaviors ?? [];
662
+ const [picked, setPicked] = useState<string | null>(null);
663
+ const goal = autoplay.on || armed ? autoplay.behavior ?? null : picked !== null && goals.includes(picked) ? picked : autoplay.behavior ?? goals[0] ?? null;
664
+ const driver = driverOf(playing, autoplay);
665
+ return (
666
+ <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap, minWidth: 0 }}>
667
+ {goals.length > 1 && (
668
+ <select
669
+ data-testid="play-autoplay-goal"
670
+ title="The goal Autoplay's bot plays"
671
+ value={goal ?? ''}
672
+ disabled={autoplay.on}
673
+ onChange={(event) => { setPicked(event.target.value); event.target.blur(); }}
674
+ style={{ background: TIMELINE_CHROME.widget, color: TIMELINE_CHROME.widgetText, border: 'none', borderRadius: TIMELINE_CHROME.widgetRadius, font: 'inherit', minWidth: 0 }}
675
+ >
676
+ {goals.map((name) => <option key={name} value={name}>{name}</option>)}
677
+ </select>
678
+ )}
679
+ {/* The tooltip lives on a wrapper too: a disabled button is the one that most needs it. */}
680
+ <span title={label} style={{ display: 'inline-flex' }}>
681
+ {/* KEEP THE KEYBOARD OFF THIS BUTTON (`PlayAreaButton`'s). Left focused, a person taking over with Space would
682
+ also click this button and turn autoplay straight back on. */}
683
+ <PlayAreaButton
684
+ testId="play-autoplay"
685
+ label={label}
686
+ pressed={autoplay.on || armed}
687
+ wide
688
+ disabled={canArm ? false : !playing || unavailable !== null}
689
+ // The switch's state NOW, as the transport's buttons read theirs: a takeover can land between the render and
690
+ // the click.
691
+ onClick={() => {
692
+ const now = gameAutoplay();
693
+ try {
694
+ if (!gamePlaying()) armAutoplay(now.armed !== true, goal ?? undefined);
695
+ else void setGameAutoplay(!now.on, 'panel', now.on ? undefined : goal ?? undefined).catch(said('Autoplay'));
696
+ } catch (error) { said('Autoplay')(error); }
697
+ }}
698
+ >
699
+ Autoplay
700
+ </PlayAreaButton>
701
+ </span>
702
+ {note !== null ? (
703
+ <span data-testid="play-autoplay-note" data-reason={unavailable ?? undefined} style={{ color: TIMELINE_CHROME.widgetText, minWidth: 0 }}>
704
+ {note}
705
+ </span>
706
+ ) : playing && (
707
+ // WHO DRIVES THE PLAYER IN VIEW, and the goal the bot plays: "Bot driving · collect-all".
708
+ <span
709
+ data-testid="play-driver"
710
+ data-driver={driver ?? undefined}
711
+ style={{ color: autoplay.on ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
712
+ >
713
+ {driver === 'bot' ? `Bot driving${autoplay.behavior && !autoplay.state ? ` · ${autoplay.behavior}` : ''}` : 'You’re driving'}
714
+ {autoplay.state ? ` · ${autoplay.state}` : ''}
715
+ </span>
716
+ )}
717
+ </div>
718
+ );
719
+ }
720
+
721
+ /** Enough to read back what just happened; the whole log is `cyclotron play-log`. */
722
+ const LOG_LINES = 200;
723
+
724
+ function factsText(facts: Record<string, unknown> | undefined): string {
725
+ if (facts === undefined) return '';
726
+ try { return JSON.stringify(facts); } catch { return ''; }
727
+ }
728
+
729
+ function PlayLogView() {
730
+ const { TIMELINE_CHROME, TIMELINE_THEME } = editorBlender().timelineGeometry;
731
+ const [kind, setKind] = useState('');
732
+ // Writes land several to a frame; the view redraws at most once per drawn frame.
733
+ const [, redraw] = useReducer((count: number) => count + 1, 0);
734
+ useEffect(() => {
735
+ let frame = 0;
736
+ const stop = subscribePlayLog(() => {
737
+ if (frame === 0) frame = requestAnimationFrame(() => { frame = 0; redraw(); });
738
+ });
739
+ return () => { stop(); cancelAnimationFrame(frame); };
740
+ }, []);
741
+ const tail = tailPlayLog(LOG_LINES, kind === '' ? undefined : kind);
742
+ const list = useRef<HTMLDivElement>(null);
743
+ // Follows the newest entry until the person scrolls up; back at the bottom, it follows again.
744
+ const following = useRef(true);
745
+ useLayoutEffect(() => {
746
+ const element = list.current;
747
+ if (element && following.current) element.scrollTop = element.scrollHeight;
748
+ });
749
+ const newest = tail.entries.at(-1)?.seq;
750
+ return (
751
+ <div data-testid="play-log" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
752
+ <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap }}>
753
+ <span>Play log</span>
754
+ <select
755
+ data-testid="play-log-kind"
756
+ aria-label="Show entries of kind"
757
+ value={kind}
758
+ // Blurred once chosen, so the player's next arrow key moves the game, not this list.
759
+ onChange={(event) => { setKind(event.target.value); event.target.blur(); }}
760
+ style={{
761
+ height: TIMELINE_CHROME.unit,
762
+ background: TIMELINE_CHROME.widget,
763
+ color: TIMELINE_CHROME.widgetText,
764
+ border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
765
+ font: 'inherit',
766
+ }}
767
+ >
768
+ <option value="">All kinds</option>
769
+ {[...new Set([...tail.kinds, ...(kind === '' ? [] : [kind])])].map((one) => <option key={one} value={one}>{one}</option>)}
770
+ </select>
771
+ <span style={{ color: TIMELINE_CHROME.widgetText }}>
772
+ {tail.total === 0 ? 'Nothing logged yet' : `${tail.total} written${tail.total > LOG_LINES ? ` · newest ${LOG_LINES} shown` : ''}`}
773
+ </span>
774
+ </div>
775
+ <div
776
+ ref={list}
777
+ data-testid="play-log-entries"
778
+ data-newest={newest}
779
+ onScroll={(event) => {
780
+ const element = event.currentTarget;
781
+ following.current = element.scrollTop + element.clientHeight >= element.scrollHeight - 4;
782
+ }}
783
+ style={{
784
+ flex: 1,
785
+ minHeight: 0,
786
+ overflowY: 'auto',
787
+ font: '12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
788
+ background: TIMELINE_THEME.header,
789
+ border: `1px solid ${TIMELINE_CHROME.rule}`,
790
+ padding: '2px 6px',
791
+ }}
792
+ >
793
+ {tail.entries.map((entry) => (
794
+ <div
795
+ key={entry.seq}
796
+ data-kind={entry.kind}
797
+ style={{ display: 'flex', gap: 10, whiteSpace: 'nowrap', color: entry.source === 'play' ? TIMELINE_CHROME.widgetText : TIMELINE_CHROME.text }}
798
+ >
799
+ <span style={{ flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' }}>{clockText(entry.simT)}</span>
800
+ <span style={{ flex: '0 0 auto' }}>{entry.kind}</span>
801
+ <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }} title={factsText(entry.facts)}>{factsText(entry.facts)}</span>
802
+ </div>
803
+ ))}
804
+ </div>
805
+ </div>
806
+ );
807
+ }