diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index febbaca..c6bc9df 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -1,6 +1,7 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { useGLTF } from "@react-three/drei"; import { ExplodableModel } from "@/components/three/models/ExplodableModel"; +import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel"; import type { RepairCasePartAnchors, RepairCasePlaceholder, @@ -67,12 +68,14 @@ export function RepairGame({ readonly RepairCasePlaceholder[] >([]); const [caseAnchors, setCaseAnchors] = useState({}); + const [brokenAnchors, setBrokenAnchors] = useState({}); const [scannedBrokenParts, setScannedBrokenParts] = useState< readonly RepairScannedBrokenPart[] >([]); const parsedScale = toVector3Scale(scale); const snappedPosition = useTerrainSnappedPosition(position); const readyForFragmentation = step === "inspected"; + const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]); useRepairFragmentationInput({ enabled: mainState === mission && readyForFragmentation, @@ -86,6 +89,7 @@ export function RepairGame({ const timeoutId = window.setTimeout(() => { setCasePlaceholders([]); setCaseAnchors({}); + setBrokenAnchors({}); setScannedBrokenParts([]); }, 0); @@ -141,13 +145,24 @@ export function RepairGame({ /> ) : null} {step === "repairing" ? ( - setMissionStep(mission, "reassembling")} - /> + <> + + setMissionStep(mission, "reassembling")} + /> + ) : null} {step === "reassembling" ? ( (); + config.brokenParts.forEach((part) => { + if (part.targetNodeName) names.add(part.targetNodeName); + else if (part.nodeName) names.add(part.nodeName); + }); + config.replacementParts.forEach((part) => { + if (part.targetNodeName) names.add(part.targetNodeName); + }); + return Array.from(names); +} diff --git a/src/components/three/gameplay/RepairRepairingStep.tsx b/src/components/three/gameplay/RepairRepairingStep.tsx index 471f71d..ca42e25 100644 --- a/src/components/three/gameplay/RepairRepairingStep.tsx +++ b/src/components/three/gameplay/RepairRepairingStep.tsx @@ -4,6 +4,7 @@ import type { RepairCasePartAnchors, RepairCasePlaceholder, } from "@/components/three/gameplay/RepairCaseModel"; +import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel"; import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel"; import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; @@ -42,6 +43,7 @@ let hasWarnedMissingPlaceholders = false; interface RepairRepairingStepProps { anchors?: RepairCasePartAnchors; + brokenAnchors?: ExplodedNodeAnchors; brokenParts: readonly RepairScannedBrokenPart[]; config: RepairMissionConfig; placeholders: readonly RepairCasePlaceholder[]; @@ -68,6 +70,7 @@ interface RepairPartPlacementFeedbackProps { export function RepairRepairingStep({ anchors = {}, + brokenAnchors = {}, brokenParts, config, placeholders, @@ -272,14 +275,18 @@ export function RepairRepairingStep({ })} {brokenPartsToDeposit.map((part, index) => { - const startOffset = + const fallbackOffset = 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 fallbackPosition: Vector3Tuple = [ + REPAIR_CASE_FOCUS_POSITION[0] + fallbackOffset[0], + REPAIR_CASE_FOCUS_POSITION[1] + fallbackOffset[1], + REPAIR_CASE_FOCUS_POSITION[2] + fallbackOffset[2], ]; + const anchorPosition = part.targetNodeName + ? brokenAnchors[part.targetNodeName] + : undefined; + const startPosition: Vector3Tuple = anchorPosition ?? fallbackPosition; const targetPositions = getBrokenPartTargetPositions( part, placeholderTargets, @@ -529,5 +536,6 @@ function getBrokenPartsToDeposit( label: part.label, modelPath: part.modelPath ?? config.modelPath, ...(part.caseSlotName ? { caseSlotName: part.caseSlotName } : {}), + ...(part.targetNodeName ? { targetNodeName: part.targetNodeName } : {}), })); } diff --git a/src/components/three/gameplay/RepairScanSequence.tsx b/src/components/three/gameplay/RepairScanSequence.tsx index c25d2c6..a0b73b5 100644 --- a/src/components/three/gameplay/RepairScanSequence.tsx +++ b/src/components/three/gameplay/RepairScanSequence.tsx @@ -97,6 +97,9 @@ function getScannedBrokenParts( ...(match.config.caseSlotName ? { caseSlotName: match.config.caseSlotName } : {}), + ...(match.config.targetNodeName + ? { targetNodeName: match.config.targetNodeName } + : {}), }; }); } diff --git a/src/components/three/models/ExplodableModel.tsx b/src/components/three/models/ExplodableModel.tsx index 711acdc..ee81f3d 100644 --- a/src/components/three/models/ExplodableModel.tsx +++ b/src/components/three/models/ExplodableModel.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; -import { Component, useEffect, useMemo } from "react"; +import { Component, useEffect, useMemo, useRef } from "react"; +import * as THREE from "three"; import { useFrame } from "@react-three/fiber"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useClonedObject } from "@/hooks/three/useClonedObject"; @@ -9,6 +10,10 @@ import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { toVector3Scale } from "@/utils/three/scale"; +export type ExplodedNodeAnchors = Readonly>; + +const _anchorWorld = new THREE.Vector3(); + interface ModelErrorBoundaryProps { children: ReactNode; modelPath: string; @@ -67,6 +72,9 @@ interface ExplodableModelInnerProps extends ModelTransformProps { split: boolean; splitDistance?: number; onPartsReady?: (parts: readonly ExplodedPart[]) => void; + hideNodeNames?: readonly string[]; + nodeAnchorNames?: readonly string[]; + onNodeAnchorsChange?: (anchors: ExplodedNodeAnchors) => void; } export function ExplodableModel( @@ -93,6 +101,9 @@ function ExplodableModelInner({ scale = 1, splitDistance = 1.2, onPartsReady, + hideNodeNames, + nodeAnchorNames, + onNodeAnchorsChange, }: ExplodableModelInnerProps): React.JSX.Element { const { scene } = useLoggedGLTF(modelPath, { scope: "ExplodableModel", @@ -106,6 +117,24 @@ function ExplodableModelInner({ [model, splitDistance], ); const parsedScale = toVector3Scale(scale); + const anchorSignatureRef = useRef(""); + + useEffect(() => { + if (!hideNodeNames || hideNodeNames.length === 0) return; + const hidden: THREE.Object3D[] = []; + model.traverse((child) => { + if (hideNodeNames.includes(child.name)) { + hidden.push(child); + child.visible = false; + } + }); + + return () => { + hidden.forEach((object) => { + object.visible = true; + }); + }; + }, [hideNodeNames, model]); useEffect(() => { explodedModel.setSplit(split); @@ -117,6 +146,35 @@ function ExplodableModelInner({ useFrame((_, delta) => { explodedModel.update(delta); + + if ( + !onNodeAnchorsChange || + !nodeAnchorNames || + nodeAnchorNames.length === 0 + ) { + return; + } + + const anchors: Record = {}; + nodeAnchorNames.forEach((name) => { + const node = model.getObjectByName(name); + if (!node) return; + node.getWorldPosition(_anchorWorld); + anchors[name] = [_anchorWorld.x, _anchorWorld.y, _anchorWorld.z]; + }); + + const signature = nodeAnchorNames + .map((name) => { + const a = anchors[name]; + return a + ? `${name}:${a[0].toFixed(3)},${a[1].toFixed(3)},${a[2].toFixed(3)}` + : `${name}:?`; + }) + .join("|"); + + if (signature === anchorSignatureRef.current) return; + anchorSignatureRef.current = signature; + onNodeAnchorsChange(anchors); }); return (