@ringozz/godot 4.7.1-1 → 4.7.1-10

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/src/debug.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  Copyright (c) Vladimir Davidovich. All rights reserved.
3
3
  ***********************************************************************/
4
4
 
5
- import { Rect2, Vector3 } from './index.ts';
5
+ import { Rect2, str, varToStr, Vector3 } from './index.ts';
6
6
  import { CanvasItem } from '../gen/classes/CanvasItem.ts';
7
7
  import { ClassDB } from '../gen/classes/ClassDB.ts';
8
8
  import { Control } from '../gen/classes/Control.ts';
@@ -14,40 +14,25 @@ import { OS } from '../gen/classes/OS.ts';
14
14
  import { Time } from '../gen/classes/Time.ts';
15
15
 
16
16
  /**
17
- * Dump a Godot object's properties as an indented string.
18
- * Uses `getPropertyList()` + `get()` to safely introspect — never invokes JS getters.
17
+ * Dump a Godot value or object as a string.
18
+ * Value types (vectors, arrays, dictionaries, …) render via Godot's `str()`.
19
+ * Objects dump via Godot's `var_to_str` — its own property walk + recursion —
20
+ * with the node's `name` prepended when present.
19
21
  *
20
- * @param obj - Any Godot wrapper (Node, Resource, etc.) or plain value
21
- * @param depth - Max recursion depth into sub-objects (default 2)
22
- * @param maxProps - Max properties to show per object (default 30)
22
+ * @param obj - Any Godot wrapper (Node, Resource, …), value type, or plain value
23
23
  */
24
- export function dumpStr(obj: unknown, depth = 2, maxProps = 30): string {
25
- if (obj == null || typeof obj !== 'object') return String(obj);
26
- let cls: string;
27
- try { cls = (obj as any).getClass?.(); } catch { cls = (obj as any).constructor?.name ?? typeof obj; }
28
- if (!cls) return String(obj);
29
- const lines = [`[${cls}]`];
30
- let count = 0;
31
- try {
32
- const plist = (obj as any).getPropertyList?.();
33
- if (plist?.size) {
34
- for (let i = 0; i < plist.size() && count < maxProps; i++) {
35
- const name = plist.get(i).get('name');
36
- if (!name || (name + '').startsWith('_')) continue;
37
- let val: unknown;
38
- try { val = (obj as any).get(name); } catch { val = '<err>'; }
39
- if (typeof val === 'object' && val !== null && depth > 1)
40
- lines.push(` ${name}:\n${indent(dumpStr(val, depth - 1, maxProps), ' ')}`);
41
- else
42
- lines.push(` ${name}: ${String(val)}`);
43
- count++;
44
- }
45
- }
46
- } catch { /* not a GodotVar */ }
47
- return lines.join('\n');
48
- }
24
+ export function dumpStr(obj: unknown): string {
25
+ if (obj == null || typeof obj !== 'object') return String(obj);
26
+
27
+ // Value types & heap containers (Vector3, Array, Dictionary, …) have no getClass() —
28
+ // let Godot's str() render them.
29
+ if (!(obj as any).getClass) return str(obj as any);
49
30
 
50
- function indent(s: string, prefix: string): string { return s.split('\n').join('\n' + prefix); }
31
+ const dump = varToStr(obj as any);
32
+ let name: unknown;
33
+ try { name = (obj as any).name; } catch { name = undefined; }
34
+ return name === undefined || name === null || name === '' ? dump : `${name}: ${dump}`;
35
+ }
51
36
 
52
37
  /**
53
38
  * Serialize a scene subtree as a tree-formatted string.
@@ -56,34 +41,36 @@ function indent(s: string, prefix: string): string { return s.split('\n').join('
56
41
  * @param depth - Max depth (default 99)
57
42
  */
58
43
  export function dumpTreeStr(node: Node, depth = 99): string {
59
- const lines: string[] = [];
60
- (function walk(n: Node, d: number, ind: string) {
61
- if (d <= 0) return;
62
- lines.push(`${ind}${n.getClass()} "${n.name}" (${n.getChildCount()} children)`);
63
- for (let i = 0; i < n.getChildCount(); i++) {
64
- const c = n.getChild(i);
65
- if (c) walk(c as Node, d - 1, ind + ' ');
66
- }
67
- })(node, depth, '');
68
- return lines.join('\n');
44
+ const lines: string[] = [];
45
+ (function walk(n: Node, d: number, ind: string) {
46
+ if (d <= 0) return;
47
+ lines.push(`${ind}${n.getClass()} "${n.name}" (${n.getChildCount()} children)`);
48
+ for (let i = 0; i < n.getChildCount(); i++) {
49
+ const c = n.getChild(i);
50
+ if (c) walk(c as Node, d - 1, ind + ' ');
51
+ }
52
+ })(node, depth, '');
53
+ return lines.join('\n');
69
54
  }
