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
@@ -0,0 +1,110 @@
import { useRef, useEffect, useState, useCallback, useMemo } from "react";
import { useAnimations } from "@react-three/drei";
import type { AnimationAction, AnimationMixer } from "three";
import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
export interface CharacterAnimationConfig {
modelPath: string;
initialAnimation?: string;
fadeDuration?: number;
}
interface UseCharacterAnimationReturn {
scene: THREE.Group;
actions: { [key: string]: AnimationAction | null };
names: string[];
mixer: AnimationMixer;
groupRef: React.MutableRefObject<THREE.Group | null>;
currentAnimation: string;
play: (name: string) => void;
stop: () => void;
fadeTo: (name: string, duration?: number) => void;
setAnimationSpeed: (speed: number) => void;
}
const DEFAULT_FADE_DURATION = 0.3;
export function useCharacterAnimation(
config: CharacterAnimationConfig,
): UseCharacterAnimationReturn {
const {
modelPath,
initialAnimation = "Idle",
fadeDuration = DEFAULT_FADE_DURATION,
} = config;
const groupRef = useRef<THREE.Group | null>(null);
const { scene, animations } = useLoggedGLTF(modelPath, {
scope: "useCharacterAnimation",
});
const model = useMemo(() => scene.clone(true), [scene]);
const { actions, names, mixer } = useAnimations(animations, groupRef);
const [currentAnimation, setCurrentAnimation] = useState(initialAnimation);
const play = useCallback(
(name: string) => {
const action = actions[name];
if (action) {
Object.values(actions).forEach((a) => {
if (a && a !== action) a.fadeOut(fadeDuration);
});
action.reset().fadeIn(fadeDuration).play();
setCurrentAnimation(name);
}
},
[actions, fadeDuration],
);
const stop = useCallback(() => {
Object.values(actions).forEach((a) => a?.fadeOut(fadeDuration));
const defaultAction = actions[initialAnimation as string];
if (defaultAction) {
defaultAction.reset().fadeIn(fadeDuration).play();
setCurrentAnimation(initialAnimation);
}
}, [actions, initialAnimation, fadeDuration]);
const fadeTo = useCallback(
(name: string, duration = fadeDuration) => {
const targetAction = actions[name];
if (targetAction) {
Object.values(actions).forEach((a) => {
if (a && a !== targetAction) a.fadeOut(duration);
});
targetAction.reset().fadeIn(duration).play();
setCurrentAnimation(name);
}
},
[actions, fadeDuration],
);
const setAnimationSpeed = useCallback(
(speed: number) => {
Object.values(actions).forEach((action) => {
action?.setEffectiveTimeScale(speed);
});
},
[actions],
);
useEffect(() => {
const defaultAction = actions[initialAnimation as string];
if (defaultAction) {
defaultAction.play();
}
}, [actions, initialAnimation]);
return {
scene: model,
actions,
names,
mixer,
groupRef,
currentAnimation,
play,
stop,
fadeTo,
setAnimationSpeed,
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { CameraMode } from "@/types/debug";
import type { CameraMode } from "@/types/debug/debug";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
export function useCameraMode(): CameraMode {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { SceneMode } from "@/types/debug";
import type { SceneMode } from "@/types/debug/debug";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
export function useSceneMode(): SceneMode {
+5
View File
@@ -0,0 +1,5 @@
import { useDebugStore } from "@/hooks/debug/useDebugStore";
export function useShowDebugOverlay(): boolean {
return useDebugStore((debug) => debug.getShowDebugOverlay());
}
+5
View File
@@ -0,0 +1,5 @@
import { useDebugStore } from "@/hooks/debug/useDebugStore";
export function useShowDebugPerf(): boolean {
return useDebugStore((debug) => debug.getShowPerf());
}
+12
View File
@@ -0,0 +1,12 @@
import { useContext } from "react";
import { DocsLanguageContext } from "@/contexts/docs/DocsLanguageContext";
export function useDocsLanguage() {
const context = useContext(DocsLanguageContext);
if (!context) {
throw new Error("useDocsLanguage must be used inside DocsLanguageProvider");
}
return context;
}
+164
View File
@@ -0,0 +1,164 @@
import { useCallback, useRef, useState } from "react";
import type { MapNode, SceneData } from "@/types/editor/editor";
interface ObjectTransform {
uuid: string;
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
scale: { x: number; y: number; z: number };
}
class HistoryManager {
private history: ObjectTransform[][] = [];
private currentIndex = -1;
private maxSize: number;
constructor(maxSize = 50) {
this.maxSize = maxSize;
}
saveSnapshot(objects: ObjectTransform[]): void {
if (this.currentIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.currentIndex + 1);
}
this.history.push(objects.map((object) => ({ ...object })));
this.currentIndex = this.history.length - 1;
if (this.history.length > this.maxSize) {
this.history.shift();
this.currentIndex--;
}
}
undo(): ObjectTransform[] | undefined {
if (this.currentIndex <= 0) return undefined;
this.currentIndex--;
return this.history[this.currentIndex];
}
redo(): ObjectTransform[] | undefined {
if (this.currentIndex >= this.history.length - 1) return undefined;
this.currentIndex++;
return this.history[this.currentIndex];
}
getUndoCount(): number {
return this.currentIndex;
}
getRedoCount(): number {
return this.history.length - 1 - this.currentIndex;
}
}
interface UseEditorHistoryResult {
undoCount: number;
redoCount: number;
handleUndo: () => void;
handleRedo: () => void;
handleTransformStart: () => void;
handleTransformEnd: () => void;
}
export function useEditorHistory(
sceneData: SceneData | null,
setSceneData: React.Dispatch<React.SetStateAction<SceneData | null>>,
): UseEditorHistoryResult {
const [undoCount, setUndoCount] = useState(0);
const [redoCount, setRedoCount] = useState(0);
const historyManager = useRef(new HistoryManager(50));
const updateHistoryCounts = useCallback(() => {
setUndoCount(historyManager.current.getUndoCount());
setRedoCount(historyManager.current.getRedoCount());
}, []);
const applySnapshot = useCallback(
(snapshot: ObjectTransform[]): void => {
setSceneData((prev) => {
if (!prev) return null;
const mapNodes = prev.mapNodes.map((node, index) => {
const transform = snapshot.find(
(item) => item.uuid === `node-${index}`,
);
if (!transform) return node;
return {
...node,
position: [
transform.position.x,
transform.position.y,
transform.position.z,
],
rotation: [
transform.rotation.x,
transform.rotation.y,
transform.rotation.z,
],
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
} satisfies MapNode;
});
return { ...prev, mapNodes };
});
},
[setSceneData],
);
const handleUndo = useCallback(() => {
const snapshot = historyManager.current.undo();
if (!snapshot) return;
applySnapshot(snapshot);
updateHistoryCounts();
}, [applySnapshot, updateHistoryCounts]);
const handleRedo = useCallback(() => {
const snapshot = historyManager.current.redo();
if (!snapshot) return;
applySnapshot(snapshot);
updateHistoryCounts();
}, [applySnapshot, updateHistoryCounts]);
const handleTransformStart = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot(sceneData));
}, [sceneData]);
const handleTransformEnd = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot(sceneData));
updateHistoryCounts();
}, [sceneData, updateHistoryCounts]);
return {
undoCount,
redoCount,
handleUndo,
handleRedo,
handleTransformStart,
handleTransformEnd,
};
}
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
return sceneData.mapNodes.map((node, index) => ({
uuid: `node-${index}`,
position: {
x: node.position[0],
y: node.position[1],
z: node.position[2],
},
rotation: {
x: node.rotation[0],
y: node.rotation[1],
z: node.rotation[2],
},
scale: { x: node.scale[0], y: node.scale[1], z: node.scale[2] },
}));
}
+65
View File
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useState } from "react";
import { createSceneDataFromFiles } from "@/utils/editor/loadEditorScene";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import type { SceneData } from "@/types/editor/editor";
interface UseEditorSceneDataResult {
hasMapJson: boolean;
isMapLoading: boolean;
sceneData: SceneData | null;
setSceneData: React.Dispatch<React.SetStateAction<SceneData | null>>;
handleFolderUpload: (
event: React.ChangeEvent<HTMLInputElement>,
) => Promise<void>;
}
export function useEditorSceneData(): UseEditorSceneDataResult {
const [hasMapJson, setHasMapJson] = useState<boolean>(false);
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
const [sceneData, setSceneData] = useState<SceneData | null>(null);
useEffect(() => {
const loadScene = async (): Promise<void> => {
setIsMapLoading(true);
try {
const loadedSceneData = await loadMapSceneData();
setSceneData(loadedSceneData);
setHasMapJson(Boolean(loadedSceneData));
} catch (error) {
console.error("Error loading map data:", error);
setHasMapJson(false);
} finally {
setIsMapLoading(false);
}
};
loadScene();
}, []);
const handleFolderUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const files = event.target.files;
if (!files) return;
try {
const uploadedSceneData = await createSceneDataFromFiles(files);
setSceneData(uploadedSceneData);
setHasMapJson(true);
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur";
console.error("Error processing upload:", error);
alert(message);
}
},
[],
);
return {
hasMapJson,
isMapLoading,
sceneData,
setSceneData,
handleFolderUpload,
};
}
@@ -0,0 +1,55 @@
import { useCallback, useEffect, useRef } from "react";
import { REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS } from "@/data/gameplay/repairGameConfig";
import { INTERACT_KEY } from "@/data/input/keybindings";
import { useBothFistsHold } from "@/hooks/handTracking/useBothFistsHold";
interface UseRepairFragmentationInputOptions {
enabled: boolean;
keyboardEnabled?: boolean;
onFragment: () => void;
}
export function useRepairFragmentationInput({
enabled,
keyboardEnabled = true,
onFragment,
}: UseRepairFragmentationInputOptions): void {
const completedRef = useRef(false);
useEffect(() => {
if (enabled) return;
completedRef.current = false;
}, [enabled]);
const fragment = useCallback(() => {
if (!enabled) return;
if (completedRef.current) return;
completedRef.current = true;
onFragment();
}, [enabled, onFragment]);
useEffect(() => {
if (!enabled || !keyboardEnabled) return undefined;
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key.toLowerCase() !== INTERACT_KEY) return;
event.preventDefault();
fragment();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [enabled, fragment, keyboardEnabled]);
useBothFistsHold({
enabled,
holdSeconds: REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS,
onComplete: fragment,
});
}
@@ -0,0 +1,9 @@
import { useGameStore } from "@/managers/stores/useGameStore";
import type {
MissionStep,
RepairMissionId,
} from "@/types/gameplay/repairMission";
export function useRepairMissionStep(mission: RepairMissionId): MissionStep {
return useGameStore((state) => state[mission].currentStep);
}
@@ -0,0 +1,30 @@
import { useGameStore } from "@/managers/stores/useGameStore";
import type { MissionStep } from "@/types/gameplay/repairMission";
export function useRepairMovementLocked(): boolean {
return false;
return useGameStore((state) => {
switch (state.mainState) {
case "bike":
return isRepairMovementLocked(state.bike.currentStep);
case "pylone":
return isRepairMovementLocked(state.pylone.currentStep);
case "ferme":
return isRepairMovementLocked(state.ferme.currentStep);
case "intro":
case "outro":
return false;
}
});
}
function isRepairMovementLocked(step: MissionStep): boolean {
return (
step === "inspected" ||
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling"
);
}
@@ -0,0 +1,48 @@
import { useEffect, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
interface UseBothFistsHoldOptions {
enabled: boolean;
holdSeconds: number;
onComplete: () => void;
}
export function useBothFistsHold({
enabled,
holdSeconds,
onComplete,
}: UseBothFistsHoldOptions): void {
const { hands } = useHandTrackingSnapshot();
const elapsedRef = useRef(0);
const completedRef = useRef(false);
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useEffect(() => {
if (enabled) return;
elapsedRef.current = 0;
completedRef.current = false;
}, [enabled]);
useFrame((_, delta) => {
if (!enabled) return;
if (completedRef.current) return;
const fistCount = hands.filter((hand) => hand.isFist).length;
if (fistCount < 2) {
elapsedRef.current = 0;
return;
}
elapsedRef.current += delta;
if (elapsedRef.current < holdSeconds) return;
completedRef.current = true;
onCompleteRef.current();
});
}
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from "react";
import {
HAND_TRACKING_FRAME_HEIGHT,
HAND_TRACKING_FRAME_WIDTH,
HAND_TRACKING_TARGET_FPS,
} from "@/data/handTrackingConfig";
import {
convertBrowserHandResult,
getBrowserHandLandmarker,
} from "@/lib/handTracking/browserHandTracking";
import {
INITIAL_HAND_TRACKING_SNAPSHOT,
getCameraStreamWithTimeout,
} from "@/lib/handTracking/handTrackingSession";
import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking";
interface UseBrowserHandTrackingOptions {
enabled: boolean;
}
export function useBrowserHandTracking({
enabled,
}: UseBrowserHandTrackingOptions): HandTrackingSnapshot {
const [snapshot, setSnapshot] = useState<HandTrackingSnapshot>(
INITIAL_HAND_TRACKING_SNAPSHOT,
);
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const intervalRef = useRef<number | null>(null);
useEffect(() => {
if (!enabled) {
return undefined;
}
let cancelled = false;
const cleanup = (): void => {
if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
videoRef.current = null;
};
const start = async (): Promise<void> => {
setSnapshot({
hands: [],
status: "requesting_camera",
usageStatus: "available",
serverStatus: "Browser JS",
error: null,
});
try {
const stream = await getCameraStreamWithTimeout({
video: {
width: HAND_TRACKING_FRAME_WIDTH,
height: HAND_TRACKING_FRAME_HEIGHT,
facingMode: "user",
},
audio: false,
});
if (cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
setSnapshot((current) => ({
...current,
status: "starting_camera",
}));
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.srcObject = stream;
await video.play();
if (cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
setSnapshot((current) => ({
...current,
status: "connecting",
serverStatus: "Loading Browser JS model",
}));
const handLandmarker = await getBrowserHandLandmarker();
if (cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
streamRef.current = stream;
videoRef.current = video;
setSnapshot((current) => ({
...current,
status: "connected",
serverStatus: "Browser JS",
}));
intervalRef.current = window.setInterval(() => {
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
const result = handLandmarker.detectForVideo(
video,
performance.now(),
);
const hands = convertBrowserHandResult(result);
setSnapshot((current) => ({
...current,
hands,
usageStatus: hands.some((hand) => hand.isFist)
? "active"
: "available",
error: null,
}));
}, 1_000 / HAND_TRACKING_TARGET_FPS);
} catch (error) {
if (cancelled) return;
setSnapshot({
hands: [],
status: "error",
usageStatus: "inactive",
serverStatus: "Browser JS",
error:
error instanceof Error
? error.message
: "Browser hand tracking failed",
});
}
};
void start();
return () => {
cancelled = true;
cleanup();
};
}, [enabled]);
return snapshot;
}
@@ -0,0 +1,28 @@
import { create } from "zustand";
export type HandTrackingGloveHandedness = "left" | "right";
type HandTrackingGloveLoadState = "idle" | "loaded" | "error";
interface HandTrackingGloveStatusState {
gloves: Record<HandTrackingGloveHandedness, HandTrackingGloveLoadState>;
setGloveStatus: (
handedness: HandTrackingGloveHandedness,
status: HandTrackingGloveLoadState,
) => void;
}
export const useHandTrackingGloveStatus =
create<HandTrackingGloveStatusState>()((set) => ({
gloves: {
left: "idle",
right: "idle",
},
setGloveStatus: (handedness, status) =>
set((state) => ({
gloves: {
...state.gloves,
[handedness]: status,
},
})),
}));
@@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking";
export const HAND_TRACKING_IDLE_SNAPSHOT: HandTrackingSnapshot = {
hands: [],
status: "idle",
usageStatus: "inactive",
serverStatus: null,
error: null,
};
export const HandTrackingContext = createContext<HandTrackingSnapshot>(
HAND_TRACKING_IDLE_SNAPSHOT,
);
export function useHandTrackingSnapshot(): HandTrackingSnapshot {
return useContext(HandTrackingContext);
}
@@ -0,0 +1,330 @@
import { useEffect, useRef, useState } from "react";
import {
HAND_TRACKING_FRAME_HEIGHT,
HAND_TRACKING_FRAME_WIDTH,
HAND_TRACKING_JPEG_QUALITY,
HAND_TRACKING_RESPONSE_TIMEOUT_MS,
HAND_TRACKING_TARGET_FPS,
getHandTrackingWsUrl,
} from "@/data/handTrackingConfig";
import {
INITIAL_HAND_TRACKING_SNAPSHOT,
getCameraStreamWithTimeout,
} from "@/lib/handTracking/handTrackingSession";
import type {
HandTrackingFrameMessage,
HandTrackingHand,
HandTrackingServerMessage,
HandTrackingSnapshot,
} from "@/types/handTracking/handTracking";
interface UseRemoteHandTrackingOptions {
enabled: boolean;
websocketUrl?: string;
}
function getBase64Payload(dataUrl: string): string {
return dataUrl.slice(dataUrl.indexOf(",") + 1);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isHandTrackingLandmark(value: unknown): boolean {
return (
isRecord(value) &&
isFiniteNumber(value.x) &&
isFiniteNumber(value.y) &&
isFiniteNumber(value.z)
);
}
function isHandTrackingHand(value: unknown): value is HandTrackingHand {
return (
isRecord(value) &&
isFiniteNumber(value.x) &&
isFiniteNumber(value.y) &&
isFiniteNumber(value.z) &&
Array.isArray(value.landmarks) &&
value.landmarks.every(isHandTrackingLandmark) &&
typeof value.handedness === "string" &&
typeof value.isFist === "boolean" &&
isFiniteNumber(value.score)
);
}
function isHandTrackingServerMessage(
value: unknown,
): value is HandTrackingServerMessage {
if (!isRecord(value) || !isFiniteNumber(value.timestamp)) return false;
if (value.type === "hands") {
return Array.isArray(value.hands) && value.hands.every(isHandTrackingHand);
}
if (value.type === "status") {
return typeof value.status === "string";
}
return (
value.type === "error" &&
Array.isArray(value.hands) &&
value.hands.every(isHandTrackingHand) &&
typeof value.message === "string"
);
}
export function useRemoteHandTracking({
enabled,
websocketUrl = getHandTrackingWsUrl(),
}: UseRemoteHandTrackingOptions): HandTrackingSnapshot {
const [snapshot, setSnapshot] = useState<HandTrackingSnapshot>(
INITIAL_HAND_TRACKING_SNAPSHOT,
);
const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const sendIntervalRef = useRef<number | null>(null);
const responseTimeoutRef = useRef<number | null>(null);
const waitingForResponseRef = useRef(false);
useEffect(() => {
if (!enabled) {
return undefined;
}
let cancelled = false;
const clearResponseTimeout = (): void => {
if (responseTimeoutRef.current === null) return;
window.clearTimeout(responseTimeoutRef.current);
responseTimeoutRef.current = null;
};
const cleanup = (): void => {
if (sendIntervalRef.current !== null) {
window.clearInterval(sendIntervalRef.current);
sendIntervalRef.current = null;
}
clearResponseTimeout();
waitingForResponseRef.current = false;
wsRef.current?.close();
wsRef.current = null;
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
videoRef.current = null;
canvasRef.current = null;
};
const markResponseReceived = (): void => {
waitingForResponseRef.current = false;
clearResponseTimeout();
};
const markInvalidResponse = (): void => {
setSnapshot((current) => ({
...current,
hands: [],
status: "error",
usageStatus: "inactive",
error: "Invalid hand tracking response",
}));
};
const sendFrame = (): void => {
const ws = wsRef.current;
const video = videoRef.current;
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (!video || !canvas || !context) return;
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
if (waitingForResponseRef.current) return;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL(
"image/jpeg",
HAND_TRACKING_JPEG_QUALITY,
);
const message: HandTrackingFrameMessage = {
type: "frame",
timestamp: Date.now(),
width: canvas.width,
height: canvas.height,
image: getBase64Payload(dataUrl),
};
waitingForResponseRef.current = true;
ws.send(JSON.stringify(message));
responseTimeoutRef.current = window.setTimeout(() => {
waitingForResponseRef.current = false;
responseTimeoutRef.current = null;
}, HAND_TRACKING_RESPONSE_TIMEOUT_MS);
};
const start = async (): Promise<void> => {
await Promise.resolve();
if (cancelled) return;
setSnapshot({
hands: [],
status: "requesting_camera",
usageStatus: "available",
serverStatus: null,
error: null,
});
try {
const stream = await getCameraStreamWithTimeout({
video: {
width: HAND_TRACKING_FRAME_WIDTH,
height: HAND_TRACKING_FRAME_HEIGHT,
facingMode: "user",
},
audio: false,
});
if (cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
setSnapshot((current) => ({
...current,
status: "starting_camera",
}));
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.srcObject = stream;
await video.play();
if (cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
setSnapshot((current) => ({
...current,
status: "connecting_server",
}));
const canvas = document.createElement("canvas");
canvas.width = HAND_TRACKING_FRAME_WIDTH;
canvas.height = HAND_TRACKING_FRAME_HEIGHT;
const ws = new WebSocket(websocketUrl);
ws.onopen = () => {
setSnapshot((current) => ({
...current,
status: "connected",
usageStatus: "available",
error: null,
}));
};
ws.onmessage = (event) => {
markResponseReceived();
if (typeof event.data !== "string") {
markInvalidResponse();
return;
}
let data: unknown;
try {
data = JSON.parse(event.data);
} catch {
markInvalidResponse();
return;
}
if (!isHandTrackingServerMessage(data)) {
markInvalidResponse();
return;
}
if (data.type === "hands") {
setSnapshot((current) => ({
...current,
hands: data.hands,
usageStatus: data.hands.some((hand) => hand.isFist)
? "active"
: "available",
serverStatus: null,
error: null,
}));
return;
}
if (data.type === "status") {
setSnapshot((current) => ({
...current,
serverStatus: data.status,
}));
return;
}
setSnapshot((current) => ({
...current,
hands: [],
status: "error",
usageStatus: "inactive",
error: data.message,
}));
};
ws.onerror = () => {
markResponseReceived();
setSnapshot((current) => ({
...current,
status: "error",
error: "Hand tracking WebSocket error",
}));
};
ws.onclose = () => {
markResponseReceived();
setSnapshot((current) => ({
...current,
status: cancelled ? "idle" : "disconnected",
}));
};
streamRef.current = stream;
videoRef.current = video;
canvasRef.current = canvas;
wsRef.current = ws;
sendIntervalRef.current = window.setInterval(
sendFrame,
1_000 / HAND_TRACKING_TARGET_FPS,
);
} catch (error) {
if (cancelled) return;
setSnapshot({
hands: [],
status: "error",
usageStatus: "inactive",
serverStatus: null,
error:
error instanceof Error ? error.message : "Hand tracking failed",
});
}
};
void start();
return () => {
cancelled = true;
cleanup();
};
}, [enabled, websocketUrl]);
return snapshot;
}
@@ -1,6 +1,6 @@
import { useSyncExternalStore } from "react";
import { InteractionManager } from "@/stateManager/InteractionManager";
import type { InteractionSnapshot } from "@/types/interaction";
import { InteractionManager } from "@/managers/InteractionManager";
import type { InteractionSnapshot } from "@/types/interaction/interaction";
const manager = InteractionManager.getInstance();
+6
View File
@@ -0,0 +1,6 @@
import { useMemo } from "react";
import type * as THREE from "three";
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
return useMemo(() => object.clone(true) as T, [object]);
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import {
logModelLoadSuccess,
type ModelLoadLogContext,
} from "@/utils/three/modelLoadLogger";
export function useLoggedGLTF(
modelPath: string,
context: Omit<ModelLoadLogContext, "modelPath">,
) {
const gltf = useGLTF(modelPath);
const hasLoggedRef = useRef(false);
const { position, rotation, scale, scope } = context;
useEffect(() => {
if (hasLoggedRef.current) return;
hasLoggedRef.current = true;
logModelLoadSuccess({ modelPath, position, rotation, scale, scope }, gltf);
}, [gltf, modelPath, position, rotation, scale, scope]);
return gltf;
}
@@ -2,17 +2,25 @@ import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import type { Object3D } from "three";
import { Octree } from "three/addons/math/Octree.js";
import type { OctreeReadyHandler } from "@/types/3d";
import type { OctreeReadyHandler } from "@/types/three/three";
export function useOctreeGraphNode(
graphNodeRef: RefObject<Object3D | null>,
onOctreeReady: OctreeReadyHandler,
rebuildKey: string | number = 0,
enabled = true,
): void {
const octreeBuilt = useRef(false);
useEffect(() => {
octreeBuilt.current = false;
}, [rebuildKey]);
useEffect(() => {
if (!enabled) return;
const graphNode = graphNodeRef.current;
if (octreeBuilt.current || !graphNode) return;
if (!enabled || octreeBuilt.current || !graphNode) return;
octreeBuilt.current = true;
graphNode.updateMatrixWorld(true);
@@ -20,5 +28,5 @@ export function useOctreeGraphNode(
const octree = new Octree();
octree.fromGraphNode(graphNode);
onOctreeReady(octree);
}, [graphNodeRef, onOctreeReady]);
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { useGameStore } from "@/stores/gameStore";
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
export function useActivityCity(): boolean {
return useGameStore((state) => state.activityCity);
return useMissionFlowStore((state) => state.activityCity);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSyncExternalStore } from "react";
import { GameStepManager } from "@/stateManager/GameStepManager";
import { GameStepManager } from "@/managers/GameStepManager";
import type { GameStepSnapshot } from "@/types/game";
const manager = GameStepManager.getInstance();
+81
View File
@@ -0,0 +1,81 @@
import { useCallback, useEffect, useState } from "react";
import type { Octree } from "three/addons/math/Octree.js";
import type { SceneMode } from "@/types/debug/debug";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
interface UseWorldSceneLoadingOptions {
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
sceneMode: SceneMode;
}
interface UseWorldSceneLoadingResult {
octree: Octree | null;
showGameStage: boolean;
handleGameMapLoaded: () => void;
handleOctreeReady: (octree: Octree) => void;
}
export function useWorldSceneLoading({
onLoadingStateChange,
sceneMode,
}: UseWorldSceneLoadingOptions): UseWorldSceneLoadingResult {
const [octree, setOctree] = useState<Octree | null>(null);
const [gameMapLoaded, setGameMapLoaded] = useState(false);
const showGameStage = sceneMode === "game" && gameMapLoaded;
const sceneReady =
(sceneMode === "game" && gameMapLoaded) ||
(sceneMode === "physics" && octree !== null);
const handleGameMapLoaded = useCallback(() => {
setGameMapLoaded(true);
}, []);
const handleOctreeReady = useCallback(
(nextOctree: Octree) => {
setOctree(nextOctree);
onLoadingStateChange?.({
currentStep: "Collision prête",
progress: 0.92,
status: "loading",
});
},
[onLoadingStateChange],
);
useEffect(() => {
onLoadingStateChange?.({
currentStep: "Initialisation du jeu",
progress: 0,
status: "loading",
});
}, [onLoadingStateChange, sceneMode]);
useEffect(() => {
if (!sceneReady) return undefined;
onLoadingStateChange?.({
currentStep: "Gameplay prêt",
progress: 0.96,
status: "loading",
});
const timeoutId = window.setTimeout(() => {
onLoadingStateChange?.({
currentStep: "Gameplay prêt",
progress: 1,
status: "ready",
});
}, 150);
return () => {
window.clearTimeout(timeoutId);
};
}, [onLoadingStateChange, sceneReady]);
return {
octree,
showGameStage,
handleGameMapLoaded,
handleOctreeReady,
};
}