@ringozz/godot 4.7.2-661 → 4.7.2-676

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/preload.ts CHANGED
@@ -5,178 +5,124 @@
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
6
  import { dirname, resolve } from 'node:path';
7
7
  import { plugin } from 'bun';
8
- import type { PackManifest } from './pack.ts';
8
+ import { classOf, moduleExtensions } from './formats.ts';
9
+ import { BOOT_MANIFEST, BOOT_MODULE, PACK_DIR, PACK_DIR_ENV, PACK_DOC, RELEASE, descriptorOf, packEntries, stagedKeys, type PackDoc, type PackEntry } from './pack.ts';
10
+ import { assetModule, packModule, type ServedAsset } from './serve.ts';
9
11
  import type { BunPlugin } from 'bun';
10
12
 
11
- // Registers the godot-assets plugin: imports of Godot source assets become
12
- // virtual modules (namespace `godot`) that mount the project's asset pack and
13
- // load the resource.
13
+ // Registers the godot-assets plugin: importing a Godot source asset becomes a module that stages what
14
+ // the source needs and loads it (default export = the load promise, `materialize` = its files staged).
14
15
  //
15
- // A packed source asset (`tscn`/`tres`/`gltf`/`gd`/font/…) is loaded from the
16
- // `.pck` baked by `godot pack`: the module mounts the pack(s) (`loadAssetPack`),
17
- // whose remap table resolves the source path to the packed binary (`.scn`/`.res`)
18
- // with refs the engine resolves itself. The
19
- // plugin never scans source text for references — the only per-file staging is
20
- // for *loose* (WebP-backed) textures the wasm engine cannot decode, whose list
21
- // is computed by the engine (`@ringozz/godot/pack`'s `dump-deps.gd`, via
22
- // `ResourceLoader.get_dependencies`) and shipped in the manifest beside the packs.
16
+ // What a source needs comes from the bake (`@ringozz/godot/pack`), never from scanning source text:
17
+ // `dump-deps.gd` walked the project at bake time and wrote, per source, the loose (WebP-backed)
18
+ // texture sources the wasm engine cannot decode — and the descriptor of every loose file the app's
19
+ // pack owns. A packed source resolves inside the mounted `.pck`; only its loose textures are staged
20
+ // per file, each with the `.import` text and product `descriptorOf` reads from the same sidecar the
21
+ // bake read.
23
22
  //
24
- // The load promise persists on `import.meta.hot.data` (carried across HMR
25
- // re-evaluations) so `use()` never re-suspends; a rejected load is evicted so
26
- // the next evaluation retries. The default export is the single plugin —
27
- // registered globally via bunfig preload and loaded by `[serve.static] plugins`.
23
+ // The bake's own pack is the module it generated beside the packs (`boot-pack.js`): its `.pck`, the
24
+ // config the runtime stages content packs from, and the mount every asset module waits for. So
25
+ // importing any asset is what mounts the app's pack, and the config an app still supplies
26
+ // (`configurePacks({ at })`, where a content release is read) merges with it in either order.
27
+ //
28
+ // The load promise persists on `import.meta.hot.data` (carried across HMR re-evaluations) so `use()`
29
+ // never re-suspends; a rejected load is evicted so the next evaluation retries. The default export is
30
+ // the single plugin — registered globally via bunfig preload and loaded by `[serve.static] plugins`.
28
31
 
29
32
  const ROOT = process.cwd();
30
33
  const NS = 'godot';
31
34
 
