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;