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(null); const localPosition = useRef(new THREE.Vector3()); const [placedPartIds, setPlacedPartIds] = useState>( {}, ); const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState< Record >({}); 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 ( setShowBlockedInstallFeedback(true)} onRepair={onRepair} /> {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 ( { handleReplacementPosition(part.id, position); }} onSnap={() => { handleReplacementSnap(part.id); }} snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION} snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS} snapTargets={placeholderPositions} > ); })} {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 ( { handleBrokenPartPosition(part.id, position, targetPositions); }} onSnap={() => { handleBrokenPartSnap(part.id); }} snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION} snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS} snapTargets={targetPositions} > ); })} {isReadyToInstall ? ( ) : null} ); } function RepairInstallTarget({ blockedFeedback, fillColor, isReadyToInstall, label, ringColor, onBlocked, onRepair, }: RepairInstallTargetProps): React.JSX.Element { return ( { if (!isReadyToInstall) { onBlocked(); return; } onRepair(); }} > {blockedFeedback ? ( ) : null} ); } function RepairPlaceholderMarkers({ positions, }: RepairPlaceholderMarkersProps): React.JSX.Element { return ( <> {positions.map((position, index) => ( ))} ); } function RepairPartPlacementFeedback({ state, }: RepairPartPlacementFeedbackProps): React.JSX.Element | null { if (!state) return null; const color = getPlacementFeedbackColor(state); return ( ); } function getPlacementFeedbackColor( state: NonNullable, ): 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 } : {}), })); }