react-three-game 0.0.15 → 0.0.17

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.
@@ -4,6 +4,46 @@ import EditorTree from './EditorTree';
4
4
  import { getAllComponents } from './components/ComponentRegistry';
5
5
  function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transformMode, setTransformMode, basePath }) {
6
6
  const [isInspectorCollapsed, setIsInspectorCollapsed] = useState(false);
7
+ const ui = {
8
+ panel: {
9
+ position: 'absolute',
10
+ top: 8,
11
+ right: 8,
12
+ zIndex: 20,
13
+ width: 260,
14
+ background: 'rgba(0,0,0,0.55)',
15
+ color: 'rgba(255,255,255,0.9)',
16
+ border: '1px solid rgba(255,255,255,0.12)',
17
+ borderRadius: 6,
18
+ overflow: 'hidden',
19
+ backdropFilter: 'blur(6px)',
20
+ WebkitBackdropFilter: 'blur(6px)',
21
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
22
+ fontSize: 11,
23
+ lineHeight: 1.2,
24
+ },
25
+ header: {
26
+ padding: '4px 6px',
27
+ display: 'flex',
28
+ alignItems: 'center',
29
+ justifyContent: 'space-between',
30
+ cursor: 'pointer',
31
+ background: 'rgba(255,255,255,0.05)',
32
+ borderBottom: '1px solid rgba(255,255,255,0.10)',
33
+ textTransform: 'uppercase',
34
+ letterSpacing: '0.08em',
35
+ fontSize: 10,
36
+ color: 'rgba(255,255,255,0.7)',
37
+ userSelect: 'none',
38
+ WebkitUserSelect: 'none',
39
+ },
40
+ left: {
41
+ position: 'absolute',
42
+ top: 8,
43
+ left: 8,
44
+ zIndex: 20,
45
+ },
46
+ };
7
47
  const updateNode = (updater) => {
8
48
  if (!prefabData || !setPrefabData || !selectedId)
9
49
  return;
@@ -24,12 +64,112 @@ function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transf
24
64
  };
25
65
  const selectedNode = selectedId && prefabData ? findNode(prefabData.root, selectedId) : null;
26
66
  // if (!selectedNode) return null;
27
- return _jsxs(_Fragment, { children: [_jsxs("div", { style: { position: 'absolute', top: "0.5rem", right: "0.5rem", zIndex: 20, backgroundColor: "rgba(0,0,0,0.7)", backdropFilter: "blur(4px)", color: "white", border: "1px solid rgba(0,255,255,0.3)" }, children: [_jsxs("div", { className: "px-1.5 py-1 font-mono text-[10px] bg-cyan-500/10 border-b border-cyan-500/30 sticky top-0 uppercase tracking-wider text-cyan-400/80 cursor-pointer hover:bg-cyan-500/20 flex items-center justify-between", onClick: () => setIsInspectorCollapsed(!isInspectorCollapsed), children: [_jsx("span", { children: "Inspector" }), _jsx("span", { className: "text-[8px]", children: isInspectorCollapsed ? '◀' : '▶' })] }), !isInspectorCollapsed && selectedNode && (_jsx(NodeInspector, { node: selectedNode, updateNode: updateNode, deleteNode: deleteNode, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }))] }), _jsx("div", { style: { position: 'absolute', top: "0.5rem", left: "0.5rem", zIndex: 20 }, children: _jsx(EditorTree, { prefabData: prefabData, setPrefabData: setPrefabData, selectedId: selectedId, setSelectedId: setSelectedId }) })] });
67
+ return _jsxs(_Fragment, { children: [_jsxs("div", { style: ui.panel, children: [_jsxs("div", { style: ui.header, onClick: () => setIsInspectorCollapsed(!isInspectorCollapsed), onPointerEnter: (e) => {
68
+ e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
69
+ }, onPointerLeave: (e) => {
70
+ e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
71
+ }, children: [_jsx("span", { children: "Inspector" }), _jsx("span", { style: { fontSize: 10, opacity: 0.8 }, children: isInspectorCollapsed ? '◀' : '▶' })] }), !isInspectorCollapsed && selectedNode && (_jsx(NodeInspector, { node: selectedNode, updateNode: updateNode, deleteNode: deleteNode, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }))] }), _jsx("div", { style: ui.left, children: _jsx(EditorTree, { prefabData: prefabData, setPrefabData: setPrefabData, selectedId: selectedId, setSelectedId: setSelectedId }) })] });
28
72
  }
