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
🔍 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:
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user