Feat/polish-intro #11
@@ -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 (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
backgroundColor: "#87CEEB",
|
||||
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 32,
|
||||
gap: 48,
|
||||
...SITE_BACKGROUND_STYLE,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="public/assets/logo/logo.jpg"
|
||||
alt="Logo"
|
||||
style={{
|
||||
width: 120,
|
||||
height: "auto",
|
||||
}}
|
||||
src="/assets/logo/logo.jpg"
|
||||
alt="Logo Altera"
|
||||
style={{ width: 120, height: "auto" }}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
|
||||
@@ -3,52 +3,61 @@ import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||
import { SiteButton } from "@/components/site/SiteButton";
|
||||
import { SITE_CONFIG } from "@/data/site/siteConfig";
|
||||
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import {
|
||||
playDialogueById,
|
||||
stopCurrentDialogue,
|
||||
} from "@/utils/dialogues/playDialogue";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const setStep = useSiteStore((state) => state.setStep);
|
||||
const setPlayerName = useGameStore((state) => state.setPlayerName);
|
||||
const [charIndex, setCharIndex] = useState(0);
|
||||
const dialogueStarted = useRef(false);
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>): 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 {
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="player-name-label"
|
||||
style={{
|
||||
color: "#F2F2F2",
|
||||
textAlign: "center",
|
||||
@@ -97,6 +107,9 @@ export function SiteNamingScreen(): React.JSX.Element {
|
||||
value={displayValue}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Écrivez votre prénom ici"
|
||||
aria-labelledby="player-name-label"
|
||||
aria-describedby="player-name-hint"
|
||||
autoComplete="off"
|
||||
style={{
|
||||
display: "flex",
|
||||
padding: "clamp(8px, 1.5vw, 10px)",
|
||||
@@ -116,6 +129,23 @@ export function SiteNamingScreen(): React.JSX.Element {
|
||||
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>
|
||||
|
||||
<SiteButton
|
||||
|
||||
@@ -1,72 +1,102 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
import { setSiteVisited } from "@/utils/cookies/siteVisitCookie";
|
||||
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 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 {
|
||||
const navigate = useNavigate();
|
||||
const reset = useSiteStore((state) => 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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -86,12 +116,12 @@ export function SiteTransitionOverlay(): React.JSX.Element {
|
||||
background: "#000",
|
||||
zIndex: 0,
|
||||
opacity: screenOpacity,
|
||||
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`,
|
||||
transition: fadeTransition,
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src="/assets/logo/logo.jpg"
|
||||
alt="Logo"
|
||||
alt="Logo Altera"
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
@@ -99,10 +129,16 @@ export function SiteTransitionOverlay(): React.JSX.Element {
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
opacity: logoOpacity,
|
||||
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`,
|
||||
transitionDelay: logoOpacity === 1 ? `${FADE_DURATION_MS}ms` : "0ms",
|
||||
transition: fadeTransition,
|
||||
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 />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Dialogue d'introduction"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
@@ -37,6 +69,7 @@ export function IntroDialogueOverlay(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
fontSize: 16,
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
|
||||
|
||||
const REVEAL_DURATION_MS = 2000;
|
||||
|
||||
/**
|
||||
* Fade-out overlay for reveal transition
|
||||
* - Starts fully black
|
||||
* - Fades out to reveal the game world
|
||||
* - Transitions to playing step when done
|
||||
* Fade-out overlay revealing the game world.
|
||||
* Calls completeIntro() when the fade is done — completeIntro also flips
|
||||
* intro.currentStep to "playing" so no separate setIntroStep call is needed.
|
||||
*/
|
||||
export function IntroRevealOverlay(): React.JSX.Element {
|
||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const [opacity, setOpacity] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
// Start fade out
|
||||
const fadeTimeout = window.setTimeout(() => {
|
||||
setOpacity(0);
|
||||
}, 100);
|
||||
|
||||
// Complete intro after fade
|
||||
const completeTimeout = window.setTimeout(() => {
|
||||
setIntroStep("playing");
|
||||
completeIntro();
|
||||
}, REVEAL_DURATION_MS);
|
||||
|
||||
@@ -30,16 +27,19 @@ export function IntroRevealOverlay(): React.JSX.Element {
|
||||
window.clearTimeout(fadeTimeout);
|
||||
window.clearTimeout(completeTimeout);
|
||||
};
|
||||
}, [setIntroStep, completeIntro]);
|
||||
}, [completeIntro]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "#000",
|
||||
opacity,
|
||||
transition: `opacity ${REVEAL_DURATION_MS}ms ease-out`,
|
||||
transition: prefersReducedMotion
|
||||
? "none"
|
||||
: `opacity ${REVEAL_DURATION_MS}ms ease-out`,
|
||||
zIndex: 998,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
|
||||
@@ -121,6 +121,7 @@ function completeIntroState(state: GameState): GameStateUpdate {
|
||||
mainState: "ebike",
|
||||
intro: {
|
||||
...state.intro,
|
||||
currentStep: "playing",
|
||||
hasCompleted: true,
|
||||
isEbikeUnlocked: true,
|
||||
},
|
||||
|
||||
@@ -12,6 +12,9 @@ import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
|
||||
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
|
||||
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
|
||||
|
||||
let manifestCache: DialogueManifest | null = null;
|
||||
let manifestPromise: Promise<DialogueManifest | null> | null = null;
|
||||
|
||||
export interface DialogueSubtitleCue {
|
||||
voice: DialogueVoice;
|
||||
cue: SubtitleCue;
|
||||
@@ -28,13 +31,21 @@ export interface DialogueSubtitleCues {
|
||||
}
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
manifestPromise = (async () => {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user