Feat/polish-intro #11

Merged
math-pixel merged 20 commits from feat/polisth-intro into develop 2026-05-31 09:01:18 +00:00
7 changed files with 206 additions and 104 deletions
Showing only changes of commit 07b09c22af - Show all commits
+6 -15
View File
@@ -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={{
+45 -15
View File
@@ -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&apos;appelle {presetPlayerName}. Tapez{" "}
{presetPlayerName.length} caractères pour révéler son nom.
</span>
</div> </div>
<SiteButton <SiteButton
+82 -46
View File
@@ -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,
+10 -10
View File
@@ -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",
}} }}
+1
View File
@@ -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,
}, },
+16 -5
View File
@@ -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(