@ringozz/godot 4.7.2-654 → 4.7.2-657

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 CHANGED
@@ -53,7 +53,7 @@ await import('./app.ts'); // may now import @ringozz/godot / react-godot freely
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
55
  | `@ringozz/godot/load` | Async resource loading: `loadResourceAsync`, `loadAsset`, `loadAssetPack` (native `.pck` packs) |
56
- | `@ringozz/godot/pack` | `buildAssetPacks()` + the `godot pack` subcommand: bake a project's `.pck`(s) + loose-texture manifest, one pack per scene |
56
+ | `@ringozz/godot/pack` | `buildAssetPacks()` + the `godot pack` subcommand: bake a project's `.pck`(s) + loose-texture manifest, one pack per level (its scene, or the `.gltfx` its plan lives in) |
57
57
  | `@ringozz/godot/debug` | `initDebug()`, `dumpStr`, `snapshot`, `inspect`, `click`, `find`, `gc` |
58
58
 
59
59
  Class-scoped enums use bare names (import `ProcessMode` from `@ringozz/godot/Node`, not `NodeProcessMode`).
@@ -97,7 +97,7 @@ const tex = await loadResourceAsync('res://textures/icon.jpg', Texture2D); //
97
97
 
98
98
  `godot pack` (the `godot` bin, `@ringozz/godot/pack`) bakes a project's assets for the runtime: it imports them, asks `dump-deps.gd` for each source's packed closure and its loose (WebP-backed) textures, and exports native `.pck`s — 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
99
 