32
- /** Extension -> class. `loose` = WebP-backed texture excluded from the pack, staged/transcoded per-file. */
33
- const FORMATS: Record<string, { cls?: string; loose?: boolean }> = {
34
- gltf: { cls: 'PackedScene' },
35
- // A scene-composition document (a glTF External Reference): an imported source whose product is a
36
- // `PackedScene`, like a `.gltf`'s — and whose product is where its meshes/textures are referenced.
37
- gltfx: { cls: 'PackedScene' },
38
- tscn: { cls: 'PackedScene' },
39
- obj: { cls: 'PackedScene' },
40
- gd: { cls: 'GDScript' },
41
- gdshader: { cls: 'Shader' },
42
- gdshaderinc: { cls: 'ShaderInclude' },
43
- jpg: { cls: 'CompressedTexture2D', loose: true },
44
- jpeg: { cls: 'CompressedTexture2D', loose: true },
45
- png: { cls: 'CompressedTexture2D', loose: true },
46
- webp: { cls: 'CompressedTexture2D', loose: true },
47
- // The layer manifest `godot/addons/sourcewebp` imports into a `.ctexarray` (a texture array whose
48
- // layers are source WebP bytes). It has no runtime deps of its own; the host stages its product.
49
- webparray: { cls: 'CompressedTexture2DArray', loose: true },
50
- svg: { cls: 'CompressedTexture2D', loose: true },
51
- exr: { cls: 'TextureLayered', loose: true },
52
- hdr: { cls: 'TextureLayered', loose: true },
53
- wav: { cls: 'AudioStreamWAV' },
54
- ogg: { cls: 'AudioStreamOggVorbis' },
55
- mp3: { cls: 'AudioStreamMP3' },
56
- tres: { cls: 'Resource' },
57
- po: { cls: 'Translation' },
58
- ttf: { cls: 'FontFile' },
59
- otf: { cls: 'FontFile' },
60
- mtl: {},
61
- };
62
-
63
- const ASSET_RE = new RegExp(
64
- `\\.(${Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k]) => k).join('|')})$`,
65
- 'i',
66
- );
67
- const CLASS_BY_EXT: Record<string, string> = Object.fromEntries(
68
- Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k, f]) => [k, f.cls!]),
69
- );
70
- const LOOSE = new Set(Object.entries(FORMATS).filter(([, f]) => f.loose).map(([k]) => k));
35
+ const ASSET_RE = new RegExp(`\\.(${moduleExtensions.join('|')})$`, 'i');
71
36
 
72
- const extOf = (abs: string) => abs.slice(abs.lastIndexOf('.') + 1).toLowerCase();
73
37
  const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
74
- const isLoose = (abs: string) => LOOSE.has(extOf(abs));
75
38
 
76
- // A project bakes native `.pck`s (`godot --export-pack`) — the engine-converted binary
77
- // resources + remap tables — through `@ringozz/godot/pack`'s `buildAssetPacks`: one per
78
- // level, plus a shared one, so a host fetches the shared data and only the level it is
79
- // playing. The manifest beside them (`<pack dir>/packs.json`) is the whole runtime contract:
80
- // which packs exist, which sources each owns, and which loose (WebP-backed) textures the host
81
- // must stage per file. A module mounts the reserved `shared` pack plus its asset's owner, so
82
- // importing a level brings in exactly what it needs.
83
- //
84
- // Everything is optional: with no manifest (desktop, or a project that has not baked) modules
85
- // load source paths directly.
86
39
  const readJson = <T>(file: string): T | null =>
87
40
  existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as T) : null;
88
41
 
