@defold-typescript/transpiler 0.30.0 → 0.31.0

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.
Files changed (37) hide show
  1. package/dist/animation-sources.d.ts +6 -0
  2. package/dist/{sprite-animation-index.d.ts → component-animation-index.d.ts} +4 -4
  3. package/dist/config-key-index.d.ts +1 -0
  4. package/dist/gui-flipbook-index.d.ts +8 -0
  5. package/dist/gui-node-index.d.ts +21 -0
  6. package/dist/index.d.ts +19 -9
  7. package/dist/index.js +1329 -400
  8. package/dist/library-dependencies.d.ts +47 -0
  9. package/dist/scene-address-declaration.d.ts +4 -2
  10. package/dist/scene-collection-roles.d.ts +30 -0
  11. package/dist/scene-component-index.d.ts +20 -0
  12. package/dist/scene-documents.d.ts +3 -0
  13. package/dist/scene-naming-context.d.ts +66 -0
  14. package/dist/scene-object-path-index.d.ts +4 -1
  15. package/dist/url-address-literals.d.ts +10 -0
  16. package/dist/url-address-slots.d.ts +3 -1
  17. package/dist/url-cross-world-address.d.ts +41 -0
  18. package/dist/url-fragment-reachability.d.ts +3 -0
  19. package/package.json +2 -2
  20. package/src/animation-sources.ts +90 -0
  21. package/src/component-animation-index.ts +399 -0
  22. package/src/config-key-index.ts +28 -2
  23. package/src/gui-flipbook-index.ts +91 -0
  24. package/src/gui-node-index.ts +42 -4
  25. package/src/index.ts +52 -10
  26. package/src/library-dependencies.ts +337 -0
  27. package/src/scene-address-declaration.ts +29 -12
  28. package/src/scene-collection-roles.ts +267 -0
  29. package/src/scene-component-index.ts +74 -6
  30. package/src/scene-documents.ts +66 -6
  31. package/src/scene-naming-context.ts +130 -0
  32. package/src/scene-object-path-index.ts +172 -33
  33. package/src/url-address-literals.ts +115 -0
  34. package/src/url-address-slots.ts +23 -2
  35. package/src/url-cross-world-address.ts +109 -0
  36. package/src/url-fragment-reachability.ts +72 -93
  37. package/src/sprite-animation-index.ts +0 -241
