react-three-game 0.0.37 → 0.0.38

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 (29) hide show
  1. package/dist/index.d.ts +5 -3
  2. package/dist/index.js +5 -5
  3. package/dist/tools/prefabeditor/EditorContext.d.ts +11 -0
  4. package/dist/tools/prefabeditor/EditorContext.js +9 -0
  5. package/dist/tools/prefabeditor/EditorTree.d.ts +1 -3
  6. package/dist/tools/prefabeditor/EditorTree.js +38 -3
  7. package/dist/tools/prefabeditor/EditorUI.d.ts +1 -5
  8. package/dist/tools/prefabeditor/EditorUI.js +4 -2
  9. package/dist/tools/prefabeditor/ExportHelper.d.ts +7 -0
  10. package/dist/tools/prefabeditor/ExportHelper.js +55 -0
  11. package/dist/tools/prefabeditor/PrefabEditor.d.ts +10 -2
  12. package/dist/tools/prefabeditor/PrefabEditor.js +60 -53
  13. package/dist/tools/prefabeditor/PrefabRoot.d.ts +4 -2
  14. package/dist/tools/prefabeditor/PrefabRoot.js +18 -41
  15. package/dist/tools/prefabeditor/components/Input.d.ts +2 -1
  16. package/dist/tools/prefabeditor/components/Input.js +9 -3
  17. package/dist/tools/prefabeditor/components/TransformComponent.js +11 -3
  18. package/dist/tools/prefabeditor/utils.d.ts +7 -1
  19. package/dist/tools/prefabeditor/utils.js +41 -0
  20. package/package.json +1 -1
  21. package/src/index.ts +12 -12
  22. package/src/tools/prefabeditor/EditorContext.tsx +20 -0
  23. package/src/tools/prefabeditor/EditorTree.tsx +83 -22
  24. package/src/tools/prefabeditor/EditorUI.tsx +2 -10
  25. package/src/tools/prefabeditor/PrefabEditor.tsx +79 -50
  26. package/src/tools/prefabeditor/PrefabRoot.tsx +26 -64
  27. package/src/tools/prefabeditor/components/Input.tsx +11 -3
  28. package/src/tools/prefabeditor/components/TransformComponent.tsx +25 -4
  29. package/src/tools/prefabeditor/utils.ts +43 -1
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { MapControls, TransformControls, useHelper } from "@react-three/drei";
4
- import { forwardRef, useCallback, useEffect, useRef, useState, } from "react";
4
+ import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from "react";
5
5
  import { BoxHelper, Euler, Group, Matrix4, Object3D, Quaternion, SRGBColorSpace, Texture, TextureLoader, Vector3, } from "three";
6
6
  import { ThreeEvent } from "@react-three/fiber";
7
7
 
@@ -12,61 +12,61 @@ import { loadModel } from "../dragdrop/modelLoader";
12
12
  import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./InstanceProvider";
13
13
  import { updateNode } from "./utils";
14
14
  import { PhysicsProps } from "./components/PhysicsComponent";
15
-
16
- /* -------------------------------------------------- */
17
- /* Setup */
18
- /* -------------------------------------------------- */
15
+ import { EditorContext } from "./EditorContext";
19
16
 
20
17
  components.forEach(registerComponent);
21
18
 
22
19
  const IDENTITY = new Matrix4();
23
20
 
24
- /* -------------------------------------------------- */
25
- /* PrefabRoot */
26
- /* -------------------------------------------------- */
21
+ export interface PrefabRootRef {
22
+ root: Group | null;
23
+ }
27
24
 
