react-three-game 0.0.17 → 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.
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import * as THREE from 'three';
2
+ import { Object3D, Group } from "three";
3
3
  export type InstanceData = {
4
4
  id: string;
5
5
  position: [number, number, number];
@@ -13,10 +13,10 @@ export type InstanceData = {
13
13
  export declare function GameInstanceProvider({ children, models, onSelect, registerRef }: {
14
14
  children: React.ReactNode;
15
15
  models: {
16
- [filename: string]: THREE.Object3D;
16
+ [filename: string]: Object3D;
17
17
  };
18
18
  onSelect?: (id: string | null) => void;
19
- registerRef?: (id: string, obj: THREE.Object3D | null) => void;
19
+ registerRef?: (id: string, obj: Object3D | null) => void;
20
20
  }): import("react/jsx-runtime").JSX.Element;
21
21
  export declare const GameInstance: React.ForwardRefExoticComponent<{
22
22
  id: string;
@@ -27,4 +27,4 @@ export declare const GameInstance: React.ForwardRefExoticComponent<{
27
27
  physics?: {
28
28
  type: "dynamic" | "fixed";
29
29
  };
30
- } & React.RefAttributes<THREE.Group<THREE.Object3DEventMap>>>;
30
+ } & React.RefAttributes<Group<import("three").Object3DEventMap>>>;
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
3
3
  import { Merged } from '@react-three/drei';
4
- import * as THREE from 'three';
5
4
  import { InstancedRigidBodies } from "@react-three/rapier";
5
+ import { Mesh, Matrix4 } from "three";
6
+ // Helper functions for comparison
6
7
  function arrayEquals(a, b) {
7
8
  if (a === b)
8
9
  return true;
@@ -30,6 +31,7 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef }
30
31
  setInstances(prev => {
31
32
  const idx = prev.findIndex(i => i.id === instance.id);
32
33
  if (idx !== -1) {
34
+ // Update existing if changed
33
35
  if (instanceEquals(prev[idx], instance)) {
34
36
  return prev;
35
37
  }
@@ -37,6 +39,7 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef }
37
39
  copy[idx] = instance;
38
40
  return copy;
39
41
  }
42
+ // Add new
40
43
  return [...prev, instance];
41
44
  });
42
45
  }, []);
@@ -47,14 +50,14 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef }
47
50
  return prev.filter(i => i.id !== id);
48
51
  });
49
52
  }, []);
50
- // Flatten all model meshes once
53
+ // Flatten all model meshes once (models → flat mesh parts)
51
54
  const { flatMeshes, modelParts } = useMemo(() => {
52
55
  const flatMeshes = {};
53
56
  const modelParts = {};
54
57
  Object.entries(models).forEach(([modelKey, model]) => {
55
58
  const root = model;
56
59
  root.updateWorldMatrix(false, true);
57
- const rootInverse = new THREE.Matrix4().copy(root.matrixWorld).invert();
60
+ const rootInverse = new Matrix4().copy(root.matrixWorld).invert();
58
61
  let partIndex = 0;
59
62
  root.traverse((obj) => {
60
63
  if (obj.isMesh) {
@@ -62,7 +65,7 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef }
62
65
  const relativeTransform = obj.matrixWorld.clone().premultiply(rootInverse);
63
66
  geom.applyMatrix4(relativeTransform);
64
67
  const partKey = `${modelKey}__${partIndex}`;
65
- flatMeshes[partKey] = new THREE.Mesh(geom, obj.material);
68
+ flatMeshes[partKey] = new Mesh(geom, obj.material);
66
69
  partIndex++;
67
70
  }
68
71
  });
@@ -70,7 +73,7 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef }
70
73
  });
71
74
  return { flatMeshes, modelParts };
72
75
  }, [models]);
73
- // Group instances by meshPath + physics type
76
+ // Group instances by meshPath + physics type for batch rendering
74
77
  const grouped = useMemo(() => {
75
78
  var _a;
76
79
  const groups = {};
@@ -104,7 +107,7 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef }
104
107
  const partCount = modelParts[modelKey] || 0;
105
108
  if (partCount === 0)
106
109
  return null;
107
- // Restrict meshes to just this model's parts for this Merged
110
+ // Create mesh subset for this specific model
108
111
  const meshesForModel = {};
109
112
  for (let i = 0; i < partCount; i++) {
110
113
  const partKey = `${modelKey}__${i}`;
@@ -113,7 +116,7 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef }
113
116
  return (_jsx(Merged, { meshes: meshesForModel, castShadow: true, receiveShadow: true, children: (instancesMap) => (_jsx(NonPhysicsInstancedGroup, { modelKey: modelKey, group: group, partCount: partCount, instancesMap: instancesMap, onSelect: onSelect, registerRef: registerRef })) }, key));
114
117
  })] }));
