@volter/play 0.5.211 → 0.5.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,477 @@
1
+ /**
2
+ * A STD GAME'S LEVEL AS ITS DOCUMENT (`docs/adr/std-scene-view.md`): `levels/<Name>Level.tsx`, a document of the
3
+ * project's table (`levels.finder.ts`) whose source is its code or the `.blend` std's export writes it from, drawn as
4
+ * the game draws it, by its own component and the game's own React
5
+ * Three Fiber, react-three-rapier and std, in editor-threejs's viewport (`ThreePageApi`): orbit, pick, frame, the
6
+ * Hierarchy.
7
+ * The level's module is `/@volter-std-scene/<level>` (`../serving/std-scene.ts`); its fiber root draws on
8
+ * the stage's own renderer and camera, the stage drawing the picture, the level's `useFrame`s run on the stage's frame,
9
+ * and its environment is the document's World (editor-threejs's `documentWorld`). The level follows the stage the
10
+ * document has now: an attachment the stage makes again (after a lost context) is bound anew.
11
+ *
12
+ * - The level's `<Canvas>` settings are the view's presentation: Rendered shading lit by the scene alone, its backdrop
13
+ * the scene's own (the level's sky), its tone mapping the canvas's; its shadows are the renderer's.
14
+ * - Its view transform (std's shaper and baked LUT) is the stage's display transform; a grading with effects beyond it
15
+ * is named in the banner, as the view does not draw them.
16
+ * - The overlays: colliders and sensors as react-three-rapier's debug lines of the paused world, and each std marker (an
17
+ * object whose `userData.behaviours` names its behaviours: a spawn point, a seat, a prompt) as a marker and its name,
18
+ * on the stage's helper layer (`std-markers`). The toolbar's Overlays shows or hides them.
19
+ * - The level's source is its record, a Blender level's `.blend` or a level's code: the view edits nothing. A Blender
20
+ * level's save exports it again (`../src/std-level.ts`), a code level's file is its own, and the view draws it again
21
+ * as anything its module depends on changes (a model a code level places among them), the camera kept.
22
+ */
23
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
24
+ import * as THREE from 'three';
25
+ import type { DocumentDisplayTransform, ViewportStage } from '@volter/editor-threejs/api';
26
+ import type { ToolContributionProps } from '@volter/sdk/contributions';
27
+ import { projectModuleChangeMatches, subscribeProjectModuleChange } from '@volter/sdk/project-module-changes';
28
+ import { beginLiveModuleRevision } from '@volter/sdk/project-module-url';
29
+ import type { PresentationLayer } from '@volter/sdk/viewport-presentation';
30
+ import { editorThreejs } from '../src/editor-threejs';
31
+ import { askPlay } from '../src/std-controls';
32
+ import { stdRun, stdRunVersion, subscribeStdRun } from '../src/std-game';
33
+
34
+ export const point = 'workspace.document';
35
+ export const title = 'Level';
36
+ /** The editor of the table's `std-level` documents: a level in code (`levels.finder.ts`). */
37
+ export const documentKind = 'std-level';
38
+
39
+ /** The level's module, as `../serving/std-scene.ts` writes it. */
40
+ interface StdSceneModule {
41
+ readonly component: string;
42
+ readonly canvas: { readonly gl?: { readonly toneMapping?: THREE.ToneMapping; readonly toneMappingExposure?: number } };
43
+ readonly gradingExtra: boolean;
44
+ mountScene(args: {
45
+ readonly renderer: THREE.WebGLRenderer;
46
+ readonly world: THREE.Scene;
47
+ readonly camera: THREE.Camera;
48
+ readonly root: THREE.Group;
49
+ readonly onError: (error: unknown) => void;
50
+ }): Promise<MountedScene>;
51
+ viewTransform(): Promise<(DocumentDisplayTransform & { dispose(): void }) | null>;
52
+ }
53
+
54
+ interface MountedScene {
55
+ draw(overlays: boolean): void;
56
+ advance(seconds: number): void;
57
+ /** A change of the game's running scene, put on this copy of the level at its next frame ({@link useRunningScene}). */
58
+ running(read: SceneRead): void;
59
+ unmount(): void;
60
+ }
61
+
62
+ /** The viewport's tone mappers, by three's constant: fiber's own default, a canvas stating none, is ACES. */
63
+ const TONE_MAPPERS = new Map<THREE.ToneMapping, 'none' | 'aces' | 'agx'>([
64
+ [THREE.NoToneMapping, 'none'],
65
+ [THREE.ACESFilmicToneMapping, 'aces'],
66
+ [THREE.AgXToneMapping, 'agx'],
67
+ ]);
68
+
69
+ const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
70
+
71
+ /** The presentation that draws a level as its canvas does; a string where the canvas asks what the viewport lacks. */
72
+ function canvasPresentation(canvas: StdSceneModule['canvas']): PresentationLayer | string {
73
+ const toneMapping = canvas.gl?.toneMapping ?? THREE.ACESFilmicToneMapping;
74
+ const mapper = TONE_MAPPERS.get(toneMapping);
75
+ if (mapper === undefined) return `The level's canvas tone-maps with three's mapper ${toneMapping}, which the viewport does not draw (it draws none, ACES and AgX).`;
76
+ return {
77
+ drawMode: 'rendered',
78
+ all: {
79
+ lighting: { source: 'scene', auto: null, tone: { mapper, exposure: canvas.gl?.toneMappingExposure ?? 1 } },
80
+ backdrop: { source: 'scene' },
81
+ },
82
+ };
83
+ }
84
+
85
+ const MARKER_COLOURS: Readonly<Record<string, number>> = { spawn: 0x4ade80, seat: 0x60a5fa, prompt: 0xfacc15 };
86
+
87
+ /** A marker's sign on the helper layer: a diamond at the object's origin and its name and behaviours above it. */
88
+ function markerSign(object: THREE.Object3D, behaviours: readonly string[]): THREE.Object3D {
89
+ const colour = MARKER_COLOURS[behaviours[0] ?? ''] ?? 0xf472b6;
90
+ const sign = new THREE.Group();
91
+ const diamond = new THREE.LineSegments(
92
+ new THREE.EdgesGeometry(new THREE.OctahedronGeometry(0.3)),
93
+ new THREE.LineBasicMaterial({ color: colour, depthTest: false, transparent: true }),
94
+ );
95
+ diamond.renderOrder = 999;
96
+ const text = `${object.name || '(unnamed)'} · ${behaviours.join(', ')}`;
97
+ const canvas = document.createElement('canvas');
98
+ const context = canvas.getContext('2d')!;
99
+ context.font = '28px Inter, system-ui, sans-serif';
100
+ canvas.width = Math.ceil(context.measureText(text).width) + 24;
101
+ canvas.height = 44;
102
+ context.font = '28px Inter, system-ui, sans-serif';
103
+ context.fillStyle = 'rgba(0, 0, 0, 0.6)';
104
+ context.fillRect(0, 0, canvas.width, canvas.height);
105
+ context.fillStyle = `#${colour.toString(16).padStart(6, '0')}`;
106
+ context.fillText(text, 12, 31);
107
+ const texture = new THREE.CanvasTexture(canvas);
108
+ texture.colorSpace = THREE.SRGBColorSpace;
109
+ const label = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false, transparent: true }));
110
+ label.scale.set((0.4 * canvas.width) / canvas.height, 0.4, 1);
111
+ label.position.y = 0.6;
112
+ label.renderOrder = 999;
113
+ sign.add(diamond, label);
114
+ return sign;
115
+ }
116
+
117
+ function disposeSigns(signs: THREE.Object3D): void {
118
+ signs.traverse((object) => {
119
+ if (object instanceof THREE.LineSegments) { object.geometry.dispose(); (object.material as THREE.Material).dispose(); }
120
+ if (object instanceof THREE.Sprite) { object.material.map?.dispose(); object.material.dispose(); }
121
+ });
122
+ }
123
+
124
+ /** The std markers under `root`: each object whose `userData.behaviours` names any. */
125
+ function markersOf(root: THREE.Object3D): { readonly object: THREE.Object3D; readonly behaviours: readonly string[] }[] {
126
+ const found: { object: THREE.Object3D; behaviours: readonly string[] }[] = [];
127
+ root.traverse((object) => {
128
+ const behaviours = (object.userData as { behaviours?: unknown }).behaviours;
129
+ if (behaviours !== null && typeof behaviours === 'object' && Object.keys(behaviours).length > 0) found.push({ object, behaviours: Object.keys(behaviours) });
130
+ });
131
+ return found;
132
+ }
133
+
134
+ /** The marker signs of `root`, kept on the stage's helper layer at each marker's place, made again as the markers change. */
135
+ function followMarkers(stage: ViewportStage, root: THREE.Object3D, shown: () => boolean): () => void {
136
+ let signature = '';
137
+ let signs: THREE.Group | null = null;
138
+ let objects: THREE.Object3D[] = [];
139
+ const stop = stage.onFrame(() => {
140
+ if (!shown()) {
141
+ if (signs) { stage.setHelper('std-markers', null); disposeSigns(signs); signs = null; signature = ''; }
142
+ return;
143
+ }
144
+ const markers = markersOf(root);
145
+ const next = markers.map(({ object, behaviours }) => `${object.uuid}:${behaviours.join(',')}`).join('|');
146
+ if (next !== signature) {
147
+ signature = next;
148
+ if (signs) disposeSigns(signs);
149
+ signs = new THREE.Group();
150
+ signs.name = 'std markers';
151
+ for (const { object, behaviours } of markers) signs.add(markerSign(object, behaviours));
152
+ objects = markers.map(({ object }) => object);
153
+ stage.setHelper('std-markers', signs);
154
+ }
155
+ objects.forEach((object, index) => signs!.children[index]!.position.setFromMatrixPosition(object.matrixWorld));
156
+ });
157
+ return () => {
158
+ stop();
159
+ stage.setHelper('std-markers', null);
160
+ if (signs) disposeSigns(signs);
161
+ };
162
+ }
163
+
164
+ /** What a binding of the level to one stage reads and reports. */
165
+ interface StageBinding {
166
+ readonly documentId: string;
167
+ readonly module: StdSceneModule;
168
+ readonly transform: DocumentDisplayTransform | null;
169
+ readonly root: THREE.Group;
170
+ readonly overlays: () => boolean;
171
+ readonly mounted: (scene: MountedScene, live: boolean) => void;
172
+ readonly framed: () => void;
173
+ readonly fail: (error: unknown) => void;
174
+ }
175
+
176
+ /**
177
+ * Each canvas's fiber lifetimes, one after another. Fiber keeps one root per canvas, and a root made on a canvas whose
178
+ * root is closing takes that root over; so a binding makes its root only once the binding before it on that canvas has
179
+ * ended its own, and the root a binding made is the one it alone ends.
180
+ */
181
+ const canvasTurns = new WeakMap<HTMLCanvasElement, Promise<void>>();
182
+ function onCanvas(canvas: HTMLCanvasElement, turn: () => Promise<void>): void {
183
+ const next = (canvasTurns.get(canvas) ?? Promise.resolve()).then(turn);
184
+ canvasTurns.set(canvas, next.catch(() => {}));
185
+ }
186
+
187
+ /**
188
+ * The level drawn on one stage attachment, as one generation: a fiber root on the attachment's renderer, its frames on
189
+ * the stage's frame, its markers and its view transform. The answer ends the generation; each of its async steps asks
190
+ * whether it is still current before it draws or installs anything, and the root it made it ends itself, after its
191
+ * last step. An attachment the stage makes again (a lost context) has another renderer, and is another generation.
192
+ */
193
+ function bindStage(stage: ViewportStage, binding: StageBinding): () => void {
194
+ const { documentId, module, transform, root } = binding;
195
+ const three = editorThreejs();
196
+ const world = three.documentWorld(root);
197
+ if (world === null) {
198
+ binding.fail(new Error("The level's root stands in no document scene of its stage."));
199
+ return () => {};
200
+ }
201
+ const rig = stage.rig();
202
+ let current = true;
203
+ let transformed = false;
204
+ let ended!: () => void;
205
+ const end = new Promise<void>((resolve) => { ended = resolve; });
206
+ const hooks: (() => void)[] = [];
207
+ onCanvas(rig.renderer.domElement, async () => {
208
+ if (!current) return;
209
+ let scene: MountedScene;
210
+ try {
211
+ scene = await module.mountScene({ renderer: rig.renderer, world, camera: rig.camera, root, onError: binding.fail });
212
+ } catch (error) {
213
+ binding.fail(error);
214
+ return;
215
+ }
216
+ // (from here this generation holds the canvas's root, and ends it below, whatever comes)
217
+ if (current) {
218
+ scene.draw(binding.overlays());
219
+ binding.mounted(scene, true);
220
+ hooks.push(stage.onFrame(() => {
221
+ scene.advance(performance.now() / 1000);
222
+ // (the stage's session stands after its rig: the transform and the framing wait for it)
223
+ if (transform !== null && !transformed) transformed = three.setDisplayTransform(documentId, transform);
224
+ binding.framed();
225
+ }));
226
+ hooks.push(followMarkers(stage, root, binding.overlays));
227
+ }
228
+ await end;
229
+ binding.mounted(scene, false);
230
+ scene.unmount();
231
+ });
232
+ return () => {
233
+ current = false;
234
+ for (const hook of hooks.splice(0).reverse()) hook();
235
+ if (transformed) three.setDisplayTransform(documentId, null);
236
+ ended();
237
+ };
238
+ }
239
+
240
+ export default function StdLevelDocument({ documentId: given, document, active }: ToolContributionProps) {
241
+ const level = document?.source?.path ?? null;
242
+ const documentId = given ?? `level:${level ?? ''}`;
243
+ // (the level in front last is the current level, which the Game view's Scene shows live)
244
+ useEffect(() => (active !== false && level !== null ? noteCurrentLevel(level, document?.label ?? level) : undefined), [active, level, document?.label]);
245
+ return <StdLevelView documentId={documentId} level={level} label={document?.label ?? level} live={false} />;
246
+ }
247
+
248
+ /** THE CURRENT LEVEL: the level document in front last, which the Game view's Scene draws live; null before any. */
249
+ let currentLevel: { readonly path: string; readonly label: string } | null = null;
250
+ const currentListeners = new Set<() => void>();
251
+ function noteCurrentLevel(path: string, label: string): undefined {
252
+ if (currentLevel?.path !== path || currentLevel.label !== label) {
253
+ currentLevel = { path, label };
254
+ for (const listener of [...currentListeners]) listener();
255
+ }
256
+ return undefined;
257
+ }
258
+ export const currentStdLevel = (): { readonly path: string; readonly label: string } | null => currentLevel;
259
+ export function subscribeCurrentStdLevel(listener: () => void): () => void {
260
+ currentListeners.add(listener);
261
+ return () => { currentListeners.delete(listener); };
262
+ }
263
+
264
+ /**
265
+ * A LEVEL'S VIEW: the level drawn as the game draws it, as authored (its document), or LIVE (the Game view's Scene,
266
+ * `../src/game-view.tsx`), the game's running scene put on this view's own copy of the level ({@link useRunningScene}).
267
+ * The same view, code and overlays either way.
268
+ */
269
+ export function StdLevelView({ documentId, level, label, live }: {
270
+ readonly documentId: string;
271
+ readonly level: string | null;
272
+ readonly label: string | null;
273
+ /** Live, it reads the running scene while the game runs, drawn or not: its copy of the level is the Hierarchy's. */
274
+ readonly live: boolean;
275
+ }) {
276
+ const { Object3DDocumentViewport, frameAll } = editorThreejs();
277
+ const root = useMemo(() => {
278
+ const group = new THREE.Group();
279
+ group.name = 'Level';
280
+ return group;
281
+ }, []);
282
+ const build = useCallback(() => ({ root, dispose() {} }), [root]);
283
+ const [revision, setRevision] = useState(() => beginLiveModuleRevision());
284
+ const [overlays, setOverlays] = useState(true);
285
+ const overlaysRef = useRef(overlays);
286
+ overlaysRef.current = overlays;
287
+ const [drawn, setDrawn] = useState<{ readonly presentation: PresentationLayer; readonly component: string } | null>(null);
288
+ const [notes, setNotes] = useState<{ readonly error: string | null; readonly gradingExtra: boolean }>({ error: null, gradingExtra: false });
289
+ const mounted = useRef<MountedScene | null>(null);
290
+ /** The level the camera was framed on, once its content first stood. */
291
+ const framed = useRef<string | null>(null);
292
+
293
+ // (the level drawn again as anything its module depends on is written, made or removed: play's serving half reads
294
+ // that from the session's module graph and names the levels a change reached, `../serving/std-scene.ts`)
295
+ useEffect(() => {
296
+ if (level === null) return;
297
+ return subscribeProjectModuleChange((_changed, affected) => {
298
+ if (affected?.some((path) => projectModuleChangeMatches(path, level))) setRevision(beginLiveModuleRevision());
299
+ });
300
+ }, [level]);
301
+
302
+ useEffect(() => {
303
+ if (level === null) return;
304
+ const three = editorThreejs();
305
+ let disposed = false;
306
+ let transform: (DocumentDisplayTransform & { dispose(): void }) | null = null;
307
+ let stopStages: (() => void) | null = null;
308
+ let bound: THREE.WebGLRenderer | null = null;
309
+ let unbind: (() => void) | null = null;
310
+ const fail = (error: unknown): void => { if (!disposed) setNotes((now) => ({ ...now, error: `${level}: ${message(error)}` })); };
311
+ void (async () => {
312
+ // (`import` is Vite's mark for a module import: without it a path ending `.blend` is not one Vite transforms, and
313
+ // the request answers 404)
314
+ const module = (await import(/* @vite-ignore */ `/@volter-std-scene/${level}?import&v=${revision}`)) as StdSceneModule;
315
+ if (disposed) return;
316
+ const look = canvasPresentation(module.canvas);
317
+ if (typeof look === 'string') { fail(new Error(look)); return; }
318
+ setDrawn((now) => (now?.component === module.component && JSON.stringify(now.presentation) === JSON.stringify(look) ? now : { presentation: look, component: module.component }));
319
+ setNotes({ error: null, gradingExtra: module.gradingExtra });
320
+ const made = await module.viewTransform();
321
+ if (disposed) { made?.dispose(); return; }
322
+ transform = made;
323
+ const binding: StageBinding = {
324
+ documentId, module, transform: made, root, fail,
325
+ overlays: () => overlaysRef.current,
326
+ mounted: (scene, live) => {
327
+ if (live) mounted.current = scene;
328
+ else if (mounted.current === scene) mounted.current = null;
329
+ },
330
+ // (framed once a level, as its content first stands: a draw again keeps the camera)
331
+ framed: () => {
332
+ if (framed.current !== level && !new THREE.Box3().setFromObject(root).isEmpty() && frameAll(documentId)) framed.current = level;
333
+ },
334
+ };
335
+ // (the level follows the stage attachment the document has now, known by its renderer: the stage doors are made
336
+ // again as any stage binds or goes, its attachment made again after a lost context has another renderer)
337
+ const follow = (): void => {
338
+ const stage = three.stage(documentId);
339
+ const renderer = stage?.rig().renderer ?? null;
340
+ if (renderer === bound) return;
341
+ unbind?.();
342
+ unbind = null;
343
+ bound = renderer;
344
+ if (stage) unbind = bindStage(stage, binding);
345
+ };
346
+ stopStages = three.onStages(follow);
347
+ follow();
348
+ })().catch(fail);
349
+ return () => {
350
+ disposed = true;
351
+ stopStages?.();
352
+ unbind?.();
353
+ transform?.dispose();
354
+ };
355
+ }, [documentId, frameAll, level, revision, root]);
356
+
357
+ useEffect(() => { mounted.current?.draw(overlays); }, [overlays]);
358
+
359
+ useRunningScene(live, mounted, useCallback(() => setRevision(beginLiveModuleRevision()), []));
360
+
361
+ if (level === null) {
362
+ return <div data-testid="std-level-empty" style={{ padding: 16, font: '12px Inter, system-ui, sans-serif' }}>
363
+ This document names no level: a level in code is levels/&lt;Name&gt;Level.tsx.
364
+ </div>;
365
+ }
366
+ const banner = notes.error ?? (notes.gradingExtra ? "The level's grading holds effects beyond its view transform, which this view does not draw: the game shows them." : null);
367
+ return (
368
+ // (filled from the host box, as the editor's other documents are: in an editor group's auto-height content box
369
+ // `height: 100%` resolves to the content's height, which left the stage a strip)
370
+ <div data-testid="std-level" style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
371
+ <div role="toolbar" aria-label="Level" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '2px 8px', font: '11px Inter, system-ui, sans-serif' }}>
372
+ <span data-testid="std-level-path">{level}</span>
373
+ <label style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
374
+ <input type="checkbox" data-testid="std-level-overlays" checked={overlays} onChange={(event) => setOverlays(event.currentTarget.checked)} />
375
+ Overlays (colliders, sensors, markers)
376
+ </label>
377
+ </div>
378
+ {banner !== null && <div role={notes.error ? 'alert' : 'status'} data-testid="std-level-banner" style={{ padding: '4px 8px', font: '11px Inter, system-ui, sans-serif', background: notes.error ? 'rgba(220, 38, 38, 0.25)' : 'rgba(250, 204, 21, 0.2)' }}>{banner}</div>}
379
+ <div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
380
+ {drawn !== null && (
381
+ <Object3DDocumentViewport documentId={documentId} sourcePath={drawn.component} displayName={label ?? level} build={build}
382
+ stageKind={documentKind} presentation={drawn.presentation} />
383
+ )}
384
+ </div>
385
+ </div>
386
+ );
387
+ }
388
+
389
+ /** One object of the game's running scene as std reads it (room/shared's scene-reading.ts). */
390
+ interface SceneObject {
391
+ readonly path: string;
392
+ readonly type: string;
393
+ readonly x: number; readonly y: number; readonly z: number;
394
+ readonly qx: number; readonly qy: number; readonly qz: number; readonly qw: number;
395
+ readonly sx: number; readonly sy: number; readonly sz: number;
396
+ readonly visible: boolean;
397
+ }
398
+
399
+ /** What changed in the game's running scene since an earlier reading (`GET /__std/scene`), and where it runs: `room:<id>`
400
+ * for the world the server runs, else the device whose page renders it. */
401
+ interface SceneRead {
402
+ readonly of: string;
403
+ readonly scene: string;
404
+ readonly at: number;
405
+ readonly whole: boolean;
406
+ readonly objects: readonly SceneObject[];
407
+ readonly gone: readonly string[];
408
+ }
409
+
410
+ /** A reading of the running scene, or none (`of: null`) where it runs nowhere yet. */
411
+ type RunningScene = SceneRead | { readonly of: null };
412
+
413
+ /** How often the running scene is read again while the game runs (ms): one reading at a time. */
414
+ const SCENE_ASK_MS = 50;
415
+
416
+ /**
417
+ * A LIVE LEVEL VIEW IS THE PLAYING WORLD while the game runs (the Game view's Scene), as an engine's scene view is
418
+ * during Play: the game's running scene, read where it runs (std-dev's `/__std/scene`, through Play's serving half),
419
+ * each change put on the view's own copy of the level by the runtime (`../serving/std-scene-runtime.js`'s
420
+ * `SceneNodes`), so its picking and overlays are the level view's own. The scene read is the world's authority: the one
421
+ * the game's server runs, where it runs one, else the one the viewed player's page renders (the first player's while the
422
+ * view is Scene or Grid), a game of one player's among them. Each reading names the one held, so a new scene, or another
423
+ * place it runs, answers whole, and so does the first reading of each copy of the level (a run's start, an edit's
424
+ * remount), which holds none. When the game stops, the level is drawn again as authored.
425
+ */
426
+ function useRunningScene(live: boolean, mounted: { readonly current: MountedScene | null }, again: () => void): void {
427
+ useSyncExternalStore(subscribeStdRun, stdRunVersion, stdRunVersion);
428
+ const running = live && (stdRun()?.url ?? null) !== null;
429
+ const againRef = useRef(again);
430
+ againRef.current = again;
431
+ // (the run over, the level drawn again as authored)
432
+ useEffect(() => {
433
+ if (!running) return undefined;
434
+ return () => { againRef.current(); };
435
+ }, [running]);
436
+ useEffect(() => {
437
+ if (!running) return;
438
+ let ended = false;
439
+ let timer: ReturnType<typeof setTimeout> | undefined;
440
+ /** The reading held: the scene read last, and its `at`. */
441
+ let last: { readonly scene: string; readonly at: number } = { scene: '', at: 0 };
442
+ let fed: MountedScene | null = null;
443
+ // (the viewed player's page, or the first player's: where a game the server runs no world for plays)
444
+ const viewedDevice = (): string | null => {
445
+ const run = stdRun();
446
+ const player = run === null ? undefined : typeof run.view === 'number' ? run.players[run.view] : run.players[0];
447
+ return player?.device ?? null;
448
+ };
449
+ // (the reading held, named to whichever place is read: a place that runs another scene answers it whole)
450
+ const held = (): string => `scene=${encodeURIComponent(last.scene)}&since=${last.at}&`;
451
+ const readScene = async (): Promise<RunningScene> => {
452
+ const server = await askPlay<RunningScene>(`/scene?${held()}`);
453
+ if (server.of !== null) return server;
454
+ const device = viewedDevice();
455
+ if (device === null) return server;
456
+ return askPlay<RunningScene>(`/scene?${held()}of=${encodeURIComponent(device)}`);
457
+ };
458
+ void (async () => {
459
+ while (!ended) {
460
+ const copy = mounted.current;
461
+ // (a new copy holds no scene, which std answers whole: what the game removed before it is hidden on it too)
462
+ if (copy !== fed) { fed = copy; last = { scene: '', at: 0 }; }
463
+ const read = copy === null ? null : await readScene().catch(() => null);
464
+ if (ended) return;
465
+ if (read !== null && read.of !== null && copy !== null && copy === mounted.current) {
466
+ last = { scene: read.scene, at: read.at };
467
+ copy.running(read);
468
+ }
469
+ await new Promise<void>((done) => { timer = setTimeout(done, SCENE_ASK_MS); });
470
+ }
471
+ })();
472
+ return () => {
473
+ ended = true;
474
+ clearTimeout(timer);
475
+ };
476
+ }, [mounted, running]);
477
+ }