@@ -0,0 +1,399 @@
1
+ import {
2
+ animationSetEntries,
3
+ normalizeDocumentKey,
4
+ readAnimationSources,
5
+ resourceKey,
6
+ } from "./animation-sources";
7
+ import { parseSceneTextFormat, type SceneMessage, SceneTextFormatError } from "./scene-text-format";
8
+
9
+ // The animation ids each script may play, keyed first by the project-relative
10
+ // resource path of the `.script` that owns them and then by the id of the
11
+ // component they belong to, plus an honest record of every link that could not
12
+ // be settled. A missing key means nothing may be offered for it; an empty set
13
+ // means the component resolved and declares no animation.
14
+ export interface ComponentAnimationIndex {
15
+ readonly byScriptResource: ReadonlyMap<string, ReadonlyMap<string, ReadonlySet<string>>>;
16
+ // The display path of the document declaring each animation id — an atlas or
17
+ // tile source for a sprite, and for a model the animation set that *lists the
18
+ // entry* producing the id rather than the root the `.model` points at — under
19
+ // the same two keys plus the id itself. Its per-component key set is exactly
20
+ // that component's id set in `byScriptResource`, so the two can never
21
+ // disagree about which ids a component declares.
22
+ readonly sourceByScriptResource: ReadonlyMap<
23
+ string,
24
+ ReadonlyMap<string, ReadonlyMap<string, string>>
25
+ >;
26
+ readonly unresolved: readonly string[];
27
+ }
28
+
29
+ const SCRIPT_SUFFIX = ".script";
30
+ const SPRITE_SUFFIX = ".sprite";
31
+ const MODEL_SUFFIX = ".model";
32
+ const ANIMATION_SET_SUFFIX = ".animationset";
33
+
34
+ // The only mesh containers `AnimationSetBuilder` accepts. Anything else in an
35
+ // entry is a build error, so it may contribute no id.
36
+ const MESH_SUFFIXES = [".gltf", ".glb"];
37
+
38
+ function firstField(message: SceneMessage, name: string): string | undefined {
39
+ return (message.fields.get(name) ?? [])[0];
40
+ }
41
+
42
+ function childrenOf(message: SceneMessage, name: string): readonly SceneMessage[] {
43
+ return message.messages.get(name) ?? [];
44
+ }
45
+
46
+ // The id an animation-set entry contributes, before its prefix: the entry's
47
+ // filename with its extension dropped. The names inside the mesh container are
48
+ // never read — for a set the editor stamps the owning name on every animation
49
+ // the file holds, so the path is the whole answer.
50
+ function basename(resource: string): string {
51
+ const file = resource.slice(resource.lastIndexOf("/") + 1);
52
+ const dot = file.lastIndexOf(".");
53
+ return dot === -1 ? file : file.slice(0, dot);
54
+ }
55
+
56
+ // Which kind of document a component reads its ids from. The two resolve
57
+ // differently — a tile source declares ids outright, an animation set names
58
+ // files whose basenames are the ids — while sharing one key space, because
59
+ // component ids are unique within a game object.
60
+ type AnimationSourceKind = "sprite" | "model";
61
+
62
+ interface AnimationSource {
63
+ readonly kind: AnimationSourceKind;
64
+ readonly path: string;
65
+ }
66
+
67
+ interface AssetIndex {
68
+ readonly animationsByTileSet: Map<string, Set<string>>;
69
+ readonly tileSetBySprite: Map<string, string>;
70
+ readonly modelDocuments: Set<string>;
71
+ readonly animationSourceByModel: Map<string, string>;
72
+ readonly entriesByAnimationSet: Map<string, readonly string[]>;
73
+ }
74
+
75
+ function readAssets(assets: ReadonlyMap<string, string>, unresolved: string[]): AssetIndex {
76
+ const animationsByTileSet = readAnimationSources(assets, unresolved);
77
+ const tileSetBySprite = new Map<string, string>();
78
+ const modelDocuments = new Set<string>();
79
+ const animationSourceByModel = new Map<string, string>();
80
+ const entriesByAnimationSet = new Map<string, readonly string[]>();
81
+
82
+ for (const [path, text] of assets) {
83
+ const displayPath = normalizeDocumentKey(path);
84
+ const isSprite = displayPath.endsWith(SPRITE_SUFFIX);
85
+ const isModel = displayPath.endsWith(MODEL_SUFFIX);
86
+ const isAnimationSet = displayPath.endsWith(ANIMATION_SET_SUFFIX);
87
+ if (!isSprite && !isModel && !isAnimationSet) continue;
88
+ let document: SceneMessage;
89
+ try {
90
+ document = parseSceneTextFormat(text);
91
+ } catch (error) {
92
+ if (!(error instanceof SceneTextFormatError)) throw error;
93
+ unresolved.push(`${displayPath}: could not be parsed (${error.message})`);
94
+ continue;
95
+ }
96
+ if (isSprite) {
97
+ const tileSet = firstField(document, "tile_set");
98
+ if (tileSet !== undefined) tileSetBySprite.set(displayPath, resourceKey(tileSet));
99
+ continue;
100
+ }
101
+ if (isModel) {
102
+ modelDocuments.add(displayPath);
103
+ const animations = firstField(document, "animations");
104
+ if (animations !== undefined) animationSourceByModel.set(displayPath, animations);
105
+ continue;
106
+ }
107
+ entriesByAnimationSet.set(displayPath, animationSetEntries(document));
108
+ }
109
+
110
+ return {
111
+ animationsByTileSet,
112
+ tileSetBySprite,
113
+ modelDocuments,
114
+ animationSourceByModel,
115
+ entriesByAnimationSet,
116
+ };
117
+ }
118
+
119
+ // The ids one animation set contributes, each mapped to the display path of the
120
+ // set that lists the entry producing it, or `undefined` when the chain itself
121
+ // could not be read. An entry in an unsupported format is refused on its own
122
+ // and the rest of the set still stands, because that entry is a build error the
123
+ // author sees; a missing document or a cycle breaks the chain instead, and a
124
+ // partial answer from a chain that is not understood is exactly the wrong guess
125
+ // this index refuses to make.
126
+ function resolveAnimationSet(input: {
127
+ path: string;
128
+ prefix: string;
129
+ stack: Set<string>;
130
+ assets: AssetIndex;
131
+ unresolved: string[];
132
+ }): Map<string, string> | undefined {
133
+ const { path, prefix, stack, assets, unresolved } = input;
134
+ const entries = assets.entriesByAnimationSet.get(path);
135
+ if (entries === undefined) return undefined;
136
+
137
+ // Branch-local, not global: a set reached from two different entries is
138
+ // reached twice legitimately, and only a set re-entered while still being
139
+ // read is a cycle.
140
+ stack.add(path);
141
+ try {
142
+ const ids = new Map<string, string>();
143
+ for (const entry of entries) {
144
+ const key = resourceKey(entry);
145
+ if (MESH_SUFFIXES.some((suffix) => key.endsWith(suffix))) {
146
+ ids.set(prefix + basename(key), path);
147
+ continue;
148
+ }
149
+ if (!key.endsWith(ANIMATION_SET_SUFFIX)) {
150
+ unresolved.push(
151
+ `${path}: the entry ${entry} is not a .gltf, .glb or .animationset, so it declares no animation id`,
152
+ );
153
+ continue;
154
+ }
155
+ if (!assets.entriesByAnimationSet.has(key)) {
156
+ unresolved.push(
157
+ `${path}: the entry ${entry} is not among the project's animation set documents`,
158
+ );
159
+ return undefined;
160
+ }
161
+ if (stack.has(key)) {
162
+ unresolved.push(
163
+ `${path}: the entry ${entry} re-enters an animation set already being read, so the chain is cyclic`,
164
+ );
165
+ return undefined;
166
+ }
167
+ const nested = resolveAnimationSet({
168
+ path: key,
169
+ prefix: `${prefix + basename(key)}/`,
170
+ stack,
171
+ assets,
172
+ unresolved,
173
+ });
174
+ if (nested === undefined) return undefined;
175
+ for (const [id, declaring] of nested) if (!ids.has(id)) ids.set(id, declaring);
176
+ }
177
+ return ids;
178
+ } finally {
179
+ stack.delete(path);
180
+ }
181
+ }
182
+
183
+ // The animation-declaring components of one game object, as
184
+ // `id -> source document`. An embedded component carries its own payload; a
185
+ // referenced one names a document that carries it instead. A `.model` naming a
186
+ // mesh source directly is refused here rather than resolved: the editor and the
187
+ // build pipeline disagree about which ids such a model can play, so no id is
188
+ // established and the model is named in `unresolved`.
189
+ function componentAnimationSources(
190
+ object: SceneMessage,
191
+ displayPath: string,
192
+ assets: AssetIndex,
193
+ unresolved: string[],
194
+ ): Map<string, AnimationSource> {
195
+ const sources = new Map<string, AnimationSource>();
196
+
197
+ function claimModel(id: string, animations: string): void {
198
+ if (!animations.endsWith(ANIMATION_SET_SUFFIX)) {
199
+ unresolved.push(
200
+ `${displayPath}: the model component "${id}" names ${animations} rather than an animation set, so none of its animation ids are established`,
201
+ );
202
+ return;
203
+ }
204
+ sources.set(id, { kind: "model", path: resourceKey(animations) });
205
+ }
206
+
207
+ for (const embedded of childrenOf(object, "embedded_components")) {
208
+ const type = firstField(embedded, "type");
209
+ if (type !== "sprite" && type !== "model") continue;
210
+ const id = firstField(embedded, "id");
211
+ const payload = firstField(embedded, "data");
212
+ if (id === undefined || payload === undefined) continue;
213
+ let document: SceneMessage;
214
+ try {
215
+ document = parseSceneTextFormat(payload);
216
+ } catch (error) {
217
+ if (!(error instanceof SceneTextFormatError)) throw error;
218
+ unresolved.push(
219
+ `${displayPath}: the embedded ${type} "${id}" could not be read (${error.message})`,
220
+ );
221
+ continue;
222
+ }
223
+ if (type === "sprite") {
224
+ const tileSet = firstField(document, "tile_set");
225
+ if (tileSet !== undefined) sources.set(id, { kind: "sprite", path: resourceKey(tileSet) });
226
+ continue;
227
+ }
228
+ const animations = firstField(document, "animations");
229
+ if (animations !== undefined) claimModel(id, animations);
230
+ }
231
+
232
+ for (const referenced of childrenOf(object, "components")) {
233
+ const component = firstField(referenced, "component");
234
+ const id = firstField(referenced, "id");
235
+ if (id === undefined || component === undefined) continue;
236
+ if (component.endsWith(SPRITE_SUFFIX)) {
237
+ const tileSet = assets.tileSetBySprite.get(resourceKey(component));
238
+ if (tileSet === undefined) {
239
+ unresolved.push(
240
+ `${displayPath}: the sprite component "${id}" names ${component}, which is not among the project's sprite documents`,
241
+ );
242
+ continue;
243
+ }
244
+ sources.set(id, { kind: "sprite", path: tileSet });
245
+ continue;
246
+ }
247
+ if (!component.endsWith(MODEL_SUFFIX)) continue;
248
+ const resource = resourceKey(component);
249
+ if (!assets.modelDocuments.has(resource)) {
250
+ unresolved.push(
251
+ `${displayPath}: the model component "${id}" names ${component}, which is not among the project's model documents`,
252
+ );
253
+ continue;
254
+ }
255
+ // A model that declares no animations at all is a legitimate authoring
256
+ // state, not a broken link: it resolves to nothing and says nothing.
257
+ const animations = assets.animationSourceByModel.get(resource);
258
+ if (animations !== undefined) claimModel(id, animations);
259
+ }
260
+
261
+ return sources;
262
+ }
263
+
264
+ function scriptResourcesOf(object: SceneMessage): string[] {
265
+ const resources: string[] = [];
266
+ for (const component of childrenOf(object, "components")) {
267
+ const resource = firstField(component, "component");
268
+ if (resource === undefined || !resource.endsWith(SCRIPT_SUFFIX)) continue;
269
+ const key = resourceKey(resource);
270
+ if (key !== SCRIPT_SUFFIX.slice(1)) resources.push(key);
271
+ }
272
+ return resources;
273
+ }
274
+
275
+ function isGameObject(message: SceneMessage): boolean {
276
+ return message.messages.has("components") || message.messages.has("embedded_components");
277
+ }
278
+
279
+ export function buildComponentAnimationIndex(input: {
280
+ scenes: ReadonlyMap<string, string>;
281
+ assets: ReadonlyMap<string, string>;
282
+ }): ComponentAnimationIndex {
283
+ const unresolved: string[] = [];
284
+ const assets = readAssets(input.assets, unresolved);
285
+ const byScriptResource = new Map<string, ReadonlyMap<string, ReadonlySet<string>>>();
286
+ const sourceByScriptResource = new Map<
287
+ string,
288
+ ReadonlyMap<string, ReadonlyMap<string, string>>
289
+ >();
290
+ const claimedBy = new Map<string, string>();
291
+
292
+ // A `.go` document is a game object at its root; a `.collection` carries one
293
+ // inside each `embedded_instances` `data:` payload, decoded exactly one level
294
+ // at a time. No `instances { prototype: }` edge is followed: the referenced
295
+ // `.go` is its own document and already claims its own script.
296
+ function walk(message: SceneMessage, blockName: string, displayPath: string): void {
297
+ if (isGameObject(message)) {
298
+ claim(message, displayPath);
299
+ }
300
+ if (blockName === "embedded_instances") {
301
+ for (const payload of message.fields.get("data") ?? []) {
302
+ try {
303
+ walk(parseSceneTextFormat(payload), "", displayPath);
304
+ } catch (error) {
305
+ if (!(error instanceof SceneTextFormatError)) throw error;
306
+ unresolved.push(
307
+ `${displayPath}: an embedded game object could not be read (${error.message})`,
308
+ );
309
+ }
310
+ }
311
+ }
312
+ for (const [name, nested] of message.messages) {
313
+ for (const child of nested) {
314
+ walk(child, name, displayPath);
315
+ }
316
+ }
317
+ }
318
+
319
+ function idsOf(
320
+ id: string,
321
+ source: AnimationSource,
322
+ displayPath: string,
323
+ ): Map<string, string> | undefined {
324
+ if (source.kind === "sprite") {
325
+ const declared = assets.animationsByTileSet.get(source.path);
326
+ if (declared !== undefined) return new Map([...declared].map((name) => [name, source.path]));
327
+ unresolved.push(
328
+ `${displayPath}: the sprite component "${id}" names the tile source /${source.path}, which is not among the project's asset documents`,
329
+ );
330
+ return undefined;
331
+ }
332
+ const resolved = resolveAnimationSet({
333
+ path: source.path,
334
+ prefix: "",
335
+ stack: new Set(),
336
+ assets,
337
+ unresolved,
338
+ });
339
+ if (resolved !== undefined) return resolved;
340
+ if (!assets.entriesByAnimationSet.has(source.path)) {
341
+ unresolved.push(
342
+ `${displayPath}: the model component "${id}" names the animation set /${source.path}, which is not among the project's asset documents`,
343
+ );
344
+ }
345
+ return undefined;
346
+ }
347
+
348
+ function claim(object: SceneMessage, displayPath: string): void {
349
+ const scripts = scriptResourcesOf(object);
350
+ if (scripts.length === 0) return;
351
+
352
+ const sources = componentAnimationSources(object, displayPath, assets, unresolved);
353
+ const animations = new Map<string, ReadonlySet<string>>();
354
+ const declaringSources = new Map<string, ReadonlyMap<string, string>>();
355
+ for (const [id, source] of sources) {
356
+ const resolved = idsOf(id, source, displayPath);
357
+ if (resolved === undefined) continue;
358
+ animations.set(id, new Set(resolved.keys()));
359
+ declaringSources.set(id, resolved);
360
+ }
361
+
362
+ for (const key of scripts) {
363
+ const owner = claimedBy.get(key);
364
+ if (owner !== undefined) {
365
+ byScriptResource.delete(key);
366
+ sourceByScriptResource.delete(key);
367
+ unresolved.push(
368
+ `${key}: claimed by both ${owner} and ${displayPath}, so its component animations are ambiguous`,
369
+ );
370
+ continue;
371
+ }
372
+ claimedBy.set(key, displayPath);
373
+ byScriptResource.set(key, animations);
374
+ sourceByScriptResource.set(key, declaringSources);
375
+ }
376
+ }
377
+
378
+ for (const [path, text] of input.scenes) {
379
+ const displayPath = normalizeDocumentKey(path);
380
+ try {
381
+ walk(parseSceneTextFormat(text), "", displayPath);
382
+ } catch (error) {
383
+ if (!(error instanceof SceneTextFormatError)) throw error;
384
+ unresolved.push(`${displayPath}: could not be parsed (${error.message})`);
385
+ }
386
+ }
387
+
388
+ return { byScriptResource, sourceByScriptResource, unresolved };
389
+ }
390
+
391
+ // The component id a same-object `"#id"` address names, or `undefined` for
392
+ // every other form. A path form names a component on a *different* game
393
+ // object, so its id must never be resolved against this script's own.
394
+ export function componentIdOfSameObjectAddress(address: string): string | undefined {
395
+ if (!address.startsWith("#")) return undefined;
396
+ const id = address.slice(1);
397
+ if (id === "" || id.includes("/") || id.includes("#")) return undefined;
398
+ return id;
399
+ }
@@ -4,8 +4,9 @@
4
4
  //
