react-three-game 0.0.27 → 0.0.29
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/dist/tools/prefabeditor/EditorTree.js +30 -36
- package/dist/tools/prefabeditor/EditorUI.js +2 -1
- package/dist/tools/prefabeditor/PrefabRoot.js +5 -2
- package/dist/tools/prefabeditor/components/PhysicsComponent.js +4 -2
- package/dist/tools/prefabeditor/components/TransformComponent.js +63 -8
- package/dist/tools/prefabeditor/types.d.ts +1 -0
- package/dist/tools/prefabeditor/utils.d.ts +1 -0
- package/dist/tools/prefabeditor/utils.js +20 -2
- package/package.json +1 -1
- package/src/tools/prefabeditor/EditorTree.tsx +49 -40
- package/src/tools/prefabeditor/EditorUI.tsx +4 -2
- package/src/tools/prefabeditor/PrefabRoot.tsx +7 -1
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +9 -5
- package/src/tools/prefabeditor/components/TransformComponent.tsx +122 -27
- package/src/tools/prefabeditor/types.ts +1 -0
- package/src/tools/prefabeditor/utils.ts +30 -0
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { getComponent } from './components/ComponentRegistry';
|
|
4
4
|
import { base, tree, menu } from './styles';
|
|
5
|
-
import { findNode, findParent, deleteNode, cloneNode } from './utils';
|
|
5
|
+
import { findNode, findParent, deleteNode, cloneNode, updateNodeById } from './utils';
|
|
6
6
|
export default function EditorTree({ prefabData, setPrefabData, selectedId, setSelectedId }) {
|
|
7
7
|
const [contextMenu, setContextMenu] = useState(null);
|
|
8
8
|
const [draggedId, setDraggedId] = useState(null);
|
|
@@ -28,6 +28,7 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
28
28
|
var _a;
|
|
29
29
|
const newNode = {
|
|
30
30
|
id: crypto.randomUUID(),
|
|
31
|
+
name: "New Node",
|
|
31
32
|
components: {
|
|
32
33
|
transform: {
|
|
33
34
|
type: "Transform",
|
|
@@ -35,30 +36,25 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
};
|
|
38
|
-
setPrefabData(prev => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
parent.children = parent.children || [];
|
|
43
|
-
parent.children.push(newNode);
|
|
44
|
-
}
|
|
45
|
-
return Object.assign(Object.assign({}, prev), { root: newRoot });
|
|
46
|
-
});
|
|
39
|
+
setPrefabData(prev => (Object.assign(Object.assign({}, prev), { root: updateNodeById(prev.root, parentId, parent => {
|
|
40
|
+
var _a;
|
|
41
|
+
return (Object.assign(Object.assign({}, parent), { children: [...((_a = parent.children) !== null && _a !== void 0 ? _a : []), newNode] }));
|
|
42
|
+
}) })));
|
|
47
43
|
setContextMenu(null);
|
|
48
44
|
};
|
|
49
45
|
const handleDuplicate = (nodeId) => {
|
|
50
46
|
if (nodeId === prefabData.root.id)
|
|
51
47
|
return;
|
|
52
48
|
setPrefabData(prev => {
|
|
53
|
-
const
|
|
54
|
-
const parent = findParent(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
49
|
+
const node = findNode(prev.root, nodeId);
|
|
50
|
+
const parent = findParent(prev.root, nodeId);
|
|
51
|
+
if (!node || !parent)
|
|
52
|
+
return prev;
|
|
53
|
+
const clone = cloneNode(node);
|
|
54
|
+
return Object.assign(Object.assign({}, prev), { root: updateNodeById(prev.root, parent.id, p => {
|
|
55
|
+
var _a;
|
|
56
|
+
return (Object.assign(Object.assign({}, p), { children: [...((_a = p.children) !== null && _a !== void 0 ? _a : []), clone] }));
|
|
57
|
+
}) });
|
|
62
58
|
});
|
|
63
59
|
setContextMenu(null);
|
|
64
60
|
};
|
|
@@ -92,28 +88,26 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
92
88
|
return;
|
|
93
89
|
e.preventDefault();
|
|
94
90
|
setPrefabData(prev => {
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
if (draggedNode && findNode(draggedNode, targetId))
|
|
99
|
-
return prev;
|
|
100
|
-
const parent = findParent(newRoot, draggedId);
|
|
101
|
-
if (!parent)
|
|
91
|
+
const draggedNode = findNode(prev.root, draggedId);
|
|
92
|
+
const oldParent = findParent(prev.root, draggedId);
|
|
93
|
+
if (!draggedNode || !oldParent)
|
|
102
94
|
return prev;
|
|
103
|
-
|
|
104
|
-
if (
|
|
95
|
+
// Prevent dropping into own subtree
|
|
96
|
+
if (findNode(draggedNode, targetId))
|
|
105
97
|
return prev;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
98
|
+
// 1. Remove from old parent
|
|
99
|
+
let root = updateNodeById(prev.root, oldParent.id, p => (Object.assign(Object.assign({}, p), { children: p.children.filter(c => c.id !== draggedId) })));
|
|
100
|
+
// 2. Add to new parent
|
|
101
|
+
root = updateNodeById(root, targetId, t => {
|
|
102
|
+
var _a;
|
|
103
|
+
return (Object.assign(Object.assign({}, t), { children: [...((_a = t.children) !== null && _a !== void 0 ? _a : []), draggedNode] }));
|
|
104
|
+
});
|
|
105
|
+
return Object.assign(Object.assign({}, prev), { root });
|
|
113
106
|
});
|
|
114
107
|
setDraggedId(null);
|
|
115
108
|
};
|
|
116
109
|
const renderNode = (node, depth = 0) => {
|
|
110
|
+
var _a;
|
|
117
111
|
if (!node)
|
|
118
112
|
return null;
|
|
119
113
|
const isSelected = node.id === selectedId;
|
|
@@ -126,7 +120,7 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
126
120
|
marginRight: 4,
|
|
127
121
|
cursor: 'pointer',
|
|
128
122
|
visibility: hasChildren ? 'visible' : 'hidden'
|
|
129
|
-
}, onClick: (e) => hasChildren && toggleCollapse(e, node.id), children: isCollapsed ? '▶' : '▼' }), !isRoot && _jsx("span", { style: { marginRight: 4, opacity: 0.4 }, children: "\u22EE\u22EE" }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis' }, children: node.id })] }), !isCollapsed && node.children && node.children.map(child => renderNode(child, depth + 1))] }, node.id));
|
|
123
|
+
}, onClick: (e) => hasChildren && toggleCollapse(e, node.id), children: isCollapsed ? '▶' : '▼' }), !isRoot && _jsx("span", { style: { marginRight: 4, opacity: 0.4 }, children: "\u22EE\u22EE" }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis' }, children: (_a = node.name) !== null && _a !== void 0 ? _a : node.id })] }), !isCollapsed && node.children && node.children.map(child => renderNode(child, depth + 1))] }, node.id));
|
|
130
124
|
};
|
|
131
125
|
return (_jsxs(_Fragment, { children: [_jsxs("div", { style: Object.assign(Object.assign({}, tree.panel), { width: collapsed ? 'auto' : 224 }), onClick: () => setContextMenu(null), children: [_jsxs("div", { style: base.header, onClick: () => setCollapsed(!collapsed), children: [_jsx("span", { children: "Scene" }), _jsx("span", { children: collapsed ? '▶' : '◀' })] }), !collapsed && _jsx("div", { style: tree.scroll, children: renderNode(prefabData.root) })] }), contextMenu && (_jsxs("div", { style: Object.assign(Object.assign({}, menu.container), { top: contextMenu.y, left: contextMenu.x }), onClick: (e) => e.stopPropagation(), onPointerLeave: () => setContextMenu(null), children: [_jsx("button", { style: menu.item, onClick: () => handleAddChild(contextMenu.nodeId), children: "Add Child" }), contextMenu.nodeId !== prefabData.root.id && (_jsxs(_Fragment, { children: [_jsx("button", { style: menu.item, onClick: () => handleDuplicate(contextMenu.nodeId), children: "Duplicate" }), _jsx("button", { style: Object.assign(Object.assign({}, menu.item), menu.danger), onClick: () => handleDelete(contextMenu.nodeId), children: "Delete" })] }))] }))] }));
|
|
132
126
|
}
|
|
@@ -32,6 +32,7 @@ function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transf
|
|
|
32
32
|
return _jsxs(_Fragment, { children: [_jsxs("div", { style: inspector.panel, children: [_jsxs("div", { style: base.header, onClick: () => setCollapsed(!collapsed), children: [_jsx("span", { children: "Inspector" }), _jsx("span", { children: collapsed ? '◀' : '▼' })] }), !collapsed && selectedNode && (_jsx(NodeInspector, { node: selectedNode, updateNode: updateNodeHandler, deleteNode: deleteNodeHandler, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }))] }), _jsx("div", { style: { position: 'absolute', top: 8, left: 8, zIndex: 20 }, children: _jsx(EditorTree, { prefabData: prefabData, setPrefabData: setPrefabData, selectedId: selectedId, setSelectedId: setSelectedId }) })] });
|
|
33
33
|
}
|
|
34
34
|
function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransformMode, basePath }) {
|
|
35
|
+
var _a;
|
|
35
36
|
const ALL_COMPONENTS = getAllComponents();
|
|
36
37
|
const allKeys = Object.keys(ALL_COMPONENTS);
|
|
37
38
|
const available = allKeys.filter(k => { var _a; return !((_a = node.components) === null || _a === void 0 ? void 0 : _a[k.toLowerCase()]); });
|
|
@@ -41,7 +42,7 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
|
|
|
41
42
|
if (!newAvailable.includes(addType))
|
|
42
43
|
setAddType(newAvailable[0] || "");
|
|
43
44
|
}, [Object.keys(node.components || {}).join(',')]);
|
|
44
|
-
return _jsxs("div", { style: inspector.content, children: [_jsxs("div", { style: base.section, children: [_jsx("div", { style: base.label, children: "Node ID" }), _jsx("input", { style: base.input, value: node.
|
|
45
|
+
return _jsxs("div", { style: inspector.content, children: [_jsxs("div", { style: base.section, children: [_jsx("div", { style: base.label, children: "Node ID" }), _jsx("input", { style: base.input, value: (_a = node.name) !== null && _a !== void 0 ? _a : "", onChange: e => updateNode(n => (Object.assign(Object.assign({}, n), { name: e.target.value }))) })] }), _jsxs("div", { style: base.section, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, children: [_jsx("div", { style: base.label, children: "Components" }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), base.btnDanger), onClick: deleteNode, children: "Delete Node" })] }), node.components && Object.entries(node.components).map(([key, comp]) => {
|
|
45
46
|
if (!comp)
|
|
46
47
|
return null;
|
|
47
48
|
const def = ALL_COMPONENTS[comp.type];
|
|
@@ -108,7 +108,7 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
|
|
|
108
108
|
return _jsxs("group", { ref: ref, children: [_jsx(GameInstanceProvider, { models: loadedModels, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, selectedId: selectedId, editMode: editMode, children: _jsx(GameObjectRenderer, { gameObject: data.root, selectedId: selectedId, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: new Matrix4() }) }), editMode && _jsxs(_Fragment, { children: [_jsx(MapControls, { makeDefault: true }), selectedId && selectedObject && (_jsx(TransformControls, { object: selectedObject, mode: transformMode, space: "local", onObjectChange: onTransformChange }))] })] });
|
|
109
109
|
});
|
|
110
110
|
function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loadedModels, loadedTextures, editMode, parentMatrix = new Matrix4(), }) {
|
|
111
|
-
var _a, _b, _c, _d, _e;
|
|
111
|
+
var _a, _b, _c, _d, _e, _f;
|
|
112
112
|
// Early return if gameObject is null or undefined
|
|
113
113
|
if (!gameObject)
|
|
114
114
|
return null;
|
|
@@ -157,7 +157,10 @@ function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loa
|
|
|
157
157
|
const innerGroup = (_jsxs("group", { ref: groupRef, position: transformProps.position, rotation: transformProps.rotation, scale: transformProps.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: [core, children] }));
|
|
158
158
|
// --- 7. Wrap with physics if needed (RigidBody as outer parent, no transform) ---
|
|
159
159
|
const physics = (_e = gameObject.components) === null || _e === void 0 ? void 0 : _e.physics;
|
|
160
|
-
if
|
|
160
|
+
// Determine if model is safe/ready for physics. No model => safe; model => only safe once loaded.
|
|
161
|
+
const modelReady = !((_f = gameObject.components) === null || _f === void 0 ? void 0 : _f.model) ||
|
|
162
|
+
!!loadedModels[gameObject.components.model.properties.filename];
|
|
163
|
+
if (physics && !editMode && modelReady) {
|
|
161
164
|
const physicsDef = getComponent('Physics');
|
|
162
165
|
if (physicsDef === null || physicsDef === void 0 ? void 0 : physicsDef.View) {
|
|
163
166
|
return (_jsx(physicsDef.View, { properties: physics.properties, children: innerGroup }));
|
|
@@ -3,14 +3,16 @@ import { RigidBody } from "@react-three/rapier";
|
|
|
3
3
|
const selectClass = "w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50";
|
|
4
4
|
const labelClass = "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5";
|
|
5
5
|
function PhysicsComponentEditor({ component, onUpdate }) {
|
|
6
|
-
const { type, collider = 'hull' } = component.properties;
|
|
6
|
+
const { type = 'dynamic', collider = 'hull' } = component.properties;
|
|
7
7
|
return (_jsxs("div", { children: [_jsx("label", { className: labelClass, children: "Type" }), _jsxs("select", { className: selectClass, value: type, onChange: e => onUpdate({ type: e.target.value }), children: [_jsx("option", { value: "dynamic", children: "Dynamic" }), _jsx("option", { value: "fixed", children: "Fixed" })] }), _jsx("label", { className: `${labelClass} mt-2`, children: "Collider" }), _jsxs("select", { className: selectClass, value: collider, onChange: e => onUpdate({ collider: e.target.value }), children: [_jsx("option", { value: "hull", children: "Hull (convex)" }), _jsx("option", { value: "trimesh", children: "Trimesh (exact)" }), _jsx("option", { value: "cuboid", children: "Cuboid (box)" }), _jsx("option", { value: "ball", children: "Ball (sphere)" })] })] }));
|
|
8
8
|
}
|
|
9
9
|
function PhysicsComponentView({ properties, editMode, children }) {
|
|
10
10
|
if (editMode)
|
|
11
11
|
return _jsx(_Fragment, { children: children });
|
|
12
12
|
const colliders = properties.collider || (properties.type === 'fixed' ? 'trimesh' : 'hull');
|
|
13
|
-
|
|
13
|
+
// Remount RigidBody when collider/type changes to avoid Rapier hook dependency warnings
|
|
14
|
+
const rbKey = `${properties.type || 'dynamic'}_${colliders}`;
|
|
15
|
+
return (_jsx(RigidBody, { type: properties.type, colliders: colliders, children: children }, rbKey));
|
|
14
16
|
}
|
|
15
17
|
const PhysicsComponent = {
|
|
16
18
|
name: 'Physics',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
3
|
function TransformComponentEditor({ component, onUpdate, transformMode, setTransformMode }) {
|
|
3
4
|
const s = {
|
|
4
5
|
button: {
|
|
@@ -33,15 +34,69 @@ const TransformComponent = {
|
|
|
33
34
|
};
|
|
34
35
|
export default TransformComponent;
|
|
35
36
|
export function Vector3Input({ label, value, onChange }) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
const [draft, setDraft] = useState(() => value.map(v => v.toString()));
|
|
38
|
+
// Sync external changes (gizmo, undo, etc.)
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
setDraft(value.map(v => v.toString()));
|
|
41
|
+
}, [value[0], value[1], value[2]]);
|
|
42
|
+
const dragState = useRef(null);
|
|
43
|
+
const commit = (index) => {
|
|
44
|
+
const num = parseFloat(draft[index]);
|
|
45
|
+
if (Number.isFinite(num)) {
|
|
46
|
+
const next = [...value];
|
|
47
|
+
next[index] = num;
|
|
48
|
+
onChange(next);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const startScrub = (e, index) => {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
dragState.current = {
|
|
54
|
+
index,
|
|
55
|
+
startX: e.clientX,
|
|
56
|
+
startValue: value[index]
|
|
57
|
+
};
|
|
58
|
+
e.target.setPointerCapture(e.pointerId);
|
|
59
|
+
document.body.style.cursor = "ew-resize";
|
|
60
|
+
};
|
|
61
|
+
const onScrubMove = (e) => {
|
|
62
|
+
if (!dragState.current)
|
|
63
|
+
return;
|
|
64
|
+
const { index, startX, startValue } = dragState.current;
|
|
65
|
+
const dx = e.clientX - startX;
|
|
66
|
+
let speed = 0.02;
|
|
67
|
+
if (e.shiftKey)
|
|
68
|
+
speed *= 0.1; // fine
|
|
69
|
+
if (e.altKey)
|
|
70
|
+
speed *= 5; // coarse
|
|
71
|
+
const nextValue = startValue + dx * speed;
|
|
72
|
+
const next = [...value];
|
|
73
|
+
next[index] = nextValue;
|
|
74
|
+
setDraft(d => {
|
|
75
|
+
const copy = [...d];
|
|
76
|
+
copy[index] = nextValue.toFixed(3);
|
|
77
|
+
return copy;
|
|
78
|
+
});
|
|
79
|
+
onChange(next);
|
|
80
|
+
};
|
|
81
|
+
const endScrub = (e) => {
|
|
82
|
+
if (!dragState.current)
|
|
83
|
+
return;
|
|
84
|
+
dragState.current = null;
|
|
85
|
+
document.body.style.cursor = "";
|
|
86
|
+
e.target.releasePointerCapture(e.pointerId);
|
|
40
87
|
};
|
|
41
88
|
const axes = [
|
|
42
|
-
{ key:
|
|
43
|
-
{ key:
|
|
44
|
-
{ key:
|
|
89
|
+
{ key: "x", color: "red", index: 0 },
|
|
90
|
+
{ key: "y", color: "green", index: 1 },
|
|
91
|
+
{ key: "z", color: "blue", index: 2 }
|
|
45
92
|
];
|
|
46
|
-
return _jsxs("div", { className: "mb-2", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1", children: label }), _jsx("div", { className: "flex gap-1", children: axes.map(({ key, color, index }) => (_jsxs("div", { className: "flex-1 flex items-center gap-1 bg-black/30 border border-cyan-500/20 rounded px-1.5 py-1 min-h-[32px]", children: [_jsx("span", { className: `text-xs font-bold text-${color}-400 w-3`, children: key.toUpperCase() }), _jsx("input", { className: "flex-1 bg-transparent text-xs text-cyan-200 font-mono outline-none w-full min-w-0", type: "
|
|
93
|
+
return (_jsxs("div", { className: "mb-2", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1", children: label }), _jsx("div", { className: "flex gap-1", children: axes.map(({ key, color, index }) => (_jsxs("div", { className: "flex-1 flex items-center gap-1 bg-black/30 border border-cyan-500/20 rounded px-1.5 py-1 min-h-[32px]", children: [_jsx("span", { className: `text-xs font-bold text-${color}-400 w-3 cursor-ew-resize select-none`, onPointerDown: e => startScrub(e, index), onPointerMove: onScrubMove, onPointerUp: endScrub, children: key.toUpperCase() }), _jsx("input", { className: "flex-1 bg-transparent text-xs text-cyan-200 font-mono outline-none w-full min-w-0", type: "text", value: draft[index], onChange: e => {
|
|
94
|
+
const next = [...draft];
|
|
95
|
+
next[index] = e.target.value;
|
|
96
|
+
setDraft(next);
|
|
97
|
+
}, onBlur: () => commit(index), onKeyDown: e => {
|
|
98
|
+
if (e.key === "Enter") {
|
|
99
|
+
e.target.blur();
|
|
100
|
+
}
|
|
101
|
+
} })] }, key))) })] }));
|
|
47
102
|
}
|
|
@@ -17,3 +17,4 @@ export declare function deleteNode(root: GameObject, id: string): GameObject | n
|
|
|
17
17
|
export declare function cloneNode(node: GameObject): GameObject;
|
|
18
18
|
/** Get component data from a node */
|
|
19
19
|
export declare function getComponent<T = any>(node: GameObject, type: string): T | undefined;
|
|
20
|
+
export declare function updateNodeById(root: GameObject, id: string, updater: (node: GameObject) => GameObject): GameObject;
|
|
@@ -61,8 +61,8 @@ export function deleteNode(root, id) {
|
|
|
61
61
|
}
|
|
62
62
|
/** Deep clone a node with new IDs */
|
|
63
63
|
export function cloneNode(node) {
|
|
64
|
-
var _a;
|
|
65
|
-
return Object.assign(Object.assign({}, node), { id: crypto.randomUUID(),
|
|
64
|
+
var _a, _b;
|
|
65
|
+
return Object.assign(Object.assign({}, node), { id: crypto.randomUUID(), name: `${(_a = node.name) !== null && _a !== void 0 ? _a : "Node"} Copy`, children: (_b = node.children) === null || _b === void 0 ? void 0 : _b.map(cloneNode) });
|
|
66
66
|
}
|
|
67
67
|
/** Get component data from a node */
|
|
68
68
|
export function getComponent(node, type) {
|
|
@@ -70,3 +70,21 @@ export function getComponent(node, type) {
|
|
|
70
70
|
const comp = Object.values((_a = node.components) !== null && _a !== void 0 ? _a : {}).find(c => (c === null || c === void 0 ? void 0 : c.type) === type);
|
|
71
71
|
return comp === null || comp === void 0 ? void 0 : comp.properties;
|
|
72
72
|
}
|
|
73
|
+
export function updateNodeById(root, id, updater) {
|
|
74
|
+
if (root.id === id) {
|
|
75
|
+
return updater(root);
|
|
76
|
+
}
|
|
77
|
+
if (!root.children) {
|
|
78
|
+
return root;
|
|
79
|
+
}
|
|
80
|
+
let didChange = false;
|
|
81
|
+
const newChildren = root.children.map(child => {
|
|
82
|
+
const updated = updateNodeById(child, id, updater);
|
|
83
|
+
if (updated !== child)
|
|
84
|
+
didChange = true;
|
|
85
|
+
return updated;
|
|
86
|
+
});
|
|
87
|
+
if (!didChange)
|
|
88
|
+
return root;
|
|
89
|
+
return Object.assign(Object.assign({}, root), { children: newChildren });
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@ import { Dispatch, SetStateAction, useState, MouseEvent } from 'react';
|
|
|
2
2
|
import { Prefab, GameObject } from "./types";
|
|
3
3
|
import { getComponent } from './components/ComponentRegistry';
|
|
4
4
|
import { base, tree, menu } from './styles';
|
|
5
|
-
import { findNode, findParent, deleteNode, cloneNode } from './utils';
|
|
5
|
+
import { findNode, findParent, deleteNode, cloneNode, updateNodeById } from './utils';
|
|
6
6
|
|
|
7
7
|
export default function EditorTree({ prefabData, setPrefabData, selectedId, setSelectedId }: {
|
|
8
8
|
prefabData?: Prefab;
|
|
@@ -36,6 +36,7 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
36
36
|
const handleAddChild = (parentId: string) => {
|
|
37
37
|
const newNode: GameObject = {
|
|
38
38
|
id: crypto.randomUUID(),
|
|
39
|
+
name: "New Node",
|
|
39
40
|
components: {
|
|
40
41
|
transform: {
|
|
41
42
|
type: "Transform",
|
|
@@ -44,15 +45,13 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
44
45
|
}
|
|
45
46
|
};
|
|
46
47
|
|
|
47
|
-
setPrefabData(prev => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return { ...prev, root: newRoot };
|
|
55
|
-
});
|
|
48
|
+
setPrefabData(prev => ({
|
|
49
|
+
...prev,
|
|
50
|
+
root: updateNodeById(prev.root, parentId, parent => ({
|
|
51
|
+
...parent,
|
|
52
|
+
children: [...(parent.children ?? []), newNode]
|
|
53
|
+
}))
|
|
54
|
+
}));
|
|
56
55
|
setContextMenu(null);
|
|
57
56
|
};
|
|
58
57
|
|
|
@@ -60,20 +59,25 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
60
59
|
if (nodeId === prefabData.root.id) return;
|
|
61
60
|
|
|
62
61
|
setPrefabData(prev => {
|
|
63
|
-
const
|
|
64
|
-
const parent = findParent(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
const node = findNode(prev.root, nodeId);
|
|
63
|
+
const parent = findParent(prev.root, nodeId);
|
|
64
|
+
if (!node || !parent) return prev;
|
|
65
|
+
|
|
66
|
+
const clone = cloneNode(node);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
...prev,
|
|
70
|
+
root: updateNodeById(prev.root, parent.id, p => ({
|
|
71
|
+
...p,
|
|
72
|
+
children: [...(p.children ?? []), clone]
|
|
73
|
+
}))
|
|
74
|
+
};
|
|
73
75
|
});
|
|
76
|
+
|
|
74
77
|
setContextMenu(null);
|
|
75
78
|
};
|
|
76
79
|
|
|
80
|
+
|
|
77
81
|
const handleDelete = (nodeId: string) => {
|
|
78
82
|
if (nodeId === prefabData.root.id) return;
|
|
79
83
|
|
|
@@ -104,29 +108,32 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
104
108
|
e.preventDefault();
|
|
105
109
|
|
|
106
110
|
setPrefabData(prev => {
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
if (draggedNode
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
111
|
+
const draggedNode = findNode(prev.root, draggedId);
|
|
112
|
+
const oldParent = findParent(prev.root, draggedId);
|
|
113
|
+
if (!draggedNode || !oldParent) return prev;
|
|
114
|
+
|
|
115
|
+
// Prevent dropping into own subtree
|
|
116
|
+
if (findNode(draggedNode, targetId)) return prev;
|
|
117
|
+
|
|
118
|
+
// 1. Remove from old parent
|
|
119
|
+
let root = updateNodeById(prev.root, oldParent.id, p => ({
|
|
120
|
+
...p,
|
|
121
|
+
children: p.children!.filter(c => c.id !== draggedId)
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
// 2. Add to new parent
|
|
125
|
+
root = updateNodeById(root, targetId, t => ({
|
|
126
|
+
...t,
|
|
127
|
+
children: [...(t.children ?? []), draggedNode]
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
return { ...prev, root };
|
|
126
131
|
});
|
|
132
|
+
|
|
127
133
|
setDraggedId(null);
|
|
128
134
|
};
|
|
129
135
|
|
|
136
|
+
|
|
130
137
|
const renderNode = (node: GameObject, depth = 0): React.ReactNode => {
|
|
131
138
|
if (!node) return null;
|
|
132
139
|
|
|
@@ -164,7 +171,9 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
164
171
|
{isCollapsed ? '▶' : '▼'}
|
|
165
172
|
</span>
|
|
166
173
|
{!isRoot && <span style={{ marginRight: 4, opacity: 0.4 }}>⋮⋮</span>}
|
|
167
|
-
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
174
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
175
|
+
{node.name ?? node.id}
|
|
176
|
+
</span>
|
|
168
177
|
</div>
|
|
169
178
|
{!isCollapsed && node.children && node.children.map(child => renderNode(child, depth + 1))}
|
|
170
179
|
</div>
|
|
@@ -85,8 +85,10 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
|
|
|
85
85
|
<div style={base.label}>Node ID</div>
|
|
86
86
|
<input
|
|
87
87
|
style={base.input}
|
|
88
|
-
value={node.
|
|
89
|
-
onChange={e =>
|
|
88
|
+
value={node.name ?? ""}
|
|
89
|
+
onChange={e =>
|
|
90
|
+
updateNode(n => ({ ...n, name: e.target.value }))
|
|
91
|
+
}
|
|
90
92
|
/>
|
|
91
93
|
</div>
|
|
92
94
|
|
|
@@ -271,7 +271,13 @@ function GameObjectRenderer({
|
|
|
271
271
|
|
|
272
272
|
// --- 7. Wrap with physics if needed (RigidBody as outer parent, no transform) ---
|
|
273
273
|
const physics = gameObject.components?.physics;
|
|
274
|
-
|
|
274
|
+
|
|
275
|
+
// Determine if model is safe/ready for physics. No model => safe; model => only safe once loaded.
|
|
276
|
+
const modelReady =
|
|
277
|
+
!gameObject.components?.model ||
|
|
278
|
+
!!loadedModels[gameObject.components.model.properties.filename];
|
|
279
|
+
|
|
280
|
+
if (physics && !editMode && modelReady) {
|
|
275
281
|
const physicsDef = getComponent('Physics');
|
|
276
282
|
if (physicsDef?.View) {
|
|
277
283
|
return (
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { RigidBody } from "@react-three/rapier";
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
2
3
|
import { Component } from "./ComponentRegistry";
|
|
3
4
|
|
|
4
5
|
const selectClass = "w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50";
|
|
5
6
|
const labelClass = "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5";
|
|
6
7
|
|
|
7
|
-
function PhysicsComponentEditor({ component, onUpdate }: { component: any; onUpdate: (props: any) => void }) {
|
|
8
|
-
const { type, collider = 'hull' } = component.properties;
|
|
8
|
+
function PhysicsComponentEditor({ component, onUpdate }: { component: { properties: { type?: 'dynamic' | 'fixed'; collider?: string;[k: string]: any } }; onUpdate: (props: Partial<Record<string, any>>) => void }) {
|
|
9
|
+
const { type = 'dynamic', collider = 'hull' } = component.properties;
|
|
9
10
|
return (
|
|
10
11
|
<div>
|
|
11
12
|
<label className={labelClass}>Type</label>
|
|
@@ -26,9 +27,9 @@ function PhysicsComponentEditor({ component, onUpdate }: { component: any; onUpd
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
interface PhysicsViewProps {
|
|
29
|
-
properties: { type
|
|
30
|
+
properties: { type?: 'dynamic' | 'fixed'; collider?: string };
|
|
30
31
|
editMode?: boolean;
|
|
31
|
-
children?:
|
|
32
|
+
children?: ReactNode;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function PhysicsComponentView({ properties, editMode, children }: PhysicsViewProps) {
|
|
@@ -36,8 +37,11 @@ function PhysicsComponentView({ properties, editMode, children }: PhysicsViewPro
|
|
|
36
37
|
|
|
37
38
|
const colliders = properties.collider || (properties.type === 'fixed' ? 'trimesh' : 'hull');
|
|
38
39
|
|
|
40
|
+
// Remount RigidBody when collider/type changes to avoid Rapier hook dependency warnings
|
|
41
|
+
const rbKey = `${properties.type || 'dynamic'}_${colliders}`;
|
|
42
|
+
|
|
39
43
|
return (
|
|
40
|
-
<RigidBody type={properties.type} colliders={colliders as any}>
|
|
44
|
+
<RigidBody key={rbKey} type={properties.type} colliders={colliders as any}>
|
|
41
45
|
{children}
|
|
42
46
|
</RigidBody>
|
|
43
47
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { Component } from "./ComponentRegistry";
|
|
3
3
|
|
|
4
4
|
function TransformComponentEditor({ component, onUpdate, transformMode, setTransformMode }: {
|
|
@@ -67,36 +67,131 @@ const TransformComponent: Component = {
|
|
|
67
67
|
|
|
68
68
|
export default TransformComponent;
|
|
69
69
|
|
|
70
|
+
export function Vector3Input({
|
|
71
|
+
label,
|
|
72
|
+
value,
|
|
73
|
+
onChange
|
|
74
|
+
}: {
|
|
75
|
+
label: string;
|
|
76
|
+
value: [number, number, number];
|
|
77
|
+
onChange: (v: [number, number, number]) => void;
|
|
78
|
+
}) {
|
|
79
|
+
const [draft, setDraft] = useState<[string, string, string]>(
|
|
80
|
+
() => value.map(v => v.toString()) as any
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Sync external changes (gizmo, undo, etc.)
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
setDraft(value.map(v => v.toString()) as any);
|
|
86
|
+
}, [value[0], value[1], value[2]]);
|
|
87
|
+
|
|
88
|
+
const dragState = useRef<{
|
|
89
|
+
index: number;
|
|
90
|
+
startX: number;
|
|
91
|
+
startValue: number;
|
|
92
|
+
} | null>(null);
|
|
93
|
+
|
|
94
|
+
const commit = (index: number) => {
|
|
95
|
+
const num = parseFloat(draft[index]);
|
|
96
|
+
if (Number.isFinite(num)) {
|
|
97
|
+
const next = [...value] as [number, number, number];
|
|
98
|
+
next[index] = num;
|
|
99
|
+
onChange(next);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const startScrub = (e: React.PointerEvent, index: number) => {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
|
|
106
|
+
dragState.current = {
|
|
107
|
+
index,
|
|
108
|
+
startX: e.clientX,
|
|
109
|
+
startValue: value[index]
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
113
|
+
document.body.style.cursor = "ew-resize";
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const onScrubMove = (e: React.PointerEvent) => {
|
|
117
|
+
if (!dragState.current) return;
|
|
118
|
+
|
|
119
|
+
const { index, startX, startValue } = dragState.current;
|
|
120
|
+
const dx = e.clientX - startX;
|
|
121
|
+
|
|
122
|
+
let speed = 0.02;
|
|
123
|
+
if (e.shiftKey) speed *= 0.1; // fine
|
|
124
|
+
if (e.altKey) speed *= 5; // coarse
|
|
70
125
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
126
|
+
const nextValue = startValue + dx * speed;
|
|
127
|
+
const next = [...value] as [number, number, number];
|
|
128
|
+
next[index] = nextValue;
|
|
129
|
+
|
|
130
|
+
setDraft(d => {
|
|
131
|
+
const copy = [...d] as any;
|
|
132
|
+
copy[index] = nextValue.toFixed(3);
|
|
133
|
+
return copy;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
onChange(next);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const endScrub = (e: React.PointerEvent) => {
|
|
140
|
+
if (!dragState.current) return;
|
|
141
|
+
|
|
142
|
+
dragState.current = null;
|
|
143
|
+
document.body.style.cursor = "";
|
|
144
|
+
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
|
76
145
|
};
|
|
77
146
|
|
|
78
147
|
const axes = [
|
|
79
|
-
{ key:
|
|
80
|
-
{ key:
|
|
81
|
-
{ key:
|
|
148
|
+
{ key: "x", color: "red", index: 0 },
|
|
149
|
+
{ key: "y", color: "green", index: 1 },
|
|
150
|
+
{ key: "z", color: "blue", index: 2 }
|
|
82
151
|
] as const;
|
|
83
152
|
|
|
84
|
-
return
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
153
|
+
return (
|
|
154
|
+
<div className="mb-2">
|
|
155
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1">
|
|
156
|
+
{label}
|
|
157
|
+
</label>
|
|
158
|
+
|
|
159
|
+
<div className="flex gap-1">
|
|
160
|
+
{axes.map(({ key, color, index }) => (
|
|
161
|
+
<div
|
|
162
|
+
key={key}
|
|
163
|
+
className="flex-1 flex items-center gap-1 bg-black/30 border border-cyan-500/20 rounded px-1.5 py-1 min-h-[32px]"
|
|
164
|
+
>
|
|
165
|
+
{/* SCRUB HANDLE */}
|
|
166
|
+
<span
|
|
167
|
+
className={`text-xs font-bold text-${color}-400 w-3 cursor-ew-resize select-none`}
|
|
168
|
+
onPointerDown={e => startScrub(e, index)}
|
|
169
|
+
onPointerMove={onScrubMove}
|
|
170
|
+
onPointerUp={endScrub}
|
|
171
|
+
>
|
|
172
|
+
{key.toUpperCase()}
|
|
173
|
+
</span>
|
|
174
|
+
|
|
175
|
+
{/* TEXT INPUT */}
|
|
176
|
+
<input
|
|
177
|
+
className="flex-1 bg-transparent text-xs text-cyan-200 font-mono outline-none w-full min-w-0"
|
|
178
|
+
type="text"
|
|
179
|
+
value={draft[index]}
|
|
180
|
+
onChange={e => {
|
|
181
|
+
const next = [...draft] as any;
|
|
182
|
+
next[index] = e.target.value;
|
|
183
|
+
setDraft(next);
|
|
184
|
+
}}
|
|
185
|
+
onBlur={() => commit(index)}
|
|
186
|
+
onKeyDown={e => {
|
|
187
|
+
if (e.key === "Enter") {
|
|
188
|
+
(e.target as HTMLInputElement).blur();
|
|
189
|
+
}
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
100
195
|
</div>
|
|
101
|
-
|
|
102
|
-
}
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -69,6 +69,7 @@ export function cloneNode(node: GameObject): GameObject {
|
|
|
69
69
|
return {
|
|
70
70
|
...node,
|
|
71
71
|
id: crypto.randomUUID(),
|
|
72
|
+
name: `${node.name ?? "Node"} Copy`,
|
|
72
73
|
children: node.children?.map(cloneNode)
|
|
73
74
|
};
|
|
74
75
|
}
|
|
@@ -78,3 +79,32 @@ export function getComponent<T = any>(node: GameObject, type: string): T | undef
|
|
|
78
79
|
const comp = Object.values(node.components ?? {}).find(c => c?.type === type);
|
|
79
80
|
return comp?.properties as T | undefined;
|
|
80
81
|
}
|
|
82
|
+
|
|
83
|
+
export function updateNodeById(
|
|
84
|
+
root: GameObject,
|
|
85
|
+
id: string,
|
|
86
|
+
updater: (node: GameObject) => GameObject
|
|
87
|
+
): GameObject {
|
|
88
|
+
if (root.id === id) {
|
|
89
|
+
return updater(root);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!root.children) {
|
|
93
|
+
return root;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let didChange = false;
|
|
97
|
+
|
|
98
|
+
const newChildren = root.children.map(child => {
|
|
99
|
+
const updated = updateNodeById(child, id, updater);
|
|
100
|
+
if (updated !== child) didChange = true;
|
|
101
|
+
return updated;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!didChange) return root;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
...root,
|
|
108
|
+
children: newChildren
|
|
109
|
+
};
|
|
110
|
+
}
|