29
73
  function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransformMode, basePath }) {
30
74
  const ALL_COMPONENTS = getAllComponents();
31
75
  const allComponentKeys = Object.keys(ALL_COMPONENTS);
32
76
  const [addComponentType, setAddComponentType] = useState(allComponentKeys[0]);
77
+ const s = {
78
+ root: {
79
+ display: 'flex',
80
+ flexDirection: 'column',
81
+ gap: 6,
82
+ padding: 6,
83
+ maxHeight: '80vh',
84
+ overflowY: 'auto',
85
+ },
86
+ section: {
87
+ paddingBottom: 6,
88
+ borderBottom: '1px solid rgba(255,255,255,0.10)',
89
+ },
90
+ label: {
91
+ display: 'block',
92
+ fontSize: 10,
93
+ opacity: 0.7,
94
+ textTransform: 'uppercase',
95
+ letterSpacing: '0.08em',
96
+ marginBottom: 4,
97
+ },
98
+ input: {
99
+ width: '100%',
100
+ background: 'rgba(255,255,255,0.06)',
101
+ border: '1px solid rgba(255,255,255,0.14)',
102
+ borderRadius: 4,
103
+ padding: '4px 6px',
104
+ color: 'rgba(255,255,255,0.92)',
105
+ font: 'inherit',
106
+ outline: 'none',
107
+ },
108
+ row: {
109
+ display: 'flex',
110
+ alignItems: 'center',
111
+ justifyContent: 'space-between',
112
+ gap: 8,
113
+ },
114
+ button: {
115
+ padding: '2px 6px',
116
+ background: 'transparent',
117
+ color: 'rgba(255,255,255,0.9)',
118
+ border: '1px solid rgba(255,255,255,0.14)',
119
+ borderRadius: 4,
120
+ cursor: 'pointer',
121
+ font: 'inherit',
122
+ },
123
+ buttonActive: {
124
+ background: 'rgba(255,255,255,0.10)',
125
+ },
126
+ smallDanger: {
127
+ background: 'transparent',
128
+ border: 'none',
129
+ cursor: 'pointer',
130
+ color: 'rgba(255,120,120,0.95)',
131
+ font: 'inherit',
132
+ padding: '2px 4px',
133
+ },
134
+ componentHeader: {
135
+ display: 'flex',
136
+ alignItems: 'center',
137
+ justifyContent: 'space-between',
138
+ padding: '4px 0',
139
+ borderBottom: '1px solid rgba(255,255,255,0.08)',
140
+ marginBottom: 4,
141
+ },
142
+ componentTitle: {
143
+ fontSize: 10,
144
+ textTransform: 'uppercase',
145
+ letterSpacing: '0.08em',
146
+ opacity: 0.8,
147
+ },
148
+ select: {
149
+ flex: 1,
150
+ background: 'rgba(255,255,255,0.06)',
151
+ border: '1px solid rgba(255,255,255,0.14)',
152
+ borderRadius: 4,
153
+ padding: '4px 6px',
154
+ color: 'rgba(255,255,255,0.92)',
155
+ font: 'inherit',
156
+ outline: 'none',
157
+ },
158
+ addButton: {
159
+ width: 28,
160
+ padding: '4px 0',
161
+ background: 'rgba(255,255,255,0.08)',
162
+ color: 'rgba(255,255,255,0.92)',
163
+ border: '1px solid rgba(255,255,255,0.14)',
164
+ borderRadius: 4,
165
+ cursor: 'pointer',
166
+ font: 'inherit',
167
+ },
168
+ disabled: {
169
+ opacity: 0.35,
170
+ cursor: 'not-allowed',
171
+ },
172
+ };
33
173
  const componentKeys = Object.keys(node.components || {}).join(',');
34
174
  useEffect(() => {
35
175
  // Components stored on nodes use lowercase keys (e.g. 'geometry'),
@@ -39,19 +179,19 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
39
179
  setAddComponentType(available[0] || "");
40
180
  }
41
181
  }, [componentKeys, addComponentType, node.components, allComponentKeys]);
