react-three-game 0.0.16 → 0.0.18

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.
Files changed (33) hide show
  1. package/README.md +88 -113
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +2 -0
  4. package/dist/tools/prefabeditor/EditorTree.js +27 -15
  5. package/dist/tools/prefabeditor/EditorUI.js +2 -8
  6. package/dist/tools/prefabeditor/InstanceProvider.d.ts +4 -4
  7. package/dist/tools/prefabeditor/InstanceProvider.js +21 -13
  8. package/dist/tools/prefabeditor/PrefabEditor.js +128 -59
  9. package/dist/tools/prefabeditor/PrefabRoot.js +51 -33
  10. package/dist/tools/prefabeditor/components/ComponentRegistry.d.ts +2 -0
  11. package/dist/tools/prefabeditor/components/DirectionalLightComponent.d.ts +3 -0
  12. package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +114 -0
  13. package/dist/tools/prefabeditor/components/ModelComponent.js +12 -4
  14. package/dist/tools/prefabeditor/components/RotatorComponent.d.ts +3 -0
  15. package/dist/tools/prefabeditor/components/RotatorComponent.js +42 -0
  16. package/dist/tools/prefabeditor/components/SpotLightComponent.js +10 -5
  17. package/dist/tools/prefabeditor/components/TransformComponent.js +28 -3
  18. package/dist/tools/prefabeditor/components/index.js +2 -0
  19. package/dist/tools/prefabeditor/hooks/useModelLoader.d.ts +10 -0
  20. package/dist/tools/prefabeditor/hooks/useModelLoader.js +40 -0
  21. package/package.json +8 -8
  22. package/src/index.ts +4 -0
  23. package/src/tools/prefabeditor/EditorTree.tsx +39 -16
  24. package/src/tools/prefabeditor/EditorUI.tsx +2 -27
  25. package/src/tools/prefabeditor/InstanceProvider.tsx +43 -32
  26. package/src/tools/prefabeditor/PrefabEditor.tsx +202 -86
  27. package/src/tools/prefabeditor/PrefabRoot.tsx +62 -54
  28. package/src/tools/prefabeditor/components/ComponentRegistry.ts +7 -1
  29. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +332 -0
  30. package/src/tools/prefabeditor/components/ModelComponent.tsx +14 -4
  31. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +27 -7
  32. package/src/tools/prefabeditor/components/TransformComponent.tsx +69 -16
  33. package/src/tools/prefabeditor/components/index.ts +2 -0