70
55
 
71
56
  /**
72
57
  * Return a snapshot of engine runtime stats as a formatted string.
73
58
  *
74
59
  * Includes process frames, FPS, physics frames, node count, time scale,
75
- * and main-loop class.
60
+ * and main-loop class. Never throws: `Engine.getMainLoop()` can be null or
61
+ * throw while the engine is starting/stopping, in which case those rows show `N/A`.
76
62
  */
77
63
  export function statsStr(): string {
78
- const tree = Engine.getMainLoop() as any;
79
- return [
80
- `Frames: ${Engine.getProcessFrames()}`,
81
- `FPS: ${Engine.getFramesPerSecond()}`,
82
- `Physics: ${Engine.getPhysicsFrames()}`,
83
- `Nodes: ${tree.getNodeCount()}`,
84
- `Time scale: ${Engine.timeScale}`,
85
- `Main loop: ${Engine.getMainLoop().getClass()}`,
86
- ].join('\n');
64
+ let tree: any = null;
65
+ try { tree = Engine.getMainLoop(); } catch { tree = null; }
66
+ return [
67
+ `Frames: ${Engine.getProcessFrames()}`,
68
+ `FPS: ${Engine.getFramesPerSecond()}`,
69
+ `Physics: ${Engine.getPhysicsFrames()}`,
70
+ `Nodes: ${tree?.getNodeCount?.() ?? 'N/A'}`,
71
+ `Time scale: ${Engine.timeScale}`,
72
+ `Main loop: ${tree ? tree.getClass() : 'N/A'}`,
73
+ ].join('\n');
87
74
  }
88
75
 
89
76
  /**
@@ -91,16 +78,43 @@ export function statsStr(): string {
91
78
  * Requires the `--expose-gc` Node.js flag.
92
79
  */
93
80
  export function gc(): string {
94
- const before = process.memoryUsage().heapUsed;
95
- if (typeof Bun !== 'undefined')
96
- Bun.gc(true);
97
- else if (globalThis.gc)
98
- globalThis.gc({ type: 'major', execution: 'sync' });
99
- else
100
- return 'gc() unavailable — need --expose-gc';
101
-
102
- const after = process.memoryUsage().heapUsed;
103
- return `GC: freed ${((before - after) / 1024).toFixed(0)} KB`;
81
+ const before = globalThis.process?.memoryUsage?.().heapUsed;
82
+ if (globalThis.Bun)
83
+ globalThis.Bun.gc(true);
84
+ else if (globalThis.gc)
85
+ globalThis.gc({ type: 'major', execution: 'sync' });
86
+ else
87
+ return 'gc() unavailable — need --expose-gc';
88
+
89
+ const after = globalThis.process?.memoryUsage?.().heapUsed;
90
+ const freed = typeof before === 'number' && typeof after === 'number' ? (before - after) / 1024 : 0;
91
+ return `GC: freed ${freed.toFixed(0)} KB`;
92
+ }
93
+
94
+ /**
95
+ * `DOMRect` is not a global in Bun (desktop), but `getBoundingClientRect()`
96
+ * constructs one. Install a minimal spec-compatible polyfill on desktop so the
97
+ * helper works identically on desktop and web (browsers already define it).
98
+ */
99
+ function ensureDOMRect(): void {
100
+ if (typeof (globalThis as any).DOMRect !== 'undefined') return;
101
+ (globalThis as any).DOMRect = class DOMRect {
102
+ x: number;
103
+ y: number;
104
+ width: number;
105
+ height: number;
106
+ constructor(x = 0, y = 0, width = 0, height = 0) {
107
+ this.x = x;
108
+ this.y = y;
109
+ this.width = width;
110
+ this.height = height;
111
+ }
112
+ get left() { return this.x; }
113
+ get top() { return this.y; }
114
+ get right() { return this.x + this.width; }
115
+ get bottom() { return this.y + this.height; }
116
+ toJSON() { return { x: this.x, y: this.y, width: this.width, height: this.height }; }
117
+ };
104
118
  }