42
- return _jsxs("div", { className: "flex flex-col gap-1 text-[11px] max-w-[250px] max-h-[80vh] overflow-y-auto", children: [_jsx("div", { className: "border-b border-cyan-500/20 pb-1 px-1.5 pt-1", children: _jsx("input", { className: "w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[11px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50", value: node.id, onChange: e => updateNode(n => (Object.assign(Object.assign({}, n), { id: e.target.value }))) }) }), _jsxs("div", { className: "flex justify-between items-center px-1.5 py-0.5 border-b border-cyan-500/20", children: [_jsx("label", { className: "text-[10px] font-mono text-cyan-400/80 uppercase tracking-wider", children: "Components" }), _jsx("button", { onClick: deleteNode, className: "text-[10px] text-red-400/80 hover:text-red-400", children: "\u2715" })] }), _jsxs("div", { className: "px-1.5 py-1 border-b border-cyan-500/20", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Mode" }), _jsx("div", { className: "flex gap-0.5", children: ["translate", "rotate", "scale"].map(mode => (_jsx("button", { onClick: () => setTransformMode(mode), className: `flex-1 px-1 py-0.5 text-[10px] font-mono border ${transformMode === mode ? 'bg-cyan-500/30 border-cyan-400/50 text-cyan-200' : 'bg-black/30 border-cyan-500/20 text-cyan-400/60 hover:border-cyan-400/30'}`, children: mode[0].toUpperCase() }, mode))) })] }), node.components && Object.entries(node.components).map(([key, comp]) => {
182
+ return _jsxs("div", { style: s.root, children: [_jsx("div", { style: s.section, children: _jsx("input", { style: s.input, value: node.id, onChange: e => updateNode(n => (Object.assign(Object.assign({}, n), { id: e.target.value }))) }) }), _jsxs("div", { style: Object.assign(Object.assign(Object.assign({}, s.row), s.section), { paddingBottom: 6 }), children: [_jsx("label", { style: Object.assign(Object.assign({}, s.label), { marginBottom: 0 }), children: "Components" }), _jsx("button", { onClick: deleteNode, style: s.smallDanger, title: "Delete node", children: "\u2715" })] }), node.components && Object.entries(node.components).map(([key, comp]) => {
43
183
  if (!comp)
44
184
  return null;
45
185
  const componentDef = ALL_COMPONENTS[comp.type];
46
186
  if (!componentDef)
47
- return _jsxs("div", { className: "px-1 py-0.5 text-red-400 text-[10px]", children: ["Unknown component type: ", comp.type, _jsx("textarea", { defaultValue: JSON.stringify(comp) })] }, key);
187
+ return _jsxs("div", { style: { padding: '4px 0', color: 'rgba(255,120,120,0.95)', fontSize: 11 }, children: ["Unknown component type: ", comp.type, _jsx("textarea", { defaultValue: JSON.stringify(comp) })] }, key);
48
188
  const EditorComp = componentDef.Editor;
49
- return (_jsxs("div", { className: 'px-1', children: [_jsxs("div", { className: "flex justify-between items-center py-0.5 border-b border-cyan-500/20 bg-cyan-500/5", children: [_jsx("span", { className: "font-mono text-[10px] text-cyan-300 uppercase", children: key }), _jsx("button", { onClick: () => updateNode(n => {
189
+ return (_jsxs("div", { style: { padding: '0 2px' }, children: [_jsxs("div", { style: s.componentHeader, children: [_jsx("span", { style: s.componentTitle, children: key }), _jsx("button", { onClick: () => updateNode(n => {
50
190
  const components = Object.assign({}, n.components);
51
191
  delete components[key];
52
192
  return Object.assign(Object.assign({}, n), { components });
53
- }), className: "text-[9px] text-red-400/60 hover:text-red-400", children: "\u2715" })] }), EditorComp ? (_jsx(EditorComp, { component: comp, onUpdate: (newProps) => updateNode(n => (Object.assign(Object.assign({}, n), { components: Object.assign(Object.assign({}, n.components), { [key]: Object.assign(Object.assign({}, comp), { properties: Object.assign(Object.assign({}, comp.properties), newProps) }) }) }))), basePath: basePath })) : null] }, key));
54
- }), _jsxs("div", { className: "px-1.5 py-1 border-t border-cyan-500/20", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Add Component" }), _jsxs("div", { className: "flex gap-0.5", children: [_jsx("select", { className: "bg-black/40 border border-cyan-500/30 px-1 py-0.5 flex-1 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50", value: addComponentType, onChange: e => setAddComponentType(e.target.value), children: allComponentKeys.filter(k => { var _a; return !((_a = node.components) === null || _a === void 0 ? void 0 : _a[k.toLowerCase()]); }).map(k => (_jsx("option", { value: k, children: k }, k))) }), _jsx("button", { className: "bg-cyan-500/20 hover:bg-cyan-500/30 border border-cyan-500/30 px-2 py-0.5 text-[10px] text-cyan-300 font-mono disabled:opacity-30", disabled: !addComponentType, onClick: () => {
193
+ }), style: s.smallDanger, title: "Remove component", children: "\u2715" })] }), EditorComp ? (_jsx(EditorComp, { component: comp, onUpdate: (newProps) => updateNode(n => (Object.assign(Object.assign({}, n), { components: Object.assign(Object.assign({}, n.components), { [key]: Object.assign(Object.assign({}, comp), { properties: Object.assign(Object.assign({}, comp.properties), newProps) }) }) }))), basePath: basePath, transformMode: transformMode, setTransformMode: setTransformMode })) : null] }, key));
194
+ }), _jsxs("div", { style: Object.assign(Object.assign({}, s.section), { borderBottom: 'none', paddingBottom: 0 }), children: [_jsx("label", { style: s.label, children: "Add Component" }), _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx("select", { style: s.select, value: addComponentType, onChange: e => setAddComponentType(e.target.value), children: allComponentKeys.filter(k => { var _a; return !((_a = node.components) === null || _a === void 0 ? void 0 : _a[k.toLowerCase()]); }).map(k => (_jsx("option", { value: k, children: k }, k))) }), _jsx("button", { style: Object.assign(Object.assign({}, s.addButton), (!addComponentType ? s.disabled : null)), disabled: !addComponentType, onClick: () => {
55
195
  var _a;
56
196
  if (!addComponentType)
57
197
  return;
@@ -60,6 +200,14 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
60
200
  const key = addComponentType.toLowerCase();
61
201
  updateNode(n => (Object.assign(Object.assign({}, n), { components: Object.assign(Object.assign({}, n.components), { [key]: { type: def.name, properties: def.defaultProperties } }) })));
62
202
  }
203
+ }, onPointerEnter: (e) => {
204
+ if (!addComponentType)
205
+ return;
206
+ e.currentTarget.style.background = 'rgba(255,255,255,0.12)';
207
+ }, onPointerLeave: (e) => {
208
+ if (!addComponentType)
209
+ return;
210
+ e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
63
211
  }, children: "+" })] })] })] });
64
212
  }
