From 712fb851ade1a4e3adc73b6e4a7a7f163a303f4b Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 3 Jun 2026 03:08:12 +0200 Subject: [PATCH] feat(outro): add fade-to-black transition screen with 'Next step: La ferme' before outro video, mute all game audio during playback --- src/components/ui/OutroVideoOverlay.tsx | 123 +++++++++++++++++++++--- 1 file changed, 108 insertions(+), 15 deletions(-) diff --git a/src/components/ui/OutroVideoOverlay.tsx b/src/components/ui/OutroVideoOverlay.tsx index e41fd24..d2949ee 100644 --- a/src/components/ui/OutroVideoOverlay.tsx +++ b/src/components/ui/OutroVideoOverlay.tsx @@ -1,19 +1,39 @@ 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 TRANSITION_FADE_MS = 600; +const TRANSITION_HOLD_MS = 2000; +const TRANSITION_TEXT_FADE_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 - * cinematic ends. Triggered by the "outro-cinematic-complete" window event - * dispatched from GameCinematics.tsx. + * End-of-demo overlay. Triggered by the "outro-cinematic-complete" window + * event 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 { - const [visible, setVisible] = useState(false); + const [stage, setStage] = useState("hidden"); const videoRef = useRef(null); + const savedVolumesRef = useRef>>({}); useEffect(() => { function handleCinematicComplete(): void { - setVisible(true); + setStage("fading-in"); } window.addEventListener( @@ -28,12 +48,62 @@ export function OutroVideoOverlay(): React.JSX.Element | null { }; }, []); + // Drive the transition timeline. useEffect(() => { - if (!visible) return; - void videoRef.current?.play(); - }, [visible]); + if (stage === "fading-in") { + const timer = window.setTimeout( + () => 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; + // 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> = {}; + 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 (
-
); }