add: snap repair parts to case placeholders
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user