feat(intro): add ebike onboarding sequence
🔍 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:
Tom Boullay
2026-05-31 10:42:46 +02:00
parent a3f611e227
commit bff8a16290
25 changed files with 620 additions and 256 deletions
+31 -4
View File
@@ -6,12 +6,14 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
import { animateCameraTransformTransition } from "@/world/GameCinematics";
import { useGameStore } from "@/managers/stores/useGameStore";
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
import {
EBIKE_CAMERA_TRANSFORM,
EBIKE_DROP_PLAYER_TRANSFORM,
EBIKE_WORLD_ROTATION_Y,
} from "@/data/ebike/ebikeConfig";
import type { Vector3Tuple } from "@/types/three/three";
import "@/types/ebike/ebikeWindow";
@@ -31,7 +33,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
const model = useClonedObject(scene);
const movementMode = useGameStore((state) => state.player.movementMode);
const mainState = useGameStore((state) => state.mainState);
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const camera = useThree((state) => state.camera);
const updateEbikeSounds = useEbikeSounds();
// Map active mainState to target repair zone coordinate
const destPos = useMemo(() => {
@@ -67,7 +72,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
position[1] - PLAYER_EYE_HEIGHT,
position[2],
]);
const restingRotationRef = useRef<number>(0);
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
const forkRef = useRef<THREE.Object3D | null>(null);
// State for debug visualization (synced from refs during useFrame)
@@ -102,6 +107,12 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
useFrame((_, delta) => {
if (groupRef.current) {
if (movementMode === "ebike") {
updateEbikeSounds({
mounted: true,
driving: window.ebikeDriveInputActive === true,
breakdown: window.ebikeBreakdownActive === true,
});
restingPositionRef.current = [
groupRef.current.position.x,
groupRef.current.position.y,
@@ -133,6 +144,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
setDebugRestingPosition([...restingPositionRef.current]);
}
} else {
updateEbikeSounds({ mounted: false, driving: false, breakdown: false });
groupRef.current.position.set(...restingPositionRef.current);
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
@@ -159,7 +171,14 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
];
const handleInteract = useCallback((): void => {
if (window.ebikeBreakdownActive === true) return;
if (movementMode === "walk") {
if (mainState === "ebike" && ebikeStep === "waiting") {
setMissionStep("ebike", "inspected");
return;
}
const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position,
);
@@ -213,7 +232,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
useGameStore.getState().setPlayerMovementMode("walk");
});
}
}, [movementMode, camera, position]);
}, [movementMode, mainState, ebikeStep, setMissionStep, camera, position]);
// Store handleInteract in a ref for use in debug folder callback
const handleInteractRef = useRef(handleInteract);
@@ -239,12 +258,20 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
return (
<>
<group ref={groupRef} position={position}>
<group
ref={groupRef}
position={position}
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
>
<primitive object={model} />
<InteractableObject
kind="trigger"
label={
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
mainState === "ebike" && ebikeStep === "waiting"
? "Inspecter l'e-bike"
: movementMode === "walk"
? "Monter sur le bike"
: "Descendre du bike"
}
position={position}
radius={15}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from "react";
import { MissionNotification } from "@/components/ui/MissionNotification";
import {
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
EBIKE_BREAKDOWN_DIALOGUE_ID,
EBIKE_INTRO_RIDE_DURATION_MS,
EBIKE_SOUNDS,
} from "@/data/ebike/ebikeConfig";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
export function EbikeIntroSequence(): React.JSX.Element | null {
const introStep = useGameStore((state) => state.intro.currentStep);
const movementMode = useGameStore((state) => state.player.movementMode);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const completeIntro = useGameStore((state) => state.completeIntro);
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
const hasStartedBreakdown = useRef(false);
useEffect(() => {
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
setIntroStep("ebike-intro-ride");
}, [introStep, movementMode, setIntroStep]);
useEffect(() => {
if (introStep !== "ebike-intro-ride") return undefined;
const timeoutId = window.setTimeout(() => {
setIntroStep("ebike-breakdown");
}, EBIKE_INTRO_RIDE_DURATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [introStep, setIntroStep]);
useEffect(() => {
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
return undefined;
}
hasStartedBreakdown.current = true;
setBreakdownDialogueDone(false);
window.ebikeBreakdownActive = true;
AudioManager.getInstance().playSound(EBIKE_SOUNDS.panne, 0.95, {
category: "sfx",
});
let isCancelled = false;
const dialogueTimeoutId = window.setTimeout(() => {
void (async () => {
const manifest = await loadDialogueManifest();
if (isCancelled || !manifest) {
setBreakdownDialogueDone(true);
return;
}
const audio = await playDialogueById(
manifest,
EBIKE_BREAKDOWN_DIALOGUE_ID,
);
if (isCancelled || !audio) {
setBreakdownDialogueDone(true);
return;
}
audio.addEventListener(
"ended",
() => {
setBreakdownDialogueDone(true);
},
{ once: true },
);
})();
}, EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS);
return () => {
isCancelled = true;
window.clearTimeout(dialogueTimeoutId);
};
}, [introStep]);
useEffect(() => {
if (introStep !== "ebike-breakdown") return;
if (!breakdownDialogueDone || movementMode !== "walk") return;
window.ebikeBreakdownActive = false;
completeIntro();
}, [breakdownDialogueDone, completeIntro, introStep, movementMode]);
useEffect(() => {
if (introStep === "ebike-breakdown") return;
window.ebikeBreakdownActive = false;
if (introStep !== "completed") {
hasStartedBreakdown.current = false;
}
}, [introStep]);
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
return null;
}
return (
<MissionNotification
mission="ebike"
visible={introStep === "await-ebike-mount"}
/>
);
}
+1 -1
View File
@@ -112,7 +112,7 @@ export function RepairGame({
<RepairMissionAssetPreloader config={config} />
</Suspense>
<Suspense fallback={null}>
{step === "waiting" ? (
{step === "waiting" && mission !== "ebike" ? (
<RepairInspectionObject
config={config}
worldPosition={snappedPosition}
+28
View File
@@ -0,0 +1,28 @@
import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotifications";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
interface MissionNotificationProps {
mission: RepairMissionId;
visible?: boolean;
}
export function MissionNotification({
mission,
visible = true,
}: MissionNotificationProps): React.JSX.Element {
return (
<div
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
aria-live="polite"
>
<div className="mission-notification__glow" />
<span className="mission-notification__image-wrap">
<img
className="mission-notification__image"
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
alt="Nouvel objectif de mission"
/>
</span>
</div>
);
}
@@ -6,11 +6,12 @@ const REVEAL_DURATION_MS = 2000;
/**
* Fade-out overlay revealing the game world.
* Calls completeIntro() when the fade is done — completeIntro also marks
* intro.currentStep as "completed" so no separate setIntroStep call is needed.
* Moves to the ebike onboarding step when the fade is done. The intro only
* completes after the player rides the ebike and triggers the breakdown.
*/
export function IntroRevealOverlay(): React.JSX.Element {
const completeIntro = useGameStore((state) => state.completeIntro);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const setCanMove = useGameStore((state) => state.setCanMove);
const prefersReducedMotion = usePrefersReducedMotion();
const [opacity, setOpacity] = useState(1);
@@ -20,14 +21,15 @@ export function IntroRevealOverlay(): React.JSX.Element {
}, 100);
const completeTimeout = window.setTimeout(() => {
completeIntro();
setCanMove(true);
setIntroStep("await-ebike-mount");
}, REVEAL_DURATION_MS);
return () => {
window.clearTimeout(fadeTimeout);
window.clearTimeout(completeTimeout);
};
}, [completeIntro]);
}, [setCanMove, setIntroStep]);
return (
<div
+28 -1
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useGameStore } from "@/managers/stores/useGameStore";
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
const SKIP_KEYS = new Set(["Enter", " "]);
const SKIP_HINT_HIDE_DELAY_MS = 1000;
/**
* Full-screen video player for the intro cinematic.
@@ -10,6 +11,8 @@ const SKIP_KEYS = new Set(["Enter", " "]);
*/
export function IntroVideoPlayer(): React.JSX.Element {
const videoRef = useRef<HTMLVideoElement>(null);
const hideHintTimeoutRef = useRef<number | null>(null);
const [showSkipHint, setShowSkipHint] = useState(false);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const handleVideoEnd = useCallback(() => {
@@ -33,11 +36,33 @@ export function IntroVideoPlayer(): React.JSX.Element {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleSkip]);
useEffect(() => {
return () => {
if (hideHintTimeoutRef.current !== null) {
window.clearTimeout(hideHintTimeoutRef.current);
}
};
}, []);
const handleMouseMove = useCallback(() => {
setShowSkipHint(true);
if (hideHintTimeoutRef.current !== null) {
window.clearTimeout(hideHintTimeoutRef.current);
}
hideHintTimeoutRef.current = window.setTimeout(() => {
setShowSkipHint(false);
hideHintTimeoutRef.current = null;
}, SKIP_HINT_HIDE_DELAY_MS);
}, []);
return (
<div
role="region"
aria-label="Vidéo d'introduction. Appuyez sur Entrée pour passer."
onClick={handleSkip}
onMouseMove={handleMouseMove}
style={{
position: "fixed",
inset: 0,
@@ -71,6 +96,8 @@ export function IntroVideoPlayer(): React.JSX.Element {
color: "rgba(255, 255, 255, 0.6)",
fontSize: 14,
fontFamily: "system-ui, sans-serif",
opacity: showSkipHint ? 1 : 0,
transition: "opacity 240ms ease",
}}
>
Appuyez pour passer