Compare commits
2 Commits
d8b916d31f
...
7bcbba4eb1
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bcbba4eb1 | |||
| 712fb851ad |
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
>
|
>
|
||||||
<video
|
{showText ? (
|
||||||
ref={videoRef}
|
<div
|
||||||
src={OUTRO_VIDEO_SRC}
|
style={{
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
color: "#F2F2F2",
|
||||||
playsInline
|
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
|
||||||
|
ref={videoRef}
|
||||||
|
src={OUTRO_VIDEO_SRC}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user