From 10b0d4fc16bcb1c9b3dfa177da5f32b2735d5fa6 Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:45:43 +0200 Subject: [PATCH] outro anim + vid --- public/cinematics.json | 18 ++---- .../gameplay/farm/FarmNarrativeFlow.tsx | 48 ++++++++++++++-- .../gameplay/pylon/PylonNarrativeFlow.tsx | 13 +++++ src/components/ui/GameUI.tsx | 2 + src/components/ui/OutroVideoOverlay.tsx | 55 +++++++++++++++++++ src/world/GameCinematics.tsx | 10 +++- 6 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 src/components/ui/OutroVideoOverlay.tsx diff --git a/public/cinematics.json b/public/cinematics.json index c05949a..56d2986 100644 --- a/public/cinematics.json +++ b/public/cinematics.json @@ -2,24 +2,18 @@ "version": 1, "cinematics": [ { - "id": "intro_overview", + "id": "outro_farm_drone", "timecode": 0, - "dialogueCues": [ - { - "time": 0, - "dialogueId": "narrateur_bienvenueaaltera" - } - ], "cameraKeyframes": [ { "time": 0, - "position": [8, 5, 12], - "target": [0, 2, 0] + "position": [-24, 5, 65], + "target": [-24, 2, 42] }, { - "time": 4, - "position": [12, 4, -6], - "target": [10, 1.4, -8] + "time": 10, + "position": [-24, 90, 200], + "target": [-24, 0, 42] } ] } diff --git a/src/components/gameplay/farm/FarmNarrativeFlow.tsx b/src/components/gameplay/farm/FarmNarrativeFlow.tsx index 172defc..19bb1a5 100644 --- a/src/components/gameplay/farm/FarmNarrativeFlow.tsx +++ b/src/components/gameplay/farm/FarmNarrativeFlow.tsx @@ -1,10 +1,10 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useGameStore } from "@/managers/stores/useGameStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import { AudioManager } from "@/managers/AudioManager"; - const HISTOIRE_AUDIO_PATH = "/sounds/dialogue/narrateur_histoireelectricienne.mp3"; +const OUTRO_DELAY_MS = 5_000; // delay after audio ends before transitioning to outro /** * Text blocks for the electricienne history narration (max 5 lines each). @@ -39,8 +39,18 @@ function buildBlockTimings( * dynamically-computed block boundaries. * Movement is intentionally NOT blocked so the player can explore while * listening to the narration. + * `onAudioEnded` fires once when the audio element emits "ended". */ -function useHistoireSubtitlePlayback(enabled: boolean): void { +function useHistoireSubtitlePlayback( + enabled: boolean, + onAudioEnded?: () => void, +): void { + // Keep callback in a ref so the effect doesn't need it as a dependency. + const onAudioEndedRef = useRef(onAudioEnded); + useEffect(() => { + onAudioEndedRef.current = onAudioEnded; + }); + useEffect(() => { if (!enabled) return undefined; @@ -75,8 +85,13 @@ function useHistoireSubtitlePlayback(enabled: boolean): void { } } + function onEnded(): void { + clearActiveSubtitle(); + onAudioEndedRef.current?.(); + } + audio.addEventListener("timeupdate", onTimeUpdate); - audio.addEventListener("ended", clearActiveSubtitle, { once: true }); + audio.addEventListener("ended", onEnded, { once: true }); } // If duration is already known (cached audio), start immediately. @@ -97,11 +112,13 @@ function useHistoireSubtitlePlayback(enabled: boolean): void { /** * Handles the farm mission narrative intro: * locked → (auto) → electricienne_history → plays audio with block subtitles + * → 5 s after audio ends → completeMission("farm") → outro */ export function FarmNarrativeFlow(): null { const mainState = useGameStore((state) => state.mainState); const step = useGameStore((state) => state.farm.currentStep); const setMissionStep = useGameStore((state) => state.setMissionStep); + const completeMission = useGameStore((state) => state.completeMission); // locked is purely a gate — transition immediately to electricienne_history. useEffect(() => { @@ -117,8 +134,31 @@ export function FarmNarrativeFlow(): null { setCanMove(true); }, [mainState, step, setCanMove]); + // After the audio finishes, wait 5 s then transition to outro. + // The timeout ID is kept in a ref so we can cancel on unmount. + const outroTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (outroTimeoutRef.current !== null) { + window.clearTimeout(outroTimeoutRef.current); + } + }; + }, []); + + const handleAudioEnded = (): void => { + if (outroTimeoutRef.current !== null) { + window.clearTimeout(outroTimeoutRef.current); + } + outroTimeoutRef.current = window.setTimeout(() => { + outroTimeoutRef.current = null; + completeMission("farm"); + }, OUTRO_DELAY_MS); + }; + useHistoireSubtitlePlayback( mainState === "farm" && step === "electricienne_history", + handleAudioEnded, ); return null; diff --git a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx index 02e2944..c3869ff 100644 --- a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx +++ b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx @@ -82,6 +82,19 @@ export function PylonNarrativeFlow(): React.JSX.Element | null { dialogueId: PYLON_NARRATIVE_DIALOGUES.searchCentral, }); + // ── inspected (demo skip) : jump straight to done after 5 s ───────────── + useEffect(() => { + if (mainState !== "pylon" || step !== "inspected") return undefined; + + const timeoutId = window.setTimeout(() => { + setMissionStep("pylon", "done"); + }, 5_000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [mainState, step, setMissionStep]); + // ── done : powerup sfx + lighting revert → auto-transition to narrator-outro useEffect(() => { if (mainState !== "pylon" || step !== "done") return undefined; diff --git a/src/components/ui/GameUI.tsx b/src/components/ui/GameUI.tsx index c7a1b49..daba721 100644 --- a/src/components/ui/GameUI.tsx +++ b/src/components/ui/GameUI.tsx @@ -4,6 +4,7 @@ import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu"; import { HandTrackingFallback } from "@/components/ui/HandTrackingFallback"; import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer"; import { InteractPrompt } from "@/components/ui/InteractPrompt"; +import { OutroVideoOverlay } from "@/components/ui/OutroVideoOverlay"; import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator"; import { Subtitles } from "@/components/ui/Subtitles"; import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay"; @@ -20,6 +21,7 @@ export function GameUI(): React.JSX.Element { + ); } diff --git a/src/components/ui/OutroVideoOverlay.tsx b/src/components/ui/OutroVideoOverlay.tsx new file mode 100644 index 0000000..dd7ac62 --- /dev/null +++ b/src/components/ui/OutroVideoOverlay.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from "react"; + +const OUTRO_VIDEO_SRC = "/cinematics/outro.mp4"; + +/** + * Full-screen video overlay that plays once after the outro drone-shot + * cinematic ends. Triggered by the "outro-cinematic-complete" window event + * dispatched from GameCinematics.tsx. + */ +export function OutroVideoOverlay(): React.JSX.Element | null { + const [visible, setVisible] = useState(false); + const videoRef = useRef(null); + + useEffect(() => { + function handleCinematicComplete(): void { + setVisible(true); + } + + window.addEventListener("outro-cinematic-complete", handleCinematicComplete); + return () => { + window.removeEventListener( + "outro-cinematic-complete", + handleCinematicComplete, + ); + }; + }, []); + + useEffect(() => { + if (!visible) return; + void videoRef.current?.play(); + }, [visible]); + + if (!visible) return null; + + return ( +
+
+ ); +} diff --git a/src/world/GameCinematics.tsx b/src/world/GameCinematics.tsx index 8470a35..364d297 100644 --- a/src/world/GameCinematics.tsx +++ b/src/world/GameCinematics.tsx @@ -118,7 +118,15 @@ function playCinematic( onUpdate: () => camera.lookAt(target), onComplete: () => { timelineRef.current = null; - useGameStore.getState().setCinematicPlaying(false); + // During the outro the camera is intentionally left at its final + // position — don't release cinematic lock so the player camera system + // can't snap it back to the player's eye position. + const { mainState } = useGameStore.getState(); + if (mainState === "outro") { + window.dispatchEvent(new CustomEvent("outro-cinematic-complete")); + } else { + useGameStore.getState().setCinematicPlaying(false); + } }, });