Compare commits
4 Commits
d29b01e398
...
7f37f9a747
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f37f9a747 | |||
| 386abf06b6 | |||
| a73f9fb951 | |||
| 193fc8b4b6 |
Binary file not shown.
Binary file not shown.
+743
-188
File diff suppressed because it is too large
Load Diff
+170
-28
@@ -2,7 +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 { EbikeSpeedmeter } from "@/components/ebike/EbikeSpeedmeter";
|
||||||
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";
|
||||||
@@ -25,6 +25,12 @@ import "@/types/ebike/ebikeWindow";
|
|||||||
|
|
||||||
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
|
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 {
|
interface EbikeProps {
|
||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
}
|
}
|
||||||
@@ -53,6 +59,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||||
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 threeScene = useThree((state) => state.scene);
|
||||||
const updateEbikeSounds = useEbikeSounds();
|
const updateEbikeSounds = useEbikeSounds();
|
||||||
const repairGameOwnsEbikeModel =
|
const repairGameOwnsEbikeModel =
|
||||||
mainState === "ebike" &&
|
mainState === "ebike" &&
|
||||||
@@ -96,6 +103,19 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
]);
|
]);
|
||||||
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
||||||
const forkRef = useRef<THREE.Object3D | null>(null);
|
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||||
|
const phareRef = useRef<THREE.Object3D | null>(null);
|
||||||
|
const headlightRef = useRef<THREE.SpotLight | null>(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)
|
// State for debug visualization (synced from refs during useFrame)
|
||||||
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
||||||
@@ -106,9 +126,42 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
parkedPosition[2],
|
parkedPosition[2],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Keep movementModeRef in sync — useFrame closures capture React state at
|
||||||
|
// render time and can become stale between renders.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (movementMode === "ebike") return;
|
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;
|
restingPositionRef.current = parkedPosition;
|
||||||
restingRotationRef.current = EBIKE_WORLD_ROTATION_Y;
|
restingRotationRef.current = EBIKE_WORLD_ROTATION_Y;
|
||||||
lastGpsUpdatePos.current.set(...parkedPosition);
|
lastGpsUpdatePos.current.set(...parkedPosition);
|
||||||
@@ -123,11 +176,24 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
}, [movementMode, parkedPosition]);
|
}, [movementMode, parkedPosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model) {
|
if (!model) return;
|
||||||
const fork = model.getObjectByName("fourche");
|
|
||||||
if (fork) {
|
let forkNode: THREE.Object3D | null = null;
|
||||||
forkRef.current = fork;
|
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]);
|
}, [model]);
|
||||||
|
|
||||||
@@ -154,11 +220,48 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
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) {
|
if (groupRef.current) {
|
||||||
if (movementMode === "ebike") {
|
// 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({
|
updateEbikeSounds({
|
||||||
mounted: true,
|
mounted: true,
|
||||||
driving: window.ebikeDriveInputActive === true,
|
driving: (window.ebikeSpeedFactor ?? 0) > 0.05,
|
||||||
breakdown: window.ebikeBreakdownActive === true,
|
breakdown: window.ebikeBreakdownActive === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,16 +272,31 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
];
|
];
|
||||||
restingRotationRef.current = groupRef.current.rotation.y;
|
restingRotationRef.current = groupRef.current.rotation.y;
|
||||||
|
|
||||||
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
|
// ── 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;
|
const steerFactor = window.ebikeSteerFactor ?? 0;
|
||||||
|
|
||||||
if (forkRef.current) {
|
if (forkRef.current) {
|
||||||
// 15 degrees is 0.26 radians
|
// Smooth the angle separately so we can apply it cleanly each frame.
|
||||||
const targetForkRotation = steerFactor * 0.26;
|
forkAngleRef.current = THREE.MathUtils.lerp(
|
||||||
forkRef.current.rotation.z = THREE.MathUtils.lerp(
|
forkAngleRef.current,
|
||||||
forkRef.current.rotation.z,
|
steerFactor * FORK_ANGLE,
|
||||||
targetForkRotation,
|
|
||||||
12 * delta,
|
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
|
// Throttled GPS start position update to prevent performance loss
|
||||||
@@ -197,9 +315,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
groupRef.current.position.set(...restingPositionRef.current);
|
groupRef.current.position.set(...restingPositionRef.current);
|
||||||
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
|
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
|
||||||
|
|
||||||
// Reset fork rotation when parked
|
// Reset fork to rest-pose when parked
|
||||||
if (forkRef.current) {
|
if (forkRef.current) {
|
||||||
forkRef.current.rotation.z = 0;
|
forkRef.current.quaternion.copy(forkInitialQuatRef.current);
|
||||||
|
forkAngleRef.current = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.ebikeParkedPosition = restingPositionRef.current;
|
window.ebikeParkedPosition = restingPositionRef.current;
|
||||||
@@ -329,6 +448,9 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
scale={EBIKE_WORLD_SCALE}
|
scale={EBIKE_WORLD_SCALE}
|
||||||
>
|
>
|
||||||
<primitive object={model} />
|
<primitive object={model} />
|
||||||
|
{/* 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). */}
|
||||||
<InteractableObject
|
<InteractableObject
|
||||||
kind="trigger"
|
kind="trigger"
|
||||||
label={interactionLabel}
|
label={interactionLabel}
|
||||||
@@ -337,16 +459,25 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
onPress={handleInteract}
|
onPress={handleInteract}
|
||||||
>
|
>
|
||||||
<mesh>
|
<mesh>
|
||||||
<boxGeometry args={[8, 9, 2]} />
|
<sphereGeometry args={[8, 15, 12]} />
|
||||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
<meshBasicMaterial colorWrite={false} color={"red"} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</InteractableObject>
|
</InteractableObject>
|
||||||
|
|
||||||
{/* Dynamic 3D GPS Dashboard Screen */}
|
{/* GPS + Speedmeter – same group so they are perfectly co-localised.
|
||||||
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
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°) */}
|
||||||
|
<group position={[2, 6, 0]} rotation={[0, -80, 0]}>
|
||||||
|
<EbikeSpeedmeter width={3} height={1.5} position={[0, 0.4, 0]} gaugeInnerR={0.33} gaugeOuterR={0.445}
|
||||||
|
gaugeWidth={2.5}
|
||||||
|
gaugeHeight={2.1}
|
||||||
|
gaugeOffsetX={0}
|
||||||
|
gaugeOffsetY={-0.19}
|
||||||
|
/>
|
||||||
<EbikeGPSMap
|
<EbikeGPSMap
|
||||||
width={0.8}
|
width={1.3}
|
||||||
height={0.8}
|
height={1}
|
||||||
startPos={gpsStartPos}
|
startPos={gpsStartPos}
|
||||||
destPos={destPos}
|
destPos={destPos}
|
||||||
mapImageUrl="/assets/world/gps/map_background.png"
|
mapImageUrl="/assets/world/gps/map_background.png"
|
||||||
@@ -359,15 +490,26 @@ 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}
|
) : null}
|
||||||
|
|
||||||
|
{/* SpotLight headlight — cone aimed forward, position & target via useFrame */}
|
||||||
|
{!repairGameOwnsEbikeModel && (
|
||||||
|
<spotLight
|
||||||
|
ref={headlightRef}
|
||||||
|
intensity={100}
|
||||||
|
color="#ffca60"
|
||||||
|
angle={Math.PI / 5} // 22.5° demi-angle — cone étroit comme une torche
|
||||||
|
penumbra={0.5} // bord doux (0 = dur, 1 = très doux)
|
||||||
|
distance={50}
|
||||||
|
decay={2.5}
|
||||||
|
castShadow={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCameraPoints && !repairGameOwnsEbikeModel && (
|
{showCameraPoints && !repairGameOwnsEbikeModel && (
|
||||||
<>
|
<>
|
||||||
<mesh position={camPointPos}>
|
{/* <mesh position={camPointPos}>
|
||||||
<sphereGeometry args={[0.3, 16, 16]} />
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color="yellow"
|
color="yellow"
|
||||||
@@ -382,7 +524,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
emissive="cyan"
|
emissive="cyan"
|
||||||
emissiveIntensity={0.5}
|
emissiveIntensity={0.5}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh> */}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -12,6 +12,28 @@ import {
|
|||||||
} from "@/pathfinding/WaypointAStar";
|
} from "@/pathfinding/WaypointAStar";
|
||||||
import type { Waypoint } from "@/pathfinding/types";
|
import type { Waypoint } from "@/pathfinding/types";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
const VERT_SHADER = /* glsl */ `
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Circular Fresnel mask: fully visible inside innerRadius, fades out to outerRadius
|
||||||
|
const FRAG_SHADER = /* glsl */ `
|
||||||
|
uniform sampler2D map;
|
||||||
|
uniform float innerRadius;
|
||||||
|
uniform float outerRadius;
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vec4 color = texture2D(map, vUv);
|
||||||
|
float dist = length(vUv - vec2(0.5));
|
||||||
|
float mask = 1.0 - smoothstep(innerRadius, outerRadius, dist);
|
||||||
|
gl_FragColor = vec4(color.rgb, color.a * mask);
|
||||||
|
}
|
||||||
|
`;
|
||||||
function computeImageSource(
|
function computeImageSource(
|
||||||
img: HTMLImageElement | HTMLCanvasElement,
|
img: HTMLImageElement | HTMLCanvasElement,
|
||||||
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
||||||
@@ -126,19 +148,57 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Canvas should only be created once
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Canvas should only be created once
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Resize the canvas whenever canvasSize changes
|
|
||||||
// Note: Modifying canvas dimensions is intentional and necessary for rendering
|
|
||||||
useEffect(() => {
|
|
||||||
// Use Object.assign to resize canvas - this is a necessary mutation for canvas rendering
|
|
||||||
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
|
|
||||||
if (textureRef.current) {
|
|
||||||
textureRef.current.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}, [canvasSize, offscreenCanvas]);
|
|
||||||
|
|
||||||
const textureRef = useRef<THREE.CanvasTexture | null>(null);
|
|
||||||
const animTimeRef = useRef<number>(0);
|
const animTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Imperative CanvasTexture — must be declared before the resize effect below
|
||||||
|
const texture = useMemo(() => {
|
||||||
|
const tex = new THREE.CanvasTexture(offscreenCanvas);
|
||||||
|
tex.format = THREE.RGBAFormat;
|
||||||
|
tex.minFilter = THREE.LinearFilter;
|
||||||
|
tex.magFilter = THREE.LinearFilter;
|
||||||
|
return tex;
|
||||||
|
}, [offscreenCanvas]);
|
||||||
|
|
||||||
|
// ShaderMaterial with circular Fresnel mask (created once)
|
||||||
|
const shaderMat = useMemo(
|
||||||
|
() =>
|
||||||
|
new THREE.ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
map: { value: null },
|
||||||
|
innerRadius: { value: 0.45 },
|
||||||
|
outerRadius: { value: 0.5 },
|
||||||
|
},
|
||||||
|
vertexShader: VERT_SHADER,
|
||||||
|
fragmentShader: FRAG_SHADER,
|
||||||
|
transparent: true,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
toneMapped: false,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync texture into uniform when it changes (canvas resize)
|
||||||
|
useEffect(() => {
|
||||||
|
shaderMat.uniforms.map.value = texture;
|
||||||
|
}, [shaderMat, texture]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
shaderMat.dispose();
|
||||||
|
texture.dispose();
|
||||||
|
},
|
||||||
|
[shaderMat, texture],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resize the canvas whenever canvasSize changes (texture declared above)
|
||||||
|
useEffect(() => {
|
||||||
|
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
}, [canvasSize, offscreenCanvas, texture]);
|
||||||
|
|
||||||
// Load waypoints (localStorage with /roadNetwork.json fallback)
|
// Load waypoints (localStorage with /roadNetwork.json fallback)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -492,42 +552,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let animId: number;
|
let animId: number;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
animTimeRef.current += 0.004; // Slow, premium sweep speed
|
animTimeRef.current += 0.004;
|
||||||
if (animTimeRef.current > 1) animTimeRef.current = 0;
|
if (animTimeRef.current > 1) animTimeRef.current = 0;
|
||||||
|
|
||||||
draw();
|
draw();
|
||||||
|
texture.needsUpdate = true;
|
||||||
// Update texture after draw
|
|
||||||
if (textureRef.current) {
|
|
||||||
textureRef.current.needsUpdate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
animId = requestAnimationFrame(tick);
|
animId = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
animId = requestAnimationFrame(tick);
|
animId = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(animId);
|
return () => cancelAnimationFrame(animId);
|
||||||
}, [draw]);
|
}, [draw, texture]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh position={position} renderOrder={renderOrder}>
|
<mesh position={position} renderOrder={renderOrder}>
|
||||||
<planeGeometry args={[width, height]} />
|
<planeGeometry args={[width, height]} />
|
||||||
<meshBasicMaterial
|
<primitive object={shaderMat} attach="material" />
|
||||||
toneMapped={false}
|
|
||||||
transparent={true}
|
|
||||||
opacity={1}
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
side={THREE.DoubleSide}
|
|
||||||
>
|
|
||||||
<canvasTexture
|
|
||||||
ref={textureRef}
|
|
||||||
attach="map"
|
|
||||||
image={offscreenCanvas}
|
|
||||||
format={THREE.RGBAFormat}
|
|
||||||
minFilter={THREE.LinearFilter}
|
|
||||||
magFilter={THREE.LinearFilter}
|
|
||||||
/>
|
|
||||||
</meshBasicMaterial>
|
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useRef, useMemo } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useTexture } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import "@/types/ebike/ebikeWindow";
|
||||||
|
|
||||||
|
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
|
||||||
|
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
|
||||||
|
|
||||||
|
export interface EbikeSpeedmeterProps {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
/** Local position offset within the parent group. Default: [0, 0, 0] */
|
||||||
|
position?: Vector3Tuple;
|
||||||
|
/**
|
||||||
|
* Needle rotation.z when speedFactor = 0.
|
||||||
|
* Default: Math.PI / 2 (pointing left — 9 o'clock)
|
||||||
|
*/
|
||||||
|
minAngle?: number;
|
||||||
|
/**
|
||||||
|
* Needle rotation.z when speedFactor = 1.
|
||||||
|
* Default: -Math.PI / 2 (pointing right — 3 o'clock)
|
||||||
|
*/
|
||||||
|
maxAngle?: number;
|
||||||
|
renderOrder?: number;
|
||||||
|
/**
|
||||||
|
* Inner radius of the gauge-fill arc, as a fraction of the canvas half-width.
|
||||||
|
* Tune this to align the fill with the cadran.png track. Default: 0.33
|
||||||
|
*/
|
||||||
|
gaugeInnerR?: number;
|
||||||
|
/**
|
||||||
|
* Outer radius of the gauge-fill arc, as a fraction of the canvas half-width.
|
||||||
|
* Tune this to align the fill with the cadran.png track. Default: 0.445
|
||||||
|
*/
|
||||||
|
gaugeOuterR?: number;
|
||||||
|
/**
|
||||||
|
* Width of the gauge-fill plane. Defaults to `width` when omitted.
|
||||||
|
* Lets you resize the fill independently of the cadran/needle.
|
||||||
|
*/
|
||||||
|
gaugeWidth?: number;
|
||||||
|
/**
|
||||||
|
* Height of the gauge-fill plane. Defaults to `height` when omitted.
|
||||||
|
* Lets you resize the fill independently of the cadran/needle.
|
||||||
|
*/
|
||||||
|
gaugeHeight?: number;
|
||||||
|
/**
|
||||||
|
* Horizontal offset of the arc pivot from the canvas centre.
|
||||||
|
* Expressed as a fraction of the canvas size: -0.1 = shift 10 % to the left,
|
||||||
|
* +0.1 = shift 10 % to the right. Default: 0
|
||||||
|
*/
|
||||||
|
gaugeOffsetX?: number;
|
||||||
|
/**
|
||||||
|
* Vertical offset of the arc pivot from its default position.
|
||||||
|
* Expressed as a fraction of the canvas size: -0.1 = shift upward (toward top
|
||||||
|
* of the plane), +0.1 = shift downward. Default: 0
|
||||||
|
*/
|
||||||
|
gaugeOffsetY?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The needle pivot is always at -height*0.38 in local space,
|
||||||
|
// which is always 12 % from the bottom of the plane (UV y = 0.12).
|
||||||
|
// With Three.js flipY texture convention, canvas y = (1 - 0.12) * size = 0.88 * size.
|
||||||
|
const NEEDLE_PIVOT_UV_Y = 0.12; // fraction from bottom
|
||||||
|
|
||||||
|
export function EbikeSpeedmeter({
|
||||||
|
width = 0.8,
|
||||||
|
height = 0.8,
|
||||||
|
position = [0, 0, 0],
|
||||||
|
minAngle = Math.PI / 2,
|
||||||
|
maxAngle = -Math.PI / 2,
|
||||||
|
renderOrder = 1000,
|
||||||
|
gaugeInnerR = 0.33,
|
||||||
|
gaugeOuterR = 0.445,
|
||||||
|
gaugeWidth,
|
||||||
|
gaugeHeight,
|
||||||
|
gaugeOffsetX = 0,
|
||||||
|
gaugeOffsetY = 0,
|
||||||
|
}: EbikeSpeedmeterProps): React.JSX.Element {
|
||||||
|
// Fall back to the main dimensions when gauge-specific ones aren't provided
|
||||||
|
const fillW = gaugeWidth ?? width;
|
||||||
|
const fillH = gaugeHeight ?? height;
|
||||||
|
const needleGroupRef = useRef<THREE.Group>(null);
|
||||||
|
const speedFactorRef = useRef(0);
|
||||||
|
|
||||||
|
// ── Dial & needle textures ──────────────────────────────────────────────────
|
||||||
|
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((tex) => {
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
tex.needsUpdate = true;
|
||||||
|
});
|
||||||
|
}, [dialTexture, needleTexture]);
|
||||||
|
|
||||||
|
// ── Gauge-fill canvas ───────────────────────────────────────────────────────
|
||||||
|
const fillCanvas = useMemo(() => {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = 256;
|
||||||
|
c.height = 256;
|
||||||
|
return c;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fillTexture = useMemo(() => {
|
||||||
|
const tex = new THREE.CanvasTexture(fillCanvas);
|
||||||
|
tex.format = THREE.RGBAFormat;
|
||||||
|
tex.minFilter = THREE.LinearFilter;
|
||||||
|
tex.magFilter = THREE.LinearFilter;
|
||||||
|
return tex;
|
||||||
|
}, [fillCanvas]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
fillTexture.dispose();
|
||||||
|
},
|
||||||
|
[fillTexture],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Frame loop ──────────────────────────────────────────────────────────────
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
// 1. Smooth speed factor
|
||||||
|
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
|
||||||
|
speedFactorRef.current = THREE.MathUtils.lerp(
|
||||||
|
speedFactorRef.current,
|
||||||
|
target,
|
||||||
|
Math.min(1, delta * 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Needle rotation
|
||||||
|
if (needleGroupRef.current) {
|
||||||
|
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||||
|
minAngle,
|
||||||
|
maxAngle,
|
||||||
|
speedFactorRef.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Draw gauge fill -------------------------------------------------------
|
||||||
|
const ctx = fillCanvas.getContext("2d", { alpha: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const size = fillCanvas.width;
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// Default centre: horizontal middle + needle-pivot height.
|
||||||
|
// gaugeOffsetX/Y shift the pivot so the arc aligns with cadran.png.
|
||||||
|
const cx = size * (0.5 + gaugeOffsetX);
|
||||||
|
const cy = size * ((1 - NEEDLE_PIVOT_UV_Y) + gaugeOffsetY); // default ≈ 0.88 × size
|
||||||
|
|
||||||
|
const outerR = size * gaugeOuterR;
|
||||||
|
const innerR = size * gaugeInnerR;
|
||||||
|
|
||||||
|
// Arc sweeps clockwise from π (left) to current needle angle
|
||||||
|
const arcStart = Math.PI;
|
||||||
|
const arcEnd = Math.PI + speedFactorRef.current * Math.PI;
|
||||||
|
|
||||||
|
if (speedFactorRef.current > 0.005) {
|
||||||
|
// Radial gradient using #3F67DD — slightly transparent at inner edge,
|
||||||
|
// fully solid at outer edge for a depth effect.
|
||||||
|
const radial = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
|
||||||
|
radial.addColorStop(0, "rgba(191, 234, 255, 0)"); // inner edge
|
||||||
|
radial.addColorStop(0.7, "rgba(118, 152, 255, 0.95)"); // outer edge
|
||||||
|
|
||||||
|
// Annular sector shape (outer arc + inner arc reversed)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, outerR, arcStart, arcEnd, false);
|
||||||
|
ctx.arc(cx, cy, innerR, arcEnd, arcStart, true);
|
||||||
|
ctx.closePath();
|
||||||
|
|
||||||
|
ctx.fillStyle = radial;
|
||||||
|
ctx.shadowBlur = 16;
|
||||||
|
ctx.shadowColor = "#3F67DD";
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fillTexture.needsUpdate = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group renderOrder={renderOrder} position={position}>
|
||||||
|
{/* Gauge fill — behind the cadran frame (size controlled by gaugeWidth/gaugeHeight) */}
|
||||||
|
<mesh renderOrder={renderOrder - 1} position={[0, 0, -0.001]}>
|
||||||
|
<planeGeometry args={[fillW, fillH]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
map={fillTexture}
|
||||||
|
transparent
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
toneMapped={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Dial frame (cadran.png) */}
|
||||||
|
<mesh renderOrder={renderOrder}>
|
||||||
|
<planeGeometry args={[width, height]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
map={dialTexture}
|
||||||
|
transparent
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
toneMapped={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Needle — pivot at bottom-centre of the arc */}
|
||||||
|
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]} rotation={[0, 0, 0]}>
|
||||||
|
<mesh
|
||||||
|
position={[0, needleHeight / 2, 0]}
|
||||||
|
renderOrder={renderOrder + 1}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[needleWidth, needleHeight]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
map={needleTexture}
|
||||||
|
transparent
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
toneMapped={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export interface CameraTransform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||||
position: [-2.6, 4.5, 0],
|
position: [-1, 1, 0],
|
||||||
rotation: [-10, -90, 0],
|
rotation: [-10, -90, 0],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -516,14 +516,29 @@ export function PlayerController({
|
|||||||
);
|
);
|
||||||
window.ebikeSteerFactor = steerFactor;
|
window.ebikeSteerFactor = steerFactor;
|
||||||
|
|
||||||
|
// ── Ebike camera tuning ──────────────────────────────────────────────────
|
||||||
|
// All motion effects in one place — set to 0 to fully disable each one.
|
||||||
|
/** Lateral camera drift when steering (0 = no sway) */
|
||||||
|
const CAM_SWAY_SIDE = -0.5;
|
||||||
|
/** Vertical camera drop when steering (0 = no recoil) */
|
||||||
|
const CAM_SWAY_VERTICAL = 0;
|
||||||
|
/** Position lerp factor. 1 = instant snap, lower = more lag/trail */
|
||||||
|
const CAM_POS_LERP = 1;
|
||||||
|
/** FOV boost at full speed in degrees (0 = constant FOV) */
|
||||||
|
const CAM_FOV_BOOST = 0.15; // speed × 0.15, capped at 3° → subtle speed sensation
|
||||||
|
/** How fast FOV lerps toward target (lower = slower breathing) */
|
||||||
|
const CAM_FOV_LERP = 4;
|
||||||
|
/** Visual body lean in radians at max steer (20° = 0.349 rad) */
|
||||||
|
const BIKE_LEAN = THREE.MathUtils.degToRad(10);
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const speed = velocity.current.length();
|
const speed = velocity.current.length();
|
||||||
const targetFov = 60 + Math.min(speed * 0.35, 9);
|
|
||||||
const perspectiveCam = camera as THREE.PerspectiveCamera;
|
const perspectiveCam = camera as THREE.PerspectiveCamera;
|
||||||
// eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes during frame updates
|
// eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes during frame updates
|
||||||
perspectiveCam.fov = THREE.MathUtils.lerp(
|
perspectiveCam.fov = THREE.MathUtils.lerp(
|
||||||
perspectiveCam.fov,
|
perspectiveCam.fov,
|
||||||
targetFov,
|
60 + Math.min(speed * CAM_FOV_BOOST, 3),
|
||||||
6 * dt,
|
CAM_FOV_LERP * dt,
|
||||||
);
|
);
|
||||||
perspectiveCam.updateProjectionMatrix();
|
perspectiveCam.updateProjectionMatrix();
|
||||||
|
|
||||||
@@ -532,9 +547,8 @@ export function PlayerController({
|
|||||||
);
|
);
|
||||||
cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
|
cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
|
||||||
|
|
||||||
const swingX = -Math.abs(steerFactor) * 1.5;
|
const swingX = -Math.abs(steerFactor) * CAM_SWAY_VERTICAL;
|
||||||
const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0;
|
const swingZ = steerFactor * CAM_SWAY_SIDE;
|
||||||
|
|
||||||
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
|
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
|
||||||
cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
|
cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
|
||||||
cameraOffset.add(cameraSwing);
|
cameraOffset.add(cameraSwing);
|
||||||
@@ -543,7 +557,7 @@ export function PlayerController({
|
|||||||
.copy(capsule.current.end)
|
.copy(capsule.current.end)
|
||||||
.add(cameraOffset);
|
.add(cameraOffset);
|
||||||
|
|
||||||
camera.position.lerp(targetCamPos, 12 * dt);
|
camera.position.lerp(targetCamPos, CAM_POS_LERP);
|
||||||
|
|
||||||
const pitchRad = THREE.MathUtils.degToRad(
|
const pitchRad = THREE.MathUtils.degToRad(
|
||||||
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
||||||
@@ -563,8 +577,12 @@ export function PlayerController({
|
|||||||
capsule.current.end.y - PLAYER_EYE_HEIGHT,
|
capsule.current.end.y - PLAYER_EYE_HEIGHT,
|
||||||
capsule.current.end.z,
|
capsule.current.end.z,
|
||||||
);
|
);
|
||||||
const leanAngle = steerFactor * 0.26;
|
ebikeVisual.rotation.set(
|
||||||
ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ");
|
steerFactor * -BIKE_LEAN,
|
||||||
|
ebikeAngle.current,
|
||||||
|
0,
|
||||||
|
"YXZ",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
camera.position.copy(capsule.current.end);
|
camera.position.copy(capsule.current.end);
|
||||||
|
|||||||
Reference in New Issue
Block a user