Merge remote-tracking branch 'origin/develop' into feat/mission-2

# Conflicts:
#	package-lock.json
#	package.json
#	src/App.tsx
#	src/components/three/interaction/CentralObject.tsx
#	src/components/three/interaction/VillageoisHelperObject.tsx
#	src/managers/GameStepManager.ts
#	src/stateManager/AudioManager.ts
#	src/world/World.tsx
#	src/world/player/PlayerController.tsx
This commit is contained in:
Tom Boullay
2026-05-11 17:46:42 +02:00
945 changed files with 26164 additions and 1569 deletions
+4 -4
View File
@@ -1,9 +1,9 @@
import { Environment as DreiEnvironment } from "@react-three/drei";
import {
GAME_SCENE_SKYBOX_PATH,
GAME_SCENE_SKY_MODEL_PATH,
PHYSICS_SCENE_BACKGROUND_COLOR,
} from "@/data/environmentConfig";
} from "@/data/world/environmentConfig";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { SkyModel } from "@/components/three/world/SkyModel";
export function Environment(): React.JSX.Element {
const sceneMode = useSceneMode();
@@ -14,5 +14,5 @@ export function Environment(): React.JSX.Element {
);
}
return <DreiEnvironment background files={GAME_SCENE_SKYBOX_PATH} />;
return <SkyModel modelPath={GAME_SCENE_SKY_MODEL_PATH} />;
}
+170
View File
@@ -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;
}
+63
View File
@@ -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;
}
+248
View File
@@ -0,0 +1,248 @@
import type { ReactNode } from "react";
import { Component, Suspense, useEffect, useState } from "react";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { GameMapCollision } from "@/world/GameMapCollision";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import type { MapNode } from "@/types/editor/editor";
import type { OctreeReadyHandler } from "@/types/three/three";
interface LoadedMapNode {
node: MapNode;
modelUrl: string | null;
}
interface ErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
modelUrl: string | null;
node: MapNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
class ModelErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error): void {
logModelLoadError(
{
modelPath: this.props.modelUrl ?? `missing:${this.props.node.name}`,
scope: "GameMap.ModelInstance",
position: this.props.node.position,
rotation: this.props.node.rotation,
scale: this.props.node.scale,
},
error,
);
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
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,
}: GameMapProps): React.JSX.Element {
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
const [mapLoaded, setMapLoaded] = useState(false);
const [visibleNodeCount, setVisibleNodeCount] = useState(0);
const visibleMapNodes = mapNodes.slice(0, visibleNodeCount);
const mapReady = mapLoaded && visibleNodeCount >= mapNodes.length;
useEffect(() => {
onLoadingStateChange?.({
currentStep: "Récupération blocking",
progress: 0.05,
status: "loading",
});
const loadMap = async () => {
try {
const sceneData = await loadMapSceneData();
if (!sceneData) {
logger.warn("GameMap", "map.json not found");
onLoadingStateChange?.({
currentStep: "Map introuvable",
progress: 1,
status: "loading",
});
return;
}
onLoadingStateChange?.({
currentStep: "Importation des models",
progress: 0.18,
status: "loading",
});
const loadedMapNodes = sceneData.mapNodes.map((node) => {
const modelUrl = sceneData.models.get(node.name);
return { node, modelUrl: modelUrl ?? null };
});
const missingModelCount = loadedMapNodes.filter(
(mapNode) => mapNode.modelUrl === null,
).length;
if (missingModelCount > 0) {
logger.warn(
"GameMap",
"Map nodes rendered as fallback cubes because model files are missing",
{
missingModelCount,
},
);
}
setMapNodes(loadedMapNodes);
setMapLoaded(true);
setVisibleNodeCount(0);
onLoadingStateChange?.({
currentStep: "Montage progressif des models",
progress: 0.25,
status: "loading",
});
} catch (error) {
logger.error("GameMap", "Error loading map", {
error: error instanceof Error ? error : new Error(String(error)),
});
onLoadingStateChange?.({
currentStep: "Erreur de chargement de la map",
progress: 1,
status: "loading",
});
}
};
loadMap();
}, [onLoaded, onLoadingStateChange]);
useEffect(() => {
if (mapNodes.length === 0 || visibleNodeCount >= mapNodes.length) return;
const frameId = window.requestAnimationFrame(() => {
setVisibleNodeCount((current) =>
Math.min(current + MAP_RENDER_BATCH_SIZE, mapNodes.length),
);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [mapNodes.length, visibleNodeCount]);
useEffect(() => {
if (mapNodes.length === 0) return;
const renderProgress =
mapNodes.length === 0 ? 1 : visibleNodeCount / mapNodes.length;
onLoadingStateChange?.({
currentStep: "Montage progressif des models",
progress: 0.25 + renderProgress * 0.45,
status: "loading",
});
}, [mapNodes.length, onLoadingStateChange, visibleNodeCount]);
return (
<>
<group>
{visibleMapNodes.map((mapNode, index) => (
<ModelErrorBoundary
key={index}
fallback={<FallbackMapNode node={mapNode.node} />}
modelUrl={mapNode.modelUrl}
node={mapNode.node}
>
{mapNode.modelUrl ? (
<Suspense fallback={<FallbackMapNode node={mapNode.node} />}>
<ModelInstance
node={mapNode.node}
modelUrl={mapNode.modelUrl}
/>
</Suspense>
) : (
<FallbackMapNode node={mapNode.node} />
)}
</ModelErrorBoundary>
))}
</group>
<GameMapCollision
buildOctree={buildOctree}
mapReady={mapReady}
nodes={mapNodes}
onLoaded={onLoaded}
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={onOctreeReady}
/>
</>
);
}
function ModelInstance({
node,
modelUrl,
}: {
node: MapNode;
modelUrl: string;
}): React.JSX.Element {
const { position, rotation, scale } = node;
const { scene } = useLoggedGLTF(modelUrl, {
scope: "GameMap.ModelInstance",
position,
rotation,
scale,
});
const sceneInstance = useClonedObject(scene);
return (
<primitive
object={sceneInstance}
position={position}
rotation={rotation}
scale={scale}
/>
);
}
function FallbackMapNode({ node }: { node: MapNode }): React.JSX.Element {
const { position, rotation, scale } = node;
return (
<mesh position={position} rotation={rotation} scale={scale}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#64748b" wireframe />
</mesh>
);
}
+214
View File
@@ -0,0 +1,214 @@
import type { ReactNode } from "react";
import {
Component,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
import type { MapNode } from "@/types/editor/editor";
import type { OctreeReadyHandler } from "@/types/three/three";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
export interface GameMapCollisionNode {
node: MapNode;
modelUrl: string | null;
}
interface ResolvedGameMapCollisionNode {
node: MapNode;
modelUrl: string;
}
interface GameMapCollisionProps {
buildOctree?: boolean;
mapReady: boolean;
nodes: readonly GameMapCollisionNode[];
onLoaded?: (() => void) | undefined;
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
onOctreeReady: OctreeReadyHandler;
}
interface CollisionErrorBoundaryProps {
children: ReactNode;
modelUrl: string;
node: MapNode;
onSettled: () => void;
}
interface CollisionErrorBoundaryState {
hasError: boolean;
}
const MAP_COLLISION_NODE_NAMES = new Set(["terrain"]);
class CollisionErrorBoundary extends Component<
CollisionErrorBoundaryProps,
CollisionErrorBoundaryState
> {
constructor(props: CollisionErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): CollisionErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error): void {
logModelLoadError(
{
modelPath: this.props.modelUrl,
scope: "GameMapCollision.ModelInstance",
position: this.props.node.position,
rotation: this.props.node.rotation,
scale: this.props.node.scale,
},
error,
);
this.props.onSettled();
}
render(): ReactNode {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
function isCollisionNode(
mapNode: GameMapCollisionNode,
): mapNode is ResolvedGameMapCollisionNode {
return (
mapNode.modelUrl !== null && MAP_COLLISION_NODE_NAMES.has(mapNode.node.name)
);
}
export function GameMapCollision({
buildOctree = true,
mapReady,
nodes,
onLoaded,
onLoadingStateChange,
onOctreeReady,
}: GameMapCollisionProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const settledCollisionNodesRef = useRef(new Set<number>());
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
const collisionNodes = nodes.filter(isCollisionNode);
const collisionReady =
mapReady && settledCollisionNodeCount >= collisionNodes.length;
const handleCollisionNodeSettled = useCallback((index: number) => {
if (settledCollisionNodesRef.current.has(index)) return;
settledCollisionNodesRef.current.add(index);
setSettledCollisionNodeCount(settledCollisionNodesRef.current.size);
}, []);
const handleOctreeReady = useCallback<OctreeReadyHandler>(
(octree) => {
onLoadingStateChange?.({
currentStep: "Collision prête",
progress: 0.92,
status: "loading",
});
onOctreeReady(octree);
onLoaded?.();
},
[onLoaded, onLoadingStateChange, onOctreeReady],
);
useOctreeGraphNode(
groupRef,
handleOctreeReady,
collisionReady ? collisionNodes.length : 0,
buildOctree && collisionReady && collisionNodes.length > 0,
);
useEffect(() => {
if (!mapReady) return;
if (collisionNodes.length === 0) {
onLoaded?.();
return;
}
if (collisionReady) return;
onLoadingStateChange?.({
currentStep: "Ajout de la collision",
progress: 0.86,
status: "loading",
});
}, [
collisionNodes.length,
collisionReady,
mapReady,
onLoaded,
onLoadingStateChange,
]);
return (
<group ref={groupRef} visible={false}>
{mapReady
? collisionNodes.map((mapNode, index) => (
<CollisionErrorBoundary
key={`collision-${index}`}
node={mapNode.node}
modelUrl={mapNode.modelUrl}
onSettled={() => handleCollisionNodeSettled(index)}
>
<Suspense fallback={null}>
<CollisionModelInstance
node={mapNode.node}
modelUrl={mapNode.modelUrl}
onLoaded={() => handleCollisionNodeSettled(index)}
/>
</Suspense>
</CollisionErrorBoundary>
))
: null}
</group>
);
}
function CollisionModelInstance({
node,
modelUrl,
onLoaded,
}: {
node: MapNode;
modelUrl: string;
onLoaded: () => void;
}): React.JSX.Element {
const { position, rotation, scale } = node;
const { scene } = useLoggedGLTF(modelUrl, {
scope: "GameMapCollision.ModelInstance",
position,
rotation,
scale,
});
const sceneInstance = useClonedObject(scene);
useEffect(() => {
onLoaded();
}, [onLoaded]);
return (
<primitive
object={sceneInstance}
position={position}
rotation={rotation}
scale={scale}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect } from "react";
import { AudioManager } from "@/managers/AudioManager";
const GAME_MUSIC_PATH = "/sounds/musique/test.mp3";
const GAME_MUSIC_VOLUME = 0.45;
export function GameMusic(): null {
useEffect(() => {
const audio = AudioManager.getInstance();
audio.playMusic(GAME_MUSIC_PATH, GAME_MUSIC_VOLUME);
return () => {
audio.stopMusic();
};
}, []);
return null;
}
+71
View File
@@ -0,0 +1,71 @@
import { RepairGame } from "@/components/three/gameplay/RepairGame";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
interface StageAnchorProps {
color: string;
position: Vector3Tuple;
scale?: number;
}
interface GameRepairZone {
mission: RepairMissionId;
position: Vector3Tuple;
}
const GAME_REPAIR_ZONES = [
{
mission: "bike",
position: [8, 0, -6],
},
{
mission: "pylone",
position: [64, 0, -66],
},
{
mission: "ferme",
position: [-24, 0, 42],
},
] as const satisfies readonly GameRepairZone[];
function StageAnchor({
color,
position,
scale = 1,
}: StageAnchorProps): React.JSX.Element {
return (
<group position={position} scale={scale}>
<mesh>
<octahedronGeometry args={[1.2, 0]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.25}
/>
</mesh>
</group>
);
}
export function GameStageContent(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
return (
<>
{mainState === "intro" ? (
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
) : null}
{GAME_REPAIR_ZONES.map((zone) => (
<RepairGame
key={zone.mission}
mission={zone.mission}
position={zone.position}
/>
))}
{mainState === "outro" ? (
<StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />
) : null}
</>
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ import {
SUN_Z_MAX,
SUN_Z_MIN,
SUN_Z_STEP,
} from "@/data/lightingConfig";
} from "@/data/world/lightingConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
type LightingState = {
-55
View File
@@ -1,55 +0,0 @@
import { useEffect, useRef } from "react";
import { useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { MAP_DEBUG_BOX_HELPER_COLOR } from "@/data/debugConfig";
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
import type { OctreeReadyHandler } from "@/types/3d";
import { Debug } from "@/utils/debug/Debug";
const MAP_PATH = "/models/map/model.gltf";
interface MapProps {
onOctreeReady: OctreeReadyHandler;
}
export function Map({ onOctreeReady }: MapProps): React.JSX.Element {
const { scene: gltfScene } = useGLTF(MAP_PATH);
const groupRef = useRef<THREE.Group>(null);
const boxHelpersRef = useRef<THREE.BoxHelper[]>([]);
const { scene } = useThree();
useOctreeGraphNode(groupRef, onOctreeReady);
useEffect(() => {
const debug = Debug.getInstance();
if (!debug.active || !groupRef.current) return;
const helpers: THREE.BoxHelper[] = [];
groupRef.current.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const helper = new THREE.BoxHelper(child, MAP_DEBUG_BOX_HELPER_COLOR);
scene.add(helper);
helpers.push(helper);
});
boxHelpersRef.current = helpers;
return () => {
helpers.forEach((h) => {
scene.remove(h);
h.dispose();
});
boxHelpersRef.current = [];
};
}, [scene]);
return (
<group ref={groupRef}>
<primitive object={gltfScene} />
</group>
);
}
useGLTF.preload(MAP_PATH);
+73 -23
View File
@@ -1,55 +1,105 @@
import { useState } from "react";
import type { Octree } from "three/addons/math/Octree.js";
import { Suspense } from "react";
import { Physics } from "@react-three/rapier";
import {
PLAYER_SPAWN_POSITION_GAME,
PLAYER_SPAWN_POSITION_PHYSICS,
} from "@/data/playerConfig";
} from "@/data/player/playerConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
import { GameFlow } from "@/components/game/GameFlow";
import {
ZoneDebugVisuals,
ZoneDetection,
} from "@/components/zone/ZoneDetection";
import { GameFlow } from "@/components/game/GameFlow";
import { CentralObject } from "@/components/3d/CentralObject";
import { VillageoisHelperObject } from "@/components/3d/VillageoisHelperObject";
import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers";
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
import { CentralObject } from "@/components/three/interaction/CentralObject";
import { VillageoisHelperObject } from "@/components/three/interaction/VillageoisHelperObject";
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 { Map } from "@/world/Map";
import { PlayerComponent } from "@/world/player/PlayerComponent";
import { TestScene } from "@/world/debug/TestScene";
import { GameMap } from "@/world/GameMap";
import { GameStageContent } from "@/world/GameStageContent";
import { Player } from "@/world/player/Player";
import { TestMap } from "@/world/debug/TestMap";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
export function World(): React.JSX.Element {
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 [octree, setOctree] = useState<Octree | null>(null);
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
: PLAYER_SPAWN_POSITION_PHYSICS;
const showHandTrackingGloves =
sceneMode === "physics" ||
(status !== "idle" && usageStatus !== "inactive");
return (
<>
<Environment />
<Lighting />
<DebugHelpers />
<ZoneDetection />
<ZoneDebugVisuals />
<GameFlow />
<VillageoisHelperObject position={[1, 12, -55]} />
<CentralObject position={[1, 15, -45]} />
{showHandTrackingGloves ? (
<Suspense fallback={null}>
<HandTrackingGlove handedness="left" />
<HandTrackingGlove handedness="right" />
</Suspense>
) : null}
{cameraMode === "debug" ? <DebugCameraControls /> : null}
{sceneMode === "game" ? (
<Map onOctreeReady={setOctree} />
<>
<GameFlow />
<ZoneDetection />
<ZoneDebugVisuals />
<VillageoisHelperObject position={[1, 12, -55]} />
<CentralObject position={[1, 15, -45]} />
{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>
) : null}
</>
) : (
<TestScene onOctreeReady={setOctree} />
<TestMap onOctreeReady={handleOctreeReady} />
)}
{cameraMode !== "debug" ? (
<PlayerComponent octree={octree} spawnPosition={playerSpawnPosition} />
{cameraMode !== "debug" && !noPlayer ? (
<Player octree={octree} spawnPosition={playerSpawnPosition} />
) : null}
</>
);
+192
View File
@@ -0,0 +1,192 @@
import type { ReactNode } from "react";
import { Component, useRef } from "react";
import * as THREE from "three";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { RepairGame } from "@/components/three/gameplay/RepairGame";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import {
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
TEST_SCENE_FLOOR_POSITION,
TEST_SCENE_FLOOR_SIZE,
TEST_SCENE_GRABBABLE_BOX_SIZE,
TEST_SCENE_GRABBABLE_COLOR,
TEST_SCENE_GRABBABLE_METALNESS,
TEST_SCENE_GRABBABLE_POSITION,
TEST_SCENE_GRABBABLE_ROUGHNESS,
TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS,
TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS,
TEST_SCENE_REPAIR_ZONES,
TEST_SCENE_TRIGGER_COLOR,
TEST_SCENE_TRIGGER_METALNESS,
TEST_SCENE_TRIGGER_POSITION,
TEST_SCENE_TRIGGER_RADIUS,
TEST_SCENE_TRIGGER_ROUGHNESS,
TEST_SCENE_TRIGGER_SEGMENTS,
TEST_SCENE_TRIGGER_SOUND_PATH,
} from "@/data/debug/testSceneConfig";
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
import type { OctreeReadyHandler } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
const ELECTRICIENNE_ANIMATED_MODEL_PATH =
"/models/electricienne_animated/model.gltf";
interface TestMapProps {
onOctreeReady: OctreeReadyHandler;
}
interface ModelPreviewErrorBoundaryProps {
children: ReactNode;
modelPath: string;
}
interface ModelPreviewErrorBoundaryState {
hasError: boolean;
}
interface RepairPlaygroundZoneMarkerProps {
color: string;
}
class ModelPreviewErrorBoundary extends Component<
ModelPreviewErrorBoundaryProps,
ModelPreviewErrorBoundaryState
> {
constructor(props: ModelPreviewErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): ModelPreviewErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error): void {
logModelLoadError(
{
modelPath: this.props.modelPath,
scope: "TestMap.ModelPreview",
position: [0, 0, -5],
scale: 1,
},
error,
);
}
render(): ReactNode {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
const floorRef = useRef<THREE.Group>(null);
useOctreeGraphNode(floorRef, onOctreeReady);
return (
<>
<group ref={floorRef}>
<mesh visible={false} position={TEST_SCENE_FLOOR_POSITION}>
<boxGeometry args={TEST_SCENE_FLOOR_SIZE} />
<meshBasicMaterial />
</mesh>
</group>
<Physics>
<RigidBody type="fixed">
<CuboidCollider
args={TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS}
position={TEST_SCENE_FLOOR_POSITION}
/>
</RigidBody>
<GrabbableObject
position={TEST_SCENE_GRABBABLE_POSITION}
colliders="cuboid"
handControlled
>
<mesh castShadow receiveShadow>
<boxGeometry args={TEST_SCENE_GRABBABLE_BOX_SIZE} />
<meshStandardMaterial
color={TEST_SCENE_GRABBABLE_COLOR}
roughness={TEST_SCENE_GRABBABLE_ROUGHNESS}
metalness={TEST_SCENE_GRABBABLE_METALNESS}
/>
</mesh>
</GrabbableObject>
<TriggerObject
position={TEST_SCENE_TRIGGER_POSITION}
soundPath={TEST_SCENE_TRIGGER_SOUND_PATH}
>
<mesh castShadow receiveShadow>
<sphereGeometry
args={[
TEST_SCENE_TRIGGER_RADIUS,
TEST_SCENE_TRIGGER_SEGMENTS,
TEST_SCENE_TRIGGER_SEGMENTS,
]}
/>
<meshStandardMaterial
color={TEST_SCENE_TRIGGER_COLOR}
roughness={TEST_SCENE_TRIGGER_ROUGHNESS}
metalness={TEST_SCENE_TRIGGER_METALNESS}
/>
</mesh>
</TriggerObject>
{TEST_SCENE_REPAIR_ZONES.map((zone) => (
<group key={zone.mission}>
<group position={zone.position}>
<RepairPlaygroundZoneMarker color={zone.color} />
</group>
<RepairGame mission={zone.mission} position={zone.position} />
</group>
))}
</Physics>
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>
<AnimatedModel
modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}
defaultAnimation="Idle"
position={[0, 0, -5]}
scale={1}
/>
</ModelPreviewErrorBoundary>
</>
);
}
function RepairPlaygroundZoneMarker({
color,
}: RepairPlaygroundZoneMarkerProps): React.JSX.Element {
return (
<group>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry
args={[
TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS,
TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS,
12,
96,
]}
/>
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.2}
/>
</mesh>
<mesh position={[0, 0.08, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.2, TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS, 96]} />
<meshBasicMaterial color={color} transparent opacity={0.12} />
</mesh>
</group>
);
}
-90
View File
@@ -1,90 +0,0 @@
import { useRef } from "react";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import * as THREE from "three";
import { GrabbableObject } from "@/components/3d/GrabbableObject";
import { TriggerObject } from "@/components/3d/TriggerObject";
import {
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
TEST_SCENE_FLOOR_POSITION,
TEST_SCENE_FLOOR_SIZE,
TEST_SCENE_GRABBABLE_BOX_SIZE,
TEST_SCENE_GRABBABLE_COLOR,
TEST_SCENE_GRABBABLE_METALNESS,
TEST_SCENE_GRABBABLE_POSITION,
TEST_SCENE_GRABBABLE_ROUGHNESS,
TEST_SCENE_TRIGGER_COLOR,
TEST_SCENE_TRIGGER_METALNESS,
TEST_SCENE_TRIGGER_POSITION,
TEST_SCENE_TRIGGER_RADIUS,
TEST_SCENE_TRIGGER_ROUGHNESS,
TEST_SCENE_TRIGGER_SEGMENTS,
TEST_SCENE_TRIGGER_SOUND_PATH,
} from "@/data/testSceneConfig";
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
import type { OctreeReadyHandler } from "@/types/3d";
interface TestSceneProps {
onOctreeReady: OctreeReadyHandler;
}
export function TestScene({
onOctreeReady,
}: TestSceneProps): React.JSX.Element {
const floorRef = useRef<THREE.Group>(null);
useOctreeGraphNode(floorRef, onOctreeReady);
return (
<>
<group ref={floorRef}>
<mesh visible={false} position={TEST_SCENE_FLOOR_POSITION}>
<boxGeometry args={TEST_SCENE_FLOOR_SIZE} />
<meshBasicMaterial />
</mesh>
</group>
<Physics>
<RigidBody type="fixed">
<CuboidCollider
args={TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS}
position={TEST_SCENE_FLOOR_POSITION}
/>
</RigidBody>
<GrabbableObject
position={TEST_SCENE_GRABBABLE_POSITION}
colliders="cuboid"
>
<mesh castShadow receiveShadow>
<boxGeometry args={TEST_SCENE_GRABBABLE_BOX_SIZE} />
<meshStandardMaterial
color={TEST_SCENE_GRABBABLE_COLOR}
roughness={TEST_SCENE_GRABBABLE_ROUGHNESS}
metalness={TEST_SCENE_GRABBABLE_METALNESS}
/>
</mesh>
</GrabbableObject>
<TriggerObject
position={TEST_SCENE_TRIGGER_POSITION}
soundPath={TEST_SCENE_TRIGGER_SOUND_PATH}
>
<mesh castShadow receiveShadow>
<sphereGeometry
args={[
TEST_SCENE_TRIGGER_RADIUS,
TEST_SCENE_TRIGGER_SEGMENTS,
TEST_SCENE_TRIGGER_SEGMENTS,
]}
/>
<meshStandardMaterial
color={TEST_SCENE_TRIGGER_COLOR}
roughness={TEST_SCENE_TRIGGER_ROUGHNESS}
metalness={TEST_SCENE_TRIGGER_METALNESS}
/>
</mesh>
</TriggerObject>
</Physics>
</>
);
}
@@ -1,19 +1,19 @@
import { useEffect } from "react";
import { useThree } from "@react-three/fiber";
import type { Octree } from "three/addons/math/Octree.js";
import type { Vector3Tuple } from "@/types/3d";
import type { Vector3Tuple } from "@/types/three/three";
import { PlayerCamera } from "@/world/player/PlayerCamera";
import { PlayerController } from "@/world/player/PlayerController";
interface PlayerComponentProps {
interface PlayerProps {
octree: Octree | null;
spawnPosition: Vector3Tuple;
}
export function PlayerComponent({
export function Player({
spawnPosition,
octree,
}: PlayerComponentProps): React.JSX.Element {
}: PlayerProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
useEffect(() => {
+90 -52
View File
@@ -11,7 +11,7 @@ import {
MOVE_LEFT_KEY,
MOVE_RIGHT_KEY,
PRIMARY_INTERACT_MOUSE_BUTTON,
} from "@/data/keybindings";
} from "@/data/input/keybindings";
import {
PLAYER_ACCELERATION_MULTIPLIER,
PLAYER_AIR_CONTROL_FACTOR,
@@ -22,10 +22,13 @@ import {
PLAYER_MAX_DELTA,
PLAYER_WALK_SPEED,
PLAYER_XZ_DAMPING_FACTOR,
} from "@/data/playerConfig";
import { InteractionManager } from "@/stateManager/InteractionManager";
import { useGameStore } from "@/stores/gameStore";
import type { Vector3Tuple } from "@/types/3d";
} from "@/data/player/playerConfig";
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
import { InteractionManager } from "@/managers/InteractionManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { Vector3Tuple } from "@/types/three/three";
type Keys = {
forward: boolean;
@@ -55,16 +58,44 @@ 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:
keys.forward = pressed;
return true;
case MOVE_BACKWARD_KEY:
keys.backward = pressed;
return true;
case MOVE_LEFT_KEY:
keys.left = pressed;
return true;
case MOVE_RIGHT_KEY:
keys.right = pressed;
return true;
default:
return false;
}
}
export function PlayerController({
octree,
spawnPosition,
}: PlayerControllerProps): null {
const camera = useThree((state) => state.camera);
const movementLocked = useRepairMovementLocked();
const movementLockedRef = useRef(movementLocked);
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
const velocity = useRef(new THREE.Vector3());
const onFloor = useRef(false);
const wantsJump = useRef(false);
const canMove = useGameStore((state) => state.canMove);
const canMove = useMissionFlowStore((state) => state.canMove);
const capsule = useRef(
new Capsule(
@@ -87,58 +118,61 @@ export function PlayerController({
camera.position.copy(capsule.current.end);
}, [camera, spawnPosition]);
useEffect(() => {
movementLockedRef.current = movementLocked;
if (!movementLocked) return;
keys.current = { ...DEFAULT_KEYS };
wantsJump.current = false;
velocity.current.setX(0);
velocity.current.setZ(0);
}, [movementLocked]);
useEffect(() => {
const interaction = InteractionManager.getInstance();
const handleKeyDown = (event: KeyboardEvent): void => {
switch (event.key.toLowerCase()) {
case MOVE_FORWARD_KEY:
keys.current.forward = true;
break;
case MOVE_BACKWARD_KEY:
keys.current.backward = true;
break;
case MOVE_LEFT_KEY:
keys.current.left = true;
break;
case MOVE_RIGHT_KEY:
keys.current.right = true;
break;
case JUMP_KEY:
wantsJump.current = true;
break;
case INTERACT_KEY:
if (interaction.getState().focused?.kind === "trigger") {
interaction.pressInteract();
}
break;
default:
return;
if (isPlayerInputLocked()) return;
if (setMovementKey(keys.current, event.key, true)) {
if (movementLockedRef.current) {
keys.current = { ...DEFAULT_KEYS };
}
event.preventDefault();
return;
}
if (event.key === JUMP_KEY) {
if (movementLockedRef.current) {
wantsJump.current = false;
event.preventDefault();
return;
}
wantsJump.current = true;
event.preventDefault();
return;
}
if (event.key.toLowerCase() === INTERACT_KEY) {
if (interaction.getState().focused?.kind === "trigger") {
interaction.pressInteract();
}
event.preventDefault();
}
event.preventDefault();
};
const handleKeyUp = (event: KeyboardEvent): void => {
switch (event.key.toLowerCase()) {
case MOVE_FORWARD_KEY:
keys.current.forward = false;
break;
case MOVE_BACKWARD_KEY:
keys.current.backward = false;
break;
case MOVE_LEFT_KEY:
keys.current.left = false;
break;
case MOVE_RIGHT_KEY:
keys.current.right = false;
break;
default:
return;
if (isPlayerInputLocked()) return;
if (setMovementKey(keys.current, event.key, false)) {
event.preventDefault();
}
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();
@@ -146,6 +180,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();
@@ -167,9 +202,10 @@ export function PlayerController({
}, []);
useFrame((_, delta) => {
if (!canMove) {
if (isPlayerInputLocked() || !canMove) {
keys.current = { ...DEFAULT_KEYS };
velocity.current.set(0, 0, 0);
camera.position.copy(capsule.current.end);
wantsJump.current = false;
return;
}
@@ -183,10 +219,12 @@ export function PlayerController({
}
_wishDir.set(0, 0, 0);
if (keys.current.forward) _wishDir.add(_forward);
if (keys.current.backward) _wishDir.sub(_forward);
if (keys.current.left) _wishDir.sub(_right);
if (keys.current.right) _wishDir.add(_right);
if (!movementLocked) {
if (keys.current.forward) _wishDir.add(_forward);
if (keys.current.backward) _wishDir.sub(_forward);
if (keys.current.left) _wishDir.sub(_right);
if (keys.current.right) _wishDir.add(_right);
}
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
const accel = onFloor.current