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
@@ -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