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.
- package/assets/architecture.png +0 -0
- package/dist/tools/prefabeditor/PrefabRoot.js +8 -3
- package/dist/tools/prefabeditor/components/ComponentRegistry.d.ts +2 -0
- package/dist/tools/prefabeditor/components/ComponentRegistry.js +5 -0
- package/dist/tools/prefabeditor/components/GeometryComponent.js +1 -0
- package/dist/tools/prefabeditor/components/MaterialComponent.js +3 -2
- package/dist/tools/prefabeditor/components/ModelComponent.js +1 -0
- package/dist/tools/prefabeditor/components/PhysicsComponent.js +1 -0
- package/dist/tools/prefabeditor/components/TextComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/TextComponent.js +103 -0
- package/dist/tools/prefabeditor/components/TransformComponent.js +1 -0
- package/dist/tools/prefabeditor/components/index.js +3 -1
- package/package.json +3 -2
- package/skill/SKILL.md +491 -0
- package/skill/package.json +17 -0
- package/src/tools/prefabeditor/PrefabRoot.tsx +11 -2
- package/src/tools/prefabeditor/components/ComponentRegistry.ts +8 -0
- package/src/tools/prefabeditor/components/GeometryComponent.tsx +1 -0
- package/src/tools/prefabeditor/components/MaterialComponent.tsx +1 -2
- package/src/tools/prefabeditor/components/ModelComponent.tsx +1 -0
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +1 -0
- package/src/tools/prefabeditor/components/TextComponent.tsx +136 -0
- package/src/tools/prefabeditor/components/TransformComponent.tsx +1 -0
- package/src/tools/prefabeditor/components/index.ts +3 -1
package/assets/architecture.png
CHANGED
|
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]) => !
|
|
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
|
+
}
|
|
@@ -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 {
|
|
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
|
|
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
|
|
@@ -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;
|
|
@@ -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.
|
|
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]) => !
|
|
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
|
+
}
|
|
@@ -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
|
|
@@ -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
|
|