@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.
- package/NOTICE +5 -5
- package/README.md +156 -172
- package/contributions/game-panel.tsx +807 -0
- package/contributions/levels.finder.ts +43 -0
- package/contributions/play.page.tsx +93 -0
- package/contributions/play.verbs.ts +74 -0
- package/contributions/std-level.document.tsx +477 -0
- package/dist-node/serving.mjs +695 -0
- package/package.json +277 -10
- package/serving/index.ts +13 -0
- package/serving/std-dev.ts +547 -0
- package/serving/std-scene-runtime.js +240 -0
- package/serving/std-scene.ts +240 -0
- package/src/editor-blender.ts +18 -0
- package/src/editor-threejs.ts +19 -0
- package/src/game-controls.ts +111 -0
- package/src/game-run.ts +165 -0
- package/src/game-view.tsx +219 -0
- package/src/play-clock.ts +62 -0
- package/src/play-log.ts +113 -114
- package/src/std-controls.ts +128 -0
- package/src/std-export.ts +120 -0
- package/src/std-game-adapter.ts +26 -0
- package/src/std-game.ts +967 -0
- package/src/std-level-files.ts +50 -0
- package/src/std-level.ts +107 -0
- package/src/std-view-bar.tsx +68 -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/model-play.ts +0 -376
- package/src/play-materials.ts +0 -199
- package/src/play-script.ts +0 -841
|
@@ -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
|
+
}
|