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