update
This commit is contained in:
+744
-189
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||||
@@ -123,11 +123,35 @@ 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) {
|
// Full recursive search — case-insensitive so it survives export renames.
|
||||||
forkRef.current = fork;
|
// Also tries the exact path Moto > * > Fourche as a fallback.
|
||||||
|
let forkNode: THREE.Object3D | null = null;
|
||||||
|
|
||||||
|
model.traverse((child) => {
|
||||||
|
if (child.name.toLowerCase() === "fourche") {
|
||||||
|
forkNode = child;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (forkNode) {
|
||||||
|
forkRef.current = forkNode;
|
||||||
|
console.log("[Ebike] Fork found:", (forkNode as THREE.Object3D).name);
|
||||||
|
} else {
|
||||||
|
// Print the full hierarchy tree so you can read the exact node names.
|
||||||
|
const lines: string[] = [];
|
||||||
|
function printTree(obj: THREE.Object3D, indent: number): void {
|
||||||
|
lines.push(" ".repeat(indent * 2) + (obj.name || "(unnamed)"));
|
||||||
|
for (const child of obj.children) {
|
||||||
|
printTree(child, indent + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printTree(model, 0);
|
||||||
|
console.warn(
|
||||||
|
'[Ebike] No node matching "fourche" (case-insensitive) found.\nFull hierarchy:\n' +
|
||||||
|
lines.join("\n"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [model]);
|
}, [model]);
|
||||||
|
|
||||||
@@ -156,9 +180,11 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (groupRef.current) {
|
if (groupRef.current) {
|
||||||
if (movementMode === "ebike") {
|
if (movementMode === "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,11 +195,11 @@ 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
|
// Smoothly rotate the front fork ("fourche") on its local Z axis
|
||||||
const steerFactor = window.ebikeSteerFactor ?? 0;
|
const steerFactor = window.ebikeSteerFactor ?? 0;
|
||||||
if (forkRef.current) {
|
if (forkRef.current) {
|
||||||
// 15 degrees is 0.26 radians
|
// 10 degrees = 0.175 radians
|
||||||
const targetForkRotation = steerFactor * 0.26;
|
const targetForkRotation = steerFactor * 0.175;
|
||||||
forkRef.current.rotation.z = THREE.MathUtils.lerp(
|
forkRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||||
forkRef.current.rotation.z,
|
forkRef.current.rotation.z,
|
||||||
targetForkRotation,
|
targetForkRotation,
|
||||||
@@ -329,6 +355,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 +366,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 +397,12 @@ 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}
|
||||||
|
|
||||||
{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 +417,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],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -512,14 +512,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();
|
||||||
|
|
||||||
@@ -528,9 +543,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);
|
||||||
@@ -539,7 +553,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],
|
||||||
@@ -559,8 +573,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