update: add runtine camera keyframe
This commit is contained in:
@@ -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]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ interface MissionState {
|
|||||||
|
|
||||||
interface GameState {
|
interface GameState {
|
||||||
mainState: MainGameState;
|
mainState: MainGameState;
|
||||||
|
isCinematicPlaying: boolean;
|
||||||
intro: IntroState;
|
intro: IntroState;
|
||||||
bike: MissionState & {
|
bike: MissionState & {
|
||||||
isRepaired: boolean;
|
isRepaired: boolean;
|
||||||
@@ -41,6 +42,7 @@ interface GameState {
|
|||||||
|
|
||||||
interface GameActions {
|
interface GameActions {
|
||||||
setMainState: (mainState: MainGameState) => void;
|
setMainState: (mainState: MainGameState) => void;
|
||||||
|
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
|
||||||
setIntroState: (intro: Partial<IntroState>) => void;
|
setIntroState: (intro: Partial<IntroState>) => void;
|
||||||
setBikeState: (bike: Partial<GameState["bike"]>) => void;
|
setBikeState: (bike: Partial<GameState["bike"]>) => void;
|
||||||
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
|
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
|
||||||
@@ -168,6 +170,7 @@ function startOutroState(state: GameState): GameStateUpdate {
|
|||||||
function createInitialGameState(): GameState {
|
function createInitialGameState(): GameState {
|
||||||
return {
|
return {
|
||||||
mainState: "intro",
|
mainState: "intro",
|
||||||
|
isCinematicPlaying: false,
|
||||||
intro: {
|
intro: {
|
||||||
dialogueAudio: null,
|
dialogueAudio: null,
|
||||||
hasCompleted: false,
|
hasCompleted: false,
|
||||||
@@ -198,6 +201,7 @@ function createInitialGameState(): GameState {
|
|||||||
export const useGameStore = create<GameStore>()((set) => ({
|
export const useGameStore = create<GameStore>()((set) => ({
|
||||||
...createInitialGameState(),
|
...createInitialGameState(),
|
||||||
setMainState: (mainState) => set({ mainState }),
|
setMainState: (mainState) => set({ mainState }),
|
||||||
|
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
|
||||||
setIntroState: (intro) =>
|
setIntroState: (intro) =>
|
||||||
set((state) => ({ intro: { ...state.intro, ...intro } })),
|
set((state) => ({ intro: { ...state.intro, ...intro } })),
|
||||||
setBikeState: (bike) =>
|
setBikeState: (bike) =>
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@@ -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<CinematicManifest | null> {
|
||||||
|
const response = await fetch(CINEMATIC_MANIFEST_PATH);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseCinematicManifest(await response.json());
|
||||||
|
}
|
||||||
@@ -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<CinematicManifest | null>(null);
|
||||||
|
const playedCinematicsRef = useRef(new Set<string>());
|
||||||
|
const timelineRef = useRef<gsap.core.Timeline | null>(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<gsap.core.Timeline | null>,
|
||||||
|
): void {
|
||||||
|
timelineRef.current?.kill();
|
||||||
|
timelineRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playCinematic(
|
||||||
|
camera: THREE.Camera,
|
||||||
|
cinematic: CinematicDefinition,
|
||||||
|
timelineRef: MutableRefObject<gsap.core.Timeline | null>,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControl
|
|||||||
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
||||||
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
||||||
import { Environment } from "@/world/Environment";
|
import { Environment } from "@/world/Environment";
|
||||||
|
import { GameCinematics } from "@/world/GameCinematics";
|
||||||
import { GameDialogues } from "@/world/GameDialogues";
|
import { GameDialogues } from "@/world/GameDialogues";
|
||||||
import { GameMusic } from "@/world/GameMusic";
|
import { GameMusic } from "@/world/GameMusic";
|
||||||
import { Lighting } from "@/world/Lighting";
|
import { Lighting } from "@/world/Lighting";
|
||||||
@@ -43,6 +44,7 @@ export function World(): React.JSX.Element {
|
|||||||
{sceneMode === "game" ? (
|
{sceneMode === "game" ? (
|
||||||
<>
|
<>
|
||||||
<GameMusic />
|
<GameMusic />
|
||||||
|
<GameCinematics />
|
||||||
<GameDialogues />
|
<GameDialogues />
|
||||||
<GameMap onOctreeReady={setOctree} />
|
<GameMap onOctreeReady={setOctree} />
|
||||||
<GameStageContent />
|
<GameStageContent />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
PLAYER_XZ_DAMPING_FACTOR,
|
PLAYER_XZ_DAMPING_FACTOR,
|
||||||
} from "@/data/player/playerConfig";
|
} from "@/data/player/playerConfig";
|
||||||
import { InteractionManager } from "@/managers/InteractionManager";
|
import { InteractionManager } from "@/managers/InteractionManager";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
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 _translateVec = new THREE.Vector3();
|
||||||
const _collisionCorrection = 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 {
|
function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean {
|
||||||
switch (key.toLowerCase()) {
|
switch (key.toLowerCase()) {
|
||||||
case MOVE_FORWARD_KEY:
|
case MOVE_FORWARD_KEY:
|
||||||
@@ -109,7 +117,7 @@ export function PlayerController({
|
|||||||
const interaction = InteractionManager.getInstance();
|
const interaction = InteractionManager.getInstance();
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
if (useSettingsStore.getState().isSettingsMenuOpen) return;
|
if (isPlayerInputLocked()) return;
|
||||||
|
|
||||||
if (setMovementKey(keys.current, event.key, true)) {
|
if (setMovementKey(keys.current, event.key, true)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -131,7 +139,7 @@ export function PlayerController({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyUp = (event: KeyboardEvent): void => {
|
const handleKeyUp = (event: KeyboardEvent): void => {
|
||||||
if (useSettingsStore.getState().isSettingsMenuOpen) return;
|
if (isPlayerInputLocked()) return;
|
||||||
|
|
||||||
if (setMovementKey(keys.current, event.key, false)) {
|
if (setMovementKey(keys.current, event.key, false)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -139,7 +147,7 @@ export function PlayerController({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (event: MouseEvent): void => {
|
const handleMouseDown = (event: MouseEvent): void => {
|
||||||
if (useSettingsStore.getState().isSettingsMenuOpen) return;
|
if (isPlayerInputLocked()) return;
|
||||||
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
||||||
if (interaction.getState().focused?.kind === "grab") {
|
if (interaction.getState().focused?.kind === "grab") {
|
||||||
interaction.pressInteract();
|
interaction.pressInteract();
|
||||||
@@ -147,7 +155,7 @@ export function PlayerController({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = (event: MouseEvent): void => {
|
const handleMouseUp = (event: MouseEvent): void => {
|
||||||
if (useSettingsStore.getState().isSettingsMenuOpen) return;
|
if (isPlayerInputLocked()) return;
|
||||||
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
||||||
if (interaction.getState().holding) {
|
if (interaction.getState().holding) {
|
||||||
interaction.releaseInteract();
|
interaction.releaseInteract();
|
||||||
@@ -169,7 +177,7 @@ export function PlayerController({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (useSettingsStore.getState().isSettingsMenuOpen) {
|
if (isPlayerInputLocked()) {
|
||||||
keys.current = { ...DEFAULT_KEYS };
|
keys.current = { ...DEFAULT_KEYS };
|
||||||
velocity.current.set(0, 0, 0);
|
velocity.current.set(0, 0, 0);
|
||||||
wantsJump.current = false;
|
wantsJump.current = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user