add: scan fragmented repair parts sequentially

This commit is contained in:
Tom Boullay
2026-05-08 02:12:58 +01:00
parent 80bc74c3a8
commit aa1284445c
9 changed files with 96 additions and 29 deletions
+1 -1
View File
@@ -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 - Reusable production `RepairGame` mounted for `bike`, `pylone`, and `ferme` mission states
- Repair mission config shared through `src/data/gameplay/repairMissions.ts` - 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/exit, `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, per-part scan visuals, multiple grabbable replacement choices, correct-part install validation, and mission completion
## Audio ## Audio
+4 -3
View File
@@ -15,7 +15,7 @@ The current user flow is:
5. The repair case appears near the mission object and can float when the player approaches it. 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`. 6. Press `E` or hold both fists closed for one second to move from `inspected` to `fragmented`.
7. The mission object uses an exploded-model transition, then moves to `scanning`. 7. The mission object uses an exploded-model transition, then moves to `scanning`.
8. The scan visual highlights the broken area and shows the `cassé.webm` prompt. 8. The scan visual moves across the fragmented model one part at a time.
9. In `repairing`, the case opens and several grabbable replacement parts appear near the case. 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. 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. 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.
@@ -33,7 +33,7 @@ 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 `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 plays the case exit animation before advancing the global mission progression. In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible and a blue scan visual moves from part to part 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 ## Key Files
@@ -44,7 +44,8 @@ In `fragmented`, the repair object is rendered with `ExplodableModel`, then auto
- `src/components/three/gameplay/RepairMissionCase.tsx` renders the mission repair case after inspection. - `src/components/three/gameplay/RepairMissionCase.tsx` renders the mission repair case after inspection.
- `src/components/three/gameplay/RepairRepairingStep.tsx` renders grabbable replacement choices, correct-part placement validation, and the install trigger in `repairing`. - `src/components/three/gameplay/RepairRepairingStep.tsx` renders grabbable replacement choices, correct-part placement validation, and the install trigger in `repairing`.
- `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene. - `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene.
- `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo, scan line, and broken prompt. - `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part.
- `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part.
- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` keyboard and hand-tracking input. - `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/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/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
+11 -15
View File
@@ -4,11 +4,8 @@ import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompleti
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject"; import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep"; import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
import { RepairScanVisual } from "@/components/three/gameplay/RepairScanVisual"; import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
import { import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
REPAIR_FRAGMENTATION_SEQUENCE_SECONDS,
REPAIR_SCAN_SEQUENCE_SECONDS,
} from "@/data/gameplay/repairGameConfig";
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions"; import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput"; import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep"; import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
@@ -47,17 +44,11 @@ export function RepairGame({
useEffect(() => { useEffect(() => {
if (mainState !== mission) return undefined; if (mainState !== mission) return undefined;
if (step !== "fragmented" && step !== "scanning") return undefined; if (step !== "fragmented") return undefined;
const nextStep = step === "fragmented" ? "scanning" : "repairing";
const sequenceSeconds =
step === "fragmented"
? REPAIR_FRAGMENTATION_SEQUENCE_SECONDS
: REPAIR_SCAN_SEQUENCE_SECONDS;
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
setMissionStep(mission, nextStep); setMissionStep(mission, "scanning");
}, sequenceSeconds * 1000); }, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS * 1000);
return () => { return () => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
@@ -79,7 +70,12 @@ export function RepairGame({
{step === "fragmented" ? ( {step === "fragmented" ? (
<ExplodableModel modelPath={config.modelPath} split /> <ExplodableModel modelPath={config.modelPath} split />
) : null} ) : null}
{step === "scanning" ? <RepairScanVisual config={config} /> : null} {step === "scanning" ? (
<RepairScanSequence
config={config}
onComplete={() => setMissionStep(mission, "repairing")}
/>
) : null}
{step === "repairing" ? ( {step === "repairing" ? (
<RepairRepairingStep <RepairRepairingStep
config={config} config={config}
@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { RepairScanVisual } from "@/components/three/gameplay/RepairScanVisual";
import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
interface RepairScanSequenceProps {
config: RepairMissionConfig;
onComplete: () => void;
}
export function RepairScanSequence({
config,
onComplete,
}: RepairScanSequenceProps): React.JSX.Element {
const [parts, setParts] = useState<readonly ExplodedPart[]>([]);
const [activePartIndex, setActivePartIndex] = useState(0);
const activePart = parts[activePartIndex];
useEffect(() => {
if (parts.length === 0) return undefined;
const timeoutId = window.setTimeout(() => {
setActivePartIndex((currentIndex) => {
const nextIndex = currentIndex + 1;
if (nextIndex >= parts.length) {
onComplete();
return currentIndex;
}
return nextIndex;
});
}, REPAIR_SCAN_PART_SECONDS * 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [activePartIndex, onComplete, parts.length]);
return (
<group>
<ExplodableModel
modelPath={config.modelPath}
split
onPartsReady={setParts}
/>
<RepairScanVisual target={activePart?.object} />
</group>
);
}
@@ -1,27 +1,36 @@
import { useRef } from "react"; import { useRef } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import * as THREE from "three"; import * as THREE from "three";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
interface RepairScanVisualProps { interface RepairScanVisualProps {
config: RepairMissionConfig; target?: THREE.Object3D | null | undefined;
} }
export function RepairScanVisual({ export function RepairScanVisual({
config, target = null,
}: RepairScanVisualProps): React.JSX.Element { }: RepairScanVisualProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const scanLineRef = useRef<THREE.Mesh>(null); const scanLineRef = useRef<THREE.Mesh>(null);
const worldPosition = useRef(new THREE.Vector3());
const localPosition = useRef(new THREE.Vector3());
useFrame(({ clock }) => { useFrame(({ clock }) => {
const group = groupRef.current;
const scanLine = scanLineRef.current; const scanLine = scanLineRef.current;
if (!scanLine) return; if (!group || !scanLine) return;
if (target) {
target.getWorldPosition(worldPosition.current);
localPosition.current.copy(worldPosition.current);
group.parent?.worldToLocal(localPosition.current);
group.position.copy(localPosition.current);
}
scanLine.position.y = 0.35 + Math.sin(clock.elapsedTime * 4) * 0.7; scanLine.position.y = 0.35 + Math.sin(clock.elapsedTime * 4) * 0.7;
}); });
return ( return (
<group> <group ref={groupRef}>
<mesh rotation={[Math.PI / 2, 0, 0]}> <mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[1.35, 0.035, 12, 96]} /> <torusGeometry args={[1.35, 0.035, 12, 96]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.75} /> <meshBasicMaterial color="#38bdf8" transparent opacity={0.75} />
@@ -39,7 +48,6 @@ export function RepairScanVisual({
<sphereGeometry args={[1.25, 32, 16]} /> <sphereGeometry args={[1.25, 32, 16]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} /> <meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
</mesh> </mesh>
<RepairPromptVideo src={config.brokenUiPath} position={[0, 2.3, 0]} />
</group> </group>
); );
} }
@@ -4,6 +4,7 @@ import { useFrame } from "@react-three/fiber";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useClonedObject } from "@/hooks/three/useClonedObject";
import { ExplodedModel } from "@/utils/three/ExplodedModel"; import { ExplodedModel } from "@/utils/three/ExplodedModel";
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import { toVector3Scale } from "@/utils/three/scale"; import { toVector3Scale } from "@/utils/three/scale";
@@ -55,6 +56,7 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
modelPath: string; modelPath: string;
split: boolean; split: boolean;
splitDistance?: number; splitDistance?: number;
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
} }
export function ExplodableModel( export function ExplodableModel(
@@ -78,6 +80,7 @@ function ExplodableModelInner({
rotation = [0, 0, 0], rotation = [0, 0, 0],
scale = 1, scale = 1,
splitDistance = 1.2, splitDistance = 1.2,
onPartsReady,
}: ExplodableModelInnerProps): React.JSX.Element { }: ExplodableModelInnerProps): React.JSX.Element {
const { scene } = useLoggedGLTF(modelPath, { const { scene } = useLoggedGLTF(modelPath, {
scope: "ExplodableModel", scope: "ExplodableModel",
@@ -96,6 +99,10 @@ function ExplodableModelInner({
explodedModel.setSplit(split); explodedModel.setSplit(split);
}, [explodedModel, split]); }, [explodedModel, split]);
useEffect(() => {
onPartsReady?.(explodedModel.getParts());
}, [explodedModel, onPartsReady]);
useFrame((_, delta) => { useFrame((_, delta) => {
explodedModel.update(delta); explodedModel.update(delta);
}); });
+1 -1
View File
@@ -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\` - \`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\` - Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`
- 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 - 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é, scan visuel par pièce, plusieurs choix de pièces grabbables, validation de la bonne pièce et complétion de mission
## Audio ## Audio
+1 -1
View File
@@ -1,3 +1,3 @@
export const REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS = 1; export const REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS = 1;
export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4; export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4;
export const REPAIR_SCAN_SEQUENCE_SECONDS = 4; export const REPAIR_SCAN_PART_SECONDS = 1.2;
+5 -1
View File
@@ -1,6 +1,6 @@
import * as THREE from "three"; import * as THREE from "three";
interface ExplodedPart { export interface ExplodedPart {
object: THREE.Object3D; object: THREE.Object3D;
originalPosition: THREE.Vector3; originalPosition: THREE.Vector3;
targetPosition: THREE.Vector3; targetPosition: THREE.Vector3;
@@ -31,6 +31,10 @@ export class ExplodedModel {
this.targetProgress = split ? 1 : 0; this.targetProgress = split ? 1 : 0;
} }
getParts(): readonly ExplodedPart[] {
return this.parts;
}
update(delta: number): void { update(delta: number): void {
const diff = this.targetProgress - this.progress; const diff = this.targetProgress - this.progress;
if (Math.abs(diff) < 0.001) { if (Math.abs(diff) < 0.001) {