89
- /** The pack every asset mounts, as `splitPacks` names it (its `name` is reserved for this). */
90
- const SHARED_PACK = 'shared';
91
-
92
- /** Where the packs were baked — `godot pack`'s `--out` (`GODOT_PACKS` overrides for a host). */
93
- const PACK_DIR = resolve(ROOT, process.env.GODOT_PACKS ?? '.godot/packs');
94
- const MANIFEST = readJson<PackManifest>(resolve(PACK_DIR, 'packs.json'));
95
-
96
- /** The packs an asset needs mounted: the shared one plus its owner. */
97
- function packsFor(resPath: string): string[] {
98
- return (MANIFEST?.packs ?? [])
99
- .filter((pack) => pack.name === SHARED_PACK || pack.owns.includes(resPath))
100
- .map((pack) => resolve(ROOT, pack.pck.replace(/^res:\/\//, '')));
101
- }
102
-
103
- /** Reads the imported destination (`dest_files`) out of an `.import` sidecar. */
104
- function parseSidecarDest(text: string): string | null {
105
- const deps = text.match(/^dest_files=\[([^\]]*)\]/m);
106
- if (deps) {
107
- const first = deps[1].match(/"([^"]+)"/);
108
- if (first) {
109
- return first[1];
110
- }
42
+ /** Where the boot pack was baked — `godot pack`'s `--out` ({@link PACK_DIR_ENV} overrides for a host). */
43
+ const DIR = resolve(ROOT, process.env[PACK_DIR_ENV] ?? PACK_DIR);
44
+ const MANIFEST = readJson<PackDoc>(resolve(DIR, BOOT_MANIFEST));
45
+ /** The bake's generated boot module (`{ mounting }` + its config); null when unbaked. */
46
+ const BOOT = MANIFEST ? resolve(DIR, BOOT_MODULE) : null;
47
+
48
+ /**
49
+ * A content pack's entries, from the bake's own documents — `release.json` and each pack's emitted
50
+ * manifest, the same shapes the runtime fetches. A pack carries no entry module: the plugin serves one
51
+ * built from these, so an entry, its `res://` path and its class come from one declaration — the rule
52
+ * the bake emits the pack's types from as well (`packEntries`).
53
+ */
54
+ const PACK_ENTRIES = readPacks();
55
+
56
+ function readPacks(): Map<string, PackEntry[]> {
57
+ const packs = new Map<string, PackEntry[]>();
58
+ const release = readJson<Record<string, unknown>>(resolve(DIR, RELEASE));
59
+ for (const pack of Object.keys(release ?? {})) {
60
+ const entries = packEntries(readJson<PackDoc>(resolve(DIR, pack, PACK_DOC))?.exports);
61
+ if (entries.length) packs.set(pack, entries);
111
62
  }
112
- const remap = text.match(/^path="([^"]+)"/m);
113
- return remap ? remap[1] : null;
63
+ return packs;
114
64
  }
115
65
 
116
- /** Adds a loose texture source to `files`: its `.import` sidecar (text) + product (file). */
117
- function addLoose(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
118
- const impAbs = srcAbs + '.import';
119
- if (!existsSync(impAbs)) {
120
- return;
121
- }
122
- files.set(resOf(srcAbs) + '.import', 'text');
123
- const dest = parseSidecarDest(readFileSync(impAbs, 'utf8'));
124
- if (dest) {
125
- files.set(dest, 'file');
126
- }
66
+ /**
67
+ * The loose texture sources one asset stages: what the bake's manifest recorded for it, or itself when
68
+ * it *is* a loose texture — a source with an `.import` sidecar is excluded from the pack, so a texture
69
+ * imported on its own has no packed source to name it (the same rule `dump-deps.gd` classifies by).
70
+ */
71
+ function needsOf(abs: string): string[] {
72
+ const res = resOf(abs);
73
+ const baked = MANIFEST?.needs[res];
74
+ if (baked) return baked;
75
+ return existsSync(abs + '.import') ? [res] : [];
127
76
  }
128
77
 
129
- function analyze(abs: string): Map<string, 'text' | 'file'> {
130
- const files = new Map<string, 'text' | 'file'>();
131
- // A directly imported loose texture stages itself; a packed resource stages the
132
- // loose textures the engine's dependency dump found for it.
133
- if (isLoose(abs)) {
134
- addLoose(abs, files);
135
- }
136
- for (const loose of MANIFEST?.loose[resOf(abs)] ?? []) {
137
- addLoose(resolve(ROOT, loose.slice('res://'.length)), files);
138
- }
139
- return files;
78
+ /**
79
+ * A loose file's staging descriptor: the bake's own (its manifest carries every loose file the app's
80
+ * pack owns), or its sidecar when the bake never saw it — which is what keeps the plugin and a content
81
+ * pack's runtime staging on the same rule, `descriptorOf`.
82
+ */
83
+ function descriptorFor(loose: string): { path: string; import: string } | null {
84
+ const baked = MANIFEST?.stage[loose];
85
+ if (baked) return baked;
86
+ const sidecar = resolve(ROOT, loose.slice('res://'.length)) + '.import';
87
+ return existsSync(sidecar) ? descriptorOf(readFileSync(sidecar, 'utf8')) : null;
140
88
  }
141
89
 
142
- function ensureModule(abs: string): string {
143
- const res = resOf(abs);
144
- const cls = CLASS_BY_EXT[extOf(abs)] ?? 'Resource';
145
- const files = analyze(abs);
146
-
147
- const imports = [
148
- `import { loadAsset, loadAssetPack } from '@ringozz/godot/load';`,
149
- `import { ${cls} } from '@ringozz/godot/${cls}';`,
150
- ];
151
- let ready = 'undefined';
152
- const mounts = packsFor(res);
153
- if (mounts.length) {
154
- mounts.forEach((url, i) => imports.push(`import pack${i} from ${JSON.stringify(url)} with { type: 'file' };`));
155
- const loads = mounts.map((_, i) => `loadAssetPack(pack${i})`);
156
- ready = loads.length === 1 ? loads[0] : `Promise.all([${loads.join(', ')}])`;
157
- }
158
- const map: string[] = [];
159
- let fi = 0;
160
- // Loose texture files: sidecars as inlined string literals (Bun's
161
- // `with { type: 'text' }` import resolves to a null/default under the dev
162
- // server's HMR wrapper for a module's own path), products fetched by path so
163
- // the host can transcode them.
164
- for (const [resPath, kind] of files) {
165
- const absP = resolve(ROOT, resPath.slice('res://'.length));
166
- if (kind === 'text') {
167
- map.push(` ${JSON.stringify(resPath)}: { content: ${JSON.stringify(readFileSync(absP, 'utf8'))} },`);
168
- continue;
169
- }
170
- const v = `f${fi++}`;
171
- imports.push(`import ${v} from ${JSON.stringify(absP)} with { type: 'file' };`);
172
- map.push(` ${JSON.stringify(resPath)}: { path: ${v} },`);
173
- }
174
-
175
- const filesVar = files.size ? `const files = {\n${map.join('\n')}\n};` : `const files = {};`;
176
- const loadAsset = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, import.meta.hot.data, ${ready});`;
177
- return imports.join('\n') + '\n' + filesVar + '\n' + loadAsset + '\n' + `export { materialize, load as default };` + '\n';
90
+ /**
91
+ * One source's module, from the bake's own records: the loose textures the bake recorded for it (`needs`
92
+ * / `stage`, or its sidecar when the bake never saw it), each as the absolute path the bundler imports
93
+ * plus the two keys it stages under. Every other decision is the module text's (`serve.ts`).
94
+ */
95
+ function servedAsset(abs: string): ServedAsset {
96
+ return {
97
+ res: resOf(abs),
98
+ // The filter is `moduleExtensions`, so a path that reached here has a class.
99
+ cls: classOf(abs)!,
100
+ boot: BOOT,
101
+ files: needsOf(abs).flatMap((loose) => {
102
+ const descriptor = descriptorFor(loose);
103
+ if (!descriptor) return [];
104
+ const { sidecar, product } = stagedKeys(loose, descriptor);
105
+ return [
106
+ {
107
+ specifier: resolve(ROOT, descriptor.path.replace(/^res:\/\//, '')),
108
+ sidecar,
109
+ content: descriptor.import,
110
+ product,
111
+ },
112
+ ];
113
+ }),
114
+ };
178
115
  }
179
116
 
117
+ /**
118
+ * Exactly the pack names the bake declares — nothing else is touched. A wider filter would shadow
119
+ * resolutions Bun makes itself (it injects a `react-refresh` import in dev, and that import must fall
120
+ * through to it, not to a plugin that declines).
121
+ */
122
+ const PACK_RE = PACK_ENTRIES.size
123
+ ? new RegExp(`^(${[...PACK_ENTRIES.keys()].map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`)
124
+ : null;
125
+
180
126
  const assetPlugin: BunPlugin = {
181
127
  name: 'godot-assets',
182
128
  setup(build) {
@@ -200,8 +146,15 @@ const assetPlugin: BunPlugin = {
200
146
  const base = args.importer ? dirname(args.importer) : (args.resolveDir ?? ROOT);
201
147
  return { path: resolve(base, args.path), namespace: NS };
202
148
  });
149
+ // A pack's own module (the pack carries no entry file, so the plugin serves it, built from the
150
+ // bake's manifests). The filter names the packs exactly: anything else resolves as usual.
151
+ if (PACK_RE) {
152
+ build.onResolve({ filter: PACK_RE }, (args) => ({ path: args.path, namespace: NS }));
153
+ }
203
154
  build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
204
- contents: ensureModule(args.path),
155
+ contents: PACK_ENTRIES.has(args.path)
156
+ ? packModule(args.path, PACK_ENTRIES.get(args.path)!)
157
+ : assetModule(servedAsset(args.path)),
205
158
  loader: 'js',
206
159
  }));
207
160
  },
package/src/serve.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ // The source text the preload plugin serves, as a virtual module per import: one for a Godot source
6
+ // asset (the loose textures it stages, then the load), one for a content pack (a named export per entry
7
+ // it declares). Pure — a module's text is a function of the facts the plugin resolved — which is what
8
+ // makes the one part of the plugin that decides what an import becomes reachable without a bake, a
9
+ // bundler or an engine. The plugin resolves those facts (`preload.ts`); nothing here touches a disk.
10
+
11
+ import { entryId, type PackEntry } from './pack.ts';
12
+
13
+ /** The facts the plugin resolved for one source asset, as the module it serves reads them. */
14
+ export interface ServedAsset {
15
+ /** The source's `res://` path — what the module loads. */
16
+ res: string;
17
+ /** The class it loads as. */
18
+ cls: string;
19
+ /**
20
+ * One entry per loose texture the source needs: the file to bundle (an absolute path, so the host can
21
+ * transcode it), and the two keys it stages under. The sidecar text is inlined rather than imported
22
+ * as text — Bun's `with { type: 'text' }` resolves to a null/default under the dev server's HMR
23
+ * wrapper for a module's own path.
24
+ */
25
+ files: Array<{ specifier: string; sidecar: string; content: string; product: string }>;
26
+ /** The boot module every asset module waits for (`{ mounting }`); `null` when the project is unbaked. */
27
+ boot: string | null;
28
+ }
29
+
30
+ /**
31
+ * One source's module: its loose textures as bundled files, and the load. The boot module supplies the
32
+ * mount to wait for, so the pack's remaps are in place before anything resolves — and importing it is
33
+ * also what mounts the boot pack.
34
+ */
35
+ export function assetModule({ res, cls, files, boot }: ServedAsset): string {
36
+ const imports = [
37
+ `import { loadAsset } from '@ringozz/godot/load';`,
38
+ `import { ${cls} } from '@ringozz/godot/${cls}';`,
39
+ ];
40
+ const map: string[] = [];
41
+ files.forEach(({ specifier, sidecar, content, product }, n) => {
42
+ imports.push(`import f${n} from ${JSON.stringify(specifier)} with { type: 'file' };`);
43
+ map.push(`\t${JSON.stringify(sidecar)}: { content: ${JSON.stringify(content)} },`);
44
+ map.push(`\t${JSON.stringify(product)}: { path: f${n} },`);
45
+ });
46
+ if (boot) imports.push(`import { mounting } from ${JSON.stringify(boot)};`);
47
+ const staged = map.length ? `const files = {\n${map.join('\n')}\n};` : 'const files = {};';
48
+ const load = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, import.meta.hot.data, ${boot ? 'mounting' : 'undefined'});`;
49
+ return `${imports.join('\n')}\n${staged}\n${load}\nexport { materialize, load as default };\n`;
50
+ }
51
+
52
+ /**
53
+ * A content pack's module: one named export per entry its document declares, each loading through
54
+ * `loadPackEntry`, so a client resolves the entry by name from the document it fetched. Nothing about
55
+ * the pack is baked in — no entry file, no path — and the class is the same format-table rule every
56
+ * asset import uses.
57
+ */
58
+ export function packModule(pack: string, entries: PackEntry[]): string {
59
+ const imports = ["import { loadPackEntry } from '@ringozz/godot/load';"];
60
+ for (const cls of new Set(entries.map((e) => e.cls))) {
61
+ imports.push(`import { ${cls} } from '@ringozz/godot/${cls}';`);
62
+ }
63
+ const lines = entries.map(
64
+ (e) => `export const ${entryId(e.entry)} = loadPackEntry(${JSON.stringify(pack)}, ${JSON.stringify(e.entry)}, ${e.cls});`,
65
+ );
66
+ return `${imports.join('\n')}\n\n${lines.join('\n')}\n`;
67
+ }