diff --git a/public/assets/world/gps/cadran.png b/public/assets/world/gps/cadran.png
new file mode 100644
index 0000000..652420c
--- /dev/null
+++ b/public/assets/world/gps/cadran.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:65883c1760293f3a415268a59cae60d0b35de8760de752c8dedb4ab3e19c0e96
+size 531191
diff --git a/public/assets/world/gps/fleche.png b/public/assets/world/gps/fleche.png
new file mode 100644
index 0000000..e146cc7
--- /dev/null
+++ b/public/assets/world/gps/fleche.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:286164bc5aeb147abb145857cb56832f04a2d17afd50a3d9000ae81059b8201e
+size 121079
diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx
index b49e3c4..addef6d 100644
--- a/src/components/ebike/Ebike.tsx
+++ b/src/components/ebike/Ebike.tsx
@@ -2,6 +2,7 @@ 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 { EbikeSpeedometer } from "@/components/ebike/EbikeSpeedometer";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject";
@@ -37,6 +38,11 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
const setMissionStep = useGameStore((state) => state.setMissionStep);
const camera = useThree((state) => state.camera);
const updateEbikeSounds = useEbikeSounds();
+ const repairGameOwnsEbikeModel =
+ mainState === "ebike" &&
+ ebikeStep !== "locked" &&
+ ebikeStep !== "waiting" &&
+ ebikeStep !== "inspected";
// Map active mainState to target repair zone coordinate
const destPos = useMemo(() => {
@@ -169,16 +175,30 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
];
+ const interactionLabel =
+ mainState === "ebike"
+ ? "Réparer l'e-bike"
+ : movementMode === "walk"
+ ? "Monter sur le bike"
+ : "Descendre du bike";
const handleInteract = useCallback((): void => {
if (window.ebikeBreakdownActive === true) return;
if (movementMode === "walk") {
- if (mainState === "ebike" && ebikeStep === "waiting") {
+ 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,
);
@@ -258,51 +278,50 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
return (
<>
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
- {/* Dynamic 3D GPS Dashboard Screen */}
-
-
+ {/* Dynamic 3D GPS Dashboard Screen */}
+
+
+
+
+
+
-
+ ) : null}
- {showCameraPoints && (
+ {showCameraPoints && !repairGameOwnsEbikeModel && (
<>
diff --git a/src/components/ebike/EbikeGPSMap.tsx b/src/components/ebike/EbikeGPSMap.tsx
index e51108c..08fab3e 100644
--- a/src/components/ebike/EbikeGPSMap.tsx
+++ b/src/components/ebike/EbikeGPSMap.tsx
@@ -89,6 +89,8 @@ export interface EbikeGPSMapProps {
* Default: 1
*/
zoom?: number;
+
+ renderOrder?: number;
}
/**
@@ -107,6 +109,7 @@ export const EbikeGPSMap: React.FC = ({
position = [0, 0, 0],
canvasSize = 1024,
zoom = 1,
+ renderOrder = 10_000,
}) => {
const [waypoints, setWaypoints] = useState([]);
const [mapImage, setMapImage] = useState<
@@ -506,12 +509,13 @@ export const EbikeGPSMap: React.FC = ({
}, [draw]);
return (
-
+
diff --git a/src/components/ebike/EbikeSpeedometer.tsx b/src/components/ebike/EbikeSpeedometer.tsx
new file mode 100644
index 0000000..82d7040
--- /dev/null
+++ b/src/components/ebike/EbikeSpeedometer.tsx
@@ -0,0 +1,90 @@
+import { useEffect, useRef } from "react";
+import { useFrame } from "@react-three/fiber";
+import { useTexture } from "@react-three/drei";
+import * as THREE from "three";
+
+const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
+const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
+const SPEEDOMETER_MIN_ANGLE = Math.PI / 2;
+const SPEEDOMETER_MAX_ANGLE = -Math.PI / 2;
+const SPEEDOMETER_RENDER_ORDER = 10_000;
+
+interface EbikeSpeedometerProps {
+ width?: number;
+ height?: number;
+}
+
+export function EbikeSpeedometer({
+ width = 0.9,
+ height = 0.5,
+}: EbikeSpeedometerProps): React.JSX.Element {
+ const needleGroupRef = useRef(null);
+ const speedFactorRef = useRef(0);
+ const [dialTexture, needleTexture] = useTexture([
+ SPEEDOMETER_DIAL_TEXTURE,
+ SPEEDOMETER_NEEDLE_TEXTURE,
+ ]) as [THREE.Texture, THREE.Texture];
+ const needleWidth = width * 0.68;
+ const needleHeight = needleWidth / 2;
+
+ useEffect(() => {
+ [dialTexture, needleTexture].forEach((texture) => {
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.needsUpdate = true;
+ });
+ }, [dialTexture, needleTexture]);
+
+ useFrame((_, delta) => {
+ const targetSpeedFactor = THREE.MathUtils.clamp(
+ window.ebikeSpeedFactor ?? 0,
+ 0,
+ 1,
+ );
+ speedFactorRef.current = THREE.MathUtils.lerp(
+ speedFactorRef.current,
+ targetSpeedFactor,
+ Math.min(1, delta * 10),
+ );
+
+ if (needleGroupRef.current) {
+ needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
+ SPEEDOMETER_MIN_ANGLE,
+ SPEEDOMETER_MAX_ANGLE,
+ speedFactorRef.current,
+ );
+ }
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}