65
213
  function findNode(root, id) {
@@ -50,11 +50,134 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }) =>
50
50
  };
51
51
  return _jsxs(_Fragment, { children: [_jsx(GameCanvas, { children: _jsxs(Physics, { paused: editMode, children: [_jsx("ambientLight", { intensity: 1.5 }), _jsx("gridHelper", { args: [10, 10], position: [0, -1, 0] }), _jsx(PrefabRoot, { data: loadedPrefab, ref: prefabRef,
52
52
  // props for edit mode
53
- editMode: editMode, onPrefabChange: updatePrefab, selectedId: selectedId, onSelect: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }), children] }) }), _jsxs("div", { style: { position: "absolute", top: "0.5rem", left: "50%", transform: "translateX(-50%)" }, className: "bg-black/70 backdrop-blur-sm border border-cyan-500/30 px-2 py-1 flex items-center gap-1", children: [_jsx("button", { className: "px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30", onClick: () => setEditMode(!editMode), children: editMode ? "▶" : "⏸" }), _jsx("span", { className: "text-cyan-500/30 text-[10px]", children: "|" }), _jsx("button", { className: "px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30", onClick: () => __awaiter(void 0, void 0, void 0, function* () {
54
- const prefab = yield loadJson();
55
- if (prefab)
56
- setLoadedPrefab(prefab);
57
- }), children: "\uD83D\uDCE5" }), _jsx("button", { className: "px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30", onClick: () => saveJson(loadedPrefab, "prefab"), children: "\uD83D\uDCBE" })] }), editMode && _jsx(EditorUI, { prefabData: loadedPrefab, setPrefabData: updatePrefab, selectedId: selectedId, setSelectedId: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath })] });
53
+ editMode: editMode, onPrefabChange: updatePrefab, selectedId: selectedId, onSelect: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }), children] }) }), _jsx(SaveDataPanel, { currentData: loadedPrefab, onDataChange: updatePrefab, editMode: editMode, onEditModeChange: setEditMode }), editMode && _jsx(EditorUI, { prefabData: loadedPrefab, setPrefabData: updatePrefab, selectedId: selectedId, setSelectedId: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath })] });
54
+ };
55
+ const SaveDataPanel = ({ currentData, onDataChange, editMode, onEditModeChange }) => {
56
+ const [history, setHistory] = useState([currentData]);
57
+ const [historyIndex, setHistoryIndex] = useState(0);
58
+ const throttleTimeoutRef = useRef(null);
59
+ const lastSavedDataRef = useRef(JSON.stringify(currentData));
60
+ // Define undo/redo handlers
61
+ const handleUndo = () => {
62
+ if (historyIndex > 0) {
63
+ const newIndex = historyIndex - 1;
64
+ setHistoryIndex(newIndex);
65
+ lastSavedDataRef.current = JSON.stringify(history[newIndex]);
66
+ onDataChange(history[newIndex]);
67
+ }
68
+ };
69
+ const handleRedo = () => {
70
+ if (historyIndex < history.length - 1) {
71
+ const newIndex = historyIndex + 1;
72
+ setHistoryIndex(newIndex);
73
+ lastSavedDataRef.current = JSON.stringify(history[newIndex]);
74
+ onDataChange(history[newIndex]);
75
+ }
76
+ };
77
+ // Keyboard shortcuts for undo/redo
78
+ useEffect(() => {
79
+ const handleKeyDown = (e) => {
80
+ // Undo: Ctrl+Z (Cmd+Z on Mac)
81
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
82
+ e.preventDefault();
83
+ handleUndo();
84
+ }
85
+ // Redo: Ctrl+Shift+Z or Ctrl+Y (Cmd+Shift+Z or Cmd+Y on Mac)
86
+ else if ((e.ctrlKey || e.metaKey) && (e.shiftKey && e.key === 'z' || e.key === 'y')) {
87
+ e.preventDefault();
88
+ handleRedo();
89
+ }
90
+ };
91
+ window.addEventListener('keydown', handleKeyDown);
92
+ return () => window.removeEventListener('keydown', handleKeyDown);
93
+ }, [historyIndex, history]);
94
+ // Throttled history update when currentData changes
95
+ useEffect(() => {
96
+ const currentDataStr = JSON.stringify(currentData);
97
+ // Skip if data hasn't actually changed
98
+ if (currentDataStr === lastSavedDataRef.current) {
99
+ return;
100
+ }
101
+ // Clear existing throttle timeout
102
+ if (throttleTimeoutRef.current) {
103
+ clearTimeout(throttleTimeoutRef.current);
104
+ }
105
+ // Set new throttled update
106
+ throttleTimeoutRef.current = setTimeout(() => {
107
+ lastSavedDataRef.current = currentDataStr;
108
+ setHistory(prev => {
109
+ // Slice history at current index (discard future states)
110
+ const newHistory = prev.slice(0, historyIndex + 1);
111
+ // Add new state
112
+ newHistory.push(currentData);
113
+ // Limit history size to 50 states
114
+ if (newHistory.length > 50) {
115
+ newHistory.shift();
116
+ return newHistory;
117
+ }
118
+ return newHistory;
119
+ });
120
+ setHistoryIndex(prev => {
121
+ const newHistory = history.slice(0, prev + 1);
122
+ newHistory.push(currentData);
123
+ return Math.min(newHistory.length - 1, 49);
124
+ });
125
+ }, 500); // 500ms throttle
126
+ return () => {
127
+ if (throttleTimeoutRef.current) {
128
+ clearTimeout(throttleTimeoutRef.current);
129
+ }
130
+ };
131
+ }, [currentData, historyIndex, history]);
132
+ const handleLoad = () => __awaiter(void 0, void 0, void 0, function* () {
133
+ const prefab = yield loadJson();
134
+ if (prefab) {
135
+ onDataChange(prefab);
136
+ // Reset history when loading new file
137
+ setHistory([prefab]);
138
+ setHistoryIndex(0);
139
+ lastSavedDataRef.current = JSON.stringify(prefab);
140
+ }
141
+ });
142
+ const canUndo = historyIndex > 0;
143
+ const canRedo = historyIndex < history.length - 1;
144
+ return _jsxs("div", { style: {
145
+ position: "absolute",
146
+ top: 8,
147
+ left: "50%",
148
+ transform: "translateX(-50%)",
149
+ display: "flex",
150
+ alignItems: "center",
151
+ gap: 6,
152
+ padding: "2px 4px",
153
+ background: "rgba(0,0,0,0.55)",
154
+ border: "1px solid rgba(255,255,255,0.12)",
155
+ borderRadius: 4,
156
+ color: "rgba(255,255,255,0.9)",
157
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
158
+ fontSize: 11,
159
+ lineHeight: 1,
160
+ WebkitUserSelect: "none",
161
+ userSelect: "none",
162
+ }, children: [_jsx(PanelButton, { onClick: () => onEditModeChange(!editMode), children: editMode ? "▶" : "⏸" }), _jsx("span", { style: { opacity: 0.35 }, children: "|" }), _jsx(PanelButton, { onClick: handleUndo, disabled: !canUndo, title: "Undo (Ctrl+Z)", children: "\u21B6" }), _jsx(PanelButton, { onClick: handleRedo, disabled: !canRedo, title: "Redo (Ctrl+Shift+Z)", children: "\u21B7" }), _jsx("span", { style: { opacity: 0.35 }, children: "|" }), _jsx(PanelButton, { onClick: handleLoad, title: "Load JSON", children: "\uD83D\uDCE5" }), _jsx(PanelButton, { onClick: () => saveJson(currentData, "prefab"), title: "Save JSON", children: "\uD83D\uDCBE" })] });
163
+ };
164
+ const PanelButton = ({ onClick, disabled, title, children }) => {
165
+ return _jsx("button", { style: {
166
+ padding: "2px 6px",
167
+ font: "inherit",
168
+ background: "transparent",
169
+ color: disabled ? "rgba(255,255,255,0.3)" : "inherit",
170
+ border: "1px solid rgba(255,255,255,0.18)",
171
+ borderRadius: 3,
172
+ cursor: disabled ? "not-allowed" : "pointer",
173
+ opacity: disabled ? 0.5 : 1,
174
+ }, onClick: onClick, disabled: disabled, title: title, onPointerEnter: (e) => {
175
+ if (!disabled) {
176
+ e.currentTarget.style.background = "rgba(255,255,255,0.08)";
177
+ }
178
+ }, onPointerLeave: (e) => {
179
+ e.currentTarget.style.background = "transparent";
180
+ }, children: children });
58
181
  };
