diff --git a/docs/technical/hand-tracking.md b/docs/technical/hand-tracking.md
index a844a81..ca2159d 100644
--- a/docs/technical/hand-tracking.md
+++ b/docs/technical/hand-tracking.md
@@ -37,6 +37,8 @@ The production repair activation conditions are:
This keeps the webcam off during `waiting`, `fragmented`, and `scanning`, then enables hand input only when the repair flow is expected to use hands.
+In the current production repair flow, `inspected` uses a two-fists hold gesture to advance to `fragmented`. The hold must last one second and is independent from local object interaction distance once the mission is in the correct state.
+
## Backend
The backend lives in `backend/` and exposes:
diff --git a/docs/technical/zustand.md b/docs/technical/zustand.md
index 1e69db0..2ed658d 100644
--- a/docs/technical/zustand.md
+++ b/docs/technical/zustand.md
@@ -136,7 +136,7 @@ For repair missions, it mounts the reusable `RepairGame` component with a missio
```
-`RepairGame` reads the active mission step from the store and writes transitions through generic actions such as `setMissionStep`. This keeps the scene component small and avoids mission-specific branching inside the repair flow.
+`RepairGame` reads the active mission step from the store and writes transitions through generic actions such as `setMissionStep`. This keeps the scene component small and avoids mission-specific branching inside the repair flow. The production repair flow currently supports `waiting -> inspected -> fragmented -> scanning -> repairing` state transitions.
That means the scene can progressively move toward this pattern:
@@ -181,4 +181,4 @@ Current overlays:
## Next Steps
-The next natural step is to extend `RepairGame` beyond `waiting -> inspected` with fragmentation, scanning, repairing, and completion behavior.
+The next natural step is to add the visual fragmentation sequence after the `fragmented` state, then continue with scanning, repairing, and completion behavior.
diff --git a/docs/user/features.md b/docs/user/features.md
index 03659cd..44bf341 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`
-- First repair-game slice supports `waiting -> inspected` with `.webm` interaction prompts and repair case spawn
+- Repair-game flow supports `waiting -> inspected -> fragmented -> scanning -> repairing` with `.webm` prompts, repair case spawn, `E`, two-fists hold input, exploded model transition, and scan visuals
## Audio
diff --git a/docs/user/main-feature.md b/docs/user/main-feature.md
index 37cbf98..378d07b 100644
--- a/docs/user/main-feature.md
+++ b/docs/user/main-feature.md
@@ -13,6 +13,7 @@ The current user flow is:
3. Aim at the object and press the interaction key when prompted.
4. The mission step moves from `waiting` to `inspected`.
5. The repair case appears near the mission object and can float when the player approaches it.
+6. Press `E` or hold both fists closed for one second to move from `inspected` to `fragmented`.
The older debug repair sandbox still exists in the physics test scene, but the production path now starts from the reusable `RepairGame` component.
@@ -26,6 +27,8 @@ In `waiting`, the active mission renders its repair object and the `interagir.we
When the player inspects the object, `RepairGame` writes `inspected` through the generic mission store action. The repair case then appears from the mission config. When the player is close enough, the existing case model floats upward and rotates gently to signal interactivity.
+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.
+
Repair module slots and exploded-model behavior still exist in the debug prototype. They will be migrated into the reusable repair flow in later steps.
## Key Files
@@ -36,7 +39,9 @@ Repair module slots and exploded-model behavior still exist in the debug prototy
- `src/components/three/gameplay/RepairInspectionObject.tsx` handles the `waiting` inspection interaction.
- `src/components/three/gameplay/RepairMissionCase.tsx` renders the mission repair case after inspection.
- `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene.
+- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` keyboard and hand-tracking input.
- `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store.
+- `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
- `src/components/three/gameplay/RepairGameZone.tsx` composes the repair-game zone.
- `src/components/three/gameplay/RepairCaseObject.tsx` connects the repair case to trigger interaction and audio.
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model.
@@ -71,7 +76,7 @@ http://localhost:5173/?debug
## Related Hand Tracking
-Hand tracking is a separate debug interaction layer. It can move grabbable physics objects with webcam input, but it is not yet integrated into the repair-game mission flow.
+Hand tracking can move grabbable physics objects with webcam input in debug scenes. In the production repair flow, it is also used for the `inspected -> fragmented` transition through the two-fists hold gesture.
For hand tracking, run the Python backend separately:
@@ -82,8 +87,8 @@ python -m backend.main
## Current Limitations
-- The reusable production `RepairGame` currently covers only `waiting -> inspected`.
+- The reusable production `RepairGame` currently covers `waiting -> inspected -> fragmented -> scanning -> repairing`; repair interactions and completion still need to be implemented.
- Mission progression exists in Zustand, but the full repair mission flow is still being integrated.
- There is no central `GameManager` in this branch.
-- Hand tracking is available as debug interaction input, not as final repair gameplay.
+- Hand tracking is available for repair-step input, but the later repair interactions are still being integrated.
- The repair-game content is configured statically in `src/data/gameplay/`.
diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx
index ae10392..320d53e 100644
--- a/src/components/three/gameplay/RepairGame.tsx
+++ b/src/components/three/gameplay/RepairGame.tsx
@@ -1,6 +1,14 @@
+import { useEffect } from "react";
+import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
+import { RepairScanVisual } from "@/components/three/gameplay/RepairScanVisual";
+import {
+ REPAIR_FRAGMENTATION_SEQUENCE_SECONDS,
+ REPAIR_SCAN_SEQUENCE_SECONDS,
+} from "@/data/gameplay/repairGameConfig";
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
+import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import type { RepairMissionId } from "@/managers/stores/useGameStore";
import { useGameStore } from "@/managers/stores/useGameStore";
@@ -26,6 +34,32 @@ export function RepairGame({
const setMissionStep = useGameStore((state) => state.setMissionStep);
const step = useRepairMissionStep(mission);
const parsedScale = toVector3Scale(scale);
+ const readyForFragmentation = step === "inspected";
+
+ useRepairFragmentationInput({
+ enabled: mainState === mission && readyForFragmentation,
+ onFragment: () => setMissionStep(mission, "fragmented"),
+ });
+
+ useEffect(() => {
+ if (mainState !== mission) return undefined;
+
+ if (step !== "fragmented" && step !== "scanning") return undefined;
+
+ const nextStep = step === "fragmented" ? "scanning" : "repairing";
+ const sequenceSeconds =
+ step === "fragmented"
+ ? REPAIR_FRAGMENTATION_SEQUENCE_SECONDS
+ : REPAIR_SCAN_SEQUENCE_SECONDS;
+
+ const timeoutId = window.setTimeout(() => {
+ setMissionStep(mission, nextStep);
+ }, sequenceSeconds * 1000);
+
+ return () => {
+ window.clearTimeout(timeoutId);
+ };
+ }, [mainState, mission, setMissionStep, step]);
if (mainState !== mission) return null;
if (step === "locked") return null;
@@ -39,7 +73,16 @@ export function RepairGame({
onInspect={() => setMissionStep(mission, "inspected")}
/>
) : null}
- {step !== "waiting" ? : null}
+ {step === "fragmented" ? (
+
+ ) : null}
+ {step === "scanning" ? : null}
+ {step !== "waiting" ? (
+
+ ) : null}
);
}
diff --git a/src/components/three/gameplay/RepairMissionCase.tsx b/src/components/three/gameplay/RepairMissionCase.tsx
index f484f87..916d9e2 100644
--- a/src/components/three/gameplay/RepairMissionCase.tsx
+++ b/src/components/three/gameplay/RepairMissionCase.tsx
@@ -1,21 +1,33 @@
import { RepairCaseModel } from "@/components/three/gameplay/RepairCaseModel";
+import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
interface RepairMissionCaseProps {
config: RepairMissionConfig;
+ showFragmentationPrompt?: boolean;
}
export function RepairMissionCase({
config,
+ showFragmentationPrompt = false,
}: RepairMissionCaseProps): React.JSX.Element {
return (
-
+
+
+ {showFragmentationPrompt ? (
+
+ ) : null}
+
);
}
diff --git a/src/components/three/gameplay/RepairScanVisual.tsx b/src/components/three/gameplay/RepairScanVisual.tsx
new file mode 100644
index 0000000..6da2f13
--- /dev/null
+++ b/src/components/three/gameplay/RepairScanVisual.tsx
@@ -0,0 +1,45 @@
+import { useRef } from "react";
+import { useFrame } from "@react-three/fiber";
+import * as THREE from "three";
+import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
+import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
+
+interface RepairScanVisualProps {
+ config: RepairMissionConfig;
+}
+
+export function RepairScanVisual({
+ config,
+}: RepairScanVisualProps): React.JSX.Element {
+ const scanLineRef = useRef(null);
+
+ useFrame(({ clock }) => {
+ const scanLine = scanLineRef.current;
+ if (!scanLine) return;
+
+ scanLine.position.y = 0.35 + Math.sin(clock.elapsedTime * 4) * 0.7;
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/data/docs/docsTranslations.ts b/src/data/docs/docsTranslations.ts
index 56ea5bc..ff97479 100644
--- a/src/data/docs/docsTranslations.ts
+++ b/src/data/docs/docsTranslations.ts
@@ -361,7 +361,7 @@ Pour les missions de réparation, il monte le composant réutilisable \`RepairGa
\`\`\`
-\`RepairGame\` lit l'étape de mission active depuis le store et écrit les transitions via des actions génériques comme \`setMissionStep\`. Cela garde le composant de scène petit et évite les branches spécifiques à chaque mission dans le flow de réparation.
+\`RepairGame\` lit l'étape de mission active depuis le store et écrit les transitions via des actions génériques comme \`setMissionStep\`. Cela garde le composant de scène petit et évite les branches spécifiques à chaque mission dans le flow de réparation. Le flow de réparation de production supporte actuellement les transitions \`waiting -> inspected -> fragmented -> scanning -> repairing\`.
La scène peut donc évoluer progressivement vers ce pattern :
@@ -406,7 +406,7 @@ Overlays actuels :
## Prochaines étapes
-La prochaine étape naturelle est d'étendre \`RepairGame\` au-delà de \`waiting -> inspected\` avec la fragmentation, le scan, la réparation et la complétion.
+La prochaine étape naturelle est d'ajouter les interactions de réparation après l'état \`repairing\`, puis de continuer avec la complétion.
`;
export const featuresFr = `# Fonctionnalités implémentées
@@ -442,7 +442,7 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
- \`RepairGame\` de production réutilisable monté pour les états de mission \`bike\`, \`pylone\` et \`ferme\`
- Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`
-- Première slice repair-game avec \`waiting -> inspected\`, prompts d'interaction \`.webm\` et apparition de la mallette
+- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing\`, prompts \`.webm\`, apparition de la mallette, touche \`E\`, hold deux poings, transition de modèle explosé et visuels de scan
## Audio
diff --git a/src/data/gameplay/repairGameConfig.ts b/src/data/gameplay/repairGameConfig.ts
index 6fd67af..9cf151e 100644
--- a/src/data/gameplay/repairGameConfig.ts
+++ b/src/data/gameplay/repairGameConfig.ts
@@ -3,6 +3,9 @@ import type { Vector3Tuple } from "@/types/three/three";
export const REPAIR_GAME_ZONE_ORIGIN: Vector3Tuple = [10, 0.4, -8];
export const REPAIR_GAME_ZONE_RADIUS = 4.2;
export const REPAIR_GAME_ZONE_LABEL = "Pack de Relance Feature";
+export const REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS = 1;
+export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4;
+export const REPAIR_SCAN_SEQUENCE_SECONDS = 4;
export const REPAIR_GAME_MODULE_SLOTS = [
{ label: "Module A", offset: [-2.2, 0, 2.2] },
diff --git a/src/hooks/gameplay/useRepairFragmentationInput.ts b/src/hooks/gameplay/useRepairFragmentationInput.ts
new file mode 100644
index 0000000..b97773d
--- /dev/null
+++ b/src/hooks/gameplay/useRepairFragmentationInput.ts
@@ -0,0 +1,53 @@
+import { useCallback, useEffect, useRef } from "react";
+import { REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS } from "@/data/gameplay/repairGameConfig";
+import { INTERACT_KEY } from "@/data/input/keybindings";
+import { useBothFistsHold } from "@/hooks/handTracking/useBothFistsHold";
+
+interface UseRepairFragmentationInputOptions {
+ enabled: boolean;
+ onFragment: () => void;
+}
+
+export function useRepairFragmentationInput({
+ enabled,
+ onFragment,
+}: UseRepairFragmentationInputOptions): void {
+ const completedRef = useRef(false);
+
+ useEffect(() => {
+ if (enabled) return;
+
+ completedRef.current = false;
+ }, [enabled]);
+
+ const fragment = useCallback(() => {
+ if (!enabled) return;
+ if (completedRef.current) return;
+
+ completedRef.current = true;
+ onFragment();
+ }, [enabled, onFragment]);
+
+ useEffect(() => {
+ if (!enabled) return undefined;
+
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ if (event.key.toLowerCase() !== INTERACT_KEY) return;
+
+ event.preventDefault();
+ fragment();
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [enabled, fragment]);
+
+ useBothFistsHold({
+ enabled,
+ holdSeconds: REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS,
+ onComplete: fragment,
+ });
+}
diff --git a/src/hooks/handTracking/useBothFistsHold.ts b/src/hooks/handTracking/useBothFistsHold.ts
new file mode 100644
index 0000000..3347031
--- /dev/null
+++ b/src/hooks/handTracking/useBothFistsHold.ts
@@ -0,0 +1,48 @@
+import { useEffect, useRef } from "react";
+import { useFrame } from "@react-three/fiber";
+import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
+
+interface UseBothFistsHoldOptions {
+ enabled: boolean;
+ holdSeconds: number;
+ onComplete: () => void;
+}
+
+export function useBothFistsHold({
+ enabled,
+ holdSeconds,
+ onComplete,
+}: UseBothFistsHoldOptions): void {
+ const { hands } = useHandTrackingSnapshot();
+ const elapsedRef = useRef(0);
+ const completedRef = useRef(false);
+ const onCompleteRef = useRef(onComplete);
+
+ useEffect(() => {
+ onCompleteRef.current = onComplete;
+ }, [onComplete]);
+
+ useEffect(() => {
+ if (enabled) return;
+
+ elapsedRef.current = 0;
+ completedRef.current = false;
+ }, [enabled]);
+
+ useFrame((_, delta) => {
+ if (!enabled) return;
+ if (completedRef.current) return;
+
+ const fistCount = hands.filter((hand) => hand.isFist).length;
+ if (fistCount < 2) {
+ elapsedRef.current = 0;
+ return;
+ }
+
+ elapsedRef.current += delta;
+ if (elapsedRef.current < holdSeconds) return;
+
+ completedRef.current = true;
+ onCompleteRef.current();
+ });
+}