outro anim + vid
This commit is contained in:
+6
-12
@@ -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;
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user