Merge branch 'develop' into feat/polish-mission-2
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

This commit is contained in:
math-pixel
2026-06-03 01:52:20 +02:00
31 changed files with 661 additions and 857 deletions
+83 -52
View File
@@ -33,9 +33,19 @@ const _up = new THREE.Vector3(0, 1, 0);
interface EbikeProps {
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 { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, {
scope: "Ebike",
@@ -45,7 +55,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
const terrainHeight = useTerrainHeightSampler();
const parkedPosition = useMemo<Vector3Tuple>(() => {
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, [
EBIKE_WORLD_SCALE,
EBIKE_WORLD_SCALE,
@@ -53,7 +63,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
]);
return [x, height + bottomOffset, z];
}, [model, position, terrainHeight]);
}, [model, position, snapToTerrain, terrainHeight]);
const movementMode = useGameStore((state) => state.player.movementMode);
const mainState = useGameStore((state) => state.mainState);
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
@@ -119,12 +129,6 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
// State for debug visualization (synced from refs during useFrame)
const [showCameraPoints, setShowCameraPoints] = useState(true);
const [debugRestingPosition, setDebugRestingPosition] =
useState<Vector3Tuple>([
parkedPosition[0],
parkedPosition[1],
parkedPosition[2],
]);
// Keep movementModeRef in sync — useFrame closures capture React state at
// render time and can become stale between renders.
@@ -135,7 +139,9 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
// SpotLight target must be in the scene to define the cone direction.
useEffect(() => {
threeScene.add(headlightTarget);
return () => { threeScene.remove(headlightTarget); };
return () => {
threeScene.remove(headlightTarget);
};
}, [threeScene, headlightTarget]);
// Link the target to the SpotLight once it mounts.
@@ -192,7 +198,9 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
console.log("[Ebike] Fork found:", (forkNode as THREE.Object3D).name);
} else {
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);
}
}, [model]);
@@ -222,11 +230,11 @@ 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
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);
@@ -307,9 +315,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
}
// Sync debug visualization state (throttled to avoid excessive re-renders)
if (showCameraPoints) {
setDebugRestingPosition([...restingPositionRef.current]);
}
// Debug visualization positions are derived elsewhere when needed.
} else {
updateEbikeSounds({ mounted: false, driving: false, breakdown: false });
groupRef.current.position.set(...restingPositionRef.current);
@@ -326,24 +332,26 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
}
});
// Debug visualization positions computed from state (not refs)
const camPointPos: Vector3Tuple = [
debugRestingPosition[0] + EBIKE_CAMERA_TRANSFORM.position[0],
debugRestingPosition[1] + EBIKE_CAMERA_TRANSFORM.position[1],
debugRestingPosition[2] + EBIKE_CAMERA_TRANSFORM.position[2],
];
const dropPointPos: Vector3Tuple = [
debugRestingPosition[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
];
const interactionLabel =
mainState === "ebike"
? "Réparer l'e-bike"
? "Lancer le repair game"
: movementMode === "walk"
? "Monter sur le bike"
: "Descendre du bike";
// Hide the interact prompt while the player is actively riding the bike
// (driving input pressed) so the "Descendre du bike" label doesn't
// pollute the view. The prompt comes back the moment the bike comes to
// a stop. window.ebikeDriveInputActive is published every frame by
// PlayerController based on whether a movement key is currently held.
const [isEbikeDriving, setIsEbikeDriving] = useState(false);
useFrame(() => {
const driving =
movementMode === "ebike" && window.ebikeDriveInputActive === true;
if (driving !== isEbikeDriving) setIsEbikeDriving(driving);
});
const showInteractPrompt = !isEbikeDriving;
const handleInteract = useCallback((): void => {
if (window.ebikeBreakdownActive === true) return;
@@ -382,9 +390,15 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
EBIKE_CAMERA_TRANSFORM.rotation[2],
];
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
useGameStore.getState().setPlayerMovementMode("ebike");
});
animateCameraTransformTransition(
targetCamPos,
targetRotation,
1,
() => {
useGameStore.getState().setPlayerMovementMode("ebike");
},
{ lockInput: false },
);
} else {
const currentPos = new THREE.Vector3();
if (groupRef.current) {
@@ -410,9 +424,15 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
THREE.MathUtils.radToDeg(currentEuler.z),
];
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
useGameStore.getState().setPlayerMovementMode("walk");
});
animateCameraTransformTransition(
targetCamPos,
targetRotation,
1,
() => {
useGameStore.getState().setPlayerMovementMode("walk");
},
{ lockInput: false },
);
}
}, [movementMode, mainState, ebikeStep, setMissionStep, camera, position]);
@@ -451,25 +471,36 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
{/* 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}
position={parkedPosition}
radius={5}
onPress={handleInteract}
>
<mesh>
<sphereGeometry args={[8, 15, 12]} />
<meshBasicMaterial colorWrite={false} color={"red"} depthWrite={false} />
</mesh>
</InteractableObject>
{showInteractPrompt ? (
<InteractableObject
kind="trigger"
label={interactionLabel}
position={parkedPosition}
radius={5}
onPress={handleInteract}
>
<mesh>
<sphereGeometry args={[8, 15, 12]} />
<meshBasicMaterial
colorWrite={false}
color={"red"}
depthWrite={false}
/>
</mesh>
</InteractableObject>
) : null}
{/* 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}
<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}
@@ -499,8 +530,8 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
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)
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}
+4
View File
@@ -181,6 +181,8 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Sync texture into uniform when it changes (canvas resize)
useEffect(() => {
// External Three.js material uniform sync — intentional side effect.
// eslint-disable-next-line react-hooks/immutability
shaderMat.uniforms.map.value = texture;
}, [shaderMat, texture]);
@@ -196,6 +198,8 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Resize the canvas whenever canvasSize changes (texture declared above)
useEffect(() => {
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
// External Three.js texture invalidation — intentional side effect.
// eslint-disable-next-line react-hooks/immutability
texture.needsUpdate = true;
}, [canvasSize, offscreenCanvas, texture]);
+11 -7
View File
@@ -123,6 +123,8 @@ export function EbikeSpeedmeter({
);
// ── Frame loop ──────────────────────────────────────────────────────────────
/* External Three.js canvas+texture sync — intentional side effects in useFrame. */
/* eslint-disable react-hooks/immutability */
useFrame((_, delta) => {
// 1. Smooth speed factor
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
@@ -151,7 +153,7 @@ export function EbikeSpeedmeter({
// 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 cy = size * (1 - NEEDLE_PIVOT_UV_Y + gaugeOffsetY); // default ≈ 0.88 × size
const outerR = size * gaugeOuterR;
const innerR = size * gaugeInnerR;
@@ -164,7 +166,7 @@ export function EbikeSpeedmeter({
// 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, "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)
@@ -181,6 +183,7 @@ export function EbikeSpeedmeter({
}
fillTexture.needsUpdate = true;
/* eslint-enable react-hooks/immutability */
});
return (
@@ -212,11 +215,12 @@ export function EbikeSpeedmeter({
</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}
>
<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}
@@ -49,6 +49,9 @@ export function PylonDownedPylon(): React.JSX.Element | null {
useEffect(() => {
if (step === "arrived") {
hasPlayedFirstAudioRef.current = false;
// Reset the "raised" latch when a new run begins. This is derived
// resync from the step prop and runs once per step transition.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsRaised(false);
}
}, [step]);
@@ -133,7 +136,10 @@ export function PylonDownedPylon(): React.JSX.Element | null {
void (async () => {
const m = await loadDialogueManifest();
if (!m) return;
await playDialogueById(m, PYLON_NARRATIVE_DIALOGUES.demandeAide);
await playDialogueById(
m,
PYLON_NARRATIVE_DIALOGUES.demandeAide,
);
})();
},
{ once: true },
@@ -143,7 +149,10 @@ export function PylonDownedPylon(): React.JSX.Element | null {
void (async () => {
const manifest = await loadDialogueManifest();
if (!manifest) return;
await playDialogueById(manifest, PYLON_NARRATIVE_DIALOGUES.demandeAide);
await playDialogueById(
manifest,
PYLON_NARRATIVE_DIALOGUES.demandeAide,
);
})();
}
} else if (step === "npc-return" && !isStraightening) {
@@ -34,7 +34,10 @@ const _target = new THREE.Vector3();
* Compute the Y rotation (radians) for a model whose default forward
* direction is +Z, so that it faces from `from` toward `to`.
*/
function faceToward(from: THREE.Vector3, to: readonly [number, number, number]): number {
function faceToward(
from: THREE.Vector3,
to: readonly [number, number, number],
): number {
const dx = to[0] - from.x;
const dz = to[2] - from.z;
return Math.atan2(dx, dz);
@@ -92,6 +95,12 @@ function PylonFarmerNPCContent(): React.JSX.Element {
const { actions } = useAnimations(animations, model);
// ─── playAnim ─────────────────────────────────────────────────────────────
// NOTE: actions is intentionally in the dep array so this callback is
// recreated when drei's internal state populates the actions map.
// External THREE.AnimationAction lifecycle methods (fadeOut/fadeIn/play +
// setLoop/clampWhenFinished mutations) are intentional side effects on
// drei-managed objects.
/* eslint-disable react-hooks/immutability */
const playAnim = useCallback(
(name: NPCAnimation, fade = ANIM_FADE): void => {
if (currentAnimRef.current === name) return;
@@ -110,6 +119,7 @@ function PylonFarmerNPCContent(): React.JSX.Element {
},
[actions],
);
/* eslint-enable react-hooks/immutability */
// ─── Async audio after pylon is raised ────────────────────────────────────
const playPostRaiseAudioAndAdvance = useCallback(async () => {
@@ -131,6 +141,9 @@ function PylonFarmerNPCContent(): React.JSX.Element {
}, [setMissionStep]);
// ─── Step-driven animation ────────────────────────────────────────────────
// Fires when step changes OR when playAnim changes (i.e. when actions load).
// playAnim mutates drei-managed AnimationAction internals (intentional).
/* eslint-disable react-hooks/immutability */
useEffect(() => {
currentAnimRef.current = null;
if (step === "arrived") {
@@ -196,7 +209,10 @@ function PylonFarmerNPCContent(): React.JSX.Element {
currentPosRef.current.lerp(_target, t);
} else if (!isStraightening && currentAnimRef.current === "walk") {
playAnim("idle");
savedRotationYRef.current = faceToward(currentPosRef.current, PYLON_WORLD_POSITION);
savedRotationYRef.current = faceToward(
currentPosRef.current,
PYLON_WORLD_POSITION,
);
}
group.position.copy(currentPosRef.current);
} else if (step === "inspected" || step === "done") {
@@ -208,8 +224,15 @@ function PylonFarmerNPCContent(): React.JSX.Element {
}
// ── Rotation ──────────────────────────────────────────────────────────
if (step === "npc-return" && !isCompleted && currentAnimRef.current === "walk") {
const walkRotY = faceToward(currentPosRef.current, PYLON_FARMER_NPC_WALK_LOOK_AT);
if (
step === "npc-return" &&
!isCompleted &&
currentAnimRef.current === "walk"
) {
const walkRotY = faceToward(
currentPosRef.current,
PYLON_FARMER_NPC_WALK_LOOK_AT,
);
group.rotation.set(0, walkRotY, 0);
} else {
group.rotation.set(0, savedRotationYRef.current, 0);
@@ -217,6 +240,7 @@ function PylonFarmerNPCContent(): React.JSX.Element {
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
});
/* eslint-enable react-hooks/immutability */
return (
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
@@ -32,7 +32,9 @@ export function PylonLightingEffect(): null {
const sunRef = useRef(new THREE.Color(LIGHTING_STATE.sunColor));
// Target colours — updated reactively when isActive changes
const targetAmbientRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.ambientColor));
const targetAmbientRef = useRef(
new THREE.Color(LIGHTING_DEFAULTS.ambientColor),
);
const targetSunRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.sunColor));
useEffect(() => {
@@ -0,0 +1,133 @@
import { useEffect, useMemo, useRef } from "react";
import gsap from "gsap";
import * as THREE from "three";
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
const BUBBLE_RADIUS_METERS = 10;
const BUBBLE_GROW_DURATION_SECONDS = 2.5;
const BUBBLE_SHRINK_DURATION_SECONDS = 1.2;
const BUBBLE_COLOR = "#060814";
const BUBBLE_OPACITY = 0.92;
const BUBBLE_SHELL_RADIUS = 1; // sphere geometry baked at radius=1, scale = radius
/**
* Dark sphere shroud rendered around the active repair model when the
* focus state is active. Grows from 0 -> BUBBLE_RADIUS_METERS using a
* GSAP `expo.out` ease so the player visually transitions from the open
* map to an isolated repair "cocoon". Reverses on focus end.
*
* The sphere uses BackSide rendering so the player remains inside the
* shroud when they stand near the repair model. A subtle decor pass
* (grid floor + soft directional light + light fog) is rendered as a
* sibling group so it appears once the bubble has expanded.
*/
export function RepairFocusBubble(): React.JSX.Element | null {
const active = useRepairFocusStore((state) => state.active);
const center = useRepairFocusStore((state) => state.center);
const groupRef = useRef<THREE.Group>(null);
const meshRef = useRef<THREE.Mesh>(null);
const decorRef = useRef<THREE.Group>(null);
const scaleRef = useRef({ value: 0.0001 });
const decorOpacityRef = useRef({ value: 0 });
const sphereGeometry = useMemo(
() => new THREE.SphereGeometry(BUBBLE_SHELL_RADIUS, 48, 32),
[],
);
const sphereMaterial = useMemo(
() =>
new THREE.MeshBasicMaterial({
color: BUBBLE_COLOR,
side: THREE.BackSide,
transparent: true,
opacity: BUBBLE_OPACITY,
depthWrite: false,
fog: false,
}),
[],
);
useEffect(() => {
return () => {
sphereGeometry.dispose();
sphereMaterial.dispose();
};
}, [sphereGeometry, sphereMaterial]);
useEffect(() => {
const targetScale = active ? BUBBLE_RADIUS_METERS : 0.0001;
const targetDecor = active ? 1 : 0;
const duration = active
? BUBBLE_GROW_DURATION_SECONDS
: BUBBLE_SHRINK_DURATION_SECONDS;
const scaleTween = gsap.to(scaleRef.current, {
value: targetScale,
duration,
ease: active ? "expo.out" : "expo.in",
onUpdate: () => {
const mesh = meshRef.current;
if (mesh) mesh.scale.setScalar(scaleRef.current.value);
},
});
const decorTween = gsap.to(decorOpacityRef.current, {
value: targetDecor,
duration: duration * 0.8,
delay: active ? duration * 0.4 : 0,
ease: "power2.inOut",
onUpdate: () => {
const decor = decorRef.current;
if (!decor) return;
decor.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material instanceof THREE.Material
) {
const material = child.material as THREE.Material & {
opacity?: number;
transparent?: boolean;
};
if (typeof material.opacity === "number") {
material.opacity = decorOpacityRef.current.value;
material.transparent = true;
}
}
});
},
});
return () => {
scaleTween.kill();
decorTween.kill();
};
}, [active]);
// Render even when inactive so the shrink tween can play out; visibility
// is implicit via near-zero scale.
return (
<group ref={groupRef} position={center}>
<mesh
ref={meshRef}
geometry={sphereGeometry}
material={sphereMaterial}
renderOrder={-1}
frustumCulled={false}
/>
<group ref={decorRef}>
{/* Subtle grid floor visible only inside the bubble */}
<gridHelper
args={[BUBBLE_RADIUS_METERS * 1.6, 24, "#1f2937", "#111827"]}
position={[0, -0.5, 0]}
/>
{/* Soft directional light for the repair model */}
<directionalLight
position={[2, 4, 3]}
intensity={0.6}
color="#cbd5f5"
/>
<ambientLight intensity={0.25} color="#1e293b" />
</group>
</group>
);
}
+44 -1
View File
@@ -25,6 +25,7 @@ import type {
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
@@ -72,8 +73,20 @@ export function RepairGame({
const [scannedBrokenParts, setScannedBrokenParts] = useState<
readonly RepairScannedBrokenPart[]
>([]);
// For the ebike mission, use the bike's live parked world position once
// the repair flow leaves the waiting/locked phase so the repair happens
// wherever the player parked the bike, not at the static zone anchor.
// window.ebikeParkedPosition is set by Ebike when the player drops the
// bike and stays stable through the rest of the repair flow.
const livePosition = useMemo<Vector3Tuple>(() => {
if (mission !== "ebike" || mainState !== mission) return position;
if (step === "locked" || step === "waiting") return position;
const parked = window.ebikeParkedPosition;
if (!parked) return position;
return [parked[0], parked[1], parked[2]];
}, [mainState, mission, position, step]);
const parsedScale = toVector3Scale(scale);
const snappedPosition = useTerrainSnappedPosition(position);
const snappedPosition = useTerrainSnappedPosition(livePosition);
const readyForFragmentation = step === "inspected";
const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]);
@@ -98,6 +111,25 @@ export function RepairGame({
};
}, [mainState, mission, step]);
// Drive the global focus bubble: active during the immersive repair
// phases so the world dims/hides outside the dark sphere shroud.
const focusCenterX = snappedPosition[0];
const focusCenterY = snappedPosition[1];
const focusCenterZ = snappedPosition[2];
useEffect(() => {
const inFocusPhase =
mainState === mission && shouldFocusBubbleBeActive(step);
if (inFocusPhase) {
useRepairFocusStore
.getState()
.setFocus(true, [focusCenterX, focusCenterY, focusCenterZ]);
return () => {
useRepairFocusStore.getState().setFocus(false);
};
}
return undefined;
}, [mainState, mission, step, focusCenterX, focusCenterY, focusCenterZ]);
useEffect(() => {
if (mainState !== mission) return undefined;
@@ -131,6 +163,7 @@ export function RepairGame({
{step === "fragmented" ? (
<ExplodableModel
modelPath={config.modelPath}
rotation={config.modelRotation ?? [0, 0, 0]}
scale={config.modelScale ?? 1}
split
/>
@@ -148,6 +181,7 @@ export function RepairGame({
<>
<ExplodableModel
modelPath={config.modelPath}
rotation={config.modelRotation ?? [0, 0, 0]}
scale={config.modelScale ?? 1}
split
hideNodeNames={brokenNodeNames}
@@ -200,6 +234,15 @@ function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
return step === "repairing" || step === "reassembling" || step === "done";
}
function shouldFocusBubbleBeActive(step: MissionStep): boolean {
return (
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling"
);
}
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
return [
...new Set([
-2
View File
@@ -5,7 +5,6 @@ import { HandTrackingFallback } from "@/components/ui/HandTrackingFallback";
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { OutroVideoOverlay } from "@/components/ui/OutroVideoOverlay";
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
import { Subtitles } from "@/components/ui/Subtitles";
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
@@ -14,7 +13,6 @@ export function GameUI(): React.JSX.Element {
<>
<DebugOverlayLayout />
<Crosshair />
<RepairMovementLockIndicator />
<InteractPrompt />
<HandTrackingVisualizer />
<HandTrackingFallback />
+5 -1
View File
@@ -9,10 +9,14 @@ export function InteractPrompt(): React.JSX.Element | null {
if (cameraMode !== "player") return null;
if (!focused || holding || focused.kind !== "trigger") return null;
const label = focused.label?.trim() ?? "";
return (
<div className="interact-prompt" aria-live="polite">
<kbd className="interact-prompt__key">{INTERACT_KEY.toUpperCase()}</kbd>
<span className="interact-prompt__label">{focused.label}</span>
{label.length > 0 ? (
<span className="interact-prompt__label">{label}</span>
) : null}
</div>
);
}
@@ -1,20 +0,0 @@
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
export function RepairMovementLockIndicator(): React.JSX.Element | null {
const cameraMode = useCameraMode();
const movementLocked = useRepairMovementLocked();
if (cameraMode !== "player") return null;
if (!movementLocked) return null;
return (
<div className="repair-movement-lock-indicator" aria-live="polite">
<span
className="repair-movement-lock-indicator__dot"
aria-hidden="true"
/>
<span>Déplacement verrouillé pendant la réparation</span>
</div>
);
}
@@ -5,8 +5,8 @@ import {
MAIN_GAME_STATES,
} from "@/data/game/gameStateConfig";
import {
getMissionStepsFor,
isMissionStep,
MISSION_STEPS,
} from "@/data/gameplay/repairMissionState";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { MainGameState } from "@/types/game";
@@ -53,7 +53,9 @@ export function GameStateDebugPanel(): React.JSX.Element {
? GAME_STEPS
: mainState === "outro"
? ["waiting", "started"]
: MISSION_STEPS;
: mainState === "ebike" || mainState === "pylon" || mainState === "farm"
? getMissionStepsFor(mainState)
: [];
function setSubState(nextSubState: string): void {
if (mainState === "intro") {
+1 -5
View File
@@ -6,11 +6,7 @@ export const PYLON_DOWNED_ROTATION: Vector3Tuple = [0, 0, -0.9];
export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0];
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [
-16.13,
3.2,
52.46
];
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [-16.13, 3.2, 52.46];
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
PYLON_WORLD_POSITION[0] + 3,
+14
View File
@@ -24,6 +24,20 @@ export const MISSION_STEPS = [
] as const satisfies readonly MissionStep[];
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
const PYLON_ONLY_MISSION_STEPS = new Set<MissionStep>([
"approaching",
"arrived",
"npc-return",
"narrator-outro",
]);
export function getMissionStepsFor(
mission: RepairMissionId,
): readonly MissionStep[] {
if (mission === "pylon") return MISSION_STEPS;
return MISSION_STEPS.filter((step) => !PYLON_ONLY_MISSION_STEPS.has(step));
}
export function isRepairMissionId(value: string): value is RepairMissionId {
return REPAIR_MISSION_ID_VALUES.has(value);
}
+6 -1
View File
@@ -3,6 +3,10 @@ import type {
RepairMissionConfig,
RepairMissionId,
} from "@/types/gameplay/repairMission";
import {
EBIKE_WORLD_ROTATION_Y,
EBIKE_WORLD_SCALE,
} from "@/data/ebike/ebikeConfig";
const REPAIR_INTERACT_UI_PATH = "/assets/world/UI/interagir.webm";
const REPAIR_BROKEN_UI_PATH = "/assets/world/UI/cassé.webm";
@@ -20,7 +24,8 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description:
"Repair the damaged cooling module before relaunching the bike",
modelPath: "/models/ebike/model.gltf",
modelScale: 0.3,
modelScale: EBIKE_WORLD_SCALE,
modelRotation: [0, EBIKE_WORLD_ROTATION_Y, 0],
stageUiPath: "/assets/world/UI/ebike-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
+1 -5
View File
@@ -4,11 +4,7 @@ import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
// Zones qui active la coupure de courant
export const PYLON_APPROACH_ZONE: ZoneConfig = {
id: "pylon-approach",
position: [
5,
4,
-21.5
],
position: [5, 4, -21.5],
radius: 10,
height: 18,
oneShot: true,
+2 -2
View File
@@ -30,8 +30,8 @@ export const CHARACTER_CONFIGS = {
position: [-40.5, 0, 45.5],
rotation: [0, -0.35, 0],
scale: [1.55, 1.55, 1.55],
animations: ["Dance"],
defaultAnimation: "Dance",
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
gerant: {
id: "gerant",
@@ -1,29 +0,0 @@
import { useGameStore } from "@/managers/stores/useGameStore";
import type { MissionStep } from "@/types/gameplay/repairMission";
export function useRepairMovementLocked(): boolean {
return useGameStore((state) => {
switch (state.mainState) {
case "ebike":
return isRepairMovementLocked(state.ebike.currentStep);
case "pylon":
return isRepairMovementLocked(state.pylon.currentStep);
case "farm":
return isRepairMovementLocked(state.farm.currentStep);
case "intro":
case "outro":
return false;
}
});
}
function isRepairMovementLocked(step: MissionStep): boolean {
return (
step === "inspected" ||
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling" ||
step === "done"
);
}
+26 -13
View File
@@ -809,35 +809,48 @@ canvas {
.interact-prompt {
position: fixed;
bottom: 30%;
bottom: 12%;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
align-items: stretch;
gap: 8px;
pointer-events: none;
z-index: 10;
}
.interact-prompt__key {
.interact-prompt__key,
.interact-prompt__label {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 4px;
font-size: 13px;
font-weight: 600;
color: white;
height: 36px;
background: rgba(10, 12, 20, 0.55);
border: 1px solid rgba(255, 255, 255, 0.7);
font-family: "Inter", sans-serif;
color: #ffffff;
}
.interact-prompt__key {
width: 36px;
font-size: 15px;
font-weight: 900;
font-style: normal;
letter-spacing: 0;
/* 3D keyboard key effect: top highlight, bottom inner darkening,
and a thin bottom drop so the key reads as physically pressed-up. */
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.25),
inset 0 -3px 0 rgba(0, 0, 0, 0.45),
0 2px 0 rgba(0, 0, 0, 0.55);
}
.interact-prompt__label {
padding: 0 12px;
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.03em;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
}
.repair-movement-lock-indicator {
@@ -0,0 +1,25 @@
import { create } from "zustand";
import type { Vector3Tuple } from "@/types/three/three";
/**
* Tracks whether a repair mini-game is currently in its "focused" phase
* (fragmented / scanning / repairing / reassembling). When active, a dark
* sphere expands around the repair model to visually isolate the player
* from the rest of the map. The store also exposes the world-space center
* of the bubble so map content can dim/hide content outside it if needed.
*/
interface RepairFocusStore {
active: boolean;
center: Vector3Tuple;
setFocus: (active: boolean, center?: Vector3Tuple) => void;
}
export const useRepairFocusStore = create<RepairFocusStore>((set) => ({
active: false,
center: [0, 0, 0],
setFocus: (active, center) =>
set((state) => ({
active,
center: center ?? state.center,
})),
}));
+7
View File
@@ -64,6 +64,13 @@ export interface RepairMissionConfig {
description: string;
modelPath: string;
modelScale?: ModelTransformProps["scale"];
/**
* World-space rotation applied to the model when mounted by RepairGame
* (fragmented + repairing steps). Should match the rotation used by the
* source object in the world (e.g. parked Ebike) so the fragmented model
* lines up visually with the inspection model.
*/
modelRotation?: Vector3Tuple;
stageUiPath: string;
interactUiPath: string;
brokenUiPath: string;
+9 -9
View File
@@ -85,7 +85,7 @@ export class Debug {
fogEnabled: boolean;
handTrackingSource: HandTrackingSource;
showDebugOverlay: boolean;
showHandTrackingSvg: boolean;
showHandTrackingModel: boolean;
showInteractionSpheres: boolean;
showPerf: boolean;
sceneMode: SceneMode;
@@ -108,7 +108,7 @@ export class Debug {
fogEnabled: FOG_CONFIG.enabled,
handTrackingSource: storedControls.handTrackingSource ?? "browser",
showDebugOverlay: true,
showHandTrackingSvg: false,
showHandTrackingModel: false,
showInteractionSpheres: false,
showPerf: true,
sceneMode: storedControls.sceneMode ?? "game",
@@ -156,10 +156,10 @@ export class Debug {
const handTrackingFolder = this.createFolder("Hand Tracking");
handTrackingFolder
?.add(this.controls, "showHandTrackingSvg")
.name("Show SVG")
?.add(this.controls, "showHandTrackingModel")
.name("Show Model")
.onChange((value: boolean) => {
this.controls.showHandTrackingSvg = value;
this.controls.showHandTrackingModel = value;
this.emit();
});
@@ -281,12 +281,12 @@ export class Debug {
return this.controls.showInteractionSpheres;
}
getShowHandTrackingSvg(): boolean {
return this.controls.showHandTrackingSvg;
getShowHandTrackingModel(): boolean {
return this.controls.showHandTrackingModel;
}
setShowHandTrackingSvg(value: boolean): void {
this.controls.showHandTrackingSvg = value;
setShowHandTrackingModel(value: boolean): void {
this.controls.showHandTrackingModel = value;
this.emit();
}
+16 -6
View File
@@ -53,13 +53,23 @@ export class ExplodedModel {
}
private createParts(model: THREE.Object3D): ExplodedPart[] {
const root =
model.children.length === 1 && model.children[0]
? model.children[0]
: model;
const directChildren = root.children.filter((child) => hasMesh(child));
// Drill down through single-mesh-bearing branches until we find a node
// with multiple mesh-bearing children (the natural "explosion group" the
// modeler authored). Falls back to flat mesh list only if no such group
// exists. This avoids exploding leaves in local space when wrapper nodes
// (e.g. "Empty" + "Moto" > "Eclatement") sit above the actual group.
let current = model;
while (true) {
const meshChildren = current.children.filter((child) => hasMesh(child));
if (meshChildren.length === 1 && meshChildren[0]) {
current = meshChildren[0];
continue;
}
break;
}
const directChildren = current.children.filter((child) => hasMesh(child));
const sourceObjects =
directChildren.length > 1 ? directChildren : getMeshes(root);
directChildren.length > 1 ? directChildren : getMeshes(current);
if (sourceObjects.length === 0) return [];
+5 -1
View File
@@ -11,6 +11,7 @@ import {
isMapModelVisible,
useMapPerformanceStore,
} from "@/managers/stores/useMapPerformanceStore";
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
import { SkyModel } from "@/components/three/world/SkyModel";
import { CloudSystem } from "@/world/clouds/CloudSystem";
import { FogSystem } from "@/world/fog/FogSystem";
@@ -24,6 +25,9 @@ export function Environment(): React.JSX.Element {
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const showSky = isMapModelVisible("sky", { groups, models });
// Hide vegetation while the repair focus bubble is active so the cocoon
// shroud is not pierced by tall trees / bushes around the repair model.
const repairFocusActive = useRepairFocusStore((state) => state.active);
if (sceneMode === "physics") {
return (
@@ -52,7 +56,7 @@ export function Environment(): React.JSX.Element {
<WaterSystem />
<CloudSystem />
<GrassSystem />
<VegetationSystem />
{repairFocusActive ? null : <VegetationSystem />}
</>
);
}
+9 -2
View File
@@ -250,7 +250,10 @@ export function animateCameraTransformTransition(
targetRotation: Vector3Tuple,
duration: number = 1,
onComplete?: () => void,
options: { lockInput?: boolean } = {},
): void {
const { lockInput = true } = options;
if (!globalCamera) {
logger.warn("GameCinematics", "Camera not found for transition");
onComplete?.();
@@ -260,7 +263,9 @@ export function animateCameraTransformTransition(
const camera = globalCamera;
cameraTransitionTimeline?.kill();
useGameStore.getState().setCinematicPlaying(true);
if (lockInput) {
useGameStore.getState().setCinematicPlaying(true);
}
// Convert target rotation in degrees to quaternion
const targetEuler = new THREE.Euler(
@@ -282,7 +287,9 @@ export function animateCameraTransformTransition(
},
onComplete: () => {
cameraTransitionTimeline = null;
useGameStore.getState().setCinematicPlaying(false);
if (lockInput) {
useGameStore.getState().setCinematicPlaying(false);
}
onComplete?.();
},
});
+6 -8
View File
@@ -1,5 +1,6 @@
import { Ebike } from "@/components/ebike/Ebike";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { RepairFocusBubble } from "@/components/three/gameplay/RepairFocusBubble";
import { RepairGame } from "@/components/three/gameplay/RepairGame";
import { FarmNarrativeFlow } from "@/components/gameplay/farm/FarmNarrativeFlow";
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
@@ -17,6 +18,7 @@ import {
OUTRO_STAGE_ANCHOR,
} from "@/data/gameplay/gameStageAnchors";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
import {
isFarmNarrativeStep,
@@ -25,13 +27,7 @@ import {
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
import {
EBIKE_WORLD_POSITION,
EBIKE_WORLD_ROTATION_Y,
EBIKE_WORLD_SCALE,
} from "@/data/ebike/ebikeConfig";
const EBIKE_CONFIG_KEY = `${EBIKE_WORLD_POSITION.join(",")}:${EBIKE_WORLD_ROTATION_Y}:${EBIKE_WORLD_SCALE}`;
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
interface StageAnchorProps {
color: string;
@@ -96,6 +92,7 @@ export function GameStageContent(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
const pylonStep = useGameStore((state) => state.pylon.currentStep);
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
const repairFocusActive = useRepairFocusStore((state) => state.active);
const farmStep = useGameStore((state) => state.farm.currentStep);
@@ -110,7 +107,7 @@ export function GameStageContent(): React.JSX.Element {
<Ebike position={EBIKE_WORLD_POSITION} />
<PylonLightingEffect />
<PylonDownedPylon />
{isDebugEnabled() ? (
{isDebugEnabled() && !repairFocusActive ? (
<>
<ZoneDebugVisual zone={PYLON_APPROACH_ZONE} active={false} />
<ZoneDebugVisual zone={PYLON_ARRIVED_ZONE} active={false} />
@@ -131,6 +128,7 @@ export function GameStageContent(): React.JSX.Element {
<RepairMissionTrigger key={config.mission} config={config} />
))}
{mainState === "outro" ? <StageAnchor {...OUTRO_STAGE_ANCHOR} /> : null}
<RepairFocusBubble />
</>
);
}
+9 -3
View File
@@ -6,6 +6,7 @@ import {
} from "@/data/player/playerConfig";
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
@@ -32,7 +33,6 @@ import { CharacterSystem } from "@/world/characters/CharacterSystem";
import { Player } from "@/world/player/Player";
import { TestMap } from "@/world/debug/TestMap";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import type { HandTrackingGloveHandedness } from "@/hooks/handTracking/useHandTrackingGloveStatus";
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
interface WorldProps {
@@ -41,7 +41,7 @@ interface WorldProps {
function hasTrackedHand(
hands: HandTrackingHand[],
handedness: HandTrackingGloveHandedness,
handedness: "left" | "right",
): boolean {
return hands.some((hand) => hand.handedness.toLowerCase() === handedness);
}
@@ -60,6 +60,9 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
(state) => state.showPlayerModel,
);
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
const showHandTrackingModel = useDebugStore((debug) =>
debug.getShowHandTrackingModel(),
);
const { hands, status, usageStatus } = useHandTrackingSnapshot();
const {
octree,
@@ -74,7 +77,10 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
? PLAYER_SPAWN_POSITION_GAME
: PLAYER_SPAWN_POSITION_PHYSICS;
const showHandTrackingGloves =
status === "connected" && usageStatus !== "inactive" && hands.length > 0;
showHandTrackingModel &&
status === "connected" &&
usageStatus !== "inactive" &&
hands.length > 0;
const showLeftHandTrackingGlove =
showHandTrackingGloves && hasTrackedHand(hands, "left");
const showRightHandTrackingGlove =
+7
View File
@@ -3,7 +3,9 @@ import { Component, useRef, useState, useEffect } from "react";
import * as THREE from "three";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { Line } from "@react-three/drei";
import { Ebike } from "@/components/ebike/Ebike";
import { RepairGame } from "@/components/three/gameplay/RepairGame";
import { RepairFocusBubble } from "@/components/three/gameplay/RepairFocusBubble";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
@@ -239,11 +241,16 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
<group position={zone.position}>
<RepairPlaygroundZoneMarker color={zone.color} />
</group>
{zone.mission === "ebike" ? (
<Ebike position={zone.position} snapToTerrain={false} />
) : null}
<RepairGame mission={zone.mission} position={zone.position} />
</group>
))}
</Physics>
<RepairFocusBubble />
{/* Dynamic Futuristic 3D GPS Dashboard Preview */}
<group
position={TEST_SCENE_GPS_PREVIEW_POSITION}
+1 -24
View File
@@ -23,7 +23,6 @@ import {
PLAYER_MAX_DELTA,
PLAYER_XZ_DAMPING_FACTOR,
} from "@/data/player/playerConfig";
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import { InteractionManager } from "@/managers/InteractionManager";
import { useGameStore } from "@/managers/stores/useGameStore";
@@ -154,9 +153,7 @@ export function PlayerController({
}: PlayerControllerProps): null {
const camera = useThree((state) => state.camera);
const sceneMode = useSceneMode();
const movementLocked = useRepairMovementLocked();
const terrainHeight = useTerrainHeightSampler();
const movementLockedRef = useRef(movementLocked);
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
const velocity = useRef(new THREE.Vector3());
const fallDuration = useRef(0);
@@ -249,17 +246,6 @@ export function PlayerController({
initializedRef.current = true;
}, [camera, initialLookAt, spawnPosition]);
useEffect(() => {
movementLockedRef.current = movementLocked;
if (!movementLocked) return;
keys.current = { ...DEFAULT_KEYS };
wantsJump.current = false;
velocity.current.setX(0);
velocity.current.setZ(0);
}, [movementLocked]);
useEffect(() => {
const interaction = InteractionManager.getInstance();
@@ -267,20 +253,11 @@ export function PlayerController({
if (isPlayerInputLocked()) return;
if (setMovementKey(keys.current, event.key, true)) {
if (movementLockedRef.current) {
keys.current = { ...DEFAULT_KEYS };
}
event.preventDefault();
return;
}
if (event.key === JUMP_KEY) {
if (movementLockedRef.current) {
wantsJump.current = false;
event.preventDefault();
return;
}
wantsJump.current = true;
event.preventDefault();
return;
@@ -386,7 +363,7 @@ export function PlayerController({
}
_wishDir.set(0, 0, 0);
if (!movementLocked && !isEbikeBreakdown) {
if (!isEbikeBreakdown) {
if (keys.current.forward) _wishDir.add(_forward);
if (keys.current.backward) _wishDir.sub(_forward);
if (!isEbikeMounted) {