Merge remote-tracking branch 'origin/develop' into feat/mission-2

# Conflicts:
#	package-lock.json
#	package.json
#	src/App.tsx
#	src/components/three/interaction/CentralObject.tsx
#	src/components/three/interaction/VillageoisHelperObject.tsx
#	src/managers/GameStepManager.ts
#	src/stateManager/AudioManager.ts
#	src/world/World.tsx
#	src/world/player/PlayerController.tsx
This commit is contained in:
Tom Boullay
2026-05-11 17:46:42 +02:00
945 changed files with 26164 additions and 1569 deletions
@@ -0,0 +1,56 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
interface RepairBrokenPartHighlightProps {
target: THREE.Object3D;
}
const _box = new THREE.Box3();
const _sphere = new THREE.Sphere();
const _worldPosition = new THREE.Vector3();
const _localPosition = new THREE.Vector3();
export function RepairBrokenPartHighlight({
target,
}: RepairBrokenPartHighlightProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
useFrame(({ clock }) => {
const group = groupRef.current;
if (!group) return;
_box.setFromObject(target).getBoundingSphere(_sphere);
_worldPosition.copy(_sphere.center);
_localPosition.copy(_worldPosition);
group.parent?.worldToLocal(_localPosition);
group.position.copy(_localPosition);
const pulse = 1 + Math.sin(clock.elapsedTime * 5) * 0.08;
const radius = Math.max(_sphere.radius, 0.35) * pulse;
group.scale.setScalar(radius);
});
return (
<group ref={groupRef}>
<mesh>
<sphereGeometry args={[1, 32, 16]} />
<meshBasicMaterial color="#ef4444" transparent opacity={0.14} />
</mesh>
<mesh>
<sphereGeometry args={[1.06, 32, 16]} />
<meshBasicMaterial
color="#ef4444"
wireframe
transparent
opacity={0.65}
/>
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[1.12, 0.025, 8, 96]} />
<meshBasicMaterial color="#dc2626" transparent opacity={0.9} />
</mesh>
</group>
);
}
@@ -0,0 +1,36 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
interface RepairBrokenPartPromptProps {
src: string;
target: THREE.Object3D;
}
const _box = new THREE.Box3();
const _sphere = new THREE.Sphere();
const _localPosition = new THREE.Vector3();
export function RepairBrokenPartPrompt({
src,
target,
}: RepairBrokenPartPromptProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
useFrame(() => {
const group = groupRef.current;
if (!group) return;
_box.setFromObject(target).getBoundingSphere(_sphere);
_localPosition.copy(_sphere.center);
group.parent?.worldToLocal(_localPosition);
group.position.copy(_localPosition);
});
return (
<group ref={groupRef}>
<RepairPromptVideo src={src} position={[0, 0, 0]} size={72} />
</group>
);
}
@@ -0,0 +1,294 @@
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_CLOSE_SOUND_PATH,
REPAIR_CASE_OPEN_SOUND_PATH,
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 { AudioManager } from "@/managers/AudioManager";
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 previousOpen = useRef(open);
const openedRotationZ = useRef(0);
const parsedScale = toVector3Scale(scale);
const placeholderNodes = useRef<THREE.Object3D[]>([]);
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;
placeholderNodes.current = [];
model.traverse((child) => {
if (
child.name.toLowerCase().startsWith(REPAIR_CASE_PLACEHOLDER_NAME_PREFIX)
) {
placeholderNodes.current.push(child);
}
});
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]);
useEffect(() => {
if (previousOpen.current === open) return;
previousOpen.current = open;
AudioManager.getInstance().playSound(
open ? REPAIR_CASE_OPEN_SOUND_PATH : REPAIR_CASE_CLOSE_SOUND_PATH,
0.85,
);
}, [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,
);
if (placeholderNodes.current.length > 0) {
const placeholders: RepairCasePlaceholder[] = [];
placeholderNodes.current.forEach((child) => {
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>
);
}
@@ -0,0 +1,52 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
const PARTICLES = Array.from({ length: 24 }, (_, index) => {
const angle = (index / 24) * Math.PI * 2;
const ring = index % 3;
return {
angle,
radius: 0.45 + ring * 0.28,
y: 0.35 + (index % 5) * 0.16,
speed: 0.8 + (index % 4) * 0.18,
};
});
export function RepairCompletionParticles(): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
useFrame(({ clock }) => {
const group = groupRef.current;
if (!group) return;
group.rotation.y = clock.elapsedTime * 0.9;
group.children.forEach((child, index) => {
const particle = PARTICLES[index];
if (!particle) return;
const pulse = 1 + Math.sin(clock.elapsedTime * 5 + index) * 0.35;
child.position.y =
particle.y + Math.sin(clock.elapsedTime * particle.speed) * 0.08;
child.scale.setScalar(pulse);
});
});
return (
<group ref={groupRef}>
{PARTICLES.map((particle, index) => (
<mesh
key={index}
position={[
Math.cos(particle.angle) * particle.radius,
particle.y,
Math.sin(particle.angle) * particle.radius,
]}
>
<sphereGeometry args={[0.045, 12, 12]} />
<meshBasicMaterial color="#86efac" transparent opacity={0.85} />
</mesh>
))}
</group>
);
}
@@ -0,0 +1,73 @@
import { useEffect, 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 { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
interface RepairCompletionStepProps {
config: RepairMissionConfig;
onComplete: () => void;
}
export function RepairCompletionStep({
config,
onComplete,
}: RepairCompletionStepProps): React.JSX.Element {
const [isClosingCase, setIsClosingCase] = useState(false);
const [isExitingCase, setIsExitingCase] = useState(false);
useEffect(() => {
if (!isClosingCase) return undefined;
const timeoutId = window.setTimeout(() => {
setIsExitingCase(true);
}, REPAIR_CASE_ANIMATION_DURATION * 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [isClosingCase]);
return (
<group>
<RepairMissionCase
config={config}
exiting={isExitingCase}
open={!isClosingCase}
onExitComplete={onComplete}
/>
<RepairObjectModel
label={config.label}
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
/>
{!isClosingCase ? (
<TriggerObject
position={[0, 1.1, 0]}
colliders="ball"
label={`Valider ${config.label}`}
radius={REPAIR_INTERACTION_RADIUS}
onTrigger={() => setIsClosingCase(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}
{!isClosingCase ? (
<RepairPromptVideo src={config.stageUiPath} position={[0, 2.55, 0]} />
) : null}
</group>
);
}
@@ -0,0 +1,192 @@
import { Suspense, useEffect, useMemo, useState } from "react";
import { useGLTF } from "@react-three/drei";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
import {
RepairScanSequence,
type RepairScannedBrokenPart,
} from "@/components/three/gameplay/RepairScanSequence";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
import {
REPAIR_MISSIONS,
type RepairMissionConfig,
} from "@/data/gameplay/repairMissions";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import type {
MissionStep,
RepairMissionId,
} from "@/types/gameplay/repairMission";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
interface RepairGameProps extends Required<
Pick<ModelTransformProps, "position">
> {
mission: RepairMissionId;
rotation?: Vector3Tuple;
scale?: ModelTransformProps["scale"];
}
interface RepairMissionAssetPreloaderProps {
config: RepairMissionConfig;
}
function RepairMissionAssetPreloader({
config,
}: RepairMissionAssetPreloaderProps): null {
const modelPaths = useMemo(
() => getRepairMissionModelPaths(config),
[config],
);
useGLTF(modelPaths);
return null;
}
export function RepairGame({
mission,
position,
rotation = [0, 0, 0],
scale = 1,
}: RepairGameProps): React.JSX.Element | null {
const config = REPAIR_MISSIONS[mission];
const mainState = useGameStore((state) => state.mainState);
const completeMission = useGameStore((state) => state.completeMission);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const step = useRepairMissionStep(mission);
const [casePlaceholders, setCasePlaceholders] = useState<
readonly RepairCasePlaceholder[]
>([]);
const [scannedBrokenParts, setScannedBrokenParts] = useState<
readonly RepairScannedBrokenPart[]
>([]);
const parsedScale = toVector3Scale(scale);
const readyForFragmentation = step === "inspected";
useRepairFragmentationInput({
enabled: mainState === mission && readyForFragmentation,
keyboardEnabled: false,
onFragment: () => setMissionStep(mission, "fragmented"),
});
useEffect(() => {
if (mainState === mission && shouldKeepRepairRuntimeState(step)) return;
const timeoutId = window.setTimeout(() => {
setCasePlaceholders([]);
setScannedBrokenParts([]);
}, 0);
return () => {
window.clearTimeout(timeoutId);
};
}, [mainState, mission, step]);
useEffect(() => {
if (mainState !== mission) return undefined;
if (step !== "fragmented") return undefined;
const timeoutId = window.setTimeout(() => {
setMissionStep(mission, "scanning");
}, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS * 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [mainState, mission, setMissionStep, step]);
if (mainState !== mission) return null;
if (step === "locked") return null;
return (
<group position={position} rotation={rotation} scale={parsedScale}>
<Suspense fallback={null}>
<RepairMissionAssetPreloader config={config} />
</Suspense>
<Suspense fallback={null}>
{step === "waiting" ? (
<RepairInspectionObject
config={config}
worldPosition={position}
onInspect={() => setMissionStep(mission, "inspected")}
/>
) : null}
{step === "fragmented" ? (
<ExplodableModel
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
split
/>
) : null}
{step === "scanning" ? (
<RepairScanSequence
config={config}
onComplete={(brokenParts) => {
setScannedBrokenParts(brokenParts);
setMissionStep(mission, "repairing");
}}
/>
) : null}
{step === "repairing" ? (
<RepairRepairingStep
brokenParts={scannedBrokenParts}
config={config}
placeholders={casePlaceholders}
onRepair={() => setMissionStep(mission, "reassembling")}
/>
) : null}
{step === "reassembling" ? (
<RepairReassemblyStep
config={config}
onComplete={() => setMissionStep(mission, "done")}
/>
) : null}
{step === "done" ? (
<RepairCompletionStep
config={config}
onComplete={() => completeMission(mission)}
/>
) : null}
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
<RepairMissionCase
config={config}
onPlaceholdersChange={setCasePlaceholders}
open={step === "repairing"}
zoomed={step === "repairing"}
showFragmentationPrompt={readyForFragmentation}
onInteract={
readyForFragmentation
? () => setMissionStep(mission, "fragmented")
: undefined
}
/>
) : null}
</Suspense>
</group>
);
}
function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
return step === "repairing" || step === "reassembling" || step === "done";
}
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
return [
...new Set([
REPAIR_CASE_MODEL_PATH,
config.modelPath,
...config.brokenParts.flatMap((part) => part.modelPath ?? []),
...config.replacementParts.flatMap((part) => part.modelPath ?? []),
]),
];
}
@@ -0,0 +1,35 @@
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairInspectionObjectProps {
config: RepairMissionConfig;
worldPosition: Vector3Tuple;
onInspect: () => void;
}
export function RepairInspectionObject({
config,
worldPosition,
onInspect,
}: RepairInspectionObjectProps): React.JSX.Element {
return (
<InteractableObject
kind="trigger"
label={`Inspecter ${config.label}`}
position={worldPosition}
radius={REPAIR_INTERACTION_RADIUS}
onPress={onInspect}
>
<RepairObjectModel
label={config.label}
modelPath={config.modelPath}
scale={config.modelScale ?? 0.9}
/>
<RepairPromptVideo src={config.stageUiPath} />
</InteractableObject>
);
}
@@ -0,0 +1,89 @@
import {
RepairCaseModel,
type RepairCasePlaceholder,
} from "@/components/three/gameplay/RepairCaseModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import {
REPAIR_CASE_FOCUS_POSITION,
REPAIR_CASE_FOCUS_SCALE,
REPAIR_CASE_MODEL_PATH,
} from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairMissionCaseProps {
config: RepairMissionConfig;
exiting?: boolean;
onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined;
onExitComplete?: (() => void) | undefined;
open?: boolean;
zoomed?: boolean;
showFragmentationPrompt?: boolean;
onInteract?: (() => void) | undefined;
}
export function RepairMissionCase({
config,
exiting = false,
onPlaceholdersChange,
onExitComplete,
open = false,
zoomed = false,
showFragmentationPrompt = false,
onInteract,
}: RepairMissionCaseProps): React.JSX.Element {
const casePosition = zoomed
? REPAIR_CASE_FOCUS_POSITION
: config.case.position;
const caseScale = zoomed ? REPAIR_CASE_FOCUS_SCALE : config.case.scale;
const modelPosition: Vector3Tuple = onInteract ? [0, 0, 0] : casePosition;
return (
<group>
{onInteract ? (
<TriggerObject
position={casePosition}
colliders="ball"
label={`Ouvrir ${config.label}`}
radius={REPAIR_INTERACTION_RADIUS}
onTrigger={onInteract}
>
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
exiting={exiting}
onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
open={open}
floating={!zoomed}
position={modelPosition}
rotation={config.case.rotation}
scale={caseScale}
/>
</TriggerObject>
) : (
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
exiting={exiting}
onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
open={open}
floating={!zoomed}
position={modelPosition}
rotation={config.case.rotation}
scale={caseScale}
/>
)}
{showFragmentationPrompt && !exiting ? (
<RepairPromptVideo
src={config.interactUiPath}
position={[casePosition[0], 2.4, casePosition[2]]}
size={80}
/>
) : null}
</group>
);
}
@@ -0,0 +1,120 @@
import type { ReactNode } from "react";
import { Component } from "react";
import { SimpleModel } from "@/components/three/models/SimpleModel";
import type { ModelTransformProps } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import { toVector3Scale } from "@/utils/three/scale";
interface RepairObjectModelProps extends ModelTransformProps {
label: string;
modelPath: string;
}
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
children: ReactNode;
}
interface RepairObjectModelBoundaryState {
hasError: boolean;
}
interface RepairObjectFallbackProps {
label: string;
position?: ModelTransformProps["position"] | undefined;
rotation?: ModelTransformProps["rotation"] | undefined;
scale?: ModelTransformProps["scale"] | undefined;
}
class RepairObjectModelBoundary extends Component<
RepairObjectModelBoundaryProps,
RepairObjectModelBoundaryState
> {
constructor(props: RepairObjectModelBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): RepairObjectModelBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error): void {
logModelLoadError(
{
modelPath: this.props.modelPath,
position: this.props.position,
rotation: this.props.rotation,
scale: this.props.scale,
scope: `RepairObjectModel.${this.props.label}`,
},
error,
);
}
render(): ReactNode {
if (this.state.hasError) {
return (
<RepairObjectFallback
label={this.props.label}
position={this.props.position}
rotation={this.props.rotation}
scale={this.props.scale}
/>
);
}
return this.props.children;
}
}
export function RepairObjectModel({
label,
modelPath,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
}: RepairObjectModelProps): React.JSX.Element {
return (
<RepairObjectModelBoundary
label={label}
modelPath={modelPath}
position={position}
rotation={rotation}
scale={scale}
>
<SimpleModel
modelPath={modelPath}
position={position}
rotation={rotation}
scale={scale}
/>
</RepairObjectModelBoundary>
);
}
function RepairObjectFallback({
label,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
}: Pick<
RepairObjectFallbackProps,
"label" | "position" | "rotation" | "scale"
>): React.JSX.Element {
return (
<group
position={position}
rotation={rotation}
scale={toVector3Scale(scale)}
>
<mesh castShadow receiveShadow>
<boxGeometry args={[1.4, 1.4, 1.4]} />
<meshStandardMaterial color="#facc15" roughness={0.6} wireframe />
</mesh>
<mesh position={[0, 1.05, 0]}>
<sphereGeometry args={[0.08, 16, 16]} />
<meshBasicMaterial color={label ? "#f8fafc" : "#facc15"} />
</mesh>
</group>
);
}
@@ -0,0 +1,25 @@
import { WorldVideoPrompt } from "@/components/three/ui/WorldVideoPrompt";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairPromptVideoProps {
src: string;
position?: Vector3Tuple;
size?: number;
billboard?: boolean;
}
export function RepairPromptVideo({
src,
position = [0, 1.8, 0],
size = 96,
billboard = true,
}: RepairPromptVideoProps): React.JSX.Element {
return (
<WorldVideoPrompt
billboard={billboard}
position={position}
size={size}
src={src}
/>
);
}
@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
interface RepairReassemblyStepProps {
config: RepairMissionConfig;
onComplete: () => void;
}
export function RepairReassemblyStep({
config,
onComplete,
}: RepairReassemblyStepProps): React.JSX.Element {
const [split, setSplit] = useState(true);
const reassemblySeconds =
config.reassemblySeconds ?? REPAIR_REASSEMBLY_SECONDS;
useEffect(() => {
const closeTimeoutId = window.setTimeout(() => {
setSplit(false);
}, 50);
const completeTimeoutId = window.setTimeout(() => {
onComplete();
}, reassemblySeconds * 1000);
return () => {
window.clearTimeout(closeTimeoutId);
window.clearTimeout(completeTimeoutId);
};
}, [onComplete, reassemblySeconds]);
return (
<group>
<ExplodableModel
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
split={split}
splitDistance={1.2}
/>
<RepairCompletionParticles />
</group>
);
}
@@ -0,0 +1,480 @@
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import {
REPAIR_CASE_FOCUS_POSITION,
REPAIR_CASE_PLACEHOLDER_SNAP_DURATION,
REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS,
} from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type {
RepairMissionConfig,
RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions";
import type { Vector3Tuple } from "@/types/three/three";
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
const _placeholderPosition = new THREE.Vector3();
const FALLBACK_PLACEHOLDER_OFFSETS: Vector3Tuple[] = [
[-1.15, 1, 0.25],
[0, 1.05, 0.45],
[1.15, 1, 0.25],
];
const BROKEN_PART_START_OFFSETS: Vector3Tuple[] = [
[-1.35, 0.55, -0.85],
[0, 0.6, -1],
[1.35, 0.55, -0.85],
];
const REPAIR_INSTALL_RADIUS = 1.1;
const VALID_PART_COLOR = "#22c55e";
const INVALID_PART_COLOR = "#ef4444";
const STORED_BROKEN_PART_COLOR = "#38bdf8";
interface RepairRepairingStepProps {
brokenParts: readonly RepairScannedBrokenPart[];
config: RepairMissionConfig;
placeholders: readonly RepairCasePlaceholder[];
onRepair: () => void;
}
interface RepairInstallTargetProps {
blockedFeedback: boolean;
fillColor: string;
isReadyToInstall: boolean;
label: string;
ringColor: string;
onBlocked: () => void;
onRepair: () => void;
}
interface RepairPlaceholderMarkersProps {
positions: readonly Vector3Tuple[];
}
interface RepairPartPlacementFeedbackProps {
state: "valid" | "invalid" | "stored" | null;
}
export function RepairRepairingStep({
brokenParts,
config,
placeholders,
onRepair,
}: RepairRepairingStepProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const localPosition = useRef(new THREE.Vector3());
const [placedPartIds, setPlacedPartIds] = useState<Record<string, boolean>>(
{},
);
const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState<
Record<string, boolean>
>({});
const [showBlockedInstallFeedback, setShowBlockedInstallFeedback] =
useState(false);
const replacementParts = getReplacementParts(config);
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
const requiredReplacementPart = replacementParts.find(
(part) => part.id === config.requiredReplacementPartId,
);
const requiredReplacementLabel =
requiredReplacementPart?.label ?? config.label;
const placeholderTargets = getPlaceholderTargets(placeholders);
const placeholderPositions = placeholderTargets.map(
(target) => target.position,
);
const hasCorrectPartPlaced = Boolean(
placedPartIds[config.requiredReplacementPartId],
);
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
(part) => depositedBrokenPartIds[part.id],
);
const hasWrongPartPlaced = replacementParts.some(
(part) =>
part.id !== config.requiredReplacementPartId && placedPartIds[part.id],
);
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
const installColor = isReadyToInstall
? "#22c55e"
: hasWrongPartPlaced
? "#ef4444"
: "#f97316";
const installFillColor = isReadyToInstall
? "#86efac"
: hasWrongPartPlaced
? "#fecaca"
: "#fed7aa";
const installLabel = isReadyToInstall
? `Installer ${requiredReplacementLabel}`
: hasWrongPartPlaced
? `Mauvaise pièce`
: hasCorrectPartPlaced
? `Ranger pièce cassée`
: `Approcher ${requiredReplacementLabel}`;
useEffect(() => {
if (!showBlockedInstallFeedback) return undefined;
const timeoutId = window.setTimeout(() => {
setShowBlockedInstallFeedback(false);
}, 900);
return () => {
window.clearTimeout(timeoutId);
};
}, [showBlockedInstallFeedback]);
function handleReplacementPosition(
partId: string,
position: THREE.Vector3,
): void {
const isPlaced = isNearPlaceholder(
getStepLocalPosition(position, groupRef.current, localPosition.current),
placeholderPositions,
);
setPlacedPartIds((current) => {
if (!current[partId] || isPlaced) return current;
return { ...current, [partId]: false };
});
}
function handleReplacementSnap(partId: string): void {
setPlacedPartIds((current) => {
if (current[partId]) return current;
return { ...current, [partId]: true };
});
}
function handleBrokenPartPosition(
partId: string,
position: THREE.Vector3,
targets: readonly Vector3Tuple[],
): void {
const isDeposited = isNearPlaceholder(
getStepLocalPosition(position, groupRef.current, localPosition.current),
targets,
);
setDepositedBrokenPartIds((current) => {
if (!current[partId] || isDeposited) return current;
return { ...current, [partId]: false };
});
}
function handleBrokenPartSnap(partId: string): void {
setDepositedBrokenPartIds((current) => {
if (current[partId]) return current;
return { ...current, [partId]: true };
});
}
return (
<group ref={groupRef}>
<RepairInstallTarget
blockedFeedback={showBlockedInstallFeedback}
fillColor={installFillColor}
isReadyToInstall={isReadyToInstall}
label={installLabel}
ringColor={installColor}
onBlocked={() => setShowBlockedInstallFeedback(true)}
onRepair={onRepair}
/>
<RepairPlaceholderMarkers positions={placeholderPositions} />
{replacementParts.map((part, index) => {
const placeholderPosition =
placeholderPositions[index % placeholderPositions.length] ??
placeholderPositions[0]!;
const isPlaced = Boolean(placedPartIds[part.id]);
const feedbackState = getReplacementFeedbackState(
part.id,
config.requiredReplacementPartId,
isPlaced,
);
return (
<GrabbableObject
key={part.id}
position={placeholderPosition}
colliders="ball"
handControlled
label={`Prendre ${part.label}`}
onPositionChange={(position) => {
handleReplacementPosition(part.id, position);
}}
onSnap={() => {
handleReplacementSnap(part.id);
}}
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
snapTargets={placeholderPositions}
>
<group>
<RepairObjectModel
label={part.label}
modelPath={part.modelPath ?? config.modelPath}
scale={0.36}
/>
<RepairPartPlacementFeedback state={feedbackState} />
</group>
</GrabbableObject>
);
})}
{brokenPartsToDeposit.map((part, index) => {
const startOffset =
BROKEN_PART_START_OFFSETS[index % BROKEN_PART_START_OFFSETS.length] ??
BROKEN_PART_START_OFFSETS[0]!;
const startPosition: Vector3Tuple = [
REPAIR_CASE_FOCUS_POSITION[0] + startOffset[0],
REPAIR_CASE_FOCUS_POSITION[1] + startOffset[1],
REPAIR_CASE_FOCUS_POSITION[2] + startOffset[2],
];
const targetPositions = getBrokenPartTargetPositions(
part,
placeholderTargets,
);
const isDeposited = Boolean(depositedBrokenPartIds[part.id]);
return (
<GrabbableObject
key={part.id}
position={startPosition}
colliders="ball"
handControlled
label={`Ranger ${part.label}`}
onPositionChange={(position) => {
handleBrokenPartPosition(part.id, position, targetPositions);
}}
onSnap={() => {
handleBrokenPartSnap(part.id);
}}
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
snapTargets={targetPositions}
>
<group>
<RepairObjectModel
label={part.label}
modelPath={part.modelPath}
scale={0.24}
/>
<mesh position={[0, 0.42, 0]}>
<sphereGeometry args={[0.11, 16, 16]} />
<meshBasicMaterial color="#ef4444" transparent opacity={0.85} />
</mesh>
<RepairPartPlacementFeedback
state={isDeposited ? "stored" : null}
/>
</group>
</GrabbableObject>
);
})}
{isReadyToInstall ? (
<RepairPromptVideo src={config.interactUiPath} position={[0, 2.3, 0]} />
) : null}
</group>
);
}
function RepairInstallTarget({
blockedFeedback,
fillColor,
isReadyToInstall,
label,
ringColor,
onBlocked,
onRepair,
}: RepairInstallTargetProps): React.JSX.Element {
return (
<TriggerObject
position={INSTALL_TARGET_POSITION}
colliders="ball"
label={label}
radius={REPAIR_INTERACTION_RADIUS}
onTrigger={() => {
if (!isReadyToInstall) {
onBlocked();
return;
}
onRepair();
}}
>
<mesh>
<torusGeometry args={[0.95, 0.045, 12, 96]} />
<meshBasicMaterial color={ringColor} transparent opacity={0.85} />
</mesh>
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.15, 0.9, 96]} />
<meshBasicMaterial color={fillColor} transparent opacity={0.35} />
</mesh>
{blockedFeedback ? (
<group position={[0, 0.28, 0]}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[1.08, 0.035, 12, 96]} />
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
</mesh>
<mesh>
<sphereGeometry args={[0.12, 16, 16]} />
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
</mesh>
</group>
) : null}
</TriggerObject>
);
}
function RepairPlaceholderMarkers({
positions,
}: RepairPlaceholderMarkersProps): React.JSX.Element {
return (
<>
{positions.map((position, index) => (
<mesh
key={`${position.join(":")}-${index}`}
position={position}
rotation={[Math.PI / 2, 0, 0]}
>
<torusGeometry args={[0.26, 0.018, 8, 48]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.55} />
</mesh>
))}
</>
);
}
function RepairPartPlacementFeedback({
state,
}: RepairPartPlacementFeedbackProps): React.JSX.Element | null {
if (!state) return null;
const color = getPlacementFeedbackColor(state);
return (
<group position={[0, 0.72, 0]}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.48, 0.035, 12, 64]} />
<meshBasicMaterial color={color} transparent opacity={0.85} />
</mesh>
<mesh position={[0, 0.08, 0]}>
<sphereGeometry args={[0.1, 16, 16]} />
<meshBasicMaterial color={color} transparent opacity={0.9} />
</mesh>
</group>
);
}
function getPlacementFeedbackColor(
state: NonNullable<RepairPartPlacementFeedbackProps["state"]>,
): string {
if (state === "valid") return VALID_PART_COLOR;
if (state === "stored") return STORED_BROKEN_PART_COLOR;
return INVALID_PART_COLOR;
}
function getReplacementFeedbackState(
partId: string,
requiredPartId: string,
isPlaced: boolean,
): RepairPartPlacementFeedbackProps["state"] {
if (!isPlaced) return null;
return partId === requiredPartId ? "valid" : "invalid";
}
function getPlaceholderTargets(
placeholders: readonly RepairCasePlaceholder[],
): readonly RepairCasePlaceholder[] {
if (placeholders.length > 0) {
return placeholders;
}
return FALLBACK_PLACEHOLDER_OFFSETS.map(
(offset, index): RepairCasePlaceholder => ({
name: `placeholder_${index + 1}`,
position: [
REPAIR_CASE_FOCUS_POSITION[0] + offset[0],
REPAIR_CASE_FOCUS_POSITION[1] + offset[1],
REPAIR_CASE_FOCUS_POSITION[2] + offset[2],
],
}),
);
}
function getBrokenPartTargetPositions(
part: RepairScannedBrokenPart,
placeholderTargets: readonly RepairCasePlaceholder[],
): readonly Vector3Tuple[] {
if (!part.placeholderName) {
return placeholderTargets.map((placeholder) => placeholder.position);
}
const matchingPlaceholder = placeholderTargets.find(
(placeholder) => placeholder.name === part.placeholderName,
);
return matchingPlaceholder
? [matchingPlaceholder.position]
: placeholderTargets.map((placeholder) => placeholder.position);
}
function isNearPlaceholder(
position: THREE.Vector3,
placeholderPositions: readonly Vector3Tuple[],
): boolean {
return placeholderPositions.some(
(placeholderPosition) =>
position.distanceTo(_placeholderPosition.set(...placeholderPosition)) <=
REPAIR_INSTALL_RADIUS,
);
}
function getStepLocalPosition(
worldPosition: THREE.Vector3,
group: THREE.Group | null,
target: THREE.Vector3,
): THREE.Vector3 {
target.copy(worldPosition);
group?.worldToLocal(target);
return target;
}
function getReplacementParts(
config: RepairMissionConfig,
): readonly RepairMissionPartConfig[] {
if (config.replacementParts.length > 0) return config.replacementParts;
return [
{
id: config.requiredReplacementPartId,
label: config.label,
modelPath: config.modelPath,
},
];
}
function getBrokenPartsToDeposit(
config: RepairMissionConfig,
brokenParts: readonly RepairScannedBrokenPart[],
): readonly RepairScannedBrokenPart[] {
if (brokenParts.length > 0) return brokenParts;
return config.brokenParts.map((part) => ({
id: part.id,
label: part.label,
modelPath: part.modelPath ?? config.modelPath,
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
}));
}
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import * as THREE from "three";
import { RepairBrokenPartHighlight } from "@/components/three/gameplay/RepairBrokenPartHighlight";
import { RepairBrokenPartPrompt } from "@/components/three/gameplay/RepairBrokenPartPrompt";
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,
RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions";
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
interface RepairScanSequenceProps {
config: RepairMissionConfig;
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
}
export interface RepairScannedBrokenPart {
id: string;
label: string;
modelPath: string;
placeholderName?: string;
}
export function RepairScanSequence({
config,
onComplete,
}: RepairScanSequenceProps): React.JSX.Element {
const [parts, setParts] = useState<readonly ExplodedPart[]>([]);
const [activePartIndex, setActivePartIndex] = useState(0);
const activePart = parts[activePartIndex];
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
const visibleBrokenPartIndexes = brokenPartIndexes.filter(
(partIndex) => partIndex <= activePartIndex,
);
useEffect(() => {
if (parts.length === 0) return undefined;
const timeoutId = window.setTimeout(() => {
setActivePartIndex((currentIndex) => {
const nextIndex = currentIndex + 1;
if (nextIndex >= parts.length) {
onComplete(getScannedBrokenParts(parts, config));
return currentIndex;
}
return nextIndex;
});
}, scanPartSeconds * 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [activePartIndex, config, onComplete, parts, scanPartSeconds]);
return (
<group>
<ExplodableModel
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
split
onPartsReady={setParts}
/>
<RepairScanVisual target={activePart?.object} />
{visibleBrokenPartIndexes.map((partIndex) => {
const part = parts[partIndex];
if (!part) return null;
return (
<group key={part.object.uuid}>
<RepairBrokenPartHighlight target={part.object} />
<RepairBrokenPartPrompt
src={config.brokenUiPath}
target={part.object}
/>
</group>
);
})}
</group>
);
}
function getScannedBrokenParts(
parts: readonly ExplodedPart[],
config: RepairMissionConfig,
): readonly RepairScannedBrokenPart[] {
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
return brokenPartIndexes.map((_, index) => {
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
return {
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`,
label: configuredPart?.label ?? `${config.label} broken part`,
modelPath: configuredPart?.modelPath ?? config.modelPath,
...(configuredPart?.placeholderName
? { placeholderName: configuredPart.placeholderName }
: {}),
};
});
}
function getBrokenPartIndexes(
parts: readonly ExplodedPart[],
brokenParts: readonly RepairMissionPartConfig[],
): number[] {
if (parts.length === 0 || brokenParts.length === 0) return [];
const matchedIndexes = brokenParts.flatMap((brokenPart) => {
const { nodeName } = brokenPart;
if (!nodeName) return [];
const index = parts.findIndex((part) =>
objectContainsNodeName(part.object, nodeName),
);
return index >= 0 ? [index] : [];
});
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)];
return parts.slice(0, brokenParts.length).map((_, index) => index);
}
function objectContainsNodeName(
object: THREE.Object3D,
nodeName: string,
): boolean {
if (object.name === nodeName) return true;
let found = false;
object.traverse((child) => {
if (child.name === nodeName) {
found = true;
}
});
return found;
}
@@ -0,0 +1,53 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
interface RepairScanVisualProps {
target?: THREE.Object3D | null | undefined;
}
export function RepairScanVisual({
target = null,
}: RepairScanVisualProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const scanLineRef = useRef<THREE.Mesh>(null);
const worldPosition = useRef(new THREE.Vector3());
const localPosition = useRef(new THREE.Vector3());
useFrame(({ clock }) => {
const group = groupRef.current;
const scanLine = scanLineRef.current;
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;
});
return (
<group ref={groupRef}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[1.35, 0.035, 12, 96]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.75} />
</mesh>
<mesh ref={scanLineRef} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.15, 1.25, 96]} />
<meshBasicMaterial
color="#7dd3fc"
side={THREE.DoubleSide}
transparent
opacity={0.45}
/>
</mesh>
<mesh position={[0, 0.85, 0]}>
<sphereGeometry args={[1.25, 32, 16]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
</mesh>
</group>
);
}
@@ -0,0 +1,367 @@
import type { ReactNode } from "react";
import { Component, useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { clone } from "three/addons/utils/SkeletonUtils.js";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import {
useHandTrackingGloveStatus,
type HandTrackingGloveHandedness,
} from "@/hooks/handTracking/useHandTrackingGloveStatus";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { HandTrackingLandmark } from "@/types/handTracking/handTracking";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
const GLOVE_CONFIGS: Record<
HandTrackingGloveHandedness,
{
modelPath: string;
rootNodeName: string;
}
> = {
left: {
modelPath: "/models/gant_l/model.gltf",
rootNodeName: "Armature",
},
right: {
modelPath: "/models/gant_r/model.gltf",
rootNodeName: "Hand_r",
},
};
const GLOVE_MODEL_SCALE = 0.33;
const HAND_SPACE_DISTANCE = 0.5;
const HAND_TRACKING_HIDE_DELAY_MS = 250;
const FINGER_LANDMARK_CHAINS = [
[0, 1, 2, 3, 4],
[0, 5, 6, 7, 8],
[0, 9, 10, 11, 12],
[0, 13, 14, 15, 16],
[0, 17, 18, 19, 20],
] as const;
const _cameraPosition = new THREE.Vector3();
const _direction = new THREE.Vector3();
const _xAxis = new THREE.Vector3();
const _yAxis = new THREE.Vector3();
const _zAxis = new THREE.Vector3();
const _matrix = new THREE.Matrix4();
const _parentInverse = new THREE.Matrix4();
const _targetQuaternion = new THREE.Quaternion();
const _boneTargetQuaternion = new THREE.Quaternion();
const _boneDeltaQuaternion = new THREE.Quaternion();
const _targetPosition = new THREE.Vector3();
const _localSegmentStart = new THREE.Vector3();
const _localSegmentEnd = new THREE.Vector3();
const _localSegmentDirection = new THREE.Vector3();
const _wristPosition = new THREE.Vector3();
const _indexPosition = new THREE.Vector3();
const _middlePosition = new THREE.Vector3();
const _ringPosition = new THREE.Vector3();
const _pinkyPosition = new THREE.Vector3();
interface FingerBonePose {
bone: THREE.Object3D;
restDirection: THREE.Vector3;
restQuaternion: THREE.Quaternion;
}
type FingerPoseChain = FingerBonePose[];
interface HandTrackingGloveProps {
handedness: HandTrackingGloveHandedness;
}
interface HandTrackingGloveErrorBoundaryProps {
children: ReactNode;
handedness: HandTrackingGloveHandedness;
modelPath: string;
}
class HandTrackingGloveErrorBoundary extends Component<
HandTrackingGloveErrorBoundaryProps,
{ hasError: boolean }
> {
constructor(props: HandTrackingGloveErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidCatch(error: Error): void {
useHandTrackingGloveStatus
.getState()
.setGloveStatus(this.props.handedness, "error");
logModelLoadError(
{
modelPath: this.props.modelPath,
scope: `HandTrackingGlove.${this.props.handedness}`,
scale: GLOVE_MODEL_SCALE,
},
error,
);
}
render(): ReactNode {
if (this.state.hasError) return null;
return this.props.children;
}
}
function landmarkToWorldPoint(
landmark: HandTrackingLandmark,
camera: THREE.Camera,
target: THREE.Vector3,
): THREE.Vector3 {
_cameraPosition.setFromMatrixPosition(camera.matrixWorld);
target.set((1 - landmark.x) * 2 - 1, -landmark.y * 2 + 1, 0.5);
target.unproject(camera);
_direction.copy(target).sub(_cameraPosition).normalize();
target.copy(_cameraPosition).addScaledVector(_direction, HAND_SPACE_DISTANCE);
return target;
}
function matchesHandedness(
handHandedness: string,
targetHandedness: HandTrackingGloveHandedness,
): boolean {
return handHandedness.toLowerCase() === targetHandedness;
}
function getFirstChildBone(object: THREE.Object3D): THREE.Object3D | null {
return object.children.find((child) => child.type === "Bone") ?? null;
}
function createFingerBonePose(bone: THREE.Object3D): FingerBonePose {
const firstChild = getFirstChildBone(bone);
const restDirection = firstChild
? firstChild.position.clone()
: new THREE.Vector3(0, 1, 0);
restDirection.applyQuaternion(bone.quaternion).normalize();
return {
bone,
restDirection,
restQuaternion: bone.quaternion.clone(),
};
}
function createFingerPoseChain(startBone: THREE.Object3D): FingerPoseChain {
const chain: FingerPoseChain = [];
let currentBone: THREE.Object3D | null = startBone;
while (currentBone && chain.length < 4) {
chain.push(createFingerBonePose(currentBone));
currentBone = getFirstChildBone(currentBone);
}
return chain;
}
function createFingerPoseChains(root: THREE.Object3D): FingerPoseChain[] {
const rootBone = root.getObjectByName("Bone");
if (!rootBone) return [];
return rootBone.children
.filter((child) => child.type === "Bone")
.slice(0, FINGER_LANDMARK_CHAINS.length)
.map(createFingerPoseChain);
}
function resetFingerPose(chains: FingerPoseChain[]): void {
for (const chain of chains) {
for (const pose of chain) {
pose.bone.quaternion.copy(pose.restQuaternion);
}
}
}
function applyFingerPose(
chains: FingerPoseChain[],
landmarks: HandTrackingLandmark[],
camera: THREE.Camera,
): void {
for (let fingerIndex = 0; fingerIndex < chains.length; fingerIndex += 1) {
const chain = chains[fingerIndex];
const landmarkChain = FINGER_LANDMARK_CHAINS[fingerIndex];
if (!chain || !landmarkChain) continue;
for (let boneIndex = 0; boneIndex < chain.length; boneIndex += 1) {
const pose = chain[boneIndex];
const fromLandmark = landmarks[landmarkChain[boneIndex] ?? -1];
const toLandmark = landmarks[landmarkChain[boneIndex + 1] ?? -1];
const parent = pose?.bone.parent;
if (!pose || !fromLandmark || !toLandmark || !parent) continue;
landmarkToWorldPoint(fromLandmark, camera, _localSegmentStart);
landmarkToWorldPoint(toLandmark, camera, _localSegmentEnd);
parent.updateWorldMatrix(true, false);
_parentInverse.copy(parent.matrixWorld).invert();
_localSegmentStart.applyMatrix4(_parentInverse);
_localSegmentEnd.applyMatrix4(_parentInverse);
_localSegmentDirection
.copy(_localSegmentEnd)
.sub(_localSegmentStart)
.normalize();
if (_localSegmentDirection.lengthSq() === 0) continue;
_boneDeltaQuaternion.setFromUnitVectors(
pose.restDirection,
_localSegmentDirection,
);
_boneTargetQuaternion
.copy(_boneDeltaQuaternion)
.multiply(pose.restQuaternion);
pose.bone.quaternion.slerp(_boneTargetQuaternion, 0.45);
}
}
}
function HandTrackingGloveModel({
handedness,
}: HandTrackingGloveProps): React.JSX.Element | null {
const groupRef = useRef<THREE.Group>(null);
const { camera } = useThree();
const { hands } = useHandTrackingSnapshot();
const setGloveStatus = useHandTrackingGloveStatus(
(state) => state.setGloveStatus,
);
const config = GLOVE_CONFIGS[handedness];
const modelPath = config.modelPath;
const gltf = useLoggedGLTF(modelPath, {
scope: `HandTrackingGlove.${handedness}`,
scale: GLOVE_MODEL_SCALE,
});
const lastTrackedAtRef = useRef<number | null>(null);
const gloveScene = useMemo(() => {
const rootNode = gltf.scene.getObjectByName(config.rootNodeName);
if (!rootNode) {
throw new Error(`Missing glove root node ${config.rootNodeName}`);
}
const clonedRootNode = clone(rootNode);
clonedRootNode.visible = false;
return clonedRootNode;
}, [config.rootNodeName, gltf.scene]);
const fingerPoseChains = useMemo(
() => createFingerPoseChains(gloveScene),
[gloveScene],
);
useEffect(() => {
setGloveStatus(handedness, "loaded");
}, [handedness, setGloveStatus]);
useFrame((_, delta) => {
const group = groupRef.current;
const trackedHand = hands.find((candidate) =>
matchesHandedness(candidate.handedness, handedness),
);
if (!group) return;
if (!trackedHand || trackedHand.landmarks.length < 21) {
const lastTrackedAt = lastTrackedAtRef.current;
const shouldHide =
lastTrackedAt === null ||
performance.now() - lastTrackedAt > HAND_TRACKING_HIDE_DELAY_MS;
if (shouldHide) {
group.visible = false;
resetFingerPose(fingerPoseChains);
}
return;
}
lastTrackedAtRef.current = performance.now();
group.visible = true;
const wrist = trackedHand.landmarks[0];
const indexMcp = trackedHand.landmarks[5];
const middleMcp = trackedHand.landmarks[9];
const ringMcp = trackedHand.landmarks[13];
const pinkyMcp = trackedHand.landmarks[17];
if (!wrist || !indexMcp || !middleMcp || !ringMcp || !pinkyMcp) {
group.visible = false;
return;
}
landmarkToWorldPoint(wrist, camera, _wristPosition);
landmarkToWorldPoint(indexMcp, camera, _indexPosition);
landmarkToWorldPoint(middleMcp, camera, _middlePosition);
landmarkToWorldPoint(ringMcp, camera, _ringPosition);
landmarkToWorldPoint(pinkyMcp, camera, _pinkyPosition);
_targetPosition
.copy(_wristPosition)
.add(_indexPosition)
.add(_middlePosition)
.add(_ringPosition)
.add(_pinkyPosition)
.multiplyScalar(0.2);
_yAxis.copy(_middlePosition).sub(_wristPosition).normalize();
_xAxis.copy(_indexPosition).sub(_pinkyPosition).normalize();
_zAxis.crossVectors(_xAxis, _yAxis).normalize();
if (
_xAxis.lengthSq() === 0 ||
_yAxis.lengthSq() === 0 ||
_zAxis.lengthSq() === 0
) {
return;
}
_xAxis.crossVectors(_yAxis, _zAxis).normalize();
_matrix.makeBasis(_xAxis, _yAxis, _zAxis);
_targetQuaternion.setFromRotationMatrix(_matrix);
group.position.lerp(_targetPosition, Math.min(1, delta * 18));
group.quaternion.slerp(_targetQuaternion, Math.min(1, delta * 18));
const palmLength = _wristPosition.distanceTo(_middlePosition);
const scale = palmLength * GLOVE_MODEL_SCALE;
group.scale.setScalar(scale);
group.updateMatrixWorld(true);
applyFingerPose(fingerPoseChains, trackedHand.landmarks, camera);
});
return <primitive ref={groupRef} object={gloveScene} />;
}
export function HandTrackingGlove({
handedness,
}: HandTrackingGloveProps): React.JSX.Element {
const modelPath = GLOVE_CONFIGS[handedness].modelPath;
return (
<HandTrackingGloveErrorBoundary
handedness={handedness}
modelPath={modelPath}
>
<HandTrackingGloveModel handedness={handedness} />
</HandTrackingGloveErrorBoundary>
);
}
useGLTF.preload(GLOVE_CONFIGS.left.modelPath);
useGLTF.preload(GLOVE_CONFIGS.right.modelPath);
@@ -0,0 +1,60 @@
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
import { Debug } from "@/utils/debug/Debug";
import type { Vector3Tuple } from "@/types/three/three";
interface CentralObjectProps {
position: Vector3Tuple;
}
export function CentralObject({
position,
}: CentralObjectProps): React.JSX.Element {
const step = useMissionFlowStore((state) => state.step);
const setStep = useMissionFlowStore((state) => state.setStep);
const setCanMove = useMissionFlowStore((state) => state.setCanMove);
const showDialog = useMissionFlowStore((state) => state.showDialog);
const debug = Debug.getInstance();
const handlePress = (): void => {
console.log("[CentralObject] handlePress called, current step:", step);
if (step === "helped") {
console.log("[CentralObject] Transitioning to manipulation");
setCanMove(false);
setStep("manipulation");
} else if (step === "searching") {
console.log("[CentralObject] Showing help message");
showDialog(
"Cet objet est trop lourd pour le porter tout seul, trouve de l'aide",
);
} else {
console.log("[CentralObject] Step is not helped or searching, skipping");
}
};
const shouldShow =
step === "helped" || step === "manipulation" || debug.active;
if (!shouldShow) {
return <></>;
}
console.log("[CentralObject] Rendering, step:", step, "position:", position);
return (
<InteractableObject
kind="trigger"
label="central"
position={position}
onPress={handlePress}
>
<group position={position}>
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="orange" />
</mesh>
</group>
</InteractableObject>
);
}
@@ -0,0 +1,347 @@
import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { RigidBody } from "@react-three/rapier";
import type { RapierRigidBody } from "@react-three/rapier";
import gsap from "gsap";
import * as THREE from "three";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import {
GRAB_DEFAULT_COLLIDERS,
GRAB_DEFAULT_LABEL,
GRAB_HOLD_DISTANCE_DEFAULT,
GRAB_HOLD_DISTANCE_MAX,
GRAB_HOLD_DISTANCE_MIN,
GRAB_HOLD_DISTANCE_STEP,
GRAB_STIFFNESS_DEFAULT,
GRAB_STIFFNESS_MAX,
GRAB_STIFFNESS_MIN,
GRAB_STIFFNESS_STEP,
GRAB_THROW_BOOST_DEFAULT,
GRAB_THROW_BOOST_MAX,
GRAB_THROW_BOOST_MIN,
GRAB_THROW_BOOST_STEP,
} from "@/data/interaction/grabConfig";
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { InteractionManager } from "@/managers/InteractionManager";
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
import type { ColliderShape, Vector3Tuple } from "@/types/three/three";
interface GrabbableObjectProps {
position: Vector3Tuple;
children: React.ReactNode;
colliders?: ColliderShape;
label?: string;
handControlled?: boolean;
onPositionChange?: (position: THREE.Vector3) => void;
onSnap?: (position: THREE.Vector3) => void;
snapDuration?: number;
snapRadius?: number;
snapTargets?: readonly Vector3Tuple[];
}
interface HandScreenPoint {
x: number;
y: number;
}
const grabDebugParams = {
stiffness: GRAB_STIFFNESS_DEFAULT,
throwBoost: GRAB_THROW_BOOST_DEFAULT,
holdDistance: GRAB_HOLD_DISTANCE_DEFAULT,
};
const ZERO_ANGULAR_VELOCITY = { x: 0, y: 0, z: 0 };
const _holdTarget = new THREE.Vector3();
const _currentPos = new THREE.Vector3();
const _velocity = new THREE.Vector3();
const _handNdc = new THREE.Vector3();
const _handHitNdc = new THREE.Vector3();
const _handDirection = new THREE.Vector3();
const _handHitDirection = new THREE.Vector3();
const _cameraPos = new THREE.Vector3();
const _objectPos = new THREE.Vector3();
const _snapPosition = new THREE.Vector3();
const _snapTargetWorldPosition = new THREE.Vector3();
const _handRaycaster = new THREE.Raycaster();
const HAND_GRAB_SCREEN_RADIUS = 0.04;
const HAND_HIT_OFFSETS: Array<[number, number]> = [
[0, 0],
[HAND_GRAB_SCREEN_RADIUS, 0],
[-HAND_GRAB_SCREEN_RADIUS, 0],
[0, HAND_GRAB_SCREEN_RADIUS],
[0, -HAND_GRAB_SCREEN_RADIUS],
];
function getHandCenterPoint(hand: HandTrackingHand): HandScreenPoint {
const landmarks = hand.landmarks;
if (landmarks.length === 0) {
return { x: hand.x, y: hand.y };
}
let minX = landmarks[0]!.x;
let maxX = landmarks[0]!.x;
let minY = landmarks[0]!.y;
let maxY = landmarks[0]!.y;
landmarks.forEach((landmark) => {
minX = Math.min(minX, landmark.x);
maxX = Math.max(maxX, landmark.x);
minY = Math.min(minY, landmark.y);
maxY = Math.max(maxY, landmark.y);
});
return {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
};
}
function getHandHit(
group: THREE.Group | null,
camera: THREE.Camera,
cameraPos: THREE.Vector3,
handCenter: HandScreenPoint,
): THREE.Intersection | null {
if (!group) return null;
const baseX = (1 - handCenter.x) * 2 - 1;
const baseY = -handCenter.y * 2 + 1;
for (const [offsetX, offsetY] of HAND_HIT_OFFSETS) {
_handHitNdc.set(baseX + offsetX, baseY + offsetY, 0.5);
_handHitNdc.unproject(camera);
_handHitDirection.subVectors(_handHitNdc, cameraPos).normalize();
_handRaycaster.set(cameraPos, _handHitDirection);
_handRaycaster.far = INTERACTION_RADIUS;
const hits = _handRaycaster.intersectObject(group, true);
if (hits?.length > 0) return hits[0] ?? null;
}
return null;
}
export function GrabbableObject({
position,
children,
colliders = GRAB_DEFAULT_COLLIDERS,
label = GRAB_DEFAULT_LABEL,
handControlled = false,
onPositionChange,
onSnap,
snapDuration = 0.25,
snapRadius = 0,
snapTargets = [],
}: GrabbableObjectProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const { hands } = useHandTrackingSnapshot();
const spaceRef = useRef<THREE.Group>(null);
const groupRef = useRef<THREE.Group>(null);
const rbRef = useRef<RapierRigidBody>(null);
const isHolding = useRef(false);
const isHandHolding = useRef(false);
const snapTween = useRef<gsap.core.Tween | null>(null);
useEffect(() => {
return () => {
snapTween.current?.kill();
};
}, []);
function snapToNearestTarget(): void {
const body = rbRef.current;
if (!body || snapTargets.length === 0 || snapRadius <= 0) return;
const translation = body.translation();
_currentPos.set(translation.x, translation.y, translation.z);
let nearestTarget: Vector3Tuple | null = null;
let nearestTargetWorld: Vector3Tuple | null = null;
let nearestDistance = snapRadius;
snapTargets.forEach((target) => {
_snapTargetWorldPosition.set(target[0], target[1], target[2]);
spaceRef.current?.localToWorld(_snapTargetWorldPosition);
const distance = _currentPos.distanceTo(_snapTargetWorldPosition);
if (distance <= nearestDistance) {
nearestDistance = distance;
nearestTarget = target;
nearestTargetWorld = [
_snapTargetWorldPosition.x,
_snapTargetWorldPosition.y,
_snapTargetWorldPosition.z,
];
}
});
if (!nearestTarget || !nearestTargetWorld) return;
snapTween.current?.kill();
const animatedPosition = {
x: _currentPos.x,
y: _currentPos.y,
z: _currentPos.z,
};
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
body.setAngvel(ZERO_ANGULAR_VELOCITY, true);
snapTween.current = gsap.to(animatedPosition, {
x: nearestTargetWorld[0],
y: nearestTargetWorld[1],
z: nearestTargetWorld[2],
duration: snapDuration,
ease: "power2.out",
onUpdate: () => {
body.setTranslation(animatedPosition, true);
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
},
onComplete: () => {
_snapPosition.set(
animatedPosition.x,
animatedPosition.y,
animatedPosition.z,
);
onSnap?.(_snapPosition);
},
});
}
useDebugFolder("GrabbableObject", (folder) => {
folder
.add(
grabDebugParams,
"stiffness",
GRAB_STIFFNESS_MIN,
GRAB_STIFFNESS_MAX,
GRAB_STIFFNESS_STEP,
)
.name("Hold stiffness");
folder
.add(
grabDebugParams,
"throwBoost",
GRAB_THROW_BOOST_MIN,
GRAB_THROW_BOOST_MAX,
GRAB_THROW_BOOST_STEP,
)
.name("Throw boost");
folder
.add(
grabDebugParams,
"holdDistance",
GRAB_HOLD_DISTANCE_MIN,
GRAB_HOLD_DISTANCE_MAX,
GRAB_HOLD_DISTANCE_STEP,
)
.name("Hold distance");
});
useFrame(() => {
if (!rbRef.current) return;
const fistHand = handControlled
? hands.find((hand) => hand.isFist)
: undefined;
const t = rbRef.current.translation();
_currentPos.set(t.x, t.y, t.z);
onPositionChange?.(_currentPos);
if (fistHand) {
const handCenter = getHandCenterPoint(fistHand);
_handNdc.set((1 - handCenter.x) * 2 - 1, -handCenter.y * 2 + 1, 0.5);
_handNdc.unproject(camera);
camera.getWorldPosition(_cameraPos);
_handDirection.subVectors(_handNdc, _cameraPos).normalize();
if (!isHandHolding.current) {
_objectPos.copy(_currentPos);
const isObjectInRange =
_cameraPos.distanceTo(_objectPos) <= INTERACTION_RADIUS;
const hit = isObjectInRange
? getHandHit(groupRef.current, camera, _cameraPos, handCenter)
: null;
isHandHolding.current = Boolean(hit);
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
}
} else {
if (isHandHolding.current) {
snapToNearestTarget();
}
isHandHolding.current = false;
InteractionManager.getInstance().setHandHolding(false);
}
if (!isHolding.current && !isHandHolding.current) return;
if (fistHand && isHandHolding.current) {
_holdTarget
.copy(_cameraPos)
.addScaledVector(_handDirection, grabDebugParams.holdDistance);
} else {
camera.getWorldDirection(_holdTarget);
_holdTarget
.multiplyScalar(grabDebugParams.holdDistance)
.add(camera.position);
}
_velocity
.subVectors(_holdTarget, _currentPos)
.multiplyScalar(grabDebugParams.stiffness);
rbRef.current.setLinvel(
{ x: _velocity.x, y: _velocity.y, z: _velocity.z },
true,
);
rbRef.current.setAngvel(ZERO_ANGULAR_VELOCITY, true);
});
return (
<group ref={spaceRef}>
<RigidBody
ref={rbRef}
type="dynamic"
colliders={colliders}
position={position}
>
<group ref={groupRef}>
<InteractableObject
kind="grab"
label={label}
position={position}
bodyRef={rbRef}
onPress={() => {
isHolding.current = true;
}}
onRelease={() => {
isHolding.current = false;
snapToNearestTarget();
if (
!rbRef.current ||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
)
return;
const v = rbRef.current.linvel();
rbRef.current.setLinvel(
{
x: v.x * grabDebugParams.throwBoost,
y: v.y * grabDebugParams.throwBoost,
z: v.z * grabDebugParams.throwBoost,
},
true,
);
}}
>
{children}
</InteractableObject>
</group>
</RigidBody>
</group>
);
}
@@ -0,0 +1,213 @@
import { useCallback, useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { RapierRigidBody } from "@react-three/rapier";
import * as THREE from "three";
import type GUI from "lil-gui";
import type { RefObject } from "react";
import {
INTERACTION_DEBUG_SPHERE_COLOR,
INTERACTION_DEBUG_SPHERE_OPACITY,
INTERACTION_DEBUG_SPHERE_SEGMENTS,
} from "@/data/debug/debugConfig";
import { Debug } from "@/utils/debug/Debug";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { InteractionManager } from "@/managers/InteractionManager";
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
import type { InteractableHandle } from "@/types/interaction/interaction";
import type { Vector3Tuple } from "@/types/three/three";
interface InteractableObjectBaseProps {
label: string;
position: Vector3Tuple;
radius?: number;
bodyRef?: RefObject<RapierRigidBody | null>;
onPress: () => void;
children: React.ReactNode;
}
interface TriggerInteractableObjectProps extends InteractableObjectBaseProps {
kind: "trigger";
}
interface GrabInteractableObjectProps extends InteractableObjectBaseProps {
kind: "grab";
onRelease: () => void;
}
type InteractableObjectProps =
| TriggerInteractableObjectProps
| GrabInteractableObjectProps;
const _cameraPos = new THREE.Vector3();
const _cameraDir = new THREE.Vector3();
const _objectPos = new THREE.Vector3();
const _raycaster = new THREE.Raycaster();
function createInteractableHandle(
props: InteractableObjectProps,
): InteractableHandle {
if (props.kind === "grab") {
return {
kind: props.kind,
label: props.label,
onPress: props.onPress,
onRelease: props.onRelease,
};
}
return {
kind: props.kind,
label: props.label,
onPress: props.onPress,
};
}
export function InteractableObject(
props: InteractableObjectProps,
): React.JSX.Element {
const {
kind,
label,
position,
radius = INTERACTION_RADIUS,
bodyRef,
onPress,
children,
} = props;
const onRelease = props.kind === "grab" ? props.onRelease : null;
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
const debugSphereRef = useRef<THREE.Mesh>(null);
const handle = useRef<InteractableHandle>(createInteractableHandle(props));
useEffect(() => {
const currentHandle = handle.current;
const manager = InteractionManager.getInstance();
if (currentHandle.kind === kind) {
currentHandle.label = label;
currentHandle.onPress = onPress;
if (currentHandle.kind === "grab") {
if (!onRelease) return;
currentHandle.onRelease = onRelease;
}
return;
}
manager.setNearby(currentHandle, false);
if (kind === "grab") {
if (!onRelease) return;
handle.current = { kind, label, onPress, onRelease };
} else {
handle.current = { kind, label, onPress };
}
if (manager.getState().focused === currentHandle) {
manager.setFocused(handle.current);
}
}, [kind, label, onPress, onRelease]);
useEffect(() => {
const currentHandle = handle.current;
return () => {
const manager = InteractionManager.getInstance();
manager.setNearby(currentHandle, false);
if (manager.getState().focused === currentHandle) {
manager.setFocused(null);
}
};
}, []);
const setupInteractionDebugFolder = useCallback((folder: GUI) => {
const debug = Debug.getInstance();
const controls = {
showInteractionSpheres: debug.getShowInteractionSpheres(),
};
folder
.add(controls, "showInteractionSpheres")
.name("Interaction Spheres")
.onChange((value: boolean) => {
debug.setShowInteractionSpheres(value);
});
folder
.add({ radius: INTERACTION_RADIUS }, "radius")
.name("Interaction radius")
.disable();
}, []);
useDebugFolder("Interaction", setupInteractionDebugFolder);
useFrame(() => {
const group = groupRef.current;
const debug = Debug.getInstance();
const manager = InteractionManager.getInstance();
if (debugSphereRef.current) {
debugSphereRef.current.visible =
debug.active && debug.getShowInteractionSpheres();
}
if (bodyRef?.current) {
const t = bodyRef.current.translation();
_objectPos.set(t.x, t.y, t.z);
} else if (group) {
group.getWorldPosition(_objectPos);
} else {
_objectPos.set(...position);
}
camera.getWorldPosition(_cameraPos);
const dist = _cameraPos.distanceTo(_objectPos);
const isNearby = dist <= radius;
manager.setNearby(handle.current, isNearby);
if (!isNearby) {
if (manager.getState().focused === handle.current) {
manager.setFocused(null);
}
return;
}
camera.getWorldDirection(_cameraDir);
_raycaster.set(_cameraPos, _cameraDir);
_raycaster.far = radius;
const hits = group ? _raycaster.intersectObject(group, true) : [];
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
if (validHit) {
manager.setFocused(handle.current);
} else if (manager.getState().focused === handle.current) {
manager.setFocused(null);
}
});
return (
<group ref={groupRef}>
{children}
<mesh ref={debugSphereRef} visible={false}>
<sphereGeometry
args={[
radius,
INTERACTION_DEBUG_SPHERE_SEGMENTS,
INTERACTION_DEBUG_SPHERE_SEGMENTS,
]}
/>
<meshBasicMaterial
color={INTERACTION_DEBUG_SPHERE_COLOR}
wireframe
transparent
opacity={INTERACTION_DEBUG_SPHERE_OPACITY}
/>
</mesh>
</group>
);
}
@@ -0,0 +1,118 @@
import { useRef, useState } from "react";
import { RigidBody } from "@react-three/rapier";
import type { RapierRigidBody } from "@react-three/rapier";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
import {
TRIGGER_DEFAULT_COLLIDERS,
TRIGGER_DEFAULT_LABEL,
TRIGGER_DEFAULT_SOUND_VOLUME,
TRIGGER_DEFAULT_SPAWN_OFFSET,
} from "@/data/interaction/triggerConfig";
import { AudioManager } from "@/managers/AudioManager";
import type { ColliderShape, Vector3Tuple } from "@/types/three/three";
interface SpawnedModel {
id: number;
position: Vector3Tuple;
}
interface TriggerObjectProps {
position: Vector3Tuple;
children: React.ReactNode;
colliders?: ColliderShape;
label?: string;
radius?: number;
soundPath?: string;
soundVolume?: number;
spawnModel?: string;
spawnOffset?: Vector3Tuple;
onTrigger?: () => void;
}
let spawnCounter = 0;
function SpawnedModelInstance({
path,
position,
}: {
path: string;
position: Vector3Tuple;
}): React.JSX.Element {
const { scene } = useLoggedGLTF(path, {
scope: "TriggerObject.SpawnedModel",
position,
});
const model = useClonedObject(scene);
return <primitive object={model} position={position} />;
}
export function TriggerObject({
position,
children,
colliders = TRIGGER_DEFAULT_COLLIDERS,
label = TRIGGER_DEFAULT_LABEL,
radius = INTERACTION_RADIUS,
soundPath,
soundVolume = TRIGGER_DEFAULT_SOUND_VOLUME,
spawnModel,
spawnOffset = TRIGGER_DEFAULT_SPAWN_OFFSET,
onTrigger,
}: TriggerObjectProps): React.JSX.Element {
const [spawned, setSpawned] = useState<SpawnedModel[]>([]);
const rbRef = useRef<RapierRigidBody>(null);
return (
<>
<RigidBody
ref={rbRef}
type="fixed"
colliders={colliders}
position={position}
>
<InteractableObject
kind="trigger"
label={label}
position={position}
radius={radius}
bodyRef={rbRef}
onPress={() => {
if (soundPath) {
AudioManager.getInstance().playSound(soundPath, soundVolume, {
category: "sfx",
});
}
onTrigger?.();
if (spawnModel) {
const spawnPos: Vector3Tuple = [
position[0] + spawnOffset[0],
position[1] + spawnOffset[1],
position[2] + spawnOffset[2],
];
setSpawned((prev) => [
...prev,
{ id: ++spawnCounter, position: spawnPos },
]);
}
}}
>
{children}
</InteractableObject>
</RigidBody>
{spawnModel &&
spawned.map((s) => (
<SpawnedModelInstance
key={s.id}
path={spawnModel}
position={s.position}
/>
))}
</>
);
}
@@ -0,0 +1,53 @@
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
import { Debug } from "@/utils/debug/Debug";
import type { Vector3Tuple } from "@/types/three/three";
interface VillageoisHelperObjectProps {
position: Vector3Tuple;
}
export function VillageoisHelperObject({
position,
}: VillageoisHelperObjectProps): React.JSX.Element {
const step = useMissionFlowStore((state) => state.step);
const setStep = useMissionFlowStore((state) => state.setStep);
const debug = Debug.getInstance();
const handlePress = (): void => {
console.log("[VillageoisHelper] handlePress called, current step:", step);
if (step === "searching") {
console.log("[VillageoisHelper] Transitioning to helped");
setStep("helped");
}
};
const shouldShow = step === "searching" || debug.active;
if (!shouldShow) {
return <></>;
}
console.log(
"[VillageoisHelper] Rendering, step:",
step,
"position:",
position,
);
return (
<InteractableObject
kind="trigger"
label="villageois_helper"
position={position}
onPress={handlePress}
>
<group position={position}>
<mesh>
<sphereGeometry args={[0.5, 16, 16]} />
<meshStandardMaterial color="cyan" />
</mesh>
</group>
</InteractableObject>
);
}
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAnimations } from "@react-three/drei";
import type { AnimationAction } from "three";
import * as THREE from "three";
import {
AnimatedModelContext,
type AnimatedModelContextValue,
} from "@/components/three/models/useAnimatedModel";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
export interface AnimatedModelConfig extends ModelTransformProps {
modelPath: string;
animations?: string[];
defaultAnimation?: string;
fadeDuration?: number;
speed?: number;
autoPlay?: boolean;
onLoaded?: () => void;
onAnimationEnd?: (animationName: string) => void;
}
interface AnimatedModelProps extends AnimatedModelConfig {
children?: React.ReactNode;
}
export function AnimatedModel({
modelPath,
defaultAnimation = "Idle",
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
fadeDuration = 0.3,
speed = 1,
autoPlay = true,
onLoaded,
onAnimationEnd,
children,
}: AnimatedModelProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { scene, animations } = useLoggedGLTF(modelPath, {
scope: "AnimatedModel",
position,
rotation,
scale,
});
const model = useMemo(() => scene.clone(true), [scene]);
const { actions, names, mixer } = useAnimations(animations, groupRef);
const [currentAnim, setCurrentAnim] = useState(defaultAnimation);
const isReady = names.length > 0;
useEffect(() => {
Object.values(actions).forEach((action) => {
action?.setEffectiveTimeScale(speed);
});
}, [actions, speed]);
useEffect(() => {
const handleFinished = (e: { action: AnimationAction }) => {
const clipName = e.action.getClip().name;
onAnimationEnd?.(clipName);
};
if (mixer) {
mixer.addEventListener("finished", handleFinished);
return () => {
mixer.removeEventListener("finished", handleFinished);
};
}
}, [mixer, onAnimationEnd]);
const play = useCallback(
(name: string, fade = fadeDuration) => {
const action = actions[name];
if (action) {
Object.values(actions).forEach((a) => {
if (a && a !== action) a.fadeOut(fade);
});
action.reset().fadeIn(fade).play();
setCurrentAnim(name);
}
},
[actions, fadeDuration],
);
const stop = useCallback(
(fade = fadeDuration) => {
Object.values(actions).forEach((a) => a?.fadeOut(fade));
const defaultAction = actions[defaultAnimation];
if (defaultAction) {
defaultAction.reset().fadeIn(fade).play();
setCurrentAnim(defaultAnimation);
}
},
[actions, defaultAnimation, fadeDuration],
);
const fadeTo = useCallback(
(name: string, fade = fadeDuration) => {
const action = actions[name];
if (action) {
Object.values(actions).forEach((a) => {
if (a && a !== action) a.fadeOut(fade);
});
action.reset().fadeIn(fade).play();
setCurrentAnim(name);
}
},
[actions, fadeDuration],
);
const setSpeed = useCallback(
(newSpeed: number) => {
Object.values(actions).forEach((action) => {
action?.setEffectiveTimeScale(newSpeed);
});
},
[actions],
);
useEffect(() => {
if (!autoPlay || names.length === 0) {
return;
}
let defaultAction = actions[defaultAnimation as string];
if (!defaultAction && names.length > 0) {
defaultAction = actions[names[0] as string];
}
if (defaultAction) {
defaultAction.play();
onLoaded?.();
}
}, [actions, defaultAnimation, names, autoPlay, onLoaded]);
const contextValue: AnimatedModelContextValue = {
play,
stop,
fadeTo,
currentAnimation: currentAnim,
isReady,
setSpeed,
names,
};
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
return (
<AnimatedModelContext.Provider value={contextValue}>
<group
ref={groupRef}
position={position}
rotation={rotation}
scale={parsedScale}
>
<primitive object={model} />
</group>
{children}
</AnimatedModelContext.Provider>
);
}
@@ -0,0 +1,144 @@
import type { ReactNode } from "react";
import { Component, useEffect, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { ExplodedModel } from "@/utils/three/ExplodedModel";
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import { toVector3Scale } from "@/utils/three/scale";
interface ModelErrorBoundaryProps {
children: ReactNode;
modelPath: string;
position?: Vector3Tuple | undefined;
rotation?: Vector3Tuple | undefined;
scale?: ModelTransformProps["scale"] | undefined;
}
interface ModelErrorBoundaryState {
hasError: boolean;
}
class ModelErrorBoundary extends Component<
ModelErrorBoundaryProps,
ModelErrorBoundaryState
> {
constructor(props: ModelErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): ModelErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error): void {
logModelLoadError(
{
modelPath: this.props.modelPath,
scope: "ExplodableModel",
position: this.props.position,
rotation: this.props.rotation,
scale: this.props.scale,
},
error,
);
}
render(): ReactNode {
if (this.state.hasError) {
return (
<MissingModelFallback
position={this.props.position}
rotation={this.props.rotation}
scale={this.props.scale}
/>
);
}
return this.props.children;
}
}
interface ExplodableModelInnerProps extends ModelTransformProps {
modelPath: string;
split: boolean;
splitDistance?: number;
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
}
export function ExplodableModel(
props: ExplodableModelInnerProps,
): React.JSX.Element {
return (
<ModelErrorBoundary
key={props.modelPath}
modelPath={props.modelPath}
position={props.position}
rotation={props.rotation}
scale={props.scale}
>
<ExplodableModelInner {...props} />
</ModelErrorBoundary>
);
}
function ExplodableModelInner({
modelPath,
split,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
splitDistance = 1.2,
onPartsReady,
}: ExplodableModelInnerProps): React.JSX.Element {
const { scene } = useLoggedGLTF(modelPath, {
scope: "ExplodableModel",
position,
rotation,
scale,
});
const model = useClonedObject(scene);
const explodedModel = useMemo(
() => new ExplodedModel(model, { distance: splitDistance }),
[model, splitDistance],
);
const parsedScale = toVector3Scale(scale);
useEffect(() => {
explodedModel.setSplit(split);
}, [explodedModel, split]);
useEffect(() => {
onPartsReady?.(explodedModel.getParts());
}, [explodedModel, onPartsReady]);
useFrame((_, delta) => {
explodedModel.update(delta);
});
return (
<group position={position} rotation={rotation} scale={parsedScale}>
<primitive object={model} />
</group>
);
}
function MissingModelFallback({
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
}: {
position?: Vector3Tuple | undefined;
rotation?: Vector3Tuple | undefined;
scale?: ModelTransformProps["scale"] | undefined;
}): React.JSX.Element {
return (
<mesh position={position} rotation={rotation} scale={toVector3Scale(scale)}>
<boxGeometry args={[0.7, 0.7, 0.7]} />
<meshStandardMaterial color="#7f1d1d" wireframe />
</mesh>
);
}
@@ -0,0 +1,46 @@
import { useMemo } from "react";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
export interface SimpleModelConfig extends ModelTransformProps {
modelPath: string;
castShadow?: boolean;
receiveShadow?: boolean;
}
interface SimpleModelProps extends SimpleModelConfig {
children?: React.ReactNode;
}
export function SimpleModel({
modelPath,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
castShadow = true,
receiveShadow = true,
children,
}: SimpleModelProps): React.JSX.Element {
const { scene } = useLoggedGLTF(modelPath, {
scope: "SimpleModel",
position,
rotation,
scale,
});
const model = useMemo(() => scene.clone(true), [scene]);
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
return (
<group position={position} rotation={rotation} scale={parsedScale}>
{children ?? (
<primitive
object={model}
castShadow={castShadow}
receiveShadow={receiveShadow}
/>
)}
</group>
);
}
@@ -0,0 +1,14 @@
import { createContext } from "react";
export interface AnimatedModelContextValue {
play: (name: string, fade?: number) => void;
stop: (fade?: number) => void;
fadeTo: (name: string, fade?: number) => void;
currentAnimation: string;
isReady: boolean;
setSpeed: (speed: number) => void;
names: string[];
}
export const AnimatedModelContext =
createContext<AnimatedModelContextValue | null>(null);
@@ -0,0 +1,42 @@
import { Html } from "@react-three/drei";
import type { Vector3Tuple } from "@/types/three/three";
interface WorldVideoPromptProps {
src: string;
position?: Vector3Tuple;
size?: number;
billboard?: boolean;
}
export function WorldVideoPrompt({
src,
position = [0, 0, 0],
size = 96,
billboard = true,
}: WorldVideoPromptProps): React.JSX.Element {
return (
<Html
position={position}
center
transform
sprite={billboard}
occlude={false}
>
<video
aria-hidden="true"
autoPlay
loop
muted
playsInline
src={src}
style={{
display: "block",
height: size,
objectFit: "contain",
pointerEvents: "none",
width: size,
}}
/>
</Html>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { useRef } from "react";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
interface SkyModelProps {
modelPath: string;
}
const SKY_MODEL_SCALE = 1;
export function SkyModel({ modelPath }: SkyModelProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
const { scene } = useLoggedGLTF(modelPath, {
scope: "SkyModel",
scale: SKY_MODEL_SCALE,
});
const model = useClonedObject(scene);
useFrame(() => {
groupRef.current?.position.copy(camera.position);
});
return (
<group ref={groupRef} scale={SKY_MODEL_SCALE} frustumCulled={false}>
<primitive object={model} />
</group>
);
}
useGLTF.preload("/models/sky/model.glb");