outro anim + vid

This commit is contained in:
math-pixel
2026-06-03 01:45:43 +02:00
parent b1037d5107
commit 10b0d4fc16
6 changed files with 129 additions and 17 deletions
+6 -12
View File
@@ -2,24 +2,18 @@
"version": 1, "version": 1,
"cinematics": [ "cinematics": [
{ {
"id": "intro_overview", "id": "outro_farm_drone",
"timecode": 0, "timecode": 0,
"dialogueCues": [
{
"time": 0,
"dialogueId": "narrateur_bienvenueaaltera"
}
],
"cameraKeyframes": [ "cameraKeyframes": [
{ {
"time": 0, "time": 0,
"position": [8, 5, 12], "position": [-24, 5, 65],
"target": [0, 2, 0] "target": [-24, 2, 42]
}, },
{ {
"time": 4, "time": 10,
"position": [12, 4, -6], "position": [-24, 90, 200],
"target": [10, 1.4, -8] "target": [-24, 0, 42]
} }
] ]
} }
@@ -1,10 +1,10 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import { AudioManager } from "@/managers/AudioManager"; import { AudioManager } from "@/managers/AudioManager";
const HISTOIRE_AUDIO_PATH = "/sounds/dialogue/narrateur_histoireelectricienne.mp3"; 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). * Text blocks for the electricienne history narration (max 5 lines each).
@@ -39,8 +39,18 @@ function buildBlockTimings(
* dynamically-computed block boundaries. * dynamically-computed block boundaries.
* Movement is intentionally NOT blocked so the player can explore while * Movement is intentionally NOT blocked so the player can explore while
* listening to the narration. * 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(() => { useEffect(() => {
if (!enabled) return undefined; 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("timeupdate", onTimeUpdate);
audio.addEventListener("ended", clearActiveSubtitle, { once: true }); audio.addEventListener("ended", onEnded, { once: true });
} }
// If duration is already known (cached audio), start immediately. // If duration is already known (cached audio), start immediately.
@@ -97,11 +112,13 @@ function useHistoireSubtitlePlayback(enabled: boolean): void {
/** /**
* Handles the farm mission narrative intro: * Handles the farm mission narrative intro:
* locked → (auto) → electricienne_history → plays audio with block subtitles * locked → (auto) → electricienne_history → plays audio with block subtitles
* → 5 s after audio ends → completeMission("farm") → outro
*/ */
export function FarmNarrativeFlow(): null { export function FarmNarrativeFlow(): null {
const mainState = useGameStore((state) => state.mainState); const mainState = useGameStore((state) => state.mainState);
const step = useGameStore((state) => state.farm.currentStep); const step = useGameStore((state) => state.farm.currentStep);
const setMissionStep = useGameStore((state) => state.setMissionStep); const setMissionStep = useGameStore((state) => state.setMissionStep);
const completeMission = useGameStore((state) => state.completeMission);
// locked is purely a gate — transition immediately to electricienne_history. // locked is purely a gate — transition immediately to electricienne_history.
useEffect(() => { useEffect(() => {
@@ -117,8 +134,31 @@ export function FarmNarrativeFlow(): null {
setCanMove(true); setCanMove(true);
}, [mainState, step, setCanMove]); }, [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<ReturnType<typeof window.setTimeout> | 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( useHistoireSubtitlePlayback(
mainState === "farm" && step === "electricienne_history", mainState === "farm" && step === "electricienne_history",
handleAudioEnded,
); );
return null; return null;
@@ -82,6 +82,19 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
dialogueId: PYLON_NARRATIVE_DIALOGUES.searchCentral, 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 // ── done : powerup sfx + lighting revert → auto-transition to narrator-outro
useEffect(() => { useEffect(() => {
if (mainState !== "pylon" || step !== "done") return undefined; if (mainState !== "pylon" || step !== "done") return undefined;
+2
View File
@@ -4,6 +4,7 @@ import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
import { HandTrackingFallback } from "@/components/ui/HandTrackingFallback"; import { HandTrackingFallback } from "@/components/ui/HandTrackingFallback";
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer"; import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { OutroVideoOverlay } from "@/components/ui/OutroVideoOverlay";
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator"; import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
import { Subtitles } from "@/components/ui/Subtitles"; import { Subtitles } from "@/components/ui/Subtitles";
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay"; import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
@@ -20,6 +21,7 @@ export function GameUI(): React.JSX.Element {
<Subtitles /> <Subtitles />
<TalkieDialogueOverlay /> <TalkieDialogueOverlay />
<GameSettingsMenu /> <GameSettingsMenu />
<OutroVideoOverlay />
</> </>
); );
} }
+55
View File
@@ -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<HTMLVideoElement>(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 (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 10000,
background: "#000",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<video
ref={videoRef}
src={OUTRO_VIDEO_SRC}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
playsInline
/>
</div>
);
}
+8
View File
@@ -118,7 +118,15 @@ function playCinematic(
onUpdate: () => camera.lookAt(target), onUpdate: () => camera.lookAt(target),
onComplete: () => { onComplete: () => {
timelineRef.current = null; timelineRef.current = null;
// 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); useGameStore.getState().setCinematicPlaying(false);
}
}, },
}); });