@ringozz/godot 4.7.2-659 → 4.7.2-664
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 +25 -6
- package/package.json +5 -4
- package/src/assets.d.ts +10 -1
- package/src/bake.ts +469 -0
- package/src/dump-deps.gd +200 -53
- package/src/editor.ts +1 -1
- package/src/formats.ts +78 -0
- package/src/load.ts +153 -17
- package/src/pack.ts +280 -221
- package/src/preload.ts +165 -208
- package/src/serve.ts +67 -0
- package/src/planRefs.ts +0 -209
package/README.md
CHANGED
|
@@ -52,8 +52,9 @@ await import('./app.ts'); // may now import @ringozz/godot / react-godot freely
|
|
|
52
52
|
| `@ringozz/godot/ClassName` | Classes, one per module — e.g. `@ringozz/godot/Label`, `@ringozz/godot/Node` |
|
|
53
53
|
| `@ringozz/godot/runtime` | Low-level dispatch: `_C`, `_S`, `_R`, `_G`, `_P`, `_V`, `toValueType`, `getGodot`, `GodotVar` |
|
|
54
54
|
| `@ringozz/godot/boot` | Platform boot: `getNativeModule()`; web `preloadGodot()` (awaited before importing the app; no-op on desktop) |
|
|
55
|
-
| `@ringozz/godot/load` | Async resource loading: `loadResourceAsync`, `loadAsset`, `loadAssetPack` (native `.pck` packs) |
|
|
56
|
-
| `@ringozz/godot/
|
|
55
|
+
| `@ringozz/godot/load` | Async resource loading: `loadResourceAsync`, `loadAsset`, `loadAssetPack` (native `.pck` packs), `configurePacks`/`loadPackEntry` (content packs) |
|
|
56
|
+
| `@ringozz/godot/bake` | `buildAssetPacks(opts, run?)` + the `godot pack` subcommand: bake the project's boot `.pck` plus one per declared content pack, with their loose-texture documents. `opts.cwd` picks the project root and `run` replaces the editor, so a test can bake a temp project |
|
|
57
|
+
| `@ringozz/godot/pack` | The pack protocol the bake writes and the plugin/runtime read: `PACK_DOC`/`RELEASE`/`FILES_DIR` and the boot's names, `parseDoc`/`serializeDoc`/`parseRelease`, `packEntries`/`godotEntry`/`entryId`/`packPath`/`pckOf`, and `resolveEntry` (what to mount and stage for one entry) |
|
|
57
58
|
| `@ringozz/godot/debug` | `initDebug()`, `dumpStr`, `snapshot`, `inspect`, `click`, `find`, `gc` |
|
|
58
59
|
|
|
59
60
|
Class-scoped enums use bare names (import `ProcessMode` from `@ringozz/godot/Node`, not `NodeProcessMode`).
|
|
@@ -91,19 +92,37 @@ const tex = await loadResourceAsync('res://textures/icon.jpg', Texture2D); //
|
|
|
91
92
|
```
|
|
92
93
|
|
|
93
94
|
- `loadResourceAsync(path, cls, files?)` — generic over the Godot class: `cls.name` is the `ResourceLoader` type hint, and the return type is `InstanceType<typeof cls>`. Loads in the background (`loadThreadedRequest` + status polling). Godot default arguments apply: the napi dispatch truncates trailing `undefined` args, so `loadThreadedRequest` uses its default `CACHE_MODE_REUSE` and the resource stays cached in the engine (scene re-references don't re-read the file). On web it only works for paths already staged into MEMFS (generated asset modules handle this). When the asset's `files` map is passed, the staged product files (`.ctex`/`.scn`/native sources) are deleted from MEMFS after the resource loads — only the `.import` sidecars are kept, since they route imported source paths to their products. The decoded texture bytes don't linger in the JS heap. `files` lists overlap (a scene and the model it instantiates can share one loose texture), so a product may need re-staging after another module's cleanup: `materialize` creates the parent directory itself and treats an already-staged file as done, because the engine's `copyToFS` throws on a path that is already there while `FileAccess.fileExists('res://…')` cannot see the Emscripten spelling of it.
|
|
94
|
-
- `loadAsset(path, cls, files,
|
|
95
|
+
- `loadAsset(path, cls, files, data, ready)` — the generated asset modules' entry point (also usable directly): returns `{ materialize, load }`. It checks the engine's `ResourceCache` first (when the asset is already loaded, `load` resolves to the cached instance and staging is skipped), stages the bundled files via Godot's `copyToFS` (zero-copy handover of the fetched bytes; a no-op on desktop where the files already exist), loads via `loadResourceAsync`, and memoizes `load` on `data` (pass `import.meta.hot.data`) so HMR re-evaluations reuse the same fulfilled promise.
|
|
95
96
|
|
|
96
97
|
### Asset packs
|
|
97
98
|
|
|
98
|
-
`godot pack` (the `godot` bin, `@ringozz/godot/
|
|
99
|
+
`godot pack` (the `godot` bin, `@ringozz/godot/bake`) bakes a project's assets for the runtime: it imports them, asks `dump-deps.gd` for every source's loose (WebP-backed) textures — including a plan, which names refs no resource edge exposes (it warns when a plan resolves no ref at all, or is stamped under another `extras` namespace — that reading has no static check) — and exports one native `.pck` — the engine-converted binary resources plus a remap table — through the project's committed `export_presets.cfg`. `loadAssetPack(url)` fetches a pack into MEMFS and hands it to `ProjectSettings.loadResourcePack`; packs are add-only, so several can be mounted and `res://` resolves across all of them.
|
|
99
100
|
|
|
100
|
-
The bake
|
|
101
|
+
The bake produces **one boot pack** — the project's own sources, exported through its committed `export_presets.cfg` preset, and always mounted by the preload plugin, which is what makes a scene's own run-time paths (a script named by a constant, a level chosen at run time) resolvable — plus **one pack per directory under `dlc/` that declares itself with a `package.json`**: that file gives the pack its name, its dependencies on other packs and its `exports` entries, the pack is that directory and nothing else (the bake rewrites the preset's one `exclude_filter` line per pack), and the bake fails if a pack references anything but the boot sources and its declared dependencies. The boot pack's document is `.godot/packs/packs.json` (`needs` = which loose files each source needs staged, `stage` = the descriptor and product of each loose file the pack owns); a content pack's is a `pack.json` emitted beside its `.pck` (`PACK_DOC` — not `package.json`, which is the pack's own manifest in its source directory, an input the bake reads) (its declared fields plus `needs`/`stage` — the same document the boot's `packs.json` is). Deploying those packs independently — versioning, an index, a CDN — is the host's business, not the bake's.
|
|
101
102
|
|
|
102
103
|
```ts
|
|
103
104
|
import { loadAssetPack } from '@ringozz/godot/load';
|
|
104
105
|
await loadAssetPack('/packs/shared.pck'); // usually the preload plugin does this per asset
|
|
105
106
|
```
|
|
106
107
|
|
|
108
|
+
### Content packs
|
|
109
|
+
|
|
110
|
+
A content pack is deployed apart from the app, so nothing about it is in the bundle: the runtime reads a **release** (which version of which pack to mount — a `bun.lock`, or the plain map a local bake emits) and each mounted pack's document, then loads a declared entry. Point the runtime at the packs once, at start-up:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { configurePacks } from '@ringozz/godot/load';
|
|
114
|
+
configurePacks({ at: (path) => `https://cdn.example/packs/${path}` }); // '<name>/<version>/<file>'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The boot pack — the app's own sources, which ship beside it — needs no config: the bake renders it into a module (`.godot/packs/boot-pack.js`: its `.pck`, plus a bundled URL per loose product it owns), which every imported asset pulls in, and `configurePacks` merges, so the app's `at` and the boot's config may land in either order. A pack declares its entries (`exports` → the `godot` condition, the `res://` path) and ships **no entry module**: the preload plugin serves one, built from the bake's documents, with one named export per entry. So importing a pack is an ordinary import — and the bake emits the types it resolves to (`package.d.ts`, named by the pack's `types`):
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { level } from '@tiv/dlc-beach'; // → a PackedScene, via loadPackEntry(pack, 'level', …)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Mounting is add-only, so every pack in the closure stays mounted for the page's lifetime.
|
|
124
|
+
|
|
125
|
+
|
|
107
126
|
### Asset imports
|
|
108
127
|
|
|
109
128
|
Asset imports are resolved by `@ringozz/godot/preload` (a Bun plugin, registered via `bunfig.toml` preload for `bun run`/`bun test` and via `[serve.static] plugins` for `Bun.serve`'s fullstack HTML routes; the module's default export is the single plugin object) into a module per asset. Only **JS imports** are virtualized — HTML asset references (`<link rel="icon">`, `<img src>`) and CSS `url()` refs fall through to Bun's normal (content-hashed) asset handling, so favicons and other web assets co-exist with Godot imports:
|
|
@@ -113,7 +132,7 @@ import foo from './models/foo.gltf';
|
|
|
113
132
|
const scene = await foo; // PackedScene
|
|
114
133
|
```
|
|
115
134
|
|
|
116
|
-
Each asset module's default export is its load **promise** (`const { materialize, load } = loadAsset(resPath, Class, files,
|
|
135
|
+
Each asset module's default export is its load **promise** (`const { materialize, load } = loadAsset(resPath, Class, files, import.meta.hot.data, mounting)` — the default is `load`, memoized on the module's HMR data), started eagerly at module import, and it also exports a `materialize` promise (its files staged). The files Godot reads are bundled into the module — `.import` sidecars are inlined as text, imported products (`.ctex`) are emitted as files — from the bake's document, never from scanning the source text: `dump-deps.gd` walked the project at bake time and wrote, per source, the loose (WebP-backed) texture sources the wasm engine cannot decode, and the pack's `stage` gives each of those its sidecar and its product. A packed source resolves inside the mounted `.pck` (the engine-converted resources plus a remap table); only its loose textures are staged per file, transcoded in the browser. The `mounting` promise comes from the bake's boot pack — the module the bake generates beside the packs (`.godot/packs/boot-pack.js`): its `.pck`, plus a bundled URL for every loose product it owns — so importing any asset is also what mounts the app's own pack, and the runtime can stage a content pack's loose files even when the boot owns them. The modules are virtual (namespace `godot`), and every import of the same asset resolves to the same one (evaluated once) — so the promise is a stable singleton, read with `await` or React 19's `use()`:
|
|
117
136
|
|
|
118
137
|
```tsx
|
|
119
138
|
import foo from './models/foo.gltf';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ringozz/godot",
|
|
3
3
|
"author": "Vladimir Davidovich",
|
|
4
|
-
"version": "4.7.2-
|
|
4
|
+
"version": "4.7.2-664",
|
|
5
5
|
"description": "Node-API bindings for Godot Engine — call Godot classes from JavaScript",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"./load": "./src/load.ts",
|
|
29
29
|
"./pack": "./src/pack.ts",
|
|
30
|
+
"./bake": "./src/bake.ts",
|
|
30
31
|
"./transcoder": {
|
|
31
32
|
"browser": "./src/transcoder.browser.ts",
|
|
32
33
|
"default": "./src/transcoder.ts"
|
|
@@ -66,9 +67,9 @@
|
|
|
66
67
|
"precision": "single"
|
|
67
68
|
},
|
|
68
69
|
"optionalDependencies": {
|
|
69
|
-
"@ringozz/godot-macos-arm64": "^4.7.2-
|
|
70
|
-
"@ringozz/godot-windows-x86_64": "^4.7.2-
|
|
71
|
-
"@ringozz/godot-web-wasm32": "^4.7.2-
|
|
70
|
+
"@ringozz/godot-macos-arm64": "^4.7.2-664",
|
|
71
|
+
"@ringozz/godot-windows-x86_64": "^4.7.2-664",
|
|
72
|
+
"@ringozz/godot-web-wasm32": "^4.7.2-664"
|
|
72
73
|
},
|
|
73
74
|
"peerDependencies": {
|
|
74
75
|
"@types/bun": "*"
|
package/src/assets.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ declare module '*.gdshaderinc' {
|
|
|
30
30
|
export default asset;
|
|
31
31
|
export const materialize: Promise<unknown>;
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
|
|
34
34
|
declare module '*.tres' {
|
|
35
35
|
import type { Resource } from '@ringozz/godot/Resource';
|
|
36
36
|
const asset: Promise<Resource>;
|
|
@@ -108,6 +108,15 @@ declare module '*.svg' {
|
|
|
108
108
|
export const materialize: Promise<unknown>;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// The layer manifest `godot/addons/sourcewebp` imports into a `.ctexarray` (a texture array whose
|
|
112
|
+
// layers are source WebP bytes); its product is what is staged.
|
|
113
|
+
declare module '*.webparray' {
|
|
114
|
+
import type { CompressedTexture2DArray } from '@ringozz/godot/CompressedTexture2DArray';
|
|
115
|
+
const asset: Promise<CompressedTexture2DArray>;
|
|
116
|
+
export default asset;
|
|
117
|
+
export const materialize: Promise<unknown>;
|
|
118
|
+
}
|
|
119
|
+
|
|
111
120
|
declare module '*.exr' {
|
|
112
121
|
import type { TextureLayered } from '@ringozz/godot/TextureLayered';
|
|
113
122
|
const asset: Promise<TextureLayered>;
|
package/src/bake.ts
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { resolveEditor } from './editor.ts';
|
|
10
|
+
import { packedExtensions, scannedExtensions } from './formats.ts';
|
|
11
|
+
import {
|
|
12
|
+
BOOT_MANIFEST,
|
|
13
|
+
BOOT_MODULE,
|
|
14
|
+
BOOT_PCK,
|
|
15
|
+
FILES_DIR,
|
|
16
|
+
PACK_DIR,
|
|
17
|
+
PACK_DOC,
|
|
18
|
+
RELEASE,
|
|
19
|
+
descriptorOf,
|
|
20
|
+
entryId,
|
|
21
|
+
packEntries,
|
|
22
|
+
pckOf,
|
|
23
|
+
serializeDoc,
|
|
24
|
+
type Exports,
|
|
25
|
+
type PackDoc,
|
|
26
|
+
type Release,
|
|
27
|
+
} from './pack.ts';
|
|
28
|
+
|
|
29
|
+
// Bakes a Godot project's assets for the runtime. Two kinds of pack, both native `.pck`s (the
|
|
30
|
+
// engine-converted binary resources + remap table) exported through the project's own committed
|
|
31
|
+
// `export_presets.cfg`:
|
|
32
|
+
//
|
|
33
|
+
// - **the boot pack**: the app's own sources (`src/`), exported with the preset, and always mounted
|
|
34
|
+
// by the runtime — a scene may load a path it assembles itself (a script named by a constant, a
|
|
35
|
+
// level chosen at run time), and only a mounted pack resolves those.
|
|
36
|
+
// - **content packs**: one per directory under `dlc/` that declares itself with a `package.json`.
|
|
37
|
+
// Each is exported on its own — the preset's `exclude_filter` is rewritten per pack, so a pack
|
|
38
|
+
// carries its own directory and nothing else — and carries the declared package fields plus the
|
|
39
|
+
// staging data the runtime needs, so it can be published and deployed apart from the app.
|
|
40
|
+
//
|
|
41
|
+
// A pack may reference the boot sources and the packs it declares as `dependencies`, never a sibling,
|
|
42
|
+
// and the bake fails otherwise: the runtime mounts a pack by import, so a reference "upward" or
|
|
43
|
+
// "sideways" is a reference that will not resolve.
|
|
44
|
+
//
|
|
45
|
+
// This is the bake half: everything that spawns the editor or touches the disk. What it writes is the
|
|
46
|
+
// pack protocol's (`pack.ts`), which the plugin and the runtime read without any of this.
|
|
47
|
+
|
|
48
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
|
|
50
|
+
/** The dump's one build-time output, rewritten by every bake. */
|
|
51
|
+
const DUMP = '.godot/pack-dump.json';
|
|
52
|
+
|
|
53
|
+
export interface BuildAssetPackOptions {
|
|
54
|
+
/** The project root the bake reads and writes (default: `process.cwd()`). */
|
|
55
|
+
cwd?: string;
|
|
56
|
+
/** res:// roots whose assets the boot pack carries (default `['res://src']`). */
|
|
57
|
+
roots?: string[];
|
|
58
|
+
/** res:// root to discover content packs under (default `res://dlc`). */
|
|
59
|
+
dlc?: string;
|
|
60
|
+
/** Output directory for the packs + documents, project-relative (default {@link PACK_DIR}). */
|
|
61
|
+
out?: string;
|
|
62
|
+
/** The `export_presets.cfg` preset every pack exports with (default `Pack`). */
|
|
63
|
+
preset?: string;
|
|
64
|
+
/** Editor executable override (default: `resolveEditor()`). */
|
|
65
|
+
godot?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A content pack: a directory that declares itself with a `package.json`. */
|
|
69
|
+
interface ContentPack {
|
|
70
|
+
/** The declared package name (`@tiv/packs/assets`), or the directory's own name. */
|
|
71
|
+
name: string;
|
|
72
|
+
/** Project-relative directory, no trailing slash (`dlc/assets`). */
|
|
73
|
+
dir: string;
|
|
74
|
+
/** Declared version, if any. */
|
|
75
|
+
version?: string;
|
|
76
|
+
/** Declared pack dependencies: package name → version range. */
|
|
77
|
+
dependencies: Record<string, string>;
|
|
78
|
+
/** The declared `exports` verbatim: how the pack names the entries a client may load. */
|
|
79
|
+
exports?: Exports;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A `spawnSync` runner for the editor — the bake's one injected dependency. */
|
|
83
|
+
export type Run = (args: string[], env?: Record<string, string>) => void;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Bakes the boot pack, then one pack per directory under `dlc/` that declares a `package.json`.
|
|
87
|
+
* Three editor runs per pack (import, dump, export); no project file is left changed.
|
|
88
|
+
*
|
|
89
|
+
* `run` is the seam: the editor by default, a stand-in in a test — so what a caller gets (the
|
|
90
|
+
* documents, the products beside them, the preset put back) can be asserted through this interface
|
|
91
|
+
* rather than through the helpers it is made of. Everything else is the filesystem, which `opts.cwd`
|
|
92
|
+
* can point at any root.
|
|
93
|
+
*/
|
|
94
|
+
export async function buildAssetPacks(opts: BuildAssetPackOptions = {}, run?: Run): Promise<void> {
|
|
95
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
96
|
+
const bootRoots = opts.roots ?? ['res://src'];
|
|
97
|
+
const out = opts.out ?? PACK_DIR;
|
|
98
|
+
const preset = opts.preset ?? 'Pack';
|
|
99
|
+
const exec = run ?? runner(opts.godot ?? (await resolveEditor()), cwd);
|
|
100
|
+
mkdirSync(resolve(cwd, out), { recursive: true });
|
|
101
|
+
|
|
102
|
+
// The editor's import scan must not walk the installed dependency tree: a workspace-linked pack
|
|
103
|
+
// would be imported a second time through the link, at a depth its relative paths (a terrain
|
|
104
|
+
// `.webparray`'s layer refs) do not survive. `.gdignore` is the editor's own skip marker; the
|
|
105
|
+
// bundler never looks at it, and the sources are imported once at their real `res://` paths.
|
|
106
|
+
const installed = resolve(cwd, 'node_modules');
|
|
107
|
+
if (existsSync(installed)) writeFileSync(join(installed, '.gdignore'), '');
|
|
108
|
+
exec(['--headless', '--path', '.', '--import']);
|
|
109
|
+
|
|
110
|
+
const dlc = rel(opts.dlc ?? 'res://dlc');
|
|
111
|
+
const packs = discoverPacks(cwd, dlc);
|
|
112
|
+
assertScopes(bootRoots.map(rel), packs);
|
|
113
|
+
const dirOf = new Map(packs.map((pack) => [pack.name, pack.dir]));
|
|
114
|
+
const release = releaseOf(packs);
|
|
115
|
+
// Every scoped directory: the boot sources and the pack directories. A pack's export excludes all
|
|
116
|
+
// of them but its own, so no source can belong to two packs.
|
|
117
|
+
const bootDirs = bootRoots.map(rel);
|
|
118
|
+
const all = [...bootDirs, ...packs.map((pack) => pack.dir)];
|
|
119
|
+
|
|
120
|
+
const presets = resolve(cwd, 'export_presets.cfg');
|
|
121
|
+
const cfg = readFileSync(presets, 'utf8');
|
|
122
|
+
// A plan's routes are relative to the converter's output root, which is where the packs live.
|
|
123
|
+
const ctx: Bake = { run: exec, cwd, cfg, preset, base: excludeFilter(cfg), all, planBase: resPath(dlc) };
|
|
124
|
+
try {
|
|
125
|
+
// **One dump for the whole project**, sliced per scope below (a loose file is staged by the pack
|
|
126
|
+
// that *owns* it, whoever needs it). One dump is also one editor run instead of one per pack.
|
|
127
|
+
const dump = runDump(ctx, [...bootRoots, ...packs.map((pack) => resPath(pack.dir))]);
|
|
128
|
+
|
|
129
|
+
const boot = manifestOf(dump, cwd, out, bootDirs);
|
|
130
|
+
assertRefs('the boot pack', boot.external, bootRoots);
|
|
131
|
+
exportPack(ctx, bootDirs, `${out}/${BOOT_PCK}`);
|
|
132
|
+
writeFileSync(resolve(cwd, out, BOOT_MANIFEST), serializeDoc({ needs: boot.needs, stage: boot.stage }));
|
|
133
|
+
writeFileSync(resolve(cwd, out, BOOT_MODULE), bootModule(boot));
|
|
134
|
+
console.log(`[pack] ${BOOT_PCK} over ${Object.keys(boot.needs).length} sources`);
|
|
135
|
+
|
|
136
|
+
for (const pack of packs) {
|
|
137
|
+
const version = release[pack.name].version;
|
|
138
|
+
// Version-addressed, exactly as a client fetches it — a local bake then exercises the same
|
|
139
|
+
// URLs a CDN serves (`packPath`), and nothing is ever rewritten in place.
|
|
140
|
+
const dir = `${out}/${pack.name}/${version}`;
|
|
141
|
+
mkdirSync(resolve(cwd, dir), { recursive: true });
|
|
142
|
+
const own = manifestOf(dump, cwd, dir, [pack.dir]);
|
|
143
|
+
const entries = packEntries(pack.exports);
|
|
144
|
+
const allowed = [...bootRoots, ...Object.keys(pack.dependencies).map((dep) => resPath(dirOf.get(dep) ?? dep))];
|
|
145
|
+
assertRefs(pack.name, own.external, allowed);
|
|
146
|
+
exportPack(ctx, [pack.dir], `${dir}/${pckOf(pack.name)}`);
|
|
147
|
+
writeFileSync(
|
|
148
|
+
resolve(cwd, dir, PACK_DOC),
|
|
149
|
+
serializeDoc({
|
|
150
|
+
// The pack's own declaration (`name`/`version`/`dependencies`/`exports`) plus what the
|
|
151
|
+
// bake computed: the same document shape the boot pack's is, so a client reads one. The
|
|
152
|
+
// `.pck` is `<name>.pck` beside it, by convention — mounting never waits on this document.
|
|
153
|
+
name: pack.name,
|
|
154
|
+
version,
|
|
155
|
+
dependencies: pack.dependencies,
|
|
156
|
+
exports: withoutTypes(pack.exports),
|
|
157
|
+
needs: own.needs,
|
|
158
|
+
stage: own.stage,
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
// The pack's types, emitted beside its own `package.json` (whose `types` names them): a
|
|
162
|
+
// build-time plugin can serve the module, not its types, so the one declaration it already
|
|
163
|
+
// carries becomes a declaration file too.
|
|
164
|
+
const types = resolve(cwd, pack.dir, 'package.d.ts');
|
|
165
|
+
if (entries.length) {
|
|
166
|
+
writeFileSync(types, packTypes(pack.exports));
|
|
167
|
+
} else {
|
|
168
|
+
rmSync(types, { force: true });
|
|
169
|
+
}
|
|
170
|
+
console.log(`[pack] ${pack.name}@${version} over ${Object.keys(own.needs).length} sources`);
|
|
171
|
+
}
|
|
172
|
+
if (packs.length) {
|
|
173
|
+
writeFileSync(resolve(cwd, out, RELEASE), serializeDoc(release));
|
|
174
|
+
}
|
|
175
|
+
} finally {
|
|
176
|
+
// The preset is scoped per pack, so it is rewritten per export and put back once.
|
|
177
|
+
writeFileSync(presets, cfg);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The bake's shared context: the runner, the project, the preset, and every scoped directory. */
|
|
182
|
+
interface Bake {
|
|
183
|
+
run: Run;
|
|
184
|
+
cwd: string;
|
|
185
|
+
cfg: string;
|
|
186
|
+
preset: string;
|
|
187
|
+
base: string[];
|
|
188
|
+
all: string[];
|
|
189
|
+
/** The converter's output root, which a plan's routes are relative to. */
|
|
190
|
+
planBase: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Scopes the preset to `mine` and exports that scope to `pck`. */
|
|
194
|
+
function exportPack(ctx: Bake, mine: string[], pck: string): void {
|
|
195
|
+
writeFileSync(resolve(ctx.cwd, 'export_presets.cfg'), withExcludes(ctx.cfg, ctx.preset, excludesFor(ctx.base, ctx.all, mine)));
|
|
196
|
+
ctx.run(['--headless', '--path', '.', '--export-pack', ctx.preset, pck]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One scope's half of the dump, as a document's `needs`/`stage` (and the refs the bake checks). A pack's
|
|
201
|
+
* export carries its own directory and nothing else, so `needs` — what each source under `dirs` needs
|
|
202
|
+
* staged — is that scope's slice, while `stage` — the descriptor of each loose file it *owns* — is
|
|
203
|
+
* judged over the **whole project**: a level's sky lives in the shared pack and is named by nothing but
|
|
204
|
+
* the level's plan, and the owner stages it all the same. `needs` stays paths (whoever owns them),
|
|
205
|
+
* which is what lets a pack borrow another's texture without carrying its bytes. `external` is what the
|
|
206
|
+
* scope's sources leak, for the ref check.
|
|
207
|
+
*/
|
|
208
|
+
function manifestOf(
|
|
209
|
+
dump: Record<string, Dumped>,
|
|
210
|
+
cwd: string,
|
|
211
|
+
dir: string,
|
|
212
|
+
dirs: string[],
|
|
213
|
+
): Pick<PackDoc, 'needs' | 'stage'> & { external: string[] } {
|
|
214
|
+
const roots = dirs.map((d) => resPath(d));
|
|
215
|
+
const needs: Record<string, string[]> = {};
|
|
216
|
+
const external: string[] = [];
|
|
217
|
+
for (const [source, d] of Object.entries(dump)) {
|
|
218
|
+
if (!roots.some((root) => source.startsWith(`${root}/`))) continue;
|
|
219
|
+
if (d.loose.length) needs[source] = d.loose;
|
|
220
|
+
external.push(...d.external);
|
|
221
|
+
}
|
|
222
|
+
const stage: Record<string, { path: string; import: string }> = {};
|
|
223
|
+
for (const loose of new Set(Object.values(dump).flatMap((d) => d.loose))) {
|
|
224
|
+
// Only this scope's own: a borrowed loose file is staged from the pack that owns it, so carrying
|
|
225
|
+
// a copy would make every level ship the shared textures — exactly what deploying a level on its
|
|
226
|
+
// own must not do.
|
|
227
|
+
if (!roots.some((root) => loose.startsWith(`${root}/`))) continue;
|
|
228
|
+
const abs = resolve(cwd, rel(loose));
|
|
229
|
+
if (!existsSync(`${abs}.import`)) continue;
|
|
230
|
+
const descriptor = descriptorOf(readFileSync(`${abs}.import`, 'utf8'));
|
|
231
|
+
if (!descriptor || !existsSync(resolve(cwd, rel(descriptor.path)))) continue;
|
|
232
|
+
mkdirSync(resolve(cwd, dir, FILES_DIR), { recursive: true });
|
|
233
|
+
copyFileSync(resolve(cwd, rel(descriptor.path)), resolve(cwd, dir, FILES_DIR, descriptor.path.split('/').pop()!));
|
|
234
|
+
stage[loose] = descriptor;
|
|
235
|
+
}
|
|
236
|
+
return { needs, stage, external: [...new Set(external)].sort() };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A `spawnSync` runner that throws on a non-zero editor exit. */
|
|
240
|
+
function runner(editor: string, cwd: string): Run {
|
|
241
|
+
return (args, env) => {
|
|
242
|
+
const r = spawnSync(editor, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env } });
|
|
243
|
+
if ((r.status ?? 1) !== 0) {
|
|
244
|
+
throw new Error(`godot-assets: \`${editor} ${args.join(' ')}\` failed (${r.status})`);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One bucket of the dump: the loose textures a source needs, and the refs that left the scan roots. */
|
|
250
|
+
interface Dumped {
|
|
251
|
+
loose: string[];
|
|
252
|
+
external: string[];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Runs `dump-deps.gd` over `scope` (res:// roots): per source, its loose textures and its leaks. */
|
|
256
|
+
function runDump(ctx: Bake, scope: string[]): Record<string, Dumped> {
|
|
257
|
+
copyFileSync(resolve(HERE, 'dump-deps.gd'), resolve(ctx.cwd, '.godot', 'pack-dump-deps.gd'));
|
|
258
|
+
ctx.run(['--headless', '--path', '.', '--script', 'res://.godot/pack-dump-deps.gd'], {
|
|
259
|
+
GODOT_DUMP_OUT: resPath(DUMP),
|
|
260
|
+
GODOT_DUMP_ROOTS: scope.join(','),
|
|
261
|
+
GODOT_DUMP_PLAN_BASE: ctx.planBase,
|
|
262
|
+
// The dump holds no extension lists of its own: they come from the format table, so a source
|
|
263
|
+
// cannot be walked by the dump and staged by the plugin for want of one entry.
|
|
264
|
+
GODOT_DUMP_PACKED: packedExtensions.join(','),
|
|
265
|
+
GODOT_DUMP_SCANNED: scannedExtensions.join(','),
|
|
266
|
+
});
|
|
267
|
+
const abs = resolve(ctx.cwd, DUMP);
|
|
268
|
+
if (!existsSync(abs)) throw new Error(`godot-assets: the dump wrote no ${DUMP} (see dump-deps.gd).`);
|
|
269
|
+
return JSON.parse(readFileSync(abs, 'utf8')) as Record<string, Dumped>;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const resPath = (rel: string) => `res://${rel.replace(/^res:\/\//, '')}`;
|
|
273
|
+
const rel = (res: string) => res.replace(/^res:\/\//, '').replace(/\/+$/, '');
|
|
274
|
+
|
|
275
|
+
/** Fails the bake on a reference that leaves the pack (a sibling, or a source no pack owns). */
|
|
276
|
+
function assertRefs(what: string, external: string[], allowed: string[]): void {
|
|
277
|
+
const illegal = external.filter((ref) => !allowed.some((dir) => ref.startsWith(`${dir}/`)));
|
|
278
|
+
if (illegal.length) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`godot-assets: ${what} references ${illegal.length} source(s) outside itself and ${allowed.join(', ')} — a pack's refs point at the boot sources or a declared dependency:\n ${illegal.slice(0, 10).join('\n ')}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Finds the content packs under `rootAbs` (searching the subdirectory `under`, so the returned `dir`s
|
|
287
|
+
* are `rootAbs`-relative): every directory holding a `package.json`, identified by that file's `name`.
|
|
288
|
+
* Packs do not nest — a pack inside a pack could not be scoped to one export — and a directory holding
|
|
289
|
+
* one is a container, so stray files beside it belong to no pack.
|
|
290
|
+
*/
|
|
291
|
+
function discoverPacks(rootAbs: string, under = ''): ContentPack[] {
|
|
292
|
+
const dirs: string[] = [];
|
|
293
|
+
walk(rootAbs, under, dirs);
|
|
294
|
+
const packs: ContentPack[] = [];
|
|
295
|
+
for (const dir of dirs.sort()) {
|
|
296
|
+
const parent = packs.find((pack) => dir.startsWith(`${pack.dir}/`));
|
|
297
|
+
if (parent) throw new Error(`godot-assets: ${dir}/ is inside the pack ${parent.dir} — packs do not nest.`);
|
|
298
|
+
if (!dir) throw new Error('godot-assets: the discovery root itself declares a package.json.');
|
|
299
|
+
const pkg = JSON.parse(readFileSync(join(rootAbs, dir, 'package.json'), 'utf8')) as {
|
|
300
|
+
name?: string;
|
|
301
|
+
version?: string;
|
|
302
|
+
dependencies?: Record<string, string>;
|
|
303
|
+
exports?: Exports;
|
|
304
|
+
};
|
|
305
|
+
packs.push({
|
|
306
|
+
name: pkg.name ?? dir.split('/').pop()!,
|
|
307
|
+
dir,
|
|
308
|
+
...(pkg.version ? { version: pkg.version } : {}),
|
|
309
|
+
dependencies: pkg.dependencies ?? {},
|
|
310
|
+
// Passed through verbatim: the declaration is what a client resolves an entry against, so
|
|
311
|
+
// the bake copies it rather than restating it (a client reads it through the entry's
|
|
312
|
+
// `godot` condition).
|
|
313
|
+
exports: pkg.exports,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return packs;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Collects every directory (relative, `/`-separated) under `rootAbs` that holds a `package.json`. */
|
|
320
|
+
function walk(rootAbs: string, dir: string, out: string[]): void {
|
|
321
|
+
const abs = dir ? join(rootAbs, dir) : rootAbs;
|
|
322
|
+
let names: string[];
|
|
323
|
+
try {
|
|
324
|
+
names = readdirSync(abs);
|
|
325
|
+
} catch {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (names.includes('package.json')) out.push(dir);
|
|
329
|
+
for (const name of names) {
|
|
330
|
+
if (name.startsWith('.') || name === 'node_modules') continue;
|
|
331
|
+
if (statSync(join(abs, name)).isDirectory()) walk(rootAbs, dir ? `${dir}/${name}` : name, out);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The release a local bake serves, in the shape `parseRelease` reads: pack name → the version it just
|
|
337
|
+
* wrote and the dependencies it declared. A deploy publishes the same graph (resolved by `bun install`
|
|
338
|
+
* into a lockfile) under one immutable name, which is the only thing that identifies a content release.
|
|
339
|
+
*/
|
|
340
|
+
function releaseOf(packs: ContentPack[]): Release {
|
|
341
|
+
return Object.fromEntries(
|
|
342
|
+
packs.map((pack) => [pack.name, { version: pack.version ?? '0.0.0', deps: Object.keys(pack.dependencies) }]),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The boot pack as a module the app's bundle imports (beside the packs it describes): its `.pck`
|
|
348
|
+
* mounted once, however many modules import this, and the same `needs`/`stage` — with each loose
|
|
349
|
+
* product it owns imported as a file, so its URL is the bundle's. A content pack may need one, and the
|
|
350
|
+
* boot is mounted like any other pack, so its files belong in a client's staging lookup. Only the app
|
|
351
|
+
* can name those URLs, which is why the bake renders them here.
|
|
352
|
+
*/
|
|
353
|
+
function bootModule({ needs, stage }: Pick<PackDoc, 'needs' | 'stage'>): string {
|
|
354
|
+
const imports: string[] = [];
|
|
355
|
+
const files: string[] = [];
|
|
356
|
+
for (const descriptor of Object.values(stage)) {
|
|
357
|
+
const name = descriptor.path.split('/').pop()!;
|
|
358
|
+
imports.push(`import file${files.length} from ${JSON.stringify(`./${FILES_DIR}/${name}`)} with { type: 'file' };`);
|
|
359
|
+
files.push(`\t${JSON.stringify(name)}: file${files.length},`);
|
|
360
|
+
}
|
|
361
|
+
return (
|
|
362
|
+
[
|
|
363
|
+
"// Generated by `godot pack`: the app's own pack, as a module (see @ringozz/godot/preload).",
|
|
364
|
+
`import bootPck from ${JSON.stringify(`./${BOOT_PCK}`)} with { type: 'file' };`,
|
|
365
|
+
`import { configurePacks, loadAssetPack } from '@ringozz/godot/load';`,
|
|
366
|
+
...imports,
|
|
367
|
+
files.length ? `const bootFiles = {\n${files.join('\n')}\n};` : 'const bootFiles = {};',
|
|
368
|
+
`configurePacks({ boot: { pck: bootPck, doc: ${JSON.stringify({ needs, stage })}, url: (file) => bootFiles[file] } });`,
|
|
369
|
+
`export const mounting = loadAssetPack(bootPck);`,
|
|
370
|
+
].join('\n') + '\n'
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* A pack's type surface, emitted beside the declaration that names it (`types` in its own
|
|
376
|
+
* `package.json`): one typed export per declared entry, so importing the module the plugin serves is
|
|
377
|
+
* type-checked with no generated module — `packEntries` resolves each entry, exactly as the served
|
|
378
|
+
* module's is. The bake's, not the protocol's: it writes it.
|
|
379
|
+
*/
|
|
380
|
+
export function packTypes(exports: Exports): string {
|
|
381
|
+
const entries = packEntries(exports);
|
|
382
|
+
const classes = [...new Set(entries.map((e) => e.cls))];
|
|
383
|
+
return [
|
|
384
|
+
"// Generated by `godot pack` from the pack's `exports` — do not edit.",
|
|
385
|
+
...classes.map((cls) => `import type { ${cls} } from '@ringozz/godot/${cls}';`),
|
|
386
|
+
'',
|
|
387
|
+
...entries.map((e) => `export declare const ${entryId(e.entry)}: Promise<${e.cls}>;`),
|
|
388
|
+
'',
|
|
389
|
+
].join('\n');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A pack's declared `exports` minus its `types` condition. The document is emitted beside the `.pck`,
|
|
394
|
+
* while the declarations a pack's `types` names stay in its source directory (the bake writes them
|
|
395
|
+
* there), so the path would not resolve from the document and nothing reads it — `packEntries` reads
|
|
396
|
+
* the `godot` condition. A subpath left with no conditions goes with it: the document says what a
|
|
397
|
+
* client may resolve.
|
|
398
|
+
*/
|
|
399
|
+
function withoutTypes(exports: Exports): Exports {
|
|
400
|
+
if (!exports) return exports;
|
|
401
|
+
const declared: Record<string, string | { [condition: string]: string | undefined }> = {};
|
|
402
|
+
for (const [subpath, value] of Object.entries(exports)) {
|
|
403
|
+
if (typeof value !== 'object') {
|
|
404
|
+
declared[subpath] = value;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const conditions = Object.fromEntries(Object.entries(value).filter(([condition]) => condition !== 'types'));
|
|
408
|
+
if (Object.keys(conditions).length) declared[subpath] = conditions;
|
|
409
|
+
}
|
|
410
|
+
return declared;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The bake's scopes must not overlap. Each pack's export excludes every *other* scoped directory, so a
|
|
415
|
+
* boot root that contains a pack would exclude that pack's own files from its own export — an empty
|
|
416
|
+
* pack, which shows up much later as a scene whose assets are missing. The mistake is easy to make
|
|
417
|
+
* (`--roots res://src,res://dlc` when the packs live under `dlc/`), so the bake says so instead.
|
|
418
|
+
*/
|
|
419
|
+
function assertScopes(bootDirs: string[], packs: ContentPack[]): void {
|
|
420
|
+
for (const pack of packs) {
|
|
421
|
+
const root = bootDirs.find((dir) => pack.dir.startsWith(`${dir}/`));
|
|
422
|
+
if (root) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`godot-assets: the boot root ${root} contains the content pack ${pack.dir} — the boot pack would carry it and the pack's own export would exclude it. Point --roots at sources only: a pack's directory is its own export.`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* A pack's `exclude_filter`: the preset's own entries plus a glob per scoped directory that is not
|
|
432
|
+
* `mine` — so the export carries this pack and nothing else.
|
|
433
|
+
*/
|
|
434
|
+
function excludesFor(base: string[], all: string[], mine: string[]): string[] {
|
|
435
|
+
return [...base, ...all.filter((dir) => !mine.includes(dir)).map((dir) => `${dir}/*`)];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** A preset block's `exclude_filter` entries. */
|
|
439
|
+
function excludeFilter(cfg: string): string[] {
|
|
440
|
+
const line = /^exclude_filter="([^"]*)"/m.exec(cfg);
|
|
441
|
+
return (line?.[1] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** The config with the preset named `preset`'s `exclude_filter` replaced by `exclude`. */
|
|
445
|
+
function withExcludes(cfg: string, preset: string, exclude: string[]): string {
|
|
446
|
+
const at = new RegExp(`^name=${JSON.stringify(preset)}$`, 'm').exec(cfg)?.index ?? -1;
|
|
447
|
+
if (at < 0) {
|
|
448
|
+
throw new Error(`godot-assets: no "${preset}" preset in export_presets.cfg (see export_presets.example.cfg).`);
|
|
449
|
+
}
|
|
450
|
+
const line = /^exclude_filter="[^"]*"$/m.exec(cfg.slice(at));
|
|
451
|
+
if (!line) throw new Error(`godot-assets: the "${preset}" preset has no exclude_filter.`);
|
|
452
|
+
const start = at + line.index;
|
|
453
|
+
return `${cfg.slice(0, start)}exclude_filter=${JSON.stringify(exclude.join(','))}${cfg.slice(start + line[0].length)}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Parses `godot pack` CLI args into {@link BuildAssetPackOptions}. */
|
|
457
|
+
export function parsePackArgs(args: string[]): BuildAssetPackOptions {
|
|
458
|
+
const opt = (flag: string): string | undefined => {
|
|
459
|
+
const i = args.indexOf(flag);
|
|
460
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
461
|
+
};
|
|
462
|
+
return {
|
|
463
|
+
roots: opt('--roots')?.split(',').filter(Boolean),
|
|
464
|
+
dlc: opt('--dlc'),
|
|
465
|
+
out: opt('--out'),
|
|
466
|
+
preset: opt('--preset'),
|
|
467
|
+
godot: opt('--godot'),
|
|
468
|
+
};
|
|
469
|
+
}
|