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.
@@ -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
- const newRoot = JSON.parse(JSON.stringify(prev.root));
40
- const parent = findNode(newRoot, parentId);
41
- if (parent) {
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 newRoot = JSON.parse(JSON.stringify(prev.root));
54
- const parent = findParent(newRoot, nodeId);
55
- const node = findNode(newRoot, nodeId);
56
- if (parent && node) {
57
- const clone = cloneNode(node);
58
- parent.children = parent.children || [];
59
- parent.children.push(clone);
60
- }
61
- return Object.assign(Object.assign({}, prev), { root: newRoot });
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
- var _a;
96
- const newRoot = JSON.parse(JSON.stringify(prev.root));
97
- const draggedNode = findNode(newRoot, draggedId);
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
- const nodeToMove = (_a = parent.children) === null || _a === void 0 ? void 0 : _a.find(c => c.id === draggedId);
104
- if (!nodeToMove)
95
+ // Prevent dropping into own subtree
96
+ if (findNode(draggedNode, targetId))
105
97
  return prev;
106
- parent.children = parent.children.filter(c => c.id !== draggedId);
107
- const target = findNode(newRoot, targetId);
108
- if (target) {
109
- target.children = target.children || [];
110
- target.children.push(nodeToMove);
111
- }
112
- return Object.assign(Object.assign({}, prev), { root: newRoot });
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.id, onChange: e => updateNode(n => (Object.assign(Object.assign({}, n), { id: 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
+ 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 handleChange = (index, val) => {
37
- const newValue = [...value];
38
- newValue[index] = parseFloat(val) || 0;
39
- onChange(newValue);
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: 'x', color: 'red', index: 0 },
43
- { key: 'y', color: 'green', index: 1 },
44
- { key: 'z', color: 'blue', index: 2 }
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: "number", step: "0.1", value: value[index].toFixed(2), onChange: e => handleChange(index, e.target.value), onFocus: e => e.target.select() })] }, key))) })] });
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
  }
@@ -5,6 +5,7 @@ export interface Prefab {
5
5
  }
6
6
  export interface GameObject {
7
7
  id: string;
8
+ name?: string;
8
9
  disabled?: boolean;
9
10
  hidden?: boolean;
10
11
  children?: GameObject[];
@@ -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(), children: (_a = node.children) === null || _a === void 0 ? void 0 : _a.map(cloneNode) });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -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
- const newRoot = JSON.parse(JSON.stringify(prev.root));
49
- const parent = findNode(newRoot, parentId);
50
- if (parent) {
51
- parent.children = parent.children || [];
52
- parent.children.push(newNode);
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 newRoot = JSON.parse(JSON.stringify(prev.root));
64
- const parent = findParent(newRoot, nodeId);
65
- const node = findNode(newRoot, nodeId);
66
-
67
- if (parent && node) {
68
- const clone = cloneNode(node);
69
- parent.children = parent.children || [];
70
- parent.children.push(clone);
71
- }
72
- return { ...prev, root: newRoot };
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 newRoot = JSON.parse(JSON.stringify(prev.root));
108
- const draggedNode = findNode(newRoot, draggedId);
109
- if (draggedNode && findNode(draggedNode, targetId)) return prev;
110
-
111
- const parent = findParent(newRoot, draggedId);
112
- if (!parent) return prev;
113
-
114
- const nodeToMove = parent.children?.find(c => c.id === draggedId);
115
- if (!nodeToMove) return prev;
116
-
117
- parent.children = parent.children!.filter(c => c.id !== draggedId);
118
-
119
- const target = findNode(newRoot, targetId);
120
- if (target) {
121
- target.children = target.children || [];
122
- target.children.push(nodeToMove);
123
- }
124
-
125
- return { ...prev, root: newRoot };
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' }}>{node.id}</span>
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.id}
89
- onChange={e => updateNode(n => ({ ...n, id: e.target.value }))}
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
- export function Vector3Input({ label, value, onChange }: { label: string, value: [number, number, number], onChange: (v: [number, number, number]) => void }) {
72
- const handleChange = (index: number, val: string) => {
73
- const newValue = [...value] as [number, number, number];
74
- newValue[index] = parseFloat(val) || 0;
75
- onChange(newValue);
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: 'x', color: 'red', index: 0 },
80
- { key: 'y', color: 'green', index: 1 },
81
- { key: 'z', color: 'blue', index: 2 }
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 <div className="mb-2">
85
- <label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1">{label}</label>
86
- <div className="flex gap-1">
87
- {axes.map(({ key, color, index }) => (
88
- <div key={key} 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]">
89
- <span className={`text-xs font-bold text-${color}-400 w-3`}>{key.toUpperCase()}</span>
90
- <input
91
- className="flex-1 bg-transparent text-xs text-cyan-200 font-mono outline-none w-full min-w-0"
92
- type="number"
93
- step="0.1"
94
- value={value[index].toFixed(2)}
95
- onChange={e => handleChange(index, e.target.value)}
96
- onFocus={e => e.target.select()}
97
- />
98
- </div>
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
- </div>
102
- }
196
+ );
197
+ }
@@ -6,6 +6,7 @@ export interface Prefab {
6
6
 
7
7
  export interface GameObject {
8
8
  id: string;
9
+ name?: string;
9
10
  disabled?: boolean;
10
11
  hidden?: boolean;
11
12
  children?: GameObject[];
@@ -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
+ }