react-three-game 0.0.50 → 0.0.51

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;
@@ -11,8 +11,9 @@ var __rest = (this && this.__rest) || function (s, e) {
11
11
  };
12
12
  import { jsx as _jsx } from "react/jsx-runtime";
13
13
  import { RigidBody } from "@react-three/rapier";
14
- import { useRef, useEffect } from 'react';
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,12 +77,17 @@ 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
+ },
79
85
  ];
80
86
  function PhysicsComponentEditor({ component, onUpdate }) {
81
87
  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
88
  }
83
89
  function PhysicsComponentView({ properties, children, position, rotation, scale, editMode, nodeId, registerRigidBodyRef }) {
84
- const { type, colliders } = properties, otherProps = __rest(properties, ["type", "colliders"]);
90
+ const { type, colliders, sensor } = properties, otherProps = __rest(properties, ["type", "colliders", "sensor"]);
85
91
  const colliderType = colliders || (type === 'fixed' ? 'trimesh' : 'hull');
86
92
  const rigidBodyRef = useRef(null);
87
93
  // Register RigidBody ref when it's available
@@ -95,12 +101,49 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
95
101
  }
96
102
  };
97
103
  }, [nodeId, registerRigidBodyRef]);
104
+ // Event handlers for physics interactions
105
+ const handleIntersectionEnter = useCallback((payload) => {
106
+ if (!nodeId)
107
+ return;
108
+ gameEvents.emit('sensor:enter', {
109
+ sourceEntityId: nodeId,
110
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
111
+ targetRigidBody: payload.other.rigidBody,
112
+ });
113
+ }, [nodeId]);
114
+ const handleIntersectionExit = useCallback((payload) => {
115
+ if (!nodeId)
116
+ return;
117
+ gameEvents.emit('sensor:exit', {
118
+ sourceEntityId: nodeId,
119
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
120
+ targetRigidBody: payload.other.rigidBody,
121
+ });
122
+ }, [nodeId]);
123
+ const handleCollisionEnter = useCallback((payload) => {
124
+ if (!nodeId)
125
+ return;
126
+ gameEvents.emit('collision:enter', {
127
+ sourceEntityId: nodeId,
128
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
129
+ targetRigidBody: payload.other.rigidBody,
130
+ });
131
+ }, [nodeId]);
132
+ const handleCollisionExit = useCallback((payload) => {
133
+ if (!nodeId)
134
+ return;
135
+ gameEvents.emit('collision:exit', {
136
+ sourceEntityId: nodeId,
137
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
138
+ targetRigidBody: payload.other.rigidBody,
139
+ });
140
+ }, [nodeId]);
98
141
  // In edit mode, include position/rotation in key to force remount when transform changes
99
142
  // This ensures the RigidBody debug visualization updates even when physics is paused
100
143
  const rbKey = editMode
101
144
  ? `${type || 'dynamic'}_${colliderType}_${position === null || position === void 0 ? void 0 : position.join(',')}_${rotation === null || rotation === void 0 ? void 0 : rotation.join(',')}`