115
118
  }
116
- // Physics instancing stays the same
119
+ // Render physics-enabled instances using InstancedRigidBodies
117
120
  function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes }) {
118
121
  const instances = useMemo(() => group.instances.map(inst => ({
119
122
  key: inst.id,
@@ -126,12 +129,17 @@ function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes }) {
126
129
  return (_jsx("instancedMesh", { args: [mesh.geometry, mesh.material, group.instances.length], castShadow: true, receiveShadow: true, frustumCulled: false }, i));
127
130
  }) }));
128
131
  }
129
- // Non-physics instanced visuals: per-instance group using Merged's Instance components
132
+ // Render non-physics instances using Merged's per-instance groups
130
133
  function NonPhysicsInstancedGroup({ modelKey, group, partCount, instancesMap, onSelect, registerRef }) {
131
134
  const clickValid = useRef(false);
132
- const handlePointerDown = (e) => { e.stopPropagation(); clickValid.current = true; };
133
- const handlePointerMove = () => { if (clickValid.current)
134
- clickValid.current = false; };
135
+ const handlePointerDown = (e) => {
136
+ e.stopPropagation();
137
+ clickValid.current = true;
138
+ };
139
+ const handlePointerMove = () => {
140
+ if (clickValid.current)
141
+ clickValid.current = false;
142
+ };
135
143
  const handlePointerUp = (e, id) => {
136
144
  if (clickValid.current) {
137
145
  e.stopPropagation();
@@ -146,7 +154,7 @@ function NonPhysicsInstancedGroup({ modelKey, group, partCount, instancesMap, on
146
154
  return _jsx(Instance, {}, i);
147
155
  }) }, inst.id))) }));
148
156
  }
149
- // --- GameInstance: just registers an instance, renders nothing ---
157
+ // GameInstance component: registers an instance for batch rendering (renders nothing itself)
150
158
  export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation, scale, physics = undefined, }, ref) => {
151
159
  const ctx = useContext(GameInstanceContext);
152
160
  const addInstance = ctx === null || ctx === void 0 ? void 0 : ctx.addInstance;
@@ -167,6 +175,6 @@ export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation
167
175
  removeInstance(instance.id);
168
176
  };
169
177
  }, [addInstance, removeInstance, instance]);
170
- // No visual here provider will render visuals for all instances
178
+ // No visual rendering - provider handles all instanced visuals
171
179
  return null;
172
180
  });
@@ -128,13 +128,15 @@ function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loa
128
128
  // Early return if gameObject is null or undefined
129
129
  if (!gameObject)
130
130
  return null;
131
- // Build a small context object to avoid long param lists
131
+ if (gameObject.disabled === true || gameObject.hidden === true)
132
+ return null;
133
+ // Build context object for passing to helper functions
132
134
  const ctx = { gameObject, selectedId, onSelect, registerRef, loadedModels, loadedTextures, editMode };
133
- // --- 1. Transform (local + world) ---
135
+ // --- 1. Compute transforms (local + world) ---
134
136
  const transformProps = getNodeTransformProps(gameObject);
135
137
  const localMatrix = new Matrix4().compose(new Vector3(...transformProps.position), new Quaternion().setFromEuler(new Euler(...transformProps.rotation)), new Vector3(...transformProps.scale));
136
138
  const worldMatrix = parentMatrix.clone().multiply(localMatrix);
137
- // preserve click/drag detection from previous implementation
139
+ // --- 2. Handle selection interaction (edit mode only) ---
138
140
  const clickValid = useRef(false);
139
141
  const handlePointerDown = (e) => {
140
142
  e.stopPropagation();
@@ -151,20 +153,18 @@ function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loa
151
153
  }
152
154
  clickValid.current = false;
153
155
  };
