merge: sync develop into env manager
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled

This commit is contained in:
Tom Boullay
2026-05-12 10:56:56 +02:00
171 changed files with 4560 additions and 1507 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>
);
}
@@ -8,20 +8,39 @@ import {
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE,
REPAIR_CASE_FLOAT_DOWN_SPEED,
REPAIR_CASE_FLOAT_HEIGHT,
REPAIR_CASE_EXIT_DURATION,
REPAIR_CASE_EXIT_Y_OFFSET,
REPAIR_CASE_FLOAT_UP_SPEED,
REPAIR_CASE_LID_NODE_NAME,
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
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 type { ModelTransformProps } from "@/types/three/three";
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(
@@ -37,6 +56,10 @@ const ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(
export function RepairCaseModel({
modelPath,
open,
exiting = false,
floating = true,
onPlaceholdersChange,
onExitComplete,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
@@ -55,22 +78,80 @@ export function RepairCaseModel({
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 =
@@ -100,14 +181,26 @@ export function RepairCaseModel({
};
}, [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;
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
const targetHeight = isNear ? REPAIR_CASE_FLOAT_HEIGHT : 0;
const floatSpeed = isNear
? REPAIR_CASE_FLOAT_UP_SPEED
@@ -119,7 +212,43 @@ export function RepairCaseModel({
floatSpeed,
delta,
);
group.position.y = position[1] + floatHeight.current;
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;
@@ -158,12 +287,7 @@ export function RepairCaseModel({
});
return (
<group
ref={groupRef}
position={position}
rotation={rotation}
scale={parsedScale}
>
<group ref={groupRef} position={position} rotation={rotation} scale={0.001}>
<primitive object={model} />
</group>
);
@@ -1,109 +0,0 @@
import type { ReactNode } from "react";
import { Component } from "react";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { RepairCaseModel } from "@/components/three/gameplay/RepairCaseModel";
import {
REPAIR_CASE_MODEL_PATH,
REPAIR_CASE_OPEN_SOUND_PATH,
} from "@/data/gameplay/repairCaseConfig";
import { AudioManager } from "@/managers/AudioManager";
import type { Vector3Tuple } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
const REPAIR_CASE_PAN_RANGE = 20;
interface RepairCaseErrorBoundaryProps {
children: ReactNode;
}
interface RepairCaseErrorBoundaryState {
hasError: boolean;
}
class RepairCaseErrorBoundary extends Component<
RepairCaseErrorBoundaryProps,
RepairCaseErrorBoundaryState
> {
constructor(props: RepairCaseErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): RepairCaseErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error): void {
logModelLoadError(
{
modelPath: REPAIR_CASE_MODEL_PATH,
scope: "RepairCaseObject",
position: [0, -0.45, 0],
scale: 1.5,
},
error,
);
}
render(): ReactNode {
if (this.state.hasError) {
return <RepairCaseFallback />;
}
return this.props.children;
}
}
interface RepairCaseObjectProps {
position: Vector3Tuple;
open: boolean;
onInspect: () => void;
}
export function RepairCaseObject({
position,
open,
onInspect,
}: RepairCaseObjectProps): React.JSX.Element {
const pan = Math.max(-1, Math.min(1, position[0] / REPAIR_CASE_PAN_RANGE));
return (
<TriggerObject
position={position}
colliders="cuboid"
label={open ? "Mallette inspectée" : "Inspecter la mallette"}
onTrigger={() => {
if (open) return;
AudioManager.getInstance().playSound(REPAIR_CASE_OPEN_SOUND_PATH, 1, {
category: "sfx",
pan,
});
onInspect();
}}
>
<RepairCaseErrorBoundary>
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
open={open}
position={[0, -0.45, 0]}
scale={1.5}
/>
</RepairCaseErrorBoundary>
</TriggerObject>
);
}
function RepairCaseFallback(): React.JSX.Element {
return (
<group position={[0, -0.25, 0]}>
<mesh castShadow receiveShadow>
<boxGeometry args={[1.5, 0.5, 1]} />
<meshStandardMaterial color="#2563eb" roughness={0.55} />
</mesh>
<mesh position={[0, 0.35, -0.25]} castShadow receiveShadow>
<boxGeometry args={[1.5, 0.12, 0.65]} />
<meshStandardMaterial color="#1d4ed8" roughness={0.55} />
</mesh>
</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 ?? []),
]),
];
}
@@ -1,124 +0,0 @@
import { Text } from "@react-three/drei";
import { RepairCaseObject } from "@/components/three/gameplay/RepairCaseObject";
import { RepairModuleSlot } from "@/components/three/gameplay/RepairModuleSlot";
import {
REPAIR_GAME_MODULE_SLOTS,
REPAIR_GAME_ZONE_LABEL,
REPAIR_GAME_ZONE_ORIGIN,
REPAIR_GAME_ZONE_RADIUS,
} from "@/data/gameplay/repairGameConfig";
import { useGameStore } from "@/managers/stores/useGameStore";
import { playGameplayDialogueById } from "@/utils/dialogues/playDialogue";
const CASE_CLOSED_STEPS = new Set(["locked", "waiting"]);
export function RepairGameZone(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
const bikeStep = useGameStore((state) => state.bike.currentStep);
const setMainState = useGameStore((state) => state.setMainState);
const setBikeState = useGameStore((state) => state.setBikeState);
const caseOpen = !CASE_CLOSED_STEPS.has(bikeStep);
const slotsDisabled = !caseOpen;
const inspectRepairCase = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (CASE_CLOSED_STEPS.has(bikeStep)) {
setBikeState({ currentStep: "inspected" });
void playGameplayDialogueById("narrateur_ebikecasse");
}
};
const markModelSelected = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (bikeStep === "inspected") {
setBikeState({ currentStep: "fragmented" });
}
};
const markModuleSplit = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (bikeStep === "fragmented") {
setBikeState({ currentStep: "scanning" });
void playGameplayDialogueById("narrateur_galetscan");
}
};
return (
<group>
<mesh
position={[
REPAIR_GAME_ZONE_ORIGIN[0],
0.025,
REPAIR_GAME_ZONE_ORIGIN[2],
]}
rotation={[-Math.PI / 2, 0, 0]}
>
<ringGeometry
args={[REPAIR_GAME_ZONE_RADIUS - 0.08, REPAIR_GAME_ZONE_RADIUS, 96]}
/>
<meshBasicMaterial color="#38bdf8" transparent opacity={0.72} />
</mesh>
<mesh
position={[
REPAIR_GAME_ZONE_ORIGIN[0],
0.02,
REPAIR_GAME_ZONE_ORIGIN[2],
]}
rotation={[-Math.PI / 2, 0, 0]}
>
<circleGeometry args={[REPAIR_GAME_ZONE_RADIUS, 96]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
</mesh>
<Text
position={[
REPAIR_GAME_ZONE_ORIGIN[0],
3.1,
REPAIR_GAME_ZONE_ORIGIN[2] - 1.8,
]}
rotation={[0, 0, 0]}
fontSize={0.55}
maxWidth={5.5}
textAlign="center"
anchorX="center"
anchorY="middle"
color="#f8fafc"
outlineWidth={0.025}
outlineColor="#0f172a"
>
{REPAIR_GAME_ZONE_LABEL}
</Text>
<RepairCaseObject
position={REPAIR_GAME_ZONE_ORIGIN}
open={caseOpen}
onInspect={inspectRepairCase}
/>
{REPAIR_GAME_MODULE_SLOTS.map((slot) => (
<RepairModuleSlot
key={slot.label}
label={slot.label}
position={[
REPAIR_GAME_ZONE_ORIGIN[0] + slot.offset[0],
REPAIR_GAME_ZONE_ORIGIN[1] + slot.offset[1],
REPAIR_GAME_ZONE_ORIGIN[2] + slot.offset[2],
]}
disabled={slotsDisabled}
onModelSelected={markModelSelected}
onSplit={markModuleSplit}
/>
))}
</group>
);
}
@@ -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>
);
}
@@ -1,113 +0,0 @@
import { Html } from "@react-three/drei";
import { useCallback, useState } from "react";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { REPAIR_GAME_MODEL_CATALOG } from "@/data/gameplay/repairGameModelCatalog";
import type { ModelCatalogItem } from "@/data/gameplay/repairGameModelCatalog";
import { useModelSelection } from "@/hooks/gameplay/useModelSelection";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairModuleSlotProps {
position: Vector3Tuple;
label: string;
disabled?: boolean;
onModelSelected?: () => void;
onSplit?: () => void;
}
export function RepairModuleSlot({
position,
label,
disabled = false,
onModelSelected,
onSplit,
}: RepairModuleSlotProps): React.JSX.Element {
const [selectedModel, setSelectedModel] = useState<ModelCatalogItem | null>(
null,
);
const [split, setSplit] = useState(false);
const handleSelect = useCallback(
(model: ModelCatalogItem) => {
setSelectedModel(model);
setSplit(false);
onModelSelected?.();
},
[onModelSelected],
);
const selection = useModelSelection(REPAIR_GAME_MODEL_CATALOG, handleSelect);
const triggerLabel = disabled
? "Ouvrir la mallette d'abord"
: selectedModel
? split
? `Réassembler ${label}`
: `Démonter ${label}`
: `Choisir ${label}`;
return (
<group>
<TriggerObject
position={position}
colliders="cuboid"
label={triggerLabel}
onTrigger={() => {
if (disabled) return;
if (selectedModel) {
setSplit((value) => {
const nextSplit = !value;
if (nextSplit) {
onSplit?.();
}
return nextSplit;
});
return;
}
selection.open();
}}
>
{selectedModel ? (
<ExplodableModel
modelPath={selectedModel.path}
split={split}
position={[0, -0.35, 0]}
scale={0.45}
/>
) : (
<mesh castShadow receiveShadow>
<boxGeometry args={[1, 0.18, 1]} />
<meshStandardMaterial
color="#38bdf8"
emissive="#082f49"
roughness={0.55}
/>
</mesh>
)}
</TriggerObject>
{selection.isOpen ? (
<Html position={[position[0], position[1] + 1.2, position[2]]} center>
<div className="model-selector-panel">
<strong>{label}</strong>
<span>Fleches: choisir</span>
<span>E/Enter: valider</span>
<ul>
{REPAIR_GAME_MODEL_CATALOG.map((model, index) => (
<li
key={model.path}
className={
index === selection.selectedIndex
? "is-selected"
: undefined
}
>
{model.name}
</li>
))}
</ul>
</div>
</Html>
) : 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>
);
}
@@ -32,7 +32,6 @@ const GLOVE_CONFIGS: Record<
const GLOVE_MODEL_SCALE = 0.33;
const HAND_SPACE_DISTANCE = 0.5;
const HAND_DEPTH_SCALE = 0.5;
const HAND_TRACKING_HIDE_DELAY_MS = 250;
const FINGER_LANDMARK_CHAINS = [
@@ -126,12 +125,7 @@ function landmarkToWorldPoint(
target.unproject(camera);
_direction.copy(target).sub(_cameraPosition).normalize();
target
.copy(_cameraPosition)
.addScaledVector(
_direction,
HAND_SPACE_DISTANCE - landmark.z * HAND_DEPTH_SCALE,
);
target.copy(_cameraPosition).addScaledVector(_direction, HAND_SPACE_DISTANCE);
return target;
}
@@ -1,7 +1,8 @@
import { useRef } from "react";
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 {
@@ -24,10 +25,7 @@ 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,
HandTrackingLandmark,
} from "@/types/handTracking/handTracking";
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
import type { ColliderShape, Vector3Tuple } from "@/types/three/three";
interface GrabbableObjectProps {
@@ -36,6 +34,16 @@ interface GrabbableObjectProps {
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 = {
@@ -55,10 +63,11 @@ 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_DEPTH_SENSITIVITY = 4;
const HAND_HIT_OFFSETS: Array<[number, number]> = [
[0, 0],
[HAND_GRAB_SCREEN_RADIUS, 0],
@@ -67,10 +76,10 @@ const HAND_HIT_OFFSETS: Array<[number, number]> = [
[0, -HAND_GRAB_SCREEN_RADIUS],
];
function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark {
const landmarks = hand.landmarks ?? [];
function getHandCenterPoint(hand: HandTrackingHand): HandScreenPoint {
const landmarks = hand.landmarks;
if (landmarks.length === 0) {
return { x: hand.x, y: hand.y, z: hand.z };
return { x: hand.x, y: hand.y };
}
let minX = landmarks[0]!.x;
@@ -88,7 +97,6 @@ function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark {
return {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
z: hand.z,
};
}
@@ -96,7 +104,7 @@ function getHandHit(
group: THREE.Group | null,
camera: THREE.Camera,
cameraPos: THREE.Vector3,
handCenter: HandTrackingLandmark,
handCenter: HandScreenPoint,
): THREE.Intersection | null {
if (!group) return null;
@@ -123,15 +131,83 @@ export function GrabbableObject({
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 handHoldDistance = useRef<number | null>(null);
const handHoldStartZ = useRef<number | null>(null);
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
@@ -172,6 +248,7 @@ export function GrabbableObject({
const t = rbRef.current.translation();
_currentPos.set(t.x, t.y, t.z);
onPositionChange?.(_currentPos);
if (fistHand) {
const handCenter = getHandCenterPoint(fistHand);
@@ -191,34 +268,22 @@ export function GrabbableObject({
: null;
isHandHolding.current = Boolean(hit);
handHoldDistance.current = hit ? GRAB_HOLD_DISTANCE_DEFAULT : null;
handHoldStartZ.current = hit ? fistHand.z : null;
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
}
} else {
if (isHandHolding.current) {
snapToNearestTarget();
}
isHandHolding.current = false;
handHoldDistance.current = null;
handHoldStartZ.current = null;
InteractionManager.getInstance().setHandHolding(false);
}
if (!isHolding.current && !isHandHolding.current) return;
if (fistHand && isHandHolding.current) {
const depthOffset =
handHoldStartZ.current === null
? 0
: (fistHand.z - handHoldStartZ.current) * HAND_DEPTH_SENSITIVITY;
const holdDistance = THREE.MathUtils.clamp(
(handHoldDistance.current ?? grabDebugParams.holdDistance) +
depthOffset,
GRAB_HOLD_DISTANCE_MIN,
GRAB_HOLD_DISTANCE_MAX,
);
_holdTarget
.copy(_cameraPos)
.addScaledVector(_handDirection, holdDistance);
.addScaledVector(_handDirection, grabDebugParams.holdDistance);
} else {
camera.getWorldDirection(_holdTarget);
_holdTarget
@@ -238,42 +303,45 @@ export function GrabbableObject({
});
return (
<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;
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 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>
);
}
@@ -19,6 +19,7 @@ import type { Vector3Tuple } from "@/types/three/three";
interface InteractableObjectBaseProps {
label: string;
position: Vector3Tuple;
radius?: number;
bodyRef?: RefObject<RapierRigidBody | null>;
onPress: () => void;
children: React.ReactNode;
@@ -64,7 +65,15 @@ function createInteractableHandle(
export function InteractableObject(
props: InteractableObjectProps,
): React.JSX.Element {
const { kind, label, position, bodyRef, onPress, children } = props;
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);
@@ -148,13 +157,15 @@ export function InteractableObject(
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 <= INTERACTION_RADIUS;
const isNearby = dist <= radius;
manager.setNearby(handle.current, isNearby);
@@ -167,7 +178,7 @@ export function InteractableObject(
camera.getWorldDirection(_cameraDir);
_raycaster.set(_cameraPos, _cameraDir);
_raycaster.far = INTERACTION_RADIUS;
_raycaster.far = radius;
const hits = group ? _raycaster.intersectObject(group, true) : [];
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
@@ -185,7 +196,7 @@ export function InteractableObject(
<mesh ref={debugSphereRef} visible={false}>
<sphereGeometry
args={[
INTERACTION_RADIUS,
radius,
INTERACTION_DEBUG_SPHERE_SEGMENTS,
INTERACTION_DEBUG_SPHERE_SEGMENTS,
]}
@@ -1,8 +1,10 @@
import { useState } from "react";
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,
@@ -22,6 +24,7 @@ interface TriggerObjectProps {
children: React.ReactNode;
colliders?: ColliderShape;
label?: string;
radius?: number;
soundPath?: string;
soundVolume?: number;
spawnModel?: string;
@@ -52,6 +55,7 @@ export function TriggerObject({
children,
colliders = TRIGGER_DEFAULT_COLLIDERS,
label = TRIGGER_DEFAULT_LABEL,
radius = INTERACTION_RADIUS,
soundPath,
soundVolume = TRIGGER_DEFAULT_SOUND_VOLUME,
spawnModel,
@@ -59,14 +63,22 @@ export function TriggerObject({
onTrigger,
}: TriggerObjectProps): React.JSX.Element {
const [spawned, setSpawned] = useState<SpawnedModel[]>([]);
const rbRef = useRef<RapierRigidBody>(null);
return (
<>
<RigidBody type="fixed" colliders={colliders} position={position}>
<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, {
+17 -20
View File
@@ -1,21 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, 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 { Vector3Tuple } from "@/types/three/three";
import type { ModelTransformProps } from "@/types/three/three";
export interface AnimatedModelConfig {
export interface AnimatedModelConfig extends ModelTransformProps {
modelPath: string;
animations?: string[];
defaultAnimation?: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple | number;
fadeDuration?: number;
speed?: number;
autoPlay?: boolean;
@@ -40,15 +36,13 @@ export function AnimatedModel({
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 { actions, names, mixer } = useAnimations(animations, scene);
const [currentAnim, setCurrentAnim] = useState(defaultAnimation);
const isReady = names.length > 0;
@@ -149,19 +143,22 @@ export function AnimatedModel({
names,
};
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
useEffect(() => {
scene.position.set(...position);
scene.rotation.set(rotation[0], rotation[1], rotation[2]);
const parsedScale =
typeof scale === "number" ? [scale, scale, scale] : (scale ?? [1, 1, 1]);
scene.scale.set(
parsedScale[0] ?? 1,
parsedScale[1] ?? 1,
parsedScale[2] ?? 1,
);
}, [scene, position, rotation, scale]);
return (
<AnimatedModelContext.Provider value={contextValue}>
<group
ref={groupRef}
position={position}
rotation={rotation}
scale={parsedScale}
>
<primitive object={model} />
</group>
<primitive object={scene} />
{children}
</AnimatedModelContext.Provider>
);
@@ -4,6 +4,7 @@ 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";
@@ -12,6 +13,8 @@ interface ModelErrorBoundaryProps {
children: ReactNode;
modelPath: string;
position?: Vector3Tuple | undefined;
rotation?: Vector3Tuple | undefined;
scale?: ModelTransformProps["scale"] | undefined;
}
interface ModelErrorBoundaryState {
@@ -37,6 +40,8 @@ class ModelErrorBoundary extends Component<
modelPath: this.props.modelPath,
scope: "ExplodableModel",
position: this.props.position,
rotation: this.props.rotation,
scale: this.props.scale,
},
error,
);
@@ -44,7 +49,13 @@ class ModelErrorBoundary extends Component<
render(): ReactNode {
if (this.state.hasError) {
return <MissingModelFallback position={this.props.position} />;
return (
<MissingModelFallback
position={this.props.position}
rotation={this.props.rotation}
scale={this.props.scale}
/>
);
}
return this.props.children;
@@ -55,6 +66,7 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
modelPath: string;
split: boolean;
splitDistance?: number;
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
}
export function ExplodableModel(
@@ -65,6 +77,8 @@ export function ExplodableModel(
key={props.modelPath}
modelPath={props.modelPath}
position={props.position}
rotation={props.rotation}
scale={props.scale}
>
<ExplodableModelInner {...props} />
</ModelErrorBoundary>
@@ -78,6 +92,7 @@ function ExplodableModelInner({
rotation = [0, 0, 0],
scale = 1,
splitDistance = 1.2,
onPartsReady,
}: ExplodableModelInnerProps): React.JSX.Element {
const { scene } = useLoggedGLTF(modelPath, {
scope: "ExplodableModel",
@@ -96,6 +111,10 @@ function ExplodableModelInner({
explodedModel.setSplit(split);
}, [explodedModel, split]);
useEffect(() => {
onPartsReady?.(explodedModel.getParts());
}, [explodedModel, onPartsReady]);
useFrame((_, delta) => {
explodedModel.update(delta);
});
@@ -109,11 +128,15 @@ function ExplodableModelInner({
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}>
<mesh position={position} rotation={rotation} scale={toVector3Scale(scale)}>
<boxGeometry args={[0.7, 0.7, 0.7]} />
<meshStandardMaterial color="#7f1d1d" wireframe />
</mesh>
+2 -5
View File
@@ -1,12 +1,9 @@
import { useMemo } from "react";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { Vector3Tuple } from "@/types/three/three";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
export interface SimpleModelConfig {
export interface SimpleModelConfig extends ModelTransformProps {
modelPath: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple | number;
castShadow?: boolean;
receiveShadow?: boolean;
}
@@ -1,4 +1,4 @@
import { createContext, useContext } from "react";
import { createContext } from "react";
export interface AnimatedModelContextValue {
play: (name: string, fade?: number) => void;
@@ -12,12 +12,3 @@ export interface AnimatedModelContextValue {
export const AnimatedModelContext =
createContext<AnimatedModelContextValue | null>(null);
export function useAnimatedModel(): AnimatedModelContextValue {
const context = useContext(AnimatedModelContext);
if (!context) {
throw new Error("useAnimatedModel must be used inside AnimatedModel");
}
return context;
}
@@ -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>
);
}
+2
View File
@@ -3,6 +3,7 @@ import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
import { Subtitles } from "@/components/ui/Subtitles";
export function GameUI(): React.JSX.Element {
@@ -10,6 +11,7 @@ export function GameUI(): React.JSX.Element {
<>
<DebugOverlayLayout />
<Crosshair />
<RepairMovementLockIndicator />
<InteractPrompt />
<HandTrackingVisualizer />
<Subtitles />
+1 -1
View File
@@ -47,7 +47,7 @@ export function HandTrackingVisualizer(): React.JSX.Element | null {
return (
<svg className="hand-tracking-visualizer" aria-hidden="true">
{hands.map((hand, handIndex) => {
const landmarks = hand.landmarks ?? [];
const landmarks = hand.landmarks;
if (landmarks.length === 0) return null;
const color = hand.isFist ? "#facc15" : "#38bdf8";
@@ -0,0 +1,20 @@
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
export function RepairMovementLockIndicator(): React.JSX.Element | null {
const cameraMode = useCameraMode();
const movementLocked = useRepairMovementLocked();
if (cameraMode !== "player") return null;
if (!movementLocked) return null;
return (
<div className="repair-movement-lock-indicator" aria-live="polite">
<span
className="repair-movement-lock-indicator__dot"
aria-hidden="true"
/>
<span>Déplacement verrouillé pendant la réparation</span>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { SceneLoadingState } from "@/types/world/sceneLoading";
interface SceneLoadingOverlayProps {
state: SceneLoadingState;
}
export function SceneLoadingOverlay({
state,
}: SceneLoadingOverlayProps): React.JSX.Element | null {
const isReady = state.status === "ready";
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
return (
<div
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
aria-live="polite"
>
<div className="scene-loading-overlay__content">
<strong>{state.currentStep}</strong>
<div className="scene-loading-overlay__track">
<span style={{ width: `${progress}%` }} />
<em>{progress}%</em>
</div>
</div>
</div>
);
}
+32 -16
View File
@@ -1,9 +1,9 @@
import { RotateCcw, StepBack, StepForward } from "lucide-react";
import {
type MainGameState,
type MissionStep,
useGameStore,
} from "@/managers/stores/useGameStore";
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
const MAIN_STATES: MainGameState[] = [
"intro",
@@ -13,16 +13,6 @@ const MAIN_STATES: MainGameState[] = [
"outro",
];
const MISSION_STEPS: MissionStep[] = [
"locked",
"waiting",
"inspected",
"fragmented",
"scanning",
"repairing",
"done",
];
function toPascalCase(value: string): string {
return value
.split(/[-_\s]+/)
@@ -33,6 +23,9 @@ function toPascalCase(value: string): string {
export function GameStateDebugPanel(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
const bikeStep = useGameStore((state) => state.bike.currentStep);
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
const fermeStep = useGameStore((state) => state.ferme.currentStep);
const detail = useGameStore((state) => {
switch (state.mainState) {
case "intro":
@@ -70,22 +63,45 @@ export function GameStateDebugPanel(): React.JSX.Element {
return;
}
if (mainState === "outro") {
setOutroState({ hasStarted: nextSubState === "started" });
return;
}
if (!isMissionStep(nextSubState)) return;
if (mainState === "bike") {
setBikeState({ currentStep: nextSubState as MissionStep });
setBikeState({ currentStep: nextSubState });
return;
}
if (mainState === "pylone") {
setPyloneState({ currentStep: nextSubState as MissionStep });
setPyloneState({ currentStep: nextSubState });
return;
}
if (mainState === "ferme") {
setFermeState({ currentStep: nextSubState as MissionStep });
setFermeState({ currentStep: nextSubState });
return;
}
}
function setDebugMainState(nextMainState: MainGameState): void {
setMainState(nextMainState);
if (nextMainState === "bike" && bikeStep === "locked") {
setBikeState({ currentStep: "waiting" });
return;
}
setOutroState({ hasStarted: nextSubState === "started" });
if (nextMainState === "pylone" && pyloneStep === "locked") {
setPyloneState({ currentStep: "waiting" });
return;
}
if (nextMainState === "ferme" && fermeStep === "locked") {
setFermeState({ currentStep: "waiting" });
}
}
return (
@@ -113,7 +129,7 @@ export function GameStateDebugPanel(): React.JSX.Element {
aria-pressed={state === mainState}
className={state === mainState ? "is-active" : undefined}
type="button"
onClick={() => setMainState(state)}
onClick={() => setDebugMainState(state)}
>
{toPascalCase(state)}
</button>