5
5
  // Deliberately a line reader rather than a parse of the scene text format:
6
6
  // `game.project` is a flat INI the editor writes, the same shape
7
- // `readExtensionDependencies` already walks. A key may carry a `#`
8
- // (`dependencies#0`), which is part of its name here and never a fragment.
7
+ // `readGameProjectDependencies` (library-dependencies.ts) already walks. A key
8
+ // may carry a `#` (`dependencies#0`), which is part of its name here and never a
9
+ // fragment.
9
10
 
10
11
  function sectionNameOf(line: string): string | undefined {
11
12
  const match = line.trim().match(/^\[(.+)\]$/);
@@ -32,3 +33,28 @@ export function buildConfigKeyIndex(gameProjectText: string): ReadonlySet<string
32
33
  }
33
34
  return keys;
34
35
  }
36
+
37
+ // One declared value, read the same way `buildConfigKeyIndex` reads the whole
38
+ // file. The section is matched in full rather than as a prefix, so a
39
+ // `main_collection_backup` beside `main_collection` answers neither.
40
+ export function readGameProjectSetting(
41
+ gameProjectText: string,
42
+ section: string,
43
+ key: string,
44
+ ): string | undefined {
45
+ let current: string | undefined;
46
+ for (const line of gameProjectText.split("\n")) {
47
+ const header = sectionNameOf(line);
48
+ if (header !== undefined) {
49
+ current = header;
50
+ continue;
51
+ }
52
+ if (current !== section) continue;
53
+ const separator = line.indexOf("=");
54
+ if (separator === -1) continue;
55
+ if (line.slice(0, separator).trim() !== key) continue;
56
+ const value = line.slice(separator + 1).trim();
57
+ if (value !== "") return value;
58
+ }
59
+ return undefined;
60
+ }
@@ -0,0 +1,91 @@
1
+ import { normalizeDocumentKey, readAnimationSources, resourceKey } from "./animation-sources";
2
+ import { parseSceneTextFormat, type SceneMessage, SceneTextFormatError } from "./scene-text-format";
3
+
4
+ // The flipbook animation ids each gui script may play, keyed by the
5
+ // project-relative resource path of the `.gui_script` that owns them, and then
6
+ // by animation id — whose value is the display path of every named texture
7
+ // declaring it, so a completion reads the keys and a provenance panel reads the
8
+ // value. A missing key means nothing may be offered for it; an empty map means
9
+ // the scene resolved and its textures declare no animation.
10
+ export interface GuiFlipbookIndex {
11
+ readonly byScriptResource: ReadonlyMap<string, ReadonlyMap<string, ReadonlySet<string>>>;
12
+ readonly unresolved: readonly string[];
13
+ }
14
+
15
+ // The scope is the `.gui` scene naming this script, never the node the call
16
+ // addresses: `gui.set_texture` retargets a node at runtime, so scoping to a
17
+ // node's authored texture would reject calls that are valid. The scene's
18
+ // `textures` blocks are the honest universe.
19
+ const SCRIPT_SUFFIX = ".gui_script";
20
+
21
+ function scriptKeyOf(script: string): string | undefined {
22
+ if (!script.endsWith(SCRIPT_SUFFIX)) return undefined;
23
+ const key = resourceKey(script);
24
+ return key === SCRIPT_SUFFIX ? undefined : key;
25
+ }
26
+
27
+ export function buildGuiFlipbookIndex(input: {
28
+ scenes: ReadonlyMap<string, string>;
29
+ assets: ReadonlyMap<string, string>;
30
+ }): GuiFlipbookIndex {
31
+ const unresolved: string[] = [];
32
+ const animationsBySource = readAnimationSources(input.assets, unresolved);
33
+ const byScriptResource = new Map<string, ReadonlyMap<string, ReadonlySet<string>>>();
34
+ const claimedBy = new Map<string, string>();
35
+
36
+ for (const [path, text] of input.scenes) {
37
+ const displayPath = normalizeDocumentKey(path);
38
+ let document: SceneMessage;
39
+ try {
40
+ document = parseSceneTextFormat(text);
41
+ } catch (error) {
42
+ if (!(error instanceof SceneTextFormatError)) throw error;
43
+ unresolved.push(`${displayPath}: could not be parsed (${error.message})`);
44
+ continue;
45
+ }
46
+
47
+ const [script] = document.fields.get("script") ?? [];
48
+ if (script === undefined) continue;
49
+ const key = scriptKeyOf(script);
50
+ if (key === undefined) continue;
51
+
52
+ const owner = claimedBy.get(key);
53
+ if (owner !== undefined) {
54
+ byScriptResource.delete(key);
55
+ unresolved.push(
56
+ `${key}: claimed by both ${owner} and ${displayPath}, so its flipbook animations are ambiguous`,
57
+ );
58
+ continue;
59
+ }
60
+ claimedBy.set(key, displayPath);
61
+
62
+ // Top-level `textures` only, for the reason `buildGuiNodeIndex` reads only
63
+ // top-level `nodes`: a `layouts` or `templates` block repeats an authored
64
+ // surface under an override rather than naming a source of its own.
65
+ const declaredBy = new Map<string, Set<string>>();
66
+ for (const message of document.messages.get("textures") ?? []) {
67
+ for (const texture of message.fields.get("texture") ?? []) {
68
+ if (texture === "") continue;
69
+ const source = resourceKey(texture);
70
+ const ids = animationsBySource.get(source);
71
+ if (ids === undefined) {
72
+ unresolved.push(
73
+ `${displayPath}: the texture ${texture} is not among the project's asset documents`,
74
+ );
75
+ continue;
76
+ }
77
+ for (const id of ids) {
78
+ const declarers = declaredBy.get(id);
79
+ if (declarers) {
80
+ declarers.add(source);
81
+ } else {
82
+ declaredBy.set(id, new Set([source]));
83
+ }
84
+ }
85
+ }
86
+ }
87
+ byScriptResource.set(key, declaredBy);
88
+ }
89
+
90
+ return { byScriptResource, unresolved };
91
+ }
@@ -1,4 +1,4 @@
1
- import { parseSceneTextFormat, SceneTextFormatError } from "./scene-text-format";
1
+ import { parseSceneTextFormat, type SceneMessage, SceneTextFormatError } from "./scene-text-format";
2
2
 
