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
+1 -1
View File
@@ -442,7 +442,7 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
- \`RepairGame\` de production réutilisable monté pour les états de mission \`bike\`, \`pylone\` et \`ferme\`
- Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`
- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, vue focalisée de la mallette, touche \`E\`, hold deux poings, transition de modèle explosé, scan visuel par pièce, marqueur rouge persistant et vidéo UI centrée sur les pièces cassées, plusieurs choix de pièces grabbables, validation de la bonne pièce et complétion de mission
- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, vue focalisée de la mallette, traverse des placeholders de mallette, placement avec snap vers placeholder, touche \`E\`, hold deux poings, transition de modèle explosé, scan visuel par pièce, marqueur rouge persistant et vidéo UI centrée sur les pièces cassées, plusieurs choix de pièces grabbables, validation de la bonne pièce et complétion de mission
## Audio
+3
View File
@@ -24,3 +24,6 @@ export const REPAIR_CASE_FOCUS_POSITION = [
0, 1.05, 2.05,
] satisfies Vector3Tuple;
export const REPAIR_CASE_FOCUS_SCALE = 2.25;
export const REPAIR_CASE_PLACEHOLDER_NAME_PREFIX = "placeholder_";
export const REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS = 0.65;
export const REPAIR_CASE_PLACEHOLDER_SNAP_DURATION = 0.25;