@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/README.md +148 -146
- package/package.json +7 -4
- package/src/assets.d.ts +130 -130
- package/src/debug.ts +357 -357
- package/src/editor.ts +107 -0
- package/src/index.ts +54 -50
- package/src/load.ts +231 -231
- package/src/preload.ts +253 -253
- package/src/runtime.ts +107 -107
- package/src/web-image.ts +153 -117
package/src/preload.ts
CHANGED
|
@@ -1,253 +1,253 @@
|
|
|
1
|
-
/**********************************************************************
|
|
2
|
-
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
-
***********************************************************************/
|
|
4
|
-
|
|
5
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
-
import { dirname, resolve } from 'node:path';
|
|
7
|
-
import { plugin } from 'bun';
|
|
8
|
-
import type { BunPlugin } from 'bun';
|
|
9
|
-
|
|
10
|
-
// Registers the godot-assets plugin: imports of Godot source assets become
|
|
11
|
-
// virtual modules (namespace `godot`) that call `loadAsset` (`@ringozz/godot/load`)
|
|
12
|
-
// and export the resulting `materialize` (own + deps' files) and load promise.
|
|
13
|
-
// `loadAsset` checks the engine's `ResourceCache` once; when the asset is
|
|
14
|
-
// already loaded (e.g. a re-evaluated module under web HMR) the load resolves to
|
|
15
|
-
// the cached instance and `materialize` short-circuits, skipping file staging
|
|
16
|
-
// and the threaded load. The load promise persists on the module's
|
|
17
|
-
// `import.meta.hot.data` (per-module state carried across hot replacements), so
|
|
18
|
-
// HMR re-evaluations return the *same* fulfilled promise object — React's `use()`
|
|
19
|
-
// keeps its tracked status and never re-suspends (no Suspense fallback flash); a
|
|
20
|
-
// rejected load is evicted from `data` so the next evaluation retries; on
|
|
21
|
-
// desktop `data` is `{}` and modules evaluate once. Dependencies are generated
|
|
22
|
-
// modules too, so a dep's load starts at module evaluation while the parent only
|
|
23
|
-
// waits for dep *files*. The default export is the single plugin — registered
|
|
24
|
-
// globally via bunfig preload and loaded by `[serve.static] plugins` for
|
|
25
|
-
// `Bun.serve` HTML routes.
|
|
26
|
-
|
|
27
|
-
const ROOT = process.cwd();
|
|
28
|
-
const NS = 'godot';
|
|
29
|
-
|
|
30
|
-
/** Format metadata: `cls` = Godot class (importable), `scan` = scan text refs, `native` = no `.import` sidecar. */
|
|
31
|
-
const FORMATS: Record<string, { cls?: string; scan?: boolean; native?: boolean }> = {
|
|
32
|
-
gltf: { cls: 'PackedScene', scan: true },
|
|
33
|
-
tscn: { cls: 'PackedScene', scan: true, native: true },
|
|
34
|
-
obj: { cls: 'PackedScene', scan: true },
|
|
35
|
-
gd: { cls: 'GDScript', scan: true, native: true },
|
|
36
|
-
jpg: { cls: 'CompressedTexture2D' },
|
|
37
|
-
jpeg: { cls: 'CompressedTexture2D' },
|
|
38
|
-
png: { cls: 'CompressedTexture2D' },
|
|
39
|
-
webp: { cls: 'CompressedTexture2D' },
|
|
40
|
-
svg: { cls: 'CompressedTexture2D' },
|
|
41
|
-
exr: { cls: 'TextureLayered' },
|
|
42
|
-
hdr: { cls: 'TextureLayered' },
|
|
43
|
-
wav: { cls: 'AudioStreamWAV' },
|
|
44
|
-
ogg: { cls: 'AudioStreamOggVorbis' },
|
|
45
|
-
mp3: { cls: 'AudioStreamMP3' },
|
|
46
|
-
tres: { cls: 'Resource', scan: true, native: true },
|
|
47
|
-
po: { cls: 'Translation', native: true },
|
|
48
|
-
ttf: { cls: 'FontFile' },
|
|
49
|
-
otf: { cls: 'FontFile' },
|
|
50
|
-
mtl: { scan: true },
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const ASSET_RE = new RegExp(
|
|
54
|
-
`\\.(${Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k]) => k).join('|')})$`,
|
|
55
|
-
'i',
|
|
56
|
-
);
|
|
57
|
-
const CLASS_BY_EXT: Record<string, string> = Object.fromEntries(
|
|
58
|
-
Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k, f]) => [k, f.cls!]),
|
|
59
|
-
);
|
|
60
|
-
const SCANNED = new Set(Object.entries(FORMATS).filter(([, f]) => f.scan).map(([k]) => k));
|
|
61
|
-
const NATIVE = new Set(Object.entries(FORMATS).filter(([, f]) => f.native).map(([k]) => k));
|
|
62
|
-
|
|
63
|
-
const extOf = (abs: string) => abs.slice(abs.lastIndexOf('.') + 1).toLowerCase();
|
|
64
|
-
const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
|
|
65
|
-
const isScannableText = (abs: string) => SCANNED.has(extOf(abs));
|
|
66
|
-
const isNativeText = (abs: string) => NATIVE.has(extOf(abs));
|
|
67
|
-
const isSourceAsset = (abs: string) => existsSync(abs + '.import') || isNativeText(abs);
|
|
68
|
-
|
|
69
|
-
/** Reads the imported destination (`dest_files`) out of an `.import` sidecar. */
|
|
70
|
-
function parseSidecarDest(text: string): string | null {
|
|
71
|
-
const deps = text.match(/^dest_files=\[([^\]]*)\]/m);
|
|
72
|
-
if (deps) {
|
|
73
|
-
const first = deps[1].match(/"([^"]+)"/);
|
|
74
|
-
if (first) {
|
|
75
|
-
return first[1];
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
const remap = text.match(/^path="([^"]+)"/m);
|
|
79
|
-
return remap ? remap[1] : null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Reference extraction for text-based source assets: absolute `res://` paths
|
|
83
|
-
// (Godot scenes/resources) and relative path tokens ending in a known asset
|
|
84
|
-
// extension (glTF uris, OBJ/MTL textures). The literal dot before the extension
|
|
85
|
-
// avoids matching MIME types inside `data:` URIs. Regexes are built fresh per
|
|
86
|
-
// call: a `/g` instance is stateful, and reusing one module-level regex across
|
|
87
|
-
// many files misbehaves under the dev server's bundler (a stale lastIndex
|
|
88
|
-
// shifted a scan's results). The `//` fragment right after `res:` is a mere
|
|
89
|
-
// path remainder of the `res://` match, not a real relative ref, so it's
|
|
90
|
-
// skipped (otherwise it mis-resolves as a Windows UNC `\\…` path).
|
|
91
|
-
//
|
|
92
|
-
// The extension must TERMINATE a token: `(?![\w.])` rejects matches where the
|
|
93
|
-
// extension is only the head of a longer identifier or a dotted sub-field, e.g.
|
|
94
|
-
// `X.po` in `X.position`, `foo.gd` in `foo.gd.position`, or `scene.tscn2`. A
|
|
95
|
-
// real relative ref (`foo.po`, `x.gd`, `ui/scene.tscn`) always ends its token.
|
|
96
|
-
// `String.raw` keeps the `\w` escapes intact — a plain template literal would
|
|
97
|
-
// drop the backslashes, turning `\w` into `w` (a wrong guard `(?![w.])`).
|
|
98
|
-
const REL_TERM = String.raw`(?![\w.])`;
|
|
99
|
-
const REL_PATTERN = String.raw`([\w./\\-]+\.(?:` + Object.keys(FORMATS).join('|') + `))` + REL_TERM;
|
|
100
|
-
function extractRefs(text: string): string[] {
|
|
101
|
-
const refs: string[] = [];
|
|
102
|
-
for (const m of text.matchAll(/res:\/\/[\w./\\-]+/g)) refs.push(m[0]);
|
|
103
|
-
const rel = new RegExp(REL_PATTERN, 'g');
|
|
104
|
-
for (const m of text.matchAll(rel)) if (!m[1].startsWith('/')) refs.push(m[1]);
|
|
105
|
-
return refs;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Adds a source asset's own files to `files`: imported assets contribute their
|
|
110
|
-
* `.import` sidecar (text) + imported product (file); native text formats
|
|
111
|
-
* (tscn/tres/po/gd) contribute the file itself (text).
|
|
112
|
-
*/
|
|
113
|
-
function addSource(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
|
|
114
|
-
const impAbs = srcAbs + '.import';
|
|
115
|
-
if (existsSync(impAbs)) {
|
|
116
|
-
files.set(resOf(srcAbs) + '.import', 'text');
|
|
117
|
-
const dest = parseSidecarDest(readFileSync(impAbs, 'utf8'));
|
|
118
|
-
if (dest) {
|
|
119
|
-
files.set(dest, 'file');
|
|
120
|
-
}
|
|
121
|
-
} else if (isNativeText(srcAbs)) {
|
|
122
|
-
stageNativeText(srcAbs, files);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Stages a native text source (`res://` path → `text`) so web staging + inlining picks it up. */
|
|
127
|
-
function stageNativeText(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
|
|
128
|
-
files.set(resOf(srcAbs), 'text');
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Collects the referenced source assets (deps) of a text source: every referenced
|
|
133
|
-
* source asset is staged (native text → `files`, products → dep modules), and
|
|
134
|
-
* loadable source assets also become dep modules so their own files + transitive
|
|
135
|
-
* deps are handled. Raw text formats without a sidecar (`.mtl`) are recursed into.
|
|
136
|
-
* Buffers (`.bin`) are skipped.
|
|
137
|
-
*/
|
|
138
|
-
function collectDeps(abs: string, visited: Set<string>, deps: string[], files: Map<string, 'text' | 'file'>): void {
|
|
139
|
-
if (visited.has(abs)) {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
visited.add(abs);
|
|
143
|
-
if (!isScannableText(abs)) {
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
for (const ref of extractRefs(readFileSync(abs, 'utf8'))) {
|
|
147
|
-
const refAbs = ref.startsWith('res://')
|
|
148
|
-
? resolve(ROOT, ref.slice('res://'.length))
|
|
149
|
-
: resolve(dirname(abs), ref);
|
|
150
|
-
if (visited.has(refAbs)) {
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
// Stage native text (e.g. `.gd`, nested `.tscn`) so the engine loads it
|
|
154
|
-
// by `res://` on web (idempotent); loadable assets also become dep
|
|
155
|
-
// modules for their own files + transitive deps.
|
|
156
|
-
if (isSourceAsset(refAbs)) {
|
|
157
|
-
if (isNativeText(refAbs)) {
|
|
158
|
-
visited.add(refAbs);
|
|
159
|
-
stageNativeText(refAbs, files);
|
|
160
|
-
}
|
|
161
|
-
if (CLASS_BY_EXT[extOf(refAbs)]) {
|
|
162
|
-
deps.push(refAbs);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
// Any scannable ref (a source's own text, or a raw leaf like `.mtl`) is
|
|
166
|
-
// recursed into so its internal `res://` refs are traced/staged too.
|
|
167
|
-
if (isScannableText(refAbs)) {
|
|
168
|
-
collectDeps(refAbs, visited, deps, files);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function analyze(abs: string): { files: Map<string, 'text' | 'file'>; deps: string[] } {
|
|
174
|
-
const files = new Map<string, 'text' | 'file'>();
|
|
175
|
-
const deps: string[] = [];
|
|
176
|
-
addSource(abs, files);
|
|
177
|
-
collectDeps(abs, new Set(), deps, files);
|
|
178
|
-
return { files, deps };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function ensureModule(abs: string): string {
|
|
182
|
-
const res = resOf(abs);
|
|
183
|
-
const cls = CLASS_BY_EXT[extOf(abs)] ?? 'Resource';
|
|
184
|
-
const { files, deps } = analyze(abs);
|
|
185
|
-
|
|
186
|
-
const imports = [
|
|
187
|
-
`import { loadAsset } from '@ringozz/godot/load';`,
|
|
188
|
-
`import { ${cls} } from '@ringozz/godot/${cls}';`,
|
|
189
|
-
];
|
|
190
|
-
const map: string[] = [];
|
|
191
|
-
let fi = 0;
|
|
192
|
-
// Text files (own `.tscn`/`.tres`/`.po`, `.import` sidecars, staged `.gd`
|
|
193
|
-
// leaves) are inlined as string literals — Bun's `with { type: 'text' }`
|
|
194
|
-
// import resolves to a null/default under the dev server's HMR wrapper for
|
|
195
|
-
// a module's own path; products (`.scn`/`.ctex`) are fetched by path.
|
|
196
|
-
for (const [resPath, kind] of files) {
|
|
197
|
-
const absP = resolve(ROOT, resPath.slice('res://'.length));
|
|
198
|
-
if (kind === 'text') {
|
|
199
|
-
map.push(` ${JSON.stringify(resPath)}: { content: ${JSON.stringify(readFileSync(absP, 'utf8'))} },`);
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
const v = `f${fi++}`;
|
|
203
|
-
imports.push(`import ${v} from ${JSON.stringify(absP)} with { type: 'file' };`);
|
|
204
|
-
map.push(` ${JSON.stringify(resPath)}: { path: ${v} },`);
|
|
205
|
-
}
|
|
206
|
-
deps.forEach((dep, i) => imports.push(`import * as dep${i} from ${JSON.stringify(dep)};`));
|
|
207
|
-
|
|
208
|
-
const filesVar = files.size ? `const files = {\n${map.join('\n')}\n};` : `const files = {};`;
|
|
209
|
-
const loadAsset = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, [${deps.map((_, i) => `dep${i}`).join(', ')}], import.meta.hot.data);`;
|
|
210
|
-
return imports.join('\n') + '\n' + filesVar + '\n' + loadAsset + '\n' + `export { materialize, load as default };` + '\n';
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const assetPlugin: BunPlugin = {
|
|
214
|
-
name: 'godot-assets',
|
|
215
|
-
setup(build) {
|
|
216
|
-
build.onResolve({ filter: ASSET_RE }, (args) => {
|
|
217
|
-
// Only JS module imports of Godot source assets become `godot`
|
|
218
|
-
// modules. `Bun.build` reports JS imports as `import-statement`
|
|
219
|
-
// (and `dynamic-import`/`require-*`) but HTML asset references
|
|
220
|
-
// (<link>/<img>) and CSS url() refs as `internal`/`url-token` —
|
|
221
|
-
// those fall through so Bun copies/hashes the file normally. The
|
|
222
|
-
// runtime preload (bun run / bun test) reports JS imports with no
|
|
223
|
-
// kind (`undefined`), so treat that as a JS import too.
|
|
224
|
-
if (
|
|
225
|
-
args.kind !== undefined &&
|
|
226
|
-
args.kind !== 'import-statement' &&
|
|
227
|
-
args.kind !== 'dynamic-import' &&
|
|
228
|
-
args.kind !== 'require-call' &&
|
|
229
|
-
args.kind !== 'require-resolve'
|
|
230
|
-
) {
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
const importerAbs = args.importer?.startsWith(`${NS}:`)
|
|
234
|
-
? args.importer.slice(NS.length + 1)
|
|
235
|
-
: args.importer;
|
|
236
|
-
const base = importerAbs ? dirname(importerAbs) : (args.resolveDir ?? ROOT);
|
|
237
|
-
const abs = resolve(base, args.path);
|
|
238
|
-
// A module's own native-text source (e.g. `.tscn`) self-imports the
|
|
239
|
-
// same path — let it load as a real file.
|
|
240
|
-
if (abs === importerAbs) {
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
return { path: abs, namespace: NS };
|
|
244
|
-
});
|
|
245
|
-
build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
|
|
246
|
-
contents: ensureModule(args.path),
|
|
247
|
-
loader: 'js',
|
|
248
|
-
}));
|
|
249
|
-
},
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
export default assetPlugin;
|
|
253
|
-
plugin(assetPlugin);
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import { plugin } from 'bun';
|
|
8
|
+
import type { BunPlugin } from 'bun';
|
|
9
|
+
|
|
10
|
+
// Registers the godot-assets plugin: imports of Godot source assets become
|
|
11
|
+
// virtual modules (namespace `godot`) that call `loadAsset` (`@ringozz/godot/load`)
|
|
12
|
+
// and export the resulting `materialize` (own + deps' files) and load promise.
|
|
13
|
+
// `loadAsset` checks the engine's `ResourceCache` once; when the asset is
|
|
14
|
+
// already loaded (e.g. a re-evaluated module under web HMR) the load resolves to
|
|
15
|
+
// the cached instance and `materialize` short-circuits, skipping file staging
|
|
16
|
+
// and the threaded load. The load promise persists on the module's
|
|
17
|
+
// `import.meta.hot.data` (per-module state carried across hot replacements), so
|
|
18
|
+
// HMR re-evaluations return the *same* fulfilled promise object — React's `use()`
|
|
19
|
+
// keeps its tracked status and never re-suspends (no Suspense fallback flash); a
|
|
20
|
+
// rejected load is evicted from `data` so the next evaluation retries; on
|
|
21
|
+
// desktop `data` is `{}` and modules evaluate once. Dependencies are generated
|
|
22
|
+
// modules too, so a dep's load starts at module evaluation while the parent only
|
|
23
|
+
// waits for dep *files*. The default export is the single plugin — registered
|
|
24
|
+
// globally via bunfig preload and loaded by `[serve.static] plugins` for
|
|
25
|
+
// `Bun.serve` HTML routes.
|
|
26
|
+
|
|
27
|
+
const ROOT = process.cwd();
|
|
28
|
+
const NS = 'godot';
|
|
29
|
+
|
|
30
|
+
/** Format metadata: `cls` = Godot class (importable), `scan` = scan text refs, `native` = no `.import` sidecar. */
|
|
31
|
+
const FORMATS: Record<string, { cls?: string; scan?: boolean; native?: boolean }> = {
|
|
32
|
+
gltf: { cls: 'PackedScene', scan: true },
|
|
33
|
+
tscn: { cls: 'PackedScene', scan: true, native: true },
|
|
34
|
+
obj: { cls: 'PackedScene', scan: true },
|
|
35
|
+
gd: { cls: 'GDScript', scan: true, native: true },
|
|
36
|
+
jpg: { cls: 'CompressedTexture2D' },
|
|
37
|
+
jpeg: { cls: 'CompressedTexture2D' },
|
|
38
|
+
png: { cls: 'CompressedTexture2D' },
|
|
39
|
+
webp: { cls: 'CompressedTexture2D' },
|
|
40
|
+
svg: { cls: 'CompressedTexture2D' },
|
|
41
|
+
exr: { cls: 'TextureLayered' },
|
|
42
|
+
hdr: { cls: 'TextureLayered' },
|
|
43
|
+
wav: { cls: 'AudioStreamWAV' },
|
|
44
|
+
ogg: { cls: 'AudioStreamOggVorbis' },
|
|
45
|
+
mp3: { cls: 'AudioStreamMP3' },
|
|
46
|
+
tres: { cls: 'Resource', scan: true, native: true },
|
|
47
|
+
po: { cls: 'Translation', native: true },
|
|
48
|
+
ttf: { cls: 'FontFile' },
|
|
49
|
+
otf: { cls: 'FontFile' },
|
|
50
|
+
mtl: { scan: true },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const ASSET_RE = new RegExp(
|
|
54
|
+
`\\.(${Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k]) => k).join('|')})$`,
|
|
55
|
+
'i',
|
|
56
|
+
);
|
|
57
|
+
const CLASS_BY_EXT: Record<string, string> = Object.fromEntries(
|
|
58
|
+
Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k, f]) => [k, f.cls!]),
|
|
59
|
+
);
|
|
60
|
+
const SCANNED = new Set(Object.entries(FORMATS).filter(([, f]) => f.scan).map(([k]) => k));
|
|
61
|
+
const NATIVE = new Set(Object.entries(FORMATS).filter(([, f]) => f.native).map(([k]) => k));
|
|
62
|
+
|
|
63
|
+
const extOf = (abs: string) => abs.slice(abs.lastIndexOf('.') + 1).toLowerCase();
|
|
64
|
+
const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
|
|
65
|
+
const isScannableText = (abs: string) => SCANNED.has(extOf(abs));
|
|
66
|
+
const isNativeText = (abs: string) => NATIVE.has(extOf(abs));
|
|
67
|
+
const isSourceAsset = (abs: string) => existsSync(abs + '.import') || isNativeText(abs);
|
|
68
|
+
|
|
69
|
+
/** Reads the imported destination (`dest_files`) out of an `.import` sidecar. */
|
|
70
|
+
function parseSidecarDest(text: string): string | null {
|
|
71
|
+
const deps = text.match(/^dest_files=\[([^\]]*)\]/m);
|
|
72
|
+
if (deps) {
|
|
73
|
+
const first = deps[1].match(/"([^"]+)"/);
|
|
74
|
+
if (first) {
|
|
75
|
+
return first[1];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const remap = text.match(/^path="([^"]+)"/m);
|
|
79
|
+
return remap ? remap[1] : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Reference extraction for text-based source assets: absolute `res://` paths
|
|
83
|
+
// (Godot scenes/resources) and relative path tokens ending in a known asset
|
|
84
|
+
// extension (glTF uris, OBJ/MTL textures). The literal dot before the extension
|
|
85
|
+
// avoids matching MIME types inside `data:` URIs. Regexes are built fresh per
|
|
86
|
+
// call: a `/g` instance is stateful, and reusing one module-level regex across
|
|
87
|
+
// many files misbehaves under the dev server's bundler (a stale lastIndex
|
|
88
|
+
// shifted a scan's results). The `//` fragment right after `res:` is a mere
|
|
89
|
+
// path remainder of the `res://` match, not a real relative ref, so it's
|
|
90
|
+
// skipped (otherwise it mis-resolves as a Windows UNC `\\…` path).
|
|
91
|
+
//
|
|
92
|
+
// The extension must TERMINATE a token: `(?![\w.])` rejects matches where the
|
|
93
|
+
// extension is only the head of a longer identifier or a dotted sub-field, e.g.
|
|
94
|
+
// `X.po` in `X.position`, `foo.gd` in `foo.gd.position`, or `scene.tscn2`. A
|
|
95
|
+
// real relative ref (`foo.po`, `x.gd`, `ui/scene.tscn`) always ends its token.
|
|
96
|
+
// `String.raw` keeps the `\w` escapes intact — a plain template literal would
|
|
97
|
+
// drop the backslashes, turning `\w` into `w` (a wrong guard `(?![w.])`).
|
|
98
|
+
const REL_TERM = String.raw`(?![\w.])`;
|
|
99
|
+
const REL_PATTERN = String.raw`([\w./\\-]+\.(?:` + Object.keys(FORMATS).join('|') + `))` + REL_TERM;
|
|
100
|
+
function extractRefs(text: string): string[] {
|
|
101
|
+
const refs: string[] = [];
|
|
102
|
+
for (const m of text.matchAll(/res:\/\/[\w./\\-]+/g)) refs.push(m[0]);
|
|
103
|
+
const rel = new RegExp(REL_PATTERN, 'g');
|
|
104
|
+
for (const m of text.matchAll(rel)) if (!m[1].startsWith('/')) refs.push(m[1]);
|
|
105
|
+
return refs;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Adds a source asset's own files to `files`: imported assets contribute their
|
|
110
|
+
* `.import` sidecar (text) + imported product (file); native text formats
|
|
111
|
+
* (tscn/tres/po/gd) contribute the file itself (text).
|
|
112
|
+
*/
|
|
113
|
+
function addSource(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
|
|
114
|
+
const impAbs = srcAbs + '.import';
|
|
115
|
+
if (existsSync(impAbs)) {
|
|
116
|
+
files.set(resOf(srcAbs) + '.import', 'text');
|
|
117
|
+
const dest = parseSidecarDest(readFileSync(impAbs, 'utf8'));
|
|
118
|
+
if (dest) {
|
|
119
|
+
files.set(dest, 'file');
|
|
120
|
+
}
|
|
121
|
+
} else if (isNativeText(srcAbs)) {
|
|
122
|
+
stageNativeText(srcAbs, files);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Stages a native text source (`res://` path → `text`) so web staging + inlining picks it up. */
|
|
127
|
+
function stageNativeText(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
|
|
128
|
+
files.set(resOf(srcAbs), 'text');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Collects the referenced source assets (deps) of a text source: every referenced
|
|
133
|
+
* source asset is staged (native text → `files`, products → dep modules), and
|
|
134
|
+
* loadable source assets also become dep modules so their own files + transitive
|
|
135
|
+
* deps are handled. Raw text formats without a sidecar (`.mtl`) are recursed into.
|
|
136
|
+
* Buffers (`.bin`) are skipped.
|
|
137
|
+
*/
|
|
138
|
+
function collectDeps(abs: string, visited: Set<string>, deps: string[], files: Map<string, 'text' | 'file'>): void {
|
|
139
|
+
if (visited.has(abs)) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
visited.add(abs);
|
|
143
|
+
if (!isScannableText(abs)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const ref of extractRefs(readFileSync(abs, 'utf8'))) {
|
|
147
|
+
const refAbs = ref.startsWith('res://')
|
|
148
|
+
? resolve(ROOT, ref.slice('res://'.length))
|
|
149
|
+
: resolve(dirname(abs), ref);
|
|
150
|
+
if (visited.has(refAbs)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Stage native text (e.g. `.gd`, nested `.tscn`) so the engine loads it
|
|
154
|
+
// by `res://` on web (idempotent); loadable assets also become dep
|
|
155
|
+
// modules for their own files + transitive deps.
|
|
156
|
+
if (isSourceAsset(refAbs)) {
|
|
157
|
+
if (isNativeText(refAbs)) {
|
|
158
|
+
visited.add(refAbs);
|
|
159
|
+
stageNativeText(refAbs, files);
|
|
160
|
+
}
|
|
161
|
+
if (CLASS_BY_EXT[extOf(refAbs)]) {
|
|
162
|
+
deps.push(refAbs);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Any scannable ref (a source's own text, or a raw leaf like `.mtl`) is
|
|
166
|
+
// recursed into so its internal `res://` refs are traced/staged too.
|
|
167
|
+
if (isScannableText(refAbs)) {
|
|
168
|
+
collectDeps(refAbs, visited, deps, files);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function analyze(abs: string): { files: Map<string, 'text' | 'file'>; deps: string[] } {
|
|
174
|
+
const files = new Map<string, 'text' | 'file'>();
|
|
175
|
+
const deps: string[] = [];
|
|
176
|
+
addSource(abs, files);
|
|
177
|
+
collectDeps(abs, new Set(), deps, files);
|
|
178
|
+
return { files, deps };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ensureModule(abs: string): string {
|
|
182
|
+
const res = resOf(abs);
|
|
183
|
+
const cls = CLASS_BY_EXT[extOf(abs)] ?? 'Resource';
|
|
184
|
+
const { files, deps } = analyze(abs);
|
|
185
|
+
|
|
186
|
+
const imports = [
|
|
187
|
+
`import { loadAsset } from '@ringozz/godot/load';`,
|
|
188
|
+
`import { ${cls} } from '@ringozz/godot/${cls}';`,
|
|
189
|
+
];
|
|
190
|
+
const map: string[] = [];
|
|
191
|
+
let fi = 0;
|
|
192
|
+
// Text files (own `.tscn`/`.tres`/`.po`, `.import` sidecars, staged `.gd`
|
|
193
|
+
// leaves) are inlined as string literals — Bun's `with { type: 'text' }`
|
|
194
|
+
// import resolves to a null/default under the dev server's HMR wrapper for
|
|
195
|
+
// a module's own path; products (`.scn`/`.ctex`) are fetched by path.
|
|
196
|
+
for (const [resPath, kind] of files) {
|
|
197
|
+
const absP = resolve(ROOT, resPath.slice('res://'.length));
|
|
198
|
+
if (kind === 'text') {
|
|
199
|
+
map.push(` ${JSON.stringify(resPath)}: { content: ${JSON.stringify(readFileSync(absP, 'utf8'))} },`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const v = `f${fi++}`;
|
|
203
|
+
imports.push(`import ${v} from ${JSON.stringify(absP)} with { type: 'file' };`);
|
|
204
|
+
map.push(` ${JSON.stringify(resPath)}: { path: ${v} },`);
|
|
205
|
+
}
|
|
206
|
+
deps.forEach((dep, i) => imports.push(`import * as dep${i} from ${JSON.stringify(dep)};`));
|
|
207
|
+
|
|
208
|
+
const filesVar = files.size ? `const files = {\n${map.join('\n')}\n};` : `const files = {};`;
|
|
209
|
+
const loadAsset = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, [${deps.map((_, i) => `dep${i}`).join(', ')}], import.meta.hot.data);`;
|
|
210
|
+
return imports.join('\n') + '\n' + filesVar + '\n' + loadAsset + '\n' + `export { materialize, load as default };` + '\n';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const assetPlugin: BunPlugin = {
|
|
214
|
+
name: 'godot-assets',
|
|
215
|
+
setup(build) {
|
|
216
|
+
build.onResolve({ filter: ASSET_RE }, (args) => {
|
|
217
|
+
// Only JS module imports of Godot source assets become `godot`
|
|
218
|
+
// modules. `Bun.build` reports JS imports as `import-statement`
|
|
219
|
+
// (and `dynamic-import`/`require-*`) but HTML asset references
|
|
220
|
+
// (<link>/<img>) and CSS url() refs as `internal`/`url-token` —
|
|
221
|
+
// those fall through so Bun copies/hashes the file normally. The
|
|
222
|
+
// runtime preload (bun run / bun test) reports JS imports with no
|
|
223
|
+
// kind (`undefined`), so treat that as a JS import too.
|
|
224
|
+
if (
|
|
225
|
+
args.kind !== undefined &&
|
|
226
|
+
args.kind !== 'import-statement' &&
|
|
227
|
+
args.kind !== 'dynamic-import' &&
|
|
228
|
+
args.kind !== 'require-call' &&
|
|
229
|
+
args.kind !== 'require-resolve'
|
|
230
|
+
) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const importerAbs = args.importer?.startsWith(`${NS}:`)
|
|
234
|
+
? args.importer.slice(NS.length + 1)
|
|
235
|
+
: args.importer;
|
|
236
|
+
const base = importerAbs ? dirname(importerAbs) : (args.resolveDir ?? ROOT);
|
|
237
|
+
const abs = resolve(base, args.path);
|
|
238
|
+
// A module's own native-text source (e.g. `.tscn`) self-imports the
|
|
239
|
+
// same path — let it load as a real file.
|
|
240
|
+
if (abs === importerAbs) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
return { path: abs, namespace: NS };
|
|
244
|
+
});
|
|
245
|
+
build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
|
|
246
|
+
contents: ensureModule(args.path),
|
|
247
|
+
loader: 'js',
|
|
248
|
+
}));
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export default assetPlugin;
|
|
253
|
+
plugin(assetPlugin);
|