react-three-game 0.0.45 → 0.0.46

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.
Binary file
@@ -11,7 +11,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
11
11
  import { MapControls, TransformControls, useHelper } from "@react-three/drei";
12
12
  import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from "react";
13
13
  import { BoxHelper, Euler, Matrix4, Quaternion, SRGBColorSpace, TextureLoader, Vector3, } from "three";
14
- import { getComponent, registerComponent } from "./components/ComponentRegistry";
14
+ import { getComponent, registerComponent, getNonComposableKeys } from "./components/ComponentRegistry";
15
15
  import components from "./components";
16
16
  import { loadModel } from "../dragdrop/modelLoader";
17
17
  import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./InstanceProvider";
@@ -245,13 +245,15 @@ function computeParentWorldMatrix(root, targetId) {
245
245
  return result !== null && result !== void 0 ? result : IDENTITY;
246
246
  }
247
247
  function renderCoreNode(gameObject, ctx, parentMatrix) {
248
- var _a, _b, _c;
248
+ var _a, _b, _c, _d;
249
249
  const geometry = (_a = gameObject.components) === null || _a === void 0 ? void 0 : _a.geometry;
250
250
  const material = (_b = gameObject.components) === null || _b === void 0 ? void 0 : _b.material;
251
251
  const model = (_c = gameObject.components) === null || _c === void 0 ? void 0 : _c.model;
252
+ const text = (_d = gameObject.components) === null || _d === void 0 ? void 0 : _d.text;
252
253
  const geometryDef = geometry && getComponent("Geometry");
253
254
  const materialDef = material && getComponent("Material");
254
255
  const modelDef = model && getComponent("Model");
256
+ const textDef = text && getComponent("Text");
255
257
  const contextProps = {
256
258
  loadedModels: ctx.loadedModels,
257
259
  loadedTextures: ctx.loadedTextures,
@@ -263,7 +265,7 @@ function renderCoreNode(gameObject, ctx, parentMatrix) {
263
265
  const leaves = [];
264
266
  if (gameObject.components) {
265
267
  Object.entries(gameObject.components)
266
- .filter(([k]) => !["geometry", "material", "model", "transform", "physics"].includes(k))
268
+ .filter(([k]) => !getNonComposableKeys().includes(k))
267
269
  .forEach(([key, comp]) => {
268
270
  if (!(comp === null || comp === void 0 ? void 0 : comp.type))
269
271
  return;
@@ -285,6 +287,9 @@ function renderCoreNode(gameObject, ctx, parentMatrix) {
285
287
  else if (geometry && (geometryDef === null || geometryDef === void 0 ? void 0 : geometryDef.View)) {
286
288
  core = (_jsxs("mesh", { castShadow: true, receiveShadow: true, children: [_jsx(geometryDef.View, Object.assign({ properties: geometry.properties }, contextProps)), material && (materialDef === null || materialDef === void 0 ? void 0 : materialDef.View) && (_jsx(materialDef.View, Object.assign({ properties: material.properties }, contextProps), "material")), leaves] }));
287
289
  }
290
+ else if (text && (textDef === null || textDef === void 0 ? void 0 : textDef.View)) {
291
+ core = (_jsxs(_Fragment, { children: [_jsx(textDef.View, Object.assign({ properties: text.properties }, contextProps)), leaves] }));
292
+ }
288
293
  else {
289
294
  core = _jsx(_Fragment, { children: leaves });
290
295
  }
@@ -10,7 +10,9 @@ export interface Component {
10
10
  }>;
11
11
  defaultProperties: any;
12
12
  View?: FC<any>;
13
+ nonComposable?: boolean;
13
14
  }
14
15
  export declare function registerComponent(component: Component): void;
15
16
  export declare function getComponent(name: string): Component | undefined;
16
17
  export declare function getAllComponents(): Record<string, Component>;
18
+ export declare function getNonComposableKeys(): string[];
@@ -11,3 +11,8 @@ export function getComponent(name) {
11
11
  export function getAllComponents() {
12
12
  return Object.assign({}, REGISTRY);
13
13
  }
14
+ export function getNonComposableKeys() {
15
+ return Object.values(REGISTRY)
16
+ .filter(c => c.nonComposable)
17
+ .map(c => c.name.toLowerCase());
18
+ }
@@ -77,6 +77,7 @@ const GeometryComponent = {
77
77
  name: 'Geometry',
78
78
  Editor: GeometryComponentEditor,
79
79
  View: GeometryComponentView,
80
+ nonComposable: true,
80
81
  defaultProperties: {
81
82
  geometryType: 'box',
82
83
  args: GEOMETRY_ARGS.box.defaults,
@@ -3,7 +3,7 @@ import { SingleTextureViewer, TextureListViewer } from '../../assetviewer/page';
3
3
  import { useEffect, useState } from 'react';
4
4
  import { FieldRenderer, Input } from './Input';
5
5
  import { useMemo } from 'react';
6
- import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace, NearestFilter, LinearFilter, NearestMipmapNearestFilter, NearestMipmapLinearFilter, LinearMipmapNearestFilter, LinearMipmapLinearFilter } from 'three';
6
+ import { RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace, NearestFilter, LinearFilter, NearestMipmapNearestFilter, NearestMipmapLinearFilter, LinearMipmapNearestFilter, LinearMipmapLinearFilter } from 'three';
7
7
  function TexturePicker({ value, onChange, basePath }) {
8
8
  const [textureFiles, setTextureFiles] = useState([]);
9
9
  const [showPicker, setShowPicker] = useState(false);
@@ -117,12 +117,13 @@ function MaterialComponentView({ properties, loadedTextures }) {
117
117
  return _jsx("meshStandardMaterial", { color: "red", wireframe: true });
118
118
  }
119
119
  const { color, wireframe = false } = properties;
120
- return (_jsx("meshStandardMaterial", { color: color, wireframe: wireframe, map: finalTexture, transparent: !!finalTexture, side: DoubleSide }, (_a = finalTexture === null || finalTexture === void 0 ? void 0 : finalTexture.uuid) !== null && _a !== void 0 ? _a : 'no-texture'));
120
+ return (_jsx("meshStandardMaterial", { color: color, wireframe: wireframe, map: finalTexture, transparent: !!finalTexture }, (_a = finalTexture === null || finalTexture === void 0 ? void 0 : finalTexture.uuid) !== null && _a !== void 0 ? _a : 'no-texture'));
121
121
  }
122
122
  const MaterialComponent = {
123
123
  name: 'Material',
124
124
  Editor: MaterialComponentEditor,
125
125
  View: MaterialComponentView,
126
+ nonComposable: true,
126
127
  defaultProperties: {
127
128
  color: '#ffffff',
128
129
  wireframe: false
@@ -58,6 +58,7 @@ const ModelComponent = {
58
58
  name: 'Model',
59
59
  Editor: ModelComponentEditor,
60
60
  View: ModelComponentView,
61
+ nonComposable: true,
61
62
  defaultProperties: {
62
63
  filename: '',
63
64
  instanced: false
@@ -39,6 +39,7 @@ const PhysicsComponent = {
39
39
  name: 'Physics',
40
40
  Editor: PhysicsComponentEditor,
41
41
  View: PhysicsComponentView,
42
+ nonComposable: true,
42
43
  defaultProperties: { type: 'dynamic', collider: 'hull' }
43
44
  };
44
45
  export default PhysicsComponent;
@@ -0,0 +1,3 @@
1
+ import { Component } from "./ComponentRegistry";
2
+ declare const TextComponent: Component;
3
+ export default TextComponent;
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { FieldRenderer } from "./Input";
3
+ import { Text } from 'three-text/three/react';
4
+ import { useRef, useState, useCallback } from 'react';
5
+ // Initialize HarfBuzz path for font shaping
6
+ Text.setHarfBuzzPath('/fonts/hb.wasm');
7
+ function TextComponentEditor({ component, onUpdate, }) {
8
+ const fields = [
9
+ {
10
+ name: 'text',
11
+ type: 'string',
12
+ label: 'Text',
13
+ placeholder: 'Enter text...',
14
+ },
15
+ {
16
+ name: 'color',
17
+ type: 'color',
18
+ label: 'Color',
19
+ },
20
+ {
21
+ name: 'font',
22
+ type: 'string',
23
+ label: 'Font',
24
+ placeholder: '/fonts/NotoSans-Regular.ttf',
25
+ },
26
+ {
27
+ name: 'size',
28
+ type: 'number',
29
+ label: 'Size',
30
+ min: 0.01,
31
+ step: 0.1,
32
+ },
33
+ {
34
+ name: 'depth',
35
+ type: 'number',
36
+ label: 'Depth',
37
+ min: 0,
38
+ step: 0.1,
39
+ },
40
+ {
41
+ name: 'width',
42
+ type: 'number',
43
+ label: 'Width',
44
+ min: 0,
45
+ step: 0.5,
46
+ },
47
+ {
48
+ name: 'align',
49
+ type: 'select',
50
+ label: 'Align',
51
+ options: [
52
+ { value: 'left', label: 'Left' },
53
+ { value: 'center', label: 'Center' },
54
+ { value: 'right', label: 'Right' },
55
+ ],
56
+ },
57
+ ];
58
+ return (_jsx(FieldRenderer, { fields: fields, values: component.properties, onChange: onUpdate }));
59
+ }
60
+ function TextComponentView({ properties }) {
61
+ const { text = '', font, size, depth, width, align, color } = properties;
62
+ const textContent = String(text || '');
63
+ const meshRef = useRef(null);
64
+ const [offset, setOffset] = useState([0, 0, 0]);
65
+ const handleLoad = useCallback((_geometry, info) => {
66
+ if (info === null || info === void 0 ? void 0 : info.planeBounds) {
67
+ const bounds = info.planeBounds;
68
+ // Calculate X offset based on alignment
69
+ let centerX = 0;
70
+ if (align === 'center') {
71
+ centerX = -(bounds.min.x + bounds.max.x) / 2;
72
+ }
73
+ else if (align === 'right') {
74
+ centerX = -bounds.max.x;
75
+ }
76
+ else {
77
+ // left alignment
78
+ centerX = -bounds.min.x;
79
+ }
80
+ const centerY = -(bounds.min.y + bounds.max.y) / 2;
81
+ setOffset([centerX, centerY, 0]);
82
+ }
83
+ }, [align]);
84
+ if (!textContent)
85
+ return null;
86
+ return (_jsx("group", { position: offset, children: _jsx(Text, { ref: meshRef, font: font, size: size, depth: depth, layout: { align, width }, color: color, onLoad: handleLoad, children: textContent }) }));
87
+ }
88
+ const TextComponent = {
89
+ name: 'Text',
90
+ Editor: TextComponentEditor,
91
+ View: TextComponentView,
92
+ nonComposable: true,
93
+ defaultProperties: {
94
+ text: 'Hello World',
95
+ color: '#888888',
96
+ font: '/fonts/NotoSans-Regular.ttf',
97
+ size: 0.5,
98
+ depth: 0,
99
+ width: 5,
100
+ align: 'center',
101
+ }
102
+ };
103
+ export default TextComponent;
@@ -41,6 +41,7 @@ function TransformComponentEditor({ component, onUpdate }) {
41
41
  const TransformComponent = {
42
42
  name: 'Transform',
43
43
  Editor: TransformComponentEditor,
44
+ nonComposable: true,
44
45
  defaultProperties: {
45
46
  position: [0, 0, 0],
46
47
  rotation: [0, 0, 0],
@@ -5,6 +5,7 @@ import PhysicsComponent from './PhysicsComponent';
5
5
  import SpotLightComponent from './SpotLightComponent';
6
6
  import DirectionalLightComponent from './DirectionalLightComponent';
7
7
  import ModelComponent from './ModelComponent';
8
+ import TextComponent from './TextComponent';
8
9
  export default [
9
10
  GeometryComponent,
10
11
  TransformComponent,
@@ -12,5 +13,6 @@ export default [
12
13
  PhysicsComponent,
13
14
  SpotLightComponent,
14
15
  DirectionalLightComponent,
15
- ModelComponent
16
+ ModelComponent,
17
+ TextComponent
16
18
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.45",
3
+ "version": "0.0.46",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -24,7 +24,8 @@
24
24
  "@react-three/rapier": ">=2.0.0",
25
25
  "react": ">=18.0.0",
26
26
  "react-dom": ">=18.0.0",
27
- "three": ">=0.182.0"
27
+ "three": ">=0.182.0",
28
+ "three-text": ">=0.4.4"
28
29
  },
29
30
  "devDependencies": {
30
31
  "@react-three/drei": "^10.7.7",
package/skill/SKILL.md ADDED
@@ -0,0 +1,491 @@
1
+ # react-three-game
2
+
3
+ A Claude Code skill for working with react-three-game, a JSON-first 3D game engine built on React Three Fiber, WebGPU, and Rapier Physics.
4
+
5
+ ## When to Use This Skill
6
+
7
+ Use this skill when:
8
+ - Creating or modifying 3D game scenes with react-three-game
9
+ - Working with prefab JSON structures
10
+ - Setting up physics-enabled game objects
11
+ - Creating custom components with editor UI (see Editor Mode section)
12
+
13
+ ## Core Concepts
14
+
15
+ ### GameObject Structure
16
+
17
+ Every game object follows this schema:
18
+
19
+ ```typescript
20
+ interface GameObject {
21
+ id: string;
22
+ disabled?: boolean;
23
+ hidden?: boolean;
24
+ components?: Record<string, { type: string; properties: any }>;
25
+ children?: GameObject[];
26
+ }
27
+ ```
28
+
29
+ ### Prefab JSON Format
30
+
31
+ Scenes are defined as JSON prefabs with a root node containing children:
32
+
33
+ ```json
34
+ {
35
+ "root": {
36
+ "id": "scene",
37
+ "children": [
38
+ {
39
+ "id": "my-object",
40
+ "components": {
41
+ "transform": { "type": "Transform", "properties": { "position": [0, 0, 0] } },
42
+ "geometry": { "type": "Geometry", "properties": { "geometryType": "box" } },
43
+ "material": { "type": "Material", "properties": { "color": "#ff0000" } }
44
+ }
45
+ }
46
+ ]
47
+ }
48
+ }
49
+ ```
50
+
51
+ ## Built-in Components
52
+
53
+ | Component | Type | Key Properties |
54
+ |-----------|------|----------------|
55
+ | Transform | `Transform` | `position: [x,y,z]`, `rotation: [x,y,z]` (radians), `scale: [x,y,z]` |
56
+ | Geometry | `Geometry` | `geometryType`: box/sphere/plane/cylinder, `args`: dimension array |
57
+ | Material | `Material` | `color`, `texture?`, `metalness?`, `roughness?`, `repeat?`, `repeatCount?` |
58
+ | Physics | `Physics` | `type`: "dynamic" or "fixed" |
59
+ | Model | `Model` | `filename` (GLB/FBX path), `instanced?` for GPU batching |
60
+ | SpotLight | `SpotLight` | `color`, `intensity`, `angle`, `penumbra`, `distance?`, `castShadow?` |
61
+ | DirectionalLight | `DirectionalLight` | `color`, `intensity`, `castShadow?`, `targetOffset?: [x,y,z]` |
62
+ | Text | `Text` | `text`, `font`, `size`, `depth`, `width`, `align`, `color` |
63
+
64
+ ### Geometry Args by Type
65
+
66
+ | geometryType | args array |
67
+ |--------------|------------|
68
+ | `box` | `[width, height, depth]` |
69
+ | `sphere` | `[radius, widthSegments, heightSegments]` |
70
+ | `plane` | `[width, height]` |
71
+ | `cylinder` | `[radiusTop, radiusBottom, height, radialSegments]` |
72
+
73
+ ### Material Texture Options
74
+
75
+ ```json
76
+ {
77
+ "type": "Material",
78
+ "properties": {
79
+ "color": "white",
80
+ "texture": "/textures/path/to/texture.png",
81
+ "repeat": true,
82
+ "repeatCount": [4, 4]
83
+ }
84
+ }
85
+ ```
86
+
87
+ - Use `"color": "white"` with textures for accurate texture colors
88
+ - `repeatCount: [x, y]` tiles the texture; match to geometry dimensions for proper scaling
89
+
90
+ ### Rotation Reference
91
+
92
+ Rotations use radians. Common values:
93
+ - `1.57` = 90° (π/2)
94
+ - `3.14` = 180° (π)
95
+ - `-1.57` = -90° (rotate plane flat: `rotation: [-1.57, 0, 0]`)
96
+
97
+ ## Common Patterns
98
+
99
+ ### Usage Modes
100
+
101
+ The library supports two modes:
102
+
103
+ **Play Mode** (default) - Immediate rendering without any editor UI. Use `GameCanvas` with `PrefabRoot` for a clean game experience.
104
+
105
+ **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.
106
+
107
+ ### Basic Scene Setup (Play Mode)
108
+
109
+ ```jsx
110
+ import { Physics } from '@react-three/rapier';
111
+ import { GameCanvas, PrefabRoot } from 'react-three-game';
112
+
113
+ <GameCanvas>
114
+ <Physics>
115
+ <PrefabRoot data={prefabData} />
116
+ </Physics>
117
+ </GameCanvas>
118
+ ```
119
+
120
+ ### Tree Manipulation Utilities
121
+
122
+ ```typescript
123
+ import { findNode, updateNode, updateNodeById, deleteNode, cloneNode } from 'react-three-game';
124
+
125
+ // Update a node by ID (optimized - avoids unnecessary object creation)
126
+ const updated = updateNodeById(root, nodeId, node => ({ ...node, disabled: true }));
127
+
128
+ // Find a node
129
+ const node = findNode(root, nodeId);
130
+
131
+ // Delete a node
132
+ const afterDelete = deleteNode(root, nodeId);
133
+
134
+ // Clone a node
135
+ const cloned = cloneNode(node);
136
+ ```
137
+
138
+ ## Building Game Levels
139
+
140
+ ### Complete Prefab Structure
141
+
142
+ ```json
143
+ {
144
+ "id": "level-id",
145
+ "name": "Level Name",
146
+ "root": {
147
+ "id": "root",
148
+ "enabled": true,
149
+ "visible": true,
150
+ "components": { ... },
151
+ "children": [ ... ]
152
+ }
153
+ }
154
+ ```
155
+
156
+ ### Floor/Ground Pattern
157
+
158
+ ```json
159
+ {
160
+ "id": "main-floor",
161
+ "components": {
162
+ "transform": { "type": "Transform", "properties": { "position": [0, -0.5, 0] } },
163
+ "geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [40, 1, 40] } },
164
+ "material": { "type": "Material", "properties": { "color": "white", "texture": "/textures/GreyboxTextures/greybox_dark_grid.png", "repeat": true, "repeatCount": [20, 20] } },
165
+ "physics": { "type": "Physics", "properties": { "type": "fixed" } }
166
+ }
167
+ }
168
+ ```
169
+
170
+ ### Platform Pattern
171
+
172
+ Floating platforms use "fixed" physics and smaller box geometry:
173
+
174
+ ```json
175
+ {
176
+ "id": "platform-1",
177
+ "components": {
178
+ "transform": { "type": "Transform", "properties": { "position": [-8, 2, -5] } },
179
+ "geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [6, 0.5, 4] } },
180
+ "material": { "type": "Material", "properties": { "color": "white", "texture": "/textures/GreyboxTextures/greybox_teal_grid.png", "repeat": true, "repeatCount": [3, 2] } },
181
+ "physics": { "type": "Physics", "properties": { "type": "fixed" } }
182
+ }
183
+ }
184
+ ```
185
+
186
+ ### Ramp Pattern
187
+
188
+ Rotate on the Z-axis to create inclined surfaces:
189
+
190
+ ```json
191
+ {
192
+ "id": "ramp",
193
+ "components": {
194
+ "transform": { "type": "Transform", "properties": { "position": [-12, 1, -5], "rotation": [0, 0, 0.3] } },
195
+ "geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [5, 0.3, 3] } },
196
+ "physics": { "type": "Physics", "properties": { "type": "fixed" } }
197
+ }
198
+ }
199
+ ```
200
+
201
+ ### Wall Pattern
202
+
203
+ Tall thin boxes positioned at boundaries:
204
+
205
+ ```json
206
+ {
207
+ "id": "wall-back",
208
+ "components": {
209
+ "transform": { "type": "Transform", "properties": { "position": [0, 3, -20] } },
210
+ "geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [40, 7, 1] } },
211
+ "physics": { "type": "Physics", "properties": { "type": "fixed" } }
212
+ }
213
+ }
214
+ ```
215
+
216
+ ### Three-Point Lighting Setup
217
+
218
+ Good lighting uses main, fill, and accent lights:
219
+
220
+ ```json
221
+ [
222
+ { "id": "main-light", "components": { "transform": { "properties": { "position": [10, 15, 10] } }, "spotlight": { "type": "SpotLight", "properties": { "color": "#ffffff", "intensity": 200, "angle": 0.8, "castShadow": true } } } },
223
+ { "id": "fill-light", "components": { "transform": { "properties": { "position": [-10, 12, -5] } }, "spotlight": { "type": "SpotLight", "properties": { "color": "#b0c4de", "intensity": 80, "angle": 0.9 } } } },
224
+ { "id": "accent-light", "components": { "transform": { "properties": { "position": [0, 10, -15] } }, "spotlight": { "type": "SpotLight", "properties": { "color": "#ffd700", "intensity": 50, "angle": 0.4 } } } }
225
+ ]
226
+ ```
227
+
228
+ ### Available Greybox Textures
229
+
230
+ Located in `/textures/GreyboxTextures/`:
231
+ - `greybox_dark_grid.png` - dark floors
232
+ - `greybox_light_grid.png` - light surfaces
233
+ - `greybox_teal_grid.png`, `greybox_purple_grid.png`, `greybox_orange_grid.png` - colored platforms
234
+ - `greybox_red_grid.png`, `greybox_blue_grid.png` - obstacles/hazards
235
+ - `greybox_yellow_grid.png`, `greybox_lime_grid.png`, `greybox_green_grid.png` - special areas
236
+
237
+ ### Metallic/Special Materials
238
+
239
+ For goal platforms or special objects, use metalness and roughness:
240
+
241
+ ```json
242
+ {
243
+ "type": "Material",
244
+ "properties": {
245
+ "color": "#FFD700",
246
+ "metalness": 0.8,
247
+ "roughness": 0.2
248
+ }
249
+ }
250
+ ```
251
+
252
+ ### Text Component
253
+
254
+ 3D text rendering using `three-text`. The Text component is non-composable (cannot have children).
255
+
256
+ | Property | Type | Default | Description |
257
+ |----------|------|---------|-------------|
258
+ | `text` | string | `"Hello World"` | Text content to display |
259
+ | `color` | string | `"#888888"` | Text color (hex or CSS color) |
260
+ | `font` | string | `"/fonts/NotoSans-Regular.ttf"` | Path to TTF font file |
261
+ | `size` | number | `0.5` | Font size in world units |
262
+ | `depth` | number | `0` | 3D extrusion depth (0 for flat text) |
263
+ | `width` | number | `5` | Text block width for wrapping/alignment |
264
+ | `align` | string | `"center"` | Horizontal alignment: `"left"`, `"center"`, `"right"` |
265
+
266
+ ```json
267
+ {
268
+ "id": "title-text",
269
+ "components": {
270
+ "transform": { "type": "Transform", "properties": { "position": [0, 3, 0] } },
271
+ "text": {
272
+ "type": "Text",
273
+ "properties": {
274
+ "text": "Welcome",
275
+ "color": "#ffffff",
276
+ "size": 1,
277
+ "depth": 0.1,
278
+ "align": "center"
279
+ }
280
+ }
281
+ }
282
+ }
283
+ ```
284
+
285
+ ### Model Placement
286
+
287
+ GLB models don't need geometry/material components:
288
+
289
+ ```json
290
+ {
291
+ "id": "tree-1",
292
+ "components": {
293
+ "transform": { "type": "Transform", "properties": { "position": [-12, 0, 10], "scale": [1.5, 1.5, 1.5] } },
294
+ "model": { "type": "Model", "properties": { "filename": "models/environment/tree.glb" } }
295
+ }
296
+ }
297
+ ```
298
+
299
+ Available models: `models/environment/tree.glb`, `models/environment/servers.glb`, `models/environment/cubeart.glb`
300
+
301
+ ## Editor Mode
302
+
303
+ Use editor mode when building scenes visually or creating custom components with inspector UI.
304
+
305
+ ### Using the Visual Editor
306
+
307
+ ```jsx
308
+ import { PrefabEditor } from 'react-three-game';
309
+
310
+ <PrefabEditor
311
+ initialPrefab={sceneData}
312
+ onPrefabChange={setSceneData}
313
+ />
314
+ ```
315
+
316
+ The editor provides a full GUI with:
317
+ - Scene hierarchy tree for navigating and selecting objects
318
+ - Component inspector panel for editing properties
319
+ - Transform gizmos for manipulating objects visually
320
+ - Keyboard shortcuts: **T** (Translate), **R** (Rotate), **S** (Scale)
321
+
322
+ ### Programmatic Updates with PrefabEditor
323
+
324
+ Use the editor ref to update prefabs programmatically:
325
+
326
+ ```jsx
327
+ import { useRef } from 'react';
328
+ import { PrefabEditor, updateNodeById } from 'react-three-game';
329
+ import type { PrefabEditorRef, Prefab } from 'react-three-game';
330
+
331
+ function Game() {
332
+ const editorRef = useRef<PrefabEditorRef>(null);
333
+
334
+ const movePlayer = () => {
335
+ if (!editorRef.current) return;
336
+ const prefab = editorRef.current.prefab;
337
+ const newRoot = updateNodeById(prefab.root, "player", node => ({
338
+ ...node,
339
+ components: {
340
+ ...node.components,
341
+ transform: {
342
+ ...node.components!.transform!,
343
+ properties: { ...node.components!.transform!.properties, position: [5, 0, 0] }
344
+ }
345
+ }
346
+ }));
347
+ editorRef.current.setPrefab({ ...prefab, root: newRoot });
348
+ };
349
+
350
+ return (
351
+ <PrefabEditor ref={editorRef} initialPrefab={sceneData}>
352
+ {/* Children render inside the Canvas - can use useFrame here */}
353
+ </PrefabEditor>
354
+ );
355
+ }
356
+ ```
357
+
358
+ The `PrefabEditorRef` provides:
359
+ - `prefab` - current prefab state
360
+ - `setPrefab(prefab)` - update the prefab
361
+ - `screenshot()` - save canvas as PNG
362
+ - `exportGLB()` - export scene as GLB
363
+
364
+ ### Live Node Updates with useFrame
365
+
366
+ To animate objects by updating the prefab JSON at runtime, pass a child component to `PrefabEditor` that uses `useFrame`:
367
+
368
+ ```tsx
369
+ import { useRef } from "react";
370
+ import { useFrame } from "@react-three/fiber";
371
+ import { PrefabEditor, updateNodeById } from "react-three-game";
372
+ import type { Prefab, PrefabEditorRef } from "react-three-game";
373
+
374
+ // Animation component runs inside the editor's Canvas
375
+ function PlayerAnimator({ editorRef }: { editorRef: React.RefObject<PrefabEditorRef | null> }) {
376
+ const velocityRef = useRef({ x: 0, z: 0 });
377
+
378
+ useFrame(() => {
379
+ if (!editorRef.current) return;
380
+
381
+ const prefab = editorRef.current.prefab;
382
+ const newRoot = updateNodeById(prefab.root, "player", (node) => {
383
+ const transform = node.components?.transform?.properties;
384
+ if (!transform) return node;
385
+
386
+ const pos = transform.position as [number, number, number];
387
+ return {
388
+ ...node,
389
+ components: {
390
+ ...node.components,
391
+ transform: {
392
+ ...node.components!.transform!,
393
+ properties: {
394
+ ...transform,
395
+ position: [pos[0] + velocityRef.current.x * 0.02, pos[1], pos[2] + velocityRef.current.z * 0.02],
396
+ },
397
+ },
398
+ },
399
+ };
400
+ });
401
+
402
+ if (newRoot !== prefab.root) {
403
+ editorRef.current.setPrefab({ ...prefab, root: newRoot });
404
+ }
405
+ });
406
+
407
+ return null;
408
+ }
409
+
410
+ // Usage
411
+ function Game() {
412
+ const editorRef = useRef<PrefabEditorRef>(null);
413
+
414
+ return (
415
+ <PrefabEditor ref={editorRef} initialPrefab={sceneData}>
416
+ <PlayerAnimator editorRef={editorRef} />
417
+ </PrefabEditor>
418
+ );
419
+ }
420
+ ```
421
+
422
+ Key points:
423
+ - Pass animation components as `children` to `PrefabEditor` - they render inside the Canvas
424
+ - Access prefab via `editorRef.current.prefab` and update via `editorRef.current.setPrefab()`
425
+ - `updateNodeById` is optimized to avoid recreating unchanged branches
426
+ - Store mutable state (velocities, timers) in refs to avoid re-renders
427
+
428
+ ### Creating a Custom Component
429
+
430
+ ```tsx
431
+ import { Component, registerComponent, FieldRenderer, FieldDefinition } from 'react-three-game';
432
+
433
+ const myFields: FieldDefinition[] = [
434
+ { name: 'speed', type: 'number', label: 'Speed', step: 0.1 },
435
+ { name: 'enabled', type: 'boolean', label: 'Enabled' },
436
+ ];
437
+
438
+ const MyComponent: Component = {
439
+ name: 'MyComponent',
440
+ Editor: ({ component, onUpdate }) => (
441
+ <FieldRenderer fields={myFields} values={component.properties} onChange={onUpdate} />
442
+ ),
443
+ View: ({ properties, children }) => {
444
+ // Runtime behavior here
445
+ return <group>{children}</group>;
446
+ },
447
+ defaultProperties: { speed: 1, enabled: true }
448
+ };
449
+
450
+ registerComponent(MyComponent);
451
+ ```
452
+
453
+ ### Field Types for Editor UI
454
+
455
+ | Type | Description | Options |
456
+ |------|-------------|---------|
457
+ | `vector3` | X/Y/Z inputs | `snap?: number` |
458
+ | `number` | Numeric input | `min?`, `max?`, `step?` |
459
+ | `string` | Text input | `placeholder?` |
460
+ | `color` | Color picker | - |
461
+ | `boolean` | Checkbox | - |
462
+ | `select` | Dropdown | `options: { value, label }[]` |
463
+ | `custom` | Custom render function | `render: (props) => ReactNode` |
464
+
465
+ ## Dependencies
466
+
467
+ Required peer dependencies:
468
+ - `@react-three/fiber`
469
+ - `@react-three/rapier`
470
+ - `three`
471
+
472
+ Install with:
473
+ ```bash
474
+ npm i react-three-game @react-three/fiber @react-three/rapier three
475
+ ```
476
+
477
+ ## File Structure
478
+
479
+ ```
480
+ /src → library source (published to npm)
481
+ /docs → Next.js demo site
482
+ /dist → built output
483
+ ```
484
+
485
+ ## Development Commands
486
+
487
+ ```bash
488
+ npm run dev # tsc --watch + docs site
489
+ npm run build # build to /dist
490
+ npm run release # build + publish
491
+ ```
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@react-three-game/skills",
3
+ "version": "0.0.1",
4
+ "description": "Agent skill for working with react-three-game - a JSON-first 3D game engine",
5
+ "repository": {
6
+ "url": "https://github.com/prnth/react-three-game/tree/main/skill"
7
+ },
8
+ "keywords": [
9
+ "agentic",
10
+ "skill",
11
+ "react-three-fiber",
12
+ "game-engine",
13
+ "3d",
14
+ "webgpu"
15
+ ],
16
+ "license": "MIT"
17
+ }
@@ -4,7 +4,7 @@ import { BoxHelper, Euler, Group, Matrix4, Object3D, Quaternion, SRGBColorSpace,
4
4
  import { ThreeEvent } from "@react-three/fiber";
5
5
 
6
6
  import { Prefab, GameObject as GameObjectType } from "./types";
7
- import { getComponent, registerComponent } from "./components/ComponentRegistry";
7
+ import { getComponent, registerComponent, getNonComposableKeys } from "./components/ComponentRegistry";
8
8
  import components from "./components";
9
9
  import { loadModel } from "../dragdrop/modelLoader";
10
10
  import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./InstanceProvider";
@@ -488,10 +488,12 @@ function renderCoreNode(
488
488
  const geometry = gameObject.components?.geometry;
489
489
  const material = gameObject.components?.material;
490
490
  const model = gameObject.components?.model;
491
+ const text = gameObject.components?.text;
491
492
 
492
493
  const geometryDef = geometry && getComponent("Geometry");
493
494
  const materialDef = material && getComponent("Material");
494
495
  const modelDef = model && getComponent("Model");
496
+ const textDef = text && getComponent("Text");
495
497
 
496
498
  const contextProps = {
497
499
  loadedModels: ctx.loadedModels,
@@ -506,7 +508,7 @@ function renderCoreNode(
506
508
 
507
509
  if (gameObject.components) {
508
510
  Object.entries(gameObject.components)
509
- .filter(([k]) => !["geometry", "material", "model", "transform", "physics"].includes(k))
511
+ .filter(([k]) => !getNonComposableKeys().includes(k))
510
512
  .forEach(([key, comp]) => {
511
513
  if (!comp?.type) return;
512
514
  const def = getComponent(comp.type);
@@ -551,6 +553,13 @@ function renderCoreNode(
551
553
  {leaves}
552
554
  </mesh>
553
555
  );
556
+ } else if (text && textDef?.View) {
557
+ core = (
558
+ <>
559
+ <textDef.View properties={text.properties} {...contextProps} />
560
+ {leaves}
561
+ </>
562
+ );
554
563
  } else {
555
564
  core = <>{leaves}</>;
556
565
  }
@@ -12,6 +12,8 @@ export interface Component {
12
12
  defaultProperties: any;
13
13
  // Allow View to accept extra props for special cases (like material)
14
14
  View?: FC<any>;
15
+ // Non-composable components have special rendering logic in PrefabRoot
16
+ nonComposable?: boolean;
15
17
  }
16
18
 
17
19
  const REGISTRY: Record<string, Component> = {};
@@ -30,3 +32,9 @@ export function getComponent(name: string): Component | undefined {
30
32
  export function getAllComponents(): Record<string, Component> {
31
33
  return { ...REGISTRY };
32
34
  }
35
+
36
+ export function getNonComposableKeys(): string[] {
37
+ return Object.values(REGISTRY)
38
+ .filter(c => c.nonComposable)
39
+ .map(c => c.name.toLowerCase());
40
+ }
@@ -110,6 +110,7 @@ const GeometryComponent: Component = {
110
110
  name: 'Geometry',
111
111
  Editor: GeometryComponentEditor,
112
112
  View: GeometryComponentView,
113
+ nonComposable: true,
113
114
  defaultProperties: {
114
115
  geometryType: 'box',
115
116
  args: GEOMETRY_ARGS.box.defaults,
@@ -4,7 +4,6 @@ import { Component } from './ComponentRegistry';
4
4
  import { FieldRenderer, FieldDefinition, Input } from './Input';
5
5
  import { useMemo } from 'react';
6
6
  import {
7
- DoubleSide,
8
7
  RepeatWrapping,
9
8
  ClampToEdgeWrapping,
10
9
  SRGBColorSpace,
@@ -190,7 +189,6 @@ function MaterialComponentView({ properties, loadedTextures }: { properties: any
190
189
  wireframe={wireframe}
191
190
  map={finalTexture}
192
191
  transparent={!!finalTexture}
193
- side={DoubleSide}
194
192
  />
195
193
  );
196
194
  }
@@ -199,6 +197,7 @@ const MaterialComponent: Component = {
199
197
  name: 'Material',
200
198
  Editor: MaterialComponentEditor,
201
199
  View: MaterialComponentView,
200
+ nonComposable: true,
202
201
  defaultProperties: {
203
202
  color: '#ffffff',
204
203
  wireframe: false
@@ -112,6 +112,7 @@ const ModelComponent: Component = {
112
112
  name: 'Model',
113
113
  Editor: ModelComponentEditor,
114
114
  View: ModelComponentView,
115
+ nonComposable: true,
115
116
  defaultProperties: {
116
117
  filename: '',
117
118
  instanced: false
@@ -82,6 +82,7 @@ const PhysicsComponent: Component = {
82
82
  name: 'Physics',
83
83
  Editor: PhysicsComponentEditor,
84
84
  View: PhysicsComponentView,
85
+ nonComposable: true,
85
86
  defaultProperties: { type: 'dynamic', collider: 'hull' }
86
87
  };
87
88
 
@@ -0,0 +1,136 @@
1
+ import { Component } from "./ComponentRegistry";
2
+ import { FieldRenderer, FieldDefinition } from "./Input";
3
+ import { Text } from 'three-text/three/react';
4
+ import { useRef, useState, useCallback } from 'react';
5
+ import { BufferGeometry, Mesh } from "three";
6
+
7
+ // Initialize HarfBuzz path for font shaping
8
+ Text.setHarfBuzzPath('/fonts/hb.wasm');
9
+
10
+ function TextComponentEditor({
11
+ component,
12
+ onUpdate,
13
+ }: {
14
+ component: any;
15
+ onUpdate: (newProps: any) => void;
16
+ }) {
17
+ const fields: FieldDefinition[] = [
18
+ {
19
+ name: 'text',
20
+ type: 'string',
21
+ label: 'Text',
22
+ placeholder: 'Enter text...',
23
+ },
24
+ {
25
+ name: 'color',
26
+ type: 'color',
27
+ label: 'Color',
28
+ },
29
+ {
30
+ name: 'font',
31
+ type: 'string',
32
+ label: 'Font',
33
+ placeholder: '/fonts/NotoSans-Regular.ttf',
34
+ },
35
+ {
36
+ name: 'size',
37
+ type: 'number',
38
+ label: 'Size',
39
+ min: 0.01,
40
+ step: 0.1,
41
+ },
42
+ {
43
+ name: 'depth',
44
+ type: 'number',
45
+ label: 'Depth',
46
+ min: 0,
47
+ step: 0.1,
48
+ },
49
+ {
50
+ name: 'width',
51
+ type: 'number',
52
+ label: 'Width',
53
+ min: 0,
54
+ step: 0.5,
55
+ },
56
+ {
57
+ name: 'align',
58
+ type: 'select',
59
+ label: 'Align',
60
+ options: [
61
+ { value: 'left', label: 'Left' },
62
+ { value: 'center', label: 'Center' },
63
+ { value: 'right', label: 'Right' },
64
+ ],
65
+ },
66
+ ];
67
+
68
+ return (
69
+ <FieldRenderer
70
+ fields={fields}
71
+ values={component.properties}
72
+ onChange={onUpdate}
73
+ />
74
+ );
75
+ }
76
+
77
+ function TextComponentView({ properties }: { properties: any }) {
78
+ const { text = '', font, size, depth, width, align, color } = properties;
79
+ const textContent = String(text || '');
80
+ const meshRef = useRef<Mesh>(null);
81
+ const [offset, setOffset] = useState<[number, number, number]>([0, 0, 0]);
82
+
83
+ const handleLoad = useCallback((_geometry: BufferGeometry, info: any) => {
84
+ if (info?.planeBounds) {
85
+ const bounds = info.planeBounds;
86
+ // Calculate X offset based on alignment
87
+ let centerX = 0;
88
+ if (align === 'center') {
89
+ centerX = -(bounds.min.x + bounds.max.x) / 2;
90
+ } else if (align === 'right') {
91
+ centerX = -bounds.max.x;
92
+ } else {
93
+ // left alignment
94
+ centerX = -bounds.min.x;
95
+ }
96
+ const centerY = -(bounds.min.y + bounds.max.y) / 2;
97
+ setOffset([centerX, centerY, 0]);
98
+ }
99
+ }, [align]);
100
+
101
+ if (!textContent) return null;
102
+
103
+ return (
104
+ <group position={offset}>
105
+ <Text
106
+ ref={meshRef}
107
+ font={font}
108
+ size={size}
109
+ depth={depth}
110
+ layout={{ align, width }}
111
+ color={color}
112
+ onLoad={handleLoad}
113
+ >
114
+ {textContent}
115
+ </Text>
116
+ </group>
117
+ );
118
+ }
119
+
120
+ const TextComponent: Component = {
121
+ name: 'Text',
122
+ Editor: TextComponentEditor,
123
+ View: TextComponentView,
124
+ nonComposable: true,
125
+ defaultProperties: {
126
+ text: 'Hello World',
127
+ color: '#888888',
128
+ font: '/fonts/NotoSans-Regular.ttf',
129
+ size: 0.5,
130
+ depth: 0,
131
+ width: 5,
132
+ align: 'center',
133
+ }
134
+ };
135
+
136
+ export default TextComponent;
@@ -104,6 +104,7 @@ function TransformComponentEditor({ component, onUpdate }: {
104
104
  const TransformComponent: Component = {
105
105
  name: 'Transform',
106
106
  Editor: TransformComponentEditor,
107
+ nonComposable: true,
107
108
  defaultProperties: {
108
109
  position: [0, 0, 0],
109
110
  rotation: [0, 0, 0],
@@ -5,6 +5,7 @@ import PhysicsComponent from './PhysicsComponent';
5
5
  import SpotLightComponent from './SpotLightComponent';
6
6
  import DirectionalLightComponent from './DirectionalLightComponent';
7
7
  import ModelComponent from './ModelComponent';
8
+ import TextComponent from './TextComponent';
8
9
 
9
10
  export default [
10
11
  GeometryComponent,
@@ -13,6 +14,7 @@ export default [
13
14
  PhysicsComponent,
14
15
  SpotLightComponent,
15
16
  DirectionalLightComponent,
16
- ModelComponent
17
+ ModelComponent,
18
+ TextComponent
17
19
  ];
18
20