59
182
  const saveJson = (data, filename) => {
60
183
  const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
@@ -188,7 +188,7 @@ function renderCoreNode(gameObject, ctx, parentMatrix) {
188
188
  const geometryDef = geometry ? getComponent('Geometry') : undefined;
189
189
  const materialDef = material ? getComponent('Material') : undefined;
190
190
  const isModelAvailable = !!(modelComp && modelComp.properties && modelComp.properties.filename && ctx.loadedModels[modelComp.properties.filename]);
191
- // Generic component views (exclude geometry/material/model)
191
+ // Generic component views (exclude geometry/material/model/transform/physics)
192
192
  const contextProps = {
193
193
  loadedModels: ctx.loadedModels,
194
194
  loadedTextures: ctx.loadedTextures,
@@ -197,29 +197,48 @@ function renderCoreNode(gameObject, ctx, parentMatrix) {
197
197
  parentMatrix,
198
198
  registerRef: ctx.registerRef,
199
199
  };
200
- const allComponentViews = gameObject.components
201
- ? Object.entries(gameObject.components)
200
+ // Separate wrapper components (that accept children) from leaf components
201
+ const wrapperComponents = [];
202
+ const leafComponents = [];
203
+ if (gameObject.components) {
204
+ Object.entries(gameObject.components)
202
205
  .filter(([key]) => key !== 'geometry' && key !== 'material' && key !== 'model' && key !== 'transform' && key !== 'physics')
203
- .map(([key, comp]) => {
206
+ .forEach(([key, comp]) => {
204
207
  if (!comp || !comp.type)
205
- return null;
208
+ return;
206
209
  const def = getComponent(comp.type);
207
210
  if (!def || !def.View)
208
- return null;
209
- return _jsx(def.View, Object.assign({ properties: comp.properties }, contextProps), key);
210
- })
211
- : null;
211
+ return;
212
+ // Check if the component View accepts children by checking function signature
213
+ // Components that wrap content should accept children prop
214
+ const viewString = def.View.toString();
215
+ if (viewString.includes('children')) {
216
+ wrapperComponents.push({ key, View: def.View, properties: comp.properties });
217
+ }
218
+ else {
219
+ leafComponents.push(_jsx(def.View, Object.assign({ properties: comp.properties }, contextProps), key));
220
+ }
221
+ });
222
+ }
223
+ // Build the core content (model or mesh)
224
+ let coreContent;
212
225
  // If we have a model (non-instanced) render it as a primitive with material override
213
226
  if (isModelAvailable) {
214
227
  const modelObj = ctx.loadedModels[modelComp.properties.filename].clone();
215
- return (_jsxs("primitive", { object: modelObj, children: [material && materialDef && materialDef.View && (_jsx(materialDef.View, { properties: material.properties, loadedTextures: ctx.loadedTextures, isSelected: ctx.selectedId === gameObject.id, editMode: ctx.editMode, parentMatrix: parentMatrix, registerRef: ctx.registerRef }, "material")), allComponentViews] }));
228
+ coreContent = (_jsxs("primitive", { object: modelObj, children: [material && materialDef && materialDef.View && (_jsx(materialDef.View, { properties: material.properties, loadedTextures: ctx.loadedTextures, isSelected: ctx.selectedId === gameObject.id, editMode: ctx.editMode, parentMatrix: parentMatrix, registerRef: ctx.registerRef }, "material")), leafComponents] }));
229
+ }
230
+ else if (geometry && geometryDef && geometryDef.View) {
231
+ // Otherwise, if geometry present, render a mesh
232
+ coreContent = (_jsxs("mesh", { children: [_jsx(geometryDef.View, Object.assign({ properties: geometry.properties }, contextProps), "geometry"), material && materialDef && materialDef.View && (_jsx(materialDef.View, { properties: material.properties, loadedTextures: ctx.loadedTextures, isSelected: ctx.selectedId === gameObject.id, editMode: ctx.editMode, parentMatrix: parentMatrix, registerRef: ctx.registerRef }, "material")), leafComponents] }));
216
233
  }
217
- // Otherwise, if geometry present, render a mesh
218
- if (geometry && geometryDef && geometryDef.View) {
219
- return (_jsxs("mesh", { children: [_jsx(geometryDef.View, Object.assign({ properties: geometry.properties }, contextProps), "geometry"), material && materialDef && materialDef.View && (_jsx(materialDef.View, { properties: material.properties, loadedTextures: ctx.loadedTextures, isSelected: ctx.selectedId === gameObject.id, editMode: ctx.editMode, parentMatrix: parentMatrix, registerRef: ctx.registerRef }, "material")), allComponentViews] }));
234
+ else {
235
+ // No geometry or model, just render leaf components
236
+ coreContent = _jsx(_Fragment, { children: leafComponents });
220
237
  }
221
- // Default: render other component views (no geometry/model)
222
- return _jsx(_Fragment, { children: allComponentViews });
238
+ // Wrap core content with wrapper components (in order)
239
+ return wrapperComponents.reduce((content, { key, View, properties }) => {
240
+ return _jsx(View, Object.assign({ properties: properties }, contextProps, { children: content }), key);
241
+ }, coreContent);
223
242
  }
224
243
  // Helper: wrap core content with physics component when necessary
225
244
  function wrapPhysicsIfNeeded(gameObject, content, ctx) {
@@ -5,6 +5,8 @@ export interface Component {
5
5
  component: any;
6
6
  onUpdate: (newComp: any) => void;
7
7
  basePath?: string;
8
+ transformMode?: "translate" | "rotate" | "scale";
9
+ setTransformMode?: (m: "translate" | "rotate" | "scale") => void;
8
10
  }>;
9
11
  defaultProperties: any;
10
12
  View?: FC<any>;
@@ -0,0 +1,3 @@
1
+ import { Component } from "./ComponentRegistry";
2
+ declare const RotatorComponent: Component;
3
+ export default RotatorComponent;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useFrame } from "@react-three/fiber";
3
+ import { useRef } from "react";
4
+ function RotatorComponentEditor({ component, onUpdate }) {
5
+ var _a, _b;
6
+ const props = {
7
+ speed: (_a = component.properties.speed) !== null && _a !== void 0 ? _a : 1.0,
8
+ axis: (_b = component.properties.axis) !== null && _b !== void 0 ? _b : 'y'
9
+ };
10
+ return _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Rotation Speed" }), _jsx("input", { type: "number", step: "0.1", className: "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", value: props.speed, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { speed: parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Rotation Axis" }), _jsxs("select", { className: "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", value: props.axis, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { axis: e.target.value })), children: [_jsx("option", { value: "x", children: "X" }), _jsx("option", { value: "y", children: "Y" }), _jsx("option", { value: "z", children: "Z" })] })] })] });
11
+ }
12
+ // The view component for Rotator
13
+ function RotatorView({ properties, children }) {
14
+ var _a, _b;
15
+ const groupRef = useRef(null);
16
+ const speed = (_a = properties.speed) !== null && _a !== void 0 ? _a : 1.0;
17
+ const axis = (_b = properties.axis) !== null && _b !== void 0 ? _b : 'y';
18
+ useFrame((state, delta) => {
19
+ if (groupRef.current) {
20
+ if (axis === 'x') {
21
+ groupRef.current.rotation.x += delta * speed;
22
+ }
23
+ else if (axis === 'y') {
24
+ groupRef.current.rotation.y += delta * speed;
25
+ }
26
+ else if (axis === 'z') {
27
+ groupRef.current.rotation.z += delta * speed;
28
+ }
29
+ }
30
+ });
31
+ return (_jsx("group", { ref: groupRef, children: children }));
32
+ }
33
+ const RotatorComponent = {
34
+ name: 'Rotator',
35
+ Editor: RotatorComponentEditor,
36
+ View: RotatorView,
37
+ defaultProperties: {
38
+ speed: 1.0,
39
+ axis: 'y'
40
+ }
41
+ };
42
+ export default RotatorComponent;
@@ -1,6 +1,26 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- function TransformComponentEditor({ component, onUpdate }) {
3
- return _jsxs("div", { className: "flex flex-col", children: [_jsx(Vector3Input, { label: "Position", value: component.properties.position, onChange: v => onUpdate({ position: v }) }), _jsx(Vector3Input, { label: "Rotation", value: component.properties.rotation, onChange: v => onUpdate({ rotation: v }) }), _jsx(Vector3Input, { label: "Scale", value: component.properties.scale, onChange: v => onUpdate({ scale: v }) })] });
2
+ function TransformComponentEditor({ component, onUpdate, transformMode, setTransformMode }) {
3
+ const s = {
4
+ button: {
5
+ padding: '2px 6px',
6
+ background: 'transparent',
7
+ color: 'rgba(255,255,255,0.9)',
8
+ border: '1px solid rgba(255,255,255,0.14)',
9
+ borderRadius: 4,
10
+ cursor: 'pointer',
11
+ font: 'inherit',
12
+ },
13
+ buttonActive: {
14
+ background: 'rgba(255,255,255,0.10)',
15
+ },
16
+ };
17
+ return _jsxs("div", { className: "flex flex-col", children: [transformMode && setTransformMode && (_jsxs("div", { className: "mb-2", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1", children: "Transform Mode" }), _jsx("div", { style: { display: 'flex', gap: 6 }, children: ["translate", "rotate", "scale"].map(mode => (_jsx("button", { onClick: () => setTransformMode(mode), style: Object.assign(Object.assign(Object.assign({}, s.button), { flex: 1 }), (transformMode === mode ? s.buttonActive : {})), onPointerEnter: (e) => {
18
+ if (transformMode !== mode)
19
+ e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
20
+ }, onPointerLeave: (e) => {
21
+ if (transformMode !== mode)
22
+ e.currentTarget.style.background = 'transparent';
23
+ }, children: mode }, mode))) })] })), _jsx(Vector3Input, { label: "Position", value: component.properties.position, onChange: v => onUpdate({ position: v }) }), _jsx(Vector3Input, { label: "Rotation", value: component.properties.rotation, onChange: v => onUpdate({ rotation: v }) }), _jsx(Vector3Input, { label: "Scale", value: component.properties.scale, onChange: v => onUpdate({ scale: v }) })] });
4
24
  }