105
119
 
106
120
  /**
@@ -111,109 +125,112 @@ export function gc(): string {
111
125
  * breakpoints in any module.
112
126
  * - Registers an `uncaughtException` handler that logs but keeps the process
113
127
  * alive for interactive debugging.
128
+ * - Polyfills `DOMRect` (needed by `getBoundingClientRect()` on desktop).
114
129
  */
115
130
  export function initDebug(): void {
116
- globalThis.process?.on('uncaughtException', (err, origin) => {
117
- console.error(`\n✗ ${origin}:`, err.stack);
118
- });
119
-
120
- Object.defineProperty(CanvasItem.prototype, 'getBoundingClientRect', {
121
- configurable: true,
122
- value(this: CanvasItem): DOMRect | null {
123
- const scale = DisplayServer.screenGetScale();
124
- if (this instanceof Control) {
125
- const rc = (this as Control).getGlobalRect();
126
- return new DOMRect(
127
- rc.position.x / scale, rc.position.y / scale,
128
- rc.size.x / scale, rc.size.y / scale,
129
- );
130
- }
131
-
132
- if (typeof (this as any).getRect !== 'function') return null;
133
-
134
- const rect = (this as any).getRect() as Rect2;
135
- const t = this.getScreenTransform();
136
-
137
- const pos = rect.position;
138
- const sz = rect.size;
139
- const tx = t.x;
140
- const ty = t.y;
141
- const to = t.origin;
142
-
143
- const x0 = pos.x, y0 = pos.y;
144
- const x1 = x0 + sz.x, y1 = y0 + sz.y;
145
- const txx = tx.x, txy = tx.y;
146
- const tyx = ty.x, tyy = ty.y;
147
- const tox = to.x, toy = to.y;
148
-
149
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
150
- const accum = (px: number, py: number) => {
151
- if (px < minX) minX = px; if (py < minY) minY = py;
152
- if (px > maxX) maxX = px; if (py > maxY) maxY = py;
153
- };
154
-
155
- accum(txx * x0 + tyx * y0 + tox, txy * x0 + tyy * y0 + toy);
156
- accum(txx * x1 + tyx * y0 + tox, txy * x1 + tyy * y0 + toy);
157
- accum(txx * x0 + tyx * y1 + tox, txy * x0 + tyy * y1 + toy);
158
- accum(txx * x1 + tyx * y1 + tox, txy * x1 + tyy * y1 + toy);
159
-
160
- return new DOMRect(
161
- minX / scale, minY / scale,
162
- (maxX - minX) / scale, (maxY - minY) / scale,
163
- );
164
- },
165
- });
166
-
167
- Object.defineProperty(Node3D.prototype, 'getBoundingClientRect', {
168
- configurable: true,
169
- value(this: Node3D): DOMRect | null {
170
- if (typeof (this as any).getAabb !== 'function') return null;
171
-
172
- const aabb = (this as any).getAabb();
173
- const gt = this.globalTransform;
174
- const viewport = this.getViewport();
175
- if (!viewport) return null;
176
- const camera = viewport.getCamera3d();
177
- if (!camera) return null;
178
- const scale = DisplayServer.screenGetScale();
179
-
180
- const b = gt.basis;
181
- const bx = b.x, by = b.y, bz = b.z;
182
- const o = gt.origin;
183
- const p = aabb.position, e = aabb.end;
184
-
185
- const [px, py, pz] = [p.x, p.y, p.z];
186
- const [ex, ey, ez] = [e.x, e.y, e.z];
187
- const [bxx, bxy, bxz] = [bx.x, bx.y, bx.z];
188
- const [byx, byy, byz] = [by.x, by.y, by.z];
189
- const [bzx, bzy, bzz] = [bz.x, bz.y, bz.z];
190
- const [ox, oy, oz] = [o.x, o.y, o.z];
191
-
192
- const corners = [
193
- [px, py, pz], [ex, py, pz], [px, ey, pz], [ex, ey, pz],
194
- [px, py, ez], [ex, py, ez], [px, ey, ez], [ex, ey, ez],
195
- ];
196
-
197
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
198
-
199
- for (const [cx, cy, cz] of corners) {
200
- const wx = bxx * cx + byx * cy + bzx * cz + ox;
201
- const wy = bxy * cx + byy * cy + bzy * cz + oy;
202
- const wz = bxz * cx + byz * cy + bzz * cz + oz;
203
- const s = camera.unprojectPosition(new Vector3(wx, wy, wz));
204
- if (s.x < minX) minX = s.x; if (s.y < minY) minY = s.y;
205
- if (s.x > maxX) maxX = s.x; if (s.y > maxY) maxY = s.y;
206
- }
207
-
208
- return new DOMRect(
209
- minX / scale, minY / scale,
210
- (maxX - minX) / scale, (maxY - minY) / scale,
211
- );
212
- },
213
- });
214
-
215
- (globalThis as any).$ = {
216
- Engine, OS, ClassDB, Time,
217
- dumpStr, dumpTreeStr, statsStr, gc,
218
- };
131
+ globalThis.process?.on?.('uncaughtException', (err, origin) => {
132
+ console.error(`\n✗ ${origin}:`, err.stack);
133
+ });
134
+
135
+ ensureDOMRect();
136
+
137
+ Object.defineProperty(CanvasItem.prototype, 'getBoundingClientRect', {
138
+ configurable: true,
139
+ value(this: CanvasItem): DOMRect | null {
140
+ const scale = DisplayServer.screenGetScale();
141
+ if (this instanceof Control) {
142
+ const rc = (this as Control).getGlobalRect();
143
+ return new DOMRect(
144
+ rc.position.x / scale, rc.position.y / scale,
145
+ rc.size.x / scale, rc.size.y / scale,
146
+ );
147
+ }
148
+
149
+ if (typeof (this as any).getRect !== 'function') return null;
150
+
151
+ const rect = (this as any).getRect() as Rect2;
152
+ const t = this.getScreenTransform();
153
+
154
+ const pos = rect.position;
155
+ const sz = rect.size;
156
+ const tx = t.x;
157
+ const ty = t.y;
158
+ const to = t.origin;
159
+
160
+ const x0 = pos.x, y0 = pos.y;
161
+ const x1 = x0 + sz.x, y1 = y0 + sz.y;
162
+ const txx = tx.x, txy = tx.y;
163
+ const tyx = ty.x, tyy = ty.y;
164
+ const tox = to.x, toy = to.y;
165
+
166
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
167
+ const accum = (px: number, py: number) => {
168
+ if (px < minX) minX = px; if (py < minY) minY = py;
169
+ if (px > maxX) maxX = px; if (py > maxY) maxY = py;
170
+ };
171
+
172
+ accum(txx * x0 + tyx * y0 + tox, txy * x0 + tyy * y0 + toy);
173
+ accum(txx * x1 + tyx * y0 + tox, txy * x1 + tyy * y0 + toy);
174
+ accum(txx * x0 + tyx * y1 + tox, txy * x0 + tyy * y1 + toy);
175
+ accum(txx * x1 + tyx * y1 + tox, txy * x1 + tyy * y1 + toy);
176
+
177
+ return new DOMRect(
178
+ minX / scale, minY / scale,
179
+ (maxX - minX) / scale, (maxY - minY) / scale,
180
+ );
181
+ },
182
+ });
183
+
184
+ Object.defineProperty(Node3D.prototype, 'getBoundingClientRect', {
185
+ configurable: true,
186
+ value(this: Node3D): DOMRect | null {
187
+ if (typeof (this as any).getAabb !== 'function') return null;
188
+
189
+ const aabb = (this as any).getAabb();
190
+ const gt = this.globalTransform;
191
+ const viewport = this.getViewport();
192
+ if (!viewport) return null;
193
+ const camera = viewport.getCamera3d();
194
+ if (!camera) return null;
195
+ const scale = DisplayServer.screenGetScale();
196
+
197
+ const b = gt.basis;
198
+ const bx = b.x, by = b.y, bz = b.z;
199
+ const o = gt.origin;
200
+ const p = aabb.position, e = aabb.end;
201
+
202
+ const [px, py, pz] = [p.x, p.y, p.z];
203
+ const [ex, ey, ez] = [e.x, e.y, e.z];
204
+ const [bxx, bxy, bxz] = [bx.x, bx.y, bx.z];
205
+ const [byx, byy, byz] = [by.x, by.y, by.z];
206
+ const [bzx, bzy, bzz] = [bz.x, bz.y, bz.z];
207
+ const [ox, oy, oz] = [o.x, o.y, o.z];
208
+
209
+ const corners = [
210
+ [px, py, pz], [ex, py, pz], [px, ey, pz], [ex, ey, pz],
211
+ [px, py, ez], [ex, py, ez], [px, ey, ez], [ex, ey, ez],
212
+ ];
213
+
214
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
215
+
216
+ for (const [cx, cy, cz] of corners) {
217
+ const wx = bxx * cx + byx * cy + bzx * cz + ox;
218
+ const wy = bxy * cx + byy * cy + bzy * cz + oy;
219
+ const wz = bxz * cx + byz * cy + bzz * cz + oz;
220
+ const s = camera.unprojectPosition(new Vector3(wx, wy, wz));
221
+ if (s.x < minX) minX = s.x; if (s.y < minY) minY = s.y;
222
+ if (s.x > maxX) maxX = s.x; if (s.y > maxY) maxY = s.y;
223
+ }
224
+
225
+ return new DOMRect(
226
+ minX / scale, minY / scale,
227
+ (maxX - minX) / scale, (maxY - minY) / scale,
228
+ );
229
+ },
230
+ });
231
+
232
+ (globalThis as any).$ = {
233
+ Engine, OS, ClassDB, Time,
234
+ dumpStr, dumpTreeStr, statsStr, gc,
235
+ };
219
236
  }
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  ***********************************************************************/
4
4
 
