fix(site): repair onboarding audio cleanup, redirect, and manifest fetches
- loadDialogueManifest: cache the resolved manifest at module level and dedupe concurrent fetches so each screen no longer re-downloads it - useGameStore: completeIntroState now also advances intro.currentStep to "playing" so callers do not need a separate setIntroStep call - SiteNamingScreen and SiteTransitionOverlay: replace ref-based guards with an isCancelled flag captured per effect. The previous guards persisted across StrictMode remounts, leaving mount 2 unable to re-run the effect after mount 1's chain was cancelled, which broke the fade animations, the second narrator dialogue and the redirect. Both screens now also call stopCurrentDialogue on unmount so audio cannot bleed across routes, and the transition gets a safety timeout in case the dialogue audio fails to fire its "ended" event - SiteTransitionOverlay: keep the <Subtitles /> mount inside the overlay so it renders inside the z-index 1000 stacking context (above the black screen); the one in SiteLayout sits behind it - IntroDialogueOverlay: route through playDialogueById instead of AudioManager.playSoundWithCallback so the narrator subtitles play in sync, and add the same isCancelled cleanup pattern - IntroRevealOverlay: rely on completeIntro alone now that it advances intro.currentStep, and skip the fade when reduced motion is requested - SiteMobileBlocker: correct logo path from public/... to /...
This commit is contained in:
@@ -1,37 +1,28 @@
|
|||||||
import { SITE_CONFIG } from "@/data/site/siteConfig";
|
import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig";
|
||||||
|
|
||||||
const MOBILE_TEXT =
|
const MOBILE_TEXT =
|
||||||
"Ce site a été conçu pour être utilisé sur ordinateur. Veuillez réessayer sur votre ordinateur pour une expérience optimale.";
|
"Ce site a été conçu pour être utilisé sur ordinateur. Veuillez réessayer sur votre ordinateur pour une expérience optimale.";
|
||||||
|
|
||||||
/**
|
|
||||||
* Mobile blocker screen
|
|
||||||
*/
|
|
||||||
export function SiteMobileBlocker(): React.JSX.Element {
|
export function SiteMobileBlocker(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="alert"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
backgroundColor: "#87CEEB",
|
|
||||||
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
|
|
||||||
backgroundSize: "cover",
|
|
||||||
backgroundPosition: "center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
padding: 32,
|
padding: 32,
|
||||||
gap: 48,
|
gap: 48,
|
||||||
|
...SITE_BACKGROUND_STYLE,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="public/assets/logo/logo.jpg"
|
src="/assets/logo/logo.jpg"
|
||||||
alt="Logo"
|
alt="Logo Altera"
|
||||||
style={{
|
style={{ width: 120, height: "auto" }}
|
||||||
width: 120,
|
|
||||||
height: "auto",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -3,52 +3,61 @@ import { useGameStore } from "@/managers/stores/useGameStore";
|
|||||||
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
import { SiteButton } from "@/components/site/SiteButton";
|
import { SiteButton } from "@/components/site/SiteButton";
|
||||||
import { SITE_CONFIG } from "@/data/site/siteConfig";
|
import { SITE_CONFIG } from "@/data/site/siteConfig";
|
||||||
|
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
import {
|
||||||
|
playDialogueById,
|
||||||
|
stopCurrentDialogue,
|
||||||
|
} from "@/utils/dialogues/playDialogue";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen 3: Name input
|
* Screen 3: Name input
|
||||||
|
* The displayed name is forced to SITE_CONFIG.presetPlayerName — the
|
||||||
|
* field reveals one letter per keystroke until the preset name is complete.
|
||||||
*/
|
*/
|
||||||
export function SiteNamingScreen(): React.JSX.Element {
|
export function SiteNamingScreen(): React.JSX.Element {
|
||||||
const setStep = useSiteStore((state) => state.setStep);
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
const setPlayerName = useGameStore((state) => state.setPlayerName);
|
const setPlayerName = useGameStore((state) => state.setPlayerName);
|
||||||
const [charIndex, setCharIndex] = useState(0);
|
const [charIndex, setCharIndex] = useState(0);
|
||||||
const dialogueStarted = useRef(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const forcedName = SITE_CONFIG.forcedName;
|
const presetPlayerName = SITE_CONFIG.presetPlayerName;
|
||||||
const displayValue = forcedName.slice(0, charIndex);
|
const displayValue = presetPlayerName.slice(0, charIndex);
|
||||||
const isComplete = charIndex >= forcedName.length;
|
const isComplete = charIndex >= presetPlayerName.length;
|
||||||
|
|
||||||
// Play dialogue when screen appears (with subtitles)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogueStarted.current) return;
|
let cancelled = false;
|
||||||
dialogueStarted.current = true;
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const manifest = await loadDialogueManifest();
|
const manifest = await loadDialogueManifest();
|
||||||
if (manifest) {
|
if (cancelled || !manifest) return;
|
||||||
await playDialogueById(manifest, "narrateur_intro_prenom");
|
await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming);
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
stopCurrentDialogue();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Focus input on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNameChange = useCallback(
|
const handleNameChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
const nextLength = Math.min(event.target.value.length, forcedName.length);
|
const nextLength = Math.min(
|
||||||
|
event.target.value.length,
|
||||||
|
presetPlayerName.length,
|
||||||
|
);
|
||||||
setCharIndex(nextLength);
|
setCharIndex(nextLength);
|
||||||
},
|
},
|
||||||
[forcedName.length],
|
[presetPlayerName.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConfirm = (): void => {
|
const handleConfirm = (): void => {
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
setPlayerName(forcedName);
|
setPlayerName(presetPlayerName);
|
||||||
setStep("transition");
|
setStep("transition");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -75,6 +84,7 @@ export function SiteNamingScreen(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
|
id="player-name-label"
|
||||||
style={{
|
style={{
|
||||||
color: "#F2F2F2",
|
color: "#F2F2F2",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@@ -97,6 +107,9 @@ export function SiteNamingScreen(): React.JSX.Element {
|
|||||||
value={displayValue}
|
value={displayValue}
|
||||||
onChange={handleNameChange}
|
onChange={handleNameChange}
|
||||||
placeholder="Écrivez votre prénom ici"
|
placeholder="Écrivez votre prénom ici"
|
||||||
|
aria-labelledby="player-name-label"
|
||||||
|
aria-describedby="player-name-hint"
|
||||||
|
autoComplete="off"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
padding: "clamp(8px, 1.5vw, 10px)",
|
padding: "clamp(8px, 1.5vw, 10px)",
|
||||||
@@ -116,6 +129,23 @@ export function SiteNamingScreen(): React.JSX.Element {
|
|||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
id="player-name-hint"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
padding: 0,
|
||||||
|
margin: -1,
|
||||||
|
overflow: "hidden",
|
||||||
|
clip: "rect(0, 0, 0, 0)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
border: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Votre personnage s'appelle {presetPlayerName}. Tapez{" "}
|
||||||
|
{presetPlayerName.length} caractères pour révéler son nom.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteButton
|
<SiteButton
|
||||||
|
|||||||
@@ -1,72 +1,102 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
import { Subtitles } from "@/components/ui/Subtitles";
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
import { setSiteVisited } from "@/utils/cookies/siteVisitCookie";
|
import { setSiteVisited } from "@/utils/cookies/siteVisitCookie";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
import {
|
||||||
|
playDialogueById,
|
||||||
|
stopCurrentDialogue,
|
||||||
|
} from "@/utils/dialogues/playDialogue";
|
||||||
|
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
|
||||||
|
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
|
||||||
|
|
||||||
const FADE_DURATION_MS = 1000;
|
const FADE_DURATION_MS = 1000;
|
||||||
|
const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000;
|
||||||
|
const NO_DIALOGUE_FALLBACK_MS = 3000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transition overlay: black screen (fade in) + logo (fade in/out) + dialogue with subtitles + redirect to /
|
* Transition overlay: black screen, logo fade-in, transition dialogue
|
||||||
|
* with subtitles, then redirect to /. A safety timeout guarantees the
|
||||||
|
* redirect happens even if the dialogue audio fails to fire `ended`.
|
||||||
*/
|
*/
|
||||||
export function SiteTransitionOverlay(): React.JSX.Element {
|
export function SiteTransitionOverlay(): React.JSX.Element {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const reset = useSiteStore((state) => state.reset);
|
const reset = useSiteStore((state) => state.reset);
|
||||||
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
const [screenOpacity, setScreenOpacity] = useState(0);
|
const [screenOpacity, setScreenOpacity] = useState(0);
|
||||||
const [logoOpacity, setLogoOpacity] = useState(0);
|
const [logoOpacity, setLogoOpacity] = useState(0);
|
||||||
const transitionStarted = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (transitionStarted.current) return;
|
|
||||||
transitionStarted.current = true;
|
|
||||||
|
|
||||||
// Fade in black screen
|
|
||||||
setScreenOpacity(1);
|
|
||||||
|
|
||||||
// Set cookie
|
|
||||||
setSiteVisited();
|
setSiteVisited();
|
||||||
|
|
||||||
// Fade in logo after the black screen transition delay.
|
let isCancelled = false;
|
||||||
setLogoOpacity(1);
|
const timeoutIds: number[] = [];
|
||||||
|
|
||||||
|
// Defer the opacity flip one tick so the CSS transition has an
|
||||||
|
// initial frame at opacity 0 before flipping to 1.
|
||||||
|
const fadeInId = window.setTimeout(() => {
|
||||||
|
setScreenOpacity(1);
|
||||||
|
setLogoOpacity(1);
|
||||||
|
}, 0);
|
||||||
|
timeoutIds.push(fadeInId);
|
||||||
|
|
||||||
|
const redirectToGame = (): void => {
|
||||||
|
if (isCancelled) return;
|
||||||
|
setLogoOpacity(0);
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
if (isCancelled) return;
|
||||||
|
reset();
|
||||||
|
navigate({ to: "/" });
|
||||||
|
}, FADE_DURATION_MS);
|
||||||
|
timeoutIds.push(id);
|
||||||
|
};
|
||||||
|
|
||||||
// Play transition dialogue (with subtitles) then fade out logo and redirect
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const manifest = await loadDialogueManifest();
|
const manifest = await loadDialogueManifest();
|
||||||
if (manifest) {
|
if (isCancelled) return;
|
||||||
const dialogueAudio = await playDialogueById(
|
|
||||||
manifest,
|
const dialogueAudio = manifest
|
||||||
"narrateur_intro_apresprenom",
|
? await playDialogueById(manifest, SITE_DIALOGUE_IDS.transition)
|
||||||
|
: null;
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
if (dialogueAudio) {
|
||||||
|
const safetyId = window.setTimeout(
|
||||||
|
redirectToGame,
|
||||||
|
DIALOGUE_FALLBACK_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
if (dialogueAudio) {
|
timeoutIds.push(safetyId);
|
||||||
dialogueAudio.addEventListener(
|
|
||||||
"ended",
|
dialogueAudio.addEventListener(
|
||||||
() => {
|
"ended",
|
||||||
// Fade out logo
|
() => {
|
||||||
setLogoOpacity(0);
|
window.clearTimeout(safetyId);
|
||||||
// Redirect after logo fade out
|
redirectToGame();
|
||||||
setTimeout(() => {
|
},
|
||||||
reset();
|
{ once: true },
|
||||||
navigate({ to: "/" });
|
);
|
||||||
}, FADE_DURATION_MS);
|
return;
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Fallback: redirect after 3s if dialogue fails
|
|
||||||
setTimeout(() => {
|
const fallbackId = window.setTimeout(
|
||||||
setLogoOpacity(0);
|
redirectToGame,
|
||||||
setTimeout(() => {
|
NO_DIALOGUE_FALLBACK_MS,
|
||||||
reset();
|
);
|
||||||
navigate({ to: "/" });
|
timeoutIds.push(fallbackId);
|
||||||
}, FADE_DURATION_MS);
|
|
||||||
}, 3000);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
timeoutIds.forEach(window.clearTimeout);
|
||||||
|
stopCurrentDialogue();
|
||||||
|
};
|
||||||
}, [navigate, reset]);
|
}, [navigate, reset]);
|
||||||
|
|
||||||
|
const fadeTransition = prefersReducedMotion
|
||||||
|
? "none"
|
||||||
|
: `opacity ${FADE_DURATION_MS}ms ease-in-out`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -86,12 +116,12 @@ export function SiteTransitionOverlay(): React.JSX.Element {
|
|||||||
background: "#000",
|
background: "#000",
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
opacity: screenOpacity,
|
opacity: screenOpacity,
|
||||||
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`,
|
transition: fadeTransition,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
src="/assets/logo/logo.jpg"
|
src="/assets/logo/logo.jpg"
|
||||||
alt="Logo"
|
alt="Logo Altera"
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
@@ -99,10 +129,16 @@ export function SiteTransitionOverlay(): React.JSX.Element {
|
|||||||
height: "auto",
|
height: "auto",
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
opacity: logoOpacity,
|
opacity: logoOpacity,
|
||||||
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`,
|
transition: fadeTransition,
|
||||||
transitionDelay: logoOpacity === 1 ? `${FADE_DURATION_MS}ms` : "0ms",
|
transitionDelay:
|
||||||
|
!prefersReducedMotion && logoOpacity === 1
|
||||||
|
? `${FADE_DURATION_MS}ms`
|
||||||
|
: "0ms",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* Subtitles must live inside this overlay's stacking context
|
||||||
|
(z-index 1000) so they render above the black screen. The
|
||||||
|
<Subtitles /> in SiteLayout sits behind this overlay. */}
|
||||||
<Subtitles />
|
<Subtitles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,31 +1,63 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
import { AudioManager } from "@/managers/AudioManager";
|
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
|
||||||
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import {
|
||||||
|
playDialogueById,
|
||||||
|
stopCurrentDialogue,
|
||||||
|
} from "@/utils/dialogues/playDialogue";
|
||||||
|
|
||||||
const INTRO_DIALOGUE_PATH = "/sounds/dialogue/narrateur_ordreebike.mp3";
|
const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Black screen overlay with dialogue audio
|
* Black screen overlay that plays the intro dialogue (with synced subtitles)
|
||||||
* - Plays narrateur_ordreebike.mp3
|
* via the dialogue manifest, then transitions to the reveal step.
|
||||||
* - Transitions to reveal step when dialogue ends
|
|
||||||
*/
|
*/
|
||||||
export function IntroDialogueOverlay(): React.JSX.Element {
|
export function IntroDialogueOverlay(): React.JSX.Element {
|
||||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
const dialogueStarted = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogueStarted.current) return;
|
let cancelled = false;
|
||||||
dialogueStarted.current = true;
|
let safetyTimeoutId: number | null = null;
|
||||||
|
|
||||||
// Play dialogue then transition to reveal
|
const advance = (): void => {
|
||||||
const audio = AudioManager.getInstance();
|
if (cancelled) return;
|
||||||
audio.playSoundWithCallback(INTRO_DIALOGUE_PATH, 0.8, () => {
|
if (safetyTimeoutId !== null) window.clearTimeout(safetyTimeoutId);
|
||||||
setIntroStep("reveal");
|
setIntroStep("reveal");
|
||||||
});
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const manifest = await loadDialogueManifest();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const audio = manifest
|
||||||
|
? await playDialogueById(manifest, SITE_DIALOGUE_IDS.introOrder)
|
||||||
|
: null;
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (!audio) {
|
||||||
|
advance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safetyTimeoutId = window.setTimeout(
|
||||||
|
advance,
|
||||||
|
DIALOGUE_FALLBACK_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
audio.addEventListener("ended", advance, { once: true });
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (safetyTimeoutId !== null) window.clearTimeout(safetyTimeoutId);
|
||||||
|
stopCurrentDialogue();
|
||||||
|
};
|
||||||
}, [setIntroStep]);
|
}, [setIntroStep]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Dialogue d'introduction"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -37,6 +69,7 @@ export function IntroDialogueOverlay(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
color: "rgba(255, 255, 255, 0.5)",
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
|
||||||
|
|
||||||
const REVEAL_DURATION_MS = 2000;
|
const REVEAL_DURATION_MS = 2000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fade-out overlay for reveal transition
|
* Fade-out overlay revealing the game world.
|
||||||
* - Starts fully black
|
* Calls completeIntro() when the fade is done — completeIntro also flips
|
||||||
* - Fades out to reveal the game world
|
* intro.currentStep to "playing" so no separate setIntroStep call is needed.
|
||||||
* - Transitions to playing step when done
|
|
||||||
*/
|
*/
|
||||||
export function IntroRevealOverlay(): React.JSX.Element {
|
export function IntroRevealOverlay(): React.JSX.Element {
|
||||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
|
||||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||||
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
const [opacity, setOpacity] = useState(1);
|
const [opacity, setOpacity] = useState(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Start fade out
|
|
||||||
const fadeTimeout = window.setTimeout(() => {
|
const fadeTimeout = window.setTimeout(() => {
|
||||||
setOpacity(0);
|
setOpacity(0);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Complete intro after fade
|
|
||||||
const completeTimeout = window.setTimeout(() => {
|
const completeTimeout = window.setTimeout(() => {
|
||||||
setIntroStep("playing");
|
|
||||||
completeIntro();
|
completeIntro();
|
||||||
}, REVEAL_DURATION_MS);
|
}, REVEAL_DURATION_MS);
|
||||||
|
|
||||||
@@ -30,16 +27,19 @@ export function IntroRevealOverlay(): React.JSX.Element {
|
|||||||
window.clearTimeout(fadeTimeout);
|
window.clearTimeout(fadeTimeout);
|
||||||
window.clearTimeout(completeTimeout);
|
window.clearTimeout(completeTimeout);
|
||||||
};
|
};
|
||||||
}, [setIntroStep, completeIntro]);
|
}, [completeIntro]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background: "#000",
|
background: "#000",
|
||||||
opacity,
|
opacity,
|
||||||
transition: `opacity ${REVEAL_DURATION_MS}ms ease-out`,
|
transition: prefersReducedMotion
|
||||||
|
? "none"
|
||||||
|
: `opacity ${REVEAL_DURATION_MS}ms ease-out`,
|
||||||
zIndex: 998,
|
zIndex: 998,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ function completeIntroState(state: GameState): GameStateUpdate {
|
|||||||
mainState: "ebike",
|
mainState: "ebike",
|
||||||
intro: {
|
intro: {
|
||||||
...state.intro,
|
...state.intro,
|
||||||
|
currentStep: "playing",
|
||||||
hasCompleted: true,
|
hasCompleted: true,
|
||||||
isEbikeUnlocked: true,
|
isEbikeUnlocked: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
|
|||||||
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
|
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
|
||||||
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
|
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
|
||||||
|
|
||||||
|
let manifestCache: DialogueManifest | null = null;
|
||||||
|
let manifestPromise: Promise<DialogueManifest | null> | null = null;
|
||||||
|
|
||||||
export interface DialogueSubtitleCue {
|
export interface DialogueSubtitleCue {
|
||||||
voice: DialogueVoice;
|
voice: DialogueVoice;
|
||||||
cue: SubtitleCue;
|
cue: SubtitleCue;
|
||||||
@@ -28,13 +31,21 @@ export interface DialogueSubtitleCues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
|
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
|
||||||
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
if (manifestCache) return manifestCache;
|
||||||
|
if (manifestPromise) return manifestPromise;
|
||||||
|
|
||||||
if (!response.ok) {
|
manifestPromise = (async () => {
|
||||||
return null;
|
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
||||||
}
|
if (!response.ok) {
|
||||||
|
manifestPromise = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const manifest = parseDialogueManifest(await response.json());
|
||||||
|
manifestCache = manifest;
|
||||||
|
return manifest;
|
||||||
|
})();
|
||||||
|
|
||||||
return parseDialogueManifest(await response.json());
|
return manifestPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDialogueVoice(
|
function getDialogueVoice(
|
||||||
|
|||||||
Reference in New Issue
Block a user