react-three-game 0.0.50 → 0.0.52

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.
package/dist/index.d.ts CHANGED
@@ -12,5 +12,9 @@ export type { PrefabRootRef } from './tools/prefabeditor/PrefabRoot';
12
12
  export type { Component } from './tools/prefabeditor/components/ComponentRegistry';
13
13
  export type { FieldDefinition, FieldType } from './tools/prefabeditor/components/Input';
14
14
  export type { Prefab, GameObject, ComponentData } from './tools/prefabeditor/types';
15
+ export { gameEvents, useGameEvent, getEntityIdFromRigidBody } from './tools/prefabeditor/GameEvents';
16
+ export type { GameEventType, GameEventMap, GameEventPayload, PhysicsEventType, PhysicsEventPayload } from './tools/prefabeditor/GameEvents';
17
+ export { entityEvents, useEntityEvent } from './tools/prefabeditor/GameEvents';
18
+ export type { EntityEventType, EntityEventPayload } from './tools/prefabeditor/GameEvents';
15
19
  export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
16
20
  export { TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
package/dist/index.js CHANGED
@@ -12,6 +12,10 @@ export { registerComponent } from './tools/prefabeditor/components/ComponentRegi
12
12
  export { FieldRenderer, Input, Label, Vector3Input, ColorInput, StringInput, BooleanInput, SelectInput, } from './tools/prefabeditor/components/Input';
13
13
  // Prefab Editor - Styles & Utils
14
14
  export * from './tools/prefabeditor/utils';
15
+ // Game Events (physics + custom events)
16
+ export { gameEvents, useGameEvent, getEntityIdFromRigidBody } from './tools/prefabeditor/GameEvents';
17
+ // Backward compatibility aliases
18
+ export { entityEvents, useEntityEvent } from './tools/prefabeditor/GameEvents';
15
19
  // Asset Tools
16
20
  export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
17
21
  export { TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
@@ -0,0 +1,54 @@
1
+ import type { RapierRigidBody } from '@react-three/rapier';
2
+ export type EntityEventType = 'sensor:enter' | 'sensor:exit' | 'collision:enter' | 'collision:exit';
3
+ export interface EntityEventPayload {
4
+ sourceEntityId: string;
5
+ targetEntityId: string | null;
6
+ targetRigidBody: RapierRigidBody | null | undefined;
7
+ }
8
+ type EventHandler = (payload: EntityEventPayload) => void;
9
+ /**
10
+ * Entity event system for physics interactions.
11
+ *
12
+ * Events:
13
+ * - sensor:enter - Fired when something enters a sensor collider
14
+ * - sensor:exit - Fired when something exits a sensor collider
15
+ * - collision:enter - Fired when a collision starts
16
+ * - collision:exit - Fired when a collision ends
17
+ */
18
+ export declare const entityEvents: {
19
+ /**
20
+ * Emit an event to all subscribers
21
+ */
22
+ emit(type: EntityEventType, payload: EntityEventPayload): void;
23
+ /**
24
+ * Subscribe to an event type
25
+ * @returns Unsubscribe function
26
+ */
27
+ on(type: EntityEventType, handler: EventHandler): () => void;
28
+ /**
29
+ * Unsubscribe from an event type
30
+ */
31
+ off(type: EntityEventType, handler: EventHandler): void;
32
+ /**
33
+ * Remove all subscribers (useful for cleanup)
34
+ */
35
+ clear(): void;
36
+ };
37
+ /**
38
+ * React hook to subscribe to entity events.
39
+ * Automatically cleans up on unmount.
40
+ *
41
+ * @example
42
+ * useEntityEvent('sensor:enter', (payload) => {
43
+ * if (payload.sourceEntityId === 'trigger-zone') {
44
+ * console.log('Player entered trigger zone!');
45
+ * }
46
+ * });
47
+ */
48
+ export declare function useEntityEvent(type: EntityEventType, handler: EventHandler, deps?: any[]): void;
49
+ /**
50
+ * Helper to extract entity ID from Rapier collision data.
51
+ * Entity IDs are stored in RigidBody userData.
52
+ */
53
+ export declare function getEntityIdFromRigidBody(rigidBody: RapierRigidBody | null | undefined): string | null;
54
+ export {};
@@ -0,0 +1,85 @@
1
+ import { useEffect, useCallback } from 'react';
2
+ // Internal subscriber storage
3
+ const subscribers = new Map();
4
+ /**
5
+ * Entity event system for physics interactions.
6
+ *
7
+ * Events:
8
+ * - sensor:enter - Fired when something enters a sensor collider
9
+ * - sensor:exit - Fired when something exits a sensor collider
10
+ * - collision:enter - Fired when a collision starts
11
+ * - collision:exit - Fired when a collision ends
12
+ */
13
+ export const entityEvents = {
14
+ /**
15
+ * Emit an event to all subscribers
16
+ */
17
+ emit(type, payload) {
18
+ const handlers = subscribers.get(type);
19
+ if (handlers) {
20
+ handlers.forEach(handler => {
21
+ try {
22
+ handler(payload);
23
+ }
24
+ catch (e) {
25
+ console.error(`Error in entityEvents handler for ${type}:`, e);
26
+ }
27
+ });
28
+ }
29
+ },
30
+ /**
31
+ * Subscribe to an event type
32
+ * @returns Unsubscribe function
33
+ */
34
+ on(type, handler) {
35
+ if (!subscribers.has(type)) {
36
+ subscribers.set(type, new Set());
37
+ }
38
+ subscribers.get(type).add(handler);
39
+ return () => {
40
+ var _a;
41
+ (_a = subscribers.get(type)) === null || _a === void 0 ? void 0 : _a.delete(handler);
42
+ };
43
+ },
44
+ /**
45
+ * Unsubscribe from an event type
46
+ */
47
+ off(type, handler) {
48
+ var _a;
49
+ (_a = subscribers.get(type)) === null || _a === void 0 ? void 0 : _a.delete(handler);
50
+ },
51
+ /**
52
+ * Remove all subscribers (useful for cleanup)
53
+ */
54
+ clear() {
55
+ subscribers.clear();
56
+ }
57
+ };
58
+ /**
59
+ * React hook to subscribe to entity events.
60
+ * Automatically cleans up on unmount.
61
+ *
62
+ * @example
63
+ * useEntityEvent('sensor:enter', (payload) => {
64
+ * if (payload.sourceEntityId === 'trigger-zone') {
65
+ * console.log('Player entered trigger zone!');
66
+ * }
67
+ * });
68
+ */
69
+ export function useEntityEvent(type, handler, deps = []) {
70
+ const stableHandler = useCallback(handler, deps);
71
+ useEffect(() => {
72
+ return entityEvents.on(type, stableHandler);
73
+ }, [type, stableHandler]);
74
+ }
75
+ /**
76
+ * Helper to extract entity ID from Rapier collision data.
77
+ * Entity IDs are stored in RigidBody userData.
78
+ */
79
+ export function getEntityIdFromRigidBody(rigidBody) {
80
+ var _a;
81
+ if (!rigidBody)
82
+ return null;
83
+ const userData = rigidBody.userData;
84
+ return (_a = userData === null || userData === void 0 ? void 0 : userData.entityId) !== null && _a !== void 0 ? _a : null;
85
+ }
@@ -0,0 +1,126 @@
1
+ import type { RapierRigidBody } from '@react-three/rapier';
2
+ /** Physics event types (built-in) */
3
+ export type PhysicsEventType = 'sensor:enter' | 'sensor:exit' | 'collision:enter' | 'collision:exit';
4
+ /** Payload for physics events */
5
+ export interface PhysicsEventPayload {
6
+ sourceEntityId: string;
7
+ targetEntityId: string | null;
8
+ targetRigidBody: RapierRigidBody | null | undefined;
9
+ }
10
+ /**
11
+ * Register your custom event types here by extending this interface:
12
+ *
13
+ * declare module 'react-three-game' {
14
+ * interface GameEventMap {
15
+ * 'player:death': { playerId: string; cause: string };
16
+ * 'score:change': { delta: number; total: number };
17
+ * }
18
+ * }
19
+ */
20
+ export interface GameEventMap {
21
+ 'sensor:enter': PhysicsEventPayload;
22
+ 'sensor:exit': PhysicsEventPayload;
23
+ 'collision:enter': PhysicsEventPayload;
24
+ 'collision:exit': PhysicsEventPayload;
25
+ }
26
+ /** All registered event types */
27
+ export type GameEventType = keyof GameEventMap | (string & {});
28
+ /** Get payload type for an event, or fallback to generic */
29
+ export type GameEventPayload<T extends string> = T extends keyof GameEventMap ? GameEventMap[T] : Record<string, unknown>;
30
+ type EventHandler<T = unknown> = (payload: T) => void;
31
+ /**
32
+ * Game event system for all game interactions.
33
+ *
34
+ * Built-in physics events:
35
+ * - sensor:enter - Something entered a sensor collider
36
+ * - sensor:exit - Something exited a sensor collider
37
+ * - collision:enter - A collision started
38
+ * - collision:exit - A collision ended
39
+ *
40
+ * Custom events:
41
+ * - Emit any event type with any payload
42
+ * - Extend GameEventMap interface for type safety
43
+ *
44
+ * @example
45
+ * // Physics events (typed)
46
+ * gameEvents.emit('sensor:enter', { sourceEntityId: 'zone', targetEntityId: 'player', targetRigidBody: rb });
47
+ *
48
+ * // Custom events
49
+ * gameEvents.emit('player:death', { playerId: 'p1', cause: 'lava' });
50
+ * gameEvents.emit('level:complete', { levelId: 3, time: 45.2 });
51
+ */
52
+ export declare const gameEvents: {
53
+ /**
54
+ * Emit an event to all subscribers
55
+ */
56
+ emit<T extends string>(type: T, payload: GameEventPayload<T>): void;
57
+ /**
58
+ * Subscribe to an event type
59
+ * @returns Unsubscribe function
60
+ */
61
+ on<T extends string>(type: T, handler: EventHandler<GameEventPayload<T>>): () => void;
62
+ /**
63
+ * Unsubscribe from an event type
64
+ */
65
+ off<T extends string>(type: T, handler: EventHandler<GameEventPayload<T>>): void;
66
+ /**
67
+ * Remove all subscribers (useful for cleanup/reset)
68
+ */
69
+ clear(): void;
70
+ /**
71
+ * Check if an event type has any subscribers
72
+ */
73
+ hasListeners(type: string): boolean;
74
+ };
75
+ /**
76
+ * React hook to subscribe to game events.
77
+ * Automatically cleans up on unmount.
78
+ *
79
+ * @example
80
+ * // Physics event
81
+ * useGameEvent('sensor:enter', (payload) => {
82
+ * if (payload.sourceEntityId === 'coin') collectCoin();
83
+ * }, []);
84
+ *
85
+ * // Custom event
86
+ * useGameEvent('player:death', (payload) => {
87
+ * showGameOver(payload.cause);
88
+ * }, []);
89
+ */
90
+ export declare function useGameEvent<T extends string>(type: T, handler: EventHandler<GameEventPayload<T>>, deps?: unknown[]): void;
91
+ /**
92
+ * Helper to extract entity ID from Rapier collision data.
93
+ * Entity IDs are stored in RigidBody userData.
94
+ */
95
+ export declare function getEntityIdFromRigidBody(rigidBody: RapierRigidBody | null | undefined): string | null;
96
+ /** @deprecated Use gameEvents instead */
97
+ export declare const entityEvents: {
98
+ /**
99
+ * Emit an event to all subscribers
100
+ */
101
+ emit<T extends string>(type: T, payload: GameEventPayload<T>): void;
102
+ /**
103
+ * Subscribe to an event type
104
+ * @returns Unsubscribe function
105
+ */
106
+ on<T extends string>(type: T, handler: EventHandler<GameEventPayload<T>>): () => void;
107
+ /**
108
+ * Unsubscribe from an event type
109
+ */
110
+ off<T extends string>(type: T, handler: EventHandler<GameEventPayload<T>>): void;
111
+ /**
112
+ * Remove all subscribers (useful for cleanup/reset)
113
+ */
114
+ clear(): void;
115
+ /**
116
+ * Check if an event type has any subscribers
117
+ */
118
+ hasListeners(type: string): boolean;
119
+ };
120
+ /** @deprecated Use useGameEvent instead */
121
+ export declare const useEntityEvent: typeof useGameEvent;
122
+ /** @deprecated Use GameEventType instead */
123
+ export type EntityEventType = PhysicsEventType;
124
+ /** @deprecated Use PhysicsEventPayload instead */
125
+ export type EntityEventPayload = PhysicsEventPayload;
126
+ export {};
@@ -0,0 +1,119 @@
1
+ import { useEffect, useCallback } from 'react';
2
+ // Internal subscriber storage
3
+ const subscribers = new Map();
4
+ /**
5
+ * Game event system for all game interactions.
6
+ *
7
+ * Built-in physics events:
8
+ * - sensor:enter - Something entered a sensor collider
9
+ * - sensor:exit - Something exited a sensor collider
10
+ * - collision:enter - A collision started
11
+ * - collision:exit - A collision ended
12
+ *
13
+ * Custom events:
14
+ * - Emit any event type with any payload
15
+ * - Extend GameEventMap interface for type safety
16
+ *
17
+ * @example
18
+ * // Physics events (typed)
19
+ * gameEvents.emit('sensor:enter', { sourceEntityId: 'zone', targetEntityId: 'player', targetRigidBody: rb });
20
+ *
21
+ * // Custom events
22
+ * gameEvents.emit('player:death', { playerId: 'p1', cause: 'lava' });
23
+ * gameEvents.emit('level:complete', { levelId: 3, time: 45.2 });
24
+ */
25
+ export const gameEvents = {
26
+ /**
27
+ * Emit an event to all subscribers
28
+ */
29
+ emit(type, payload) {
30
+ const handlers = subscribers.get(type);
31
+ if (handlers) {
32
+ handlers.forEach(handler => {
33
+ try {
34
+ handler(payload);
35
+ }
36
+ catch (e) {
37
+ console.error(`Error in gameEvents handler for ${type}:`, e);
38
+ }
39
+ });
40
+ }
41
+ },
42
+ /**
43
+ * Subscribe to an event type
44
+ * @returns Unsubscribe function
45
+ */
46
+ on(type, handler) {
47
+ if (!subscribers.has(type)) {
48
+ subscribers.set(type, new Set());
49
+ }
50
+ subscribers.get(type).add(handler);
51
+ return () => {
52
+ var _a;
53
+ (_a = subscribers.get(type)) === null || _a === void 0 ? void 0 : _a.delete(handler);
54
+ };
55
+ },
56
+ /**
57
+ * Unsubscribe from an event type
58
+ */
59
+ off(type, handler) {
60
+ var _a;
61
+ (_a = subscribers.get(type)) === null || _a === void 0 ? void 0 : _a.delete(handler);
62
+ },
63
+ /**
64
+ * Remove all subscribers (useful for cleanup/reset)
65
+ */
66
+ clear() {
67
+ subscribers.clear();
68
+ },
69
+ /**
70
+ * Check if an event type has any subscribers
71
+ */
72
+ hasListeners(type) {
73
+ var _a, _b;
74
+ return ((_b = (_a = subscribers.get(type)) === null || _a === void 0 ? void 0 : _a.size) !== null && _b !== void 0 ? _b : 0) > 0;
75
+ }
76
+ };
77
+ /**
78
+ * React hook to subscribe to game events.
79
+ * Automatically cleans up on unmount.
80
+ *
81
+ * @example
82
+ * // Physics event
83
+ * useGameEvent('sensor:enter', (payload) => {
84
+ * if (payload.sourceEntityId === 'coin') collectCoin();
85
+ * }, []);
86
+ *
87
+ * // Custom event
88
+ * useGameEvent('player:death', (payload) => {
89
+ * showGameOver(payload.cause);
90
+ * }, []);
91
+ */
92
+ export function useGameEvent(type, handler, deps = []) {
93
+ // eslint-disable-next-line react-hooks/exhaustive-deps
94
+ const stableHandler = useCallback(handler, deps);
95
+ useEffect(() => {
96
+ return gameEvents.on(type, stableHandler);
97
+ }, [type, stableHandler]);
98
+ }
99
+ // ============================================================================
100
+ // Helpers
101
+ // ============================================================================
102
+ /**
103
+ * Helper to extract entity ID from Rapier collision data.
104
+ * Entity IDs are stored in RigidBody userData.
105
+ */
106
+ export function getEntityIdFromRigidBody(rigidBody) {
107
+ var _a;
108
+ if (!rigidBody)
109
+ return null;
110
+ const userData = rigidBody.userData;
111
+ return (_a = userData === null || userData === void 0 ? void 0 : userData.entityId) !== null && _a !== void 0 ? _a : null;
112
+ }
113
+ // ============================================================================
114
+ // Backward Compatibility Aliases
115
+ // ============================================================================
116
+ /** @deprecated Use gameEvents instead */
117
+ export const entityEvents = gameEvents;
118
+ /** @deprecated Use useGameEvent instead */
119
+ export const useEntityEvent = useGameEvent;
@@ -1,5 +1,7 @@
1
1
  import type { RigidBodyOptions } from "@react-three/rapier";
2
2
  import { Component } from "./ComponentRegistry";
3
- export type PhysicsProps = RigidBodyOptions;
3
+ export type PhysicsProps = RigidBodyOptions & {
4
+ activeCollisionTypes?: 'all' | undefined;
5
+ };
4
6
  declare const PhysicsComponent: Component;
5
7
  export default PhysicsComponent;
@@ -10,9 +10,10 @@ var __rest = (this && this.__rest) || function (s, e) {
10
10
  return t;
11
11
  };
12
12
  import { jsx as _jsx } from "react/jsx-runtime";
13
- import { RigidBody } from "@react-three/rapier";
14
- import { useRef, useEffect } from 'react';
13
+ import { RigidBody, useRapier } from "@react-three/rapier";
14
+ import { useRef, useEffect, useCallback } from 'react';
15
15
  import { FieldRenderer } from "./Input";
16
+ import { gameEvents, getEntityIdFromRigidBody } from "../GameEvents";
16
17
  const physicsFields = [
17
18
  {
18
19
  name: 'type',
@@ -76,14 +77,29 @@ const physicsFields = [
76
77
  label: 'Gravity Scale',
77
78
  step: 0.1,
78
79
  },
80
+ {
81
+ name: 'sensor',
82
+ type: 'boolean',
83
+ label: 'Sensor (Trigger Only)',
84
+ },
85
+ {
86
+ name: 'activeCollisionTypes',
87
+ type: 'select',
88
+ label: 'Collision Detection',
89
+ options: [
90
+ { value: '', label: 'Default (Dynamic only)' },
91
+ { value: 'all', label: 'All (includes kinematic & fixed)' },
92
+ ],
93
+ },
79
94
  ];
80
95
  function PhysicsComponentEditor({ component, onUpdate }) {
81
96
  return (_jsx(FieldRenderer, { fields: physicsFields, values: component.properties, onChange: (props) => onUpdate(Object.assign(Object.assign({}, component), { properties: Object.assign(Object.assign({}, component.properties), props) })) }));
82
97
  }
83
98
  function PhysicsComponentView({ properties, children, position, rotation, scale, editMode, nodeId, registerRigidBodyRef }) {
84
- const { type, colliders } = properties, otherProps = __rest(properties, ["type", "colliders"]);
99
+ const { type, colliders, sensor, activeCollisionTypes } = properties, otherProps = __rest(properties, ["type", "colliders", "sensor", "activeCollisionTypes"]);
85
100
  const colliderType = colliders || (type === 'fixed' ? 'trimesh' : 'hull');
86
101
  const rigidBodyRef = useRef(null);
102
+ const { rapier } = useRapier();
87
103
  // Register RigidBody ref when it's available
88
104
  useEffect(() => {
89
105
  if (nodeId && registerRigidBodyRef && rigidBodyRef.current) {
@@ -95,12 +111,62 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
95
111
  }
96
112
  };
97
113
  }, [nodeId, registerRigidBodyRef]);
114
+ // Configure active collision types for kinematic/sensor bodies
115
+ useEffect(() => {
116
+ if (activeCollisionTypes === 'all' && rigidBodyRef.current) {
117
+ const rb = rigidBodyRef.current;
118
+ // Apply to all colliders on this rigid body
119
+ for (let i = 0; i < rb.numColliders(); i++) {
120
+ const collider = rb.collider(i);
121
+ collider.setActiveCollisionTypes(rapier.ActiveCollisionTypes.DEFAULT |
122
+ rapier.ActiveCollisionTypes.KINEMATIC_FIXED |
123
+ rapier.ActiveCollisionTypes.KINEMATIC_KINEMATIC);
124
+ }
125
+ }
126
+ }, [activeCollisionTypes, rapier, type, colliders]);
127
+ // Event handlers for physics interactions
128
+ const handleIntersectionEnter = useCallback((payload) => {
129
+ if (!nodeId)
130
+ return;
131
+ gameEvents.emit('sensor:enter', {
132
+ sourceEntityId: nodeId,
133
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
134
+ targetRigidBody: payload.other.rigidBody,
135
+ });
136
+ }, [nodeId]);
137
+ const handleIntersectionExit = useCallback((payload) => {
138
+ if (!nodeId)
139
+ return;
140
+ gameEvents.emit('sensor:exit', {
141
+ sourceEntityId: nodeId,
142
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
143
+ targetRigidBody: payload.other.rigidBody,
144
+ });
145
+ }, [nodeId]);
146
+ const handleCollisionEnter = useCallback((payload) => {
147
+ if (!nodeId)
148
+ return;
149
+ gameEvents.emit('collision:enter', {
150
+ sourceEntityId: nodeId,
151
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
152
+ targetRigidBody: payload.other.rigidBody,
153
+ });
154
+ }, [nodeId]);
155
+ const handleCollisionExit = useCallback((payload) => {
156
+ if (!nodeId)
157
+ return;
158
+ gameEvents.emit('collision:exit', {
159
+ sourceEntityId: nodeId,
160
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
161
+ targetRigidBody: payload.other.rigidBody,
162
+ });
163
+ }, [nodeId]);
98
164
  // In edit mode, include position/rotation in key to force remount when transform changes
99
165
  // This ensures the RigidBody debug visualization updates even when physics is paused
100
166
  const rbKey = editMode
101
167
  ? `${type || 'dynamic'}_${colliderType}_${position === null || position === void 0 ? void 0 : position.join(',')}_${rotation === null || rotation === void 0 ? void 0 : rotation.join(',')}`
102
168
  : `${type || 'dynamic'}_${colliderType}`;
103
- return (_jsx(RigidBody, Object.assign({ ref: rigidBodyRef, type: type, colliders: colliderType, position: position, rotation: rotation, scale: scale }, otherProps, { children: children }), rbKey));
169
+ return (_jsx(RigidBody, Object.assign({ ref: rigidBodyRef, type: type, colliders: colliderType, position: position, rotation: rotation, scale: scale, sensor: sensor, userData: { entityId: nodeId }, onIntersectionEnter: handleIntersectionEnter, onIntersectionExit: handleIntersectionExit, onCollisionEnter: handleCollisionEnter, onCollisionExit: handleCollisionExit }, otherProps, { children: children }), rbKey));
104
170
  }
105
171
  const PhysicsComponent = {
106
172
  name: 'Physics',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.50",
3
+ "version": "0.0.52",
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 @@ Agents can programmatically generate 3D assets:
22
22
  ```tsx
23
23
  import { useRef, useEffect } from 'react';
24
24
  import { PrefabEditor, exportGLBData } from 'react-three-game';
25
- import type { PrefabEditorRef } from 'react-three-game';
25
+ import type { PrefabEditorRef } from 'react-three-game'
26
26
 
27
27
  const jsonPrefab = {
28
28
  root: {
@@ -116,7 +116,7 @@ Scenes are defined as JSON prefabs with a root node containing children:
116
116
  | Transform | `Transform` | `position: [x,y,z]`, `rotation: [x,y,z]` (radians), `scale: [x,y,z]` |
117
117
  | Geometry | `Geometry` | `geometryType`: box/sphere/plane/cylinder, `args`: dimension array |
118
118
  | Material | `Material` | `color`, `texture?`, `metalness?`, `roughness?`, `repeat?`, `repeatCount?` |
119
- | Physics | `Physics` | `type`: dynamic/fixed/kinematicPosition/kinematicVelocity, `mass?`, `restitution?`, `friction?`, `linearDamping?`, `angularDamping?`, `gravityScale?`, plus any Rapier RigidBody props - [See advanced physics guide](./rules/ADVANCED_PHYSICS.md) |
119
+ | Physics | `Physics` | `type`: dynamic/fixed/kinematicPosition/kinematicVelocity, `mass?`, `restitution?`, `friction?`, `linearDamping?`, `angularDamping?`, `gravityScale?`, `sensor?`, `activeCollisionTypes?: 'all'` (enable kinematic/fixed collision detection), plus any Rapier RigidBody props - [See advanced physics guide](./rules/ADVANCED_PHYSICS.md) |
120
120
  | Model | `Model` | `filename` (GLB/FBX path), `instanced?` for GPU batching |
121
121
  | SpotLight | `SpotLight` | `color`, `intensity`, `angle`, `penumbra`, `distance?`, `castShadow?` |
122
122
  | DirectionalLight | `DirectionalLight` | `color`, `intensity`, `castShadow?`, `targetOffset?: [x,y,z]` |
@@ -378,3 +378,79 @@ registerComponent(MyComponent);
378
378
 
379
379
  **Field types**: `vector3`, `number`, `string`, `color`, `boolean`, `select`, `custom`
380
380
 
381
+ ## Game Events
382
+
383
+ A general-purpose event system for game-wide communication. Handles physics events, gameplay events, and any custom events.
384
+
385
+ ### Core API
386
+
387
+ ```tsx
388
+ import { gameEvents, useGameEvent } from 'react-three-game';
389
+
390
+ // Emit events
391
+ gameEvents.emit('player:death', { playerId: 'p1', cause: 'lava' });
392
+ gameEvents.emit('score:change', { delta: 100, total: 500 });
393
+
394
+ // Subscribe (React hook - auto cleanup on unmount)
395
+ useGameEvent('player:death', (payload) => {
396
+ showGameOver(payload.cause);
397
+ }, []);
398
+
399
+ // Subscribe (manual - returns unsubscribe function)
400
+ const unsub = gameEvents.on('score:change', (payload) => {
401
+ updateUI(payload.total);
402
+ });
403
+ unsub(); // cleanup
404
+ ```
405
+
406
+ ### Built-in Physics Events
407
+
408
+ Physics components automatically emit these events:
409
+
410
+ | Event | When | Payload |
411
+ |-------|------|---------|
412
+ | `sensor:enter` | Something enters a sensor collider | `{ sourceEntityId, targetEntityId, targetRigidBody }` |
413
+ | `sensor:exit` | Something exits a sensor collider | `{ sourceEntityId, targetEntityId, targetRigidBody }` |
414
+ | `collision:enter` | A collision starts | `{ sourceEntityId, targetEntityId, targetRigidBody }` |
415
+ | `collision:exit` | A collision ends | `{ sourceEntityId, targetEntityId, targetRigidBody }` |
416
+
417
+ **Collision filtering**: By default, kinematic/fixed bodies don't detect each other. For kinematic sensors or projectiles to detect walls/floors, add `"activeCollisionTypes": "all"` to the Physics properties.
418
+
419
+ See [Advanced Physics](./rules/ADVANCED_PHYSICS.md) for sensor setup and collision handling patterns.
420
+
421
+ ### TypeScript: Typed Custom Events
422
+
423
+ Extend `GameEventMap` for type-safe custom events:
424
+
425
+ ```typescript
426
+ declare module 'react-three-game' {
427
+ interface GameEventMap {
428
+ 'player:death': { playerId: string; cause: string };
429
+ 'score:change': { delta: number; total: number };
430
+ 'level:complete': { levelId: number; time: number };
431
+ }
432
+ }
433
+ ```
434
+
435
+ ### Common Patterns
436
+
437
+ ```tsx
438
+ // Gameplay controller
439
+ function GameController() {
440
+ const [score, setScore] = useState(0);
441
+
442
+ useGameEvent('score:change', ({ total }) => setScore(total), []);
443
+ useGameEvent('player:death', () => setGameOver(true), []);
444
+
445
+ return <ScoreUI score={score} />;
446
+ }
447
+
448
+ // Pickup system
449
+ useGameEvent('sensor:enter', (payload) => {
450
+ if (payload.sourceEntityId.startsWith('coin-')) {
451
+ gameEvents.emit('score:change', { delta: 10, total: score + 10 });
452
+ removeEntity(payload.sourceEntityId);
453
+ }
454
+ }, [score]);
455
+ ```
456
+
@@ -50,6 +50,7 @@ Complete reference for `Physics` component properties:
50
50
  | `enabledRotations` | `[bool, bool, bool]` | `[true, true, true]` | Lock rotation per axis |
51
51
  | `ccd` | `boolean` | `false` | Continuous collision detection (fast objects) |
52
52
  | `sensor` | `boolean` | `false` | Trigger only, no collision response |
53
+ | `activeCollisionTypes` | `'all'` | - | Enable kinematic/fixed collision detection (default: dynamic only) |
53
54
  | `collisionGroups` | `number` | - | Rapier collision groups bitfield |
54
55
  | `solverGroups` | `number` | - | Rapier solver groups bitfield |
55
56
 
@@ -347,3 +348,123 @@ Add multiple instances - they'll be automatically batched:
347
348
  - **Scale handling**: Visual scale is applied per-instance, but collider scale may differ
348
349
  - **Transform updates**: Use `updateNodeById` to move instances (triggers re-sync)
349
350
  - **Memory**: One set of GPU buffers shared across all instances
351
+
352
+ ## Sensors & Collision Events
353
+
354
+ Sensors are colliders that detect intersections without generating physical contact forces. Use them for trigger zones, pickup areas, damage zones, and gameplay triggers.
355
+
356
+ ### Creating a Sensor
357
+
358
+ Set `sensor: true` in the Physics component:
359
+
360
+ ```json
361
+ {
362
+ "id": "trigger-zone",
363
+ "components": {
364
+ "transform": { "type": "Transform", "properties": { "position": [0, 1, 0] } },
365
+ "geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [4, 2, 4] } },
366
+ "physics": { "type": "Physics", "properties": { "type": "fixed", "sensor": true } }
367
+ }
368
+ }
369
+ ```
370
+
371
+ **Kinematic/Fixed Collision Detection**: By default, sensors only detect `dynamic` bodies. For kinematic sensors (like bullets) or to detect kinematic players, add `"activeCollisionTypes": "all"`:
372
+
373
+ ```json
374
+ {
375
+ "physics": {
376
+ "type": "Physics",
377
+ "properties": {
378
+ "type": "kinematicPosition",
379
+ "sensor": true,
380
+ "activeCollisionTypes": "all" // Detects walls, floors, kinematic bodies
381
+ }
382
+ }
383
+ }
384
+ ```
385
+
386
+ ### Physics Event Payload
387
+
388
+ All physics events include:
389
+
390
+ ```typescript
391
+ {
392
+ sourceEntityId: string; // The prefab entity that owns the collider
393
+ targetEntityId: string | null; // The other entity (if it's a prefab entity)
394
+ targetRigidBody: RapierRigidBody; // Direct access to the other RigidBody
395
+ }
396
+ ```
397
+
398
+ `targetEntityId` is `null` when colliding with non-prefab physics bodies (custom R3F components). Use `targetRigidBody` to inspect those.
399
+
400
+ ### Common Sensor Patterns
401
+
402
+ **Pickup Item:**
403
+ ```json
404
+ {
405
+ "id": "coin",
406
+ "components": {
407
+ "transform": { "type": "Transform", "properties": { "position": [5, 0.5, 0] } },
408
+ "model": { "type": "Model", "properties": { "filename": "models/coin.glb" } },
409
+ "physics": { "type": "Physics", "properties": { "type": "fixed", "sensor": true } }
410
+ }
411
+ }
412
+ ```
413
+
414
+ ```tsx
415
+ useGameEvent('sensor:enter', (payload) => {
416
+ if (payload.sourceEntityId === 'coin' && payload.targetEntityId === 'player') {
417
+ removeCoin();
418
+ gameEvents.emit('score:change', { delta: 100, total: score + 100 });
419
+ }
420
+ }, [score]);
421
+ ```
422
+
423
+ **Damage Zone:**
424
+ ```json
425
+ {
426
+ "id": "lava",
427
+ "components": {
428
+ "transform": { "type": "Transform", "properties": { "position": [0, 0, 0] } },
429
+ "geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [10, 0.5, 10] } },
430
+ "material": { "type": "Material", "properties": { "color": "#ff4400" } },
431
+ "physics": { "type": "Physics", "properties": { "type": "fixed", "sensor": true } }
432
+ }
433
+ }
434
+ ```
435
+
436
+ ```tsx
437
+ useGameEvent('sensor:enter', ({ sourceEntityId, targetEntityId }) => {
438
+ if (sourceEntityId === 'lava') {
439
+ gameEvents.emit('player:damage', { entityId: targetEntityId, amount: 50 });
440
+ }
441
+ }, []);
442
+ ```
443
+
444
+ **Level Transition:**
445
+ ```tsx
446
+ useGameEvent('sensor:enter', ({ sourceEntityId, targetEntityId }) => {
447
+ if (sourceEntityId === 'exit-door' && targetEntityId === 'player') {
448
+ loadNextLevel();
449
+ }
450
+ }, []);
451
+ ```
452
+
453
+ ### Interop with Custom R3F Physics
454
+
455
+ For custom RigidBody components to participate in the event system, set `userData.entityId`:
456
+
457
+ ```tsx
458
+ <RigidBody userData={{ entityId: 'player' }}>
459
+ <PlayerMesh />
460
+ </RigidBody>
461
+ ```
462
+
463
+ Now when prefab sensors detect this body, `targetEntityId` will be `'player'`.
464
+
465
+ ### Tips
466
+
467
+ - Sensors fire events for **all** intersecting bodies - filter by ID
468
+ - `sensor:exit` fires when something leaves a sensor zone
469
+ - `collision:enter/exit` fires for non-sensor physics bodies
470
+ - Entity IDs stored in `RigidBody.userData.entityId`
package/src/index.ts CHANGED
@@ -35,6 +35,13 @@ export type { Component } from './tools/prefabeditor/components/ComponentRegistr
35
35
  export type { FieldDefinition, FieldType } from './tools/prefabeditor/components/Input';
36
36
  export type { Prefab, GameObject, ComponentData } from './tools/prefabeditor/types';
37
37
 
38
+ // Game Events (physics + custom events)
39
+ export { gameEvents, useGameEvent, getEntityIdFromRigidBody } from './tools/prefabeditor/GameEvents';
40
+ export type { GameEventType, GameEventMap, GameEventPayload, PhysicsEventType, PhysicsEventPayload } from './tools/prefabeditor/GameEvents';
41
+ // Backward compatibility aliases
42
+ export { entityEvents, useEntityEvent } from './tools/prefabeditor/GameEvents';
43
+ export type { EntityEventType, EntityEventPayload } from './tools/prefabeditor/GameEvents';
44
+
38
45
  // Asset Tools
39
46
  export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
40
47
  export {
@@ -0,0 +1,191 @@
1
+ import { useEffect, useCallback } from 'react';
2
+ import type { RapierRigidBody } from '@react-three/rapier';
3
+
4
+ // ============================================================================
5
+ // Built-in Event Types & Payloads
6
+ // ============================================================================
7
+
8
+ /** Physics event types (built-in) */
9
+ export type PhysicsEventType =
10
+ | 'sensor:enter'
11
+ | 'sensor:exit'
12
+ | 'collision:enter'
13
+ | 'collision:exit';
14
+
15
+ /** Payload for physics events */
16
+ export interface PhysicsEventPayload {
17
+ sourceEntityId: string;
18
+ targetEntityId: string | null;
19
+ targetRigidBody: RapierRigidBody | null | undefined;
20
+ }
21
+
22
+ // ============================================================================
23
+ // Event Type Registry - Maps event names to their payload types
24
+ // ============================================================================
25
+
26
+ /**
27
+ * Register your custom event types here by extending this interface:
28
+ *
29
+ * declare module 'react-three-game' {
30
+ * interface GameEventMap {
31
+ * 'player:death': { playerId: string; cause: string };
32
+ * 'score:change': { delta: number; total: number };
33
+ * }
34
+ * }
35
+ */
36
+ export interface GameEventMap {
37
+ 'sensor:enter': PhysicsEventPayload;
38
+ 'sensor:exit': PhysicsEventPayload;
39
+ 'collision:enter': PhysicsEventPayload;
40
+ 'collision:exit': PhysicsEventPayload;
41
+ }
42
+
43
+ /** All registered event types */
44
+ export type GameEventType = keyof GameEventMap | (string & {});
45
+
46
+ /** Get payload type for an event, or fallback to generic */
47
+ export type GameEventPayload<T extends string> = T extends keyof GameEventMap
48
+ ? GameEventMap[T]
49
+ : Record<string, unknown>;
50
+
51
+ // ============================================================================
52
+ // Event System Implementation
53
+ // ============================================================================
54
+
55
+ type EventHandler<T = unknown> = (payload: T) => void;
56
+
57
+ // Internal subscriber storage
58
+ const subscribers = new Map<string, Set<EventHandler<any>>>();
59
+
60
+ /**
61
+ * Game event system for all game interactions.
62
+ *
63
+ * Built-in physics events:
64
+ * - sensor:enter - Something entered a sensor collider
65
+ * - sensor:exit - Something exited a sensor collider
66
+ * - collision:enter - A collision started
67
+ * - collision:exit - A collision ended
68
+ *
69
+ * Custom events:
70
+ * - Emit any event type with any payload
71
+ * - Extend GameEventMap interface for type safety
72
+ *
73
+ * @example
74
+ * // Physics events (typed)
75
+ * gameEvents.emit('sensor:enter', { sourceEntityId: 'zone', targetEntityId: 'player', targetRigidBody: rb });
76
+ *
77
+ * // Custom events
78
+ * gameEvents.emit('player:death', { playerId: 'p1', cause: 'lava' });
79
+ * gameEvents.emit('level:complete', { levelId: 3, time: 45.2 });
80
+ */
81
+ export const gameEvents = {
82
+ /**
83
+ * Emit an event to all subscribers
84
+ */
85
+ emit<T extends string>(type: T, payload: GameEventPayload<T>): void {
86
+ const handlers = subscribers.get(type);
87
+ if (handlers) {
88
+ handlers.forEach(handler => {
89
+ try {
90
+ handler(payload);
91
+ } catch (e) {
92
+ console.error(`Error in gameEvents handler for ${type}:`, e);
93
+ }
94
+ });
95
+ }
96
+ },
97
+
98
+ /**
99
+ * Subscribe to an event type
100
+ * @returns Unsubscribe function
101
+ */
102
+ on<T extends string>(type: T, handler: EventHandler<GameEventPayload<T>>): () => void {
103
+ if (!subscribers.has(type)) {
104
+ subscribers.set(type, new Set());
105
+ }
106
+ subscribers.get(type)!.add(handler);
107
+
108
+ return () => {
109
+ subscribers.get(type)?.delete(handler);
110
+ };
111
+ },
112
+
113
+ /**
114
+ * Unsubscribe from an event type
115
+ */
116
+ off<T extends string>(type: T, handler: EventHandler<GameEventPayload<T>>): void {
117
+ subscribers.get(type)?.delete(handler);
118
+ },
119
+
120
+ /**
121
+ * Remove all subscribers (useful for cleanup/reset)
122
+ */
123
+ clear(): void {
124
+ subscribers.clear();
125
+ },
126
+
127
+ /**
128
+ * Check if an event type has any subscribers
129
+ */
130
+ hasListeners(type: string): boolean {
131
+ return (subscribers.get(type)?.size ?? 0) > 0;
132
+ }
133
+ };
134
+
135
+ /**
136
+ * React hook to subscribe to game events.
137
+ * Automatically cleans up on unmount.
138
+ *
139
+ * @example
140
+ * // Physics event
141
+ * useGameEvent('sensor:enter', (payload) => {
142
+ * if (payload.sourceEntityId === 'coin') collectCoin();
143
+ * }, []);
144
+ *
145
+ * // Custom event
146
+ * useGameEvent('player:death', (payload) => {
147
+ * showGameOver(payload.cause);
148
+ * }, []);
149
+ */
150
+ export function useGameEvent<T extends string>(
151
+ type: T,
152
+ handler: EventHandler<GameEventPayload<T>>,
153
+ deps: unknown[] = []
154
+ ): void {
155
+ // eslint-disable-next-line react-hooks/exhaustive-deps
156
+ const stableHandler = useCallback(handler, deps);
157
+
158
+ useEffect(() => {
159
+ return gameEvents.on(type, stableHandler);
160
+ }, [type, stableHandler]);
161
+ }
162
+
163
+ // ============================================================================
164
+ // Helpers
165
+ // ============================================================================
166
+
167
+ /**
168
+ * Helper to extract entity ID from Rapier collision data.
169
+ * Entity IDs are stored in RigidBody userData.
170
+ */
171
+ export function getEntityIdFromRigidBody(rigidBody: RapierRigidBody | null | undefined): string | null {
172
+ if (!rigidBody) return null;
173
+ const userData = rigidBody.userData as { entityId?: string } | undefined;
174
+ return userData?.entityId ?? null;
175
+ }
176
+
177
+ // ============================================================================
178
+ // Backward Compatibility Aliases
179
+ // ============================================================================
180
+
181
+ /** @deprecated Use gameEvents instead */
182
+ export const entityEvents = gameEvents;
183
+
184
+ /** @deprecated Use useGameEvent instead */
185
+ export const useEntityEvent = useGameEvent;
186
+
187
+ /** @deprecated Use GameEventType instead */
188
+ export type EntityEventType = PhysicsEventType;
189
+
190
+ /** @deprecated Use PhysicsEventPayload instead */
191
+ export type EntityEventPayload = PhysicsEventPayload;
@@ -1,12 +1,15 @@
1
- import { RigidBody, RapierRigidBody } from "@react-three/rapier";
2
- import type { RigidBodyOptions } from "@react-three/rapier";
1
+ import { RigidBody, RapierRigidBody, useRapier } from "@react-three/rapier";
2
+ import type { RigidBodyOptions, CollisionPayload, IntersectionEnterPayload, IntersectionExitPayload } from "@react-three/rapier";
3
3
  import type { ReactNode } from 'react';
4
- import { useRef, useEffect } from 'react';
4
+ import { useRef, useEffect, useCallback } from 'react';
5
5
  import { Component } from "./ComponentRegistry";
6
6
  import { FieldRenderer, FieldDefinition } from "./Input";
7
7
  import { ComponentData } from "../types";
8
+ import { gameEvents, getEntityIdFromRigidBody } from "../GameEvents";
8
9
 
9
- export type PhysicsProps = RigidBodyOptions;
10
+ export type PhysicsProps = RigidBodyOptions & {
11
+ activeCollisionTypes?: 'all' | undefined;
12
+ };
10
13
 
11
14
  const physicsFields: FieldDefinition[] = [
12
15
  {
@@ -71,6 +74,20 @@ const physicsFields: FieldDefinition[] = [
71
74
  label: 'Gravity Scale',
72
75
  step: 0.1,
73
76
  },
77
+ {
78
+ name: 'sensor',
79
+ type: 'boolean',
80
+ label: 'Sensor (Trigger Only)',
81
+ },
82
+ {
83
+ name: 'activeCollisionTypes',
84
+ type: 'select',
85
+ label: 'Collision Detection',
86
+ options: [
87
+ { value: '', label: 'Default (Dynamic only)' },
88
+ { value: 'all', label: 'All (includes kinematic & fixed)' },
89
+ ],
90
+ },
74
91
  ];
75
92
 
76
93
  function PhysicsComponentEditor({ component, onUpdate }: { component: ComponentData; onUpdate: (newComp: any) => void }) {
@@ -95,9 +112,10 @@ interface PhysicsViewProps {
95
112
  }
96
113
 
97
114
  function PhysicsComponentView({ properties, children, position, rotation, scale, editMode, nodeId, registerRigidBodyRef }: PhysicsViewProps) {
98
- const { type, colliders, ...otherProps } = properties;
115
+ const { type, colliders, sensor, activeCollisionTypes, ...otherProps } = properties;
99
116
  const colliderType = colliders || (type === 'fixed' ? 'trimesh' : 'hull');
100
117
  const rigidBodyRef = useRef<RapierRigidBody>(null);
118
+ const { rapier } = useRapier();
101
119
 
102
120
  // Register RigidBody ref when it's available
103
121
  useEffect(() => {
@@ -111,6 +129,59 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
111
129
  };
112
130
  }, [nodeId, registerRigidBodyRef]);
113
131
 
132
+ // Configure active collision types for kinematic/sensor bodies
133
+ useEffect(() => {
134
+ if (activeCollisionTypes === 'all' && rigidBodyRef.current) {
135
+ const rb = rigidBodyRef.current;
136
+ // Apply to all colliders on this rigid body
137
+ for (let i = 0; i < rb.numColliders(); i++) {
138
+ const collider = rb.collider(i);
139
+ collider.setActiveCollisionTypes(
140
+ rapier.ActiveCollisionTypes.DEFAULT |
141
+ rapier.ActiveCollisionTypes.KINEMATIC_FIXED |
142
+ rapier.ActiveCollisionTypes.KINEMATIC_KINEMATIC
143
+ );
144
+ }
145
+ }
146
+ }, [activeCollisionTypes, rapier, type, colliders]);
147
+
148
+ // Event handlers for physics interactions
149
+ const handleIntersectionEnter = useCallback((payload: IntersectionEnterPayload) => {
150
+ if (!nodeId) return;
151
+ gameEvents.emit('sensor:enter', {
152
+ sourceEntityId: nodeId,
153
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
154
+ targetRigidBody: payload.other.rigidBody,
155
+ });
156
+ }, [nodeId]);
157
+
158
+ const handleIntersectionExit = useCallback((payload: IntersectionExitPayload) => {
159
+ if (!nodeId) return;
160
+ gameEvents.emit('sensor:exit', {
161
+ sourceEntityId: nodeId,
162
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
163
+ targetRigidBody: payload.other.rigidBody,
164
+ });
165
+ }, [nodeId]);
166
+
167
+ const handleCollisionEnter = useCallback((payload: CollisionPayload) => {
168
+ if (!nodeId) return;
169
+ gameEvents.emit('collision:enter', {
170
+ sourceEntityId: nodeId,
171
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
172
+ targetRigidBody: payload.other.rigidBody,
173
+ });
174
+ }, [nodeId]);
175
+
176
+ const handleCollisionExit = useCallback((payload: CollisionPayload) => {
177
+ if (!nodeId) return;
178
+ gameEvents.emit('collision:exit', {
179
+ sourceEntityId: nodeId,
180
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
181
+ targetRigidBody: payload.other.rigidBody,
182
+ });
183
+ }, [nodeId]);
184
+
114
185
  // In edit mode, include position/rotation in key to force remount when transform changes
115
186
  // This ensures the RigidBody debug visualization updates even when physics is paused
116
187
  const rbKey = editMode
@@ -126,6 +197,12 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
126
197
  position={position}
127
198
  rotation={rotation}
128
199
  scale={scale}
200
+ sensor={sensor}
201
+ userData={{ entityId: nodeId }}
202
+ onIntersectionEnter={handleIntersectionEnter}
203
+ onIntersectionExit={handleIntersectionExit}
204
+ onCollisionEnter={handleCollisionEnter}
205
+ onCollisionExit={handleCollisionExit}
129
206
  {...otherProps}
130
207
  >
131
208
  {children}