5
25
  const TransformComponent = {
6
26
  name: 'Transform',
@@ -18,5 +38,10 @@ export function Vector3Input({ label, value, onChange }) {
18
38
  newValue[index] = parseFloat(val) || 0;
19
39
  onChange(newValue);
20
40
  };
21
- return _jsxs("div", { className: "mb-1", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: label }), _jsxs("div", { className: "flex gap-0.5", children: [_jsxs("div", { className: "relative flex-1", children: [_jsx("span", { className: "absolute left-0.5 top-0 text-[8px] text-red-400/80 font-mono", children: "X" }), _jsx("input", { className: "w-full bg-black/40 border border-cyan-500/30 pl-3 pr-0.5 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50", type: "number", step: "0.1", value: value[0], onChange: e => handleChange(0, e.target.value) })] }), _jsxs("div", { className: "relative flex-1", children: [_jsx("span", { className: "absolute left-0.5 top-0 text-[8px] text-green-400/80 font-mono", children: "Y" }), _jsx("input", { className: "w-full bg-black/40 border border-cyan-500/30 pl-3 pr-0.5 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50", type: "number", step: "0.1", value: value[1], onChange: e => handleChange(1, e.target.value) })] }), _jsxs("div", { className: "relative flex-1", children: [_jsx("span", { className: "absolute left-0.5 top-0 text-[8px] text-blue-400/80 font-mono", children: "Z" }), _jsx("input", { className: "w-full bg-black/40 border border-cyan-500/30 pl-3 pr-0.5 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50", type: "number", step: "0.1", value: value[2], onChange: e => handleChange(2, e.target.value) })] })] })] });
41
+ const axes = [
42
+ { key: 'x', color: 'red', index: 0 },
43
+ { key: 'y', color: 'green', index: 1 },
44
+ { key: 'z', color: 'blue', index: 2 }
45
+ ];
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))) })] });
22
47
  }