28
- export const PrefabRoot = forwardRef<Group, {
25
+ export const PrefabRoot = forwardRef<PrefabRootRef, {
29
26
  editMode?: boolean;
30
27
  data: Prefab;
31
28
  onPrefabChange?: (data: Prefab) => void;
32
29
  selectedId?: string | null;
33
30
  onSelect?: (id: string | null) => void;
34
31
  onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
35
- transformMode?: "translate" | "rotate" | "scale";
36
32
  basePath?: string;
37
- }>(({ editMode, data, onPrefabChange, selectedId, onSelect, onClick, transformMode, basePath = "" }, ref) => {
33
+ }>(({ editMode, data, onPrefabChange, selectedId, onSelect, onClick, basePath = "" }, ref) => {
38
34
 
35
+ // optional editor context
36
+ const editorContext = useContext(EditorContext);
37
+ const transformMode = editorContext?.transformMode ?? "translate";
38
+ const snapResolution = editorContext?.snapResolution ?? 0;
39
+
40
+ // prefab root state
39
41
  const [models, setModels] = useState<Record<string, Object3D>>({});
40
42
  const [textures, setTextures] = useState<Record<string, Texture>>({});
41
43
  const loading = useRef(new Set<string>());
42
44
  const objectRefs = useRef<Record<string, Object3D | null>>({});
43
45
  const [selectedObject, setSelectedObject] = useState<Object3D | null>(null);
46
+ const rootRef = useRef<Group>(null);
47
+
48
+ useImperativeHandle(ref, () => ({
49
+ root: rootRef.current
50
+ }), []);
44
51
 
45
52
  const registerRef = useCallback((id: string, obj: Object3D | null) => {
46
53
  objectRefs.current[id] = obj;
47
54
  if (id === selectedId) setSelectedObject(obj);
48
55
  }, [selectedId]);
49
56
 
50
- // Suppress TransformControls scene graph warnings during transitions
51
57
  useEffect(() => {
52
58
  const originalError = console.error;
53
59
  console.error = (...args: any[]) => {
54
- if (typeof args[0] === 'string' && args[0].includes('TransformControls') && args[0].includes('scene graph')) {
55
- return; // Suppress this specific error
56
- }
60
+ if (typeof args[0] === 'string' && args[0].includes('TransformControls') && args[0].includes('scene graph')) return;
57
61
  originalError.apply(console, args);
58
62
  };
59
- return () => {
60
- console.error = originalError;
61
- };
63
+ return () => { console.error = originalError; };
62
64
  }, []);
63
65
 
64
66
  useEffect(() => {
65
67
  setSelectedObject(selectedId ? objectRefs.current[selectedId] ?? null : null);
66
68
  }, [selectedId]);
67
69
 
68
- /* ---------------- Transform writeback ---------------- */
69
-
70
70
  const onTransformChange = () => {
71
71
  if (!selectedId || !onPrefabChange) return;
72
72
 
@@ -92,8 +92,6 @@ export const PrefabRoot = forwardRef<Group, {
92
92
  onPrefabChange({ ...data, root });
93
93
  };
94
94
 
95
- /* ---------------- Asset loading ---------------- */
96
-
97
95
  useEffect(() => {
98
96
  const modelsToLoad = new Set<string>();
99
97
  const texturesToLoad = new Set<string>();
@@ -134,10 +132,8 @@ export const PrefabRoot = forwardRef<Group, {
134
132
  });
135
133
  }, [data, models, textures]);
136
134
 
137
- /* ---------------- Render ---------------- */
138
-
139
135
  return (
140
- <group ref={ref}>
136
+ <group ref={rootRef}>
141
137
  <GameInstanceProvider
142
138
  models={models}
143
139
  selectedId={selectedId}
@@ -163,10 +159,14 @@ export const PrefabRoot = forwardRef<Group, {
163
159
  <MapControls makeDefault />
164
160
  {selectedObject && (
165
161
  <TransformControls
162
+ key={`transform-${snapResolution}`}
166
163
  object={selectedObject}
167
164
  mode={transformMode}
168
165
  space="local"
169
166
  onObjectChange={onTransformChange}
167
+ translationSnap={snapResolution > 0 ? snapResolution : undefined}
168
+ rotationSnap={snapResolution > 0 ? snapResolution : undefined}
169
+ scaleSnap={snapResolution > 0 ? snapResolution : undefined}
170
170
  />
171
171
  )}
172
172
  </>
@@ -175,10 +175,6 @@ export const PrefabRoot = forwardRef<Group, {
175
175
  );
176
176
  });
177
177
 
178
- /* -------------------------------------------------- */
179
- /* Renderer Switch */
180
- /* -------------------------------------------------- */
181
-
182
178
  export function GameObjectRenderer(props: RendererProps) {
183
179
  const node = props.gameObject;
184
180
  if (!node || node.hidden || node.disabled) return null;
@@ -188,17 +184,14 @@ export function GameObjectRenderer(props: RendererProps) {
188
184
  const [isTransitioning, setIsTransitioning] = useState(false);
189
185
 
190
186
  useEffect(() => {
191
- // Detect instanced mode change
192
187
  if (prevInstancedRef.current !== undefined && prevInstancedRef.current !== isInstanced) {
193
188
  setIsTransitioning(true);
194
- // Wait for cleanup, then allow new mode to render
195
189
  const timer = setTimeout(() => setIsTransitioning(false), 100);
196
190
  return () => clearTimeout(timer);
197
191
  }
198
192
  prevInstancedRef.current = isInstanced;
199
193
  }, [isInstanced]);
200
194
 
201
- // Don't render during transition to avoid physics conflicts
202
195
  if (isTransitioning) return null;
203
196
 
204
197
  const key = `${node.id}_${isInstanced ? 'instanced' : 'standard'}`;
@@ -207,9 +200,6 @@ export function GameObjectRenderer(props: RendererProps) {
207
200
  : <StandardNode key={key} {...props} />;
208
201
  }
209
202
 
210
- /* -------------------------------------------------- */
211
- /* InstancedNode (terminal) */
212
- /* -------------------------------------------------- */
213
203
  function isPhysicsProps(v: any): v is PhysicsProps {
214
204
  return v?.type === "fixed" || v?.type === "dynamic";
215
205
  }
@@ -217,8 +207,6 @@ function isPhysicsProps(v: any): v is PhysicsProps {
217
207
  function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, registerRef, selectedId: _selectedId, onSelect, onClick }: RendererProps) {
218
208
  const world = parentMatrix.clone().multiply(compose(gameObject));
219
209
  const { position: worldPosition, rotation: worldRotation, scale: worldScale } = decompose(world);
220
-
221
- // Get local transform for proxy group (used by transform controls)
222
210
  const localTransform = getNodeTransformProps(gameObject);
223
211
 
224
212
  const physicsProps = isPhysicsProps(
@@ -239,12 +227,9 @@ function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, register
239
227
 
240
228
  const modelUrl = gameObject.components?.model?.properties?.filename;
241
229
 
242
- // In edit mode, create a proxy group at the same position for transform controls
243
- // The GameInstance still needs the actual position so it renders correctly
244
230
  if (editMode) {
245
231
  return (
246
232
  <>
247
- {/* Proxy group for transform controls - uses LOCAL transform */}
248
233
  <group
249
234
  ref={groupRef}
250
235
  position={localTransform.position}
@@ -261,12 +246,10 @@ function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, register
261
246
  clickValid.current = false;
262
247
  }}
263
248
  >
264
- {/* Tiny invisible mesh for raycasting/selection */}
265
249
  <mesh visible={false}>
266
250
  <boxGeometry args={[0.01, 0.01, 0.01]} />
267
251
  </mesh>
268
252
  </group>
269
- {/* Actual instance rendered by provider - uses WORLD transform */}
270
253
  <GameInstance
271
254
  id={gameObject.id}
272
255
  modelUrl={modelUrl}
@@ -291,10 +274,6 @@ function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, register
291
274
  );
292
275
  }
293
276
 
294
- /* -------------------------------------------------- */
295
- /* StandardNode */
296
- /* -------------------------------------------------- */
297
-
298
277
  function StandardNode({
299
278
  gameObject,
300
279
  selectedId,
@@ -311,11 +290,8 @@ function StandardNode({
311
290
  const helperRef = useRef<Object3D | null>(null);
312
291
  const clickValid = useRef(false);
313
292
  const isSelected = selectedId === gameObject.id;
314
-
315
- // Check if this object still exists as an instance (to prevent physics overlap)
316
293
  const stillInstanced = useInstanceCheck(gameObject.id);
317
294
 
318
- // Use helperRef for BoxHelper (shows actual content bounds at correct position)
319
295
  useHelper(
320
296
  editMode && isSelected ? helperRef as React.RefObject<Object3D> : null,
321
297
  BoxHelper,
@@ -348,8 +324,6 @@ function StandardNode({
348
324
  loadedModels[gameObject.components.model.properties.filename];
349
325
  const hasPhysics = physics && ready && !stillInstanced;
350
326
  const transform = getNodeTransformProps(gameObject);
351
-
352
- // Prepare physics wrapper if needed
353
327
  const physicsDef = hasPhysics ? getComponent("Physics") : null;
354
328
  const isInstanced = gameObject.components?.model?.properties?.instanced;
355
329
  const physicsKey = `physics_${gameObject.id}_${isInstanced ? 'instanced' : 'standard'}`;
@@ -364,7 +338,6 @@ function StandardNode({
364
338
  {gameObject.children?.map(child => (
365
339
  <GameObjectRenderer
366
340
  key={child.id}
367
- {...{ child }}
368
341
  gameObject={child}
369
342
  selectedId={selectedId}
370
343
  onSelect={onSelect}
@@ -379,23 +352,19 @@ function StandardNode({
379
352
  </group>
380
353
  );
381
354
 
382
- // In edit mode, use proxy group pattern
383
355
  if (editMode) {
384
356
  return (
385
357
  <>
386
- {/* Proxy group for transform controls - uses LOCAL transform */}
387
358
  <group
388
359
  ref={groupRef}
389
360
  position={transform.position}
390
361
  rotation={transform.rotation}
391
362
  scale={transform.scale}
392
363
  >
393
- {/* Tiny invisible mesh for raycasting/selection */}
394
364
  <mesh visible={false}>
395
365
  <boxGeometry args={[0.01, 0.01, 0.01]} />
396
366
  </mesh>
397
367
  </group>
398
- {/* Helper group for BoxHelper - same transform as proxy, contains actual geometry */}
399
368
  <group
400
369
  ref={helperRef}
401
370
  position={transform.position}
@@ -404,7 +373,6 @@ function StandardNode({
404
373
  >
405
374
  {inner}
406
375
  </group>
407
- {/* Actual content with physics wrapper if needed */}
408
376
  {hasPhysics && physicsDef?.View ? (
409
377
  <physicsDef.View
410
378
  key={physicsKey}
@@ -419,7 +387,6 @@ function StandardNode({
419
387
  );
420
388
  }
421
389
 
422
- // In play mode, apply transform directly to content
423
390
  if (hasPhysics && physicsDef?.View) {
424
391
  return (
425
392
  <physicsDef.View
@@ -448,12 +415,8 @@ function StandardNode({
448
415
  );
449
416
  }
450
417
 
451
- /* -------------------------------------------------- */
452
- /* Types & Helpers */
453
- /* -------------------------------------------------- */
454
-
455
418
  interface RendererProps {
456
- gameObject: GameObjectType; // ← no longer optional
419
+ gameObject: GameObjectType;
457
420
  selectedId?: string | null;
458
421
  onSelect?: (id: string) => void;
459
422
  onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
@@ -551,7 +514,6 @@ function renderCoreNode(
551
514
  const def = getComponent(comp.type);
552
515
  if (!def?.View) return;
553
516
 
554
- // crude but works with your existing component API
555
517
  if (def.View.toString().includes("children")) {
556
518
  wrappers.push({ key, View: def.View, properties: comp.properties });
557
519
  } else {
@@ -52,12 +52,19 @@ export function Label({ children }: { children: React.ReactNode }) {
52
52
  export function Vector3Input({
53
53
  label,
54
54
  value,
55
- onChange
55
+ onChange,
56
+ snap
56
57
  }: {
57
58
  label: string;
58
59
  value: [number, number, number];
59
60
  onChange: (v: [number, number, number]) => void;
61
+ snap?: number;
60
62
  }) {
63
+ const snapValue = (num: number) => {
64
+ if (!snap) return num;
65
+ return Math.round(num / snap) * snap;
66
+ };
67
+
61
68
  const [draft, setDraft] = useState<[string, string, string]>(
62
69
  () => value.map(v => v.toString()) as any
63
70
  );
@@ -77,7 +84,7 @@ export function Vector3Input({
77
84
  const num = parseFloat(draft[index]);
78
85
  if (Number.isFinite(num)) {
79
86
  const next = [...value] as [number, number, number];
80
- next[index] = num;
87
+ next[index] = snapValue(num);
81
88
  onChange(next);
82
89
  }
83
90
  };
@@ -105,7 +112,8 @@ export function Vector3Input({
105
112
  if (e.shiftKey) speed *= 0.1; // fine
106
113
  if (e.altKey) speed *= 5; // coarse
107
114
 
108
- const nextValue = startValue + dx * speed;
115
+ const rawValue = startValue + dx * speed;
116
+ const nextValue = snapValue(rawValue);
109
117
  const next = [...value] as [number, number, number];
110
118
  next[index] = nextValue;
111
119
 
@@ -1,5 +1,6 @@
1
1
  import { Component } from "./ComponentRegistry";
2
2
  import { Vector3Input, Label } from "./Input";
3
+ import { useEditorContext } from "../EditorContext";
3
4
 
4
5
  const buttonStyle = {
5
6
  padding: '2px 6px',
@@ -18,10 +19,12 @@ function TransformComponentEditor({ component, onUpdate, transformMode, setTrans
18
19
  transformMode?: "translate" | "rotate" | "scale";
19
20
  setTransformMode?: (m: "translate" | "rotate" | "scale") => void;
20
21
  }) {
22
+ const { snapResolution, setSnapResolution } = useEditorContext();
23
+
21
24
  return <div style={{ display: 'flex', flexDirection: 'column' }}>
22
25
  {transformMode && setTransformMode && (
23
26
  <div style={{ marginBottom: 8 }}>
24
- <Label>Transform Mode</Label>
27
+ <Label>Transform Mode {snapResolution > 0 && `(Snap: ${snapResolution})`}</Label>
25
28
  <div style={{ display: 'flex', gap: 6 }}>
26
29
  {["translate", "rotate", "scale"].map(mode => {
27
30
  const isActive = transformMode === mode;
@@ -45,11 +48,29 @@ function TransformComponentEditor({ component, onUpdate, transformMode, setTrans
45
48
  );
46
49
  })}
47
50
  </div>
51
+ <div style={{ marginTop: 6 }}>
52
+ <button
53
+ onClick={() => setSnapResolution(snapResolution > 0 ? 0 : 0.1)}
54
+ style={{
55
+ ...buttonStyle,
56
+ background: snapResolution > 0 ? 'rgba(255,255,255,0.10)' : 'transparent',
57
+ width: '100%',
58
+ }}
59
+ onPointerEnter={(e) => {
60
+ if (snapResolution === 0) e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
61
+ }}
62
+ onPointerLeave={(e) => {
63
+ if (snapResolution === 0) e.currentTarget.style.background = 'transparent';
64
+ }}
65
+ >
66
+ Snap: {snapResolution > 0 ? `ON (${snapResolution})` : 'OFF'}
67
+ </button>
68
+ </div>
48
69
  </div>
49
70
  )}
50
- <Vector3Input label="Position" value={component.properties.position} onChange={v => onUpdate({ position: v })} />
51
- <Vector3Input label="Rotation" value={component.properties.rotation} onChange={v => onUpdate({ rotation: v })} />
52
- <Vector3Input label="Scale" value={component.properties.scale} onChange={v => onUpdate({ scale: v })} />
71
+ <Vector3Input label="Position" value={component.properties.position} onChange={v => onUpdate({ position: v })} snap={snapResolution} />
72
+ <Vector3Input label="Rotation" value={component.properties.rotation} onChange={v => onUpdate({ rotation: v })} snap={snapResolution} />
73
+ <Vector3Input label="Scale" value={component.properties.scale} onChange={v => onUpdate({ scale: v })} snap={snapResolution} />
53
74
  </div>;
54
75
  }
55
76
 
@@ -1,4 +1,37 @@
1
- import { GameObject } from "./types";
1
+ import { GameObject, Prefab } from "./types";
2
+
3
+ /** Save a prefab as JSON file */
4
+ export function saveJson(data: Prefab, filename: string) {
5
+ const a = document.createElement('a');
6
+ a.href = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
7
+ a.download = `${filename || 'prefab'}.json`;
8
+ a.click();
9
+ }
10
+
11
+ /** Load a prefab from JSON file */
12
+ export function loadJson(): Promise<Prefab | undefined> {
13
+ return new Promise(resolve => {
14
+ const input = document.createElement('input');
15
+ input.type = 'file';
16
+ input.accept = '.json,application/json';
17
+ input.onchange = e => {
18
+ const file = (e.target as HTMLInputElement).files?.[0];
19
+ if (!file) return resolve(undefined);
20
+ const reader = new FileReader();
21
+ reader.onload = e => {
22
+ try {
23
+ const text = e.target?.result;
24
+ if (typeof text === 'string') resolve(JSON.parse(text) as Prefab);
25
+ } catch (err) {
26
+ console.error('Error parsing prefab JSON:', err);
27
+ resolve(undefined);
28
+ }
29
+ };
30
+ reader.readAsText(file);
31
+ };
32
+ input.click();
33
+ });
34
+ }
2
35
 
3
36
  /** Find a node by ID in the tree */
4
37
  export function findNode(root: GameObject, id: string): GameObject | null {
@@ -74,6 +107,15 @@ export function cloneNode(node: GameObject): GameObject {
74
107
  };
75
108
  }
76
109
 
110
+ /** Recursively update all IDs in a node tree */
111
+ export function regenerateIds(node: GameObject): GameObject {
112
+ return {
113
+ ...node,
114
+ id: crypto.randomUUID(),
115
+ children: node.children?.map(regenerateIds)
116
+ };
117
+ }
118
+
77
119
  /** Get component data from a node */
78
120
  export function getComponent<T = any>(node: GameObject, type: string): T | undefined {
79
121
  const comp = Object.values(node.components ?? {}).find(c => c?.type === type);