react-three-game 0.0.47 → 0.0.48
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/.github/copilot-instructions.md +1 -1
- package/dist/tools/prefabeditor/components/AmbientLightComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/AmbientLightComponent.js +23 -0
- package/dist/tools/prefabeditor/components/GeometryComponent.js +7 -0
- package/dist/tools/prefabeditor/components/Input.js +23 -1
- package/dist/tools/prefabeditor/components/index.js +2 -0
- package/package.json +1 -1
- package/react-three-game-skill/README.md +1 -0
- package/react-three-game-skill/react-three-game/SKILL.md +104 -319
- package/src/tools/prefabeditor/components/AmbientLightComponent.tsx +40 -0
- package/src/tools/prefabeditor/components/GeometryComponent.tsx +7 -0
- package/src/tools/prefabeditor/components/Input.tsx +32 -3
- package/src/tools/prefabeditor/components/index.ts +2 -0
|
@@ -61,7 +61,7 @@ Use node materials only: `MeshStandardNodeMaterial`, `MeshBasicNodeMaterial` (no
|
|
|
61
61
|
Set `model.properties.instanced = true` → uses `InstanceProvider.tsx` for batched rendering with physics.
|
|
62
62
|
|
|
63
63
|
## Built-in Components
|
|
64
|
-
`Transform`, `Geometry` (box/sphere/plane), `Material` (color/texture), `Physics` (dynamic/fixed), `Model` (GLB/FBX), `SpotLight`, `DirectionalLight`
|
|
64
|
+
`Transform`, `Geometry` (box/sphere/plane/cylinder), `Material` (color/texture), `Physics` (dynamic/fixed), `Model` (GLB/FBX), `SpotLight`, `DirectionalLight`, `AmbientLight`, `Text`
|
|
65
65
|
|
|
66
66
|
## Custom Components (User-space)
|
|
67
67
|
See `docs/app/demo/editor/RotatorComponent.tsx` for runtime behavior example using `useFrame`. Register with `registerComponent()` before rendering `<PrefabEditor>`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { FieldRenderer } from "./Input";
|
|
3
|
+
const ambientLightFields = [
|
|
4
|
+
{ name: 'color', type: 'color', label: 'Color' },
|
|
5
|
+
{ name: 'intensity', type: 'number', label: 'Intensity', step: 0.1, min: 0 },
|
|
6
|
+
];
|
|
7
|
+
function AmbientLightComponentEditor({ component, onUpdate, }) {
|
|
8
|
+
return (_jsx(FieldRenderer, { fields: ambientLightFields, values: component.properties, onChange: onUpdate }));
|
|
9
|
+
}
|
|
10
|
+
function AmbientLightComponentView({ properties }) {
|
|
11
|
+
const { color = '#ffffff', intensity = 1 } = properties;
|
|
12
|
+
return _jsx("ambientLight", { color: color, intensity: intensity });
|
|
13
|
+
}
|
|
14
|
+
const AmbientLightComponent = {
|
|
15
|
+
name: 'AmbientLight',
|
|
16
|
+
Editor: AmbientLightComponentEditor,
|
|
17
|
+
View: AmbientLightComponentView,
|
|
18
|
+
defaultProperties: {
|
|
19
|
+
color: '#ffffff',
|
|
20
|
+
intensity: 1,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
export default AmbientLightComponent;
|
|
@@ -13,6 +13,10 @@ const GEOMETRY_ARGS = {
|
|
|
13
13
|
labels: ["Width", "Height"],
|
|
14
14
|
defaults: [1, 1],
|
|
15
15
|
},
|
|
16
|
+
cylinder: {
|
|
17
|
+
labels: ["Radius Top", "Radius Bottom", "Height", "Radial Segments"],
|
|
18
|
+
defaults: [1, 1, 1, 32],
|
|
19
|
+
},
|
|
16
20
|
};
|
|
17
21
|
function GeometryComponentEditor({ component, onUpdate, }) {
|
|
18
22
|
const { geometryType, args = [] } = component.properties;
|
|
@@ -26,6 +30,7 @@ function GeometryComponentEditor({ component, onUpdate, }) {
|
|
|
26
30
|
{ value: 'box', label: 'Box' },
|
|
27
31
|
{ value: 'sphere', label: 'Sphere' },
|
|
28
32
|
{ value: 'plane', label: 'Plane' },
|
|
33
|
+
{ value: 'cylinder', label: 'Cylinder' },
|
|
29
34
|
],
|
|
30
35
|
},
|
|
31
36
|
{
|
|
@@ -69,6 +74,8 @@ function GeometryComponentView({ properties, children }) {
|
|
|
69
74
|
return _jsx("sphereGeometry", { args: args });
|
|
70
75
|
case "plane":
|
|
71
76
|
return _jsx("planeGeometry", { args: args });
|
|
77
|
+
case "cylinder":
|
|
78
|
+
return _jsx("cylinderGeometry", { args: args });
|
|
72
79
|
default:
|
|
73
80
|
return _jsx("boxGeometry", { args: [1, 1, 1] });
|
|
74
81
|
}
|
|
@@ -25,7 +25,29 @@ const styles = {
|
|
|
25
25
|
},
|
|
26
26
|
};
|
|
27
27
|
export function Input({ value, onChange, step, min, max, style }) {
|
|
28
|
-
|
|
28
|
+
const [draft, setDraft] = useState(() => value.toString());
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setDraft(value.toString());
|
|
31
|
+
}, [value]);
|
|
32
|
+
const handleChange = (e) => {
|
|
33
|
+
const inputValue = e.target.value;
|
|
34
|
+
setDraft(inputValue);
|
|
35
|
+
const num = parseFloat(inputValue);
|
|
36
|
+
if (Number.isFinite(num)) {
|
|
37
|
+
onChange(num);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const handleBlur = () => {
|
|
41
|
+
const num = parseFloat(draft);
|
|
42
|
+
if (!Number.isFinite(num)) {
|
|
43
|
+
setDraft(value.toString());
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
return (_jsx("input", { type: "text", value: draft, onChange: handleChange, onBlur: handleBlur, onKeyDown: e => {
|
|
47
|
+
if (e.key === 'Enter') {
|
|
48
|
+
e.target.blur();
|
|
49
|
+
}
|
|
50
|
+
}, step: step, min: min, max: max, style: Object.assign(Object.assign({}, styles.input), style) }));
|
|
29
51
|
}
|
|
30
52
|
export function Label({ children }) {
|
|
31
53
|
return _jsx("label", { style: styles.label, children: children });
|
|
@@ -4,6 +4,7 @@ import MaterialComponent from './MaterialComponent';
|
|
|
4
4
|
import PhysicsComponent from './PhysicsComponent';
|
|
5
5
|
import SpotLightComponent from './SpotLightComponent';
|
|
6
6
|
import DirectionalLightComponent from './DirectionalLightComponent';
|
|
7
|
+
import AmbientLightComponent from './AmbientLightComponent';
|
|
7
8
|
import ModelComponent from './ModelComponent';
|
|
8
9
|
import TextComponent from './TextComponent';
|
|
9
10
|
export default [
|
|
@@ -13,6 +14,7 @@ export default [
|
|
|
13
14
|
PhysicsComponent,
|
|
14
15
|
SpotLightComponent,
|
|
15
16
|
DirectionalLightComponent,
|
|
17
|
+
AmbientLightComponent,
|
|
16
18
|
ModelComponent,
|
|
17
19
|
TextComponent
|
|
18
20
|
];
|
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Agent skill for [react-three-game](https://github.com/prnthh/react-three-game)
|
|
@@ -61,6 +61,19 @@ function AgentExporter() {
|
|
|
61
61
|
|
|
62
62
|
## Core Concepts
|
|
63
63
|
|
|
64
|
+
### Asset Paths and Public Directory
|
|
65
|
+
|
|
66
|
+
**All asset paths are relative to `/public`** and omit the `/public` prefix:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"texture": "/textures/floor.png",
|
|
71
|
+
"model": "/models/car.glb",
|
|
72
|
+
"font": "/fonts/font.ttf"
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Path `"/any/path/file.ext"` refers to `/public/any/path/file.ext`.
|
|
64
77
|
### GameObject Structure
|
|
65
78
|
|
|
66
79
|
Every game object follows this schema:
|
|
@@ -108,8 +121,17 @@ Scenes are defined as JSON prefabs with a root node containing children:
|
|
|
108
121
|
| Model | `Model` | `filename` (GLB/FBX path), `instanced?` for GPU batching |
|
|
109
122
|
| SpotLight | `SpotLight` | `color`, `intensity`, `angle`, `penumbra`, `distance?`, `castShadow?` |
|
|
110
123
|
| DirectionalLight | `DirectionalLight` | `color`, `intensity`, `castShadow?`, `targetOffset?: [x,y,z]` |
|
|
124
|
+
| AmbientLight | `AmbientLight` | `color`, `intensity` |
|
|
111
125
|
| Text | `Text` | `text`, `font`, `size`, `depth`, `width`, `align`, `color` |
|
|
112
126
|
|
|
127
|
+
### Text Component
|
|
128
|
+
|
|
129
|
+
Requires `hb.wasm` and a font file (TTF/WOFF) in `/public/fonts/`:
|
|
130
|
+
- hb.wasm: https://github.com/prnthh/react-three-game/raw/refs/heads/main/docs/public/fonts/hb.wasm
|
|
131
|
+
- Sample font: https://github.com/prnthh/react-three-game/raw/refs/heads/main/docs/public/fonts/NotoSans-Regular.ttf
|
|
132
|
+
|
|
133
|
+
Font property: `"font": "/fonts/NotoSans-Regular.ttf"`
|
|
134
|
+
|
|
113
135
|
### Geometry Args by Type
|
|
114
136
|
|
|
115
137
|
| geometryType | args array |
|
|
@@ -119,41 +141,31 @@ Scenes are defined as JSON prefabs with a root node containing children:
|
|
|
119
141
|
| `plane` | `[width, height]` |
|
|
120
142
|
| `cylinder` | `[radiusTop, radiusBottom, height, radialSegments]` |
|
|
121
143
|
|
|
122
|
-
### Material
|
|
144
|
+
### Material Textures
|
|
123
145
|
|
|
124
146
|
```json
|
|
125
147
|
{
|
|
126
|
-
"
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
"material": {
|
|
149
|
+
"type": "Material",
|
|
150
|
+
"properties": {
|
|
151
|
+
"color": "white",
|
|
152
|
+
"texture": "/textures/floor.png",
|
|
153
|
+
"repeat": true,
|
|
154
|
+
"repeatCount": [4, 4]
|
|
155
|
+
}
|
|
132
156
|
}
|
|
133
157
|
}
|
|
134
158
|
```
|
|
135
159
|
|
|
136
|
-
|
|
137
|
-
- `repeatCount: [x, y]` tiles the texture; match to geometry dimensions for proper scaling
|
|
138
|
-
|
|
139
|
-
### Rotation Reference
|
|
160
|
+
### Rotations
|
|
140
161
|
|
|
141
|
-
|
|
142
|
-
- `1.57` = 90° (π/2)
|
|
143
|
-
- `3.14` = 180° (π)
|
|
144
|
-
- `-1.57` = -90° (rotate plane flat: `rotation: [-1.57, 0, 0]`)
|
|
162
|
+
Use radians: `1.57` = 90°, `3.14` = 180°, `-1.57` = -90°
|
|
145
163
|
|
|
146
164
|
## Common Patterns
|
|
147
165
|
|
|
148
166
|
### Usage Modes
|
|
149
167
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
**Play Mode** (default) - Immediate rendering without any editor UI. Use `GameCanvas` with `PrefabRoot` for a clean game experience.
|
|
153
|
-
|
|
154
|
-
**Editor Mode** - Visual GUI using `PrefabEditor` for scene inspection and custom component development. See the [Editor Mode](#editor-mode) section at the end of this document.
|
|
155
|
-
|
|
156
|
-
### Basic Scene Setup (Play Mode)
|
|
168
|
+
**GameCanvas + PrefabRoot**: Production gameplay. Requires explicit `<Physics>` wrapper. Physics always active. Can compose with other R3F components. For headless mode, use `<PrefabRoot>` without GameCanvas.
|
|
157
169
|
|
|
158
170
|
```jsx
|
|
159
171
|
import { Physics } from '@react-three/rapier';
|
|
@@ -166,83 +178,56 @@ import { GameCanvas, PrefabRoot } from 'react-three-game';
|
|
|
166
178
|
</GameCanvas>
|
|
167
179
|
```
|
|
168
180
|
|
|
169
|
-
|
|
181
|
+
**PrefabEditor**: Level editors, scene authoring, prototyping. Includes canvas, physics, UI. Physics activates in play mode only.
|
|
182
|
+
|
|
183
|
+
```jsx
|
|
184
|
+
import { PrefabEditor } from 'react-three-game';
|
|
185
|
+
|
|
186
|
+
<PrefabEditor initialPrefab={prefabData} />
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Tree Utilities
|
|
170
190
|
|
|
171
191
|
```typescript
|
|
172
|
-
import {
|
|
192
|
+
import { updateNodeById, findNode, deleteNode, cloneNode, exportGLBData } from 'react-three-game';
|
|
173
193
|
|
|
174
|
-
// Update a node by ID (optimized - avoids unnecessary object creation)
|
|
175
194
|
const updated = updateNodeById(root, nodeId, node => ({ ...node, disabled: true }));
|
|
176
|
-
|
|
177
|
-
// Find a node
|
|
178
195
|
const node = findNode(root, nodeId);
|
|
179
|
-
|
|
180
|
-
// Delete a node
|
|
181
196
|
const afterDelete = deleteNode(root, nodeId);
|
|
182
|
-
|
|
183
|
-
// Clone a node
|
|
184
197
|
const cloned = cloneNode(node);
|
|
185
|
-
|
|
186
|
-
// Save/load JSON
|
|
187
|
-
saveJson(prefab, 'my-scene');
|
|
188
|
-
const loaded = await loadJson();
|
|
189
|
-
|
|
190
|
-
// Export to GLB
|
|
191
|
-
await exportGLB(sceneRoot, { filename: 'my-scene.glb' });
|
|
192
|
-
const glbData = await exportGLBData(sceneRoot); // Returns ArrayBuffer
|
|
198
|
+
const glbData = await exportGLBData(sceneRoot);
|
|
193
199
|
```
|
|
194
200
|
|
|
195
|
-
##
|
|
201
|
+
## Level Patterns
|
|
196
202
|
|
|
197
|
-
###
|
|
203
|
+
### Floor
|
|
198
204
|
|
|
199
205
|
```json
|
|
200
206
|
{
|
|
201
|
-
"id": "
|
|
202
|
-
"name": "Level Name",
|
|
203
|
-
"root": {
|
|
204
|
-
"id": "root",
|
|
205
|
-
"enabled": true,
|
|
206
|
-
"visible": true,
|
|
207
|
-
"components": { ... },
|
|
208
|
-
"children": [ ... ]
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
### Floor/Ground Pattern
|
|
214
|
-
|
|
215
|
-
```json
|
|
216
|
-
{
|
|
217
|
-
"id": "main-floor",
|
|
207
|
+
"id": "floor",
|
|
218
208
|
"components": {
|
|
219
209
|
"transform": { "type": "Transform", "properties": { "position": [0, -0.5, 0] } },
|
|
220
210
|
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [40, 1, 40] } },
|
|
221
|
-
"material": { "type": "Material", "properties": { "
|
|
211
|
+
"material": { "type": "Material", "properties": { "texture": "/textures/floor.png", "repeat": true, "repeatCount": [20, 20] } },
|
|
222
212
|
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
223
213
|
}
|
|
224
214
|
}
|
|
225
215
|
```
|
|
226
216
|
|
|
227
|
-
### Platform
|
|
228
|
-
|
|
229
|
-
Floating platforms use "fixed" physics and smaller box geometry:
|
|
217
|
+
### Platform
|
|
230
218
|
|
|
231
219
|
```json
|
|
232
220
|
{
|
|
233
|
-
"id": "platform
|
|
221
|
+
"id": "platform",
|
|
234
222
|
"components": {
|
|
235
223
|
"transform": { "type": "Transform", "properties": { "position": [-8, 2, -5] } },
|
|
236
224
|
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [6, 0.5, 4] } },
|
|
237
|
-
"material": { "type": "Material", "properties": { "color": "white", "texture": "/textures/GreyboxTextures/greybox_teal_grid.png", "repeat": true, "repeatCount": [3, 2] } },
|
|
238
225
|
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
239
226
|
}
|
|
240
227
|
}
|
|
241
228
|
```
|
|
242
229
|
|
|
243
|
-
### Ramp
|
|
244
|
-
|
|
245
|
-
Rotate on the Z-axis to create inclined surfaces:
|
|
230
|
+
### Ramp
|
|
246
231
|
|
|
247
232
|
```json
|
|
248
233
|
{
|
|
@@ -256,12 +241,11 @@ Rotate on the Z-axis to create inclined surfaces:
|
|
|
256
241
|
```
|
|
257
242
|
|
|
258
243
|
### Wall Pattern
|
|
259
|
-
|
|
260
|
-
Tall thin boxes positioned at boundaries:
|
|
244
|
+
### Wall
|
|
261
245
|
|
|
262
246
|
```json
|
|
263
247
|
{
|
|
264
|
-
"id": "wall
|
|
248
|
+
"id": "wall",
|
|
265
249
|
"components": {
|
|
266
250
|
"transform": { "type": "Transform", "properties": { "position": [0, 3, -20] } },
|
|
267
251
|
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [40, 7, 1] } },
|
|
@@ -270,128 +254,64 @@ Tall thin boxes positioned at boundaries:
|
|
|
270
254
|
}
|
|
271
255
|
```
|
|
272
256
|
|
|
273
|
-
###
|
|
274
|
-
|
|
275
|
-
Good lighting uses main, fill, and accent lights:
|
|
257
|
+
### Lighting
|
|
276
258
|
|
|
277
259
|
```json
|
|
278
260
|
[
|
|
279
|
-
{ "id": "
|
|
280
|
-
{ "id": "
|
|
281
|
-
{ "id": "accent-light", "components": { "transform": { "properties": { "position": [0, 10, -15] } }, "spotlight": { "type": "SpotLight", "properties": { "color": "#ffd700", "intensity": 50, "angle": 0.4 } } } }
|
|
261
|
+
{ "id": "spot", "components": { "transform": { "properties": { "position": [10, 15, 10] } }, "spotlight": { "type": "SpotLight", "properties": { "intensity": 200, "angle": 0.8, "castShadow": true } } } },
|
|
262
|
+
{ "id": "ambient", "components": { "ambientlight": { "type": "AmbientLight", "properties": { "intensity": 0.4 } } } }
|
|
282
263
|
]
|
|
283
264
|
```
|
|
284
265
|
|
|
285
|
-
###
|
|
286
|
-
|
|
287
|
-
Located in `/textures/GreyboxTextures/`:
|
|
288
|
-
- `greybox_dark_grid.png` - dark floors
|
|
289
|
-
- `greybox_light_grid.png` - light surfaces
|
|
290
|
-
- `greybox_teal_grid.png`, `greybox_purple_grid.png`, `greybox_orange_grid.png` - colored platforms
|
|
291
|
-
- `greybox_red_grid.png`, `greybox_blue_grid.png` - obstacles/hazards
|
|
292
|
-
- `greybox_yellow_grid.png`, `greybox_lime_grid.png`, `greybox_green_grid.png` - special areas
|
|
293
|
-
|
|
294
|
-
### Metallic/Special Materials
|
|
295
|
-
|
|
296
|
-
For goal platforms or special objects, use metalness and roughness:
|
|
266
|
+
### Text
|
|
297
267
|
|
|
298
268
|
```json
|
|
299
269
|
{
|
|
300
|
-
"
|
|
301
|
-
"properties": {
|
|
302
|
-
"color": "#FFD700",
|
|
303
|
-
"metalness": 0.8,
|
|
304
|
-
"roughness": 0.2
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
### Text Component
|
|
310
|
-
|
|
311
|
-
3D text rendering using `three-text`. The Text component is non-composable (cannot have children).
|
|
312
|
-
|
|
313
|
-
| Property | Type | Default | Description |
|
|
314
|
-
|----------|------|---------|-------------|
|
|
315
|
-
| `text` | string | `"Hello World"` | Text content to display |
|
|
316
|
-
| `color` | string | `"#888888"` | Text color (hex or CSS color) |
|
|
317
|
-
| `font` | string | `"/fonts/NotoSans-Regular.ttf"` | Path to TTF font file |
|
|
318
|
-
| `size` | number | `0.5` | Font size in world units |
|
|
319
|
-
| `depth` | number | `0` | 3D extrusion depth (0 for flat text) |
|
|
320
|
-
| `width` | number | `5` | Text block width for wrapping/alignment |
|
|
321
|
-
| `align` | string | `"center"` | Horizontal alignment: `"left"`, `"center"`, `"right"` |
|
|
322
|
-
|
|
323
|
-
```json
|
|
324
|
-
{
|
|
325
|
-
"id": "title-text",
|
|
270
|
+
"id": "text",
|
|
326
271
|
"components": {
|
|
327
272
|
"transform": { "type": "Transform", "properties": { "position": [0, 3, 0] } },
|
|
328
|
-
"text": {
|
|
329
|
-
"type": "Text",
|
|
330
|
-
"properties": {
|
|
331
|
-
"text": "Welcome",
|
|
332
|
-
"color": "#ffffff",
|
|
333
|
-
"size": 1,
|
|
334
|
-
"depth": 0.1,
|
|
335
|
-
"align": "center"
|
|
336
|
-
}
|
|
337
|
-
}
|
|
273
|
+
"text": { "type": "Text", "properties": { "text": "Welcome", "font": "/fonts/font.ttf", "size": 1, "depth": 0.1 } }
|
|
338
274
|
}
|
|
339
275
|
}
|
|
340
276
|
```
|
|
341
277
|
|
|
342
|
-
### Model
|
|
343
|
-
|
|
344
|
-
GLB models don't need geometry/material components:
|
|
278
|
+
### Model
|
|
345
279
|
|
|
346
280
|
```json
|
|
347
281
|
{
|
|
348
|
-
"id": "
|
|
282
|
+
"id": "model",
|
|
349
283
|
"components": {
|
|
350
|
-
"transform": { "type": "Transform", "properties": { "position": [
|
|
351
|
-
"model": { "type": "Model", "properties": { "filename": "models/
|
|
284
|
+
"transform": { "type": "Transform", "properties": { "position": [0, 0, 0], "scale": [1.5, 1.5, 1.5] } },
|
|
285
|
+
"model": { "type": "Model", "properties": { "filename": "/models/tree.glb" } }
|
|
352
286
|
}
|
|
353
287
|
}
|
|
354
288
|
```
|
|
355
289
|
|
|
356
|
-
|
|
290
|
+
## Editor
|
|
357
291
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
Use editor mode when building scenes visually or creating custom components with inspector UI.
|
|
361
|
-
|
|
362
|
-
### Using the Visual Editor
|
|
292
|
+
### Basic Usage
|
|
363
293
|
|
|
364
294
|
```jsx
|
|
365
295
|
import { PrefabEditor } from 'react-three-game';
|
|
366
296
|
|
|
367
|
-
<PrefabEditor
|
|
368
|
-
initialPrefab={sceneData}
|
|
369
|
-
onPrefabChange={setSceneData}
|
|
370
|
-
/>
|
|
297
|
+
<PrefabEditor initialPrefab={sceneData} onPrefabChange={setSceneData} />
|
|
371
298
|
```
|
|
372
299
|
|
|
373
|
-
|
|
374
|
-
- Scene hierarchy tree for navigating and selecting objects
|
|
375
|
-
- Component inspector panel for editing properties
|
|
376
|
-
- Transform gizmos for manipulating objects visually
|
|
377
|
-
- Keyboard shortcuts: **T** (Translate), **R** (Rotate), **S** (Scale)
|
|
300
|
+
Keyboard shortcuts: **T** (Translate), **R** (Rotate), **S** (Scale)
|
|
378
301
|
|
|
379
|
-
### Programmatic Updates
|
|
380
|
-
|
|
381
|
-
Use the editor ref to update prefabs programmatically:
|
|
302
|
+
### Programmatic Updates
|
|
382
303
|
|
|
383
304
|
```jsx
|
|
384
305
|
import { useRef } from 'react';
|
|
385
306
|
import { PrefabEditor, updateNodeById } from 'react-three-game';
|
|
386
|
-
import type { PrefabEditorRef
|
|
307
|
+
import type { PrefabEditorRef } from 'react-three-game';
|
|
387
308
|
|
|
388
|
-
function
|
|
309
|
+
function Scene() {
|
|
389
310
|
const editorRef = useRef<PrefabEditorRef>(null);
|
|
390
311
|
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
const newRoot = updateNodeById(prefab.root, "player", node => ({
|
|
312
|
+
const moveBall = () => {
|
|
313
|
+
const prefab = editorRef.current!.prefab;
|
|
314
|
+
const newRoot = updateNodeById(prefab.root, "ball", node => ({
|
|
395
315
|
...node,
|
|
396
316
|
components: {
|
|
397
317
|
...node.components,
|
|
@@ -401,209 +321,74 @@ function Game() {
|
|
|
401
321
|
}
|
|
402
322
|
}
|
|
403
323
|
}));
|
|
404
|
-
editorRef.current
|
|
324
|
+
editorRef.current!.setPrefab({ ...prefab, root: newRoot });
|
|
405
325
|
};
|
|
406
326
|
|
|
407
|
-
return
|
|
408
|
-
<PrefabEditor ref={editorRef} initialPrefab={sceneData}>
|
|
409
|
-
{/* Children render inside the Canvas - can use useFrame here */}
|
|
410
|
-
</PrefabEditor>
|
|
411
|
-
);
|
|
327
|
+
return <PrefabEditor ref={editorRef} initialPrefab={sceneData} />;
|
|
412
328
|
}
|
|
413
329
|
```
|
|
414
330
|
|
|
415
|
-
|
|
416
|
-
- `prefab` - current prefab state
|
|
417
|
-
- `setPrefab(prefab)` - update the prefab
|
|
418
|
-
- `screenshot()` - save canvas as PNG
|
|
419
|
-
- `exportGLB()` - export scene as GLB
|
|
420
|
-
- `rootRef` - reference to the Three.js scene root for programmatic GLB export
|
|
331
|
+
**PrefabEditorRef**: `prefab`, `setPrefab()`, `screenshot()`, `exportGLB()`, `rootRef`
|
|
421
332
|
|
|
422
|
-
###
|
|
423
|
-
|
|
424
|
-
Export Three.js scenes to GLB format from JSON prefabs:
|
|
333
|
+
### GLB Export
|
|
425
334
|
|
|
426
335
|
```tsx
|
|
427
|
-
import {
|
|
428
|
-
import { PrefabEditor, exportGLB, exportGLBData } from 'react-three-game';
|
|
429
|
-
import type { PrefabEditorRef } from 'react-three-game';
|
|
430
|
-
|
|
431
|
-
function ExportScene() {
|
|
432
|
-
const editorRef = useRef<PrefabEditorRef>(null);
|
|
433
|
-
|
|
434
|
-
const handleExport = async () => {
|
|
435
|
-
const sceneRoot = editorRef.current?.rootRef.current?.root;
|
|
436
|
-
if (!sceneRoot) return;
|
|
437
|
-
|
|
438
|
-
// Option 1: Export and trigger browser download
|
|
439
|
-
await exportGLB(sceneRoot, {
|
|
440
|
-
filename: 'my-scene.glb',
|
|
441
|
-
binary: true,
|
|
442
|
-
onComplete: (result) => console.log('Export complete'),
|
|
443
|
-
onError: (error) => console.error('Export failed', error)
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
// Option 2: Get raw ArrayBuffer without downloading
|
|
447
|
-
const glbData = await exportGLBData(sceneRoot);
|
|
448
|
-
// Upload to server, save to file system, etc.
|
|
449
|
-
};
|
|
336
|
+
import { exportGLBData } from 'react-three-game';
|
|
450
337
|
|
|
451
|
-
|
|
452
|
-
<>
|
|
453
|
-
<PrefabEditor ref={editorRef} initialPrefab={jsonPrefab} />
|
|
454
|
-
<button onClick={handleExport}>Export GLB</button>
|
|
455
|
-
</>
|
|
456
|
-
);
|
|
457
|
-
}
|
|
338
|
+
const glbData = await exportGLBData(editorRef.current!.rootRef.current!.root);
|
|
458
339
|
```
|
|
459
340
|
|
|
460
|
-
|
|
461
|
-
- `filename` - Output filename (triggers download if provided)
|
|
462
|
-
- `binary` - Export as binary GLB (true) or JSON glTF (false)
|
|
463
|
-
- `onComplete` - Callback when export succeeds
|
|
464
|
-
- `onError` - Callback when export fails
|
|
465
|
-
|
|
466
|
-
**Common Agent Pattern:**
|
|
467
|
-
```tsx
|
|
468
|
-
useEffect(() => {
|
|
469
|
-
// Wait for scene to fully render
|
|
470
|
-
const timer = setTimeout(async () => {
|
|
471
|
-
const sceneRoot = editorRef.current?.rootRef.current?.root;
|
|
472
|
-
if (sceneRoot) {
|
|
473
|
-
const glbData = await exportGLBData(sceneRoot);
|
|
474
|
-
// Process the ArrayBuffer
|
|
475
|
-
}
|
|
476
|
-
}, 1000);
|
|
477
|
-
return () => clearTimeout(timer);
|
|
478
|
-
}, []);
|
|
479
|
-
```
|
|
480
|
-
|
|
481
|
-
### Live Node Updates with useFrame
|
|
482
|
-
|
|
483
|
-
To animate objects by updating the prefab JSON at runtime, pass a child component to `PrefabEditor` that uses `useFrame`:
|
|
341
|
+
### Runtime Animation
|
|
484
342
|
|
|
485
343
|
```tsx
|
|
486
344
|
import { useRef } from "react";
|
|
487
345
|
import { useFrame } from "@react-three/fiber";
|
|
488
346
|
import { PrefabEditor, updateNodeById } from "react-three-game";
|
|
489
|
-
import type { Prefab, PrefabEditorRef } from "react-three-game";
|
|
490
|
-
|
|
491
|
-
// Animation component runs inside the editor's Canvas
|
|
492
|
-
function PlayerAnimator({ editorRef }: { editorRef: React.RefObject<PrefabEditorRef | null> }) {
|
|
493
|
-
const velocityRef = useRef({ x: 0, z: 0 });
|
|
494
347
|
|
|
348
|
+
function Animator({ editorRef }) {
|
|
495
349
|
useFrame(() => {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
transform: {
|
|
509
|
-
...node.components!.transform!,
|
|
510
|
-
properties: {
|
|
511
|
-
...transform,
|
|
512
|
-
position: [pos[0] + velocityRef.current.x * 0.02, pos[1], pos[2] + velocityRef.current.z * 0.02],
|
|
513
|
-
},
|
|
514
|
-
},
|
|
515
|
-
},
|
|
516
|
-
};
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
if (newRoot !== prefab.root) {
|
|
520
|
-
editorRef.current.setPrefab({ ...prefab, root: newRoot });
|
|
521
|
-
}
|
|
350
|
+
const prefab = editorRef.current!.prefab;
|
|
351
|
+
const newRoot = updateNodeById(prefab.root, "ball", node => ({
|
|
352
|
+
...node,
|
|
353
|
+
components: {
|
|
354
|
+
...node.components,
|
|
355
|
+
transform: {
|
|
356
|
+
...node.components!.transform!,
|
|
357
|
+
properties: { ...node.components!.transform!.properties, position: [x, y, z] }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}));
|
|
361
|
+
editorRef.current!.setPrefab({ ...prefab, root: newRoot });
|
|
522
362
|
});
|
|
523
|
-
|
|
524
363
|
return null;
|
|
525
364
|
}
|
|
526
365
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const editorRef = useRef<PrefabEditorRef>(null);
|
|
530
|
-
|
|
366
|
+
function Scene() {
|
|
367
|
+
const editorRef = useRef(null);
|
|
531
368
|
return (
|
|
532
|
-
<PrefabEditor ref={editorRef} initialPrefab={
|
|
533
|
-
<
|
|
369
|
+
<PrefabEditor ref={editorRef} initialPrefab={data}>
|
|
370
|
+
<Animator editorRef={editorRef} />
|
|
534
371
|
</PrefabEditor>
|
|
535
372
|
);
|
|
536
373
|
}
|
|
537
374
|
```
|
|
538
375
|
|
|
539
|
-
|
|
540
|
-
- Pass animation components as `children` to `PrefabEditor` - they render inside the Canvas
|
|
541
|
-
- Access prefab via `editorRef.current.prefab` and update via `editorRef.current.setPrefab()`
|
|
542
|
-
- `updateNodeById` is optimized to avoid recreating unchanged branches
|
|
543
|
-
- Store mutable state (velocities, timers) in refs to avoid re-renders
|
|
544
|
-
|
|
545
|
-
### Creating a Custom Component
|
|
376
|
+
### Custom Component
|
|
546
377
|
|
|
547
378
|
```tsx
|
|
548
|
-
import { Component, registerComponent, FieldRenderer
|
|
549
|
-
|
|
550
|
-
const myFields: FieldDefinition[] = [
|
|
551
|
-
{ name: 'speed', type: 'number', label: 'Speed', step: 0.1 },
|
|
552
|
-
{ name: 'enabled', type: 'boolean', label: 'Enabled' },
|
|
553
|
-
];
|
|
379
|
+
import { Component, registerComponent, FieldRenderer } from 'react-three-game';
|
|
554
380
|
|
|
555
381
|
const MyComponent: Component = {
|
|
556
382
|
name: 'MyComponent',
|
|
557
383
|
Editor: ({ component, onUpdate }) => (
|
|
558
|
-
<FieldRenderer fields={
|
|
384
|
+
<FieldRenderer fields={[{ name: 'speed', type: 'number', step: 0.1 }]} values={component.properties} onChange={onUpdate} />
|
|
559
385
|
),
|
|
560
|
-
View: ({ properties, children }) => {
|
|
561
|
-
|
|
562
|
-
return <group>{children}</group>;
|
|
563
|
-
},
|
|
564
|
-
defaultProperties: { speed: 1, enabled: true }
|
|
386
|
+
View: ({ properties, children }) => <group>{children}</group>,
|
|
387
|
+
defaultProperties: { speed: 1 }
|
|
565
388
|
};
|
|
566
389
|
|
|
567
390
|
registerComponent(MyComponent);
|
|
568
391
|
```
|
|
569
392
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
| Type | Description | Options |
|
|
573
|
-
|------|-------------|---------|
|
|
574
|
-
| `vector3` | X/Y/Z inputs | `snap?: number` |
|
|
575
|
-
| `number` | Numeric input | `min?`, `max?`, `step?` |
|
|
576
|
-
| `string` | Text input | `placeholder?` |
|
|
577
|
-
| `color` | Color picker | - |
|
|
578
|
-
| `boolean` | Checkbox | - |
|
|
579
|
-
| `select` | Dropdown | `options: { value, label }[]` |
|
|
580
|
-
| `custom` | Custom render function | `render: (props) => ReactNode` |
|
|
581
|
-
|
|
582
|
-
## Dependencies
|
|
583
|
-
|
|
584
|
-
Required peer dependencies:
|
|
585
|
-
- `@react-three/fiber`
|
|
586
|
-
- `@react-three/rapier`
|
|
587
|
-
- `three`
|
|
588
|
-
|
|
589
|
-
Install with:
|
|
590
|
-
```bash
|
|
591
|
-
npm i react-three-game @react-three/fiber @react-three/rapier three
|
|
592
|
-
```
|
|
593
|
-
|
|
594
|
-
## File Structure
|
|
595
|
-
|
|
596
|
-
```
|
|
597
|
-
/src → library source (published to npm)
|
|
598
|
-
/docs → Next.js demo site
|
|
599
|
-
/dist → built output
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
## Development Commands
|
|
603
|
-
|
|
604
|
-
```bash
|
|
605
|
-
npm run dev # tsc --watch + docs site
|
|
606
|
-
npm run build # build to /dist
|
|
607
|
-
npm run release # build + publish
|
|
608
|
-
```
|
|
393
|
+
**Field types**: `vector3`, `number`, `string`, `color`, `boolean`, `select`, `custom`
|
|
609
394
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Component } from "./ComponentRegistry";
|
|
2
|
+
import { FieldRenderer, FieldDefinition } from "./Input";
|
|
3
|
+
|
|
4
|
+
const ambientLightFields: FieldDefinition[] = [
|
|
5
|
+
{ name: 'color', type: 'color', label: 'Color' },
|
|
6
|
+
{ name: 'intensity', type: 'number', label: 'Intensity', step: 0.1, min: 0 },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
function AmbientLightComponentEditor({
|
|
10
|
+
component,
|
|
11
|
+
onUpdate,
|
|
12
|
+
}: {
|
|
13
|
+
component: any;
|
|
14
|
+
onUpdate: (newProps: any) => void;
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<FieldRenderer
|
|
18
|
+
fields={ambientLightFields}
|
|
19
|
+
values={component.properties}
|
|
20
|
+
onChange={onUpdate}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function AmbientLightComponentView({ properties }: { properties: any }) {
|
|
26
|
+
const { color = '#ffffff', intensity = 1 } = properties;
|
|
27
|
+
return <ambientLight color={color} intensity={intensity} />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const AmbientLightComponent: Component = {
|
|
31
|
+
name: 'AmbientLight',
|
|
32
|
+
Editor: AmbientLightComponentEditor,
|
|
33
|
+
View: AmbientLightComponentView,
|
|
34
|
+
defaultProperties: {
|
|
35
|
+
color: '#ffffff',
|
|
36
|
+
intensity: 1,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default AmbientLightComponent;
|
|
@@ -17,6 +17,10 @@ const GEOMETRY_ARGS: Record<string, {
|
|
|
17
17
|
labels: ["Width", "Height"],
|
|
18
18
|
defaults: [1, 1],
|
|
19
19
|
},
|
|
20
|
+
cylinder: {
|
|
21
|
+
labels: ["Radius Top", "Radius Bottom", "Height", "Radial Segments"],
|
|
22
|
+
defaults: [1, 1, 1, 32],
|
|
23
|
+
},
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
function GeometryComponentEditor({
|
|
@@ -38,6 +42,7 @@ function GeometryComponentEditor({
|
|
|
38
42
|
{ value: 'box', label: 'Box' },
|
|
39
43
|
{ value: 'sphere', label: 'Sphere' },
|
|
40
44
|
{ value: 'plane', label: 'Plane' },
|
|
45
|
+
{ value: 'cylinder', label: 'Cylinder' },
|
|
41
46
|
],
|
|
42
47
|
},
|
|
43
48
|
{
|
|
@@ -101,6 +106,8 @@ function GeometryComponentView({ properties, children }: { properties: any, chil
|
|
|
101
106
|
return <sphereGeometry args={args as [number, number?, number?]} />;
|
|
102
107
|
case "plane":
|
|
103
108
|
return <planeGeometry args={args as [number, number]} />;
|
|
109
|
+
case "cylinder":
|
|
110
|
+
return <cylinderGeometry args={args as [number, number, number, number?]} />;
|
|
104
111
|
default:
|
|
105
112
|
return <boxGeometry args={[1, 1, 1]} />;
|
|
106
113
|
}
|
|
@@ -96,11 +96,40 @@ interface InputProps {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export function Input({ value, onChange, step, min, max, style }: InputProps) {
|
|
99
|
+
const [draft, setDraft] = useState<string>(() => value.toString());
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
setDraft(value.toString());
|
|
103
|
+
}, [value]);
|
|
104
|
+
|
|
105
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
106
|
+
const inputValue = e.target.value;
|
|
107
|
+
setDraft(inputValue);
|
|
108
|
+
|
|
109
|
+
const num = parseFloat(inputValue);
|
|
110
|
+
if (Number.isFinite(num)) {
|
|
111
|
+
onChange(num);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleBlur = () => {
|
|
116
|
+
const num = parseFloat(draft);
|
|
117
|
+
if (!Number.isFinite(num)) {
|
|
118
|
+
setDraft(value.toString());
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
99
122
|
return (
|
|
100
123
|
<input
|
|
101
|
-
type="
|
|
102
|
-
value={
|
|
103
|
-
onChange={
|
|
124
|
+
type="text"
|
|
125
|
+
value={draft}
|
|
126
|
+
onChange={handleChange}
|
|
127
|
+
onBlur={handleBlur}
|
|
128
|
+
onKeyDown={e => {
|
|
129
|
+
if (e.key === 'Enter') {
|
|
130
|
+
(e.target as HTMLInputElement).blur();
|
|
131
|
+
}
|
|
132
|
+
}}
|
|
104
133
|
step={step}
|
|
105
134
|
min={min}
|
|
106
135
|
max={max}
|
|
@@ -4,6 +4,7 @@ import MaterialComponent from './MaterialComponent';
|
|
|
4
4
|
import PhysicsComponent from './PhysicsComponent';
|
|
5
5
|
import SpotLightComponent from './SpotLightComponent';
|
|
6
6
|
import DirectionalLightComponent from './DirectionalLightComponent';
|
|
7
|
+
import AmbientLightComponent from './AmbientLightComponent';
|
|
7
8
|
import ModelComponent from './ModelComponent';
|
|
8
9
|
import TextComponent from './TextComponent';
|
|
9
10
|
|
|
@@ -14,6 +15,7 @@ export default [
|
|
|
14
15
|
PhysicsComponent,
|
|
15
16
|
SpotLightComponent,
|
|
16
17
|
DirectionalLightComponent,
|
|
18
|
+
AmbientLightComponent,
|
|
17
19
|
ModelComponent,
|
|
18
20
|
TextComponent
|
|
19
21
|
];
|