diff --git a/docs/user/features.md b/docs/user/features.md
index 1df61ee..4cb4329 100644
--- a/docs/user/features.md
+++ b/docs/user/features.md
@@ -31,7 +31,7 @@ This document lists features that are implemented in the current codebase.
- Reusable production `RepairGame` mounted for `bike`, `pylone`, and `ferme` mission states
- Repair mission config shared through `src/data/gameplay/repairMissions.ts`
-- Repair-game flow supports `waiting -> inspected -> fragmented -> scanning -> repairing -> done -> next mission` with `.webm` prompts, repair case spawn/opening, `E`, two-fists hold input, exploded model transition, scan visuals, multiple grabbable replacement choices, correct-part install validation, and mission completion
+- Repair-game flow supports `waiting -> inspected -> fragmented -> scanning -> repairing -> done -> next mission` with `.webm` prompts, repair case spawn/opening/exit, `E`, two-fists hold input, exploded model transition, scan visuals, multiple grabbable replacement choices, correct-part install validation, and mission completion
## Audio
diff --git a/docs/user/main-feature.md b/docs/user/main-feature.md
index 53b7c29..7ca2ea5 100644
--- a/docs/user/main-feature.md
+++ b/docs/user/main-feature.md
@@ -19,7 +19,7 @@ The current user flow is:
9. In `repairing`, the case opens and several grabbable replacement parts appear near the case.
10. Move the correct replacement part close to the install target.
11. Press `E` on the green install target to move to `done` and show the reassembled object. Wrong parts turn the target red and cannot finish the repair.
-12. Press `E` on the completion target to call `completeMission` and move to the next mission, or to `outro` after `ferme`.
+12. Press `E` on the completion target. The repair case closes, returns to the ground, disappears, then `completeMission` moves to the next mission or to `outro` after `ferme`.
## Why It Matters
@@ -33,12 +33,12 @@ When the player inspects the object, `RepairGame` writes `inspected` through the
In `inspected`, `RepairGame` can also move to `fragmented`. The player can use the interaction key or hold both fists closed for one second. The hand-tracking path is state-based, so it does not depend on being inside a local object interaction radius.
-In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, a blue scan visual and the `cassé.webm` prompt are shown before the flow advances to `repairing`. In `repairing`, the case opens, several grabbable replacement parts appear, and the install target only validates the configured correct part for the active mission. In `done`, the repaired object remains visible with a completion target that advances the global mission progression.
+In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, a blue scan visual and the `cassé.webm` prompt are shown before the flow advances to `repairing`. In `repairing`, the case opens, several grabbable replacement parts appear, and the install target only validates the configured correct part for the active mission. In `done`, the repaired object remains visible with a completion target that plays the case exit animation before advancing the global mission progression.
## Key Files
- `src/world/GameStageContent.tsx` mounts production `RepairGame` instances for `bike`, `pylone`, and `ferme`.
-- `src/components/three/gameplay/RepairCompletionStep.tsx` renders the final repaired object, completion target, and mission UI prompt.
+- `src/components/three/gameplay/RepairCompletionStep.tsx` renders the final repaired object, completion target, case exit animation, and mission UI prompt.
- `src/components/three/gameplay/RepairGame.tsx` composes the reusable production repair flow.
- `src/components/three/gameplay/RepairInspectionObject.tsx` handles the `waiting` inspection interaction.
- `src/components/three/gameplay/RepairMissionCase.tsx` renders the mission repair case after inspection.
diff --git a/src/components/three/gameplay/RepairCaseModel.tsx b/src/components/three/gameplay/RepairCaseModel.tsx
index 49cd9f1..ab5085f 100644
--- a/src/components/three/gameplay/RepairCaseModel.tsx
+++ b/src/components/three/gameplay/RepairCaseModel.tsx
@@ -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
diff --git a/src/components/three/gameplay/RepairCompletionStep.tsx b/src/components/three/gameplay/RepairCompletionStep.tsx
index 621fd03..21ba652 100644
--- a/src/components/three/gameplay/RepairCompletionStep.tsx
+++ b/src/components/three/gameplay/RepairCompletionStep.tsx
@@ -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 (
+
+
-
-
-
-
-
-
-
-
-
-
+ {!isCompleting ? (
+ setIsCompleting(true)}
+ >
+
+
+
+
+
+
+
+
+
+ ) : null}
-
+ {!isCompleting ? (
+
+ ) : null}
);
}
diff --git a/src/components/three/gameplay/RepairMissionCase.tsx b/src/components/three/gameplay/RepairMissionCase.tsx
index 46bb6a4..7fd6234 100644
--- a/src/components/three/gameplay/RepairMissionCase.tsx
+++ b/src/components/three/gameplay/RepairMissionCase.tsx
@@ -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({
- {showFragmentationPrompt ? (
+ {showFragmentationPrompt && !exiting ? (
inspected -> fragmented -> scanning -> repairing -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture de la mallette, touche \`E\`, hold deux poings, transition de modèle explosé, visuels de scan, plusieurs choix de pièces grabbables, validation de la bonne pièce et complétion de mission
+- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, touche \`E\`, hold deux poings, transition de modèle explosé, visuels de scan, plusieurs choix de pièces grabbables, validation de la bonne pièce et complétion de mission
## Audio
diff --git a/src/data/gameplay/repairCaseConfig.ts b/src/data/gameplay/repairCaseConfig.ts
index 1dc48d1..df05773 100644
--- a/src/data/gameplay/repairCaseConfig.ts
+++ b/src/data/gameplay/repairCaseConfig.ts
@@ -8,6 +8,8 @@ export const REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES = 115;
export const REPAIR_CASE_ANIMATION_DURATION = 0.8;
export const REPAIR_CASE_POP_DURATION = 0.45;
export const REPAIR_CASE_POP_Y_OFFSET = -0.25;
+export const REPAIR_CASE_EXIT_DURATION = 0.5;
+export const REPAIR_CASE_EXIT_Y_OFFSET = -0.35;
export const REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE = 5;
export const REPAIR_CASE_FLOAT_HEIGHT = 1;