@ringozz/godot 4.7.2-657 → 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/dump-deps.gd.uid +1 -0
- package/src/pack.ts +84 -86
- package/src/preload.ts +11 -8
- package/src/web-image.ts +388 -388
- package/src/planRefs.ts +0 -169
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)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://dfbmw3t660p7v
|
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;
|