7.7 KiB
Interaction System Technical Notes
This document explains the shared trigger, grab, focus, and hand-grab system.
Purpose
The app has several ways for the player to affect the 3D scene:
- press
Eon focused trigger objects - hold the primary mouse button on grabbable objects
- close a tracked hand into a fist to grab hand-controlled objects
- release objects and optionally snap them into target positions
The implementation keeps those rules in a reusable interaction layer so gameplay features such as the repair game do not each create their own input system.
Main Files
| File | Responsibility |
|---|---|
src/managers/InteractionManager.ts |
Shared interaction state and imperative actions |
src/hooks/interaction/useInteraction.ts |
React subscription to the manager |
src/components/three/interaction/InteractableObject.tsx |
Distance/raycast focus detection |
src/components/three/interaction/TriggerObject.tsx |
Press-to-trigger wrapper |
src/components/three/interaction/GrabbableObject.tsx |
Physics-backed grab and hand grab wrapper |
src/components/ui/InteractPrompt.tsx |
HTML prompt for focused trigger interactions |
src/world/player/PlayerController.tsx |
Keyboard/mouse input bridge |
Architecture
The interaction system has three layers:
- R3F objects detect focus and register handles.
InteractionManagerstores the current interaction snapshot.- UI and player input read the snapshot and trigger the selected action.
This is intentionally not Zustand. Interaction focus and holding state are short-lived, frame-adjacent runtime state. A small singleton plus useSyncExternalStore is a better fit than putting high-frequency interaction details into the durable game progression store.
Interaction Snapshot
The snapshot type lives in:
src/types/interaction/interaction.ts
interface InteractionSnapshot {
focused: InteractableHandle | null;
nearby: boolean;
holding: boolean;
handHolding: boolean;
}
Meaning:
focused: the interactable currently aimed at by the camera raynearby: at least one interactable is within interaction radiusholding: mouse/player-controller grab is activehandHolding: hand-tracking grab is active
nearby, holding, and handHolding are also used by the hand-tracking provider to decide when webcam tracking should stay active in the debug physics scene.
Focus Detection
Focus detection lives in:
src/components/three/interaction/InteractableObject.tsx
Each frame, it:
- finds the interactable world position from its Rapier body or group transform
- checks distance from the camera
- marks the handle as nearby if it is inside radius
- raycasts from the camera forward direction
- sets the focused handle when the ray hits the object
- clears focus if the object is no longer nearby or no longer aimed at
This gives a simple first-person interaction model: the player must be close enough and looking at the object.
Trigger Objects
Trigger implementation:
src/components/three/interaction/TriggerObject.tsx
TriggerObject wraps children in a fixed Rapier body and exposes a trigger handle.
When triggered, it can:
- play an optional SFX through
AudioManager - call
onTrigger - spawn an optional model at an offset
Typical users:
- repair-object inspection
- repair-case open/fragment interaction
- install target
- completion target
- debug scene trigger sphere
Grabbable Objects
Grab implementation:
src/components/three/interaction/GrabbableObject.tsx
GrabbableObject wraps children in a dynamic Rapier body and exposes a grab handle.
Mouse/controller grab flow:
- Player focuses the object.
- Mouse down calls
InteractionManager.pressInteract(). - The object enters holding mode.
- Each frame, velocity is pushed toward a hold target in front of the camera.
- Mouse up calls
releaseInteract(). - The object can snap to the nearest configured target.
Important tuning values live in:
src/data/interaction/grabConfig.ts
The debug GUI exposes hold stiffness, throw boost, and hold distance.
Snap-To-Target
GrabbableObject supports:
snapTargetssnapRadiussnapDurationonSnap
On release, the object finds the nearest target inside snapRadius. If a target is found, GSAP animates the Rapier body translation to that target and calls onSnap.
The repair game uses this to place replacement parts and broken parts into case placeholders.
Hand-Controlled Grab
If handControlled is true, GrabbableObject also reads:
useHandTrackingSnapshot()
Hand grab flow:
- Find a detected hand where
hand.isFistis true. - Compute the visual center of the hand from landmark bounds.
- Convert that screen-space point to a camera ray.
- Raycast against the object.
- Use a small set of offset rays around the center to make hit detection more forgiving.
- If the object is in range and hit, enter
handHolding. - Move the object toward a hold target in front of the camera while the fist remains closed.
- When the fist opens or disappears, release and snap if possible.
This is an approximation, not a full 3D hand collider. It is a practical prototype compromise because MediaPipe gives normalized camera-space landmarks and relative depth, not stable world-space hand meshes.
Player Input Bridge
The player controller owns raw keyboard and mouse input:
src/world/player/PlayerController.tsx
It calls:
interaction.pressInteract()whenEis pressed and the focused handle is a triggerinteraction.pressInteract()on mouse down when the focused handle is a grabinteraction.releaseInteract()on mouse up when a grab is active
Input is ignored while:
- the settings menu is open
- a cinematic is playing
Movement lock is read separately from useRepairMovementLocked, which locks the player during focused repair steps.
UI Prompt
The prompt lives in:
src/components/ui/InteractPrompt.tsx
It appears only when:
- camera mode is
player - a focused interaction exists
- the player is not holding an object
- the focused interaction is a trigger
The prompt does not appear for grab objects, because grabs are mouse/hand actions rather than E trigger actions.
Debug Controls
Interaction debugging is split between:
- lil-gui
Interactionfolder for showing interaction spheres - lil-gui
GrabbableObjectfolder for grab tuning - debug physics scene for live trigger/grab testing
- hand-tracking debug panel for hand grab state
Use:
http://localhost:5173/?debug
Then switch the scene mode to Physics from lil-gui.
Why This Architecture Works
The interaction layer separates concerns:
- R3F objects know their distance/raycast hit state.
- The player controller owns input events.
- UI only subscribes to a snapshot.
- Gameplay objects receive semantic callbacks like
onTrigger,onSnap, oronPositionChange.
This keeps the repair game focused on gameplay rules instead of low-level input plumbing.
Known Limitations
- Only one focused handle is stored at a time.
- The focus rule is camera ray based, so side-facing interactions can feel strict without larger meshes or radii.
- Hand grab uses screen-space raycasts, not physical hand colliders.
- The manager is singleton-based, so tests must call
destroy()or isolate state when needed. nearbyis boolean, not a list exposed to UI, so the current UI cannot rank multiple nearby objects.