154
- if (gameObject.disabled === true || gameObject.hidden === true)
155
- return null;
156
- // --- 2. If instanced, short-circuit to a tiny clean branch ---
156
+ // --- 3. If instanced model, short-circuit to GameInstance (terminal node) ---
157
157
  const isInstanced = !!((_c = (_b = (_a = gameObject.components) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.properties) === null || _c === void 0 ? void 0 : _c.instanced);
158
158
  if (isInstanced) {
159
159
  return renderInstancedNode(gameObject, worldMatrix, ctx);
160
160
  }
161
- // --- 3. Core content decided by component registry ---
161
+ // --- 4. Render core content using component system ---
162
162
  const core = renderCoreNode(gameObject, ctx, parentMatrix);
163
- // --- 5. Render children (always relative transforms) ---
164
- const children = ((_d = gameObject.children) !== null && _d !== void 0 ? _d : []).map((child) => (_jsx(GameObjectRenderer, { gameObject: child, selectedId: selectedId, onSelect: onSelect, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: worldMatrix }, child.id)));
165
- // --- 4. Wrap with physics if needed ---
163
+ // --- 5. Wrap with physics if needed (except in edit mode) ---
166
164
  const physicsWrapped = wrapPhysicsIfNeeded(gameObject, core, ctx);
167
- // --- 6. Final group wrapper ---
165
+ // --- 6. Render children recursively (always relative transforms) ---
166
+ const children = ((_d = gameObject.children) !== null && _d !== void 0 ? _d : []).map((child) => (_jsx(GameObjectRenderer, { gameObject: child, selectedId: selectedId, onSelect: onSelect, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: worldMatrix }, child.id)));
167
+ // --- 7. Final group wrapper with local transform ---
168
168
  return (_jsxs("group", { ref: (el) => registerRef(gameObject.id, el), position: transformProps.position, rotation: transformProps.rotation, scale: transformProps.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: [physicsWrapped, children] }));
169
169
  }
170
170
  // Helper: render an instanced GameInstance (terminal node)
@@ -179,16 +179,16 @@ function renderInstancedNode(gameObject, worldMatrix, ctx) {
179
179
  const modelUrl = (_d = (_c = (_b = gameObject.components) === null || _b === void 0 ? void 0 : _b.model) === null || _c === void 0 ? void 0 : _c.properties) === null || _d === void 0 ? void 0 : _d.filename;
180
180
  return (_jsx(GameInstance, { id: gameObject.id, modelUrl: modelUrl, position: [wp.x, wp.y, wp.z], rotation: [we.x, we.y, we.z], scale: [ws.x, ws.y, ws.z], physics: ctx.editMode ? undefined : physics === null || physics === void 0 ? void 0 : physics.properties }));
181
181
  }
182
- // Helper: render main model/geometry content for a non-instanced node
182
+ // Helper: render main content for a non-instanced node using the component system
183
183
  function renderCoreNode(gameObject, ctx, parentMatrix) {
184
184
  var _a, _b, _c;
185
185
  const geometry = (_a = gameObject.components) === null || _a === void 0 ? void 0 : _a.geometry;
186
186
  const material = (_b = gameObject.components) === null || _b === void 0 ? void 0 : _b.material;
187
- const modelComp = (_c = gameObject.components) === null || _c === void 0 ? void 0 : _c.model;
187
+ const model = (_c = gameObject.components) === null || _c === void 0 ? void 0 : _c.model;
188
188
  const geometryDef = geometry ? getComponent('Geometry') : undefined;
189
189
  const materialDef = material ? getComponent('Material') : undefined;
190
- const isModelAvailable = !!(modelComp && modelComp.properties && modelComp.properties.filename && ctx.loadedModels[modelComp.properties.filename]);
191
- // Generic component views (exclude geometry/material/model/transform/physics)
190
+ const modelDef = model ? getComponent('Model') : undefined;
191
+ // Context props for all component Views
192
192
  const contextProps = {
193
193
  loadedModels: ctx.loadedModels,
194
194
  loadedTextures: ctx.loadedTextures,
@@ -197,20 +197,19 @@ function renderCoreNode(gameObject, ctx, parentMatrix) {
197
197
  parentMatrix,
198
198
  registerRef: ctx.registerRef,
199
199
  };
200
- // Separate wrapper components (that accept children) from leaf components
200
+ // Collect wrapper and leaf components (excluding transform/physics which are handled separately)
201
201
  const wrapperComponents = [];
202
202
  const leafComponents = [];
203
203
  if (gameObject.components) {
204
204
  Object.entries(gameObject.components)
205
- .filter(([key]) => key !== 'geometry' && key !== 'material' && key !== 'model' && key !== 'transform' && key !== 'physics')
205
+ .filter(([key]) => !['geometry', 'material', 'model', 'transform', 'physics'].includes(key))
206
206
  .forEach(([key, comp]) => {
207
207
  if (!comp || !comp.type)
208
208
  return;
209
209
  const def = getComponent(comp.type);
210
210
  if (!def || !def.View)
211
211
  return;
212
- // Check if the component View accepts children by checking function signature
213
- // Components that wrap content should accept children prop
212
+ // Components that accept children are wrappers, others are leaves
214
213
  const viewString = def.View.toString();
215
214
  if (viewString.includes('children')) {
216
215
  wrapperComponents.push({ key, View: def.View, properties: comp.properties });
@@ -220,19 +219,19 @@ function renderCoreNode(gameObject, ctx, parentMatrix) {
220
219
  }
221
220
  });
222
221
  }
223
- // Build the core content (model or mesh)
222
+ // Build core content based on what components exist
224
223
  let coreContent;
225
- // If we have a model (non-instanced) render it as a primitive with material override
226
- if (isModelAvailable) {
227
- const modelObj = ctx.loadedModels[modelComp.properties.filename].clone();
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] }));
224
+ // Priority: Model > Geometry + Material > Empty
225
+ if (model && modelDef && modelDef.View) {
226
+ // Model component wraps its children (including material override)
227
+ coreContent = (_jsxs(modelDef.View, Object.assign({ properties: model.properties }, contextProps, { children: [material && materialDef && materialDef.View && (_jsx(materialDef.View, Object.assign({ properties: material.properties }, contextProps), "material")), leafComponents] })));
229
228
  }
