feat(ebike): add speedometer
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

This commit is contained in:
Tom Boullay
2026-05-31 11:36:19 +02:00
parent 2c2a90264d
commit 396e7e4ff0
5 changed files with 161 additions and 42 deletions
Binary file not shown.
Binary file not shown.
+28 -9
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap"; import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
import { EbikeSpeedometer } from "@/components/ebike/EbikeSpeedometer";
import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject"; 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 setMissionStep = useGameStore((state) => state.setMissionStep);
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const updateEbikeSounds = useEbikeSounds(); const updateEbikeSounds = useEbikeSounds();
const repairGameOwnsEbikeModel =
mainState === "ebike" &&
ebikeStep !== "locked" &&
ebikeStep !== "waiting" &&
ebikeStep !== "inspected";
// Map active mainState to target repair zone coordinate // Map active mainState to target repair zone coordinate
const destPos = useMemo(() => { const destPos = useMemo(() => {
@@ -169,16 +175,30 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1], debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2], 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 => { const handleInteract = useCallback((): void => {
if (window.ebikeBreakdownActive === true) return; if (window.ebikeBreakdownActive === true) return;
if (movementMode === "walk") { if (movementMode === "walk") {
if (mainState === "ebike" && ebikeStep === "waiting") { if (
mainState === "ebike" &&
(ebikeStep === "locked" || ebikeStep === "waiting")
) {
setMissionStep("ebike", "inspected"); setMissionStep("ebike", "inspected");
return; return;
} }
if (mainState === "ebike" && ebikeStep === "inspected") {
setMissionStep("ebike", "fragmented");
return;
}
const cameraOffset = new THREE.Vector3( const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position, ...EBIKE_CAMERA_TRANSFORM.position,
); );
@@ -258,6 +278,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
return ( return (
<> <>
{!repairGameOwnsEbikeModel ? (
<group <group
ref={groupRef} ref={groupRef}
position={position} position={position}
@@ -266,13 +287,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
<primitive object={model} /> <primitive object={model} />
<InteractableObject <InteractableObject
kind="trigger" kind="trigger"
label={ label={interactionLabel}
mainState === "ebike" && ebikeStep === "waiting"
? "Inspecter l'e-bike"
: movementMode === "walk"
? "Monter sur le bike"
: "Descendre du bike"
}
position={position} position={position}
radius={15} radius={15}
onPress={handleInteract} onPress={handleInteract}
@@ -300,9 +315,13 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
zoom={4} zoom={4}
/> />
</group> </group>
<group position={[0, 6.35, 0]} rotation={[0, 90, 0]}>
<EbikeSpeedometer />
</group> </group>
</group>
) : null}
{showCameraPoints && ( {showCameraPoints && !repairGameOwnsEbikeModel && (
<> <>
<mesh position={camPointPos}> <mesh position={camPointPos}>
<sphereGeometry args={[0.3, 16, 16]} /> <sphereGeometry args={[0.3, 16, 16]} />
+5 -1
View File
@@ -89,6 +89,8 @@ export interface EbikeGPSMapProps {
* Default: 1 * Default: 1
*/ */
zoom?: number; zoom?: number;
renderOrder?: number;
} }
/** /**
@@ -107,6 +109,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
position = [0, 0, 0], position = [0, 0, 0],
canvasSize = 1024, canvasSize = 1024,
zoom = 1, zoom = 1,
renderOrder = 10_000,
}) => { }) => {
const [waypoints, setWaypoints] = useState<Waypoint[]>([]); const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [mapImage, setMapImage] = useState< const [mapImage, setMapImage] = useState<
@@ -506,12 +509,13 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
}, [draw]); }, [draw]);
return ( return (
<mesh castShadow receiveShadow position={position}> <mesh position={position} renderOrder={renderOrder}>
<planeGeometry args={[width, height]} /> <planeGeometry args={[width, height]} />
<meshBasicMaterial <meshBasicMaterial
toneMapped={false} toneMapped={false}
transparent={true} transparent={true}
opacity={1} opacity={1}
depthTest={false}
depthWrite={false} depthWrite={false}
side={THREE.DoubleSide} side={THREE.DoubleSide}
> >
+90
View File
@@ -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<THREE.Group>(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 (
<group renderOrder={SPEEDOMETER_RENDER_ORDER}>
<mesh renderOrder={SPEEDOMETER_RENDER_ORDER}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={dialTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]}>
<mesh
position={[0, needleHeight / 2, 0]}
renderOrder={SPEEDOMETER_RENDER_ORDER + 1}
>
<planeGeometry args={[needleWidth, needleHeight]} />
<meshBasicMaterial
map={needleTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
</group>
</group>
);
}