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 +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.js +46 -3
- package/package.json +1 -1
- package/react-three-game-skill/react-three-game/SKILL.md +74 -0
- package/react-three-game-skill/react-three-game/rules/ADVANCED_PHYSICS.md +106 -1
- package/src/index.ts +7 -0
- package/src/tools/prefabeditor/GameEvents.ts +191 -0
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +52 -3
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
|
@@ -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}
|