5
5
  export * from '../gen/index.ts';
6
+ import * as ValueTypes from '../gen/value-types/index.ts';
7
+ import * as HeapTypes from '../gen/heap-types/index.ts';
6
8
  import { Engine } from '../gen/classes/Engine.ts';
7
9
  import { GodotInstance } from '../gen/classes/GodotInstance.ts';
8
10
  import { SceneTree } from '../gen/classes/SceneTree.ts';
@@ -11,6 +13,8 @@ import { gc } from './debug.ts';
11
13
  import { cancelAnimationFrame, getGodot, requestAnimationFrame } from './runtime.ts';
12
14
 
13
15
  // ---- make sure these classes are not tree-shaked ----
16
+ void ValueTypes;
17
+ void HeapTypes;
14
18
  void GodotInstance;
15
19
  void SceneTree;
16
20
  void Window;
@@ -22,7 +26,7 @@ Object.assign(globalThis as any, { requestAnimationFrame, cancelAnimationFrame }
22
26
  export async function runGodot(signal?: AbortSignal, unmount?: () => PromiseLike<void>) {
23
27
  signal?.throwIfAborted();
24
28
  signal?.addEventListener('abort', () => {
25
- (Engine.getMainLoop() as SceneTree).quit();
29
+ (Engine.getMainLoop() as SceneTree)?.quit();
26
30
  }, { once: true });
27
31
 
28
32
  const godot = getGodot();
package/src/load.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ import { DirAccess } from '../gen/classes/DirAccess.ts';
6
+ import { FileAccess } from '../gen/classes/FileAccess.ts';
7
+ import { ProjectSettings } from '../gen/classes/ProjectSettings.ts';
8
+ import { ResourceLoader, ThreadLoadStatus } from '../gen/classes/ResourceLoader.ts';
9
+ import { ResourceUID } from '../gen/classes/ResourceUID.ts';
10
+ import type { Resource } from '../gen/classes/Resource.ts';
11
+ import { stageFile } from './runtime.ts';
12
+ import { decodeCtex } from './web-image.ts';
13
+
14
+ /**
15
+ * A Godot `Resource` subclass constructor: its `.name` is the registered class
16
+ * name (used as the `ResourceLoader` type hint), and `InstanceType<C>` is the
17
+ * loaded resource type.
18
+ */
19
+ type ResourceConstructor = { new(...args: any[]): Resource } & Function;
20
+
21
+ /**
22
+ * A file to stage before loading, keyed by `res://` path: sidecars (`.import`)
23
+ * arrive as `content` (text); imported products (`.scn`/`.ctex`) arrive as
24
+ * `path` (fetched).
25
+ */
26
+ interface AssetFile {
27
+ content?: string;
28
+ path?: string;
29
+ }
30
+
31
+ export type AssetFiles = Record<string, AssetFile>;
32
+
33
+ const UID_RE = /uid="(uid:\/\/[\w.]+)"/;
34
+
35
+ // Godot encodes `uid://` numbers in base 34 over `a-y` then `0-8`
36
+ // (core/io/resource_uid.cpp). Decode in JS with BigInt: `ResourceUID.textToId`
37
+ // returns a JS number, and these ids exceed `Number.MAX_SAFE_INTEGER`, so the
38
+ // low digits would be lost. BigInt flows through `_C` losslessly (int64).
39
+ const UID_CHARS = 'abcdefghijklmnopqrstuvwxy012345678';
40
+
41
+ function uidToId(uid: string): bigint {
42
+ let id = 0n;
43
+ for (let i = 6; i < uid.length; i++) {
44
+ id = id * 34n + BigInt(UID_CHARS.indexOf(uid[i]));
45
+ }
46
+ return id;
47
+ }
48
+
49
+ /**
50
+ * Registers an asset's `uid://` → `res://` path so scene ext_resource references
51
+ * resolve it (Godot's `ResourceUID` map) instead of warning and falling back to
52
+ * the stored text path. Idempotent; skips unknown/malformed uids.
53
+ */
54
+ function registerAssetUid(uid: string, resPath: string): void {
55
+ const id = uidToId(uid);
56
+ if (id !== 0n && !ResourceUID.hasId(id as unknown as number)) {
57
+ ResourceUID.addId(id as unknown as number, resPath);
58
+ }
59
+ }
60
+
61
+ async function materialize([resPath, entry]: [string, AssetFile]): Promise<void> {
62
+ if (FileAccess.fileExists(resPath)) {
63
+ return;
64
+ }
65
+ let bytes: Uint8Array;
66
+ if (entry.content !== undefined) {
67
+ // Sidecars (`.import`) and native text sources carry the asset's `uid=`;
68
+ // register it so scenes resolve by uid. `.import` maps to the source path
69
+ // (resPath minus the suffix); native files map to themselves.
70
+ const uid = entry.content.match(UID_RE)?.[1];
71
+ if (uid) {
72
+ registerAssetUid(uid, resPath.endsWith('.import') ? resPath.slice(0, -'.import'.length) : resPath);
73
+ }
74
+ bytes = new TextEncoder().encode(entry.content);
75
+ } else if (entry.path) {
76
+ const res = await fetch(entry.path);
77
+ if (!res.ok) {
78
+ return;
79
+ }
80
+ bytes = new Uint8Array(await res.arrayBuffer());
81
+ if (resPath.endsWith('.ctex')) {
82
+ // On web, decode embedded PNG/WebP blobs with the browser before
83
+ // staging, so the engine never runs an image codec (see web-image.ts).
84
+ bytes = await decodeCtex(bytes);
85
+ }
86
+ } else {
87
+ return;
88
+ }
89
+ stageFile?.(ProjectSettings.globalizePath(resPath), bytes);
90
+ }
91
+
92
+ const nextTick = () => new Promise(requestAnimationFrame);
93
+
94
+ /**
95
+ * An asset module as generated by `@ringozz/godot/preload`: `default` is the
96
+ * load promise, `materialize` resolves once its bundled files (plus its deps'
97
+ * files) are staged on the engine's filesystem.
98
+ */
99
+ interface AssetModule {
100
+ default: Promise<unknown>;
101
+ materialize: Promise<unknown>;
102
+ }
103
+
104
+ /**
105
+ * Stages the asset's bundled files before loading: `.import` sidecars as text
106
+ * and imported products (`.scn`/`.ctex`) as fetched paths. On web the bytes are
107
+ * written straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only,
108
+ * no wasm copy); a no-op on desktop where the files already exist. `deps`
109
+ * supplies referenced asset modules whose file-staging is awaited (so Godot can
110
+ * resolve them) and whose load failures are logged.
111
+ */
112
+ function materializeFiles(files: AssetFiles, deps: AssetModule[] = []): Promise<unknown> {
113
+ for (const dep of deps) {
114
+ dep.default.catch((err) => console.error('[godot] dependency load failed:', err));
115
+ }
116
+ if (stageFile === undefined) {
117
+ // Desktop: the files already exist on disk (res:// = cwd) and uids come
118
+ // from `.godot/uid_cache.bin`, so there is nothing to stage.
119
+ return Promise.resolve();
120
+ }
121
+ return Promise.all([...Object.entries(files).map(materialize), ...deps.map((dep) => dep.materialize)]);
122
+ }
123
+
124
+ /**
125
+ * Returns a promise resolving to the resource already cached in the engine at
126
+ * `path` (`ResourceCache`), or `null` when it isn't loaded yet. Used by
127
+ * {@link loadAsset} to skip file staging and the threaded load when a
128
+ * re-evaluated module (e.g. web HMR) references an asset that is still cached.
129
+ * `getCachedRef` returns the same JS wrapper as `loadThreadedGet` (instance
130
+ * binding), so identity is preserved.
131
+ */
132
+ function cachedResource<C extends ResourceConstructor>(path: string): Promise<InstanceType<C>> | null {
133
+ const result = ResourceLoader.getCachedRef(path) as InstanceType<C>;
134
+ return result ? Promise.resolve(result) : null;
135
+ }
136
+
137
+ /**
138
+ * Entry point for the generated asset modules (`@ringozz/godot/preload`): checks
139
+ * the engine's `ResourceCache` once (`cachedResource`), stages the bundled files
140
+ * via `materializeFiles` when needed, then loads via {@link loadResourceAsync},
141
+ * memoizing the resulting load promise on `data` (the module's
142
+ * `import.meta.hot.data`, carried across HMR re-evaluations; `{}` on desktop
143
+ * where modules evaluate once). Memoization keeps `use()` seeing the **same**
144
+ * fulfilled promise object across re-evaluations — no Suspense fallback flash on
145
+ * hot reload; a rejected load is evicted so the next evaluation retries. Returns
146
+ * the `materialize` promise (own + deps' files staged) and the load promise.
147
+ */
148
+ export function loadAsset<C extends ResourceConstructor>(
149
+ path: string,
150
+ cls: C,
151
+ files: AssetFiles,
152
+ deps: AssetModule[] = [],
153
+ data: Record<string, unknown> = {},
154
+ ): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
155
+ const cached = cachedResource<C>(path);
156
+ const materialize = cached ? Promise.resolve() : materializeFiles(files, deps);
157
+ const existing = data[path] as Promise<InstanceType<C>> | undefined;
158
+ const load = existing ?? cached ?? materialize.then(() => loadResourceAsync(path, cls, files));
159
+ data[path] = load;
160
+ load.catch(() => {
161
+ if (data[path] === load) delete data[path];
162
+ });
163
+ return { materialize, load };
164
+ }
165
+
166
+ /**
167
+ * Loads a resource in the background. `cls` supplies both the `ResourceLoader`
168
+ * type hint (its registered `.name`) and the return type. On web, call
169
+ * {@link materializeFiles} with the asset's bundled files first; on desktop the
170
+ * files already exist. Pass the same `files` map to have the staged files
171
+ * deleted from MEMFS once the resource is loaded (the resource stays cached in
172
+ * the engine, so the bytes are no longer needed).
173
+ */
174
+ export async function loadResourceAsync<C extends ResourceConstructor>(
175
+ path: string,
176
+ cls: C,
177
+ files?: AssetFiles,
178
+ ): Promise<InstanceType<C>> {
179
+ const err = ResourceLoader.loadThreadedRequest(path, cls.name);
180
+ if (err) {
181
+ throw new Error(`loadResourceAsync(${path}): loadThreadedRequest failed (${err})`);
182
+ }
183
+
184
+ let result: InstanceType<C>;
185
+ while (true) {
186
+ const status = ResourceLoader.loadThreadedGetStatus(path);
187
+ if (status === ThreadLoadStatus.THREAD_LOAD_LOADED) {
188
+ result = ResourceLoader.loadThreadedGet(path) as InstanceType<C>;
189
+ break;
190
+ }
191
+ if (status === ThreadLoadStatus.THREAD_LOAD_FAILED || status === ThreadLoadStatus.THREAD_LOAD_INVALID_RESOURCE) {
192
+ throw new Error(`loadResourceAsync(${path}): load failed (status ${status})`);
193
+ }
194
+ await nextTick();
195
+ }
196
+ if (files && stageFile !== undefined) {
197
+ // Keep `.import` sidecars — they route imported source paths to their
198
+ // products (ResourceFormatImporter recognizes a path by its sidecar).
199
+ // Delete everything else: the products are read only on a cache miss, and
200
+ // the resource stays cached after loading, so the bytes are dead weight.
201
+ // Best-effort: a file may already be gone (another module's cleanup or a
202
+ // cache-miss re-stage).
203
+ for (const resPath of Object.keys(files)) {
204
+ if (!resPath.endsWith('.import')) {
205
+ DirAccess.removeAbsolute(resPath);
206
+ }
207
+ }
208
+ }
209
+ return result;
210
+ }