@volter/editor-model-play 0.5.188 → 0.5.190
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 +71 -0
- package/contributions/model-play.command.ts +37 -0
- package/contributions/model-play.service.tsx +13 -2
- package/package.json +3 -2
- package/src/play-log.ts +183 -0
- package/src/play-materials.ts +173 -0
- package/src/play-script.ts +132 -15
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Volter Model Play
|
|
2
|
+
|
|
3
|
+
Play scripts on detached model documents. The header's Play runs
|
|
4
|
+
`src/models/<name>.play.ts` beside `src/models/<name>.blend` on a detached copy
|
|
5
|
+
of the model; the model, its selection and its history stand as they were, and
|
|
6
|
+
Stop returns to them. The script's default export is called once with the play
|
|
7
|
+
context (`ModelPlayContext`, `src/play-script.ts`) and answers the game:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import type { ModelPlayContext, ModelPlayGame } from '@volter/editor-model-play/play-script';
|
|
11
|
+
|
|
12
|
+
export default (play: ModelPlayContext): ModelPlayGame => {
|
|
13
|
+
const player = play.find('Player')!;
|
|
14
|
+
let checkpoint = false;
|
|
15
|
+
return {
|
|
16
|
+
update(dt) {
|
|
17
|
+
player.position.y += dt;
|
|
18
|
+
if (!checkpoint && player.position.y > 10) {
|
|
19
|
+
checkpoint = true;
|
|
20
|
+
play.tint('Checkpoint.Pad', '#2bff6b');
|
|
21
|
+
play.log('checkpoint', { stage: 2, at: player.position });
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## The play log
|
|
29
|
+
|
|
30
|
+
`play.log(kind, facts?)` writes one entry, stamped with `simT` (seconds of
|
|
31
|
+
simulation since Play started, the sum of the `dt`s given to `update`) and
|
|
32
|
+
`tick` (the update in progress). Facts are snapshotted as JSON when logged. The
|
|
33
|
+
runner adds `play-start`, `script-reload` (`{ reason: 'saved' | 'dependency-deleted', path }`),
|
|
34
|
+
`script-error` (`{ phase, message }`), `tint-unsupported` (`{ object, material, why }`)
|
|
35
|
+
and `play-stop` (`{ reason }`). Log transitions rather than every frame. Logging
|
|
36
|
+
never throws and never changes timing or state; a script's `log`, `tint` and
|
|
37
|
+
`setOpacity` do nothing once it has been replaced or Play has stopped.
|
|
38
|
+
|
|
39
|
+
Each model document keeps its own log; a read takes the active Play's unless it
|
|
40
|
+
names a document. The newest 5000 entries are kept (older ones are counted as
|
|
41
|
+
`dropped`), each entry's facts up to 2048 characters of JSON. Play from a fresh
|
|
42
|
+
copy empties the document's log; a script reload does not, and the log stays readable after Stop until the
|
|
43
|
+
next Play. `console.log` does not reach the session's console feed (only
|
|
44
|
+
warnings and errors do); the play log does.
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
volter-model-editor play-log # every kept entry, one per line
|
|
48
|
+
volter-model-editor play-log --kind death # one kind
|
|
49
|
+
volter-model-editor play-log --since 12.5 --json # entries at or after simT 12.5, as JSON
|
|
50
|
+
volter-model-editor play-log --document <id> # another model document's log
|
|
51
|
+
volter-model-editor eval "(await editor.modelPlayLog({ kind: 'death' })).entries"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Recolouring the copy
|
|
55
|
+
|
|
56
|
+
`play.tint(object, color)` draws an object (a name, or what `find` answered)
|
|
57
|
+
and every mesh under it in `color`; an emitting surface glows in it, and an
|
|
58
|
+
image texture is multiplied by it. `play.setOpacity(object, opacity)` fades it.
|
|
59
|
+
`null` returns the authored look. Other objects wearing the same Blender
|
|
60
|
+
material are not affected.
|
|
61
|
+
|
|
62
|
+
Use these instead of editing materials directly: a presented mesh's `material`
|
|
63
|
+
is an array with one `MeshPhysicalMaterial` per Blender material slot, shared by
|
|
64
|
+
every object with that material; the presenter re-assigns those slots whenever
|
|
65
|
+
it re-applies shading; `clone()` drops its shader hooks; and a node graph that
|
|
66
|
+
drives Base Color or Alpha ignores `color` and `opacity`. A tinted or faded
|
|
67
|
+
object wears copies of its own slots that draw the material's constant inputs
|
|
68
|
+
(so a node graph's other inputs are not drawn while it does), and keeps them
|
|
69
|
+
across a script reload. An object with a material the document cannot copy
|
|
70
|
+
(one the script made itself) is left as it is, and the log says so once with
|
|
71
|
+
`tint-unsupported`.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MODEL PLAY VERB of the session wire (`@volter/editor-sdk/commands`, a
|
|
3
|
+
* `workspace.command` contribution): `model-play-log`, the read behind
|
|
4
|
+
* `volter-model-editor play-log` and `editor.modelPlayLog()` in `eval`.
|
|
5
|
+
*
|
|
6
|
+
* It answers one model document's play log (`../src/play-log.ts`) — `documentId`'s, or the
|
|
7
|
+
* active Play's when omitted; the current run's, or the last one's after Stop — filtered by
|
|
8
|
+
* `since` (simulation seconds, inclusive) and `kind`. A read
|
|
9
|
+
* only: no play gate, since an empty log is the honest answer when nothing has played.
|
|
10
|
+
*/
|
|
11
|
+
import type { CommandContribution } from '@volter/editor-sdk/commands';
|
|
12
|
+
import { readModelPlayLog } from '../src/play-log';
|
|
13
|
+
|
|
14
|
+
export const point = 'workspace.command';
|
|
15
|
+
|
|
16
|
+
export const commands: CommandContribution['commands'] = {
|
|
17
|
+
'model-play-log': {
|
|
18
|
+
derivedRefresh: 'none',
|
|
19
|
+
handle: (command) => {
|
|
20
|
+
const since = command['since'];
|
|
21
|
+
const kind = command['kind'];
|
|
22
|
+
const documentId = command['documentId'];
|
|
23
|
+
if (documentId !== undefined && typeof documentId !== 'string')
|
|
24
|
+
return { ok: false, error: `model-play-log's documentId is a model document id, a string; it was given ${JSON.stringify(documentId)}.` };
|
|
25
|
+
if (since !== undefined && (typeof since !== 'number' || !Number.isFinite(since)))
|
|
26
|
+
return { ok: false, error: `model-play-log's since is simulation seconds, a number; it was given ${JSON.stringify(since)}.` };
|
|
27
|
+
if (kind !== undefined && typeof kind !== 'string')
|
|
28
|
+
return { ok: false, error: `model-play-log's kind is an entry kind, a string; it was given ${JSON.stringify(kind)}.` };
|
|
29
|
+
const reading = readModelPlayLog({
|
|
30
|
+
...(documentId === undefined ? {} : { documentId }),
|
|
31
|
+
...(since === undefined ? {} : { since }),
|
|
32
|
+
...(kind === undefined ? {} : { kind }),
|
|
33
|
+
});
|
|
34
|
+
return { ok: true, data: { ...reading } };
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -5,6 +5,7 @@ import { registerDocumentPlayExtension, type DocumentPlayControlProps } from '@v
|
|
|
5
5
|
import { escapeModelPlay, modelPlaying, setModelPlaying, subscribeModelPlay } from '../src/model-play';
|
|
6
6
|
import { runPlayScript } from '../src/play-script';
|
|
7
7
|
import type * as THREE from 'three';
|
|
8
|
+
import { getCurrentProject, onProjectChange } from '@volter/editor-sdk/kit/active-project';
|
|
8
9
|
|
|
9
10
|
export const point = 'workspace.service';
|
|
10
11
|
function usePlaying(documentId: string | undefined): boolean {
|
|
@@ -28,11 +29,21 @@ function Menu({ documentId, onClose }: DocumentPlayControlProps) {
|
|
|
28
29
|
}
|
|
29
30
|
export function start(): () => void {
|
|
30
31
|
const unregister = registerDocumentPlayExtension('model', {
|
|
31
|
-
Control, Menu, playing: modelPlaying, setPlaying: setModelPlaying, escape: escapeModelPlay,
|
|
32
|
+
Control, Menu, playing: modelPlaying, setPlaying: setModelPlaying, escape: escapeModelPlay,
|
|
33
|
+
subscribe(listener) {
|
|
34
|
+
const stopPlay = subscribeModelPlay(listener), stopProject = onProjectChange(listener);
|
|
35
|
+
return () => { stopPlay(); stopProject(); };
|
|
36
|
+
},
|
|
37
|
+
aspectRatio() {
|
|
38
|
+
const size = getCurrentProject()?.config.resolution;
|
|
39
|
+
const ratio = size ? size.width / size.height : null;
|
|
40
|
+
return ratio !== null && Number.isFinite(ratio) && ratio > 0 ? ratio : null;
|
|
41
|
+
},
|
|
32
42
|
run(stage) {
|
|
33
43
|
// The document kind lends native scene objects; this tool owns their Three types.
|
|
34
44
|
return runPlayScript({ ...stage, blend: stage.sourcePath, root: stage.root as THREE.Object3D,
|
|
35
|
-
camera: stage.camera as () => THREE.Camera, editingCamera: stage.editingCamera as () => THREE.Camera
|
|
45
|
+
camera: stage.camera as () => THREE.Camera, editingCamera: stage.editingCamera as () => THREE.Camera,
|
|
46
|
+
ownMaterial: stage.ownMaterial as ((material: THREE.Material) => THREE.Material | null) | undefined });
|
|
36
47
|
},
|
|
37
48
|
});
|
|
38
49
|
return unregister;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/editor-model-play",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.190",
|
|
4
4
|
"author": "Volter AI, Inc.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -23,11 +23,12 @@
|
|
|
23
23
|
},
|
|
24
24
|
"volter": {
|
|
25
25
|
"contributions": [
|
|
26
|
+
"./contributions/model-play.command.ts",
|
|
26
27
|
"./contributions/model-play.service.tsx"
|
|
27
28
|
]
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
|
-
"@volter/editor-sdk": "0.5.
|
|
31
|
+
"@volter/editor-sdk": "0.5.190"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
33
34
|
"react": "^19.0.0",
|
package/src/play-log.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MODEL PLAY LOG — what a play script says happened, stamped with the run's own clock.
|
|
3
|
+
*
|
|
4
|
+
* The game editor's play log is the receipt of a run (`play-*.jsonl`, `log-format.ts`); this
|
|
5
|
+
* is Model Play's. A script calls `play.log(kind, facts)` (`play-script.ts`) on the
|
|
6
|
+
* transitions that explain a run — a jump, a landing, a death and its cause, autoplay's
|
|
7
|
+
* choice — and an agent reads them back with `volter-model-editor play-log` or
|
|
8
|
+
* `editor.modelPlayLog()` in `eval`. `console.log` cannot do this job: the session's
|
|
9
|
+
* console feed keeps warnings and errors only.
|
|
10
|
+
*
|
|
11
|
+
* Every entry carries `simT` (the seconds of simulation since Play started: the sum of the
|
|
12
|
+
* `dt`s the runner has handed to `update`) and `tick` (the number of the update in
|
|
13
|
+
* progress; 0 while the script's default export runs), the same two stamps the game
|
|
14
|
+
* editor's entries carry. The runner adds its own lifecycle entries (`source: 'play'`):
|
|
15
|
+
* `play-start`, `script-reload` with its reason, `script-error`, `tint-unsupported`,
|
|
16
|
+
* `play-stop`.
|
|
17
|
+
*
|
|
18
|
+
* ONE LOG PER MODEL DOCUMENT, each its document's latest run. Two documents playing at once
|
|
19
|
+
* keep separate logs; a read names its document or takes the active Play's (the latest
|
|
20
|
+
* started that still plays, else the latest started). Play starting from a fresh copy
|
|
21
|
+
* replaces its document's log; a script reload does not, and the log outlives Stop so a
|
|
22
|
+
* finished run can be read. The runner writes through the {@link ModelPlayRun} handle Play
|
|
23
|
+
* started: once that run has ended its handle writes nothing, so a stale timer of an old
|
|
24
|
+
* script cannot reach the next run's log.
|
|
25
|
+
*
|
|
26
|
+
* BOUNDED AND PAGE-LIFETIME. The newest {@link MODEL_PLAY_LOG_CAPACITY} entries of a run are
|
|
27
|
+
* kept in a ring (older ones are counted in `dropped`); one entry's facts are kept as JSON of
|
|
28
|
+
* at most {@link MODEL_PLAY_LOG_FACTS_CHARS} characters. It is never stored: a reload of the
|
|
29
|
+
* page forgets it, as it forgets the game.
|
|
30
|
+
*
|
|
31
|
+
* Logging is a read of the script's own values: it never throws into the script and touches
|
|
32
|
+
* neither the clock nor the copy.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Entries a run's ring keeps; the oldest beyond this are dropped and counted. */
|
|
36
|
+
export const MODEL_PLAY_LOG_CAPACITY = 5000;
|
|
37
|
+
/** The longest JSON one entry's facts keep; longer facts are kept truncated, as a string. */
|
|
38
|
+
export const MODEL_PLAY_LOG_FACTS_CHARS = 2048;
|
|
39
|
+
|
|
40
|
+
export interface ModelPlayLogEntry {
|
|
41
|
+
/** The entry's place in this run, from 0: a gap in `seq` is entries the ring dropped. */
|
|
42
|
+
readonly seq: number;
|
|
43
|
+
/** Wall-clock ms. */
|
|
44
|
+
readonly t: number;
|
|
45
|
+
/** Seconds of simulation since Play started, across script reloads. */
|
|
46
|
+
readonly simT: number;
|
|
47
|
+
/** The update in progress (the first is 1); 0 before the first. */
|
|
48
|
+
readonly tick: number;
|
|
49
|
+
readonly kind: string;
|
|
50
|
+
/** `script` for the play script's own entries; `play` for the runner's lifecycle ones. */
|
|
51
|
+
readonly source: 'script' | 'play';
|
|
52
|
+
readonly facts?: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ModelPlayLogReading {
|
|
56
|
+
/** The run is still playing; false after Stop, or when nothing has played. */
|
|
57
|
+
readonly playing: boolean;
|
|
58
|
+
/** The model document and play script of the run read; null when nothing has played. */
|
|
59
|
+
readonly documentId: string | null;
|
|
60
|
+
readonly script: string | null;
|
|
61
|
+
readonly startedAt: number | null;
|
|
62
|
+
/** Every model document with a log, so a read can name another. */
|
|
63
|
+
readonly documents: readonly string[];
|
|
64
|
+
/** The run's clock now. */
|
|
65
|
+
readonly simT: number;
|
|
66
|
+
readonly tick: number;
|
|
67
|
+
readonly capacity: number;
|
|
68
|
+
/** Entries this run has written, and how many of those the ring has dropped. */
|
|
69
|
+
readonly total: number;
|
|
70
|
+
readonly dropped: number;
|
|
71
|
+
/** The kept entries that match the read's filters, oldest first. */
|
|
72
|
+
readonly entries: readonly ModelPlayLogEntry[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ModelPlayLogQuery {
|
|
76
|
+
/** The model document whose log to read; the active Play's when omitted. */
|
|
77
|
+
readonly documentId?: string;
|
|
78
|
+
/** Only entries at or after this simulation time (seconds). */
|
|
79
|
+
readonly since?: number;
|
|
80
|
+
/** Only entries of this kind. */
|
|
81
|
+
readonly kind?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Run {
|
|
85
|
+
readonly documentId: string;
|
|
86
|
+
readonly script: string;
|
|
87
|
+
readonly startedAt: number;
|
|
88
|
+
playing: boolean;
|
|
89
|
+
simT: number;
|
|
90
|
+
tick: number;
|
|
91
|
+
readonly ring: ModelPlayLogEntry[];
|
|
92
|
+
total: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The runner's door onto its own run's log: inert once the run has ended. */
|
|
96
|
+
export interface ModelPlayRun {
|
|
97
|
+
/** The runner is about to call `update(deltaSeconds)`. */
|
|
98
|
+
advance(deltaSeconds: number): void;
|
|
99
|
+
append(source: ModelPlayLogEntry['source'], kind: string, facts?: unknown): void;
|
|
100
|
+
/** `play-stop`, then nothing more is written; the entries stay readable. */
|
|
101
|
+
end(facts?: Record<string, unknown>): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// One registry per page, whichever copy of this module a contribution loaded it through: the
|
|
105
|
+
// runner writes it from the service, the session verb reads it from the command module.
|
|
106
|
+
const key = Symbol.for('volter.model-play-log');
|
|
107
|
+
const page = globalThis as typeof globalThis & { [key]?: Map<string, Run> };
|
|
108
|
+
const runs: Map<string, Run> = page[key] ??= new Map();
|
|
109
|
+
|
|
110
|
+
function keptFacts(facts: unknown): Record<string, unknown> | undefined {
|
|
111
|
+
if (facts === undefined || facts === null) return undefined;
|
|
112
|
+
let json: string | undefined;
|
|
113
|
+
try { json = JSON.stringify(facts); }
|
|
114
|
+
catch (error) { return { unserializable: error instanceof Error ? error.message : String(error) }; }
|
|
115
|
+
if (json === undefined) return undefined;
|
|
116
|
+
if (json.length > MODEL_PLAY_LOG_FACTS_CHARS) return { truncated: json.slice(0, MODEL_PLAY_LOG_FACTS_CHARS) };
|
|
117
|
+
// A snapshot, so a script that goes on mutating what it logged does not rewrite history.
|
|
118
|
+
const value: unknown = JSON.parse(json);
|
|
119
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : { value };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function append(run: Run, source: ModelPlayLogEntry['source'], kind: string, facts?: unknown): void {
|
|
123
|
+
try {
|
|
124
|
+
const kept = keptFacts(facts);
|
|
125
|
+
const entry: ModelPlayLogEntry = {
|
|
126
|
+
seq: run.total, t: Date.now(), simT: run.simT, tick: run.tick, kind: String(kind), source,
|
|
127
|
+
...(kept ? { facts: kept } : {}),
|
|
128
|
+
};
|
|
129
|
+
run.ring[run.total % MODEL_PLAY_LOG_CAPACITY] = entry;
|
|
130
|
+
run.total += 1;
|
|
131
|
+
} catch { /* A log that cannot be written is not the script's failure. */ }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Play started from a fresh copy: the document's log replaced by an empty one at zero,
|
|
135
|
+
* opened by `play-start`. */
|
|
136
|
+
export function beginModelPlayLog(documentId: string, script: string): ModelPlayRun {
|
|
137
|
+
const run: Run = { documentId, script, startedAt: Date.now(), playing: true, simT: 0, tick: 0, ring: [], total: 0 };
|
|
138
|
+
// Re-inserted, so the map's order is the order Plays started.
|
|
139
|
+
runs.delete(documentId);
|
|
140
|
+
runs.set(documentId, run);
|
|
141
|
+
append(run, 'play', 'play-start', { documentId, script });
|
|
142
|
+
return {
|
|
143
|
+
advance(deltaSeconds) {
|
|
144
|
+
if (!run.playing) return;
|
|
145
|
+
run.tick += 1;
|
|
146
|
+
run.simT += deltaSeconds;
|
|
147
|
+
},
|
|
148
|
+
append(source, kind, facts) { if (run.playing) append(run, source, kind, facts); },
|
|
149
|
+
end(facts) {
|
|
150
|
+
if (!run.playing) return;
|
|
151
|
+
append(run, 'play', 'play-stop', facts);
|
|
152
|
+
run.playing = false;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function activeRun(): Run | undefined {
|
|
158
|
+
const started = [...runs.values()].reverse();
|
|
159
|
+
return started.find((run) => run.playing) ?? started[0];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function readModelPlayLog(query: ModelPlayLogQuery = {}): ModelPlayLogReading {
|
|
163
|
+
const run = query.documentId === undefined ? activeRun() : runs.get(query.documentId);
|
|
164
|
+
const documents = [...runs.keys()];
|
|
165
|
+
if (!run) {
|
|
166
|
+
return {
|
|
167
|
+
playing: false, documentId: query.documentId ?? null, script: null, startedAt: null, documents,
|
|
168
|
+
simT: 0, tick: 0, capacity: MODEL_PLAY_LOG_CAPACITY, total: 0, dropped: 0, entries: [],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const dropped = Math.max(0, run.total - MODEL_PLAY_LOG_CAPACITY);
|
|
172
|
+
const entries: ModelPlayLogEntry[] = [];
|
|
173
|
+
for (let seq = dropped; seq < run.total; seq++) {
|
|
174
|
+
const entry = run.ring[seq % MODEL_PLAY_LOG_CAPACITY]!;
|
|
175
|
+
if (query.since !== undefined && entry.simT < query.since) continue;
|
|
176
|
+
if (query.kind !== undefined && entry.kind !== query.kind) continue;
|
|
177
|
+
entries.push(entry);
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
playing: run.playing, documentId: run.documentId, script: run.script, startedAt: run.startedAt, documents,
|
|
181
|
+
simT: run.simT, tick: run.tick, capacity: MODEL_PLAY_LOG_CAPACITY, total: run.total, dropped, entries,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RECOLOURING THE DETACHED COPY — `play.tint` and `play.setOpacity` (`play-script.ts`).
|
|
3
|
+
*
|
|
4
|
+
* Changing a presented mesh's colour the three.js way does not work on the copy, for reasons
|
|
5
|
+
* the presenter has and a script cannot see:
|
|
6
|
+
*
|
|
7
|
+
* - A Blender mesh's `material` is an ARRAY, one entry per material slot (a lone material only
|
|
8
|
+
* for an object with no slots, which wears the presenter's grey fallback), so
|
|
9
|
+
* `mesh.material.color` is `undefined`.
|
|
10
|
+
* - The entries are SHARED: one material per Blender material, worn by every object with it
|
|
11
|
+
* in a slot. Recolouring one in place recolours them all.
|
|
12
|
+
* - The presenter OWNS `mesh.material`: whenever it re-applies shading it assigns every mesh's
|
|
13
|
+
* slots again from its own table, so materials a script assigned are put back.
|
|
14
|
+
* - `clone()` drops the presenter's draw hooks (its shader, private uniforms and node graph).
|
|
15
|
+
* - A material whose Base Color or Alpha a node graph drives never reads `color` or `opacity`:
|
|
16
|
+
* the graph's output replaces them in the shader.
|
|
17
|
+
*
|
|
18
|
+
* So an override is per OBJECT: the object's slots are swapped for copies of their own (the
|
|
19
|
+
* document's `ownMaterial`, which keeps its hooks and draws the material's constants, its
|
|
20
|
+
* image texture included), the copies carry the tint and opacity, and the slots the presenter
|
|
21
|
+
* gave are remembered. A slot the document cannot copy (no `ownMaterial`, or a material that
|
|
22
|
+
* is not the document's) leaves the object as it is and says so through `unsupported`; a
|
|
23
|
+
* `clone()` would draw without the presenter's hooks.
|
|
24
|
+
*
|
|
25
|
+
* The presenter's slots are re-read whenever they may have moved: an object whose slots the
|
|
26
|
+
* presenter has put back is given copies of the slots it now has, both each frame and before
|
|
27
|
+
* any change, so clearing returns the presenter's current slots, never a stale set. An object
|
|
28
|
+
* the script removed from the copy is let go on the next frame.
|
|
29
|
+
*/
|
|
30
|
+
import type * as THREE from 'three';
|
|
31
|
+
|
|
32
|
+
type Slots = THREE.Material | THREE.Material[];
|
|
33
|
+
type Colored = THREE.Material & { color?: THREE.Color; emissive?: THREE.Color };
|
|
34
|
+
|
|
35
|
+
interface Override {
|
|
36
|
+
readonly mesh: THREE.Mesh;
|
|
37
|
+
authored: Slots;
|
|
38
|
+
shown: Slots;
|
|
39
|
+
copies: Colored[];
|
|
40
|
+
color: THREE.ColorRepresentation | null;
|
|
41
|
+
opacity: number | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const slotsOf = (slots: Slots): THREE.Material[] => Array.isArray(slots) ? slots : [slots];
|
|
45
|
+
const isColor = (value: unknown): value is THREE.Color =>
|
|
46
|
+
typeof value === 'object' && value !== null && (value as THREE.Color).isColor === true;
|
|
47
|
+
|
|
48
|
+
export interface MaterialOverrides {
|
|
49
|
+
tint(target: THREE.Object3D, color: THREE.ColorRepresentation | null): void;
|
|
50
|
+
setOpacity(target: THREE.Object3D, opacity: number | null): void;
|
|
51
|
+
/** After the script's update: let go of removed objects, and re-wear the copies on any
|
|
52
|
+
* object the presenter re-dressed. */
|
|
53
|
+
frame(): void;
|
|
54
|
+
/** Return every object the presenter's slots and release the copies; later calls do nothing. */
|
|
55
|
+
dispose(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `ownMaterial` answers the document's copy of one of its materials, or null for one that is
|
|
60
|
+
* not the document's. `unsupported` hears of an object that could not be overridden.
|
|
61
|
+
*/
|
|
62
|
+
export function materialOverrides(options: {
|
|
63
|
+
readonly root: THREE.Object3D;
|
|
64
|
+
readonly ownMaterial: ((material: THREE.Material) => THREE.Material | null) | undefined;
|
|
65
|
+
readonly unsupported: (mesh: THREE.Mesh, material: THREE.Material) => void;
|
|
66
|
+
}): MaterialOverrides {
|
|
67
|
+
const { root, ownMaterial } = options;
|
|
68
|
+
const overrides = new Map<THREE.Mesh, Override>();
|
|
69
|
+
let disposed = false;
|
|
70
|
+
const release = (override: Override): void => {
|
|
71
|
+
for (const copy of override.copies) copy.dispose();
|
|
72
|
+
override.copies = [];
|
|
73
|
+
};
|
|
74
|
+
/** Wear copies of the object's slots; false (and nothing worn) when one cannot be copied. */
|
|
75
|
+
const dress = (override: Override): boolean => {
|
|
76
|
+
release(override);
|
|
77
|
+
const copies: Colored[] = [];
|
|
78
|
+
for (const material of slotsOf(override.authored)) {
|
|
79
|
+
const copy = ownMaterial?.(material) ?? null;
|
|
80
|
+
if (!copy) {
|
|
81
|
+
for (const made of copies) made.dispose();
|
|
82
|
+
override.mesh.material = override.authored;
|
|
83
|
+
options.unsupported(override.mesh, material);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
copies.push(copy as Colored);
|
|
87
|
+
}
|
|
88
|
+
override.copies = copies;
|
|
89
|
+
override.shown = Array.isArray(override.authored) ? copies : copies[0]!;
|
|
90
|
+
override.mesh.material = override.shown;
|
|
91
|
+
return true;
|
|
92
|
+
};
|
|
93
|
+
// Values only: the copies were made from the authored slots, so each starts from its own.
|
|
94
|
+
const paint = (override: Override): void => {
|
|
95
|
+
const authored = slotsOf(override.authored) as Colored[];
|
|
96
|
+
override.copies.forEach((copy, index) => {
|
|
97
|
+
const source = authored[index]!;
|
|
98
|
+
if (isColor(copy.color) && isColor(source.color)) {
|
|
99
|
+
if (override.color === null) copy.color.copy(source.color);
|
|
100
|
+
else copy.color.set(override.color);
|
|
101
|
+
}
|
|
102
|
+
// An emitting surface glows in its tint; one that does not stays unlit by it.
|
|
103
|
+
if (isColor(copy.emissive) && isColor(source.emissive)) {
|
|
104
|
+
const emits = source.emissive.r + source.emissive.g + source.emissive.b > 0;
|
|
105
|
+
if (override.color === null || !emits) copy.emissive.copy(source.emissive);
|
|
106
|
+
else copy.emissive.set(override.color);
|
|
107
|
+
}
|
|
108
|
+
copy.opacity = override.opacity ?? source.opacity;
|
|
109
|
+
const transparent = source.transparent || copy.opacity < 1;
|
|
110
|
+
if (copy.transparent !== transparent) {
|
|
111
|
+
copy.transparent = transparent;
|
|
112
|
+
copy.needsUpdate = true;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
const forget = (override: Override): void => {
|
|
117
|
+
if (override.mesh.material === override.shown) override.mesh.material = override.authored;
|
|
118
|
+
release(override);
|
|
119
|
+
overrides.delete(override.mesh);
|
|
120
|
+
};
|
|
121
|
+
/** The presenter re-assigned the object's slots since it last wore copies: adopt its slots
|
|
122
|
+
* as the authored ones and dress again. False when they cannot be copied. */
|
|
123
|
+
const resync = (override: Override): boolean => {
|
|
124
|
+
if (override.mesh.material === override.shown) return true;
|
|
125
|
+
override.authored = override.mesh.material;
|
|
126
|
+
override.shown = override.mesh.material;
|
|
127
|
+
if (dress(override)) return true;
|
|
128
|
+
release(override);
|
|
129
|
+
overrides.delete(override.mesh);
|
|
130
|
+
return false;
|
|
131
|
+
};
|
|
132
|
+
const change = (target: THREE.Object3D, edit: (override: Override) => void): void => {
|
|
133
|
+
if (disposed) return;
|
|
134
|
+
target.traverse((object) => {
|
|
135
|
+
const mesh = object as THREE.Mesh;
|
|
136
|
+
if (mesh.isMesh !== true || !mesh.material) return;
|
|
137
|
+
let override = overrides.get(mesh);
|
|
138
|
+
if (override && !resync(override)) override = undefined;
|
|
139
|
+
if (!override) {
|
|
140
|
+
const fresh: Override = { mesh, authored: mesh.material, shown: mesh.material, copies: [], color: null, opacity: null };
|
|
141
|
+
edit(fresh);
|
|
142
|
+
if (fresh.color === null && fresh.opacity === null) return;
|
|
143
|
+
if (!dress(fresh)) return;
|
|
144
|
+
overrides.set(mesh, fresh);
|
|
145
|
+
override = fresh;
|
|
146
|
+
} else edit(override);
|
|
147
|
+
if (override.color === null && override.opacity === null) forget(override);
|
|
148
|
+
else paint(override);
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
const inCopy = (object: THREE.Object3D): boolean => {
|
|
152
|
+
for (let at: THREE.Object3D | null = object; at; at = at.parent) if (at === root) return true;
|
|
153
|
+
return false;
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
tint(target, color) { change(target, (override) => { override.color = color; }); },
|
|
157
|
+
setOpacity(target, opacity) {
|
|
158
|
+
if (opacity !== null && !Number.isFinite(opacity)) throw new Error(`setOpacity takes a number from 0 to 1, or null; it was given ${String(opacity)}.`);
|
|
159
|
+
const value = opacity === null ? null : Math.min(1, Math.max(0, opacity));
|
|
160
|
+
change(target, (override) => { override.opacity = value; });
|
|
161
|
+
},
|
|
162
|
+
frame() {
|
|
163
|
+
for (const override of [...overrides.values()]) {
|
|
164
|
+
if (!inCopy(override.mesh)) { forget(override); continue; }
|
|
165
|
+
if (override.mesh.material !== override.shown && resync(override)) paint(override);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
dispose() {
|
|
169
|
+
disposed = true;
|
|
170
|
+
for (const override of [...overrides.values()]) forget(override);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/play-script.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* update, holding keys empty until arrival. Stop freezes the copy and blends
|
|
24
24
|
* back before disposing it; Escape during either blend completes that blend.
|
|
25
25
|
*/
|
|
26
|
+
import { editorHost } from '@volter/editor-sdk/host';
|
|
26
27
|
import { getCurrentProject } from '@volter/editor-sdk/kit/active-project';
|
|
27
28
|
import { surfaceAcceptsKey, surfaceHoldsKeyboard } from '@volter/editor-sdk/kit/surface-keyboard';
|
|
28
29
|
import {
|
|
@@ -38,6 +39,8 @@ import { projectPlayLayers } from '@volter/editor-sdk/kit/project-play-layers';
|
|
|
38
39
|
import { beginProjectMountEpoch, projectEntryImportUrl } from '@volter/editor-sdk/session/project-module-url';
|
|
39
40
|
import { cameraTransition } from './camera-transition';
|
|
40
41
|
import { finishModelPlay, registerModelPlayStop } from './model-play';
|
|
42
|
+
import { beginModelPlayLog } from './play-log';
|
|
43
|
+
import { materialOverrides } from './play-materials';
|
|
41
44
|
interface PlayComposition {
|
|
42
45
|
readonly entries: readonly string[];
|
|
43
46
|
loadScript(): Promise<{ default?: unknown }>;
|
|
@@ -57,6 +60,38 @@ export interface ModelPlayContext {
|
|
|
57
60
|
readonly camera: THREE.Camera;
|
|
58
61
|
/** The keys held now, by `KeyboardEvent.code` (`ArrowUp`, `KeyW`, `Space`). */
|
|
59
62
|
readonly keys: ReadonlySet<string>;
|
|
63
|
+
/**
|
|
64
|
+
* Write one entry to the play log, stamped with the run's simulation time and frame
|
|
65
|
+
* (`play-log.ts`): a kind and the facts that explain it, JSON-serialisable and snapshotted
|
|
66
|
+
* now. Log transitions (a landing, a death and its cause, autoplay's choice), not every
|
|
67
|
+
* frame. Read back with `volter-model-editor play-log [--since <simT>] [--kind <k>] [--json]`
|
|
68
|
+
* or `editor.modelPlayLog({ since, kind })` in `eval`. Never throws, never changes the game,
|
|
69
|
+
* and does nothing once this script has been replaced or Play has stopped.
|
|
70
|
+
*
|
|
71
|
+
* play.log('death', { cause: 'lava', at: player.position, stage });
|
|
72
|
+
*/
|
|
73
|
+
log(kind: string, facts?: Record<string, unknown>): void;
|
|
74
|
+
/**
|
|
75
|
+
* Draw an object and the meshes under it in `color` (a `THREE.Color`, `'#2bff6b'`,
|
|
76
|
+
* `0x2bff6b`); an emitting surface glows in it too, an image texture is multiplied by it.
|
|
77
|
+
* `null` returns the authored colours. Other objects wearing the same Blender material keep
|
|
78
|
+
* theirs. An object is a name or one `find` answered.
|
|
79
|
+
*
|
|
80
|
+
* THE PRESENTED MATERIALS, which is why this exists: a mesh's `material` is an array with
|
|
81
|
+
* one shared `MeshPhysicalMaterial` per Blender material slot (a lone material only for an
|
|
82
|
+
* object without slots), so `material.color` is undefined, and editing an entry recolours
|
|
83
|
+
* every object wearing that Blender material. The presenter re-assigns those slots
|
|
84
|
+
* whenever it re-applies shading, `clone()` drops its shader hooks, and a node graph
|
|
85
|
+
* driving Base Color or Alpha ignores `color` and `opacity`. A tinted or faded object
|
|
86
|
+
* wears copies of its own slots, drawn from their constant inputs (a node graph's
|
|
87
|
+
* other inputs are not drawn while it does), and keeps them through a script reload. An
|
|
88
|
+
* object with a material the document cannot copy (one the script made) is left as it is,
|
|
89
|
+
* and the log says so once with a `tint-unsupported` entry.
|
|
90
|
+
*/
|
|
91
|
+
tint(object: THREE.Object3D | string, color: THREE.ColorRepresentation | null): void;
|
|
92
|
+
/** Fade an object and the meshes under it to `opacity` (0 to 1); `null` returns the
|
|
93
|
+
* authored opacity. Its colour is untouched, and the copies are `tint`'s. */
|
|
94
|
+
setOpacity(object: THREE.Object3D | string, opacity: number | null): void;
|
|
60
95
|
}
|
|
61
96
|
|
|
62
97
|
export interface ModelPlayGame {
|
|
@@ -106,14 +141,43 @@ export function runPlayScript(options: {
|
|
|
106
141
|
readonly container: HTMLElement;
|
|
107
142
|
readonly ready: () => void;
|
|
108
143
|
readonly returning: () => void;
|
|
144
|
+
readonly ownMaterial?: ((material: THREE.Material) => THREE.Material | null) | undefined;
|
|
109
145
|
}): () => void {
|
|
110
|
-
const { blend, root, camera, onFrame
|
|
146
|
+
const { blend, root, camera, onFrame } = options;
|
|
111
147
|
const modulePath = playScriptPath(blend);
|
|
148
|
+
// A fresh copy is a fresh run: its log starts empty, its clock at zero. Writes go through
|
|
149
|
+
// this run's handle, which is inert once the run has ended.
|
|
150
|
+
const run = beginModelPlayLog(options.documentId, modulePath);
|
|
151
|
+
const report = (phase: 'start' | 'update' | 'stop', title: string, error: unknown): void => {
|
|
152
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
153
|
+
run.append('play', 'script-error', { phase, message: detail });
|
|
154
|
+
options.report(title, detail);
|
|
155
|
+
};
|
|
156
|
+
// Said once per object: a script that tints every frame would otherwise fill the log.
|
|
157
|
+
const unsupported = new WeakSet<THREE.Mesh>();
|
|
158
|
+
const materials = materialOverrides({
|
|
159
|
+
root,
|
|
160
|
+
ownMaterial: options.ownMaterial,
|
|
161
|
+
unsupported: (mesh, material) => {
|
|
162
|
+
if (unsupported.has(mesh)) return;
|
|
163
|
+
unsupported.add(mesh);
|
|
164
|
+
run.append('play', 'tint-unsupported', { object: mesh.name, material: material.name,
|
|
165
|
+
why: options.ownMaterial ? 'the material is not one the document presents' : 'this document lends no material copies' });
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const objectOf = (target: THREE.Object3D | string): THREE.Object3D => {
|
|
169
|
+
if (typeof target !== 'string') return target;
|
|
170
|
+
const object = root.getObjectByName(target);
|
|
171
|
+
if (!object) throw new Error(`The model has no object named ${target}.`);
|
|
172
|
+
return object;
|
|
173
|
+
};
|
|
112
174
|
const keys = new Set<string>();
|
|
113
175
|
const heldKeys = new Set<string>();
|
|
114
176
|
const transition = cameraTransition(options.editingCamera());
|
|
115
177
|
options.container.style.opacity = '0';
|
|
116
|
-
|
|
178
|
+
/** One script's context: its log, tint and opacity do nothing once that script is gone
|
|
179
|
+
* (replaced, failed, or the run stopped), so a stale timer cannot reach a later one. */
|
|
180
|
+
const contextFor = (alive: { value: boolean }): ModelPlayContext => ({
|
|
117
181
|
root,
|
|
118
182
|
find(name) {
|
|
119
183
|
const object = root.getObjectByName(name) ?? null;
|
|
@@ -125,11 +189,30 @@ export function runPlayScript(options: {
|
|
|
125
189
|
return camera();
|
|
126
190
|
},
|
|
127
191
|
keys,
|
|
128
|
-
|
|
192
|
+
log(kind, facts) { if (alive.value) run.append('script', kind, facts); },
|
|
193
|
+
tint(object, color) { if (alive.value) materials.tint(objectOf(object), color); },
|
|
194
|
+
setOpacity(object, opacity) { if (alive.value) materials.setOpacity(objectOf(object), opacity); },
|
|
195
|
+
});
|
|
196
|
+
const scripts = new WeakMap<ModelPlayGame, { value: boolean }>();
|
|
129
197
|
let stopped = false;
|
|
130
198
|
let attempt = 0;
|
|
131
199
|
let game: ModelPlayGame | null = null;
|
|
132
200
|
let composition: PlayComposition | null = null;
|
|
201
|
+
let startedAt: number | null = null;
|
|
202
|
+
let endedAt: number | null = null;
|
|
203
|
+
const live = editorHost().live;
|
|
204
|
+
const unregisterLive = live.register({
|
|
205
|
+
id: `model-script:${options.documentId}`,
|
|
206
|
+
mounted: () => !stopped && game !== null,
|
|
207
|
+
// The live game owns the viewport through the camera return. Its update
|
|
208
|
+
// loop is held during that transition; Edit begins when disposal completes.
|
|
209
|
+
playing: () => !stopped && game !== null,
|
|
210
|
+
stop: () => { if (!stopped) finishModelPlay(options.documentId); },
|
|
211
|
+
instanceContainer: (id) => !id && !stopped && game !== null ? options.container : null,
|
|
212
|
+
startedAt: () => startedAt,
|
|
213
|
+
endedAt: () => endedAt,
|
|
214
|
+
surface: () => 'three',
|
|
215
|
+
});
|
|
133
216
|
let pending: { game: ModelPlayGame; composition: PlayComposition | null } | null = null;
|
|
134
217
|
// A failed mount/update still owns a dependency graph whose next save retries it.
|
|
135
218
|
let retryEntries: readonly string[] = [];
|
|
@@ -137,25 +220,34 @@ export function runPlayScript(options: {
|
|
|
137
220
|
const dispose = (ending: ModelPlayGame | null, layers: PlayComposition | null): void => {
|
|
138
221
|
try { ending?.dispose?.(); }
|
|
139
222
|
catch (error) {
|
|
140
|
-
report(`${modulePath} failed while stopping`, error
|
|
141
|
-
} finally {
|
|
223
|
+
report('stop', `${modulePath} failed while stopping`, error);
|
|
224
|
+
} finally {
|
|
225
|
+
// After its own dispose, which may still log; nothing it scheduled may.
|
|
226
|
+
const alive = ending && scripts.get(ending);
|
|
227
|
+
if (alive) alive.value = false;
|
|
228
|
+
layers?.dispose();
|
|
229
|
+
}
|
|
142
230
|
};
|
|
143
231
|
const end = (): void => {
|
|
144
232
|
dispose(game, composition);
|
|
145
233
|
game = null;
|
|
146
234
|
composition = null;
|
|
235
|
+
if (startedAt !== null) endedAt = Date.now();
|
|
236
|
+
live.notifyChanged();
|
|
147
237
|
};
|
|
148
|
-
|
|
238
|
+
/** `reload` says why a replacement mounts, for the log's `script-reload`; null for Play's first start. */
|
|
239
|
+
const start = async (reload: { readonly reason: string; readonly path: string } | null): Promise<void> => {
|
|
149
240
|
const mine = ++attempt;
|
|
150
241
|
if (pending) { dispose(pending.game, pending.composition); pending = null; }
|
|
151
242
|
let nextComposition: PlayComposition | undefined;
|
|
243
|
+
const alive = { value: true };
|
|
152
244
|
try {
|
|
153
245
|
if (mountLayers) {
|
|
154
246
|
const project = getCurrentProject();
|
|
155
247
|
if (!project) throw new Error('No project is open.');
|
|
156
248
|
const epoch = beginProjectMountEpoch();
|
|
157
249
|
const container = document.createElement('div');
|
|
158
|
-
container.dataset
|
|
250
|
+
container.dataset['mountEpoch'] = String(epoch);
|
|
159
251
|
Object.assign(container.style, { position: 'absolute', inset: '0', visibility: 'hidden', pointerEvents: 'none' });
|
|
160
252
|
options.container.appendChild(container);
|
|
161
253
|
let layers;
|
|
@@ -173,21 +265,27 @@ export function runPlayScript(options: {
|
|
|
173
265
|
};
|
|
174
266
|
}
|
|
175
267
|
if (stopped || mine !== attempt) { nextComposition?.dispose(); return; }
|
|
176
|
-
|
|
268
|
+
// Before the replacement's default export runs, so what it logs follows this.
|
|
269
|
+
if (reload) run.append('play', 'script-reload', reload);
|
|
270
|
+
const next = await startGame(modulePath, contextFor(alive), nextComposition);
|
|
271
|
+
scripts.set(next, alive);
|
|
177
272
|
if (stopped || mine !== attempt) {
|
|
178
273
|
dispose(next, nextComposition ?? null);
|
|
179
274
|
return;
|
|
180
275
|
}
|
|
181
276
|
pending = { game: next, composition: nextComposition ?? null };
|
|
182
277
|
} catch (error) {
|
|
278
|
+
alive.value = false;
|
|
183
279
|
nextComposition?.dispose();
|
|
184
280
|
if (stopped || mine !== attempt) return;
|
|
185
|
-
report(`${modulePath} did not start`, error
|
|
281
|
+
report('start', `${modulePath} did not start`, error);
|
|
186
282
|
}
|
|
187
283
|
};
|
|
188
284
|
let firstFrame = true;
|
|
189
285
|
let returning = false;
|
|
286
|
+
let stopReason = 'stop';
|
|
190
287
|
const stopRequest = registerModelPlayStop(options.documentId, (escape) => {
|
|
288
|
+
if (escape) stopReason = 'escape';
|
|
191
289
|
keys.clear();
|
|
192
290
|
heldKeys.clear();
|
|
193
291
|
if (firstFrame || transition.stop(escape)) finishModelPlay(options.documentId);
|
|
@@ -203,6 +301,7 @@ export function runPlayScript(options: {
|
|
|
203
301
|
else if (!transition.acceptingKeys()) keys.clear();
|
|
204
302
|
else for (const key of heldKeys) keys.add(key);
|
|
205
303
|
let replacementUpdated = false;
|
|
304
|
+
if (pending || game) run.advance(deltaSeconds);
|
|
206
305
|
if (pending) {
|
|
207
306
|
const next = pending;
|
|
208
307
|
pending = null;
|
|
@@ -210,12 +309,15 @@ export function runPlayScript(options: {
|
|
|
210
309
|
next.game.update(deltaSeconds);
|
|
211
310
|
end();
|
|
212
311
|
game = next.game;
|
|
312
|
+
startedAt = Date.now();
|
|
313
|
+
endedAt = null;
|
|
213
314
|
composition = next.composition;
|
|
315
|
+
live.notifyChanged();
|
|
214
316
|
composition?.reveal();
|
|
215
317
|
replacementUpdated = true;
|
|
216
318
|
} catch (error) {
|
|
217
319
|
dispose(next.game, next.composition);
|
|
218
|
-
report(`${modulePath} did not start`, error
|
|
320
|
+
report('start', `${modulePath} did not start`, error);
|
|
219
321
|
}
|
|
220
322
|
}
|
|
221
323
|
if (game === null) return;
|
|
@@ -226,9 +328,11 @@ export function runPlayScript(options: {
|
|
|
226
328
|
if (firstFrame) { firstFrame = false; options.ready(); }
|
|
227
329
|
} catch (error) {
|
|
228
330
|
end();
|
|
229
|
-
report(`${modulePath} failed`, error
|
|
331
|
+
report('update', `${modulePath} failed`, error);
|
|
230
332
|
return;
|
|
231
333
|
}
|
|
334
|
+
materials.frame();
|
|
335
|
+
keys.clear();
|
|
232
336
|
root.updateMatrixWorld(true);
|
|
233
337
|
});
|
|
234
338
|
const onKeyDown = (event: KeyboardEvent): void => {
|
|
@@ -237,22 +341,33 @@ export function runPlayScript(options: {
|
|
|
237
341
|
if (transition.acceptingKeys()) keys.add(event.code);
|
|
238
342
|
};
|
|
239
343
|
const onKeyUp = (event: KeyboardEvent): void => {
|
|
240
|
-
|
|
344
|
+
// Preserve a between-frame tap until one game update has observed it.
|
|
241
345
|
heldKeys.delete(event.code);
|
|
242
346
|
};
|
|
243
347
|
const onBlur = (): void => { keys.clear(); heldKeys.clear(); };
|
|
244
348
|
window.addEventListener('keydown', onKeyDown, true);
|
|
245
349
|
window.addEventListener('keyup', onKeyUp, true);
|
|
246
350
|
window.addEventListener('blur', onBlur);
|
|
247
|
-
const stopChanges = subscribeProjectModuleChange((changed, affected) => {
|
|
351
|
+
const stopChanges = subscribeProjectModuleChange((changed, affected, type) => {
|
|
352
|
+
// Removing this run's entry ends its lifetime. A rename can remove it
|
|
353
|
+
// before the model document switches; importing that vanished entry
|
|
354
|
+
// would manufacture a mount failure while the old game is still alive.
|
|
355
|
+
// A missing dependency continues through the ordinary error-reporting path.
|
|
356
|
+
if (type === 'delete' && projectModuleChangeMatches(changed, modulePath)) {
|
|
357
|
+
stopReason = 'script-deleted';
|
|
358
|
+
finishModelPlay(options.documentId);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
248
361
|
// A composed HUD and script must remount together, including shared-store
|
|
249
362
|
// edits. A fresh epoch is threaded to both through the UI tool door.
|
|
250
363
|
const entries = [modulePath, ...retryEntries, ...(composition?.entries ?? []), ...(pending?.composition?.entries ?? [])];
|
|
251
|
-
if ([changed, ...(affected ?? [])].some(path => entries.some(entry => projectModuleChangeMatches(path, entry))))
|
|
364
|
+
if ([changed, ...(affected ?? [])].some(path => entries.some(entry => projectModuleChangeMatches(path, entry))))
|
|
365
|
+
void start({ reason: type === 'delete' ? 'dependency-deleted' : 'saved', path: changed });
|
|
252
366
|
});
|
|
253
|
-
void start();
|
|
367
|
+
void start(null);
|
|
254
368
|
return () => {
|
|
255
369
|
stopped = true;
|
|
370
|
+
unregisterLive();
|
|
256
371
|
stopRequest();
|
|
257
372
|
finishModelPlay(options.documentId);
|
|
258
373
|
stopFrames();
|
|
@@ -262,5 +377,7 @@ export function runPlayScript(options: {
|
|
|
262
377
|
window.removeEventListener('blur', onBlur);
|
|
263
378
|
if (pending) { dispose(pending.game, pending.composition); pending = null; }
|
|
264
379
|
end();
|
|
380
|
+
materials.dispose();
|
|
381
|
+
run.end({ reason: stopReason });
|
|
265
382
|
};
|
|
266
383
|
}
|