From 4c5e2ed945e5b5bd6ba437f3fbeb4e4c56bc96fb Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 30 May 2026 02:14:10 +0200 Subject: [PATCH 01/20] feat(types): add SiteStep and refactor GameStep for new intro flow --- src/components/game/GameFlow.tsx | 72 --------- .../three/interaction/NPCHelper.tsx | 42 ----- .../three/interaction/PyloneDestroyed.tsx | 52 ------ src/components/ui/IntroUI.tsx | 129 --------------- src/components/zone/ZoneDetection.tsx | 148 ------------------ src/data/game/gameStateConfig.ts | 35 +++-- src/data/zones.ts | 19 --- src/managers/stores/useGameStore.ts | 2 +- src/pages/page.tsx | 3 - src/types/game.ts | 36 ++--- src/world/World.tsx | 12 -- 11 files changed, 42 insertions(+), 508 deletions(-) delete mode 100644 src/components/game/GameFlow.tsx delete mode 100644 src/components/three/interaction/NPCHelper.tsx delete mode 100644 src/components/three/interaction/PyloneDestroyed.tsx delete mode 100644 src/components/ui/IntroUI.tsx delete mode 100644 src/components/zone/ZoneDetection.tsx delete mode 100644 src/data/zones.ts diff --git a/src/components/game/GameFlow.tsx b/src/components/game/GameFlow.tsx deleted file mode 100644 index 2720a22..0000000 --- a/src/components/game/GameFlow.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect, useRef } from "react"; -import { AudioManager } from "@/managers/AudioManager"; -import { useGameStore } from "@/managers/stores/useGameStore"; -import { AUDIO_PATHS } from "@/data/audioConfig"; - -export function GameFlow(): null { - const step = useGameStore((state) => state.intro.currentStep); - const setStep = useGameStore((state) => state.setIntroStep); - const setActivityCity = useGameStore((state) => state.setActivityCity); - const setCanMove = useGameStore((state) => state.setCanMove); - const completeIntro = useGameStore((state) => state.completeIntro); - const hasInitialized = useRef(false); - - useEffect(() => { - if (!hasInitialized.current && step === "intro") { - hasInitialized.current = true; - setStep("start-intro"); - } - }, [step, setStep]); - - useEffect(() => { - if (step === "start-intro") { - const audio = AudioManager.getInstance(); - audio.playSoundWithCallback(AUDIO_PATHS.intro, 0.5, () => { - setStep("naming"); - }); - - return () => {}; - } - - if (step === "bienvenue") { - const audio = AudioManager.getInstance(); - audio.playSoundWithCallback(AUDIO_PATHS.bienvenue, 0.5, () => { - setCanMove(true); - setStep("star-move"); - }); - - return () => {}; - } - - if (step === "mission2") { - setActivityCity(false); - const audio = AudioManager.getInstance(); - audio.playSound(AUDIO_PATHS.alertCentral, 0.5); - } - - if (step === "searching") { - const audio = AudioManager.getInstance(); - audio.playSound(AUDIO_PATHS.searching, 0.5); - } - - if (step === "helped") { - const audio = AudioManager.getInstance(); - audio.playSound(AUDIO_PATHS.helped, 0.5); - } - - if (step === "manipulation") { - setCanMove(false); - const timeoutId = window.setTimeout(() => { - completeIntro(); - }, 1000); - - return () => { - window.clearTimeout(timeoutId); - }; - } - - return undefined; - }, [completeIntro, step, setStep, setActivityCity, setCanMove]); - - return null; -} diff --git a/src/components/three/interaction/NPCHelper.tsx b/src/components/three/interaction/NPCHelper.tsx deleted file mode 100644 index e89e981..0000000 --- a/src/components/three/interaction/NPCHelper.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { InteractableObject } from "@/components/three/interaction/InteractableObject"; -import { useGameStore } from "@/managers/stores/useGameStore"; -import { Debug } from "@/utils/debug/Debug"; -import type { Vector3Tuple } from "@/types/three/three"; - -interface NPCHelperProps { - position: Vector3Tuple; -} - -export function NPCHelper({ position }: NPCHelperProps): React.JSX.Element { - const step = useGameStore((state) => state.intro.currentStep); - const setStep = useGameStore((state) => state.setIntroStep); - const debug = Debug.getInstance(); - - const handlePress = (): void => { - if (step === "searching") { - setStep("helped"); - } - }; - - const shouldShow = step === "searching" || debug.active; - - if (!shouldShow) { - return <>; - } - - return ( - - - - - - - - - ); -} diff --git a/src/components/three/interaction/PyloneDestroyed.tsx b/src/components/three/interaction/PyloneDestroyed.tsx deleted file mode 100644 index f7c567c..0000000 --- a/src/components/three/interaction/PyloneDestroyed.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { InteractableObject } from "@/components/three/interaction/InteractableObject"; -import { useGameStore } from "@/managers/stores/useGameStore"; -import { Debug } from "@/utils/debug/Debug"; -import type { Vector3Tuple } from "@/types/three/three"; - -interface PyloneDestroyedProps { - position: Vector3Tuple; -} - -export function PyloneDestroyed({ - position, -}: PyloneDestroyedProps): React.JSX.Element { - const step = useGameStore((state) => state.intro.currentStep); - const setStep = useGameStore((state) => state.setIntroStep); - const setCanMove = useGameStore((state) => state.setCanMove); - const showDialog = useGameStore((state) => state.showDialog); - const debug = Debug.getInstance(); - - const handlePress = (): void => { - if (step === "helped") { - setCanMove(false); - setStep("manipulation"); - } else if (step === "searching") { - showDialog( - "Cet objet est trop lourd pour le porter tout seul, trouve de l'aide", - ); - } - }; - - const shouldShow = - step === "helped" || step === "manipulation" || debug.active; - - if (!shouldShow) { - return <>; - } - - return ( - - - - - - - - - ); -} diff --git a/src/components/ui/IntroUI.tsx b/src/components/ui/IntroUI.tsx deleted file mode 100644 index 45ace4e..0000000 --- a/src/components/ui/IntroUI.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useState } from "react"; -import { useGameStore } from "@/managers/stores/useGameStore"; - -export function IntroUI(): React.JSX.Element | null { - const step = useGameStore((state) => state.intro.currentStep); - const setPlayerName = useGameStore((state) => state.setPlayerName); - const setStep = useGameStore((state) => state.setIntroStep); - const [inputValue, setInputValue] = useState(""); - - if (step !== "naming") return null; - - const handleSubmit = (): void => { - if (inputValue.trim() === "") return; - - setPlayerName(inputValue.trim()); - setStep("bienvenue"); - }; - - const handleKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === "Enter") { - handleSubmit(); - } - }; - - return ( -
-
-

- Quel est votre prenom ? -

- setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Votre prenom" - autoFocus - style={{ - padding: "0.75rem", - fontSize: "1rem", - borderRadius: "6px", - border: "1px solid #444", - backgroundColor: "#2a2a2a", - color: "#fff", - outline: "none", - }} - /> - -
-
- ); -} - -export function BienvenueDisplay(): React.JSX.Element | null { - const step = useGameStore((state) => state.intro.currentStep); - const playerName = useGameStore((state) => state.missionFlow.playerName); - - if (step !== "bienvenue") return null; - - return ( -
-

- Bienvenue {playerName} ! -

-
- ); -} diff --git a/src/components/zone/ZoneDetection.tsx b/src/components/zone/ZoneDetection.tsx deleted file mode 100644 index 0bbb1c1..0000000 --- a/src/components/zone/ZoneDetection.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useFrame, useThree } from "@react-three/fiber"; -import * as THREE from "three"; -import { ZONES } from "@/data/zones"; -import { useGameStore } from "@/managers/stores/useGameStore"; -import { Debug } from "@/utils/debug/Debug"; -import { GAME_STEPS } from "@/data/game/gameStateConfig"; - -const _playerPos = new THREE.Vector3(); -const _zonePos = new THREE.Vector3(); - -export function ZoneDetection(): null { - const camera = useThree((state) => state.camera); - const triggeredZones = useRef>(new Set()); - const debug = Debug.getInstance(); - const step = useGameStore((state) => state.intro.currentStep); - const setStep = useGameStore((state) => state.setIntroStep); - - useEffect(() => { - if (!debug.active) return; - - const folder = debug.createFolder("Game"); - if (!folder) return; - - const gameState = { step: step }; - const playerPos = { x: 0, y: 0, z: 0 }; - - folder.add(gameState, "step", GAME_STEPS).name("Game Step").disable(); - - folder.add(playerPos, "x").name("Player X").listen().disable(); - folder.add(playerPos, "y").name("Player Y").listen().disable(); - folder.add(playerPos, "z").name("Player Z").listen().disable(); - - const unsubStore = useGameStore.subscribe((state) => { - gameState.step = state.intro.currentStep; - folder.controllersRecursive().forEach((c) => c.updateDisplay()); - }); - - let frameId: number; - const updatePlayerPos = (): void => { - camera.getWorldPosition(_playerPos); - playerPos.x = Math.round(_playerPos.x * 100) / 100; - playerPos.y = Math.round(_playerPos.y * 100) / 100; - playerPos.z = Math.round(_playerPos.z * 100) / 100; - folder.controllersRecursive().forEach((c) => c.updateDisplay()); - frameId = requestAnimationFrame(updatePlayerPos); - }; - updatePlayerPos(); - - return () => { - cancelAnimationFrame(frameId); - debug.destroyFolder("Game"); - unsubStore(); - }; - }, [debug, camera, step]); - - useFrame(() => { - camera.getWorldPosition(_playerPos); - - for (const zone of ZONES) { - if (triggeredZones.current.has(zone.id)) continue; - - _zonePos.set(...zone.position); - - const distanceSq = _playerPos.distanceToSquared(_zonePos); - - if (distanceSq <= zone.radius * zone.radius) { - setStep(zone.targetStep); - triggeredZones.current.add(zone.id); - break; - } - } - }); - - return null; -} - -export function ZoneDebugVisuals(): React.JSX.Element | null { - const debug = Debug.getInstance(); - const camera = useThree((state) => state.camera); - const [triggeredZones, setTriggeredZones] = useState>(new Set()); - - useFrame(() => { - camera.getWorldPosition(_playerPos); - - for (const zone of ZONES) { - if (triggeredZones.has(zone.id)) continue; - - _zonePos.set(...zone.position); - - const distanceSq = _playerPos.distanceToSquared(_zonePos); - - if (distanceSq <= zone.radius * zone.radius) { - setTriggeredZones((prev) => new Set(prev).add(zone.id)); - break; - } - } - }); - - if (!debug.active) return null; - - return ( - <> - {ZONES.map((zone) => ( - - ))} - - ); -} - -function ZoneVisual({ - position, - radius, - height, - triggered, -}: { - position: [number, number, number]; - radius: number; - height: number; - triggered: boolean; -}): React.JSX.Element { - const color = triggered ? "#00ff00" : "#ff0000"; - - return ( - - - - - - - - - - - ); -} diff --git a/src/data/game/gameStateConfig.ts b/src/data/game/gameStateConfig.ts index d85af1a..be1b286 100644 --- a/src/data/game/gameStateConfig.ts +++ b/src/data/game/gameStateConfig.ts @@ -1,16 +1,24 @@ -import type { GameStep, MainGameState } from "@/types/game"; +import type { GameStep, MainGameState, SiteStep } from "@/types/game"; -export const GAME_STEPS: readonly GameStep[] = [ - "intro", - "start-intro", +/** + * Steps for the /site onboarding page + */ +export const SITE_STEPS: readonly SiteStep[] = [ + "welcome", + "situation", "naming", - "bienvenue", - "star-move", - "mission2", - "searching", - "helped", - "manipulation", - "outOfFabrik", + "transition", +]; + +/** + * Steps for the intro sequence (after /site, on / route) + */ +export const GAME_STEPS: readonly GameStep[] = [ + "loading-map", + "video", + "dialogue-intro", + "reveal", + "playing", ]; export const MAIN_GAME_STATES: readonly MainGameState[] = [ @@ -21,9 +29,14 @@ export const MAIN_GAME_STATES: readonly MainGameState[] = [ "outro", ] as const; +const SITE_STEP_VALUES: ReadonlySet = new Set(SITE_STEPS); const GAME_STEP_VALUES: ReadonlySet = new Set(GAME_STEPS); const MAIN_GAME_STATE_VALUES: ReadonlySet = new Set(MAIN_GAME_STATES); +export function isSiteStep(value: unknown): value is SiteStep { + return typeof value === "string" && SITE_STEP_VALUES.has(value); +} + export function isGameStep(value: unknown): value is GameStep { return typeof value === "string" && GAME_STEP_VALUES.has(value); } diff --git a/src/data/zones.ts b/src/data/zones.ts deleted file mode 100644 index cf88747..0000000 --- a/src/data/zones.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Zone } from "@/types/game"; -import type { Vector3Tuple } from "@/types/three/three"; - -export const ZONES: Zone[] = [ - { - id: "fabrikExit", - position: [-5, 25, -15] as Vector3Tuple, - radius: 10, - height: 20, - targetStep: "mission2", - }, - { - id: "searchingZone", - position: [-5, 25, -30] as Vector3Tuple, - radius: 10, - height: 20, - targetStep: "searching", - }, -]; diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index 099caab..e1eb7e5 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -255,7 +255,7 @@ function createInitialGameState(): GameState { currentSpeed: PLAYER_WALK_SPEED, }, intro: { - currentStep: "intro", + currentStep: "loading-map", dialogueAudio: null, hasCompleted: false, isEbikeUnlocked: false, diff --git a/src/pages/page.tsx b/src/pages/page.tsx index deae3e3..a148d3c 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -4,7 +4,6 @@ import * as THREE from "three"; import { DebugPerf } from "@/components/debug/DebugPerf"; import { DialogMessage } from "@/components/ui/DialogMessage"; import { GameUI } from "@/components/ui/GameUI"; -import { BienvenueDisplay, IntroUI } from "@/components/ui/IntroUI"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig"; import { useGameStore } from "@/managers/stores/useGameStore"; @@ -94,8 +93,6 @@ export function HomePage(): React.JSX.Element { - - {dialogMessage ? ( : null} {sceneMode === "game" ? ( <> - - - - - Date: Sat, 30 May 2026 03:59:45 +0200 Subject: [PATCH 02/20] update: reorganize public assets --- public/assets/bg-site.png | 3 +++ public/assets/{ => world}/UI/cassé.webm | 0 public/assets/{ => world}/UI/centrale.webm | 0 public/assets/{ => world}/UI/ebike.webm | 0 public/assets/{ => world}/UI/interagir.webm | 0 public/assets/{ => world}/UI/laferme.webm | 0 public/assets/{ => world}/gps/map_background.png | 0 public/{assets => }/cinematics/intro.mp4 | 0 public/{assets => }/cinematics/outro.mp4 | 0 src/components/ebike/Ebike.tsx | 2 +- src/data/gameplay/repairMissions.ts | 10 +++++----- src/pages/backgroundmap/page.tsx | 2 +- src/world/debug/TestMap.tsx | 2 +- 13 files changed, 11 insertions(+), 8 deletions(-) create mode 100644 public/assets/bg-site.png rename public/assets/{ => world}/UI/cassé.webm (100%) rename public/assets/{ => world}/UI/centrale.webm (100%) rename public/assets/{ => world}/UI/ebike.webm (100%) rename public/assets/{ => world}/UI/interagir.webm (100%) rename public/assets/{ => world}/UI/laferme.webm (100%) rename public/assets/{ => world}/gps/map_background.png (100%) rename public/{assets => }/cinematics/intro.mp4 (100%) rename public/{assets => }/cinematics/outro.mp4 (100%) diff --git a/public/assets/bg-site.png b/public/assets/bg-site.png new file mode 100644 index 0000000..d3a1e36 --- /dev/null +++ b/public/assets/bg-site.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e32d667a6e17ca75437f7fde9bad637bfd691543f14e48d7bca82f95f993414 +size 1469658 diff --git a/public/assets/UI/cassé.webm b/public/assets/world/UI/cassé.webm similarity index 100% rename from public/assets/UI/cassé.webm rename to public/assets/world/UI/cassé.webm diff --git a/public/assets/UI/centrale.webm b/public/assets/world/UI/centrale.webm similarity index 100% rename from public/assets/UI/centrale.webm rename to public/assets/world/UI/centrale.webm diff --git a/public/assets/UI/ebike.webm b/public/assets/world/UI/ebike.webm similarity index 100% rename from public/assets/UI/ebike.webm rename to public/assets/world/UI/ebike.webm diff --git a/public/assets/UI/interagir.webm b/public/assets/world/UI/interagir.webm similarity index 100% rename from public/assets/UI/interagir.webm rename to public/assets/world/UI/interagir.webm diff --git a/public/assets/UI/laferme.webm b/public/assets/world/UI/laferme.webm similarity index 100% rename from public/assets/UI/laferme.webm rename to public/assets/world/UI/laferme.webm diff --git a/public/assets/gps/map_background.png b/public/assets/world/gps/map_background.png similarity index 100% rename from public/assets/gps/map_background.png rename to public/assets/world/gps/map_background.png diff --git a/public/assets/cinematics/intro.mp4 b/public/cinematics/intro.mp4 similarity index 100% rename from public/assets/cinematics/intro.mp4 rename to public/cinematics/intro.mp4 diff --git a/public/assets/cinematics/outro.mp4 b/public/cinematics/outro.mp4 similarity index 100% rename from public/assets/cinematics/outro.mp4 rename to public/cinematics/outro.mp4 diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index b7104c4..38f7f24 100644 --- a/src/components/ebike/Ebike.tsx +++ b/src/components/ebike/Ebike.tsx @@ -263,7 +263,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { height={0.8} startPos={gpsStartPos} destPos={destPos} - mapImageUrl="/assets/gps/map_background.png" + mapImageUrl="/assets/world/gps/map_background.png" worldBounds={{ minX: -166, maxX: 163, diff --git a/src/data/gameplay/repairMissions.ts b/src/data/gameplay/repairMissions.ts index 8fa0ceb..3ba887f 100644 --- a/src/data/gameplay/repairMissions.ts +++ b/src/data/gameplay/repairMissions.ts @@ -4,8 +4,8 @@ import type { RepairMissionId, } from "@/types/gameplay/repairMission"; -const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm"; -const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm"; +const REPAIR_INTERACT_UI_PATH = "/assets/world/UI/interagir.webm"; +const REPAIR_BROKEN_UI_PATH = "/assets/world/UI/cassé.webm"; const DEFAULT_REPAIR_CASE = { position: [0, 0.4, 1.8], @@ -21,7 +21,7 @@ export const REPAIR_MISSIONS: Record = { "Repair the damaged cooling module before relaunching the bike", modelPath: "/models/ebike/model.gltf", modelScale: 0.3, - stageUiPath: "/assets/UI/ebike.webm", + stageUiPath: "/assets/world/UI/ebike.webm", interactUiPath: REPAIR_INTERACT_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH, case: DEFAULT_REPAIR_CASE, @@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record = { description: "Restore the pylon lamp relay and damaged panel before reconnecting the grid", modelPath: "/models/pylone/model.gltf", - stageUiPath: "/assets/UI/centrale.webm", + stageUiPath: "/assets/world/UI/centrale.webm", interactUiPath: REPAIR_INTERACT_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH, case: DEFAULT_REPAIR_CASE, @@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record = { description: "Stabilize the irrigation loop and humidity sensor before restarting the farm", modelPath: "/models/fermeverticale/model.gltf", - stageUiPath: "/assets/UI/laferme.webm", + stageUiPath: "/assets/world/UI/laferme.webm", interactUiPath: REPAIR_INTERACT_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH, case: DEFAULT_REPAIR_CASE, diff --git a/src/pages/backgroundmap/page.tsx b/src/pages/backgroundmap/page.tsx index 75fcd49..24cb981 100644 --- a/src/pages/backgroundmap/page.tsx +++ b/src/pages/backgroundmap/page.tsx @@ -157,7 +157,7 @@ function CameraManager({ const dataUrl = gl.domElement.toDataURL("image/png"); const a = document.createElement("a"); a.href = dataUrl; - a.download = "/assets/gps/map_background.png"; + a.download = "map_background.png"; a.click(); }; return () => { diff --git a/src/world/debug/TestMap.tsx b/src/world/debug/TestMap.tsx index 062b560..0056b21 100644 --- a/src/world/debug/TestMap.tsx +++ b/src/world/debug/TestMap.tsx @@ -275,7 +275,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { height={4} startPos={{ x: 10, y: 0, z: -10 }} destPos={{ x: -40, y: 0, z: 30 }} - mapImageUrl="/assets/gps/map_background.png" + mapImageUrl="/assets/world/gps/map_background.png" worldBounds={{ minX: -166, maxX: 163, -- 2.52.0 From a2cff0567e596ebba5a6051a04a01e4e20f98e4e Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 30 May 2026 04:00:09 +0200 Subject: [PATCH 03/20] feat: add site onboarding route --- src/components/site/SiteButton.tsx | 52 +++++++ src/components/site/SiteCard.tsx | 68 +++++++++ src/components/site/SiteDisclaimerScreen.tsx | 83 +++++++++++ src/components/site/SiteLayout.tsx | 33 +++++ src/components/site/SiteMobileBlocker.tsx | 53 +++++++ src/components/site/SiteNamingScreen.tsx | 133 ++++++++++++++++++ src/components/site/SiteSituationScreen.tsx | 82 +++++++++++ src/components/site/SiteTransitionOverlay.tsx | 109 ++++++++++++++ src/components/site/SiteWelcomeScreen.tsx | 120 ++++++++++++++++ src/data/site/siteConfig.ts | 35 +++++ src/managers/stores/useSiteStore.ts | 31 ++++ src/pages/site/page.tsx | 64 +++++++++ src/router.tsx | 8 ++ src/types/game.ts | 1 + src/utils/cookies/siteVisitCookie.ts | 35 +++++ 15 files changed, 907 insertions(+) create mode 100644 src/components/site/SiteButton.tsx create mode 100644 src/components/site/SiteCard.tsx create mode 100644 src/components/site/SiteDisclaimerScreen.tsx create mode 100644 src/components/site/SiteLayout.tsx create mode 100644 src/components/site/SiteMobileBlocker.tsx create mode 100644 src/components/site/SiteNamingScreen.tsx create mode 100644 src/components/site/SiteSituationScreen.tsx create mode 100644 src/components/site/SiteTransitionOverlay.tsx create mode 100644 src/components/site/SiteWelcomeScreen.tsx create mode 100644 src/data/site/siteConfig.ts create mode 100644 src/managers/stores/useSiteStore.ts create mode 100644 src/pages/site/page.tsx create mode 100644 src/utils/cookies/siteVisitCookie.ts diff --git a/src/components/site/SiteButton.tsx b/src/components/site/SiteButton.tsx new file mode 100644 index 0000000..cee1f8a --- /dev/null +++ b/src/components/site/SiteButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/site/SiteCard.tsx b/src/components/site/SiteCard.tsx new file mode 100644 index 0000000..05d4eca --- /dev/null +++ b/src/components/site/SiteCard.tsx @@ -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 ( + + ); +} diff --git a/src/components/site/SiteDisclaimerScreen.tsx b/src/components/site/SiteDisclaimerScreen.tsx new file mode 100644 index 0000000..caf02ab --- /dev/null +++ b/src/components/site/SiteDisclaimerScreen.tsx @@ -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 ( +
+

+ {DISCLAIMER_TEXT} +

+
+ ); +} diff --git a/src/components/site/SiteLayout.tsx b/src/components/site/SiteLayout.tsx new file mode 100644 index 0000000..98ecf21 --- /dev/null +++ b/src/components/site/SiteLayout.tsx @@ -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 ( +
+ {children} + +
+ ); +} diff --git a/src/components/site/SiteMobileBlocker.tsx b/src/components/site/SiteMobileBlocker.tsx new file mode 100644 index 0000000..ef47a16 --- /dev/null +++ b/src/components/site/SiteMobileBlocker.tsx @@ -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 ( +
+ Logo +

+ {MOBILE_TEXT} +

+
+ ); +} diff --git a/src/components/site/SiteNamingScreen.tsx b/src/components/site/SiteNamingScreen.tsx new file mode 100644 index 0000000..5afe1d6 --- /dev/null +++ b/src/components/site/SiteNamingScreen.tsx @@ -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(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): 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 ( +
+
+

+ Quel est votre prénom ? +

+ + +
+ + +
+ ); +} diff --git a/src/components/site/SiteSituationScreen.tsx b/src/components/site/SiteSituationScreen.tsx new file mode 100644 index 0000000..d24589d --- /dev/null +++ b/src/components/site/SiteSituationScreen.tsx @@ -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 ( +
+

+ Quelle est votre situation ? +

+ +
+ {SITUATION_CARDS.map((card, index) => ( + { + if (!card.disabled) { + setSelectedSituation(index); + } + }} + /> + ))} +
+ + +
+ ); +} diff --git a/src/components/site/SiteTransitionOverlay.tsx b/src/components/site/SiteTransitionOverlay.tsx new file mode 100644 index 0000000..3c7d150 --- /dev/null +++ b/src/components/site/SiteTransitionOverlay.tsx @@ -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 ( +
+
+ Logo + +
+ ); +} diff --git a/src/components/site/SiteWelcomeScreen.tsx b/src/components/site/SiteWelcomeScreen.tsx new file mode 100644 index 0000000..9220053 --- /dev/null +++ b/src/components/site/SiteWelcomeScreen.tsx @@ -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 ( +
+
+

+ BIENVENUE A ALTERA +

+

+ Communauté convivialiste +

+
+ +

+ Choisissez une expérience : +

+ +
+ {EXPERIENCE_CARDS.map((card, index) => ( + { + if (!card.disabled) { + setSelectedExperience(index); + } + }} + /> + ))} +
+ + +
+ ); +} diff --git a/src/data/site/siteConfig.ts b/src/data/site/siteConfig.ts new file mode 100644 index 0000000..59593c2 --- /dev/null +++ b/src/data/site/siteConfig.ts @@ -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 }, +]; diff --git a/src/managers/stores/useSiteStore.ts b/src/managers/stores/useSiteStore.ts new file mode 100644 index 0000000..15e3fd1 --- /dev/null +++ b/src/managers/stores/useSiteStore.ts @@ -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()((set) => ({ + ...initialState, + setStep: (step) => set({ currentStep: step }), + setSelectedExperience: (index) => set({ selectedExperience: index }), + setSelectedSituation: (index) => set({ selectedSituation: index }), + reset: () => set(initialState), +})); diff --git a/src/pages/site/page.tsx b/src/pages/site/page.tsx new file mode 100644 index 0000000..ac889c3 --- /dev/null +++ b/src/pages/site/page.tsx @@ -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 ; + } + + if (currentStep === "disclaimer") { + return ; + } + + return ( + + {currentStep === "welcome" && } + {currentStep === "situation" && } + {currentStep === "naming" && } + {currentStep === "transition" && } + + ); +} diff --git a/src/router.tsx b/src/router.tsx index c00c2d0..f9c481e 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -5,6 +5,7 @@ import { createRouter, } from "@tanstack/react-router"; import { HomePage } from "@/pages/page"; +import { SitePage } from "@/pages/site/page"; import { EditorPage } from "@/pages/editor/page"; import { GalleryPage } from "@/pages/gallery/page"; import { WaypointEditorPage } from "@/pages/waypoint/page"; @@ -42,6 +43,12 @@ const indexRoute = createRoute({ component: HomePage, }); +const siteRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/site", + component: SitePage, +}); + const editorRoute = createRoute({ getParentRoute: () => rootRoute, path: "/editor", @@ -102,6 +109,7 @@ const docsChildRoutes = [ const routeTree = rootRoute.addChildren([ indexRoute, + siteRoute, editorRoute, galleryRoute, waypointRoute, diff --git a/src/types/game.ts b/src/types/game.ts index 9d4a9a7..07da6b7 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -4,6 +4,7 @@ import type { RepairMissionId } from "@/types/gameplay/repairMission"; * Steps for the /site onboarding page */ export type SiteStep = + | "disclaimer" // Écran 0: Avertissement (ordi recommandé, bonne connexion) | "welcome" // Écran 1: Bienvenue à Altera | "situation" // Écran 2: Quelle est votre situation | "naming" // Écran 3: Quel est votre prénom (Danyl) diff --git a/src/utils/cookies/siteVisitCookie.ts b/src/utils/cookies/siteVisitCookie.ts new file mode 100644 index 0000000..716aa2e --- /dev/null +++ b/src/utils/cookies/siteVisitCookie.ts @@ -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=/;`; +} -- 2.52.0 From ce5dc8ada0726f6e581962656e17e9c1a2adcd8b Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 30 May 2026 04:00:20 +0200 Subject: [PATCH 04/20] update: intro flow overlays --- .../ui/intro/IntroDialogueOverlay.tsx | 50 ++++++++++++ .../ui/intro/IntroRevealOverlay.tsx | 48 +++++++++++ src/components/ui/intro/IntroVideoPlayer.tsx | 80 +++++++++++++++++++ src/components/ui/intro/index.ts | 3 + src/pages/page.tsx | 41 +++++++++- 5 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/intro/IntroDialogueOverlay.tsx create mode 100644 src/components/ui/intro/IntroRevealOverlay.tsx create mode 100644 src/components/ui/intro/IntroVideoPlayer.tsx create mode 100644 src/components/ui/intro/index.ts diff --git a/src/components/ui/intro/IntroDialogueOverlay.tsx b/src/components/ui/intro/IntroDialogueOverlay.tsx new file mode 100644 index 0000000..029619e --- /dev/null +++ b/src/components/ui/intro/IntroDialogueOverlay.tsx @@ -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 ( +
+ + ... + +
+ ); +} diff --git a/src/components/ui/intro/IntroRevealOverlay.tsx b/src/components/ui/intro/IntroRevealOverlay.tsx new file mode 100644 index 0000000..9565e2b --- /dev/null +++ b/src/components/ui/intro/IntroRevealOverlay.tsx @@ -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 ( +
+ ); +} diff --git a/src/components/ui/intro/IntroVideoPlayer.tsx b/src/components/ui/intro/IntroVideoPlayer.tsx new file mode 100644 index 0000000..d595330 --- /dev/null +++ b/src/components/ui/intro/IntroVideoPlayer.tsx @@ -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(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 ( +
+
+ ); +} diff --git a/src/components/ui/intro/index.ts b/src/components/ui/intro/index.ts new file mode 100644 index 0000000..6031a8e --- /dev/null +++ b/src/components/ui/intro/index.ts @@ -0,0 +1,3 @@ +export { IntroVideoPlayer } from "./IntroVideoPlayer"; +export { IntroDialogueOverlay } from "./IntroDialogueOverlay"; +export { IntroRevealOverlay } from "./IntroRevealOverlay"; diff --git a/src/pages/page.tsx b/src/pages/page.tsx index a148d3c..45611d7 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -1,18 +1,35 @@ import { Suspense, useCallback, useEffect, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; import { Canvas } from "@react-three/fiber"; import * as THREE from "three"; import { DebugPerf } from "@/components/debug/DebugPerf"; import { DialogMessage } from "@/components/ui/DialogMessage"; import { GameUI } from "@/components/ui/GameUI"; +import { + IntroDialogueOverlay, + IntroRevealOverlay, + IntroVideoPlayer, +} from "@/components/ui/intro"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig"; import { useGameStore } from "@/managers/stores/useGameStore"; import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider"; import type { SceneLoadingState } from "@/types/world/sceneLoading"; +import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie"; import { logger } from "@/utils/core/Logger"; import { World } from "@/world/World"; 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( (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( ({ gl }: { gl: THREE.WebGLRenderer }) => { const canvas = gl.domElement; @@ -75,6 +98,19 @@ export function HomePage(): React.JSX.Element { [], ); + const renderIntroOverlay = () => { + switch (introStep) { + case "video": + return ; + case "dialogue-intro": + return ; + case "reveal": + return ; + default: + return null; + } + }; + return ( ) : null} - + {introStep === "loading-map" && ( + + )} + {renderIntroOverlay()} ); } -- 2.52.0 From 02c1fb33d0bf4c0493eb2a5bb580a4caa5a00884 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 30 May 2026 04:00:25 +0200 Subject: [PATCH 05/20] feat(dialogues): support multi-cue subtitles --- public/sounds/dialogue/dialogues.json | 2 +- .../dialogue/subtitles/fr/narrateur.srt | 4 +- .../editor/EditorDialogueManifestPanel.tsx | 35 +++++++--- src/components/editor/EditorSrtPanel.tsx | 67 +++++++++++++------ src/types/dialogues/dialogues.ts | 20 +++++- .../dialogues/dialogueManifestValidation.ts | 32 +++++++-- src/utils/dialogues/loadDialogueManifest.ts | 39 +++++++++-- src/utils/dialogues/playDialogue.ts | 46 ++++++++++--- 8 files changed, 192 insertions(+), 53 deletions(-) diff --git a/public/sounds/dialogue/dialogues.json b/public/sounds/dialogue/dialogues.json index af596ef..0fd116f 100644 --- a/public/sounds/dialogue/dialogues.json +++ b/public/sounds/dialogue/dialogues.json @@ -38,7 +38,7 @@ "id": "narrateur_intro_prenom", "voice": "narrateur", "audio": "/sounds/dialogue/narrateur_intro_prenom.mp3", - "subtitleCueIndex": 2 + "subtitleCueIndices": [1, 2] }, { "id": "narrateur_intro_apresprenom", diff --git a/public/sounds/dialogue/subtitles/fr/narrateur.srt b/public/sounds/dialogue/subtitles/fr/narrateur.srt index ab3b902..cbb89c2 100644 --- a/public/sounds/dialogue/subtitles/fr/narrateur.srt +++ b/public/sounds/dialogue/subtitles/fr/narrateur.srt @@ -1,9 +1,9 @@ 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. 2 -00:00:00,000 --> 00:00:11,592 +00:00:09,000 --> 00:00:11,592 Avant de commencer, comment tu t'appelles ? 3 diff --git a/src/components/editor/EditorDialogueManifestPanel.tsx b/src/components/editor/EditorDialogueManifestPanel.tsx index 3dcb9bf..132e232 100644 --- a/src/components/editor/EditorDialogueManifestPanel.tsx +++ b/src/components/editor/EditorDialogueManifestPanel.tsx @@ -6,6 +6,10 @@ import type { DialogueSpeaker, DialogueVoiceId, } from "@/types/dialogues/dialogues"; +import { + getDialogueCueIndices, + getDialogueFirstCueIndex, +} from "@/types/dialogues/dialogues"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { playDialogueById } from "@/utils/dialogues/playDialogue"; import { parseSrt } from "@/utils/subtitles/parseSrt"; @@ -34,7 +38,7 @@ function getNextCueIndex( ): number { const cueIndexes = manifest.dialogues .filter((dialogue) => dialogue.voice === voice) - .map((dialogue) => dialogue.subtitleCueIndex); + .flatMap((dialogue) => getDialogueCueIndices(dialogue)); return Math.max(0, ...cueIndexes) + 1; } @@ -93,12 +97,15 @@ async function createFrenchSrtCue( manifest: DialogueManifest, dialogue: DialogueDefinition, ): Promise { + const firstCueIndex = getDialogueFirstCueIndex(dialogue); + if (firstCueIndex === undefined) return; + const srtPath = getFrenchSrtPath(dialogue.voice); const response = await fetch(srtPath); const content = response.ok ? await response.text() : ""; const nextContent = appendSrtCueIfMissing( content, - dialogue.subtitleCueIndex, + firstCueIndex, getVoiceSpeaker(manifest, dialogue.voice), ); @@ -122,7 +129,8 @@ function getManifestErrors(manifest: DialogueManifest | null): string[] { 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.`); } @@ -160,9 +168,18 @@ function getPatchedDialogue( id: patch.id ?? dialogue.id, voice: patch.voice ?? dialogue.voice, 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 (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode; } else if (dialogue.timecode !== undefined) { @@ -252,8 +269,9 @@ export function EditorDialogueManifestPanel(): React.JSX.Element { try { await createFrenchSrtCue(nextManifest, dialogue); + const cueIndex = getDialogueFirstCueIndex(dialogue) ?? "?"; 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) { const message = err instanceof Error ? err.message : "Erreur inconnue"; @@ -333,12 +351,13 @@ export function EditorDialogueManifestPanel(): React.JSX.Element { async function handleCreateFrenchSrtCue(): Promise { if (!manifest || !selectedDialogue) return; + const cueIndex = getDialogueFirstCueIndex(selectedDialogue) ?? "?"; setIsCreatingSrtCue(true); - setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`); + setStatus(`Creation de la cue FR ${cueIndex}...`); try { await createFrenchSrtCue(manifest, selectedDialogue); - setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`); + setStatus(`Cue FR ${cueIndex} prete.`); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus(message); @@ -478,7 +497,7 @@ export function EditorDialogueManifestPanel(): React.JSX.Element { type="number" min="1" step="1" - value={selectedDialogue.subtitleCueIndex} + value={getDialogueFirstCueIndex(selectedDialogue) ?? ""} onChange={(event) => updateSelectedDialogue({ subtitleCueIndex: Math.max(1, Number(event.target.value)), diff --git a/src/components/editor/EditorSrtPanel.tsx b/src/components/editor/EditorSrtPanel.tsx index 78950ef..9ff26ec 100644 --- a/src/components/editor/EditorSrtPanel.tsx +++ b/src/components/editor/EditorSrtPanel.tsx @@ -7,6 +7,10 @@ import type { DialogueSpeaker, DialogueVoiceId, } from "@/types/dialogues/dialogues"; +import { + getDialogueCueIndices, + getDialogueFirstCueIndex, +} from "@/types/dialogues/dialogues"; import { logger } from "@/utils/core/Logger"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { @@ -181,7 +185,7 @@ function getExpectedCueIndexes( voice: DialogueVoiceId, ): number[] { return getExpectedDialogues(manifest, voice) - .map((dialogue) => dialogue.subtitleCueIndex) + .flatMap((dialogue) => getDialogueCueIndices(dialogue)) .filter( (cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index, ) @@ -196,7 +200,11 @@ function getExpectedDialogues( return [...manifest.dialogues] .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( @@ -577,7 +585,7 @@ export function EditorSrtPanel(): React.JSX.Element { )} {expectedDialogues.map((dialogue) => ( ))} @@ -585,7 +593,7 @@ export function EditorSrtPanel(): React.JSX.Element { {selectedDialogue && (
- Cue {selectedDialogue.subtitleCueIndex} + Cue {getDialogueFirstCueIndex(selectedDialogue) ?? "?"} {selectedDialogue.id}
)} diff --git a/src/types/dialogues/dialogues.ts b/src/types/dialogues/dialogues.ts index b95ef73..4a4785e 100644 --- a/src/types/dialogues/dialogues.ts +++ b/src/types/dialogues/dialogues.ts @@ -13,7 +13,8 @@ export interface DialogueDefinition { id: string; voice: DialogueVoiceId; audio: string; - subtitleCueIndex: number; + subtitleCueIndex?: number; + subtitleCueIndices?: number[]; timecode?: number; } @@ -22,3 +23,20 @@ export interface DialogueManifest { voices: DialogueVoice[]; 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]; +} diff --git a/src/utils/dialogues/dialogueManifestValidation.ts b/src/utils/dialogues/dialogueManifestValidation.ts index 2a43f08..3ed8822 100644 --- a/src/utils/dialogues/dialogueManifestValidation.ts +++ b/src/utils/dialogues/dialogueManifestValidation.ts @@ -93,13 +93,26 @@ function parseDialogueDefinition( throw new Error(`Dialogue ${data.id} has an invalid audio path`); } + // Support both subtitleCueIndex (legacy) and subtitleCueIndices (new) const subtitleCueIndex = data.subtitleCueIndex; - if ( - typeof subtitleCueIndex !== "number" || - !Number.isInteger(subtitleCueIndex) || - subtitleCueIndex < 1 - ) { - throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`); + const subtitleCueIndices = data.subtitleCueIndices; + + const hasLegacyIndex = + typeof subtitleCueIndex === "number" && + Number.isInteger(subtitleCueIndex) && + 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; @@ -111,9 +124,14 @@ function parseDialogueDefinition( id: data.id, voice: data.voice, audio: data.audio, - subtitleCueIndex, }; + if (hasNewIndices) { + dialogue.subtitleCueIndices = subtitleCueIndices as number[]; + } else if (hasLegacyIndex) { + dialogue.subtitleCueIndex = subtitleCueIndex; + } + if (timecode !== undefined) dialogue.timecode = timecode; return dialogue; diff --git a/src/utils/dialogues/loadDialogueManifest.ts b/src/utils/dialogues/loadDialogueManifest.ts index 718f4d9..e791bb5 100644 --- a/src/utils/dialogues/loadDialogueManifest.ts +++ b/src/utils/dialogues/loadDialogueManifest.ts @@ -3,6 +3,7 @@ import type { DialogueManifest, DialogueVoice, } from "@/types/dialogues/dialogues"; +import { getDialogueCueIndices } from "@/types/dialogues/dialogues"; import type { SubtitleLanguage } from "@/types/settings/settings"; import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation"; import { parseSrt } from "@/utils/subtitles/parseSrt"; @@ -17,6 +18,15 @@ export interface DialogueSubtitleCue { subtitlePath: string; } +/** + * Multiple subtitle cues for a single dialogue + */ +export interface DialogueSubtitleCues { + voice: DialogueVoice; + cues: SubtitleCue[]; + subtitlePath: string; +} + export async function loadDialogueManifest(): Promise { const response = await fetch(DIALOGUE_MANIFEST_PATH); @@ -39,21 +49,40 @@ export async function loadDialogueSubtitleCue( dialogue: DialogueDefinition, language: SubtitleLanguage, ): Promise { + 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 { const voice = getDialogueVoice(manifest, dialogue.voice); if (!voice) return null; const subtitles = await loadVoiceSubtitleCues(voice, language); if (!subtitles) return null; - const cue = subtitles.cues.find( - (item) => item.index === dialogue.subtitleCueIndex, - ); + const cueIndices = getDialogueCueIndices(dialogue); + 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 { voice, - cue, + cues, subtitlePath: subtitles.path, }; } diff --git a/src/utils/dialogues/playDialogue.ts b/src/utils/dialogues/playDialogue.ts index fd850d8..6943a17 100644 --- a/src/utils/dialogues/playDialogue.ts +++ b/src/utils/dialogues/playDialogue.ts @@ -3,7 +3,8 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import type { DialogueManifest } from "@/types/dialogues/dialogues"; 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 { manifest: DialogueManifest; @@ -15,6 +16,8 @@ const DIALOGUE_PLAY_START_TIMEOUT_MS = 800; const dialogueQueue: QueuedDialogueRequest[] = []; let isDialogueQueuePlaying = false; +let currentDialogueAudio: HTMLAudioElement | null = null; + export function queueDialogueById( manifest: DialogueManifest, 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( manifest: DialogueManifest, dialogueId: string, ): Promise { + stopCurrentDialogue(); + const dialogue = manifest.dialogues.find((item) => item.id === dialogueId); if (!dialogue) return null; const subtitleLanguage = useSettingsStore.getState().subtitleLanguage; - const subtitle = await loadDialogueSubtitleCue( + const subtitleData = await loadDialogueSubtitleCues( manifest, dialogue, subtitleLanguage, @@ -48,7 +62,11 @@ export async function playDialogueById( category: "dialogue", }); - if (!subtitle) return audio; + currentDialogueAudio = audio; + + if (!subtitleData || subtitleData.cues.length === 0) return audio; + + const { voice, cues } = subtitleData; const clearSubtitle = (): void => { useSubtitleStore.getState().clearActiveSubtitle(); @@ -60,18 +78,28 @@ export async function playDialogueById( audio.removeEventListener("ended", cleanup); audio.removeEventListener("pause", cleanup); 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 currentTime = audio.currentTime; - const shouldShowSubtitle = - currentTime >= subtitle.cue.startTime && - currentTime <= subtitle.cue.endTime; + const activeCue = findActiveCue(currentTime); - if (shouldShowSubtitle) { + if (activeCue) { useSubtitleStore.getState().setActiveSubtitle({ - speaker: subtitle.voice.speaker, - text: subtitle.cue.text, + speaker: voice.speaker, + text: activeCue.text, }); return; } -- 2.52.0 From 60e3c9251123fbbc1c47a1632f7a27ec3a5983bc Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 30 May 2026 17:06:29 +0200 Subject: [PATCH 06/20] fix(site): update situation cards --- src/components/site/SiteCard.tsx | 22 +++++++++++++++++---- src/components/site/SiteSituationScreen.tsx | 8 +++++--- src/data/site/siteConfig.ts | 10 +++++----- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/components/site/SiteCard.tsx b/src/components/site/SiteCard.tsx index 05d4eca..18d98e3 100644 --- a/src/components/site/SiteCard.tsx +++ b/src/components/site/SiteCard.tsx @@ -4,17 +4,21 @@ interface SiteCardProps { config: SiteCardConfig; selected: boolean; onSelect: () => void; + variant?: "default" | "situation"; } export function SiteCard({ config, selected, onSelect, + variant = "default", }: SiteCardProps): React.JSX.Element { const { label, imagePath, disabled } = config; + const isSituation = variant === "situation"; const getBackground = (): string => { if (imagePath) return `url(${imagePath}) center/cover`; + if (isSituation) return "rgba(255, 255, 255, 0.42)"; if (disabled) return "#b8b8b8"; if (selected) return "#d9d9d9"; return "#e8e8e8"; @@ -22,11 +26,14 @@ export function SiteCard({ const getBorder = (): string => { if (selected) return "3px solid #a8d5a2"; + if (isSituation) return "3px solid rgba(255, 255, 255, 0.55)"; if (disabled) return "none"; return "2px solid #ffffff"; }; const getTextColor = (): string => { + if (isSituation && disabled) return "rgba(77, 77, 77, 0.72)"; + if (isSituation) return "#4d4d4d"; if (disabled) return "#888888"; return "#666666"; }; @@ -37,8 +44,12 @@ export function SiteCard({ onClick={onSelect} disabled={disabled} style={{ - width: "clamp(120px, 15vw, 160px)", - height: "clamp(140px, 18vw, 180px)", + width: isSituation + ? "clamp(220px, 24vw, 300px)" + : "clamp(120px, 15vw, 160px)", + height: isSituation + ? "clamp(48px, 6vw, 60px)" + : "clamp(140px, 18vw, 180px)", border: getBorder(), background: getBackground(), cursor: disabled ? "not-allowed" : "pointer", @@ -54,10 +65,13 @@ export function SiteCard({ {label} diff --git a/src/components/site/SiteSituationScreen.tsx b/src/components/site/SiteSituationScreen.tsx index d24589d..52f21b0 100644 --- a/src/components/site/SiteSituationScreen.tsx +++ b/src/components/site/SiteSituationScreen.tsx @@ -52,10 +52,11 @@ export function SiteSituationScreen(): React.JSX.Element {
{SITUATION_CARDS.map((card, index) => ( @@ -63,6 +64,7 @@ export function SiteSituationScreen(): React.JSX.Element { key={card.id} config={card} selected={selectedSituation === index} + variant="situation" onSelect={() => { if (!card.disabled) { setSelectedSituation(index); diff --git a/src/data/site/siteConfig.ts b/src/data/site/siteConfig.ts index 59593c2..6f4d36c 100644 --- a/src/data/site/siteConfig.ts +++ b/src/data/site/siteConfig.ts @@ -24,12 +24,12 @@ export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [ * 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-refugie-climat", label: "Réfugié.e climatique", disabled: true }, + { id: "sit-refugie-guerre", label: "Réfugié.e de guerre", disabled: true }, { - id: "sit-refugies", - label: "Réfugiés Climatiques arrivants", + id: "sit-sans-domicile", + label: "Sans domicile fixe", disabled: false, }, - { id: "sit-seniors", label: "Seniors Hyper-Connectés", disabled: true }, + { id: "sit-autre", label: "Autre", disabled: true }, ]; -- 2.52.0 From 29342d796c9334d5ecb9b46630736351a1357823 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 30 May 2026 17:21:44 +0200 Subject: [PATCH 07/20] fix(site): reduce situation card font size --- src/components/site/SiteCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/site/SiteCard.tsx b/src/components/site/SiteCard.tsx index 18d98e3..3a50b71 100644 --- a/src/components/site/SiteCard.tsx +++ b/src/components/site/SiteCard.tsx @@ -66,7 +66,7 @@ export function SiteCard({ style={{ color: getTextColor(), fontSize: isSituation - ? "clamp(18px, 2.3vw, 27px)" + ? "clamp(14px, 1.8vw, 22px)" : "clamp(10px, 1.5vw, 14px)", fontWeight: isSituation ? 700 : 500, textAlign: "center", -- 2.52.0 From 6ae21a2427eccd981cefb324f23633a452dae12c Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 30 May 2026 17:56:42 +0200 Subject: [PATCH 08/20] fix(site): unified card styles, import Nersans One font, native naming input --- public/fonts/NersansOne.woff | Bin 0 -> 10892 bytes public/fonts/NersansOne.woff2 | Bin 0 -> 7372 bytes src/components/site/SiteCard.tsx | 16 ++++++---------- src/components/site/SiteNamingScreen.tsx | 19 +++++++------------ src/data/site/siteConfig.ts | 4 ++-- src/index.css | 10 ++++++++++ 6 files changed, 25 insertions(+), 24 deletions(-) create mode 100644 public/fonts/NersansOne.woff create mode 100644 public/fonts/NersansOne.woff2 diff --git a/public/fonts/NersansOne.woff b/public/fonts/NersansOne.woff new file mode 100644 index 0000000000000000000000000000000000000000..b030e6f80bc51306a2af906f215f6ae4cbb6cb7a GIT binary patch literal 10892 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 literal 0 HcmV?d00001 diff --git a/src/components/site/SiteCard.tsx b/src/components/site/SiteCard.tsx index 3a50b71..df2e1b3 100644 --- a/src/components/site/SiteCard.tsx +++ b/src/components/site/SiteCard.tsx @@ -18,24 +18,20 @@ export function SiteCard({ const getBackground = (): string => { if (imagePath) return `url(${imagePath}) center/cover`; - if (isSituation) return "rgba(255, 255, 255, 0.42)"; - if (disabled) return "#b8b8b8"; - if (selected) return "#d9d9d9"; - return "#e8e8e8"; + if (disabled) return "rgba(255, 255, 255, 0.42)"; + return "#b8b8b8"; }; const getBorder = (): string => { if (selected) return "3px solid #a8d5a2"; if (isSituation) return "3px solid rgba(255, 255, 255, 0.55)"; - if (disabled) return "none"; - return "2px solid #ffffff"; + if (disabled) return "3px solid rgba(255, 255, 255, 0.55)"; + return "3px solid rgba(255, 255, 255, 0.55)"; }; const getTextColor = (): string => { - if (isSituation && disabled) return "rgba(77, 77, 77, 0.72)"; - if (isSituation) return "#4d4d4d"; - if (disabled) return "#888888"; - return "#666666"; + if (disabled) return "rgba(77, 77, 77, 0.72)"; + return "#4d4d4d"; }; return ( diff --git a/src/components/site/SiteNamingScreen.tsx b/src/components/site/SiteNamingScreen.tsx index 5afe1d6..1e77712 100644 --- a/src/components/site/SiteNamingScreen.tsx +++ b/src/components/site/SiteNamingScreen.tsx @@ -38,16 +38,12 @@ export function SiteNamingScreen(): React.JSX.Element { inputRef.current?.focus(); }, []); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent): 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)); - } + const handleNameChange = useCallback( + (event: React.ChangeEvent): void => { + const nextLength = Math.min(event.target.value.length, forcedName.length); + setCharIndex(nextLength); }, - [isComplete, forcedName.length], + [forcedName.length], ); const handleConfirm = (): void => { @@ -99,8 +95,7 @@ export function SiteNamingScreen(): React.JSX.Element { ref={inputRef} type="text" value={displayValue} - onKeyDown={handleKeyDown} - readOnly + onChange={handleNameChange} placeholder="Écrivez votre prénom ici" style={{ display: "flex", @@ -114,7 +109,7 @@ export function SiteNamingScreen(): React.JSX.Element { background: "#D9D9D9", outline: "none", color: "#333", - caretColor: "transparent", + caretColor: "#333", fontFamily: "Inter, system-ui, sans-serif", fontSize: "clamp(16px, 2.5vw, 20px)", textAlign: "left", diff --git a/src/data/site/siteConfig.ts b/src/data/site/siteConfig.ts index 6f4d36c..b00dc02 100644 --- a/src/data/site/siteConfig.ts +++ b/src/data/site/siteConfig.ts @@ -24,11 +24,11 @@ export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [ * Cards for screen 2: "Quelle est votre situation ?" */ export const SITUATION_CARDS: readonly SiteCardConfig[] = [ - { id: "sit-refugie-climat", label: "Réfugié.e climatique", disabled: true }, + { id: "sit-refugie-climat", label: "Sans domicile fixe", disabled: true }, { id: "sit-refugie-guerre", label: "Réfugié.e de guerre", disabled: true }, { id: "sit-sans-domicile", - label: "Sans domicile fixe", + label: "Réfugié.e climatique", disabled: false, }, { id: "sit-autre", label: "Autre", disabled: true }, diff --git a/src/index.css b/src/index.css index bed3353..0b0e4ea 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,15 @@ @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); +@font-face { + font-family: "Nersans One"; + src: + url("/fonts/NersansOne.woff2") format("woff2"), + url("/fonts/NersansOne.woff") format("woff"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + /* Base document reset */ :root { color-scheme: dark; -- 2.52.0 From 0f6860f1ae8a097e4d1edb514b5937ae27638cc1 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 30 May 2026 18:43:35 +0200 Subject: [PATCH 09/20] refactor(site): extract shared utilities and centralise dialogue IDs - new src/hooks/ui/useIsMobile.ts (matchMedia + useSyncExternalStore) replacing the resize-handler hook inlined inside pages/site/page.tsx - new src/hooks/ui/usePrefersReducedMotion.ts - new src/data/site/dialogueIds.ts so site and intro components stop carrying hard-coded narrator IDs - siteConfig: add SITE_BACKGROUND_STYLE shared by SiteLayout and SiteMobileBlocker, rename forcedName to presetPlayerName, fix the swapped id/label pairing on situation cards - useSiteStore: rename selectedExperience/Situation to *Index so the stored value (an array index) is obvious in callers - audioConfig: drop dead AUDIO_PATHS placeholders - propagate the renames and SITE_BACKGROUND_STYLE through SiteLayout, SiteWelcomeScreen, SiteSituationScreen and pages/site/page.tsx --- src/components/site/SiteLayout.tsx | 8 ++--- src/components/site/SiteSituationScreen.tsx | 14 +++++---- src/components/site/SiteWelcomeScreen.tsx | 14 +++++---- src/data/audioConfig.ts | 8 ----- src/data/site/dialogueIds.ts | 9 ++++++ src/data/site/siteConfig.ts | 23 +++++++++++--- src/hooks/ui/useIsMobile.ts | 31 ++++++++++++++++++ src/hooks/ui/usePrefersReducedMotion.ts | 29 +++++++++++++++++ src/managers/stores/useSiteStore.ts | 17 +++++----- src/pages/site/page.tsx | 35 +-------------------- 10 files changed, 116 insertions(+), 72 deletions(-) create mode 100644 src/data/site/dialogueIds.ts create mode 100644 src/hooks/ui/useIsMobile.ts create mode 100644 src/hooks/ui/usePrefersReducedMotion.ts diff --git a/src/components/site/SiteLayout.tsx b/src/components/site/SiteLayout.tsx index 98ecf21..7af128d 100644 --- a/src/components/site/SiteLayout.tsx +++ b/src/components/site/SiteLayout.tsx @@ -1,5 +1,5 @@ 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"; interface SiteLayoutProps { @@ -16,11 +16,7 @@ export function SiteLayout({ children }: SiteLayoutProps): React.JSX.Element { flexDirection: "column", alignItems: "center", justifyContent: "center", - backgroundColor: "#87CEEB", - backgroundImage: `url(${SITE_CONFIG.backgroundImage})`, - backgroundSize: "cover", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", + ...SITE_BACKGROUND_STYLE, fontFamily: "system-ui, -apple-system, sans-serif", color: "#fff", overflow: "hidden", diff --git a/src/components/site/SiteSituationScreen.tsx b/src/components/site/SiteSituationScreen.tsx index 52f21b0..deaba42 100644 --- a/src/components/site/SiteSituationScreen.tsx +++ b/src/components/site/SiteSituationScreen.tsx @@ -7,13 +7,15 @@ 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 selectedSituationIndex = useSiteStore( + (state) => state.selectedSituationIndex, + ); + const setSelectedSituationIndex = useSiteStore( + (state) => state.setSelectedSituationIndex, ); const setStep = useSiteStore((state) => state.setStep); - const canProceed = selectedSituation !== null; + const canProceed = selectedSituationIndex !== null; const handleConfirm = (): void => { if (canProceed) { @@ -63,11 +65,11 @@ export function SiteSituationScreen(): React.JSX.Element { { if (!card.disabled) { - setSelectedSituation(index); + setSelectedSituationIndex(index); } }} /> diff --git a/src/components/site/SiteWelcomeScreen.tsx b/src/components/site/SiteWelcomeScreen.tsx index 9220053..d51a3b6 100644 --- a/src/components/site/SiteWelcomeScreen.tsx +++ b/src/components/site/SiteWelcomeScreen.tsx @@ -7,13 +7,15 @@ 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 selectedExperienceIndex = useSiteStore( + (state) => state.selectedExperienceIndex, + ); + const setSelectedExperienceIndex = useSiteStore( + (state) => state.setSelectedExperienceIndex, ); const setStep = useSiteStore((state) => state.setStep); - const canProceed = selectedExperience !== null; + const canProceed = selectedExperienceIndex !== null; const handleNext = (): void => { if (canProceed) { @@ -104,10 +106,10 @@ export function SiteWelcomeScreen(): React.JSX.Element { { if (!card.disabled) { - setSelectedExperience(index); + setSelectedExperienceIndex(index); } }} /> diff --git a/src/data/audioConfig.ts b/src/data/audioConfig.ts index bef58ef..e8a6a3f 100644 --- a/src/data/audioConfig.ts +++ b/src/data/audioConfig.ts @@ -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 const DEFAULT_CATEGORY_VOLUMES: Record = { diff --git a/src/data/site/dialogueIds.ts b/src/data/site/dialogueIds.ts new file mode 100644 index 0000000..9855bb7 --- /dev/null +++ b/src/data/site/dialogueIds.ts @@ -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; diff --git a/src/data/site/siteConfig.ts b/src/data/site/siteConfig.ts index b00dc02..b5c42d0 100644 --- a/src/data/site/siteConfig.ts +++ b/src/data/site/siteConfig.ts @@ -1,8 +1,23 @@ +import type { CSSProperties } from "react"; + +const BACKGROUND_IMAGE = "/assets/bg-site.png"; + export const SITE_CONFIG = { - backgroundImage: "/assets/bg-site.png", - forcedName: "Danyl", + backgroundImage: BACKGROUND_IMAGE, + presetPlayerName: "Danyl", } 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 { id: string; label: string; @@ -24,10 +39,10 @@ export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [ * Cards for screen 2: "Quelle est votre situation ?" */ 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-sans-domicile", + id: "sit-refugie-climat", label: "Réfugié.e climatique", disabled: false, }, diff --git a/src/hooks/ui/useIsMobile.ts b/src/hooks/ui/useIsMobile.ts new file mode 100644 index 0000000..590b911 --- /dev/null +++ b/src/hooks/ui/useIsMobile.ts @@ -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, + ); +} diff --git a/src/hooks/ui/usePrefersReducedMotion.ts b/src/hooks/ui/usePrefersReducedMotion.ts new file mode 100644 index 0000000..f16464d --- /dev/null +++ b/src/hooks/ui/usePrefersReducedMotion.ts @@ -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, + ); +} diff --git a/src/managers/stores/useSiteStore.ts b/src/managers/stores/useSiteStore.ts index 15e3fd1..85171f8 100644 --- a/src/managers/stores/useSiteStore.ts +++ b/src/managers/stores/useSiteStore.ts @@ -3,14 +3,14 @@ import type { SiteStep } from "@/types/game"; interface SiteState { currentStep: SiteStep; - selectedExperience: number | null; - selectedSituation: number | null; + selectedExperienceIndex: number | null; + selectedSituationIndex: number | null; } interface SiteActions { setStep: (step: SiteStep) => void; - setSelectedExperience: (index: number) => void; - setSelectedSituation: (index: number) => void; + setSelectedExperienceIndex: (index: number) => void; + setSelectedSituationIndex: (index: number) => void; reset: () => void; } @@ -18,14 +18,15 @@ type SiteStore = SiteState & SiteActions; const initialState: SiteState = { currentStep: "disclaimer", - selectedExperience: null, - selectedSituation: null, + selectedExperienceIndex: null, + selectedSituationIndex: null, }; export const useSiteStore = create()((set) => ({ ...initialState, setStep: (step) => set({ currentStep: step }), - setSelectedExperience: (index) => set({ selectedExperience: index }), - setSelectedSituation: (index) => set({ selectedSituation: index }), + setSelectedExperienceIndex: (index) => + set({ selectedExperienceIndex: index }), + setSelectedSituationIndex: (index) => set({ selectedSituationIndex: index }), reset: () => set(initialState), })); diff --git a/src/pages/site/page.tsx b/src/pages/site/page.tsx index ac889c3..722fea6 100644 --- a/src/pages/site/page.tsx +++ b/src/pages/site/page.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from "react"; import { useSiteStore } from "@/managers/stores/useSiteStore"; import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen"; import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen"; @@ -7,39 +6,7 @@ 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; -} +import { useIsMobile } from "@/hooks/ui/useIsMobile"; export function SitePage(): React.JSX.Element { const currentStep = useSiteStore((state) => state.currentStep); -- 2.52.0 From 07b09c22afd4f342cd8affb798c2b48d4a85aca8 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 30 May 2026 18:43:53 +0200 Subject: [PATCH 10/20] fix(site): repair onboarding audio cleanup, redirect, and manifest fetches - loadDialogueManifest: cache the resolved manifest at module level and dedupe concurrent fetches so each screen no longer re-downloads it - useGameStore: completeIntroState now also advances intro.currentStep to "playing" so callers do not need a separate setIntroStep call - SiteNamingScreen and SiteTransitionOverlay: replace ref-based guards with an isCancelled flag captured per effect. The previous guards persisted across StrictMode remounts, leaving mount 2 unable to re-run the effect after mount 1's chain was cancelled, which broke the fade animations, the second narrator dialogue and the redirect. Both screens now also call stopCurrentDialogue on unmount so audio cannot bleed across routes, and the transition gets a safety timeout in case the dialogue audio fails to fire its "ended" event - SiteTransitionOverlay: keep the mount inside the overlay so it renders inside the z-index 1000 stacking context (above the black screen); the one in SiteLayout sits behind it - IntroDialogueOverlay: route through playDialogueById instead of AudioManager.playSoundWithCallback so the narrator subtitles play in sync, and add the same isCancelled cleanup pattern - IntroRevealOverlay: rely on completeIntro alone now that it advances intro.currentStep, and skip the fade when reduced motion is requested - SiteMobileBlocker: correct logo path from public/... to /... --- src/components/site/SiteMobileBlocker.tsx | 21 +-- src/components/site/SiteNamingScreen.tsx | 60 ++++++-- src/components/site/SiteTransitionOverlay.tsx | 128 +++++++++++------- .../ui/intro/IntroDialogueOverlay.tsx | 59 ++++++-- .../ui/intro/IntroRevealOverlay.tsx | 20 +-- src/managers/stores/useGameStore.ts | 1 + src/utils/dialogues/loadDialogueManifest.ts | 21 ++- 7 files changed, 206 insertions(+), 104 deletions(-) diff --git a/src/components/site/SiteMobileBlocker.tsx b/src/components/site/SiteMobileBlocker.tsx index ef47a16..acce806 100644 --- a/src/components/site/SiteMobileBlocker.tsx +++ b/src/components/site/SiteMobileBlocker.tsx @@ -1,37 +1,28 @@ -import { SITE_CONFIG } from "@/data/site/siteConfig"; +import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig"; const MOBILE_TEXT = "Ce site a été conçu pour être utilisé sur ordinateur. Veuillez réessayer sur votre ordinateur pour une expérience optimale."; -/** - * Mobile blocker screen - */ export function SiteMobileBlocker(): React.JSX.Element { return (
Logo

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

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

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