outro anim + vid
This commit is contained in:
@@ -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<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(
|
||||
mainState === "farm" && step === "electricienne_history",
|
||||
handleAudioEnded,
|
||||
);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
<Subtitles />
|
||||
<TalkieDialogueOverlay />
|
||||
<GameSettingsMenu />
|
||||
<OutroVideoOverlay />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user