230
229
  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] }));
230
+ // Geometry + Material = mesh
231
+ coreContent = (_jsxs("mesh", { castShadow: true, receiveShadow: true, children: [_jsx(geometryDef.View, Object.assign({ properties: geometry.properties }, contextProps)), material && materialDef && materialDef.View && (_jsx(materialDef.View, Object.assign({ properties: material.properties }, contextProps), "material")), leafComponents] }));
233
232
  }
234
233
  else {
235
- // No geometry or model, just render leaf components
234
+ // No visual component - just render leaves
236
235
  coreContent = _jsx(_Fragment, { children: leafComponents });
237
236
  }
238
237
  // Wrap core content with wrapper components (in order)
@@ -0,0 +1,3 @@
1
+ import { Component } from "./ComponentRegistry";
2
+ declare const DirectionalLightComponent: Component;
3
+ export default DirectionalLightComponent;
@@ -0,0 +1,114 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRef, useEffect } from "react";
3
+ import { useFrame, useThree } from "@react-three/fiber";
4
+ import { CameraHelper, Object3D, Vector3 } from "three";
5
+ function DirectionalLightComponentEditor({ component, onUpdate }) {
6
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
7
+ const props = {
8
+ color: (_a = component.properties.color) !== null && _a !== void 0 ? _a : '#ffffff',
9
+ intensity: (_b = component.properties.intensity) !== null && _b !== void 0 ? _b : 1.0,
10
+ castShadow: (_c = component.properties.castShadow) !== null && _c !== void 0 ? _c : true,
11
+ shadowMapSize: (_d = component.properties.shadowMapSize) !== null && _d !== void 0 ? _d : 1024,
12
+ shadowCameraNear: (_e = component.properties.shadowCameraNear) !== null && _e !== void 0 ? _e : 0.1,
13
+ shadowCameraFar: (_f = component.properties.shadowCameraFar) !== null && _f !== void 0 ? _f : 100,
14
+ shadowCameraTop: (_g = component.properties.shadowCameraTop) !== null && _g !== void 0 ? _g : 30,
15
+ shadowCameraBottom: (_h = component.properties.shadowCameraBottom) !== null && _h !== void 0 ? _h : -30,
16
+ shadowCameraLeft: (_j = component.properties.shadowCameraLeft) !== null && _j !== void 0 ? _j : -30,
17
+ shadowCameraRight: (_k = component.properties.shadowCameraRight) !== null && _k !== void 0 ? _k : 30,
18
+ targetOffset: (_l = component.properties.targetOffset) !== null && _l !== void 0 ? _l : [0, -5, 0]
19
+ };
20
+ 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: "Color" }), _jsxs("div", { className: "flex gap-0.5", children: [_jsx("input", { type: "color", className: "h-5 w-5 bg-transparent border-none cursor-pointer", value: props.color, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'color': e.target.value })) }), _jsx("input", { type: "text", className: "flex-1 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.color, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'color': e.target.value })) })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Intensity" }), _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.intensity, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'intensity': parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Cast Shadow" }), _jsx("input", { type: "checkbox", className: "h-4 w-4 bg-black/40 border border-cyan-500/30 cursor-pointer", checked: props.castShadow, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'castShadow': e.target.checked })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Shadow Map Size" }), _jsx("input", { type: "number", step: "256", 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.shadowMapSize, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'shadowMapSize': parseFloat(e.target.value) })) })] }), _jsxs("div", { className: "border-t border-cyan-500/20 pt-2 mt-2", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1", children: "Shadow Camera" }), _jsxs("div", { className: "grid grid-cols-2 gap-1", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-[8px] text-cyan-400/50 mb-0.5", children: "Near" }), _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.shadowCameraNear, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'shadowCameraNear': parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[8px] text-cyan-400/50 mb-0.5", children: "Far" }), _jsx("input", { type: "number", step: "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.shadowCameraFar, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'shadowCameraFar': parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[8px] text-cyan-400/50 mb-0.5", children: "Top" }), _jsx("input", { type: "number", step: "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.shadowCameraTop, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'shadowCameraTop': parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[8px] text-cyan-400/50 mb-0.5", children: "Bottom" }), _jsx("input", { type: "number", step: "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.shadowCameraBottom, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'shadowCameraBottom': parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[8px] text-cyan-400/50 mb-0.5", children: "Left" }), _jsx("input", { type: "number", step: "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.shadowCameraLeft, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'shadowCameraLeft': parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[8px] text-cyan-400/50 mb-0.5", children: "Right" }), _jsx("input", { type: "number", step: "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.shadowCameraRight, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'shadowCameraRight': parseFloat(e.target.value) })) })] })] })] }), _jsxs("div", { className: "border-t border-cyan-500/20 pt-2 mt-2", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1", children: "Target Offset" }), _jsxs("div", { className: "grid grid-cols-3 gap-1", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-[8px] text-cyan-400/50 mb-0.5", children: "X" }), _jsx("input", { type: "number", step: "0.5", 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.targetOffset[0], onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'targetOffset': [parseFloat(e.target.value), props.targetOffset[1], props.targetOffset[2]] })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[8px] text-cyan-400/50 mb-0.5", children: "Y" }), _jsx("input", { type: "number", step: "0.5", 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.targetOffset[1], onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'targetOffset': [props.targetOffset[0], parseFloat(e.target.value), props.targetOffset[2]] })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[8px] text-cyan-400/50 mb-0.5", children: "Z" }), _jsx("input", { type: "number", step: "0.5", 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.targetOffset[2], onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'targetOffset': [props.targetOffset[0], props.targetOffset[1], parseFloat(e.target.value)] })) })] })] })] })] });
21
+ }
22
+ function DirectionalLightView({ properties, editMode }) {
23
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
24
+ const color = (_a = properties.color) !== null && _a !== void 0 ? _a : '#ffffff';
25
+ const intensity = (_b = properties.intensity) !== null && _b !== void 0 ? _b : 1.0;
26
+ const castShadow = (_c = properties.castShadow) !== null && _c !== void 0 ? _c : true;
27
+ const shadowMapSize = (_d = properties.shadowMapSize) !== null && _d !== void 0 ? _d : 1024;
28
+ const shadowCameraNear = (_e = properties.shadowCameraNear) !== null && _e !== void 0 ? _e : 0.1;
29
+ const shadowCameraFar = (_f = properties.shadowCameraFar) !== null && _f !== void 0 ? _f : 100;
30
+ const shadowCameraTop = (_g = properties.shadowCameraTop) !== null && _g !== void 0 ? _g : 30;
31
+ const shadowCameraBottom = (_h = properties.shadowCameraBottom) !== null && _h !== void 0 ? _h : -30;
32
+ const shadowCameraLeft = (_j = properties.shadowCameraLeft) !== null && _j !== void 0 ? _j : -30;
33
+ const shadowCameraRight = (_k = properties.shadowCameraRight) !== null && _k !== void 0 ? _k : 30;
34
+ const targetOffset = (_l = properties.targetOffset) !== null && _l !== void 0 ? _l : [0, -5, 0];
35
+ const { scene } = useThree();
36
+ const directionalLightRef = useRef(null);
37
+ const targetRef = useRef(new Object3D());
38
+ const lastUpdate = useRef(0);
39
+ const cameraHelperRef = useRef(null);
40
+ const lastPositionRef = useRef(new Vector3());
41
+ // Add target to scene
42
+ useEffect(() => {
43
+ if (targetRef.current) {
44
+ scene.add(targetRef.current);
45
+ return () => {
46
+ scene.remove(targetRef.current);
47
+ };
48
+ }
49
+ }, [scene]);
50
+ // Update target position when light position or offset changes
51
+ useEffect(() => {
52
+ if (directionalLightRef.current && targetRef.current) {
53
+ const lightWorldPos = new Vector3();
54
+ directionalLightRef.current.getWorldPosition(lightWorldPos);
55
+ targetRef.current.position.set(lightWorldPos.x + targetOffset[0], lightWorldPos.y + targetOffset[1], lightWorldPos.z + targetOffset[2]);
56
+ directionalLightRef.current.target = targetRef.current;
57
+ }
58
+ });
59
+ useEffect(() => {
60
+ // Create camera helper for edit mode and add to scene
61
+ if (editMode && directionalLightRef.current && directionalLightRef.current.shadow.camera) {
62
+ const helper = new CameraHelper(directionalLightRef.current.shadow.camera);
63
+ cameraHelperRef.current = helper;
64
+ scene.add(helper);
65
+ return () => {
66
+ if (cameraHelperRef.current) {
67
+ scene.remove(cameraHelperRef.current);
68
+ cameraHelperRef.current.dispose();
69
+ }
70
+ };
71
+ }
72
+ }, [editMode, scene]);
73
+ useFrame(({ clock }) => {
74
+ if (!directionalLightRef.current || !directionalLightRef.current.shadow)
75
+ return;
76
+ // Disable auto-update for shadows
77
+ if (directionalLightRef.current.shadow.autoUpdate) {
78
+ directionalLightRef.current.shadow.autoUpdate = false;
79
+ directionalLightRef.current.shadow.needsUpdate = true;
80
+ }
81
+ // Check if position has changed
82
+ const currentPosition = new Vector3();
83
+ directionalLightRef.current.getWorldPosition(currentPosition);
84
+ const positionChanged = !currentPosition.equals(lastPositionRef.current);
85
+ if (positionChanged) {
86
+ lastPositionRef.current.copy(currentPosition);
87
+ directionalLightRef.current.shadow.needsUpdate = true;
88
+ lastUpdate.current = clock.elapsedTime; // Reset timer on position change
89
+ }
90
+ // Update shadow map infrequently (every 5 seconds) if position hasn't changed
91
+ if (!editMode && !positionChanged && clock.elapsedTime - lastUpdate.current > 5) {
92
+ lastUpdate.current = clock.elapsedTime;
93
+ directionalLightRef.current.shadow.needsUpdate = true;
94
+ }
95
+ // Update camera helper in edit mode
96
+ if (editMode && cameraHelperRef.current) {
97
+ cameraHelperRef.current.update();
98
+ }
99
+ });
100
+ return (_jsxs(_Fragment, { children: [_jsx("directionalLight", { ref: directionalLightRef, color: color, intensity: intensity, castShadow: castShadow, "shadow-mapSize": [shadowMapSize, shadowMapSize], "shadow-bias": -0.001, "shadow-normalBias": 0.02, children: _jsx("orthographicCamera", { attach: "shadow-camera", near: shadowCameraNear, far: shadowCameraFar, top: shadowCameraTop, bottom: shadowCameraBottom, left: shadowCameraLeft, right: shadowCameraRight }) }), editMode && (_jsxs(_Fragment, { children: [_jsxs("mesh", { children: [_jsx("sphereGeometry", { args: [0.3, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true })] }), _jsxs("mesh", { position: targetOffset, children: [_jsx("sphereGeometry", { args: [0.2, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true, opacity: 0.5, transparent: true })] }), _jsxs("line", { children: [_jsx("bufferGeometry", { onUpdate: (geo) => {
101
+ const points = [
102
+ new Vector3(0, 0, 0),
103
+ new Vector3(targetOffset[0], targetOffset[1], targetOffset[2])
104
+ ];
105
+ geo.setFromPoints(points);
106
+ } }), _jsx("lineBasicMaterial", { color: color, opacity: 0.6, transparent: true })] })] }))] }));
107
+ }
108
+ const DirectionalLightComponent = {
109
+ name: 'DirectionalLight',
110
+ Editor: DirectionalLightComponentEditor,
111
+ View: DirectionalLightView,
112
+ defaultProperties: {}
113
+ };
114
+ export default DirectionalLightComponent;
@@ -21,12 +21,20 @@ function ModelComponentEditor({ component, onUpdate, basePath = "" }) {
21
21
  function ModelComponentView({ properties, loadedModels, children }) {
22
22
  // Instanced models are handled elsewhere (GameInstance), so only render non-instanced here
23
23
  if (!properties.filename || properties.instanced)
24
- return children || null;
24
+ return _jsx(_Fragment, { children: children });
25
25
  if (loadedModels && loadedModels[properties.filename]) {
26
- return _jsxs(_Fragment, { children: [_jsx("primitive", { object: loadedModels[properties.filename].clone() }), children] });
26
+ const clonedModel = loadedModels[properties.filename].clone();
27
+ // Enable shadows on all meshes in the model
28
+ clonedModel.traverse((obj) => {
29
+ if (obj.isMesh) {
30
+ obj.castShadow = true;
31
+ obj.receiveShadow = true;
32
+ }
33
+ });
34
+ return _jsx("primitive", { object: clonedModel, children: children });
27
35
  }
28
- // Optionally, render a placeholder if model is not loaded
29
- return children || null;
36
+ // Model not loaded yet - render children only
37
+ return _jsx(_Fragment, { children: children });
30
38
  }
31
39
  const ModelComponent = {
32
40
  name: 'Model',
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRef, useEffect } from "react";
2
3
  function SpotLightComponentEditor({ component, onUpdate }) {
3
4
  var _a, _b, _c, _d, _e, _f;
4
- // Provide default values to prevent NaN
5
5
  const props = {
6
6
  color: (_a = component.properties.color) !== null && _a !== void 0 ? _a : '#ffffff',
7
7
  intensity: (_b = component.properties.intensity) !== null && _b !== void 0 ? _b : 1.0,
@@ -12,17 +12,22 @@ function SpotLightComponentEditor({ component, onUpdate }) {
12
12
  };
13
13
  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: "Color" }), _jsxs("div", { className: "flex gap-0.5", children: [_jsx("input", { type: "color", className: "h-5 w-5 bg-transparent border-none cursor-pointer", value: props.color, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'color': e.target.value })) }), _jsx("input", { type: "text", className: "flex-1 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.color, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'color': e.target.value })) })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Intensity" }), _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.intensity, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'intensity': parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Angle" }), _jsx("input", { type: "number", step: "0.1", min: "0", max: Math.PI, 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.angle, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'angle': parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Penumbra" }), _jsx("input", { type: "number", step: "0.1", min: "0", max: "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.penumbra, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'penumbra': parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Distance" }), _jsx("input", { type: "number", step: "1", min: "0", 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.distance, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'distance': parseFloat(e.target.value) })) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Cast Shadow" }), _jsx("input", { type: "checkbox", className: "h-4 w-4 bg-black/40 border border-cyan-500/30 cursor-pointer", checked: props.castShadow, onChange: e => onUpdate(Object.assign(Object.assign({}, component.properties), { 'castShadow': e.target.checked })) })] })] });
14
14
  }
15
- // The view component for SpotLight
16
- function SpotLightView({ properties }) {
15
+ function SpotLightView({ properties, editMode }) {
17
16
  var _a, _b, _c, _d, _e, _f;
18
- // Provide defaults in case properties are missing
19
17
  const color = (_a = properties.color) !== null && _a !== void 0 ? _a : '#ffffff';
20
18
  const intensity = (_b = properties.intensity) !== null && _b !== void 0 ? _b : 1.0;
21
19
  const angle = (_c = properties.angle) !== null && _c !== void 0 ? _c : Math.PI / 6;
22
20
  const penumbra = (_d = properties.penumbra) !== null && _d !== void 0 ? _d : 0.5;
23
21
  const distance = (_e = properties.distance) !== null && _e !== void 0 ? _e : 100;
24
22
  const castShadow = (_f = properties.castShadow) !== null && _f !== void 0 ? _f : true;
25
- return (_jsx(_Fragment, { children: _jsx("spotLight", { color: color, intensity: intensity, angle: angle, penumbra: penumbra, distance: distance, castShadow: castShadow }) }));
23
+ const spotLightRef = useRef(null);
24
+ const targetRef = useRef(null);
25
+ useEffect(() => {
26
+ if (spotLightRef.current && targetRef.current) {
27
+ spotLightRef.current.target = targetRef.current;
28
+ }
29
+ }, []);
30
+ return (_jsxs(_Fragment, { children: [_jsx("spotLight", { ref: spotLightRef, color: color, intensity: intensity, angle: angle, penumbra: penumbra, distance: distance, castShadow: castShadow, "shadow-bias": -0.0001, "shadow-normalBias": 0.02 }), _jsx("object3D", { ref: targetRef, position: [0, -5, 0] }), editMode && (_jsxs(_Fragment, { children: [_jsxs("mesh", { children: [_jsx("sphereGeometry", { args: [0.2, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true })] }), _jsxs("mesh", { position: [0, -5, 0], children: [_jsx("sphereGeometry", { args: [0.15, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true, opacity: 0.5, transparent: true })] })] }))] }));
26
31
  }
27
32
  const SpotLightComponent = {
28
33
  name: 'SpotLight',
@@ -3,6 +3,7 @@ import TransformComponent from './TransformComponent';
3
3
  import MaterialComponent from './MaterialComponent';
4
4
  import PhysicsComponent from './PhysicsComponent';
5
5
  import SpotLightComponent from './SpotLightComponent';
6
+ import DirectionalLightComponent from './DirectionalLightComponent';
6
7
  import ModelComponent from './ModelComponent';
7
8
  export default [
8
9
  GeometryComponent,
@@ -10,5 +11,6 @@ export default [
10
11
  MaterialComponent,
11
12
  PhysicsComponent,
12
13
  SpotLightComponent,
14
+ DirectionalLightComponent,
13
15
  ModelComponent
14
16
  ];
@@ -0,0 +1,10 @@
1
+ import { Object3D } from 'three';
2
+ /**
3
+ * Hook to load a model (GLB/GLTF/FBX) using drei's optimized loaders
4
+ * Returns the loaded model with proper caching and suspense support
5
+ */
6
+ export declare function useModel(filename: string | undefined): Object3D | null;
7
+ /**
8
+ * Preload a model to avoid suspense boundaries during runtime
9
+ */
10
+ export declare function preloadModel(filename: string): void;
@@ -0,0 +1,40 @@
1
+ import { useGLTF, useFBX } from '@react-three/drei';
2
+ import { useMemo } from 'react';
3
+ /**
4
+ * Hook to load a model (GLB/GLTF/FBX) using drei's optimized loaders
5
+ * Returns the loaded model with proper caching and suspense support
6
+ */
7
+ export function useModel(filename) {
8
+ const isFBX = filename === null || filename === void 0 ? void 0 : filename.toLowerCase().endsWith('.fbx');
9
+ const isGLTF = (filename === null || filename === void 0 ? void 0 : filename.toLowerCase().endsWith('.glb')) || (filename === null || filename === void 0 ? void 0 : filename.toLowerCase().endsWith('.gltf'));
10
+ // Normalize path (ensure leading slash)
11
+ const normalizedPath = useMemo(() => {
12
+ if (!filename)
13
+ return '';
14
+ return filename.startsWith('/') ? filename : `/${filename}`;
15
+ }, [filename]);
16
+ // Load models using drei hooks (these handle caching automatically)
17
+ const gltf = useGLTF(isGLTF && normalizedPath ? normalizedPath : '', true);
18
+ const fbx = useFBX(isFBX && normalizedPath ? normalizedPath : '');
19
+ // Return the appropriate model
20
+ if (!filename)
21
+ return null;
22
+ if (isGLTF)
23
+ return gltf.scene;
24
+ if (isFBX)
25
+ return fbx;
26
+ return null;
27
+ }
28
+ /**
29
+ * Preload a model to avoid suspense boundaries during runtime
30
+ */
31
+ export function preloadModel(filename) {
32
+ const normalizedPath = filename.startsWith('/') ? filename : `/${filename}`;
33
+ const isFBX = filename.toLowerCase().endsWith('.fbx');
34
+ if (isFBX) {
35
+ useFBX.preload(normalizedPath);
36
+ }
37
+ else {
38
+ useGLTF.preload(normalizedPath);
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "@react-three/rapier": ">=2.0.0",
23
23
  "react": ">=18.0.0",
24
24
  "react-dom": ">=18.0.0",
25
- "three": ">=0.181.0"
25
+ "three": ">=0.182.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@react-three/drei": "^10.7.7",
@@ -34,7 +34,7 @@
34
34
  "concurrently": "^9.2.1",
35
35
  "react": "^19.2.0",
36
36
  "react-dom": "^19.2.0",
37
- "three": "^0.181.2",
37
+ "three": "^0.182.0",
38
38
  "typescript": "^5.9.3"
39
39
  }
40
40
  }