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
+1 -1
View File
@@ -31,7 +31,7 @@ This document lists features that are implemented in the current codebase.
- Reusable production `RepairGame` mounted for `bike`, `pylone`, and `ferme` mission states - Reusable production `RepairGame` mounted for `bike`, `pylone`, and `ferme` mission states
- Repair mission config shared through `src/data/gameplay/repairMissions.ts` - Repair mission config shared through `src/data/gameplay/repairMissions.ts`
- Repair-game flow supports `waiting -> inspected -> fragmented -> scanning -> repairing -> done -> next mission` with `.webm` prompts, repair case spawn/opening/exit, focused repair-case view, `E`, two-fists hold input, exploded model transition, per-part scan visuals, persistent red broken-part markers, centered broken-part UI videos, multiple grabbable replacement choices, correct-part install validation, and mission completion - Repair-game flow supports `waiting -> inspected -> fragmented -> scanning -> repairing -> done -> next mission` with `.webm` prompts, repair case spawn/opening/exit, focused repair-case view, case placeholder traversal, snap-to-placeholder placement, `E`, two-fists hold input, exploded model transition, per-part scan visuals, persistent red broken-part markers, centered broken-part UI videos, multiple grabbable replacement choices, correct-part install validation, and mission completion
## Audio ## Audio
+5 -5
View File
@@ -16,8 +16,8 @@ The current user flow is:
6. Press `E` or hold both fists closed for one second to move from `inspected` to `fragmented`. 6. Press `E` or hold both fists closed for one second to move from `inspected` to `fragmented`.
7. The mission object uses an exploded-model transition, then moves to `scanning`. 7. The mission object uses an exploded-model transition, then moves to `scanning`.
8. The scan visual moves across the fragmented model one part at a time and keeps a red marker plus the `cassé.webm` prompt centered on any configured broken part once it has been found. 8. The scan visual moves across the fragmented model one part at a time and keeps a red marker plus the `cassé.webm` prompt centered on any configured broken part once it has been found.
9. In `repairing`, the case opens in a larger focused view and several grabbable replacement parts appear near it. 9. In `repairing`, the case opens in a larger focused view and several grabbable replacement parts appear on the case placeholders.
10. Move the correct replacement part close to the install target. 10. Move the correct replacement part close to a placeholder. When released near a placeholder, it snaps into place with a short animation.
11. Press `E` on the green install target to move to `done` and show the reassembled object. Wrong parts turn the target red and cannot finish the repair. 11. Press `E` on the green install target to move to `done` and show the reassembled object. Wrong parts turn the target red and cannot finish the repair.
12. Press `E` on the completion target. The repair case closes, returns to the ground, disappears, then `completeMission` moves to the next mission or to `outro` after `ferme`. 12. Press `E` on the completion target. The repair case closes, returns to the ground, disappears, then `completeMission` moves to the next mission or to `outro` after `ferme`.
@@ -33,7 +33,7 @@ When the player inspects the object, `RepairGame` writes `inspected` through the
In `inspected`, `RepairGame` can also move to `fragmented`. The player can use the interaction key or hold both fists closed for one second. The hand-tracking path is state-based, so it does not depend on being inside a local object interaction radius. In `inspected`, `RepairGame` can also move to `fragmented`. The player can use the interaction key or hold both fists closed for one second. The hand-tracking path is state-based, so it does not depend on being inside a local object interaction radius.
In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan can match a specific `nodeName` when mission data provides one, otherwise it falls back to the first scanned parts as placeholder broken parts. In `repairing`, the case opens in a larger focused transform, several grabbable replacement parts appear around that focused case view, and the install target only validates the configured correct part for the active mission. In `done`, the repaired object remains visible with a completion target that plays the case exit animation before advancing the global mission progression. In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan can match a specific `nodeName` when mission data provides one, otherwise it falls back to the first scanned parts as placeholder broken parts. In `repairing`, the case opens in a larger focused transform, `RepairCaseModel` traverses the case GLTF for empty nodes named `placeholder_*`, several grabbable replacement parts appear on those placeholder positions, and releasing a part near a placeholder snaps it into place with a short GSAP animation. If the current case asset has no placeholder nodes, the flow keeps using fallback focus positions. The install target only validates the configured correct part for the active mission. In `done`, the repaired object remains visible with a completion target that plays the case exit animation before advancing the global mission progression.
## Key Files ## Key Files
@@ -44,14 +44,14 @@ In `fragmented`, the repair object is rendered with `ExplodableModel`, then auto
- `src/components/three/gameplay/RepairBrokenPartPrompt.tsx` centers the configured broken UI video on detected broken parts during scanning. - `src/components/three/gameplay/RepairBrokenPartPrompt.tsx` centers the configured broken UI video on detected broken parts during scanning.
- `src/components/three/gameplay/RepairInspectionObject.tsx` handles the `waiting` inspection interaction. - `src/components/three/gameplay/RepairInspectionObject.tsx` handles the `waiting` inspection interaction.
- `src/components/three/gameplay/RepairMissionCase.tsx` renders the mission repair case after inspection. - `src/components/three/gameplay/RepairMissionCase.tsx` renders the mission repair case after inspection.
- `src/components/three/gameplay/RepairRepairingStep.tsx` renders grabbable replacement choices, correct-part placement validation, and the install trigger in `repairing`. - `src/components/three/gameplay/RepairRepairingStep.tsx` renders grabbable replacement choices, placeholder placement markers, snap placement behavior, correct-part placement validation, and the install trigger in `repairing`.
- `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene. - `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene.
- `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part. - `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part.
- `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part. - `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part.
- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` keyboard and hand-tracking input. - `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` keyboard and hand-tracking input.
- `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store. - `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store.
- `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture. - `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model. - `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model, and exposes `placeholder_*` transforms when the GLTF provides them.
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization. - `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
- `src/data/gameplay/repairCaseConfig.ts` stores repair case model, sound, and animation constants. - `src/data/gameplay/repairCaseConfig.ts` stores repair case model, sound, and animation constants.
- `src/data/gameplay/repairGameConfig.ts` stores repair flow timing constants. - `src/data/gameplay/repairGameConfig.ts` stores repair flow timing constants.
@@ -13,6 +13,7 @@ import {
REPAIR_CASE_FLOAT_UP_SPEED, REPAIR_CASE_FLOAT_UP_SPEED,
REPAIR_CASE_LID_NODE_NAME, REPAIR_CASE_LID_NODE_NAME,
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES, REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
REPAIR_CASE_PLACEHOLDER_NAME_PREFIX,
REPAIR_CASE_POP_DURATION, REPAIR_CASE_POP_DURATION,
REPAIR_CASE_POP_Y_OFFSET, REPAIR_CASE_POP_Y_OFFSET,
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES, REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
@@ -20,13 +21,22 @@ import {
} from "@/data/gameplay/repairCaseConfig"; } from "@/data/gameplay/repairCaseConfig";
import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; 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"; import { toVector3Scale } from "@/utils/three/scale";
export interface RepairCasePlaceholder {
name: string;
position: Vector3Tuple;
}
interface RepairCaseModelProps extends ModelTransformProps { interface RepairCaseModelProps extends ModelTransformProps {
modelPath: string; modelPath: string;
open: boolean; open: boolean;
exiting?: boolean; exiting?: boolean;
floating?: boolean;
onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined;
onExitComplete?: (() => void) | undefined; onExitComplete?: (() => void) | undefined;
} }
@@ -44,6 +54,8 @@ export function RepairCaseModel({
modelPath, modelPath,
open, open,
exiting = false, exiting = false,
floating = true,
onPlaceholdersChange,
onExitComplete, onExitComplete,
position = [0, 0, 0], position = [0, 0, 0],
rotation = [0, 0, 0], rotation = [0, 0, 0],
@@ -65,14 +77,22 @@ export function RepairCaseModel({
const phase = useRef({ x: 0, y: 0, z: 0 }); const phase = useRef({ x: 0, y: 0, z: 0 });
const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET }); const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
const onExitCompleteRef = useRef(onExitComplete); const onExitCompleteRef = useRef(onExitComplete);
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
const initialOpen = useRef(open); const initialOpen = useRef(open);
const openedRotationZ = useRef(0); const openedRotationZ = useRef(0);
const parsedScale = toVector3Scale(scale); const parsedScale = toVector3Scale(scale);
const placeholderSignature = useRef("__initial__");
const placeholderPosition = useRef(new THREE.Vector3());
const placeholderLocalPosition = useRef(new THREE.Vector3());
useEffect(() => { useEffect(() => {
onExitCompleteRef.current = onExitComplete; onExitCompleteRef.current = onExitComplete;
}, [onExitComplete]); }, [onExitComplete]);
useEffect(() => {
onPlaceholdersChangeRef.current = onPlaceholdersChange;
}, [onPlaceholdersChange]);
useEffect(() => { useEffect(() => {
const popAnimation = pop.current; const popAnimation = pop.current;
@@ -153,6 +173,7 @@ export function RepairCaseModel({
group.getWorldPosition(worldPosition.current); group.getWorldPosition(worldPosition.current);
const isNear = const isNear =
floating &&
!exiting && !exiting &&
worldPosition.current.distanceTo(camera.position) <= worldPosition.current.distanceTo(camera.position) <=
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE; REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
@@ -174,6 +195,43 @@ export function RepairCaseModel({
parsedScale[2] * pop.current.scale, 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; animationActiveRef.current = isNear;
if (animationActiveRef.current) { 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 { ExplodableModel } from "@/components/three/models/ExplodableModel";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep"; import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject"; import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
@@ -33,6 +34,9 @@ export function RepairGame({
const completeMission = useGameStore((state) => state.completeMission); const completeMission = useGameStore((state) => state.completeMission);
const setMissionStep = useGameStore((state) => state.setMissionStep); const setMissionStep = useGameStore((state) => state.setMissionStep);
const step = useRepairMissionStep(mission); const step = useRepairMissionStep(mission);
const [casePlaceholders, setCasePlaceholders] = useState<
readonly RepairCasePlaceholder[]
>([]);
const parsedScale = toVector3Scale(scale); const parsedScale = toVector3Scale(scale);
const readyForFragmentation = step === "inspected"; const readyForFragmentation = step === "inspected";
@@ -79,6 +83,7 @@ export function RepairGame({
{step === "repairing" ? ( {step === "repairing" ? (
<RepairRepairingStep <RepairRepairingStep
config={config} config={config}
placeholders={casePlaceholders}
onRepair={() => setMissionStep(mission, "done")} onRepair={() => setMissionStep(mission, "done")}
/> />
) : null} ) : null}
@@ -91,6 +96,7 @@ export function RepairGame({
{step !== "waiting" && step !== "done" ? ( {step !== "waiting" && step !== "done" ? (
<RepairMissionCase <RepairMissionCase
config={config} config={config}
onPlaceholdersChange={setCasePlaceholders}
open={step === "repairing"} open={step === "repairing"}
zoomed={step === "repairing"} zoomed={step === "repairing"}
showFragmentationPrompt={readyForFragmentation} 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 { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { import {
REPAIR_CASE_FOCUS_POSITION, REPAIR_CASE_FOCUS_POSITION,
@@ -10,6 +13,9 @@ import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
interface RepairMissionCaseProps { interface RepairMissionCaseProps {
config: RepairMissionConfig; config: RepairMissionConfig;
exiting?: boolean; exiting?: boolean;
onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined;
onExitComplete?: (() => void) | undefined; onExitComplete?: (() => void) | undefined;
open?: boolean; open?: boolean;
zoomed?: boolean; zoomed?: boolean;
@@ -19,6 +25,7 @@ interface RepairMissionCaseProps {
export function RepairMissionCase({ export function RepairMissionCase({
config, config,
exiting = false, exiting = false,
onPlaceholdersChange,
onExitComplete, onExitComplete,
open = false, open = false,
zoomed = false, zoomed = false,
@@ -35,7 +42,9 @@ export function RepairMissionCase({
modelPath={REPAIR_CASE_MODEL_PATH} modelPath={REPAIR_CASE_MODEL_PATH}
exiting={exiting} exiting={exiting}
onExitComplete={onExitComplete} onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
open={open} open={open}
floating={!zoomed}
position={casePosition} position={casePosition}
rotation={config.case.rotation} rotation={config.case.rotation}
scale={caseScale} scale={caseScale}
@@ -1,10 +1,15 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import * as THREE from "three"; import * as THREE from "three";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel"; import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo"; import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; 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 { import type {
RepairMissionConfig, RepairMissionConfig,
RepairMissionPartConfig, RepairMissionPartConfig,
@@ -12,7 +17,7 @@ import type {
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0]; 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[] = [ const REPLACEMENT_START_OFFSETS: Vector3Tuple[] = [
[-1.15, 1, 0.25], [-1.15, 1, 0.25],
[0, 1.05, 0.45], [0, 1.05, 0.45],
@@ -22,11 +27,13 @@ const REPAIR_INSTALL_RADIUS = 1.1;
interface RepairRepairingStepProps { interface RepairRepairingStepProps {
config: RepairMissionConfig; config: RepairMissionConfig;
placeholders: readonly RepairCasePlaceholder[];
onRepair: () => void; onRepair: () => void;
} }
export function RepairRepairingStep({ export function RepairRepairingStep({
config, config,
placeholders,
onRepair, onRepair,
}: RepairRepairingStepProps): React.JSX.Element { }: RepairRepairingStepProps): React.JSX.Element {
const [placedPartIds, setPlacedPartIds] = useState<Record<string, boolean>>( const [placedPartIds, setPlacedPartIds] = useState<Record<string, boolean>>(
@@ -38,6 +45,7 @@ export function RepairRepairingStep({
); );
const requiredReplacementLabel = const requiredReplacementLabel =
requiredReplacementPart?.label ?? config.label; requiredReplacementPart?.label ?? config.label;
const placeholderPositions = getPlaceholderPositions(placeholders);
const hasCorrectPartPlaced = Boolean( const hasCorrectPartPlaced = Boolean(
placedPartIds[config.requiredReplacementPartId], placedPartIds[config.requiredReplacementPartId],
); );
@@ -58,17 +66,24 @@ export function RepairRepairingStep({
const handleReplacementPosition = useCallback( const handleReplacementPosition = useCallback(
(partId: string, position: THREE.Vector3) => { (partId: string, position: THREE.Vector3) => {
const isPlaced = const isPlaced = isNearPlaceholder(position, placeholderPositions);
position.distanceTo(INSTALL_TARGET_VECTOR) <= REPAIR_INSTALL_RADIUS;
setPlacedPartIds((current) => { 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 ( return (
<group> <group>
<TriggerObject <TriggerObject
@@ -101,25 +116,38 @@ export function RepairRepairingStep({
</mesh> </mesh>
</TriggerObject> </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) => { {replacementParts.map((part, index) => {
const offset = const placeholderPosition =
REPLACEMENT_START_OFFSETS[index % REPLACEMENT_START_OFFSETS.length] ?? placeholderPositions[index % placeholderPositions.length] ??
REPLACEMENT_START_OFFSETS[0]!; placeholderPositions[0]!;
return ( return (
<GrabbableObject <GrabbableObject
key={part.id} key={part.id}
position={[ position={placeholderPosition}
REPAIR_CASE_FOCUS_POSITION[0] + offset[0],
REPAIR_CASE_FOCUS_POSITION[1] + offset[1],
REPAIR_CASE_FOCUS_POSITION[2] + offset[2],
]}
colliders="ball" colliders="ball"
handControlled handControlled
label={`Prendre ${part.label}`} label={`Prendre ${part.label}`}
onPositionChange={(position) => { onPositionChange={(position) => {
handleReplacementPosition(part.id, position); handleReplacementPosition(part.id, position);
}} }}
onSnap={() => {
handleReplacementSnap(part.id);
}}
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
snapTargets={placeholderPositions}
> >
<RepairObjectModel <RepairObjectModel
label={part.label} 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( function getReplacementParts(
config: RepairMissionConfig, config: RepairMissionConfig,
): readonly RepairMissionPartConfig[] { ): readonly RepairMissionPartConfig[] {
@@ -1,7 +1,8 @@
import { useRef } from "react"; import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { RigidBody } from "@react-three/rapier"; import { RigidBody } from "@react-three/rapier";
import type { RapierRigidBody } from "@react-three/rapier"; import type { RapierRigidBody } from "@react-three/rapier";
import gsap from "gsap";
import * as THREE from "three"; import * as THREE from "three";
import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { import {
@@ -37,6 +38,10 @@ interface GrabbableObjectProps {
label?: string; label?: string;
handControlled?: boolean; handControlled?: boolean;
onPositionChange?: (position: THREE.Vector3) => void; onPositionChange?: (position: THREE.Vector3) => void;
onSnap?: (position: THREE.Vector3) => void;
snapDuration?: number;
snapRadius?: number;
snapTargets?: readonly Vector3Tuple[];
} }
const grabDebugParams = { const grabDebugParams = {
@@ -56,6 +61,7 @@ const _handDirection = new THREE.Vector3();
const _handHitDirection = new THREE.Vector3(); const _handHitDirection = new THREE.Vector3();
const _cameraPos = new THREE.Vector3(); const _cameraPos = new THREE.Vector3();
const _objectPos = new THREE.Vector3(); const _objectPos = new THREE.Vector3();
const _snapPosition = new THREE.Vector3();
const _handRaycaster = new THREE.Raycaster(); const _handRaycaster = new THREE.Raycaster();
const HAND_GRAB_SCREEN_RADIUS = 0.04; const HAND_GRAB_SCREEN_RADIUS = 0.04;
@@ -125,6 +131,10 @@ export function GrabbableObject({
label = GRAB_DEFAULT_LABEL, label = GRAB_DEFAULT_LABEL,
handControlled = false, handControlled = false,
onPositionChange, onPositionChange,
onSnap,
snapDuration = 0.25,
snapRadius = 0,
snapTargets = [],
}: GrabbableObjectProps): React.JSX.Element { }: GrabbableObjectProps): React.JSX.Element {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const { hands } = useHandTrackingSnapshot(); const { hands } = useHandTrackingSnapshot();
@@ -134,6 +144,63 @@ export function GrabbableObject({
const isHandHolding = useRef(false); const isHandHolding = useRef(false);
const handHoldDistance = useRef<number | null>(null); const handHoldDistance = useRef<number | null>(null);
const handHoldStartZ = 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) => { useDebugFolder("GrabbableObject", (folder) => {
folder folder
@@ -199,6 +266,9 @@ export function GrabbableObject({
InteractionManager.getInstance().setHandHolding(isHandHolding.current); InteractionManager.getInstance().setHandHolding(isHandHolding.current);
} }
} else { } else {
if (isHandHolding.current) {
snapToNearestTarget();
}
isHandHolding.current = false; isHandHolding.current = false;
handHoldDistance.current = null; handHoldDistance.current = null;
handHoldStartZ.current = null; handHoldStartZ.current = null;
@@ -258,6 +328,7 @@ export function GrabbableObject({
}} }}
onRelease={() => { onRelease={() => {
isHolding.current = false; isHolding.current = false;
snapToNearestTarget();
if ( if (
!rbRef.current || !rbRef.current ||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT 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\` - \`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\` - 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 ## Audio
+3
View File
@@ -24,3 +24,6 @@ export const REPAIR_CASE_FOCUS_POSITION = [
0, 1.05, 2.05, 0, 1.05, 2.05,
] satisfies Vector3Tuple; ] satisfies Vector3Tuple;
export const REPAIR_CASE_FOCUS_SCALE = 2.25; 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;