@volter/play 0.5.211 → 0.5.213
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +5 -5
- package/README.md +156 -172
- package/contributions/game-panel.tsx +807 -0
- package/contributions/levels.finder.ts +43 -0
- package/contributions/play.page.tsx +93 -0
- package/contributions/play.verbs.ts +74 -0
- package/contributions/std-level.document.tsx +477 -0
- package/dist-node/serving.mjs +695 -0
- package/package.json +277 -10
- package/serving/index.ts +13 -0
- package/serving/std-dev.ts +547 -0
- package/serving/std-scene-runtime.js +240 -0
- package/serving/std-scene.ts +240 -0
- package/src/editor-blender.ts +18 -0
- package/src/editor-threejs.ts +19 -0
- package/src/game-controls.ts +111 -0
- package/src/game-run.ts +165 -0
- package/src/game-view.tsx +219 -0
- package/src/play-clock.ts +62 -0
- package/src/play-log.ts +113 -114
- package/src/std-controls.ts +128 -0
- package/src/std-export.ts +120 -0
- package/src/std-game-adapter.ts +26 -0
- package/src/std-game.ts +967 -0
- package/src/std-level-files.ts +50 -0
- package/src/std-level.ts +107 -0
- package/src/std-view-bar.tsx +68 -0
- package/contributions/model-play.command.ts +0 -37
- package/contributions/model-play.service.tsx +0 -152
- package/src/camera-transition.ts +0 -87
- package/src/model-play.ts +0 -376
- package/src/play-materials.ts +0 -199
- package/src/play-script.ts +0 -841
package/src/play-log.ts
CHANGED
|
@@ -1,75 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE
|
|
2
|
+
* THE PLAY LOG: what the game's run says happened.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
4
|
+
* Each player's page traces its run (std's trace, as the dev server's receiver writes it, `build/runs/<run>/<page>.jsonl`)
|
|
5
|
+
* and the run reads each trace into this log, each entry tagged with its player's slot (`std-game.ts`, `source:
|
|
6
|
+
* 'script'`). Play adds its own lifecycle entries (`source: 'play'`): `play-start`, `running`, `player-joined`,
|
|
7
|
+
* `player-page-loaded`, `player-left`, `view`, `autoplay-on` and `autoplay-off` (each with `by`), `pause`, `resume`,
|
|
8
|
+
* `step` and `speed` (each with the clock std answered), `capture` (its file), `script-error` (with its `phase`) and
|
|
9
|
+
* `play-stop`. An agent reads them back with `cyclotron play-log` or `editor.contributedVerb('play-log')` in
|
|
10
|
+
* `eval`: the session's console feed keeps warnings and errors only.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the same call (`play-script.ts`), so they stand still while paused and run at the speed. The
|
|
16
|
-
* runner adds its own lifecycle entries (`source: 'play'`): `play-start`, `play-restart` (a
|
|
17
|
-
* Restart's fresh run, right after its `play-start`), `script-reload` with its reason,
|
|
18
|
-
* `script-error`, `tint-unsupported`, `tint-unknown-object`, `pause`, `resume`, `step` (one per
|
|
19
|
-
* stepped update, with its `dt`), `speed` (on a change, and at a start that is not 1×),
|
|
20
|
-
* `autoplay-on` and `autoplay-off` (each with `by`: `panel`, `cli`, `takeover`, `script`),
|
|
21
|
-
* `autoplay-unavailable` (a script ran its first update without offering a bot),
|
|
22
|
-
* `play-stop`.
|
|
12
|
+
* WHEN EACH HAPPENED: a trace entry carries its page's own time, its trace's step (std's trace format 3: `s`, sixty a
|
|
13
|
+
* second of the page's game clock, its `performance.now` on a live page) as `tick` and that step in seconds as `simT`;
|
|
14
|
+
* Play's own entries carry the run's elapsed seconds since its `play-start` as `simT`, and the newest trace step heard
|
|
15
|
+
* as `tick`. So `--since` reads the run's seconds, a page's from its own start.
|
|
23
16
|
*
|
|
24
|
-
* ONE LOG
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* replaces its document's log; a script reload does not, and the log outlives Stop so a
|
|
28
|
-
* finished run can be read. The runner writes through the {@link ModelPlayRun} handle Play
|
|
29
|
-
* started: once that run has ended its handle writes nothing, so a stale timer of an old
|
|
30
|
-
* script cannot reach the next run's log.
|
|
17
|
+
* ONE LOG, the latest run's. Each Play replaces it, and it outlives Stop so a finished run can be read. The run writes
|
|
18
|
+
* through the {@link PlayLogRun} handle Play started: once that run has ended its handle writes nothing, so a stale
|
|
19
|
+
* timer of an old run cannot reach the next run's log.
|
|
31
20
|
*
|
|
32
|
-
* BOUNDED AND PAGE-LIFETIME. The newest {@link
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* page forgets it, as it forgets the game.
|
|
21
|
+
* BOUNDED AND PAGE-LIFETIME. The newest {@link PLAY_LOG_CAPACITY} entries of a run are kept in a ring (older
|
|
22
|
+
* ones are counted in `dropped`); one entry's facts are kept as JSON of at most {@link PLAY_LOG_FACTS_CHARS}
|
|
23
|
+
* characters. It is never stored: a reload of the page forgets it, as it forgets the run.
|
|
36
24
|
*
|
|
37
|
-
* THE GAME PANEL DRAWS IT LIVE: {@link
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* Logging is a read of the script's own values: it never throws into the script and touches
|
|
41
|
-
* neither the clock nor the copy.
|
|
25
|
+
* THE GAME PANEL DRAWS IT LIVE: {@link subscribePlayLog} hears every write, and {@link tailPlayLog} reads the newest
|
|
26
|
+
* entries without copying the ring.
|
|
42
27
|
*/
|
|
43
28
|
|
|
44
|
-
/**
|
|
45
|
-
export
|
|
46
|
-
/** The
|
|
47
|
-
export const MODEL_PLAY_LOG_FACTS_CHARS = 2048;
|
|
48
|
-
|
|
49
|
-
export interface ModelPlayLogEntry {
|
|
50
|
-
/** The entry's place in this run, from 0: a gap in `seq` is entries the ring dropped. */
|
|
29
|
+
/** One entry of the play log (`play-log`): read from a player's page's trace, or written by Play. */
|
|
30
|
+
export interface PlayLogEntry {
|
|
31
|
+
/** The entry's place in its run, from 0: a gap in `seq` is entries the bounded log dropped. */
|
|
51
32
|
readonly seq: number;
|
|
52
33
|
/** Wall-clock ms. */
|
|
53
34
|
readonly t: number;
|
|
54
|
-
/**
|
|
35
|
+
/** When it happened: a trace entry's page time (its step's seconds, and its step), Play's own the run's elapsed
|
|
36
|
+
* seconds (and the newest step heard). */
|
|
55
37
|
readonly simT: number;
|
|
56
|
-
/** The update in progress (the first is 1); 0 before the first. */
|
|
57
38
|
readonly tick: number;
|
|
58
39
|
readonly kind: string;
|
|
59
|
-
/** `script` for the
|
|
40
|
+
/** `script` for the game's own entries (its pages' traces); `play` for Play's lifecycle ones. */
|
|
60
41
|
readonly source: 'script' | 'play';
|
|
61
42
|
readonly facts?: Record<string, unknown>;
|
|
62
43
|
}
|
|
63
44
|
|
|
64
|
-
|
|
45
|
+
/** Entries a run's ring keeps; the oldest beyond this are dropped and counted. */
|
|
46
|
+
const PLAY_LOG_CAPACITY = 5000;
|
|
47
|
+
/** The longest JSON one entry's facts keep; longer facts are kept truncated, as a string. */
|
|
48
|
+
const PLAY_LOG_FACTS_CHARS = 2048;
|
|
49
|
+
|
|
50
|
+
export interface PlayLogReading {
|
|
65
51
|
/** The run is still playing; false after Stop, or when nothing has played. */
|
|
66
52
|
readonly playing: boolean;
|
|
67
|
-
/**
|
|
68
|
-
readonly documentId: string | null;
|
|
53
|
+
/** What the run ran (the game's dev server command); null when nothing has played. */
|
|
69
54
|
readonly script: string | null;
|
|
70
55
|
readonly startedAt: number | null;
|
|
71
|
-
/** Every model document with a log, so a read can name another. */
|
|
72
|
-
readonly documents: readonly string[];
|
|
73
56
|
/** The run's clock now. */
|
|
74
57
|
readonly simT: number;
|
|
75
58
|
readonly tick: number;
|
|
@@ -78,46 +61,42 @@ export interface ModelPlayLogReading {
|
|
|
78
61
|
readonly total: number;
|
|
79
62
|
readonly dropped: number;
|
|
80
63
|
/** The kept entries that match the read's filters, oldest first. */
|
|
81
|
-
readonly entries: readonly
|
|
64
|
+
readonly entries: readonly PlayLogEntry[];
|
|
82
65
|
}
|
|
83
66
|
|
|
84
|
-
export interface
|
|
85
|
-
/**
|
|
86
|
-
readonly documentId?: string;
|
|
87
|
-
/** Only entries at or after this simulation time (seconds). */
|
|
67
|
+
export interface PlayLogQuery {
|
|
68
|
+
/** Only entries at or after this game time (seconds). */
|
|
88
69
|
readonly since?: number;
|
|
89
70
|
/** Only entries of this kind. */
|
|
90
71
|
readonly kind?: string;
|
|
91
72
|
}
|
|
92
73
|
|
|
93
74
|
interface Run {
|
|
94
|
-
readonly documentId: string;
|
|
95
75
|
readonly script: string;
|
|
96
76
|
readonly startedAt: number;
|
|
97
77
|
playing: boolean;
|
|
98
78
|
simT: number;
|
|
99
79
|
tick: number;
|
|
100
|
-
readonly ring:
|
|
80
|
+
readonly ring: PlayLogEntry[];
|
|
101
81
|
total: number;
|
|
102
82
|
/** Every kind this run has written, for the panel's filter. */
|
|
103
83
|
readonly kinds: Set<string>;
|
|
104
84
|
}
|
|
105
85
|
|
|
106
|
-
/** The
|
|
107
|
-
export interface
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
append(source: ModelPlayLogEntry['source'], kind: string, facts?: unknown): void;
|
|
86
|
+
/** The run's door onto its own log: inert once the run has ended. */
|
|
87
|
+
export interface PlayLogRun {
|
|
88
|
+
/** An entry, stamped with `at` (a trace line's page time) or else the run's elapsed seconds. */
|
|
89
|
+
append(source: PlayLogEntry['source'], kind: string, facts?: unknown, at?: { readonly simT: number; readonly tick: number }): void;
|
|
111
90
|
/** `play-stop`, then nothing more is written; the entries stay readable. */
|
|
112
91
|
end(facts?: Record<string, unknown>): void;
|
|
113
92
|
}
|
|
114
93
|
|
|
115
94
|
// One registry per page, whichever copy of this module a contribution loaded it through: the
|
|
116
|
-
//
|
|
117
|
-
const key = Symbol.for('volter.
|
|
118
|
-
const page = globalThis as typeof globalThis & { [key]?:
|
|
119
|
-
const
|
|
120
|
-
const listenersKey = Symbol.for('volter.
|
|
95
|
+
// run writes it, the session verb reads it from the command module.
|
|
96
|
+
const key = Symbol.for('volter.play-log');
|
|
97
|
+
const page = globalThis as typeof globalThis & { [key]?: { run: Run | null } };
|
|
98
|
+
const held: { run: Run | null } = page[key] ??= { run: null };
|
|
99
|
+
const listenersKey = Symbol.for('volter.play-log.listeners');
|
|
121
100
|
const pageListeners = globalThis as typeof globalThis & { [listenersKey]?: Set<() => void> };
|
|
122
101
|
const listeners: Set<() => void> = pageListeners[listenersKey] ??= new Set();
|
|
123
102
|
|
|
@@ -127,53 +106,78 @@ function publish(): void {
|
|
|
127
106
|
}
|
|
128
107
|
}
|
|
129
108
|
|
|
130
|
-
/** Hear every write to
|
|
131
|
-
export function
|
|
109
|
+
/** Hear every write to the log (several may land in one frame; coalesce). */
|
|
110
|
+
export function subscribePlayLog(listener: () => void): () => void {
|
|
132
111
|
listeners.add(listener);
|
|
133
112
|
return () => { listeners.delete(listener); };
|
|
134
113
|
}
|
|
135
114
|
|
|
115
|
+
/** The facts that say what happened, first in an entry's facts: an error's message (std's trace `label`), then its
|
|
116
|
+
* source and where. */
|
|
117
|
+
const MEANING_FIRST = ['label', 'message', 'why', 'text', 'source', 'file', 'line', 'column', 'slot'];
|
|
118
|
+
/** The frames of a stack an entry keeps once its facts are too long. */
|
|
119
|
+
const STACK_FRAMES = 4;
|
|
120
|
+
|
|
121
|
+
/** Facts in the order that keeps their meaning: what happened first, everything else after. */
|
|
122
|
+
function meaningFirst(facts: Record<string, unknown>): Record<string, unknown> {
|
|
123
|
+
const first = MEANING_FIRST.filter((key) => key in facts);
|
|
124
|
+
return Object.fromEntries([...first, ...Object.keys(facts).filter((key) => !first.includes(key))].map((key) => [key, facts[key]]));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Facts too long to keep whole, shortened where it costs no meaning: the trace's session goes, a URL keeps its path,
|
|
128
|
+
* a stack its first frames, each frame's URL its path. */
|
|
129
|
+
function shortened(facts: Record<string, unknown>): Record<string, unknown> {
|
|
130
|
+
const path = (text: string): string => text.replace(/\bhttps?:\/\/[^/\s)]+/g, '').replace(/\?[^\s):]*/g, '');
|
|
131
|
+
const kept: Record<string, unknown> = {};
|
|
132
|
+
for (const [key, value] of Object.entries(facts)) {
|
|
133
|
+
if (key === 'session') continue;
|
|
134
|
+
kept[key] = typeof value !== 'string' ? value
|
|
135
|
+
: key === 'stack' ? path(value.split('\n').slice(0, STACK_FRAMES + 1).join('\n'))
|
|
136
|
+
: key === 'label' || key === 'message' ? value
|
|
137
|
+
: path(value);
|
|
138
|
+
}
|
|
139
|
+
return kept;
|
|
140
|
+
}
|
|
141
|
+
|
|
136
142
|
function keptFacts(facts: unknown): Record<string, unknown> | undefined {
|
|
137
143
|
if (facts === undefined || facts === null) return undefined;
|
|
138
144
|
let json: string | undefined;
|
|
139
|
-
|
|
145
|
+
const ordered = typeof facts === 'object' && !Array.isArray(facts) ? meaningFirst(facts as Record<string, unknown>) : facts;
|
|
146
|
+
try { json = JSON.stringify(ordered); }
|
|
140
147
|
catch (error) { return { unserializable: error instanceof Error ? error.message : String(error) }; }
|
|
141
148
|
if (json === undefined) return undefined;
|
|
142
|
-
if (json.length >
|
|
149
|
+
if (json.length > PLAY_LOG_FACTS_CHARS && ordered !== facts) json = JSON.stringify(shortened(ordered as Record<string, unknown>));
|
|
150
|
+
if (json.length > PLAY_LOG_FACTS_CHARS) return { truncated: json.slice(0, PLAY_LOG_FACTS_CHARS) };
|
|
143
151
|
// A snapshot, so a script that goes on mutating what it logged does not rewrite history.
|
|
144
152
|
const value: unknown = JSON.parse(json);
|
|
145
153
|
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : { value };
|
|
146
154
|
}
|
|
147
155
|
|
|
148
|
-
function append(run: Run, source:
|
|
156
|
+
function append(run: Run, source: PlayLogEntry['source'], kind: string, facts?: unknown, at?: { readonly simT: number; readonly tick: number }): void {
|
|
149
157
|
try {
|
|
150
158
|
const kept = keptFacts(facts);
|
|
151
|
-
const
|
|
152
|
-
|
|
159
|
+
const t = Date.now();
|
|
160
|
+
const simT = at?.simT ?? (t - run.startedAt) / 1000;
|
|
161
|
+
if (at !== undefined) run.tick = Math.max(run.tick, at.tick);
|
|
162
|
+
run.simT = Math.max(run.simT, (t - run.startedAt) / 1000);
|
|
163
|
+
const entry: PlayLogEntry = {
|
|
164
|
+
seq: run.total, t, simT, tick: at?.tick ?? run.tick, kind: String(kind), source,
|
|
153
165
|
...(kept ? { facts: kept } : {}),
|
|
154
166
|
};
|
|
155
|
-
run.ring[run.total %
|
|
167
|
+
run.ring[run.total % PLAY_LOG_CAPACITY] = entry;
|
|
156
168
|
run.total += 1;
|
|
157
169
|
run.kinds.add(entry.kind);
|
|
158
|
-
} catch { /* A log that cannot be written is not the
|
|
170
|
+
} catch { /* A log that cannot be written is not the run's failure. */ }
|
|
159
171
|
publish();
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
runs.delete(documentId);
|
|
168
|
-
runs.set(documentId, run);
|
|
169
|
-
append(run, 'play', 'play-start', { documentId, script });
|
|
174
|
+
/** A run started: the log replaced by an empty one at zero, opened by `play-start`. */
|
|
175
|
+
export function beginPlayLog(script: string): PlayLogRun {
|
|
176
|
+
const run: Run = { script, startedAt: Date.now(), playing: true, simT: 0, tick: 0, ring: [], total: 0, kinds: new Set() };
|
|
177
|
+
held.run = run;
|
|
178
|
+
append(run, 'play', 'play-start', { script });
|
|
170
179
|
return {
|
|
171
|
-
|
|
172
|
-
if (!run.playing) return;
|
|
173
|
-
run.tick += 1;
|
|
174
|
-
run.simT += deltaSeconds;
|
|
175
|
-
},
|
|
176
|
-
append(source, kind, facts) { if (run.playing) append(run, source, kind, facts); },
|
|
180
|
+
append(source, kind, facts, at) { if (run.playing) append(run, source, kind, facts, at); },
|
|
177
181
|
end(facts) {
|
|
178
182
|
if (!run.playing) return;
|
|
179
183
|
append(run, 'play', 'play-stop', facts);
|
|
@@ -182,51 +186,46 @@ export function beginModelPlayLog(documentId: string, script: string): ModelPlay
|
|
|
182
186
|
};
|
|
183
187
|
}
|
|
184
188
|
|
|
185
|
-
function
|
|
186
|
-
const
|
|
187
|
-
return started.find((run) => run.playing) ?? started[0];
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export function readModelPlayLog(query: ModelPlayLogQuery = {}): ModelPlayLogReading {
|
|
191
|
-
const run = query.documentId === undefined ? activeRun() : runs.get(query.documentId);
|
|
192
|
-
const documents = [...runs.keys()];
|
|
189
|
+
export function readPlayLog(query: PlayLogQuery = {}): PlayLogReading {
|
|
190
|
+
const { run } = held;
|
|
193
191
|
if (!run) {
|
|
194
192
|
return {
|
|
195
|
-
playing: false,
|
|
196
|
-
simT: 0, tick: 0, capacity:
|
|
193
|
+
playing: false, script: null, startedAt: null,
|
|
194
|
+
simT: 0, tick: 0, capacity: PLAY_LOG_CAPACITY, total: 0, dropped: 0, entries: [],
|
|
197
195
|
};
|
|
198
196
|
}
|
|
199
|
-
const dropped = Math.max(0, run.total -
|
|
200
|
-
const entries:
|
|
197
|
+
const dropped = Math.max(0, run.total - PLAY_LOG_CAPACITY);
|
|
198
|
+
const entries: PlayLogEntry[] = [];
|
|
201
199
|
for (let seq = dropped; seq < run.total; seq++) {
|
|
202
|
-
const entry = run.ring[seq %
|
|
200
|
+
const entry = run.ring[seq % PLAY_LOG_CAPACITY]!;
|
|
203
201
|
if (query.since !== undefined && entry.simT < query.since) continue;
|
|
204
202
|
if (query.kind !== undefined && entry.kind !== query.kind) continue;
|
|
205
203
|
entries.push(entry);
|
|
206
204
|
}
|
|
207
205
|
return {
|
|
208
|
-
playing: run.playing,
|
|
209
|
-
simT: run.simT,
|
|
206
|
+
playing: run.playing, script: run.script, startedAt: run.startedAt,
|
|
207
|
+
simT: run.playing ? Math.max(run.simT, (Date.now() - run.startedAt) / 1000) : run.simT, tick: run.tick,
|
|
208
|
+
capacity: PLAY_LOG_CAPACITY, total: run.total, dropped, entries,
|
|
210
209
|
};
|
|
211
210
|
}
|
|
212
211
|
|
|
213
|
-
export interface
|
|
214
|
-
/** Entries the
|
|
212
|
+
export interface PlayLogTail {
|
|
213
|
+
/** Entries the latest run has written; 0 when nothing has played. */
|
|
215
214
|
readonly total: number;
|
|
216
215
|
/** Every kind that run has written, in the order first seen. */
|
|
217
216
|
readonly kinds: readonly string[];
|
|
218
217
|
/** The newest kept entries (of the asked kind), at most `last`, oldest first. */
|
|
219
|
-
readonly entries: readonly
|
|
218
|
+
readonly entries: readonly PlayLogEntry[];
|
|
220
219
|
}
|
|
221
220
|
|
|
222
|
-
/** The newest `last` entries of
|
|
223
|
-
export function
|
|
224
|
-
const run =
|
|
221
|
+
/** The newest `last` entries of the log, walked from the newest end. */
|
|
222
|
+
export function tailPlayLog(last: number, kind?: string): PlayLogTail {
|
|
223
|
+
const { run } = held;
|
|
225
224
|
if (!run) return { total: 0, kinds: [], entries: [] };
|
|
226
|
-
const oldest = Math.max(0, run.total -
|
|
227
|
-
const entries:
|
|
225
|
+
const oldest = Math.max(0, run.total - PLAY_LOG_CAPACITY);
|
|
226
|
+
const entries: PlayLogEntry[] = [];
|
|
228
227
|
for (let seq = run.total - 1; seq >= oldest && entries.length < last; seq--) {
|
|
229
|
-
const entry = run.ring[seq %
|
|
228
|
+
const entry = run.ring[seq % PLAY_LOG_CAPACITY]!;
|
|
230
229
|
if (kind === undefined || entry.kind === kind) entries.push(entry);
|
|
231
230
|
}
|
|
232
231
|
return { total: run.total, kinds: [...run.kinds], entries: entries.reverse() };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STD'S DEVICE CONTROLS, AS PLAY SPEAKS THEM. Every word Play says to a player's page goes through this module, so a
|
|
3
|
+
* change in std's shapes is a change here alone. Each is std's own control (`POST /__std/devices/<id>/control`,
|
|
4
|
+
* std-dev's std-endpoints.ts), reached through play's session route (`../serving/std-dev.ts`), which holds the dev
|
|
5
|
+
* server's token:
|
|
6
|
+
*
|
|
7
|
+
* - `clock`: `{ control: 'clock', action: 'pause' | 'resume' | 'step' | 'speed', frames?, speed? }`, answered with the
|
|
8
|
+
* page's clock, `{ paused, speed, time, frame }` (`time` in seconds; speed one of 0.25, 0.5, 1, 2, 4; a step pauses
|
|
9
|
+
* first, then runs `frames` frames of 1/60 s). It holds the page: a room the game hosts beside it runs on, since
|
|
10
|
+
* Colyseus has no pause for a room.
|
|
11
|
+
* - `inspect`: `{ control: 'inspect' }`, answered with the scene the page last drew and its labelled screen nodes,
|
|
12
|
+
* `{ objects, screen, truncated? }` (at most 2000 objects and 500 nodes, `truncated` saying how many were left out).
|
|
13
|
+
* - `act`: `{ control: 'act', actions, observe? }`, std-driver/3 `Action`s through the page's own input path, answered
|
|
14
|
+
* with the observation (std-driver/3 `Observation`) where `observe` asks for one, else `{ ok: true }`. A refused
|
|
15
|
+
* action fails the whole control, the actions before it already taken.
|
|
16
|
+
*
|
|
17
|
+
* `clock`, `inspect` and `act` need a device of exactly one page (a tab or a frame, as each of Play's players is).
|
|
18
|
+
* Each refusal is std's own answer (HTTP 400, `{ error }`), thrown with its words.
|
|
19
|
+
* - `autoplay` and `manual`: who drives the page, its bot (with its slot and goal) or the person.
|
|
20
|
+
* - `capture`: the page's next frame, written by std's receiver, answered with its file.
|
|
21
|
+
*
|
|
22
|
+
* The session's other routes for a std game (whether the project is one, its dev server, its devices, its pages and
|
|
23
|
+
* their traces, its bot's goals) are asked through {@link askPlay} too.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ROUTE = '/__editor/play/std';
|
|
27
|
+
|
|
28
|
+
/** One of play's std routes, its JSON answer; a refusal thrown with what the route said. */
|
|
29
|
+
export async function askPlay<T>(path: string, init?: RequestInit): Promise<T> {
|
|
30
|
+
const response = await fetch(`${ROUTE}${path}`, init);
|
|
31
|
+
const body = (await response.json()) as T & { error?: string };
|
|
32
|
+
if (!response.ok) throw new Error(body.error ?? `${ROUTE}${path} answered ${response.status}`);
|
|
33
|
+
return body;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function control<T>(device: string, said: Record<string, unknown>): Promise<T> {
|
|
37
|
+
return askPlay<T>(`/devices/${encodeURIComponent(device)}/control`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'content-type': 'application/json' },
|
|
40
|
+
body: JSON.stringify(said),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A page's clock as std answers it: held or running, its speed, its game seconds and its frames. */
|
|
45
|
+
export interface StdClock {
|
|
46
|
+
readonly paused: boolean;
|
|
47
|
+
readonly speed: number;
|
|
48
|
+
readonly time: number;
|
|
49
|
+
readonly frame: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What Play asks of a page's clock. */
|
|
53
|
+
export type StdClockAction =
|
|
54
|
+
| { readonly action: 'pause' }
|
|
55
|
+
| { readonly action: 'resume' }
|
|
56
|
+
| { readonly action: 'step'; readonly frames: number }
|
|
57
|
+
| { readonly action: 'speed'; readonly speed: number };
|
|
58
|
+
|
|
59
|
+
/** A page's clock held, released, stepped or set to a speed; its clock after. */
|
|
60
|
+
export function stdClock(device: string, asked: StdClockAction): Promise<StdClock> {
|
|
61
|
+
return control<StdClock>(device, { control: 'clock', ...asked });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One object of the scene a page last drew, as std's inspect reads it. */
|
|
65
|
+
interface StdInspectedObject {
|
|
66
|
+
readonly name: string;
|
|
67
|
+
/** three's own `type`. */
|
|
68
|
+
readonly type: string;
|
|
69
|
+
/** Its names from the scene down, joined by `/`. */
|
|
70
|
+
readonly path: string;
|
|
71
|
+
/** Its world position, `[x, y, z]`. */
|
|
72
|
+
readonly position: readonly number[];
|
|
73
|
+
readonly visible: boolean;
|
|
74
|
+
/** Its label: its `userData`'s `role`, `aria-label` and `aria-description`. */
|
|
75
|
+
readonly userData?: { readonly role?: string; readonly 'aria-label'?: string; readonly 'aria-description'?: string };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The scene a page last drew and its labelled screen nodes, as std bounds them. */
|
|
79
|
+
export interface StdInspection {
|
|
80
|
+
readonly objects: readonly StdInspectedObject[];
|
|
81
|
+
readonly screen: readonly StdScreenNode[];
|
|
82
|
+
/** How many objects and nodes past std's bound were left out, where any were. */
|
|
83
|
+
readonly truncated?: { readonly objects: number; readonly screen: number };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One labelled node of a page's screen, as std's inspect names it (std-driver/3's `NodeObserved`). */
|
|
87
|
+
interface StdScreenNode {
|
|
88
|
+
readonly role?: string;
|
|
89
|
+
readonly name?: string;
|
|
90
|
+
readonly [key: string]: unknown;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function stdInspect(device: string): Promise<StdInspection> {
|
|
94
|
+
return control<StdInspection>(device, { control: 'inspect' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One std-driver/3 action: a key pressed or let go, or the pointer at a point of the page (0 to 1, x right, y down)
|
|
98
|
+
* with `button` pressed or let go. */
|
|
99
|
+
export type StdAction =
|
|
100
|
+
| { readonly key: { readonly key: string; readonly down: boolean } }
|
|
101
|
+
| { readonly pointer: { readonly x: number; readonly y: number; readonly button?: number; readonly down?: boolean } };
|
|
102
|
+
|
|
103
|
+
/** Actions taken through the page's own input path, as a player's own; the observation after them (std-driver/3's
|
|
104
|
+
* `Observation`) where asked, else `{ ok: true }`. */
|
|
105
|
+
export function stdAct(device: string, actions: readonly StdAction[], observe = false): Promise<unknown> {
|
|
106
|
+
return control(device, { control: 'act', actions, ...(observe ? { observe: true } : {}) });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Who drives a page: its bot, playing `goal` as the player `slot`, or the person. */
|
|
110
|
+
export function stdDrive(device: string, driver: { readonly slot: string; readonly goal: string } | null): Promise<unknown> {
|
|
111
|
+
return control(device, driver === null ? { control: 'manual' } : { control: 'autoplay', slot: driver.slot, goal: driver.goal });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A frame std's receiver wrote (`stdCapture`'s file), as the PNG itself. */
|
|
115
|
+
export async function stdCaptured(file: string): Promise<Blob> {
|
|
116
|
+
const name = file.split(/[\\/]/).at(-1) ?? file;
|
|
117
|
+
const response = await fetch(`${ROUTE}/capture?file=${encodeURIComponent(name)}`);
|
|
118
|
+
if (!response.ok) throw new Error(((await response.json()) as { error?: string }).error ?? `${ROUTE}/capture answered ${response.status}`);
|
|
119
|
+
return response.blob();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The page's next frame, captured by std's receiver; its file. */
|
|
123
|
+
export async function stdCapture(device: string): Promise<string> {
|
|
124
|
+
const { files } = await control<{ files: string[] }>(device, { control: 'capture', id: crypto.randomUUID() });
|
|
125
|
+
const file = files[0];
|
|
126
|
+
if (file === undefined) throw new Error(`std's receiver wrote no capture for ${device}.`);
|
|
127
|
+
return file;
|
|
128
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A STD GAME EXPORTED, AS PLAY ASKS IT (`POST /__editor/play/std/export`, `../serving/std-dev.ts`): to the web (`std
|
|
3
|
+
* build`) or to an engine, made one of the game's targets first where the asker says to add it (`std target add`), as
|
|
4
|
+
* an engine's editor builds for a platform once it is added. One export at a time, the session's per project. Its
|
|
5
|
+
* state (the target, whether it runs, the last line std printed, how it ended, whether the target was added, where the
|
|
6
|
+
* export is) is the open project's, a store the Game panel draws and the `export` verb answers with; each line std
|
|
7
|
+
* prints goes to the editor's console. Another project opened, the state is that project's: none yet.
|
|
8
|
+
*/
|
|
9
|
+
import { onProjectChange } from '@volter/sdk/active-project';
|
|
10
|
+
import { editorHost } from '@volter/sdk/host';
|
|
11
|
+
import { askPlay } from './std-controls';
|
|
12
|
+
|
|
13
|
+
/** Where a std game exports: the web, or an engine std ships to. */
|
|
14
|
+
export const EXPORT_TARGETS = ['web', 'roblox', 'unreal'] as const;
|
|
15
|
+
export type ExportTarget = (typeof EXPORT_TARGETS)[number];
|
|
16
|
+
export const isExportTarget = (value: unknown): value is ExportTarget =>
|
|
17
|
+
typeof value === 'string' && (EXPORT_TARGETS as readonly string[]).includes(value);
|
|
18
|
+
|
|
19
|
+
/** Each target's name as a person reads it. */
|
|
20
|
+
export const EXPORT_LABELS: Readonly<Record<ExportTarget, string>> = { web: 'Web', roblox: 'Roblox', unreal: 'Unreal' };
|
|
21
|
+
|
|
22
|
+
/** An export: running, or how it ended. */
|
|
23
|
+
export interface StdExport {
|
|
24
|
+
readonly target: ExportTarget;
|
|
25
|
+
readonly running: boolean;
|
|
26
|
+
/** The last line std printed. */
|
|
27
|
+
readonly line: string | null;
|
|
28
|
+
/** Whether it ended exported; null while it runs. */
|
|
29
|
+
readonly ok: boolean | null;
|
|
30
|
+
/** Whether the engine became a target of the game for it. */
|
|
31
|
+
readonly added: boolean;
|
|
32
|
+
/** Where the export is, from the game's folder, once it ended. */
|
|
33
|
+
readonly out: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let current: StdExport | null = null;
|
|
37
|
+
/** The export's start: only its own answers land, never an earlier start's still running. */
|
|
38
|
+
let started = 0;
|
|
39
|
+
const listeners = new Set<() => void>();
|
|
40
|
+
|
|
41
|
+
onProjectChange(() => {
|
|
42
|
+
current = null;
|
|
43
|
+
started += 1;
|
|
44
|
+
for (const listener of listeners) listener();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function publish(next: StdExport): void {
|
|
48
|
+
current = next;
|
|
49
|
+
for (const listener of listeners) listener();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The latest export, running or ended; null before any. */
|
|
53
|
+
export const stdExport = (): StdExport | null => current;
|
|
54
|
+
|
|
55
|
+
/** Told each time the export changes. */
|
|
56
|
+
export function onStdExport(listener: () => void): () => void {
|
|
57
|
+
listeners.add(listener);
|
|
58
|
+
return () => listeners.delete(listener);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The engines the game ships to besides the web (std's targets), as the session reads its packages. */
|
|
62
|
+
export async function stdTargets(): Promise<readonly ExportTarget[]> {
|
|
63
|
+
return (await askPlay<{ targets?: readonly ExportTarget[] }>('')).targets ?? [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The game exported to `target`, started, an engine added as the game's target first where `add` says (refused,
|
|
67
|
+
* by the session, for an engine the game doesn't target otherwise); refused while another export runs. Its state as
|
|
68
|
+
* it starts. */
|
|
69
|
+
export function startStdExport(target: ExportTarget, add = false): StdExport {
|
|
70
|
+
if (current?.running === true) throw new Error(`An export to ${EXPORT_LABELS[current.target]} is running: wait for it to end.`);
|
|
71
|
+
started += 1;
|
|
72
|
+
const mine = started;
|
|
73
|
+
publish({ target, running: true, line: null, ok: null, added: false, out: null });
|
|
74
|
+
// (an answer for another project opened since, or an earlier start, is the console's alone)
|
|
75
|
+
const ours = (): boolean => started === mine && current !== null;
|
|
76
|
+
const said = (line: string): void => {
|
|
77
|
+
editorHost().console.log(line, 'export');
|
|
78
|
+
if (ours() && current !== null) publish({ ...current, line });
|
|
79
|
+
};
|
|
80
|
+
const ended = (ok: boolean, added: boolean, out: string | null): void => {
|
|
81
|
+
const label = EXPORT_LABELS[target];
|
|
82
|
+
if (ok) editorHost().console.log(`Exported to ${label}: ${out ?? ''}${added ? ` (${label} added as a target)` : ''}`, 'export');
|
|
83
|
+
else editorHost().console.error(`The export to ${label} failed: ${ours() ? current?.line ?? 'std said nothing' : 'its project was closed'}`, 'export');
|
|
84
|
+
if (ours() && current !== null) publish({ ...current, running: false, ok, added, out });
|
|
85
|
+
};
|
|
86
|
+
void (async () => {
|
|
87
|
+
const response = await fetch('/__editor/play/std/export', {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'content-type': 'application/json' },
|
|
90
|
+
body: JSON.stringify({ target, add }),
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok || response.body === null) {
|
|
93
|
+
said(((await response.json().catch(() => ({}))) as { error?: string }).error ?? `The export answered ${response.status}.`);
|
|
94
|
+
ended(false, false, null);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
98
|
+
let rest = '';
|
|
99
|
+
for (;;) {
|
|
100
|
+
const { value, done } = await reader.read();
|
|
101
|
+
if (done) break;
|
|
102
|
+
rest += value;
|
|
103
|
+
for (let at = rest.indexOf('\n'); at >= 0; at = rest.indexOf('\n')) {
|
|
104
|
+
const message = JSON.parse(rest.slice(0, at)) as { line?: string; end?: { ok?: boolean; added?: boolean; out?: string } };
|
|
105
|
+
rest = rest.slice(at + 1);
|
|
106
|
+
if (message.end !== undefined) {
|
|
107
|
+
ended(message.end.ok === true, message.end.added === true, message.end.out ?? null);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (typeof message.line === 'string') said(message.line);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
said('The export ended before it said how.');
|
|
114
|
+
ended(false, false, null);
|
|
115
|
+
})().catch((error: unknown) => {
|
|
116
|
+
said(error instanceof Error ? error.message : String(error));
|
|
117
|
+
ended(false, false, null);
|
|
118
|
+
});
|
|
119
|
+
return current ?? { target, running: true, line: null, ok: null, added: false, out: null };
|
|
120
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A STD GAME'S EDITOR, as Play supplies it (`package.json#contributes.volter.projectAdapters`, for a project with
|
|
3
|
+
* `std.json`). A std game carries no editor code: std's design has the editor add nothing to the game (design 8), so
|
|
4
|
+
* the adapter a Cyclotron project would keep in `editor/volter.adapter.ts` is Play's. It is the one a models project
|
|
5
|
+
* gets from `cyclotron create`, Blender's layout, keymap and properties inspector, with the game's Blender files as its
|
|
6
|
+
* documents: every `.blend` under `levels/` and every prefab's under `prefabs/` (std's `prefabs/<name>/<name>.blend`),
|
|
7
|
+
* and each level (`levels/<Name>Level.tsx`) a document of its own (`stdLevelDocuments`), the main level the one the
|
|
8
|
+
* project opens on.
|
|
9
|
+
*/
|
|
10
|
+
import { defineAdapter } from '@volter/project/adapter/adapter-module';
|
|
11
|
+
import { ModelLayout } from '@volter/editor-blender/layouts';
|
|
12
|
+
import { blenderKeymap } from '@volter/editor-blender/looks';
|
|
13
|
+
// (the game's dev server serves this module, so the SDK's change channel it brings is that server's: it joins the
|
|
14
|
+
// editor's page to the server's file events, which a std game's own modules never import, and a level's document and
|
|
15
|
+
// Play's remount hear each save's export through it)
|
|
16
|
+
import '@volter/sdk/project-module-changes';
|
|
17
|
+
|
|
18
|
+
export default defineAdapter({
|
|
19
|
+
editor: { Layout: ModelLayout, keymap: blenderKeymap, inspector: 'properties' },
|
|
20
|
+
documents: {
|
|
21
|
+
find: [
|
|
22
|
+
{ finder: 'modelsFromBlendFiles', include: ['levels/**/*.blend', 'prefabs/**/*.blend'] },
|
|
23
|
+
{ finder: 'stdLevelDocuments', include: ['levels/**/*.tsx', 'levels/**/*.blend'] },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
});
|