2 Commits

Author SHA1 Message Date
math-pixel a73f9fb951 fixed ebike 2026-06-02 19:21:52 +02:00
math-pixel 193fc8b4b6 update 2026-06-02 16:31:36 +02:00
47 changed files with 1277 additions and 327 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+743 -188
View File
File diff suppressed because it is too large Load Diff
+170 -28
View File
@@ -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> */}
</> </>
)} )}
</> </>
+75 -37
View File
@@ -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>
); );
}; };
+233
View File
@@ -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>
);
}
+1 -1
View File
@@ -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],
}; };
+27 -9
View File
@@ -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);