Merge branch 'develop' into feat/repair-game
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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 type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||
import { logger } from "@/utils/core/logger";
|
||||
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { queueDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
export function GameCinematics(): null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||
const [dialogueManifest, setDialogueManifest] =
|
||||
useState<DialogueManifest | null>(null);
|
||||
const playedCinematicsRef = useRef(new Set<string>());
|
||||
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const activeAudios = activeAudiosRef.current;
|
||||
|
||||
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),
|
||||
});
|
||||
});
|
||||
|
||||
void loadDialogueManifest()
|
||||
.then((loadedManifest) => {
|
||||
if (mounted) setDialogueManifest(loadedManifest);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error("GameCinematics", "Failed to load dialogue manifest", {
|
||||
error: error instanceof Error ? error : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
stopActiveCinematic(timelineRef);
|
||||
activeAudios.forEach((audio) => audio.pause());
|
||||
activeAudios.clear();
|
||||
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 (cinematic.dialogueCues && !dialogueManifest) return;
|
||||
if (playedCinematicsRef.current.has(cinematic.id)) return;
|
||||
|
||||
playedCinematicsRef.current.add(cinematic.id);
|
||||
playCinematic(camera, cinematic, timelineRef, {
|
||||
dialogueManifest,
|
||||
activeAudiosRef,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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>,
|
||||
dialogueOptions: {
|
||||
dialogueManifest: DialogueManifest | null;
|
||||
activeAudiosRef: MutableRefObject<Set<HTMLAudioElement>>;
|
||||
},
|
||||
): 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,
|
||||
);
|
||||
});
|
||||
|
||||
cinematic.dialogueCues?.forEach((cue) => {
|
||||
timeline.call(
|
||||
() => {
|
||||
if (!dialogueOptions.dialogueManifest) return;
|
||||
|
||||
void queueDialogueById(
|
||||
dialogueOptions.dialogueManifest,
|
||||
cue.dialogueId,
|
||||
).then((audio) => {
|
||||
if (!audio) return;
|
||||
|
||||
dialogueOptions.activeAudiosRef.current.add(audio);
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => dialogueOptions.activeAudiosRef.current.delete(audio),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
cue.time,
|
||||
);
|
||||
});
|
||||
|
||||
timelineRef.current = timeline;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import {
|
||||
clearQueuedDialogues,
|
||||
queueDialogueById,
|
||||
} from "@/utils/dialogues/playDialogue";
|
||||
import { logger } from "@/utils/core/logger";
|
||||
|
||||
export function GameDialogues(): null {
|
||||
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
|
||||
const playedDialoguesRef = useRef(new Set<string>());
|
||||
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const activeAudios = activeAudiosRef.current;
|
||||
|
||||
void loadDialogueManifest()
|
||||
.then((loadedManifest) => {
|
||||
if (mounted) setManifest(loadedManifest);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error("GameDialogues", "Failed to load dialogue manifest", {
|
||||
error: error instanceof Error ? error : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearQueuedDialogues();
|
||||
activeAudios.forEach((audio) => audio.pause());
|
||||
activeAudios.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!manifest) return;
|
||||
|
||||
const elapsedTime = clock.getElapsedTime();
|
||||
|
||||
manifest.dialogues.forEach((dialogue) => {
|
||||
if (dialogue.timecode === undefined) return;
|
||||
if (dialogue.timecode > elapsedTime) return;
|
||||
if (playedDialoguesRef.current.has(dialogue.id)) return;
|
||||
|
||||
playedDialoguesRef.current.add(dialogue.id);
|
||||
|
||||
void queueDialogueById(manifest, dialogue.id).then((audio) => {
|
||||
if (!audio) return;
|
||||
activeAudiosRef.current.add(audio);
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => activeAudiosRef.current.delete(audio),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -65,11 +65,13 @@ interface GameMapProps {
|
||||
onLoaded?: (() => void) | undefined;
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
onOctreeReady: OctreeReadyHandler;
|
||||
buildOctree?: boolean;
|
||||
}
|
||||
|
||||
const MAP_RENDER_BATCH_SIZE = 12;
|
||||
|
||||
export function GameMap({
|
||||
buildOctree = true,
|
||||
onLoaded,
|
||||
onLoadingStateChange,
|
||||
onOctreeReady,
|
||||
@@ -197,6 +199,7 @@ export function GameMap({
|
||||
))}
|
||||
</group>
|
||||
<GameMapCollision
|
||||
buildOctree={buildOctree}
|
||||
mapReady={mapReady}
|
||||
nodes={mapNodes}
|
||||
onLoaded={onLoaded}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ResolvedGameMapCollisionNode {
|
||||
}
|
||||
|
||||
interface GameMapCollisionProps {
|
||||
buildOctree?: boolean;
|
||||
mapReady: boolean;
|
||||
nodes: readonly GameMapCollisionNode[];
|
||||
onLoaded?: (() => void) | undefined;
|
||||
@@ -92,6 +93,7 @@ function isCollisionNode(
|
||||
}
|
||||
|
||||
export function GameMapCollision({
|
||||
buildOctree = true,
|
||||
mapReady,
|
||||
nodes,
|
||||
onLoaded,
|
||||
@@ -129,7 +131,7 @@ export function GameMapCollision({
|
||||
groupRef,
|
||||
handleOctreeReady,
|
||||
collisionReady ? collisionNodes.length : 0,
|
||||
collisionReady && collisionNodes.length > 0,
|
||||
buildOctree && collisionReady && collisionNodes.length > 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+27
-8
@@ -12,6 +12,8 @@ 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";
|
||||
import { GameMap } from "@/world/GameMap";
|
||||
@@ -24,12 +26,23 @@ interface WorldProps {
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
}
|
||||
|
||||
function hasBootFlag(name: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return new URLSearchParams(window.location.search).has(name);
|
||||
}
|
||||
|
||||
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
const cameraMode = useCameraMode();
|
||||
const sceneMode = useSceneMode();
|
||||
const { status, usageStatus } = useHandTrackingSnapshot();
|
||||
const { octree, showGameStage, handleGameMapLoaded, handleOctreeReady } =
|
||||
useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||
const noCinematics = hasBootFlag("noCinematics");
|
||||
const noDialogues = hasBootFlag("noDialogues");
|
||||
const noMap = hasBootFlag("noMap");
|
||||
const noMusic = hasBootFlag("noMusic");
|
||||
const noOctree = hasBootFlag("noOctree");
|
||||
const noPlayer = hasBootFlag("noPlayer");
|
||||
const playerSpawnPosition =
|
||||
sceneMode === "game"
|
||||
? PLAYER_SPAWN_POSITION_GAME
|
||||
@@ -52,13 +65,18 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||
{sceneMode === "game" ? (
|
||||
<>
|
||||
<GameMusic />
|
||||
<GameMap
|
||||
onLoaded={handleGameMapLoaded}
|
||||
onLoadingStateChange={onLoadingStateChange}
|
||||
onOctreeReady={handleOctreeReady}
|
||||
/>
|
||||
{showGameStage ? (
|
||||
{noMusic ? null : <GameMusic />}
|
||||
{noCinematics ? null : <GameCinematics />}
|
||||
{noDialogues ? null : <GameDialogues />}
|
||||
{noMap ? null : (
|
||||
<GameMap
|
||||
buildOctree={!noOctree}
|
||||
onLoaded={handleGameMapLoaded}
|
||||
onLoadingStateChange={onLoadingStateChange}
|
||||
onOctreeReady={handleOctreeReady}
|
||||
/>
|
||||
)}
|
||||
{noMap || showGameStage ? (
|
||||
<Physics>
|
||||
<GameStageContent />
|
||||
</Physics>
|
||||
@@ -67,7 +85,8 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
) : (
|
||||
<TestMap onOctreeReady={handleOctreeReady} />
|
||||
)}
|
||||
{cameraMode !== "debug" ? (
|
||||
|
||||
{cameraMode !== "debug" && !noPlayer ? (
|
||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
type Keys = {
|
||||
@@ -55,6 +57,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:
|
||||
@@ -122,6 +131,8 @@ export function PlayerController({
|
||||
const interaction = InteractionManager.getInstance();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (isPlayerInputLocked()) return;
|
||||
|
||||
if (setMovementKey(keys.current, event.key, true)) {
|
||||
if (movementLockedRef.current) {
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
@@ -151,12 +162,15 @@ export function PlayerController({
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent): void => {
|
||||
if (isPlayerInputLocked()) return;
|
||||
|
||||
if (setMovementKey(keys.current, event.key, false)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: MouseEvent): void => {
|
||||
if (isPlayerInputLocked()) return;
|
||||
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
||||
if (interaction.getState().focused?.kind === "grab") {
|
||||
interaction.pressInteract();
|
||||
@@ -164,6 +178,7 @@ export function PlayerController({
|
||||
};
|
||||
|
||||
const handleMouseUp = (event: MouseEvent): void => {
|
||||
if (isPlayerInputLocked()) return;
|
||||
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
||||
if (interaction.getState().holding) {
|
||||
interaction.releaseInteract();
|
||||
@@ -185,6 +200,13 @@ export function PlayerController({
|
||||
}, []);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (isPlayerInputLocked()) {
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
velocity.current.set(0, 0, 0);
|
||||
wantsJump.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
||||
|
||||
camera.getWorldDirection(_forward);
|
||||
|
||||
Reference in New Issue
Block a user