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 +4 -0
- package/dist/index.js +4 -0
- package/dist/tools/prefabeditor/EntityEvents.d.ts +54 -0
- package/dist/tools/prefabeditor/EntityEvents.js +85 -0
- package/dist/tools/prefabeditor/GameEvents.d.ts +126 -0
- package/dist/tools/prefabeditor/GameEvents.js +119 -0
- package/dist/tools/prefabeditor/components/PhysicsComponent.d.ts +3 -1
- package/dist/tools/prefabeditor/components/PhysicsComponent.js +70 -4
- package/package.json +1 -1
- package/react-three-game-skill/react-three-game/SKILL.md +78 -2
- package/react-three-game-skill/react-three-game/rules/ADVANCED_PHYSICS.md +121 -0
- package/src/index.ts +7 -0
- package/src/tools/prefabeditor/GameEvents.ts +191 -0
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +82 -5
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
|
@@ -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}
|