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.
This commit is contained in:
Tom Boullay
2026-06-02 18:46:34 +02:00
parent d1bf438465
commit 5b123f9704
4 changed files with 124 additions and 37 deletions
@@ -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 (
<RepairObjectModelBoundary
@@ -87,6 +89,7 @@ export function RepairObjectModel({
position={position}
rotation={rotation}
scale={scale}
opacity={ghosted ? 0.35 : 1}
/>
</RepairObjectModelBoundary>
);
@@ -81,6 +81,9 @@ export function RepairRepairingStep({
const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState<
Record<string, boolean>
>({});
const [heldPartByLockGroup, setHeldPartByLockGroup] = useState<
Record<string, string>
>({});
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 (
<group ref={groupRef}>
<RepairInstallTarget
@@ -211,6 +232,10 @@ export function RepairRepairingStep({
config.requiredReplacementPartIds,
isPlaced,
);
const lockedByOther =
part.caseLockGroup !== undefined &&
heldPartByLockGroup[part.caseLockGroup] !== undefined &&
heldPartByLockGroup[part.caseLockGroup] !== part.id;
return (
<GrabbableObject
@@ -218,7 +243,11 @@ export function RepairRepairingStep({
position={placeholderPosition}
colliders="ball"
handControlled
disabled={lockedByOther}
label={`Prendre ${part.label}`}
onGrabChange={(held) => {
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}
/>
<RepairPartPlacementFeedback state={feedbackState} />
</group>
@@ -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);
}
}
if (!isHolding.current && !isHandHolding.current) return;
@@ -311,6 +335,9 @@ export function GrabbableObject({
position={position}
>
<group ref={groupRef}>
{disabled ? (
children
) : (
<InteractableObject
kind="grab"
label={label}
@@ -318,9 +345,11 @@ export function GrabbableObject({
bodyRef={rbRef}
onPress={() => {
isHolding.current = true;
onGrabChange?.(true);
}}
onRelease={() => {
isHolding.current = false;
onGrabChange?.(false);
snapToNearestTarget();
if (
!rbRef.current ||
@@ -340,6 +369,7 @@ export function GrabbableObject({
>
{children}
</InteractableObject>
)}
</group>
</RigidBody>
</group>
@@ -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;