@ringozz/react-godot 1.0.0-2 → 1.0.0-4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,94 +1,280 @@
1
- # @ringozz/react-godot
2
-
3
- React bindings for the Godot Engine, built on [`@ringozz/godot`](../godot/README.md). A custom `react-reconciler` host config lets you build Godot scenes declaratively with JSX.
4
-
5
- ## Installation
6
-
7
- ```sh
8
- bun add @ringozz/react-godot @ringozz/godot react
9
- ```
10
-
11
- `@ringozz/godot` and `react` (`^19.2`) are peer dependencies. Configure the JSX runtime in `tsconfig.json`:
12
-
13
- ```json
14
- {
15
- "compilerOptions": {
16
- "jsx": "react-jsx",
17
- "jsxImportSource": "@ringozz/react-godot"
18
- }
19
- }
20
- ```
21
-
22
- ## API
23
-
24
- ```ts
25
- import { createRoot, createPortal, useSignal, useMutableCallback, type ComponentProps } from '@ringozz/react-godot';
26
- ```
27
-
28
- - **`createRoot(parent: Node)`** mounts a React tree into a Godot `Node` (e.g. `SceneTree.root`). Returns `{ render, unmount }`; `render` resolves when the commit completes. StrictMode is enabled only when `NODE_ENV === 'development'`.
29
- - **`createPortal(children, container)`** — renders into another Godot `Node` (a `CanvasLayer` for overlays, a `SubViewport`, a dedicated parent node). The container must be a `Node` — portals cannot target `Resource`s. Unmounting frees the portal's children.
30
- - **`useSignal<T extends (...args: any[]) => any>(signal, handler)`** — connects a Godot `Signal` for the component's lifetime, disconnecting on unmount. Uses a mutable callback ref, so the handler always sees fresh state.
31
- - **`useMutableCallback<T>(fn)`** — returns a `RefObject<T>` whose `.current` always holds the latest `fn`.
32
- - **`ComponentProps<typeof ClassName>`** — full prop validation for an element (Godot properties + `children`/`ref` + `object`/`attach`).
33
-
34
- ## Example
35
-
36
- ```tsx
37
- import { Engine } from '@ringozz/godot/Engine';
38
- import { SceneTree } from '@ringozz/godot/SceneTree';
39
- import { Label } from '@ringozz/godot/Label';
40
- import { createRoot, useSignal } from '@ringozz/react-godot';
41
- import { useRef } from 'react';
42
-
43
- const tree = Engine.getMainLoop() as SceneTree;
44
- const { render } = createRoot(tree.root);
45
-
46
- function Hud() {
47
- const label = useRef<Label>(null);
48
- useSignal(tree.processFrame, () => {
49
- if (label.current) label.current.text = String(Math.floor(performance.now() / 1000));
50
- });
51
- return <Label ref={label} position={[16, 16]} text="ticking" />;
52
- }
53
-
54
- render(<Hud />);
55
- ```
56
-
57
- ### Render a component
58
-
59
- ```tsx
60
- function App() {
61
- return (
62
- <WorldEnvironment>
63
- <Environment attach="environment" backgroundColor={[0.05, 0.1, 0.25]} />
64
- </WorldEnvironment>
65
- );
66
- }
67
- ```
68
-
69
- ## How elements map to Godot
70
-
71
- - Each element instantiates its class via `ClassDB.instantiate(type)` (or uses an existing instance passed through the `object` prop, which bypasses instantiation and `.reference()`s `RefCounted` objects).
72
- - **`attach="prop"`** sets the element as a named property of its parent instead of adding it as a child — use it for resources. A physics body's shape goes on the **shape resource element**, never on the `CollisionShape3D` node:
73
-
74
- ```tsx
75
- <StaticBody3D>
76
- <CollisionShape3D>
77
- <BoxShape3D attach="shape" size={[1, 1, 1]} />
78
- </CollisionShape3D>
79
- <MeshInstance3D>
80
- <BoxMesh attach="mesh" />
81
- <StandardMaterial3D attach="materialOverride" />
82
- </MeshInstance3D>
83
- </StaticBody3D>
84
- ```
85
-
86
- The `attach` value may not change across updates.
87
- - **`object` prop**: pass an existing Godot instance (nodes or resources) to render rather than create one.
88
- - **Text**: standalone text nodes are unsupported. A single string child is mapped to the node's `text` property — fine for `Label`/`Label3D` — but arrays/mixed string children throw; use a `text` prop instead.
89
-
90
- ## Notes
91
-
92
- - `Node` children are added/moved with `addChild`/`moveChild`/`removeChild`; unmounting a subtree calls `free()` on each deleted instance.
93
- - Portals and other non-React children of a root container are never touched by the reconciler.
94
- - See the demo in the repo (`dev/DemoApp.tsx`) for physics, camera controls, and a portal HUD, and `AGENTS.md` for reconciler internals.
1
+ # `@ringozz/react-godot`
2
+
3
+ Declarative, component-driven **Godot 4** scene graph management in React. Built for high-performance applications using `@ringozz/godot`.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ `@ringozz/react-godot` brings a **React Three Fiber (R3F)**-like developer experience to Godot. It provides a custom React reconciler that renders React components directly into Godot's live engine scene tree (`SceneTree`).
10
+
11
+ If you are familiar with React Three Fiber, the core paradigms will feel instantly recognizable:
12
+ - **Declarative Nodes**: Godot 3D/2D nodes (e.g., `<RigidBody3D>`, `<Camera3D>`, `<DirectionalLight3D>`) map directly to JSX components.
13
+ - **Resource Attachment**: Use the `attach` prop to assign nested Godot resources (materials, shapes, meshes, environments) directly to parent properties.
14
+ - **Reactive Engine Loop**: Use hooks like `useSignal` to hook into Godot signals (`processFrame`, `windowInput`) without triggering React state re-renders.
15
+
16
+ ---
17
+
18
+ ## Installation & Setup
19
+
20
+ ### 1. Dependencies
21
+
22
+ ```bash
23
+ npm install @ringozz/react-godot react
24
+ ```
25
+
26
+ ### 2. TypeScript Configuration (`tsconfig.json`)
27
+
28
+ To enable custom JSX element resolution for Godot nodes, configure `jsxImportSource` and module settings in `tsconfig.json`:
29
+
30
+ ```json
31
+ {
32
+ "compilerOptions": {
33
+ "module": "esnext",
34
+ "target": "es2025",
35
+ "jsx": "react-jsx",
36
+ "jsxImportSource": "@ringozz/react-godot",
37
+ "rewriteRelativeImportExtensions": true,
38
+ "verbatimModuleSyntax": true
39
+ }
40
+ }
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Application Entry & Portal Rendering
46
+
47
+ ### 1. Mounting with `createRoot(parent: Node)`
48
+ `createRoot` is the primary entry point for mounting a React component tree into a Godot target `Node` (typically `SceneTree.root`).
49
+
50
+ ```tsx
51
+ import { Engine } from '@ringozz/godot/Engine';
52
+ import { SceneTree } from '@ringozz/godot/SceneTree';
53
+ import { createRoot } from '@ringozz/react-godot';
54
+ import { App } from './App';
55
+
56
+ const tree = Engine.getMainLoop() as SceneTree;
57
+ const { render } = createRoot(tree.root);
58
+
59
+ render(<App />);
60
+ ```
61
+
62
+ - Returns `{ render, unmount }`.
63
+ - Enables React `StrictMode` automatically in development (`NODE_ENV === 'development'`).
64
+
65
+ ### 2. Render Portals with `createPortal(children, container)`
66
+ `createPortal` allows rendering a React sub-tree into a different Godot `Node` in the hierarchy—such as a `CanvasLayer` for 2D UI overlays, a `SubViewport`, or an isolated container node.
67
+
68
+ ```tsx
69
+ import { createPortal } from '@ringozz/react-godot';
70
+ import { Label } from '@ringozz/godot/Label';
71
+
72
+ function Overlay({ overlayNode }: { overlayNode: Node }) {
73
+ return createPortal(
74
+ <Label text="HUD Overlay" position={[20, 20]} />,
75
+ overlayNode
76
+ );
77
+ }
78
+ ```
79
+
80
+ - **Container Constraint**: The target `container` must be a Godot `Node` (it cannot target `Resource` instances).
81
+ - **Cleanup**: Unmounting the portal safely disposes of and frees its child Godot nodes.
82
+
83
+ ---
84
+
85
+ ## Core Concepts & Patterns
86
+
87
+ ### 1. Existing Objects with `object` Prop
88
+
89
+ If you already have a pre-existing Godot `Node` or `Resource` instance (e.g. instantiated from C++ or loaded from disk), pass it via the `object` prop to adopt it into the React component tree instead of letting the reconciler instantiate a new object:
90
+
91
+ ```tsx
92
+ <Node object={myExistingGodotNode}>
93
+ {/* Children attached to existing node */}
94
+ </Node>
95
+ ```
96
+
97
+ ### 2. Resource Nesting with `attach`
98
+
99
+ In Godot, nodes often hold references to `Resource` objects (such as shapes, meshes, materials, or environments). Use the `attach` prop to automatically assign a child resource to a specific property on its parent:
100
+
101
+ ```tsx
102
+ /* Attaching a shape to CollisionShape3D */
103
+ <CollisionShape3D>
104
+ <BoxShape3D attach="shape" size={[1, 1, 1]} />
105
+ </CollisionShape3D>
106
+
107
+ /* Attaching a material override to MeshInstance3D */
108
+ <MeshInstance3D>
109
+ <BoxMesh attach="mesh" />
110
+ <StandardMaterial3D attach="materialOverride" albedoColor={[1, 0, 0]} />
111
+ </MeshInstance3D>
112
+
113
+ /* Attaching physics material to RigidBody3D */
114
+ <RigidBody3D>
115
+ <PhysicsMaterial attach="physicsMaterialOverride" bounce={0.8} />
116
+ </RigidBody3D>
117
+
118
+ /* Attaching environment to WorldEnvironment */
119
+ <WorldEnvironment>
120
+ <Environment attach="environment" backgroundColor={[0.1, 0.1, 0.2]} />
121
+ </WorldEnvironment>
122
+ ```
123
+
124
+ ### 2. Properties & Helper Types
125
+
126
+ Props map directly to Godot node setters and properties. Helper arrays are automatically cast to Godot types:
127
+
128
+ - **Vectors**: `position={[0, 1, 0]}`, `scale={[2, 2, 2]}`
129
+ - **Colors**: `albedoColor={[1, 0.2, 0.2]}` or `Color` objects
130
+ - **Sub-properties / Dash props**: Set individual vector/color components directly using dash syntax:
131
+ ```tsx
132
+ <BoxMesh attach="mesh" size-x={0.5} size-y={1.0} size-z={0.5} />
133
+ ```
134
+
135
+ ### 3. Engine Signals & Event Subscription (`useSignal`)
136
+
137
+ Subscribe to Godot engine signals cleanly with `useSignal`.
138
+
139
+ ```tsx
140
+ import { Engine } from '@ringozz/godot';
141
+ import { SceneTree } from '@ringozz/godot/SceneTree';
142
+ import { useSignal } from '@ringozz/react-godot';
143
+
144
+ const tree = Engine.getMainLoop() as SceneTree;
145
+ const root = tree.root;
146
+
147
+ // Subscribe to frame updates (process loop)
148
+ useSignal(tree.processFrame, () => {
149
+ // Update animations, camera matrix, physics targets, etc.
150
+ });
151
+
152
+ // Subscribe to window input events
153
+ useSignal(root.windowInput, (event) => {
154
+ if (event instanceof InputEventMouseMotion) {
155
+ // Handle mouse motion
156
+ }
157
+ // IMPORTANT: Always free transient input event objects
158
+ event.free();
159
+ });
160
+ ```
161
+
162
+ > **Memory Tip**: Godot input events should be explicitly freed by calling `event.free()` inside your signal handler.
163
+
164
+ ---
165
+
166
+ ## Full Example
167
+
168
+ Below is a complete interactive 3D physics scene with custom camera controls and physics bodies:
169
+
170
+ ```tsx
171
+ import { Color, Key, MouseButton, Vector3, Engine } from '@ringozz/godot';
172
+ import { BoxMesh } from '@ringozz/godot/BoxMesh';
173
+ import { BoxShape3D } from '@ringozz/godot/BoxShape3D';
174
+ import { Camera3D } from '@ringozz/godot/Camera3D';
175
+ import { CollisionShape3D } from '@ringozz/godot/CollisionShape3D';
176
+ import { DirectionalLight3D } from '@ringozz/godot/DirectionalLight3D';
177
+ import { BGMode, Environment, ToneMapper } from '@ringozz/godot/Environment';
178
+ import { InputEventMouseButton } from '@ringozz/godot/InputEventMouseButton';
179
+ import { InputEventMouseMotion } from '@ringozz/godot/InputEventMouseMotion';
180
+ import { MeshInstance3D } from '@ringozz/godot/MeshInstance3D';
181
+ import { PhysicsMaterial } from '@ringozz/godot/PhysicsMaterial';
182
+ import { RigidBody3D } from '@ringozz/godot/RigidBody3D';
183
+ import { SceneTree } from '@ringozz/godot/SceneTree';
184
+ import { SphereMesh } from '@ringozz/godot/SphereMesh';
185
+ import { SphereShape3D } from '@ringozz/godot/SphereShape3D';
186
+ import { StandardMaterial3D } from '@ringozz/godot/StandardMaterial3D';
187
+ import { StaticBody3D } from '@ringozz/godot/StaticBody3D';
188
+ import { WorldEnvironment } from '@ringozz/godot/WorldEnvironment';
189
+ import { useSignal, type ComponentProps } from '@ringozz/react-godot';
190
+ import { useRef, type ReactElement } from 'react';
191
+
192
+ const tree = Engine.getMainLoop() as SceneTree;
193
+ const root = tree.root;
194
+
195
+ function PhysBody({ color, shape, children, ...rest }: {
196
+ color: Color | number[];
197
+ shape: ReactElement;
198
+ } & ComponentProps<typeof RigidBody3D>) {
199
+ return (
200
+ <RigidBody3D {...rest}>
201
+ <MeshInstance3D>
202
+ {children}
203
+ <StandardMaterial3D attach="materialOverride" albedoColor={color} />
204
+ </MeshInstance3D>
205
+ <CollisionShape3D>{shape}</CollisionShape3D>
206
+ </RigidBody3D>
207
+ );
208
+ }
209
+
210
+ export function App() {
211
+ const cameraRef = useRef<Camera3D>(null);
212
+ const isDragging = useRef(false);
213
+ const yaw = useRef(0.7);
214
+ const pitch = useRef(0.3);
215
+ const zoom = useRef(7);
216
+
217
+ useSignal(root.windowInput, (event) => {
218
+ if (event instanceof InputEventMouseMotion && isDragging.current) {
219
+ yaw.current -= event.relative.x * 0.005;
220
+ pitch.current = Math.max(-1.4, Math.min(1.4, pitch.current + event.relative.y * 0.005));
221
+ } else if (event instanceof InputEventMouseButton) {
222
+ if (event.buttonIndex === MouseButton.MOUSE_BUTTON_LEFT) {
223
+ isDragging.current = event.pressed;
224
+ }
225
+ }
226
+ event.free();
227
+ });
228
+
229
+ useSignal(tree.processFrame, () => {
230
+ const camera = cameraRef.current;
231
+ if (!camera) return;
232
+
233
+ const cx = zoom.current * Math.cos(pitch.current) * Math.sin(yaw.current);
234
+ const cy = zoom.current * Math.sin(pitch.current) + 1;
235
+ const cz = zoom.current * Math.cos(pitch.current) * Math.cos(yaw.current);
236
+ camera.position = new Vector3(cx, cy, cz);
237
+ camera.lookAt(new Vector3(0, 1, 0), new Vector3(0, 1, 0));
238
+ });
239
+
240
+ return (
241
+ <>
242
+ <WorldEnvironment>
243
+ <Environment attach="environment" tonemapMode={ToneMapper.TONE_MAPPER_AGX} backgroundMode={BGMode.BG_COLOR} backgroundColor={[0.05, 0.1, 0.25]} />
244
+ </WorldEnvironment>
245
+ <DirectionalLight3D name="Sun" rotation={[-0.8, 0.5, 0]} />
246
+ <Camera3D name="Camera" ref={cameraRef} />
247
+
248
+ {/* Physics Sphere */}
249
+ <PhysBody name="BouncySphere" position={[0, 4, 0]} color={[0.2, 1, 0.2]}
250
+ shape={<SphereShape3D attach="shape" radius={0.5} />}>
251
+ <SphereMesh attach="mesh" radius={0.5} height={1} />
252
+ <PhysicsMaterial attach="physicsMaterialOverride" bounce={0.8} />
253
+ </PhysBody>
254
+
255
+ {/* Ground Plane */}
256
+ <StaticBody3D name="Ground">
257
+ <CollisionShape3D>
258
+ <BoxShape3D attach="shape" size={[10, 0.1, 10]} />
259
+ </CollisionShape3D>
260
+ <MeshInstance3D>
261
+ <BoxMesh attach="mesh" size-x={10} size-y={0.1} size-z={10} />
262
+ <StandardMaterial3D attach="materialOverride" albedoColor={[0.2, 0.2, 0.25]} />
263
+ </MeshInstance3D>
264
+ </StaticBody3D>
265
+ </>
266
+ );
267
+ }
268
+ ```
269
+
270
+ ---
271
+
272
+ ## API Summary
273
+
274
+ | Export | Type | Description |
275
+ | :--- | :--- | :--- |
276
+ | `createRoot(parent)` | Function | Mounts a React component tree into a target Godot `Node` (e.g. `tree.root`). Returns `{ render, unmount }`. |
277
+ | `createPortal(children, container)` | Function | Renders children into a target Godot `Node` (e.g. `CanvasLayer` or `SubViewport`). |
278
+ | `useSignal(signal, callback)` | Hook | Connects a callback to a Godot object signal (e.g. `tree.processFrame`, `node.treeEntered`). Disconnects on unmount. |
279
+ | `useMutableCallback(fn)` | Hook | Returns a `RefObject` whose `.current` always points to the latest callback implementation. |
280
+ | `ComponentProps<T>` | Type | Utility type to infer valid React JSX props for any Godot node class `T`. |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ringozz/react-godot",
3
3
  "author": "Vladimir Davidovich",
4
- "version": "1.0.0-2",
4
+ "version": "1.0.0-4",
5
5
  "description": "A React renderer for Godot Engine via @ringozz/godot",
6
6
  "type": "module",
7
7
  "main": "./src/index.ts",
@@ -16,11 +16,11 @@
16
16
  "*.md"
17
17
  ],
18
18
  "dependencies": {
19
+ "@ringozz/godot": "^4.7.1-7",
19
20
  "@types/react-reconciler": "^0.33.0",
20
21
  "react-reconciler": "^0.33.0"
21
22
  },
22
23
  "peerDependencies": {
23
- "@ringozz/godot": "^4.7.1-3",
24
24
  "@types/react": "^19.2.0",
25
25
  "react": "^19.2.0"
26
26
  }
package/src/index.ts CHANGED
@@ -1,58 +1,58 @@
1
- /**********************************************************************
2
- Copyright (c) Vladimir Davidovich. All rights reserved.
3
- ***********************************************************************/
4
-
5
- import Reconciler from 'react-reconciler';
6
- import Constants from 'react-reconciler/constants.js';
7
- import type { Node } from '@ringozz/godot/Node';
8
- import * as FiberConfig from './react-fiber.ts';
9
- export * from './react-hooks.ts';
10
- export type * from './react-types.ts';
11
-
12
- const reconciler = Reconciler(FiberConfig);
13
- reconciler.injectIntoDevTools(undefined as never);
14
-
15
- export function createRoot(parent: Node) {
16
- const isStrictMode = process.env.NODE_ENV === 'development';
17
- const concurrentUpdatesByDefaultOverride = false;
18
- const identifierPrefix = '';
19
- /* eslint-disable @typescript-eslint/no-explicit-any */
20
- const onUncaughtError = (reconciler as any).defaultOnUncaughtError;
21
- const onCaughtError = (reconciler as any).defaultOnCaughtError;
22
- const onRecoverableError = (reconciler as any).defaultOnRecoverableError;
23
- const onDefaultTransitionIndicator = () => { };
24
- const root = reconciler.createContainer(
25
- parent,
26
- Constants.ConcurrentRoot,
27
- null,
28
- isStrictMode,
29
- concurrentUpdatesByDefaultOverride,
30
- identifierPrefix,
31
- onUncaughtError,
32
- onCaughtError,
33
- onRecoverableError,
34
- onDefaultTransitionIndicator
35
- );
36
- return {
37
- render: (component: React.ReactNode) => new Promise<void>((resolve, reject) => {
38
- try {
39
- reconciler.updateContainer(component, root, null, resolve);
40
- } catch (e) {
41
- reject(e);
42
- }
43
- }),
44
- unmount: () => {
45
- try {
46
- reconciler.updateContainerSync(null, root, null);
47
- reconciler.flushSyncWork();
48
- return Promise.resolve();
49
- } catch (e) {
50
- return Promise.reject(e);
51
- }
52
- }
53
- };
54
- }
55
-
56
- export function createPortal(children: React.ReactNode, container: Node, key?: string | null): React.ReactPortal {
57
- return reconciler.createPortal(children, container, null, key) as unknown as React.ReactPortal;
58
- }
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ import Reconciler from 'react-reconciler';
6
+ import Constants from 'react-reconciler/constants.js';
7
+ import type { Node } from '@ringozz/godot/Node';
8
+ import * as FiberConfig from './react-fiber.ts';
9
+ export * from './react-hooks.ts';
10
+ export type * from './react-types.ts';
11
+
12
+ const reconciler = Reconciler(FiberConfig);
13
+ reconciler.injectIntoDevTools(undefined as never);
14
+
15
+ export function createRoot(parent: Node) {
16
+ const isStrictMode = !!import.meta.env?.DEV;
17
+ const concurrentUpdatesByDefaultOverride = false;
18
+ const identifierPrefix = '';
19
+ /* eslint-disable @typescript-eslint/no-explicit-any */
20
+ const onUncaughtError = (reconciler as any).defaultOnUncaughtError;
21
+ const onCaughtError = (reconciler as any).defaultOnCaughtError;
22
+ const onRecoverableError = (reconciler as any).defaultOnRecoverableError;
23
+ const onDefaultTransitionIndicator = () => { };
24
+ const root = reconciler.createContainer(
25
+ parent,
26
+ Constants.ConcurrentRoot,
27
+ null,
28
+ isStrictMode,
29
+ concurrentUpdatesByDefaultOverride,
30
+ identifierPrefix,
31
+ onUncaughtError,
32
+ onCaughtError,
33
+ onRecoverableError,
34
+ onDefaultTransitionIndicator
35
+ );
36
+ return {
37
+ render: (component: React.ReactNode) => new Promise<void>((resolve, reject) => {
38
+ try {
39
+ reconciler.updateContainer(component, root, null, resolve);
40
+ } catch (e) {
41
+ reject(e);
42
+ }
43
+ }),
44
+ unmount: () => {
45
+ try {
46
+ reconciler.updateContainerSync(null, root, null);
47
+ reconciler.flushSyncWork();
48
+ return Promise.resolve();
49
+ } catch (e) {
50
+ return Promise.reject(e);
51
+ }
52
+ }
53
+ };
54
+ }
55
+
56
+ export function createPortal(children: React.ReactNode, container: Node, key?: string | null): React.ReactPortal {
57
+ return reconciler.createPortal(children, container, null, key) as unknown as React.ReactPortal;
58
+ }