4 Commits

Author SHA1 Message Date
math-pixel 7f37f9a747 Merge branch 'develop' into feat/e-bike
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
2026-06-02 20:36:04 +02:00
math-pixel 386abf06b6 Merge branch 'develop' into feat/e-bike 2026-06-02 19:23:01 +02:00
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
8 changed files with 1254 additions and 268 deletions
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
@@ -516,14 +516,29 @@ export function PlayerController({
); );
window.ebikeSteerFactor = steerFactor; window.ebikeSteerFactor = steerFactor;
// ── Ebike camera tuning ──────────────────────────────────────────────────
// All motion effects in one place — set to 0 to fully disable each one.
/** Lateral camera drift when steering (0 = no sway) */
const CAM_SWAY_SIDE = -0.5;
/** Vertical camera drop when steering (0 = no recoil) */
const CAM_SWAY_VERTICAL = 0;
/** Position lerp factor. 1 = instant snap, lower = more lag/trail */
const CAM_POS_LERP = 1;
/** FOV boost at full speed in degrees (0 = constant FOV) */
const CAM_FOV_BOOST = 0.15; // speed × 0.15, capped at 3° → subtle speed sensation
/** How fast FOV lerps toward target (lower = slower breathing) */
const CAM_FOV_LERP = 4;
/** Visual body lean in radians at max steer (20° = 0.349 rad) */
const BIKE_LEAN = THREE.MathUtils.degToRad(10);
// ─────────────────────────────────────────────────────────────────────────
const speed = velocity.current.length(); const speed = velocity.current.length();
const targetFov = 60 + Math.min(speed * 0.35, 9);
const perspectiveCam = camera as THREE.PerspectiveCamera; const perspectiveCam = camera as THREE.PerspectiveCamera;
// eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes during frame updates // eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes during frame updates
perspectiveCam.fov = THREE.MathUtils.lerp( perspectiveCam.fov = THREE.MathUtils.lerp(
perspectiveCam.fov, perspectiveCam.fov,
targetFov, 60 + Math.min(speed * CAM_FOV_BOOST, 3),
6 * dt, CAM_FOV_LERP * dt,
); );
perspectiveCam.updateProjectionMatrix(); perspectiveCam.updateProjectionMatrix();
@@ -532,9 +547,8 @@ export function PlayerController({
); );
cameraOffset.applyAxisAngle(_up, ebikeAngle.current); cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
const swingX = -Math.abs(steerFactor) * 1.5; const swingX = -Math.abs(steerFactor) * CAM_SWAY_VERTICAL;
const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0; const swingZ = steerFactor * CAM_SWAY_SIDE;
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ); const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
cameraSwing.applyAxisAngle(_up, ebikeAngle.current); cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
cameraOffset.add(cameraSwing); cameraOffset.add(cameraSwing);
@@ -543,7 +557,7 @@ export function PlayerController({
.copy(capsule.current.end) .copy(capsule.current.end)
.add(cameraOffset); .add(cameraOffset);
camera.position.lerp(targetCamPos, 12 * dt); camera.position.lerp(targetCamPos, CAM_POS_LERP);
const pitchRad = THREE.MathUtils.degToRad( const pitchRad = THREE.MathUtils.degToRad(
EBIKE_CAMERA_TRANSFORM.rotation[0], EBIKE_CAMERA_TRANSFORM.rotation[0],
@@ -563,8 +577,12 @@ export function PlayerController({
capsule.current.end.y - PLAYER_EYE_HEIGHT, capsule.current.end.y - PLAYER_EYE_HEIGHT,
capsule.current.end.z, capsule.current.end.z,
); );
const leanAngle = steerFactor * 0.26; ebikeVisual.rotation.set(
ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ"); steerFactor * -BIKE_LEAN,
ebikeAngle.current,
0,
"YXZ",
);
} }
} else { } else {
camera.position.copy(capsule.current.end); camera.position.copy(capsule.current.end);