Compare commits
4 Commits
4c5e2ed945
...
02c1fb33d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 02c1fb33d0 | |||
| ce5dc8ada0 | |||
| a2cff0567e | |||
| 8cfee1ac93 |
BIN
Binary file not shown.
@@ -38,7 +38,7 @@
|
|||||||
"id": "narrateur_intro_prenom",
|
"id": "narrateur_intro_prenom",
|
||||||
"voice": "narrateur",
|
"voice": "narrateur",
|
||||||
"audio": "/sounds/dialogue/narrateur_intro_prenom.mp3",
|
"audio": "/sounds/dialogue/narrateur_intro_prenom.mp3",
|
||||||
"subtitleCueIndex": 2
|
"subtitleCueIndices": [1, 2]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "narrateur_intro_apresprenom",
|
"id": "narrateur_intro_apresprenom",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
1
|
1
|
||||||
00:00:00,000 --> 00:00:02,760
|
00:00:00,000 --> 00:00:09,000
|
||||||
Bonjour à toi, futur habitant d'Altéra ! Aujourd'hui tu vas découvrir le rôle de technicien au sein de La Fabrik qui s'occupe des technologies et réparation Low-Tech.
|
Bonjour à toi, futur habitant d'Altéra ! Aujourd'hui tu vas découvrir le rôle de technicien au sein de La Fabrik qui s'occupe des technologies et réparation Low-Tech.
|
||||||
|
|
||||||
2
|
2
|
||||||
00:00:00,000 --> 00:00:11,592
|
00:00:09,000 --> 00:00:11,592
|
||||||
Avant de commencer, comment tu t'appelles ?
|
Avant de commencer, comment tu t'appelles ?
|
||||||
|
|
||||||
3
|
3
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
height={0.8}
|
height={0.8}
|
||||||
startPos={gpsStartPos}
|
startPos={gpsStartPos}
|
||||||
destPos={destPos}
|
destPos={destPos}
|
||||||
mapImageUrl="/assets/gps/map_background.png"
|
mapImageUrl="/assets/world/gps/map_background.png"
|
||||||
worldBounds={{
|
worldBounds={{
|
||||||
minX: -166,
|
minX: -166,
|
||||||
maxX: 163,
|
maxX: 163,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type {
|
|||||||
DialogueSpeaker,
|
DialogueSpeaker,
|
||||||
DialogueVoiceId,
|
DialogueVoiceId,
|
||||||
} from "@/types/dialogues/dialogues";
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import {
|
||||||
|
getDialogueCueIndices,
|
||||||
|
getDialogueFirstCueIndex,
|
||||||
|
} from "@/types/dialogues/dialogues";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||||
@@ -34,7 +38,7 @@ function getNextCueIndex(
|
|||||||
): number {
|
): number {
|
||||||
const cueIndexes = manifest.dialogues
|
const cueIndexes = manifest.dialogues
|
||||||
.filter((dialogue) => dialogue.voice === voice)
|
.filter((dialogue) => dialogue.voice === voice)
|
||||||
.map((dialogue) => dialogue.subtitleCueIndex);
|
.flatMap((dialogue) => getDialogueCueIndices(dialogue));
|
||||||
|
|
||||||
return Math.max(0, ...cueIndexes) + 1;
|
return Math.max(0, ...cueIndexes) + 1;
|
||||||
}
|
}
|
||||||
@@ -93,12 +97,15 @@ async function createFrenchSrtCue(
|
|||||||
manifest: DialogueManifest,
|
manifest: DialogueManifest,
|
||||||
dialogue: DialogueDefinition,
|
dialogue: DialogueDefinition,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const firstCueIndex = getDialogueFirstCueIndex(dialogue);
|
||||||
|
if (firstCueIndex === undefined) return;
|
||||||
|
|
||||||
const srtPath = getFrenchSrtPath(dialogue.voice);
|
const srtPath = getFrenchSrtPath(dialogue.voice);
|
||||||
const response = await fetch(srtPath);
|
const response = await fetch(srtPath);
|
||||||
const content = response.ok ? await response.text() : "";
|
const content = response.ok ? await response.text() : "";
|
||||||
const nextContent = appendSrtCueIfMissing(
|
const nextContent = appendSrtCueIfMissing(
|
||||||
content,
|
content,
|
||||||
dialogue.subtitleCueIndex,
|
firstCueIndex,
|
||||||
getVoiceSpeaker(manifest, dialogue.voice),
|
getVoiceSpeaker(manifest, dialogue.voice),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,7 +129,8 @@ function getManifestErrors(manifest: DialogueManifest | null): string[] {
|
|||||||
errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`);
|
errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isInteger(dialogue.subtitleCueIndex)) {
|
const cueIndices = getDialogueCueIndices(dialogue);
|
||||||
|
if (cueIndices.length === 0) {
|
||||||
errors.push(`${label}: cue SRT invalide.`);
|
errors.push(`${label}: cue SRT invalide.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +168,18 @@ function getPatchedDialogue(
|
|||||||
id: patch.id ?? dialogue.id,
|
id: patch.id ?? dialogue.id,
|
||||||
voice: patch.voice ?? dialogue.voice,
|
voice: patch.voice ?? dialogue.voice,
|
||||||
audio: patch.audio ?? dialogue.audio,
|
audio: patch.audio ?? dialogue.audio,
|
||||||
subtitleCueIndex: patch.subtitleCueIndex ?? dialogue.subtitleCueIndex,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (patch.subtitleCueIndex !== undefined) {
|
||||||
|
nextDialogue.subtitleCueIndex = patch.subtitleCueIndex;
|
||||||
|
} else if (dialogue.subtitleCueIndex !== undefined) {
|
||||||
|
nextDialogue.subtitleCueIndex = dialogue.subtitleCueIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogue.subtitleCueIndices !== undefined) {
|
||||||
|
nextDialogue.subtitleCueIndices = dialogue.subtitleCueIndices;
|
||||||
|
}
|
||||||
|
|
||||||
if ("timecode" in patch) {
|
if ("timecode" in patch) {
|
||||||
if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode;
|
if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode;
|
||||||
} else if (dialogue.timecode !== undefined) {
|
} else if (dialogue.timecode !== undefined) {
|
||||||
@@ -252,8 +269,9 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await createFrenchSrtCue(nextManifest, dialogue);
|
await createFrenchSrtCue(nextManifest, dialogue);
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(dialogue) ?? "?";
|
||||||
setStatus(
|
setStatus(
|
||||||
`Nouveau dialogue ajoute avec cue FR ${dialogue.subtitleCueIndex}. Sauvegarde le manifeste pour le garder.`,
|
`Nouveau dialogue ajoute avec cue FR ${cueIndex}. Sauvegarde le manifeste pour le garder.`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
@@ -333,12 +351,13 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
async function handleCreateFrenchSrtCue(): Promise<void> {
|
async function handleCreateFrenchSrtCue(): Promise<void> {
|
||||||
if (!manifest || !selectedDialogue) return;
|
if (!manifest || !selectedDialogue) return;
|
||||||
|
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue) ?? "?";
|
||||||
setIsCreatingSrtCue(true);
|
setIsCreatingSrtCue(true);
|
||||||
setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`);
|
setStatus(`Creation de la cue FR ${cueIndex}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createFrenchSrtCue(manifest, selectedDialogue);
|
await createFrenchSrtCue(manifest, selectedDialogue);
|
||||||
setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`);
|
setStatus(`Cue FR ${cueIndex} prete.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
setStatus(message);
|
setStatus(message);
|
||||||
@@ -478,7 +497,7 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
step="1"
|
step="1"
|
||||||
value={selectedDialogue.subtitleCueIndex}
|
value={getDialogueFirstCueIndex(selectedDialogue) ?? ""}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedDialogue({
|
updateSelectedDialogue({
|
||||||
subtitleCueIndex: Math.max(1, Number(event.target.value)),
|
subtitleCueIndex: Math.max(1, Number(event.target.value)),
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type {
|
|||||||
DialogueSpeaker,
|
DialogueSpeaker,
|
||||||
DialogueVoiceId,
|
DialogueVoiceId,
|
||||||
} from "@/types/dialogues/dialogues";
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import {
|
||||||
|
getDialogueCueIndices,
|
||||||
|
getDialogueFirstCueIndex,
|
||||||
|
} from "@/types/dialogues/dialogues";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
import {
|
import {
|
||||||
@@ -181,7 +185,7 @@ function getExpectedCueIndexes(
|
|||||||
voice: DialogueVoiceId,
|
voice: DialogueVoiceId,
|
||||||
): number[] {
|
): number[] {
|
||||||
return getExpectedDialogues(manifest, voice)
|
return getExpectedDialogues(manifest, voice)
|
||||||
.map((dialogue) => dialogue.subtitleCueIndex)
|
.flatMap((dialogue) => getDialogueCueIndices(dialogue))
|
||||||
.filter(
|
.filter(
|
||||||
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
|
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
|
||||||
)
|
)
|
||||||
@@ -196,7 +200,11 @@ function getExpectedDialogues(
|
|||||||
|
|
||||||
return [...manifest.dialogues]
|
return [...manifest.dialogues]
|
||||||
.filter((dialogue) => dialogue.voice === voice)
|
.filter((dialogue) => dialogue.voice === voice)
|
||||||
.sort((a, b) => a.subtitleCueIndex - b.subtitleCueIndex);
|
.sort((a, b) => {
|
||||||
|
const aIndex = getDialogueFirstCueIndex(a) ?? 0;
|
||||||
|
const bIndex = getDialogueFirstCueIndex(b) ?? 0;
|
||||||
|
return aIndex - bIndex;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCueBlockRange(
|
function findCueBlockRange(
|
||||||
@@ -577,7 +585,7 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
{expectedDialogues.map((dialogue) => (
|
{expectedDialogues.map((dialogue) => (
|
||||||
<option key={dialogue.id} value={dialogue.id}>
|
<option key={dialogue.id} value={dialogue.id}>
|
||||||
Cue {dialogue.subtitleCueIndex} - {dialogue.id}
|
Cue {getDialogueFirstCueIndex(dialogue) ?? "?"} - {dialogue.id}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -585,7 +593,7 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
|
|
||||||
{selectedDialogue && (
|
{selectedDialogue && (
|
||||||
<div className="editor-srt-audio-card">
|
<div className="editor-srt-audio-card">
|
||||||
<span>Cue {selectedDialogue.subtitleCueIndex}</span>
|
<span>Cue {getDialogueFirstCueIndex(selectedDialogue) ?? "?"}</span>
|
||||||
<strong>{selectedDialogue.id}</strong>
|
<strong>{selectedDialogue.id}</strong>
|
||||||
<audio
|
<audio
|
||||||
key={selectedDialogue.audio}
|
key={selectedDialogue.audio}
|
||||||
@@ -609,39 +617,52 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
<div className="editor-srt-time-actions">
|
<div className="editor-srt-time-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
disabled={
|
||||||
handleSetCueTime(selectedDialogue.subtitleCueIndex, "start")
|
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||||
}
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||||
|
if (cueIndex !== undefined)
|
||||||
|
handleSetCueTime(cueIndex, "start");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Set start
|
Set start
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
disabled={
|
||||||
handleSetCueTime(selectedDialogue.subtitleCueIndex, "end")
|
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||||
}
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||||
|
if (cueIndex !== undefined) handleSetCueTime(cueIndex, "end");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Set end
|
Set end
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
disabled={
|
||||||
handleNudgeCue(
|
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||||
selectedDialogue.subtitleCueIndex,
|
|
||||||
-CUE_NUDGE_SECONDS,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||||
|
if (cueIndex !== undefined)
|
||||||
|
handleNudgeCue(cueIndex, -CUE_NUDGE_SECONDS);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
-100ms
|
-100ms
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
disabled={
|
||||||
handleNudgeCue(
|
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||||
selectedDialogue.subtitleCueIndex,
|
|
||||||
CUE_NUDGE_SECONDS,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||||
|
if (cueIndex !== undefined)
|
||||||
|
handleNudgeCue(cueIndex, CUE_NUDGE_SECONDS);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
+100ms
|
+100ms
|
||||||
</button>
|
</button>
|
||||||
@@ -649,9 +670,15 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
<button
|
<button
|
||||||
className="editor-srt-jump-button"
|
className="editor-srt-jump-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleJumpToCue(selectedDialogue.subtitleCueIndex)}
|
disabled={
|
||||||
|
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||||
|
if (cueIndex !== undefined) handleJumpToCue(cueIndex);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Aller a la cue {selectedDialogue.subtitleCueIndex}
|
Aller a la cue {getDialogueFirstCueIndex(selectedDialogue) ?? "?"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface SiteButtonProps {
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteButton({
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
onClick,
|
||||||
|
}: SiteButtonProps): React.JSX.Element {
|
||||||
|
const [isPressed, setIsPressed] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseDown={() => setIsPressed(true)}
|
||||||
|
onMouseUp={() => setIsPressed(false)}
|
||||||
|
onMouseLeave={() => setIsPressed(false)}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
padding: "12px 20px",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
background: disabled ? "#b0b0b0" : "#FFF",
|
||||||
|
boxShadow: disabled
|
||||||
|
? "none"
|
||||||
|
: isPressed
|
||||||
|
? "0 4px 10px 0 rgba(0, 0, 0, 0.35)"
|
||||||
|
: "0 7px 14.4px 0 rgba(0, 0, 0, 0.25)",
|
||||||
|
border: "none",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
color: disabled ? "#888888" : "#000",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(18px, 3vw, 26px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.3px",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
transition: "box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { SiteCardConfig } from "@/data/site/siteConfig";
|
||||||
|
|
||||||
|
interface SiteCardProps {
|
||||||
|
config: SiteCardConfig;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteCard({
|
||||||
|
config,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: SiteCardProps): React.JSX.Element {
|
||||||
|
const { label, imagePath, disabled } = config;
|
||||||
|
|
||||||
|
const getBackground = (): string => {
|
||||||
|
if (imagePath) return `url(${imagePath}) center/cover`;
|
||||||
|
if (disabled) return "#b8b8b8";
|
||||||
|
if (selected) return "#d9d9d9";
|
||||||
|
return "#e8e8e8";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBorder = (): string => {
|
||||||
|
if (selected) return "3px solid #a8d5a2";
|
||||||
|
if (disabled) return "none";
|
||||||
|
return "2px solid #ffffff";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColor = (): string => {
|
||||||
|
if (disabled) return "#888888";
|
||||||
|
return "#666666";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
width: "clamp(120px, 15vw, 160px)",
|
||||||
|
height: "clamp(140px, 18vw, 180px)",
|
||||||
|
border: getBorder(),
|
||||||
|
background: getBackground(),
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
outline: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!imagePath && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: getTextColor(),
|
||||||
|
fontSize: "clamp(10px, 1.5vw, 14px)",
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "center",
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
|
||||||
|
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.";
|
||||||
|
|
||||||
|
const TEXT_DISPLAY_DURATION = 5000;
|
||||||
|
const FADE_OUT_DURATION = 1000;
|
||||||
|
const TRANSITION_DELAY = 250;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen 0: Disclaimer
|
||||||
|
*/
|
||||||
|
export function SiteDisclaimerScreen(): React.JSX.Element {
|
||||||
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
const [textOpacity, setTextOpacity] = useState(0);
|
||||||
|
const hasSkipped = useRef(false);
|
||||||
|
|
||||||
|
const handleSkip = useCallback(() => {
|
||||||
|
if (hasSkipped.current) return;
|
||||||
|
hasSkipped.current = true;
|
||||||
|
setStep("welcome");
|
||||||
|
}, [setStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fade in text
|
||||||
|
const fadeInTimeout = window.setTimeout(() => {
|
||||||
|
setTextOpacity(1);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Start fade out after display duration
|
||||||
|
const fadeOutTimeout = window.setTimeout(() => {
|
||||||
|
setTextOpacity(0);
|
||||||
|
}, TEXT_DISPLAY_DURATION);
|
||||||
|
|
||||||
|
// Transition to welcome after fade out + delay
|
||||||
|
const transitionTimeout = window.setTimeout(
|
||||||
|
() => {
|
||||||
|
handleSkip();
|
||||||
|
},
|
||||||
|
TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(fadeInTimeout);
|
||||||
|
window.clearTimeout(fadeOutTimeout);
|
||||||
|
window.clearTimeout(transitionTimeout);
|
||||||
|
};
|
||||||
|
}, [handleSkip]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleSkip}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 48,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxWidth: 800,
|
||||||
|
opacity: textOpacity,
|
||||||
|
transition: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DISCLAIMER_TEXT}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { SITE_CONFIG } from "@/data/site/siteConfig";
|
||||||
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
|
|
||||||
|
interface SiteLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteLayout({ children }: SiteLayoutProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#87CEEB",
|
||||||
|
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
color: "#fff",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Subtitles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { SITE_CONFIG } 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
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="public/assets/logo/logo.jpg"
|
||||||
|
alt="Logo"
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: "auto",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 4px 10px rgba(0, 0, 0, 0.4)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxWidth: 320,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MOBILE_TEXT}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
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 { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen 3: Name input
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Play dialogue when screen appears (with subtitles)
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialogueStarted.current) return;
|
||||||
|
dialogueStarted.current = true;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const manifest = await loadDialogueManifest();
|
||||||
|
if (manifest) {
|
||||||
|
await playDialogueById(manifest, "narrateur_intro_prenom");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Focus input on mount
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Only process if not complete and it's a letter key
|
||||||
|
if (!isComplete && e.key.length === 1 && /[a-zA-Z]/.test(e.key)) {
|
||||||
|
setCharIndex((prev) => Math.min(prev + 1, forcedName.length));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isComplete, forcedName.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirm = (): void => {
|
||||||
|
if (isComplete) {
|
||||||
|
setPlayerName(forcedName);
|
||||||
|
setStep("transition");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 80,
|
||||||
|
padding: 24,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 950,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(18px, 3vw, 26px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.3px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quel est votre prénom ?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={displayValue}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
readOnly
|
||||||
|
placeholder="Écrivez votre prénom ici"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
padding: "clamp(8px, 1.5vw, 10px)",
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 800,
|
||||||
|
minWidth: 280,
|
||||||
|
gap: 10,
|
||||||
|
border: "4px solid #FFF",
|
||||||
|
background: "#D9D9D9",
|
||||||
|
outline: "none",
|
||||||
|
color: "#333",
|
||||||
|
caretColor: "transparent",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(16px, 2.5vw, 20px)",
|
||||||
|
textAlign: "left",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteButton
|
||||||
|
label="CONFIRMER"
|
||||||
|
disabled={!isComplete}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { SiteCard } from "@/components/site/SiteCard";
|
||||||
|
import { SiteButton } from "@/components/site/SiteButton";
|
||||||
|
import { SITUATION_CARDS } from "@/data/site/siteConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen 2: Situation selection
|
||||||
|
*/
|
||||||
|
export function SiteSituationScreen(): React.JSX.Element {
|
||||||
|
const selectedSituation = useSiteStore((state) => state.selectedSituation);
|
||||||
|
const setSelectedSituation = useSiteStore(
|
||||||
|
(state) => state.setSelectedSituation,
|
||||||
|
);
|
||||||
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
|
||||||
|
const canProceed = selectedSituation !== null;
|
||||||
|
|
||||||
|
const handleConfirm = (): void => {
|
||||||
|
if (canProceed) {
|
||||||
|
setStep("naming");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 40,
|
||||||
|
padding: 24,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 1208,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(20px, 4vw, 32px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.6px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quelle est votre situation ?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SITUATION_CARDS.map((card, index) => (
|
||||||
|
<SiteCard
|
||||||
|
key={card.id}
|
||||||
|
config={card}
|
||||||
|
selected={selectedSituation === index}
|
||||||
|
onSelect={() => {
|
||||||
|
if (!card.disabled) {
|
||||||
|
setSelectedSituation(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteButton
|
||||||
|
label="CONFIRMER"
|
||||||
|
disabled={!canProceed}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useRef, 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";
|
||||||
|
|
||||||
|
const FADE_DURATION_MS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition overlay: black screen (fade in) + logo (fade in/out) + dialogue with subtitles + redirect to /
|
||||||
|
*/
|
||||||
|
export function SiteTransitionOverlay(): React.JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const reset = useSiteStore((state) => state.reset);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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 (dialogueAudio) {
|
||||||
|
dialogueAudio.addEventListener(
|
||||||
|
"ended",
|
||||||
|
() => {
|
||||||
|
// Fade out logo
|
||||||
|
setLogoOpacity(0);
|
||||||
|
// Redirect after logo fade out
|
||||||
|
setTimeout(() => {
|
||||||
|
reset();
|
||||||
|
navigate({ to: "/" });
|
||||||
|
}, FADE_DURATION_MS);
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: redirect after 3s if dialogue fails
|
||||||
|
setTimeout(() => {
|
||||||
|
setLogoOpacity(0);
|
||||||
|
setTimeout(() => {
|
||||||
|
reset();
|
||||||
|
navigate({ to: "/" });
|
||||||
|
}, FADE_DURATION_MS);
|
||||||
|
}, 3000);
|
||||||
|
})();
|
||||||
|
}, [navigate, reset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
zIndex: 0,
|
||||||
|
opacity: screenOpacity,
|
||||||
|
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="/assets/logo/logo.jpg"
|
||||||
|
alt="Logo"
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
width: "min(300px, 45vw)",
|
||||||
|
height: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
opacity: logoOpacity,
|
||||||
|
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`,
|
||||||
|
transitionDelay: logoOpacity === 1 ? `${FADE_DURATION_MS}ms` : "0ms",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Subtitles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { SiteCard } from "@/components/site/SiteCard";
|
||||||
|
import { SiteButton } from "@/components/site/SiteButton";
|
||||||
|
import { EXPERIENCE_CARDS } from "@/data/site/siteConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen 1: Welcome
|
||||||
|
*/
|
||||||
|
export function SiteWelcomeScreen(): React.JSX.Element {
|
||||||
|
const selectedExperience = useSiteStore((state) => state.selectedExperience);
|
||||||
|
const setSelectedExperience = useSiteStore(
|
||||||
|
(state) => state.setSelectedExperience,
|
||||||
|
);
|
||||||
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
|
||||||
|
const canProceed = selectedExperience !== null;
|
||||||
|
|
||||||
|
const handleNext = (): void => {
|
||||||
|
if (canProceed) {
|
||||||
|
setStep("situation");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 40,
|
||||||
|
padding: 24,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 1208,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: '"Nersans One", system-ui, sans-serif',
|
||||||
|
fontSize: "clamp(40px, 8vw, 64px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-3px",
|
||||||
|
margin: 0,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
BIENVENUE A ALTERA
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(18px, 3vw, 26px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.3px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Communauté convivialiste
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(20px, 4vw, 32px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.6px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Choisissez une expérience :
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{EXPERIENCE_CARDS.map((card, index) => (
|
||||||
|
<SiteCard
|
||||||
|
key={card.id}
|
||||||
|
config={card}
|
||||||
|
selected={selectedExperience === index}
|
||||||
|
onSelect={() => {
|
||||||
|
if (!card.disabled) {
|
||||||
|
setSelectedExperience(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteButton label="SUIVANT" disabled={!canProceed} onClick={handleNext} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
|
||||||
|
const INTRO_DIALOGUE_PATH = "/sounds/dialogue/narrateur_ordreebike.mp3";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Black screen overlay with dialogue audio
|
||||||
|
* - Plays narrateur_ordreebike.mp3
|
||||||
|
* - Transitions to reveal step when dialogue ends
|
||||||
|
*/
|
||||||
|
export function IntroDialogueOverlay(): React.JSX.Element {
|
||||||
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
|
const dialogueStarted = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialogueStarted.current) return;
|
||||||
|
dialogueStarted.current = true;
|
||||||
|
|
||||||
|
// Play dialogue then transition to reveal
|
||||||
|
const audio = AudioManager.getInstance();
|
||||||
|
audio.playSoundWithCallback(INTRO_DIALOGUE_PATH, 0.8, () => {
|
||||||
|
setIntroStep("reveal");
|
||||||
|
});
|
||||||
|
}, [setIntroStep]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
zIndex: 999,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
export function IntroRevealOverlay(): React.JSX.Element {
|
||||||
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
|
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||||
|
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);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(fadeTimeout);
|
||||||
|
window.clearTimeout(completeTimeout);
|
||||||
|
};
|
||||||
|
}, [setIntroStep, completeIntro]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
opacity,
|
||||||
|
transition: `opacity ${REVEAL_DURATION_MS}ms ease-out`,
|
||||||
|
zIndex: 998,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useCallback, useRef, useEffect } from "react";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
|
||||||
|
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen video player for intro cinematic
|
||||||
|
* - Plays intro.mp4 in fullscreen
|
||||||
|
* - Automatically advances to dialogue-intro step when video ends
|
||||||
|
* - Allows skipping with Enter/Space/Click
|
||||||
|
*/
|
||||||
|
export function IntroVideoPlayer(): React.JSX.Element {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
|
|
||||||
|
const handleVideoEnd = useCallback(() => {
|
||||||
|
setIntroStep("dialogue-intro");
|
||||||
|
}, [setIntroStep]);
|
||||||
|
|
||||||
|
const handleSkip = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
|
setIntroStep("dialogue-intro");
|
||||||
|
}, [setIntroStep]);
|
||||||
|
|
||||||
|
// Handle keyboard skip (Enter/Space)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSkip();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [handleSkip]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleSkip}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
zIndex: 1000,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={INTRO_VIDEO_PATH}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
onEnded={handleVideoEnd}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 32,
|
||||||
|
right: 32,
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Appuyez pour passer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { IntroVideoPlayer } from "./IntroVideoPlayer";
|
||||||
|
export { IntroDialogueOverlay } from "./IntroDialogueOverlay";
|
||||||
|
export { IntroRevealOverlay } from "./IntroRevealOverlay";
|
||||||
@@ -4,8 +4,8 @@ import type {
|
|||||||
RepairMissionId,
|
RepairMissionId,
|
||||||
} from "@/types/gameplay/repairMission";
|
} from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm";
|
const REPAIR_INTERACT_UI_PATH = "/assets/world/UI/interagir.webm";
|
||||||
const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm";
|
const REPAIR_BROKEN_UI_PATH = "/assets/world/UI/cassé.webm";
|
||||||
|
|
||||||
const DEFAULT_REPAIR_CASE = {
|
const DEFAULT_REPAIR_CASE = {
|
||||||
position: [0, 0.4, 1.8],
|
position: [0, 0.4, 1.8],
|
||||||
@@ -21,7 +21,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
"Repair the damaged cooling module before relaunching the bike",
|
"Repair the damaged cooling module before relaunching the bike",
|
||||||
modelPath: "/models/ebike/model.gltf",
|
modelPath: "/models/ebike/model.gltf",
|
||||||
modelScale: 0.3,
|
modelScale: 0.3,
|
||||||
stageUiPath: "/assets/UI/ebike.webm",
|
stageUiPath: "/assets/world/UI/ebike.webm",
|
||||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
description:
|
description:
|
||||||
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||||
modelPath: "/models/pylone/model.gltf",
|
modelPath: "/models/pylone/model.gltf",
|
||||||
stageUiPath: "/assets/UI/centrale.webm",
|
stageUiPath: "/assets/world/UI/centrale.webm",
|
||||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
description:
|
description:
|
||||||
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
||||||
modelPath: "/models/fermeverticale/model.gltf",
|
modelPath: "/models/fermeverticale/model.gltf",
|
||||||
stageUiPath: "/assets/UI/laferme.webm",
|
stageUiPath: "/assets/world/UI/laferme.webm",
|
||||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const SITE_CONFIG = {
|
||||||
|
backgroundImage: "/assets/bg-site.png",
|
||||||
|
forcedName: "Danyl",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface SiteCardConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
imagePath?: string;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for screen 1: "Choisissez une expérience"
|
||||||
|
*/
|
||||||
|
export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [
|
||||||
|
{ id: "exp-fabrik", label: "La Fabrik", disabled: false },
|
||||||
|
{ id: "exp-ferme", label: "La Ferme verticale", disabled: true },
|
||||||
|
{ id: "exp-energie", label: "La Zone d'énergie", disabled: true },
|
||||||
|
{ id: "exp-ecole", label: "L'École", disabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for screen 2: "Quelle est votre situation ?"
|
||||||
|
*/
|
||||||
|
export const SITUATION_CARDS: readonly SiteCardConfig[] = [
|
||||||
|
{ id: "sit-habitants", label: "Habitants d'Altera", disabled: true },
|
||||||
|
{ id: "sit-apprentis", label: "Apprentis-Citoyens", disabled: true },
|
||||||
|
{
|
||||||
|
id: "sit-refugies",
|
||||||
|
label: "Réfugiés Climatiques arrivants",
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{ id: "sit-seniors", label: "Seniors Hyper-Connectés", disabled: true },
|
||||||
|
];
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { SiteStep } from "@/types/game";
|
||||||
|
|
||||||
|
interface SiteState {
|
||||||
|
currentStep: SiteStep;
|
||||||
|
selectedExperience: number | null;
|
||||||
|
selectedSituation: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteActions {
|
||||||
|
setStep: (step: SiteStep) => void;
|
||||||
|
setSelectedExperience: (index: number) => void;
|
||||||
|
setSelectedSituation: (index: number) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteStore = SiteState & SiteActions;
|
||||||
|
|
||||||
|
const initialState: SiteState = {
|
||||||
|
currentStep: "disclaimer",
|
||||||
|
selectedExperience: null,
|
||||||
|
selectedSituation: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSiteStore = create<SiteStore>()((set) => ({
|
||||||
|
...initialState,
|
||||||
|
setStep: (step) => set({ currentStep: step }),
|
||||||
|
setSelectedExperience: (index) => set({ selectedExperience: index }),
|
||||||
|
setSelectedSituation: (index) => set({ selectedSituation: index }),
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}));
|
||||||
@@ -157,7 +157,7 @@ function CameraManager({
|
|||||||
const dataUrl = gl.domElement.toDataURL("image/png");
|
const dataUrl = gl.domElement.toDataURL("image/png");
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = dataUrl;
|
a.href = dataUrl;
|
||||||
a.download = "/assets/gps/map_background.png";
|
a.download = "map_background.png";
|
||||||
a.click();
|
a.click();
|
||||||
};
|
};
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
+40
-1
@@ -1,18 +1,35 @@
|
|||||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||||
import { DialogMessage } from "@/components/ui/DialogMessage";
|
import { DialogMessage } from "@/components/ui/DialogMessage";
|
||||||
import { GameUI } from "@/components/ui/GameUI";
|
import { GameUI } from "@/components/ui/GameUI";
|
||||||
|
import {
|
||||||
|
IntroDialogueOverlay,
|
||||||
|
IntroRevealOverlay,
|
||||||
|
IntroVideoPlayer,
|
||||||
|
} from "@/components/ui/intro";
|
||||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||||
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||||
|
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { World } from "@/world/World";
|
import { World } from "@/world/World";
|
||||||
|
|
||||||
export function HomePage(): React.JSX.Element {
|
export function HomePage(): React.JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||||
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasSiteBeenVisitedToday()) {
|
||||||
|
navigate({ to: "/site", replace: true });
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
const dialogMessage = useGameStore(
|
const dialogMessage = useGameStore(
|
||||||
(state) => state.missionFlow.dialogMessage,
|
(state) => state.missionFlow.dialogMessage,
|
||||||
);
|
);
|
||||||
@@ -49,6 +66,12 @@ export function HomePage(): React.JSX.Element {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introStep === "loading-map" && sceneLoadingState.status === "ready") {
|
||||||
|
setIntroStep("video");
|
||||||
|
}
|
||||||
|
}, [introStep, sceneLoadingState.status, setIntroStep]);
|
||||||
|
|
||||||
const handleCanvasCreated = useCallback(
|
const handleCanvasCreated = useCallback(
|
||||||
({ gl }: { gl: THREE.WebGLRenderer }) => {
|
({ gl }: { gl: THREE.WebGLRenderer }) => {
|
||||||
const canvas = gl.domElement;
|
const canvas = gl.domElement;
|
||||||
@@ -75,6 +98,19 @@ export function HomePage(): React.JSX.Element {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderIntroOverlay = () => {
|
||||||
|
switch (introStep) {
|
||||||
|
case "video":
|
||||||
|
return <IntroVideoPlayer />;
|
||||||
|
case "dialogue-intro":
|
||||||
|
return <IntroDialogueOverlay />;
|
||||||
|
case "reveal":
|
||||||
|
return <IntroRevealOverlay />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HandTrackingProvider>
|
<HandTrackingProvider>
|
||||||
<Canvas
|
<Canvas
|
||||||
@@ -100,7 +136,10 @@ export function HomePage(): React.JSX.Element {
|
|||||||
onClose={hideDialog}
|
onClose={hideDialog}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<SceneLoadingOverlay state={sceneLoadingState} />
|
{introStep === "loading-map" && (
|
||||||
|
<SceneLoadingOverlay state={sceneLoadingState} />
|
||||||
|
)}
|
||||||
|
{renderIntroOverlay()}
|
||||||
</HandTrackingProvider>
|
</HandTrackingProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen";
|
||||||
|
import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
|
||||||
|
import { SiteSituationScreen } from "@/components/site/SiteSituationScreen";
|
||||||
|
import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
|
||||||
|
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
|
||||||
|
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
||||||
|
import { SiteLayout } from "@/components/site/SiteLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
const currentStep = useSiteStore((state) => state.currentStep);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return <SiteMobileBlocker />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === "disclaimer") {
|
||||||
|
return <SiteDisclaimerScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteLayout>
|
||||||
|
{currentStep === "welcome" && <SiteWelcomeScreen />}
|
||||||
|
{currentStep === "situation" && <SiteSituationScreen />}
|
||||||
|
{currentStep === "naming" && <SiteNamingScreen />}
|
||||||
|
{currentStep === "transition" && <SiteTransitionOverlay />}
|
||||||
|
</SiteLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createRouter,
|
createRouter,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { HomePage } from "@/pages/page";
|
import { HomePage } from "@/pages/page";
|
||||||
|
import { SitePage } from "@/pages/site/page";
|
||||||
import { EditorPage } from "@/pages/editor/page";
|
import { EditorPage } from "@/pages/editor/page";
|
||||||
import { GalleryPage } from "@/pages/gallery/page";
|
import { GalleryPage } from "@/pages/gallery/page";
|
||||||
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
||||||
@@ -42,6 +43,12 @@ const indexRoute = createRoute({
|
|||||||
component: HomePage,
|
component: HomePage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/site",
|
||||||
|
component: SitePage,
|
||||||
|
});
|
||||||
|
|
||||||
const editorRoute = createRoute({
|
const editorRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/editor",
|
path: "/editor",
|
||||||
@@ -102,6 +109,7 @@ const docsChildRoutes = [
|
|||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
|
siteRoute,
|
||||||
editorRoute,
|
editorRoute,
|
||||||
galleryRoute,
|
galleryRoute,
|
||||||
waypointRoute,
|
waypointRoute,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export interface DialogueDefinition {
|
|||||||
id: string;
|
id: string;
|
||||||
voice: DialogueVoiceId;
|
voice: DialogueVoiceId;
|
||||||
audio: string;
|
audio: string;
|
||||||
subtitleCueIndex: number;
|
subtitleCueIndex?: number;
|
||||||
|
subtitleCueIndices?: number[];
|
||||||
timecode?: number;
|
timecode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,3 +23,20 @@ export interface DialogueManifest {
|
|||||||
voices: DialogueVoice[];
|
voices: DialogueVoice[];
|
||||||
dialogues: DialogueDefinition[];
|
dialogues: DialogueDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDialogueCueIndices(dialogue: DialogueDefinition): number[] {
|
||||||
|
if (dialogue.subtitleCueIndices && dialogue.subtitleCueIndices.length > 0) {
|
||||||
|
return dialogue.subtitleCueIndices;
|
||||||
|
}
|
||||||
|
if (dialogue.subtitleCueIndex !== undefined) {
|
||||||
|
return [dialogue.subtitleCueIndex];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDialogueFirstCueIndex(
|
||||||
|
dialogue: DialogueDefinition,
|
||||||
|
): number | undefined {
|
||||||
|
const indices = getDialogueCueIndices(dialogue);
|
||||||
|
return indices[0];
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
|||||||
* Steps for the /site onboarding page
|
* Steps for the /site onboarding page
|
||||||
*/
|
*/
|
||||||
export type SiteStep =
|
export type SiteStep =
|
||||||
|
| "disclaimer" // Écran 0: Avertissement (ordi recommandé, bonne connexion)
|
||||||
| "welcome" // Écran 1: Bienvenue à Altera
|
| "welcome" // Écran 1: Bienvenue à Altera
|
||||||
| "situation" // Écran 2: Quelle est votre situation
|
| "situation" // Écran 2: Quelle est votre situation
|
||||||
| "naming" // Écran 3: Quel est votre prénom (Danyl)
|
| "naming" // Écran 3: Quel est votre prénom (Danyl)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const COOKIE_NAME = "siteVisited";
|
||||||
|
const EXPIRY_HOURS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the site has been visited today (within 24 hours)
|
||||||
|
*/
|
||||||
|
export function hasSiteBeenVisitedToday(): boolean {
|
||||||
|
const cookies = document.cookie.split(";");
|
||||||
|
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
const [name, value] = cookie.trim().split("=");
|
||||||
|
if (name === COOKIE_NAME && value === "true") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the site visited cookie with 24-hour expiration
|
||||||
|
*/
|
||||||
|
export function setSiteVisited(): void {
|
||||||
|
const expiryDate = new Date();
|
||||||
|
expiryDate.setTime(expiryDate.getTime() + EXPIRY_HOURS * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
document.cookie = `${COOKIE_NAME}=true; expires=${expiryDate.toUTCString()}; path=/; SameSite=Strict`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the site visited cookie (useful for debugging)
|
||||||
|
*/
|
||||||
|
export function clearSiteVisited(): void {
|
||||||
|
document.cookie = `${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
|
}
|
||||||
@@ -93,13 +93,26 @@ function parseDialogueDefinition(
|
|||||||
throw new Error(`Dialogue ${data.id} has an invalid audio path`);
|
throw new Error(`Dialogue ${data.id} has an invalid audio path`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Support both subtitleCueIndex (legacy) and subtitleCueIndices (new)
|
||||||
const subtitleCueIndex = data.subtitleCueIndex;
|
const subtitleCueIndex = data.subtitleCueIndex;
|
||||||
if (
|
const subtitleCueIndices = data.subtitleCueIndices;
|
||||||
typeof subtitleCueIndex !== "number" ||
|
|
||||||
!Number.isInteger(subtitleCueIndex) ||
|
const hasLegacyIndex =
|
||||||
subtitleCueIndex < 1
|
typeof subtitleCueIndex === "number" &&
|
||||||
) {
|
Number.isInteger(subtitleCueIndex) &&
|
||||||
throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`);
|
subtitleCueIndex >= 1;
|
||||||
|
|
||||||
|
const hasNewIndices =
|
||||||
|
Array.isArray(subtitleCueIndices) &&
|
||||||
|
subtitleCueIndices.length > 0 &&
|
||||||
|
subtitleCueIndices.every(
|
||||||
|
(idx) => typeof idx === "number" && Number.isInteger(idx) && idx >= 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasLegacyIndex && !hasNewIndices) {
|
||||||
|
throw new Error(
|
||||||
|
`Dialogue ${data.id} must have subtitleCueIndex or subtitleCueIndices`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timecode = data.timecode;
|
const timecode = data.timecode;
|
||||||
@@ -111,9 +124,14 @@ function parseDialogueDefinition(
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
voice: data.voice,
|
voice: data.voice,
|
||||||
audio: data.audio,
|
audio: data.audio,
|
||||||
subtitleCueIndex,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (hasNewIndices) {
|
||||||
|
dialogue.subtitleCueIndices = subtitleCueIndices as number[];
|
||||||
|
} else if (hasLegacyIndex) {
|
||||||
|
dialogue.subtitleCueIndex = subtitleCueIndex;
|
||||||
|
}
|
||||||
|
|
||||||
if (timecode !== undefined) dialogue.timecode = timecode;
|
if (timecode !== undefined) dialogue.timecode = timecode;
|
||||||
|
|
||||||
return dialogue;
|
return dialogue;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
DialogueManifest,
|
DialogueManifest,
|
||||||
DialogueVoice,
|
DialogueVoice,
|
||||||
} from "@/types/dialogues/dialogues";
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import { getDialogueCueIndices } from "@/types/dialogues/dialogues";
|
||||||
import type { SubtitleLanguage } from "@/types/settings/settings";
|
import type { SubtitleLanguage } from "@/types/settings/settings";
|
||||||
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
|
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
|
||||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||||
@@ -17,6 +18,15 @@ export interface DialogueSubtitleCue {
|
|||||||
subtitlePath: string;
|
subtitlePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiple subtitle cues for a single dialogue
|
||||||
|
*/
|
||||||
|
export interface DialogueSubtitleCues {
|
||||||
|
voice: DialogueVoice;
|
||||||
|
cues: SubtitleCue[];
|
||||||
|
subtitlePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
|
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
|
||||||
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
||||||
|
|
||||||
@@ -39,21 +49,40 @@ export async function loadDialogueSubtitleCue(
|
|||||||
dialogue: DialogueDefinition,
|
dialogue: DialogueDefinition,
|
||||||
language: SubtitleLanguage,
|
language: SubtitleLanguage,
|
||||||
): Promise<DialogueSubtitleCue | null> {
|
): Promise<DialogueSubtitleCue | null> {
|
||||||
|
const result = await loadDialogueSubtitleCues(manifest, dialogue, language);
|
||||||
|
const firstCue = result?.cues[0];
|
||||||
|
if (!result || !firstCue) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
voice: result.voice,
|
||||||
|
cue: firstCue,
|
||||||
|
subtitlePath: result.subtitlePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDialogueSubtitleCues(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
dialogue: DialogueDefinition,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
): Promise<DialogueSubtitleCues | null> {
|
||||||
const voice = getDialogueVoice(manifest, dialogue.voice);
|
const voice = getDialogueVoice(manifest, dialogue.voice);
|
||||||
if (!voice) return null;
|
if (!voice) return null;
|
||||||
|
|
||||||
const subtitles = await loadVoiceSubtitleCues(voice, language);
|
const subtitles = await loadVoiceSubtitleCues(voice, language);
|
||||||
if (!subtitles) return null;
|
if (!subtitles) return null;
|
||||||
|
|
||||||
const cue = subtitles.cues.find(
|
const cueIndices = getDialogueCueIndices(dialogue);
|
||||||
(item) => item.index === dialogue.subtitleCueIndex,
|
if (cueIndices.length === 0) return null;
|
||||||
);
|
|
||||||
|
|
||||||
if (!cue) return null;
|
const cues = cueIndices
|
||||||
|
.map((index) => subtitles.cues.find((item) => item.index === index))
|
||||||
|
.filter((cue): cue is SubtitleCue => cue !== undefined);
|
||||||
|
|
||||||
|
if (cues.length === 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
voice,
|
voice,
|
||||||
cue,
|
cues,
|
||||||
subtitlePath: subtitles.path,
|
subtitlePath: subtitles.path,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
|||||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadDialogueSubtitleCue } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueSubtitleCues } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
|
||||||
|
|
||||||
interface QueuedDialogueRequest {
|
interface QueuedDialogueRequest {
|
||||||
manifest: DialogueManifest;
|
manifest: DialogueManifest;
|
||||||
@@ -15,6 +16,8 @@ const DIALOGUE_PLAY_START_TIMEOUT_MS = 800;
|
|||||||
const dialogueQueue: QueuedDialogueRequest[] = [];
|
const dialogueQueue: QueuedDialogueRequest[] = [];
|
||||||
let isDialogueQueuePlaying = false;
|
let isDialogueQueuePlaying = false;
|
||||||
|
|
||||||
|
let currentDialogueAudio: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
export function queueDialogueById(
|
export function queueDialogueById(
|
||||||
manifest: DialogueManifest,
|
manifest: DialogueManifest,
|
||||||
dialogueId: string,
|
dialogueId: string,
|
||||||
@@ -31,15 +34,26 @@ export function clearQueuedDialogues(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stopCurrentDialogue(): void {
|
||||||
|
if (currentDialogueAudio && !currentDialogueAudio.paused) {
|
||||||
|
currentDialogueAudio.pause();
|
||||||
|
currentDialogueAudio.currentTime = 0;
|
||||||
|
}
|
||||||
|
currentDialogueAudio = null;
|
||||||
|
useSubtitleStore.getState().clearActiveSubtitle();
|
||||||
|
}
|
||||||
|
|
||||||
export async function playDialogueById(
|
export async function playDialogueById(
|
||||||
manifest: DialogueManifest,
|
manifest: DialogueManifest,
|
||||||
dialogueId: string,
|
dialogueId: string,
|
||||||
): Promise<HTMLAudioElement | null> {
|
): Promise<HTMLAudioElement | null> {
|
||||||
|
stopCurrentDialogue();
|
||||||
|
|
||||||
const dialogue = manifest.dialogues.find((item) => item.id === dialogueId);
|
const dialogue = manifest.dialogues.find((item) => item.id === dialogueId);
|
||||||
if (!dialogue) return null;
|
if (!dialogue) return null;
|
||||||
|
|
||||||
const subtitleLanguage = useSettingsStore.getState().subtitleLanguage;
|
const subtitleLanguage = useSettingsStore.getState().subtitleLanguage;
|
||||||
const subtitle = await loadDialogueSubtitleCue(
|
const subtitleData = await loadDialogueSubtitleCues(
|
||||||
manifest,
|
manifest,
|
||||||
dialogue,
|
dialogue,
|
||||||
subtitleLanguage,
|
subtitleLanguage,
|
||||||
@@ -48,7 +62,11 @@ export async function playDialogueById(
|
|||||||
category: "dialogue",
|
category: "dialogue",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!subtitle) return audio;
|
currentDialogueAudio = audio;
|
||||||
|
|
||||||
|
if (!subtitleData || subtitleData.cues.length === 0) return audio;
|
||||||
|
|
||||||
|
const { voice, cues } = subtitleData;
|
||||||
|
|
||||||
const clearSubtitle = (): void => {
|
const clearSubtitle = (): void => {
|
||||||
useSubtitleStore.getState().clearActiveSubtitle();
|
useSubtitleStore.getState().clearActiveSubtitle();
|
||||||
@@ -60,18 +78,28 @@ export async function playDialogueById(
|
|||||||
audio.removeEventListener("ended", cleanup);
|
audio.removeEventListener("ended", cleanup);
|
||||||
audio.removeEventListener("pause", cleanup);
|
audio.removeEventListener("pause", cleanup);
|
||||||
clearSubtitle();
|
clearSubtitle();
|
||||||
|
if (currentDialogueAudio === audio) {
|
||||||
|
currentDialogueAudio = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findActiveCue = (currentTime: number): SubtitleCue | null => {
|
||||||
|
for (const cue of cues) {
|
||||||
|
if (currentTime >= cue.startTime && currentTime <= cue.endTime) {
|
||||||
|
return cue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncSubtitle = (): void => {
|
const syncSubtitle = (): void => {
|
||||||
const currentTime = audio.currentTime;
|
const currentTime = audio.currentTime;
|
||||||
const shouldShowSubtitle =
|
const activeCue = findActiveCue(currentTime);
|
||||||
currentTime >= subtitle.cue.startTime &&
|
|
||||||
currentTime <= subtitle.cue.endTime;
|
|
||||||
|
|
||||||
if (shouldShowSubtitle) {
|
if (activeCue) {
|
||||||
useSubtitleStore.getState().setActiveSubtitle({
|
useSubtitleStore.getState().setActiveSubtitle({
|
||||||
speaker: subtitle.voice.speaker,
|
speaker: voice.speaker,
|
||||||
text: subtitle.cue.text,
|
text: activeCue.text,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
|||||||
height={4}
|
height={4}
|
||||||
startPos={{ x: 10, y: 0, z: -10 }}
|
startPos={{ x: 10, y: 0, z: -10 }}
|
||||||
destPos={{ x: -40, y: 0, z: 30 }}
|
destPos={{ x: -40, y: 0, z: 30 }}
|
||||||
mapImageUrl="/assets/gps/map_background.png"
|
mapImageUrl="/assets/world/gps/map_background.png"
|
||||||
worldBounds={{
|
worldBounds={{
|
||||||
minX: -166,
|
minX: -166,
|
||||||
maxX: 163,
|
maxX: 163,
|
||||||
|
|||||||
Reference in New Issue
Block a user