This commit is contained in:
math-pixel
2026-06-02 16:31:36 +02:00
parent 2c194cdd2e
commit 193fc8b4b6
6 changed files with 1135 additions and 256 deletions
+743 -188
View File
File diff suppressed because it is too large Load Diff
+55 -20
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";
@@ -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> */}
</> </>
)} )}
</> </>
+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);