2 Commits

Author SHA1 Message Date
Tom Boullay 7bcbba4eb1 fix(ui): polish demo-flow overlays
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
- OutroVideoOverlay: stagger reveal so 'Next step :' appears immediately and
  'La ferme' fades in 500ms later, instead of both showing at once.
- MissionNotification: enforce 589/211 aspect-ratio + objectFit cover on the
  <video> branch so webm assets (square 2000x2000) render at the same place
  as the legacy PNG notifications instead of shifting the layout.
- HandTrackingTutorial: add a 5000ms fallback timeout that auto-dismisses
  the overlay if MediaPipe never reports a hand (camera blocked, mouse-only
  player), so the screen never stays stuck.
2026-06-03 03:27:18 +02:00
Tom Boullay 712fb851ad feat(outro): add fade-to-black transition screen with 'Next step: La ferme' before outro video, mute all game audio during playback 2026-06-03 03:08:12 +02:00
3 changed files with 162 additions and 15 deletions
@@ -1,6 +1,11 @@
import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotifications"; import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotifications";
import type { RepairMissionId } from "@/types/gameplay/repairMission"; import type { RepairMissionId } from "@/types/gameplay/repairMission";
// Reference aspect ratio of the original PNG mission notifications
// (589 × 211). Webm assets are square (2000 × 2000), so without this hint the
// <video> element renders at the wrong dimensions and shifts the layout.
const NOTIFICATION_ASPECT_RATIO = "589 / 211";
interface MissionNotificationProps { interface MissionNotificationProps {
mission?: RepairMissionId; mission?: RepairMissionId;
imagePath?: string; imagePath?: string;
@@ -26,6 +31,10 @@ export function MissionNotification({
{isVideo ? ( {isVideo ? (
<video <video
className="mission-notification__image" className="mission-notification__image"
style={{
aspectRatio: NOTIFICATION_ASPECT_RATIO,
objectFit: "cover",
}}
src={src} src={src}
aria-label="Nouvel objectif de mission" aria-label="Nouvel objectif de mission"
autoPlay autoPlay
+131 -9
View File
@@ -1,19 +1,42 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { AudioCategory } from "@/data/audioConfig";
import { AudioManager } from "@/managers/AudioManager";
const OUTRO_VIDEO_SRC = "/cinematics/outro.mp4"; const OUTRO_VIDEO_SRC = "/cinematics/outro.mp4";
const TRANSITION_FADE_MS = 600;
const TRANSITION_HOLD_MS = 2000;
const TRANSITION_TEXT_FADE_MS = 500;
// Delay between "Next step :" appearing and "La ferme" fading in.
const TRANSITION_LAFERME_DELAY_MS = 500;
const MUTED_CATEGORIES: readonly AudioCategory[] = ["music", "sfx", "dialogue"];
type Stage =
| "hidden"
| "fading-in"
| "showing-text"
| "fading-text-out"
| "video";
/** /**
* Full-screen video overlay that plays once after the outro drone-shot * End-of-demo overlay. Triggered by the "outro-cinematic-complete" window
* cinematic ends. Triggered by the "outro-cinematic-complete" window event * event dispatched from GameCinematics.tsx.
* dispatched from GameCinematics.tsx. *
* Sequence:
* 1. Fade to black (TRANSITION_FADE_MS)
* 2. Reveal "Next step: La ferme" text + hold (TRANSITION_HOLD_MS)
* 3. Fade text out (TRANSITION_TEXT_FADE_MS)
* 4. Play `outro.mp4` full-screen with all game audio muted
*/ */
export function OutroVideoOverlay(): React.JSX.Element | null { export function OutroVideoOverlay(): React.JSX.Element | null {
const [visible, setVisible] = useState(false); const [stage, setStage] = useState<Stage>("hidden");
const [lafermeVisible, setLafermeVisible] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const savedVolumesRef = useRef<Partial<Record<AudioCategory, number>>>({});
useEffect(() => { useEffect(() => {
function handleCinematicComplete(): void { function handleCinematicComplete(): void {
setVisible(true); setStage("fading-in");
} }
window.addEventListener( window.addEventListener(
@@ -28,12 +51,80 @@ export function OutroVideoOverlay(): React.JSX.Element | null {
}; };
}, []); }, []);
// Drive the transition timeline.
useEffect(() => { useEffect(() => {
if (!visible) return; if (stage === "fading-in") {
void videoRef.current?.play(); const timer = window.setTimeout(
}, [visible]); () => setStage("showing-text"),
TRANSITION_FADE_MS,
);
return () => window.clearTimeout(timer);
}
if (stage === "showing-text") {
const timer = window.setTimeout(
() => setStage("fading-text-out"),
TRANSITION_HOLD_MS,
);
return () => window.clearTimeout(timer);
}
if (stage === "fading-text-out") {
const timer = window.setTimeout(
() => setStage("video"),
TRANSITION_TEXT_FADE_MS,
);
return () => window.clearTimeout(timer);
}
return undefined;
}, [stage]);
if (!visible) return null; // Stagger the second word ("La ferme") so it fades in after "Next step :"
// is already visible.
useEffect(() => {
if (stage === "showing-text") {
const timer = window.setTimeout(
() => setLafermeVisible(true),
TRANSITION_LAFERME_DELAY_MS,
);
return () => window.clearTimeout(timer);
}
if (stage === "hidden" || stage === "fading-in") {
// Reset the staged reveal so a re-triggered outro replays correctly.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLafermeVisible(false);
}
return undefined;
}, [stage]);
// Mute all game audio while the video is showing; restore on cleanup so
// a re-mounted page doesn't stay silent.
useEffect(() => {
if (stage !== "video") return;
const audioManager = AudioManager.getInstance();
const saved: Partial<Record<AudioCategory, number>> = {};
for (const category of MUTED_CATEGORIES) {
saved[category] = audioManager.getCategoryVolume(category);
audioManager.setCategoryVolume(category, 0);
}
savedVolumesRef.current = saved;
void videoRef.current?.play();
return () => {
for (const category of MUTED_CATEGORIES) {
const previous = savedVolumesRef.current[category];
if (previous !== undefined) {
audioManager.setCategoryVolume(category, previous);
}
}
savedVolumesRef.current = {};
};
}, [stage]);
if (stage === "hidden") return null;
const showText = stage === "showing-text" || stage === "fading-text-out";
const textOpacity = stage === "showing-text" ? 1 : 0;
return ( return (
<div <div
@@ -45,14 +136,45 @@ export function OutroVideoOverlay(): React.JSX.Element | null {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
opacity: stage === "fading-in" ? 0 : 1,
transition: `opacity ${TRANSITION_FADE_MS}ms ease-out`,
pointerEvents: stage === "video" ? "auto" : "none",
}}
aria-hidden={stage !== "video"}
>
{showText ? (
<div
style={{
color: "#F2F2F2",
textAlign: "center",
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
fontFamily: "Inter, system-ui, sans-serif",
fontSize: "clamp(24px, 4vw, 48px)",
fontWeight: 700,
letterSpacing: "-1.3px",
opacity: textOpacity,
transition: `opacity ${TRANSITION_TEXT_FADE_MS}ms ease-in`,
}} }}
> >
Next step :{" "}
<span
style={{
opacity: lafermeVisible ? 1 : 0,
transition: `opacity ${TRANSITION_TEXT_FADE_MS}ms ease-in`,
}}
>
La ferme
</span>
</div>
) : null}
{stage === "video" ? (
<video <video
ref={videoRef} ref={videoRef}
src={OUTRO_VIDEO_SRC} src={OUTRO_VIDEO_SRC}
style={{ width: "100%", height: "100%", objectFit: "cover" }} style={{ width: "100%", height: "100%", objectFit: "cover" }}
playsInline playsInline
/> />
) : null}
</div> </div>
); );
} }
@@ -14,6 +14,11 @@ const HAND_TUTORIAL_STEPS: ReadonlySet<MissionStep> = new Set([
"inspected", "inspected",
]); ]);
// Fallback: if hand detection never fires (camera blocked, MediaPipe failure,
// player using mouse), the tutorial auto-dismisses after this delay so it
// never blocks the screen indefinitely.
const HAND_TUTORIAL_FALLBACK_TIMEOUT_MS = 5000;
/** /**
* First-time hand-tracking tutorial. Visible during the early ebike repair * First-time hand-tracking tutorial. Visible during the early ebike repair
* steps until MediaPipe actually detects a hand on screen. Once dismissed it * steps until MediaPipe actually detects a hand on screen. Once dismissed it
@@ -39,6 +44,17 @@ export function HandTrackingTutorial(): React.JSX.Element | null {
} }
}, [handsDetected, dismissed]); }, [handsDetected, dismissed]);
// Fallback timeout: dismiss the tutorial even if no hand is ever detected,
// so the overlay never gets stuck on screen.
useEffect(() => {
if (!isInShowWindow || dismissed) return undefined;
const timer = window.setTimeout(
() => setDismissed(true),
HAND_TUTORIAL_FALLBACK_TIMEOUT_MS,
);
return () => window.clearTimeout(timer);
}, [isInShowWindow, dismissed]);
if (!isInShowWindow || dismissed) return null; if (!isInShowWindow || dismissed) return null;
return ( return (