diff --git a/.agent/AGENT.md b/.agent/AGENT.md index 0f732ba..aa8477b 100644 --- a/.agent/AGENT.md +++ b/.agent/AGENT.md @@ -24,7 +24,8 @@ You are working on **La Fabrik**, an interactive 3D web experience built with Re ## Current Architecture Rules -- Scene objects live in `src/world/` and `src/components/3d/`. +- Scene objects live in `src/world/` and `src/components/three/`. +- Shared 3D components are grouped by domain under `src/components/three/models/`, `src/components/three/interaction/`, `src/components/three/gameplay/`, and `src/components/three/world/`. - HTML overlays live in `src/components/ui/`. - Shared static config lives in `src/data/`. - Debug tooling lives in `src/utils/debug/` and `src/hooks/debug/`. diff --git a/.agent/skills/debug.md b/.agent/skills/debug.md index 94ae992..be23b67 100644 --- a/.agent/skills/debug.md +++ b/.agent/skills/debug.md @@ -58,7 +58,7 @@ if (debug.active) { r3f-perf is loaded only in debug mode to avoid dependency issues in production: ```tsx -// src/utils/debug/DebugPerf.tsx +// src/components/debug/DebugPerf.tsx import { Suspense, lazy } from "react"; import { Debug } from "@/utils/debug/Debug"; diff --git a/.agent/skills/managers.md b/.agent/skills/managers.md index 3fea605..d7752e1 100644 --- a/.agent/skills/managers.md +++ b/.agent/skills/managers.md @@ -28,12 +28,13 @@ export class SomeManager { ## Managers in this project -| Manager | File | Role | -| ------------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `GameManager` | `src/stateManager/GameManager.ts` | Single source of truth. Owns phase, zone, mission, input lock, dialogue. Has `subscribe()` + `getState()`. | -| `CinematicManager` | `src/stateManager/CinematicManager.ts` | GSAP timelines. Locks/unlocks input via GameManager. | -| `AudioManager` | `src/stateManager/AudioManager.ts` | Music, SFX, spatial audio. Reads phase from GameManager. | -| `ZoneManager` | `src/stateManager/ZoneManager.ts` | Zone entry/exit detection, LOD triggers. Notifies GameManager of zone changes. | +| Manager | File | Role | +| -------------------- | ------------------------------------ | ----------------------------------------------------------------------------- | +| `AudioManager` | `src/managers/AudioManager.ts` | Music and SFX playback. | +| `InteractionManager` | `src/managers/InteractionManager.ts` | Focus, nearby, trigger, grab, and hand-grab interaction state. | +| `GameManager` | target-state only | Future single source of truth for phase, zone, mission, input lock, dialogue. | +| `CinematicManager` | target-state only | Future GSAP timeline orchestrator. | +| `ZoneManager` | target-state only | Future zone entry/exit detection and LOD triggers. | ## GameManager is the orchestrator diff --git a/README.md b/README.md index b7acd20..41d4525 100644 --- a/README.md +++ b/README.md @@ -48,25 +48,24 @@ la-fabrik/ │ └── sounds/ │ └── src/ - ├── world/ # Single persistent 3D world - │ ├── World.tsx # Main scene composition - │ ├── Map.tsx # Base map, always mounted + ├── world/ # Persistent 3D world composition + │ ├── World.tsx # Active scene composition + │ ├── GameMap.tsx # Map loading and octree collision │ ├── Lighting.tsx # Ambient, directional, point lights - │ ├── Environment.tsx # HDRI, fog, sky - │ ├── PostFX.tsx # Bloom, SSAO, chromatic aberration - │ ├── zones/ # Spatial zones — LOD per zone - │ │ ├── WorkshopZone.tsx - │ │ ├── PowerGridZone.tsx - │ │ ├── FarmZone.tsx - │ │ ├── SchoolZone.tsx - │ │ └── ResidentialZone.tsx + │ ├── Environment.tsx # Scene background / sky model + │ ├── GameMusic.tsx # Game scene music lifecycle + │ ├── debug/ # Debug-only test scene + │ │ └── TestMap.tsx │ └── player/ │ ├── FPSController.tsx # PointerLockControls + Rapier movement │ └── Crosshair.tsx │ ├── components/ - │ ├── 3d/ # Shared reusable 3D elements - │ │ └── InteractiveObject.tsx # Raycasting + outline wrapper + │ ├── three/ # Shared R3F components by domain + │ │ ├── gameplay/repairGame/ # Core repair gameplay prototype + │ │ ├── interaction/ # Trigger, grab, focus wrappers + │ │ ├── models/ # GLTF model components + │ │ └── world/ # Environment-specific 3D objects │ └── ui/ # HTML overlays — outside Canvas │ ├── NarrativeOverlay.tsx # Floating dialogues │ ├── MissionHUD.tsx # Current objective @@ -74,11 +73,9 @@ la-fabrik/ │ ├── CinematicBars.tsx # GSAP black bars │ └── LoadingScreen.tsx # Asset progress │ - ├── stateManager/ # All logic, state, orchestration - │ ├── GameManager.ts # Single source of truth: phase, zone, mission - │ ├── CinematicManager.ts # GSAP timelines, camera lock/unlock - │ ├── AudioManager.ts # Music, SFX, spatial audio - │ └── ZoneManager.ts # Zone detection, LOD triggers + ├── managers/ # Current singleton-style services + │ ├── AudioManager.ts # Music and SFX playback + │ └── InteractionManager.ts # Focus, nearby, grab state │ ├── hooks/ # React hooks — thin wrappers on managers │ ├── useGameState.ts # Subscribes to GameManager @@ -89,9 +86,10 @@ la-fabrik/ │ └── useLOD.ts │ ├── data/ - │ ├── zones.ts # { id, position, radius, missionId } - │ ├── dialogues.ts # Narrative scripts, PNJ states - │ └── missions.ts # Mission definitions, steps + │ ├── interaction/ # Interaction tuning + │ ├── player/ # Player tuning + │ ├── repairGame/ # Repair gameplay static config + │ └── world/ # Environment and lighting config │ ├── shaders/ │ └── hologram/ diff --git a/docs/technical/animation.md b/docs/technical/animation.md index 3bbd2b2..ac06e59 100644 --- a/docs/technical/animation.md +++ b/docs/technical/animation.md @@ -30,7 +30,7 @@ The project provides three main types of model instantiation: Use for GLTF models **without** skeleton/armature and no animations. ```tsx -import { SimpleModel } from "@/components/3d"; +import { SimpleModel } from "@/components/three/models/SimpleModel"; @@ -195,11 +198,9 @@ import { AnimatedModel, GrabbableObject } from "@/components/3d"; Or create an animated character that can be grabbed: ```tsx -import { - AnimatedModel, - GrabbableObject, - useAnimatedModel, -} from "@/components/3d"; +import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; +import { AnimatedModel } from "@/components/three/models/AnimatedModel"; +import { useAnimatedModel } from "@/components/three/models/useAnimatedModel"; // Controller that triggers animations when grabbed function AnimatedGrabber() { @@ -240,7 +241,7 @@ function AnimatedGrabber() { Objects that can be picked up by the player. ```tsx -import { GrabbableObject } from "@/components/3d"; +import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; @@ -255,7 +256,7 @@ import { GrabbableObject } from "@/components/3d"; Objects that trigger events when interacted with. ```tsx -import { TriggerObject } from "@/components/3d"; +import { TriggerObject } from "@/components/three/interaction/TriggerObject"; console.log("Interacted!")} + onPress={() => console.log("Interacted!")} > @@ -306,8 +309,8 @@ If animated models don't appear, they may be too small or too large. Try: ### Cloning -- `SimpleModel` uses `scene.clone()` for proper React lifecycle -- `AnimatedModel` uses the original scene directly to preserve SkinnedMesh + Armature structure +- `SimpleModel` memoizes a cloned scene for proper React lifecycle +- `AnimatedModel` memoizes a cloned scene and binds animations through a group ref ### Animation System @@ -326,13 +329,15 @@ This system intentionally avoids complex state machines (like Unity's Animator). ``` src/ -├── components/3d/ -│ ├── AnimatedModel.tsx # Animated model component + context -│ ├── SimpleModel.tsx # Static model component -│ ├── GrabbableObject.tsx # Pickable object -│ ├── TriggerObject.tsx # Trigger event object -│ ├── InteractableObject.tsx -│ └── index.ts # Central exports +├── components/three/ +│ ├── models/ +│ │ ├── AnimatedModel.tsx # Animated model component + context +│ │ ├── SimpleModel.tsx # Static model component +│ │ └── useAnimatedModel.ts # Animated model context hook +│ └── interaction/ +│ ├── GrabbableObject.tsx # Pickable object +│ ├── TriggerObject.tsx # Trigger event object +│ └── InteractableObject.tsx └── hooks/ └── useCharacterAnimation.ts # Animation hook (legacy) ``` diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index f25359b..87ad7c5 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -4,8 +4,9 @@ This document describes the code that exists today in the repository. ## Runtime Structure -- `src/main.tsx` mounts React and wraps the app in `BrowserRouter`. -- `src/App.tsx` declares the top-level routes: +- `src/main.tsx` mounts React. +- `src/App.tsx` mounts the TanStack `RouterProvider`. +- `src/router.tsx` declares the top-level routes: - `/` mounts the playable 3D scene, debug perf overlay, and HTML overlays. - `/editor` mounts the map editor page. - `src/world/World.tsx` composes the active scene, including: @@ -15,21 +16,21 @@ This document describes the code that exists today in the repository. - the player rig when the active camera mode is `player` - `src/world/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, and builds the collision octree. - `src/world/debug/TestMap.tsx` provides a debug-oriented interaction and physics map. -- `src/world/player/PlayerComponent.tsx` mounts the camera and controller. +- `src/world/player/Player.tsx` mounts the camera and controller. - `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, and interaction input. ## Interaction Model -- `src/stateManager/InteractionManager.ts` is the current interaction state source. -- `src/components/3d/InteractableObject.tsx` handles focus detection through distance and raycasting. -- `src/components/3d/TriggerObject.tsx` implements trigger-style interactions. -- `src/components/3d/GrabbableObject.tsx` implements hold-and-release interactions. +- `src/managers/InteractionManager.ts` is the current interaction state source. +- `src/components/three/interaction/InteractableObject.tsx` handles focus detection through distance and raycasting. +- `src/components/three/interaction/TriggerObject.tsx` implements trigger-style interactions. +- `src/components/three/interaction/GrabbableObject.tsx` implements hold-and-release interactions. - `src/hooks/useInteraction.ts` exposes the interaction snapshot to React UI. - `src/components/ui/InteractPrompt.tsx` shows the `E` prompt for trigger interactions. ## Audio -- `src/stateManager/AudioManager.ts` currently provides pooled one-shot sound playback. +- `src/managers/AudioManager.ts` currently provides pooled one-shot sound playback and looped music playback. - Trigger interactions may play audio directly through `AudioManager`. ## Debug System @@ -37,13 +38,20 @@ This document describes the code that exists today in the repository. - Debug mode is enabled with `?debug`. - `src/utils/debug/Debug.ts` owns the `lil-gui` instance and debug controls. - `src/hooks/debug/useCameraMode.ts` and `src/hooks/debug/useSceneMode.ts` subscribe to debug state. -- `src/utils/debug/DebugPerf.tsx` lazily mounts `r3f-perf` in debug mode. -- `src/utils/debug/scene/DebugHelpers.tsx` mounts debug helpers. -- `src/utils/debug/scene/DebugCameraControls.tsx` mounts the free debug camera. +- `src/components/debug/DebugPerf.tsx` lazily mounts `r3f-perf` in debug mode. +- `src/components/debug/scene/DebugHelpers.tsx` mounts debug helpers. +- `src/components/debug/scene/DebugCameraControls.tsx` mounts the free debug camera. + +## 3D Component Domains + +- `src/components/three/models/` contains reusable model loaders such as `SimpleModel`, `AnimatedModel`, and `ExplodableModel`. +- `src/components/three/interaction/` contains reusable interaction wrappers such as `InteractableObject`, `TriggerObject`, and `GrabbableObject`. +- `src/components/three/gameplay/repairGame/` contains the current core repair gameplay prototype: the repair case, repair game zone, and module slots. +- `src/components/three/world/` contains reusable world/environment objects such as `SkyModel`. ## Editor System -- `src/pages/editor/EditorPage.tsx` is the route-level editor page for `/editor`. +- `src/pages/editor/page.tsx` is the route-level editor page for `/editor`. - `src/components/editor/EditorControls.tsx` renders the HTML editor control panel. - `src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, shortcuts, and map rendering. - `src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls. diff --git a/docs/technical/hand-tracking.md b/docs/technical/hand-tracking.md index 45b3f96..38072d4 100644 --- a/docs/technical/hand-tracking.md +++ b/docs/technical/hand-tracking.md @@ -64,7 +64,7 @@ interface HandTrackingHand { ## Grab Targeting -The hand grab logic lives in `src/components/three/GrabbableObject.tsx`. +The hand grab logic lives in `src/components/three/interaction/GrabbableObject.tsx`. The object is moved toward the visual center of the hand. That center is computed from the bounding box of all landmarks: diff --git a/src/components/three/MainFeatureZone.tsx b/src/components/three/MainFeatureZone.tsx deleted file mode 100644 index a0a3388..0000000 --- a/src/components/three/MainFeatureZone.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from "react"; -import { Text } from "@react-three/drei"; -import { MainFeatureObject } from "@/components/three/MainFeatureObject"; -import { ModelSelectorPlaceholder } from "@/components/three/ModelSelectorPlaceholder"; - -const ZONE_ORIGIN = [10, 0.4, -8] as const; -const ZONE_RADIUS = 4.2; - -export function MainFeatureZone(): React.JSX.Element { - const [caseOpen, setCaseOpen] = useState(false); - - return ( - - - - - - - - - - - - - Pack de Relance Feature - - - setCaseOpen((value) => !value)} - /> - - - - - - ); -} diff --git a/src/components/three/RepairCaseModel.tsx b/src/components/three/RepairCaseModel.tsx deleted file mode 100644 index 4271cab..0000000 --- a/src/components/three/RepairCaseModel.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; -import { useGLTF } from "@react-three/drei"; -import gsap from "gsap"; -import * as THREE from "three"; -import type { Vector3Tuple } from "@/types/three"; - -interface RepairCaseModelProps { - modelPath: string; - open: boolean; - position?: Vector3Tuple; - rotation?: Vector3Tuple; - scale?: number | Vector3Tuple; -} - -const CASE_LID_NODE_NAME = "partiesup"; -const CASE_CLOSED_ROTATION_OFFSET_Z = 0; -const CASE_OPEN_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(115); -const CASE_ANIMATION_DURATION = 0.8; - -export function RepairCaseModel({ - modelPath, - open, - position = [0, 0, 0], - rotation = [0, 0, 0], - scale = 1, -}: RepairCaseModelProps): React.JSX.Element { - const { scene } = useGLTF(modelPath); - const model = useMemo(() => scene.clone(true), [scene]); - const lidRef = useRef(null); - const initialOpen = useRef(open); - const openedRotationZ = useRef(0); - const parsedScale = - typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale; - - useEffect(() => { - const lid = model.getObjectByName(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: CASE_ANIMATION_DURATION, - ease: "power2.inOut", - overwrite: true, - }); - - return () => { - gsap.killTweensOf(lid.rotation); - }; - }, [open]); - - return ( - - - - ); -} diff --git a/src/components/three/gameplay/repairGame/RepairCaseModel.tsx b/src/components/three/gameplay/repairGame/RepairCaseModel.tsx new file mode 100644 index 0000000..7a405bf --- /dev/null +++ b/src/components/three/gameplay/repairGame/RepairCaseModel.tsx @@ -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(null); + const lidRef = useRef(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 ( + + + + ); +} diff --git a/src/components/three/MainFeatureObject.tsx b/src/components/three/gameplay/repairGame/RepairCaseObject.tsx similarity index 52% rename from src/components/three/MainFeatureObject.tsx rename to src/components/three/gameplay/repairGame/RepairCaseObject.tsx index 6515bc9..a877e08 100644 --- a/src/components/three/MainFeatureObject.tsx +++ b/src/components/three/gameplay/repairGame/RepairCaseObject.tsx @@ -1,23 +1,24 @@ -import { TriggerObject } from "@/components/three/TriggerObject"; -import { RepairCaseModel } from "@/components/three/RepairCaseModel"; +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 MainFeatureObjectProps { +interface RepairCaseObjectProps { position: Vector3Tuple; open: boolean; onToggle: () => void; } -const CASE_MODEL_PATH = "/models/packderelance/model.gltf"; -const CASE_OPEN_SOUND_PATH = "/sounds/effect/open-malette.mp3"; -const CASE_CLOSE_SOUND_PATH = "/sounds/effect/close-malette.mp3"; - -export function MainFeatureObject({ +export function RepairCaseObject({ position, open, onToggle, -}: MainFeatureObjectProps): React.JSX.Element { +}: RepairCaseObjectProps): React.JSX.Element { return ( { AudioManager.getInstance().playSound( - open ? CASE_CLOSE_SOUND_PATH : CASE_OPEN_SOUND_PATH, + open ? REPAIR_CASE_CLOSE_SOUND_PATH : REPAIR_CASE_OPEN_SOUND_PATH, ); onToggle(); }} > + + + + + + + + + + + + {REPAIR_GAME_ZONE_LABEL} + + + setCaseOpen((value) => !value)} + /> + + {REPAIR_GAME_MODULE_SLOTS.map((slot) => ( + + ))} + + ); +} diff --git a/src/components/three/ModelSelectorPlaceholder.tsx b/src/components/three/gameplay/repairGame/RepairModuleSlot.tsx similarity index 78% rename from src/components/three/ModelSelectorPlaceholder.tsx rename to src/components/three/gameplay/repairGame/RepairModuleSlot.tsx index e47847f..0d1f80a 100644 --- a/src/components/three/ModelSelectorPlaceholder.tsx +++ b/src/components/three/gameplay/repairGame/RepairModuleSlot.tsx @@ -1,21 +1,21 @@ import { Html } from "@react-three/drei"; import { useCallback, useState } from "react"; -import { TriggerObject } from "@/components/three/TriggerObject"; -import { ExplodableModel } from "@/components/three/ExplodableModel"; -import { MAIN_FEATURE_MODEL_CATALOG } from "@/data/mainFeature/modelCatalog"; -import type { ModelCatalogItem } from "@/data/mainFeature/modelCatalog"; +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 ModelSelectorPlaceholderProps { +interface RepairModuleSlotProps { position: Vector3Tuple; label: string; } -export function ModelSelectorPlaceholder({ +export function RepairModuleSlot({ position, label, -}: ModelSelectorPlaceholderProps): React.JSX.Element { +}: RepairModuleSlotProps): React.JSX.Element { const [selectedModel, setSelectedModel] = useState( null, ); @@ -24,7 +24,7 @@ export function ModelSelectorPlaceholder({ setSelectedModel(model); setSplit(false); }, []); - const selection = useModelSelection(MAIN_FEATURE_MODEL_CATALOG, handleSelect); + const selection = useModelSelection(REPAIR_GAME_MODEL_CATALOG, handleSelect); const triggerLabel = selectedModel ? split ? `Réassembler ${label}` @@ -72,7 +72,7 @@ export function ModelSelectorPlaceholder({ Fleches: choisir E/Enter: valider
    - {MAIN_FEATURE_MODEL_CATALOG.map((model, index) => ( + {REPAIR_GAME_MODEL_CATALOG.map((model, index) => (
  • { folder .add( - params, + grabDebugParams, "stiffness", GRAB_STIFFNESS_MIN, GRAB_STIFFNESS_MAX, @@ -146,7 +146,7 @@ export function GrabbableObject({ .name("Hold stiffness"); folder .add( - params, + grabDebugParams, "throwBoost", GRAB_THROW_BOOST_MIN, GRAB_THROW_BOOST_MAX, @@ -155,7 +155,7 @@ export function GrabbableObject({ .name("Throw boost"); folder .add( - params, + grabDebugParams, "holdDistance", GRAB_HOLD_DISTANCE_MIN, GRAB_HOLD_DISTANCE_MAX, @@ -211,7 +211,8 @@ export function GrabbableObject({ ? 0 : (fistHand.z - handHoldStartZ.current) * HAND_DEPTH_SENSITIVITY; const holdDistance = THREE.MathUtils.clamp( - (handHoldDistance.current ?? params.holdDistance) + depthOffset, + (handHoldDistance.current ?? grabDebugParams.holdDistance) + + depthOffset, GRAB_HOLD_DISTANCE_MIN, GRAB_HOLD_DISTANCE_MAX, ); @@ -221,12 +222,14 @@ export function GrabbableObject({ .addScaledVector(_handDirection, holdDistance); } else { camera.getWorldDirection(_holdTarget); - _holdTarget.multiplyScalar(params.holdDistance).add(camera.position); + _holdTarget + .multiplyScalar(grabDebugParams.holdDistance) + .add(camera.position); } _velocity .subVectors(_holdTarget, _currentPos) - .multiplyScalar(params.stiffness); + .multiplyScalar(grabDebugParams.stiffness); rbRef.current.setLinvel( { x: _velocity.x, y: _velocity.y, z: _velocity.z }, @@ -255,15 +258,15 @@ export function GrabbableObject({ isHolding.current = false; if ( !rbRef.current || - params.throwBoost === GRAB_THROW_BOOST_DEFAULT + grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT ) return; const v = rbRef.current.linvel(); rbRef.current.setLinvel( { - x: v.x * params.throwBoost, - y: v.y * params.throwBoost, - z: v.z * params.throwBoost, + x: v.x * grabDebugParams.throwBoost, + y: v.y * grabDebugParams.throwBoost, + z: v.z * grabDebugParams.throwBoost, }, true, ); diff --git a/src/components/three/InteractableObject.tsx b/src/components/three/interaction/InteractableObject.tsx similarity index 100% rename from src/components/three/InteractableObject.tsx rename to src/components/three/interaction/InteractableObject.tsx diff --git a/src/components/three/TriggerObject.tsx b/src/components/three/interaction/TriggerObject.tsx similarity index 87% rename from src/components/three/TriggerObject.tsx rename to src/components/three/interaction/TriggerObject.tsx index bd6fb8c..cd2ea29 100644 --- a/src/components/three/TriggerObject.tsx +++ b/src/components/three/interaction/TriggerObject.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useGLTF } from "@react-three/drei"; import { RigidBody } from "@react-three/rapier"; -import { InteractableObject } from "@/components/three/InteractableObject"; +import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { TRIGGER_DEFAULT_COLLIDERS, TRIGGER_DEFAULT_LABEL, @@ -28,7 +28,7 @@ interface TriggerObjectProps { onTrigger?: () => void; } -let _spawnCounter = 0; +let spawnCounter = 0; function SpawnedModelInstance({ path, @@ -38,7 +38,9 @@ function SpawnedModelInstance({ position: Vector3Tuple; }): React.JSX.Element { const { scene } = useGLTF(path); - return ; + const model = useMemo(() => scene.clone(true), [scene]); + + return ; } export function TriggerObject({ @@ -76,7 +78,7 @@ export function TriggerObject({ ]; setSpawned((prev) => [ ...prev, - { id: ++_spawnCounter, position: spawnPos }, + { id: ++spawnCounter, position: spawnPos }, ]); } }} diff --git a/src/components/three/AnimatedModel.tsx b/src/components/three/models/AnimatedModel.tsx similarity index 65% rename from src/components/three/AnimatedModel.tsx rename to src/components/three/models/AnimatedModel.tsx index 099f14c..2770223 100644 --- a/src/components/three/AnimatedModel.tsx +++ b/src/components/three/models/AnimatedModel.tsx @@ -1,8 +1,11 @@ -/* eslint-disable react-hooks/immutability */ -import { createContext, useRef, useState, useEffect, useCallback } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useGLTF, useAnimations } from "@react-three/drei"; import type { AnimationAction } from "three"; import * as THREE from "three"; +import { + AnimatedModelContext, + type AnimatedModelContextValue, +} from "@/components/three/models/useAnimatedModel"; import type { Vector3Tuple } from "@/types/three"; export interface AnimatedModelConfig { @@ -19,22 +22,6 @@ export interface AnimatedModelConfig { onAnimationEnd?: (animationName: string) => void; } -export interface AnimatedModelContextValue { - play: (name: string, fade?: number) => void; - stop: (fade?: number) => void; - fadeTo: (name: string, fade?: number) => void; - currentAnimation: string; - isReady: boolean; - setSpeed: (speed: number) => void; - names: string[]; -} - -const AnimatedModelContext = createContext( - null, -); - -export { AnimatedModelContext }; - interface AnimatedModelProps extends AnimatedModelConfig { children?: React.ReactNode; } @@ -53,19 +40,18 @@ export function AnimatedModel({ children, }: AnimatedModelProps): React.JSX.Element { const groupRef = useRef(null); - - void groupRef; const { scene, animations } = useGLTF(modelPath); - const { actions, names, mixer } = useAnimations(animations, scene); + const model = useMemo(() => scene.clone(true), [scene]); + const { actions, names, mixer } = useAnimations(animations, groupRef); const [currentAnim, setCurrentAnim] = useState(defaultAnimation); - const [isReady, setIsReady] = useState(false); + const isReady = names.length > 0; useEffect(() => { - if (mixer) { - mixer.timeScale = speed; - } - }, [mixer, speed]); + Object.values(actions).forEach((action) => { + action?.setEffectiveTimeScale(speed); + }); + }, [actions, speed]); useEffect(() => { const handleFinished = (e: { action: AnimationAction }) => { @@ -123,39 +109,27 @@ export function AnimatedModel({ const setSpeed = useCallback( (newSpeed: number) => { - if (mixer) { - mixer.timeScale = newSpeed; - } + Object.values(actions).forEach((action) => { + action?.setEffectiveTimeScale(newSpeed); + }); }, - [mixer], + [actions], ); useEffect(() => { if (!autoPlay || names.length === 0) { - console.log("[AnimatedModel] No animation found in model"); return; } - console.log(`[AnimatedModel] Available animations: ${names.join(", ")}`); - let defaultAction = actions[defaultAnimation as string]; if (!defaultAction && names.length > 0) { - console.log( - `[AnimatedModel] "${defaultAnimation}" not found, using: ${names[0]}`, - ); defaultAction = actions[names[0] as string]; } if (defaultAction) { defaultAction.play(); - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsReady(true); - - setCurrentAnim(defaultAction.getClip().name); onLoaded?.(); - } else { - console.log("[AnimatedModel] No available animation in actions"); } }, [actions, defaultAnimation, names, autoPlay, onLoaded]); @@ -169,21 +143,19 @@ export function AnimatedModel({ names, }; - useEffect(() => { - scene.position.set(...position); - scene.rotation.set( - (rotation[0] * Math.PI) / 180, - (rotation[1] * Math.PI) / 180, - (rotation[2] * Math.PI) / 180, - ); - const s = - typeof scale === "number" ? [scale, scale, scale] : (scale ?? [1, 1, 1]); - scene.scale.set(s[0] ?? 1, s[1] ?? 1, s[2] ?? 1); - }, [scene, position, rotation, scale]); + const parsedScale = + typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale; return ( - + + + {children} ); diff --git a/src/components/three/ExplodableModel.tsx b/src/components/three/models/ExplodableModel.tsx similarity index 100% rename from src/components/three/ExplodableModel.tsx rename to src/components/three/models/ExplodableModel.tsx diff --git a/src/components/three/SimpleModel.tsx b/src/components/three/models/SimpleModel.tsx similarity index 89% rename from src/components/three/SimpleModel.tsx rename to src/components/three/models/SimpleModel.tsx index 6c7b9c2..4d07416 100644 --- a/src/components/three/SimpleModel.tsx +++ b/src/components/three/models/SimpleModel.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useGLTF } from "@react-three/drei"; import type { Vector3Tuple } from "@/types/three"; @@ -24,6 +25,7 @@ export function SimpleModel({ children, }: SimpleModelProps): React.JSX.Element { const { scene } = useGLTF(modelPath); + const model = useMemo(() => scene.clone(true), [scene]); const parsedScale = typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale; @@ -32,7 +34,7 @@ export function SimpleModel({ {children ?? ( diff --git a/src/components/three/models/useAnimatedModel.ts b/src/components/three/models/useAnimatedModel.ts new file mode 100644 index 0000000..8fee82a --- /dev/null +++ b/src/components/three/models/useAnimatedModel.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; + +export interface AnimatedModelContextValue { + play: (name: string, fade?: number) => void; + stop: (fade?: number) => void; + fadeTo: (name: string, fade?: number) => void; + currentAnimation: string; + isReady: boolean; + setSpeed: (speed: number) => void; + names: string[]; +} + +export const AnimatedModelContext = + createContext(null); + +export function useAnimatedModel(): AnimatedModelContextValue { + const context = useContext(AnimatedModelContext); + if (!context) { + throw new Error("useAnimatedModel must be used inside AnimatedModel"); + } + + return context; +} diff --git a/src/components/three/SkyModel.tsx b/src/components/three/world/SkyModel.tsx similarity index 100% rename from src/components/three/SkyModel.tsx rename to src/components/three/world/SkyModel.tsx diff --git a/src/data/docs/docsTranslations.ts b/src/data/docs/docsTranslations.ts index 24f16a0..18385c7 100644 --- a/src/data/docs/docsTranslations.ts +++ b/src/data/docs/docsTranslations.ts @@ -106,9 +106,9 @@ Ce document décrit le code réellement présent aujourd'hui dans le dépôt. ## Modèle d'interaction - \`src/managers/InteractionManager.ts\` est la source d'état actuelle des interactions. -- \`src/components/three/InteractableObject.tsx\` gère la détection de focus par distance et raycasting. -- \`src/components/three/TriggerObject.tsx\` implémente les interactions de type trigger. -- \`src/components/three/GrabbableObject.tsx\` implémente les interactions saisir / relâcher. +- \`src/components/three/interaction/InteractableObject.tsx\` gère la détection de focus par distance et raycasting. +- \`src/components/three/interaction/TriggerObject.tsx\` implémente les interactions de type trigger. +- \`src/components/three/interaction/GrabbableObject.tsx\` implémente les interactions saisir / relâcher. - \`src/hooks/useInteraction.ts\` expose un snapshot d'interaction à l'UI React. - \`src/components/ui/InteractPrompt.tsx\` affiche le prompt \`E\` pour les interactions trigger. diff --git a/src/data/repairGame/repairCaseConfig.ts b/src/data/repairGame/repairCaseConfig.ts new file mode 100644 index 0000000..60dbcbc --- /dev/null +++ b/src/data/repairGame/repairCaseConfig.ts @@ -0,0 +1,15 @@ +export const REPAIR_CASE_MODEL_PATH = "/models/packderelance/model.gltf"; +export const REPAIR_CASE_OPEN_SOUND_PATH = "/sounds/effect/open-malette.mp3"; +export const REPAIR_CASE_CLOSE_SOUND_PATH = "/sounds/effect/close-malette.mp3"; + +export const REPAIR_CASE_LID_NODE_NAME = "partiesup"; +export const REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES = 0; +export const REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES = 115; +export const REPAIR_CASE_ANIMATION_DURATION = 0.8; + +export const REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE = 5; +export const REPAIR_CASE_FLOAT_HEIGHT = 1; +export const REPAIR_CASE_FLOAT_UP_SPEED = 2.4; +export const REPAIR_CASE_FLOAT_DOWN_SPEED = 1.8; +export const REPAIR_CASE_ROTATION_RESET_SPEED = 3; +export const REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES = 5; diff --git a/src/data/repairGame/repairGameConfig.ts b/src/data/repairGame/repairGameConfig.ts new file mode 100644 index 0000000..f92d896 --- /dev/null +++ b/src/data/repairGame/repairGameConfig.ts @@ -0,0 +1,11 @@ +import type { Vector3Tuple } from "@/types/three"; + +export const REPAIR_GAME_ZONE_ORIGIN: Vector3Tuple = [10, 0.4, -8]; +export const REPAIR_GAME_ZONE_RADIUS = 4.2; +export const REPAIR_GAME_ZONE_LABEL = "Pack de Relance Feature"; + +export const REPAIR_GAME_MODULE_SLOTS = [ + { label: "Module A", offset: [-2.2, 0, 2.2] }, + { label: "Module B", offset: [0, 0, 2.6] }, + { label: "Module C", offset: [2.2, 0, 2.2] }, +] satisfies Array<{ label: string; offset: Vector3Tuple }>; diff --git a/src/data/mainFeature/modelCatalog.ts b/src/data/repairGame/repairGameModelCatalog.ts similarity index 95% rename from src/data/mainFeature/modelCatalog.ts rename to src/data/repairGame/repairGameModelCatalog.ts index ed10ee0..60dcbb2 100644 --- a/src/data/mainFeature/modelCatalog.ts +++ b/src/data/repairGame/repairGameModelCatalog.ts @@ -3,7 +3,7 @@ export interface ModelCatalogItem { path: string; } -export const MAIN_FEATURE_MODEL_CATALOG: ModelCatalogItem[] = [ +export const REPAIR_GAME_MODEL_CATALOG: ModelCatalogItem[] = [ { name: "Electricienne", path: "/models/elecsimple/model.gltf" }, { name: "Electricienne complete", path: "/models/elec/model.gltf" }, { name: "Eolienne", path: "/models/eolienne/model.gltf" }, diff --git a/src/hooks/useCharacterAnimation.ts b/src/hooks/useCharacterAnimation.ts index c9dcdb5..02aac0f 100644 --- a/src/hooks/useCharacterAnimation.ts +++ b/src/hooks/useCharacterAnimation.ts @@ -1,5 +1,4 @@ -/* eslint-disable react-hooks/immutability */ -import { useRef, useEffect, useState, useCallback } from "react"; +import { useRef, useEffect, useState, useCallback, useMemo } from "react"; import { useGLTF, useAnimations } from "@react-three/drei"; import type { AnimationAction, AnimationMixer } from "three"; import * as THREE from "three"; @@ -36,6 +35,7 @@ export function useCharacterAnimation( const groupRef = useRef(null); const { scene, animations } = useGLTF(modelPath); + const model = useMemo(() => scene.clone(true), [scene]); const { actions, names, mixer } = useAnimations(animations, groupRef); const [currentAnimation, setCurrentAnimation] = useState(initialAnimation); @@ -78,11 +78,11 @@ export function useCharacterAnimation( const setAnimationSpeed = useCallback( (speed: number) => { - if (mixer) { - mixer.timeScale = speed; - } + Object.values(actions).forEach((action) => { + action?.setEffectiveTimeScale(speed); + }); }, - [mixer], + [actions], ); useEffect(() => { @@ -93,7 +93,7 @@ export function useCharacterAnimation( }, [actions, initialAnimation]); return { - scene, + scene: model, actions, names, mixer, diff --git a/src/hooks/useModelSelection.ts b/src/hooks/useModelSelection.ts index 05b9a24..52665b6 100644 --- a/src/hooks/useModelSelection.ts +++ b/src/hooks/useModelSelection.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import type { ModelCatalogItem } from "@/data/mainFeature/modelCatalog"; +import type { ModelCatalogItem } from "@/data/repairGame/repairGameModelCatalog"; interface UseModelSelectionResult { isOpen: boolean; diff --git a/src/world/Environment.tsx b/src/world/Environment.tsx index bd71cfe..152234f 100644 --- a/src/world/Environment.tsx +++ b/src/world/Environment.tsx @@ -3,7 +3,7 @@ import { PHYSICS_SCENE_BACKGROUND_COLOR, } from "@/data/world/environmentConfig"; import { useSceneMode } from "@/hooks/debug/useSceneMode"; -import { SkyModel } from "@/components/three/SkyModel"; +import { SkyModel } from "@/components/three/world/SkyModel"; export function Environment(): React.JSX.Element { const sceneMode = useSceneMode(); diff --git a/src/world/debug/TestMap.tsx b/src/world/debug/TestMap.tsx index ee1a3ff..8e87a42 100644 --- a/src/world/debug/TestMap.tsx +++ b/src/world/debug/TestMap.tsx @@ -1,9 +1,9 @@ import { useRef } from "react"; import * as THREE from "three"; import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; -import { GrabbableObject } from "@/components/three/GrabbableObject"; -import { MainFeatureZone } from "@/components/three/MainFeatureZone"; -import { TriggerObject } from "@/components/three/TriggerObject"; +import { RepairGameZone } from "@/components/three/gameplay/repairGame/RepairGameZone"; +import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; +import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS, TEST_SCENE_FLOOR_POSITION, @@ -85,7 +85,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { - + {/* Temporary: re-enable when Git LFS downloads are available again.