@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
package/src/editor-blender.ts
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* EDITOR-BLENDER'S API, AS PLAY'S PAGE HALF KEPT IT (`../contributions/play.page.tsx`)
|
|
3
|
-
*
|
|
4
|
-
* editor-blender's modules: its API is the one instance editor-blender's own `activate` returned.
|
|
2
|
+
* EDITOR-BLENDER'S API, AS PLAY'S PAGE HALF KEPT IT (`../contributions/play.page.tsx`). play's modules read it here
|
|
3
|
+
* rather than importing editor-blender's modules: its API is the one instance editor-blender's own `activate` returned.
|
|
5
4
|
*/
|
|
6
|
-
import type { BlenderPageApi
|
|
5
|
+
import type { BlenderPageApi } from '@volter/editor-blender/api';
|
|
7
6
|
|
|
8
7
|
let api: BlenderPageApi | null = null;
|
|
9
|
-
let modelPlay: DocumentPlayExtension | null = null;
|
|
10
|
-
const listeners = new Set<() => void>();
|
|
11
8
|
|
|
12
9
|
/** Kept by play's `activate`, and released (`null`) when it deactivates. */
|
|
13
|
-
export function keepEditorBlender(next: BlenderPageApi | null
|
|
10
|
+
export function keepEditorBlender(next: BlenderPageApi | null): void {
|
|
14
11
|
api = next;
|
|
15
|
-
modelPlay = registered;
|
|
16
|
-
for (const listener of listeners) listener();
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
/** editor-blender's API. play's modules run once its page half has activated, which is after editor-blender's. */
|
|
@@ -21,13 +16,3 @@ export function editorBlender(): BlenderPageApi {
|
|
|
21
16
|
if (!api) throw new Error("@volter/play is not active, so editor-blender's API is not at hand.");
|
|
22
17
|
return api;
|
|
23
18
|
}
|
|
24
|
-
|
|
25
|
-
/** The model Play play registered, or `null` while it is not. */
|
|
26
|
-
export function registeredModelPlay(): DocumentPlayExtension | null {
|
|
27
|
-
return modelPlay;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function subscribeRegisteredModelPlay(listener: () => void): () => void {
|
|
31
|
-
listeners.add(listener);
|
|
32
|
-
return () => { listeners.delete(listener); };
|
|
33
|
-
}
|
package/src/editor-threejs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EDITOR-THREEJS'S API, AS PLAY'S PAGE HALF KEPT IT (`../contributions/play.page.tsx`): the shared viewport and its
|
|
3
|
-
* stages, which a std level
|
|
3
|
+
* stages, which a std level in code is drawn in (`../contributions/std-level.document.tsx`). play reads it here rather than importing
|
|
4
4
|
* editor-threejs's modules: its API is the one instance editor-threejs's own `activate` returned.
|
|
5
5
|
*/
|
|
6
6
|
import type { ThreePageApi } from '@volter/editor-threejs/api';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GAME'S CONTROLS, which the Game panel draws (`../contributions/game-panel.tsx`) and its verbs run behind command
|
|
3
|
+
* ids (`volter.play.<verb>`): Play and Stop, pause, step, speed, Restart and Autoplay, each a call on the run
|
|
4
|
+
* (`game-run.ts`) or std's own control said to the game's pages (`std-game.ts`, `std-controls.ts`).
|
|
5
|
+
*
|
|
6
|
+
* PLAY SAVES THE EDIT FIRST: Play (and Restart) save the `.blend` Blender holds where it changed (a level's or a
|
|
7
|
+
* prefab's, whichever was edited last), which runs std's level export (`std-level.ts`), and the run's pages load once
|
|
8
|
+
* that export has run, so the game plays the current edit (`savedEdit`). A save that fails is said in the play log, and
|
|
9
|
+
* the game plays the level as last exported.
|
|
10
|
+
*
|
|
11
|
+
* AUTOPLAY IS THE EDITOR'S SWITCH, and the person always wins: std's own play of one of the game's goals, recorded and
|
|
12
|
+
* checked, on the player in view while the Game view is on screen and out of view otherwise (`std-game.ts`'s
|
|
13
|
+
* `setStdAutoplay`); a person's own press in the player's page takes it back, which ends the play. Stopped, it is
|
|
14
|
+
* armed for the next start.
|
|
15
|
+
*/
|
|
16
|
+
import { armGameAutoplay, gameArm, gamePlaying, restartGame, setGamePlaying, subscribeGameClock } from './game-run';
|
|
17
|
+
import { setStdAutoplay, setStdPaused, setStdSpeed, stdAutoplay, stdRun, stepStd, subscribeStdRun } from './std-game';
|
|
18
|
+
|
|
19
|
+
/** Autoplay as the panel reads it: on or off, whether it can be switched on and why not, armed
|
|
20
|
+
* while stopped, the goals the game's bot offers and the one it plays, and how std's own play of a goal stands
|
|
21
|
+
* (`<goal>: playing`, `achieved`, or `failed — why`, `headless ` before it where it plays out of view). */
|
|
22
|
+
export interface GameAutoplay {
|
|
23
|
+
/** Whether std's play of a goal plays now, wherever it plays (Off ends it). */
|
|
24
|
+
readonly on: boolean;
|
|
25
|
+
/** Whether that play drives the player in view, on its page. */
|
|
26
|
+
readonly driving?: boolean;
|
|
27
|
+
readonly available: boolean;
|
|
28
|
+
readonly unavailable?: string | null;
|
|
29
|
+
readonly armed?: boolean;
|
|
30
|
+
readonly behaviors?: readonly string[];
|
|
31
|
+
readonly behavior?: string | null;
|
|
32
|
+
readonly state?: string | null;
|
|
33
|
+
/** std's play's report (`build/runs/<run>/report-<n>.json`), once std has written it. */
|
|
34
|
+
readonly report?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The save Play runs first (play's page half keeps it, `play.page.tsx`), and the last one Play asked for. */
|
|
38
|
+
let saveEdit: (() => Promise<string | null>) | null = null;
|
|
39
|
+
let saving: Promise<string | null> = Promise.resolve(null);
|
|
40
|
+
|
|
41
|
+
/** Kept by play's page half while it is active: what saves the edit Blender holds, answering why it failed, if it
|
|
42
|
+
* did. */
|
|
43
|
+
export function keepEditSave(save: (() => Promise<string | null>) | null): void {
|
|
44
|
+
saveEdit = save;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The save the run's start waits for, answering why it failed (null: saved, or nothing to save). */
|
|
48
|
+
export const savedEdit = (): Promise<string | null> => saving;
|
|
49
|
+
|
|
50
|
+
function save(): void {
|
|
51
|
+
saving = saveEdit?.() ?? Promise.resolve(null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Play (the edit saved first) or Stop. */
|
|
55
|
+
export function playGame(on: boolean): void {
|
|
56
|
+
if (on && !gamePlaying()) save();
|
|
57
|
+
setGamePlaying(on);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Restart: the edit saved, every player's page loaded afresh; a game not playing simply starts. */
|
|
61
|
+
export function restartPlay(): void {
|
|
62
|
+
if (!gamePlaying()) { playGame(true); return; }
|
|
63
|
+
save();
|
|
64
|
+
restartGame();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const pauseGame = (paused: boolean): Promise<void> => setStdPaused(paused).then(() => {});
|
|
68
|
+
export const stepGame = (frames = 1): Promise<void> => stepStd(frames).then(() => {});
|
|
69
|
+
export const setGameSpeedOf = (speed: number): Promise<void> => setStdSpeed(speed);
|
|
70
|
+
|
|
71
|
+
/** Every change of the clock or the run, which Autoplay's state follows. */
|
|
72
|
+
export function subscribeGameControls(listener: () => void): () => void {
|
|
73
|
+
const stopClock = subscribeGameClock(listener), stopRun = subscribeStdRun(listener);
|
|
74
|
+
return () => { stopClock(); stopRun(); };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The last autoplay state read, and the state it was read over: one snapshot until it changes. */
|
|
78
|
+
let snapshot: { readonly key: string; readonly value: GameAutoplay } | null = null;
|
|
79
|
+
|
|
80
|
+
export function gameAutoplay(): GameAutoplay {
|
|
81
|
+
const arm = gameArm();
|
|
82
|
+
const running = stdRun() !== null;
|
|
83
|
+
const { on, driving, goal, goals, unavailable, played } = stdAutoplay();
|
|
84
|
+
const value: GameAutoplay = {
|
|
85
|
+
on: running && on,
|
|
86
|
+
driving: running && driving,
|
|
87
|
+
available: running ? on || unavailable === null : arm === null && goals.length > 0,
|
|
88
|
+
unavailable: running && !on ? unavailable : null,
|
|
89
|
+
armed: arm !== null,
|
|
90
|
+
behaviors: goals,
|
|
91
|
+
behavior: arm !== null ? arm.goal ?? goal : goal,
|
|
92
|
+
state: played === null ? null
|
|
93
|
+
: `${played.device === 'headless' ? 'headless ' : ''}${played.goal}: ${played.state === 'failed' ? `failed — ${played.why ?? 'std did not say why'}` : played.state}`
|
|
94
|
+
+ (played.checks === null ? '' : ` · ${played.checks}`),
|
|
95
|
+
report: played?.report ?? null,
|
|
96
|
+
};
|
|
97
|
+
const key = JSON.stringify(value);
|
|
98
|
+
if (snapshot?.key !== key) snapshot = { key, value };
|
|
99
|
+
return snapshot.value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Switch the bot on (the game playing) or off, with `goal` (the game's own when absent). */
|
|
103
|
+
export function setGameAutoplay(on: boolean, by: 'panel' | 'cli', goal?: string): Promise<void> {
|
|
104
|
+
if (!stdRun()) throw new Error('Nothing is playing; `play` starts it, then `play autoplay on`.');
|
|
105
|
+
return setStdAutoplay(on, by, goal);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Arm (or disarm) Autoplay for the next start, while stopped. */
|
|
109
|
+
export function armAutoplay(armed: boolean, goal?: string): void {
|
|
110
|
+
armGameAutoplay(armed, goal ?? null);
|
|
111
|
+
}
|
package/src/game-run.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GAME'S RUN, the project's one (Play in the Game panel, `contributions/game-panel.tsx`; drawn in the Game view,
|
|
3
|
+
* `game-view.tsx`): whether it plays, its clock as std last answered it, its generation and autoplay armed for its next
|
|
4
|
+
* start. Kept for the page's life and never stored: a reload opens the editor, not a game.
|
|
5
|
+
*
|
|
6
|
+
* THE CLOCK. Pause, step and speed are std's `clock` control on each player's page (`std-controls.ts`); each answer is
|
|
7
|
+
* the page's clock (`{ paused, speed, time, frame }`), kept here beside whether a game runs and why not, and announced
|
|
8
|
+
* through the clock's own subscription. The speed is the person's: kept across Plays, and said to each player's page as
|
|
9
|
+
* it joins. Its time comes from one of two readings, and `from` says which:
|
|
10
|
+
* - held (paused or stepped): std's answer to the control, exact, as the play log's `pause` and `step` entries record it;
|
|
11
|
+
* - running: the newest step the viewed player's page traced (std's trace format 3, `s`, sixty a game second from the
|
|
12
|
+
* trace's start, since std's live clock is the page's `performance.now`, `adoptLiveClock`). A client page writes a
|
|
13
|
+
* trace line only as something happens (an input, a word said, its bot's intent), so this holds while the page is
|
|
14
|
+
* idle: it is when the page last traced, not the clock now.
|
|
15
|
+
*
|
|
16
|
+
* AUTOPLAY ARMED WHILE STOPPED: the next start drives the viewed player's bot as its page joins, with the goal it was
|
|
17
|
+
* armed with (`std-game.ts`). Stop drops the arm.
|
|
18
|
+
*/
|
|
19
|
+
const listeners = new Set<() => void>();
|
|
20
|
+
const clockListeners = new Set<() => void>();
|
|
21
|
+
|
|
22
|
+
/** The speeds the panel offers, slowest first: std's clock takes 0.25 to 4. */
|
|
23
|
+
export const GAME_SPEEDS: readonly number[] = [0.25, 0.5, 1, 2, 4];
|
|
24
|
+
|
|
25
|
+
export interface GameClock {
|
|
26
|
+
/** The viewed player's page's game seconds and `tick`, read as `from` says. */
|
|
27
|
+
readonly time: number;
|
|
28
|
+
readonly tick: number;
|
|
29
|
+
/** Where `time` and `tick` were read. `std-answer`: std's answer to the pause or step that held the game (`tick` its
|
|
30
|
+
* frame). `newest-traced-step`: the newest step the viewed page traced while the game ran, `time` that step in
|
|
31
|
+
* seconds (0 until its trace says a line past its start); it holds while that page traces nothing. After Stop the
|
|
32
|
+
* clock keeps the run's last reading and where it was read, with `running` and `paused` false: a reading still in
|
|
33
|
+
* flight when its run ended is not written (`std-game.ts`). */
|
|
34
|
+
readonly from: 'std-answer' | 'newest-traced-step';
|
|
35
|
+
readonly paused: boolean;
|
|
36
|
+
/** Simulation seconds per real second; one of `GAME_SPEEDS`. */
|
|
37
|
+
readonly speed: number;
|
|
38
|
+
/** The run plays but no game runs: its dev server could not start, or no player's page joined. */
|
|
39
|
+
readonly failure: string | null;
|
|
40
|
+
/** A player's page has joined this run. A reader that must know whether Play WORKED (the CLI's `play`) waits for
|
|
41
|
+
* this or for `failure`, never for "playing" alone. */
|
|
42
|
+
readonly running: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let playing = false;
|
|
46
|
+
/** Replaced, never mutated, so a snapshot read by `useSyncExternalStore` changes identity exactly when it changes
|
|
47
|
+
* value. */
|
|
48
|
+
let clock: GameClock = { time: 0, tick: 0, from: 'newest-traced-step', paused: false, speed: 1, failure: null, running: false };
|
|
49
|
+
/** The newest step the viewed page traced, and its seconds: the running clock's reading, kept while the game is held. */
|
|
50
|
+
let traced = { tick: 0, time: 0 };
|
|
51
|
+
/** Bumped by Restart: the Game view runs the game again on it. */
|
|
52
|
+
let generation = 0;
|
|
53
|
+
/** Autoplay armed while stopped, with the goal it drives (null: the game's own). */
|
|
54
|
+
let arm: { readonly goal: string | null } | null = null;
|
|
55
|
+
|
|
56
|
+
function publish(): void {
|
|
57
|
+
for (const listener of [...listeners]) listener();
|
|
58
|
+
}
|
|
59
|
+
function publishGameClock(): void {
|
|
60
|
+
for (const listener of [...clockListeners]) listener();
|
|
61
|
+
}
|
|
62
|
+
function setClock(next: Partial<GameClock>): void {
|
|
63
|
+
clock = { ...clock, ...next };
|
|
64
|
+
publishGameClock();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const gamePlaying = (): boolean => playing;
|
|
68
|
+
|
|
69
|
+
export function setGamePlaying(value: boolean): void {
|
|
70
|
+
if (playing === value) {
|
|
71
|
+
// Stop with nothing playing lets go of an arm made while stopped.
|
|
72
|
+
if (!value && arm !== null) { arm = null; publishGameClock(); }
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
playing = value;
|
|
76
|
+
if (value) {
|
|
77
|
+
// A new run's clock starts at zero and running; the speed is the person's and stays.
|
|
78
|
+
traced = { tick: 0, time: 0 };
|
|
79
|
+
setClock({ time: 0, tick: 0, from: 'newest-traced-step', paused: false, failure: null, running: false });
|
|
80
|
+
} else {
|
|
81
|
+
arm = null;
|
|
82
|
+
// The clock keeps the stopped run's time and frames, so `play state` still says how far it got.
|
|
83
|
+
setClock({ paused: false, failure: null, running: false });
|
|
84
|
+
}
|
|
85
|
+
publish();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The run's report that no game runs although the run plays (`null` once a player's page joins, which is also the
|
|
89
|
+
* moment the run is `running`). */
|
|
90
|
+
export function setGameFailure(failure: string | null): void {
|
|
91
|
+
const running = failure === null;
|
|
92
|
+
if (!playing || (clock.failure === failure && clock.running === running)) return;
|
|
93
|
+
setClock({ failure, running });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A page's clock as std answered it (`std-controls.ts`'s `StdClock`), kept as the run's: held, its time and frame
|
|
97
|
+
* exactly as answered; running, its speed, the time going back to the newest traced step. */
|
|
98
|
+
export function setGameClock(said: { readonly paused: boolean; readonly speed: number; readonly time: number; readonly frame: number }): void {
|
|
99
|
+
const next: Pick<GameClock, 'paused' | 'speed' | 'time' | 'tick' | 'from'> = said.paused
|
|
100
|
+
? { paused: true, speed: said.speed, time: said.time, tick: said.frame, from: 'std-answer' }
|
|
101
|
+
: { paused: false, speed: said.speed, ...traced, from: 'newest-traced-step' };
|
|
102
|
+
if (clock.paused === next.paused && clock.speed === next.speed && clock.time === next.time && clock.tick === next.tick
|
|
103
|
+
&& clock.from === next.from) return;
|
|
104
|
+
setClock(next);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The newest step the viewed player's page traced (std's trace format 3, `s`): the run's clock while it runs. */
|
|
108
|
+
export function setGameTraceStep(step: number, stepsPerSecond: number): void {
|
|
109
|
+
traced = { tick: step, time: step / stepsPerSecond };
|
|
110
|
+
if (clock.paused || (clock.tick === step && clock.from === 'newest-traced-step')) return;
|
|
111
|
+
setClock({ ...traced, from: 'newest-traced-step' });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The person's speed, kept for the page's life and said to each player's page as it joins. */
|
|
115
|
+
export function setGameSpeed(speed: number): void {
|
|
116
|
+
if (!GAME_SPEEDS.includes(speed))
|
|
117
|
+
throw new Error(`Play speed ${speed} is not one the panel offers: ${GAME_SPEEDS.join(', ')}.`);
|
|
118
|
+
if (clock.speed !== speed) setClock({ speed });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function subscribeGame(listener: () => void): () => void {
|
|
122
|
+
listeners.add(listener);
|
|
123
|
+
return () => { listeners.delete(listener); };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const gameClock = (): GameClock => clock;
|
|
127
|
+
|
|
128
|
+
export function subscribeGameClock(listener: () => void): () => void {
|
|
129
|
+
clockListeners.add(listener);
|
|
130
|
+
return () => { clockListeners.delete(listener); };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const gameGeneration = (): number => generation;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* BEGIN THE RUN AGAIN. Playing stays true throughout and the generation moves, which is what makes the Game view run
|
|
137
|
+
* the game again: every player's page loaded afresh. A game that is not playing simply starts.
|
|
138
|
+
*/
|
|
139
|
+
export function restartGame(): void {
|
|
140
|
+
if (!playing) { setGamePlaying(true); return; }
|
|
141
|
+
generation += 1;
|
|
142
|
+
traced = { tick: 0, time: 0 };
|
|
143
|
+
setClock({ time: 0, tick: 0, from: 'newest-traced-step', paused: false, failure: null, running: false });
|
|
144
|
+
publish();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Autoplay armed for the next start, and the goal it drives; null when it is not armed. */
|
|
148
|
+
export const gameArm = (): { readonly goal: string | null } | null => arm;
|
|
149
|
+
|
|
150
|
+
/** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button before Play. Disarming
|
|
151
|
+
* always succeeds. */
|
|
152
|
+
export function armGameAutoplay(armed: boolean, goal: string | null = null): void {
|
|
153
|
+
if (armed && playing) throw new Error('The game is already playing; switch autoplay on instead of arming it.');
|
|
154
|
+
if (armed) arm = { goal };
|
|
155
|
+
else if (arm === null) return;
|
|
156
|
+
else arm = null;
|
|
157
|
+
publishGameClock();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The arm taken by the start it was made for. */
|
|
161
|
+
export function takeGameArm(): { readonly goal: string | null } | null {
|
|
162
|
+
const taken = arm;
|
|
163
|
+
if (taken !== null) { arm = null; publishGameClock(); }
|
|
164
|
+
return taken;
|
|
165
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GAME, PLAYED AS THE PROJECT'S (`docs/adr/std-scene-view.md`, decision 8): Play is the project's (`game-run.ts`),
|
|
3
|
+
* as an engine's Play button is, whatever document is in front, and the game shows in a GAME VIEW of its own, as
|
|
4
|
+
* Unity's Game view and Godot's Game workspace do. Play opens the Game view as a tab of the editor (`GAME_DOCUMENT`) and
|
|
5
|
+
* brings it to the front; Stop closes it and brings back the tab that was in front. Another tab in front never stops
|
|
6
|
+
* the game, and closing the Game view does.
|
|
7
|
+
*
|
|
8
|
+
* THE PLAYERS' PAGES OUTLIVE THE TAB'S BOX. The host parks a document's box out of the page while its tab is behind
|
|
9
|
+
* another, and a frame taken out of the page loads again, so the run draws in the Game view's retained layer
|
|
10
|
+
* (`@volter/sdk/document-layer`), mounted in the workbench's container, which the Game view's box claims while it is
|
|
11
|
+
* shown (laid over it, hidden once no view claims it), as Code-OSS keeps a webview that retains its context; the
|
|
12
|
+
* editor's doors that read the document in front (`editor.document.key`, `query`, `inspect`, `capture`) read the layer as
|
|
13
|
+
* its content. A hidden layer's pages are throttled, as a hidden retained webview is, and kept.
|
|
14
|
+
*/
|
|
15
|
+
import { type CSSProperties, useLayoutEffect, useRef, useSyncExternalStore } from 'react';
|
|
16
|
+
import { editorHost } from '@volter/sdk/host';
|
|
17
|
+
import { useProgress } from '@volter/sdk/progress';
|
|
18
|
+
import { StateSurface, themeVars } from '@volter/sdk/widgets';
|
|
19
|
+
import { claimDocumentLayer, claimedBox, documentLayer, documentLayerOf, releaseDocumentLayer } from '@volter/sdk/document-layer';
|
|
20
|
+
import {
|
|
21
|
+
activateWorkspaceDocument,
|
|
22
|
+
activeWorkspaceDocumentId,
|
|
23
|
+
closeWorkspaceDocument,
|
|
24
|
+
openWorkspaceDocument,
|
|
25
|
+
openWorkspaceDocuments,
|
|
26
|
+
workspaceDocumentSelection,
|
|
27
|
+
} from '@volter/sdk/workspace-document-registry';
|
|
28
|
+
import { setDocumentPlaySignal } from '@volter/sdk/document-play-extension';
|
|
29
|
+
import { currentStdLevel, StdLevelView, subscribeCurrentStdLevel } from '../contributions/std-level.document';
|
|
30
|
+
import { gameClock, gameGeneration, gamePlaying, setGamePlaying, subscribeGame, subscribeGameClock } from './game-run';
|
|
31
|
+
import { stdRun, stdRunVersion, subscribeStdRun, type GameStage, type StdTileBox } from './std-game';
|
|
32
|
+
|
|
33
|
+
/** The Game view's id, and the run's: the project's one game. */
|
|
34
|
+
const GAME_DOCUMENT = 'play:game';
|
|
35
|
+
/** The Game view's Scene: the current level's view, live, a view of the Game view's document. */
|
|
36
|
+
const SCENE_VIEW = `${GAME_DOCUMENT}#scene`;
|
|
37
|
+
|
|
38
|
+
/** The run's stage, made as Play starts and kept across its restarts, in the Game view's retained layer: the players'
|
|
39
|
+
* frames, and over them the view bar and the grid's tiles. It takes no press itself: each player's frame and the view
|
|
40
|
+
* bar take their own, so the live Scene beneath it is the editor's to orbit and pick. */
|
|
41
|
+
interface StageLayer {
|
|
42
|
+
readonly host: HTMLDivElement;
|
|
43
|
+
readonly players: HTMLDivElement;
|
|
44
|
+
readonly overlay: HTMLDivElement;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let stage: StageLayer | null = null;
|
|
48
|
+
const stageListeners = new Set<() => void>();
|
|
49
|
+
function setStage(next: StageLayer | null): void {
|
|
50
|
+
stage = next;
|
|
51
|
+
if (next === null) releaseDocumentLayer(GAME_DOCUMENT);
|
|
52
|
+
for (const listener of [...stageListeners]) listener();
|
|
53
|
+
}
|
|
54
|
+
function subscribeStage(listener: () => void): () => void {
|
|
55
|
+
stageListeners.add(listener);
|
|
56
|
+
return () => { stageListeners.delete(listener); };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeStage(): StageLayer {
|
|
60
|
+
const host = documentLayer(GAME_DOCUMENT);
|
|
61
|
+
host.dataset['testid'] = 'game-stage';
|
|
62
|
+
const players = document.createElement('div');
|
|
63
|
+
Object.assign(players.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
|
|
64
|
+
const overlay = document.createElement('div');
|
|
65
|
+
Object.assign(overlay.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
|
|
66
|
+
host.replaceChildren(players, overlay);
|
|
67
|
+
return { host, players, overlay };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Where the live Scene stands in the Game view, as the run places it (`GameStage.scene`). */
|
|
71
|
+
let sceneArea: StdTileBox | 'whole' | null = null;
|
|
72
|
+
const sceneListeners = new Set<() => void>();
|
|
73
|
+
function placeScene(area: StdTileBox | 'whole' | null): void {
|
|
74
|
+
if (JSON.stringify(area) === JSON.stringify(sceneArea)) return;
|
|
75
|
+
sceneArea = area;
|
|
76
|
+
for (const listener of [...sceneListeners]) listener();
|
|
77
|
+
}
|
|
78
|
+
function subscribeSceneArea(listener: () => void): () => void {
|
|
79
|
+
sceneListeners.add(listener);
|
|
80
|
+
return () => { sceneListeners.delete(listener); };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The Game view: the run's stage over its box, the live Scene where the run places it (the current level's view,
|
|
84
|
+
* live, `std-level.document.tsx`, mounted for the run's whole life so its camera stays), and what the run says. */
|
|
85
|
+
function GameView({ active }: { readonly active: boolean }) {
|
|
86
|
+
const box = useRef<HTMLDivElement | null>(null);
|
|
87
|
+
const held = useSyncExternalStore(subscribeStage, () => stage, () => null);
|
|
88
|
+
const area = useSyncExternalStore(subscribeSceneArea, () => sceneArea, () => null);
|
|
89
|
+
const level = useSyncExternalStore(subscribeCurrentStdLevel, currentStdLevel, () => null);
|
|
90
|
+
const playing = useSyncExternalStore(subscribeGame, gamePlaying, () => false);
|
|
91
|
+
const failure = useSyncExternalStore(subscribeGameClock, () => gameClock().failure, () => null);
|
|
92
|
+
const running = useSyncExternalStore(subscribeGameClock, () => gameClock().running, () => false);
|
|
93
|
+
useSyncExternalStore(subscribeStdRun, stdRunVersion, stdRunVersion);
|
|
94
|
+
useLayoutEffect(() => {
|
|
95
|
+
if (!active || held === null || box.current === null) return undefined;
|
|
96
|
+
return claimDocumentLayer(GAME_DOCUMENT, box.current);
|
|
97
|
+
}, [active, held]);
|
|
98
|
+
// (the play frame `capture --region play` takes, `editor-chrome-capture.ts`: the viewed player's page in the stage, or
|
|
99
|
+
// the Scene; Grid shows several, and names none)
|
|
100
|
+
const view = stdRun()?.view;
|
|
101
|
+
useLayoutEffect(() => {
|
|
102
|
+
if (held === null) return;
|
|
103
|
+
if (typeof view === 'number') held.host.dataset['volterPlayFrame'] = '';
|
|
104
|
+
else delete held.host.dataset['volterPlayFrame'];
|
|
105
|
+
}, [held, view]);
|
|
106
|
+
const said = !playing ? 'Stopped: press Play in the Game panel.' : failure;
|
|
107
|
+
useProgress(playing && !failure && !running ? { documentId: GAME_DOCUMENT, title: 'Starting the game' } : null);
|
|
108
|
+
const sceneBox: CSSProperties = area === null ? { inset: 0, visibility: 'hidden' } : area === 'whole' ? { inset: 0 } : {
|
|
109
|
+
left: `${area.left * 100}%`, top: `${area.top * 100}%`, width: `${area.width * 100}%`, height: `${area.height * 100}%`,
|
|
110
|
+
};
|
|
111
|
+
return (
|
|
112
|
+
<div ref={box} data-testid="game-view" style={{ position: 'absolute', inset: 0, background: themeVars.surface.inset }}>
|
|
113
|
+
{playing && (
|
|
114
|
+
<div data-testid="game-view-scene" {...(view === 'scene' ? { 'data-volter-play-frame': '' } : {})}
|
|
115
|
+
style={{ position: 'absolute', ...sceneBox, display: 'flex', flexDirection: 'column' }}>
|
|
116
|
+
{level === null
|
|
117
|
+
? <StateSurface fill title="Open a level to see it here, live." />
|
|
118
|
+
: <StdLevelView documentId={SCENE_VIEW} level={level.path} label={level.label} live />}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
{said !== null && <StateSurface fill tone={playing ? 'error' : 'neutral'} title={said} style={{ pointerEvents: 'none' }} />}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What runs the game in a stage, as play's page half runs it (`play.page.tsx`): the answer stops it. */
|
|
127
|
+
export type GameRunner = (stage: GameStage) => () => void;
|
|
128
|
+
|
|
129
|
+
/** Whether the Game view holds the keyboard: it is the document in front, and the focus is the page's own, in the
|
|
130
|
+
* Game view (its box or its layer), or the pane that holds the Game view alone, never another part of the workbench. */
|
|
131
|
+
function gameViewHasKeys(): boolean {
|
|
132
|
+
const shown = claimedBox(GAME_DOCUMENT);
|
|
133
|
+
if (activeWorkspaceDocumentId() !== GAME_DOCUMENT || shown === null) return false;
|
|
134
|
+
const focused = document.activeElement;
|
|
135
|
+
if (focused === null || focused === document.body) return true;
|
|
136
|
+
if (shown.contains(focused) || (documentLayerOf(GAME_DOCUMENT)?.contains(focused) ?? false)) return true;
|
|
137
|
+
// (a pane parks the documents behind its tab, unseen: the one it shows is the Game view)
|
|
138
|
+
const seen = [...focused.querySelectorAll<HTMLElement>('[data-volter-document]')].filter((one) => one.checkVisibility());
|
|
139
|
+
return focused.contains(shown) && seen.length === 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The Game view brought to the front while the game runs (`play` asked of a game already playing), as Play brings it
|
|
143
|
+
* there as the run starts; nothing while the game has no Game view. */
|
|
144
|
+
export function showGameView(): void {
|
|
145
|
+
if (openWorkspaceDocuments().some((open) => open.descriptor.id === GAME_DOCUMENT)) activateWorkspaceDocument(GAME_DOCUMENT);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* THE PROJECT'S RUN, kept while play is active: Play opens the Game view and runs the game in its stage, a Restart runs
|
|
150
|
+
* it again, and Stop (or the Game view closed) ends it and brings back the tab that was in front.
|
|
151
|
+
*/
|
|
152
|
+
export function keepGameRuns(run: GameRunner): () => void {
|
|
153
|
+
let stop: (() => void) | null = null;
|
|
154
|
+
let generation = -1;
|
|
155
|
+
let before: string | null = null;
|
|
156
|
+
const view = (): boolean => openWorkspaceDocuments().some((open) => open.descriptor.id === GAME_DOCUMENT);
|
|
157
|
+
const sync = (): void => {
|
|
158
|
+
const playing = gamePlaying();
|
|
159
|
+
const now = gameGeneration();
|
|
160
|
+
if (playing && (stop === null || generation !== now)) {
|
|
161
|
+
stop?.();
|
|
162
|
+
stop = null;
|
|
163
|
+
generation = now;
|
|
164
|
+
if (!view()) {
|
|
165
|
+
const front = activeWorkspaceDocumentId();
|
|
166
|
+
before = front === GAME_DOCUMENT ? before : front;
|
|
167
|
+
openWorkspaceDocument({
|
|
168
|
+
id: GAME_DOCUMENT,
|
|
169
|
+
title: 'Game',
|
|
170
|
+
kind: 'tool-contribution',
|
|
171
|
+
Content: GameView,
|
|
172
|
+
// (the Game view closed is the game stopped)
|
|
173
|
+
onDispose: () => { if (gamePlaying()) setGamePlaying(false); },
|
|
174
|
+
// (the Hierarchy and the Inspector in every view are the running level's, its Scene's copy, as Unity's
|
|
175
|
+
// Hierarchy shows the running scene whichever view is focused: `docs/adr/std-scene-view.md`, decision 8)
|
|
176
|
+
selection: () => workspaceDocumentSelection(SCENE_VIEW),
|
|
177
|
+
});
|
|
178
|
+
} else activateWorkspaceDocument(GAME_DOCUMENT);
|
|
179
|
+
if (stage === null) setStage(makeStage());
|
|
180
|
+
const { players, overlay } = stage!;
|
|
181
|
+
stop = run({
|
|
182
|
+
container: players,
|
|
183
|
+
overlay,
|
|
184
|
+
report: (title, detail) => editorHost().console.error(`${title}: ${detail}`, 'play'),
|
|
185
|
+
keys: gameViewHasKeys,
|
|
186
|
+
scene: placeScene,
|
|
187
|
+
// (the stage's layer laid over the Game view's box, in a page that is seen: its players' pages draw)
|
|
188
|
+
shown: () => document.visibilityState !== 'hidden' && claimedBox(GAME_DOCUMENT) !== null,
|
|
189
|
+
});
|
|
190
|
+
} else if (!playing && stop !== null) {
|
|
191
|
+
stop();
|
|
192
|
+
stop = null;
|
|
193
|
+
placeScene(null);
|
|
194
|
+
setStage(null);
|
|
195
|
+
const back = before;
|
|
196
|
+
before = null;
|
|
197
|
+
if (view()) closeWorkspaceDocument(GAME_DOCUMENT);
|
|
198
|
+
if (back !== null) activateWorkspaceDocument(back);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const unsubscribe = subscribeGame(sync);
|
|
202
|
+
// (the host hears that the Game view is the document that plays, and whether it is paused: its status line says so)
|
|
203
|
+
setDocumentPlaySignal('play', {
|
|
204
|
+
state: (documentId) => (documentId !== GAME_DOCUMENT || !gamePlaying() ? 'stopped' : gameClock().paused ? 'paused' : 'playing'),
|
|
205
|
+
subscribe: (listener) => {
|
|
206
|
+
const stopRun = subscribeGame(listener), stopClock = subscribeGameClock(listener);
|
|
207
|
+
return () => { stopRun(); stopClock(); };
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
sync();
|
|
211
|
+
return () => {
|
|
212
|
+
unsubscribe();
|
|
213
|
+
setDocumentPlaySignal('play', null);
|
|
214
|
+
stop?.();
|
|
215
|
+
stop = null;
|
|
216
|
+
if (view()) closeWorkspaceDocument(GAME_DOCUMENT);
|
|
217
|
+
setStage(null);
|
|
218
|
+
};
|
|
219
|
+
}
|
package/src/play-clock.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE GAME PANEL'S CLOCK, READ AT TWO RATES (`../contributions/game-panel.tsx`).
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
4
|
+
* The run announces its clock every frame, and one changed text node in the workbench's
|
|
5
5
|
* document repaints and re-layerizes the page around it (about 4 ms a frame measured), so the
|
|
6
6
|
* panel does not re-render on every announcement.
|
|
7
7
|
*
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* {@link CLOCK_TEXT_INTERVAL_MS} apart. The text's last change always lands (a trailing update),
|
|
11
11
|
* so a paused game's step or a stop reads exactly.
|
|
12
12
|
*/
|
|
13
|
-
import type {
|
|
13
|
+
import type { GameClock } from './game-run';
|
|
14
14
|
|
|
15
15
|
/** The clock text's update period: ten times a second, faster than its hundredths can be read. */
|
|
16
16
|
export const CLOCK_TEXT_INTERVAL_MS = 100;
|
|
@@ -46,11 +46,11 @@ export function throttledSubscription(
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/** The clock's state without its time: what the panel's controls and status line read. */
|
|
49
|
-
export type ClockState = Pick<
|
|
49
|
+
export type ClockState = Pick<GameClock, 'paused' | 'speed' | 'failure' | 'running'>;
|
|
50
50
|
|
|
51
51
|
/** A snapshot reader for {@link ClockState} that answers the SAME object until a field changes, so
|
|
52
52
|
* `useSyncExternalStore` re-renders only on a change of state, not on every tick. */
|
|
53
|
-
export function clockStateReader(read: () =>
|
|
53
|
+
export function clockStateReader(read: () => GameClock): () => ClockState {
|
|
54
54
|
let held: ClockState | null = null;
|
|
55
55
|
return () => {
|
|
56
56
|
const clock = read();
|