383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useFrame, useThree } from "@react-three/fiber";
|
|
import { RigidBody } from "@react-three/rapier";
|
|
import type { RapierRigidBody } from "@react-three/rapier";
|
|
import gsap from "gsap";
|
|
import * as THREE from "three";
|
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
|
import {
|
|
GRAB_DEFAULT_COLLIDERS,
|
|
GRAB_DEFAULT_LABEL,
|
|
GRAB_HOLD_DISTANCE_DEFAULT,
|
|
GRAB_HOLD_DISTANCE_MAX,
|
|
GRAB_HOLD_DISTANCE_MIN,
|
|
GRAB_HOLD_DISTANCE_STEP,
|
|
GRAB_STIFFNESS_DEFAULT,
|
|
GRAB_STIFFNESS_MAX,
|
|
GRAB_STIFFNESS_MIN,
|
|
GRAB_STIFFNESS_STEP,
|
|
GRAB_THROW_BOOST_DEFAULT,
|
|
GRAB_THROW_BOOST_MAX,
|
|
GRAB_THROW_BOOST_MIN,
|
|
GRAB_THROW_BOOST_STEP,
|
|
} from "@/data/interaction/grabConfig";
|
|
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
|
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
|
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
|
import { InteractionManager } from "@/managers/InteractionManager";
|
|
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
|
|
import type { ColliderShape, Vector3Tuple } from "@/types/three/three";
|
|
|
|
interface GrabbableObjectProps {
|
|
position: Vector3Tuple;
|
|
children: React.ReactNode;
|
|
colliders?: ColliderShape;
|
|
label?: string;
|
|
handControlled?: boolean;
|
|
disabled?: boolean;
|
|
lockUntilGrab?: boolean;
|
|
onGrabChange?: (held: boolean) => void;
|
|
onPositionChange?: (position: THREE.Vector3) => void;
|
|
onSnap?: (position: THREE.Vector3) => void;
|
|
snapDuration?: number;
|
|
snapRadius?: number;
|
|
snapTargets?: readonly Vector3Tuple[];
|
|
}
|
|
|
|
interface HandScreenPoint {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
const grabDebugParams = {
|
|
stiffness: GRAB_STIFFNESS_DEFAULT,
|
|
throwBoost: GRAB_THROW_BOOST_DEFAULT,
|
|
holdDistance: GRAB_HOLD_DISTANCE_DEFAULT,
|
|
};
|
|
|
|
const ZERO_ANGULAR_VELOCITY = { x: 0, y: 0, z: 0 };
|
|
|
|
const _holdTarget = new THREE.Vector3();
|
|
const _currentPos = new THREE.Vector3();
|
|
const _velocity = new THREE.Vector3();
|
|
const _handNdc = new THREE.Vector3();
|
|
const _handHitNdc = new THREE.Vector3();
|
|
const _handDirection = new THREE.Vector3();
|
|
const _handHitDirection = new THREE.Vector3();
|
|
const _cameraPos = new THREE.Vector3();
|
|
const _objectPos = new THREE.Vector3();
|
|
const _snapPosition = new THREE.Vector3();
|
|
const _snapTargetWorldPosition = new THREE.Vector3();
|
|
const _handRaycaster = new THREE.Raycaster();
|
|
|
|
const HAND_GRAB_SCREEN_RADIUS = 0.04;
|
|
const HAND_HIT_OFFSETS: Array<[number, number]> = [
|
|
[0, 0],
|
|
[HAND_GRAB_SCREEN_RADIUS, 0],
|
|
[-HAND_GRAB_SCREEN_RADIUS, 0],
|
|
[0, HAND_GRAB_SCREEN_RADIUS],
|
|
[0, -HAND_GRAB_SCREEN_RADIUS],
|
|
];
|
|
|
|
function getHandCenterPoint(hand: HandTrackingHand): HandScreenPoint {
|
|
const landmarks = hand.landmarks;
|
|
if (landmarks.length === 0) {
|
|
return { x: hand.x, y: hand.y };
|
|
}
|
|
|
|
let minX = landmarks[0]!.x;
|
|
let maxX = landmarks[0]!.x;
|
|
let minY = landmarks[0]!.y;
|
|
let maxY = landmarks[0]!.y;
|
|
|
|
landmarks.forEach((landmark) => {
|
|
minX = Math.min(minX, landmark.x);
|
|
maxX = Math.max(maxX, landmark.x);
|
|
minY = Math.min(minY, landmark.y);
|
|
maxY = Math.max(maxY, landmark.y);
|
|
});
|
|
|
|
return {
|
|
x: (minX + maxX) / 2,
|
|
y: (minY + maxY) / 2,
|
|
};
|
|
}
|
|
|
|
function getHandHit(
|
|
group: THREE.Group | null,
|
|
camera: THREE.Camera,
|
|
cameraPos: THREE.Vector3,
|
|
handCenter: HandScreenPoint,
|
|
): THREE.Intersection | null {
|
|
if (!group) return null;
|
|
|
|
const baseX = (1 - handCenter.x) * 2 - 1;
|
|
const baseY = -handCenter.y * 2 + 1;
|
|
|
|
for (const [offsetX, offsetY] of HAND_HIT_OFFSETS) {
|
|
_handHitNdc.set(baseX + offsetX, baseY + offsetY, 0.5);
|
|
_handHitNdc.unproject(camera);
|
|
_handHitDirection.subVectors(_handHitNdc, cameraPos).normalize();
|
|
_handRaycaster.set(cameraPos, _handHitDirection);
|
|
_handRaycaster.far = INTERACTION_RADIUS;
|
|
|
|
const hits = _handRaycaster.intersectObject(group, true);
|
|
if (hits?.length > 0) return hits[0] ?? null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function GrabbableObject({
|
|
position,
|
|
children,
|
|
colliders = GRAB_DEFAULT_COLLIDERS,
|
|
label = GRAB_DEFAULT_LABEL,
|
|
handControlled = false,
|
|
disabled = false,
|
|
lockUntilGrab = false,
|
|
onGrabChange,
|
|
onPositionChange,
|
|
onSnap,
|
|
snapDuration = 0.25,
|
|
snapRadius = 0,
|
|
snapTargets = [],
|
|
}: GrabbableObjectProps): React.JSX.Element {
|
|
const camera = useThree((state) => state.camera);
|
|
const { hands } = useHandTrackingSnapshot();
|
|
const spaceRef = useRef<THREE.Group>(null);
|
|
const groupRef = useRef<THREE.Group>(null);
|
|
const rbRef = useRef<RapierRigidBody>(null);
|
|
const isHolding = useRef(false);
|
|
const isHandHolding = useRef(false);
|
|
const [hasBeenGrabbed, setHasBeenGrabbed] = useState(false);
|
|
const snapTween = useRef<gsap.core.Tween | null>(null);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
snapTween.current?.kill();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!disabled) return;
|
|
if (isHolding.current) {
|
|
isHolding.current = false;
|
|
onGrabChange?.(false);
|
|
}
|
|
if (isHandHolding.current) {
|
|
isHandHolding.current = false;
|
|
InteractionManager.getInstance().setHandHolding(false);
|
|
onGrabChange?.(false);
|
|
}
|
|
}, [disabled, onGrabChange]);
|
|
|
|
function snapToNearestTarget(): void {
|
|
const body = rbRef.current;
|
|
if (!body || snapTargets.length === 0 || snapRadius <= 0) return;
|
|
|
|
const translation = body.translation();
|
|
_currentPos.set(translation.x, translation.y, translation.z);
|
|
|
|
let nearestTarget: Vector3Tuple | null = null;
|
|
let nearestTargetWorld: Vector3Tuple | null = null;
|
|
let nearestDistance = snapRadius;
|
|
snapTargets.forEach((target) => {
|
|
_snapTargetWorldPosition.set(target[0], target[1], target[2]);
|
|
spaceRef.current?.localToWorld(_snapTargetWorldPosition);
|
|
const distance = _currentPos.distanceTo(_snapTargetWorldPosition);
|
|
if (distance <= nearestDistance) {
|
|
nearestDistance = distance;
|
|
nearestTarget = target;
|
|
nearestTargetWorld = [
|
|
_snapTargetWorldPosition.x,
|
|
_snapTargetWorldPosition.y,
|
|
_snapTargetWorldPosition.z,
|
|
];
|
|
}
|
|
});
|
|
|
|
if (!nearestTarget || !nearestTargetWorld) return;
|
|
|
|
snapTween.current?.kill();
|
|
const animatedPosition = {
|
|
x: _currentPos.x,
|
|
y: _currentPos.y,
|
|
z: _currentPos.z,
|
|
};
|
|
|
|
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
|
body.setAngvel(ZERO_ANGULAR_VELOCITY, true);
|
|
snapTween.current = gsap.to(animatedPosition, {
|
|
x: nearestTargetWorld[0],
|
|
y: nearestTargetWorld[1],
|
|
z: nearestTargetWorld[2],
|
|
duration: snapDuration,
|
|
ease: "power2.out",
|
|
onUpdate: () => {
|
|
body.setTranslation(animatedPosition, true);
|
|
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
|
},
|
|
onComplete: () => {
|
|
_snapPosition.set(
|
|
animatedPosition.x,
|
|
animatedPosition.y,
|
|
animatedPosition.z,
|
|
);
|
|
onSnap?.(_snapPosition);
|
|
},
|
|
});
|
|
}
|
|
|
|
useDebugFolder("GrabbableObject", (folder) => {
|
|
folder
|
|
.add(
|
|
grabDebugParams,
|
|
"stiffness",
|
|
GRAB_STIFFNESS_MIN,
|
|
GRAB_STIFFNESS_MAX,
|
|
GRAB_STIFFNESS_STEP,
|
|
)
|
|
.name("Hold stiffness");
|
|
folder
|
|
.add(
|
|
grabDebugParams,
|
|
"throwBoost",
|
|
GRAB_THROW_BOOST_MIN,
|
|
GRAB_THROW_BOOST_MAX,
|
|
GRAB_THROW_BOOST_STEP,
|
|
)
|
|
.name("Throw boost");
|
|
folder
|
|
.add(
|
|
grabDebugParams,
|
|
"holdDistance",
|
|
GRAB_HOLD_DISTANCE_MIN,
|
|
GRAB_HOLD_DISTANCE_MAX,
|
|
GRAB_HOLD_DISTANCE_STEP,
|
|
)
|
|
.name("Hold distance");
|
|
});
|
|
|
|
useFrame(() => {
|
|
if (!rbRef.current) return;
|
|
|
|
const t = rbRef.current.translation();
|
|
_currentPos.set(t.x, t.y, t.z);
|
|
onPositionChange?.(_currentPos);
|
|
|
|
if (disabled) return;
|
|
|
|
const fistHand = handControlled
|
|
? hands.find((hand) => hand.isFist)
|
|
: undefined;
|
|
|
|
if (fistHand) {
|
|
const handCenter = getHandCenterPoint(fistHand);
|
|
|
|
_handNdc.set((1 - handCenter.x) * 2 - 1, -handCenter.y * 2 + 1, 0.5);
|
|
_handNdc.unproject(camera);
|
|
camera.getWorldPosition(_cameraPos);
|
|
_handDirection.subVectors(_handNdc, _cameraPos).normalize();
|
|
|
|
if (!isHandHolding.current) {
|
|
_objectPos.copy(_currentPos);
|
|
|
|
const isObjectInRange =
|
|
_cameraPos.distanceTo(_objectPos) <= INTERACTION_RADIUS;
|
|
const hit = isObjectInRange
|
|
? getHandHit(groupRef.current, camera, _cameraPos, handCenter)
|
|
: null;
|
|
|
|
const hadHit = Boolean(hit);
|
|
if (hadHit) {
|
|
setHasBeenGrabbed(true);
|
|
isHandHolding.current = true;
|
|
InteractionManager.getInstance().setHandHolding(true);
|
|
onGrabChange?.(true);
|
|
}
|
|
}
|
|
} else {
|
|
if (isHandHolding.current) {
|
|
snapToNearestTarget();
|
|
isHandHolding.current = false;
|
|
InteractionManager.getInstance().setHandHolding(false);
|
|
onGrabChange?.(false);
|
|
}
|
|
}
|
|
|
|
if (!isHolding.current && !isHandHolding.current) return;
|
|
|
|
if (fistHand && isHandHolding.current) {
|
|
_holdTarget
|
|
.copy(_cameraPos)
|
|
.addScaledVector(_handDirection, grabDebugParams.holdDistance);
|
|
} else {
|
|
camera.getWorldDirection(_holdTarget);
|
|
_holdTarget
|
|
.multiplyScalar(grabDebugParams.holdDistance)
|
|
.add(camera.position);
|
|
}
|
|
|
|
_velocity
|
|
.subVectors(_holdTarget, _currentPos)
|
|
.multiplyScalar(grabDebugParams.stiffness);
|
|
|
|
rbRef.current.setLinvel(
|
|
{ x: _velocity.x, y: _velocity.y, z: _velocity.z },
|
|
true,
|
|
);
|
|
rbRef.current.setAngvel(ZERO_ANGULAR_VELOCITY, true);
|
|
});
|
|
|
|
return (
|
|
<group ref={spaceRef}>
|
|
<RigidBody
|
|
ref={rbRef}
|
|
type={lockUntilGrab && !hasBeenGrabbed ? "fixed" : "dynamic"}
|
|
colliders={colliders}
|
|
position={position}
|
|
>
|
|
<group ref={groupRef}>
|
|
{disabled ? (
|
|
children
|
|
) : (
|
|
<InteractableObject
|
|
kind="grab"
|
|
label={label}
|
|
position={position}
|
|
bodyRef={rbRef}
|
|
onPress={() => {
|
|
setHasBeenGrabbed(true);
|
|
isHolding.current = true;
|
|
onGrabChange?.(true);
|
|
}}
|
|
onRelease={() => {
|
|
isHolding.current = false;
|
|
onGrabChange?.(false);
|
|
snapToNearestTarget();
|
|
if (
|
|
!rbRef.current ||
|
|
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
|
|
)
|
|
return;
|
|
const v = rbRef.current.linvel();
|
|
rbRef.current.setLinvel(
|
|
{
|
|
x: v.x * grabDebugParams.throwBoost,
|
|
y: v.y * grabDebugParams.throwBoost,
|
|
z: v.z * grabDebugParams.throwBoost,
|
|
},
|
|
true,
|
|
);
|
|
}}
|
|
>
|
|
{children}
|
|
</InteractableObject>
|
|
)}
|
|
</group>
|
|
</RigidBody>
|
|
</group>
|
|
);
|
|
}
|