From 5b123f97046d5120df24a83f1a638883acb9f3fb Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Tue, 2 Jun 2026 18:46:34 +0200 Subject: [PATCH] feat(repair): soft-lock mutually exclusive replacement parts When a replacement part with a caseLockGroup is grabbed, sibling parts sharing the same group become non-interactable and ghosted (35% opacity) until the held part is released. This implements the pylon cable choice where the player picks either cable1 or cable2 (both valid) without being able to grab both simultaneously. - GrabbableObject: add disabled prop (skips interaction frame logic and unmounts InteractableObject so it does not register with the manager) and onGrabChange callback fired on press, release, hand grab, and hand release. Force-releases when disabled becomes true mid-grab. - SimpleModel: add opacity prop, traversed onto cloned mesh materials (safe because cloneResources clones materials per instance). - RepairObjectModel: forward ghosted prop as opacity 0.35. - RepairRepairingStep: track heldPartByLockGroup and pass disabled + ghosted to siblings of the currently held part. --- .../three/gameplay/RepairObjectModel.tsx | 3 + .../three/gameplay/RepairRepairingStep.tsx | 30 +++++ .../three/interaction/GrabbableObject.tsx | 104 +++++++++++------- src/components/three/models/SimpleModel.tsx | 24 ++++ 4 files changed, 124 insertions(+), 37 deletions(-) diff --git a/src/components/three/gameplay/RepairObjectModel.tsx b/src/components/three/gameplay/RepairObjectModel.tsx index cb678c0..e8671c8 100644 --- a/src/components/three/gameplay/RepairObjectModel.tsx +++ b/src/components/three/gameplay/RepairObjectModel.tsx @@ -8,6 +8,7 @@ import { toVector3Scale } from "@/utils/three/scale"; interface RepairObjectModelProps extends ModelTransformProps { label: string; modelPath: string; + ghosted?: boolean; } interface RepairObjectModelBoundaryProps extends RepairObjectModelProps { @@ -73,6 +74,7 @@ export function RepairObjectModel({ position = [0, 0, 0], rotation = [0, 0, 0], scale = 1, + ghosted = false, }: RepairObjectModelProps): React.JSX.Element { return ( ); diff --git a/src/components/three/gameplay/RepairRepairingStep.tsx b/src/components/three/gameplay/RepairRepairingStep.tsx index 27d20de..471f71d 100644 --- a/src/components/three/gameplay/RepairRepairingStep.tsx +++ b/src/components/three/gameplay/RepairRepairingStep.tsx @@ -81,6 +81,9 @@ export function RepairRepairingStep({ const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState< Record >({}); + const [heldPartByLockGroup, setHeldPartByLockGroup] = useState< + Record + >({}); const [showBlockedInstallFeedback, setShowBlockedInstallFeedback] = useState(false); const replacementParts = getReplacementParts(config); @@ -183,6 +186,24 @@ export function RepairRepairingStep({ }); } + function handleReplacementGrabChange( + part: RepairMissionPartConfig, + held: boolean, + ): void { + if (!part.caseLockGroup) return; + const group = part.caseLockGroup; + setHeldPartByLockGroup((current) => { + if (held) { + if (current[group] === part.id) return current; + return { ...current, [group]: part.id }; + } + if (current[group] !== part.id) return current; + const next = { ...current }; + delete next[group]; + return next; + }); + } + return ( { + handleReplacementGrabChange(part, held); + }} onPositionChange={(position) => { handleReplacementPosition(part.id, position); }} @@ -234,6 +263,7 @@ export function RepairRepairingStep({ label={part.label} modelPath={part.modelPath ?? config.modelPath} scale={0.36} + ghosted={lockedByOther} /> diff --git a/src/components/three/interaction/GrabbableObject.tsx b/src/components/three/interaction/GrabbableObject.tsx index 62821da..f0cc054 100644 --- a/src/components/three/interaction/GrabbableObject.tsx +++ b/src/components/three/interaction/GrabbableObject.tsx @@ -34,6 +34,8 @@ interface GrabbableObjectProps { colliders?: ColliderShape; label?: string; handControlled?: boolean; + disabled?: boolean; + onGrabChange?: (held: boolean) => void; onPositionChange?: (position: THREE.Vector3) => void; onSnap?: (position: THREE.Vector3) => void; snapDuration?: number; @@ -131,6 +133,8 @@ export function GrabbableObject({ colliders = GRAB_DEFAULT_COLLIDERS, label = GRAB_DEFAULT_LABEL, handControlled = false, + disabled = false, + onGrabChange, onPositionChange, onSnap, snapDuration = 0.25, @@ -152,6 +156,19 @@ export function GrabbableObject({ }; }, []); + useEffect(() => { + if (!disabled) return; + if (isHolding.current) { + isHolding.current = false; + onGrabChange?.(false); + } + if (isHandHolding.current) { + isHandHolding.current = false; + InteractionManager.getInstance().setHandHolding(false); + onGrabChange?.(false); + } + }, [disabled, onGrabChange]); + function snapToNearestTarget(): void { const body = rbRef.current; if (!body || snapTargets.length === 0 || snapRadius <= 0) return; @@ -242,14 +259,16 @@ export function GrabbableObject({ useFrame(() => { if (!rbRef.current) return; - const fistHand = handControlled - ? hands.find((hand) => hand.isFist) - : undefined; - const t = rbRef.current.translation(); _currentPos.set(t.x, t.y, t.z); onPositionChange?.(_currentPos); + if (disabled) return; + + const fistHand = handControlled + ? hands.find((hand) => hand.isFist) + : undefined; + if (fistHand) { const handCenter = getHandCenterPoint(fistHand); @@ -267,15 +286,20 @@ export function GrabbableObject({ ? getHandHit(groupRef.current, camera, _cameraPos, handCenter) : null; - isHandHolding.current = Boolean(hit); - InteractionManager.getInstance().setHandHolding(isHandHolding.current); + const hadHit = Boolean(hit); + if (hadHit) { + isHandHolding.current = true; + InteractionManager.getInstance().setHandHolding(true); + onGrabChange?.(true); + } } } else { if (isHandHolding.current) { snapToNearestTarget(); + isHandHolding.current = false; + InteractionManager.getInstance().setHandHolding(false); + onGrabChange?.(false); } - isHandHolding.current = false; - InteractionManager.getInstance().setHandHolding(false); } if (!isHolding.current && !isHandHolding.current) return; @@ -311,35 +335,41 @@ export function GrabbableObject({ position={position} > - { - 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} - + {disabled ? ( + children + ) : ( + { + isHolding.current = true; + onGrabChange?.(true); + }} + onRelease={() => { + isHolding.current = false; + onGrabChange?.(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} + + )} diff --git a/src/components/three/models/SimpleModel.tsx b/src/components/three/models/SimpleModel.tsx index 0122940..7d0e0b1 100644 --- a/src/components/three/models/SimpleModel.tsx +++ b/src/components/three/models/SimpleModel.tsx @@ -17,10 +17,29 @@ function applyShadowSettings( }); } +function applyOpacity(object: THREE.Object3D, opacity: number): void { + object.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + + const materials = Array.isArray(child.material) + ? child.material + : [child.material]; + + materials.forEach((material) => { + if (!(material instanceof THREE.Material)) return; + material.transparent = opacity < 1; + material.opacity = opacity; + material.depthWrite = opacity >= 1; + material.needsUpdate = true; + }); + }); +} + interface SimpleModelConfig extends ModelTransformProps { modelPath: string; castShadow?: boolean; receiveShadow?: boolean; + opacity?: number; } interface SimpleModelProps extends SimpleModelConfig { @@ -34,6 +53,7 @@ export function SimpleModel({ scale = 1, castShadow = true, receiveShadow = true, + opacity = 1, children, }: SimpleModelProps): React.JSX.Element { const { scene } = useLoggedGLTF(modelPath, { @@ -48,6 +68,10 @@ export function SimpleModel({ applyShadowSettings(model, castShadow, receiveShadow); }, [castShadow, model, receiveShadow]); + useEffect(() => { + applyOpacity(model, opacity); + }, [model, opacity]); + const parsedScale = typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;