@ringozz/godot 4.7.1-9 → 4.7.2-570
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 +12 -7
- package/gen/classes/DisplayServer.ts +3 -4
- package/gen/classes/Light3D.ts +4 -2
- package/gen/classes/RandomNumberGenerator.ts +2 -2
- package/gen/heap-types/GodotArray.ts +4 -1
- package/gen/heap-types/GodotDictionary.ts +6 -1
- package/package.json +16 -10
- package/src/assets.d.ts +14 -0
- package/src/boot.browser.ts +5 -3
- package/src/boot.ts +3 -2
- package/src/debug.ts +236 -118
- package/src/index.ts +15 -3
- package/src/load.ts +23 -2
- package/src/preload.ts +19 -1
- package/src/runtime.ts +17 -4
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ const done = runGodot(); // pumps frames until aborted
|
|
|
33
33
|
|
|
34
34
|
### Web
|
|
35
35
|
|
|
36
|
-
The wasm engine boots asynchronously, so on web the entry script must boot it **before** importing the app
|
|
36
|
+
The wasm engine boots asynchronously, so on web the entry script must boot it **before** importing the app: `@ringozz/godot/boot`'s `preloadGodot()` starts the engine, and `@ringozz/godot/runtime` then reads the booted module synchronously (the package's `exports` map routes `./boot` to the web leaf in browser-targeted bundles). This keeps the shared module graph top-level-await free, which is also what makes Bun's HMR dev server work. On desktop `preloadGodot()` is a no-op — the engine boots at import.
|
|
37
37
|
|
|
38
38
|
```ts
|
|
39
39
|
// your web entry (referenced by <script type="module"> in the HTML)
|
|
@@ -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`, `_get`, `_set`, `_R`, `_G`, `_P`, `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` |
|
|
56
|
-
| `@ringozz/godot/debug` | `initDebug()`, `dumpStr`, `
|
|
56
|
+
| `@ringozz/godot/debug` | `initDebug()`, `dumpStr`, `snapshot`, `inspect`, `click`, `find`, `gc` |
|
|
57
57
|
|
|
58
58
|
Class-scoped enums use bare names (import `ProcessMode` from `@ringozz/godot/Node`, not `NodeProcessMode`).
|
|
59
59
|
|
|
@@ -74,7 +74,7 @@ const tex = await loadResourceAsync('res://textures/icon.jpg', Texture2D); //
|
|
|
74
74
|
- `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.
|
|
75
75
|
- `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.
|
|
76
76
|
|
|
77
|
-
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:
|
|
77
|
+
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:
|
|
78
78
|
|
|
79
79
|
```ts
|
|
80
80
|
import foo from './models/foo.gltf';
|
|
@@ -97,7 +97,7 @@ function Foo() {
|
|
|
97
97
|
</Suspense>
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
Each extension maps to its Godot class (source assets only — `gltf`/`tscn`/`obj` → `PackedScene`, `jpg`/`jpeg`/`png`/`webp` → `CompressedTexture2D`, `svg` → `Texture2D`, `exr`/`hdr` → `TextureLayered`, `wav` → `AudioStreamWAV`, `ogg` → `AudioStreamOggVorbis`, `mp3` → `AudioStreamMP3`, `tres` → `Resource`, `po` → `Translation`), declared in `src/assets.d.ts`. Imported products (`.scn`/`.ctex`) are never imported directly.
|
|
100
|
+
Each extension maps to its Godot class (source assets only — `gltf`/`tscn`/`obj` → `PackedScene`, `jpg`/`jpeg`/`png`/`webp` → `CompressedTexture2D`, `svg` → `Texture2D`, `exr`/`hdr` → `TextureLayered`, `wav` → `AudioStreamWAV`, `ogg` → `AudioStreamOggVorbis`, `mp3` → `AudioStreamMP3`, `ttf`/`otf` → `FontFile`, `tres` → `Resource`, `po` → `Translation`), declared in `src/assets.d.ts`. Imported products (`.scn`/`.ctex`) are never imported directly.
|
|
101
101
|
|
|
102
102
|
Textures are shared only through Godot's `ResourceLoader` path cache. The glTF import pipeline may produce per-scene texture instances (same `res://` path, separate objects), so don't assume two scenes referencing the same image share one texture object.
|
|
103
103
|
|
|
@@ -106,12 +106,17 @@ Textures are shared only through Godot's `ResourceLoader` path cache. The glTF i
|
|
|
106
106
|
```ts
|
|
107
107
|
import { initDebug } from '@ringozz/godot/debug';
|
|
108
108
|
initDebug();
|
|
109
|
-
// singletons + helpers now on globalThis.$: $.Engine, $.dumpStr(node), $.
|
|
109
|
+
// singletons + helpers now on globalThis.$: $.Engine, $.tree, $.dumpStr(node), $.snapshot([pattern]), $.inspect(path), $.click(path), $.find([pattern], [type]), $.paused, $.gc()
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
`dumpStr` renders value types inline (`(1.0, 2.0, 3.0)`, arrays, dictionaries) via Godot's `str()` and dumps objects via Godot's `var_to_str`, with the node's `name` prepended.
|
|
112
|
+
- `dumpStr(obj)` renders value types inline (`(1.0, 2.0, 3.0)`, arrays, dictionaries) via Godot's `str()` and dumps objects via Godot's `var_to_str`, with the node's `name` prepended.
|
|
113
|
+
- `snapshot(pattern?, node?)` dumps the scene tree from the root `Window` as `Class "name" (N children)` lines; pass a string (case-insensitive substring) or `RegExp` to filter. `$.snapshot()` with no arg dumps the whole tree.
|
|
114
|
+
- `inspect(path)` resolves a node by absolute NodePath (`/root/Box`) or name/glob pattern (`findChild`) and returns its `dumpStr`.
|
|
115
|
+
- `click(path)` synthesizes a left-click at the center of a `Control` via `Window.pushInput` (device-space coordinates), returning the node's `dumpStr` or `null` if it isn't a `Control`.
|
|
116
|
+
- `find(pattern?, type?)` returns a JS array of nodes matching a name glob and/or class (`$.find('*', 'RigidBody3D')` lists all physics bodies).
|
|
117
|
+
- `$.tree` is the live `SceneTree`; `$.paused = true` freezes the scene tree for inspection; `$.paused = false` resumes.
|
|
113
118
|
|
|
114
|
-
`initDebug` also registers an `uncaughtException` handler that keeps the process alive, polyfills `DOMRect` (not defined in Bun, needed by `getBoundingClientRect()`), and adds `getBoundingClientRect()` to `CanvasItem`/`Node3D
|
|
119
|
+
`initDebug` also registers an `uncaughtException` handler that keeps the process alive, polyfills `DOMRect` (not defined in Bun, needed by `getBoundingClientRect()`), and adds `getBoundingClientRect()` to `CanvasItem`/`Node3D` (Node3D covers descendant meshes; returns CSS-pixel coordinates).
|
|
115
120
|
|
|
116
121
|
## Notes
|
|
117
122
|
|
|
@@ -729,7 +729,7 @@ export class DisplayServer extends GodotVar {
|
|
|
729
729
|
static screenGetDpi(screen?: number /* = -1 */): number { return _C($(), 10248, screen) as number; }
|
|
730
730
|
|
|
731
731
|
/**
|
|
732
|
-
* Returns the scale factor of the specified screen by index. Returns `1.0` if `screen` is invalid.
|
|
732
|
+
* Returns the scale factor of the specified screen by index. Returns `1.0` if `screen` is invalid. See also {@link screenGetMaxScale}.
|
|
733
733
|
* **Note:** One of the following constants can be used as `screen`: {@link SCREEN_OF_MAIN_WINDOW}, {@link SCREEN_PRIMARY}, {@link SCREEN_WITH_MOUSE_FOCUS}, or {@link SCREEN_WITH_KEYBOARD_FOCUS}.
|
|
734
734
|
* **Note:** On macOS, the returned value is `2.0` for hiDPI (Retina) screens, and `1.0` for all other cases.
|
|
735
735
|
* **Note:** On Linux (Wayland), the returned value is accurate only when `screen` is {@link SCREEN_OF_MAIN_WINDOW}. Due to API limitations, passing a direct index will return a rounded-up integer, if the screen has a fractional scale (e.g. `1.25` would get rounded up to `2.0`).
|
|
@@ -743,9 +743,8 @@ export class DisplayServer extends GodotVar {
|
|
|
743
743
|
static isTouchscreenAvailable(): boolean { return _C($(), 8369) as boolean; }
|
|
744
744
|
|
|
745
745
|
/**
|
|
746
|
-
* Returns the greatest scale factor of all screens.
|
|
747
|
-
* **Note:** On macOS returned value is `2.0` if there is at least one hiDPI (Retina) screen in the system, and `1.0` in all other cases.
|
|
748
|
-
* **Note:** This method is implemented only on macOS.
|
|
746
|
+
* Returns the greatest scale factor of all screens. See also {@link screenGetScale}.
|
|
747
|
+
* **Note:** On macOS, the returned value is `2.0` if there is at least one hiDPI (Retina) screen in the system, and `1.0` in all other cases.
|
|
749
748
|
*/
|
|
750
749
|
static screenGetMaxScale(): number { return _C($(), 10253) as number; }
|
|
751
750
|
|
package/gen/classes/Light3D.ts
CHANGED
|
@@ -296,12 +296,14 @@ export const BakeMode = {
|
|
|
296
296
|
*/
|
|
297
297
|
BAKE_DISABLED: 0,
|
|
298
298
|
/**
|
|
299
|
-
* Light is taken into account in static baking ({@link VoxelGI}, {@link LightmapGI}, SDFGI ({@link Environment.sdfgiEnabled})). The light can be moved around or modified, but its global illumination will not update in real-time.
|
|
299
|
+
* Light is taken into account in static baking ({@link VoxelGI}, {@link LightmapGI}, SDFGI ({@link Environment.sdfgiEnabled})). The light can be moved around or modified, but its global illumination will not update in real-time.
|
|
300
300
|
* **Note:** The light is not baked in {@link LightmapGI} if {@link editorOnly} is `true`.
|
|
301
|
+
* **Note:** When using {@link LightmapGI}, both the direct and indirect light are baked. Since direct light is baked, the light doesn't display a specular lobe on static lightmapped meshes. Shadows on static lightmapped meshes will also look less detailed, but the light still casts shadows that can be displayed on dynamic objects. Since real-time light computations are skipped on static lightmapped meshes, this bake mode improves runtime performance compared to {@link BAKE_DYNAMIC} and {@link BAKE_DISABLED}.
|
|
301
302
|
*/
|
|
302
303
|
BAKE_STATIC: 1,
|
|
303
304
|
/**
|
|
304
|
-
* Light is taken into account in dynamic baking ({@link VoxelGI} and SDFGI ({@link Environment.sdfgiEnabled})
|
|
305
|
+
* Light is taken into account in dynamic baking ({@link VoxelGI} and SDFGI ({@link Environment.sdfgiEnabled})). The light can be moved around or modified with global illumination updating in real-time. The light's global illumination appearance will be slightly different compared to {@link BAKE_STATIC}. This has a greater performance cost compared to {@link BAKE_STATIC}. When using SDFGI, the update speed of dynamic lights is affected by {@link ProjectSettings.rendering/globalIllumination/sdfgi/framesToUpdateLights}.
|
|
306
|
+
* **Note:** When using {@link LightmapGI}, the light's indirect light is baked, but direct light and shadows remain real-time. This mode allows performing *subtle* changes to a light's color, energy, and position while still looking fairly correct. For example, you can use this to create flickering static torches that have their indirect light baked.
|
|
305
307
|
*/
|
|
306
308
|
BAKE_DYNAMIC: 2,
|
|
307
309
|
} as const;
|
|
@@ -48,9 +48,9 @@ export class RandomNumberGenerator extends RefCounted {
|
|
|
48
48
|
randiRange(from: number, to: number): number { return _C(this, 9725, from, to) as number; }
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* Returns a random integer between `0` and the size of the array that is passed as a parameter. Each value in the array should be a floating-point number that represents the relative likelihood that it will be returned as an index. A higher value means the value is more likely to be returned as an index, while a value of `0` means it will never be returned as an index.
|
|
51
|
+
* Returns a random integer between `0` and the size of the array that is passed as a parameter. Each value in the array should be a non-negative floating-point number that represents the relative likelihood that it will be returned as an index. A higher value means the value is more likely to be returned as an index, while a value of `0` means it will never be returned as an index.
|
|
52
52
|
* For example, if {@link code skip-lint}{@link 0.5, 1, 1, 2} is passed as a parameter, then the method is twice as likely to return `3` (the index of the value `2`) and twice as unlikely to return `0` (the index of the value `0.5`) compared to the indices `1` and `2`.
|
|
53
|
-
* Prints an error and returns `-1` if the array is empty.
|
|
53
|
+
* Prints an error and returns `-1` if the array is empty or contains any negative values.
|
|
54
54
|
*
|
|
55
55
|
*
|
|
56
56
|
* ```gdscript
|
|
@@ -6,7 +6,10 @@ import type { Callable, StringName, Variant } from './index.ts';
|
|
|
6
6
|
|
|
7
7
|
/** Godot's Array type — lazy, indexed container. */
|
|
8
8
|
export class GodotArray<T = any> extends GodotVar implements Iterable<T> {
|
|
9
|
-
constructor(
|
|
9
|
+
constructor(from?: Iterable<T>) {
|
|
10
|
+
super(28);
|
|
11
|
+
if (from != null) for (const x of from) _C(this, 9630, x);
|
|
12
|
+
}
|
|
10
13
|
|
|
11
14
|
get length(): number { return _C(this, 13608) as number; }
|
|
12
15
|
|
|
@@ -6,7 +6,12 @@ import type { GodotArray, StringName, Variant } from './index.ts';
|
|
|
6
6
|
|
|
7
7
|
/** Godot's Dictionary type — lazy, keyed container. */
|
|
8
8
|
export class GodotDictionary<K = any, V = any> extends GodotVar implements Iterable<[K, V]> {
|
|
9
|
-
constructor(
|
|
9
|
+
constructor(from?: Record<string, V> | GodotDictionary<K, V>) {
|
|
10
|
+
super(27);
|
|
11
|
+
if (from == null) return;
|
|
12
|
+
if (from instanceof GodotDictionary) { this.assign(from); return; }
|
|
13
|
+
for (const [k, v] of Object.entries(from)) this.set(k as K, v);
|
|
14
|
+
}
|
|
10
15
|
|
|
11
16
|
*[Symbol.iterator](): Iterator<[K, V]> {
|
|
12
17
|
const ks = _C(this, 8530) as any;
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ringozz/godot",
|
|
3
3
|
"author": "Vladimir Davidovich",
|
|
4
|
-
"version": "4.7.
|
|
4
|
+
"version": "4.7.2-570",
|
|
5
5
|
"description": "Node-API bindings for Godot Engine — call Godot classes from JavaScript",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
6
9
|
"keywords": [
|
|
7
10
|
"godot"
|
|
8
11
|
],
|
|
@@ -11,16 +14,19 @@
|
|
|
11
14
|
"types": "./gen/index.ts",
|
|
12
15
|
"exports": {
|
|
13
16
|
".": "./src/index.ts",
|
|
17
|
+
"./assets": {
|
|
18
|
+
"types": "./src/assets.d.ts"
|
|
19
|
+
},
|
|
14
20
|
"./runtime": "./src/runtime.ts",
|
|
15
|
-
"./boot":
|
|
21
|
+
"./boot": {
|
|
22
|
+
"browser": "./src/boot.browser.ts",
|
|
23
|
+
"default": "./src/boot.ts"
|
|
24
|
+
},
|
|
16
25
|
"./load": "./src/load.ts",
|
|
17
26
|
"./debug": "./src/debug.ts",
|
|
18
27
|
"./preload": "./src/preload.ts",
|
|
19
28
|
"./*": "./gen/classes/*.ts"
|
|
20
29
|
},
|
|
21
|
-
"browser": {
|
|
22
|
-
"./src/boot.ts": "./src/boot.browser.ts"
|
|
23
|
-
},
|
|
24
30
|
"files": [
|
|
25
31
|
"src/",
|
|
26
32
|
"gen/",
|
|
@@ -29,16 +35,16 @@
|
|
|
29
35
|
"godot": {
|
|
30
36
|
"version_major": 4,
|
|
31
37
|
"version_minor": 7,
|
|
32
|
-
"version_patch":
|
|
38
|
+
"version_patch": 2,
|
|
33
39
|
"version_status": "stable",
|
|
34
40
|
"version_build": "official",
|
|
35
|
-
"version_full_name": "Godot Engine v4.7.
|
|
41
|
+
"version_full_name": "Godot Engine v4.7.2.stable.official",
|
|
36
42
|
"precision": "single"
|
|
37
43
|
},
|
|
38
44
|
"optionalDependencies": {
|
|
39
|
-
"@ringozz/godot-macos-arm64": "^4.7.
|
|
40
|
-
"@ringozz/godot-windows-x86_64": "^4.7.
|
|
41
|
-
"@ringozz/godot-web-wasm32": "^4.7.
|
|
45
|
+
"@ringozz/godot-macos-arm64": "^4.7.2-570",
|
|
46
|
+
"@ringozz/godot-windows-x86_64": "^4.7.2-570",
|
|
47
|
+
"@ringozz/godot-web-wasm32": "^4.7.2-570"
|
|
42
48
|
},
|
|
43
49
|
"peerDependencies": {
|
|
44
50
|
"@types/bun": "*"
|
package/src/assets.d.ts
CHANGED
|
@@ -101,6 +101,20 @@ declare module '*.wav' {
|
|
|
101
101
|
export const materialize: Promise<unknown>;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
declare module '*.ttf' {
|
|
105
|
+
import type { FontFile } from '@ringozz/godot/FontFile';
|
|
106
|
+
const asset: Promise<FontFile>;
|
|
107
|
+
export default asset;
|
|
108
|
+
export const materialize: Promise<unknown>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
declare module '*.otf' {
|
|
112
|
+
import type { FontFile } from '@ringozz/godot/FontFile';
|
|
113
|
+
const asset: Promise<FontFile>;
|
|
114
|
+
export default asset;
|
|
115
|
+
export const materialize: Promise<unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
104
118
|
declare module '*.ogg' {
|
|
105
119
|
import type { AudioStreamOggVorbis } from '@ringozz/godot/AudioStreamOggVorbis';
|
|
106
120
|
const asset: Promise<AudioStreamOggVorbis>;
|
package/src/boot.browser.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// *namespace* (whose `.default` is the wasm module). This module is a leaf — it
|
|
8
8
|
// imports nothing from the godot class graph, so a web entry can call
|
|
9
9
|
// `preloadGodot()` *before* importing the app without pulling in `runtime.ts`.
|
|
10
|
-
// `runtime.ts` statically imports this module too
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// `runtime.ts` statically imports this module too — by package self-reference
|
|
11
|
+
// (`@ringozz/godot/boot`, whose `exports` `./boot` entry's `browser` condition
|
|
12
|
+
// selects this file), the same resolution as the entrypoint's import, so both
|
|
13
|
+
// sides are guaranteed the same module instance and `nativeModule` below is the
|
|
14
|
+
// handoff — no global needed.
|
|
13
15
|
|
|
14
16
|
let nativeModule: unknown = null;
|
|
15
17
|
|
package/src/boot.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
// Desktop boot leaf: resolves the native addon for the current host and
|
|
6
6
|
// hands it to `runtime.ts` as the native module *namespace* (whose `.default`
|
|
7
|
-
// is the addon's `module.exports`). `runtime.ts` imports this module
|
|
8
|
-
//
|
|
7
|
+
// is the addon's `module.exports`). `runtime.ts` imports this module by package
|
|
8
|
+
// self-reference (`@ringozz/godot/boot`), whose `exports` `./boot` entry's
|
|
9
|
+
// `browser` condition selects `boot.browser.ts` when bundling for
|
|
9
10
|
// `target: 'browser'` — so the Bun-only `import.meta.require` below never
|
|
10
11
|
// reaches a web bundle.
|
|
11
12
|
|
package/src/debug.ts
CHANGED
|
@@ -2,16 +2,27 @@
|
|
|
2
2
|
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
3
|
***********************************************************************/
|
|
4
4
|
|
|
5
|
-
import { Rect2, str, varToStr, Vector3 } from './index.ts';
|
|
5
|
+
import { MouseButton, Rect2, str, varToStr, Vector2, Vector3 } from './index.ts';
|
|
6
6
|
import { CanvasItem } from '../gen/classes/CanvasItem.ts';
|
|
7
7
|
import { ClassDB } from '../gen/classes/ClassDB.ts';
|
|
8
8
|
import { Control } from '../gen/classes/Control.ts';
|
|
9
9
|
import { DisplayServer } from '../gen/classes/DisplayServer.ts';
|
|
10
10
|
import { Engine } from '../gen/classes/Engine.ts';
|
|
11
|
+
import { InputEventMouseButton } from '../gen/classes/InputEventMouseButton.ts';
|
|
11
12
|
import { Node } from '../gen/classes/Node.ts';
|
|
12
13
|
import { Node3D } from '../gen/classes/Node3D.ts';
|
|
13
14
|
import { OS } from '../gen/classes/OS.ts';
|
|
15
|
+
import { SceneTree } from '../gen/classes/SceneTree.ts';
|
|
14
16
|
import { Time } from '../gen/classes/Time.ts';
|
|
17
|
+
import type { Viewport } from '../gen/classes/Viewport.ts';
|
|
18
|
+
import type { Window } from '../gen/classes/Window.ts';
|
|
19
|
+
|
|
20
|
+
const tree = Engine.getMainLoop() as SceneTree;
|
|
21
|
+
|
|
22
|
+
/** Does a snapshot line match a string (case-insensitive substring) or `RegExp` pattern? */
|
|
23
|
+
function matches(pattern: string | RegExp | undefined, line: string): boolean {
|
|
24
|
+
return !pattern || (pattern instanceof RegExp ? pattern.test(line) : line.toLowerCase().includes(pattern.toLowerCase()));
|
|
25
|
+
}
|
|
15
26
|
|
|
16
27
|
/**
|
|
17
28
|
* Dump a Godot value or object as a string.
|
|
@@ -35,42 +46,95 @@ export function dumpStr(obj: unknown): string {
|
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
/**
|
|
38
|
-
* Serialize a scene subtree as a tree-formatted string.
|
|
49
|
+
* Serialize a scene subtree as a tree-formatted string, optionally filtered.
|
|
50
|
+
*
|
|
51
|
+
* Emits one line per node: `Class "name" (N children)`. When `pattern` is a
|
|
52
|
+
* string, only lines containing it (case-insensitive) are kept; a `RegExp`
|
|
53
|
+
* matches against the full line.
|
|
39
54
|
*
|
|
40
|
-
* @param
|
|
55
|
+
* @param pattern - Optional substring or `RegExp` to filter the lines
|
|
56
|
+
* @param node - Root node to start from (defaults to the scene tree's root Window)
|
|
41
57
|
* @param depth - Max depth (default 99)
|
|
42
58
|
*/
|
|
43
|
-
export function
|
|
59
|
+
export function snapshot(pattern?: string | RegExp, node?: Node, depth = 99): string {
|
|
60
|
+
const root = node ?? tree.root;
|
|
44
61
|
const lines: string[] = [];
|
|
45
62
|
(function walk(n: Node, d: number, ind: string) {
|
|
46
63
|
if (d <= 0) return;
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
const count = n.getChildCount();
|
|
65
|
+
const line = `${ind}${n.getClass()} "${n.name}" (${count} children)`;
|
|
66
|
+
if (matches(pattern, line)) lines.push(line);
|
|
67
|
+
for (let i = 0; i < count; i++) {
|
|
49
68
|
const c = n.getChild(i);
|
|
50
69
|
if (c) walk(c as Node, d - 1, ind + ' ');
|
|
51
70
|
}
|
|
52
|
-
})(
|
|
71
|
+
})(root, depth, '');
|
|
53
72
|
return lines.join('\n');
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
/**
|
|
57
|
-
*
|
|
76
|
+
* Resolve a node from the scene root by absolute NodePath (`/root/...`) or
|
|
77
|
+
* name glob (`findChild`). Returns `null` when not found.
|
|
78
|
+
*/
|
|
79
|
+
function resolveNode(path: string, root: Window): Node | null {
|
|
80
|
+
if (path.startsWith('/')) return root.getNode(path);
|
|
81
|
+
return root.findChild(path, true, false);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a node by path (absolute NodePath or name glob) from the scene root
|
|
86
|
+
* and dump it via {@link dumpStr}.
|
|
87
|
+
*
|
|
88
|
+
* @param path - An absolute NodePath like `/root/Box`, or a `findChild` name/glob pattern
|
|
89
|
+
*/
|
|
90
|
+
export function inspect(path: string): string | null {
|
|
91
|
+
const node = resolveNode(path, tree.root);
|
|
92
|
+
return node ? dumpStr(node) : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Synthesize a left-click at the center of a {@link Control} matched by `path`.
|
|
97
|
+
*
|
|
98
|
+
* Press and release are pushed through `Window.pushInput` (the GUI-routing
|
|
99
|
+
* path — `Input.parseInputEvent` doesn't reliably reach `Control.guiInput` on
|
|
100
|
+
* the wasm build). Positions are in device space (logical × `contentScaleFactor`).
|
|
101
|
+
*
|
|
102
|
+
* @param path - An absolute NodePath like `/root/...`, or a `findChild` name/glob pattern
|
|
103
|
+
* @returns The node's `dumpStr`, or `null` if not found or not a `Control`
|
|
104
|
+
*/
|
|
105
|
+
export function click(path: string): string | null {
|
|
106
|
+
const root = tree.root;
|
|
107
|
+
const node = resolveNode(path, root);
|
|
108
|
+
if (!(node instanceof Control)) return null;
|
|
109
|
+
const rect = node.getGlobalRect();
|
|
110
|
+
const scale = root.contentScaleFactor;
|
|
111
|
+
const position = new Vector2(
|
|
112
|
+
(rect.position.x + rect.size.x / 2) * scale,
|
|
113
|
+
(rect.position.y + rect.size.y / 2) * scale,
|
|
114
|
+
);
|
|
115
|
+
const press = (down: boolean) => {
|
|
116
|
+
const event = new InputEventMouseButton();
|
|
117
|
+
event.position = position;
|
|
118
|
+
event.globalPosition = position;
|
|
119
|
+
event.buttonIndex = MouseButton.MOUSE_BUTTON_LEFT;
|
|
120
|
+
event.pressed = down;
|
|
121
|
+
root.pushInput(event);
|
|
122
|
+
};
|
|
123
|
+
press(true);
|
|
124
|
+
setTimeout(() => press(false), 50);
|
|
125
|
+
return dumpStr(node);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Enumerate nodes in the scene by name pattern and/or class, in tree order.
|
|
58
130
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
131
|
+
* @param pattern - Name glob matched by `findChildren` (default `"*"`)
|
|
132
|
+
* @param type - Class name to filter by, e.g. `"RigidBody3D"` (default `""` = any)
|
|
133
|
+
* @param node - Root node to search under (defaults to the scene tree's root Window)
|
|
62
134
|
*/
|
|
63
|
-
export function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return [
|
|
67
|
-
`Frames: ${Engine.getProcessFrames()}`,
|
|
68
|
-
`FPS: ${Engine.getFramesPerSecond()}`,
|
|
69
|
-
`Physics: ${Engine.getPhysicsFrames()}`,
|
|
70
|
-
`Nodes: ${tree?.getNodeCount?.() ?? 'N/A'}`,
|
|
71
|
-
`Time scale: ${Engine.timeScale}`,
|
|
72
|
-
`Main loop: ${tree ? tree.getClass() : 'N/A'}`,
|
|
73
|
-
].join('\n');
|
|
135
|
+
export function find(pattern = '*', type = '', node?: Node): Node[] {
|
|
136
|
+
const root = node ?? tree.root;
|
|
137
|
+
return [...root.findChildren(pattern, type, true, false)];
|
|
74
138
|
}
|
|
75
139
|
|
|
76
140
|
/**
|
|
@@ -117,6 +181,150 @@ function ensureDOMRect(): void {
|
|
|
117
181
|
};
|
|
118
182
|
}
|
|
119
183
|
|
|
184
|
+
type Push = (x: number, y: number) => void;
|
|
185
|
+
type BoundsCollect = (node: any, push: Push, viewport: Viewport) => void;
|
|
186
|
+
|
|
187
|
+
/** Affine 2D point mapper from a transform's basis/origin, optionally scaled. */
|
|
188
|
+
function makeAffine(t: any, scale = 1): (x: number, y: number) => [number, number] {
|
|
189
|
+
const tox = t.origin.x, toy = t.origin.y;
|
|
190
|
+
const txx = t.x.x, txy = t.x.y;
|
|
191
|
+
const tyx = t.y.x, tyy = t.y.y;
|
|
192
|
+
return (x, y) => [(tox + txx * x + tyx * y) * scale, (toy + txy * x + tyy * y) * scale];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Axis-aligned 2D bounds accumulator in CSS pixels. Non-finite points (e.g.
|
|
197
|
+
* geometry behind the camera) are dropped; `rect()` is `null` when no point
|
|
198
|
+
* was added.
|
|
199
|
+
*/
|
|
200
|
+
function cssBounds(): { add(x: number, y: number): void; rect(): [number, number, number, number] | null } {
|
|
201
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
202
|
+
let found = false;
|
|
203
|
+
return {
|
|
204
|
+
add(x, y) {
|
|
205
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
|
206
|
+
if (x < minX) minX = x; if (y < minY) minY = y;
|
|
207
|
+
if (x > maxX) maxX = x; if (y > maxY) maxY = y;
|
|
208
|
+
found = true;
|
|
209
|
+
},
|
|
210
|
+
rect() {
|
|
211
|
+
return found ? [minX, minY, maxX - minX, maxY - minY] : null;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** World-space AABB (min, max) of a local AABB under its global transform, or `null` when degenerate. */
|
|
217
|
+
function worldAabb(aabb: any, gt: any): [number, number, number, number, number, number] | null {
|
|
218
|
+
if (aabb.size.x === 0 && aabb.size.y === 0 && aabb.size.z === 0) return null;
|
|
219
|
+
const bx = gt.basis.x, by = gt.basis.y, bz = gt.basis.z;
|
|
220
|
+
const cx = aabb.position.x + aabb.size.x / 2;
|
|
221
|
+
const cy = aabb.position.y + aabb.size.y / 2;
|
|
222
|
+
const cz = aabb.position.z + aabb.size.z / 2;
|
|
223
|
+
const ex = Math.hypot(bx.x, bx.y, bx.z) * aabb.size.x / 2;
|
|
224
|
+
const ey = Math.hypot(by.x, by.y, by.z) * aabb.size.y / 2;
|
|
225
|
+
const ez = Math.hypot(bz.x, bz.y, bz.z) * aabb.size.z / 2;
|
|
226
|
+
const ox = gt.origin.x, oy = gt.origin.y, oz = gt.origin.z;
|
|
227
|
+
const wx = ox + bx.x * cx + by.x * cy + bz.x * cz;
|
|
228
|
+
const wy = oy + bx.y * cx + by.y * cy + bz.y * cz;
|
|
229
|
+
const wz = oz + bx.z * cx + by.z * cy + bz.z * cz;
|
|
230
|
+
return [wx - ex, wy - ey, wz - ez, wx + ex, wy + ey, wz + ez];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* World-space AABB of a node's visual content: the union of its own AABB (when
|
|
235
|
+
* it is a `VisualInstance3D`) and every descendant `VisualInstance3D`'s, each in
|
|
236
|
+
* its own global transform — mirrors the editor's `_calculate_spatial_bounds`.
|
|
237
|
+
* Returns `null` when there is no non-degenerate visual.
|
|
238
|
+
*/
|
|
239
|
+
function worldBounds(node: Node3D): [number, number, number, number, number, number] | null {
|
|
240
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
241
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
242
|
+
const add = (aabb: any, gt: any) => {
|
|
243
|
+
const box = worldAabb(aabb, gt);
|
|
244
|
+
if (!box) return;
|
|
245
|
+
if (box[0] < minX) minX = box[0]; if (box[3] > maxX) maxX = box[3];
|
|
246
|
+
if (box[1] < minY) minY = box[1]; if (box[4] > maxY) maxY = box[4];
|
|
247
|
+
if (box[2] < minZ) minZ = box[2]; if (box[5] > maxZ) maxZ = box[5];
|
|
248
|
+
};
|
|
249
|
+
if (typeof (node as any).getAabb === 'function') {
|
|
250
|
+
add((node as any).getAabb(), node.globalTransform);
|
|
251
|
+
}
|
|
252
|
+
for (const child of node.findChildren('*', 'VisualInstance3D', true, false) as unknown as any[]) {
|
|
253
|
+
if (child && typeof child.getAabb === 'function') {
|
|
254
|
+
add(child.getAabb(), child.globalTransform);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return minX === Infinity ? null : [minX, minY, minZ, maxX, maxY, maxZ];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Compute a node's bounding rect in CSS pixels. The callback pushes points in
|
|
262
|
+
* viewport logical units; they're mapped through the viewport's screen transform
|
|
263
|
+
* (content scale, stretch, SubViewport/child-window placement), then divided by
|
|
264
|
+
* screenGetScale to reach CSS pixels. Never throws: React DevTools calls this on
|
|
265
|
+
* every commit and must not see a throw (e.g. a wrapper freed by an unmount).
|
|
266
|
+
*/
|
|
267
|
+
function boundsForViewport(node: Node, collect: (push: Push, viewport: Viewport) => void): DOMRect | null {
|
|
268
|
+
try {
|
|
269
|
+
const viewport = node.getViewport();
|
|
270
|
+
if (!viewport) return null;
|
|
271
|
+
const xform = makeAffine(viewport.getScreenTransform(), 1 / DisplayServer.screenGetScale());
|
|
272
|
+
const bounds = cssBounds();
|
|
273
|
+
collect((x, y) => bounds.add(...xform(x, y)), viewport);
|
|
274
|
+
const r = bounds.rect();
|
|
275
|
+
return r ? new DOMRect(r[0], r[1], r[2], r[3]) : null;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Install `getBoundingClientRect` on a node prototype. */
|
|
282
|
+
function defineBoundingClientRect(proto: object, collect: BoundsCollect): void {
|
|
283
|
+
Object.defineProperty(proto, 'getBoundingClientRect', {
|
|
284
|
+
configurable: true,
|
|
285
|
+
value(this: Node): DOMRect | null {
|
|
286
|
+
return boundsForViewport(this, (push, viewport) => collect(this, push, viewport));
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Push the four corners of an axis-aligned rect through `emit`. */
|
|
292
|
+
function pushRect(emit: (x: number, y: number) => void, x0: number, y0: number, x1: number, y1: number): void {
|
|
293
|
+
for (const px of [x0, x1]) {
|
|
294
|
+
for (const py of [y0, y1]) {
|
|
295
|
+
emit(px, py);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const canvasItemBounds: BoundsCollect = (node, push) => {
|
|
301
|
+
if (node instanceof Control) {
|
|
302
|
+
const rc = node.getGlobalRect();
|
|
303
|
+
pushRect(push, rc.position.x, rc.position.y, rc.position.x + rc.size.x, rc.position.y + rc.size.y);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (typeof node.getRect !== 'function') return;
|
|
307
|
+
const rect = node.getRect() as Rect2;
|
|
308
|
+
const xform = makeAffine(node.getScreenTransform());
|
|
309
|
+
pushRect((px, py) => push(...xform(px, py)), rect.position.x, rect.position.y, rect.position.x + rect.size.x, rect.position.y + rect.size.y);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const node3dBounds: BoundsCollect = (node, push, viewport) => {
|
|
313
|
+
const camera = viewport.getCamera3d();
|
|
314
|
+
if (!camera) return;
|
|
315
|
+
const box = worldBounds(node);
|
|
316
|
+
if (!box) return;
|
|
317
|
+
const [minX, minY, minZ, maxX, maxY, maxZ] = box;
|
|
318
|
+
for (let i = 0; i < 8; i++) {
|
|
319
|
+
const s = camera.unprojectPosition(new Vector3(
|
|
320
|
+
i & 1 ? maxX : minX,
|
|
321
|
+
i & 2 ? maxY : minY,
|
|
322
|
+
i & 4 ? maxZ : minZ,
|
|
323
|
+
));
|
|
324
|
+
push(s.x, s.y);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
120
328
|
/**
|
|
121
329
|
* Bootstrap the debugging environment.
|
|
122
330
|
*
|
|
@@ -133,104 +341,14 @@ export function initDebug(): void {
|
|
|
133
341
|
});
|
|
134
342
|
|
|
135
343
|
ensureDOMRect();
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
configurable: true,
|
|
139
|
-
value(this: CanvasItem): DOMRect | null {
|
|
140
|
-
const scale = DisplayServer.screenGetScale();
|
|
141
|
-
if (this instanceof Control) {
|
|
142
|
-
const rc = (this as Control).getGlobalRect();
|
|
143
|
-
return new DOMRect(
|
|
144
|
-
rc.position.x / scale, rc.position.y / scale,
|
|
145
|
-
rc.size.x / scale, rc.size.y / scale,
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (typeof (this as any).getRect !== 'function') return null;
|
|
150
|
-
|
|
151
|
-
const rect = (this as any).getRect() as Rect2;
|
|
152
|
-
const t = this.getScreenTransform();
|
|
153
|
-
|
|
154
|
-
const pos = rect.position;
|
|
155
|
-
const sz = rect.size;
|
|
156
|
-
const tx = t.x;
|
|
157
|
-
const ty = t.y;
|
|
158
|
-
const to = t.origin;
|
|
159
|
-
|
|
160
|
-
const x0 = pos.x, y0 = pos.y;
|
|
161
|
-
const x1 = x0 + sz.x, y1 = y0 + sz.y;
|
|
162
|
-
const txx = tx.x, txy = tx.y;
|
|
163
|
-
const tyx = ty.x, tyy = ty.y;
|
|
164
|
-
const tox = to.x, toy = to.y;
|
|
165
|
-
|
|
166
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
167
|
-
const accum = (px: number, py: number) => {
|
|
168
|
-
if (px < minX) minX = px; if (py < minY) minY = py;
|
|
169
|
-
if (px > maxX) maxX = px; if (py > maxY) maxY = py;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
accum(txx * x0 + tyx * y0 + tox, txy * x0 + tyy * y0 + toy);
|
|
173
|
-
accum(txx * x1 + tyx * y0 + tox, txy * x1 + tyy * y0 + toy);
|
|
174
|
-
accum(txx * x0 + tyx * y1 + tox, txy * x0 + tyy * y1 + toy);
|
|
175
|
-
accum(txx * x1 + tyx * y1 + tox, txy * x1 + tyy * y1 + toy);
|
|
176
|
-
|
|
177
|
-
return new DOMRect(
|
|
178
|
-
minX / scale, minY / scale,
|
|
179
|
-
(maxX - minX) / scale, (maxY - minY) / scale,
|
|
180
|
-
);
|
|
181
|
-
},
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
Object.defineProperty(Node3D.prototype, 'getBoundingClientRect', {
|
|
185
|
-
configurable: true,
|
|
186
|
-
value(this: Node3D): DOMRect | null {
|
|
187
|
-
if (typeof (this as any).getAabb !== 'function') return null;
|
|
188
|
-
|
|
189
|
-
const aabb = (this as any).getAabb();
|
|
190
|
-
const gt = this.globalTransform;
|
|
191
|
-
const viewport = this.getViewport();
|
|
192
|
-
if (!viewport) return null;
|
|
193
|
-
const camera = viewport.getCamera3d();
|
|
194
|
-
if (!camera) return null;
|
|
195
|
-
const scale = DisplayServer.screenGetScale();
|
|
196
|
-
|
|
197
|
-
const b = gt.basis;
|
|
198
|
-
const bx = b.x, by = b.y, bz = b.z;
|
|
199
|
-
const o = gt.origin;
|
|
200
|
-
const p = aabb.position, e = aabb.end;
|
|
201
|
-
|
|
202
|
-
const [px, py, pz] = [p.x, p.y, p.z];
|
|
203
|
-
const [ex, ey, ez] = [e.x, e.y, e.z];
|
|
204
|
-
const [bxx, bxy, bxz] = [bx.x, bx.y, bx.z];
|
|
205
|
-
const [byx, byy, byz] = [by.x, by.y, by.z];
|
|
206
|
-
const [bzx, bzy, bzz] = [bz.x, bz.y, bz.z];
|
|
207
|
-
const [ox, oy, oz] = [o.x, o.y, o.z];
|
|
208
|
-
|
|
209
|
-
const corners = [
|
|
210
|
-
[px, py, pz], [ex, py, pz], [px, ey, pz], [ex, ey, pz],
|
|
211
|
-
[px, py, ez], [ex, py, ez], [px, ey, ez], [ex, ey, ez],
|
|
212
|
-
];
|
|
213
|
-
|
|
214
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
215
|
-
|
|
216
|
-
for (const [cx, cy, cz] of corners) {
|
|
217
|
-
const wx = bxx * cx + byx * cy + bzx * cz + ox;
|
|
218
|
-
const wy = bxy * cx + byy * cy + bzy * cz + oy;
|
|
219
|
-
const wz = bxz * cx + byz * cy + bzz * cz + oz;
|
|
220
|
-
const s = camera.unprojectPosition(new Vector3(wx, wy, wz));
|
|
221
|
-
if (s.x < minX) minX = s.x; if (s.y < minY) minY = s.y;
|
|
222
|
-
if (s.x > maxX) maxX = s.x; if (s.y > maxY) maxY = s.y;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return new DOMRect(
|
|
226
|
-
minX / scale, minY / scale,
|
|
227
|
-
(maxX - minX) / scale, (maxY - minY) / scale,
|
|
228
|
-
);
|
|
229
|
-
},
|
|
230
|
-
});
|
|
344
|
+
defineBoundingClientRect(CanvasItem.prototype, canvasItemBounds);
|
|
345
|
+
defineBoundingClientRect(Node3D.prototype, node3dBounds);
|
|
231
346
|
|
|
232
347
|
(globalThis as any).$ = {
|
|
233
348
|
Engine, OS, ClassDB, Time,
|
|
234
|
-
dumpStr,
|
|
349
|
+
dumpStr, snapshot, inspect, click, find, gc,
|
|
350
|
+
get tree(): SceneTree { return tree; },
|
|
351
|
+
get paused(): boolean { return tree.paused; },
|
|
352
|
+
set paused(v: boolean) { tree.paused = v; },
|
|
235
353
|
};
|
|
236
354
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { GodotInstance } from '../gen/classes/GodotInstance.ts';
|
|
|
10
10
|
import { SceneTree } from '../gen/classes/SceneTree.ts';
|
|
11
11
|
import { Window } from '../gen/classes/Window.ts';
|
|
12
12
|
import { gc } from './debug.ts';
|
|
13
|
-
import { cancelAnimationFrame, getGodot, requestAnimationFrame } from './runtime.ts';
|
|
13
|
+
import { cancelAnimationFrame, cleanupHooks, getGodot, requestAnimationFrame } from './runtime.ts';
|
|
14
14
|
|
|
15
15
|
// ---- make sure these classes are not tree-shaked ----
|
|
16
16
|
void ValueTypes;
|
|
@@ -34,7 +34,19 @@ export async function runGodot(signal?: AbortSignal, unmount?: () => PromiseLike
|
|
|
34
34
|
await new Promise(requestAnimationFrame);
|
|
35
35
|
|
|
36
36
|
await unmount?.();
|
|
37
|
-
|
|
37
|
+
while (cleanupHooks.size) {
|
|
38
|
+
const hooks = Array.from(cleanupHooks); cleanupHooks.clear();
|
|
39
|
+
await Promise.all(hooks.map((hook) => hook()));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Let pending Napi wrapper finalizers run before tearing down the engine:
|
|
43
|
+
// Bun.gc(true) marks unreachable wrappers but their ~GodotVar/~Variant
|
|
44
|
+
// (which unrefs RefCounted objects) only runs on later macrotasks — and one
|
|
45
|
+
// wrapper's finalizer can free others. Freeing the engine first would report
|
|
46
|
+
// every still-referenced RefCounted as leaked, so drain a few gc+tick rounds.
|
|
47
|
+
for (let i = 0; i < 4; i++) {
|
|
48
|
+
console.log(gc());
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
50
|
+
}
|
|
38
51
|
return godot.free();
|
|
39
52
|
}
|
|
40
|
-
|
package/src/load.ts
CHANGED
|
@@ -8,9 +8,25 @@ import { ProjectSettings } from '../gen/classes/ProjectSettings.ts';
|
|
|
8
8
|
import { ResourceLoader, ThreadLoadStatus } from '../gen/classes/ResourceLoader.ts';
|
|
9
9
|
import { ResourceUID } from '../gen/classes/ResourceUID.ts';
|
|
10
10
|
import type { Resource } from '../gen/classes/Resource.ts';
|
|
11
|
-
import { stageFile } from './runtime.ts';
|
|
11
|
+
import { cleanupHooks, stageFile } from './runtime.ts';
|
|
12
12
|
import { decodeCtex } from './web-image.ts';
|
|
13
13
|
|
|
14
|
+
// Each loaded wrapper is released at shutdown via a `cleanupHooks` entry
|
|
15
|
+
// holding a WeakRef: module-scope asset imports (the preload modules' memoized
|
|
16
|
+
// load promises) keep their wrappers reachable, so GC alone can't collect them
|
|
17
|
+
// before the engine stops. WeakRef keeps the entries from pinning resources
|
|
18
|
+
// during the app's lifetime.
|
|
19
|
+
function track<T extends Resource>(res: T): T {
|
|
20
|
+
const ref = new WeakRef<Resource>(res);
|
|
21
|
+
cleanupHooks.add(() => {
|
|
22
|
+
const r = ref.deref();
|
|
23
|
+
if (r) {
|
|
24
|
+
try { r.free(); } catch {}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return res;
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
/**
|
|
15
31
|
* A Godot `Resource` subclass constructor: its `.name` is the registered class
|
|
16
32
|
* name (used as the `ResourceLoader` type hint), and `InstanceType<C>` is the
|
|
@@ -153,6 +169,7 @@ export function loadAsset<C extends ResourceConstructor>(
|
|
|
153
169
|
data: Record<string, unknown> = {},
|
|
154
170
|
): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
|
|
155
171
|
const cached = cachedResource<C>(path);
|
|
172
|
+
if (cached) cached.then(track);
|
|
156
173
|
const materialize = cached ? Promise.resolve() : materializeFiles(files, deps);
|
|
157
174
|
const existing = data[path] as Promise<InstanceType<C>> | undefined;
|
|
158
175
|
const load = existing ?? cached ?? materialize.then(() => loadResourceAsync(path, cls, files));
|
|
@@ -189,6 +206,10 @@ export async function loadResourceAsync<C extends ResourceConstructor>(
|
|
|
189
206
|
break;
|
|
190
207
|
}
|
|
191
208
|
if (status === ThreadLoadStatus.THREAD_LOAD_FAILED || status === ThreadLoadStatus.THREAD_LOAD_INVALID_RESOURCE) {
|
|
209
|
+
// Collect the engine's LoadToken so a failed load doesn't leave it
|
|
210
|
+
// registered (a bare `RefCounted` leaked at exit). Safe no-op when
|
|
211
|
+
// no token exists.
|
|
212
|
+
ResourceLoader.loadThreadedGet(path);
|
|
192
213
|
throw new Error(`loadResourceAsync(${path}): load failed (status ${status})`);
|
|
193
214
|
}
|
|
194
215
|
await nextTick();
|
|
@@ -206,5 +227,5 @@ export async function loadResourceAsync<C extends ResourceConstructor>(
|
|
|
206
227
|
}
|
|
207
228
|
}
|
|
208
229
|
}
|
|
209
|
-
return result;
|
|
230
|
+
return track(result);
|
|
210
231
|
}
|
package/src/preload.ts
CHANGED
|
@@ -36,7 +36,7 @@ const FORMATS: Record<string, { cls?: string; scan?: boolean; native?: boolean }
|
|
|
36
36
|
jpeg: { cls: 'CompressedTexture2D' },
|
|
37
37
|
png: { cls: 'CompressedTexture2D' },
|
|
38
38
|
webp: { cls: 'CompressedTexture2D' },
|
|
39
|
-
svg: { cls: '
|
|
39
|
+
svg: { cls: 'CompressedTexture2D' },
|
|
40
40
|
exr: { cls: 'TextureLayered' },
|
|
41
41
|
hdr: { cls: 'TextureLayered' },
|
|
42
42
|
wav: { cls: 'AudioStreamWAV' },
|
|
@@ -44,6 +44,8 @@ const FORMATS: Record<string, { cls?: string; scan?: boolean; native?: boolean }
|
|
|
44
44
|
mp3: { cls: 'AudioStreamMP3' },
|
|
45
45
|
tres: { cls: 'Resource', scan: true, native: true },
|
|
46
46
|
po: { cls: 'Translation', native: true },
|
|
47
|
+
ttf: { cls: 'FontFile' },
|
|
48
|
+
otf: { cls: 'FontFile' },
|
|
47
49
|
mtl: { scan: true },
|
|
48
50
|
};
|
|
49
51
|
|
|
@@ -175,6 +177,22 @@ const assetPlugin: BunPlugin = {
|
|
|
175
177
|
name: 'godot-assets',
|
|
176
178
|
setup(build) {
|
|
177
179
|
build.onResolve({ filter: ASSET_RE }, (args) => {
|
|
180
|
+
// Only JS module imports of Godot source assets become `godot`
|
|
181
|
+
// modules. `Bun.build` reports JS imports as `import-statement`
|
|
182
|
+
// (and `dynamic-import`/`require-*`) but HTML asset references
|
|
183
|
+
// (<link>/<img>) and CSS url() refs as `internal`/`url-token` —
|
|
184
|
+
// those fall through so Bun copies/hashes the file normally. The
|
|
185
|
+
// runtime preload (bun run / bun test) reports JS imports with no
|
|
186
|
+
// kind (`undefined`), so treat that as a JS import too.
|
|
187
|
+
if (
|
|
188
|
+
args.kind !== undefined &&
|
|
189
|
+
args.kind !== 'import-statement' &&
|
|
190
|
+
args.kind !== 'dynamic-import' &&
|
|
191
|
+
args.kind !== 'require-call' &&
|
|
192
|
+
args.kind !== 'require-resolve'
|
|
193
|
+
) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
178
196
|
const importerAbs = args.importer?.startsWith(`${NS}:`)
|
|
179
197
|
? args.importer.slice(NS.length + 1)
|
|
180
198
|
: args.importer;
|
package/src/runtime.ts
CHANGED
|
@@ -3,11 +3,21 @@
|
|
|
3
3
|
***********************************************************************/
|
|
4
4
|
|
|
5
5
|
/** Shared runtime: platform-specific native-module selection lives in
|
|
6
|
-
* `boot.ts` (desktop) / `boot.browser.ts` (web, via the
|
|
7
|
-
* `browser`
|
|
8
|
-
* `module.exports` or the wasm module's `.default`.
|
|
6
|
+
* `boot.ts` (desktop) / `boot.browser.ts` (web, via the `exports` `./boot`
|
|
7
|
+
* entry's `browser` condition). `_mod` is the native module — the desktop
|
|
8
|
+
* addon's `module.exports` or the wasm module's `.default`.
|
|
9
|
+
*
|
|
10
|
+
* The boot leaf is imported by **package self-reference** (`@ringozz/godot/boot`),
|
|
11
|
+
* not by relative path: a bare specifier resolves through this package's own
|
|
12
|
+
* `exports` map (whose `./boot` entry's `browser` condition selects
|
|
13
|
+
* `boot.browser.ts` on web) exactly like the entrypoint's
|
|
14
|
+
* `import { preloadGodot } from '@ringozz/godot/boot'`, so both sides land on the
|
|
15
|
+
* same absolute path and share ONE `boot.browser.ts` module instance. (A relative
|
|
16
|
+
* `./boot.ts` import would let a consumer's bundler realpath it into
|
|
17
|
+
* `node_modules/.bun/...` while the entrypoint keeps the `node_modules/@ringozz/godot`
|
|
18
|
+
* path — two instances, and the preload handoff below would read `null`.)
|
|
9
19
|
*/
|
|
10
|
-
import { getNativeModule } from '
|
|
20
|
+
import { getNativeModule } from '@ringozz/godot/boot';
|
|
11
21
|
const { default: _mod } = getNativeModule() as any;
|
|
12
22
|
|
|
13
23
|
/** Godot instance */
|
|
@@ -69,3 +79,6 @@ export function _P(signal: any, onfulfilled?: any, onrejected?: any) {
|
|
|
69
79
|
if (res !== 0) reject(new Error('Failed to connect signal: error code ' + res));
|
|
70
80
|
}).then(onfulfilled, onrejected);
|
|
71
81
|
}
|
|
82
|
+
|
|
83
|
+
/** Cleanup callbacks run once by `runGodot` just before the engine is freed; may be async. */
|
|
84
|
+
export const cleanupHooks: Set<() => void | Promise<void>> = new Set();
|