diff --git a/public/cinematics.json b/public/cinematics.json new file mode 100644 index 0000000..b7be4c7 --- /dev/null +++ b/public/cinematics.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "cinematics": [ + { + "id": "intro_overview", + "timecode": 0, + "cameraKeyframes": [ + { + "time": 0, + "position": [8, 5, 12], + "target": [0, 2, 0] + }, + { + "time": 4, + "position": [12, 4, -6], + "target": [10, 1.4, -8] + } + ] + } + ] +} diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index c58bc89..0b5a463 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -23,6 +23,7 @@ interface MissionState { interface GameState { mainState: MainGameState; + isCinematicPlaying: boolean; intro: IntroState; bike: MissionState & { isRepaired: boolean; @@ -41,6 +42,7 @@ interface GameState { interface GameActions { setMainState: (mainState: MainGameState) => void; + setCinematicPlaying: (isCinematicPlaying: boolean) => void; setIntroState: (intro: Partial) => void; setBikeState: (bike: Partial) => void; setPyloneState: (pylone: Partial) => void; @@ -168,6 +170,7 @@ function startOutroState(state: GameState): GameStateUpdate { function createInitialGameState(): GameState { return { mainState: "intro", + isCinematicPlaying: false, intro: { dialogueAudio: null, hasCompleted: false, @@ -198,6 +201,7 @@ function createInitialGameState(): GameState { export const useGameStore = create()((set) => ({ ...createInitialGameState(), setMainState: (mainState) => set({ mainState }), + setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }), setIntroState: (intro) => set((state) => ({ intro: { ...state.intro, ...intro } })), setBikeState: (bike) => diff --git a/src/types/cinematics/cinematics.ts b/src/types/cinematics/cinematics.ts new file mode 100644 index 0000000..589149e --- /dev/null +++ b/src/types/cinematics/cinematics.ts @@ -0,0 +1,18 @@ +import type { Vector3Tuple } from "@/types/three/three"; + +export interface CinematicCameraKeyframe { + time: number; + position: Vector3Tuple; + target: Vector3Tuple; +} + +export interface CinematicDefinition { + id: string; + timecode?: number; + cameraKeyframes: CinematicCameraKeyframe[]; +} + +export interface CinematicManifest { + version: 1; + cinematics: CinematicDefinition[]; +} diff --git a/src/utils/cinematics/cinematicManifestValidation.ts b/src/utils/cinematics/cinematicManifestValidation.ts new file mode 100644 index 0000000..655a47b --- /dev/null +++ b/src/utils/cinematics/cinematicManifestValidation.ts @@ -0,0 +1,82 @@ +import type { + CinematicCameraKeyframe, + CinematicDefinition, + CinematicManifest, +} from "@/types/cinematics/cinematics"; +import type { Vector3Tuple } from "@/types/three/three"; + +export function parseCinematicManifest(data: unknown): CinematicManifest { + if (!isRecord(data) || data.version !== 1) { + throw new Error("Invalid cinematic manifest version"); + } + + if (!Array.isArray(data.cinematics)) { + throw new Error("Cinematic manifest requires a cinematics array"); + } + + return { + version: 1, + cinematics: data.cinematics.map(parseCinematicDefinition), + }; +} + +function parseCinematicDefinition(data: unknown): CinematicDefinition { + if (!isRecord(data) || typeof data.id !== "string") { + throw new Error("Invalid cinematic definition"); + } + + if (!Array.isArray(data.cameraKeyframes)) { + throw new Error(`Cinematic ${data.id} requires cameraKeyframes`); + } + + const cameraKeyframes = data.cameraKeyframes.map(parseCameraKeyframe); + if (cameraKeyframes.length < 2) { + throw new Error(`Cinematic ${data.id} requires at least two keyframes`); + } + + cameraKeyframes.forEach((keyframe, index) => { + const previousKeyframe = cameraKeyframes[index - 1]; + if (previousKeyframe && keyframe.time <= previousKeyframe.time) { + throw new Error(`Cinematic ${data.id} keyframe times must increase`); + } + }); + + const cinematic: CinematicDefinition = { + id: data.id, + cameraKeyframes, + }; + + if (typeof data.timecode === "number") { + cinematic.timecode = data.timecode; + } + + return cinematic; +} + +function parseCameraKeyframe(data: unknown): CinematicCameraKeyframe { + if (!isRecord(data) || typeof data.time !== "number") { + throw new Error("Invalid cinematic camera keyframe"); + } + + return { + time: data.time, + position: parseVector3(data.position), + target: parseVector3(data.target), + }; +} + +function parseVector3(value: unknown): Vector3Tuple { + if ( + !Array.isArray(value) || + value.length !== 3 || + value.some((item) => typeof item !== "number") + ) { + throw new Error("Invalid cinematic vector"); + } + + return [value[0], value[1], value[2]]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/utils/cinematics/loadCinematicManifest.ts b/src/utils/cinematics/loadCinematicManifest.ts new file mode 100644 index 0000000..2ed1d29 --- /dev/null +++ b/src/utils/cinematics/loadCinematicManifest.ts @@ -0,0 +1,14 @@ +import type { CinematicManifest } from "@/types/cinematics/cinematics"; +import { parseCinematicManifest } from "@/utils/cinematics/cinematicManifestValidation"; + +const CINEMATIC_MANIFEST_PATH = "/cinematics.json"; + +export async function loadCinematicManifest(): Promise { + const response = await fetch(CINEMATIC_MANIFEST_PATH); + + if (!response.ok) { + return null; + } + + return parseCinematicManifest(await response.json()); +} diff --git a/src/world/GameCinematics.tsx b/src/world/GameCinematics.tsx new file mode 100644 index 0000000..7cda405 --- /dev/null +++ b/src/world/GameCinematics.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from "react"; +import type { MutableRefObject } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import gsap from "gsap"; +import * as THREE from "three"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import type { + CinematicDefinition, + CinematicManifest, +} from "@/types/cinematics/cinematics"; +import { logger } from "@/utils/core/logger"; +import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest"; + +export function GameCinematics(): null { + const camera = useThree((state) => state.camera); + const [manifest, setManifest] = useState(null); + const playedCinematicsRef = useRef(new Set()); + const timelineRef = useRef(null); + + useEffect(() => { + let mounted = true; + + void loadCinematicManifest() + .then((loadedManifest) => { + if (mounted) setManifest(loadedManifest); + }) + .catch((error: unknown) => { + logger.error("GameCinematics", "Failed to load cinematic manifest", { + error: error instanceof Error ? error : String(error), + }); + }); + + return () => { + mounted = false; + stopActiveCinematic(timelineRef); + useGameStore.getState().setCinematicPlaying(false); + }; + }, []); + + useFrame(({ clock }) => { + if (!manifest) return; + + const elapsedTime = clock.getElapsedTime(); + + manifest.cinematics.forEach((cinematic) => { + if (cinematic.timecode === undefined) return; + if (cinematic.timecode > elapsedTime) return; + if (playedCinematicsRef.current.has(cinematic.id)) return; + + playedCinematicsRef.current.add(cinematic.id); + playCinematic(camera, cinematic, timelineRef); + }); + }); + + return null; +} + +function stopActiveCinematic( + timelineRef: MutableRefObject, +): void { + timelineRef.current?.kill(); + timelineRef.current = null; +} + +function playCinematic( + camera: THREE.Camera, + cinematic: CinematicDefinition, + timelineRef: MutableRefObject, +): void { + const firstKeyframe = cinematic.cameraKeyframes[0]; + if (!firstKeyframe) return; + + document.exitPointerLock(); + timelineRef.current?.kill(); + useGameStore.getState().setCinematicPlaying(true); + + const target = new THREE.Vector3(...firstKeyframe.target); + camera.position.set(...firstKeyframe.position); + camera.lookAt(target); + + const timeline = gsap.timeline({ + onUpdate: () => camera.lookAt(target), + onComplete: () => { + timelineRef.current = null; + useGameStore.getState().setCinematicPlaying(false); + }, + }); + + cinematic.cameraKeyframes.slice(1).forEach((keyframe, index) => { + const previousKeyframe = cinematic.cameraKeyframes[index]; + if (!previousKeyframe) return; + + const duration = keyframe.time - previousKeyframe.time; + timeline.to( + camera.position, + { + x: keyframe.position[0], + y: keyframe.position[1], + z: keyframe.position[2], + duration, + ease: "power2.inOut", + }, + previousKeyframe.time, + ); + timeline.to( + target, + { + x: keyframe.target[0], + y: keyframe.target[1], + z: keyframe.target[2], + duration, + ease: "power2.inOut", + }, + previousKeyframe.time, + ); + }); + + timelineRef.current = timeline; +} diff --git a/src/world/World.tsx b/src/world/World.tsx index eb22071..454915e 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -10,6 +10,7 @@ import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControl import { DebugHelpers } from "@/components/debug/scene/DebugHelpers"; import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove"; import { Environment } from "@/world/Environment"; +import { GameCinematics } from "@/world/GameCinematics"; import { GameDialogues } from "@/world/GameDialogues"; import { GameMusic } from "@/world/GameMusic"; import { Lighting } from "@/world/Lighting"; @@ -43,6 +44,7 @@ export function World(): React.JSX.Element { {sceneMode === "game" ? ( <> + diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx index d7c3e32..e7a3fb6 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -24,6 +24,7 @@ import { PLAYER_XZ_DAMPING_FACTOR, } from "@/data/player/playerConfig"; import { InteractionManager } from "@/managers/InteractionManager"; +import { useGameStore } from "@/managers/stores/useGameStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import type { Vector3Tuple } from "@/types/three/three"; @@ -55,6 +56,13 @@ const _up = new THREE.Vector3(0, 1, 0); const _translateVec = new THREE.Vector3(); const _collisionCorrection = new THREE.Vector3(); +function isPlayerInputLocked(): boolean { + return ( + useSettingsStore.getState().isSettingsMenuOpen || + useGameStore.getState().isCinematicPlaying + ); +} + function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean { switch (key.toLowerCase()) { case MOVE_FORWARD_KEY: @@ -109,7 +117,7 @@ export function PlayerController({ const interaction = InteractionManager.getInstance(); const handleKeyDown = (event: KeyboardEvent): void => { - if (useSettingsStore.getState().isSettingsMenuOpen) return; + if (isPlayerInputLocked()) return; if (setMovementKey(keys.current, event.key, true)) { event.preventDefault(); @@ -131,7 +139,7 @@ export function PlayerController({ }; const handleKeyUp = (event: KeyboardEvent): void => { - if (useSettingsStore.getState().isSettingsMenuOpen) return; + if (isPlayerInputLocked()) return; if (setMovementKey(keys.current, event.key, false)) { event.preventDefault(); @@ -139,7 +147,7 @@ export function PlayerController({ }; const handleMouseDown = (event: MouseEvent): void => { - if (useSettingsStore.getState().isSettingsMenuOpen) return; + if (isPlayerInputLocked()) return; if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return; if (interaction.getState().focused?.kind === "grab") { interaction.pressInteract(); @@ -147,7 +155,7 @@ export function PlayerController({ }; const handleMouseUp = (event: MouseEvent): void => { - if (useSettingsStore.getState().isSettingsMenuOpen) return; + if (isPlayerInputLocked()) return; if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return; if (interaction.getState().holding) { interaction.releaseInteract(); @@ -169,7 +177,7 @@ export function PlayerController({ }, []); useFrame((_, delta) => { - if (useSettingsStore.getState().isSettingsMenuOpen) { + if (isPlayerInputLocked()) { keys.current = { ...DEFAULT_KEYS }; velocity.current.set(0, 0, 0); wantsJump.current = false;