react-three-game 0.0.28 → 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/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/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];
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|