Compare commits
4 Commits
d29b01e398
...
7f37f9a747
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f37f9a747 | |||
| 386abf06b6 | |||
| a73f9fb951 | |||
| 193fc8b4b6 |
Binary file not shown.
Binary file not shown.
+744
-189
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 { useFrame, useThree } from "@react-three/fiber";
|
||||
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 { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
@@ -25,6 +25,12 @@ 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;
|
||||
}
|
||||
@@ -53,6 +59,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
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" &&
|
||||
@@ -96,6 +103,19 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
]);
|
||||
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
||||
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)
|
||||
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
||||
@@ -106,9 +126,42 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
parkedPosition[2],
|
||||
]);
|
||||
|
||||
// Keep movementModeRef in sync — useFrame closures capture React state at
|
||||
// render time and can become stale between renders.
|
||||
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;
|
||||
restingRotationRef.current = EBIKE_WORLD_ROTATION_Y;
|
||||
lastGpsUpdatePos.current.set(...parkedPosition);
|
||||
@@ -123,11 +176,24 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
}, [movementMode, parkedPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
const fork = model.getObjectByName("fourche");
|
||||
if (fork) {
|
||||
forkRef.current = fork;
|
||||
}
|
||||
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]);
|
||||
|
||||
@@ -154,11 +220,48 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
}, []);
|
||||
|
||||
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 (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({
|
||||
mounted: true,
|
||||
driving: window.ebikeDriveInputActive === true,
|
||||
driving: (window.ebikeSpeedFactor ?? 0) > 0.05,
|
||||
breakdown: window.ebikeBreakdownActive === true,
|
||||
});
|
||||
|
||||
@@ -169,16 +272,31 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
];
|
||||
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;
|
||||
|
||||
if (forkRef.current) {
|
||||
// 15 degrees is 0.26 radians
|
||||
const targetForkRotation = steerFactor * 0.26;
|
||||
forkRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||
forkRef.current.rotation.z,
|
||||
targetForkRotation,
|
||||
// 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
|
||||
@@ -197,9 +315,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
groupRef.current.position.set(...restingPositionRef.current);
|
||||
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
|
||||
|
||||
// Reset fork rotation when parked
|
||||
// Reset fork to rest-pose when parked
|
||||
if (forkRef.current) {
|
||||
forkRef.current.rotation.z = 0;
|
||||
forkRef.current.quaternion.copy(forkInitialQuatRef.current);
|
||||
forkAngleRef.current = 0;
|
||||
}
|
||||
}
|
||||
window.ebikeParkedPosition = restingPositionRef.current;
|
||||
@@ -329,6 +448,9 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
scale={EBIKE_WORLD_SCALE}
|
||||
>
|
||||
<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
|
||||
kind="trigger"
|
||||
label={interactionLabel}
|
||||
@@ -337,16 +459,25 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
onPress={handleInteract}
|
||||
>
|
||||
<mesh>
|
||||
<boxGeometry args={[8, 9, 2]} />
|
||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||
<sphereGeometry args={[8, 15, 12]} />
|
||||
<meshBasicMaterial colorWrite={false} color={"red"} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
|
||||
{/* Dynamic 3D GPS Dashboard Screen */}
|
||||
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
||||
{/* 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°) */}
|
||||
<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
|
||||
width={0.8}
|
||||
height={0.8}
|
||||
width={1.3}
|
||||
height={1}
|
||||
startPos={gpsStartPos}
|
||||
destPos={destPos}
|
||||
mapImageUrl="/assets/world/gps/map_background.png"
|
||||
@@ -359,15 +490,26 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
zoom={4}
|
||||
/>
|
||||
</group>
|
||||
<group position={[0, 6.35, 0]} rotation={[0, 90, 0]}>
|
||||
<EbikeSpeedometer />
|
||||
</group>
|
||||
</group>
|
||||
) : 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 && (
|
||||
<>
|
||||
<mesh position={camPointPos}>
|
||||
{/* <mesh position={camPointPos}>
|
||||
<sphereGeometry args={[0.3, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color="yellow"
|
||||
@@ -382,7 +524,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
emissive="cyan"
|
||||
emissiveIntensity={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
</mesh> */}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,28 @@ import {
|
||||
} from "@/pathfinding/WaypointAStar";
|
||||
import type { Waypoint } from "@/pathfinding/types";
|
||||
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(
|
||||
img: HTMLImageElement | HTMLCanvasElement,
|
||||
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
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -492,42 +552,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
useEffect(() => {
|
||||
let animId: number;
|
||||
const tick = () => {
|
||||
animTimeRef.current += 0.004; // Slow, premium sweep speed
|
||||
animTimeRef.current += 0.004;
|
||||
if (animTimeRef.current > 1) animTimeRef.current = 0;
|
||||
|
||||
draw();
|
||||
|
||||
// Update texture after draw
|
||||
if (textureRef.current) {
|
||||
textureRef.current.needsUpdate = true;
|
||||
}
|
||||
|
||||
texture.needsUpdate = true;
|
||||
animId = requestAnimationFrame(tick);
|
||||
};
|
||||
animId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(animId);
|
||||
}, [draw]);
|
||||
}, [draw, texture]);
|
||||
|
||||
return (
|
||||
<mesh position={position} renderOrder={renderOrder}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
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>
|
||||
<primitive object={shaderMat} attach="material" />
|
||||
</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 = {
|
||||
position: [-2.6, 4.5, 0],
|
||||
position: [-1, 1, 0],
|
||||
rotation: [-10, -90, 0],
|
||||
};
|
||||
|
||||
|
||||
@@ -516,14 +516,29 @@ export function PlayerController({
|
||||
);
|
||||
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 targetFov = 60 + Math.min(speed * 0.35, 9);
|
||||
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
|
||||
perspectiveCam.fov = THREE.MathUtils.lerp(
|
||||
perspectiveCam.fov,
|
||||
targetFov,
|
||||
6 * dt,
|
||||
60 + Math.min(speed * CAM_FOV_BOOST, 3),
|
||||
CAM_FOV_LERP * dt,
|
||||
);
|
||||
perspectiveCam.updateProjectionMatrix();
|
||||
|
||||
@@ -532,9 +547,8 @@ export function PlayerController({
|
||||
);
|
||||
cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
|
||||
|
||||
const swingX = -Math.abs(steerFactor) * 1.5;
|
||||
const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0;
|
||||
|
||||
const swingX = -Math.abs(steerFactor) * CAM_SWAY_VERTICAL;
|
||||
const swingZ = steerFactor * CAM_SWAY_SIDE;
|
||||
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
|
||||
cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
|
||||
cameraOffset.add(cameraSwing);
|
||||
@@ -543,7 +557,7 @@ export function PlayerController({
|
||||
.copy(capsule.current.end)
|
||||
.add(cameraOffset);
|
||||
|
||||
camera.position.lerp(targetCamPos, 12 * dt);
|
||||
camera.position.lerp(targetCamPos, CAM_POS_LERP);
|
||||
|
||||
const pitchRad = THREE.MathUtils.degToRad(
|
||||
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
||||
@@ -563,8 +577,12 @@ export function PlayerController({
|
||||
capsule.current.end.y - PLAYER_EYE_HEIGHT,
|
||||
capsule.current.end.z,
|
||||
);
|
||||
const leanAngle = steerFactor * 0.26;
|
||||
ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ");
|
||||
ebikeVisual.rotation.set(
|
||||
steerFactor * -BIKE_LEAN,
|
||||
ebikeAngle.current,
|
||||
0,
|
||||
"YXZ",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
camera.position.copy(capsule.current.end);
|
||||
|
||||
Reference in New Issue
Block a user