3
3
  // The node ids each script may address, keyed by the project-relative resource
4
4
  // path of the `.gui_script` that owns them, plus an honest record of every scene
@@ -25,6 +25,46 @@ function scriptKeyOf(script: string): string | undefined {
25
25
  return key === SCRIPT_SUFFIX ? undefined : key;
26
26
  }
27
27
 
28
+ /**
29
+ * The gui-script resource one already-parsed `.gui` names, keyed the way this
30
+ * index keys it. Exported so the naming-context resolver reaches a gui script
31
+ * through the same rule rather than a second reader — the aggregation on top
32
+ * differs on purpose (this index refuses a resource two scenes claim, while two
33
+ * scenes driving one gui script really is two naming contexts), but the key
34
+ * shape has one owner.
35
+ */
36
+ export function guiScriptResourceOf(document: SceneMessage): string | undefined {
37
+ const [script] = document.fields.get("script") ?? [];
38
+ return script === undefined ? undefined : scriptKeyOf(script);
39
+ }
40
+
41
+ /**
42
+ * Every `.gui` document's display path mapped to the gui-script resource it
43
+ * names, skipping a `.gui` that names none and one this parser cannot read.
44
+ *
45
+ * The map `buildScriptNamingContexts` consumes, built once here so the editor
46
+ * plugin and the CLI reach a gui script through one reader. Unlike
47
+ * `buildGuiNodeIndex` it records no ambiguity: two `.gui` scenes driving one gui
48
+ * script really are two naming contexts, while they would make that script's
49
+ * node ids unanswerable.
50
+ */
51
+ export function guiScriptResourcesOf(
52
+ documents: ReadonlyMap<string, string>,
53
+ ): ReadonlyMap<string, string> {
54
+ const scripts = new Map<string, string>();
55
+ for (const [displayPath, text] of documents) {
56
+ let resource: string | undefined;
57
+ try {
58
+ resource = guiScriptResourceOf(parseSceneTextFormat(text));
59
+ } catch (error) {
60
+ if (!(error instanceof SceneTextFormatError)) throw error;
61
+ continue;
62
+ }
63
+ if (resource !== undefined) scripts.set(displayPath, resource);
64
+ }
65
+ return scripts;
66
+ }
67
+
28
68
  export function buildGuiNodeIndex(documents: ReadonlyMap<string, string>): GuiNodeIndex {
29
69
  const byScriptResource = new Map<string, ReadonlySet<string>>();
30
70
  const claimedBy = new Map<string, string>();
@@ -40,9 +80,7 @@ export function buildGuiNodeIndex(documents: ReadonlyMap<string, string>): GuiNo
40
80
  continue;
41
81
  }
42
82
 
43
- const [script] = document.fields.get("script") ?? [];
44
- if (script === undefined) continue;
45
- const key = scriptKeyOf(script);
83
+ const key = guiScriptResourceOf(document);
46
84
  if (key === undefined) continue;
47
85
 
48
86
  const owner = claimedBy.get(key);