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:
@@ -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,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,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 {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
||||
|
||||
export function useShowDebugOverlay(): boolean {
|
||||
return useDebugStore((debug) => debug.getShowDebugOverlay());
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
||||
|
||||
export function useShowDebugPerf(): boolean {
|
||||
return useDebugStore((debug) => debug.getShowPerf());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] },
|
||||
}));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user