import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import * as THREE from "three"; import { useFrame, useThree } from "@react-three/fiber"; import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap"; import { EbikeSpeedmeter } from "@/components/ebike/EbikeSpeedmeter"; import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds"; import { getObjectBottomOffset, useTerrainHeightSampler, } from "@/hooks/three/useTerrainHeight"; import { animateCameraTransformTransition } from "@/world/GameCinematics"; import { useGameStore } from "@/managers/stores/useGameStore"; import { EBIKE_CAMERA_TRANSFORM, EBIKE_DROP_PLAYER_TRANSFORM, EBIKE_WORLD_SCALE, EBIKE_WORLD_ROTATION_Y, } from "@/data/ebike/ebikeConfig"; import type { Vector3Tuple } from "@/types/three/three"; import "@/types/ebike/ebikeWindow"; const EBIKE_MODEL_PATH = "/models/ebike/model.gltf"; // Reusable vectors — allocated once to avoid per-frame GC pressure const _phareWorldPos = new THREE.Vector3(); const _bikeForward = new THREE.Vector3(); const _aimDir = new THREE.Vector3(); const _up = new THREE.Vector3(0, 1, 0); interface EbikeProps { position: Vector3Tuple; /** * When true (default), the parked position is snapped to the world terrain * height. Pass false in test scenes that don't render the world terrain so * the bike stays at the explicit Y of {@link position} instead of floating * at the (invisible) terrain height. */ snapToTerrain?: boolean; } export function Ebike({ position, snapToTerrain = true, }: EbikeProps): React.JSX.Element { const groupRef = useRef(null); const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, { scope: "Ebike", position: position, }); const model = useClonedObject(scene); const terrainHeight = useTerrainHeightSampler(); const parkedPosition = useMemo(() => { const [x, y, z] = position; const height = snapToTerrain ? (terrainHeight.getHeight(x, z) ?? y) : y; const bottomOffset = getObjectBottomOffset(model, [ EBIKE_WORLD_SCALE, EBIKE_WORLD_SCALE, EBIKE_WORLD_SCALE, ]); return [x, height + bottomOffset, z]; }, [model, position, snapToTerrain, terrainHeight]); const movementMode = useGameStore((state) => state.player.movementMode); const mainState = useGameStore((state) => state.mainState); const ebikeStep = useGameStore((state) => state.ebike.currentStep); const setMissionStep = useGameStore((state) => state.setMissionStep); const camera = useThree((state) => state.camera); const threeScene = useThree((state) => state.scene); const updateEbikeSounds = useEbikeSounds(); const repairGameOwnsEbikeModel = mainState === "ebike" && ebikeStep !== "locked" && ebikeStep !== "waiting" && ebikeStep !== "inspected"; // Map active mainState to target repair zone coordinate const destPos = useMemo(() => { switch (mainState) { case "ebike": return { x: 8, y: 0, z: -6 }; case "pylon": return { x: 64, y: 0, z: -66 }; case "farm": return { x: -24, y: 0, z: 42 }; default: return undefined; } }, [mainState]); // Throttled GPS start position to optimize pathfinding A* algorithm execution const [gpsStartPos, setGpsStartPos] = useState<{ x: number; y: number; z: number; }>({ x: parkedPosition[0], y: parkedPosition[1], z: parkedPosition[2], }); const lastGpsUpdatePos = useRef( new THREE.Vector3(...parkedPosition), ); // Use ref for internal state, and state for debug visualization (to avoid ref access during render) const restingPositionRef = useRef([ parkedPosition[0], parkedPosition[1], parkedPosition[2], ]); const restingRotationRef = useRef(EBIKE_WORLD_ROTATION_Y); const forkRef = useRef(null); const phareRef = useRef(null); const headlightRef = useRef(null); // SpotLight target — must live in the scene to define the cone direction. const headlightTarget = useMemo(() => new THREE.Object3D(), []); // Original quaternion of the Fourche node — rotation is applied on top of this. const forkInitialQuatRef = useRef(new THREE.Quaternion()); // Smoothed steer angle for the fork (avoids direct Euler manipulation). const forkAngleRef = useRef(0); // Ref copy of movementMode — useFrame closures can capture stale React state. const movementModeRef = useRef(movementMode); // Becomes true the first time the player mounts. After that, dismounting // must NOT reset position back to the original spawn point. const hasRiddenRef = useRef(false); // State for debug visualization (synced from refs during useFrame) const [showCameraPoints, setShowCameraPoints] = useState(true); // Keep movementModeRef in sync — useFrame closures capture React state at // render time and can become stale between renders. useEffect(() => { movementModeRef.current = movementMode; }, [movementMode]); // SpotLight target must be in the scene to define the cone direction. useEffect(() => { threeScene.add(headlightTarget); return () => { threeScene.remove(headlightTarget); }; }, [threeScene, headlightTarget]); // Link the target to the SpotLight once it mounts. useEffect(() => { if (headlightRef.current) { headlightRef.current.target = headlightTarget; } }, [headlightTarget]); useEffect(() => { if (movementMode === "ebike") { // Player just mounted — mark as ridden so we never reset position again. hasRiddenRef.current = true; return; } if (hasRiddenRef.current) { // Player dismounted: keep the position the bike was left at. // Just make sure the window vars are up to date for the next mount. window.ebikeParkedPosition = restingPositionRef.current; window.ebikeParkedRotation = restingRotationRef.current; return; } // Bike has never been ridden yet — safe to (re)place it at the spawn point. // This also fires when parkedPosition recalculates (e.g. terrain loads late). restingPositionRef.current = parkedPosition; restingRotationRef.current = EBIKE_WORLD_ROTATION_Y; lastGpsUpdatePos.current.set(...parkedPosition); if (groupRef.current) { groupRef.current.position.set(...parkedPosition); groupRef.current.rotation.set(0, EBIKE_WORLD_ROTATION_Y, 0); } window.ebikeParkedPosition = parkedPosition; window.ebikeParkedRotation = EBIKE_WORLD_ROTATION_Y; }, [movementMode, parkedPosition]); useEffect(() => { if (!model) return; let forkNode: THREE.Object3D | null = null; model.traverse((child) => { if (child.name.toLowerCase() === "fourche") forkNode = child; if (child.name === "Phare") phareRef.current = child; }); if (forkNode) { forkRef.current = forkNode; // Snapshot the rest-pose quaternion — steering is applied on top of this. forkInitialQuatRef.current.copy((forkNode as THREE.Object3D).quaternion); forkAngleRef.current = 0; console.log("[Ebike] Fork found:", (forkNode as THREE.Object3D).name); } else { const names: string[] = []; model.traverse((c) => { if (c.name) names.push(c.name); }); console.warn("[Ebike] Fork not found. All nodes:", names); } }, [model]); useEffect(() => { if (!model) return; model.traverse((child) => { if (child instanceof THREE.Mesh) { child.castShadow = true; child.receiveShadow = true; } }); }, [model]); useEffect(() => { window.ebikeVisualGroup = groupRef; window.ebikeParkedPosition = restingPositionRef.current; window.ebikeParkedRotation = restingRotationRef.current; return () => { window.ebikeVisualGroup = null; window.ebikeParkedPosition = null; window.ebikeParkedRotation = null; }; }, []); useFrame((_, delta) => { // ── SpotLight headlight — tune the constants below ──────────────────────── // ── SpotLight headlight — tune these four constants ─────────────────────── const LIGHT_OFFSET_X = -0.7; // position : left(-) / right(+) const LIGHT_OFFSET_Y = 1.5; // position : down(-) / up(+) const LIGHT_OFFSET_Z = 0; // position : backward(-) / forward(+) const LIGHT_AIM_DEG = 90; // aim rotation around Y : 0=forward, -90=left, +90=right const LIGHT_TARGET_DIST = 20; // metres devant la position de la lumière // ───────────────────────────────────────────────────────────────────────── if (headlightRef.current && phareRef.current && groupRef.current) { phareRef.current.getWorldPosition(_phareWorldPos); groupRef.current.getWorldDirection(_bikeForward); // Position offset in bike-local space (no GC — reusing module-level vectors) const right = _bikeForward.clone().cross(_up).normalize(); _phareWorldPos .addScaledVector(right, LIGHT_OFFSET_X) .addScaledVector(_up, LIGHT_OFFSET_Y) .addScaledVector(_bikeForward, LIGHT_OFFSET_Z); headlightRef.current.position.copy(_phareWorldPos); // Aim direction: rotate forward around Y by LIGHT_AIM_DEG _aimDir .copy(_bikeForward) .applyAxisAngle(_up, THREE.MathUtils.degToRad(LIGHT_AIM_DEG)); headlightTarget.position .copy(_phareWorldPos) .addScaledVector(_aimDir, LIGHT_TARGET_DIST); headlightTarget.updateMatrixWorld(); } // ────────────────────────────────────────────────────────────────────────── if (groupRef.current) { // Use the ref — not the React state — to avoid stale closure bugs in // R3F's frame loop (the state value may not update until the next render). if (movementModeRef.current === "ebike") { // Sound plays whenever the bike is actually moving (speedFactor > 5 %), // not only while the input key is held. updateEbikeSounds({ mounted: true, driving: (window.ebikeSpeedFactor ?? 0) > 0.05, breakdown: window.ebikeBreakdownActive === true, }); restingPositionRef.current = [ groupRef.current.position.x, groupRef.current.position.y, groupRef.current.position.z, ]; restingRotationRef.current = groupRef.current.rotation.y; // ── Fork steering via quaternion ────────────────────────────────────── // We rotate around the fork's LOCAL Y axis (steering tube) by composing // a fresh quaternion on top of the rest-pose snapshot taken at load time. // This is axis-agnostic: correct regardless of how Blender exported the node. // Tune FORK_ANGLE (radians) or negate it if the visual direction is wrong. const FORK_ANGLE = 0.12; // 10° const steerFactor = window.ebikeSteerFactor ?? 0; if (forkRef.current) { // Smooth the angle separately so we can apply it cleanly each frame. forkAngleRef.current = THREE.MathUtils.lerp( forkAngleRef.current, steerFactor * FORK_ANGLE, 12 * delta, ); // Build steer quat around LOCAL Y of the fork node. const steerQuat = new THREE.Quaternion().setFromAxisAngle( new THREE.Vector3(0, 1, 0), forkAngleRef.current, ); // Apply on top of rest-pose: Q_final = Q_rest × Q_steer forkRef.current.quaternion.multiplyQuaternions( forkInitialQuatRef.current, steerQuat, ); } // Throttled GPS start position update to prevent performance loss const currentPos = groupRef.current.position; if (currentPos.distanceTo(lastGpsUpdatePos.current) > 2.0) { lastGpsUpdatePos.current.copy(currentPos); setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z }); } // Sync debug visualization state (throttled to avoid excessive re-renders) // Debug visualization positions are derived elsewhere when needed. } else { updateEbikeSounds({ mounted: false, driving: false, breakdown: false }); groupRef.current.position.set(...restingPositionRef.current); groupRef.current.rotation.set(0, restingRotationRef.current, 0); // Reset fork to rest-pose when parked if (forkRef.current) { forkRef.current.quaternion.copy(forkInitialQuatRef.current); forkAngleRef.current = 0; } } window.ebikeParkedPosition = restingPositionRef.current; window.ebikeParkedRotation = restingRotationRef.current; } }); const interactionLabel = mainState === "ebike" ? "Lancer le repair game" : movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"; // Hide the interact prompt while the player is actively riding the bike // (driving input pressed) so the "Descendre du bike" label doesn't // pollute the view. The prompt comes back the moment the bike comes to // a stop. window.ebikeDriveInputActive is published every frame by // PlayerController based on whether a movement key is currently held. const [isEbikeDriving, setIsEbikeDriving] = useState(false); useFrame(() => { const driving = movementMode === "ebike" && window.ebikeDriveInputActive === true; if (driving !== isEbikeDriving) setIsEbikeDriving(driving); }); const showInteractPrompt = !isEbikeDriving; const handleInteract = useCallback((): void => { if (window.ebikeBreakdownActive === true) return; if (movementMode === "walk") { if ( mainState === "ebike" && (ebikeStep === "locked" || ebikeStep === "waiting") ) { setMissionStep("ebike", "inspected"); return; } if (mainState === "ebike" && ebikeStep === "inspected") { setMissionStep("ebike", "fragmented"); return; } const cameraOffset = new THREE.Vector3( ...EBIKE_CAMERA_TRANSFORM.position, ); cameraOffset.applyAxisAngle( new THREE.Vector3(0, 1, 0), restingRotationRef.current, ); const targetCamPos: Vector3Tuple = [ restingPositionRef.current[0] + cameraOffset.x, restingPositionRef.current[1] + cameraOffset.y, restingPositionRef.current[2] + cameraOffset.z, ]; const targetRotation: Vector3Tuple = [ EBIKE_CAMERA_TRANSFORM.rotation[0], EBIKE_CAMERA_TRANSFORM.rotation[1] + THREE.MathUtils.radToDeg(restingRotationRef.current), EBIKE_CAMERA_TRANSFORM.rotation[2], ]; animateCameraTransformTransition( targetCamPos, targetRotation, 1, () => { useGameStore.getState().setPlayerMovementMode("ebike"); }, { lockInput: false }, ); } else { const currentPos = new THREE.Vector3(); if (groupRef.current) { groupRef.current.getWorldPosition(currentPos); } else { currentPos.set(...position); } const targetCamPos: Vector3Tuple = [ currentPos.x + EBIKE_DROP_PLAYER_TRANSFORM.position[0], currentPos.y + EBIKE_DROP_PLAYER_TRANSFORM.position[1], currentPos.z + EBIKE_DROP_PLAYER_TRANSFORM.position[2], ]; // Get camera's current rotation in degrees so we keep the exact orientation during dismount const currentEuler = new THREE.Euler().setFromQuaternion( camera.quaternion, "YXZ", ); const targetRotation: Vector3Tuple = [ THREE.MathUtils.radToDeg(currentEuler.x), THREE.MathUtils.radToDeg(currentEuler.y), THREE.MathUtils.radToDeg(currentEuler.z), ]; animateCameraTransformTransition( targetCamPos, targetRotation, 1, () => { useGameStore.getState().setPlayerMovementMode("walk"); }, { lockInput: false }, ); } }, [movementMode, mainState, ebikeStep, setMissionStep, camera, position]); // Store handleInteract in a ref for use in debug folder callback const handleInteractRef = useRef(handleInteract); useEffect(() => { handleInteractRef.current = handleInteract; }, [handleInteract]); // Mutable object for lil-gui binding const debugState = useRef({ showCameraPoints: true }); useDebugFolder("Ebike", (folder) => { folder .add(debugState.current, "showCameraPoints") .name("Show Camera Points") .onChange((value: boolean) => { setShowCameraPoints(value); }); folder .add({ toggleRide: () => handleInteractRef.current() }, "toggleRide") .name("Monter / Descendre"); }); return ( <> {!repairGameOwnsEbikeModel ? ( {/* radius 20 → ~7 unités monde (scale 0.35). Sphère omnidirectionnelle pour que le raycast fonctionne quelle que soit l'orientation de la caméra (montée ou à pied). */} {showInteractPrompt ? ( ) : null} {/* GPS + Speedmeter – same group so they are perfectly co-localised. GPS: full circle (Fresnel mask), renderOrder 10 000 Speedmeter: upper-half arc overlay, renderOrder 10 001 rotation: Math.PI/2 radians = 90° (NOT the number 90 which = ~116.6°) */} ) : null} {/* SpotLight headlight — cone aimed forward, position & target via useFrame */} {!repairGameOwnsEbikeModel && ( )} {showCameraPoints && !repairGameOwnsEbikeModel && ( <> {/* */} )} ); }