Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a73f9fb951 | |||
| 193fc8b4b6 | |||
| 2c194cdd2e | |||
| feaf502e44 |
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+744
-189
File diff suppressed because it is too large
Load Diff
+170
-28
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||
import { EbikeSpeedometer } from "@/components/ebike/EbikeSpeedometer";
|
||||
import { EbikeSpeedmeter } from "@/components/ebike/EbikeSpeedmeter";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
@@ -25,6 +25,12 @@ import "@/types/ebike/ebikeWindow";
|
||||
|
||||
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
|
||||
|
||||
// Reusable vectors — allocated once to avoid per-frame GC pressure
|
||||
const _phareWorldPos = new THREE.Vector3();
|
||||
const _bikeForward = new THREE.Vector3();
|
||||
const _aimDir = new THREE.Vector3();
|
||||
const _up = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
interface EbikeProps {
|
||||
position: Vector3Tuple;
|
||||
}
|
||||
@@ -53,6 +59,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const camera = useThree((state) => state.camera);
|
||||
const threeScene = useThree((state) => state.scene);
|
||||
const updateEbikeSounds = useEbikeSounds();
|
||||
const repairGameOwnsEbikeModel =
|
||||
mainState === "ebike" &&
|
||||
@@ -96,6 +103,19 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
]);
|
||||
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
||||
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||
const phareRef = useRef<THREE.Object3D | null>(null);
|
||||
const headlightRef = useRef<THREE.SpotLight | null>(null);
|
||||
// SpotLight target — must live in the scene to define the cone direction.
|
||||
const headlightTarget = useMemo(() => new THREE.Object3D(), []);
|
||||
// Original quaternion of the Fourche node — rotation is applied on top of this.
|
||||
const forkInitialQuatRef = useRef(new THREE.Quaternion());
|
||||
// Smoothed steer angle for the fork (avoids direct Euler manipulation).
|
||||
const forkAngleRef = useRef(0);
|
||||
// Ref copy of movementMode — useFrame closures can capture stale React state.
|
||||
const movementModeRef = useRef(movementMode);
|
||||
// Becomes true the first time the player mounts. After that, dismounting
|
||||
// must NOT reset position back to the original spawn point.
|
||||
const hasRiddenRef = useRef(false);
|
||||
|
||||
// State for debug visualization (synced from refs during useFrame)
|
||||
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
||||
@@ -106,9 +126,42 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
parkedPosition[2],
|
||||
]);
|
||||
|
||||
// Keep movementModeRef in sync — useFrame closures capture React state at
|
||||
// render time and can become stale between renders.
|
||||
useEffect(() => {
|
||||
if (movementMode === "ebike") return;
|
||||
movementModeRef.current = movementMode;
|
||||
}, [movementMode]);
|
||||
|
||||
// SpotLight target must be in the scene to define the cone direction.
|
||||
useEffect(() => {
|
||||
threeScene.add(headlightTarget);
|
||||
return () => { threeScene.remove(headlightTarget); };
|
||||
}, [threeScene, headlightTarget]);
|
||||
|
||||
// Link the target to the SpotLight once it mounts.
|
||||
useEffect(() => {
|
||||
if (headlightRef.current) {
|
||||
headlightRef.current.target = headlightTarget;
|
||||
}
|
||||
}, [headlightTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (movementMode === "ebike") {
|
||||
// Player just mounted — mark as ridden so we never reset position again.
|
||||
hasRiddenRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasRiddenRef.current) {
|
||||
// Player dismounted: keep the position the bike was left at.
|
||||
// Just make sure the window vars are up to date for the next mount.
|
||||
window.ebikeParkedPosition = restingPositionRef.current;
|
||||
window.ebikeParkedRotation = restingRotationRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
// Bike has never been ridden yet — safe to (re)place it at the spawn point.
|
||||
// This also fires when parkedPosition recalculates (e.g. terrain loads late).
|
||||
restingPositionRef.current = parkedPosition;
|
||||
restingRotationRef.current = EBIKE_WORLD_ROTATION_Y;
|
||||
lastGpsUpdatePos.current.set(...parkedPosition);
|
||||
@@ -123,11 +176,24 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
}, [movementMode, parkedPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
const fork = model.getObjectByName("fourche");
|
||||
if (fork) {
|
||||
forkRef.current = fork;
|
||||
}
|
||||
if (!model) return;
|
||||
|
||||
let forkNode: THREE.Object3D | null = null;
|
||||
model.traverse((child) => {
|
||||
if (child.name.toLowerCase() === "fourche") forkNode = child;
|
||||
if (child.name === "Phare") phareRef.current = child;
|
||||
});
|
||||
|
||||
if (forkNode) {
|
||||
forkRef.current = forkNode;
|
||||
// Snapshot the rest-pose quaternion — steering is applied on top of this.
|
||||
forkInitialQuatRef.current.copy((forkNode as THREE.Object3D).quaternion);
|
||||
forkAngleRef.current = 0;
|
||||
console.log("[Ebike] Fork found:", (forkNode as THREE.Object3D).name);
|
||||
} else {
|
||||
const names: string[] = [];
|
||||
model.traverse((c) => { if (c.name) names.push(c.name); });
|
||||
console.warn("[Ebike] Fork not found. All nodes:", names);
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
@@ -154,11 +220,48 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
}, []);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
// ── SpotLight headlight — tune the constants below ────────────────────────
|
||||
// ── SpotLight headlight — tune these four constants ───────────────────────
|
||||
const LIGHT_OFFSET_X = -0.7; // position : left(-) / right(+)
|
||||
const LIGHT_OFFSET_Y = 1.5; // position : down(-) / up(+)
|
||||
const LIGHT_OFFSET_Z = 0; // position : backward(-) / forward(+)
|
||||
const LIGHT_AIM_DEG = 90; // aim rotation around Y : 0=forward, -90=left, +90=right
|
||||
const LIGHT_TARGET_DIST = 20; // metres devant la position de la lumière
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
if (headlightRef.current && phareRef.current && groupRef.current) {
|
||||
phareRef.current.getWorldPosition(_phareWorldPos);
|
||||
groupRef.current.getWorldDirection(_bikeForward);
|
||||
|
||||
// Position offset in bike-local space (no GC — reusing module-level vectors)
|
||||
const right = _bikeForward.clone().cross(_up).normalize();
|
||||
_phareWorldPos
|
||||
.addScaledVector(right, LIGHT_OFFSET_X)
|
||||
.addScaledVector(_up, LIGHT_OFFSET_Y)
|
||||
.addScaledVector(_bikeForward, LIGHT_OFFSET_Z);
|
||||
|
||||
headlightRef.current.position.copy(_phareWorldPos);
|
||||
|
||||
// Aim direction: rotate forward around Y by LIGHT_AIM_DEG
|
||||
_aimDir
|
||||
.copy(_bikeForward)
|
||||
.applyAxisAngle(_up, THREE.MathUtils.degToRad(LIGHT_AIM_DEG));
|
||||
|
||||
headlightTarget.position
|
||||
.copy(_phareWorldPos)
|
||||
.addScaledVector(_aimDir, LIGHT_TARGET_DIST);
|
||||
headlightTarget.updateMatrixWorld();
|
||||
}
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (groupRef.current) {
|
||||
if (movementMode === "ebike") {
|
||||
// Use the ref — not the React state — to avoid stale closure bugs in
|
||||
// R3F's frame loop (the state value may not update until the next render).
|
||||
if (movementModeRef.current === "ebike") {
|
||||
// Sound plays whenever the bike is actually moving (speedFactor > 5 %),
|
||||
// not only while the input key is held.
|
||||
updateEbikeSounds({
|
||||
mounted: true,
|
||||
driving: window.ebikeDriveInputActive === true,
|
||||
driving: (window.ebikeSpeedFactor ?? 0) > 0.05,
|
||||
breakdown: window.ebikeBreakdownActive === true,
|
||||
});
|
||||
|
||||
@@ -169,16 +272,31 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
];
|
||||
restingRotationRef.current = groupRef.current.rotation.y;
|
||||
|
||||
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
|
||||
// ── Fork steering via quaternion ──────────────────────────────────────
|
||||
// We rotate around the fork's LOCAL Y axis (steering tube) by composing
|
||||
// a fresh quaternion on top of the rest-pose snapshot taken at load time.
|
||||
// This is axis-agnostic: correct regardless of how Blender exported the node.
|
||||
// Tune FORK_ANGLE (radians) or negate it if the visual direction is wrong.
|
||||
const FORK_ANGLE = 0.12; // 10°
|
||||
const steerFactor = window.ebikeSteerFactor ?? 0;
|
||||
|
||||
if (forkRef.current) {
|
||||
// 15 degrees is 0.26 radians
|
||||
const targetForkRotation = steerFactor * 0.26;
|
||||
forkRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||
forkRef.current.rotation.z,
|
||||
targetForkRotation,
|
||||
// Smooth the angle separately so we can apply it cleanly each frame.
|
||||
forkAngleRef.current = THREE.MathUtils.lerp(
|
||||
forkAngleRef.current,
|
||||
steerFactor * FORK_ANGLE,
|
||||
12 * delta,
|
||||
);
|
||||
// Build steer quat around LOCAL Y of the fork node.
|
||||
const steerQuat = new THREE.Quaternion().setFromAxisAngle(
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
forkAngleRef.current,
|
||||
);
|
||||
// Apply on top of rest-pose: Q_final = Q_rest × Q_steer
|
||||
forkRef.current.quaternion.multiplyQuaternions(
|
||||
forkInitialQuatRef.current,
|
||||
steerQuat,
|
||||
);
|
||||
}
|
||||
|
||||
// Throttled GPS start position update to prevent performance loss
|
||||
@@ -197,9 +315,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
groupRef.current.position.set(...restingPositionRef.current);
|
||||
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
|
||||
|
||||
// Reset fork rotation when parked
|
||||
// Reset fork to rest-pose when parked
|
||||
if (forkRef.current) {
|
||||
forkRef.current.rotation.z = 0;
|
||||
forkRef.current.quaternion.copy(forkInitialQuatRef.current);
|
||||
forkAngleRef.current = 0;
|
||||
}
|
||||
}
|
||||
window.ebikeParkedPosition = restingPositionRef.current;
|
||||
@@ -329,6 +448,9 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
scale={EBIKE_WORLD_SCALE}
|
||||
>
|
||||
<primitive object={model} />
|
||||
{/* radius 20 → ~7 unités monde (scale 0.35).
|
||||
Sphère omnidirectionnelle pour que le raycast fonctionne
|
||||
quelle que soit l'orientation de la caméra (montée ou à pied). */}
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={interactionLabel}
|
||||
@@ -337,16 +459,25 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
onPress={handleInteract}
|
||||
>
|
||||
<mesh>
|
||||
<boxGeometry args={[8, 9, 2]} />
|
||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||
<sphereGeometry args={[8, 15, 12]} />
|
||||
<meshBasicMaterial colorWrite={false} color={"red"} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
|
||||
{/* Dynamic 3D GPS Dashboard Screen */}
|
||||
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
||||
{/* GPS + Speedmeter – same group so they are perfectly co-localised.
|
||||
GPS: full circle (Fresnel mask), renderOrder 10 000
|
||||
Speedmeter: upper-half arc overlay, renderOrder 10 001
|
||||
rotation: Math.PI/2 radians = 90° (NOT the number 90 which = ~116.6°) */}
|
||||
<group position={[2, 6, 0]} rotation={[0, -80, 0]}>
|
||||
<EbikeSpeedmeter width={3} height={1.5} position={[0, 0.4, 0]} gaugeInnerR={0.33} gaugeOuterR={0.445}
|
||||
gaugeWidth={2.5}
|
||||
gaugeHeight={2.1}
|
||||
gaugeOffsetX={0}
|
||||
gaugeOffsetY={-0.19}
|
||||
/>
|
||||
<EbikeGPSMap
|
||||
width={0.8}
|
||||
height={0.8}
|
||||
width={1.3}
|
||||
height={1}
|
||||
startPos={gpsStartPos}
|
||||
destPos={destPos}
|
||||
mapImageUrl="/assets/world/gps/map_background.png"
|
||||
@@ -359,15 +490,26 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
zoom={4}
|
||||
/>
|
||||
</group>
|
||||
<group position={[0, 6.35, 0]} rotation={[0, 90, 0]}>
|
||||
<EbikeSpeedometer />
|
||||
</group>
|
||||
</group>
|
||||
) : null}
|
||||
|
||||
{/* SpotLight headlight — cone aimed forward, position & target via useFrame */}
|
||||
{!repairGameOwnsEbikeModel && (
|
||||
<spotLight
|
||||
ref={headlightRef}
|
||||
intensity={100}
|
||||
color="#ffca60"
|
||||
angle={Math.PI / 5} // 22.5° demi-angle — cone étroit comme une torche
|
||||
penumbra={0.5} // bord doux (0 = dur, 1 = très doux)
|
||||
distance={50}
|
||||
decay={2.5}
|
||||
castShadow={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCameraPoints && !repairGameOwnsEbikeModel && (
|
||||
<>
|
||||
<mesh position={camPointPos}>
|
||||
{/* <mesh position={camPointPos}>
|
||||
<sphereGeometry args={[0.3, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color="yellow"
|
||||
@@ -382,7 +524,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
emissive="cyan"
|
||||
emissiveIntensity={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
</mesh> */}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,28 @@ import {
|
||||
} from "@/pathfinding/WaypointAStar";
|
||||
import type { Waypoint } from "@/pathfinding/types";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const VERT_SHADER = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Circular Fresnel mask: fully visible inside innerRadius, fades out to outerRadius
|
||||
const FRAG_SHADER = /* glsl */ `
|
||||
uniform sampler2D map;
|
||||
uniform float innerRadius;
|
||||
uniform float outerRadius;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vec4 color = texture2D(map, vUv);
|
||||
float dist = length(vUv - vec2(0.5));
|
||||
float mask = 1.0 - smoothstep(innerRadius, outerRadius, dist);
|
||||
gl_FragColor = vec4(color.rgb, color.a * mask);
|
||||
}
|
||||
`;
|
||||
function computeImageSource(
|
||||
img: HTMLImageElement | HTMLCanvasElement,
|
||||
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
||||
@@ -126,19 +148,57 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Canvas should only be created once
|
||||
}, []);
|
||||
|
||||
// Resize the canvas whenever canvasSize changes
|
||||
// Note: Modifying canvas dimensions is intentional and necessary for rendering
|
||||
useEffect(() => {
|
||||
// Use Object.assign to resize canvas - this is a necessary mutation for canvas rendering
|
||||
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
|
||||
if (textureRef.current) {
|
||||
textureRef.current.needsUpdate = true;
|
||||
}
|
||||
}, [canvasSize, offscreenCanvas]);
|
||||
|
||||
const textureRef = useRef<THREE.CanvasTexture | null>(null);
|
||||
const animTimeRef = useRef<number>(0);
|
||||
|
||||
// Imperative CanvasTexture — must be declared before the resize effect below
|
||||
const texture = useMemo(() => {
|
||||
const tex = new THREE.CanvasTexture(offscreenCanvas);
|
||||
tex.format = THREE.RGBAFormat;
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
return tex;
|
||||
}, [offscreenCanvas]);
|
||||
|
||||
// ShaderMaterial with circular Fresnel mask (created once)
|
||||
const shaderMat = useMemo(
|
||||
() =>
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
map: { value: null },
|
||||
innerRadius: { value: 0.45 },
|
||||
outerRadius: { value: 0.5 },
|
||||
},
|
||||
vertexShader: VERT_SHADER,
|
||||
fragmentShader: FRAG_SHADER,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
toneMapped: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Sync texture into uniform when it changes (canvas resize)
|
||||
useEffect(() => {
|
||||
shaderMat.uniforms.map.value = texture;
|
||||
}, [shaderMat, texture]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
shaderMat.dispose();
|
||||
texture.dispose();
|
||||
},
|
||||
[shaderMat, texture],
|
||||
);
|
||||
|
||||
// Resize the canvas whenever canvasSize changes (texture declared above)
|
||||
useEffect(() => {
|
||||
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
|
||||
texture.needsUpdate = true;
|
||||
}, [canvasSize, offscreenCanvas, texture]);
|
||||
|
||||
// Load waypoints (localStorage with /roadNetwork.json fallback)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -492,42 +552,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
useEffect(() => {
|
||||
let animId: number;
|
||||
const tick = () => {
|
||||
animTimeRef.current += 0.004; // Slow, premium sweep speed
|
||||
animTimeRef.current += 0.004;
|
||||
if (animTimeRef.current > 1) animTimeRef.current = 0;
|
||||
|
||||
draw();
|
||||
|
||||
// Update texture after draw
|
||||
if (textureRef.current) {
|
||||
textureRef.current.needsUpdate = true;
|
||||
}
|
||||
|
||||
texture.needsUpdate = true;
|
||||
animId = requestAnimationFrame(tick);
|
||||
};
|
||||
animId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(animId);
|
||||
}, [draw]);
|
||||
}, [draw, texture]);
|
||||
|
||||
return (
|
||||
<mesh position={position} renderOrder={renderOrder}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
toneMapped={false}
|
||||
transparent={true}
|
||||
opacity={1}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
side={THREE.DoubleSide}
|
||||
>
|
||||
<canvasTexture
|
||||
ref={textureRef}
|
||||
attach="map"
|
||||
image={offscreenCanvas}
|
||||
format={THREE.RGBAFormat}
|
||||
minFilter={THREE.LinearFilter}
|
||||
magFilter={THREE.LinearFilter}
|
||||
/>
|
||||
</meshBasicMaterial>
|
||||
<primitive object={shaderMat} attach="material" />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useRef, useMemo } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useTexture } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import "@/types/ebike/ebikeWindow";
|
||||
|
||||
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
|
||||
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
|
||||
|
||||
export interface EbikeSpeedmeterProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Local position offset within the parent group. Default: [0, 0, 0] */
|
||||
position?: Vector3Tuple;
|
||||
/**
|
||||
* Needle rotation.z when speedFactor = 0.
|
||||
* Default: Math.PI / 2 (pointing left — 9 o'clock)
|
||||
*/
|
||||
minAngle?: number;
|
||||
/**
|
||||
* Needle rotation.z when speedFactor = 1.
|
||||
* Default: -Math.PI / 2 (pointing right — 3 o'clock)
|
||||
*/
|
||||
maxAngle?: number;
|
||||
renderOrder?: number;
|
||||
/**
|
||||
* Inner radius of the gauge-fill arc, as a fraction of the canvas half-width.
|
||||
* Tune this to align the fill with the cadran.png track. Default: 0.33
|
||||
*/
|
||||
gaugeInnerR?: number;
|
||||
/**
|
||||
* Outer radius of the gauge-fill arc, as a fraction of the canvas half-width.
|
||||
* Tune this to align the fill with the cadran.png track. Default: 0.445
|
||||
*/
|
||||
gaugeOuterR?: number;
|
||||
/**
|
||||
* Width of the gauge-fill plane. Defaults to `width` when omitted.
|
||||
* Lets you resize the fill independently of the cadran/needle.
|
||||
*/
|
||||
gaugeWidth?: number;
|
||||
/**
|
||||
* Height of the gauge-fill plane. Defaults to `height` when omitted.
|
||||
* Lets you resize the fill independently of the cadran/needle.
|
||||
*/
|
||||
gaugeHeight?: number;
|
||||
/**
|
||||
* Horizontal offset of the arc pivot from the canvas centre.
|
||||
* Expressed as a fraction of the canvas size: -0.1 = shift 10 % to the left,
|
||||
* +0.1 = shift 10 % to the right. Default: 0
|
||||
*/
|
||||
gaugeOffsetX?: number;
|
||||
/**
|
||||
* Vertical offset of the arc pivot from its default position.
|
||||
* Expressed as a fraction of the canvas size: -0.1 = shift upward (toward top
|
||||
* of the plane), +0.1 = shift downward. Default: 0
|
||||
*/
|
||||
gaugeOffsetY?: number;
|
||||
}
|
||||
|
||||
// The needle pivot is always at -height*0.38 in local space,
|
||||
// which is always 12 % from the bottom of the plane (UV y = 0.12).
|
||||
// With Three.js flipY texture convention, canvas y = (1 - 0.12) * size = 0.88 * size.
|
||||
const NEEDLE_PIVOT_UV_Y = 0.12; // fraction from bottom
|
||||
|
||||
export function EbikeSpeedmeter({
|
||||
width = 0.8,
|
||||
height = 0.8,
|
||||
position = [0, 0, 0],
|
||||
minAngle = Math.PI / 2,
|
||||
maxAngle = -Math.PI / 2,
|
||||
renderOrder = 1000,
|
||||
gaugeInnerR = 0.33,
|
||||
gaugeOuterR = 0.445,
|
||||
gaugeWidth,
|
||||
gaugeHeight,
|
||||
gaugeOffsetX = 0,
|
||||
gaugeOffsetY = 0,
|
||||
}: EbikeSpeedmeterProps): React.JSX.Element {
|
||||
// Fall back to the main dimensions when gauge-specific ones aren't provided
|
||||
const fillW = gaugeWidth ?? width;
|
||||
const fillH = gaugeHeight ?? height;
|
||||
const needleGroupRef = useRef<THREE.Group>(null);
|
||||
const speedFactorRef = useRef(0);
|
||||
|
||||
// ── Dial & needle textures ──────────────────────────────────────────────────
|
||||
const [dialTexture, needleTexture] = useTexture([
|
||||
SPEEDOMETER_DIAL_TEXTURE,
|
||||
SPEEDOMETER_NEEDLE_TEXTURE,
|
||||
]) as [THREE.Texture, THREE.Texture];
|
||||
|
||||
const needleWidth = width * 0.68;
|
||||
const needleHeight = needleWidth / 2;
|
||||
|
||||
useEffect(() => {
|
||||
[dialTexture, needleTexture].forEach((tex) => {
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.needsUpdate = true;
|
||||
});
|
||||
}, [dialTexture, needleTexture]);
|
||||
|
||||
// ── Gauge-fill canvas ───────────────────────────────────────────────────────
|
||||
const fillCanvas = useMemo(() => {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 256;
|
||||
c.height = 256;
|
||||
return c;
|
||||
}, []);
|
||||
|
||||
const fillTexture = useMemo(() => {
|
||||
const tex = new THREE.CanvasTexture(fillCanvas);
|
||||
tex.format = THREE.RGBAFormat;
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
return tex;
|
||||
}, [fillCanvas]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
fillTexture.dispose();
|
||||
},
|
||||
[fillTexture],
|
||||
);
|
||||
|
||||
// ── Frame loop ──────────────────────────────────────────────────────────────
|
||||
useFrame((_, delta) => {
|
||||
// 1. Smooth speed factor
|
||||
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
|
||||
speedFactorRef.current = THREE.MathUtils.lerp(
|
||||
speedFactorRef.current,
|
||||
target,
|
||||
Math.min(1, delta * 10),
|
||||
);
|
||||
|
||||
// 2. Needle rotation
|
||||
if (needleGroupRef.current) {
|
||||
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||
minAngle,
|
||||
maxAngle,
|
||||
speedFactorRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Draw gauge fill -------------------------------------------------------
|
||||
const ctx = fillCanvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const size = fillCanvas.width;
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// Default centre: horizontal middle + needle-pivot height.
|
||||
// gaugeOffsetX/Y shift the pivot so the arc aligns with cadran.png.
|
||||
const cx = size * (0.5 + gaugeOffsetX);
|
||||
const cy = size * ((1 - NEEDLE_PIVOT_UV_Y) + gaugeOffsetY); // default ≈ 0.88 × size
|
||||
|
||||
const outerR = size * gaugeOuterR;
|
||||
const innerR = size * gaugeInnerR;
|
||||
|
||||
// Arc sweeps clockwise from π (left) to current needle angle
|
||||
const arcStart = Math.PI;
|
||||
const arcEnd = Math.PI + speedFactorRef.current * Math.PI;
|
||||
|
||||
if (speedFactorRef.current > 0.005) {
|
||||
// Radial gradient using #3F67DD — slightly transparent at inner edge,
|
||||
// fully solid at outer edge for a depth effect.
|
||||
const radial = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
|
||||
radial.addColorStop(0, "rgba(191, 234, 255, 0)"); // inner edge
|
||||
radial.addColorStop(0.7, "rgba(118, 152, 255, 0.95)"); // outer edge
|
||||
|
||||
// Annular sector shape (outer arc + inner arc reversed)
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, outerR, arcStart, arcEnd, false);
|
||||
ctx.arc(cx, cy, innerR, arcEnd, arcStart, true);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = radial;
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.shadowColor = "#3F67DD";
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
fillTexture.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<group renderOrder={renderOrder} position={position}>
|
||||
{/* Gauge fill — behind the cadran frame (size controlled by gaugeWidth/gaugeHeight) */}
|
||||
<mesh renderOrder={renderOrder - 1} position={[0, 0, -0.001]}>
|
||||
<planeGeometry args={[fillW, fillH]} />
|
||||
<meshBasicMaterial
|
||||
map={fillTexture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Dial frame (cadran.png) */}
|
||||
<mesh renderOrder={renderOrder}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
map={dialTexture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Needle — pivot at bottom-centre of the arc */}
|
||||
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]} rotation={[0, 0, 0]}>
|
||||
<mesh
|
||||
position={[0, needleHeight / 2, 0]}
|
||||
renderOrder={renderOrder + 1}
|
||||
>
|
||||
<planeGeometry args={[needleWidth, needleHeight]} />
|
||||
<meshBasicMaterial
|
||||
map={needleTexture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export function MissionNotification({
|
||||
}: MissionNotificationProps): React.JSX.Element {
|
||||
const src =
|
||||
imagePath ?? (mission ? MISSION_NOTIFICATION_IMAGE_PATHS[mission] : "");
|
||||
const isVideo = src.toLowerCase().endsWith(".webm");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -22,11 +23,24 @@ export function MissionNotification({
|
||||
>
|
||||
<div className="mission-notification__glow" />
|
||||
<span className="mission-notification__image-wrap">
|
||||
<img
|
||||
className="mission-notification__image"
|
||||
src={src}
|
||||
alt="Nouvel objectif de mission"
|
||||
/>
|
||||
{isVideo ? (
|
||||
<video
|
||||
className="mission-notification__image"
|
||||
src={src}
|
||||
aria-label="Nouvel objectif de mission"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
className="mission-notification__image"
|
||||
src={src}
|
||||
alt="Nouvel objectif de mission"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface CameraTransform {
|
||||
}
|
||||
|
||||
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||
position: [-2.6, 4.5, 0],
|
||||
position: [-1, 1, 0],
|
||||
rotation: [-10, -90, 0],
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ export const EBIKE_WORLD_SCALE = 0.35;
|
||||
export const EBIKE_INTRO_BREAKDOWN_DISTANCE = 15;
|
||||
export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
|
||||
|
||||
export const EBIKE_MAX_SPEED = 3;
|
||||
export const EBIKE_ACCELERATION_DURATION_MS = 2000;
|
||||
export const EBIKE_DECELERATION_DURATION_MS = 2000;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const INTRO_MISSION_NOTIFICATION_IMAGE_PATH =
|
||||
|
||||
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
|
||||
{
|
||||
ebike: "/assets/world/UI/ebike-mission-notification.png",
|
||||
pylon: "/assets/world/UI/pylon-mission-notification.png",
|
||||
farm: "/assets/world/UI/farm-mission-notification.png",
|
||||
ebike: "/assets/world/UI/ebike-mission-notification.webm",
|
||||
pylon: "/assets/world/UI/pylon-mission-notification.webm",
|
||||
farm: "/assets/world/UI/farm-mission-notification.webm",
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
"Repair the damaged cooling module before relaunching the bike",
|
||||
modelPath: "/models/ebike/model.gltf",
|
||||
modelScale: 0.3,
|
||||
stageUiPath: "/assets/world/UI/ebike.webm",
|
||||
stageUiPath: "/assets/world/UI/ebike-mission-notification.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
description:
|
||||
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
stageUiPath: "/assets/world/UI/centrale.webm",
|
||||
stageUiPath: "/assets/world/UI/pylon-mission-notification.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
description:
|
||||
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
||||
modelPath: "/models/fermeverticale/model.gltf",
|
||||
stageUiPath: "/assets/world/UI/laferme.webm",
|
||||
stageUiPath: "/assets/world/UI/farm-mission-notification.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
|
||||
@@ -5,7 +5,7 @@ export const PLAYER_EYE_HEIGHT = 1.75;
|
||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||
|
||||
export const PLAYER_WALK_SPEED = 5;
|
||||
export const PLAYER_EBIKE_SPEED = 30;
|
||||
export const PLAYER_EBIKE_SPEED = 20;
|
||||
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||
export const PLAYER_JUMP_SPEED = 9;
|
||||
export const PLAYER_GRAVITY = 30;
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
EBIKE_ACCELERATION_DURATION_MS,
|
||||
EBIKE_CAMERA_TRANSFORM,
|
||||
EBIKE_DECELERATION_DURATION_MS,
|
||||
EBIKE_MAX_SPEED,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
|
||||
/** Global window properties used for ebike communication */
|
||||
@@ -415,7 +414,7 @@ export function PlayerController({
|
||||
}
|
||||
|
||||
const movementSpeed = isEbikeMounted
|
||||
? EBIKE_MAX_SPEED * ebikeSpeedFactor.current
|
||||
? currentSpeed * ebikeSpeedFactor.current
|
||||
: currentSpeed;
|
||||
const accel = onFloor.current
|
||||
? movementSpeed
|
||||
@@ -513,14 +512,29 @@ export function PlayerController({
|
||||
);
|
||||
window.ebikeSteerFactor = steerFactor;
|
||||
|
||||
// ── Ebike camera tuning ──────────────────────────────────────────────────
|
||||
// All motion effects in one place — set to 0 to fully disable each one.
|
||||
/** Lateral camera drift when steering (0 = no sway) */
|
||||
const CAM_SWAY_SIDE = -0.5;
|
||||
/** Vertical camera drop when steering (0 = no recoil) */
|
||||
const CAM_SWAY_VERTICAL = 0;
|
||||
/** Position lerp factor. 1 = instant snap, lower = more lag/trail */
|
||||
const CAM_POS_LERP = 1;
|
||||
/** FOV boost at full speed in degrees (0 = constant FOV) */
|
||||
const CAM_FOV_BOOST = 0.15; // speed × 0.15, capped at 3° → subtle speed sensation
|
||||
/** How fast FOV lerps toward target (lower = slower breathing) */
|
||||
const CAM_FOV_LERP = 4;
|
||||
/** Visual body lean in radians at max steer (20° = 0.349 rad) */
|
||||
const BIKE_LEAN = THREE.MathUtils.degToRad(10);
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const speed = velocity.current.length();
|
||||
const targetFov = 60 + Math.min(speed * 0.35, 9);
|
||||
const perspectiveCam = camera as THREE.PerspectiveCamera;
|
||||
// eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes during frame updates
|
||||
perspectiveCam.fov = THREE.MathUtils.lerp(
|
||||
perspectiveCam.fov,
|
||||
targetFov,
|
||||
6 * dt,
|
||||
60 + Math.min(speed * CAM_FOV_BOOST, 3),
|
||||
CAM_FOV_LERP * dt,
|
||||
);
|
||||
perspectiveCam.updateProjectionMatrix();
|
||||
|
||||
@@ -529,9 +543,8 @@ export function PlayerController({
|
||||
);
|
||||
cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
|
||||
|
||||
const swingX = -Math.abs(steerFactor) * 1.5;
|
||||
const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0;
|
||||
|
||||
const swingX = -Math.abs(steerFactor) * CAM_SWAY_VERTICAL;
|
||||
const swingZ = steerFactor * CAM_SWAY_SIDE;
|
||||
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
|
||||
cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
|
||||
cameraOffset.add(cameraSwing);
|
||||
@@ -540,7 +553,7 @@ export function PlayerController({
|
||||
.copy(capsule.current.end)
|
||||
.add(cameraOffset);
|
||||
|
||||
camera.position.lerp(targetCamPos, 12 * dt);
|
||||
camera.position.lerp(targetCamPos, CAM_POS_LERP);
|
||||
|
||||
const pitchRad = THREE.MathUtils.degToRad(
|
||||
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
||||
@@ -560,8 +573,12 @@ export function PlayerController({
|
||||
capsule.current.end.y - PLAYER_EYE_HEIGHT,
|
||||
capsule.current.end.z,
|
||||
);
|
||||
const leanAngle = steerFactor * 0.26;
|
||||
ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ");
|
||||
ebikeVisual.rotation.set(
|
||||
steerFactor * -BIKE_LEAN,
|
||||
ebikeAngle.current,
|
||||
0,
|
||||
"YXZ",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
camera.position.copy(capsule.current.end);
|
||||
|
||||
Reference in New Issue
Block a user