react-three-game 0.0.16 → 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.
@@ -67,92 +67,12 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: { b
67
67
  </Physics>
68
68
  </GameCanvas>
69
69
 
70
- <div
71
- style={{
72
- position: "absolute",
73
- top: 8,
74
- left: "50%",
75
- transform: "translateX(-50%)",
76
- display: "flex",
77
- alignItems: "center",
78
- gap: 6,
79
- padding: "2px 4px",
80
- background: "rgba(0,0,0,0.55)",
81
- border: "1px solid rgba(255,255,255,0.12)",
82
- borderRadius: 4,
83
- color: "rgba(255,255,255,0.9)",
84
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
85
- fontSize: 11,
86
- lineHeight: 1,
87
- WebkitUserSelect: "none",
88
- userSelect: "none",
89
- }}
90
- >
91
- <button
92
- style={{
93
- padding: "2px 6px",
94
- font: "inherit",
95
- background: "transparent",
96
- color: "inherit",
97
- border: "1px solid rgba(255,255,255,0.18)",
98
- borderRadius: 3,
99
- cursor: "pointer",
100
- }}
101
- onClick={() => setEditMode(!editMode)}
102
- onPointerEnter={(e) => {
103
- (e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.08)";
104
- }}
105
- onPointerLeave={(e) => {
106
- (e.currentTarget as HTMLButtonElement).style.background = "transparent";
107
- }}
108
- >
109
- {editMode ? "▶" : "⏸"}
110
- </button>
111
- <span style={{ opacity: 0.35 }}>|</span>
112
- <button
113
- style={{
114
- padding: "2px 6px",
115
- font: "inherit",
116
- background: "transparent",
117
- color: "inherit",
118
- border: "1px solid rgba(255,255,255,0.18)",
119
- borderRadius: 3,
120
- cursor: "pointer",
121
- }}
122
- onClick={async () => {
123
- const prefab = await loadJson();
124
- if (prefab) setLoadedPrefab(prefab);
125
- }}
126
- onPointerEnter={(e) => {
127
- (e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.08)";
128
- }}
129
- onPointerLeave={(e) => {
130
- (e.currentTarget as HTMLButtonElement).style.background = "transparent";
131
- }}
132
- >
133
- 📥
134
- </button>
135
- <button
136
- style={{
137
- padding: "2px 6px",
138
- font: "inherit",
139
- background: "transparent",
140
- color: "inherit",
141
- border: "1px solid rgba(255,255,255,0.18)",
142
- borderRadius: 3,
143
- cursor: "pointer",
144
- }}
145
- onClick={() => saveJson(loadedPrefab, "prefab")}
146
- onPointerEnter={(e) => {
147
- (e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.08)";
148
- }}
149
- onPointerLeave={(e) => {
150
- (e.currentTarget as HTMLButtonElement).style.background = "transparent";
151
- }}
152
- >
153
- 💾
154
- </button>
155
- </div>
70
+ <SaveDataPanel
71
+ currentData={loadedPrefab}
72
+ onDataChange={updatePrefab}
73
+ editMode={editMode}
74
+ onEditModeChange={setEditMode}
75
+ />
156
76
  {editMode && <EditorUI
157
77
  prefabData={loadedPrefab}
158
78
  setPrefabData={updatePrefab}
@@ -165,6 +85,202 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: { b
165
85
  </>
166
86
  }
167
87
 
88
+ const SaveDataPanel = ({
89
+ currentData,
90
+ onDataChange,
91
+ editMode,
92
+ onEditModeChange
93
+ }: {
94
+ currentData: Prefab;
95
+ onDataChange: (data: Prefab) => void;
96
+ editMode: boolean;
97
+ onEditModeChange: (mode: boolean) => void;
98
+ }) => {
99
+ const [history, setHistory] = useState<Prefab[]>([currentData]);
100
+ const [historyIndex, setHistoryIndex] = useState(0);
101
+ const throttleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
102
+ const lastSavedDataRef = useRef<string>(JSON.stringify(currentData));
103
+
104
+ // Define undo/redo handlers
105
+ const handleUndo = () => {
106
+ if (historyIndex > 0) {
107
+ const newIndex = historyIndex - 1;
108
+ setHistoryIndex(newIndex);
109
+ lastSavedDataRef.current = JSON.stringify(history[newIndex]);
110
+ onDataChange(history[newIndex]);
111
+ }
112
+ };
113
+
114
+ const handleRedo = () => {
115
+ if (historyIndex < history.length - 1) {
116
+ const newIndex = historyIndex + 1;
117
+ setHistoryIndex(newIndex);
118
+ lastSavedDataRef.current = JSON.stringify(history[newIndex]);
119
+ onDataChange(history[newIndex]);
120
+ }
121
+ };
122
+
123
+ // Keyboard shortcuts for undo/redo
124
+ useEffect(() => {
125
+ const handleKeyDown = (e: KeyboardEvent) => {
126
+ // Undo: Ctrl+Z (Cmd+Z on Mac)
127
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
128
+ e.preventDefault();
129
+ handleUndo();
130
+ }
131
+ // Redo: Ctrl+Shift+Z or Ctrl+Y (Cmd+Shift+Z or Cmd+Y on Mac)
132
+ else if ((e.ctrlKey || e.metaKey) && (e.shiftKey && e.key === 'z' || e.key === 'y')) {
133
+ e.preventDefault();
134
+ handleRedo();
135
+ }
136
+ };
137
+
138
+ window.addEventListener('keydown', handleKeyDown);
139
+ return () => window.removeEventListener('keydown', handleKeyDown);
140
+ }, [historyIndex, history]);
141
+
142
+ // Throttled history update when currentData changes
143
+ useEffect(() => {
144
+ const currentDataStr = JSON.stringify(currentData);
145
+
146
+ // Skip if data hasn't actually changed
147
+ if (currentDataStr === lastSavedDataRef.current) {
148
+ return;
149
+ }
150
+
151
+ // Clear existing throttle timeout
152
+ if (throttleTimeoutRef.current) {
153
+ clearTimeout(throttleTimeoutRef.current);
154
+ }
155
+
156
+ // Set new throttled update
157
+ throttleTimeoutRef.current = setTimeout(() => {
158
+ lastSavedDataRef.current = currentDataStr;
159
+
160
+ setHistory(prev => {
161
+ // Slice history at current index (discard future states)
162
+ const newHistory = prev.slice(0, historyIndex + 1);
163
+ // Add new state
164
+ newHistory.push(currentData);
165
+ // Limit history size to 50 states
166
+ if (newHistory.length > 50) {
167
+ newHistory.shift();
168
+ return newHistory;
169
+ }
170
+ return newHistory;
171
+ });
172
+
173
+ setHistoryIndex(prev => {
174
+ const newHistory = history.slice(0, prev + 1);
175
+ newHistory.push(currentData);
176
+ return Math.min(newHistory.length - 1, 49);
177
+ });
178
+ }, 500); // 500ms throttle
179
+
180
+ return () => {
181
+ if (throttleTimeoutRef.current) {
182
+ clearTimeout(throttleTimeoutRef.current);
183
+ }
184
+ };
185
+ }, [currentData, historyIndex, history]);
186
+
187
+ const handleLoad = async () => {
188
+ const prefab = await loadJson();
189
+ if (prefab) {
190
+ onDataChange(prefab);
191
+ // Reset history when loading new file
192
+ setHistory([prefab]);
193
+ setHistoryIndex(0);
194
+ lastSavedDataRef.current = JSON.stringify(prefab);
195
+ }
196
+ };
197
+
198
+ const canUndo = historyIndex > 0;
199
+ const canRedo = historyIndex < history.length - 1;
200
+
201
+ return <div style={{
202
+ position: "absolute",
203
+ top: 8,
204
+ left: "50%",
205
+ transform: "translateX(-50%)",
206
+ display: "flex",
207
+ alignItems: "center",
208
+ gap: 6,
209
+ padding: "2px 4px",
210
+ background: "rgba(0,0,0,0.55)",
211
+ border: "1px solid rgba(255,255,255,0.12)",
212
+ borderRadius: 4,
213
+ color: "rgba(255,255,255,0.9)",
214
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
215
+ fontSize: 11,
216
+ lineHeight: 1,
217
+ WebkitUserSelect: "none",
218
+ userSelect: "none",
219
+ }}>
220
+ <PanelButton onClick={() => onEditModeChange(!editMode)}>
221
+ {editMode ? "▶" : "⏸"}
222
+ </PanelButton>
223
+
224
+ <span style={{ opacity: 0.35 }}>|</span>
225
+
226
+ <PanelButton onClick={handleUndo} disabled={!canUndo} title="Undo (Ctrl+Z)">
227
+
228
+ </PanelButton>
229
+
230
+ <PanelButton onClick={handleRedo} disabled={!canRedo} title="Redo (Ctrl+Shift+Z)">
231
+
232
+ </PanelButton>
233
+
234
+ <span style={{ opacity: 0.35 }}>|</span>
235
+
236
+ <PanelButton onClick={handleLoad} title="Load JSON">
237
+ 📥
238
+ </PanelButton>
239
+
240
+ <PanelButton onClick={() => saveJson(currentData, "prefab")} title="Save JSON">
241
+ 💾
242
+ </PanelButton>
243
+ </div>;
244
+ };
245
+
246
+ const PanelButton = ({
247
+ onClick,
248
+ disabled,
249
+ title,
250
+ children
251
+ }: {
252
+ onClick: () => void;
253
+ disabled?: boolean;
254
+ title?: string;
255
+ children: React.ReactNode;
256
+ }) => {
257
+ return <button
258
+ style={{
259
+ padding: "2px 6px",
260
+ font: "inherit",
261
+ background: "transparent",
262
+ color: disabled ? "rgba(255,255,255,0.3)" : "inherit",
263
+ border: "1px solid rgba(255,255,255,0.18)",
264
+ borderRadius: 3,
265
+ cursor: disabled ? "not-allowed" : "pointer",
266
+ opacity: disabled ? 0.5 : 1,
267
+ }}
268
+ onClick={onClick}
269
+ disabled={disabled}
270
+ title={title}
271
+ onPointerEnter={(e) => {
272
+ if (!disabled) {
273
+ (e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.08)";
274
+ }
275
+ }}
276
+ onPointerLeave={(e) => {
277
+ (e.currentTarget as HTMLButtonElement).style.background = "transparent";
278
+ }}
279
+ >
280
+ {children}
281
+ </button>;
282
+ };
283
+
168
284
  const saveJson = (data: any, filename: string) => {
169
285
  const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
170
286
  const downloadAnchorNode = document.createElement('a');
@@ -311,7 +311,7 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
311
311
 
312
312
  const isModelAvailable = !!(modelComp && modelComp.properties && modelComp.properties.filename && ctx.loadedModels[modelComp.properties.filename]);
313
313
 
314
- // Generic component views (exclude geometry/material/model)
314
+ // Generic component views (exclude geometry/material/model/transform/physics)
315
315
  const contextProps = {
316
316
  loadedModels: ctx.loadedModels,
317
317
  loadedTextures: ctx.loadedTextures,
@@ -320,21 +320,37 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
320
320
  parentMatrix,
321
321
  registerRef: ctx.registerRef,
322
322
  };
323
- const allComponentViews = gameObject.components
324
- ? Object.entries(gameObject.components)
323
+
324
+ // Separate wrapper components (that accept children) from leaf components
325
+ const wrapperComponents: Array<{ key: string; View: any; properties: any }> = [];
326
+ const leafComponents: React.ReactNode[] = [];
327
+
328
+ if (gameObject.components) {
329
+ Object.entries(gameObject.components)
325
330
  .filter(([key]) => key !== 'geometry' && key !== 'material' && key !== 'model' && key !== 'transform' && key !== 'physics')
326
- .map(([key, comp]) => {
327
- if (!comp || !comp.type) return null;
331
+ .forEach(([key, comp]) => {
332
+ if (!comp || !comp.type) return;
328
333
  const def = getComponent(comp.type);
329
- if (!def || !def.View) return null;
330
- return <def.View key={key} properties={comp.properties} {...contextProps} />;
331
- })
332
- : null;
334
+ if (!def || !def.View) return;
335
+
336
+ // Check if the component View accepts children by checking function signature
337
+ // Components that wrap content should accept children prop
338
+ const viewString = def.View.toString();
339
+ if (viewString.includes('children')) {
340
+ wrapperComponents.push({ key, View: def.View, properties: comp.properties });
341
+ } else {
342
+ leafComponents.push(<def.View key={key} properties={comp.properties} {...contextProps} />);
343
+ }
344
+ });
345
+ }
346
+
347
+ // Build the core content (model or mesh)
348
+ let coreContent: React.ReactNode;
333
349
 
334
350
  // If we have a model (non-instanced) render it as a primitive with material override
335
351
  if (isModelAvailable) {
336
352
  const modelObj = ctx.loadedModels[modelComp.properties.filename].clone();
337
- return (
353
+ coreContent = (
338
354
  <primitive object={modelObj}>
339
355
  {material && materialDef && materialDef.View && (
340
356
  <materialDef.View
@@ -347,14 +363,12 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
347
363
  registerRef={ctx.registerRef}
348
364
  />
349
365
  )}
350
- {allComponentViews}
366
+ {leafComponents}
351
367
  </primitive>
352
368
  );
353
- }
354
-
355
- // Otherwise, if geometry present, render a mesh
356
- if (geometry && geometryDef && geometryDef.View) {
357
- return (
369
+ } else if (geometry && geometryDef && geometryDef.View) {
370
+ // Otherwise, if geometry present, render a mesh
371
+ coreContent = (
358
372
  <mesh>
359
373
  <geometryDef.View key="geometry" properties={geometry.properties} {...contextProps} />
360
374
  {material && materialDef && materialDef.View && (
@@ -368,13 +382,18 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
368
382
  registerRef={ctx.registerRef}
369
383
  />
370
384
  )}
371
- {allComponentViews}
385
+ {leafComponents}
372
386
  </mesh>
373
387
  );
388
+ } else {
389
+ // No geometry or model, just render leaf components
390
+ coreContent = <>{leafComponents}</>;
374
391
  }
375
392
 
376
- // Default: render other component views (no geometry/model)
377
- return <>{allComponentViews}</>;
393
+ // Wrap core content with wrapper components (in order)
394
+ return wrapperComponents.reduce((content, { key, View, properties }) => {
395
+ return <View key={key} properties={properties} {...contextProps}>{content}</View>;
396
+ }, coreContent);
378
397
  }
379
398
 
380
399
  // Helper: wrap core content with physics component when necessary
@@ -2,7 +2,13 @@ import { FC } from "react";
2
2
 
3
3
  export interface Component {
4
4
  name: string;
5
- Editor: FC<{ component: any; onUpdate: (newComp: any) => void; basePath?: string }>;
5
+ Editor: FC<{
6
+ component: any;
7
+ onUpdate: (newComp: any) => void;
8
+ basePath?: string;
9
+ transformMode?: "translate" | "rotate" | "scale";
10
+ setTransformMode?: (m: "translate" | "rotate" | "scale") => void;
11
+ }>;
6
12
  defaultProperties: any;
7
13
  // Allow View to accept extra props for special cases (like material)
8
14
  View?: FC<any>;
@@ -1,8 +1,54 @@
1
1
 
2
2
  import { Component } from "./ComponentRegistry";
3
3
 
4
- function TransformComponentEditor({ component, onUpdate }: { component: any; onUpdate: (newComp: any) => void }) {
4
+ function TransformComponentEditor({ component, onUpdate, transformMode, setTransformMode }: {
5
+ component: any;
6
+ onUpdate: (newComp: any) => void;
7
+ transformMode?: "translate" | "rotate" | "scale";
8
+ setTransformMode?: (m: "translate" | "rotate" | "scale") => void;
9
+ }) {
10
+ const s = {
11
+ button: {
12
+ padding: '2px 6px',
13
+ background: 'transparent',
14
+ color: 'rgba(255,255,255,0.9)',
15
+ border: '1px solid rgba(255,255,255,0.14)',
16
+ borderRadius: 4,
17
+ cursor: 'pointer',
18
+ font: 'inherit',
19
+ },
20
+ buttonActive: {
21
+ background: 'rgba(255,255,255,0.10)',
22
+ },
23
+ };
24
+
5
25
  return <div className="flex flex-col">
26
+ {transformMode && setTransformMode && (
27
+ <div className="mb-2">
28
+ <label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1">Transform Mode</label>
29
+ <div style={{ display: 'flex', gap: 6 }}>
30
+ {["translate", "rotate", "scale"].map(mode => (
31
+ <button
32
+ key={mode}
33
+ onClick={() => setTransformMode(mode as any)}
34
+ style={{
35
+ ...s.button,
36
+ flex: 1,
37
+ ...(transformMode === mode ? s.buttonActive : {}),
38
+ }}
39
+ onPointerEnter={(e) => {
40
+ if (transformMode !== mode) (e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,255,255,0.08)';
41
+ }}
42
+ onPointerLeave={(e) => {
43
+ if (transformMode !== mode) (e.currentTarget as HTMLButtonElement).style.background = 'transparent';
44
+ }}
45
+ >
46
+ {mode}
47
+ </button>
48
+ ))}
49
+ </div>
50
+ </div>
51
+ )}
6
52
  <Vector3Input label="Position" value={component.properties.position} onChange={v => onUpdate({ position: v })} />
7
53
  <Vector3Input label="Rotation" value={component.properties.rotation} onChange={v => onUpdate({ rotation: v })} />
8
54
  <Vector3Input label="Scale" value={component.properties.scale} onChange={v => onUpdate({ scale: v })} />
@@ -29,21 +75,28 @@ export function Vector3Input({ label, value, onChange }: { label: string, value:
29
75
  onChange(newValue);
30
76
  };
31
77
 
32
- return <div className="mb-1">
33
- <label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">{label}</label>
34
- <div className="flex gap-0.5">
35
- <div className="relative flex-1">
36
- <span className="absolute left-0.5 top-0 text-[8px] text-red-400/80 font-mono">X</span>
37
- <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)} />
38
- </div>
39
- <div className="relative flex-1">
40
- <span className="absolute left-0.5 top-0 text-[8px] text-green-400/80 font-mono">Y</span>
41
- <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)} />
42
- </div>
43
- <div className="relative flex-1">
44
- <span className="absolute left-0.5 top-0 text-[8px] text-blue-400/80 font-mono">Z</span>
45
- <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)} />
46
- </div>
78
+ const axes = [
79
+ { key: 'x', color: 'red', index: 0 },
80
+ { key: 'y', color: 'green', index: 1 },
81
+ { key: 'z', color: 'blue', index: 2 }
82
+ ] as const;
83
+
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
+ ))}
47
100
  </div>
48
101
  </div>
49
102
  }