@volter/play 0.5.211 → 0.5.212
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +5 -5
- package/README.md +125 -172
- package/contributions/game-panel.tsx +908 -0
- package/contributions/model-play.verbs.ts +44 -0
- package/contributions/play.page.tsx +211 -0
- package/contributions/std-scene.document.tsx +345 -0
- package/dist-node/serving.mjs +664 -0
- package/package.json +257 -12
- package/serving/index.ts +13 -0
- package/serving/std-dev.ts +514 -0
- package/serving/std-scene-runtime.js +119 -0
- package/serving/std-scene.ts +213 -0
- package/src/editor-blender.ts +33 -0
- package/src/editor-threejs.ts +19 -0
- package/src/model-play.ts +60 -287
- package/src/play-clock.ts +62 -0
- package/src/play-log.ts +77 -62
- package/src/std-controls.ts +128 -0
- package/src/std-export.ts +128 -0
- package/src/std-game-adapter.ts +20 -0
- package/src/std-game.ts +841 -0
- package/src/std-level-files.ts +33 -0
- package/src/std-level.ts +107 -0
- package/src/std-scene-level.ts +59 -0
- package/src/std-view-bar.tsx +78 -0
- package/contributions/model-play.command.ts +0 -37
- package/contributions/model-play.service.tsx +0 -152
- package/src/camera-transition.ts +0 -87
- package/src/play-materials.ts +0 -199
- package/src/play-script.ts +0 -841
package/src/model-play.ts
CHANGED
|
@@ -1,145 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WHETHER A MODEL DOCUMENT IS PLAYING (Play in the Game panel, `
|
|
3
|
-
* `blender-runtime.document.tsx`). Playing, the document's
|
|
4
|
-
* model
|
|
5
|
-
*
|
|
6
|
-
* returns to them. Kept for the page's life and never stored: a reload opens the model, not a
|
|
7
|
-
* game.
|
|
2
|
+
* WHETHER A MODEL DOCUMENT IS PLAYING (Play in the Game panel, `contributions/game-panel.tsx`; drawn by
|
|
3
|
+
* `blender-runtime.document.tsx`). Playing, the document's stage shows the std game's players' pages (`std-game.ts`);
|
|
4
|
+
* the model, its selection and its history stand as they were, and Stop returns to them. Kept for the page's life and
|
|
5
|
+
* never stored: a reload opens the model, not a game.
|
|
8
6
|
*
|
|
9
|
-
* AND
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* page's own clock (`performance.now()`, `Date.now()`) instead is outside that reach.
|
|
7
|
+
* AND ITS CLOCK, AS STD LAST ANSWERED IT. Pause, step and speed are std's `clock` control on each player's page
|
|
8
|
+
* (`std-controls.ts`); each answer is the page's clock (`{ paused, speed, time, frame }`), kept here per document
|
|
9
|
+
* beside whether a game runs and why not, and announced through the clock's own subscription. The speed is the
|
|
10
|
+
* person's: kept across Plays, and said to each player's page as it joins.
|
|
14
11
|
*
|
|
15
|
-
* AND
|
|
16
|
-
* (`
|
|
17
|
-
* is off whenever a run begins — Play, Restart, Stop — and only the Game panel's toggle or its
|
|
18
|
-
* verb (`volter.model-play.autoplay`) turns it on. The one carry-over is an ARM: a person may
|
|
19
|
-
* press Autoplay while stopped (there is no bot yet — a script offers it only once it runs), and
|
|
20
|
-
* the next start turns autoplay on the moment its script has run its first update with a bot, or
|
|
21
|
-
* drops the arm if it offers none. The runner turns it off the moment a person
|
|
22
|
-
* presses a key or touches the game (`takeover`), or when the running script stops offering a
|
|
23
|
-
* bot (`script`). Its changes are announced through the clock's subscription, which the panel
|
|
24
|
-
* and the runner already hold.
|
|
12
|
+
* AND AUTOPLAY ARMED WHILE STOPPED: the next start drives the viewed player's bot as its page joins, with the goal it
|
|
13
|
+
* was armed with (`std-game.ts`). Stop drops the arm.
|
|
25
14
|
*/
|
|
26
15
|
const listeners = new Set<() => void>();
|
|
27
16
|
const clockListeners = new Set<() => void>();
|
|
28
17
|
const playing = new Set<string>();
|
|
29
|
-
const stops = new Map<string, { readonly stop: (escape: boolean) => void; readonly generation: number }>();
|
|
30
18
|
|
|
31
|
-
/** The speeds the transport offers, slowest first
|
|
19
|
+
/** The speeds the transport offers, slowest first: std's clock takes 0.25 to 4. */
|
|
32
20
|
export const MODEL_PLAY_SPEEDS: readonly number[] = [0.25, 0.5, 1, 2, 4];
|
|
33
|
-
/** The `dt` one Step hands the game: one nominal 60 Hz frame, whatever the speed. */
|
|
34
|
-
export const MODEL_PLAY_STEP_SECONDS = 1 / 60;
|
|
35
21
|
|
|
36
22
|
export interface ModelPlayClock {
|
|
23
|
+
/** The viewed player's page's game seconds and frames, as std last answered them. */
|
|
37
24
|
readonly time: number;
|
|
38
25
|
readonly tick: number;
|
|
39
26
|
readonly paused: boolean;
|
|
40
27
|
readonly speed: number;
|
|
41
|
-
/** The run is playing but no game runs:
|
|
28
|
+
/** The run is playing but no game runs: its dev server could not start, or no player's page joined. */
|
|
42
29
|
readonly failure: string | null;
|
|
43
|
-
/** A
|
|
30
|
+
/** A player's page has joined this run (`DocumentPlayClock.running`). */
|
|
44
31
|
readonly running: boolean;
|
|
45
32
|
}
|
|
46
33
|
const STILL: ModelPlayClock = { time: 0, tick: 0, paused: false, speed: 1, failure: null, running: false };
|
|
47
|
-
/** Replaced, never mutated, so a snapshot read by `useSyncExternalStore` changes identity
|
|
48
|
-
*
|
|
34
|
+
/** Replaced, never mutated, so a snapshot read by `useSyncExternalStore` changes identity exactly when it changes
|
|
35
|
+
* value. */
|
|
49
36
|
const clocks = new Map<string, ModelPlayClock>();
|
|
50
|
-
/** Steps asked for while paused and not yet run; the runner takes one per drawn frame. */
|
|
51
|
-
const steps = new Map<string, number>();
|
|
52
37
|
/** Bumped by Restart; the document keys its detached copy on it (`DocumentPlayTransport`). */
|
|
53
38
|
const generations = new Map<string, number>();
|
|
54
|
-
/**
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
/** Who last switched autoplay: the panel's toggle, a verb (CLI or `eval`), a person's input
|
|
58
|
-
* in the game, the running script no longer offering a bot (replaced, failed, or its bot
|
|
59
|
-
* threw), or the run's time limit. */
|
|
60
|
-
export type ModelPlayAutoplayBy = 'panel' | 'cli' | 'takeover' | 'script' | 'limit';
|
|
61
|
-
/**
|
|
62
|
-
* EVERY AUTOPLAY RUN HAS A LIMIT, in simulation seconds: a bot whose game never ends would
|
|
63
|
-
* otherwise drive forever, and an agent waiting on it would wait forever. Reaching it turns
|
|
64
|
-
* autoplay off, pauses the game and logs `autoplay-limit` (`play-script.ts`).
|
|
65
|
-
*/
|
|
66
|
-
export const MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS = 300;
|
|
67
|
-
export interface ModelPlayAutoplay {
|
|
68
|
-
/** The bot drives: its keys are merged into the keys the script reads. */
|
|
69
|
-
readonly on: boolean;
|
|
70
|
-
/** The running script registered a bot with `play.autoplay`. */
|
|
71
|
-
readonly available: boolean;
|
|
72
|
-
/** The behaviours the running script's bot offers (`play.autoplay({ win, lose })`), in its
|
|
73
|
-
* order; a bare function is the one behaviour `play`. */
|
|
74
|
-
readonly behaviors: readonly string[];
|
|
75
|
-
/** The behaviour driving now, or armed for the next start. */
|
|
76
|
-
readonly behavior: string | null;
|
|
77
|
-
/** This run's limit and the simulation time it began at; null while autoplay is off. */
|
|
78
|
-
readonly limit: number | null;
|
|
79
|
-
readonly since: number | null;
|
|
80
|
-
readonly by: ModelPlayAutoplayBy | null;
|
|
81
|
-
/** Pressed while stopped: the next start turns autoplay on once its script offers a bot. */
|
|
82
|
-
readonly armed: boolean;
|
|
83
|
-
/** A person's key or pointer reached the game in this run. Until one does, nobody drives
|
|
84
|
-
* while autoplay is off — the game runs on no input. */
|
|
85
|
-
readonly person: boolean;
|
|
86
|
-
/** What the bot last said it is doing (`{ keys, state }` from its controller); kept after
|
|
87
|
-
* autoplay goes off, so a limit or takeover still shows where the bot was. */
|
|
88
|
-
readonly state: string | null;
|
|
89
|
-
/** Why the last arm was not taken at start (the bot offers no bot, or not the behaviour it asked
|
|
90
|
-
* for, or several with none named); null otherwise. Said in the panel and the play log. */
|
|
91
|
-
readonly refused: string | null;
|
|
92
|
-
}
|
|
93
|
-
/** What a switch-on asks for: the behaviour (required when the bot offers several) and the limit. */
|
|
94
|
-
export interface ModelPlayAutoplayRequest {
|
|
95
|
-
readonly behavior?: string | null;
|
|
96
|
-
readonly limit?: number | null;
|
|
97
|
-
}
|
|
98
|
-
const NO_BOT: ModelPlayAutoplay = { on: false, available: false, behaviors: [], behavior: null, limit: null, since: null, by: null, armed: false, person: false, state: null, refused: null };
|
|
99
|
-
const ARMED: ModelPlayAutoplay = { ...NO_BOT, armed: true };
|
|
100
|
-
/** Replaced, never mutated, as the clocks are. Absent is {@link NO_BOT}: a new run's state. */
|
|
101
|
-
const autoplays = new Map<string, ModelPlayAutoplay>();
|
|
102
|
-
|
|
103
|
-
/** A new run's autoplay: off, no bot yet, nobody driving. Play keeps an arm made while stopped
|
|
104
|
-
* (with the behaviour and limit it asked for); Stop drops it. The behaviours the last run's bot
|
|
105
|
-
* offered are kept, not available: while stopped they are what the panel's picker offers for an
|
|
106
|
-
* arm, which is checked against the bot the next start offers. */
|
|
107
|
-
function resetAutoplay(documentId: string, keepArm: boolean): void {
|
|
108
|
-
const now = modelPlayAutoplay(documentId);
|
|
109
|
-
if (keepArm && now.armed) autoplays.set(documentId, { ...ARMED, behaviors: now.behaviors, behavior: now.behavior, limit: now.limit });
|
|
110
|
-
else if (now.behaviors.length) autoplays.set(documentId, { ...NO_BOT, behaviors: now.behaviors });
|
|
111
|
-
else autoplays.delete(documentId);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** A limit as given: a positive number of simulation seconds, or the default. */
|
|
115
|
-
function autoplayLimit(limit: number | null | undefined): number {
|
|
116
|
-
if (limit === undefined || limit === null) return MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS;
|
|
117
|
-
if (!Number.isFinite(limit) || limit <= 0) throw new Error(`An autoplay limit is a positive number of simulation seconds; got ${String(limit)}.`);
|
|
118
|
-
return limit;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** The behaviour a switch-on drives: the one named, which the bot must offer; with none named,
|
|
122
|
-
* the bot's only behaviour. A bot with several needs one named, so a run always says what its
|
|
123
|
-
* bot was trying to do. */
|
|
124
|
-
function chosenBehavior(behaviors: readonly string[], named: string | null | undefined): string {
|
|
125
|
-
if (named) {
|
|
126
|
-
if (!behaviors.includes(named))
|
|
127
|
-
throw new Error(`This game's bot has no behaviour "${named}"; it offers ${behaviors.map(b => `"${b}"`).join(', ')}.`);
|
|
128
|
-
return named;
|
|
129
|
-
}
|
|
130
|
-
if (behaviors.length === 1) return behaviors[0]!;
|
|
131
|
-
throw new Error(`This game's bot offers several behaviours (${behaviors.join(', ')}); name the one to run: \`play autoplay on <behaviour>\`.`);
|
|
132
|
-
}
|
|
39
|
+
/** Autoplay armed while stopped, with the goal it drives (null: the game's own). */
|
|
40
|
+
const arms = new Map<string, { readonly goal: string | null }>();
|
|
133
41
|
|
|
134
42
|
function publish(): void {
|
|
135
43
|
for (const listener of [...listeners]) listener();
|
|
136
44
|
}
|
|
137
|
-
function
|
|
45
|
+
export function publishModelPlayClock(): void {
|
|
138
46
|
for (const listener of [...clockListeners]) listener();
|
|
139
47
|
}
|
|
140
48
|
function setClock(documentId: string, next: Partial<ModelPlayClock>): void {
|
|
141
49
|
clocks.set(documentId, { ...modelPlayClock(documentId), ...next });
|
|
142
|
-
|
|
50
|
+
publishModelPlayClock();
|
|
143
51
|
}
|
|
144
52
|
|
|
145
53
|
export function modelPlaying(documentId: string): boolean {
|
|
@@ -148,64 +56,25 @@ export function modelPlaying(documentId: string): boolean {
|
|
|
148
56
|
|
|
149
57
|
export function setModelPlaying(documentId: string, value: boolean): void {
|
|
150
58
|
if (playing.has(documentId) === value) {
|
|
151
|
-
// Stop with nothing playing is how a closing document lets go of its play: an arm made while
|
|
152
|
-
|
|
153
|
-
if (!value && modelPlayAutoplay(documentId).armed) { resetAutoplay(documentId, false); publishClock(); }
|
|
59
|
+
// Stop with nothing playing is how a closing document lets go of its play: an arm made while stopped goes with it.
|
|
60
|
+
if (!value && arms.delete(documentId)) publishModelPlayClock();
|
|
154
61
|
return;
|
|
155
62
|
}
|
|
156
|
-
const stop = value ? null : currentStop(documentId);
|
|
157
|
-
if (stop) { stop(false); return; }
|
|
158
63
|
if (value) {
|
|
159
64
|
playing.add(documentId);
|
|
160
|
-
// A new run starts
|
|
161
|
-
steps.delete(documentId);
|
|
162
|
-
resetAutoplay(documentId, true);
|
|
65
|
+
// A new run's clock starts at zero and running; the speed is the person's and stays.
|
|
163
66
|
setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
|
|
164
67
|
} else {
|
|
165
68
|
playing.delete(documentId);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
resetAutoplay(documentId, false);
|
|
169
|
-
// The clock keeps the stopped run's time and tick, so the panel still says how far it got.
|
|
170
|
-
// Re-issued even unchanged, after `playing` has changed: every way a game stops (Stop,
|
|
171
|
-
// Escape, a deleted script, a mode switch, an agent's `stop`) ends here, and a reader of
|
|
172
|
-
// the clock alone must see the run end too.
|
|
69
|
+
arms.delete(documentId);
|
|
70
|
+
// The clock keeps the stopped run's time and frames, so the panel still says how far it got.
|
|
173
71
|
setClock(documentId, { paused: false, failure: null, running: false });
|
|
174
72
|
}
|
|
175
73
|
publish();
|
|
176
74
|
}
|
|
177
75
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
setModelPlaying(documentId, false);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export function escapeModelPlay(documentId: string): void {
|
|
184
|
-
const stop = currentStop(documentId);
|
|
185
|
-
if (stop) stop(true);
|
|
186
|
-
else finishModelPlay(documentId);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* A RUNNER'S STOP, BOUND TO ITS GENERATION. Stop and Escape go to the runner of the CURRENT
|
|
191
|
-
* generation, which blends the camera back first; a stop a Restart has superseded is never
|
|
192
|
-
* asked, because that runner stands still from the moment its generation passes
|
|
193
|
-
* (`play-script.ts`) and would never finish the blend — the stop would be lost. With no current
|
|
194
|
-
* runner (a restarted game still preparing) the play simply ends.
|
|
195
|
-
*/
|
|
196
|
-
export function registerModelPlayStop(documentId: string, stop: (escape: boolean) => void): () => void {
|
|
197
|
-
const entry = { stop, generation: modelPlayGeneration(documentId) };
|
|
198
|
-
stops.set(documentId, entry);
|
|
199
|
-
return () => { if (stops.get(documentId) === entry) stops.delete(documentId); };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function currentStop(documentId: string): ((escape: boolean) => void) | null {
|
|
203
|
-
const entry = stops.get(documentId);
|
|
204
|
-
return entry !== undefined && entry.generation === modelPlayGeneration(documentId) ? entry.stop : null;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** The runner's report that no game runs although the run plays (`null` once one does, which
|
|
208
|
-
* is also the moment the run is `running`). */
|
|
76
|
+
/** The run's report that no game runs although the run plays (`null` once a player's page joins, which is also the
|
|
77
|
+
* moment the run is `running`). */
|
|
209
78
|
export function setModelPlayFailure(documentId: string, failure: string | null): void {
|
|
210
79
|
const clock = modelPlayClock(documentId);
|
|
211
80
|
const running = failure === null;
|
|
@@ -213,11 +82,23 @@ export function setModelPlayFailure(documentId: string, failure: string | null):
|
|
|
213
82
|
setClock(documentId, { failure, running });
|
|
214
83
|
}
|
|
215
84
|
|
|
85
|
+
/** A page's clock as std answered it (`std-controls.ts`'s `StdClock`), kept as the run's. */
|
|
86
|
+
export function setModelPlayClock(documentId: string, clock: { readonly paused: boolean; readonly speed: number; readonly time: number; readonly frame: number }): void {
|
|
87
|
+
const now = modelPlayClock(documentId);
|
|
88
|
+
if (now.paused === clock.paused && now.speed === clock.speed && now.time === clock.time && now.tick === clock.frame) return;
|
|
89
|
+
setClock(documentId, { paused: clock.paused, speed: clock.speed, time: clock.time, tick: clock.frame });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The person's speed, kept for the page's life and said to each player's page as it joins. */
|
|
93
|
+
export function setModelPlaySpeed(documentId: string, speed: number): void {
|
|
94
|
+
if (!MODEL_PLAY_SPEEDS.includes(speed))
|
|
95
|
+
throw new Error(`Play speed ${speed} is not one the transport offers: ${MODEL_PLAY_SPEEDS.join(', ')}.`);
|
|
96
|
+
if (modelPlayClock(documentId).speed !== speed) setClock(documentId, { speed });
|
|
97
|
+
}
|
|
98
|
+
|
|
216
99
|
export function subscribeModelPlay(listener: () => void): () => void {
|
|
217
100
|
listeners.add(listener);
|
|
218
|
-
return () => {
|
|
219
|
-
listeners.delete(listener);
|
|
220
|
-
};
|
|
101
|
+
return () => { listeners.delete(listener); };
|
|
221
102
|
}
|
|
222
103
|
|
|
223
104
|
export function modelPlayClock(documentId: string): ModelPlayClock {
|
|
@@ -226,45 +107,7 @@ export function modelPlayClock(documentId: string): ModelPlayClock {
|
|
|
226
107
|
|
|
227
108
|
export function subscribeModelPlayClock(listener: () => void): () => void {
|
|
228
109
|
clockListeners.add(listener);
|
|
229
|
-
return () => {
|
|
230
|
-
clockListeners.delete(listener);
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Hold or release the game's updates. Only a playing document has updates to hold. */
|
|
235
|
-
export function setModelPlayPaused(documentId: string, paused: boolean): void {
|
|
236
|
-
if (!playing.has(documentId) || modelPlayClock(documentId).paused === paused) return;
|
|
237
|
-
if (!paused) steps.delete(documentId);
|
|
238
|
-
setClock(documentId, { paused });
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** One update while paused. Steps asked for faster than frames are drawn queue, one per frame,
|
|
242
|
-
* so each one is seen. */
|
|
243
|
-
export function stepModelPlay(documentId: string): void {
|
|
244
|
-
if (!playing.has(documentId) || !modelPlayClock(documentId).paused) return;
|
|
245
|
-
steps.set(documentId, (steps.get(documentId) ?? 0) + 1);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** The runner's half of {@link stepModelPlay}: whether this frame runs a step. */
|
|
249
|
-
export function takeModelPlayStep(documentId: string): boolean {
|
|
250
|
-
const pending = steps.get(documentId) ?? 0;
|
|
251
|
-
if (pending === 0) return false;
|
|
252
|
-
if (pending === 1) steps.delete(documentId);
|
|
253
|
-
else steps.set(documentId, pending - 1);
|
|
254
|
-
return true;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export function setModelPlaySpeed(documentId: string, speed: number): void {
|
|
258
|
-
if (!MODEL_PLAY_SPEEDS.includes(speed))
|
|
259
|
-
throw new Error(`Play speed ${speed} is not one the transport offers: ${MODEL_PLAY_SPEEDS.join(', ')}.`);
|
|
260
|
-
if (modelPlayClock(documentId).speed !== speed) setClock(documentId, { speed });
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** The runner's report of the updates it ran this frame. */
|
|
264
|
-
export function advanceModelPlayClock(documentId: string, seconds: number, ticks: number): void {
|
|
265
|
-
if (ticks === 0) return;
|
|
266
|
-
const clock = modelPlayClock(documentId);
|
|
267
|
-
setClock(documentId, { time: clock.time + seconds, tick: clock.tick + ticks });
|
|
110
|
+
return () => { clockListeners.delete(listener); };
|
|
268
111
|
}
|
|
269
112
|
|
|
270
113
|
export function modelPlayGeneration(documentId: string): number {
|
|
@@ -272,105 +115,35 @@ export function modelPlayGeneration(documentId: string): number {
|
|
|
272
115
|
}
|
|
273
116
|
|
|
274
117
|
/**
|
|
275
|
-
* BEGIN THE RUN AGAIN
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* down without ending the play. A document that is not playing simply starts.
|
|
118
|
+
* BEGIN THE RUN AGAIN. Playing stays true throughout (nothing returns to the model) and the generation moves, which is
|
|
119
|
+
* what makes the document detach a new copy and run Play's runner again: every player's page loaded afresh. A document
|
|
120
|
+
* that is not playing simply starts.
|
|
279
121
|
*/
|
|
280
122
|
export function restartModelPlay(documentId: string): void {
|
|
281
123
|
if (!playing.has(documentId)) { setModelPlaying(documentId, true); return; }
|
|
282
124
|
generations.set(documentId, modelPlayGeneration(documentId) + 1);
|
|
283
|
-
restarted.add(documentId);
|
|
284
|
-
steps.delete(documentId);
|
|
285
|
-
// An arm not yet taken (the game was still starting) carries to the fresh run.
|
|
286
|
-
resetAutoplay(documentId, true);
|
|
287
125
|
setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
|
|
288
126
|
publish();
|
|
289
127
|
}
|
|
290
128
|
|
|
291
|
-
/**
|
|
292
|
-
export function
|
|
293
|
-
return
|
|
129
|
+
/** Autoplay armed for the next start, and the goal it drives; null when it is not armed. */
|
|
130
|
+
export function modelPlayArm(documentId: string): { readonly goal: string | null } | null {
|
|
131
|
+
return arms.get(documentId) ?? null;
|
|
294
132
|
}
|
|
295
133
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
function setAutoplay(documentId: string, next: Partial<ModelPlayAutoplay>): void {
|
|
301
|
-
autoplays.set(documentId, { ...modelPlayAutoplay(documentId), ...next });
|
|
302
|
-
publishClock();
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** Switch the running script's bot on or off. On asks for a playing document whose script
|
|
306
|
-
* registered a bot, names the behaviour to drive (required when the bot offers several) and
|
|
307
|
-
* starts the run's limit; off always succeeds, and also drops an arm not yet taken — a person
|
|
308
|
-
* who takes over while the game is still starting is driving, and the arm must not override them. */
|
|
309
|
-
export function setModelPlayAutoplay(documentId: string, on: boolean, by: ModelPlayAutoplayBy, request: ModelPlayAutoplayRequest = {}): void {
|
|
310
|
-
const now = modelPlayAutoplay(documentId);
|
|
311
|
-
if (on && !playing.has(documentId))
|
|
312
|
-
throw new Error(`Autoplay is available once the game is running, and nothing is playing in ${documentId}; \`play\` starts it, then \`play autoplay on\`.`);
|
|
313
|
-
const clock = modelPlayClock(documentId);
|
|
314
|
-
if (on && !now.available)
|
|
315
|
-
throw new Error(clock.running
|
|
316
|
-
? 'No autoplay: this game doesn’t provide a bot — its play script registers none with `play.autoplay(controller)`.'
|
|
317
|
-
: `Autoplay is available once the game is running, and it is not running yet${clock.failure ? ` (${clock.failure})` : ''}.`);
|
|
318
|
-
if (on) {
|
|
319
|
-
const behavior = chosenBehavior(now.behaviors, request.behavior);
|
|
320
|
-
const limit = autoplayLimit(request.limit);
|
|
321
|
-
// Switching on again (another behaviour, a new limit) is a new run of the bot.
|
|
322
|
-
setAutoplay(documentId, { on, by, behavior, limit, since: clock.time, armed: false, state: null, refused: null });
|
|
323
|
-
} else if (now.on || now.armed) setAutoplay(documentId, { on, by, armed: false, since: null });
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button
|
|
327
|
-
* before Play. The behaviour and limit are checked when the start takes the arm, since the bot
|
|
328
|
-
* is offered only then. Disarming always succeeds. */
|
|
329
|
-
export function armModelPlayAutoplay(documentId: string, armed: boolean, request: ModelPlayAutoplayRequest = {}): void {
|
|
134
|
+
/** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button before Play. Disarming
|
|
135
|
+
* always succeeds. */
|
|
136
|
+
export function armModelPlayAutoplay(documentId: string, armed: boolean, goal: string | null = null): void {
|
|
330
137
|
if (armed && playing.has(documentId))
|
|
331
138
|
throw new Error(`${documentId} is already playing; switch autoplay on instead of arming it.`);
|
|
332
|
-
if (armed)
|
|
333
|
-
else if (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
/** The runner's report that a script has run its first update, offering a bot's behaviours or
|
|
337
|
-
* none. An arm is taken here: on with a bot that has the behaviour it asked for (or only one),
|
|
338
|
-
* dropped otherwise. */
|
|
339
|
-
export function settleModelPlayAutoplay(documentId: string, behaviors: readonly string[]): void {
|
|
340
|
-
const now = modelPlayAutoplay(documentId);
|
|
341
|
-
if (!playing.has(documentId)) return;
|
|
342
|
-
if (!now.armed) { setModelPlayAutoplayAvailable(documentId, behaviors); return; }
|
|
343
|
-
let behavior: string | null = null;
|
|
344
|
-
let refused: string | null = null;
|
|
345
|
-
if (!behaviors.length) refused = 'the play script registers no bot with play.autoplay';
|
|
346
|
-
else {
|
|
347
|
-
try { behavior = chosenBehavior(behaviors, now.behavior); }
|
|
348
|
-
catch (error) { refused = error instanceof Error ? error.message : String(error); }
|
|
349
|
-
}
|
|
350
|
-
setAutoplay(documentId, behavior !== null
|
|
351
|
-
? { available: true, behaviors, armed: false, on: true, by: 'panel', behavior, limit: now.limit ?? MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS, since: modelPlayClock(documentId).time, state: null, refused: null }
|
|
352
|
-
: { available: behaviors.length > 0, behaviors, armed: false, behavior: null, limit: null, by: 'script', refused });
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** The runner's report of the behaviours the running script's bot offers (none: no bot). Losing
|
|
356
|
-
* the bot, or the behaviour driving, turns autoplay off. */
|
|
357
|
-
export function setModelPlayAutoplayAvailable(documentId: string, behaviors: readonly string[]): void {
|
|
358
|
-
const now = modelPlayAutoplay(documentId);
|
|
359
|
-
if (!playing.has(documentId)) return;
|
|
360
|
-
const available = behaviors.length > 0;
|
|
361
|
-
const same = now.available === available && now.behaviors.length === behaviors.length && now.behaviors.every((b, i) => b === behaviors[i]);
|
|
362
|
-
if (same) return;
|
|
363
|
-
const lost = now.on && (now.behavior === null || !behaviors.includes(now.behavior));
|
|
364
|
-
setAutoplay(documentId, lost ? { available, behaviors, on: false, by: 'script', since: null } : { available, behaviors });
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** The runner's report of what the driving bot says it is doing; announced only when it changes. */
|
|
368
|
-
export function setModelPlayBotState(documentId: string, state: string | null): void {
|
|
369
|
-
if (playing.has(documentId) && modelPlayAutoplay(documentId).state !== state) setAutoplay(documentId, { state });
|
|
139
|
+
if (armed) arms.set(documentId, { goal });
|
|
140
|
+
else if (!arms.delete(documentId)) return;
|
|
141
|
+
publishModelPlayClock();
|
|
370
142
|
}
|
|
371
143
|
|
|
372
|
-
/** The
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (
|
|
144
|
+
/** The arm taken by the start it was made for. */
|
|
145
|
+
export function takeModelPlayArm(documentId: string): { readonly goal: string | null } | null {
|
|
146
|
+
const arm = arms.get(documentId) ?? null;
|
|
147
|
+
if (arm !== null) { arms.delete(documentId); publishModelPlayClock(); }
|
|
148
|
+
return arm;
|
|
376
149
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GAME PANEL'S CLOCK, READ AT TWO RATES (`../contributions/game-panel.tsx`).
|
|
3
|
+
*
|
|
4
|
+
* The Play tool announces its clock every frame, and one changed text node in the workbench's
|
|
5
|
+
* document repaints and re-layerizes the page around it (about 4 ms a frame measured), so the
|
|
6
|
+
* panel does not re-render on every announcement.
|
|
7
|
+
*
|
|
8
|
+
* So the panel reads the clock's STATE (paused, speed, failure, running), which changes when a
|
|
9
|
+
* person or the game changes it and re-renders at once, and its TEXT (time and tick) at most
|
|
10
|
+
* {@link CLOCK_TEXT_INTERVAL_MS} apart. The text's last change always lands (a trailing update),
|
|
11
|
+
* so a paused game's step or a stop reads exactly.
|
|
12
|
+
*/
|
|
13
|
+
import type { DocumentPlayClock } from '@volter/editor-blender/api';
|
|
14
|
+
|
|
15
|
+
/** The clock text's update period: ten times a second, faster than its hundredths can be read. */
|
|
16
|
+
export const CLOCK_TEXT_INTERVAL_MS = 100;
|
|
17
|
+
|
|
18
|
+
/** `subscribe`, with the listener called at most once per `interval` ms, always after the last
|
|
19
|
+
* announcement (trailing). For `useSyncExternalStore`. */
|
|
20
|
+
export function throttledSubscription(
|
|
21
|
+
subscribe: (listener: () => void) => () => void,
|
|
22
|
+
interval: number,
|
|
23
|
+
timers: { set: (run: () => void, ms: number) => unknown; clear: (handle: unknown) => void; now: () => number } = {
|
|
24
|
+
set: (run, ms) => setTimeout(run, ms),
|
|
25
|
+
clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
26
|
+
now: () => performance.now(),
|
|
27
|
+
},
|
|
28
|
+
): (listener: () => void) => () => void {
|
|
29
|
+
return (listener) => {
|
|
30
|
+
let pending: unknown = null;
|
|
31
|
+
let last = -Infinity;
|
|
32
|
+
const unsubscribe = subscribe(() => {
|
|
33
|
+
if (pending !== null) return;
|
|
34
|
+
pending = timers.set(() => {
|
|
35
|
+
pending = null;
|
|
36
|
+
last = timers.now();
|
|
37
|
+
listener();
|
|
38
|
+
}, Math.max(0, last + interval - timers.now()));
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
if (pending !== null) timers.clear(pending);
|
|
42
|
+
pending = null;
|
|
43
|
+
unsubscribe();
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The clock's state without its time: what the panel's controls and status line read. */
|
|
49
|
+
export type ClockState = Pick<DocumentPlayClock, 'paused' | 'speed' | 'failure' | 'running'>;
|
|
50
|
+
|
|
51
|
+
/** A snapshot reader for {@link ClockState} that answers the SAME object until a field changes, so
|
|
52
|
+
* `useSyncExternalStore` re-renders only on a change of state, not on every tick. */
|
|
53
|
+
export function clockStateReader(read: () => DocumentPlayClock): () => ClockState {
|
|
54
|
+
let held: ClockState | null = null;
|
|
55
|
+
return () => {
|
|
56
|
+
const clock = read();
|
|
57
|
+
if (held === null || held.paused !== clock.paused || held.speed !== clock.speed ||
|
|
58
|
+
(held.failure ?? null) !== (clock.failure ?? null) || held.running !== clock.running)
|
|
59
|
+
held = { paused: clock.paused, speed: clock.speed, failure: clock.failure ?? null, running: clock.running };
|
|
60
|
+
return held;
|
|
61
|
+
};
|
|
62
|
+
}
|