add: animation on repair case

This commit is contained in:
Tom Boullay
2026-05-08 02:10:19 +01:00
parent eee69825c6
commit 95d9bd4f3e
7 changed files with 77 additions and 23 deletions
@@ -8,6 +8,8 @@ import {
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,
@@ -24,6 +26,8 @@ import { toVector3Scale } from "@/utils/three/scale";
interface RepairCaseModelProps extends ModelTransformProps {
modelPath: string;
open: boolean;
exiting?: boolean;
onExitComplete?: (() => void) | undefined;
}
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
@@ -39,6 +43,8 @@ const ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(
export function RepairCaseModel({
modelPath,
open,
exiting = false,
onExitComplete,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
@@ -58,10 +64,15 @@ export function RepairCaseModel({
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 initialOpen = useRef(open);
const openedRotationZ = useRef(0);
const parsedScale = toVector3Scale(scale);
useEffect(() => {
onExitCompleteRef.current = onExitComplete;
}, [onExitComplete]);
useEffect(() => {
const popAnimation = pop.current;
@@ -83,6 +94,26 @@ export function RepairCaseModel({
};
}, []);
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;
@@ -122,8 +153,9 @@ export function RepairCaseModel({
group.getWorldPosition(worldPosition.current);
const isNear =
!exiting &&
worldPosition.current.distanceTo(camera.position) <=
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
const targetHeight = isNear ? REPAIR_CASE_FLOAT_HEIGHT : 0;
const floatSpeed = isNear
? REPAIR_CASE_FLOAT_UP_SPEED
@@ -1,5 +1,7 @@
import { useState } from "react";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
@@ -12,31 +14,43 @@ export function RepairCompletionStep({
config,
onComplete,
}: RepairCompletionStepProps): React.JSX.Element {
const [isCompleting, setIsCompleting] = useState(false);
return (
<group>
<RepairMissionCase
config={config}
exiting={isCompleting}
onExitComplete={onComplete}
/>
<RepairObjectModel
label={config.label}
modelPath={config.modelPath}
scale={1}
/>
<TriggerObject
position={[0, 1.1, 0]}
colliders="ball"
label={`Valider ${config.label}`}
onTrigger={onComplete}
>
<mesh>
<torusGeometry args={[1.35, 0.045, 12, 96]} />
<meshBasicMaterial color="#22c55e" transparent opacity={0.85} />
</mesh>
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.2, 1.25, 96]} />
<meshBasicMaterial color="#bbf7d0" transparent opacity={0.3} />
</mesh>
</TriggerObject>
{!isCompleting ? (
<TriggerObject
position={[0, 1.1, 0]}
colliders="ball"
label={`Valider ${config.label}`}
onTrigger={() => setIsCompleting(true)}
>
<mesh>
<torusGeometry args={[1.35, 0.045, 12, 96]} />
<meshBasicMaterial color="#22c55e" transparent opacity={0.85} />
</mesh>
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.2, 1.25, 96]} />
<meshBasicMaterial color="#bbf7d0" transparent opacity={0.3} />
</mesh>
</TriggerObject>
) : null}
<RepairPromptVideo src={config.stageUiPath} position={[0, 2.55, 0]} />
{!isCompleting ? (
<RepairPromptVideo src={config.stageUiPath} position={[0, 2.55, 0]} />
) : null}
</group>
);
}
@@ -5,12 +5,16 @@ import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
interface RepairMissionCaseProps {
config: RepairMissionConfig;
exiting?: boolean;
onExitComplete?: (() => void) | undefined;
open?: boolean;
showFragmentationPrompt?: boolean;
}
export function RepairMissionCase({
config,
exiting = false,
onExitComplete,
open = false,
showFragmentationPrompt = false,
}: RepairMissionCaseProps): React.JSX.Element {
@@ -18,12 +22,14 @@ export function RepairMissionCase({
<group>
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
exiting={exiting}
onExitComplete={onExitComplete}
open={open}
position={config.case.position}
rotation={config.case.rotation}
scale={config.case.scale}
/>
{showFragmentationPrompt ? (
{showFragmentationPrompt && !exiting ? (
<RepairPromptVideo
src={config.interactUiPath}
position={[config.case.position[0], 2.4, config.case.position[2]]}