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
+30 -7
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]);
@@ -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}
+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>