Files
La-Fabrik/src/components/three/gameplay/RepairRepairingStep.tsx
T
2026-05-11 16:41:11 +02:00

481 lines
14 KiB
TypeScript

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 } : {}),
}));
}