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
🔍 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:
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user