react-three-game 0.0.47 → 0.0.49
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 +9 -4
- package/README.md +17 -5
- package/dist/helpers/index.d.ts +0 -2
- package/dist/helpers/index.js +1 -2
- package/dist/tools/prefabeditor/PrefabRoot.js +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/PhysicsComponent.d.ts +2 -7
- package/dist/tools/prefabeditor/components/PhysicsComponent.js +61 -7
- package/dist/tools/prefabeditor/components/index.js +2 -0
- package/dist/tools/prefabeditor/types.d.ts +0 -1
- package/package.json +1 -1
- package/react-three-game-skill/README.md +7 -0
- package/react-three-game-skill/react-three-game/SKILL.md +108 -321
- package/src/helpers/index.ts +0 -4
- package/src/tools/prefabeditor/PrefabRoot.tsx +1 -1
- 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/PhysicsComponent.tsx +58 -20
- package/src/tools/prefabeditor/components/index.ts +2 -0
- package/src/tools/prefabeditor/types.ts +0 -1
|
@@ -15,7 +15,6 @@ interface Prefab { id?: string; name?: string; root: GameObject; }
|
|
|
15
15
|
interface GameObject {
|
|
16
16
|
id: string; // Use crypto.randomUUID() for new nodes
|
|
17
17
|
disabled?: boolean;
|
|
18
|
-
hidden?: boolean;
|
|
19
18
|
components?: Record<string, { type: string; properties: any }>;
|
|
20
19
|
children?: GameObject[];
|
|
21
20
|
}
|
|
@@ -38,11 +37,17 @@ const MyComponent: Component = {
|
|
|
38
37
|
| File | Purpose |
|
|
39
38
|
|------|---------|
|
|
40
39
|
| `src/index.ts` | All public exports - add new features here |
|
|
41
|
-
| `src/tools/prefabeditor/PrefabRoot.tsx` |
|
|
42
|
-
| `src/tools/prefabeditor/PrefabEditor.tsx` |
|
|
40
|
+
| `src/tools/prefabeditor/PrefabRoot.tsx` | Pure renderer - renders prefab as Three.js objects for R3F integration |
|
|
41
|
+
| `src/tools/prefabeditor/PrefabEditor.tsx` | Managed scene with editor UI and play/pause controls for physics |
|
|
43
42
|
| `src/tools/prefabeditor/utils.ts` | Tree helpers: `findNode`, `updateNode`, `deleteNode`, `cloneNode` |
|
|
44
43
|
| `src/shared/GameCanvas.tsx` | WebGPU renderer setup (use `MeshStandardNodeMaterial`) |
|
|
45
44
|
|
|
45
|
+
## Usage Modes
|
|
46
|
+
|
|
47
|
+
**GameCanvas + PrefabRoot**: Pure renderer for embedding prefab data in standard R3F applications. Minimal wrapper - just renders the prefab as Three.js objects. Requires manual `<Physics>` setup. Physics always active. Use this to integrate prefabs into larger R3F scenes.
|
|
48
|
+
|
|
49
|
+
**PrefabEditor**: Managed scene with editor UI and play/pause controls for physics. Full authoring tool for level design and prototyping. Includes canvas, physics, transform gizmos, and inspector. Physics only runs in play mode. Can pass R3F components as children.
|
|
50
|
+
|
|
46
51
|
## Critical Patterns
|
|
47
52
|
|
|
48
53
|
### Tree Manipulation (Immutable)
|
|
@@ -61,7 +66,7 @@ Use node materials only: `MeshStandardNodeMaterial`, `MeshBasicNodeMaterial` (no
|
|
|
61
66
|
Set `model.properties.instanced = true` → uses `InstanceProvider.tsx` for batched rendering with physics.
|
|
62
67
|
|
|
63
68
|
## Built-in Components
|
|
64
|
-
`Transform`, `Geometry` (box/sphere/plane), `Material` (color/texture), `Physics` (dynamic/fixed), `Model` (GLB/FBX), `SpotLight`, `DirectionalLight`
|
|
69
|
+
`Transform`, `Geometry` (box/sphere/plane/cylinder), `Material` (color/texture), `Physics` (dynamic/fixed), `Model` (GLB/FBX), `SpotLight`, `DirectionalLight`, `AmbientLight`, `Text`
|
|
65
70
|
|
|
66
71
|
## Custom Components (User-space)
|
|
67
72
|
See `docs/app/demo/editor/RotatorComponent.tsx` for runtime behavior example using `useFrame`. Register with `registerComponent()` before rendering `<PrefabEditor>`.
|
package/README.md
CHANGED
|
@@ -14,7 +14,13 @@ npm i react-three-game @react-three/fiber @react-three/rapier three
|
|
|
14
14
|
npx skills add https://github.com/prnthh/react-three-game-skill
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
## Usage
|
|
17
|
+
## Usage Modes
|
|
18
|
+
|
|
19
|
+
**GameCanvas + PrefabRoot**: Pure renderer for embedding prefab data in standard R3F applications. Minimal wrapper - just renders the prefab as Three.js objects. Requires manual `<Physics>` setup. Physics always active. Use this to integrate prefabs into larger R3F scenes.
|
|
20
|
+
|
|
21
|
+
**PrefabEditor**: Managed scene with editor UI and play/pause controls for physics. Full authoring tool for level design and prototyping. Includes canvas, physics, transform gizmos, and inspector. Physics only runs in play mode. Can pass R3F components as children.
|
|
22
|
+
|
|
23
|
+
## Basic Usage
|
|
18
24
|
|
|
19
25
|
```jsx
|
|
20
26
|
import { Physics } from '@react-three/rapier';
|
|
@@ -57,7 +63,6 @@ import { GameCanvas, PrefabRoot } from 'react-three-game';
|
|
|
57
63
|
interface GameObject {
|
|
58
64
|
id: string;
|
|
59
65
|
disabled?: boolean;
|
|
60
|
-
hidden?: boolean;
|
|
61
66
|
components?: Record<string, { type: string; properties: any }>;
|
|
62
67
|
children?: GameObject[];
|
|
63
68
|
}
|
|
@@ -70,7 +75,7 @@ interface GameObject {
|
|
|
70
75
|
| Transform | `position`, `rotation`, `scale` — all `[x,y,z]` arrays, rotation in radians |
|
|
71
76
|
| Geometry | `geometryType`: box/sphere/plane/cylinder, `args`: dimension array |
|
|
72
77
|
| Material | `color`, `texture?`, `metalness?`, `roughness?` |
|
|
73
|
-
| Physics | `type`: dynamic/fixed |
|
|
78
|
+
| Physics | `type`: dynamic/fixed/kinematicPosition/kinematicVelocity, `mass?`, `restitution?` (bounciness), `friction?`, plus any Rapier props |
|
|
74
79
|
| Model | `filename` (GLB/FBX path), `instanced?` for GPU batching |
|
|
75
80
|
| SpotLight | `color`, `intensity`, `angle`, `penumbra` |
|
|
76
81
|
|
|
@@ -133,14 +138,21 @@ The `FieldRenderer` component auto-generates editor UI from a field schema:
|
|
|
133
138
|
}
|
|
134
139
|
```
|
|
135
140
|
|
|
136
|
-
##
|
|
141
|
+
## Prefab Editor
|
|
137
142
|
|
|
138
143
|
```jsx
|
|
139
144
|
import { PrefabEditor } from 'react-three-game';
|
|
145
|
+
|
|
146
|
+
// Standalone editor
|
|
140
147
|
<PrefabEditor initialPrefab={sceneData} onPrefabChange={setSceneData} />
|
|
148
|
+
|
|
149
|
+
// With custom R3F components
|
|
150
|
+
<PrefabEditor initialPrefab={sceneData}>
|
|
151
|
+
<CustomComponent />
|
|
152
|
+
</PrefabEditor>
|
|
141
153
|
```
|
|
142
154
|
|
|
143
|
-
Keys: **T**ranslate / **R**otate / **S**cale. Drag tree nodes to reparent. Import/export JSON.
|
|
155
|
+
Keys: **T**ranslate / **R**otate / **S**cale. Drag tree nodes to reparent. Import/export JSON. Physics only runs in play mode.
|
|
144
156
|
|
|
145
157
|
## Internals
|
|
146
158
|
|
package/dist/helpers/index.d.ts
CHANGED
|
@@ -18,8 +18,6 @@ export interface GroundOptions {
|
|
|
18
18
|
repeatCount?: [number, number];
|
|
19
19
|
/** Physics body type. Defaults to "fixed". */
|
|
20
20
|
physicsType?: "fixed" | "dynamic" | "kinematic";
|
|
21
|
-
/** Set true to hide the node. */
|
|
22
|
-
hidden?: boolean;
|
|
23
21
|
/** Set true to disable the node. */
|
|
24
22
|
disabled?: boolean;
|
|
25
23
|
}
|
package/dist/helpers/index.js
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
* - Physics (fixed by default)
|
|
9
9
|
*/
|
|
10
10
|
export function ground(options = {}) {
|
|
11
|
-
const { id = "ground", size = 50, position = [0, 0, 0], rotation = [-Math.PI / 2, 0, 0], scale = [1, 1, 1], color = "white", texture, repeat = texture ? true : false, repeatCount = [25, 25], physicsType = "fixed",
|
|
11
|
+
const { id = "ground", size = 50, position = [0, 0, 0], rotation = [-Math.PI / 2, 0, 0], scale = [1, 1, 1], color = "white", texture, repeat = texture ? true : false, repeatCount = [25, 25], physicsType = "fixed", disabled = false, } = options;
|
|
12
12
|
return {
|
|
13
13
|
id,
|
|
14
14
|
disabled,
|
|
15
|
-
hidden,
|
|
16
15
|
components: {
|
|
17
16
|
transform: {
|
|
18
17
|
type: "Transform",
|
|
@@ -108,7 +108,7 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
|
|
|
108
108
|
export function GameObjectRenderer(props) {
|
|
109
109
|
var _a, _b, _c;
|
|
110
110
|
const node = props.gameObject;
|
|
111
|
-
if (!node || node.
|
|
111
|
+
if (!node || node.disabled)
|
|
112
112
|
return null;
|
|
113
113
|
const isInstanced = (_c = (_b = (_a = node.components) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.properties) === null || _c === void 0 ? void 0 : _c.instanced;
|
|
114
114
|
const prevInstancedRef = useRef(undefined);
|
|
@@ -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 });
|
|
@@ -1,10 +1,5 @@
|
|
|
1
|
+
import type { RigidBodyOptions } from "@react-three/rapier";
|
|
1
2
|
import { Component } from "./ComponentRegistry";
|
|
2
|
-
export
|
|
3
|
-
type: "fixed" | "dynamic";
|
|
4
|
-
collider?: string;
|
|
5
|
-
mass?: number;
|
|
6
|
-
restitution?: number;
|
|
7
|
-
friction?: number;
|
|
8
|
-
}
|
|
3
|
+
export type PhysicsProps = RigidBodyOptions;
|
|
9
4
|
declare const PhysicsComponent: Component;
|
|
10
5
|
export default PhysicsComponent;
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
2
|
+
var t = {};
|
|
3
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
4
|
+
t[p] = s[p];
|
|
5
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
6
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
7
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
8
|
+
t[p[i]] = s[p[i]];
|
|
9
|
+
}
|
|
10
|
+
return t;
|
|
11
|
+
};
|
|
1
12
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
13
|
import { RigidBody } from "@react-three/rapier";
|
|
3
14
|
import { FieldRenderer } from "./Input";
|
|
@@ -9,10 +20,12 @@ const physicsFields = [
|
|
|
9
20
|
options: [
|
|
10
21
|
{ value: 'dynamic', label: 'Dynamic' },
|
|
11
22
|
{ value: 'fixed', label: 'Fixed' },
|
|
23
|
+
{ value: 'kinematicPosition', label: 'Kinematic Position' },
|
|
24
|
+
{ value: 'kinematicVelocity', label: 'Kinematic Velocity' },
|
|
12
25
|
],
|
|
13
26
|
},
|
|
14
27
|
{
|
|
15
|
-
name: '
|
|
28
|
+
name: 'colliders',
|
|
16
29
|
type: 'select',
|
|
17
30
|
label: 'Collider',
|
|
18
31
|
options: [
|
|
@@ -22,24 +35,65 @@ const physicsFields = [
|
|
|
22
35
|
{ value: 'ball', label: 'Ball (sphere)' },
|
|
23
36
|
],
|
|
24
37
|
},
|
|
38
|
+
{
|
|
39
|
+
name: 'mass',
|
|
40
|
+
type: 'number',
|
|
41
|
+
label: 'Mass',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'restitution',
|
|
45
|
+
type: 'number',
|
|
46
|
+
label: 'Restitution (Bounciness)',
|
|
47
|
+
min: 0,
|
|
48
|
+
max: 1,
|
|
49
|
+
step: 0.1,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'friction',
|
|
53
|
+
type: 'number',
|
|
54
|
+
label: 'Friction',
|
|
55
|
+
min: 0,
|
|
56
|
+
step: 0.1,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'linearDamping',
|
|
60
|
+
type: 'number',
|
|
61
|
+
label: 'Linear Damping',
|
|
62
|
+
min: 0,
|
|
63
|
+
step: 0.1,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'angularDamping',
|
|
67
|
+
type: 'number',
|
|
68
|
+
label: 'Angular Damping',
|
|
69
|
+
min: 0,
|
|
70
|
+
step: 0.1,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'gravityScale',
|
|
74
|
+
type: 'number',
|
|
75
|
+
label: 'Gravity Scale',
|
|
76
|
+
step: 0.1,
|
|
77
|
+
},
|
|
25
78
|
];
|
|
26
79
|
function PhysicsComponentEditor({ component, onUpdate }) {
|
|
27
|
-
return (_jsx(FieldRenderer, { fields: physicsFields, values: component.properties, onChange: onUpdate }));
|
|
80
|
+
return (_jsx(FieldRenderer, { fields: physicsFields, values: component.properties, onChange: (props) => onUpdate(Object.assign(Object.assign({}, component), { properties: Object.assign(Object.assign({}, component.properties), props) })) }));
|
|
28
81
|
}
|
|
29
82
|
function PhysicsComponentView({ properties, children, position, rotation, scale, editMode }) {
|
|
30
|
-
const colliders = properties
|
|
83
|
+
const { type, colliders } = properties, otherProps = __rest(properties, ["type", "colliders"]);
|
|
84
|
+
const colliderType = colliders || (type === 'fixed' ? 'trimesh' : 'hull');
|
|
31
85
|
// In edit mode, include position/rotation in key to force remount when transform changes
|
|
32
86
|
// This ensures the RigidBody debug visualization updates even when physics is paused
|
|
33
87
|
const rbKey = editMode
|
|
34
|
-
? `${
|
|
35
|
-
: `${
|
|
36
|
-
return (_jsx(RigidBody, { type:
|
|
88
|
+
? `${type || 'dynamic'}_${colliderType}_${position === null || position === void 0 ? void 0 : position.join(',')}_${rotation === null || rotation === void 0 ? void 0 : rotation.join(',')}`
|
|
89
|
+
: `${type || 'dynamic'}_${colliderType}`;
|
|
90
|
+
return (_jsx(RigidBody, Object.assign({ type: type, colliders: colliderType, position: position, rotation: rotation, scale: scale }, otherProps, { children: children }), rbKey));
|
|
37
91
|
}
|
|
38
92
|
const PhysicsComponent = {
|
|
39
93
|
name: 'Physics',
|
|
40
94
|
Editor: PhysicsComponentEditor,
|
|
41
95
|
View: PhysicsComponentView,
|
|
42
96
|
nonComposable: true,
|
|
43
|
-
defaultProperties: { type: 'dynamic',
|
|
97
|
+
defaultProperties: { type: 'dynamic', colliders: 'hull' }
|
|
44
98
|
};
|
|
45
99
|
export default PhysicsComponent;
|
|
@@ -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
|
@@ -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:
|
|
@@ -69,7 +82,6 @@ Every game object follows this schema:
|
|
|
69
82
|
interface GameObject {
|
|
70
83
|
id: string;
|
|
71
84
|
disabled?: boolean;
|
|
72
|
-
hidden?: boolean;
|
|
73
85
|
components?: Record<string, { type: string; properties: any }>;
|
|
74
86
|
children?: GameObject[];
|
|
75
87
|
}
|
|
@@ -104,12 +116,21 @@ Scenes are defined as JSON prefabs with a root node containing children:
|
|
|
104
116
|
| Transform | `Transform` | `position: [x,y,z]`, `rotation: [x,y,z]` (radians), `scale: [x,y,z]` |
|
|
105
117
|
| Geometry | `Geometry` | `geometryType`: box/sphere/plane/cylinder, `args`: dimension array |
|
|
106
118
|
| Material | `Material` | `color`, `texture?`, `metalness?`, `roughness?`, `repeat?`, `repeatCount?` |
|
|
107
|
-
| Physics | `Physics` | `type`:
|
|
119
|
+
| Physics | `Physics` | `type`: dynamic/fixed/kinematicPosition/kinematicVelocity, `mass?`, `restitution?`, `friction?`, `linearDamping?`, `angularDamping?`, `gravityScale?`, plus any Rapier RigidBody props |
|
|
108
120
|
| Model | `Model` | `filename` (GLB/FBX path), `instanced?` for GPU batching |
|
|
109
121
|
| SpotLight | `SpotLight` | `color`, `intensity`, `angle`, `penumbra`, `distance?`, `castShadow?` |
|
|
110
122
|
| DirectionalLight | `DirectionalLight` | `color`, `intensity`, `castShadow?`, `targetOffset?: [x,y,z]` |
|
|
123
|
+
| AmbientLight | `AmbientLight` | `color`, `intensity` |
|
|
111
124
|
| Text | `Text` | `text`, `font`, `size`, `depth`, `width`, `align`, `color` |
|
|
112
125
|
|
|
126
|
+
### Text Component
|
|
127
|
+
|
|
128
|
+
Requires `hb.wasm` and a font file (TTF/WOFF) in `/public/fonts/`:
|
|
129
|
+
- hb.wasm: https://github.com/prnthh/react-three-game/raw/refs/heads/main/docs/public/fonts/hb.wasm
|
|
130
|
+
- Sample font: https://github.com/prnthh/react-three-game/raw/refs/heads/main/docs/public/fonts/NotoSans-Regular.ttf
|
|
131
|
+
|
|
132
|
+
Font property: `"font": "/fonts/NotoSans-Regular.ttf"`
|
|
133
|
+
|
|
113
134
|
### Geometry Args by Type
|
|
114
135
|
|
|
115
136
|
| geometryType | args array |
|
|
@@ -119,41 +140,31 @@ Scenes are defined as JSON prefabs with a root node containing children:
|
|
|
119
140
|
| `plane` | `[width, height]` |
|
|
120
141
|
| `cylinder` | `[radiusTop, radiusBottom, height, radialSegments]` |
|
|
121
142
|
|
|
122
|
-
### Material
|
|
143
|
+
### Material Textures
|
|
123
144
|
|
|
124
145
|
```json
|
|
125
146
|
{
|
|
126
|
-
"
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
147
|
+
"material": {
|
|
148
|
+
"type": "Material",
|
|
149
|
+
"properties": {
|
|
150
|
+
"color": "white",
|
|
151
|
+
"texture": "/textures/floor.png",
|
|
152
|
+
"repeat": true,
|
|
153
|
+
"repeatCount": [4, 4]
|
|
154
|
+
}
|
|
132
155
|
}
|
|
133
156
|
}
|
|
134
157
|
```
|
|
135
158
|
|
|
136
|
-
|
|
137
|
-
- `repeatCount: [x, y]` tiles the texture; match to geometry dimensions for proper scaling
|
|
138
|
-
|
|
139
|
-
### Rotation Reference
|
|
159
|
+
### Rotations
|
|
140
160
|
|
|
141
|
-
|
|
142
|
-
- `1.57` = 90° (π/2)
|
|
143
|
-
- `3.14` = 180° (π)
|
|
144
|
-
- `-1.57` = -90° (rotate plane flat: `rotation: [-1.57, 0, 0]`)
|
|
161
|
+
Use radians: `1.57` = 90°, `3.14` = 180°, `-1.57` = -90°
|
|
145
162
|
|
|
146
163
|
## Common Patterns
|
|
147
164
|
|
|
148
165
|
### Usage Modes
|
|
149
166
|
|
|
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)
|
|
167
|
+
**GameCanvas + PrefabRoot**: Pure renderer for embedding prefab data in standard R3F applications. Minimal wrapper - just renders the prefab as Three.js objects. Requires manual `<Physics>` setup. Physics always active. Use this to integrate prefabs into larger R3F scenes.
|
|
157
168
|
|
|
158
169
|
```jsx
|
|
159
170
|
import { Physics } from '@react-three/rapier';
|
|
@@ -162,87 +173,63 @@ import { GameCanvas, PrefabRoot } from 'react-three-game';
|
|
|
162
173
|
<GameCanvas>
|
|
163
174
|
<Physics>
|
|
164
175
|
<PrefabRoot data={prefabData} />
|
|
176
|
+
<CustomComponent />
|
|
165
177
|
</Physics>
|
|
166
178
|
</GameCanvas>
|
|
167
179
|
```
|
|
168
180
|
|
|
169
|
-
|
|
181
|
+
**PrefabEditor**: Managed scene with editor UI and play/pause controls for physics. Full authoring tool for level design and prototyping. Includes canvas, physics, transform gizmos, and inspector. Physics only runs in play mode. Can pass R3F components as children.
|
|
182
|
+
|
|
183
|
+
```jsx
|
|
184
|
+
import { PrefabEditor } from 'react-three-game';
|
|
185
|
+
|
|
186
|
+
<PrefabEditor initialPrefab={prefabData}>
|
|
187
|
+
<CustomComponent />
|
|
188
|
+
</PrefabEditor>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Tree Utilities
|
|
170
192
|
|
|
171
193
|
```typescript
|
|
172
|
-
import {
|
|
194
|
+
import { updateNodeById, findNode, deleteNode, cloneNode, exportGLBData } from 'react-three-game';
|
|
173
195
|
|
|
174
|
-
// Update a node by ID (optimized - avoids unnecessary object creation)
|
|
175
196
|
const updated = updateNodeById(root, nodeId, node => ({ ...node, disabled: true }));
|
|
176
|
-
|
|
177
|
-
// Find a node
|
|
178
197
|
const node = findNode(root, nodeId);
|
|
179
|
-
|
|
180
|
-
// Delete a node
|
|
181
198
|
const afterDelete = deleteNode(root, nodeId);
|
|
182
|
-
|
|
183
|
-
// Clone a node
|
|
184
199
|
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
|
|
200
|
+
const glbData = await exportGLBData(sceneRoot);
|
|
193
201
|
```
|
|
194
202
|
|
|
195
|
-
##
|
|
203
|
+
## Level Patterns
|
|
196
204
|
|
|
197
|
-
###
|
|
205
|
+
### Floor
|
|
198
206
|
|
|
199
207
|
```json
|
|
200
208
|
{
|
|
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",
|
|
209
|
+
"id": "floor",
|
|
218
210
|
"components": {
|
|
219
211
|
"transform": { "type": "Transform", "properties": { "position": [0, -0.5, 0] } },
|
|
220
212
|
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [40, 1, 40] } },
|
|
221
|
-
"material": { "type": "Material", "properties": { "
|
|
213
|
+
"material": { "type": "Material", "properties": { "texture": "/textures/floor.png", "repeat": true, "repeatCount": [20, 20] } },
|
|
222
214
|
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
223
215
|
}
|
|
224
216
|
}
|
|
225
217
|
```
|
|
226
218
|
|
|
227
|
-
### Platform
|
|
228
|
-
|
|
229
|
-
Floating platforms use "fixed" physics and smaller box geometry:
|
|
219
|
+
### Platform
|
|
230
220
|
|
|
231
221
|
```json
|
|
232
222
|
{
|
|
233
|
-
"id": "platform
|
|
223
|
+
"id": "platform",
|
|
234
224
|
"components": {
|
|
235
225
|
"transform": { "type": "Transform", "properties": { "position": [-8, 2, -5] } },
|
|
236
226
|
"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
227
|
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
239
228
|
}
|
|
240
229
|
}
|
|
241
230
|
```
|
|
242
231
|
|
|
243
|
-
### Ramp
|
|
244
|
-
|
|
245
|
-
Rotate on the Z-axis to create inclined surfaces:
|
|
232
|
+
### Ramp
|
|
246
233
|
|
|
247
234
|
```json
|
|
248
235
|
{
|
|
@@ -256,12 +243,11 @@ Rotate on the Z-axis to create inclined surfaces:
|
|
|
256
243
|
```
|
|
257
244
|
|
|
258
245
|
### Wall Pattern
|
|
259
|
-
|
|
260
|
-
Tall thin boxes positioned at boundaries:
|
|
246
|
+
### Wall
|
|
261
247
|
|
|
262
248
|
```json
|
|
263
249
|
{
|
|
264
|
-
"id": "wall
|
|
250
|
+
"id": "wall",
|
|
265
251
|
"components": {
|
|
266
252
|
"transform": { "type": "Transform", "properties": { "position": [0, 3, -20] } },
|
|
267
253
|
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [40, 7, 1] } },
|
|
@@ -270,128 +256,64 @@ Tall thin boxes positioned at boundaries:
|
|
|
270
256
|
}
|
|
271
257
|
```
|
|
272
258
|
|
|
273
|
-
###
|
|
274
|
-
|
|
275
|
-
Good lighting uses main, fill, and accent lights:
|
|
259
|
+
### Lighting
|
|
276
260
|
|
|
277
261
|
```json
|
|
278
262
|
[
|
|
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 } } } }
|
|
263
|
+
{ "id": "spot", "components": { "transform": { "properties": { "position": [10, 15, 10] } }, "spotlight": { "type": "SpotLight", "properties": { "intensity": 200, "angle": 0.8, "castShadow": true } } } },
|
|
264
|
+
{ "id": "ambient", "components": { "ambientlight": { "type": "AmbientLight", "properties": { "intensity": 0.4 } } } }
|
|
282
265
|
]
|
|
283
266
|
```
|
|
284
267
|
|
|
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:
|
|
268
|
+
### Text
|
|
297
269
|
|
|
298
270
|
```json
|
|
299
271
|
{
|
|
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",
|
|
272
|
+
"id": "text",
|
|
326
273
|
"components": {
|
|
327
274
|
"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
|
-
}
|
|
275
|
+
"text": { "type": "Text", "properties": { "text": "Welcome", "font": "/fonts/font.ttf", "size": 1, "depth": 0.1 } }
|
|
338
276
|
}
|
|
339
277
|
}
|
|
340
278
|
```
|
|
341
279
|
|
|
342
|
-
### Model
|
|
343
|
-
|
|
344
|
-
GLB models don't need geometry/material components:
|
|
280
|
+
### Model
|
|
345
281
|
|
|
346
282
|
```json
|
|
347
283
|
{
|
|
348
|
-
"id": "
|
|
284
|
+
"id": "model",
|
|
349
285
|
"components": {
|
|
350
|
-
"transform": { "type": "Transform", "properties": { "position": [
|
|
351
|
-
"model": { "type": "Model", "properties": { "filename": "models/
|
|
286
|
+
"transform": { "type": "Transform", "properties": { "position": [0, 0, 0], "scale": [1.5, 1.5, 1.5] } },
|
|
287
|
+
"model": { "type": "Model", "properties": { "filename": "/models/tree.glb" } }
|
|
352
288
|
}
|
|
353
289
|
}
|
|
354
290
|
```
|
|
355
291
|
|
|
356
|
-
|
|
292
|
+
## Editor
|
|
357
293
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
Use editor mode when building scenes visually or creating custom components with inspector UI.
|
|
361
|
-
|
|
362
|
-
### Using the Visual Editor
|
|
294
|
+
### Basic Usage
|
|
363
295
|
|
|
364
296
|
```jsx
|
|
365
297
|
import { PrefabEditor } from 'react-three-game';
|
|
366
298
|
|
|
367
|
-
<PrefabEditor
|
|
368
|
-
initialPrefab={sceneData}
|
|
369
|
-
onPrefabChange={setSceneData}
|
|
370
|
-
/>
|
|
299
|
+
<PrefabEditor initialPrefab={sceneData} onPrefabChange={setSceneData} />
|
|
371
300
|
```
|
|
372
301
|
|
|
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)
|
|
302
|
+
Keyboard shortcuts: **T** (Translate), **R** (Rotate), **S** (Scale)
|
|
378
303
|
|
|
379
|
-
### Programmatic Updates
|
|
380
|
-
|
|
381
|
-
Use the editor ref to update prefabs programmatically:
|
|
304
|
+
### Programmatic Updates
|
|
382
305
|
|
|
383
306
|
```jsx
|
|
384
307
|
import { useRef } from 'react';
|
|
385
308
|
import { PrefabEditor, updateNodeById } from 'react-three-game';
|
|
386
|
-
import type { PrefabEditorRef
|
|
309
|
+
import type { PrefabEditorRef } from 'react-three-game';
|
|
387
310
|
|
|
388
|
-
function
|
|
311
|
+
function Scene() {
|
|
389
312
|
const editorRef = useRef<PrefabEditorRef>(null);
|
|
390
313
|
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
const newRoot = updateNodeById(prefab.root, "player", node => ({
|
|
314
|
+
const moveBall = () => {
|
|
315
|
+
const prefab = editorRef.current!.prefab;
|
|
316
|
+
const newRoot = updateNodeById(prefab.root, "ball", node => ({
|
|
395
317
|
...node,
|
|
396
318
|
components: {
|
|
397
319
|
...node.components,
|
|
@@ -401,209 +323,74 @@ function Game() {
|
|
|
401
323
|
}
|
|
402
324
|
}
|
|
403
325
|
}));
|
|
404
|
-
editorRef.current
|
|
326
|
+
editorRef.current!.setPrefab({ ...prefab, root: newRoot });
|
|
405
327
|
};
|
|
406
328
|
|
|
407
|
-
return
|
|
408
|
-
<PrefabEditor ref={editorRef} initialPrefab={sceneData}>
|
|
409
|
-
{/* Children render inside the Canvas - can use useFrame here */}
|
|
410
|
-
</PrefabEditor>
|
|
411
|
-
);
|
|
329
|
+
return <PrefabEditor ref={editorRef} initialPrefab={sceneData} />;
|
|
412
330
|
}
|
|
413
331
|
```
|
|
414
332
|
|
|
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
|
|
333
|
+
**PrefabEditorRef**: `prefab`, `setPrefab()`, `screenshot()`, `exportGLB()`, `rootRef`
|
|
421
334
|
|
|
422
|
-
###
|
|
423
|
-
|
|
424
|
-
Export Three.js scenes to GLB format from JSON prefabs:
|
|
335
|
+
### GLB Export
|
|
425
336
|
|
|
426
337
|
```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
|
-
};
|
|
338
|
+
import { exportGLBData } from 'react-three-game';
|
|
450
339
|
|
|
451
|
-
|
|
452
|
-
<>
|
|
453
|
-
<PrefabEditor ref={editorRef} initialPrefab={jsonPrefab} />
|
|
454
|
-
<button onClick={handleExport}>Export GLB</button>
|
|
455
|
-
</>
|
|
456
|
-
);
|
|
457
|
-
}
|
|
340
|
+
const glbData = await exportGLBData(editorRef.current!.rootRef.current!.root);
|
|
458
341
|
```
|
|
459
342
|
|
|
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`:
|
|
343
|
+
### Runtime Animation
|
|
484
344
|
|
|
485
345
|
```tsx
|
|
486
346
|
import { useRef } from "react";
|
|
487
347
|
import { useFrame } from "@react-three/fiber";
|
|
488
348
|
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
349
|
|
|
350
|
+
function Animator({ editorRef }) {
|
|
495
351
|
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
|
-
}
|
|
352
|
+
const prefab = editorRef.current!.prefab;
|
|
353
|
+
const newRoot = updateNodeById(prefab.root, "ball", node => ({
|
|
354
|
+
...node,
|
|
355
|
+
components: {
|
|
356
|
+
...node.components,
|
|
357
|
+
transform: {
|
|
358
|
+
...node.components!.transform!,
|
|
359
|
+
properties: { ...node.components!.transform!.properties, position: [x, y, z] }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}));
|
|
363
|
+
editorRef.current!.setPrefab({ ...prefab, root: newRoot });
|
|
522
364
|
});
|
|
523
|
-
|
|
524
365
|
return null;
|
|
525
366
|
}
|
|
526
367
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const editorRef = useRef<PrefabEditorRef>(null);
|
|
530
|
-
|
|
368
|
+
function Scene() {
|
|
369
|
+
const editorRef = useRef(null);
|
|
531
370
|
return (
|
|
532
|
-
<PrefabEditor ref={editorRef} initialPrefab={
|
|
533
|
-
<
|
|
371
|
+
<PrefabEditor ref={editorRef} initialPrefab={data}>
|
|
372
|
+
<Animator editorRef={editorRef} />
|
|
534
373
|
</PrefabEditor>
|
|
535
374
|
);
|
|
536
375
|
}
|
|
537
376
|
```
|
|
538
377
|
|
|
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
|
|
378
|
+
### Custom Component
|
|
546
379
|
|
|
547
380
|
```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
|
-
];
|
|
381
|
+
import { Component, registerComponent, FieldRenderer } from 'react-three-game';
|
|
554
382
|
|
|
555
383
|
const MyComponent: Component = {
|
|
556
384
|
name: 'MyComponent',
|
|
557
385
|
Editor: ({ component, onUpdate }) => (
|
|
558
|
-
<FieldRenderer fields={
|
|
386
|
+
<FieldRenderer fields={[{ name: 'speed', type: 'number', step: 0.1 }]} values={component.properties} onChange={onUpdate} />
|
|
559
387
|
),
|
|
560
|
-
View: ({ properties, children }) => {
|
|
561
|
-
|
|
562
|
-
return <group>{children}</group>;
|
|
563
|
-
},
|
|
564
|
-
defaultProperties: { speed: 1, enabled: true }
|
|
388
|
+
View: ({ properties, children }) => <group>{children}</group>,
|
|
389
|
+
defaultProperties: { speed: 1 }
|
|
565
390
|
};
|
|
566
391
|
|
|
567
392
|
registerComponent(MyComponent);
|
|
568
393
|
```
|
|
569
394
|
|
|
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
|
-
```
|
|
395
|
+
**Field types**: `vector3`, `number`, `string`, `color`, `boolean`, `select`, `custom`
|
|
609
396
|
|
package/src/helpers/index.ts
CHANGED
|
@@ -25,8 +25,6 @@ export interface GroundOptions {
|
|
|
25
25
|
/** Physics body type. Defaults to "fixed". */
|
|
26
26
|
physicsType?: "fixed" | "dynamic" | "kinematic";
|
|
27
27
|
|
|
28
|
-
/** Set true to hide the node. */
|
|
29
|
-
hidden?: boolean;
|
|
30
28
|
/** Set true to disable the node. */
|
|
31
29
|
disabled?: boolean;
|
|
32
30
|
}
|
|
@@ -52,14 +50,12 @@ export function ground(options: GroundOptions = {}): GameObject {
|
|
|
52
50
|
repeat = texture ? true : false,
|
|
53
51
|
repeatCount = [25, 25],
|
|
54
52
|
physicsType = "fixed",
|
|
55
|
-
hidden = false,
|
|
56
53
|
disabled = false,
|
|
57
54
|
} = options;
|
|
58
55
|
|
|
59
56
|
return {
|
|
60
57
|
id,
|
|
61
58
|
disabled,
|
|
62
|
-
hidden,
|
|
63
59
|
components: {
|
|
64
60
|
transform: {
|
|
65
61
|
type: "Transform",
|
|
@@ -175,7 +175,7 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
|
|
|
175
175
|
|
|
176
176
|
export function GameObjectRenderer(props: RendererProps) {
|
|
177
177
|
const node = props.gameObject;
|
|
178
|
-
if (!node || node.
|
|
178
|
+
if (!node || node.disabled) return null;
|
|
179
179
|
|
|
180
180
|
const isInstanced = node.components?.model?.properties?.instanced;
|
|
181
181
|
const prevInstancedRef = useRef<boolean | undefined>(undefined);
|
|
@@ -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}
|
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
import { RigidBody, RapierRigidBody } from "@react-three/rapier";
|
|
2
|
+
import type { RigidBodyOptions } from "@react-three/rapier";
|
|
2
3
|
import type { ReactNode } from 'react';
|
|
3
|
-
import { useEffect, useRef } from 'react';
|
|
4
4
|
import { Component } from "./ComponentRegistry";
|
|
5
5
|
import { FieldRenderer, FieldDefinition } from "./Input";
|
|
6
|
-
import {
|
|
6
|
+
import { ComponentData } from "../types";
|
|
7
7
|
|
|
8
|
-
export
|
|
9
|
-
type: "fixed" | "dynamic";
|
|
10
|
-
collider?: string;
|
|
11
|
-
mass?: number;
|
|
12
|
-
restitution?: number;
|
|
13
|
-
friction?: number;
|
|
14
|
-
}
|
|
8
|
+
export type PhysicsProps = RigidBodyOptions;
|
|
15
9
|
|
|
16
10
|
const physicsFields: FieldDefinition[] = [
|
|
17
11
|
{
|
|
@@ -21,10 +15,12 @@ const physicsFields: FieldDefinition[] = [
|
|
|
21
15
|
options: [
|
|
22
16
|
{ value: 'dynamic', label: 'Dynamic' },
|
|
23
17
|
{ value: 'fixed', label: 'Fixed' },
|
|
18
|
+
{ value: 'kinematicPosition', label: 'Kinematic Position' },
|
|
19
|
+
{ value: 'kinematicVelocity', label: 'Kinematic Velocity' },
|
|
24
20
|
],
|
|
25
21
|
},
|
|
26
22
|
{
|
|
27
|
-
name: '
|
|
23
|
+
name: 'colliders',
|
|
28
24
|
type: 'select',
|
|
29
25
|
label: 'Collider',
|
|
30
26
|
options: [
|
|
@@ -34,20 +30,60 @@ const physicsFields: FieldDefinition[] = [
|
|
|
34
30
|
{ value: 'ball', label: 'Ball (sphere)' },
|
|
35
31
|
],
|
|
36
32
|
},
|
|
33
|
+
{
|
|
34
|
+
name: 'mass',
|
|
35
|
+
type: 'number',
|
|
36
|
+
label: 'Mass',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'restitution',
|
|
40
|
+
type: 'number',
|
|
41
|
+
label: 'Restitution (Bounciness)',
|
|
42
|
+
min: 0,
|
|
43
|
+
max: 1,
|
|
44
|
+
step: 0.1,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'friction',
|
|
48
|
+
type: 'number',
|
|
49
|
+
label: 'Friction',
|
|
50
|
+
min: 0,
|
|
51
|
+
step: 0.1,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'linearDamping',
|
|
55
|
+
type: 'number',
|
|
56
|
+
label: 'Linear Damping',
|
|
57
|
+
min: 0,
|
|
58
|
+
step: 0.1,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'angularDamping',
|
|
62
|
+
type: 'number',
|
|
63
|
+
label: 'Angular Damping',
|
|
64
|
+
min: 0,
|
|
65
|
+
step: 0.1,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'gravityScale',
|
|
69
|
+
type: 'number',
|
|
70
|
+
label: 'Gravity Scale',
|
|
71
|
+
step: 0.1,
|
|
72
|
+
},
|
|
37
73
|
];
|
|
38
74
|
|
|
39
|
-
function PhysicsComponentEditor({ component, onUpdate }: { component:
|
|
75
|
+
function PhysicsComponentEditor({ component, onUpdate }: { component: ComponentData; onUpdate: (newComp: any) => void }) {
|
|
40
76
|
return (
|
|
41
77
|
<FieldRenderer
|
|
42
78
|
fields={physicsFields}
|
|
43
79
|
values={component.properties}
|
|
44
|
-
onChange={onUpdate}
|
|
80
|
+
onChange={(props) => onUpdate({ ...component, properties: { ...component.properties, ...props } })}
|
|
45
81
|
/>
|
|
46
82
|
);
|
|
47
83
|
}
|
|
48
84
|
|
|
49
85
|
interface PhysicsViewProps {
|
|
50
|
-
properties:
|
|
86
|
+
properties: PhysicsProps;
|
|
51
87
|
editMode?: boolean;
|
|
52
88
|
children?: ReactNode;
|
|
53
89
|
position?: [number, number, number];
|
|
@@ -56,22 +92,24 @@ interface PhysicsViewProps {
|
|
|
56
92
|
}
|
|
57
93
|
|
|
58
94
|
function PhysicsComponentView({ properties, children, position, rotation, scale, editMode }: PhysicsViewProps) {
|
|
59
|
-
const
|
|
95
|
+
const { type, colliders, ...otherProps } = properties;
|
|
96
|
+
const colliderType = colliders || (type === 'fixed' ? 'trimesh' : 'hull');
|
|
60
97
|
|
|
61
98
|
// In edit mode, include position/rotation in key to force remount when transform changes
|
|
62
99
|
// This ensures the RigidBody debug visualization updates even when physics is paused
|
|
63
100
|
const rbKey = editMode
|
|
64
|
-
? `${
|
|
65
|
-
: `${
|
|
101
|
+
? `${type || 'dynamic'}_${colliderType}_${position?.join(',')}_${rotation?.join(',')}`
|
|
102
|
+
: `${type || 'dynamic'}_${colliderType}`;
|
|
66
103
|
|
|
67
104
|
return (
|
|
68
105
|
<RigidBody
|
|
69
106
|
key={rbKey}
|
|
70
|
-
type={
|
|
71
|
-
colliders={
|
|
107
|
+
type={type}
|
|
108
|
+
colliders={colliderType as any}
|
|
72
109
|
position={position}
|
|
73
110
|
rotation={rotation}
|
|
74
111
|
scale={scale}
|
|
112
|
+
{...otherProps}
|
|
75
113
|
>
|
|
76
114
|
{children}
|
|
77
115
|
</RigidBody>
|
|
@@ -83,7 +121,7 @@ const PhysicsComponent: Component = {
|
|
|
83
121
|
Editor: PhysicsComponentEditor,
|
|
84
122
|
View: PhysicsComponentView,
|
|
85
123
|
nonComposable: true,
|
|
86
|
-
defaultProperties: { type: 'dynamic',
|
|
124
|
+
defaultProperties: { type: 'dynamic', colliders: 'hull' }
|
|
87
125
|
};
|
|
88
126
|
|
|
89
|
-
export default PhysicsComponent;
|
|
127
|
+
export default PhysicsComponent;
|
|
@@ -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
|
];
|