@volter/play 0.5.212 → 0.5.214
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/README.md +82 -51
- package/contributions/game-panel.tsx +228 -329
- package/contributions/levels.finder.ts +43 -0
- package/contributions/play.page.tsx +56 -174
- package/contributions/play.verbs.ts +74 -0
- package/contributions/{std-scene.document.tsx → std-level.document.tsx} +154 -22
- package/dist-node/serving.mjs +81 -39
- package/package.json +76 -54
- package/serving/index.ts +1 -1
- package/serving/std-dev.ts +86 -53
- package/serving/std-scene-runtime.js +128 -7
- package/serving/std-scene.ts +67 -24
- package/src/editor-blender.ts +4 -19
- package/src/editor-threejs.ts +1 -1
- 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 +4 -4
- package/src/play-log.ts +57 -73
- package/src/std-controls.ts +7 -7
- package/src/std-export.ts +0 -8
- package/src/std-game-adapter.ts +10 -4
- package/src/std-game.ts +320 -191
- package/src/std-level-files.ts +18 -1
- package/src/std-level.ts +4 -4
- package/src/std-view-bar.tsx +7 -17
- package/contributions/model-play.verbs.ts +0 -44
- package/src/model-play.ts +0 -149
- package/src/std-scene-level.ts +0 -59
|
@@ -1,36 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE GAME PANEL —
|
|
3
|
-
*
|
|
4
|
-
* version of something like that. Perhaps that's where the 'autoplay' controls and info
|
|
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.
|
|
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").
|
|
14
6
|
*
|
|
15
7
|
* ## Where it is
|
|
16
8
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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.
|
|
20
12
|
*
|
|
21
|
-
* ## What it drives is the
|
|
13
|
+
* ## What it drives is the game's run
|
|
22
14
|
*
|
|
23
|
-
* Every control here is a call on the
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* command
|
|
28
|
-
* does: `cyclotron play pause`, or `await editor.command('volter.model-play.pause')` under `eval`.
|
|
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`.
|
|
29
20
|
*
|
|
30
21
|
* ## Autoplay is the editor's switch, and the person always wins
|
|
31
22
|
*
|
|
32
23
|
* 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.
|
|
24
|
+
* player in view, and with which goal, is this panel's toggle (or `volter.play.autoplay`).
|
|
34
25
|
* A person's own key or click in the player's page takes it back on the spot (std's page does
|
|
35
26
|
* that), so the driver line reads "You're driving". Stopped, pressing Autoplay arms it for the
|
|
36
27
|
* next start. Where it cannot be switched on, the panel SAYS why, and `play state` carries the
|
|
@@ -38,8 +29,8 @@
|
|
|
38
29
|
*
|
|
39
30
|
* ## The play log, live
|
|
40
31
|
*
|
|
41
|
-
* The right of the panel is the run's play log as it is written (
|
|
42
|
-
* the
|
|
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
|
|
43
34
|
* newest entry until the person scrolls up to read, and follows again once they scroll back to
|
|
44
35
|
* the bottom.
|
|
45
36
|
*
|
|
@@ -50,12 +41,6 @@
|
|
|
50
41
|
* playhead's blue, which is Blender's own selected-widget colour in the same theme.
|
|
51
42
|
*/
|
|
52
43
|
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
44
|
import {
|
|
60
45
|
type ReactNode,
|
|
61
46
|
useCallback,
|
|
@@ -67,129 +52,75 @@ import {
|
|
|
67
52
|
useState,
|
|
68
53
|
useSyncExternalStore,
|
|
69
54
|
} from 'react';
|
|
70
|
-
import { editorBlender
|
|
55
|
+
import { editorBlender } from '../src/editor-blender';
|
|
71
56
|
import { CLOCK_TEXT_INTERVAL_MS, type ClockState, clockStateReader, throttledSubscription } from '../src/play-clock';
|
|
72
57
|
import { getCurrentProject, onProjectChange } from '@volter/sdk/active-project';
|
|
73
58
|
import { editorHost } from '@volter/sdk/host';
|
|
74
|
-
import {
|
|
75
|
-
import {
|
|
76
|
-
|
|
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';
|
|
77
76
|
|
|
78
77
|
/** Said where the project is not a std game, whose Play has nothing to run. */
|
|
79
78
|
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
79
|
|
|
89
80
|
/** The bottom area's mode and the model document it serves: editor-blender's, through its API. */
|
|
90
81
|
const playMode = () => editorBlender().playMode;
|
|
91
82
|
|
|
92
83
|
// ---------------------------------------------------------------------------------------------
|
|
93
|
-
// THE VERBS — `volter.
|
|
84
|
+
// THE VERBS — `volter.play.<verb>`, the panel's controls behind command ids.
|
|
94
85
|
// ---------------------------------------------------------------------------------------------
|
|
95
86
|
|
|
96
|
-
/**
|
|
97
|
-
function
|
|
98
|
-
const named = args?.['document'];
|
|
99
|
-
if (typeof named === 'string' && named !== '') return named;
|
|
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 {
|
|
100
89
|
const served = playMode().servedModelDocument();
|
|
101
|
-
if (served === null) throw new Error('No model document is
|
|
90
|
+
if (served === null) throw new Error('No model document is in front: Game | Animation is a model\'s bottom area.');
|
|
102
91
|
return served;
|
|
103
92
|
}
|
|
104
93
|
|
|
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
94
|
/** THE ONE REPORT every verb answers with — what the panel draws, as data. A control's effect
|
|
161
95
|
* lands on the next drawn frame (a step runs there), so read `state` again to see it. */
|
|
162
|
-
function gameState(
|
|
163
|
-
const
|
|
164
|
-
const blend = playMode().modelDocumentSource(documentId);
|
|
96
|
+
function gameState(): unknown {
|
|
97
|
+
const model = playMode().servedModelDocument();
|
|
165
98
|
return {
|
|
166
|
-
document
|
|
167
|
-
mode: playMode().modelPlayMode(
|
|
168
|
-
playing:
|
|
169
|
-
script:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
autoplay: autoplayState(documentId),
|
|
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 } } : {}),
|
|
176
108
|
export: stdExport(),
|
|
177
109
|
};
|
|
178
110
|
}
|
|
179
111
|
|
|
180
|
-
/** Who drives the player in view: the bot while
|
|
181
|
-
function driverOf(playing: boolean, autoplay:
|
|
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 {
|
|
182
114
|
if (!playing) return null;
|
|
183
|
-
return autoplay.
|
|
115
|
+
return autoplay.driving === true ? 'bot' : 'person';
|
|
184
116
|
}
|
|
185
117
|
|
|
186
118
|
/** The bot's switch and who drives (`driverOf`), null when nothing plays; `why` says why the bot cannot be switched
|
|
187
119
|
* on, null when it can. */
|
|
188
|
-
function autoplayState(
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
const why = autoplayWhy(playing, found?.transport?.clock(documentId) ?? STILL, autoplay);
|
|
120
|
+
function autoplayState(): unknown {
|
|
121
|
+
const autoplay = gameAutoplay();
|
|
122
|
+
const playing = gamePlaying();
|
|
123
|
+
const why = autoplayWhy(playing, gameClock(), autoplay);
|
|
193
124
|
return { ...autoplay, driver: driverOf(playing, autoplay), why };
|
|
194
125
|
}
|
|
195
126
|
|
|
@@ -197,14 +128,14 @@ function autoplayState(documentId: string): unknown {
|
|
|
197
128
|
* WHY THE BOT CANNOT BE SWITCHED ON: `not-running` while stopped or still starting (a player's page must have joined),
|
|
198
129
|
* `game` once it runs and the run says why (`autoplay.unavailable`: no goals, no player in view), null when it can.
|
|
199
130
|
*/
|
|
200
|
-
function autoplayUnavailable(playing: boolean, clock: Pick<
|
|
131
|
+
function autoplayUnavailable(playing: boolean, clock: Pick<GameClock, 'failure' | 'running'>, autoplay: GameAutoplay): 'not-running' | 'game' | null {
|
|
201
132
|
if (playing && clock.running && autoplay.available) return null;
|
|
202
133
|
if (!playing || !clock.running) return 'not-running';
|
|
203
134
|
return 'game';
|
|
204
135
|
}
|
|
205
136
|
|
|
206
137
|
/** {@link autoplayUnavailable} as the CLI says it. */
|
|
207
|
-
function autoplayWhy(playing: boolean, clock: Pick<
|
|
138
|
+
function autoplayWhy(playing: boolean, clock: Pick<GameClock, 'failure' | 'running'>, autoplay: GameAutoplay): string | null {
|
|
208
139
|
switch (autoplayUnavailable(playing, clock, autoplay)) {
|
|
209
140
|
case null: return null;
|
|
210
141
|
case 'game': return autoplay.unavailable ?? 'No autoplay for the player in view.';
|
|
@@ -214,134 +145,124 @@ function autoplayWhy(playing: boolean, clock: Pick<DocumentPlayClock, 'failure'
|
|
|
214
145
|
}
|
|
215
146
|
}
|
|
216
147
|
|
|
217
|
-
function requirePlaying(
|
|
218
|
-
if (!
|
|
219
|
-
throw new Error(`Nothing is playing in ${documentId}, so there is nothing to ${verb}; \`play\` starts it.`);
|
|
148
|
+
function requirePlaying(verb: string): void {
|
|
149
|
+
if (!gamePlaying()) throw new Error(`Nothing is playing, so there is nothing to ${verb}; \`play\` starts it.`);
|
|
220
150
|
}
|
|
221
151
|
|
|
222
|
-
/** The Game panel's verbs: the Code-OSS commands `volter.
|
|
223
|
-
export const
|
|
224
|
-
{ id: 'state', run: (
|
|
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() },
|
|
225
155
|
{
|
|
226
156
|
id: 'mode',
|
|
227
157
|
run: (args) => {
|
|
228
|
-
const documentId = verbDocument(args);
|
|
229
158
|
const mode = args?.['mode'] ?? args?.['to'];
|
|
230
159
|
if (mode !== undefined) {
|
|
231
160
|
const asked = playMode().playModeOf(mode);
|
|
232
161
|
if (asked === null) throw new Error(`mode is \`game\` or \`animation\`; got ${String(mode)}.`);
|
|
233
|
-
editorBlender().switchPlayMode(
|
|
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);
|
|
234
179
|
}
|
|
235
|
-
return gameState(
|
|
180
|
+
return gameState();
|
|
236
181
|
},
|
|
237
182
|
},
|
|
238
183
|
{
|
|
239
184
|
id: 'game-mode',
|
|
240
|
-
run: (
|
|
185
|
+
run: () => { editorBlender().switchPlayMode(modelInFront(), 'game'); return gameState(); },
|
|
241
186
|
},
|
|
242
187
|
{
|
|
243
188
|
id: 'animation-mode',
|
|
244
|
-
run: (
|
|
189
|
+
run: () => { editorBlender().switchPlayMode(modelInFront(), 'animation'); return gameState(); },
|
|
245
190
|
},
|
|
246
191
|
{
|
|
247
|
-
//
|
|
248
|
-
// then sees the panel that owns the run an agent started.
|
|
192
|
+
// (the game played in the Game view, brought to the front where it already plays, as Play opens it)
|
|
249
193
|
id: 'play',
|
|
250
|
-
run: (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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);
|
|
194
|
+
run: () => {
|
|
195
|
+
if (!gamePlaying()) playGame(true);
|
|
196
|
+
else showGameView();
|
|
197
|
+
return gameState();
|
|
264
198
|
},
|
|
265
199
|
},
|
|
200
|
+
{ id: 'stop', run: () => { playGame(false); return gameState(); } },
|
|
266
201
|
{
|
|
267
202
|
id: 'pause',
|
|
268
|
-
run: async (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return gameState(documentId);
|
|
203
|
+
run: async () => {
|
|
204
|
+
requirePlaying('pause');
|
|
205
|
+
await pauseGame(true);
|
|
206
|
+
return gameState();
|
|
273
207
|
},
|
|
274
208
|
},
|
|
275
209
|
{
|
|
276
210
|
id: 'resume',
|
|
277
|
-
run: async (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return gameState(documentId);
|
|
211
|
+
run: async () => {
|
|
212
|
+
requirePlaying('resume');
|
|
213
|
+
await pauseGame(false);
|
|
214
|
+
return gameState();
|
|
282
215
|
},
|
|
283
216
|
},
|
|
284
217
|
{
|
|
285
218
|
// `count` frames of 1/60 s, which std runs (pausing the game first) and answers.
|
|
286
219
|
id: 'step',
|
|
287
220
|
run: async (args) => {
|
|
288
|
-
|
|
289
|
-
requirePlaying(documentId, 'step');
|
|
290
|
-
const { transport } = verbExtension();
|
|
221
|
+
requirePlaying('step');
|
|
291
222
|
const raw = args?.['count'];
|
|
292
223
|
const count = raw === undefined ? 1 : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
|
|
293
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)}.`);
|
|
294
|
-
await
|
|
295
|
-
return gameState(
|
|
225
|
+
await stepGame(count);
|
|
226
|
+
return gameState();
|
|
296
227
|
},
|
|
297
228
|
},
|
|
298
229
|
{
|
|
299
230
|
id: 'speed',
|
|
300
231
|
run: async (args) => {
|
|
301
|
-
const documentId = verbDocument(args);
|
|
302
|
-
const { transport } = verbExtension();
|
|
303
232
|
const raw = args?.['speed'] ?? args?.['to'];
|
|
304
233
|
const speed = typeof raw === 'string' ? Number(raw.replace(/x$/i, '')) : Number(raw);
|
|
305
|
-
if (!
|
|
306
|
-
throw new Error(`speed is one of ${
|
|
307
|
-
await
|
|
308
|
-
return gameState(
|
|
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();
|
|
309
238
|
},
|
|
310
239
|
},
|
|
311
240
|
{
|
|
312
|
-
// THE EDITOR'S AUTOPLAY SWITCH: on plays one of the game's goals (`goal`, the game's own when absent), off
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// `
|
|
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.
|
|
316
246
|
id: 'autoplay',
|
|
317
247
|
run: async (args) => {
|
|
318
|
-
const documentId = verbDocument(args);
|
|
319
248
|
const raw = args?.['on'];
|
|
320
249
|
const on = raw === true || raw === 'on' || raw === 'true' ? true
|
|
321
250
|
: raw === false || raw === 'off' || raw === 'false' ? false : undefined;
|
|
322
251
|
if (on === undefined) throw new Error(`autoplay's \`on\` is true or false (\`play autoplay on [<goal>]|off\`); got ${String(raw)}.`);
|
|
323
252
|
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
253
|
// (refused while nothing plays; the run says its own reasons, a headless play needing no player's page)
|
|
329
|
-
if (on && !
|
|
330
|
-
await
|
|
331
|
-
return gameState(
|
|
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();
|
|
332
257
|
},
|
|
333
258
|
},
|
|
334
259
|
{
|
|
335
260
|
id: 'restart',
|
|
336
|
-
run: (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (playMode().modelPlayMode(documentId) !== 'game') editorBlender().switchPlayMode(documentId, 'game');
|
|
340
|
-
found.transport.restart(documentId);
|
|
341
|
-
return gameState(documentId);
|
|
261
|
+
run: () => {
|
|
262
|
+
restartPlay();
|
|
263
|
+
return gameState();
|
|
342
264
|
},
|
|
343
265
|
},
|
|
344
|
-
]).concat([
|
|
345
266
|
{
|
|
346
267
|
// THE GAME EXPORTED, as the panel's Export menu does it, the project's and so no document's: `target` web, roblox or
|
|
347
268
|
// unreal; an engine the game doesn't target refused unless `add` (true) makes it one first, as the menu's "Add
|
|
@@ -363,7 +284,7 @@ export const MODEL_PLAY_VIEW_VERBS: readonly ViewVerb[] = waitingForTheDocument(
|
|
|
363
284
|
return { export: stdExport(), targets: await stdTargets() };
|
|
364
285
|
},
|
|
365
286
|
},
|
|
366
|
-
]
|
|
287
|
+
];
|
|
367
288
|
|
|
368
289
|
// ---------------------------------------------------------------------------------------------
|
|
369
290
|
// THE PANEL
|
|
@@ -398,24 +319,21 @@ function clockText(seconds: number): string {
|
|
|
398
319
|
/** The clock as std last answered it, redrawn at most every {@link CLOCK_TEXT_INTERVAL_MS} ms and always after the
|
|
399
320
|
* last change (`../src/play-clock.ts`): the game's seconds and frames where it is paused, which a step moves exactly;
|
|
400
321
|
* running, its speed, the time being the game's own between answers. */
|
|
401
|
-
function PlayClockText(
|
|
322
|
+
function PlayClockText() {
|
|
402
323
|
const { TIMELINE_CHROME } = editorBlender().timelineGeometry;
|
|
403
|
-
const subscribe = useMemo(
|
|
404
|
-
() => throttledSubscription((listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}), CLOCK_TEXT_INTERVAL_MS),
|
|
405
|
-
[transport],
|
|
406
|
-
);
|
|
324
|
+
const subscribe = useMemo(() => throttledSubscription(subscribeGameClock, CLOCK_TEXT_INTERVAL_MS), []);
|
|
407
325
|
const text = useSyncExternalStore(
|
|
408
326
|
subscribe,
|
|
409
327
|
() => {
|
|
410
|
-
const clock =
|
|
411
|
-
const playing =
|
|
328
|
+
const clock = gameClock();
|
|
329
|
+
const playing = gamePlaying();
|
|
412
330
|
return !playing ? `stopped · ${speedText(clock.speed)}` : clock.paused ? `${clockText(clock.time)} · frame ${clock.tick}` : `running · ${speedText(clock.speed)}`;
|
|
413
331
|
},
|
|
414
332
|
() => `${clockText(0)} · frame 0`,
|
|
415
333
|
);
|
|
416
334
|
return (
|
|
417
335
|
<span
|
|
418
|
-
data-testid="
|
|
336
|
+
data-testid="play-clock"
|
|
419
337
|
style={{ color: TIMELINE_CHROME.widgetText, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}
|
|
420
338
|
>
|
|
421
339
|
{text}
|
|
@@ -429,58 +347,38 @@ function speedText(speed: number): string {
|
|
|
429
347
|
|
|
430
348
|
/** A control's refusal, said in the console under its name. */
|
|
431
349
|
const said = (control: string) => (error: unknown): void =>
|
|
432
|
-
editorHost().console.error(`${control}: ${error instanceof Error ? error.message : String(error)}`, '
|
|
350
|
+
editorHost().console.error(`${control}: ${error instanceof Error ? error.message : String(error)}`, 'play');
|
|
433
351
|
|
|
434
352
|
export function GamePanel() {
|
|
435
353
|
const { PlayAreaButton, PlayModeSwitch, timelineGeometry: { HEADER_HEIGHT, TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
|
|
436
354
|
const { subscribePlayMode, playModeVersion } = playMode();
|
|
437
355
|
useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
|
|
438
|
-
|
|
439
|
-
const
|
|
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
|
-
);
|
|
356
|
+
// (the project's run, whatever document is in front)
|
|
357
|
+
const playing = useSyncExternalStore(subscribeGame, gamePlaying, () => false);
|
|
446
358
|
// THE CLOCK'S STATE, not its time (`../src/play-clock.ts`): the panel re-renders when a game is
|
|
447
359
|
// paused, sped up, fails or starts running, never once a frame; its time is {@link PlayClockText}'s.
|
|
448
|
-
const readClockState = useMemo(
|
|
449
|
-
|
|
450
|
-
[documentId, transport],
|
|
451
|
-
);
|
|
452
|
-
const clock = useSyncExternalStore(subscribeClock, readClockState, () => STILL);
|
|
360
|
+
const readClockState = useMemo(() => clockStateReader(gameClock), []);
|
|
361
|
+
const clock = useSyncExternalStore(subscribeGameClock, readClockState, readClockState);
|
|
453
362
|
// Whether the project is a std game, which Play plays (undefined while the session is asked).
|
|
454
363
|
const std = useSyncExternalStore(subscribeStdGame, isStdGame, () => undefined);
|
|
455
|
-
|
|
456
|
-
const
|
|
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.
|
|
364
|
+
const autoplay = useSyncExternalStore(subscribeGameControls, gameAutoplay, gameAutoplay);
|
|
365
|
+
const scriptPath = STD_DEV;
|
|
466
366
|
// THE GAME'S NEWEST ERROR, a player's page's (`std-game.ts`): said where the person looks, as the console says each.
|
|
467
367
|
useSyncExternalStore(subscribeStdRun, stdRunVersion, stdRunVersion);
|
|
468
|
-
const
|
|
469
|
-
|
|
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`)
|
|
470
371
|
const headlessLine = playing && autoplay.state ? ` · ${autoplay.state}` : '';
|
|
471
|
-
const
|
|
472
|
-
const status = documentId === null
|
|
473
|
-
? 'No model document is open.'
|
|
474
|
-
: std === false
|
|
372
|
+
const status = std === false
|
|
475
373
|
? NOT_STD
|
|
476
374
|
: playing && clock.failure
|
|
477
|
-
// The run plays but no game runs (`
|
|
375
|
+
// The run plays but no game runs (`GameClock.failure`): say so, not "Playing".
|
|
478
376
|
? `Not running — ${clock.failure.replace(/\.$/, '')}. Restart (or save the game) to retry, or Stop.${headlessLine}`
|
|
479
377
|
: playing && thrown !== null
|
|
480
378
|
? `Playing — the game threw: ${thrown}${headlessLine}`
|
|
481
379
|
: playing
|
|
482
|
-
? `${clock.paused ? 'Paused' : 'Playing'} ${scriptPath
|
|
483
|
-
:
|
|
380
|
+
? `${clock.paused ? 'Paused' : 'Playing'} ${scriptPath}` + headlessLine
|
|
381
|
+
: `Stopped · Play runs ${scriptPath}`;
|
|
484
382
|
return (
|
|
485
383
|
<div
|
|
486
384
|
data-testid="game-panel"
|
|
@@ -516,77 +414,83 @@ export function GamePanel() {
|
|
|
516
414
|
<div style={{ flex: 1 }} />
|
|
517
415
|
<div style={{ display: 'flex' }} role="group" aria-label="Game transport">
|
|
518
416
|
<PlayAreaButton
|
|
519
|
-
testId="
|
|
520
|
-
label={playing ? 'Stop
|
|
417
|
+
testId="play-button"
|
|
418
|
+
label={playing ? 'Stop the game' : 'Play the game'}
|
|
521
419
|
pressed={playing}
|
|
522
420
|
wide
|
|
523
|
-
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
onClick={() => { if (documentId !== null && found) found.setPlaying(documentId, !found.playing(documentId)); }}
|
|
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()); }}
|
|
527
424
|
>
|
|
528
425
|
{mark(playing ? MARKS.stop : MARKS.play)}
|
|
529
426
|
{playing ? 'Stop' : 'Play'}
|
|
530
427
|
</PlayAreaButton>
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
{mark(MARKS.capture)}
|
|
565
|
-
</PlayAreaButton>
|
|
566
|
-
</>
|
|
567
|
-
)}
|
|
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>
|
|
568
461
|
</div>
|
|
569
|
-
{
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
{speedText(speed)}
|
|
582
|
-
</PlayAreaButton>
|
|
583
|
-
))}
|
|
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>
|
|
584
474
|
</div>
|
|
585
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>
|
|
586
490
|
<div style={{ flex: 1 }} />
|
|
587
491
|
{std === true && <ExportControl />}
|
|
588
492
|
{/* THE CLOCK IS THE GAME'S, as std answered it: its seconds and frames, still while paused. */}
|
|
589
|
-
<PlayClockText
|
|
493
|
+
<PlayClockText />
|
|
590
494
|
</div>
|
|
591
495
|
<div
|
|
592
496
|
style={{
|
|
@@ -600,15 +504,13 @@ export function GamePanel() {
|
|
|
600
504
|
}}
|
|
601
505
|
>
|
|
602
506
|
<div style={{ flex: '0 1 260px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
603
|
-
<span data-testid="
|
|
507
|
+
<span data-testid="play-status" title={status} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
604
508
|
{status}
|
|
605
509
|
</span>
|
|
606
|
-
{
|
|
607
|
-
<AutoplayControl documentId={documentId} playing={playing} clock={clock} autoplay={autoplay} transport={transport} />
|
|
608
|
-
)}
|
|
510
|
+
<AutoplayControl playing={playing} clock={clock} autoplay={autoplay} />
|
|
609
511
|
{std === true && <ExportNote />}
|
|
610
512
|
</div>
|
|
611
|
-
|
|
513
|
+
<PlayLogView />
|
|
612
514
|
</div>
|
|
613
515
|
</div>
|
|
614
516
|
);
|
|
@@ -639,10 +541,9 @@ function ExportControl() {
|
|
|
639
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
|
|
640
542
|
// comes to close it)
|
|
641
543
|
useEffect(() => {
|
|
642
|
-
setExportMenuOpen(open);
|
|
643
544
|
if (!open) return undefined;
|
|
644
545
|
const outside = (event: PointerEvent): void => { if (!menu.current?.contains(event.target as Node)) setOpen(false); };
|
|
645
|
-
// (Escape closes the menu alone
|
|
546
|
+
// (Escape closes the menu alone)
|
|
646
547
|
const escape = (event: KeyboardEvent): void => {
|
|
647
548
|
if (event.key !== 'Escape') return;
|
|
648
549
|
event.preventDefault();
|
|
@@ -652,7 +553,6 @@ function ExportControl() {
|
|
|
652
553
|
window.addEventListener('pointerdown', outside, true);
|
|
653
554
|
window.addEventListener('keydown', escape, true);
|
|
654
555
|
return () => {
|
|
655
|
-
setExportMenuOpen(false);
|
|
656
556
|
window.removeEventListener('pointerdown', outside, true);
|
|
657
557
|
window.removeEventListener('keydown', escape, true);
|
|
658
558
|
};
|
|
@@ -665,7 +565,7 @@ function ExportControl() {
|
|
|
665
565
|
return (
|
|
666
566
|
<div ref={menu} style={{ position: 'relative', display: 'inline-flex' }}>
|
|
667
567
|
<PlayAreaButton
|
|
668
|
-
testId="
|
|
568
|
+
testId="play-export"
|
|
669
569
|
label={running && current !== null ? `Exporting to ${EXPORT_LABELS[current.target]}` : 'Export the game: to the web, or to an engine'}
|
|
670
570
|
pressed={open}
|
|
671
571
|
wide
|
|
@@ -689,7 +589,7 @@ function ExportControl() {
|
|
|
689
589
|
key={target}
|
|
690
590
|
type="button"
|
|
691
591
|
role="menuitem"
|
|
692
|
-
data-testid={`
|
|
592
|
+
data-testid={`play-export-${target}`}
|
|
693
593
|
onMouseDown={(event) => event.preventDefault()}
|
|
694
594
|
onClick={() => choose(target)}
|
|
695
595
|
style={{ background: 'none', border: 'none', color: TIMELINE_CHROME.text, font: 'inherit', textAlign: 'left', padding: '4px 8px', cursor: 'pointer' }}
|
|
@@ -712,7 +612,7 @@ function ExportNote() {
|
|
|
712
612
|
const text = exportText(current, label);
|
|
713
613
|
return (
|
|
714
614
|
<span
|
|
715
|
-
data-testid="
|
|
615
|
+
data-testid="play-export-note"
|
|
716
616
|
data-export={current.running ? 'running' : current.ok === true ? 'exported' : 'failed'}
|
|
717
617
|
title={text}
|
|
718
618
|
style={{ color: TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
|
|
@@ -732,25 +632,24 @@ function exportText(current: StdExport, label: string): string {
|
|
|
732
632
|
// AUTOPLAY AND THE PLAY LOG
|
|
733
633
|
// ---------------------------------------------------------------------------------------------
|
|
734
634
|
|
|
735
|
-
function AutoplayControl({
|
|
736
|
-
readonly documentId: string;
|
|
635
|
+
function AutoplayControl({ playing, clock, autoplay }: {
|
|
737
636
|
readonly playing: boolean;
|
|
738
637
|
readonly clock: ClockState;
|
|
739
|
-
readonly autoplay:
|
|
740
|
-
readonly transport: DocumentPlayTransport;
|
|
638
|
+
readonly autoplay: GameAutoplay;
|
|
741
639
|
}) {
|
|
742
640
|
const { PlayAreaButton, timelineGeometry: { TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
|
|
743
641
|
const unavailable = autoplayUnavailable(playing, clock, autoplay);
|
|
744
642
|
const armed = autoplay.armed === true;
|
|
745
643
|
// STOPPED, the button ARMS: the next start switches the bot on once its player joins.
|
|
746
|
-
const canArm = !playing
|
|
644
|
+
const canArm = !playing;
|
|
747
645
|
const label = unavailable === 'game'
|
|
748
646
|
? autoplay.unavailable ?? 'No autoplay for the player in view'
|
|
749
647
|
: unavailable === 'not-running'
|
|
750
648
|
? canArm
|
|
751
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'
|
|
752
650
|
: 'Autoplay is available once a player has joined'
|
|
753
|
-
: autoplay.on ? 'Stop autoplay and drive yourself' : '
|
|
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)';
|
|
754
653
|
// SAID, NOT ONLY HOVERED: why there is nothing to switch.
|
|
755
654
|
const note = unavailable === 'game'
|
|
756
655
|
? autoplay.unavailable ?? 'No autoplay for the player in view'
|
|
@@ -767,7 +666,7 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
|
|
|
767
666
|
<div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap, minWidth: 0 }}>
|
|
768
667
|
{goals.length > 1 && (
|
|
769
668
|
<select
|
|
770
|
-
data-testid="
|
|
669
|
+
data-testid="play-autoplay-goal"
|
|
771
670
|
title="The goal Autoplay's bot plays"
|
|
772
671
|
value={goal ?? ''}
|
|
773
672
|
disabled={autoplay.on}
|
|
@@ -782,18 +681,18 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
|
|
|
782
681
|
{/* KEEP THE KEYBOARD OFF THIS BUTTON (`PlayAreaButton`'s). Left focused, a person taking over with Space would
|
|
783
682
|
also click this button and turn autoplay straight back on. */}
|
|
784
683
|
<PlayAreaButton
|
|
785
|
-
testId="
|
|
684
|
+
testId="play-autoplay"
|
|
786
685
|
label={label}
|
|
787
686
|
pressed={autoplay.on || armed}
|
|
788
687
|
wide
|
|
789
|
-
disabled={canArm ? false : !playing || unavailable !== null
|
|
688
|
+
disabled={canArm ? false : !playing || unavailable !== null}
|
|
790
689
|
// The switch's state NOW, as the transport's buttons read theirs: a takeover can land between the render and
|
|
791
690
|
// the click.
|
|
792
691
|
onClick={() => {
|
|
793
|
-
const now =
|
|
692
|
+
const now = gameAutoplay();
|
|
794
693
|
try {
|
|
795
|
-
if (!(
|
|
796
|
-
else void
|
|
694
|
+
if (!gamePlaying()) armAutoplay(now.armed !== true, goal ?? undefined);
|
|
695
|
+
else void setGameAutoplay(!now.on, 'panel', now.on ? undefined : goal ?? undefined).catch(said('Autoplay'));
|
|
797
696
|
} catch (error) { said('Autoplay')(error); }
|
|
798
697
|
}}
|
|
799
698
|
>
|
|
@@ -801,18 +700,18 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
|
|
|
801
700
|
</PlayAreaButton>
|
|
802
701
|
</span>
|
|
803
702
|
{note !== null ? (
|
|
804
|
-
<span data-testid="
|
|
703
|
+
<span data-testid="play-autoplay-note" data-reason={unavailable ?? undefined} style={{ color: TIMELINE_CHROME.widgetText, minWidth: 0 }}>
|
|
805
704
|
{note}
|
|
806
705
|
</span>
|
|
807
706
|
) : playing && (
|
|
808
707
|
// WHO DRIVES THE PLAYER IN VIEW, and the goal the bot plays: "Bot driving · collect-all".
|
|
809
708
|
<span
|
|
810
|
-
data-testid="
|
|
709
|
+
data-testid="play-driver"
|
|
811
710
|
data-driver={driver ?? undefined}
|
|
812
711
|
style={{ color: autoplay.on ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
|
|
813
712
|
>
|
|
814
|
-
{driver === 'bot' ? `Bot driving${autoplay.behavior ? ` · ${autoplay.behavior}` : ''}` : 'You’re driving'}
|
|
815
|
-
{autoplay.state ? ` ·
|
|
713
|
+
{driver === 'bot' ? `Bot driving${autoplay.behavior && !autoplay.state ? ` · ${autoplay.behavior}` : ''}` : 'You’re driving'}
|
|
714
|
+
{autoplay.state ? ` · ${autoplay.state}` : ''}
|
|
816
715
|
</span>
|
|
817
716
|
)}
|
|
818
717
|
</div>
|
|
@@ -827,19 +726,19 @@ function factsText(facts: Record<string, unknown> | undefined): string {
|
|
|
827
726
|
try { return JSON.stringify(facts); } catch { return ''; }
|
|
828
727
|
}
|
|
829
728
|
|
|
830
|
-
function PlayLogView(
|
|
729
|
+
function PlayLogView() {
|
|
831
730
|
const { TIMELINE_CHROME, TIMELINE_THEME } = editorBlender().timelineGeometry;
|
|
832
731
|
const [kind, setKind] = useState('');
|
|
833
732
|
// Writes land several to a frame; the view redraws at most once per drawn frame.
|
|
834
733
|
const [, redraw] = useReducer((count: number) => count + 1, 0);
|
|
835
734
|
useEffect(() => {
|
|
836
735
|
let frame = 0;
|
|
837
|
-
const stop =
|
|
736
|
+
const stop = subscribePlayLog(() => {
|
|
838
737
|
if (frame === 0) frame = requestAnimationFrame(() => { frame = 0; redraw(); });
|
|
839
738
|
});
|
|
840
739
|
return () => { stop(); cancelAnimationFrame(frame); };
|
|
841
740
|
}, []);
|
|
842
|
-
const tail =
|
|
741
|
+
const tail = tailPlayLog(LOG_LINES, kind === '' ? undefined : kind);
|
|
843
742
|
const list = useRef<HTMLDivElement>(null);
|
|
844
743
|
// Follows the newest entry until the person scrolls up; back at the bottom, it follows again.
|
|
845
744
|
const following = useRef(true);
|
|
@@ -849,11 +748,11 @@ function PlayLogView({ documentId }: { readonly documentId: string }) {
|
|
|
849
748
|
});
|
|
850
749
|
const newest = tail.entries.at(-1)?.seq;
|
|
851
750
|
return (
|
|
852
|
-
<div data-testid="
|
|
751
|
+
<div data-testid="play-log" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
853
752
|
<div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap }}>
|
|
854
753
|
<span>Play log</span>
|
|
855
754
|
<select
|
|
856
|
-
data-testid="
|
|
755
|
+
data-testid="play-log-kind"
|
|
857
756
|
aria-label="Show entries of kind"
|
|
858
757
|
value={kind}
|
|
859
758
|
// Blurred once chosen, so the player's next arrow key moves the game, not this list.
|
|
@@ -875,7 +774,7 @@ function PlayLogView({ documentId }: { readonly documentId: string }) {
|
|
|
875
774
|
</div>
|
|
876
775
|
<div
|
|
877
776
|
ref={list}
|
|
878
|
-
data-testid="
|
|
777
|
+
data-testid="play-log-entries"
|
|
879
778
|
data-newest={newest}
|
|
880
779
|
onScroll={(event) => {
|
|
881
780
|
const element = event.currentTarget;
|