102
145
  : `${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));
146
+ 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
147
  }
105
148
  const PhysicsComponent = {
106
149
  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.51",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -378,3 +378,77 @@ 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
+ See [Advanced Physics](./rules/ADVANCED_PHYSICS.md) for sensor setup and collision handling patterns.
418
+
419
+ ### TypeScript: Typed Custom Events
420
+
421
+ Extend `GameEventMap` for type-safe custom events:
422
+
423
+ ```typescript
424
+ declare module 'react-three-game' {
425
+ interface GameEventMap {
426
+ 'player:death': { playerId: string; cause: string };
427
+ 'score:change': { delta: number; total: number };
428
+ 'level:complete': { levelId: number; time: number };
429
+ }
430
+ }
431
+ ```
432
+
433
+ ### Common Patterns
434
+
435
+ ```tsx
436
+ // Gameplay controller
437
+ function GameController() {
438
+ const [score, setScore] = useState(0);
439
+
440
+ useGameEvent('score:change', ({ total }) => setScore(total), []);
441
+ useGameEvent('player:death', () => setGameOver(true), []);
442
+
443
+ return <ScoreUI score={score} />;
444
+ }
445
+
446
+ // Pickup system
447
+ useGameEvent('sensor:enter', (payload) => {
448
+ if (payload.sourceEntityId.startsWith('coin-')) {
449
+ gameEvents.emit('score:change', { delta: 10, total: score + 10 });
450
+ removeEntity(payload.sourceEntityId);
451
+ }
452
+ }, [score]);
453
+ ```
454
+
@@ -49,7 +49,7 @@ Complete reference for `Physics` component properties:
49
49
  | `enabledTranslations` | `[bool, bool, bool]` | `[true, true, true]` | Lock per axis (X, Y, Z) |
50
50
  | `enabledRotations` | `[bool, bool, bool]` | `[true, true, true]` | Lock rotation per axis |
51
51
  | `ccd` | `boolean` | `false` | Continuous collision detection (fast objects) |
52
- | `sensor` | `boolean` | `false` | Trigger only, no collision response |
52
+ | `sensor` | `boolean` | `false` | Trigger only, no collision response (see Sensors & Events) |
53
53
  | `collisionGroups` | `number` | - | Rapier collision groups bitfield |
54
54
  | `solverGroups` | `number` | - | Rapier solver groups bitfield |
55
55
 
@@ -347,3 +347,108 @@ Add multiple instances - they'll be automatically batched:
347
347
  - **Scale handling**: Visual scale is applied per-instance, but collider scale may differ
348
348
  - **Transform updates**: Use `updateNodeById` to move instances (triggers re-sync)
349
349
  - **Memory**: One set of GPU buffers shared across all instances
350
+
351
+ ## Sensors & Collision Events
352
+
353
+ Sensors are colliders that detect intersections without generating physical contact forces. Use them for trigger zones, pickup areas, damage zones, and gameplay triggers.
354
+
355
+ ### Creating a Sensor
356
+
357
+ Set `sensor: true` in the Physics component:
358
+
359
+ ```json
360
+ {
361
+ "id": "trigger-zone",
362
+ "components": {
363
+ "transform": { "type": "Transform", "properties": { "position": [0, 1, 0] } },
364
+ "geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [4, 2, 4] } },
365
+ "physics": { "type": "Physics", "properties": { "type": "fixed", "sensor": true } }
366
+ }
367
+ }
368
+ ```
369
+
370
+ ### Physics Event Payload
371
+
372
+ All physics events include:
373
+
374
+ ```typescript
375
+ {
376
+ sourceEntityId: string; // The prefab entity that owns the collider
377
+ targetEntityId: string | null; // The other entity (if it's a prefab entity)
378
+ targetRigidBody: RapierRigidBody; // Direct access to the other RigidBody
379
+ }
380
+ ```
381
+
382
+ `targetEntityId` is `null` when colliding with non-prefab physics bodies (custom R3F components). Use `targetRigidBody` to inspect those.
383
+
384
+ ### Common Sensor Patterns
385
+
386
+ **Pickup Item:**
387
+ ```json
388
+ {
389
+ "id": "coin",
390
+ "components": {
391
+ "transform": { "type": "Transform", "properties": { "position": [5, 0.5, 0] } },
392
+ "model": { "type": "Model", "properties": { "filename": "models/coin.glb" } },
393
+ "physics": { "type": "Physics", "properties": { "type": "fixed", "sensor": true } }
394
+ }
395
+ }
396
+ ```
397
+
398
+ ```tsx
399
+ useGameEvent('sensor:enter', (payload) => {
400
+ if (payload.sourceEntityId === 'coin' && payload.targetEntityId === 'player') {
401
+ removeCoin();
402
+ gameEvents.emit('score:change', { delta: 100, total: score + 100 });
403
+ }
404
+ }, [score]);
405
+ ```
406
+
407
+ **Damage Zone:**
408
+ ```json
409
+ {
410
+ "id": "lava",
411
+ "components": {
412
+ "transform": { "type": "Transform", "properties": { "position": [0, 0, 0] } },
413
+ "geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [10, 0.5, 10] } },
414
+ "material": { "type": "Material", "properties": { "color": "#ff4400" } },
415
+ "physics": { "type": "Physics", "properties": { "type": "fixed", "sensor": true } }
416
+ }
417
+ }
418
+ ```
419
+
420
+ ```tsx
421
+ useGameEvent('sensor:enter', ({ sourceEntityId, targetEntityId }) => {
422
+ if (sourceEntityId === 'lava') {
423
+ gameEvents.emit('player:damage', { entityId: targetEntityId, amount: 50 });
424
+ }
425
+ }, []);
426
+ ```
427
+
428
+ **Level Transition:**
429
+ ```tsx
430
+ useGameEvent('sensor:enter', ({ sourceEntityId, targetEntityId }) => {
431
+ if (sourceEntityId === 'exit-door' && targetEntityId === 'player') {
432
+ loadNextLevel();
433
+ }
434
+ }, []);
435
+ ```
436
+
437
+ ### Interop with Custom R3F Physics
438
+
439
+ For custom RigidBody components to participate in the event system, set `userData.entityId`:
440
+
441
+ ```tsx
442
+ <RigidBody userData={{ entityId: 'player' }}>
443
+ <PlayerMesh />
444
+ </RigidBody>
445
+ ```
446
+
447
+ Now when prefab sensors detect this body, `targetEntityId` will be `'player'`.
448
+
449
+ ### Tips
450
+
451
+ - Sensors fire events for **all** intersecting bodies - filter by ID
452
+ - `sensor:exit` fires when something leaves a sensor zone
453
+ - `collision:enter/exit` fires for non-sensor physics bodies
454
+ - 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,10 +1,11 @@
1
1
  import { RigidBody, RapierRigidBody } from "@react-three/rapier";
2
- import type { RigidBodyOptions } 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
10
  export type PhysicsProps = RigidBodyOptions;
10
11
 
@@ -71,6 +72,11 @@ const physicsFields: FieldDefinition[] = [
71
72
  label: 'Gravity Scale',
72
73
  step: 0.1,
73
74
  },
75
+ {
76
+ name: 'sensor',
77
+ type: 'boolean',
78
+ label: 'Sensor (Trigger Only)',
79
+ },
74
80
  ];
75
81
 
76
82
  function PhysicsComponentEditor({ component, onUpdate }: { component: ComponentData; onUpdate: (newComp: any) => void }) {
@@ -95,7 +101,7 @@ interface PhysicsViewProps {
95
101
  }
96
102
 
97
103
  function PhysicsComponentView({ properties, children, position, rotation, scale, editMode, nodeId, registerRigidBodyRef }: PhysicsViewProps) {
98
- const { type, colliders, ...otherProps } = properties;
104
+ const { type, colliders, sensor, ...otherProps } = properties;
99
105
  const colliderType = colliders || (type === 'fixed' ? 'trimesh' : 'hull');
100
106
  const rigidBodyRef = useRef<RapierRigidBody>(null);
101
107
 
@@ -111,6 +117,43 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
111
117
  };
112
118
  }, [nodeId, registerRigidBodyRef]);
113
119
 
120
+ // Event handlers for physics interactions
121
+ const handleIntersectionEnter = useCallback((payload: IntersectionEnterPayload) => {
122
+ if (!nodeId) return;
123
+ gameEvents.emit('sensor:enter', {
124
+ sourceEntityId: nodeId,
125
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
126
+ targetRigidBody: payload.other.rigidBody,
127
+ });
128
+ }, [nodeId]);
129
+
130
+ const handleIntersectionExit = useCallback((payload: IntersectionExitPayload) => {
131
+ if (!nodeId) return;
132
+ gameEvents.emit('sensor:exit', {
133
+ sourceEntityId: nodeId,
134
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
135
+ targetRigidBody: payload.other.rigidBody,
136
+ });
137
+ }, [nodeId]);
138
+
139
+ const handleCollisionEnter = useCallback((payload: CollisionPayload) => {
140
+ if (!nodeId) return;
141
+ gameEvents.emit('collision:enter', {
142
+ sourceEntityId: nodeId,
143
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
144
+ targetRigidBody: payload.other.rigidBody,
145
+ });
146
+ }, [nodeId]);
147
+
148
+ const handleCollisionExit = useCallback((payload: CollisionPayload) => {
149
+ if (!nodeId) return;
150
+ gameEvents.emit('collision:exit', {
151
+ sourceEntityId: nodeId,
152
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
153
+ targetRigidBody: payload.other.rigidBody,
154
+ });
155
+ }, [nodeId]);
156
+
114
157
  // In edit mode, include position/rotation in key to force remount when transform changes
115
158
  // This ensures the RigidBody debug visualization updates even when physics is paused
116
159
  const rbKey = editMode
@@ -126,6 +169,12 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
126
169
  position={position}
127
170
  rotation={rotation}
128
171
  scale={scale}
172
+ sensor={sensor}
173
+ userData={{ entityId: nodeId }}
174
+ onIntersectionEnter={handleIntersectionEnter}
175
+ onIntersectionExit={handleIntersectionExit}
176
+ onCollisionEnter={handleCollisionEnter}
177
+ onCollisionExit={handleCollisionExit}
129
178
  {...otherProps}
130
179
  >
131
180
  {children}