diff --git a/src/components/site/SiteMobileBlocker.tsx b/src/components/site/SiteMobileBlocker.tsx index ef47a16..acce806 100644 --- a/src/components/site/SiteMobileBlocker.tsx +++ b/src/components/site/SiteMobileBlocker.tsx @@ -1,37 +1,28 @@ -import { SITE_CONFIG } from "@/data/site/siteConfig"; +import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig"; 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."; -/** - * Mobile blocker screen - */ export function SiteMobileBlocker(): React.JSX.Element { return (
Logo

state.setStep); const setPlayerName = useGameStore((state) => state.setPlayerName); const [charIndex, setCharIndex] = useState(0); - const dialogueStarted = useRef(false); const inputRef = useRef(null); - const forcedName = SITE_CONFIG.forcedName; - const displayValue = forcedName.slice(0, charIndex); - const isComplete = charIndex >= forcedName.length; + const presetPlayerName = SITE_CONFIG.presetPlayerName; + const displayValue = presetPlayerName.slice(0, charIndex); + const isComplete = charIndex >= presetPlayerName.length; - // Play dialogue when screen appears (with subtitles) useEffect(() => { - if (dialogueStarted.current) return; - dialogueStarted.current = true; + let cancelled = false; void (async () => { const manifest = await loadDialogueManifest(); - if (manifest) { - await playDialogueById(manifest, "narrateur_intro_prenom"); - } + if (cancelled || !manifest) return; + await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming); })(); + + return () => { + cancelled = true; + stopCurrentDialogue(); + }; }, []); - // Focus input on mount useEffect(() => { inputRef.current?.focus(); }, []); const handleNameChange = useCallback( (event: React.ChangeEvent): void => { - const nextLength = Math.min(event.target.value.length, forcedName.length); + const nextLength = Math.min( + event.target.value.length, + presetPlayerName.length, + ); setCharIndex(nextLength); }, - [forcedName.length], + [presetPlayerName.length], ); const handleConfirm = (): void => { if (isComplete) { - setPlayerName(forcedName); + setPlayerName(presetPlayerName); setStep("transition"); } }; @@ -75,6 +84,7 @@ export function SiteNamingScreen(): React.JSX.Element { }} >

+ + Votre personnage s'appelle {presetPlayerName}. Tapez{" "} + {presetPlayerName.length} caractères pour révéler son nom. +

state.reset); + const prefersReducedMotion = usePrefersReducedMotion(); const [screenOpacity, setScreenOpacity] = useState(0); const [logoOpacity, setLogoOpacity] = useState(0); - const transitionStarted = useRef(false); useEffect(() => { - if (transitionStarted.current) return; - transitionStarted.current = true; - - // Fade in black screen - setScreenOpacity(1); - - // Set cookie setSiteVisited(); - // Fade in logo after the black screen transition delay. - setLogoOpacity(1); + let isCancelled = false; + 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 () => { const manifest = await loadDialogueManifest(); - if (manifest) { - const dialogueAudio = await playDialogueById( - manifest, - "narrateur_intro_apresprenom", + if (isCancelled) return; + + const dialogueAudio = manifest + ? await playDialogueById(manifest, SITE_DIALOGUE_IDS.transition) + : null; + if (isCancelled) return; + + if (dialogueAudio) { + const safetyId = window.setTimeout( + redirectToGame, + DIALOGUE_FALLBACK_TIMEOUT_MS, ); - if (dialogueAudio) { - dialogueAudio.addEventListener( - "ended", - () => { - // Fade out logo - setLogoOpacity(0); - // Redirect after logo fade out - setTimeout(() => { - reset(); - navigate({ to: "/" }); - }, FADE_DURATION_MS); - }, - { once: true }, - ); - return; - } + timeoutIds.push(safetyId); + + dialogueAudio.addEventListener( + "ended", + () => { + window.clearTimeout(safetyId); + redirectToGame(); + }, + { once: true }, + ); + return; } - // Fallback: redirect after 3s if dialogue fails - setTimeout(() => { - setLogoOpacity(0); - setTimeout(() => { - reset(); - navigate({ to: "/" }); - }, FADE_DURATION_MS); - }, 3000); + + const fallbackId = window.setTimeout( + redirectToGame, + NO_DIALOGUE_FALLBACK_MS, + ); + timeoutIds.push(fallbackId); })(); + + return () => { + isCancelled = true; + timeoutIds.forEach(window.clearTimeout); + stopCurrentDialogue(); + }; }, [navigate, reset]); + const fadeTransition = prefersReducedMotion + ? "none" + : `opacity ${FADE_DURATION_MS}ms ease-in-out`; + return (
Logo + {/* Subtitles must live inside this overlay's stacking context + (z-index 1000) so they render above the black screen. The + in SiteLayout sits behind this overlay. */}
); diff --git a/src/components/ui/intro/IntroDialogueOverlay.tsx b/src/components/ui/intro/IntroDialogueOverlay.tsx index 029619e..8e79634 100644 --- a/src/components/ui/intro/IntroDialogueOverlay.tsx +++ b/src/components/ui/intro/IntroDialogueOverlay.tsx @@ -1,31 +1,63 @@ -import { useEffect, useRef } from "react"; -import { AudioManager } from "@/managers/AudioManager"; +import { useEffect } from "react"; 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 - * - Plays narrateur_ordreebike.mp3 - * - Transitions to reveal step when dialogue ends + * Black screen overlay that plays the intro dialogue (with synced subtitles) + * via the dialogue manifest, then transitions to the reveal step. */ export function IntroDialogueOverlay(): React.JSX.Element { const setIntroStep = useGameStore((state) => state.setIntroStep); - const dialogueStarted = useRef(false); useEffect(() => { - if (dialogueStarted.current) return; - dialogueStarted.current = true; + let cancelled = false; + let safetyTimeoutId: number | null = null; - // Play dialogue then transition to reveal - const audio = AudioManager.getInstance(); - audio.playSoundWithCallback(INTRO_DIALOGUE_PATH, 0.8, () => { + const advance = (): void => { + if (cancelled) return; + if (safetyTimeoutId !== null) window.clearTimeout(safetyTimeoutId); 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]); return (