Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 970adf4853 | |||
| 07b09c22af | |||
| 0f6860f1ae |
@@ -21,6 +21,7 @@ export function SiteButton({
|
|||||||
onMouseDown={() => setIsPressed(true)}
|
onMouseDown={() => setIsPressed(true)}
|
||||||
onMouseUp={() => setIsPressed(false)}
|
onMouseUp={() => setIsPressed(false)}
|
||||||
onMouseLeave={() => setIsPressed(false)}
|
onMouseLeave={() => setIsPressed(false)}
|
||||||
|
className="site-button"
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
padding: "12px 20px",
|
padding: "12px 20px",
|
||||||
|
|||||||
@@ -22,23 +22,18 @@ export function SiteCard({
|
|||||||
return "#b8b8b8";
|
return "#b8b8b8";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBorder = (): string => {
|
const borderColor = selected ? "#a8d5a2" : "rgba(255, 255, 255, 0.55)";
|
||||||
if (selected) return "3px solid #a8d5a2";
|
|
||||||
if (isSituation) return "3px solid rgba(255, 255, 255, 0.55)";
|
|
||||||
if (disabled) return "3px solid rgba(255, 255, 255, 0.55)";
|
|
||||||
return "3px solid rgba(255, 255, 255, 0.55)";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTextColor = (): string => {
|
const textColor = disabled ? "rgba(77, 77, 77, 0.72)" : "#4d4d4d";
|
||||||
if (disabled) return "rgba(77, 77, 77, 0.72)";
|
|
||||||
return "#4d4d4d";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={label}
|
||||||
|
className="site-card-button"
|
||||||
style={{
|
style={{
|
||||||
width: isSituation
|
width: isSituation
|
||||||
? "clamp(220px, 24vw, 300px)"
|
? "clamp(220px, 24vw, 300px)"
|
||||||
@@ -46,21 +41,20 @@ export function SiteCard({
|
|||||||
height: isSituation
|
height: isSituation
|
||||||
? "clamp(48px, 6vw, 60px)"
|
? "clamp(48px, 6vw, 60px)"
|
||||||
: "clamp(140px, 18vw, 180px)",
|
: "clamp(140px, 18vw, 180px)",
|
||||||
border: getBorder(),
|
border: `3px solid ${borderColor}`,
|
||||||
background: getBackground(),
|
background: getBackground(),
|
||||||
cursor: disabled ? "not-allowed" : "pointer",
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
transition: "all 0.15s ease",
|
transition: "all 0.15s ease",
|
||||||
outline: "none",
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!imagePath && (
|
{!imagePath && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: getTextColor(),
|
color: textColor,
|
||||||
fontSize: isSituation
|
fontSize: isSituation
|
||||||
? "clamp(14px, 1.8vw, 22px)"
|
? "clamp(14px, 1.8vw, 22px)"
|
||||||
: "clamp(10px, 1.5vw, 14px)",
|
: "clamp(10px, 1.5vw, 14px)",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
|
||||||
|
|
||||||
const DISCLAIMER_TEXT =
|
const DISCLAIMER_TEXT =
|
||||||
"Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante.";
|
"Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante.";
|
||||||
@@ -7,13 +8,15 @@ const DISCLAIMER_TEXT =
|
|||||||
const TEXT_DISPLAY_DURATION = 5000;
|
const TEXT_DISPLAY_DURATION = 5000;
|
||||||
const FADE_OUT_DURATION = 1000;
|
const FADE_OUT_DURATION = 1000;
|
||||||
const TRANSITION_DELAY = 250;
|
const TRANSITION_DELAY = 250;
|
||||||
|
const SKIP_KEYS = new Set(["Enter", " ", "Escape"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen 0: Disclaimer
|
* Screen 0: Disclaimer
|
||||||
*/
|
*/
|
||||||
export function SiteDisclaimerScreen(): React.JSX.Element {
|
export function SiteDisclaimerScreen(): React.JSX.Element {
|
||||||
const setStep = useSiteStore((state) => state.setStep);
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
const [textOpacity, setTextOpacity] = useState(0);
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
const [textOpacity, setTextOpacity] = useState(prefersReducedMotion ? 1 : 0);
|
||||||
const hasSkipped = useRef(false);
|
const hasSkipped = useRef(false);
|
||||||
|
|
||||||
const handleSkip = useCallback(() => {
|
const handleSkip = useCallback(() => {
|
||||||
@@ -23,33 +26,40 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
|
|||||||
}, [setStep]);
|
}, [setStep]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fade in text
|
|
||||||
const fadeInTimeout = window.setTimeout(() => {
|
const fadeInTimeout = window.setTimeout(() => {
|
||||||
setTextOpacity(1);
|
setTextOpacity(1);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Start fade out after display duration
|
|
||||||
const fadeOutTimeout = window.setTimeout(() => {
|
const fadeOutTimeout = window.setTimeout(() => {
|
||||||
setTextOpacity(0);
|
setTextOpacity(0);
|
||||||
}, TEXT_DISPLAY_DURATION);
|
}, TEXT_DISPLAY_DURATION);
|
||||||
|
|
||||||
// Transition to welcome after fade out + delay
|
|
||||||
const transitionTimeout = window.setTimeout(
|
const transitionTimeout = window.setTimeout(
|
||||||
() => {
|
handleSkip,
|
||||||
handleSkip();
|
|
||||||
},
|
|
||||||
TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY,
|
TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (SKIP_KEYS.has(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSkip();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(fadeInTimeout);
|
window.clearTimeout(fadeInTimeout);
|
||||||
window.clearTimeout(fadeOutTimeout);
|
window.clearTimeout(fadeOutTimeout);
|
||||||
window.clearTimeout(transitionTimeout);
|
window.clearTimeout(transitionTimeout);
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [handleSkip]);
|
}, [handleSkip]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Avertissement"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
@@ -63,6 +73,7 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
color: "#F2F2F2",
|
color: "#F2F2F2",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@@ -72,7 +83,9 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
|
|||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
maxWidth: 800,
|
maxWidth: 800,
|
||||||
opacity: textOpacity,
|
opacity: textOpacity,
|
||||||
transition: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
|
transition: prefersReducedMotion
|
||||||
|
? "none"
|
||||||
|
: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { SITE_CONFIG } from "@/data/site/siteConfig";
|
import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig";
|
||||||
import { Subtitles } from "@/components/ui/Subtitles";
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
|
|
||||||
interface SiteLayoutProps {
|
interface SiteLayoutProps {
|
||||||
@@ -16,11 +16,7 @@ export function SiteLayout({ children }: SiteLayoutProps): React.JSX.Element {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "#87CEEB",
|
...SITE_BACKGROUND_STYLE,
|
||||||
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
|
|
||||||
backgroundSize: "cover",
|
|
||||||
backgroundPosition: "center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { SITUATION_CARDS } from "@/data/site/siteConfig";
|
|||||||
* Screen 2: Situation selection
|
* Screen 2: Situation selection
|
||||||
*/
|
*/
|
||||||
export function SiteSituationScreen(): React.JSX.Element {
|
export function SiteSituationScreen(): React.JSX.Element {
|
||||||
const selectedSituation = useSiteStore((state) => state.selectedSituation);
|
const selectedSituationIndex = useSiteStore(
|
||||||
const setSelectedSituation = useSiteStore(
|
(state) => state.selectedSituationIndex,
|
||||||
(state) => state.setSelectedSituation,
|
);
|
||||||
|
const setSelectedSituationIndex = useSiteStore(
|
||||||
|
(state) => state.setSelectedSituationIndex,
|
||||||
);
|
);
|
||||||
const setStep = useSiteStore((state) => state.setStep);
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
|
||||||
const canProceed = selectedSituation !== null;
|
const canProceed = selectedSituationIndex !== null;
|
||||||
|
|
||||||
const handleConfirm = (): void => {
|
const handleConfirm = (): void => {
|
||||||
if (canProceed) {
|
if (canProceed) {
|
||||||
@@ -63,11 +65,11 @@ export function SiteSituationScreen(): React.JSX.Element {
|
|||||||
<SiteCard
|
<SiteCard
|
||||||
key={card.id}
|
key={card.id}
|
||||||
config={card}
|
config={card}
|
||||||
selected={selectedSituation === index}
|
selected={selectedSituationIndex === index}
|
||||||
variant="situation"
|
variant="situation"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (!card.disabled) {
|
if (!card.disabled) {
|
||||||
setSelectedSituation(index);
|
setSelectedSituationIndex(index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|
||||||
// Play transition dialogue (with subtitles) then fade out logo and redirect
|
// Defer the opacity flip one tick so the CSS transition has an
|
||||||
void (async () => {
|
// initial frame at opacity 0 before flipping to 1.
|
||||||
const manifest = await loadDialogueManifest();
|
const fadeInId = window.setTimeout(() => {
|
||||||
if (manifest) {
|
setScreenOpacity(1);
|
||||||
const dialogueAudio = await playDialogueById(
|
setLogoOpacity(1);
|
||||||
manifest,
|
}, 0);
|
||||||
"narrateur_intro_apresprenom",
|
timeoutIds.push(fadeInId);
|
||||||
);
|
|
||||||
if (dialogueAudio) {
|
const redirectToGame = (): void => {
|
||||||
dialogueAudio.addEventListener(
|
if (isCancelled) return;
|
||||||
"ended",
|
|
||||||
() => {
|
|
||||||
// Fade out logo
|
|
||||||
setLogoOpacity(0);
|
setLogoOpacity(0);
|
||||||
// Redirect after logo fade out
|
const id = window.setTimeout(() => {
|
||||||
setTimeout(() => {
|
if (isCancelled) return;
|
||||||
reset();
|
reset();
|
||||||
navigate({ to: "/" });
|
navigate({ to: "/" });
|
||||||
}, FADE_DURATION_MS);
|
}, FADE_DURATION_MS);
|
||||||
|
timeoutIds.push(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const manifest = await loadDialogueManifest();
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
timeoutIds.push(safetyId);
|
||||||
|
|
||||||
|
dialogueAudio.addEventListener(
|
||||||
|
"ended",
|
||||||
|
() => {
|
||||||
|
window.clearTimeout(safetyId);
|
||||||
|
redirectToGame();
|
||||||
},
|
},
|
||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Fallback: redirect after 3s if dialogue fails
|
const fallbackId = window.setTimeout(
|
||||||
setTimeout(() => {
|
redirectToGame,
|
||||||
setLogoOpacity(0);
|
NO_DIALOGUE_FALLBACK_MS,
|
||||||
setTimeout(() => {
|
);
|
||||||
reset();
|
timeoutIds.push(fallbackId);
|
||||||
navigate({ to: "/" });
|
|
||||||
}, 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { EXPERIENCE_CARDS } from "@/data/site/siteConfig";
|
|||||||
* Screen 1: Welcome
|
* Screen 1: Welcome
|
||||||
*/
|
*/
|
||||||
export function SiteWelcomeScreen(): React.JSX.Element {
|
export function SiteWelcomeScreen(): React.JSX.Element {
|
||||||
const selectedExperience = useSiteStore((state) => state.selectedExperience);
|
const selectedExperienceIndex = useSiteStore(
|
||||||
const setSelectedExperience = useSiteStore(
|
(state) => state.selectedExperienceIndex,
|
||||||
(state) => state.setSelectedExperience,
|
);
|
||||||
|
const setSelectedExperienceIndex = useSiteStore(
|
||||||
|
(state) => state.setSelectedExperienceIndex,
|
||||||
);
|
);
|
||||||
const setStep = useSiteStore((state) => state.setStep);
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
|
||||||
const canProceed = selectedExperience !== null;
|
const canProceed = selectedExperienceIndex !== null;
|
||||||
|
|
||||||
const handleNext = (): void => {
|
const handleNext = (): void => {
|
||||||
if (canProceed) {
|
if (canProceed) {
|
||||||
@@ -104,10 +106,10 @@ export function SiteWelcomeScreen(): React.JSX.Element {
|
|||||||
<SiteCard
|
<SiteCard
|
||||||
key={card.id}
|
key={card.id}
|
||||||
config={card}
|
config={card}
|
||||||
selected={selectedExperience === index}
|
selected={selectedExperienceIndex === index}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (!card.disabled) {
|
if (!card.disabled) {
|
||||||
setSelectedExperience(index);
|
setSelectedExperienceIndex(index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useCallback, useRef, useEffect } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
|
||||||
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
|
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
|
||||||
|
const SKIP_KEYS = new Set(["Enter", " "]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen video player for intro cinematic
|
* Full-screen video player for the intro cinematic.
|
||||||
* - Plays intro.mp4 in fullscreen
|
* Advances to the dialogue-intro step when the video ends or the user skips.
|
||||||
* - Automatically advances to dialogue-intro step when video ends
|
|
||||||
* - Allows skipping with Enter/Space/Click
|
|
||||||
*/
|
*/
|
||||||
export function IntroVideoPlayer(): React.JSX.Element {
|
export function IntroVideoPlayer(): React.JSX.Element {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
@@ -18,16 +17,13 @@ export function IntroVideoPlayer(): React.JSX.Element {
|
|||||||
}, [setIntroStep]);
|
}, [setIntroStep]);
|
||||||
|
|
||||||
const handleSkip = useCallback(() => {
|
const handleSkip = useCallback(() => {
|
||||||
if (videoRef.current) {
|
videoRef.current?.pause();
|
||||||
videoRef.current.pause();
|
|
||||||
}
|
|
||||||
setIntroStep("dialogue-intro");
|
setIntroStep("dialogue-intro");
|
||||||
}, [setIntroStep]);
|
}, [setIntroStep]);
|
||||||
|
|
||||||
// Handle keyboard skip (Enter/Space)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
if (SKIP_KEYS.has(event.key)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSkip();
|
handleSkip();
|
||||||
}
|
}
|
||||||
@@ -39,6 +35,8 @@ export function IntroVideoPlayer(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Vidéo d'introduction. Appuyez sur Entrée pour passer."
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
@@ -56,6 +54,7 @@ export function IntroVideoPlayer(): React.JSX.Element {
|
|||||||
src={INTRO_VIDEO_PATH}
|
src={INTRO_VIDEO_PATH}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
|
preload="auto"
|
||||||
onEnded={handleVideoEnd}
|
onEnded={handleVideoEnd}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -64,6 +63,7 @@ export function IntroVideoPlayer(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 32,
|
bottom: 32,
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
export const AUDIO_PATHS = {
|
|
||||||
intro: "/sounds/effect/fa.mp3",
|
|
||||||
bienvenue: "/sounds/effect/fa.mp3",
|
|
||||||
alertCentral: "/sounds/effect/fa.mp3",
|
|
||||||
searching: "/sounds/effect/fa.mp3",
|
|
||||||
helped: "/sounds/effect/fa.mp3",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type AudioCategory = "music" | "sfx" | "dialogue";
|
export type AudioCategory = "music" | "sfx" | "dialogue";
|
||||||
|
|
||||||
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Dialogue manifest IDs used by the /site flow and the intro sequence.
|
||||||
|
* Defined once here so components don't hold magic strings.
|
||||||
|
*/
|
||||||
|
export const SITE_DIALOGUE_IDS = {
|
||||||
|
naming: "narrateur_intro_prenom",
|
||||||
|
transition: "narrateur_intro_apresprenom",
|
||||||
|
introOrder: "narrateur_ordreebike",
|
||||||
|
} as const;
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
const BACKGROUND_IMAGE = "/assets/bg-site.png";
|
||||||
|
|
||||||
export const SITE_CONFIG = {
|
export const SITE_CONFIG = {
|
||||||
backgroundImage: "/assets/bg-site.png",
|
backgroundImage: BACKGROUND_IMAGE,
|
||||||
forcedName: "Danyl",
|
presetPlayerName: "Danyl",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared background style used by SiteLayout and SiteMobileBlocker.
|
||||||
|
*/
|
||||||
|
export const SITE_BACKGROUND_STYLE: CSSProperties = {
|
||||||
|
backgroundColor: "#87CEEB",
|
||||||
|
backgroundImage: `url(${BACKGROUND_IMAGE})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
};
|
||||||
|
|
||||||
export interface SiteCardConfig {
|
export interface SiteCardConfig {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -24,10 +39,10 @@ export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [
|
|||||||
* Cards for screen 2: "Quelle est votre situation ?"
|
* Cards for screen 2: "Quelle est votre situation ?"
|
||||||
*/
|
*/
|
||||||
export const SITUATION_CARDS: readonly SiteCardConfig[] = [
|
export const SITUATION_CARDS: readonly SiteCardConfig[] = [
|
||||||
{ id: "sit-refugie-climat", label: "Sans domicile fixe", disabled: true },
|
{ id: "sit-sans-domicile", label: "Sans domicile fixe", disabled: true },
|
||||||
{ id: "sit-refugie-guerre", label: "Réfugié.e de guerre", disabled: true },
|
{ id: "sit-refugie-guerre", label: "Réfugié.e de guerre", disabled: true },
|
||||||
{
|
{
|
||||||
id: "sit-sans-domicile",
|
id: "sit-refugie-climat",
|
||||||
label: "Réfugié.e climatique",
|
label: "Réfugié.e climatique",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const MOBILE_MEDIA_QUERY =
|
||||||
|
"(max-width: 767px), (pointer: coarse) and (hover: none)";
|
||||||
|
|
||||||
|
function subscribeToMobileQuery(callback: () => void): () => void {
|
||||||
|
const query = window.matchMedia(MOBILE_MEDIA_QUERY);
|
||||||
|
query.addEventListener("change", callback);
|
||||||
|
return () => query.removeEventListener("change", callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMobileSnapshot(): boolean {
|
||||||
|
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerMobileSnapshot(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the device is a phone or a touch-only tablet.
|
||||||
|
* Uses matchMedia so layout decisions follow CSS conventions
|
||||||
|
* and avoid resize-handler churn.
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeToMobileQuery,
|
||||||
|
getMobileSnapshot,
|
||||||
|
getServerMobileSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
||||||
|
|
||||||
|
function subscribeToReducedMotion(callback: () => void): () => void {
|
||||||
|
const query = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||||
|
query.addEventListener("change", callback);
|
||||||
|
return () => query.removeEventListener("change", callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReducedMotionSnapshot(): boolean {
|
||||||
|
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerReducedMotionSnapshot(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the user has requested reduced motion at the OS level.
|
||||||
|
* UI fades and transitions should collapse to 0ms when this is true.
|
||||||
|
*/
|
||||||
|
export function usePrefersReducedMotion(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeToReducedMotion,
|
||||||
|
getReducedMotionSnapshot,
|
||||||
|
getServerReducedMotionSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,18 @@ select {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Site onboarding — accessible focus rings (WCAG 2.4.7) */
|
||||||
|
.site-card-button:focus-visible,
|
||||||
|
.site-button:focus-visible {
|
||||||
|
outline: 3px solid #ffffff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card-button[aria-pressed="true"]:focus-visible {
|
||||||
|
outline-color: #a8d5a2;
|
||||||
|
}
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import type { SiteStep } from "@/types/game";
|
|||||||
|
|
||||||
interface SiteState {
|
interface SiteState {
|
||||||
currentStep: SiteStep;
|
currentStep: SiteStep;
|
||||||
selectedExperience: number | null;
|
selectedExperienceIndex: number | null;
|
||||||
selectedSituation: number | null;
|
selectedSituationIndex: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteActions {
|
interface SiteActions {
|
||||||
setStep: (step: SiteStep) => void;
|
setStep: (step: SiteStep) => void;
|
||||||
setSelectedExperience: (index: number) => void;
|
setSelectedExperienceIndex: (index: number) => void;
|
||||||
setSelectedSituation: (index: number) => void;
|
setSelectedSituationIndex: (index: number) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,14 +18,15 @@ type SiteStore = SiteState & SiteActions;
|
|||||||
|
|
||||||
const initialState: SiteState = {
|
const initialState: SiteState = {
|
||||||
currentStep: "disclaimer",
|
currentStep: "disclaimer",
|
||||||
selectedExperience: null,
|
selectedExperienceIndex: null,
|
||||||
selectedSituation: null,
|
selectedSituationIndex: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSiteStore = create<SiteStore>()((set) => ({
|
export const useSiteStore = create<SiteStore>()((set) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
setStep: (step) => set({ currentStep: step }),
|
setStep: (step) => set({ currentStep: step }),
|
||||||
setSelectedExperience: (index) => set({ selectedExperience: index }),
|
setSelectedExperienceIndex: (index) =>
|
||||||
setSelectedSituation: (index) => set({ selectedSituation: index }),
|
set({ selectedExperienceIndex: index }),
|
||||||
|
setSelectedSituationIndex: (index) => set({ selectedSituationIndex: index }),
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
}));
|
}));
|
||||||
|
|||||||
+1
-34
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen";
|
import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen";
|
||||||
import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
|
import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
|
||||||
@@ -7,39 +6,7 @@ import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
|
|||||||
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
|
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
|
||||||
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
||||||
import { SiteLayout } from "@/components/site/SiteLayout";
|
import { SiteLayout } from "@/components/site/SiteLayout";
|
||||||
|
import { useIsMobile } from "@/hooks/ui/useIsMobile";
|
||||||
/**
|
|
||||||
* Check if user is on mobile device
|
|
||||||
*/
|
|
||||||
function useIsMobile(): boolean {
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkMobile = (): void => {
|
|
||||||
const userAgent = navigator.userAgent.toLowerCase();
|
|
||||||
const mobileKeywords = [
|
|
||||||
"android",
|
|
||||||
"webos",
|
|
||||||
"iphone",
|
|
||||||
"ipad",
|
|
||||||
"ipod",
|
|
||||||
"blackberry",
|
|
||||||
"windows phone",
|
|
||||||
];
|
|
||||||
const isMobileDevice = mobileKeywords.some((keyword) =>
|
|
||||||
userAgent.includes(keyword),
|
|
||||||
);
|
|
||||||
const isSmallScreen = window.innerWidth < 768;
|
|
||||||
setIsMobile(isMobileDevice || isSmallScreen);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkMobile();
|
|
||||||
window.addEventListener("resize", checkMobile);
|
|
||||||
return () => window.removeEventListener("resize", checkMobile);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return isMobile;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SitePage(): React.JSX.Element {
|
export function SitePage(): React.JSX.Element {
|
||||||
const currentStep = useSiteStore((state) => state.currentStep);
|
const currentStep = useSiteStore((state) => state.currentStep);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
manifestPromise = (async () => {
|
||||||
|
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
manifestPromise = null;
|
||||||
return 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