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
+60 -41
View File
@@ -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 (
<>
<group
ref={groupRef}
position={position}
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
>
<primitive object={model} />
<InteractableObject
kind="trigger"
label={
mainState === "ebike" && ebikeStep === "waiting"
? "Inspecter l'e-bike"
: movementMode === "walk"
? "Monter sur le bike"
: "Descendre du bike"
}
{!repairGameOwnsEbikeModel ? (
<group
ref={groupRef}
position={position}
radius={15}
onPress={handleInteract}
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
>
<mesh>
<boxGeometry args={[10, 13, 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
</InteractableObject>
<primitive object={model} />
<InteractableObject
kind="trigger"
label={interactionLabel}
position={position}
radius={15}
onPress={handleInteract}
>
<mesh>
<boxGeometry args={[10, 13, 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
</InteractableObject>
{/* Dynamic 3D GPS Dashboard Screen */}
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
<EbikeGPSMap
width={0.8}
height={0.8}
startPos={gpsStartPos}
destPos={destPos}
mapImageUrl="/assets/world/gps/map_background.png"
worldBounds={{
minX: -166,
maxX: 163,
minZ: -142,
maxZ: 138,
}}
zoom={4}
/>
{/* Dynamic 3D GPS Dashboard Screen */}
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
<EbikeGPSMap
width={0.8}
height={0.8}
startPos={gpsStartPos}
destPos={destPos}
mapImageUrl="/assets/world/gps/map_background.png"
worldBounds={{
minX: -166,
maxX: 163,
minZ: -142,
maxZ: 138,
}}
zoom={4}
/>
</group>
<group position={[0, 6.35, 0]} rotation={[0, 90, 0]}>
<EbikeSpeedometer />
</group>
</group>
</group>
) : null}
{showCameraPoints && (
{showCameraPoints && !repairGameOwnsEbikeModel && (
<>
<mesh position={camPointPos}>
<sphereGeometry args={[0.3, 16, 16]} />
+5 -1
View File
@@ -89,6 +89,8 @@ export interface EbikeGPSMapProps {
* Default: 1
*/
zoom?: number;
renderOrder?: number;
}
/**
@@ -107,6 +109,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
position = [0, 0, 0],
canvasSize = 1024,
zoom = 1,
renderOrder = 10_000,
}) => {
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [mapImage, setMapImage] = useState<
@@ -506,12 +509,13 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
}, [draw]);
return (
<mesh castShadow receiveShadow position={position}>
<mesh position={position} renderOrder={renderOrder}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
toneMapped={false}
transparent={true}
opacity={1}
depthTest={false}
depthWrite={false}
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>
);
}