@@ -1,6 +1,6 @@
1
1
  export interface Prefab {
2
- id: string;
3
- name: string;
2
+ id?: string;
3
+ name?: string;
4
4
  description?: string;
5
5
  author?: string;
6
6
  version?: string;
@@ -1 +1,2 @@
1
+ // import { ThreeElements } from "@react-three/fiber"
1
2
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -17,12 +17,12 @@
17
17
  "type": "module",
18
18
  "workspaces": ["docs"],
19
19
  "peerDependencies": {
20
- "@react-three/fiber": "^9.0.0",
21
- "@react-three/drei": "^10.0.0",
22
- "@react-three/rapier": "^2.0.0",
23
- "react": "^18.0.0 || ^19.0.0",
24
- "react-dom": "^18.0.0 || ^19.0.0",
25
- "three": "^0.181.0"
20
+ "@react-three/fiber": ">=9.0.0",
21
+ "@react-three/drei": ">=10.0.0",
22
+ "@react-three/rapier": ">=2.0.0",
23
+ "react": ">=18.0.0",
24
+ "react-dom": ">=18.0.0",
25
+ "three": ">=0.181.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@react-three/drei": "^10.7.7",
package/src/index.ts CHANGED
@@ -10,6 +10,10 @@ export {
10
10
  SharedCanvas,
11
11
  } from './tools/assetviewer/page';
12
12
 
13
+ // Component Registry
14
+ export { registerComponent } from './tools/prefabeditor/components/ComponentRegistry';
15
+ export type { Component } from './tools/prefabeditor/components/ComponentRegistry';
16
+
13
17
  // Helpers
14
18
  export * from './helpers';
15
19
 
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  // DragDropLoader.tsx
2
3
  import { useEffect, ChangeEvent } from "react";
3
4
  import { DRACOLoader, FBXLoader, GLTFLoader } from "three/examples/jsm/Addons.js";