@ringozz/godot 4.7.2-659 → 4.7.2-661
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 +4 -4
- package/package.json +4 -4
- package/src/dump-deps.gd +85 -12
- package/src/pack.ts +84 -86
- package/src/preload.ts +11 -8
- package/src/planRefs.ts +0 -209
package/README.md
CHANGED
|
@@ -91,13 +91,13 @@ const tex = await loadResourceAsync('res://textures/icon.jpg', Texture2D); //
|
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
- `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,
|
|
94
|
+
- `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
95
|
|
|
96
96
|
### Asset packs
|
|
97
97
|
|
|
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.
|
|
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 — including a level's plan, which names refs no resource edge exposes — 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 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
|
|
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). 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';
|
|
@@ -113,7 +113,7 @@ import foo from './models/foo.gltf';
|
|
|
113
113
|
const scene = await foo; // PackedScene
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
Each asset module's default export is its load **promise** (`const { materialize, load } = loadAsset(resPath, Class, files,
|
|
116
|
+
Each asset module's default export is its load **promise** (`const { materialize, load } = loadAsset(resPath, Class, files, import.meta.hot.data, ready)` — the default is `load`, memoized on the module's HMR data), started eagerly at module import, and it also exports a `materialize` promise (its own files plus its deps' files). The files Godot reads are bundled into the module — `.import` sidecars and native text sources (`tscn`/`tres`/`po`/`gd`/`gdshader`/`gdshaderinc`) are inlined as text, imported products (`.scn`/`.ctex`) are emitted as files — and the module imports its **dependencies as generated modules too**, waiting only for their `materialize` before loading itself (dep loads start at module evaluation and run concurrently). Text-based source assets (`.gltf`/`.tscn`/`.tres`/`.obj`/`.gd`/`.gdshader`/`.gdshaderinc`) are scanned for references (`res://` paths and relative paths ending in known asset extensions); referenced loadable assets become dep modules, native-text leaves are staged as raw text so the engine loads them by `res://` on web, raw `.mtl` files (OBJ materials) are recursed into, and raw `.bin` buffers are import-time only. 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
117
|
|
|
118
118
|
```tsx
|
|
119
119
|
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-661",
|
|
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-
|
|
70
|
-
"@ringozz/godot-windows-x86_64": "^4.7.2-
|
|
71
|
-
"@ringozz/godot-web-wasm32": "^4.7.2-
|
|
69
|
+
"@ringozz/godot-macos-arm64": "^4.7.2-661",
|
|
70
|
+
"@ringozz/godot-windows-x86_64": "^4.7.2-661",
|
|
71
|
+
"@ringozz/godot-web-wasm32": "^4.7.2-661"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"@types/bun": "*"
|
package/src/dump-deps.gd
CHANGED
|
@@ -16,6 +16,12 @@ extends SceneTree
|
|
|
16
16
|
## is *loose* when it has an `.import` sidecar but is not a packed resource type — the set is
|
|
17
17
|
## derived, not listed.
|
|
18
18
|
##
|
|
19
|
+
## A third source, because neither above can see it: a **level built from its plan**
|
|
20
|
+
## (`extras.gltfx.plan`, which a host script reads at run time). It names its terrain by
|
|
21
|
+
## *directory* and its ground/environment maps by relative route — no resource edge and no `res://`
|
|
22
|
+
## literal — so `_plan_refs` reads the plan off the `.gltfx` and classifies its refs. A scene that
|
|
23
|
+
## instances the `.gltfx` inherits them through `_collect`'s recursion.
|
|
24
|
+
##
|
|
19
25
|
## Driven by `@ringozz/godot/pack`'s `buildAssetPacks`, which sets:
|
|
20
26
|
## GODOT_DUMP_OUT – output res:// path (default res://.godot/pack-dump.json) — a build
|
|
21
27
|
## input: the bake merges it into the manifest beside the packs.
|
|
@@ -38,7 +44,8 @@ func _initialize() -> void:
|
|
|
38
44
|
roots = ["res://"]
|
|
39
45
|
var sources := {}
|
|
40
46
|
for r in roots:
|
|
41
|
-
|
|
47
|
+
var root := String(r).rstrip("/") + "/"
|
|
48
|
+
_scan(root, root, sources)
|
|
42
49
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out.get_base_dir()))
|
|
43
50
|
var f := FileAccess.open(out, FileAccess.WRITE)
|
|
44
51
|
f.store_string(JSON.stringify(sources, " "))
|
|
@@ -47,19 +54,20 @@ func _initialize() -> void:
|
|
|
47
54
|
quit()
|
|
48
55
|
|
|
49
56
|
|
|
50
|
-
|
|
57
|
+
## `root` is the scan root `path` lives under (a plan's refs are relative to it), carried down the walk.
|
|
58
|
+
func _scan(root: String, path: String, sources: Dictionary) -> void:
|
|
51
59
|
var d := DirAccess.open(path)
|
|
52
60
|
if d == null:
|
|
53
61
|
return
|
|
54
62
|
for dir in d.get_directories():
|
|
55
63
|
if dir.begins_with(".") or dir == "node_modules":
|
|
56
64
|
continue
|
|
57
|
-
_scan(path.path_join(dir) + "/", sources)
|
|
65
|
+
_scan(root, path.path_join(dir) + "/", sources)
|
|
58
66
|
for file in d.get_files():
|
|
59
67
|
var p := path.path_join(file)
|
|
60
68
|
if p.get_extension().to_lower() in PACKED:
|
|
61
69
|
var seen := {}
|
|
62
|
-
var loose := _loose(p, seen)
|
|
70
|
+
var loose := _loose(root, p, seen)
|
|
63
71
|
# The graph holds authored packed sources only: the walk also visits the engine's
|
|
64
72
|
# own products (`res://.godot/imported/…`, reached through the `.import` sidecar),
|
|
65
73
|
# and a product is carried by its *source*, not owned in its own right.
|
|
@@ -70,24 +78,24 @@ func _scan(path: String, sources: Dictionary) -> void:
|
|
|
70
78
|
|
|
71
79
|
## The loose texture sources reachable from `path` (transitive), sorted. `seen` collects every
|
|
72
80
|
## packed source the walk touches — the graph — including `path` itself.
|
|
73
|
-
func _loose(path: String, seen: Dictionary) -> Array:
|
|
81
|
+
func _loose(root: String, path: String, seen: Dictionary) -> Array:
|
|
74
82
|
var loose := {}
|
|
75
|
-
_collect(path, loose, seen)
|
|
83
|
+
_collect(root, path, loose, seen)
|
|
76
84
|
var arr := loose.keys()
|
|
77
85
|
arr.sort()
|
|
78
86
|
return arr
|
|
79
87
|
|
|
80
88
|
|
|
81
|
-
func _classify(dep: String, loose: Dictionary, seen: Dictionary) -> void:
|
|
89
|
+
func _classify(root: String, dep: String, loose: Dictionary, seen: Dictionary) -> void:
|
|
82
90
|
if dep.is_empty():
|
|
83
91
|
return
|
|
84
92
|
if dep.get_extension().to_lower() in PACKED:
|
|
85
|
-
_collect(dep, loose, seen)
|
|
93
|
+
_collect(root, dep, loose, seen)
|
|
86
94
|
elif FileAccess.file_exists(dep + ".import"):
|
|
87
95
|
loose[dep] = true
|
|
88
96
|
|
|
89
97
|
|
|
90
|
-
func _collect(path: String, loose: Dictionary, seen: Dictionary) -> void:
|
|
98
|
+
func _collect(root: String, path: String, loose: Dictionary, seen: Dictionary) -> void:
|
|
91
99
|
if seen.has(path):
|
|
92
100
|
return
|
|
93
101
|
seen[path] = true
|
|
@@ -98,14 +106,17 @@ func _collect(path: String, loose: Dictionary, seen: Dictionary) -> void:
|
|
|
98
106
|
var cfg := ConfigFile.new()
|
|
99
107
|
if cfg.load(imp) == OK:
|
|
100
108
|
for dst in cfg.get_value("deps", "dest_files", []):
|
|
101
|
-
_collect(String(dst), loose, seen)
|
|
109
|
+
_collect(root, String(dst), loose, seen)
|
|
102
110
|
for dep in ResourceLoader.get_dependencies(path):
|
|
103
|
-
_classify(_res_path(String(dep)), loose, seen)
|
|
111
|
+
_classify(root, _res_path(String(dep)), loose, seen)
|
|
104
112
|
# String-valued resource paths are invisible to dependency analysis; scan the
|
|
105
113
|
# text for loose refs the engine cannot report.
|
|
106
114
|
if path.get_extension().to_lower() in SCANNED:
|
|
107
115
|
for p in _text_refs(path):
|
|
108
|
-
_classify(p, loose, seen)
|
|
116
|
+
_classify(root, p, loose, seen)
|
|
117
|
+
# A level built from its plan names nothing above: its refs come from the plan itself.
|
|
118
|
+
if path.get_extension().to_lower() == "gltfx":
|
|
119
|
+
_plan_refs(root, path, loose, seen)
|
|
109
120
|
|
|
110
121
|
|
|
111
122
|
## `res://` path from a dependency entry (`uid://…::::res://…` or plain).
|
|
@@ -124,3 +135,65 @@ func _text_refs(path: String) -> Array:
|
|
|
124
135
|
for m in re.search_all(FileAccess.get_file_as_string(path)):
|
|
125
136
|
refs.append(m.get_string())
|
|
126
137
|
return refs
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
## A level's refs, read from the plan its `.gltfx` carries (`extras.gltfx.plan`). They are
|
|
141
|
+
## invisible to every source above (a *directory* rather than a route, relative routes, and a host
|
|
142
|
+
## script that reads the JSON instead of a resource edge), so the plan is their only statement. Each
|
|
143
|
+
## ref goes through `_classify`: what has an `.import` sidecar is staged, the header JSONs (which the
|
|
144
|
+
## packs carry) are not.
|
|
145
|
+
func _plan_refs(root: String, path: String, loose: Dictionary, seen: Dictionary) -> void:
|
|
146
|
+
var doc = JSON.parse_string(FileAccess.get_file_as_string(path))
|
|
147
|
+
var extras: Variant = doc.get("extras") if doc is Dictionary else null
|
|
148
|
+
var stamp: Variant = extras.get("gltfx") if extras is Dictionary else null
|
|
149
|
+
var plan: Variant = stamp.get("plan") if stamp is Dictionary else null
|
|
150
|
+
if not plan is Dictionary:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# The terrain: the plan names its directory, and the directory is the atlas list.
|
|
154
|
+
var terrain: Variant = plan.get("terrain")
|
|
155
|
+
if terrain is Dictionary and terrain.get("dir") is String:
|
|
156
|
+
_terrain_refs(root, root.path_join(String(terrain["dir"])), loose, seen)
|
|
157
|
+
|
|
158
|
+
# The rest are texture routes — the ground's patches / the water's maps, and the sky.
|
|
159
|
+
var ground: Variant = plan.get("ground")
|
|
160
|
+
if ground is Array:
|
|
161
|
+
for g in ground:
|
|
162
|
+
if not g is Dictionary:
|
|
163
|
+
continue
|
|
164
|
+
var fields: Variant = g.get("material") if g.get("source") == "overlay" else g.get("water")
|
|
165
|
+
if fields is Dictionary:
|
|
166
|
+
for key in ["diffuse", "normal", "orm"]:
|
|
167
|
+
_classify_route(root, fields.get(key), loose, seen)
|
|
168
|
+
var lighting: Variant = plan.get("lighting")
|
|
169
|
+
if lighting is Dictionary:
|
|
170
|
+
# A host renders one time of day: the authored stage's env, which the plan mirrors as `env`.
|
|
171
|
+
var stages: Variant = lighting.get("stages")
|
|
172
|
+
var env: Variant = stages[0].get("env") if stages is Array and stages.size() > 0 and stages[0] is Dictionary else null
|
|
173
|
+
if not env is Dictionary:
|
|
174
|
+
env = lighting.get("env")
|
|
175
|
+
_classify_route(root, env.get("map") if env is Dictionary else null, loose, seen)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
## A terrain directory **is** its atlas list (every atlas name is top-level in it), so each file is a
|
|
179
|
+
## ref — except a layered container's *layers*: the manifest names them and their bytes live in its
|
|
180
|
+
## imported product, so staging them would ship megabytes nothing reads.
|
|
181
|
+
func _terrain_refs(root: String, dir: String, loose: Dictionary, seen: Dictionary) -> void:
|
|
182
|
+
var files := DirAccess.get_files_at(dir)
|
|
183
|
+
var layers := {}
|
|
184
|
+
for name in files:
|
|
185
|
+
if name.to_lower().ends_with(".webparray"):
|
|
186
|
+
for line in FileAccess.get_file_as_string(dir.path_join(name)).split("\n"):
|
|
187
|
+
var ref := line.strip_edges()
|
|
188
|
+
if not ref.is_empty() and not ref.begins_with("#"):
|
|
189
|
+
layers[ref.get_file()] = true
|
|
190
|
+
for name in files:
|
|
191
|
+
if not layers.has(name.get_file()):
|
|
192
|
+
_classify(root, dir.path_join(name), loose, seen)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
## Classifies a plan's route: relative and unsuffixed, so the served `.webp` spelling is tried too.
|
|
196
|
+
func _classify_route(root: String, route: Variant, loose: Dictionary, seen: Dictionary) -> void:
|
|
197
|
+
if route is String:
|
|
198
|
+
for suffix in ["", ".webp"]:
|
|
199
|
+
_classify(root, root.path_join(route + suffix), loose, seen)
|
package/src/pack.ts
CHANGED
|
@@ -7,25 +7,23 @@ 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';
|
|
11
10
|
|
|
12
|
-
// Bakes a Godot project's assets for the runtime: imports them, then exports one native
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// source's packed closure — which decides what a pack owns — and the loose (WebP-backed)
|
|
11
|
+
// Bakes a Godot project's assets for the runtime: imports them, then exports one native `.pck` **per
|
|
12
|
+
// level** plus a shared one, so a host fetches the shared data and only the level it is playing.
|
|
13
|
+
// `dump-deps.gd` supplies both inputs (no host-side scanning), in the dump's own two words: each
|
|
14
|
+
// source's `packed` closure — which decides what a pack *owns* — and the `loose` (WebP-backed)
|
|
16
15
|
// texture sources, which the host stages and transcodes per file.
|
|
17
16
|
//
|
|
18
|
-
// The
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// The one file the runtime reads is `<out>/packs.json`, beside the packs: every pack's `pck` and
|
|
18
|
+
// `owns`, plus the `loose` lists. `preload.ts` mounts the reserved `shared` pack and the asset's
|
|
19
|
+
// owner.
|
|
21
20
|
//
|
|
22
|
-
// The project owns `export_presets.cfg` (a committed, editor-managed file), and the scoping
|
|
23
|
-
//
|
|
24
|
-
// `exclude_filter` — the repo's `all_resources` convention, which ignores `include_filter`
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// ends: they are derived and the file is tracked.
|
|
21
|
+
// The project owns `export_presets.cfg` (a committed, editor-managed file), and the scoping has to
|
|
22
|
+
// live there: `--export-pack` runs against a preset. Per-pack scoping is therefore an
|
|
23
|
+
// `exclude_filter` — the repo's `all_resources` convention, which ignores `include_filter` and,
|
|
24
|
+
// unlike the selected modes, never pulls dependencies back in — so each pack excludes exactly the
|
|
25
|
+
// sources the others own (see `splitPacks`). Those presets are cloned from the project's own base
|
|
26
|
+
// preset and the file is restored once the bake ends: they are derived, and the file is tracked.
|
|
29
27
|
|
|
30
28
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
29
|
|
|
@@ -43,74 +41,63 @@ export interface BuildAssetPackOptions {
|
|
|
43
41
|
godot?: string;
|
|
44
42
|
}
|
|
45
43
|
|
|
46
|
-
/** A pack
|
|
47
|
-
interface
|
|
48
|
-
/** `shared`, or a
|
|
44
|
+
/** A pack: `shared`, or a level's file stem, and the sources it owns. */
|
|
45
|
+
export interface Pack {
|
|
46
|
+
/** `shared`, or a level's file stem. */
|
|
49
47
|
name: string;
|
|
50
|
-
/** Mount for every asset (the shared pack). */
|
|
51
|
-
always?: boolean;
|
|
52
48
|
/** The packable sources this pack owns — the lookup the plugin mounts by. */
|
|
53
49
|
owns: string[];
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
/**
|
|
57
|
-
interface
|
|
52
|
+
/** A pack as the manifest carries it: a {@link Pack} plus its `.pck`. */
|
|
53
|
+
interface PackFile extends Pack {
|
|
58
54
|
/** `res://` path of the `.pck`, for `loadAssetPack`. */
|
|
59
55
|
pck: string;
|
|
60
56
|
}
|
|
61
57
|
|
|
62
58
|
/** The runtime manifest beside the packs: what to mount, and what to stage per file. */
|
|
63
|
-
export interface
|
|
64
|
-
packs:
|
|
59
|
+
export interface PackManifest {
|
|
60
|
+
packs: PackFile[];
|
|
65
61
|
/** Packed source → the loose texture sources it needs at runtime (from `dump-deps.gd`). */
|
|
66
|
-
|
|
62
|
+
loose: Record<string, string[]>;
|
|
67
63
|
}
|
|
68
64
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
[source: string]: string[];
|
|
72
|
-
}
|
|
65
|
+
/** One bucket of the dump: a source → its closure (`dump-deps.gd`'s `packed` or `loose` half). */
|
|
66
|
+
type Closures = Record<string, string[]>;
|
|
73
67
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
graph: Graph;
|
|
77
|
-
deps: Record<string, string[]>;
|
|
78
|
-
}
|
|
68
|
+
/** A `spawnSync` runner for the editor. */
|
|
69
|
+
type Run = (args: string[], env?: Record<string, string>) => void;
|
|
79
70
|
|
|
80
71
|
/**
|
|
81
|
-
* The ownership partition, from the
|
|
82
|
-
* the scanned sources — a project's levels are its scenes, so nothing declares them
|
|
83
|
-
* **a source exactly one
|
|
84
|
-
* rides its pack, though it lives in a shared asset tree), while **a source several
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* is mounted by *owner*.
|
|
72
|
+
* The ownership partition, from the packed closure of every source. A **level is a scene**
|
|
73
|
+
* (`.tscn`) among the scanned sources — a project's levels are its scenes, so nothing declares them
|
|
74
|
+
* — and: **a source exactly one level reaches is that level's** (so a model only one level uses
|
|
75
|
+
* rides its pack, though it lives in a shared asset tree), while **a source several levels reach, or
|
|
76
|
+
* none does, is shared**. A level whose own pack would be empty is dropped: every source it reaches
|
|
77
|
+
* is reached by another level too, so it is already in `shared`, and a pack is mounted by *owner*.
|
|
88
78
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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.
|
|
79
|
+
* When a project stops baking one scene per level — a level built from its plan at run time is a
|
|
80
|
+
* single shared scene plus the plan — the level unit becomes the `.gltfx` its plan lives in instead;
|
|
81
|
+
* until then both units exist and this keying keeps the scenes.
|
|
95
82
|
*/
|
|
96
|
-
export function splitPacks(
|
|
83
|
+
export function splitPacks(packed: Closures): Pack[] {
|
|
97
84
|
const stem = (asset: string) =>
|
|
98
85
|
asset.slice(asset.lastIndexOf('/') + 1).replace(/\.(prefab|level)\.[^.]+$/i, '').replace(/\.[^.]+$/, '');
|
|
99
86
|
// A level is its scene — and, in a project that builds levels from their plans at run time, the
|
|
100
87
|
// `.gltfx` its plan lives in. A `.gltfx` a scene **reaches** is that scene's composition, not a
|
|
101
88
|
// second level: keying both would make every source of a baked level look shared.
|
|
102
|
-
const scenes = Object.keys(
|
|
103
|
-
const reachedByScene = new Set(scenes.flatMap((s) =>
|
|
89
|
+
const scenes = Object.keys(packed).filter((s) => s.toLowerCase().endsWith('.tscn')).sort();
|
|
90
|
+
const reachedByScene = new Set(scenes.flatMap((s) => packed[s] ?? []));
|
|
104
91
|
const levels = [
|
|
105
92
|
...scenes,
|
|
106
|
-
...Object.keys(
|
|
93
|
+
...Object.keys(packed).filter((s) => s.toLowerCase().endsWith('.gltfx') && !reachedByScene.has(s)),
|
|
107
94
|
].sort();
|
|
108
|
-
const closure = new Map(levels.map((s) => [s, new Set([s, ...(
|
|
95
|
+
const closure = new Map(levels.map((s) => [s, new Set([s, ...(packed[s] ?? [])])]));
|
|
109
96
|
|
|
110
97
|
// One pack per level, named after its file stem (`shared` is reserved; a repeated stem —
|
|
111
98
|
// two `level.tscn` in different directories — gets a suffix).
|
|
112
99
|
const used = new Set(['shared']);
|
|
113
|
-
const packOf = new Map<string,
|
|
100
|
+
const packOf = new Map<string, Pack>();
|
|
114
101
|
for (const level of levels) {
|
|
115
102
|
let name = stem(level);
|
|
116
103
|
for (let n = 2; used.has(name); n++) name = `${stem(level)}-${n}`;
|
|
@@ -119,15 +106,14 @@ export function splitPacks(graph: Graph): AssetPackSplit[] {
|
|
|
119
106
|
}
|
|
120
107
|
|
|
121
108
|
const shared: string[] = [];
|
|
122
|
-
const sources = new Set([...Object.keys(
|
|
109
|
+
const sources = new Set([...Object.keys(packed), ...Object.values(packed).flat()]);
|
|
123
110
|
for (const source of sources) {
|
|
124
111
|
const owners = levels.filter((level) => closure.get(level)!.has(source));
|
|
125
112
|
if (owners.length === 1) packOf.get(owners[0])!.owns.push(source);
|
|
126
113
|
else shared.push(source);
|
|
127
114
|
}
|
|
128
|
-
shared.sort();
|
|
129
115
|
return [
|
|
130
|
-
{ name: 'shared',
|
|
116
|
+
{ name: 'shared', owns: shared.sort() },
|
|
131
117
|
...[...packOf.values()]
|
|
132
118
|
.filter((pack) => pack.owns.length > 0)
|
|
133
119
|
.map((pack) => ({ ...pack, owns: pack.owns.sort() })),
|
|
@@ -135,8 +121,9 @@ export function splitPacks(graph: Graph): AssetPackSplit[] {
|
|
|
135
121
|
}
|
|
136
122
|
|
|
137
123
|
/**
|
|
138
|
-
* Bakes the packs (see {@link splitPacks}) and writes the runtime manifest
|
|
139
|
-
* scene under its roots gets a single
|
|
124
|
+
* Bakes the packs (see {@link splitPacks}) and writes the runtime manifest: import, dump, split,
|
|
125
|
+
* then one preset and one `.pck` per pack. A project with no scene under its roots gets a single
|
|
126
|
+
* `shared.pck` and the same manifest.
|
|
140
127
|
*/
|
|
141
128
|
export async function buildAssetPacks(opts: BuildAssetPackOptions = {}): Promise<void> {
|
|
142
129
|
const editor = opts.godot ?? (await resolveEditor());
|
|
@@ -146,50 +133,49 @@ export async function buildAssetPacks(opts: BuildAssetPackOptions = {}): Promise
|
|
|
146
133
|
const base = opts.preset ?? 'Pack';
|
|
147
134
|
const prefix = `${base}: `;
|
|
148
135
|
const presets = resolve(cwd, 'export_presets.cfg');
|
|
149
|
-
|
|
150
136
|
const cfg = readFileSync(presets, 'utf8');
|
|
151
137
|
const basePreset = presetBlock(cfg, base);
|
|
152
|
-
const baseExcludes = excludeFilter(basePreset);
|
|
153
138
|
mkdirSync(resolve(cwd, out), { recursive: true });
|
|
154
139
|
|
|
155
140
|
const run = runner(editor, cwd);
|
|
156
141
|
run(['--headless', '--path', '.', '--import']);
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
mergePlanRefs(graph, deps, planRefs(cwd, roots));
|
|
162
|
-
|
|
163
|
-
const packs = splitPacks(graph);
|
|
164
|
-
const owned = packs.flatMap((pack) => pack.owns);
|
|
165
|
-
const manifest: AssetPackManifestEntry[] = [];
|
|
142
|
+
// `dump-deps.gd` reads a level's plan for the refs no resource edge exposes, so its `loose` lists
|
|
143
|
+
// carry the level's whole closure with or without a baked scene.
|
|
144
|
+
const { packed, loose } = dump(run, cwd, roots);
|
|
145
|
+
const packs = splitPacks(packed);
|
|
166
146
|
|
|
167
|
-
//
|
|
147
|
+
// One preset per pack, all written from the project's own base preset before any export: each
|
|
148
|
+
// excludes exactly the sources the other packs own (see the module comment).
|
|
168
149
|
const original = pruneGenerated(cfg, prefix);
|
|
169
|
-
const
|
|
150
|
+
const first = (original.match(/^\[preset\.\d+\]/gm) ?? []).length;
|
|
151
|
+
const baseExcludes = excludeFilter(basePreset);
|
|
152
|
+
const generated = packs.map((pack, i) =>
|
|
153
|
+
presetFor(basePreset, first + i, `${prefix}${pack.name}`, excludesFor(baseExcludes, packs, pack)),
|
|
154
|
+
);
|
|
170
155
|
try {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
...owned.filter((source) => !owns.includes(source)).map((source) => source.replace(/^res:\/\//, '')),
|
|
175
|
-
];
|
|
176
|
-
writeFileSync(presets, `${original.trimEnd()}\n\n${presetFor(basePreset, index, `${prefix}${name}`, exclude)}\n`);
|
|
156
|
+
writeFileSync(presets, `${original.trimEnd()}\n\n${generated.join('\n\n')}\n`);
|
|
157
|
+
const manifest: PackFile[] = [];
|
|
158
|
+
for (const { name, owns } of packs) {
|
|
177
159
|
const pck = `${out}/${name}.pck`;
|
|
178
160
|
run(['--headless', '--path', '.', '--export-pack', `${prefix}${name}`, pck]);
|
|
179
|
-
manifest.push({ name, pck: resPath(pck),
|
|
161
|
+
manifest.push({ name, pck: resPath(pck), owns });
|
|
180
162
|
}
|
|
163
|
+
writeFileSync(
|
|
164
|
+
resolve(cwd, out, 'packs.json'),
|
|
165
|
+
`${JSON.stringify({ packs: manifest, loose } satisfies PackManifest, null, '\t')}\n`,
|
|
166
|
+
);
|
|
167
|
+
const sources = manifest.reduce((n, pack) => n + pack.owns.length, 0);
|
|
168
|
+
console.log(`[pack] ${manifest.length} packs (${manifest.map((p) => `${p.name}:${p.owns.length}`).join(', ')}) over ${sources} sources`);
|
|
181
169
|
} finally {
|
|
182
170
|
// Derived — leave the project's own file as it was, so a bake does not churn a tracked,
|
|
183
171
|
// editor-managed file.
|
|
184
172
|
writeFileSync(presets, original);
|
|
185
173
|
}
|
|
186
|
-
writeFileSync(resolve(cwd, out, 'packs.json'), `${JSON.stringify({ packs: manifest, deps } satisfies AssetPackManifest, null, '\t')}\n`);
|
|
187
|
-
console.log(`[pack] ${manifest.length} packs (${manifest.map((p) => `${p.name}:${p.owns.length}`).join(', ')}) over ${owned.length} sources`);
|
|
188
174
|
}
|
|
189
175
|
|
|
190
176
|
/** A `spawnSync` runner that throws on a non-zero editor exit. */
|
|
191
|
-
function runner(editor: string, cwd: string) {
|
|
192
|
-
return (args
|
|
177
|
+
function runner(editor: string, cwd: string): Run {
|
|
178
|
+
return (args, env) => {
|
|
193
179
|
const r = spawnSync(editor, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env } });
|
|
194
180
|
if ((r.status ?? 1) !== 0) {
|
|
195
181
|
throw new Error(`godot-assets: \`${editor} ${args.join(' ')}\` failed (${r.status})`);
|
|
@@ -197,8 +183,8 @@ function runner(editor: string, cwd: string) {
|
|
|
197
183
|
};
|
|
198
184
|
}
|
|
199
185
|
|
|
200
|
-
/** Runs `dump-deps.gd
|
|
201
|
-
function dump(run:
|
|
186
|
+
/** Runs `dump-deps.gd`: every source's packed closure, and the loose textures it needs. */
|
|
187
|
+
function dump(run: Run, cwd: string, roots: string[]): { packed: Closures; loose: Closures } {
|
|
202
188
|
copyFileSync(resolve(HERE, 'dump-deps.gd'), resolve(cwd, '.godot', 'pack-dump-deps.gd'));
|
|
203
189
|
run(['--headless', '--path', '.', '--script', 'res://.godot/pack-dump-deps.gd'], {
|
|
204
190
|
GODOT_DUMP_OUT: resPath(DUMP),
|
|
@@ -209,13 +195,25 @@ function dump(run: (args: string[], env?: Record<string, string>) => void, cwd:
|
|
|
209
195
|
const sources = JSON.parse(readFileSync(abs, 'utf8')) as Record<string, { packed: string[]; loose: string[] }>;
|
|
210
196
|
const entries = Object.entries(sources);
|
|
211
197
|
return {
|
|
212
|
-
|
|
213
|
-
|
|
198
|
+
packed: Object.fromEntries(entries.map(([source, s]) => [source, s.packed])),
|
|
199
|
+
loose: Object.fromEntries(entries.map(([source, s]) => [source, s.loose])),
|
|
214
200
|
};
|
|
215
201
|
}
|
|
216
202
|
|
|
217
203
|
const resPath = (rel: string) => `res://${rel.replace(/^res:\/\//, '')}`;
|
|
218
204
|
|
|
205
|
+
/** A pack's `exclude_filter`: the base preset's own excludes plus every source the others own. */
|
|
206
|
+
function excludesFor(base: string[], packs: Pack[], mine: Pack): string[] {
|
|
207
|
+
const own = new Set(mine.owns);
|
|
208
|
+
return [
|
|
209
|
+
...base,
|
|
210
|
+
...packs
|
|
211
|
+
.flatMap((pack) => pack.owns)
|
|
212
|
+
.filter((source) => !own.has(source))
|
|
213
|
+
.map((source) => source.replace(/^res:\/\//, '')),
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
|
|
219
217
|
/** The preset whose `name=` matches, as its raw `[preset.N]` … block (options included). */
|
|
220
218
|
function presetBlock(cfg: string, name: string): string {
|
|
221
219
|
const m = new RegExp(`^name=${JSON.stringify(name)}$`, 'm').exec(cfg);
|
package/src/preload.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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 {
|
|
8
|
+
import type { PackManifest } from './pack.ts';
|
|
9
9
|
import type { BunPlugin } from 'bun';
|
|
10
10
|
|
|
11
11
|
// Registers the godot-assets plugin: imports of Godot source assets become
|
|
@@ -75,25 +75,28 @@ const isLoose = (abs: string) => LOOSE.has(extOf(abs));
|
|
|
75
75
|
|
|
76
76
|
// A project bakes native `.pck`s (`godot --export-pack`) — the engine-converted binary
|
|
77
77
|
// resources + remap tables — through `@ringozz/godot/pack`'s `buildAssetPacks`: one per
|
|
78
|
-
//
|
|
78
|
+
// level, plus a shared one, so a host fetches the shared data and only the level it is
|
|
79
79
|
// playing. The manifest beside them (`<pack dir>/packs.json`) is the whole runtime contract:
|
|
80
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
|
|
82
|
-
//
|
|
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
83
|
//
|
|
84
84
|
// Everything is optional: with no manifest (desktop, or a project that has not baked) modules
|
|
85
85
|
// load source paths directly.
|
|
86
86
|
const readJson = <T>(file: string): T | null =>
|
|
87
87
|
existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as T) : null;
|
|
88
88
|
|
|
89
|
+
/** The pack every asset mounts, as `splitPacks` names it (its `name` is reserved for this). */
|
|
90
|
+
const SHARED_PACK = 'shared';
|
|
91
|
+
|
|
89
92
|
/** Where the packs were baked — `godot pack`'s `--out` (`GODOT_PACKS` overrides for a host). */
|
|
90
93
|
const PACK_DIR = resolve(ROOT, process.env.GODOT_PACKS ?? '.godot/packs');
|
|
91
|
-
const MANIFEST = readJson<
|
|
94
|
+
const MANIFEST = readJson<PackManifest>(resolve(PACK_DIR, 'packs.json'));
|
|
92
95
|
|
|
93
|
-
/** The packs an asset needs mounted: the
|
|
96
|
+
/** The packs an asset needs mounted: the shared one plus its owner. */
|
|
94
97
|
function packsFor(resPath: string): string[] {
|
|
95
98
|
return (MANIFEST?.packs ?? [])
|
|
96
|
-
.filter((pack) => pack.
|
|
99
|
+
.filter((pack) => pack.name === SHARED_PACK || pack.owns.includes(resPath))
|
|
97
100
|
.map((pack) => resolve(ROOT, pack.pck.replace(/^res:\/\//, '')));
|
|
98
101
|
}
|
|
99
102
|
|
|
@@ -130,7 +133,7 @@ function analyze(abs: string): Map<string, 'text' | 'file'> {
|
|
|
130
133
|
if (isLoose(abs)) {
|
|
131
134
|
addLoose(abs, files);
|
|
132
135
|
}
|
|
133
|
-
for (const loose of MANIFEST?.
|
|
136
|
+
for (const loose of MANIFEST?.loose[resOf(abs)] ?? []) {
|
|
134
137
|
addLoose(resolve(ROOT, loose.slice('res://'.length)), files);
|
|
135
138
|
}
|
|
136
139
|
return files;
|
package/src/planRefs.ts
DELETED
|
@@ -1,209 +0,0 @@
|
|
|
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
|
-
/** A path's last segment. */
|
|
53
|
-
const base = (p: string) => p.slice(p.lastIndexOf('/') + 1);
|
|
54
|
-
|
|
55
|
-
/** Every file under `dir`, as paths relative to `dir` (posix, one level of nesting per entry). */
|
|
56
|
-
function walk(dir: string, prefix = ''): string[] {
|
|
57
|
-
const out: string[] = [];
|
|
58
|
-
let entries: string[];
|
|
59
|
-
try {
|
|
60
|
-
entries = readdirSync(dir);
|
|
61
|
-
} catch {
|
|
62
|
-
return out;
|
|
63
|
-
}
|
|
64
|
-
for (const name of entries) {
|
|
65
|
-
if (name.startsWith('.')) continue;
|
|
66
|
-
const full = join(dir, name);
|
|
67
|
-
const rel = prefix ? `${prefix}/${name}` : name;
|
|
68
|
-
try {
|
|
69
|
-
if (readdirSync(full).length >= 0) {
|
|
70
|
-
out.push(...walk(full, rel));
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
} catch {
|
|
74
|
-
// not a directory
|
|
75
|
-
}
|
|
76
|
-
out.push(rel);
|
|
77
|
-
}
|
|
78
|
-
return out;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* The environment a **host renders**: the level's authored stage — `stages[0].env`, which the plan
|
|
83
|
-
* also carries as `lighting.env`. A host shows one time of day, so the other stages (a viewer's
|
|
84
|
-
* blend material) and the reflection probe (which a host does not sample) are not runtime refs.
|
|
85
|
-
*/
|
|
86
|
-
function authoredEnvMap(lighting: Record<string, unknown>): string | null {
|
|
87
|
-
const stages = Array.isArray(lighting.stages) ? lighting.stages : [];
|
|
88
|
-
const stage0 = stages.length && isObject(stages[0]) ? (stages[0] as Record<string, unknown>) : null;
|
|
89
|
-
const env = (stage0 && isObject(stage0.env) ? stage0.env : null) ?? (isObject(lighting.env) ? lighting.env : null);
|
|
90
|
-
return env && typeof env.map === 'string' ? env.map : null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* The layer file names the `.webparray` manifests in `dir` name, one per line. Those layers are read
|
|
95
|
-
* by their *importer* and live inside the imported product afterwards, so nothing loads them at run
|
|
96
|
-
* time: they are import-time inputs, and the directory walk must not stage them.
|
|
97
|
-
*/
|
|
98
|
-
function layerSources(dir: string): Set<string> {
|
|
99
|
-
const names = new Set<string>();
|
|
100
|
-
for (const manifest of walk(dir)) {
|
|
101
|
-
if (!manifest.toLowerCase().endsWith(".webparray")) continue;
|
|
102
|
-
let text: string;
|
|
103
|
-
try {
|
|
104
|
-
text = readFileSync(join(dir, ...manifest.split("/")), "utf8");
|
|
105
|
-
} catch {
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
for (const line of text.split("\n")) {
|
|
109
|
-
const ref = line.trim();
|
|
110
|
-
if (ref && !ref.startsWith("#")) names.add(base(ref));
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return names;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** The resource route of a ref the plan names, probing the served `.webp` suffix a texture carries. */
|
|
117
|
-
function routeOf(root: { disk: string; res: string }, route: string): string | null {
|
|
118
|
-
for (const candidate of [route, `${route}.webp`]) {
|
|
119
|
-
if (existsSync(join(root.disk, ...candidate.split('/')))) return `${root.res}/${candidate}`;
|
|
120
|
-
}
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Every `.gltfx` under `roots` with its plan-derived refs. A level's terrain is discovered by
|
|
126
|
-
* *listing* its directory (the tree holds exactly the level's atlas there, so no name list to keep in
|
|
127
|
-
* step), its manifests' layers are read from the manifests, and its ground/environment textures are
|
|
128
|
-
* probed per route (a plan route may or may not carry the served `.webp` suffix).
|
|
129
|
-
*/
|
|
130
|
-
export function planRefs(projectDir: string, roots: string[]): Map<string, PlanRefs> {
|
|
131
|
-
const out = new Map<string, PlanRefs>();
|
|
132
|
-
for (const root of rootDirs(projectDir, roots)) {
|
|
133
|
-
for (const file of walk(root.disk)) {
|
|
134
|
-
if (!file.toLowerCase().endsWith('.gltfx')) continue;
|
|
135
|
-
let doc: unknown;
|
|
136
|
-
try {
|
|
137
|
-
doc = JSON.parse(readFileSync(join(root.disk, ...file.split('/')), 'utf8'));
|
|
138
|
-
} catch {
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
const plan = planOf(doc);
|
|
142
|
-
if (!plan) continue;
|
|
143
|
-
const levelRoute = `${root.res}/${file}`;
|
|
144
|
-
const loose = new Set<string>();
|
|
145
|
-
|
|
146
|
-
// The terrain: the plan names its directory, and the tree holds exactly the level's own atlas
|
|
147
|
-
// there (the height and tile-normal maps, the composed weight planes, the passmap and its
|
|
148
|
-
// meta, the header JSON and the layered containers) — so the directory *is* the list. A
|
|
149
|
-
// container's *layers* are deliberately not listed: they live inside its imported product,
|
|
150
|
-
// and are import-time inputs, so nothing stages them at run time.
|
|
151
|
-
const terrain = isObject(plan.terrain) ? plan.terrain : null;
|
|
152
|
-
if (terrain && typeof terrain.dir === 'string') {
|
|
153
|
-
const dir = terrain.dir;
|
|
154
|
-
const abs = join(root.disk, ...dir.split('/'));
|
|
155
|
-
const layers = layerSources(abs);
|
|
156
|
-
for (const name of walk(abs)) {
|
|
157
|
-
if (name.endsWith(".import") || layers.has(base(name))) continue;
|
|
158
|
-
loose.add(`${root.res}/${dir}/${name}`);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// The ground's patches and the rig's skies: plain resource routes with the served suffix.
|
|
163
|
-
const ground = Array.isArray(plan.ground) ? plan.ground : [];
|
|
164
|
-
for (const g of ground) {
|
|
165
|
-
if (!isObject(g)) continue;
|
|
166
|
-
const fields = g.source === 'overlay' ? [isObject(g.material) ? g.material : {}] : [isObject(g.water) ? g.water : {}];
|
|
167
|
-
for (const f of fields) {
|
|
168
|
-
for (const key of ['diffuse', 'normal', 'orm']) {
|
|
169
|
-
if (typeof f[key] !== 'string') continue;
|
|
170
|
-
const route = routeOf(root, f[key] as string);
|
|
171
|
-
if (route) loose.add(route);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
const lighting = isObject(plan.lighting) ? plan.lighting : {};
|
|
176
|
-
const map = authoredEnvMap(lighting);
|
|
177
|
-
const route = map ? routeOf(root, map) : null;
|
|
178
|
-
if (route) loose.add(route);
|
|
179
|
-
|
|
180
|
-
out.set(levelRoute, { loose: [...loose].sort() });
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return out;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Merge `planRefs` into a dump's `graph`/`loose` halves: a `.gltfx` source gains its plan's refs, and
|
|
188
|
-
* so does every source that reaches it (a scene that instances it, so mounting the scene stages the
|
|
189
|
-
* level's terrain and ground too — a union, and a no-op for a project with no plan).
|
|
190
|
-
*/
|
|
191
|
-
export function mergePlanRefs(
|
|
192
|
-
graph: Record<string, string[]>,
|
|
193
|
-
deps: Record<string, string[]>,
|
|
194
|
-
refs: Map<string, PlanRefs>,
|
|
195
|
-
): void {
|
|
196
|
-
for (const [level, r] of refs) {
|
|
197
|
-
if (!(level in graph)) continue;
|
|
198
|
-
deps[level] = [...new Set([...(deps[level] ?? []), ...r.loose])].sort();
|
|
199
|
-
}
|
|
200
|
-
// A scene that instances a `.gltfx` carries that plan's refs too: mounting the scene must stage the
|
|
201
|
-
// level's terrain and ground, not only its model composition. (The scene may already name some of
|
|
202
|
-
// them — a baked one does — so this is a union, and a no-op for a project with no plan.)
|
|
203
|
-
for (const source of Object.keys(graph)) {
|
|
204
|
-
const plans = graph[source].filter((dep) => refs.has(dep));
|
|
205
|
-
if (!plans.length || refs.has(source)) continue;
|
|
206
|
-
const extra = plans.flatMap((p) => refs.get(p)!.loose);
|
|
207
|
-
deps[source] = [...new Set([...(deps[source] ?? []), ...extra])].sort();
|
|
208
|
-
}
|
|
209
|
-
}
|