@ringozz/godot 4.7.2-652 → 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 +15 -2
- package/package.json +4 -4
- package/src/dump-deps.gd +126 -113
- package/src/editor.ts +2 -2
- package/src/export_presets.example.cfg +5 -1
- package/src/load.ts +10 -1
- package/src/pack.ts +262 -87
- package/src/planRefs.ts +169 -0
- package/src/preload.ts +33 -18
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` | `
|
|
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`).
|
|
@@ -90,9 +90,22 @@ const node = scene.instantiate();
|
|
|
90
90
|
const tex = await loadResourceAsync('res://textures/icon.jpg', Texture2D); // Texture2D
|
|
91
91
|
```
|
|
92
92
|
|
|
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.
|
|
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
94
|
- `loadAsset(path, cls, files, deps, data)` — 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
|
+
### Asset packs
|
|
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.
|
|
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; 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
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { loadAssetPack } from '@ringozz/godot/load';
|
|
104
|
+
await loadAssetPack('/packs/shared.pck'); // usually the preload plugin does this per asset
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Asset imports
|
|
108
|
+
|
|
96
109
|
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:
|
|
97
110
|
|
|
98
111
|
```ts
|
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-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-
|
|
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-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/dump-deps.gd
CHANGED
|
@@ -1,113 +1,126 @@
|
|
|
1
|
-
@tool
|
|
2
|
-
extends SceneTree
|
|
3
|
-
## Dumps, for every
|
|
4
|
-
## texture sources
|
|
5
|
-
## by the host
|
|
6
|
-
##
|
|
7
|
-
##
|
|
8
|
-
##
|
|
9
|
-
##
|
|
10
|
-
##
|
|
11
|
-
##
|
|
12
|
-
##
|
|
13
|
-
##
|
|
14
|
-
##
|
|
15
|
-
##
|
|
16
|
-
##
|
|
17
|
-
##
|
|
18
|
-
|
|
19
|
-
##
|
|
20
|
-
##
|
|
21
|
-
##
|
|
22
|
-
##
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
1
|
+
@tool
|
|
2
|
+
extends SceneTree
|
|
3
|
+
## Dumps, for every packable (`.import`-less) source under the given roots, two things:
|
|
4
|
+
## - `loose`: the **loose** (WebP-backed) texture sources it needs — those must stay out of
|
|
5
|
+
## the asset packs and be staged/transcoded by the host, and the preload plugin
|
|
6
|
+
## (`@ringozz/godot/preload`) reads them from the manifest beside the packs so it never
|
|
7
|
+
## scans source text for references.
|
|
8
|
+
## - `packed`: the packed sources reachable from it — the graph `@ringozz/godot/pack`'s
|
|
9
|
+
## `splitPacks` uses to decide which pack owns a source (the ones only one scene reaches
|
|
10
|
+
## are that scene's).
|
|
11
|
+
##
|
|
12
|
+
## Sources of both (union): the engine's `ResourceLoader.get_dependencies` (for imported
|
|
13
|
+
## sources the real refs live on the imported product, reached via the `.import`
|
|
14
|
+
## `dest_files`), plus a text scan for string-valued paths (`albedo_array =
|
|
15
|
+
## "res://.../*.webparray"`, `load("res://...")`) that no static resource edge exposes. A dep
|
|
16
|
+
## is *loose* when it has an `.import` sidecar but is not a packed resource type — the set is
|
|
17
|
+
## derived, not listed.
|
|
18
|
+
##
|
|
19
|
+
## Driven by `@ringozz/godot/pack`'s `buildAssetPacks`, which sets:
|
|
20
|
+
## GODOT_DUMP_OUT – output res:// path (default res://.godot/pack-dump.json) — a build
|
|
21
|
+
## input: the bake merges it into the manifest beside the packs.
|
|
22
|
+
## GODOT_DUMP_ROOTS – comma-separated res:// roots to scan (default res://)
|
|
23
|
+
|
|
24
|
+
## Imported source extensions: `_classify` recurses into these (an imported source's own deps are
|
|
25
|
+
## empty — its *product* carries the refs) where any other extension with a sidecar is a loose texture.
|
|
26
|
+
## `gltfx` is a scene-composition source and `gltfxmesh` a shared-mesh one: their products reference the
|
|
27
|
+
## meshes and textures they compose.
|
|
28
|
+
const PACKED := ["tscn", "tres", "gltf", "gltfx", "gltfxmesh", "obj", "gd", "gdshader", "gdshaderinc", "ttf", "otf", "po"]
|
|
29
|
+
const SCANNED := ["tscn", "tres", "gd", "gdshader", "gdshaderinc", "gltf", "obj"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
func _initialize() -> void:
|
|
33
|
+
var out := OS.get_environment("GODOT_DUMP_OUT")
|
|
34
|
+
if out.is_empty():
|
|
35
|
+
out = "res://.godot/pack-dump.json"
|
|
36
|
+
var roots := OS.get_environment("GODOT_DUMP_ROOTS").split(",", false)
|
|
37
|
+
if roots.is_empty():
|
|
38
|
+
roots = ["res://"]
|
|
39
|
+
var sources := {}
|
|
40
|
+
for r in roots:
|
|
41
|
+
_scan(String(r).rstrip("/") + "/", sources)
|
|
42
|
+
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out.get_base_dir()))
|
|
43
|
+
var f := FileAccess.open(out, FileAccess.WRITE)
|
|
44
|
+
f.store_string(JSON.stringify(sources, " "))
|
|
45
|
+
f.close()
|
|
46
|
+
print("[dump-deps] ", sources.size(), " sources -> ", out)
|
|
47
|
+
quit()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
func _scan(path: String, sources: Dictionary) -> void:
|
|
51
|
+
var d := DirAccess.open(path)
|
|
52
|
+
if d == null:
|
|
53
|
+
return
|
|
54
|
+
for dir in d.get_directories():
|
|
55
|
+
if dir.begins_with(".") or dir == "node_modules":
|
|
56
|
+
continue
|
|
57
|
+
_scan(path.path_join(dir) + "/", sources)
|
|
58
|
+
for file in d.get_files():
|
|
59
|
+
var p := path.path_join(file)
|
|
60
|
+
if p.get_extension().to_lower() in PACKED:
|
|
61
|
+
var seen := {}
|
|
62
|
+
var loose := _loose(p, seen)
|
|
63
|
+
# The graph holds authored packed sources only: the walk also visits the engine's
|
|
64
|
+
# own products (`res://.godot/imported/…`, reached through the `.import` sidecar),
|
|
65
|
+
# and a product is carried by its *source*, not owned in its own right.
|
|
66
|
+
var packed := seen.keys().filter(func(s: String) -> bool: return not s.begins_with("res://.godot/"))
|
|
67
|
+
packed.sort()
|
|
68
|
+
sources[p] = {"packed": packed, "loose": loose}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
## The loose texture sources reachable from `path` (transitive), sorted. `seen` collects every
|
|
72
|
+
## packed source the walk touches — the graph — including `path` itself.
|
|
73
|
+
func _loose(path: String, seen: Dictionary) -> Array:
|
|
74
|
+
var loose := {}
|
|
75
|
+
_collect(path, loose, seen)
|
|
76
|
+
var arr := loose.keys()
|
|
77
|
+
arr.sort()
|
|
78
|
+
return arr
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
func _classify(dep: String, loose: Dictionary, seen: Dictionary) -> void:
|
|
82
|
+
if dep.is_empty():
|
|
83
|
+
return
|
|
84
|
+
if dep.get_extension().to_lower() in PACKED:
|
|
85
|
+
_collect(dep, loose, seen)
|
|
86
|
+
elif FileAccess.file_exists(dep + ".import"):
|
|
87
|
+
loose[dep] = true
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
func _collect(path: String, loose: Dictionary, seen: Dictionary) -> void:
|
|
91
|
+
if seen.has(path):
|
|
92
|
+
return
|
|
93
|
+
seen[path] = true
|
|
94
|
+
# An imported source's own `get_dependencies` is empty (the importer hides the
|
|
95
|
+
# refs); the imported *product* carries them — recurse through the sidecar.
|
|
96
|
+
var imp := path + ".import"
|
|
97
|
+
if FileAccess.file_exists(imp):
|
|
98
|
+
var cfg := ConfigFile.new()
|
|
99
|
+
if cfg.load(imp) == OK:
|
|
100
|
+
for dst in cfg.get_value("deps", "dest_files", []):
|
|
101
|
+
_collect(String(dst), loose, seen)
|
|
102
|
+
for dep in ResourceLoader.get_dependencies(path):
|
|
103
|
+
_classify(_res_path(String(dep)), loose, seen)
|
|
104
|
+
# String-valued resource paths are invisible to dependency analysis; scan the
|
|
105
|
+
# text for loose refs the engine cannot report.
|
|
106
|
+
if path.get_extension().to_lower() in SCANNED:
|
|
107
|
+
for p in _text_refs(path):
|
|
108
|
+
_classify(p, loose, seen)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
## `res://` path from a dependency entry (`uid://…::::res://…` or plain).
|
|
112
|
+
func _res_path(dep: String) -> String:
|
|
113
|
+
var i := dep.find("res://")
|
|
114
|
+
if i < 0:
|
|
115
|
+
return ""
|
|
116
|
+
return dep.substr(i)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
## Literal `res://` paths appearing in a text source.
|
|
120
|
+
func _text_refs(path: String) -> Array:
|
|
121
|
+
var re := RegEx.new()
|
|
122
|
+
re.compile('res://[^"\']+')
|
|
123
|
+
var refs := []
|
|
124
|
+
for m in re.search_all(FileAccess.get_file_as_string(path)):
|
|
125
|
+
refs.append(m.get_string())
|
|
126
|
+
return refs
|
package/src/editor.ts
CHANGED
|
@@ -107,8 +107,8 @@ export async function resolveEditor(): Promise<string> {
|
|
|
107
107
|
if (import.meta.main) {
|
|
108
108
|
const command = process.argv[2];
|
|
109
109
|
if (command === 'pack') {
|
|
110
|
-
const {
|
|
111
|
-
await
|
|
110
|
+
const { buildAssetPacks, parsePackArgs } = await import('./pack.ts');
|
|
111
|
+
await buildAssetPacks(parsePackArgs(process.argv.slice(3)));
|
|
112
112
|
process.exit(0);
|
|
113
113
|
}
|
|
114
114
|
const exe = await resolveEditor();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
; Example Godot export preset for `@ringozz/godot/pack`'s `
|
|
1
|
+
; Example Godot export preset for `@ringozz/godot/pack`'s `buildAssetPacks()`.
|
|
2
2
|
; Copy to the project root as `export_presets.cfg` (the project owns it) and add
|
|
3
3
|
; your own non-asset trees to `exclude_filter`.
|
|
4
4
|
;
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
; engine has no WebP decoder, so those products are staged/transcoded by the host
|
|
7
7
|
; (see `dump-deps.gd`), not read from the pack. `export_filter="all_resources"`
|
|
8
8
|
; ignores `include_filter` — scope with `exclude_filter` only.
|
|
9
|
+
;
|
|
10
|
+
; The bake always splits — one pack per scene (`Pack: <scene>`) plus a shared one — and
|
|
11
|
+
; derives those presets from this one, pruning them again after exporting. Don't write
|
|
12
|
+
; them by hand.
|
|
9
13
|
|
|
10
14
|
[preset.0]
|
|
11
15
|
|
package/src/load.ts
CHANGED
|
@@ -143,7 +143,16 @@ async function materialize([resPath, entry]: [string, AssetFile]): Promise<void>
|
|
|
143
143
|
} else {
|
|
144
144
|
return;
|
|
145
145
|
}
|
|
146
|
-
|
|
146
|
+
// A staged product may already be there even though `FileAccess.fileExists(resPath)` says
|
|
147
|
+
// otherwise — the engine's `res://` lookup and the Emscripten path disagree by the leading
|
|
148
|
+
// slash — and the engine's `copyToFS` throws on a file it already has (`FS.createDataFile`
|
|
149
|
+
// does not replace). `files` lists overlap, and staged products are removed once their
|
|
150
|
+
// resource loads, so this is the normal re-stage, not an error: the bytes are the same file.
|
|
151
|
+
try {
|
|
152
|
+
stageFile?.(ProjectSettings.globalizePath(resPath), bytes);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (!FileAccess.fileExists(ProjectSettings.globalizePath(resPath).replace(/\/{2,}/g, '/'))) throw err;
|
|
155
|
+
}
|
|
147
156
|
}
|
|
148
157
|
|
|
149
158
|
const nextTick = () => new Promise(requestAnimationFrame);
|
package/src/pack.ts
CHANGED
|
@@ -1,87 +1,262 @@
|
|
|
1
|
-
/**********************************************************************
|
|
2
|
-
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
-
***********************************************************************/
|
|
4
|
-
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
7
|
-
import { dirname, resolve } from 'node:path';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import { resolveEditor } from './editor.ts';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
// `
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { resolveEditor } from './editor.ts';
|
|
10
|
+
import { mergePlanRefs, planRefs } from './planRefs.ts';
|
|
11
|
+
|
|
12
|
+
// Bakes a Godot project's assets for the runtime: imports them, then exports one native
|
|
13
|
+
// `.pck` **per scene** plus a shared one, so a host fetches the shared data and only the
|
|
14
|
+
// level it is playing. `dump-deps.gd` supplies both inputs (no host-side scanning): each
|
|
15
|
+
// source's packed closure — which decides what a pack owns — and the loose (WebP-backed)
|
|
16
|
+
// texture sources, which the host stages and transcodes per file.
|
|
17
|
+
//
|
|
18
|
+
// The result the runtime reads is one file beside the packs: `<out>/packs.json`, holding
|
|
19
|
+
// each pack (its path, whether it mounts always, and the sources it owns) plus those loose
|
|
20
|
+
// texture lists. `preload.ts` reads it to mount the right packs per asset.
|
|
21
|
+
//
|
|
22
|
+
// The project owns `export_presets.cfg` (a committed, editor-managed file), and the scoping
|
|
23
|
+
// has to live there: `--export-pack` runs against a preset. Per-pack scoping is therefore an
|
|
24
|
+
// `exclude_filter` — the repo's `all_resources` convention, which ignores `include_filter`
|
|
25
|
+
// and, unlike the selected modes, never pulls dependencies back in — so each pack excludes
|
|
26
|
+
// exactly the sources the others own (see `splitPacks`). Those presets are cloned from the
|
|
27
|
+
// project's own base preset, one written at a time, and the file is restored when the bake
|
|
28
|
+
// ends: they are derived and the file is tracked.
|
|
29
|
+
|
|
30
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
|
|
32
|
+
/** The dump's one build-time output, merged into the manifest beside the packs. */
|
|
33
|
+
const DUMP = '.godot/pack-dump.json';
|
|
34
|
+
|
|
35
|
+
export interface BuildAssetPackOptions {
|
|
36
|
+
/** res:// roots whose assets are baked (default `['res://src']`). */
|
|
37
|
+
roots?: string[];
|
|
38
|
+
/** Output directory for the packs + manifest, project-relative (default `.godot/packs`). */
|
|
39
|
+
out?: string;
|
|
40
|
+
/** The `export_presets.cfg` preset the generated pack presets derive from (default `Pack`). */
|
|
41
|
+
preset?: string;
|
|
42
|
+
/** Editor executable override (default: `resolveEditor()`). */
|
|
43
|
+
godot?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A pack's assignment: its name, whether it mounts always, and the sources it owns. */
|
|
47
|
+
interface AssetPackSplit {
|
|
48
|
+
/** `shared`, or a scene's file stem. */
|
|
49
|
+
name: string;
|
|
50
|
+
/** Mount for every asset (the shared pack). */
|
|
51
|
+
always?: boolean;
|
|
52
|
+
/** The packable sources this pack owns — the lookup the plugin mounts by. */
|
|
53
|
+
owns: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One baked pack as the runtime sees it. */
|
|
57
|
+
interface AssetPackManifestEntry extends AssetPackSplit {
|
|
58
|
+
/** `res://` path of the `.pck`, for `loadAssetPack`. */
|
|
59
|
+
pck: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The runtime manifest beside the packs: what to mount, and what to stage per file. */
|
|
63
|
+
export interface AssetPackManifest {
|
|
64
|
+
packs: AssetPackManifestEntry[];
|
|
65
|
+
/** Packed source → the loose texture sources it needs at runtime (from `dump-deps.gd`). */
|
|
66
|
+
deps: Record<string, string[]>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Each packed source's packed closure, as `dump-deps.gd` reports it. */
|
|
70
|
+
interface Graph {
|
|
71
|
+
[source: string]: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The two build-time outputs of `dump-deps.gd`. */
|
|
75
|
+
interface Dump {
|
|
76
|
+
graph: Graph;
|
|
77
|
+
deps: Record<string, string[]>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The ownership partition, from the dependency graph. A **level is a scene** (`.tscn`) among
|
|
82
|
+
* the scanned sources — a project's levels are its scenes, so nothing declares them — and:
|
|
83
|
+
* **a source exactly one scene reaches is that scene's** (so a model only one level uses
|
|
84
|
+
* rides its pack, though it lives in a shared asset tree), while **a source several scenes
|
|
85
|
+
* reach, or none does, is shared**. A scene whose own pack would be empty is dropped: every
|
|
86
|
+
* source it reaches is reached by another scene too, so it is already in `shared`, and a pack
|
|
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.
|
|
95
|
+
*/
|
|
96
|
+
export function splitPacks(graph: Graph): AssetPackSplit[] {
|
|
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.
|
|
102
|
+
const scenes = Object.keys(graph).filter((s) => s.toLowerCase().endsWith('.tscn')).sort();
|
|
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] ?? [])])]));
|
|
109
|
+
|
|
110
|
+
// One pack per level, named after its file stem (`shared` is reserved; a repeated stem —
|
|
111
|
+
// two `level.tscn` in different directories — gets a suffix).
|
|
112
|
+
const used = new Set(['shared']);
|
|
113
|
+
const packOf = new Map<string, AssetPackSplit>();
|
|
114
|
+
for (const level of levels) {
|
|
115
|
+
let name = stem(level);
|
|
116
|
+
for (let n = 2; used.has(name); n++) name = `${stem(level)}-${n}`;
|
|
117
|
+
used.add(name);
|
|
118
|
+
packOf.set(level, { name, owns: [] });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const shared: string[] = [];
|
|
122
|
+
const sources = new Set([...Object.keys(graph), ...Object.values(graph).flat()]);
|
|
123
|
+
for (const source of sources) {
|
|
124
|
+
const owners = levels.filter((level) => closure.get(level)!.has(source));
|
|
125
|
+
if (owners.length === 1) packOf.get(owners[0])!.owns.push(source);
|
|
126
|
+
else shared.push(source);
|
|
127
|
+
}
|
|
128
|
+
shared.sort();
|
|
129
|
+
return [
|
|
130
|
+
{ name: 'shared', always: true, owns: shared },
|
|
131
|
+
...[...packOf.values()]
|
|
132
|
+
.filter((pack) => pack.owns.length > 0)
|
|
133
|
+
.map((pack) => ({ ...pack, owns: pack.owns.sort() })),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Bakes the packs (see {@link splitPacks}) and writes the runtime manifest. A project with no
|
|
139
|
+
* scene under its roots gets a single `shared.pck` and the same manifest.
|
|
140
|
+
*/
|
|
141
|
+
export async function buildAssetPacks(opts: BuildAssetPackOptions = {}): Promise<void> {
|
|
142
|
+
const editor = opts.godot ?? (await resolveEditor());
|
|
143
|
+
const cwd = process.cwd();
|
|
144
|
+
const roots = opts.roots ?? ['res://src'];
|
|
145
|
+
const out = opts.out ?? '.godot/packs';
|
|
146
|
+
const base = opts.preset ?? 'Pack';
|
|
147
|
+
const prefix = `${base}: `;
|
|
148
|
+
const presets = resolve(cwd, 'export_presets.cfg');
|
|
149
|
+
|
|
150
|
+
const cfg = readFileSync(presets, 'utf8');
|
|
151
|
+
const basePreset = presetBlock(cfg, base);
|
|
152
|
+
const baseExcludes = excludeFilter(basePreset);
|
|
153
|
+
mkdirSync(resolve(cwd, out), { recursive: true });
|
|
154
|
+
|
|
155
|
+
const run = runner(editor, cwd);
|
|
156
|
+
run(['--headless', '--path', '.', '--import']);
|
|
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));
|
|
162
|
+
|
|
163
|
+
const packs = splitPacks(graph);
|
|
164
|
+
const owned = packs.flatMap((pack) => pack.owns);
|
|
165
|
+
const manifest: AssetPackManifestEntry[] = [];
|
|
166
|
+
|
|
167
|
+
// The project's own presets, with any this tool generated in an earlier run dropped.
|
|
168
|
+
const original = pruneGenerated(cfg, prefix);
|
|
169
|
+
const index = (original.match(/^\[preset\.\d+\]/gm) ?? []).length;
|
|
170
|
+
try {
|
|
171
|
+
for (const { name, always, owns } of packs) {
|
|
172
|
+
const exclude = [
|
|
173
|
+
...baseExcludes,
|
|
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`);
|
|
177
|
+
const pck = `${out}/${name}.pck`;
|
|
178
|
+
run(['--headless', '--path', '.', '--export-pack', `${prefix}${name}`, pck]);
|
|
179
|
+
manifest.push({ name, pck: resPath(pck), ...(always ? { always } : {}), owns });
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
// Derived — leave the project's own file as it was, so a bake does not churn a tracked,
|
|
183
|
+
// editor-managed file.
|
|
184
|
+
writeFileSync(presets, original);
|
|
185
|
+
}
|
|
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
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A `spawnSync` runner that throws on a non-zero editor exit. */
|
|
191
|
+
function runner(editor: string, cwd: string) {
|
|
192
|
+
return (args: string[], env?: Record<string, string>) => {
|
|
193
|
+
const r = spawnSync(editor, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env } });
|
|
194
|
+
if ((r.status ?? 1) !== 0) {
|
|
195
|
+
throw new Error(`godot-assets: \`${editor} ${args.join(' ')}\` failed (${r.status})`);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Runs `dump-deps.gd` and splits its output into the graph and the loose-texture lists. */
|
|
201
|
+
function dump(run: (args: string[], env?: Record<string, string>) => void, cwd: string, roots: string[]): Dump {
|
|
202
|
+
copyFileSync(resolve(HERE, 'dump-deps.gd'), resolve(cwd, '.godot', 'pack-dump-deps.gd'));
|
|
203
|
+
run(['--headless', '--path', '.', '--script', 'res://.godot/pack-dump-deps.gd'], {
|
|
204
|
+
GODOT_DUMP_OUT: resPath(DUMP),
|
|
205
|
+
GODOT_DUMP_ROOTS: roots.join(','),
|
|
206
|
+
});
|
|
207
|
+
const abs = resolve(cwd, DUMP);
|
|
208
|
+
if (!existsSync(abs)) throw new Error(`godot-assets: the dump wrote no ${DUMP} (see dump-deps.gd).`);
|
|
209
|
+
const sources = JSON.parse(readFileSync(abs, 'utf8')) as Record<string, { packed: string[]; loose: string[] }>;
|
|
210
|
+
const entries = Object.entries(sources);
|
|
211
|
+
return {
|
|
212
|
+
graph: Object.fromEntries(entries.map(([source, s]) => [source, s.packed])),
|
|
213
|
+
deps: Object.fromEntries(entries.map(([source, s]) => [source, s.loose])),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const resPath = (rel: string) => `res://${rel.replace(/^res:\/\//, '')}`;
|
|
218
|
+
|
|
219
|
+
/** The preset whose `name=` matches, as its raw `[preset.N]` … block (options included). */
|
|
220
|
+
function presetBlock(cfg: string, name: string): string {
|
|
221
|
+
const m = new RegExp(`^name=${JSON.stringify(name)}$`, 'm').exec(cfg);
|
|
222
|
+
if (!m) throw new Error(`godot-assets: no "${name}" preset in export_presets.cfg (see export_presets.example.cfg).`);
|
|
223
|
+
const rest = cfg.slice(cfg.lastIndexOf('[preset.', m.index));
|
|
224
|
+
// The next *preset* header — not this one's `[preset.N.options]`.
|
|
225
|
+
const end = rest.search(/\n\[preset\.\d+\]/);
|
|
226
|
+
return (end < 0 ? rest : rest.slice(0, end)).trimEnd();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A preset block's `exclude_filter` entries. */
|
|
230
|
+
function excludeFilter(block: string): string[] {
|
|
231
|
+
const line = /^exclude_filter="([^"]*)"/m.exec(block);
|
|
232
|
+
return (line?.[1] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A pack's preset: the base preset's own (platform + options), renamed and re-excluded. */
|
|
236
|
+
function presetFor(base: string, index: number, name: string, exclude: string[]): string {
|
|
237
|
+
return base
|
|
238
|
+
.replace(/^\[preset\.\d+\]/, `[preset.${index}]`)
|
|
239
|
+
.replace(/\[preset\.\d+\.options\]/, `[preset.${index}.options]`)
|
|
240
|
+
.replace(/^name="[^"]*"/m, `name=${JSON.stringify(name)}`)
|
|
241
|
+
.replace(/^exclude_filter="[^"]*"/m, `exclude_filter=${JSON.stringify(exclude.join(','))}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Drops the presets this tool generated in an earlier run (so re-bakes are idempotent). */
|
|
245
|
+
function pruneGenerated(cfg: string, prefix: string): string {
|
|
246
|
+
const generated = new RegExp(`^name="${prefix}[^"]*"$`, 'm');
|
|
247
|
+
return cfg.split(/(?=\[preset\.\d+\])/).filter((block) => !generated.test(block)).join('');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Parses `godot pack` CLI args into {@link BuildAssetPackOptions}. */
|
|
251
|
+
export function parsePackArgs(args: string[]): BuildAssetPackOptions {
|
|
252
|
+
const opt = (flag: string): string | undefined => {
|
|
253
|
+
const i = args.indexOf(flag);
|
|
254
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
roots: opt('--roots')?.split(',').filter(Boolean),
|
|
258
|
+
out: opt('--out'),
|
|
259
|
+
preset: opt('--preset'),
|
|
260
|
+
godot: opt('--godot'),
|
|
261
|
+
};
|
|
262
|
+
}
|
package/src/planRefs.ts
ADDED
|
@@ -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
|
+
}
|
package/src/preload.ts
CHANGED
|
@@ -5,6 +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 { AssetPackManifest } from './pack.ts';
|
|
8
9
|
import type { BunPlugin } from 'bun';
|
|
9
10
|
|
|
10
11
|
// Registers the godot-assets plugin: imports of Godot source assets become
|
|
@@ -12,13 +13,13 @@ import type { BunPlugin } from 'bun';
|
|
|
12
13
|
// load the resource.
|
|
13
14
|
//
|
|
14
15
|
// A packed source asset (`tscn`/`tres`/`gltf`/`gd`/font/…) is loaded from the
|
|
15
|
-
// `.pck` baked by `godot pack`: the module mounts the pack (`loadAssetPack`),
|
|
16
|
+
// `.pck` baked by `godot pack`: the module mounts the pack(s) (`loadAssetPack`),
|
|
16
17
|
// whose remap table resolves the source path to the packed binary (`.scn`/`.res`)
|
|
17
18
|
// with refs the engine resolves itself. The
|
|
18
19
|
// plugin never scans source text for references — the only per-file staging is
|
|
19
20
|
// for *loose* (WebP-backed) textures the wasm engine cannot decode, whose list
|
|
20
|
-
// is computed by the engine (`@ringozz/godot/pack`'s `dump-deps.gd
|
|
21
|
-
//
|
|
21
|
+
// is computed by the engine (`@ringozz/godot/pack`'s `dump-deps.gd`, via
|
|
22
|
+
// `ResourceLoader.get_dependencies`) and shipped in the manifest beside the packs.
|
|
22
23
|
//
|
|
23
24
|
// The load promise persists on `import.meta.hot.data` (carried across HMR
|
|
24
25
|
// re-evaluations) so `use()` never re-suspends; a rejected load is evicted so
|
|
@@ -72,17 +73,29 @@ const extOf = (abs: string) => abs.slice(abs.lastIndexOf('.') + 1).toLowerCase()
|
|
|
72
73
|
const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
|
|
73
74
|
const isLoose = (abs: string) => LOOSE.has(extOf(abs));
|
|
74
75
|
|
|
75
|
-
// A project
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
// A project bakes native `.pck`s (`godot --export-pack`) — the engine-converted binary
|
|
77
|
+
// resources + remap tables — through `@ringozz/godot/pack`'s `buildAssetPacks`: one per
|
|
78
|
+
// scene, plus a shared one, so a host fetches the shared data and only the level it is
|
|
79
|
+
// playing. The manifest beside them (`<pack dir>/packs.json`) is the whole runtime contract:
|
|
80
|
+
// which packs exist, which sources each owns, and which loose (WebP-backed) textures the host
|
|
81
|
+
// must stage per file. A module mounts the packs marked `always` (the shared pack) plus its
|
|
82
|
+
// asset's owner, so importing a level brings in exactly what it needs.
|
|
83
|
+
//
|
|
84
|
+
// Everything is optional: with no manifest (desktop, or a project that has not baked) modules
|
|
85
|
+
// load source paths directly.
|
|
86
|
+
const readJson = <T>(file: string): T | null =>
|
|
87
|
+
existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as T) : null;
|
|
88
|
+
|
|
89
|
+
/** Where the packs were baked — `godot pack`'s `--out` (`GODOT_PACKS` overrides for a host). */
|
|
90
|
+
const PACK_DIR = resolve(ROOT, process.env.GODOT_PACKS ?? '.godot/packs');
|
|
91
|
+
const MANIFEST = readJson<AssetPackManifest>(resolve(PACK_DIR, 'packs.json'));
|
|
92
|
+
|
|
93
|
+
/** The packs an asset needs mounted: the always-mounted ones plus its owner. */
|
|
94
|
+
function packsFor(resPath: string): string[] {
|
|
95
|
+
return (MANIFEST?.packs ?? [])
|
|
96
|
+
.filter((pack) => pack.always || pack.owns.includes(resPath))
|
|
97
|
+
.map((pack) => resolve(ROOT, pack.pck.replace(/^res:\/\//, '')));
|
|
98
|
+
}
|
|
86
99
|
|
|
87
100
|
/** Reads the imported destination (`dest_files`) out of an `.import` sidecar. */
|
|
88
101
|
function parseSidecarDest(text: string): string | null {
|
|
@@ -117,7 +130,7 @@ function analyze(abs: string): Map<string, 'text' | 'file'> {
|
|
|
117
130
|
if (isLoose(abs)) {
|
|
118
131
|
addLoose(abs, files);
|
|
119
132
|
}
|
|
120
|
-
for (const loose of
|
|
133
|
+
for (const loose of MANIFEST?.deps[resOf(abs)] ?? []) {
|
|
121
134
|
addLoose(resolve(ROOT, loose.slice('res://'.length)), files);
|
|
122
135
|
}
|
|
123
136
|
return files;
|
|
@@ -133,9 +146,11 @@ function ensureModule(abs: string): string {
|
|
|
133
146
|
`import { ${cls} } from '@ringozz/godot/${cls}';`,
|
|
134
147
|
];
|
|
135
148
|
let ready = 'undefined';
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
149
|
+
const mounts = packsFor(res);
|
|
150
|
+
if (mounts.length) {
|
|
151
|
+
mounts.forEach((url, i) => imports.push(`import pack${i} from ${JSON.stringify(url)} with { type: 'file' };`));
|
|
152
|
+
const loads = mounts.map((_, i) => `loadAssetPack(pack${i})`);
|
|
153
|
+
ready = loads.length === 1 ? loads[0] : `Promise.all([${loads.join(', ')}])`;
|
|
139
154
|
}
|
|
140
155
|
const map: string[] = [];
|
|
141
156
|
let fi = 0;
|