add: snap repair parts to case placeholders

This commit is contained in:
Tom Boullay
2026-05-08 02:33:06 +01:00
parent d02ef54bdc
commit bebb9ac5a3
9 changed files with 228 additions and 26 deletions
@@ -13,6 +13,7 @@ import {
REPAIR_CASE_FLOAT_UP_SPEED,
REPAIR_CASE_LID_NODE_NAME,
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
REPAIR_CASE_PLACEHOLDER_NAME_PREFIX,
REPAIR_CASE_POP_DURATION,
REPAIR_CASE_POP_Y_OFFSET,
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
@@ -20,13 +21,22 @@ import {
} from "@/data/gameplay/repairCaseConfig";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps } from "@/types/three/three";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
export interface RepairCasePlaceholder {
name: string;
position: Vector3Tuple;
}
interface RepairCaseModelProps extends ModelTransformProps {
modelPath: string;
open: boolean;
exiting?: boolean;
floating?: boolean;
onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined;
onExitComplete?: (() => void) | undefined;
}
@@ -44,6 +54,8 @@ export function RepairCaseModel({
modelPath,
open,
exiting = false,
floating = true,
onPlaceholdersChange,
onExitComplete,
position = [0, 0, 0],
rotation = [0, 0, 0],
@@ -65,14 +77,22 @@ export function RepairCaseModel({
const phase = useRef({ x: 0, y: 0, z: 0 });
const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
const onExitCompleteRef = useRef(onExitComplete);
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
const initialOpen = useRef(open);
const openedRotationZ = useRef(0);
const parsedScale = toVector3Scale(scale);
const placeholderSignature = useRef("__initial__");
const placeholderPosition = useRef(new THREE.Vector3());
const placeholderLocalPosition = useRef(new THREE.Vector3());
useEffect(() => {
onExitCompleteRef.current = onExitComplete;
}, [onExitComplete]);
useEffect(() => {
onPlaceholdersChangeRef.current = onPlaceholdersChange;
}, [onPlaceholdersChange]);
useEffect(() => {
const popAnimation = pop.current;
@@ -153,6 +173,7 @@ export function RepairCaseModel({
group.getWorldPosition(worldPosition.current);
const isNear =
floating &&
!exiting &&
worldPosition.current.distanceTo(camera.position) <=
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
@@ -174,6 +195,43 @@ export function RepairCaseModel({
parsedScale[2] * pop.current.scale,
);
const placeholders: RepairCasePlaceholder[] = [];
model.traverse((child) => {
if (
!child.name
.toLowerCase()
.startsWith(REPAIR_CASE_PLACEHOLDER_NAME_PREFIX)
) {
return;
}
child.getWorldPosition(placeholderPosition.current);
placeholderLocalPosition.current.copy(placeholderPosition.current);
group.parent?.worldToLocal(placeholderLocalPosition.current);
placeholders.push({
name: child.name,
position: [
placeholderLocalPosition.current.x,
placeholderLocalPosition.current.y,
placeholderLocalPosition.current.z,
],
});
});
placeholders.sort((a, b) => a.name.localeCompare(b.name));
const nextSignature = placeholders
.map(
(placeholder) =>
`${placeholder.name}:${placeholder.position
.map((value) => value.toFixed(3))
.join(",")}`,
)
.join("|");
if (nextSignature !== placeholderSignature.current) {
placeholderSignature.current = nextSignature;
onPlaceholdersChangeRef.current?.(placeholders);
}
animationActiveRef.current = isNear;
if (animationActiveRef.current) {
+7 -1
View File
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
@@ -33,6 +34,9 @@ export function RepairGame({
const completeMission = useGameStore((state) => state.completeMission);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const step = useRepairMissionStep(mission);
const [casePlaceholders, setCasePlaceholders] = useState<
readonly RepairCasePlaceholder[]
>([]);
const parsedScale = toVector3Scale(scale);
const readyForFragmentation = step === "inspected";
@@ -79,6 +83,7 @@ export function RepairGame({
{step === "repairing" ? (
<RepairRepairingStep
config={config}
placeholders={casePlaceholders}
onRepair={() => setMissionStep(mission, "done")}
/>
) : null}
@@ -91,6 +96,7 @@ export function RepairGame({
{step !== "waiting" && step !== "done" ? (
<RepairMissionCase
config={config}
onPlaceholdersChange={setCasePlaceholders}
open={step === "repairing"}
zoomed={step === "repairing"}
showFragmentationPrompt={readyForFragmentation}
@@ -1,4 +1,7 @@
import { RepairCaseModel } from "@/components/three/gameplay/RepairCaseModel";
import {
RepairCaseModel,
type RepairCasePlaceholder,
} from "@/components/three/gameplay/RepairCaseModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import {
REPAIR_CASE_FOCUS_POSITION,
@@ -10,6 +13,9 @@ import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
interface RepairMissionCaseProps {
config: RepairMissionConfig;
exiting?: boolean;
onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined;
onExitComplete?: (() => void) | undefined;
open?: boolean;
zoomed?: boolean;
@@ -19,6 +25,7 @@ interface RepairMissionCaseProps {
export function RepairMissionCase({
config,
exiting = false,
onPlaceholdersChange,
onExitComplete,
open = false,
zoomed = false,
@@ -35,7 +42,9 @@ export function RepairMissionCase({
modelPath={REPAIR_CASE_MODEL_PATH}
exiting={exiting}
onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
open={open}
floating={!zoomed}
position={casePosition}
rotation={config.case.rotation}
scale={caseScale}
@@ -1,10 +1,15 @@
import { useCallback, 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 { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { REPAIR_CASE_FOCUS_POSITION } from "@/data/gameplay/repairCaseConfig";
import {
REPAIR_CASE_FOCUS_POSITION,
REPAIR_CASE_PLACEHOLDER_SNAP_DURATION,
REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS,
} from "@/data/gameplay/repairCaseConfig";
import type {
RepairMissionConfig,
RepairMissionPartConfig,
@@ -12,7 +17,7 @@ import type {
import type { Vector3Tuple } from "@/types/three/three";
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
const INSTALL_TARGET_VECTOR = new THREE.Vector3(...INSTALL_TARGET_POSITION);
const _placeholderPosition = new THREE.Vector3();
const REPLACEMENT_START_OFFSETS: Vector3Tuple[] = [
[-1.15, 1, 0.25],
[0, 1.05, 0.45],
@@ -22,11 +27,13 @@ const REPAIR_INSTALL_RADIUS = 1.1;
interface RepairRepairingStepProps {
config: RepairMissionConfig;
placeholders: readonly RepairCasePlaceholder[];
onRepair: () => void;
}
export function RepairRepairingStep({
config,
placeholders,
onRepair,
}: RepairRepairingStepProps): React.JSX.Element {
const [placedPartIds, setPlacedPartIds] = useState<Record<string, boolean>>(
@@ -38,6 +45,7 @@ export function RepairRepairingStep({
);
const requiredReplacementLabel =
requiredReplacementPart?.label ?? config.label;
const placeholderPositions = getPlaceholderPositions(placeholders);
const hasCorrectPartPlaced = Boolean(
placedPartIds[config.requiredReplacementPartId],
);
@@ -58,17 +66,24 @@ export function RepairRepairingStep({
const handleReplacementPosition = useCallback(
(partId: string, position: THREE.Vector3) => {
const isPlaced =
position.distanceTo(INSTALL_TARGET_VECTOR) <= REPAIR_INSTALL_RADIUS;
const isPlaced = isNearPlaceholder(position, placeholderPositions);
setPlacedPartIds((current) => {
if (current[partId] === isPlaced) return current;
if (!current[partId] || isPlaced) return current;
return { ...current, [partId]: isPlaced };
return { ...current, [partId]: false };
});
},
[],
[placeholderPositions],
);
const handleReplacementSnap = useCallback((partId: string) => {
setPlacedPartIds((current) => {
if (current[partId]) return current;
return { ...current, [partId]: true };
});
}, []);
return (
<group>
<TriggerObject
@@ -101,25 +116,38 @@ export function RepairRepairingStep({
</mesh>
</TriggerObject>
{placeholderPositions.map((position, index) => (
<mesh
key={`${position.join(":")}-${index}`}
position={position}
rotation={[Math.PI / 2, 0, 0]}
>
<torusGeometry args={[0.26, 0.018, 8, 48]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.55} />
</mesh>
))}
{replacementParts.map((part, index) => {
const offset =
REPLACEMENT_START_OFFSETS[index % REPLACEMENT_START_OFFSETS.length] ??
REPLACEMENT_START_OFFSETS[0]!;
const placeholderPosition =
placeholderPositions[index % placeholderPositions.length] ??
placeholderPositions[0]!;
return (
<GrabbableObject
key={part.id}
position={[
REPAIR_CASE_FOCUS_POSITION[0] + offset[0],
REPAIR_CASE_FOCUS_POSITION[1] + offset[1],
REPAIR_CASE_FOCUS_POSITION[2] + offset[2],
]}
position={placeholderPosition}
colliders="ball"
handControlled
label={`Prendre ${part.label}`}
onPositionChange={(position) => {
handleReplacementPosition(part.id, position);
}}
onSnap={() => {
handleReplacementSnap(part.id);
}}
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
snapTargets={placeholderPositions}
>
<RepairObjectModel
label={part.label}
@@ -135,6 +163,33 @@ export function RepairRepairingStep({
);
}
function getPlaceholderPositions(
placeholders: readonly RepairCasePlaceholder[],
): readonly Vector3Tuple[] {
if (placeholders.length > 0) {
return placeholders.map((placeholder) => placeholder.position);
}
return REPLACEMENT_START_OFFSETS.map(
(offset): Vector3Tuple => [
REPAIR_CASE_FOCUS_POSITION[0] + offset[0],
REPAIR_CASE_FOCUS_POSITION[1] + offset[1],
REPAIR_CASE_FOCUS_POSITION[2] + offset[2],
],
);
}
function isNearPlaceholder(
position: THREE.Vector3,
placeholderPositions: readonly Vector3Tuple[],
): boolean {
return placeholderPositions.some(
(placeholderPosition) =>
position.distanceTo(_placeholderPosition.set(...placeholderPosition)) <=
REPAIR_INSTALL_RADIUS,
);
}
function getReplacementParts(
config: RepairMissionConfig,
): readonly RepairMissionPartConfig[] {
@@ -1,7 +1,8 @@
import { useRef } from "react";
import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { RigidBody } from "@react-three/rapier";
import type { RapierRigidBody } from "@react-three/rapier";
import gsap from "gsap";
import * as THREE from "three";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import {
@@ -37,6 +38,10 @@ interface GrabbableObjectProps {
label?: string;
handControlled?: boolean;
onPositionChange?: (position: THREE.Vector3) => void;
onSnap?: (position: THREE.Vector3) => void;
snapDuration?: number;
snapRadius?: number;
snapTargets?: readonly Vector3Tuple[];
}
const grabDebugParams = {
@@ -56,6 +61,7 @@ const _handDirection = new THREE.Vector3();
const _handHitDirection = new THREE.Vector3();
const _cameraPos = new THREE.Vector3();
const _objectPos = new THREE.Vector3();
const _snapPosition = new THREE.Vector3();
const _handRaycaster = new THREE.Raycaster();
const HAND_GRAB_SCREEN_RADIUS = 0.04;
@@ -125,6 +131,10 @@ export function GrabbableObject({
label = GRAB_DEFAULT_LABEL,
handControlled = false,
onPositionChange,
onSnap,
snapDuration = 0.25,
snapRadius = 0,
snapTargets = [],
}: GrabbableObjectProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const { hands } = useHandTrackingSnapshot();
@@ -134,6 +144,63 @@ export function GrabbableObject({
const isHandHolding = useRef(false);
const handHoldDistance = useRef<number | null>(null);
const handHoldStartZ = useRef<number | null>(null);
const snapTween = useRef<gsap.core.Tween | null>(null);
useEffect(() => {
return () => {
snapTween.current?.kill();
};
}, []);
function snapToNearestTarget(): void {
const body = rbRef.current;
if (!body || snapTargets.length === 0 || snapRadius <= 0) return;
const translation = body.translation();
_currentPos.set(translation.x, translation.y, translation.z);
let nearestTarget: Vector3Tuple | null = null;
let nearestDistance = snapRadius;
snapTargets.forEach((target) => {
_snapPosition.set(target[0], target[1], target[2]);
const distance = _currentPos.distanceTo(_snapPosition);
if (distance <= nearestDistance) {
nearestDistance = distance;
nearestTarget = target;
}
});
if (!nearestTarget) return;
snapTween.current?.kill();
const animatedPosition = {
x: _currentPos.x,
y: _currentPos.y,
z: _currentPos.z,
};
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
body.setAngvel(ZERO_ANGULAR_VELOCITY, true);
snapTween.current = gsap.to(animatedPosition, {
x: nearestTarget[0],
y: nearestTarget[1],
z: nearestTarget[2],
duration: snapDuration,
ease: "power2.out",
onUpdate: () => {
body.setTranslation(animatedPosition, true);
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
},
onComplete: () => {
_snapPosition.set(
animatedPosition.x,
animatedPosition.y,
animatedPosition.z,
);
onSnap?.(_snapPosition);
},
});
}
useDebugFolder("GrabbableObject", (folder) => {
folder
@@ -199,6 +266,9 @@ export function GrabbableObject({
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
}
} else {
if (isHandHolding.current) {
snapToNearestTarget();
}
isHandHolding.current = false;
handHoldDistance.current = null;
handHoldStartZ.current = null;
@@ -258,6 +328,7 @@ export function GrabbableObject({
}}
onRelease={() => {
isHolding.current = false;
snapToNearestTarget();
if (
!rbRef.current ||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT