@ringozz/godot 4.7.2-614 → 4.7.2-616

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/load.ts CHANGED
@@ -1,231 +1,231 @@
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 { cleanupHooks, stageFile } from './runtime.ts';
12
- import { decodeCtex } from './web-image.ts';
13
-
14
- // Each loaded wrapper is released at shutdown via a `cleanupHooks` entry
15
- // holding a WeakRef: module-scope asset imports (the preload modules' memoized
16
- // load promises) keep their wrappers reachable, so GC alone can't collect them
17
- // before the engine stops. WeakRef keeps the entries from pinning resources
18
- // during the app's lifetime.
19
- function track<T extends Resource>(res: T): T {
20
- const ref = new WeakRef<Resource>(res);
21
- cleanupHooks.add(() => {
22
- const r = ref.deref();
23
- if (r) {
24
- try { r.free(); } catch {}
25
- }
26
- });
27
- return res;
28
- }
29
-
30
- /**
31
- * A Godot `Resource` subclass constructor: its `.name` is the registered class
32
- * name (used as the `ResourceLoader` type hint), and `InstanceType<C>` is the
33
- * loaded resource type.
34
- */
35
- type ResourceConstructor = { new(...args: any[]): Resource } & Function;
36
-
37
- /**
38
- * A file to stage before loading, keyed by `res://` path: sidecars (`.import`)
39
- * arrive as `content` (text); imported products (`.scn`/`.ctex`) arrive as
40
- * `path` (fetched).
41
- */
42
- interface AssetFile {
43
- content?: string;
44
- path?: string;
45
- }
46
-
47
- export type AssetFiles = Record<string, AssetFile>;
48
-
49
- const UID_RE = /uid="(uid:\/\/[\w.]+)"/;
50
-
51
- // Godot encodes `uid://` numbers in base 34 over `a-y` then `0-8`
52
- // (core/io/resource_uid.cpp). Decode in JS with BigInt: `ResourceUID.textToId`
53
- // returns a JS number, and these ids exceed `Number.MAX_SAFE_INTEGER`, so the
54
- // low digits would be lost. BigInt flows through `_C` losslessly (int64).
55
- const UID_CHARS = 'abcdefghijklmnopqrstuvwxy012345678';
56
-
57
- function uidToId(uid: string): bigint {
58
- let id = 0n;
59
- for (let i = 6; i < uid.length; i++) {
60
- id = id * 34n + BigInt(UID_CHARS.indexOf(uid[i]));
61
- }
62
- return id;
63
- }
64
-
65
- /**
66
- * Registers an asset's `uid://` → `res://` path so scene ext_resource references
67
- * resolve it (Godot's `ResourceUID` map) instead of warning and falling back to
68
- * the stored text path. Idempotent; skips unknown/malformed uids.
69
- */
70
- function registerAssetUid(uid: string, resPath: string): void {
71
- const id = uidToId(uid);
72
- if (id !== 0n && !ResourceUID.hasId(id as unknown as number)) {
73
- ResourceUID.addId(id as unknown as number, resPath);
74
- }
75
- }
76
-
77
- async function materialize([resPath, entry]: [string, AssetFile]): Promise<void> {
78
- if (FileAccess.fileExists(resPath)) {
79
- return;
80
- }
81
- let bytes: Uint8Array;
82
- if (entry.content !== undefined) {
83
- // Sidecars (`.import`) and native text sources carry the asset's `uid=`;
84
- // register it so scenes resolve by uid. `.import` maps to the source path
85
- // (resPath minus the suffix); native files map to themselves.
86
- const uid = entry.content.match(UID_RE)?.[1];
87
- if (uid) {
88
- registerAssetUid(uid, resPath.endsWith('.import') ? resPath.slice(0, -'.import'.length) : resPath);
89
- }
90
- bytes = new TextEncoder().encode(entry.content);
91
- } else if (entry.path) {
92
- const res = await fetch(entry.path);
93
- if (!res.ok) {
94
- return;
95
- }
96
- bytes = new Uint8Array(await res.arrayBuffer());
97
- if (resPath.endsWith('.ctex')) {
98
- // On web, decode embedded PNG/WebP blobs with the browser before
99
- // staging, so the engine never runs an image codec (see web-image.ts).
100
- bytes = await decodeCtex(bytes);
101
- }
102
- } else {
103
- return;
104
- }
105
- stageFile?.(ProjectSettings.globalizePath(resPath), bytes);
106
- }
107
-
108
- const nextTick = () => new Promise(requestAnimationFrame);
109
-
110
- /**
111
- * An asset module as generated by `@ringozz/godot/preload`: `default` is the
112
- * load promise, `materialize` resolves once its bundled files (plus its deps'
113
- * files) are staged on the engine's filesystem.
114
- */
115
- interface AssetModule {
116
- default: Promise<unknown>;
117
- materialize: Promise<unknown>;
118
- }
119
-
120
- /**
121
- * Stages the asset's bundled files before loading: `.import` sidecars as text
122
- * and imported products (`.scn`/`.ctex`) as fetched paths. On web the bytes are
123
- * written straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only,
124
- * no wasm copy); a no-op on desktop where the files already exist. `deps`
125
- * supplies referenced asset modules whose file-staging is awaited (so Godot can
126
- * resolve them) and whose load failures are logged.
127
- */
128
- function materializeFiles(files: AssetFiles, deps: AssetModule[] = []): Promise<unknown> {
129
- for (const dep of deps) {
130
- dep.default.catch((err) => console.error('[godot] dependency load failed:', err));
131
- }
132
- if (stageFile === undefined) {
133
- // Desktop: the files already exist on disk (res:// = cwd) and uids come
134
- // from `.godot/uid_cache.bin`, so there is nothing to stage.
135
- return Promise.resolve();
136
- }
137
- return Promise.all([...Object.entries(files).map(materialize), ...deps.map((dep) => dep.materialize)]);
138
- }
139
-
140
- /**
141
- * Returns a promise resolving to the resource already cached in the engine at
142
- * `path` (`ResourceCache`), or `null` when it isn't loaded yet. Used by
143
- * {@link loadAsset} to skip file staging and the threaded load when a
144
- * re-evaluated module (e.g. web HMR) references an asset that is still cached.
145
- * `getCachedRef` returns the same JS wrapper as `loadThreadedGet` (instance
146
- * binding), so identity is preserved.
147
- */
148
- function cachedResource<C extends ResourceConstructor>(path: string): Promise<InstanceType<C>> | null {
149
- const result = ResourceLoader.getCachedRef(path) as InstanceType<C>;
150
- return result ? Promise.resolve(result) : null;
151
- }
152
-
153
- /**
154
- * Entry point for the generated asset modules (`@ringozz/godot/preload`): checks
155
- * the engine's `ResourceCache` once (`cachedResource`), stages the bundled files
156
- * via `materializeFiles` when needed, then loads via {@link loadResourceAsync},
157
- * memoizing the resulting load promise on `data` (the module's
158
- * `import.meta.hot.data`, carried across HMR re-evaluations; `{}` on desktop
159
- * where modules evaluate once). Memoization keeps `use()` seeing the **same**
160
- * fulfilled promise object across re-evaluations — no Suspense fallback flash on
161
- * hot reload; a rejected load is evicted so the next evaluation retries. Returns
162
- * the `materialize` promise (own + deps' files staged) and the load promise.
163
- */
164
- export function loadAsset<C extends ResourceConstructor>(
165
- path: string,
166
- cls: C,
167
- files: AssetFiles,
168
- deps: AssetModule[] = [],
169
- data: Record<string, unknown> = {},
170
- ): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
171
- const cached = cachedResource<C>(path);
172
- if (cached) cached.then(track);
173
- const materialize = cached ? Promise.resolve() : materializeFiles(files, deps);
174
- const existing = data[path] as Promise<InstanceType<C>> | undefined;
175
- const load = existing ?? cached ?? materialize.then(() => loadResourceAsync(path, cls, files));
176
- data[path] = load;
177
- load.catch(() => {
178
- if (data[path] === load) delete data[path];
179
- });
180
- return { materialize, load };
181
- }
182
-
183
- /**
184
- * Loads a resource in the background. `cls` supplies both the `ResourceLoader`
185
- * type hint (its registered `.name`) and the return type. On web, call
186
- * {@link materializeFiles} with the asset's bundled files first; on desktop the
187
- * files already exist. Pass the same `files` map to have the staged files
188
- * deleted from MEMFS once the resource is loaded (the resource stays cached in
189
- * the engine, so the bytes are no longer needed).
190
- */
191
- export async function loadResourceAsync<C extends ResourceConstructor>(
192
- path: string,
193
- cls: C,
194
- files?: AssetFiles,
195
- ): Promise<InstanceType<C>> {
196
- const err = ResourceLoader.loadThreadedRequest(path, cls.name);
197
- if (err) {
198
- throw new Error(`loadResourceAsync(${path}): loadThreadedRequest failed (${err})`);
199
- }
200
-
201
- let result: InstanceType<C>;
202
- while (true) {
203
- const status = ResourceLoader.loadThreadedGetStatus(path);
204
- if (status === ThreadLoadStatus.THREAD_LOAD_LOADED) {
205
- result = ResourceLoader.loadThreadedGet(path) as InstanceType<C>;
206
- break;
207
- }
208
- if (status === ThreadLoadStatus.THREAD_LOAD_FAILED || status === ThreadLoadStatus.THREAD_LOAD_INVALID_RESOURCE) {
209
- // Collect the engine's LoadToken so a failed load doesn't leave it
210
- // registered (a bare `RefCounted` leaked at exit). Safe no-op when
211
- // no token exists.
212
- ResourceLoader.loadThreadedGet(path);
213
- throw new Error(`loadResourceAsync(${path}): load failed (status ${status})`);
214
- }
215
- await nextTick();
216
- }
217
- if (files && stageFile !== undefined) {
218
- // Keep `.import` sidecars — they route imported source paths to their
219
- // products (ResourceFormatImporter recognizes a path by its sidecar).
220
- // Delete everything else: the products are read only on a cache miss, and
221
- // the resource stays cached after loading, so the bytes are dead weight.
222
- // Best-effort: a file may already be gone (another module's cleanup or a
223
- // cache-miss re-stage).
224
- for (const resPath of Object.keys(files)) {
225
- if (!resPath.endsWith('.import')) {
226
- DirAccess.removeAbsolute(resPath);
227
- }
228
- }
229
- }
230
- return track(result);
231
- }
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 { cleanupHooks, stageFile } from './runtime.ts';
12
+ import { decodeCtex } from './web-image.ts';
13
+
14
+ // Each loaded wrapper is released at shutdown via a `cleanupHooks` entry
15
+ // holding a WeakRef: module-scope asset imports (the preload modules' memoized
16
+ // load promises) keep their wrappers reachable, so GC alone can't collect them
17
+ // before the engine stops. WeakRef keeps the entries from pinning resources
18
+ // during the app's lifetime.
19
+ function track<T extends Resource>(res: T): T {
20
+ const ref = new WeakRef<Resource>(res);
21
+ cleanupHooks.add(() => {
22
+ const r = ref.deref();
23
+ if (r) {
24
+ try { r.free(); } catch {}
25
+ }
26
+ });
27
+ return res;
28
+ }
29
+
30
+ /**
31
+ * A Godot `Resource` subclass constructor: its `.name` is the registered class
32
+ * name (used as the `ResourceLoader` type hint), and `InstanceType<C>` is the
33
+ * loaded resource type.
34
+ */
35
+ type ResourceConstructor = { new(...args: any[]): Resource } & Function;
36
+
37
+ /**
38
+ * A file to stage before loading, keyed by `res://` path: sidecars (`.import`)
39
+ * arrive as `content` (text); imported products (`.scn`/`.ctex`) arrive as
40
+ * `path` (fetched).
41
+ */
42
+ interface AssetFile {
43
+ content?: string;
44
+ path?: string;
45
+ }
46
+
47
+ export type AssetFiles = Record<string, AssetFile>;
48
+
49
+ const UID_RE = /uid="(uid:\/\/[\w.]+)"/;
50
+
51
+ // Godot encodes `uid://` numbers in base 34 over `a-y` then `0-8`
52
+ // (core/io/resource_uid.cpp). Decode in JS with BigInt: `ResourceUID.textToId`
53
+ // returns a JS number, and these ids exceed `Number.MAX_SAFE_INTEGER`, so the
54
+ // low digits would be lost. BigInt flows through `_C` losslessly (int64).
55
+ const UID_CHARS = 'abcdefghijklmnopqrstuvwxy012345678';
56
+
57
+ function uidToId(uid: string): bigint {
58
+ let id = 0n;
59
+ for (let i = 6; i < uid.length; i++) {
60
+ id = id * 34n + BigInt(UID_CHARS.indexOf(uid[i]));
61
+ }
62
+ return id;
63
+ }
64
+
65
+ /**
66
+ * Registers an asset's `uid://` → `res://` path so scene ext_resource references
67
+ * resolve it (Godot's `ResourceUID` map) instead of warning and falling back to
68
+ * the stored text path. Idempotent; skips unknown/malformed uids.
69
+ */
70
+ function registerAssetUid(uid: string, resPath: string): void {
71
+ const id = uidToId(uid);
72
+ if (id !== 0n && !ResourceUID.hasId(id as unknown as number)) {
73
+ ResourceUID.addId(id as unknown as number, resPath);
74
+ }
75
+ }
76
+
77
+ async function materialize([resPath, entry]: [string, AssetFile]): Promise<void> {
78
+ if (FileAccess.fileExists(resPath)) {
79
+ return;
80
+ }
81
+ let bytes: Uint8Array;
82
+ if (entry.content !== undefined) {
83
+ // Sidecars (`.import`) and native text sources carry the asset's `uid=`;
84
+ // register it so scenes resolve by uid. `.import` maps to the source path
85
+ // (resPath minus the suffix); native files map to themselves.
86
+ const uid = entry.content.match(UID_RE)?.[1];
87
+ if (uid) {
88
+ registerAssetUid(uid, resPath.endsWith('.import') ? resPath.slice(0, -'.import'.length) : resPath);
89
+ }
90
+ bytes = new TextEncoder().encode(entry.content);
91
+ } else if (entry.path) {
92
+ const res = await fetch(entry.path);
93
+ if (!res.ok) {
94
+ return;
95
+ }
96
+ bytes = new Uint8Array(await res.arrayBuffer());
97
+ if (resPath.endsWith('.ctex')) {
98
+ // On web, decode embedded PNG/WebP blobs with the browser before
99
+ // staging, so the engine never runs an image codec (see web-image.ts).
100
+ bytes = await decodeCtex(bytes);
101
+ }
102
+ } else {
103
+ return;
104
+ }
105
+ stageFile?.(ProjectSettings.globalizePath(resPath), bytes);
106
+ }
107
+
108
+ const nextTick = () => new Promise(requestAnimationFrame);
109
+
110
+ /**
111
+ * An asset module as generated by `@ringozz/godot/preload`: `default` is the
112
+ * load promise, `materialize` resolves once its bundled files (plus its deps'
113
+ * files) are staged on the engine's filesystem.
114
+ */
115
+ interface AssetModule {
116
+ default: Promise<unknown>;
117
+ materialize: Promise<unknown>;
118
+ }
119
+
120
+ /**
121
+ * Stages the asset's bundled files before loading: `.import` sidecars as text
122
+ * and imported products (`.scn`/`.ctex`) as fetched paths. On web the bytes are
123
+ * written straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only,
124
+ * no wasm copy); a no-op on desktop where the files already exist. `deps`
125
+ * supplies referenced asset modules whose file-staging is awaited (so Godot can
126
+ * resolve them) and whose load failures are logged.
127
+ */
128
+ function materializeFiles(files: AssetFiles, deps: AssetModule[] = []): Promise<unknown> {
129
+ for (const dep of deps) {
130
+ dep.default.catch((err) => console.error('[godot] dependency load failed:', err));
131
+ }
132
+ if (stageFile === undefined) {
133
+ // Desktop: the files already exist on disk (res:// = cwd) and uids come
134
+ // from `.godot/uid_cache.bin`, so there is nothing to stage.
135
+ return Promise.resolve();
136
+ }
137
+ return Promise.all([...Object.entries(files).map(materialize), ...deps.map((dep) => dep.materialize)]);
138
+ }
139
+
140
+ /**
141
+ * Returns a promise resolving to the resource already cached in the engine at
142
+ * `path` (`ResourceCache`), or `null` when it isn't loaded yet. Used by
143
+ * {@link loadAsset} to skip file staging and the threaded load when a
144
+ * re-evaluated module (e.g. web HMR) references an asset that is still cached.
145
+ * `getCachedRef` returns the same JS wrapper as `loadThreadedGet` (instance
146
+ * binding), so identity is preserved.
147
+ */
148
+ function cachedResource<C extends ResourceConstructor>(path: string): Promise<InstanceType<C>> | null {
149
+ const result = ResourceLoader.getCachedRef(path) as InstanceType<C>;
150
+ return result ? Promise.resolve(result) : null;
151
+ }
152
+
153
+ /**
154
+ * Entry point for the generated asset modules (`@ringozz/godot/preload`): checks
155
+ * the engine's `ResourceCache` once (`cachedResource`), stages the bundled files
156
+ * via `materializeFiles` when needed, then loads via {@link loadResourceAsync},
157
+ * memoizing the resulting load promise on `data` (the module's
158
+ * `import.meta.hot.data`, carried across HMR re-evaluations; `{}` on desktop
159
+ * where modules evaluate once). Memoization keeps `use()` seeing the **same**
160
+ * fulfilled promise object across re-evaluations — no Suspense fallback flash on
161
+ * hot reload; a rejected load is evicted so the next evaluation retries. Returns
162
+ * the `materialize` promise (own + deps' files staged) and the load promise.
163
+ */
164
+ export function loadAsset<C extends ResourceConstructor>(
165
+ path: string,
166
+ cls: C,
167
+ files: AssetFiles,
168
+ deps: AssetModule[] = [],
169
+ data: Record<string, unknown> = {},
170
+ ): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
171
+ const cached = cachedResource<C>(path);
172
+ if (cached) cached.then(track);
173
+ const materialize = cached ? Promise.resolve() : materializeFiles(files, deps);
174
+ const existing = data[path] as Promise<InstanceType<C>> | undefined;
175
+ const load = existing ?? cached ?? materialize.then(() => loadResourceAsync(path, cls, files));
176
+ data[path] = load;
177
+ load.catch(() => {
178
+ if (data[path] === load) delete data[path];
179
+ });
180
+ return { materialize, load };
181
+ }
182
+
183
+ /**
184
+ * Loads a resource in the background. `cls` supplies both the `ResourceLoader`
185
+ * type hint (its registered `.name`) and the return type. On web, call
186
+ * {@link materializeFiles} with the asset's bundled files first; on desktop the
187
+ * files already exist. Pass the same `files` map to have the staged files
188
+ * deleted from MEMFS once the resource is loaded (the resource stays cached in
189
+ * the engine, so the bytes are no longer needed).
190
+ */
191
+ export async function loadResourceAsync<C extends ResourceConstructor>(
192
+ path: string,
193
+ cls: C,
194
+ files?: AssetFiles,
195
+ ): Promise<InstanceType<C>> {
196
+ const err = ResourceLoader.loadThreadedRequest(path, cls.name);
197
+ if (err) {
198
+ throw new Error(`loadResourceAsync(${path}): loadThreadedRequest failed (${err})`);
199
+ }
200
+
201
+ let result: InstanceType<C>;
202
+ while (true) {
203
+ const status = ResourceLoader.loadThreadedGetStatus(path);
204
+ if (status === ThreadLoadStatus.THREAD_LOAD_LOADED) {
205
+ result = ResourceLoader.loadThreadedGet(path) as InstanceType<C>;
206
+ break;
207
+ }
208
+ if (status === ThreadLoadStatus.THREAD_LOAD_FAILED || status === ThreadLoadStatus.THREAD_LOAD_INVALID_RESOURCE) {
209
+ // Collect the engine's LoadToken so a failed load doesn't leave it
210
+ // registered (a bare `RefCounted` leaked at exit). Safe no-op when
211
+ // no token exists.
212
+ ResourceLoader.loadThreadedGet(path);
213
+ throw new Error(`loadResourceAsync(${path}): load failed (status ${status})`);
214
+ }
215
+ await nextTick();
216
+ }
217
+ if (files && stageFile !== undefined) {
218
+ // Keep `.import` sidecars — they route imported source paths to their
219
+ // products (ResourceFormatImporter recognizes a path by its sidecar).
220
+ // Delete everything else: the products are read only on a cache miss, and
221
+ // the resource stays cached after loading, so the bytes are dead weight.
222
+ // Best-effort: a file may already be gone (another module's cleanup or a
223
+ // cache-miss re-stage).
224
+ for (const resPath of Object.keys(files)) {
225
+ if (!resPath.endsWith('.import')) {
226
+ DirAccess.removeAbsolute(resPath);
227
+ }
228
+ }
229
+ }
230
+ return track(result);
231
+ }