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()} ); }