@@ -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');
@@ -203,11 +203,12 @@ function GameObjectRenderer({
203
203
 
204
204
  // Early return if gameObject is null or undefined
205
205
  if (!gameObject) return null;
206
+ if (gameObject.disabled === true || gameObject.hidden === true) return null;
206
207
 
207
- // Build a small context object to avoid long param lists
208
+ // Build context object for passing to helper functions
208
209
  const ctx = { gameObject, selectedId, onSelect, registerRef, loadedModels, loadedTextures, editMode };
209
210
 
210
- // --- 1. Transform (local + world) ---
211
+ // --- 1. Compute transforms (local + world) ---
211
212
  const transformProps = getNodeTransformProps(gameObject);
212
213
  const localMatrix = new Matrix4().compose(
213
214
  new Vector3(...transformProps.position),
@@ -216,7 +217,7 @@ function GameObjectRenderer({
216
217
  );
217
218
  const worldMatrix = parentMatrix.clone().multiply(localMatrix);
218
219
 
219
- // preserve click/drag detection from previous implementation
220
+ // --- 2. Handle selection interaction (edit mode only) ---
220
221
  const clickValid = useRef(false);
221
222
  const handlePointerDown = (e: ThreeEvent<PointerEvent>) => {
222
223
  e.stopPropagation();
@@ -233,18 +234,19 @@ function GameObjectRenderer({
233
234
  clickValid.current = false;
234
235
  };
235
236
 
236
- if (gameObject.disabled === true || gameObject.hidden === true) return null;
237
-
238
- // --- 2. If instanced, short-circuit to a tiny clean branch ---
237
+ // --- 3. If instanced model, short-circuit to GameInstance (terminal node) ---
239
238
  const isInstanced = !!gameObject.components?.model?.properties?.instanced;
240
239
  if (isInstanced) {
241
240
  return renderInstancedNode(gameObject, worldMatrix, ctx);
242
241
  }
243
242
 
244
- // --- 3. Core content decided by component registry ---
243
+ // --- 4. Render core content using component system ---
245
244
  const core = renderCoreNode(gameObject, ctx, parentMatrix);
246
245
 
247
- // --- 5. Render children (always relative transforms) ---
246
+ // --- 5. Wrap with physics if needed (except in edit mode) ---
247
+ const physicsWrapped = wrapPhysicsIfNeeded(gameObject, core, ctx);
248
+
249
+ // --- 6. Render children recursively (always relative transforms) ---
248
250
  const children = (gameObject.children ?? []).map((child) => (
249
251
  <GameObjectRenderer
250
252
  key={child.id}
@@ -259,10 +261,7 @@ function GameObjectRenderer({
259
261
  />
260
262
  ));
261
263
 
262
- // --- 4. Wrap with physics if needed ---
263
- const physicsWrapped = wrapPhysicsIfNeeded(gameObject, core, ctx);
264
-
265
- // --- 6. Final group wrapper ---
264
+ // --- 7. Final group wrapper with local transform ---
266
265
  return (
267
266
  <group
268
267
  ref={(el) => registerRef(gameObject.id, el)}
@@ -300,18 +299,17 @@ function renderInstancedNode(gameObject: GameObjectType, worldMatrix: Matrix4, c
300
299
  );
301
300
  }
302
301
 
303
- // Helper: render main model/geometry content for a non-instanced node
302
+ // Helper: render main content for a non-instanced node using the component system
304
303
  function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matrix4 | undefined) {
305
304
  const geometry = gameObject.components?.geometry;
306
305
  const material = gameObject.components?.material;
307
- const modelComp = gameObject.components?.model;
306
+ const model = gameObject.components?.model;
308
307
 
309
308
  const geometryDef = geometry ? getComponent('Geometry') : undefined;
310
309
  const materialDef = material ? getComponent('Material') : undefined;
310
+ const modelDef = model ? getComponent('Model') : undefined;
311
311
 
312
- const isModelAvailable = !!(modelComp && modelComp.properties && modelComp.properties.filename && ctx.loadedModels[modelComp.properties.filename]);
313
-
314
- // Generic component views (exclude geometry/material/model)
312
+ // Context props for all component Views
315
313
  const contextProps = {
316
314
  loadedModels: ctx.loadedModels,
317
315
  loadedTextures: ctx.loadedTextures,
@@ -320,61 +318,71 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
320
318
  parentMatrix,
321
319
  registerRef: ctx.registerRef,
322
320
  };
323
- const allComponentViews = gameObject.components
324
- ? Object.entries(gameObject.components)
325
- .filter(([key]) => key !== 'geometry' && key !== 'material' && key !== 'model' && key !== 'transform' && key !== 'physics')
326
- .map(([key, comp]) => {
327
- if (!comp || !comp.type) return null;
321
+
322
+ // Collect wrapper and leaf components (excluding transform/physics which are handled separately)
323
+ const wrapperComponents: Array<{ key: string; View: any; properties: any }> = [];
324
+ const leafComponents: React.ReactNode[] = [];
325
+
326
+ if (gameObject.components) {
327
+ Object.entries(gameObject.components)
328
+ .filter(([key]) => !['geometry', 'material', 'model', 'transform', 'physics'].includes(key))
329
+ .forEach(([key, comp]) => {
330
+ if (!comp || !comp.type) return;
328
331
  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;
333
-
334
- // If we have a model (non-instanced) render it as a primitive with material override
335
- if (isModelAvailable) {
336
- const modelObj = ctx.loadedModels[modelComp.properties.filename].clone();
337
- return (
338
- <primitive object={modelObj}>
332
+ if (!def || !def.View) return;
333
+
334
+ // Components that accept children are wrappers, others are leaves
335
+ const viewString = def.View.toString();
336
+ if (viewString.includes('children')) {
337
+ wrapperComponents.push({ key, View: def.View, properties: comp.properties });
338
+ } else {
339
+ leafComponents.push(<def.View key={key} properties={comp.properties} {...contextProps} />);
340
+ }
341
+ });
342
+ }
343
+
344
+ // Build core content based on what components exist
345
+ let coreContent: React.ReactNode;
346
+
347
+ // Priority: Model > Geometry + Material > Empty
348
+ if (model && modelDef && modelDef.View) {
349
+ // Model component wraps its children (including material override)
350
+ coreContent = (
351
+ <modelDef.View properties={model.properties} {...contextProps}>
339
352
  {material && materialDef && materialDef.View && (
340
353
  <materialDef.View
341
354
  key="material"
342
355
  properties={material.properties}
343
- loadedTextures={ctx.loadedTextures}
344
- isSelected={ctx.selectedId === gameObject.id}
345
- editMode={ctx.editMode}
346
- parentMatrix={parentMatrix}
347
- registerRef={ctx.registerRef}
356
+ {...contextProps}
348
357
  />
349
358
  )}
350
- {allComponentViews}
351
- </primitive>
359
+ {leafComponents}
360
+ </modelDef.View>
352
361
  );
353
- }
354
-
355
- // Otherwise, if geometry present, render a mesh
356
- if (geometry && geometryDef && geometryDef.View) {
357
- return (
358
- <mesh>
359
- <geometryDef.View key="geometry" properties={geometry.properties} {...contextProps} />
362
+ } else if (geometry && geometryDef && geometryDef.View) {
363
+ // Geometry + Material = mesh
364
+ coreContent = (
365
+ <mesh castShadow receiveShadow>
366
+ <geometryDef.View properties={geometry.properties} {...contextProps} />
360
367
  {material && materialDef && materialDef.View && (
361
368
  <materialDef.View
362
369
  key="material"
363
370
  properties={material.properties}
364
- loadedTextures={ctx.loadedTextures}
365
- isSelected={ctx.selectedId === gameObject.id}
366
- editMode={ctx.editMode}
367
- parentMatrix={parentMatrix}
368
- registerRef={ctx.registerRef}
371
+ {...contextProps}
369
372
  />
370
373
  )}
371
- {allComponentViews}
374
+ {leafComponents}
372
375
  </mesh>
373
376
  );
377
+ } else {
378
+ // No visual component - just render leaves
379
+ coreContent = <>{leafComponents}</>;
374
380
  }
375
381
 
376
- // Default: render other component views (no geometry/model)
377
- return <>{allComponentViews}</>;
382
+ // Wrap core content with wrapper components (in order)
383
+ return wrapperComponents.reduce((content, { key, View, properties }) => {
384
+ return <View key={key} properties={properties} {...contextProps}>{content}</View>;
385
+ }, coreContent);
378
386
  }
379
387
 
380
388
  // 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>;