refactor: organize three components by domain
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
REPAIR_CASE_ANIMATION_DURATION,
|
||||
REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES,
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE,
|
||||
REPAIR_CASE_FLOAT_DOWN_SPEED,
|
||||
REPAIR_CASE_FLOAT_HEIGHT,
|
||||
REPAIR_CASE_FLOAT_UP_SPEED,
|
||||
REPAIR_CASE_LID_NODE_NAME,
|
||||
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
|
||||
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
} from "@/data/repairGame/repairCaseConfig";
|
||||
import type { Vector3Tuple } from "@/types/three";
|
||||
|
||||
interface RepairCaseModelProps {
|
||||
modelPath: string;
|
||||
open: boolean;
|
||||
position?: Vector3Tuple;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: number | Vector3Tuple;
|
||||
}
|
||||
|
||||
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
|
||||
REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES,
|
||||
);
|
||||
const CASE_OPEN_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
|
||||
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
|
||||
);
|
||||
const ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(
|
||||
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
|
||||
);
|
||||
|
||||
export function RepairCaseModel({
|
||||
modelPath,
|
||||
open,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: RepairCaseModelProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const { scene } = useGLTF(modelPath);
|
||||
const model = useMemo(() => scene.clone(true), [scene]);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const lidRef = useRef<THREE.Object3D | null>(null);
|
||||
const worldPosition = useRef(new THREE.Vector3());
|
||||
const floatHeight = useRef(0);
|
||||
const animationActiveRef = useRef(false);
|
||||
const phase = useRef({ x: 0, y: 0, z: 0 });
|
||||
const initialOpen = useRef(open);
|
||||
const openedRotationZ = useRef(0);
|
||||
const parsedScale =
|
||||
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
|
||||
|
||||
useEffect(() => {
|
||||
phase.current = {
|
||||
x: Math.random() * Math.PI * 2,
|
||||
y: Math.random() * Math.PI * 2,
|
||||
z: Math.random() * Math.PI * 2,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const lid = model.getObjectByName(REPAIR_CASE_LID_NODE_NAME);
|
||||
lidRef.current = lid ?? null;
|
||||
openedRotationZ.current = lid?.rotation.z ?? 0;
|
||||
|
||||
if (lid) {
|
||||
lid.rotation.z =
|
||||
openedRotationZ.current +
|
||||
(initialOpen.current
|
||||
? CASE_OPEN_ROTATION_OFFSET_Z
|
||||
: CASE_CLOSED_ROTATION_OFFSET_Z);
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
const lid = lidRef.current;
|
||||
if (!lid) return;
|
||||
|
||||
const targetRotation =
|
||||
openedRotationZ.current +
|
||||
(open ? CASE_OPEN_ROTATION_OFFSET_Z : CASE_CLOSED_ROTATION_OFFSET_Z);
|
||||
gsap.to(lid.rotation, {
|
||||
z: targetRotation,
|
||||
duration: REPAIR_CASE_ANIMATION_DURATION,
|
||||
ease: "power2.inOut",
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(lid.rotation);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useFrame(({ clock }, delta) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
group.getWorldPosition(worldPosition.current);
|
||||
const isNear =
|
||||
worldPosition.current.distanceTo(camera.position) <=
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
|
||||
const targetHeight = isNear ? REPAIR_CASE_FLOAT_HEIGHT : 0;
|
||||
const floatSpeed = isNear
|
||||
? REPAIR_CASE_FLOAT_UP_SPEED
|
||||
: REPAIR_CASE_FLOAT_DOWN_SPEED;
|
||||
|
||||
floatHeight.current = THREE.MathUtils.damp(
|
||||
floatHeight.current,
|
||||
targetHeight,
|
||||
floatSpeed,
|
||||
delta,
|
||||
);
|
||||
group.position.y = position[1] + floatHeight.current;
|
||||
|
||||
animationActiveRef.current = isNear;
|
||||
|
||||
if (animationActiveRef.current) {
|
||||
const time = clock.elapsedTime;
|
||||
group.rotation.x =
|
||||
rotation[0] +
|
||||
Math.sin(time * 0.7 + phase.current.x) * ROTATION_AMPLITUDE;
|
||||
group.rotation.y =
|
||||
rotation[1] +
|
||||
Math.sin(time * 0.55 + phase.current.y) * ROTATION_AMPLITUDE;
|
||||
group.rotation.z =
|
||||
rotation[2] +
|
||||
Math.sin(time * 0.8 + phase.current.z) * ROTATION_AMPLITUDE;
|
||||
return;
|
||||
}
|
||||
|
||||
group.rotation.x = THREE.MathUtils.damp(
|
||||
group.rotation.x,
|
||||
rotation[0],
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
delta,
|
||||
);
|
||||
group.rotation.y = THREE.MathUtils.damp(
|
||||
group.rotation.y,
|
||||
rotation[1],
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
delta,
|
||||
);
|
||||
group.rotation.z = THREE.MathUtils.damp(
|
||||
group.rotation.z,
|
||||
rotation[2],
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
delta,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={parsedScale}
|
||||
>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import { RepairCaseModel } from "@/components/three/gameplay/repairGame/RepairCaseModel";
|
||||
import {
|
||||
REPAIR_CASE_CLOSE_SOUND_PATH,
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
REPAIR_CASE_OPEN_SOUND_PATH,
|
||||
} from "@/data/repairGame/repairCaseConfig";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import type { Vector3Tuple } from "@/types/three";
|
||||
|
||||
interface RepairCaseObjectProps {
|
||||
position: Vector3Tuple;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function RepairCaseObject({
|
||||
position,
|
||||
open,
|
||||
onToggle,
|
||||
}: RepairCaseObjectProps): React.JSX.Element {
|
||||
return (
|
||||
<TriggerObject
|
||||
position={position}
|
||||
colliders="cuboid"
|
||||
label={open ? "Fermer la mallette" : "Ouvrir la mallette"}
|
||||
onTrigger={() => {
|
||||
AudioManager.getInstance().playSound(
|
||||
open ? REPAIR_CASE_CLOSE_SOUND_PATH : REPAIR_CASE_OPEN_SOUND_PATH,
|
||||
);
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
open={open}
|
||||
position={[0, -0.45, 0]}
|
||||
scale={1.5}
|
||||
/>
|
||||
</TriggerObject>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { Text } from "@react-three/drei";
|
||||
import { RepairCaseObject } from "@/components/three/gameplay/repairGame/RepairCaseObject";
|
||||
import { RepairModuleSlot } from "@/components/three/gameplay/repairGame/RepairModuleSlot";
|
||||
import {
|
||||
REPAIR_GAME_MODULE_SLOTS,
|
||||
REPAIR_GAME_ZONE_LABEL,
|
||||
REPAIR_GAME_ZONE_ORIGIN,
|
||||
REPAIR_GAME_ZONE_RADIUS,
|
||||
} from "@/data/repairGame/repairGameConfig";
|
||||
|
||||
export function RepairGameZone(): React.JSX.Element {
|
||||
const [caseOpen, setCaseOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0],
|
||||
0.025,
|
||||
REPAIR_GAME_ZONE_ORIGIN[2],
|
||||
]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<ringGeometry
|
||||
args={[REPAIR_GAME_ZONE_RADIUS - 0.08, REPAIR_GAME_ZONE_RADIUS, 96]}
|
||||
/>
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.72} />
|
||||
</mesh>
|
||||
|
||||
<mesh
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0],
|
||||
0.02,
|
||||
REPAIR_GAME_ZONE_ORIGIN[2],
|
||||
]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<circleGeometry args={[REPAIR_GAME_ZONE_RADIUS, 96]} />
|
||||
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
|
||||
</mesh>
|
||||
|
||||
<Text
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0],
|
||||
3.1,
|
||||
REPAIR_GAME_ZONE_ORIGIN[2] - 1.8,
|
||||
]}
|
||||
rotation={[0, 0, 0]}
|
||||
fontSize={0.55}
|
||||
maxWidth={5.5}
|
||||
textAlign="center"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
color="#f8fafc"
|
||||
outlineWidth={0.025}
|
||||
outlineColor="#0f172a"
|
||||
>
|
||||
{REPAIR_GAME_ZONE_LABEL}
|
||||
</Text>
|
||||
|
||||
<RepairCaseObject
|
||||
position={REPAIR_GAME_ZONE_ORIGIN}
|
||||
open={caseOpen}
|
||||
onToggle={() => setCaseOpen((value) => !value)}
|
||||
/>
|
||||
|
||||
{REPAIR_GAME_MODULE_SLOTS.map((slot) => (
|
||||
<RepairModuleSlot
|
||||
key={slot.label}
|
||||
label={slot.label}
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0] + slot.offset[0],
|
||||
REPAIR_GAME_ZONE_ORIGIN[1] + slot.offset[1],
|
||||
REPAIR_GAME_ZONE_ORIGIN[2] + slot.offset[2],
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import { useCallback, useState } from "react";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { REPAIR_GAME_MODEL_CATALOG } from "@/data/repairGame/repairGameModelCatalog";
|
||||
import type { ModelCatalogItem } from "@/data/repairGame/repairGameModelCatalog";
|
||||
import { useModelSelection } from "@/hooks/useModelSelection";
|
||||
import type { Vector3Tuple } from "@/types/three";
|
||||
|
||||
interface RepairModuleSlotProps {
|
||||
position: Vector3Tuple;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function RepairModuleSlot({
|
||||
position,
|
||||
label,
|
||||
}: RepairModuleSlotProps): React.JSX.Element {
|
||||
const [selectedModel, setSelectedModel] = useState<ModelCatalogItem | null>(
|
||||
null,
|
||||
);
|
||||
const [split, setSplit] = useState(false);
|
||||
const handleSelect = useCallback((model: ModelCatalogItem) => {
|
||||
setSelectedModel(model);
|
||||
setSplit(false);
|
||||
}, []);
|
||||
const selection = useModelSelection(REPAIR_GAME_MODEL_CATALOG, handleSelect);
|
||||
const triggerLabel = selectedModel
|
||||
? split
|
||||
? `Réassembler ${label}`
|
||||
: `Démonter ${label}`
|
||||
: `Choisir ${label}`;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<TriggerObject
|
||||
position={position}
|
||||
colliders="cuboid"
|
||||
label={triggerLabel}
|
||||
onTrigger={() => {
|
||||
if (selectedModel) {
|
||||
setSplit((value) => !value);
|
||||
return;
|
||||
}
|
||||
|
||||
selection.open();
|
||||
}}
|
||||
>
|
||||
{selectedModel ? (
|
||||
<ExplodableModel
|
||||
modelPath={selectedModel.path}
|
||||
split={split}
|
||||
position={[0, -0.35, 0]}
|
||||
scale={0.45}
|
||||
/>
|
||||
) : (
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[1, 0.18, 1]} />
|
||||
<meshStandardMaterial
|
||||
color="#38bdf8"
|
||||
emissive="#082f49"
|
||||
roughness={0.55}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
</TriggerObject>
|
||||
|
||||
{selection.isOpen ? (
|
||||
<Html position={[position[0], position[1] + 1.2, position[2]]} center>
|
||||
<div className="model-selector-panel">
|
||||
<strong>{label}</strong>
|
||||
<span>Fleches: choisir</span>
|
||||
<span>E/Enter: valider</span>
|
||||
<ul>
|
||||
{REPAIR_GAME_MODEL_CATALOG.map((model, index) => (
|
||||
<li
|
||||
key={model.path}
|
||||
className={
|
||||
index === selection.selectedIndex
|
||||
? "is-selected"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{model.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Html>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user