fix(repair): keep ebike at zone Y in test scene

Adds an opt-out 'snapToTerrain' prop on Ebike so the parked position
keeps the explicit Y supplied by callers instead of resolving against
the world terrain GLTF. TestMap passes snapToTerrain={false} since it
does not render the world terrain — without this the bike was being
positioned at the invisible terrain height, far above the test floor,
and looked missing.
This commit is contained in:
Tom Boullay
2026-06-02 22:10:31 +02:00
parent 2a6a028e1d
commit 6a0215d1a6
2 changed files with 38 additions and 15 deletions
+37 -14
View File
@@ -33,9 +33,19 @@ const _up = new THREE.Vector3(0, 1, 0);
interface EbikeProps { interface EbikeProps {
position: Vector3Tuple; position: Vector3Tuple;
/**
* When true (default), the parked position is snapped to the world terrain
* height. Pass false in test scenes that don't render the world terrain so
* the bike stays at the explicit Y of {@link position} instead of floating
* at the (invisible) terrain height.
*/
snapToTerrain?: boolean;
} }
export function Ebike({ position }: EbikeProps): React.JSX.Element { export function Ebike({
position,
snapToTerrain = true,
}: EbikeProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, { const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, {
scope: "Ebike", scope: "Ebike",
@@ -45,7 +55,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
const terrainHeight = useTerrainHeightSampler(); const terrainHeight = useTerrainHeightSampler();
const parkedPosition = useMemo<Vector3Tuple>(() => { const parkedPosition = useMemo<Vector3Tuple>(() => {
const [x, y, z] = position; const [x, y, z] = position;
const height = terrainHeight.getHeight(x, z) ?? y; const height = snapToTerrain ? (terrainHeight.getHeight(x, z) ?? y) : y;
const bottomOffset = getObjectBottomOffset(model, [ const bottomOffset = getObjectBottomOffset(model, [
EBIKE_WORLD_SCALE, EBIKE_WORLD_SCALE,
EBIKE_WORLD_SCALE, EBIKE_WORLD_SCALE,
@@ -53,7 +63,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
]); ]);
return [x, height + bottomOffset, z]; return [x, height + bottomOffset, z];
}, [model, position, terrainHeight]); }, [model, position, snapToTerrain, terrainHeight]);
const movementMode = useGameStore((state) => state.player.movementMode); const movementMode = useGameStore((state) => state.player.movementMode);
const mainState = useGameStore((state) => state.mainState); const mainState = useGameStore((state) => state.mainState);
const ebikeStep = useGameStore((state) => state.ebike.currentStep); const ebikeStep = useGameStore((state) => state.ebike.currentStep);
@@ -135,7 +145,9 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
// SpotLight target must be in the scene to define the cone direction. // SpotLight target must be in the scene to define the cone direction.
useEffect(() => { useEffect(() => {
threeScene.add(headlightTarget); threeScene.add(headlightTarget);
return () => { threeScene.remove(headlightTarget); }; return () => {
threeScene.remove(headlightTarget);
};
}, [threeScene, headlightTarget]); }, [threeScene, headlightTarget]);
// Link the target to the SpotLight once it mounts. // Link the target to the SpotLight once it mounts.
@@ -192,7 +204,9 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
console.log("[Ebike] Fork found:", (forkNode as THREE.Object3D).name); console.log("[Ebike] Fork found:", (forkNode as THREE.Object3D).name);
} else { } else {
const names: string[] = []; const names: string[] = [];
model.traverse((c) => { if (c.name) names.push(c.name); }); model.traverse((c) => {
if (c.name) names.push(c.name);
});
console.warn("[Ebike] Fork not found. All nodes:", names); console.warn("[Ebike] Fork not found. All nodes:", names);
} }
}, [model]); }, [model]);
@@ -222,11 +236,11 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
useFrame((_, delta) => { useFrame((_, delta) => {
// ── SpotLight headlight — tune the constants below ──────────────────────── // ── SpotLight headlight — tune the constants below ────────────────────────
// ── SpotLight headlight — tune these four constants ─────────────────────── // ── SpotLight headlight — tune these four constants ───────────────────────
const LIGHT_OFFSET_X = -0.7; // position : left(-) / right(+) const LIGHT_OFFSET_X = -0.7; // position : left(-) / right(+)
const LIGHT_OFFSET_Y = 1.5; // position : down(-) / up(+) const LIGHT_OFFSET_Y = 1.5; // position : down(-) / up(+)
const LIGHT_OFFSET_Z = 0; // position : backward(-) / forward(+) 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_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 const LIGHT_TARGET_DIST = 20; // metres devant la position de la lumière
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
if (headlightRef.current && phareRef.current && groupRef.current) { if (headlightRef.current && phareRef.current && groupRef.current) {
phareRef.current.getWorldPosition(_phareWorldPos); phareRef.current.getWorldPosition(_phareWorldPos);
@@ -460,7 +474,11 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
> >
<mesh> <mesh>
<sphereGeometry args={[8, 15, 12]} /> <sphereGeometry args={[8, 15, 12]} />
<meshBasicMaterial colorWrite={false} color={"red"} depthWrite={false} /> <meshBasicMaterial
colorWrite={false}
color={"red"}
depthWrite={false}
/>
</mesh> </mesh>
</InteractableObject> </InteractableObject>
@@ -469,7 +487,12 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
Speedmeter: upper-half arc overlay, renderOrder 10 001 Speedmeter: upper-half arc overlay, renderOrder 10 001
rotation: Math.PI/2 radians = 90° (NOT the number 90 which = ~116.6°) */} rotation: Math.PI/2 radians = 90° (NOT the number 90 which = ~116.6°) */}
<group position={[2, 6, 0]} rotation={[0, -80, 0]}> <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} <EbikeSpeedmeter
width={3}
height={1.5}
position={[0, 0.4, 0]}
gaugeInnerR={0.33}
gaugeOuterR={0.445}
gaugeWidth={2.5} gaugeWidth={2.5}
gaugeHeight={2.1} gaugeHeight={2.1}
gaugeOffsetX={0} gaugeOffsetX={0}
@@ -499,8 +522,8 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
ref={headlightRef} ref={headlightRef}
intensity={100} intensity={100}
color="#ffca60" color="#ffca60"
angle={Math.PI / 5} // 22.5° demi-angle — cone étroit comme une torche angle={Math.PI / 5} // 22.5° demi-angle — cone étroit comme une torche
penumbra={0.5} // bord doux (0 = dur, 1 = très doux) penumbra={0.5} // bord doux (0 = dur, 1 = très doux)
distance={50} distance={50}
decay={2.5} decay={2.5}
castShadow={false} castShadow={false}
+1 -1
View File
@@ -241,7 +241,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
<RepairPlaygroundZoneMarker color={zone.color} /> <RepairPlaygroundZoneMarker color={zone.color} />
</group> </group>
{zone.mission === "ebike" ? ( {zone.mission === "ebike" ? (
<Ebike position={zone.position} /> <Ebike position={zone.position} snapToTerrain={false} />
) : null} ) : null}
<RepairGame mission={zone.mission} position={zone.position} /> <RepairGame mission={zone.mission} position={zone.position} />
</group> </group>