Files
La-Fabrik/src/components/three/gameplay/RepairCaseModel.tsx
T
2026-05-08 02:33:06 +01:00

277 lines
7.7 KiB
TypeScript

import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import gsap from "gsap";
import * as THREE from "three";
import {
REPAIR_CASE_ANIMATION_DURATION,
REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES,
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE,
REPAIR_CASE_FLOAT_DOWN_SPEED,
REPAIR_CASE_FLOAT_HEIGHT,
REPAIR_CASE_EXIT_DURATION,
REPAIR_CASE_EXIT_Y_OFFSET,
REPAIR_CASE_FLOAT_UP_SPEED,
REPAIR_CASE_LID_NODE_NAME,
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
REPAIR_CASE_PLACEHOLDER_NAME_PREFIX,
REPAIR_CASE_POP_DURATION,
REPAIR_CASE_POP_Y_OFFSET,
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
REPAIR_CASE_ROTATION_RESET_SPEED,
} from "@/data/gameplay/repairCaseConfig";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
export interface RepairCasePlaceholder {
name: string;
position: Vector3Tuple;
}
interface RepairCaseModelProps extends ModelTransformProps {
modelPath: string;
open: boolean;
exiting?: boolean;
floating?: boolean;
onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined;
onExitComplete?: (() => void) | undefined;
}
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES,
);
const CASE_OPEN_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
);
const ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
);
export function RepairCaseModel({
modelPath,
open,
exiting = false,
floating = true,
onPlaceholdersChange,
onExitComplete,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
}: RepairCaseModelProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const { scene } = useLoggedGLTF(modelPath, {
scope: "RepairCaseModel",
position,
rotation,
scale,
});
const model = useClonedObject(scene);
const groupRef = useRef<THREE.Group>(null);
const lidRef = useRef<THREE.Object3D | null>(null);
const worldPosition = useRef(new THREE.Vector3());
const floatHeight = useRef(0);
const animationActiveRef = useRef(false);
const phase = useRef({ x: 0, y: 0, z: 0 });
const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
const onExitCompleteRef = useRef(onExitComplete);
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
const initialOpen = useRef(open);
const openedRotationZ = useRef(0);
const parsedScale = toVector3Scale(scale);
const placeholderSignature = useRef("__initial__");
const placeholderPosition = useRef(new THREE.Vector3());
const placeholderLocalPosition = useRef(new THREE.Vector3());
useEffect(() => {
onExitCompleteRef.current = onExitComplete;
}, [onExitComplete]);
useEffect(() => {
onPlaceholdersChangeRef.current = onPlaceholdersChange;
}, [onPlaceholdersChange]);
useEffect(() => {
const popAnimation = pop.current;
phase.current = {
x: Math.random() * Math.PI * 2,
y: Math.random() * Math.PI * 2,
z: Math.random() * Math.PI * 2,
};
gsap.to(popAnimation, {
scale: 1,
yOffset: 0,
duration: REPAIR_CASE_POP_DURATION,
ease: "back.out(1.7)",
});
return () => {
gsap.killTweensOf(popAnimation);
};
}, []);
useEffect(() => {
if (!exiting) return undefined;
const popAnimation = pop.current;
gsap.to(popAnimation, {
scale: 0.001,
yOffset: REPAIR_CASE_EXIT_Y_OFFSET,
duration: REPAIR_CASE_EXIT_DURATION,
ease: "back.in(1.4)",
overwrite: true,
onComplete: () => {
onExitCompleteRef.current?.();
},
});
return () => {
gsap.killTweensOf(popAnimation);
};
}, [exiting]);
useEffect(() => {
const lid = model.getObjectByName(REPAIR_CASE_LID_NODE_NAME);
lidRef.current = lid ?? null;
openedRotationZ.current = lid?.rotation.z ?? 0;
if (lid) {
lid.rotation.z =
openedRotationZ.current +
(initialOpen.current
? CASE_OPEN_ROTATION_OFFSET_Z
: CASE_CLOSED_ROTATION_OFFSET_Z);
}
}, [model]);
useEffect(() => {
const lid = lidRef.current;
if (!lid) return;
const targetRotation =
openedRotationZ.current +
(open ? CASE_OPEN_ROTATION_OFFSET_Z : CASE_CLOSED_ROTATION_OFFSET_Z);
gsap.to(lid.rotation, {
z: targetRotation,
duration: REPAIR_CASE_ANIMATION_DURATION,
ease: "power2.inOut",
overwrite: true,
});
return () => {
gsap.killTweensOf(lid.rotation);
};
}, [open]);
useFrame(({ clock }, delta) => {
const group = groupRef.current;
if (!group) return;
group.getWorldPosition(worldPosition.current);
const isNear =
floating &&
!exiting &&
worldPosition.current.distanceTo(camera.position) <=
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
const targetHeight = isNear ? REPAIR_CASE_FLOAT_HEIGHT : 0;
const floatSpeed = isNear
? REPAIR_CASE_FLOAT_UP_SPEED
: REPAIR_CASE_FLOAT_DOWN_SPEED;
floatHeight.current = THREE.MathUtils.damp(
floatHeight.current,
targetHeight,
floatSpeed,
delta,
);
group.position.y = position[1] + floatHeight.current + pop.current.yOffset;
group.scale.set(
parsedScale[0] * pop.current.scale,
parsedScale[1] * pop.current.scale,
parsedScale[2] * pop.current.scale,
);
const placeholders: RepairCasePlaceholder[] = [];
model.traverse((child) => {
if (
!child.name
.toLowerCase()
.startsWith(REPAIR_CASE_PLACEHOLDER_NAME_PREFIX)
) {
return;
}
child.getWorldPosition(placeholderPosition.current);
placeholderLocalPosition.current.copy(placeholderPosition.current);
group.parent?.worldToLocal(placeholderLocalPosition.current);
placeholders.push({
name: child.name,
position: [
placeholderLocalPosition.current.x,
placeholderLocalPosition.current.y,
placeholderLocalPosition.current.z,
],
});
});
placeholders.sort((a, b) => a.name.localeCompare(b.name));
const nextSignature = placeholders
.map(
(placeholder) =>
`${placeholder.name}:${placeholder.position
.map((value) => value.toFixed(3))
.join(",")}`,
)
.join("|");
if (nextSignature !== placeholderSignature.current) {
placeholderSignature.current = nextSignature;
onPlaceholdersChangeRef.current?.(placeholders);
}
animationActiveRef.current = isNear;
if (animationActiveRef.current) {
const time = clock.elapsedTime;
group.rotation.x =
rotation[0] +
Math.sin(time * 0.7 + phase.current.x) * ROTATION_AMPLITUDE;
group.rotation.y =
rotation[1] +
Math.sin(time * 0.55 + phase.current.y) * ROTATION_AMPLITUDE;
group.rotation.z =
rotation[2] +
Math.sin(time * 0.8 + phase.current.z) * ROTATION_AMPLITUDE;
return;
}
group.rotation.x = THREE.MathUtils.damp(
group.rotation.x,
rotation[0],
REPAIR_CASE_ROTATION_RESET_SPEED,
delta,
);
group.rotation.y = THREE.MathUtils.damp(
group.rotation.y,
rotation[1],
REPAIR_CASE_ROTATION_RESET_SPEED,
delta,
);
group.rotation.z = THREE.MathUtils.damp(
group.rotation.z,
rotation[2],
REPAIR_CASE_ROTATION_RESET_SPEED,
delta,
);
});
return (
<group ref={groupRef} position={position} rotation={rotation} scale={0.001}>
<primitive object={model} />
</group>
);
}