100
- The bake always splits: **one pack per scene, plus a shared pack**, so a host fetches the shared data and only the level it is playing. A project with no scene under its roots gets a single `shared.pck`. What the runtime reads is `.godot/packs/packs.json` beside the packs: which packs exist, which sources each owns, and which loose (WebP-backed) textures the host must stage and transcode per file. The preload plugin mounts the right packs per asset, so importing a level pulls in the shared pack and that level's, nothing else. How the split is decided — and why it needs no configuration — is in `AGENTS.md`.
100
+ The bake always splits: **one pack per level, plus a shared pack**, so a host fetches the shared data and only the level it is playing. A level is its scene — or, in a project that builds levels from their plans at run time, the `.gltfx` its plan lives in; a project with neither under its roots gets a single `shared.pck`. What the runtime reads is `.godot/packs/packs.json` beside the packs: which packs exist, which sources each owns, and which loose sources the host must stage per file (the WebP-backed textures are transcoded; a plan's plain header JSONs are just written). The preload plugin mounts the right packs per asset, so importing a level pulls in the shared pack and that level's, nothing else. How the split is decided — and why it needs no configuration — is in `AGENTS.md`.
101
101
 
102
102
  ```ts
103
103
  import { loadAssetPack } from '@ringozz/godot/load';
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-654",
4
+ "version": "4.7.2-657",
5
5
  "description": "Node-API bindings for Godot Engine — call Godot classes from JavaScript",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -66,9 +66,9 @@
66
66
  "precision": "single"
67
67
  },
68
68
  "optionalDependencies": {
69
- "@ringozz/godot-macos-arm64": "^4.7.2-654",
70
- "@ringozz/godot-windows-x86_64": "^4.7.2-654",
71
- "@ringozz/godot-web-wasm32": "^4.7.2-654"
69
+ "@ringozz/godot-macos-arm64": "^4.7.2-657",
70
+ "@ringozz/godot-windows-x86_64": "^4.7.2-657",
71
+ "@ringozz/godot-web-wasm32": "^4.7.2-657"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "@types/bun": "*"
package/src/pack.ts CHANGED
@@ -7,6 +7,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
7
7
  import { dirname, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { resolveEditor } from './editor.ts';
10
+ import { mergePlanRefs, planRefs } from './planRefs.ts';
10
11
 
11
12
  // Bakes a Godot project's assets for the runtime: imports them, then exports one native
12
13
  // `.pck` **per scene** plus a shared one, so a host fetches the shared data and only the
@@ -84,27 +85,43 @@ interface Dump {
84
85
  * reach, or none does, is shared**. A scene whose own pack would be empty is dropped: every
85
86
  * source it reaches is reached by another scene too, so it is already in `shared`, and a pack
86
87
  * is mounted by *owner*.
88
+ *
89
+ * The graph it reads is the dump's *plus* every level's plan refs (`mergePlanRefs`), so a level's
90
+ * terrain, its layered containers and their layers, and its ground/environment textures are part of
91
+ * its closure whether or not a baked scene names them. When a project stops baking one scene per
92
+ * level — a level built from its plan at run time is a single shared scene plus the plan — the level
93
+ * unit becomes the `.gltfx` its plan lives in instead; until then both units exist and this keying
94
+ * keeps the scenes.
87
95
  */
88
96
  export function splitPacks(graph: Graph): AssetPackSplit[] {
89
- const stem = (asset: string) => asset.slice(asset.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '');
97
+ const stem = (asset: string) =>
98
+ asset.slice(asset.lastIndexOf('/') + 1).replace(/\.(prefab|level)\.[^.]+$/i, '').replace(/\.[^.]+$/, '');
99
+ // A level is its scene — and, in a project that builds levels from their plans at run time, the
100
+ // `.gltfx` its plan lives in. A `.gltfx` a scene **reaches** is that scene's composition, not a
101
+ // second level: keying both would make every source of a baked level look shared.
90
102
  const scenes = Object.keys(graph).filter((s) => s.toLowerCase().endsWith('.tscn')).sort();
91
- const closure = new Map(scenes.map((s) => [s, new Set([s, ...(graph[s] ?? [])])]));
103
+ const reachedByScene = new Set(scenes.flatMap((s) => graph[s] ?? []));
104
+ const levels = [
105
+ ...scenes,
106
+ ...Object.keys(graph).filter((s) => s.toLowerCase().endsWith('.gltfx') && !reachedByScene.has(s)),
107
+ ].sort();
108
+ const closure = new Map(levels.map((s) => [s, new Set([s, ...(graph[s] ?? [])])]));
92
109
 
93
- // One pack per scene, named after its file stem (`shared` is reserved; a repeated stem —
110
+ // One pack per level, named after its file stem (`shared` is reserved; a repeated stem —
94
111
  // two `level.tscn` in different directories — gets a suffix).
95
112
  const used = new Set(['shared']);
96
113
  const packOf = new Map<string, AssetPackSplit>();
97
- for (const scene of scenes) {
98
- let name = stem(scene);
99
- for (let n = 2; used.has(name); n++) name = `${stem(scene)}-${n}`;
114
+ for (const level of levels) {
115
+ let name = stem(level);
116
+ for (let n = 2; used.has(name); n++) name = `${stem(level)}-${n}`;
100
117
  used.add(name);
101
- packOf.set(scene, { name, owns: [] });
118
+ packOf.set(level, { name, owns: [] });
102
119
  }
103
120
 
104
121
  const shared: string[] = [];
105
122
  const sources = new Set([...Object.keys(graph), ...Object.values(graph).flat()]);
106
123
  for (const source of sources) {
107
- const owners = scenes.filter((scene) => closure.get(scene)!.has(source));
124
+ const owners = levels.filter((level) => closure.get(level)!.has(source));
108
125
  if (owners.length === 1) packOf.get(owners[0])!.owns.push(source);
109
126
  else shared.push(source);
110
127
  }
@@ -138,6 +155,10 @@ export async function buildAssetPacks(opts: BuildAssetPackOptions = {}): Promise
138
155
  const run = runner(editor, cwd);
139
156
  run(['--headless', '--path', '.', '--import']);
140
157
  const { graph, deps } = dump(run, cwd, roots);
158
+ // A level's plan names what no scene does any more — its terrain directory, its layered containers
159
+ // and their layers, its ground/environment textures (`planRefs`). Merged here so the graph and the
160
+ // loose lists carry the level's whole closure with or without a baked scene.
161
+ mergePlanRefs(graph, deps, planRefs(cwd, roots));
141
162
 
142
163
  const packs = splitPacks(graph);
143
164
  const owned = packs.flatMap((pack) => pack.owns);
@@ -0,0 +1,169 @@
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ // A level's refs, read from its **plan**. A `.gltfx` level scene carries the converter's derived
6
+ // render state under `extras.wartales.plan`: it names the level's terrain by *directory* and its
7
+ // ground/environment textures by resource route. A host's closure walk cannot see any of it — the
8
+ // routes are relative to the manifest that names them, a layered container's layers are plain text
9
+ // lines, and a runtime script reads the plan dynamically (so no scene names them literally any more).
10
+ // Reading it here keeps a `.gltfx` source's closure complete without a baked scene.
11
+ //
12
+ // Pure filesystem: a project directory plus the `res://` roots a host scans in, the refs out. No
13
+ // engine, no editor — so it is unit-testable and runs before the pack bake.
14
+
15
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ /** The refs a `.gltfx`'s plan implies, ready to merge into a dump. */
19
+ export interface PlanRefs {
20
+ /** The level source's own extra loose refs: its terrain data, layered-container manifests, and the
21
+ * ground/environment textures its plan names. */
22
+ loose: string[];
23
+ }
24
+
25
+ // Two of the refs a plan names are plain files rather than imported sources — the terrain's header
26
+ // JSONs, which a plan-built level reads at run time. They are in the level's `loose` list, so a
27
+ // *staging* host writes them; a *pack* needs no help either, because Godot loads a `.json` as a
28
+ // `JSON` resource (verified: `ResourceLoader.exists` is true and the class is `JSON`), so an
29
+ // `all_resources` preset packs it — and since no pack *owns* a source it is not excluded from any,
30
+ // every pack carries those few KB.
31
+
32
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
33
+
34
+ /** The `extras.wartales.plan` of a parsed gltfx document, or null for a foreign/older document. */
35
+ export function planOf(doc: unknown): Record<string, unknown> | null {
36
+ if (!isObject(doc) || !isObject(doc.extras)) return null;
37
+ const w = (doc.extras as Record<string, unknown>)['wartales'];
38
+ if (!isObject(w) || !isObject(w.plan)) return null;
39
+ return w.plan as Record<string, unknown>;
40
+ }
41
+
42
+ /** Every `src/<dir>`-style root as an absolute disk directory, paired with its `res://` prefix. */
43
+ function rootDirs(projectDir: string, roots: string[]): { disk: string; res: string }[] {
44
+ const out: { disk: string; res: string }[] = [];
45
+ for (const root of roots) {
46
+ const rel = root.replace(/^res:\/\//, '');
47
+ out.push({ disk: join(projectDir, ...rel.split('/')), res: `res://${rel}` });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** Every file under `dir`, as paths relative to `dir` (posix, one level of nesting per entry). */
53
+ function walk(dir: string, prefix = ''): string[] {
54
+ const out: string[] = [];
55
+ let entries: string[];
56
+ try {
57
+ entries = readdirSync(dir);
58
+ } catch {
59
+ return out;
60
+ }
61
+ for (const name of entries) {
62
+ if (name.startsWith('.')) continue;
63
+ const full = join(dir, name);
64
+ const rel = prefix ? `${prefix}/${name}` : name;
65
+ try {
66
+ if (readdirSync(full).length >= 0) {
67
+ out.push(...walk(full, rel));
68
+ continue;
69
+ }
70
+ } catch {
71
+ // not a directory
72
+ }
73
+ out.push(rel);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** The resource route of a ref the plan names, probing the served `.webp` suffix a texture carries. */
79
+ function routeOf(root: { disk: string; res: string }, route: string): string | null {
80
+ for (const candidate of [route, `${route}.webp`]) {
81
+ if (existsSync(join(root.disk, ...candidate.split('/')))) return `${root.res}/${candidate}`;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * Every `.gltfx` under `roots` with its plan-derived refs. A level's terrain is discovered by
88
+ * *listing* its directory (the tree holds exactly the level's atlas there, so no name list to keep in
89
+ * step), its manifests' layers are read from the manifests, and its ground/environment textures are
90
+ * probed per route (a plan route may or may not carry the served `.webp` suffix).
91
+ */
92
+ export function planRefs(projectDir: string, roots: string[]): Map<string, PlanRefs> {
93
+ const out = new Map<string, PlanRefs>();
94
+ for (const root of rootDirs(projectDir, roots)) {
95
+ for (const file of walk(root.disk)) {
96
+ if (!file.toLowerCase().endsWith('.gltfx')) continue;
97
+ let doc: unknown;
98
+ try {
99
+ doc = JSON.parse(readFileSync(join(root.disk, ...file.split('/')), 'utf8'));
100
+ } catch {
101
+ continue;
102
+ }
103
+ const plan = planOf(doc);
104
+ if (!plan) continue;
105
+ const levelRoute = `${root.res}/${file}`;
106
+ const loose = new Set<string>();
107
+
108
+ // The terrain: the plan names its directory, and the tree holds exactly the level's own atlas
109
+ // there (the height and tile-normal maps, the composed weight planes, the passmap and its
110
+ // meta, the header JSON and the layered containers) — so the directory *is* the list. A
111
+ // container's *layers* are deliberately not listed: they live inside its imported product,
112
+ // and are import-time inputs, so nothing stages them at run time.
113
+ const terrain = isObject(plan.terrain) ? plan.terrain : null;
114
+ if (terrain && typeof terrain.dir === 'string') {
115
+ const dir = terrain.dir;
116
+ for (const name of walk(join(root.disk, ...dir.split('/')))) loose.add(`${root.res}/${dir}/${name}`);
117
+ }
118
+
119
+ // The ground's patches and the rig's skies: plain resource routes with the served suffix.
120
+ const ground = Array.isArray(plan.ground) ? plan.ground : [];
121
+ for (const g of ground) {
122
+ if (!isObject(g)) continue;
123
+ const fields = g.source === 'overlay' ? [isObject(g.material) ? g.material : {}] : [isObject(g.water) ? g.water : {}];
124
+ for (const f of fields) {
125
+ for (const key of ['diffuse', 'normal', 'orm']) {
126
+ if (typeof f[key] !== 'string') continue;
127
+ const route = routeOf(root, f[key] as string);
128
+ if (route) loose.add(route);
129
+ }
130
+ }
131
+ }
132
+ const lighting = isObject(plan.lighting) ? plan.lighting : {};
133
+ const envs = [lighting.env, lighting.probe, ...(Array.isArray(lighting.stages) ? lighting.stages.map((s) => (isObject(s) ? s.env : null)) : [])];
134
+ for (const e of envs) {
135
+ if (!isObject(e) || typeof e.map !== 'string') continue;
136
+ const route = routeOf(root, e.map as string);
137
+ if (route) loose.add(route);
138
+ }
139
+
140
+ out.set(levelRoute, { loose: [...loose].sort() });
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * Merge `planRefs` into a dump's `graph`/`loose` halves: a `.gltfx` source gains its plan's refs, and
148
+ * so does every source that reaches it (a scene that instances it, so mounting the scene stages the
149
+ * level's terrain and ground too — a union, and a no-op for a project with no plan).
150
+ */
151
+ export function mergePlanRefs(
152
+ graph: Record<string, string[]>,
153
+ deps: Record<string, string[]>,
154
+ refs: Map<string, PlanRefs>,
155
+ ): void {
156
+ for (const [level, r] of refs) {
157
+ if (!(level in graph)) continue;
158
+ deps[level] = [...new Set([...(deps[level] ?? []), ...r.loose])].sort();
159
+ }
160
+ // A scene that instances a `.gltfx` carries that plan's refs too: mounting the scene must stage the
161
+ // level's terrain and ground, not only its model composition. (The scene may already name some of
162
+ // them — a baked one does — so this is a union, and a no-op for a project with no plan.)
163
+ for (const source of Object.keys(graph)) {
164
+ const plans = graph[source].filter((dep) => refs.has(dep));
165
+ if (!plans.length || refs.has(source)) continue;
166
+ const extra = plans.flatMap((p) => refs.get(p)!.loose);
167
+ deps[source] = [...new Set([...(deps[source] ?? []), ...extra])].sort();
168
+ }
169
+ }