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:
+3
-42
@@ -1,47 +1,8 @@
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Crosshair } from "@/components/ui/Crosshair";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { IntroUI, BienvenueDisplay } from "@/components/ui/IntroUI";
|
||||
import { DialogMessage } from "@/components/ui/DialogMessage";
|
||||
import { useGameStore } from "@/stores/gameStore";
|
||||
import { DebugPerf } from "@/utils/debug/DebugPerf";
|
||||
import { World } from "@/world/World";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { router } from "@/router";
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const dialogMessage = useGameStore((state) => state.dialogMessage);
|
||||
const hideDialog = useGameStore((state) => state.hideDialog);
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogMessage) {
|
||||
const timer = setTimeout(() => {
|
||||
hideDialog();
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [dialogMessage, hideDialog]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
|
||||
<Suspense fallback={null}>
|
||||
<World />
|
||||
<DebugPerf />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
<Crosshair />
|
||||
<InteractPrompt />
|
||||
<IntroUI />
|
||||
<BienvenueDisplay />
|
||||
{dialogMessage && (
|
||||
<DialogMessage
|
||||
message={dialogMessage}
|
||||
duration={3000}
|
||||
onClose={hideDialog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { RigidBody } from "@react-three/rapier";
|
||||
import type { RapierRigidBody } from "@react-three/rapier";
|
||||
import * as THREE from "three";
|
||||
import { InteractableObject } from "@/components/3d/InteractableObject";
|
||||
import {
|
||||
GRAB_DEFAULT_COLLIDERS,
|
||||
GRAB_DEFAULT_LABEL,
|
||||
GRAB_HOLD_DISTANCE_DEFAULT,
|
||||
GRAB_HOLD_DISTANCE_MAX,
|
||||
GRAB_HOLD_DISTANCE_MIN,
|
||||
GRAB_HOLD_DISTANCE_STEP,
|
||||
GRAB_STIFFNESS_DEFAULT,
|
||||
GRAB_STIFFNESS_MAX,
|
||||
GRAB_STIFFNESS_MIN,
|
||||
GRAB_STIFFNESS_STEP,
|
||||
GRAB_THROW_BOOST_DEFAULT,
|
||||
GRAB_THROW_BOOST_MAX,
|
||||
GRAB_THROW_BOOST_MIN,
|
||||
GRAB_THROW_BOOST_STEP,
|
||||
} from "@/data/grabConfig";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import type { ColliderShape, Vector3Tuple } from "@/types/3d";
|
||||
|
||||
interface GrabbableObjectProps {
|
||||
position: Vector3Tuple;
|
||||
children: React.ReactNode;
|
||||
colliders?: ColliderShape;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Shared params let one debug folder drive every instance.
|
||||
const params = {
|
||||
stiffness: GRAB_STIFFNESS_DEFAULT,
|
||||
throwBoost: GRAB_THROW_BOOST_DEFAULT,
|
||||
holdDistance: GRAB_HOLD_DISTANCE_DEFAULT,
|
||||
};
|
||||
|
||||
const ZERO_ANGULAR_VELOCITY = { x: 0, y: 0, z: 0 };
|
||||
|
||||
const _holdTarget = new THREE.Vector3();
|
||||
const _currentPos = new THREE.Vector3();
|
||||
const _velocity = new THREE.Vector3();
|
||||
|
||||
export function GrabbableObject({
|
||||
position,
|
||||
children,
|
||||
colliders = GRAB_DEFAULT_COLLIDERS,
|
||||
label = GRAB_DEFAULT_LABEL,
|
||||
}: GrabbableObjectProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const rbRef = useRef<RapierRigidBody>(null);
|
||||
const isHolding = useRef(false);
|
||||
|
||||
useDebugFolder("GrabbableObject", (folder) => {
|
||||
folder
|
||||
.add(
|
||||
params,
|
||||
"stiffness",
|
||||
GRAB_STIFFNESS_MIN,
|
||||
GRAB_STIFFNESS_MAX,
|
||||
GRAB_STIFFNESS_STEP,
|
||||
)
|
||||
.name("Hold stiffness");
|
||||
folder
|
||||
.add(
|
||||
params,
|
||||
"throwBoost",
|
||||
GRAB_THROW_BOOST_MIN,
|
||||
GRAB_THROW_BOOST_MAX,
|
||||
GRAB_THROW_BOOST_STEP,
|
||||
)
|
||||
.name("Throw boost");
|
||||
folder
|
||||
.add(
|
||||
params,
|
||||
"holdDistance",
|
||||
GRAB_HOLD_DISTANCE_MIN,
|
||||
GRAB_HOLD_DISTANCE_MAX,
|
||||
GRAB_HOLD_DISTANCE_STEP,
|
||||
)
|
||||
.name("Hold distance");
|
||||
});
|
||||
|
||||
useFrame(() => {
|
||||
if (!isHolding.current || !rbRef.current) return;
|
||||
|
||||
camera.getWorldDirection(_holdTarget);
|
||||
_holdTarget.multiplyScalar(params.holdDistance).add(camera.position);
|
||||
|
||||
const t = rbRef.current.translation();
|
||||
_currentPos.set(t.x, t.y, t.z);
|
||||
|
||||
_velocity
|
||||
.subVectors(_holdTarget, _currentPos)
|
||||
.multiplyScalar(params.stiffness);
|
||||
|
||||
rbRef.current.setLinvel(
|
||||
{ x: _velocity.x, y: _velocity.y, z: _velocity.z },
|
||||
true,
|
||||
);
|
||||
rbRef.current.setAngvel(ZERO_ANGULAR_VELOCITY, true);
|
||||
});
|
||||
|
||||
return (
|
||||
<RigidBody
|
||||
ref={rbRef}
|
||||
type="dynamic"
|
||||
colliders={colliders}
|
||||
position={position}
|
||||
>
|
||||
<InteractableObject
|
||||
kind="grab"
|
||||
label={label}
|
||||
position={position}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
isHolding.current = true;
|
||||
}}
|
||||
onRelease={() => {
|
||||
isHolding.current = false;
|
||||
if (!rbRef.current || params.throwBoost === GRAB_THROW_BOOST_DEFAULT)
|
||||
return;
|
||||
const v = rbRef.current.linvel();
|
||||
rbRef.current.setLinvel(
|
||||
{
|
||||
x: v.x * params.throwBoost,
|
||||
y: v.y * params.throwBoost,
|
||||
z: v.z * params.throwBoost,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InteractableObject>
|
||||
</RigidBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useShowDebugPerf } from "@/hooks/debug/useShowDebugPerf";
|
||||
|
||||
const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf })));
|
||||
|
||||
const DEBUG_GUI_WIDTH = 245;
|
||||
const DEBUG_PANEL_GAP = 20;
|
||||
|
||||
export function DebugPerf(): React.JSX.Element | null {
|
||||
const showDebugPerf = useShowDebugPerf();
|
||||
|
||||
if (!showDebugPerf) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Perf
|
||||
position="top-right"
|
||||
style={{ right: DEBUG_GUI_WIDTH + DEBUG_PANEL_GAP }}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+5
-4
@@ -3,17 +3,18 @@ import {
|
||||
DEBUG_CAMERA_DAMPING_FACTOR,
|
||||
DEBUG_CAMERA_MAX_DISTANCE,
|
||||
DEBUG_CAMERA_MIN_DISTANCE,
|
||||
} from "@/data/debugConfig";
|
||||
} from "@/data/debug/debugConfig";
|
||||
import {
|
||||
PLAYER_EYE_HEIGHT,
|
||||
PLAYER_SPAWN_POSITION_GAME,
|
||||
} from "@/data/playerConfig";
|
||||
} from "@/data/player/playerConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const DEBUG_CAMERA_TARGET = [
|
||||
const DEBUG_CAMERA_TARGET: Vector3Tuple = [
|
||||
PLAYER_SPAWN_POSITION_GAME[0],
|
||||
PLAYER_EYE_HEIGHT,
|
||||
PLAYER_SPAWN_POSITION_GAME[2],
|
||||
] as const;
|
||||
];
|
||||
|
||||
export function DebugCameraControls(): React.JSX.Element {
|
||||
return (
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
DEBUG_GRID_SECONDARY_COLOR,
|
||||
DEBUG_GRID_SIZE,
|
||||
DEBUG_GRID_Y,
|
||||
} from "@/data/debugConfig";
|
||||
} from "@/data/debug/debugConfig";
|
||||
import { Debug } from "@/utils/debug/Debug";
|
||||
|
||||
export function DebugHelpers(): React.JSX.Element | null {
|
||||
@@ -0,0 +1,51 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { useDocsLanguage } from "@/hooks/docs/useDocsLanguage";
|
||||
|
||||
interface DocsDocumentProps {
|
||||
title: string;
|
||||
meta: string;
|
||||
content: string;
|
||||
frContent: string;
|
||||
}
|
||||
|
||||
export function DocsDocument({
|
||||
title,
|
||||
meta,
|
||||
content,
|
||||
frContent,
|
||||
}: DocsDocumentProps): React.JSX.Element {
|
||||
const { language, toggleLanguage } = useDocsLanguage();
|
||||
const translatedContent = language === "fr" ? frContent : content;
|
||||
|
||||
return (
|
||||
<div className="docs-content">
|
||||
<header className="docs-content__header">
|
||||
<span>{title}</span>
|
||||
<button
|
||||
className="docs-language-toggle"
|
||||
type="button"
|
||||
onClick={toggleLanguage}
|
||||
aria-label="Changer la langue de la documentation"
|
||||
>
|
||||
<span className={language === "fr" ? "is-active" : undefined}>
|
||||
FR
|
||||
</span>
|
||||
<span className={language === "en" ? "is-active" : undefined}>
|
||||
EN
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<article className="docs-section">
|
||||
<div className="docs-section__eyebrow">
|
||||
<span>{title}</span>
|
||||
<span>{meta}</span>
|
||||
</div>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{translatedContent}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { Home } from "lucide-react";
|
||||
import { docGroups } from "@/data/docs/docsSections";
|
||||
import { DocsLanguageProvider } from "@/providers/docs/DocsLanguageProvider";
|
||||
|
||||
export function DocsLayout(): React.JSX.Element {
|
||||
return (
|
||||
<DocsLanguageProvider>
|
||||
<main className="docs-page">
|
||||
<aside className="docs-sidebar" aria-label="Documentation">
|
||||
<header className="docs-sidebar__header">
|
||||
<h1>Folders</h1>
|
||||
<Link
|
||||
className="docs-home-link"
|
||||
to="/"
|
||||
aria-label="Retour à l'accueil"
|
||||
>
|
||||
<Home size={18} strokeWidth={2.25} aria-hidden="true" />
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
{docGroups.map((group) => (
|
||||
<section className="docs-nav-group" key={group.label}>
|
||||
<h2>{group.label}</h2>
|
||||
|
||||
{group.sections.map((section) => (
|
||||
<Link
|
||||
activeProps={{
|
||||
className: "docs-nav-item docs-nav-item--active",
|
||||
}}
|
||||
activeOptions={{ exact: true }}
|
||||
className="docs-nav-item"
|
||||
key={section.path}
|
||||
to={section.path}
|
||||
>
|
||||
<span>
|
||||
<strong>{section.title}</strong>
|
||||
<small>{section.subtitle}</small>
|
||||
</span>
|
||||
<span className="docs-nav-item__meta">{section.meta}</span>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<Outlet />
|
||||
</main>
|
||||
</DocsLanguageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
CinematicCameraKeyframe,
|
||||
CinematicDefinition,
|
||||
CinematicDialogueCue,
|
||||
CinematicManifest,
|
||||
} from "@/types/cinematics/cinematics";
|
||||
import type {
|
||||
DialogueDefinition,
|
||||
DialogueManifest,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
|
||||
type CinematicPatch = Partial<Omit<CinematicDefinition, "timecode">> & {
|
||||
timecode?: number | undefined;
|
||||
};
|
||||
|
||||
type VectorAxis = 0 | 1 | 2;
|
||||
const VECTOR_AXES: { label: "X" | "Y" | "Z"; axis: VectorAxis }[] = [
|
||||
{ label: "X", axis: 0 },
|
||||
{ label: "Y", axis: 1 },
|
||||
{ label: "Z", axis: 2 },
|
||||
];
|
||||
|
||||
function createCinematic(index: number): CinematicDefinition {
|
||||
return {
|
||||
id: `new_cinematic_${index}`,
|
||||
cameraKeyframes: [
|
||||
{ time: 0, position: [0, 3, 8], target: [0, 1.5, 0] },
|
||||
{ time: 3, position: [6, 3, 8], target: [0, 1.5, 0] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createKeyframe(
|
||||
previousKeyframe: CinematicCameraKeyframe,
|
||||
): CinematicCameraKeyframe {
|
||||
return {
|
||||
time: previousKeyframe.time + 3,
|
||||
position: [...previousKeyframe.position],
|
||||
target: [...previousKeyframe.target],
|
||||
};
|
||||
}
|
||||
|
||||
function createDialogueCue(
|
||||
dialogues: DialogueDefinition[],
|
||||
previousCue: CinematicDialogueCue | null,
|
||||
): CinematicDialogueCue {
|
||||
return {
|
||||
time: previousCue ? previousCue.time + 1 : 0,
|
||||
dialogueId: dialogues[0]?.id ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function getManifestErrors(
|
||||
manifest: CinematicManifest | null,
|
||||
dialogueIds: Set<string>,
|
||||
): string[] {
|
||||
if (!manifest) return ["Manifeste absent."];
|
||||
|
||||
const errors: string[] = [];
|
||||
const ids = new Set<string>();
|
||||
|
||||
manifest.cinematics.forEach((cinematic, cinematicIndex) => {
|
||||
const label = cinematic.id || `Cinematique ${cinematicIndex + 1}`;
|
||||
|
||||
if (!cinematic.id.trim()) errors.push(`${label}: id obligatoire.`);
|
||||
if (ids.has(cinematic.id)) errors.push(`${label}: id duplique.`);
|
||||
ids.add(cinematic.id);
|
||||
|
||||
if (
|
||||
cinematic.timecode !== undefined &&
|
||||
(!Number.isFinite(cinematic.timecode) || cinematic.timecode < 0)
|
||||
) {
|
||||
errors.push(`${label}: timecode invalide.`);
|
||||
}
|
||||
|
||||
if (cinematic.cameraKeyframes.length < 2) {
|
||||
errors.push(`${label}: au moins deux keyframes camera sont requises.`);
|
||||
}
|
||||
|
||||
cinematic.cameraKeyframes.forEach((keyframe, keyframeIndex) => {
|
||||
const previousKeyframe = cinematic.cameraKeyframes[keyframeIndex - 1];
|
||||
|
||||
if (!Number.isFinite(keyframe.time) || keyframe.time < 0) {
|
||||
errors.push(`${label}: keyframe ${keyframeIndex + 1} time invalide.`);
|
||||
}
|
||||
|
||||
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
|
||||
errors.push(`${label}: les temps des keyframes doivent augmenter.`);
|
||||
}
|
||||
});
|
||||
|
||||
cinematic.dialogueCues?.forEach((cue, cueIndex) => {
|
||||
if (!Number.isFinite(cue.time) || cue.time < 0) {
|
||||
errors.push(`${label}: dialogue cue ${cueIndex + 1} time invalide.`);
|
||||
}
|
||||
|
||||
if (!cue.dialogueId.trim()) {
|
||||
errors.push(`${label}: dialogue cue ${cueIndex + 1} id obligatoire.`);
|
||||
} else if (dialogueIds.size > 0 && !dialogueIds.has(cue.dialogueId)) {
|
||||
errors.push(`${label}: dialogue cue ${cueIndex + 1} dialogue inconnu.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function saveCinematicManifest(
|
||||
manifest: CinematicManifest,
|
||||
): Promise<void> {
|
||||
const response = await fetch("/api/save-cinematics", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(manifest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(body?.error ?? "Sauvegarde des cinematics impossible");
|
||||
}
|
||||
}
|
||||
|
||||
function getPatchedCinematic(
|
||||
cinematic: CinematicDefinition,
|
||||
patch: CinematicPatch,
|
||||
): CinematicDefinition {
|
||||
const nextCinematic: CinematicDefinition = {
|
||||
id: patch.id ?? cinematic.id,
|
||||
cameraKeyframes: patch.cameraKeyframes ?? cinematic.cameraKeyframes,
|
||||
};
|
||||
|
||||
const dialogueCues = patch.dialogueCues ?? cinematic.dialogueCues;
|
||||
if (dialogueCues) {
|
||||
nextCinematic.dialogueCues = dialogueCues;
|
||||
}
|
||||
|
||||
if ("timecode" in patch) {
|
||||
if (patch.timecode !== undefined) nextCinematic.timecode = patch.timecode;
|
||||
} else if (cinematic.timecode !== undefined) {
|
||||
nextCinematic.timecode = cinematic.timecode;
|
||||
}
|
||||
|
||||
return nextCinematic;
|
||||
}
|
||||
|
||||
function updateVector(
|
||||
vector: Vector3Tuple,
|
||||
axis: VectorAxis,
|
||||
value: number,
|
||||
): Vector3Tuple {
|
||||
const nextVector: Vector3Tuple = [...vector];
|
||||
nextVector[axis] = value;
|
||||
return nextVector;
|
||||
}
|
||||
|
||||
interface EditorCinematicManifestPanelProps {
|
||||
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
|
||||
}
|
||||
|
||||
export function EditorCinematicManifestPanel({
|
||||
onPreviewCinematic,
|
||||
}: EditorCinematicManifestPanelProps): React.JSX.Element {
|
||||
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||
const [dialogueManifest, setDialogueManifest] =
|
||||
useState<DialogueManifest | null>(null);
|
||||
const [selectedCinematicId, setSelectedCinematicId] = useState("");
|
||||
const [status, setStatus] = useState("Chargement des cinematics...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const dialogueIds = new Set(
|
||||
dialogueManifest?.dialogues.map((dialogue) => dialogue.id) ?? [],
|
||||
);
|
||||
const errors = getManifestErrors(manifest, dialogueIds);
|
||||
const selectedCinematic =
|
||||
manifest?.cinematics.find(
|
||||
(cinematic) => cinematic.id === selectedCinematicId,
|
||||
) ??
|
||||
manifest?.cinematics[0] ??
|
||||
null;
|
||||
|
||||
async function handleLoad(): Promise<void> {
|
||||
setStatus("Chargement des cinematics...");
|
||||
|
||||
try {
|
||||
const [loadedManifest, loadedDialogueManifest] = await Promise.all([
|
||||
loadCinematicManifest(),
|
||||
loadDialogueManifest(),
|
||||
]);
|
||||
setManifest(loadedManifest);
|
||||
setDialogueManifest(loadedDialogueManifest);
|
||||
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
|
||||
setStatus(
|
||||
loadedManifest
|
||||
? `Manifeste charge: ${loadedManifest.cinematics.length} cinematics.`
|
||||
: "Manifeste cinematics introuvable ou invalide.",
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
setManifest(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!manifest) return;
|
||||
if (errors.length > 0) {
|
||||
setStatus("Corrige les erreurs avant de sauvegarder.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Sauvegarde des cinematics...");
|
||||
|
||||
try {
|
||||
await saveCinematicManifest(manifest);
|
||||
setStatus("Manifeste sauvegarde dans public/cinematics.json.");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddCinematic(): void {
|
||||
if (!manifest) return;
|
||||
|
||||
const cinematic = createCinematic(manifest.cinematics.length + 1);
|
||||
setManifest({
|
||||
...manifest,
|
||||
cinematics: [...manifest.cinematics, cinematic],
|
||||
});
|
||||
setSelectedCinematicId(cinematic.id);
|
||||
setStatus("Nouvelle cinematic ajoutee localement.");
|
||||
}
|
||||
|
||||
function handleRemoveCinematic(cinematicId: string): void {
|
||||
if (!manifest) return;
|
||||
|
||||
const nextCinematics = manifest.cinematics.filter(
|
||||
(cinematic) => cinematic.id !== cinematicId,
|
||||
);
|
||||
setManifest({ ...manifest, cinematics: nextCinematics });
|
||||
setSelectedCinematicId(nextCinematics[0]?.id ?? "");
|
||||
setStatus("Cinematic supprimee localement.");
|
||||
}
|
||||
|
||||
function updateSelectedCinematic(
|
||||
patch: CinematicPatch,
|
||||
nextId = selectedCinematicId,
|
||||
): void {
|
||||
if (!manifest || !selectedCinematic) return;
|
||||
|
||||
setManifest({
|
||||
...manifest,
|
||||
cinematics: manifest.cinematics.map((cinematic) =>
|
||||
cinematic.id === selectedCinematic.id
|
||||
? getPatchedCinematic(cinematic, patch)
|
||||
: cinematic,
|
||||
),
|
||||
});
|
||||
setSelectedCinematicId(nextId);
|
||||
}
|
||||
|
||||
function updateKeyframe(
|
||||
keyframeIndex: number,
|
||||
patch: Partial<CinematicCameraKeyframe>,
|
||||
): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
updateSelectedCinematic({
|
||||
cameraKeyframes: selectedCinematic.cameraKeyframes.map(
|
||||
(keyframe, index) =>
|
||||
index === keyframeIndex ? { ...keyframe, ...patch } : keyframe,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddKeyframe(): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
const previousKeyframe =
|
||||
selectedCinematic.cameraKeyframes[
|
||||
selectedCinematic.cameraKeyframes.length - 1
|
||||
];
|
||||
if (!previousKeyframe) return;
|
||||
|
||||
updateSelectedCinematic({
|
||||
cameraKeyframes: [
|
||||
...selectedCinematic.cameraKeyframes,
|
||||
createKeyframe(previousKeyframe),
|
||||
],
|
||||
});
|
||||
setStatus("Keyframe ajoutee localement.");
|
||||
}
|
||||
|
||||
function handleRemoveKeyframe(keyframeIndex: number): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
updateSelectedCinematic({
|
||||
cameraKeyframes: selectedCinematic.cameraKeyframes.filter(
|
||||
(_keyframe, index) => index !== keyframeIndex,
|
||||
),
|
||||
});
|
||||
setStatus("Keyframe supprimee localement.");
|
||||
}
|
||||
|
||||
function updateDialogueCue(
|
||||
cueIndex: number,
|
||||
patch: Partial<CinematicDialogueCue>,
|
||||
): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
const dialogueCues = selectedCinematic.dialogueCues ?? [];
|
||||
updateSelectedCinematic({
|
||||
dialogueCues: dialogueCues.map((cue, index) =>
|
||||
index === cueIndex ? { ...cue, ...patch } : cue,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddDialogueCue(): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
const dialogueCues = selectedCinematic.dialogueCues ?? [];
|
||||
const previousCue = dialogueCues[dialogueCues.length - 1] ?? null;
|
||||
updateSelectedCinematic({
|
||||
dialogueCues: [
|
||||
...dialogueCues,
|
||||
createDialogueCue(dialogueManifest?.dialogues ?? [], previousCue),
|
||||
],
|
||||
});
|
||||
setStatus("Dialogue cue ajoutee localement.");
|
||||
}
|
||||
|
||||
function handleRemoveDialogueCue(cueIndex: number): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
updateSelectedCinematic({
|
||||
dialogueCues: (selectedCinematic.dialogueCues ?? []).filter(
|
||||
(_cue, index) => index !== cueIndex,
|
||||
),
|
||||
});
|
||||
setStatus("Dialogue cue supprimee localement.");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void Promise.all([loadCinematicManifest(), loadDialogueManifest()])
|
||||
.then(([loadedManifest, loadedDialogueManifest]) => {
|
||||
if (!mounted) return;
|
||||
|
||||
setManifest(loadedManifest);
|
||||
setDialogueManifest(loadedDialogueManifest);
|
||||
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
|
||||
setStatus(
|
||||
loadedManifest
|
||||
? `Manifeste charge: ${loadedManifest.cinematics.length} cinematics.`
|
||||
: "Manifeste cinematics introuvable ou invalide.",
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!mounted) return;
|
||||
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
setManifest(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="editor-cinematic-manifest-section"
|
||||
aria-labelledby="cinematic-manifest-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="cinematic-manifest-heading">Cinematics</h3>
|
||||
<span>{manifest?.cinematics.length ?? 0} items</span>
|
||||
</div>
|
||||
|
||||
<div className="editor-cinematic-manifest-actions">
|
||||
<button type="button" onClick={() => void handleLoad()}>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
Reload
|
||||
</button>
|
||||
<button type="button" disabled={!manifest} onClick={handleAddCinematic}>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!manifest || errors.length > 0 || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
<Save size={14} aria-hidden="true" />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{manifest && (
|
||||
<label className="editor-cinematic-manifest-select">
|
||||
Cinematic
|
||||
<select
|
||||
value={selectedCinematic?.id ?? ""}
|
||||
onChange={(event) => setSelectedCinematicId(event.target.value)}
|
||||
>
|
||||
{manifest.cinematics.map((cinematic) => (
|
||||
<option key={cinematic.id} value={cinematic.id}>
|
||||
{cinematic.id || "Cinematic sans id"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedCinematic && (
|
||||
<div className="editor-cinematic-manifest-form">
|
||||
<label>
|
||||
ID
|
||||
<input
|
||||
value={selectedCinematic.id}
|
||||
onChange={(event) =>
|
||||
updateSelectedCinematic(
|
||||
{ id: event.target.value },
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Timecode global optionnel
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={selectedCinematic.timecode ?? ""}
|
||||
placeholder="Aucun"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value.trim();
|
||||
updateSelectedCinematic({
|
||||
timecode: value === "" ? undefined : Number(value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="editor-cinematic-keyframes">
|
||||
<div className="editor-cinematic-keyframes-heading">
|
||||
<strong>Camera keyframes</strong>
|
||||
<button type="button" onClick={handleAddKeyframe}>
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
Add keyframe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedCinematic.cameraKeyframes.map(
|
||||
(keyframe, keyframeIndex) => (
|
||||
<div
|
||||
className="editor-cinematic-keyframe"
|
||||
key={`${selectedCinematic.id}-${keyframeIndex}`}
|
||||
>
|
||||
<div className="editor-cinematic-keyframe-heading">
|
||||
<strong>Keyframe {keyframeIndex + 1}</strong>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedCinematic.cameraKeyframes.length <= 2}
|
||||
onClick={() => handleRemoveKeyframe(keyframeIndex)}
|
||||
>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Time
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={keyframe.time}
|
||||
onChange={(event) =>
|
||||
updateKeyframe(keyframeIndex, {
|
||||
time: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<VectorInputs
|
||||
label="Position"
|
||||
value={keyframe.position}
|
||||
onChange={(axis, value) =>
|
||||
updateKeyframe(keyframeIndex, {
|
||||
position: updateVector(keyframe.position, axis, value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<VectorInputs
|
||||
label="Target"
|
||||
value={keyframe.target}
|
||||
onChange={(axis, value) =>
|
||||
updateKeyframe(keyframeIndex, {
|
||||
target: updateVector(keyframe.target, axis, value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="editor-cinematic-dialogue-cues">
|
||||
<div className="editor-cinematic-dialogue-cues-heading">
|
||||
<strong>Dialogue cues</strong>
|
||||
<button type="button" onClick={handleAddDialogueCue}>
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
Add dialogue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(selectedCinematic.dialogueCues ?? []).length === 0 ? (
|
||||
<p>Aucun dialogue synchronise avec cette cinematic.</p>
|
||||
) : (
|
||||
(selectedCinematic.dialogueCues ?? []).map((cue, cueIndex) => (
|
||||
<div
|
||||
className="editor-cinematic-dialogue-cue"
|
||||
key={`${selectedCinematic.id}-dialogue-${cueIndex}`}
|
||||
>
|
||||
<div className="editor-cinematic-dialogue-cue-heading">
|
||||
<strong>Dialogue {cueIndex + 1}</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveDialogueCue(cueIndex)}
|
||||
>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Time
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={cue.time}
|
||||
onChange={(event) =>
|
||||
updateDialogueCue(cueIndex, {
|
||||
time: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Dialogue
|
||||
<select
|
||||
value={cue.dialogueId}
|
||||
onChange={(event) =>
|
||||
updateDialogueCue(cueIndex, {
|
||||
dialogueId: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
{dialogueManifest?.dialogues.length ? (
|
||||
dialogueManifest.dialogues.map((dialogue) => (
|
||||
<option key={dialogue.id} value={dialogue.id}>
|
||||
{dialogue.id}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value={cue.dialogueId}>
|
||||
{cue.dialogueId || "Aucun dialogue disponible"}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="editor-cinematic-manifest-preview"
|
||||
type="button"
|
||||
disabled={errors.length > 0 || !onPreviewCinematic}
|
||||
onClick={() => onPreviewCinematic?.(selectedCinematic)}
|
||||
>
|
||||
<Play size={14} aria-hidden="true" />
|
||||
Preview cinematic
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="editor-cinematic-manifest-delete"
|
||||
type="button"
|
||||
onClick={() => handleRemoveCinematic(selectedCinematic.id)}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete cinematic
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="editor-cinematic-manifest-status">{status}</p>
|
||||
<div
|
||||
className={`editor-cinematic-manifest-diagnostic ${errors.length === 0 ? "is-valid" : "is-invalid"}`}
|
||||
>
|
||||
<strong>
|
||||
{errors.length === 0
|
||||
? "Manifeste local valide."
|
||||
: `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`}
|
||||
</strong>
|
||||
{errors.length > 0 && (
|
||||
<ul>
|
||||
{errors.map((error) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface VectorInputsProps {
|
||||
label: string;
|
||||
value: Vector3Tuple;
|
||||
onChange: (axis: VectorAxis, value: number) => void;
|
||||
}
|
||||
|
||||
function VectorInputs({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: VectorInputsProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="editor-cinematic-vector-inputs">
|
||||
<span>{label}</span>
|
||||
{VECTOR_AXES.map(({ label: axisLabel, axis }) => (
|
||||
<label key={axisLabel}>
|
||||
{axisLabel}
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={value[axis]}
|
||||
onChange={(event) => onChange(axis, Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
Box,
|
||||
Braces,
|
||||
Download,
|
||||
Expand,
|
||||
Keyboard,
|
||||
Lock,
|
||||
MousePointer2,
|
||||
Move3D,
|
||||
Redo2,
|
||||
RotateCw,
|
||||
Save,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
||||
|
||||
interface EditorControlsProps {
|
||||
transformMode: TransformMode;
|
||||
onTransformModeChange: (mode: TransformMode) => void;
|
||||
selectedNodeIndex: number | null;
|
||||
mapNodes: MapNode[];
|
||||
nodesCount: number;
|
||||
selectedNodeName: string | null;
|
||||
undoCount: number;
|
||||
redoCount: number;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onExportJson: () => void;
|
||||
onSaveToServer?: (() => void | Promise<void>) | undefined;
|
||||
onPlayerMode?: (() => void) | undefined;
|
||||
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
|
||||
isPlayerMode?: boolean;
|
||||
}
|
||||
|
||||
const TRANSFORM_OPTIONS = [
|
||||
{ mode: "translate", label: "Translate", shortcut: "T", Icon: Move3D },
|
||||
{ mode: "rotate", label: "Rotate", shortcut: "R", Icon: RotateCw },
|
||||
{ mode: "scale", label: "Scale", shortcut: "S", Icon: Expand },
|
||||
] as const;
|
||||
|
||||
const EDITOR_SHORTCUTS = [
|
||||
["Click", "Select object"],
|
||||
["T / R / S", "Transform mode"],
|
||||
["Ctrl Z / Y", "Undo / redo"],
|
||||
["Esc", "Deselect"],
|
||||
["WASD", "Move when locked"],
|
||||
] as const;
|
||||
|
||||
export function EditorControls({
|
||||
transformMode,
|
||||
onTransformModeChange,
|
||||
selectedNodeIndex,
|
||||
mapNodes,
|
||||
nodesCount,
|
||||
selectedNodeName,
|
||||
undoCount,
|
||||
redoCount,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onExportJson,
|
||||
onSaveToServer,
|
||||
onPlayerMode,
|
||||
onPreviewCinematic,
|
||||
isPlayerMode,
|
||||
}: EditorControlsProps): React.JSX.Element {
|
||||
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
|
||||
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="editor-controls-panel" aria-label="Editor controls">
|
||||
<header className="editor-panel-header">
|
||||
<span className="editor-panel-kicker">Map Editor</span>
|
||||
<h2>Scene controls</h2>
|
||||
<p>Select an object, choose a transform mode, then drag the gizmo.</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="transform-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="transform-heading">Transform</h3>
|
||||
<span>T / R / S</span>
|
||||
</div>
|
||||
|
||||
<div className="editor-transform-buttons">
|
||||
{TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`editor-transform-button ${transformMode === mode ? "active" : ""}`}
|
||||
onClick={() => onTransformModeChange(mode)}
|
||||
aria-pressed={transformMode === mode}
|
||||
>
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
<kbd>{shortcut}</kbd>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="editor-history-buttons">
|
||||
<button
|
||||
className="editor-history-button"
|
||||
onClick={onUndo}
|
||||
disabled={undoCount === 0}
|
||||
>
|
||||
<Undo2 size={15} aria-hidden="true" />
|
||||
Undo
|
||||
<span>{undoCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className="editor-history-button"
|
||||
onClick={onRedo}
|
||||
disabled={redoCount === 0}
|
||||
>
|
||||
<Redo2 size={15} aria-hidden="true" />
|
||||
Redo
|
||||
<span>{redoCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="file-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="file-heading">File</h3>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="editor-action-button editor-action-button-primary"
|
||||
onClick={onExportJson}
|
||||
>
|
||||
<Download size={16} aria-hidden="true" />
|
||||
Export JSON
|
||||
</button>
|
||||
|
||||
{onSaveToServer && (
|
||||
<button className="editor-action-button" onClick={onSaveToServer}>
|
||||
<Save size={16} aria-hidden="true" />
|
||||
Save to server
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="view-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="view-heading">View</h3>
|
||||
</div>
|
||||
|
||||
{onPlayerMode && (
|
||||
<button
|
||||
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
|
||||
onClick={onPlayerMode}
|
||||
aria-pressed={isPlayerMode}
|
||||
>
|
||||
<Lock size={16} aria-hidden="true" />
|
||||
{viewModeLabel}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="selection-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="selection-heading">Selection</h3>
|
||||
<span>{nodesCount} nodes</span>
|
||||
</div>
|
||||
|
||||
{selectedNodeIndex !== null ? (
|
||||
<div className="editor-selected-info">
|
||||
<Box size={17} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
|
||||
</strong>
|
||||
<span>
|
||||
Index {selectedNodeIndex + 1} of {nodesCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="editor-no-selection">
|
||||
<MousePointer2 size={17} aria-hidden="true" />
|
||||
No object selected
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="shortcuts-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="shortcuts-heading">Shortcuts</h3>
|
||||
<Keyboard size={15} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<dl className="editor-shortcuts-list">
|
||||
{EDITOR_SHORTCUTS.map(([keys, description]) => (
|
||||
<div key={keys}>
|
||||
<dt>{keys}</dt>
|
||||
<dd>{description}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="editor-json-section" aria-labelledby="json-heading">
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="json-heading">JSON</h3>
|
||||
<span>{jsonPreview.label}</span>
|
||||
</div>
|
||||
|
||||
<pre className="editor-json-view" aria-label={jsonPreview.label}>
|
||||
{jsonPreview.lines.map((line) => (
|
||||
<code
|
||||
key={line.number}
|
||||
className={line.isSelected ? "is-selected" : undefined}
|
||||
>
|
||||
<span>{line.number}</span>
|
||||
{line.content || " "}
|
||||
</code>
|
||||
))}
|
||||
</pre>
|
||||
|
||||
<div className="editor-json-hint">
|
||||
<Braces size={14} aria-hidden="true" />
|
||||
{selectedNodeIndex === null
|
||||
? "Raw map JSON"
|
||||
: `Selected node ${selectedNodeIndex + 1} raw lines`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
|
||||
<EditorDialogueManifestPanel />
|
||||
<EditorSrtPanel />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface JsonPreviewLine {
|
||||
number: number;
|
||||
content: string;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
interface JsonPreview {
|
||||
label: string;
|
||||
lines: JsonPreviewLine[];
|
||||
}
|
||||
|
||||
function getJsonPreview(
|
||||
mapNodes: MapNode[],
|
||||
selectedNodeIndex: number | null,
|
||||
): JsonPreview {
|
||||
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
|
||||
|
||||
if (selectedNodeIndex === null || !ranges[selectedNodeIndex]) {
|
||||
return {
|
||||
label: `${lines.length} raw lines`,
|
||||
lines: lines.map((content, index) => ({
|
||||
number: index + 1,
|
||||
content,
|
||||
isSelected: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const range = ranges[selectedNodeIndex];
|
||||
const selectedLines = lines.slice(range.start - 1, range.end);
|
||||
|
||||
return {
|
||||
label: `Lines ${range.start}-${range.end}`,
|
||||
lines: selectedLines.map((content, index) => ({
|
||||
number: range.start + index,
|
||||
content,
|
||||
isSelected: true,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function formatMapNodesWithRanges(mapNodes: MapNode[]): {
|
||||
lines: string[];
|
||||
ranges: Array<{ start: number; end: number }>;
|
||||
} {
|
||||
const lines = ["["];
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
|
||||
mapNodes.forEach((node, index) => {
|
||||
const objectLines = JSON.stringify(node, null, 2)
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`);
|
||||
|
||||
if (index < mapNodes.length - 1) {
|
||||
objectLines[objectLines.length - 1] += ",";
|
||||
}
|
||||
|
||||
const start = lines.length + 1;
|
||||
lines.push(...objectLines);
|
||||
ranges.push({ start, end: lines.length });
|
||||
});
|
||||
|
||||
lines.push("]");
|
||||
|
||||
return { lines, ranges };
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
DialogueDefinition,
|
||||
DialogueManifest,
|
||||
DialogueSpeaker,
|
||||
DialogueVoiceId,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||
|
||||
const DEFAULT_VOICE: DialogueVoiceId = "narrateur";
|
||||
type DialoguePatch = Partial<Omit<DialogueDefinition, "timecode">> & {
|
||||
timecode?: number | undefined;
|
||||
};
|
||||
|
||||
function createDialogue(
|
||||
index: number,
|
||||
manifest: DialogueManifest,
|
||||
voice: DialogueVoiceId,
|
||||
): DialogueDefinition {
|
||||
return {
|
||||
id: `new_dialogue_${index}`,
|
||||
voice,
|
||||
audio: `/sounds/dialogue/new_dialogue_${index}.mp3`,
|
||||
subtitleCueIndex: getNextCueIndex(manifest, voice),
|
||||
};
|
||||
}
|
||||
|
||||
function getNextCueIndex(
|
||||
manifest: DialogueManifest,
|
||||
voice: DialogueVoiceId,
|
||||
): number {
|
||||
const cueIndexes = manifest.dialogues
|
||||
.filter((dialogue) => dialogue.voice === voice)
|
||||
.map((dialogue) => dialogue.subtitleCueIndex);
|
||||
|
||||
return Math.max(0, ...cueIndexes) + 1;
|
||||
}
|
||||
|
||||
function getVoiceSpeaker(
|
||||
manifest: DialogueManifest,
|
||||
voice: DialogueVoiceId,
|
||||
): DialogueSpeaker {
|
||||
return (
|
||||
manifest.voices.find((item) => item.id === voice)?.speaker ?? "Narrateur"
|
||||
);
|
||||
}
|
||||
|
||||
function getFrenchSrtPath(voice: DialogueVoiceId): string {
|
||||
return `/sounds/dialogue/subtitles/fr/${voice}.srt`;
|
||||
}
|
||||
|
||||
function createSrtCueBlock(cueIndex: number, speaker: DialogueSpeaker): string {
|
||||
return `${cueIndex}\n00:00:00,000 --> 00:00:02,000\n${speaker}: Nouveau sous-titre ${cueIndex} a definir`;
|
||||
}
|
||||
|
||||
function appendSrtCueIfMissing(
|
||||
content: string,
|
||||
cueIndex: number,
|
||||
speaker: DialogueSpeaker,
|
||||
): string {
|
||||
const cues = parseSrt(content);
|
||||
if (cues.some((cue) => cue.index === cueIndex)) return content;
|
||||
|
||||
const trimmedContent = content.trim();
|
||||
const cueBlock = createSrtCueBlock(cueIndex, speaker);
|
||||
return trimmedContent
|
||||
? `${trimmedContent}\n\n${cueBlock}\n`
|
||||
: `${cueBlock}\n`;
|
||||
}
|
||||
|
||||
async function saveSrtFile(
|
||||
voice: DialogueVoiceId,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch("/api/save-srt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ voice, language: "fr", content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(body?.error ?? "Sauvegarde SRT impossible");
|
||||
}
|
||||
}
|
||||
|
||||
async function createFrenchSrtCue(
|
||||
manifest: DialogueManifest,
|
||||
dialogue: DialogueDefinition,
|
||||
): Promise<void> {
|
||||
const srtPath = getFrenchSrtPath(dialogue.voice);
|
||||
const response = await fetch(srtPath);
|
||||
const content = response.ok ? await response.text() : "";
|
||||
const nextContent = appendSrtCueIfMissing(
|
||||
content,
|
||||
dialogue.subtitleCueIndex,
|
||||
getVoiceSpeaker(manifest, dialogue.voice),
|
||||
);
|
||||
|
||||
await saveSrtFile(dialogue.voice, nextContent);
|
||||
}
|
||||
|
||||
function getManifestErrors(manifest: DialogueManifest | null): string[] {
|
||||
if (!manifest) return ["Manifeste absent."];
|
||||
|
||||
const errors: string[] = [];
|
||||
const ids = new Set<string>();
|
||||
|
||||
manifest.dialogues.forEach((dialogue, index) => {
|
||||
const label = dialogue.id || `Dialogue ${index + 1}`;
|
||||
|
||||
if (!dialogue.id.trim()) errors.push(`${label}: id obligatoire.`);
|
||||
if (ids.has(dialogue.id)) errors.push(`${label}: id duplique.`);
|
||||
ids.add(dialogue.id);
|
||||
|
||||
if (!dialogue.audio.startsWith("/sounds/dialogue/")) {
|
||||
errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(dialogue.subtitleCueIndex)) {
|
||||
errors.push(`${label}: cue SRT invalide.`);
|
||||
}
|
||||
|
||||
if (
|
||||
dialogue.timecode !== undefined &&
|
||||
(!Number.isFinite(dialogue.timecode) || dialogue.timecode < 0)
|
||||
) {
|
||||
errors.push(`${label}: timecode invalide.`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function saveDialogueManifest(manifest: DialogueManifest): Promise<void> {
|
||||
const response = await fetch("/api/save-dialogues", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(manifest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(body?.error ?? "Sauvegarde du manifeste impossible");
|
||||
}
|
||||
}
|
||||
|
||||
function getPatchedDialogue(
|
||||
dialogue: DialogueDefinition,
|
||||
patch: DialoguePatch,
|
||||
): DialogueDefinition {
|
||||
const nextDialogue: DialogueDefinition = {
|
||||
id: patch.id ?? dialogue.id,
|
||||
voice: patch.voice ?? dialogue.voice,
|
||||
audio: patch.audio ?? dialogue.audio,
|
||||
subtitleCueIndex: patch.subtitleCueIndex ?? dialogue.subtitleCueIndex,
|
||||
};
|
||||
|
||||
if ("timecode" in patch) {
|
||||
if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode;
|
||||
} else if (dialogue.timecode !== undefined) {
|
||||
nextDialogue.timecode = dialogue.timecode;
|
||||
}
|
||||
|
||||
return nextDialogue;
|
||||
}
|
||||
|
||||
export function EditorDialogueManifestPanel(): React.JSX.Element {
|
||||
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
|
||||
const [selectedDialogueId, setSelectedDialogueId] = useState("");
|
||||
const [status, setStatus] = useState("Chargement du manifeste...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isPreviewing, setIsPreviewing] = useState(false);
|
||||
const [isCreatingSrtCue, setIsCreatingSrtCue] = useState(false);
|
||||
const errors = getManifestErrors(manifest);
|
||||
const selectedDialogue =
|
||||
manifest?.dialogues.find(
|
||||
(dialogue) => dialogue.id === selectedDialogueId,
|
||||
) ??
|
||||
manifest?.dialogues[0] ??
|
||||
null;
|
||||
const voices = manifest?.voices ?? [];
|
||||
|
||||
async function handleLoad(): Promise<void> {
|
||||
setStatus("Chargement du manifeste...");
|
||||
|
||||
try {
|
||||
const loadedManifest = await loadDialogueManifest();
|
||||
setManifest(loadedManifest);
|
||||
setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? "");
|
||||
setStatus(
|
||||
loadedManifest
|
||||
? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.`
|
||||
: "Manifeste introuvable ou invalide.",
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
setManifest(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!manifest) return;
|
||||
if (errors.length > 0) {
|
||||
setStatus("Corrige les erreurs avant de sauvegarder.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Sauvegarde du manifeste...");
|
||||
|
||||
try {
|
||||
await saveDialogueManifest(manifest);
|
||||
setStatus(
|
||||
"Manifeste sauvegarde dans public/sounds/dialogue/dialogues.json.",
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddDialogue(): Promise<void> {
|
||||
if (!manifest) return;
|
||||
|
||||
const voice = selectedDialogue?.voice ?? DEFAULT_VOICE;
|
||||
const dialogue = createDialogue(
|
||||
manifest.dialogues.length + 1,
|
||||
manifest,
|
||||
voice,
|
||||
);
|
||||
const nextManifest = {
|
||||
...manifest,
|
||||
dialogues: [...manifest.dialogues, dialogue],
|
||||
};
|
||||
|
||||
setManifest(nextManifest);
|
||||
setSelectedDialogueId(dialogue.id);
|
||||
setIsCreatingSrtCue(true);
|
||||
setStatus("Nouveau dialogue ajoute localement. Creation de la cue FR...");
|
||||
|
||||
try {
|
||||
await createFrenchSrtCue(nextManifest, dialogue);
|
||||
setStatus(
|
||||
`Nouveau dialogue ajoute avec cue FR ${dialogue.subtitleCueIndex}. Sauvegarde le manifeste pour le garder.`,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(
|
||||
`Dialogue ajoute localement, mais cue FR non creee: ${message}`,
|
||||
);
|
||||
} finally {
|
||||
setIsCreatingSrtCue(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveDialogue(dialogueId: string): void {
|
||||
if (!manifest) return;
|
||||
|
||||
const nextDialogues = manifest.dialogues.filter(
|
||||
(dialogue) => dialogue.id !== dialogueId,
|
||||
);
|
||||
setManifest({ ...manifest, dialogues: nextDialogues });
|
||||
setSelectedDialogueId(nextDialogues[0]?.id ?? "");
|
||||
setStatus("Dialogue supprime localement.");
|
||||
}
|
||||
|
||||
function updateSelectedDialogue(
|
||||
patch: DialoguePatch,
|
||||
nextId = selectedDialogueId,
|
||||
): void {
|
||||
if (!manifest || !selectedDialogue) return;
|
||||
|
||||
setManifest({
|
||||
...manifest,
|
||||
dialogues: manifest.dialogues.map((dialogue) =>
|
||||
dialogue.id === selectedDialogue.id
|
||||
? getPatchedDialogue(dialogue, patch)
|
||||
: dialogue,
|
||||
),
|
||||
});
|
||||
setSelectedDialogueId(nextId);
|
||||
}
|
||||
|
||||
async function handlePreviewDialogue(): Promise<void> {
|
||||
if (!manifest || !selectedDialogue) return;
|
||||
if (errors.length > 0) {
|
||||
setStatus("Corrige les erreurs avant de lancer la preview.");
|
||||
return;
|
||||
}
|
||||
|
||||
previewAudioRef.current?.pause();
|
||||
previewAudioRef.current = null;
|
||||
setIsPreviewing(true);
|
||||
setStatus(`Preview dialogue: ${selectedDialogue.id}`);
|
||||
|
||||
try {
|
||||
const audio = await playDialogueById(manifest, selectedDialogue.id);
|
||||
previewAudioRef.current = audio;
|
||||
|
||||
if (!audio) {
|
||||
setStatus("Dialogue introuvable pour la preview.");
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFinish = (): void => {
|
||||
audio.removeEventListener("ended", handleFinish);
|
||||
audio.removeEventListener("pause", handleFinish);
|
||||
if (previewAudioRef.current === audio) previewAudioRef.current = null;
|
||||
setIsPreviewing(false);
|
||||
};
|
||||
|
||||
audio.addEventListener("ended", handleFinish);
|
||||
audio.addEventListener("pause", handleFinish);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
setIsPreviewing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateFrenchSrtCue(): Promise<void> {
|
||||
if (!manifest || !selectedDialogue) return;
|
||||
|
||||
setIsCreatingSrtCue(true);
|
||||
setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`);
|
||||
|
||||
try {
|
||||
await createFrenchSrtCue(manifest, selectedDialogue);
|
||||
setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsCreatingSrtCue(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void loadDialogueManifest()
|
||||
.then((loadedManifest) => {
|
||||
if (!mounted) return;
|
||||
|
||||
setManifest(loadedManifest);
|
||||
setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? "");
|
||||
setStatus(
|
||||
loadedManifest
|
||||
? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.`
|
||||
: "Manifeste introuvable ou invalide.",
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!mounted) return;
|
||||
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
setManifest(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
previewAudioRef.current?.pause();
|
||||
previewAudioRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="editor-dialogue-manifest-section"
|
||||
aria-labelledby="dialogue-manifest-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="dialogue-manifest-heading">Dialogues</h3>
|
||||
<span>{manifest?.dialogues.length ?? 0} items</span>
|
||||
</div>
|
||||
|
||||
<div className="editor-dialogue-manifest-actions">
|
||||
<button type="button" onClick={() => void handleLoad()}>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!manifest || isCreatingSrtCue}
|
||||
onClick={() => void handleAddDialogue()}
|
||||
>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
{isCreatingSrtCue ? "Adding..." : "Add"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!manifest || errors.length > 0 || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
<Save size={14} aria-hidden="true" />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{manifest && (
|
||||
<label className="editor-dialogue-manifest-select">
|
||||
Dialogue
|
||||
<select
|
||||
value={selectedDialogue?.id ?? ""}
|
||||
onChange={(event) => setSelectedDialogueId(event.target.value)}
|
||||
>
|
||||
{manifest.dialogues.map((dialogue) => (
|
||||
<option key={dialogue.id} value={dialogue.id}>
|
||||
{dialogue.id || "Dialogue sans id"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedDialogue && (
|
||||
<div className="editor-dialogue-manifest-form">
|
||||
<label>
|
||||
ID
|
||||
<input
|
||||
value={selectedDialogue.id}
|
||||
onChange={(event) =>
|
||||
updateSelectedDialogue(
|
||||
{ id: event.target.value },
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Voix
|
||||
<select
|
||||
value={selectedDialogue.voice}
|
||||
onChange={(event) =>
|
||||
updateSelectedDialogue({
|
||||
voice: event.target.value as DialogueVoiceId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{voices.map((voice) => (
|
||||
<option key={voice.id} value={voice.id}>
|
||||
{voice.speaker}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Audio
|
||||
<input
|
||||
value={selectedDialogue.audio}
|
||||
onChange={(event) =>
|
||||
updateSelectedDialogue({ audio: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Cue SRT
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={selectedDialogue.subtitleCueIndex}
|
||||
onChange={(event) =>
|
||||
updateSelectedDialogue({
|
||||
subtitleCueIndex: Math.max(1, Number(event.target.value)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Timecode global optionnel
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={selectedDialogue.timecode ?? ""}
|
||||
placeholder="Aucun"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value.trim();
|
||||
updateSelectedDialogue({
|
||||
timecode: value === "" ? undefined : Number(value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="editor-dialogue-manifest-srt-cue"
|
||||
type="button"
|
||||
disabled={isCreatingSrtCue}
|
||||
onClick={() => void handleCreateFrenchSrtCue()}
|
||||
>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
{isCreatingSrtCue ? "Creating..." : "Create FR SRT cue"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="editor-dialogue-manifest-preview"
|
||||
type="button"
|
||||
disabled={errors.length > 0 || isPreviewing}
|
||||
onClick={() => void handlePreviewDialogue()}
|
||||
>
|
||||
<Play size={14} aria-hidden="true" />
|
||||
{isPreviewing ? "Playing..." : "Preview dialogue"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="editor-dialogue-manifest-delete"
|
||||
type="button"
|
||||
onClick={() => handleRemoveDialogue(selectedDialogue.id)}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete dialogue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="editor-dialogue-manifest-status">{status}</p>
|
||||
<div
|
||||
className={`editor-dialogue-manifest-diagnostic ${errors.length === 0 ? "is-valid" : "is-invalid"}`}
|
||||
>
|
||||
<strong>
|
||||
{errors.length === 0
|
||||
? "Manifeste local valide."
|
||||
: `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`}
|
||||
</strong>
|
||||
{errors.length > 0 && (
|
||||
<ul>
|
||||
{errors.map((error) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, RefreshCw, Save } from "lucide-react";
|
||||
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
|
||||
import type {
|
||||
DialogueDefinition,
|
||||
DialogueManifest,
|
||||
DialogueSpeaker,
|
||||
DialogueVoiceId,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||
|
||||
interface SrtVoiceOption {
|
||||
id: DialogueVoiceId;
|
||||
label: DialogueSpeaker;
|
||||
}
|
||||
|
||||
interface SrtDiagnostic {
|
||||
cueCount: number;
|
||||
expectedCueCount: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface TextRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface DialogueValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type CueTimeEdge = "start" | "end";
|
||||
const CUE_NUDGE_SECONDS = 0.1;
|
||||
|
||||
const SRT_VOICES: SrtVoiceOption[] = [
|
||||
{ id: "narrateur", label: "Narrateur" },
|
||||
{ id: "fermier", label: "Fermier" },
|
||||
{ id: "electricienne", label: "Electricienne" },
|
||||
];
|
||||
const DEFAULT_SRT_VOICE: SrtVoiceOption = {
|
||||
id: "narrateur",
|
||||
label: "Narrateur",
|
||||
};
|
||||
|
||||
const SRT_LANGUAGES: SubtitleLanguage[] = ["fr", "en"];
|
||||
const SRT_TIME_LINE_PATTERN =
|
||||
/^\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}$/;
|
||||
|
||||
function getSrtPath(
|
||||
voice: DialogueVoiceId,
|
||||
language: SubtitleLanguage,
|
||||
): string {
|
||||
return `/sounds/dialogue/subtitles/${language}/${voice}.srt`;
|
||||
}
|
||||
|
||||
function createSrtTemplate(
|
||||
speaker: DialogueSpeaker,
|
||||
expectedCueIndexes: number[],
|
||||
): string {
|
||||
const cueIndexes = expectedCueIndexes.length > 0 ? expectedCueIndexes : [1];
|
||||
|
||||
return `${cueIndexes
|
||||
.map((cueIndex, index) => {
|
||||
const startTime = index * 3;
|
||||
const endTime = startTime + 2;
|
||||
|
||||
return `${cueIndex}\n${formatSrtTime(startTime)} --> ${formatSrtTime(endTime)}\n${speaker}: Sous-titre ${cueIndex} a definir`;
|
||||
})
|
||||
.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
function formatSrtTime(totalSeconds: number): string {
|
||||
const safeSeconds = Math.max(0, totalSeconds);
|
||||
const totalMilliseconds = Math.round(safeSeconds * 1000);
|
||||
const milliseconds = totalMilliseconds % 1000;
|
||||
const totalWholeSeconds = Math.floor(totalMilliseconds / 1000);
|
||||
const hours = Math.floor(totalWholeSeconds / 3600);
|
||||
const minutes = Math.floor((totalWholeSeconds % 3600) / 60);
|
||||
const seconds = totalWholeSeconds % 60;
|
||||
|
||||
return `${padTime(hours)}:${padTime(minutes)}:${padTime(seconds)},${padMilliseconds(milliseconds)}`;
|
||||
}
|
||||
|
||||
function formatPreviewTime(totalSeconds: number): string {
|
||||
return `${Math.max(0, totalSeconds).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function parseSrtTime(value: string): number | null {
|
||||
const match = value.match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, hours, minutes, seconds, milliseconds] = match;
|
||||
if (!hours || !minutes || !seconds || !milliseconds) return null;
|
||||
|
||||
return (
|
||||
Number(hours) * 3600 +
|
||||
Number(minutes) * 60 +
|
||||
Number(seconds) +
|
||||
Number(milliseconds) / 1000
|
||||
);
|
||||
}
|
||||
|
||||
function padTime(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
function padMilliseconds(value: number): string {
|
||||
return value.toString().padStart(3, "0");
|
||||
}
|
||||
|
||||
function getSrtDiagnostic(
|
||||
content: string,
|
||||
expectedCueIndexes: number[],
|
||||
): SrtDiagnostic {
|
||||
const normalizedContent = content.replace(/^\uFEFF/, "").replace(/\r/g, "");
|
||||
const blocks = normalizedContent
|
||||
.trim()
|
||||
.split(/\n{2,}/)
|
||||
.filter(Boolean);
|
||||
const cues = parseSrt(content);
|
||||
const errors: string[] = [];
|
||||
const indexes = new Set<number>();
|
||||
|
||||
if (blocks.length === 0) {
|
||||
errors.push("Le fichier SRT est vide.");
|
||||
}
|
||||
|
||||
blocks.forEach((block, blockIndex) => {
|
||||
const lines = block
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const displayIndex = blockIndex + 1;
|
||||
const cueIndex = Number(lines[0]);
|
||||
|
||||
if (lines.length < 3) {
|
||||
errors.push(
|
||||
`Bloc ${displayIndex}: il manque un index, un timecode ou un texte.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(cueIndex)) {
|
||||
errors.push(`Bloc ${displayIndex}: l'index doit etre un nombre entier.`);
|
||||
} else if (indexes.has(cueIndex)) {
|
||||
errors.push(`Bloc ${displayIndex}: l'index ${cueIndex} est duplique.`);
|
||||
} else {
|
||||
indexes.add(cueIndex);
|
||||
}
|
||||
|
||||
if (!SRT_TIME_LINE_PATTERN.test(lines[1] ?? "")) {
|
||||
errors.push(
|
||||
`Bloc ${displayIndex}: le timecode doit utiliser HH:MM:SS,mmm --> HH:MM:SS,mmm.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (blocks.length > 0 && cues.length !== blocks.length) {
|
||||
errors.push(
|
||||
"Un ou plusieurs blocs ont une duree invalide ou un timecode illisible.",
|
||||
);
|
||||
}
|
||||
|
||||
const cueIndexes = new Set(cues.map((cue) => cue.index));
|
||||
const missingCueIndexes = expectedCueIndexes.filter(
|
||||
(cueIndex) => !cueIndexes.has(cueIndex),
|
||||
);
|
||||
|
||||
if (missingCueIndexes.length > 0) {
|
||||
errors.push(
|
||||
`Cues attendues par le manifeste manquantes: ${missingCueIndexes.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cueCount: cues.length,
|
||||
expectedCueCount: expectedCueIndexes.length,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function getExpectedCueIndexes(
|
||||
manifest: DialogueManifest | null,
|
||||
voice: DialogueVoiceId,
|
||||
): number[] {
|
||||
return getExpectedDialogues(manifest, voice)
|
||||
.map((dialogue) => dialogue.subtitleCueIndex)
|
||||
.filter(
|
||||
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
|
||||
)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function getExpectedDialogues(
|
||||
manifest: DialogueManifest | null,
|
||||
voice: DialogueVoiceId,
|
||||
): DialogueDefinition[] {
|
||||
if (!manifest) return [];
|
||||
|
||||
return [...manifest.dialogues]
|
||||
.filter((dialogue) => dialogue.voice === voice)
|
||||
.sort((a, b) => a.subtitleCueIndex - b.subtitleCueIndex);
|
||||
}
|
||||
|
||||
function findCueBlockRange(
|
||||
content: string,
|
||||
cueIndex: number,
|
||||
): TextRange | null {
|
||||
const normalizedContent = content.replace(/\r/g, "");
|
||||
const cuePattern = new RegExp(`(^|\\n)${cueIndex}\\n`, "m");
|
||||
const match = normalizedContent.match(cuePattern);
|
||||
|
||||
if (!match || match.index === undefined) return null;
|
||||
|
||||
const start = match.index + (match[1] ? 1 : 0);
|
||||
const nextBlockIndex = normalizedContent.indexOf("\n\n", start);
|
||||
const end = nextBlockIndex === -1 ? normalizedContent.length : nextBlockIndex;
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function updateCueTimecode(
|
||||
content: string,
|
||||
cueIndex: number,
|
||||
edge: CueTimeEdge,
|
||||
time: number,
|
||||
): string | null {
|
||||
const range = findCueBlockRange(content, cueIndex);
|
||||
if (!range) return null;
|
||||
|
||||
const block = content.slice(range.start, range.end);
|
||||
const lines = block.split("\n");
|
||||
const timecodeLine = lines[1];
|
||||
if (!timecodeLine) return null;
|
||||
|
||||
const [start, end] = timecodeLine.split(" --> ");
|
||||
if (!start || !end) return null;
|
||||
|
||||
lines[1] =
|
||||
edge === "start"
|
||||
? `${formatSrtTime(time)} --> ${end}`
|
||||
: `${start} --> ${formatSrtTime(time)}`;
|
||||
|
||||
return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`;
|
||||
}
|
||||
|
||||
function nudgeCueTimecode(
|
||||
content: string,
|
||||
cueIndex: number,
|
||||
delta: number,
|
||||
): string | null {
|
||||
const range = findCueBlockRange(content, cueIndex);
|
||||
if (!range) return null;
|
||||
|
||||
const block = content.slice(range.start, range.end);
|
||||
const lines = block.split("\n");
|
||||
const timecodeLine = lines[1];
|
||||
if (!timecodeLine) return null;
|
||||
|
||||
const [start, end] = timecodeLine.split(" --> ");
|
||||
if (!start || !end) return null;
|
||||
|
||||
const startTime = parseSrtTime(start);
|
||||
const endTime = parseSrtTime(end);
|
||||
if (startTime === null || endTime === null) return null;
|
||||
|
||||
const nextStartTime = Math.max(0, startTime + delta);
|
||||
const nextEndTime = Math.max(nextStartTime + 0.001, endTime + delta);
|
||||
lines[1] = `${formatSrtTime(nextStartTime)} --> ${formatSrtTime(nextEndTime)}`;
|
||||
|
||||
return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`;
|
||||
}
|
||||
|
||||
function downloadSrtFile(
|
||||
voice: DialogueVoiceId,
|
||||
language: SubtitleLanguage,
|
||||
content: string,
|
||||
): void {
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${voice}.${language}.srt`;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
async function saveSrtFile(
|
||||
voice: DialogueVoiceId,
|
||||
language: SubtitleLanguage,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch("/api/save-srt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ voice, language, content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(body?.error ?? "Sauvegarde SRT impossible");
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDialogueAssets(): Promise<DialogueValidationResult> {
|
||||
const response = await fetch("/api/validate-dialogues");
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| Partial<DialogueValidationResult>
|
||||
| { error?: string }
|
||||
| null;
|
||||
|
||||
if (!body) {
|
||||
throw new Error("Validation dialogues impossible");
|
||||
}
|
||||
|
||||
if (
|
||||
"valid" in body &&
|
||||
typeof body.valid === "boolean" &&
|
||||
Array.isArray(body.errors) &&
|
||||
Array.isArray(body.warnings)
|
||||
) {
|
||||
return {
|
||||
valid: body.valid,
|
||||
errors: body.errors.filter((item) => typeof item === "string"),
|
||||
warnings: body.warnings.filter((item) => typeof item === "string"),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"error" in body && body.error
|
||||
? body.error
|
||||
: "Validation dialogues impossible",
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorSrtPanel(): React.JSX.Element {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [voice, setVoice] = useState<DialogueVoiceId>("narrateur");
|
||||
const [language, setLanguage] = useState<SubtitleLanguage>("fr");
|
||||
const [content, setContent] = useState("");
|
||||
const [status, setStatus] = useState("Chargement du SRT...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isValidatingDialogues, setIsValidatingDialogues] = useState(false);
|
||||
const [dialogueValidationResult, setDialogueValidationResult] =
|
||||
useState<DialogueValidationResult | null>(null);
|
||||
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
|
||||
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
||||
const [selectedDialogueId, setSelectedDialogueId] = useState("");
|
||||
const selectedVoice =
|
||||
SRT_VOICES.find((item) => item.id === voice) ?? DEFAULT_SRT_VOICE;
|
||||
const expectedDialogues = getExpectedDialogues(manifest, voice);
|
||||
const expectedCueIndexes = getExpectedCueIndexes(manifest, voice);
|
||||
const parsedCues = parseSrt(content);
|
||||
const activeCue =
|
||||
parsedCues.find(
|
||||
(cue) =>
|
||||
audioCurrentTime >= cue.startTime && audioCurrentTime < cue.endTime,
|
||||
) ?? null;
|
||||
const diagnostic = getSrtDiagnostic(content, expectedCueIndexes);
|
||||
const isSrtValid = diagnostic.errors.length === 0;
|
||||
const dialogueValidationClass = dialogueValidationResult
|
||||
? dialogueValidationResult.valid
|
||||
? "is-valid"
|
||||
: "is-invalid"
|
||||
: "is-idle";
|
||||
const srtTemplate = createSrtTemplate(
|
||||
selectedVoice.label,
|
||||
expectedCueIndexes,
|
||||
);
|
||||
const selectedDialogue =
|
||||
expectedDialogues.find((dialogue) => dialogue.id === selectedDialogueId) ??
|
||||
expectedDialogues[0] ??
|
||||
null;
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!isSrtValid) {
|
||||
setStatus("Corrige les erreurs SRT avant de sauvegarder.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Sauvegarde du SRT...");
|
||||
|
||||
try {
|
||||
await saveSrtFile(voice, language, content);
|
||||
setStatus(`Sauvegarde dans ${getSrtPath(voice, language)}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(`${message}. Utilise Export SRT si le serveur dev est absent.`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleValidateDialogues(): Promise<void> {
|
||||
setIsValidatingDialogues(true);
|
||||
setDialogueValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await validateDialogueAssets();
|
||||
setDialogueValidationResult(result);
|
||||
setStatus(
|
||||
result.valid
|
||||
? "Validation dialogues terminee."
|
||||
: "Validation dialogues terminee avec erreurs.",
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(`${message}. Verifie que le serveur Vite est lance.`);
|
||||
} finally {
|
||||
setIsValidatingDialogues(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleJumpToCue(cueIndex: number): void {
|
||||
const range = findCueBlockRange(content, cueIndex);
|
||||
|
||||
if (!range || !textareaRef.current) {
|
||||
setStatus(`Cue ${cueIndex} introuvable dans le SRT.`);
|
||||
return;
|
||||
}
|
||||
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(range.start, range.end);
|
||||
setStatus(`Cue ${cueIndex} selectionnee dans le SRT.`);
|
||||
}
|
||||
|
||||
function handleSetCueTime(cueIndex: number, edge: CueTimeEdge): void {
|
||||
const updatedContent = updateCueTimecode(
|
||||
content,
|
||||
cueIndex,
|
||||
edge,
|
||||
audioCurrentTime,
|
||||
);
|
||||
|
||||
if (!updatedContent) {
|
||||
setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setContent(updatedContent);
|
||||
setStatus(
|
||||
`Cue ${cueIndex}: ${edge === "start" ? "debut" : "fin"} place a ${formatSrtTime(audioCurrentTime)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleNudgeCue(cueIndex: number, delta: number): void {
|
||||
const updatedContent = nudgeCueTimecode(content, cueIndex, delta);
|
||||
|
||||
if (!updatedContent) {
|
||||
setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setContent(updatedContent);
|
||||
setStatus(
|
||||
`Cue ${cueIndex} decalee de ${delta > 0 ? "+" : ""}${delta.toFixed(1)}s.`,
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void loadDialogueManifest()
|
||||
.then((loadedManifest) => {
|
||||
if (mounted) setManifest(loadedManifest);
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) setManifest(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const srtPath = getSrtPath(voice, language);
|
||||
|
||||
void fetch(srtPath)
|
||||
.then(async (response) => {
|
||||
if (!mounted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
setContent(srtTemplate);
|
||||
setStatus("Fichier absent, template local cree");
|
||||
return;
|
||||
}
|
||||
|
||||
setContent(await response.text());
|
||||
setStatus(`Charge depuis ${srtPath}`);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setContent(srtTemplate);
|
||||
setStatus("Erreur de chargement, template local cree");
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [language, selectedVoice.label, srtTemplate, voice]);
|
||||
|
||||
return (
|
||||
<section className="editor-srt-section" aria-labelledby="srt-heading">
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="srt-heading">SRT</h3>
|
||||
<span>{language.toUpperCase()}</span>
|
||||
</div>
|
||||
|
||||
<div className="editor-srt-controls">
|
||||
<label>
|
||||
Voix
|
||||
<select
|
||||
value={voice}
|
||||
onChange={(event) =>
|
||||
setVoice(event.target.value as DialogueVoiceId)
|
||||
}
|
||||
>
|
||||
{SRT_VOICES.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Langue
|
||||
<select
|
||||
value={language}
|
||||
onChange={(event) =>
|
||||
setLanguage(event.target.value as SubtitleLanguage)
|
||||
}
|
||||
>
|
||||
{SRT_LANGUAGES.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="editor-srt-preview">
|
||||
<label>
|
||||
Dialogue audio
|
||||
<select
|
||||
value={selectedDialogue?.id ?? ""}
|
||||
onChange={(event) => setSelectedDialogueId(event.target.value)}
|
||||
disabled={expectedDialogues.length === 0}
|
||||
>
|
||||
{expectedDialogues.length === 0 && (
|
||||
<option value="">Aucun dialogue</option>
|
||||
)}
|
||||
{expectedDialogues.map((dialogue) => (
|
||||
<option key={dialogue.id} value={dialogue.id}>
|
||||
Cue {dialogue.subtitleCueIndex} - {dialogue.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedDialogue && (
|
||||
<div className="editor-srt-audio-card">
|
||||
<span>Cue {selectedDialogue.subtitleCueIndex}</span>
|
||||
<strong>{selectedDialogue.id}</strong>
|
||||
<audio
|
||||
key={selectedDialogue.audio}
|
||||
controls
|
||||
src={selectedDialogue.audio}
|
||||
onLoadedMetadata={() => setAudioCurrentTime(0)}
|
||||
onTimeUpdate={(event) =>
|
||||
setAudioCurrentTime(event.currentTarget.currentTime)
|
||||
}
|
||||
/>
|
||||
<div className="editor-srt-active-cue">
|
||||
<span>Temps audio: {formatPreviewTime(audioCurrentTime)}</span>
|
||||
{activeCue ? (
|
||||
<p>
|
||||
<strong>Cue {activeCue.index}</strong> {activeCue.text}
|
||||
</p>
|
||||
) : (
|
||||
<p>Aucune cue active a ce moment.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="editor-srt-time-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleSetCueTime(selectedDialogue.subtitleCueIndex, "start")
|
||||
}
|
||||
>
|
||||
Set start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleSetCueTime(selectedDialogue.subtitleCueIndex, "end")
|
||||
}
|
||||
>
|
||||
Set end
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleNudgeCue(
|
||||
selectedDialogue.subtitleCueIndex,
|
||||
-CUE_NUDGE_SECONDS,
|
||||
)
|
||||
}
|
||||
>
|
||||
-100ms
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleNudgeCue(
|
||||
selectedDialogue.subtitleCueIndex,
|
||||
CUE_NUDGE_SECONDS,
|
||||
)
|
||||
}
|
||||
>
|
||||
+100ms
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="editor-srt-jump-button"
|
||||
type="button"
|
||||
onClick={() => handleJumpToCue(selectedDialogue.subtitleCueIndex)}
|
||||
>
|
||||
Aller a la cue {selectedDialogue.subtitleCueIndex}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="editor-srt-textarea"
|
||||
value={content}
|
||||
spellCheck={false}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
aria-label="SRT content"
|
||||
/>
|
||||
|
||||
<div className="editor-srt-actions">
|
||||
<button
|
||||
className="editor-action-button"
|
||||
type="button"
|
||||
onClick={() => setContent(srtTemplate)}
|
||||
>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
Template
|
||||
</button>
|
||||
<button
|
||||
className="editor-action-button editor-action-button-primary"
|
||||
type="button"
|
||||
disabled={isSaving || !isSrtValid}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
<Save size={15} aria-hidden="true" />
|
||||
{isSaving ? "Saving..." : "Save SRT"}
|
||||
</button>
|
||||
<button
|
||||
className="editor-action-button"
|
||||
type="button"
|
||||
onClick={() => downloadSrtFile(voice, language, content)}
|
||||
>
|
||||
<Download size={15} aria-hidden="true" />
|
||||
Export SRT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="editor-srt-status">{status}</p>
|
||||
<div className={`editor-dialogue-validation ${dialogueValidationClass}`}>
|
||||
<div className="editor-dialogue-validation__heading">
|
||||
<div>
|
||||
<strong>Manifeste dialogues</strong>
|
||||
<span>Audio, SRT FR et cues references</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isValidatingDialogues}
|
||||
onClick={() => void handleValidateDialogues()}
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
{isValidatingDialogues ? "Validation..." : "Validate"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dialogueValidationResult && (
|
||||
<div className="editor-dialogue-validation__result">
|
||||
<p>
|
||||
{dialogueValidationResult.valid
|
||||
? "Manifeste valide."
|
||||
: `${dialogueValidationResult.errors.length} erreur${dialogueValidationResult.errors.length > 1 ? "s" : ""} detectee${dialogueValidationResult.errors.length > 1 ? "s" : ""}.`}
|
||||
{dialogueValidationResult.warnings.length > 0 &&
|
||||
` ${dialogueValidationResult.warnings.length} warning${dialogueValidationResult.warnings.length > 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
{dialogueValidationResult.errors.length > 0 && (
|
||||
<ul className="editor-dialogue-validation__errors">
|
||||
{dialogueValidationResult.errors.map((error, index) => (
|
||||
<li key={`${error}-${index}`}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{dialogueValidationResult.warnings.length > 0 && (
|
||||
<ul className="editor-dialogue-validation__warnings">
|
||||
{dialogueValidationResult.warnings.map((warning, index) => (
|
||||
<li key={`${warning}-${index}`}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`editor-srt-diagnostic ${isSrtValid ? "is-valid" : "is-invalid"}`}
|
||||
>
|
||||
<strong>
|
||||
{isSrtValid
|
||||
? `${diagnostic.cueCount} cue${diagnostic.cueCount > 1 ? "s" : ""} valide${diagnostic.cueCount > 1 ? "s" : ""} / ${diagnostic.expectedCueCount} attendue${diagnostic.expectedCueCount > 1 ? "s" : ""}`
|
||||
: `${diagnostic.errors.length} erreur${diagnostic.errors.length > 1 ? "s" : ""} SRT`}
|
||||
</strong>
|
||||
{!isSrtValid && (
|
||||
<ul>
|
||||
{diagnostic.errors.map((error) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { Grid, TransformControls } from "@react-three/drei";
|
||||
import type { ThreeEvent } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
||||
|
||||
interface EditorMapProps {
|
||||
sceneData: SceneData;
|
||||
selectedNodeIndex: number | null;
|
||||
onSelectNode: (index: number | null) => void;
|
||||
hoveredNodeIndex: number | null;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||
}
|
||||
|
||||
type EditorNodeObjectRef = React.RefObject<Map<number, THREE.Object3D>>;
|
||||
|
||||
interface EditorNodeCommonProps {
|
||||
index: number;
|
||||
node: MapNode;
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
objectsMapRef: EditorNodeObjectRef;
|
||||
onSelectNode: (index: number | null) => void;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
}
|
||||
|
||||
interface EditorNodePointerHandlers {
|
||||
onClick: (event: ThreeEvent<MouseEvent>) => void;
|
||||
onPointerEnter: (event: ThreeEvent<PointerEvent>) => void;
|
||||
onPointerLeave: (event: ThreeEvent<PointerEvent>) => void;
|
||||
}
|
||||
|
||||
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
|
||||
object.position.set(...node.position);
|
||||
object.rotation.set(...node.rotation);
|
||||
object.scale.set(...node.scale);
|
||||
}
|
||||
|
||||
function useRegisteredEditorNode(
|
||||
objectRef: React.RefObject<THREE.Object3D | null>,
|
||||
index: number,
|
||||
node: MapNode,
|
||||
objectsMapRef: EditorNodeObjectRef,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const object = objectRef.current;
|
||||
if (object) {
|
||||
applyNodeTransform(object, node);
|
||||
object.userData = { nodeIndex: index, nodeName: node.name };
|
||||
objectsMapRef.current.set(index, object);
|
||||
}
|
||||
|
||||
const currentMap = objectsMapRef.current;
|
||||
const currentIndex = index;
|
||||
return () => {
|
||||
currentMap.delete(currentIndex);
|
||||
};
|
||||
}, [index, node, objectRef, objectsMapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const object = objectRef.current;
|
||||
if (object) {
|
||||
applyNodeTransform(object, node);
|
||||
}
|
||||
}, [node, objectRef]);
|
||||
}
|
||||
|
||||
function disposeMaterial(material: THREE.Material | THREE.Material[]): void {
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
return;
|
||||
}
|
||||
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
function cloneHighlightedMaterial(
|
||||
material: THREE.Material | THREE.Material[],
|
||||
color: string,
|
||||
): THREE.Material | THREE.Material[] {
|
||||
if (Array.isArray(material)) {
|
||||
return material.map((item) => cloneHighlightedMaterial(item, color)).flat();
|
||||
}
|
||||
|
||||
const clone = material.clone();
|
||||
if (clone instanceof THREE.MeshStandardMaterial) {
|
||||
clone.color.set(color);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function getNodeHighlightColor(
|
||||
isSelected: boolean,
|
||||
isHovered: boolean,
|
||||
): string | null {
|
||||
if (isSelected) return "#ffffff";
|
||||
if (isHovered) return "#b8b8b8";
|
||||
return null;
|
||||
}
|
||||
|
||||
function createEditorNodePointerHandlers(
|
||||
index: number,
|
||||
onSelectNode: (index: number | null) => void,
|
||||
onHoverNode: (index: number | null) => void,
|
||||
): EditorNodePointerHandlers {
|
||||
return {
|
||||
onClick: (event) => {
|
||||
event.stopPropagation();
|
||||
onSelectNode(index);
|
||||
},
|
||||
onPointerEnter: (event) => {
|
||||
event.stopPropagation();
|
||||
onHoverNode(index);
|
||||
},
|
||||
onPointerLeave: (event) => {
|
||||
event.stopPropagation();
|
||||
onHoverNode(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function EditorMap({
|
||||
sceneData,
|
||||
selectedNodeIndex,
|
||||
onSelectNode,
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
onNodeTransform,
|
||||
}: EditorMapProps): React.JSX.Element {
|
||||
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
||||
|
||||
const handleTransformMouseDown = () => {
|
||||
onTransformStart();
|
||||
};
|
||||
|
||||
const handleTransformMouseUp = () => {
|
||||
if (selectedNodeIndex !== null) {
|
||||
const obj = objectsMapRef.current.get(selectedNodeIndex);
|
||||
if (!obj) return;
|
||||
const node = sceneData.mapNodes[selectedNodeIndex];
|
||||
if (node) {
|
||||
const updatedNode: MapNode = {
|
||||
...node,
|
||||
position: [obj.position.x, obj.position.y, obj.position.z],
|
||||
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
|
||||
scale: [obj.scale.x, obj.scale.y, obj.scale.z],
|
||||
};
|
||||
onNodeTransform(selectedNodeIndex, updatedNode);
|
||||
}
|
||||
}
|
||||
onTransformEnd();
|
||||
};
|
||||
|
||||
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNodeIndex !== null) {
|
||||
const obj = objectsMapRef.current.get(selectedNodeIndex);
|
||||
setSelectedObject(obj || null);
|
||||
} else {
|
||||
setSelectedObject(null);
|
||||
}
|
||||
}, [selectedNodeIndex]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid
|
||||
args={[100, 100]}
|
||||
cellSize={1}
|
||||
cellThickness={0.5}
|
||||
cellColor="#242424"
|
||||
sectionSize={5}
|
||||
sectionThickness={1}
|
||||
sectionColor="#3a3a3a"
|
||||
fadeDistance={50}
|
||||
fadeStrength={1}
|
||||
followCamera={false}
|
||||
infiniteGrid={false}
|
||||
/>
|
||||
<axesHelper args={[10]} />
|
||||
|
||||
<group
|
||||
onClick={(e: ThreeEvent<MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
onSelectNode(null);
|
||||
}}
|
||||
>
|
||||
{sceneData.mapNodes.map((node, index) => {
|
||||
const modelUrl = sceneData.models.get(node.name);
|
||||
|
||||
if (modelUrl) {
|
||||
return (
|
||||
<EditorModelNode
|
||||
key={index}
|
||||
index={index}
|
||||
node={node}
|
||||
modelUrl={modelUrl}
|
||||
isSelected={selectedNodeIndex === index}
|
||||
isHovered={hoveredNodeIndex === index}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<EditorFallbackNode
|
||||
key={index}
|
||||
index={index}
|
||||
node={node}
|
||||
isSelected={selectedNodeIndex === index}
|
||||
isHovered={hoveredNodeIndex === index}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</group>
|
||||
|
||||
{selectedObject && (
|
||||
<TransformControls
|
||||
object={selectedObject}
|
||||
mode={transformMode}
|
||||
onMouseDown={handleTransformMouseDown}
|
||||
onMouseUp={handleTransformMouseUp}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorModelNode({
|
||||
index,
|
||||
node,
|
||||
modelUrl,
|
||||
isSelected,
|
||||
isHovered,
|
||||
objectsMapRef,
|
||||
onSelectNode,
|
||||
onHoverNode,
|
||||
}: EditorNodeCommonProps & {
|
||||
modelUrl: string;
|
||||
}) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const originalMaterialsRef = useRef(
|
||||
new Map<THREE.Mesh, THREE.Material | THREE.Material[]>(),
|
||||
);
|
||||
const { scene } = useLoggedGLTF(modelUrl, {
|
||||
scope: "EditorMap.EditorModelNode",
|
||||
position: node.position,
|
||||
rotation: node.rotation,
|
||||
scale: node.scale,
|
||||
});
|
||||
const sceneInstance = useClonedObject(scene);
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
onHoverNode,
|
||||
);
|
||||
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupRef.current) return;
|
||||
const highlightColor = getNodeHighlightColor(isSelected, isHovered);
|
||||
|
||||
groupRef.current.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalMaterial = originalMaterialsRef.current.get(child);
|
||||
|
||||
if (!originalMaterial) {
|
||||
originalMaterialsRef.current.set(child, child.material);
|
||||
}
|
||||
|
||||
if (child.material !== originalMaterial && originalMaterial) {
|
||||
disposeMaterial(child.material);
|
||||
}
|
||||
|
||||
if (highlightColor) {
|
||||
child.material = cloneHighlightedMaterial(
|
||||
originalMaterial ?? child.material,
|
||||
highlightColor,
|
||||
);
|
||||
} else if (originalMaterial) {
|
||||
child.material = originalMaterial;
|
||||
}
|
||||
});
|
||||
}, [isSelected, isHovered]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
const originalMaterials = originalMaterialsRef.current;
|
||||
|
||||
return () => {
|
||||
if (!group) return;
|
||||
|
||||
group.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalMaterial = originalMaterials.get(child);
|
||||
if (originalMaterial && child.material !== originalMaterial) {
|
||||
disposeMaterial(child.material);
|
||||
child.material = originalMaterial;
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<primitive
|
||||
ref={groupRef}
|
||||
object={sceneInstance}
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorFallbackNode({
|
||||
index,
|
||||
node,
|
||||
isSelected,
|
||||
isHovered,
|
||||
objectsMapRef,
|
||||
onSelectNode,
|
||||
onHoverNode,
|
||||
}: EditorNodeCommonProps) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
onHoverNode,
|
||||
);
|
||||
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
|
||||
|
||||
const color = getNodeHighlightColor(isSelected, isHovered) ?? "#6f6f6f";
|
||||
|
||||
return (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
{...pointerHandlers}
|
||||
>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color={color} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||
import { FlyController } from "@/controls/editor/FlyController";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
||||
|
||||
export interface EditorCinematicPreviewRequest {
|
||||
id: string;
|
||||
cinematic: CinematicDefinition;
|
||||
}
|
||||
|
||||
interface EditorSceneProps {
|
||||
sceneData: SceneData;
|
||||
selectedNodeIndex: number | null;
|
||||
onSelectNode: (index: number | null) => void;
|
||||
hoveredNodeIndex: number | null;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
onTransformModeChange: (mode: TransformMode) => void;
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
isPlayerMode?: boolean;
|
||||
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
|
||||
onCinematicPreviewComplete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export function EditorScene({
|
||||
sceneData,
|
||||
selectedNodeIndex,
|
||||
onSelectNode,
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
onTransformModeChange,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
onNodeTransform,
|
||||
onUndo,
|
||||
onRedo,
|
||||
isPlayerMode = false,
|
||||
cinematicPreviewRequest = null,
|
||||
onCinematicPreviewComplete,
|
||||
}: EditorSceneProps): React.JSX.Element {
|
||||
const isCinematicPreviewing = cinematicPreviewRequest !== null;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === "z" || e.key === "Z") {
|
||||
e.preventDefault();
|
||||
onUndo();
|
||||
return;
|
||||
}
|
||||
if (e.key === "y" || e.key === "Y") {
|
||||
e.preventDefault();
|
||||
onRedo();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedNodeIndex !== null) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "escape":
|
||||
onSelectNode(null);
|
||||
break;
|
||||
case "t":
|
||||
onTransformModeChange("translate");
|
||||
break;
|
||||
case "r":
|
||||
onTransformModeChange("rotate");
|
||||
break;
|
||||
case "s":
|
||||
onTransformModeChange("scale");
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedNodeIndex, onSelectNode, onTransformModeChange, onUndo, onRedo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorCinematicPreviewPlayer
|
||||
request={cinematicPreviewRequest}
|
||||
onComplete={onCinematicPreviewComplete}
|
||||
/>
|
||||
|
||||
{isPlayerMode ? (
|
||||
<FlyController disabled={isCinematicPreviewing} />
|
||||
) : (
|
||||
<OrbitControls
|
||||
enabled={!isCinematicPreviewing}
|
||||
enableDamping
|
||||
dampingFactor={0.05}
|
||||
mouseButtons={{
|
||||
LEFT: 0,
|
||||
MIDDLE: 1,
|
||||
RIGHT: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditorMap
|
||||
sceneData={sceneData}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
onSelectNode={onSelectNode}
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={onHoverNode}
|
||||
transformMode={transformMode}
|
||||
onTransformStart={onTransformStart}
|
||||
onTransformEnd={onTransformEnd}
|
||||
onNodeTransform={onNodeTransform}
|
||||
/>
|
||||
|
||||
<ambientLight intensity={0.6} />
|
||||
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
||||
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditorCinematicPreviewPlayerProps {
|
||||
request: EditorCinematicPreviewRequest | null;
|
||||
onComplete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
function EditorCinematicPreviewPlayer({
|
||||
request,
|
||||
onComplete,
|
||||
}: EditorCinematicPreviewPlayerProps): null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
timelineRef.current?.kill();
|
||||
timelineRef.current = null;
|
||||
|
||||
if (!request) return undefined;
|
||||
|
||||
const firstKeyframe = request.cinematic.cameraKeyframes[0];
|
||||
if (!firstKeyframe) return undefined;
|
||||
|
||||
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;
|
||||
onComplete?.();
|
||||
},
|
||||
});
|
||||
|
||||
request.cinematic.cameraKeyframes.slice(1).forEach((keyframe, index) => {
|
||||
const previousKeyframe = request.cinematic.cameraKeyframes[index];
|
||||
if (!previousKeyframe) return;
|
||||
|
||||
const duration = keyframe.time - previousKeyframe.time;
|
||||
timeline.to(
|
||||
camera.position,
|
||||
{
|
||||
x: keyframe.position[0],
|
||||
y: keyframe.position[1],
|
||||
z: keyframe.position[2],
|
||||
duration,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
previousKeyframe.time,
|
||||
);
|
||||
timeline.to(
|
||||
target,
|
||||
{
|
||||
x: keyframe.target[0],
|
||||
y: keyframe.target[1],
|
||||
z: keyframe.target[2],
|
||||
duration,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
previousKeyframe.time,
|
||||
);
|
||||
});
|
||||
|
||||
timelineRef.current = timeline;
|
||||
|
||||
return () => {
|
||||
timeline.kill();
|
||||
if (timelineRef.current === timeline) timelineRef.current = null;
|
||||
};
|
||||
}, [camera, onComplete, request]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGameStore } from "@/stores/gameStore";
|
||||
import { AudioManager } from "@/stateManager/AudioManager";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
|
||||
import { AUDIO_PATHS } from "@/data/audioConfig";
|
||||
|
||||
export function GameFlow(): null {
|
||||
const step = useGameStore((state) => state.step);
|
||||
const setStep = useGameStore((state) => state.setStep);
|
||||
const setActivityCity = useGameStore((state) => state.setActivityCity);
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
const step = useMissionFlowStore((state) => state.step);
|
||||
const setStep = useMissionFlowStore((state) => state.setStep);
|
||||
const setActivityCity = useMissionFlowStore((state) => state.setActivityCity);
|
||||
const setCanMove = useMissionFlowStore((state) => state.setCanMove);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
interface RepairBrokenPartHighlightProps {
|
||||
target: THREE.Object3D;
|
||||
}
|
||||
|
||||
const _box = new THREE.Box3();
|
||||
const _sphere = new THREE.Sphere();
|
||||
const _worldPosition = new THREE.Vector3();
|
||||
const _localPosition = new THREE.Vector3();
|
||||
|
||||
export function RepairBrokenPartHighlight({
|
||||
target,
|
||||
}: RepairBrokenPartHighlightProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
_box.setFromObject(target).getBoundingSphere(_sphere);
|
||||
|
||||
_worldPosition.copy(_sphere.center);
|
||||
_localPosition.copy(_worldPosition);
|
||||
group.parent?.worldToLocal(_localPosition);
|
||||
group.position.copy(_localPosition);
|
||||
|
||||
const pulse = 1 + Math.sin(clock.elapsedTime * 5) * 0.08;
|
||||
const radius = Math.max(_sphere.radius, 0.35) * pulse;
|
||||
group.scale.setScalar(radius);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 32, 16]} />
|
||||
<meshBasicMaterial color="#ef4444" transparent opacity={0.14} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1.06, 32, 16]} />
|
||||
<meshBasicMaterial
|
||||
color="#ef4444"
|
||||
wireframe
|
||||
transparent
|
||||
opacity={0.65}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.12, 0.025, 8, 96]} />
|
||||
<meshBasicMaterial color="#dc2626" transparent opacity={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
|
||||
interface RepairBrokenPartPromptProps {
|
||||
src: string;
|
||||
target: THREE.Object3D;
|
||||
}
|
||||
|
||||
const _box = new THREE.Box3();
|
||||
const _sphere = new THREE.Sphere();
|
||||
const _localPosition = new THREE.Vector3();
|
||||
|
||||
export function RepairBrokenPartPrompt({
|
||||
src,
|
||||
target,
|
||||
}: RepairBrokenPartPromptProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
_box.setFromObject(target).getBoundingSphere(_sphere);
|
||||
_localPosition.copy(_sphere.center);
|
||||
group.parent?.worldToLocal(_localPosition);
|
||||
group.position.copy(_localPosition);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<RepairPromptVideo src={src} position={[0, 0, 0]} size={72} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
REPAIR_CASE_ANIMATION_DURATION,
|
||||
REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES,
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE,
|
||||
REPAIR_CASE_FLOAT_DOWN_SPEED,
|
||||
REPAIR_CASE_FLOAT_HEIGHT,
|
||||
REPAIR_CASE_EXIT_DURATION,
|
||||
REPAIR_CASE_EXIT_Y_OFFSET,
|
||||
REPAIR_CASE_FLOAT_UP_SPEED,
|
||||
REPAIR_CASE_LID_NODE_NAME,
|
||||
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
|
||||
REPAIR_CASE_CLOSE_SOUND_PATH,
|
||||
REPAIR_CASE_OPEN_SOUND_PATH,
|
||||
REPAIR_CASE_PLACEHOLDER_NAME_PREFIX,
|
||||
REPAIR_CASE_POP_DURATION,
|
||||
REPAIR_CASE_POP_Y_OFFSET,
|
||||
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
export interface RepairCasePlaceholder {
|
||||
name: string;
|
||||
position: Vector3Tuple;
|
||||
}
|
||||
|
||||
interface RepairCaseModelProps extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
open: boolean;
|
||||
exiting?: boolean;
|
||||
floating?: boolean;
|
||||
onPlaceholdersChange?:
|
||||
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
|
||||
| undefined;
|
||||
onExitComplete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
|
||||
REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES,
|
||||
);
|
||||
const CASE_OPEN_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
|
||||
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
|
||||
);
|
||||
const ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(
|
||||
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
|
||||
);
|
||||
|
||||
export function RepairCaseModel({
|
||||
modelPath,
|
||||
open,
|
||||
exiting = false,
|
||||
floating = true,
|
||||
onPlaceholdersChange,
|
||||
onExitComplete,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: RepairCaseModelProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const { scene } = useLoggedGLTF(modelPath, {
|
||||
scope: "RepairCaseModel",
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
});
|
||||
const model = useClonedObject(scene);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const lidRef = useRef<THREE.Object3D | null>(null);
|
||||
const worldPosition = useRef(new THREE.Vector3());
|
||||
const floatHeight = useRef(0);
|
||||
const animationActiveRef = useRef(false);
|
||||
const phase = useRef({ x: 0, y: 0, z: 0 });
|
||||
const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
|
||||
const onExitCompleteRef = useRef(onExitComplete);
|
||||
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
|
||||
const initialOpen = useRef(open);
|
||||
const previousOpen = useRef(open);
|
||||
const openedRotationZ = useRef(0);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const placeholderNodes = useRef<THREE.Object3D[]>([]);
|
||||
const placeholderSignature = useRef("__initial__");
|
||||
const placeholderPosition = useRef(new THREE.Vector3());
|
||||
const placeholderLocalPosition = useRef(new THREE.Vector3());
|
||||
|
||||
useEffect(() => {
|
||||
onExitCompleteRef.current = onExitComplete;
|
||||
}, [onExitComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
onPlaceholdersChangeRef.current = onPlaceholdersChange;
|
||||
}, [onPlaceholdersChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const popAnimation = pop.current;
|
||||
|
||||
phase.current = {
|
||||
x: Math.random() * Math.PI * 2,
|
||||
y: Math.random() * Math.PI * 2,
|
||||
z: Math.random() * Math.PI * 2,
|
||||
};
|
||||
|
||||
gsap.to(popAnimation, {
|
||||
scale: 1,
|
||||
yOffset: 0,
|
||||
duration: REPAIR_CASE_POP_DURATION,
|
||||
ease: "back.out(1.7)",
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(popAnimation);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!exiting) return undefined;
|
||||
|
||||
const popAnimation = pop.current;
|
||||
gsap.to(popAnimation, {
|
||||
scale: 0.001,
|
||||
yOffset: REPAIR_CASE_EXIT_Y_OFFSET,
|
||||
duration: REPAIR_CASE_EXIT_DURATION,
|
||||
ease: "back.in(1.4)",
|
||||
overwrite: true,
|
||||
onComplete: () => {
|
||||
onExitCompleteRef.current?.();
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(popAnimation);
|
||||
};
|
||||
}, [exiting]);
|
||||
|
||||
useEffect(() => {
|
||||
const lid = model.getObjectByName(REPAIR_CASE_LID_NODE_NAME);
|
||||
lidRef.current = lid ?? null;
|
||||
openedRotationZ.current = lid?.rotation.z ?? 0;
|
||||
placeholderNodes.current = [];
|
||||
|
||||
model.traverse((child) => {
|
||||
if (
|
||||
child.name.toLowerCase().startsWith(REPAIR_CASE_PLACEHOLDER_NAME_PREFIX)
|
||||
) {
|
||||
placeholderNodes.current.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
if (lid) {
|
||||
lid.rotation.z =
|
||||
openedRotationZ.current +
|
||||
(initialOpen.current
|
||||
? CASE_OPEN_ROTATION_OFFSET_Z
|
||||
: CASE_CLOSED_ROTATION_OFFSET_Z);
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
const lid = lidRef.current;
|
||||
if (!lid) return;
|
||||
|
||||
const targetRotation =
|
||||
openedRotationZ.current +
|
||||
(open ? CASE_OPEN_ROTATION_OFFSET_Z : CASE_CLOSED_ROTATION_OFFSET_Z);
|
||||
gsap.to(lid.rotation, {
|
||||
z: targetRotation,
|
||||
duration: REPAIR_CASE_ANIMATION_DURATION,
|
||||
ease: "power2.inOut",
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(lid.rotation);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousOpen.current === open) return;
|
||||
|
||||
previousOpen.current = open;
|
||||
AudioManager.getInstance().playSound(
|
||||
open ? REPAIR_CASE_OPEN_SOUND_PATH : REPAIR_CASE_CLOSE_SOUND_PATH,
|
||||
0.85,
|
||||
);
|
||||
}, [open]);
|
||||
|
||||
useFrame(({ clock }, delta) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
group.getWorldPosition(worldPosition.current);
|
||||
const isNear =
|
||||
floating &&
|
||||
!exiting &&
|
||||
worldPosition.current.distanceTo(camera.position) <=
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
|
||||
const targetHeight = isNear ? REPAIR_CASE_FLOAT_HEIGHT : 0;
|
||||
const floatSpeed = isNear
|
||||
? REPAIR_CASE_FLOAT_UP_SPEED
|
||||
: REPAIR_CASE_FLOAT_DOWN_SPEED;
|
||||
|
||||
floatHeight.current = THREE.MathUtils.damp(
|
||||
floatHeight.current,
|
||||
targetHeight,
|
||||
floatSpeed,
|
||||
delta,
|
||||
);
|
||||
group.position.y = position[1] + floatHeight.current + pop.current.yOffset;
|
||||
group.scale.set(
|
||||
parsedScale[0] * pop.current.scale,
|
||||
parsedScale[1] * pop.current.scale,
|
||||
parsedScale[2] * pop.current.scale,
|
||||
);
|
||||
|
||||
if (placeholderNodes.current.length > 0) {
|
||||
const placeholders: RepairCasePlaceholder[] = [];
|
||||
placeholderNodes.current.forEach((child) => {
|
||||
child.getWorldPosition(placeholderPosition.current);
|
||||
placeholderLocalPosition.current.copy(placeholderPosition.current);
|
||||
group.parent?.worldToLocal(placeholderLocalPosition.current);
|
||||
placeholders.push({
|
||||
name: child.name,
|
||||
position: [
|
||||
placeholderLocalPosition.current.x,
|
||||
placeholderLocalPosition.current.y,
|
||||
placeholderLocalPosition.current.z,
|
||||
],
|
||||
});
|
||||
});
|
||||
placeholders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const nextSignature = placeholders
|
||||
.map(
|
||||
(placeholder) =>
|
||||
`${placeholder.name}:${placeholder.position
|
||||
.map((value) => value.toFixed(3))
|
||||
.join(",")}`,
|
||||
)
|
||||
.join("|");
|
||||
if (nextSignature !== placeholderSignature.current) {
|
||||
placeholderSignature.current = nextSignature;
|
||||
onPlaceholdersChangeRef.current?.(placeholders);
|
||||
}
|
||||
}
|
||||
|
||||
animationActiveRef.current = isNear;
|
||||
|
||||
if (animationActiveRef.current) {
|
||||
const time = clock.elapsedTime;
|
||||
group.rotation.x =
|
||||
rotation[0] +
|
||||
Math.sin(time * 0.7 + phase.current.x) * ROTATION_AMPLITUDE;
|
||||
group.rotation.y =
|
||||
rotation[1] +
|
||||
Math.sin(time * 0.55 + phase.current.y) * ROTATION_AMPLITUDE;
|
||||
group.rotation.z =
|
||||
rotation[2] +
|
||||
Math.sin(time * 0.8 + phase.current.z) * ROTATION_AMPLITUDE;
|
||||
return;
|
||||
}
|
||||
|
||||
group.rotation.x = THREE.MathUtils.damp(
|
||||
group.rotation.x,
|
||||
rotation[0],
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
delta,
|
||||
);
|
||||
group.rotation.y = THREE.MathUtils.damp(
|
||||
group.rotation.y,
|
||||
rotation[1],
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
delta,
|
||||
);
|
||||
group.rotation.z = THREE.MathUtils.damp(
|
||||
group.rotation.z,
|
||||
rotation[2],
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
delta,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={position} rotation={rotation} scale={0.001}>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
const PARTICLES = Array.from({ length: 24 }, (_, index) => {
|
||||
const angle = (index / 24) * Math.PI * 2;
|
||||
const ring = index % 3;
|
||||
return {
|
||||
angle,
|
||||
radius: 0.45 + ring * 0.28,
|
||||
y: 0.35 + (index % 5) * 0.16,
|
||||
speed: 0.8 + (index % 4) * 0.18,
|
||||
};
|
||||
});
|
||||
|
||||
export function RepairCompletionParticles(): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
group.rotation.y = clock.elapsedTime * 0.9;
|
||||
group.children.forEach((child, index) => {
|
||||
const particle = PARTICLES[index];
|
||||
if (!particle) return;
|
||||
|
||||
const pulse = 1 + Math.sin(clock.elapsedTime * 5 + index) * 0.35;
|
||||
child.position.y =
|
||||
particle.y + Math.sin(clock.elapsedTime * particle.speed) * 0.08;
|
||||
child.scale.setScalar(pulse);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{PARTICLES.map((particle, index) => (
|
||||
<mesh
|
||||
key={index}
|
||||
position={[
|
||||
Math.cos(particle.angle) * particle.radius,
|
||||
particle.y,
|
||||
Math.sin(particle.angle) * particle.radius,
|
||||
]}
|
||||
>
|
||||
<sphereGeometry args={[0.045, 12, 12]} />
|
||||
<meshBasicMaterial color="#86efac" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
|
||||
interface RepairCompletionStepProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function RepairCompletionStep({
|
||||
config,
|
||||
onComplete,
|
||||
}: RepairCompletionStepProps): React.JSX.Element {
|
||||
const [isClosingCase, setIsClosingCase] = useState(false);
|
||||
const [isExitingCase, setIsExitingCase] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClosingCase) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIsExitingCase(true);
|
||||
}, REPAIR_CASE_ANIMATION_DURATION * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isClosingCase]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
exiting={isExitingCase}
|
||||
open={!isClosingCase}
|
||||
onExitComplete={onComplete}
|
||||
/>
|
||||
|
||||
<RepairObjectModel
|
||||
label={config.label}
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
/>
|
||||
|
||||
{!isClosingCase ? (
|
||||
<TriggerObject
|
||||
position={[0, 1.1, 0]}
|
||||
colliders="ball"
|
||||
label={`Valider ${config.label}`}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={() => setIsClosingCase(true)}
|
||||
>
|
||||
<mesh>
|
||||
<torusGeometry args={[1.35, 0.045, 12, 96]} />
|
||||
<meshBasicMaterial color="#22c55e" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.2, 1.25, 96]} />
|
||||
<meshBasicMaterial color="#bbf7d0" transparent opacity={0.3} />
|
||||
</mesh>
|
||||
</TriggerObject>
|
||||
) : null}
|
||||
|
||||
{!isClosingCase ? (
|
||||
<RepairPromptVideo src={config.stageUiPath} position={[0, 2.55, 0]} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
|
||||
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
||||
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
|
||||
import {
|
||||
RepairScanSequence,
|
||||
type RepairScannedBrokenPart,
|
||||
} from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import {
|
||||
REPAIR_MISSIONS,
|
||||
type RepairMissionConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||
import type {
|
||||
MissionStep,
|
||||
RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
interface RepairGameProps extends Required<
|
||||
Pick<ModelTransformProps, "position">
|
||||
> {
|
||||
mission: RepairMissionId;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: ModelTransformProps["scale"];
|
||||
}
|
||||
|
||||
interface RepairMissionAssetPreloaderProps {
|
||||
config: RepairMissionConfig;
|
||||
}
|
||||
|
||||
function RepairMissionAssetPreloader({
|
||||
config,
|
||||
}: RepairMissionAssetPreloaderProps): null {
|
||||
const modelPaths = useMemo(
|
||||
() => getRepairMissionModelPaths(config),
|
||||
[config],
|
||||
);
|
||||
|
||||
useGLTF(modelPaths);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RepairGame({
|
||||
mission,
|
||||
position,
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: RepairGameProps): React.JSX.Element | null {
|
||||
const config = REPAIR_MISSIONS[mission];
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const step = useRepairMissionStep(mission);
|
||||
const [casePlaceholders, setCasePlaceholders] = useState<
|
||||
readonly RepairCasePlaceholder[]
|
||||
>([]);
|
||||
const [scannedBrokenParts, setScannedBrokenParts] = useState<
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const readyForFragmentation = step === "inspected";
|
||||
|
||||
useRepairFragmentationInput({
|
||||
enabled: mainState === mission && readyForFragmentation,
|
||||
keyboardEnabled: false,
|
||||
onFragment: () => setMissionStep(mission, "fragmented"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mainState === mission && shouldKeepRepairRuntimeState(step)) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCasePlaceholders([]);
|
||||
setScannedBrokenParts([]);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, mission, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mainState !== mission) return undefined;
|
||||
|
||||
if (step !== "fragmented") return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setMissionStep(mission, "scanning");
|
||||
}, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, mission, setMissionStep, step]);
|
||||
|
||||
if (mainState !== mission) return null;
|
||||
if (step === "locked") return null;
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
||||
<Suspense fallback={null}>
|
||||
<RepairMissionAssetPreloader config={config} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{step === "waiting" ? (
|
||||
<RepairInspectionObject
|
||||
config={config}
|
||||
worldPosition={position}
|
||||
onInspect={() => setMissionStep(mission, "inspected")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "fragmented" ? (
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
/>
|
||||
) : null}
|
||||
{step === "scanning" ? (
|
||||
<RepairScanSequence
|
||||
config={config}
|
||||
onComplete={(brokenParts) => {
|
||||
setScannedBrokenParts(brokenParts);
|
||||
setMissionStep(mission, "repairing");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{step === "repairing" ? (
|
||||
<RepairRepairingStep
|
||||
brokenParts={scannedBrokenParts}
|
||||
config={config}
|
||||
placeholders={casePlaceholders}
|
||||
onRepair={() => setMissionStep(mission, "reassembling")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "reassembling" ? (
|
||||
<RepairReassemblyStep
|
||||
config={config}
|
||||
onComplete={() => setMissionStep(mission, "done")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "done" ? (
|
||||
<RepairCompletionStep
|
||||
config={config}
|
||||
onComplete={() => completeMission(mission)}
|
||||
/>
|
||||
) : null}
|
||||
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
onPlaceholdersChange={setCasePlaceholders}
|
||||
open={step === "repairing"}
|
||||
zoomed={step === "repairing"}
|
||||
showFragmentationPrompt={readyForFragmentation}
|
||||
onInteract={
|
||||
readyForFragmentation
|
||||
? () => setMissionStep(mission, "fragmented")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Suspense>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
|
||||
return step === "repairing" || step === "reassembling" || step === "done";
|
||||
}
|
||||
|
||||
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
config.modelPath,
|
||||
...config.brokenParts.flatMap((part) => part.modelPath ?? []),
|
||||
...config.replacementParts.flatMap((part) => part.modelPath ?? []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairInspectionObjectProps {
|
||||
config: RepairMissionConfig;
|
||||
worldPosition: Vector3Tuple;
|
||||
onInspect: () => void;
|
||||
}
|
||||
|
||||
export function RepairInspectionObject({
|
||||
config,
|
||||
worldPosition,
|
||||
onInspect,
|
||||
}: RepairInspectionObjectProps): React.JSX.Element {
|
||||
return (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={`Inspecter ${config.label}`}
|
||||
position={worldPosition}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onPress={onInspect}
|
||||
>
|
||||
<RepairObjectModel
|
||||
label={config.label}
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 0.9}
|
||||
/>
|
||||
<RepairPromptVideo src={config.stageUiPath} />
|
||||
</InteractableObject>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
RepairCaseModel,
|
||||
type RepairCasePlaceholder,
|
||||
} from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import {
|
||||
REPAIR_CASE_FOCUS_POSITION,
|
||||
REPAIR_CASE_FOCUS_SCALE,
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairMissionCaseProps {
|
||||
config: RepairMissionConfig;
|
||||
exiting?: boolean;
|
||||
onPlaceholdersChange?:
|
||||
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
|
||||
| undefined;
|
||||
onExitComplete?: (() => void) | undefined;
|
||||
open?: boolean;
|
||||
zoomed?: boolean;
|
||||
showFragmentationPrompt?: boolean;
|
||||
onInteract?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export function RepairMissionCase({
|
||||
config,
|
||||
exiting = false,
|
||||
onPlaceholdersChange,
|
||||
onExitComplete,
|
||||
open = false,
|
||||
zoomed = false,
|
||||
showFragmentationPrompt = false,
|
||||
onInteract,
|
||||
}: RepairMissionCaseProps): React.JSX.Element {
|
||||
const casePosition = zoomed
|
||||
? REPAIR_CASE_FOCUS_POSITION
|
||||
: config.case.position;
|
||||
const caseScale = zoomed ? REPAIR_CASE_FOCUS_SCALE : config.case.scale;
|
||||
const modelPosition: Vector3Tuple = onInteract ? [0, 0, 0] : casePosition;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{onInteract ? (
|
||||
<TriggerObject
|
||||
position={casePosition}
|
||||
colliders="ball"
|
||||
label={`Ouvrir ${config.label}`}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={onInteract}
|
||||
>
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
exiting={exiting}
|
||||
onExitComplete={onExitComplete}
|
||||
onPlaceholdersChange={onPlaceholdersChange}
|
||||
open={open}
|
||||
floating={!zoomed}
|
||||
position={modelPosition}
|
||||
rotation={config.case.rotation}
|
||||
scale={caseScale}
|
||||
/>
|
||||
</TriggerObject>
|
||||
) : (
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
exiting={exiting}
|
||||
onExitComplete={onExitComplete}
|
||||
onPlaceholdersChange={onPlaceholdersChange}
|
||||
open={open}
|
||||
floating={!zoomed}
|
||||
position={modelPosition}
|
||||
rotation={config.case.rotation}
|
||||
scale={caseScale}
|
||||
/>
|
||||
)}
|
||||
{showFragmentationPrompt && !exiting ? (
|
||||
<RepairPromptVideo
|
||||
src={config.interactUiPath}
|
||||
position={[casePosition[0], 2.4, casePosition[2]]}
|
||||
size={80}
|
||||
/>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
import { SimpleModel } from "@/components/three/models/SimpleModel";
|
||||
import type { ModelTransformProps } from "@/types/three/three";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
interface RepairObjectModelProps extends ModelTransformProps {
|
||||
label: string;
|
||||
modelPath: string;
|
||||
}
|
||||
|
||||
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface RepairObjectModelBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
interface RepairObjectFallbackProps {
|
||||
label: string;
|
||||
position?: ModelTransformProps["position"] | undefined;
|
||||
rotation?: ModelTransformProps["rotation"] | undefined;
|
||||
scale?: ModelTransformProps["scale"] | undefined;
|
||||
}
|
||||
|
||||
class RepairObjectModelBoundary extends Component<
|
||||
RepairObjectModelBoundaryProps,
|
||||
RepairObjectModelBoundaryState
|
||||
> {
|
||||
constructor(props: RepairObjectModelBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): RepairObjectModelBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
logModelLoadError(
|
||||
{
|
||||
modelPath: this.props.modelPath,
|
||||
position: this.props.position,
|
||||
rotation: this.props.rotation,
|
||||
scale: this.props.scale,
|
||||
scope: `RepairObjectModel.${this.props.label}`,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<RepairObjectFallback
|
||||
label={this.props.label}
|
||||
position={this.props.position}
|
||||
rotation={this.props.rotation}
|
||||
scale={this.props.scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function RepairObjectModel({
|
||||
label,
|
||||
modelPath,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: RepairObjectModelProps): React.JSX.Element {
|
||||
return (
|
||||
<RepairObjectModelBoundary
|
||||
label={label}
|
||||
modelPath={modelPath}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
>
|
||||
<SimpleModel
|
||||
modelPath={modelPath}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
/>
|
||||
</RepairObjectModelBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairObjectFallback({
|
||||
label,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: Pick<
|
||||
RepairObjectFallbackProps,
|
||||
"label" | "position" | "rotation" | "scale"
|
||||
>): React.JSX.Element {
|
||||
return (
|
||||
<group
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={toVector3Scale(scale)}
|
||||
>
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[1.4, 1.4, 1.4]} />
|
||||
<meshStandardMaterial color="#facc15" roughness={0.6} wireframe />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.05, 0]}>
|
||||
<sphereGeometry args={[0.08, 16, 16]} />
|
||||
<meshBasicMaterial color={label ? "#f8fafc" : "#facc15"} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { WorldVideoPrompt } from "@/components/three/ui/WorldVideoPrompt";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairPromptVideoProps {
|
||||
src: string;
|
||||
position?: Vector3Tuple;
|
||||
size?: number;
|
||||
billboard?: boolean;
|
||||
}
|
||||
|
||||
export function RepairPromptVideo({
|
||||
src,
|
||||
position = [0, 1.8, 0],
|
||||
size = 96,
|
||||
billboard = true,
|
||||
}: RepairPromptVideoProps): React.JSX.Element {
|
||||
return (
|
||||
<WorldVideoPrompt
|
||||
billboard={billboard}
|
||||
position={position}
|
||||
size={size}
|
||||
src={src}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
|
||||
interface RepairReassemblyStepProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function RepairReassemblyStep({
|
||||
config,
|
||||
onComplete,
|
||||
}: RepairReassemblyStepProps): React.JSX.Element {
|
||||
const [split, setSplit] = useState(true);
|
||||
const reassemblySeconds =
|
||||
config.reassemblySeconds ?? REPAIR_REASSEMBLY_SECONDS;
|
||||
|
||||
useEffect(() => {
|
||||
const closeTimeoutId = window.setTimeout(() => {
|
||||
setSplit(false);
|
||||
}, 50);
|
||||
const completeTimeoutId = window.setTimeout(() => {
|
||||
onComplete();
|
||||
}, reassemblySeconds * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(closeTimeoutId);
|
||||
window.clearTimeout(completeTimeoutId);
|
||||
};
|
||||
}, [onComplete, reassemblySeconds]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split={split}
|
||||
splitDistance={1.2}
|
||||
/>
|
||||
<RepairCompletionParticles />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import {
|
||||
REPAIR_CASE_FOCUS_POSITION,
|
||||
REPAIR_CASE_PLACEHOLDER_SNAP_DURATION,
|
||||
REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type {
|
||||
RepairMissionConfig,
|
||||
RepairMissionPartConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
|
||||
const _placeholderPosition = new THREE.Vector3();
|
||||
const FALLBACK_PLACEHOLDER_OFFSETS: Vector3Tuple[] = [
|
||||
[-1.15, 1, 0.25],
|
||||
[0, 1.05, 0.45],
|
||||
[1.15, 1, 0.25],
|
||||
];
|
||||
const BROKEN_PART_START_OFFSETS: Vector3Tuple[] = [
|
||||
[-1.35, 0.55, -0.85],
|
||||
[0, 0.6, -1],
|
||||
[1.35, 0.55, -0.85],
|
||||
];
|
||||
const REPAIR_INSTALL_RADIUS = 1.1;
|
||||
const VALID_PART_COLOR = "#22c55e";
|
||||
const INVALID_PART_COLOR = "#ef4444";
|
||||
const STORED_BROKEN_PART_COLOR = "#38bdf8";
|
||||
|
||||
interface RepairRepairingStepProps {
|
||||
brokenParts: readonly RepairScannedBrokenPart[];
|
||||
config: RepairMissionConfig;
|
||||
placeholders: readonly RepairCasePlaceholder[];
|
||||
onRepair: () => void;
|
||||
}
|
||||
|
||||
interface RepairInstallTargetProps {
|
||||
blockedFeedback: boolean;
|
||||
fillColor: string;
|
||||
isReadyToInstall: boolean;
|
||||
label: string;
|
||||
ringColor: string;
|
||||
onBlocked: () => void;
|
||||
onRepair: () => void;
|
||||
}
|
||||
|
||||
interface RepairPlaceholderMarkersProps {
|
||||
positions: readonly Vector3Tuple[];
|
||||
}
|
||||
|
||||
interface RepairPartPlacementFeedbackProps {
|
||||
state: "valid" | "invalid" | "stored" | null;
|
||||
}
|
||||
|
||||
export function RepairRepairingStep({
|
||||
brokenParts,
|
||||
config,
|
||||
placeholders,
|
||||
onRepair,
|
||||
}: RepairRepairingStepProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const localPosition = useRef(new THREE.Vector3());
|
||||
const [placedPartIds, setPlacedPartIds] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [showBlockedInstallFeedback, setShowBlockedInstallFeedback] =
|
||||
useState(false);
|
||||
const replacementParts = getReplacementParts(config);
|
||||
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
|
||||
const requiredReplacementPart = replacementParts.find(
|
||||
(part) => part.id === config.requiredReplacementPartId,
|
||||
);
|
||||
const requiredReplacementLabel =
|
||||
requiredReplacementPart?.label ?? config.label;
|
||||
const placeholderTargets = getPlaceholderTargets(placeholders);
|
||||
const placeholderPositions = placeholderTargets.map(
|
||||
(target) => target.position,
|
||||
);
|
||||
const hasCorrectPartPlaced = Boolean(
|
||||
placedPartIds[config.requiredReplacementPartId],
|
||||
);
|
||||
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
|
||||
(part) => depositedBrokenPartIds[part.id],
|
||||
);
|
||||
const hasWrongPartPlaced = replacementParts.some(
|
||||
(part) =>
|
||||
part.id !== config.requiredReplacementPartId && placedPartIds[part.id],
|
||||
);
|
||||
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
|
||||
const installColor = isReadyToInstall
|
||||
? "#22c55e"
|
||||
: hasWrongPartPlaced
|
||||
? "#ef4444"
|
||||
: "#f97316";
|
||||
const installFillColor = isReadyToInstall
|
||||
? "#86efac"
|
||||
: hasWrongPartPlaced
|
||||
? "#fecaca"
|
||||
: "#fed7aa";
|
||||
const installLabel = isReadyToInstall
|
||||
? `Installer ${requiredReplacementLabel}`
|
||||
: hasWrongPartPlaced
|
||||
? `Mauvaise pièce`
|
||||
: hasCorrectPartPlaced
|
||||
? `Ranger pièce cassée`
|
||||
: `Approcher ${requiredReplacementLabel}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBlockedInstallFeedback) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowBlockedInstallFeedback(false);
|
||||
}, 900);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [showBlockedInstallFeedback]);
|
||||
|
||||
function handleReplacementPosition(
|
||||
partId: string,
|
||||
position: THREE.Vector3,
|
||||
): void {
|
||||
const isPlaced = isNearPlaceholder(
|
||||
getStepLocalPosition(position, groupRef.current, localPosition.current),
|
||||
placeholderPositions,
|
||||
);
|
||||
setPlacedPartIds((current) => {
|
||||
if (!current[partId] || isPlaced) return current;
|
||||
|
||||
return { ...current, [partId]: false };
|
||||
});
|
||||
}
|
||||
|
||||
function handleReplacementSnap(partId: string): void {
|
||||
setPlacedPartIds((current) => {
|
||||
if (current[partId]) return current;
|
||||
|
||||
return { ...current, [partId]: true };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrokenPartPosition(
|
||||
partId: string,
|
||||
position: THREE.Vector3,
|
||||
targets: readonly Vector3Tuple[],
|
||||
): void {
|
||||
const isDeposited = isNearPlaceholder(
|
||||
getStepLocalPosition(position, groupRef.current, localPosition.current),
|
||||
targets,
|
||||
);
|
||||
setDepositedBrokenPartIds((current) => {
|
||||
if (!current[partId] || isDeposited) return current;
|
||||
|
||||
return { ...current, [partId]: false };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrokenPartSnap(partId: string): void {
|
||||
setDepositedBrokenPartIds((current) => {
|
||||
if (current[partId]) return current;
|
||||
|
||||
return { ...current, [partId]: true };
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<RepairInstallTarget
|
||||
blockedFeedback={showBlockedInstallFeedback}
|
||||
fillColor={installFillColor}
|
||||
isReadyToInstall={isReadyToInstall}
|
||||
label={installLabel}
|
||||
ringColor={installColor}
|
||||
onBlocked={() => setShowBlockedInstallFeedback(true)}
|
||||
onRepair={onRepair}
|
||||
/>
|
||||
|
||||
<RepairPlaceholderMarkers positions={placeholderPositions} />
|
||||
|
||||
{replacementParts.map((part, index) => {
|
||||
const placeholderPosition =
|
||||
placeholderPositions[index % placeholderPositions.length] ??
|
||||
placeholderPositions[0]!;
|
||||
const isPlaced = Boolean(placedPartIds[part.id]);
|
||||
const feedbackState = getReplacementFeedbackState(
|
||||
part.id,
|
||||
config.requiredReplacementPartId,
|
||||
isPlaced,
|
||||
);
|
||||
|
||||
return (
|
||||
<GrabbableObject
|
||||
key={part.id}
|
||||
position={placeholderPosition}
|
||||
colliders="ball"
|
||||
handControlled
|
||||
label={`Prendre ${part.label}`}
|
||||
onPositionChange={(position) => {
|
||||
handleReplacementPosition(part.id, position);
|
||||
}}
|
||||
onSnap={() => {
|
||||
handleReplacementSnap(part.id);
|
||||
}}
|
||||
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
|
||||
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
|
||||
snapTargets={placeholderPositions}
|
||||
>
|
||||
<group>
|
||||
<RepairObjectModel
|
||||
label={part.label}
|
||||
modelPath={part.modelPath ?? config.modelPath}
|
||||
scale={0.36}
|
||||
/>
|
||||
<RepairPartPlacementFeedback state={feedbackState} />
|
||||
</group>
|
||||
</GrabbableObject>
|
||||
);
|
||||
})}
|
||||
|
||||
{brokenPartsToDeposit.map((part, index) => {
|
||||
const startOffset =
|
||||
BROKEN_PART_START_OFFSETS[index % BROKEN_PART_START_OFFSETS.length] ??
|
||||
BROKEN_PART_START_OFFSETS[0]!;
|
||||
const startPosition: Vector3Tuple = [
|
||||
REPAIR_CASE_FOCUS_POSITION[0] + startOffset[0],
|
||||
REPAIR_CASE_FOCUS_POSITION[1] + startOffset[1],
|
||||
REPAIR_CASE_FOCUS_POSITION[2] + startOffset[2],
|
||||
];
|
||||
const targetPositions = getBrokenPartTargetPositions(
|
||||
part,
|
||||
placeholderTargets,
|
||||
);
|
||||
const isDeposited = Boolean(depositedBrokenPartIds[part.id]);
|
||||
|
||||
return (
|
||||
<GrabbableObject
|
||||
key={part.id}
|
||||
position={startPosition}
|
||||
colliders="ball"
|
||||
handControlled
|
||||
label={`Ranger ${part.label}`}
|
||||
onPositionChange={(position) => {
|
||||
handleBrokenPartPosition(part.id, position, targetPositions);
|
||||
}}
|
||||
onSnap={() => {
|
||||
handleBrokenPartSnap(part.id);
|
||||
}}
|
||||
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
|
||||
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
|
||||
snapTargets={targetPositions}
|
||||
>
|
||||
<group>
|
||||
<RepairObjectModel
|
||||
label={part.label}
|
||||
modelPath={part.modelPath}
|
||||
scale={0.24}
|
||||
/>
|
||||
<mesh position={[0, 0.42, 0]}>
|
||||
<sphereGeometry args={[0.11, 16, 16]} />
|
||||
<meshBasicMaterial color="#ef4444" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<RepairPartPlacementFeedback
|
||||
state={isDeposited ? "stored" : null}
|
||||
/>
|
||||
</group>
|
||||
</GrabbableObject>
|
||||
);
|
||||
})}
|
||||
|
||||
{isReadyToInstall ? (
|
||||
<RepairPromptVideo src={config.interactUiPath} position={[0, 2.3, 0]} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairInstallTarget({
|
||||
blockedFeedback,
|
||||
fillColor,
|
||||
isReadyToInstall,
|
||||
label,
|
||||
ringColor,
|
||||
onBlocked,
|
||||
onRepair,
|
||||
}: RepairInstallTargetProps): React.JSX.Element {
|
||||
return (
|
||||
<TriggerObject
|
||||
position={INSTALL_TARGET_POSITION}
|
||||
colliders="ball"
|
||||
label={label}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={() => {
|
||||
if (!isReadyToInstall) {
|
||||
onBlocked();
|
||||
return;
|
||||
}
|
||||
|
||||
onRepair();
|
||||
}}
|
||||
>
|
||||
<mesh>
|
||||
<torusGeometry args={[0.95, 0.045, 12, 96]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.15, 0.9, 96]} />
|
||||
<meshBasicMaterial color={fillColor} transparent opacity={0.35} />
|
||||
</mesh>
|
||||
{blockedFeedback ? (
|
||||
<group position={[0, 0.28, 0]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.08, 0.035, 12, 96]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.12, 16, 16]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
) : null}
|
||||
</TriggerObject>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairPlaceholderMarkers({
|
||||
positions,
|
||||
}: RepairPlaceholderMarkersProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{positions.map((position, index) => (
|
||||
<mesh
|
||||
key={`${position.join(":")}-${index}`}
|
||||
position={position}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<torusGeometry args={[0.26, 0.018, 8, 48]} />
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.55} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairPartPlacementFeedback({
|
||||
state,
|
||||
}: RepairPartPlacementFeedbackProps): React.JSX.Element | null {
|
||||
if (!state) return null;
|
||||
|
||||
const color = getPlacementFeedbackColor(state);
|
||||
|
||||
return (
|
||||
<group position={[0, 0.72, 0]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.48, 0.035, 12, 64]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.08, 0]}>
|
||||
<sphereGeometry args={[0.1, 16, 16]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function getPlacementFeedbackColor(
|
||||
state: NonNullable<RepairPartPlacementFeedbackProps["state"]>,
|
||||
): string {
|
||||
if (state === "valid") return VALID_PART_COLOR;
|
||||
if (state === "stored") return STORED_BROKEN_PART_COLOR;
|
||||
|
||||
return INVALID_PART_COLOR;
|
||||
}
|
||||
|
||||
function getReplacementFeedbackState(
|
||||
partId: string,
|
||||
requiredPartId: string,
|
||||
isPlaced: boolean,
|
||||
): RepairPartPlacementFeedbackProps["state"] {
|
||||
if (!isPlaced) return null;
|
||||
|
||||
return partId === requiredPartId ? "valid" : "invalid";
|
||||
}
|
||||
|
||||
function getPlaceholderTargets(
|
||||
placeholders: readonly RepairCasePlaceholder[],
|
||||
): readonly RepairCasePlaceholder[] {
|
||||
if (placeholders.length > 0) {
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
return FALLBACK_PLACEHOLDER_OFFSETS.map(
|
||||
(offset, index): RepairCasePlaceholder => ({
|
||||
name: `placeholder_${index + 1}`,
|
||||
position: [
|
||||
REPAIR_CASE_FOCUS_POSITION[0] + offset[0],
|
||||
REPAIR_CASE_FOCUS_POSITION[1] + offset[1],
|
||||
REPAIR_CASE_FOCUS_POSITION[2] + offset[2],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getBrokenPartTargetPositions(
|
||||
part: RepairScannedBrokenPart,
|
||||
placeholderTargets: readonly RepairCasePlaceholder[],
|
||||
): readonly Vector3Tuple[] {
|
||||
if (!part.placeholderName) {
|
||||
return placeholderTargets.map((placeholder) => placeholder.position);
|
||||
}
|
||||
|
||||
const matchingPlaceholder = placeholderTargets.find(
|
||||
(placeholder) => placeholder.name === part.placeholderName,
|
||||
);
|
||||
|
||||
return matchingPlaceholder
|
||||
? [matchingPlaceholder.position]
|
||||
: placeholderTargets.map((placeholder) => placeholder.position);
|
||||
}
|
||||
|
||||
function isNearPlaceholder(
|
||||
position: THREE.Vector3,
|
||||
placeholderPositions: readonly Vector3Tuple[],
|
||||
): boolean {
|
||||
return placeholderPositions.some(
|
||||
(placeholderPosition) =>
|
||||
position.distanceTo(_placeholderPosition.set(...placeholderPosition)) <=
|
||||
REPAIR_INSTALL_RADIUS,
|
||||
);
|
||||
}
|
||||
|
||||
function getStepLocalPosition(
|
||||
worldPosition: THREE.Vector3,
|
||||
group: THREE.Group | null,
|
||||
target: THREE.Vector3,
|
||||
): THREE.Vector3 {
|
||||
target.copy(worldPosition);
|
||||
group?.worldToLocal(target);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function getReplacementParts(
|
||||
config: RepairMissionConfig,
|
||||
): readonly RepairMissionPartConfig[] {
|
||||
if (config.replacementParts.length > 0) return config.replacementParts;
|
||||
|
||||
return [
|
||||
{
|
||||
id: config.requiredReplacementPartId,
|
||||
label: config.label,
|
||||
modelPath: config.modelPath,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getBrokenPartsToDeposit(
|
||||
config: RepairMissionConfig,
|
||||
brokenParts: readonly RepairScannedBrokenPart[],
|
||||
): readonly RepairScannedBrokenPart[] {
|
||||
if (brokenParts.length > 0) return brokenParts;
|
||||
|
||||
return config.brokenParts.map((part) => ({
|
||||
id: part.id,
|
||||
label: part.label,
|
||||
modelPath: part.modelPath ?? config.modelPath,
|
||||
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { RepairBrokenPartHighlight } from "@/components/three/gameplay/RepairBrokenPartHighlight";
|
||||
import { RepairBrokenPartPrompt } from "@/components/three/gameplay/RepairBrokenPartPrompt";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { RepairScanVisual } from "@/components/three/gameplay/RepairScanVisual";
|
||||
import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import type {
|
||||
RepairMissionConfig,
|
||||
RepairMissionPartConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
||||
|
||||
interface RepairScanSequenceProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
|
||||
}
|
||||
|
||||
export interface RepairScannedBrokenPart {
|
||||
id: string;
|
||||
label: string;
|
||||
modelPath: string;
|
||||
placeholderName?: string;
|
||||
}
|
||||
|
||||
export function RepairScanSequence({
|
||||
config,
|
||||
onComplete,
|
||||
}: RepairScanSequenceProps): React.JSX.Element {
|
||||
const [parts, setParts] = useState<readonly ExplodedPart[]>([]);
|
||||
const [activePartIndex, setActivePartIndex] = useState(0);
|
||||
const activePart = parts[activePartIndex];
|
||||
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
|
||||
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
|
||||
const visibleBrokenPartIndexes = brokenPartIndexes.filter(
|
||||
(partIndex) => partIndex <= activePartIndex,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (parts.length === 0) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setActivePartIndex((currentIndex) => {
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= parts.length) {
|
||||
onComplete(getScannedBrokenParts(parts, config));
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
});
|
||||
}, scanPartSeconds * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [activePartIndex, config, onComplete, parts, scanPartSeconds]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
onPartsReady={setParts}
|
||||
/>
|
||||
<RepairScanVisual target={activePart?.object} />
|
||||
{visibleBrokenPartIndexes.map((partIndex) => {
|
||||
const part = parts[partIndex];
|
||||
if (!part) return null;
|
||||
|
||||
return (
|
||||
<group key={part.object.uuid}>
|
||||
<RepairBrokenPartHighlight target={part.object} />
|
||||
<RepairBrokenPartPrompt
|
||||
src={config.brokenUiPath}
|
||||
target={part.object}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function getScannedBrokenParts(
|
||||
parts: readonly ExplodedPart[],
|
||||
config: RepairMissionConfig,
|
||||
): readonly RepairScannedBrokenPart[] {
|
||||
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
|
||||
|
||||
return brokenPartIndexes.map((_, index) => {
|
||||
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
|
||||
|
||||
return {
|
||||
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`,
|
||||
label: configuredPart?.label ?? `${config.label} broken part`,
|
||||
modelPath: configuredPart?.modelPath ?? config.modelPath,
|
||||
...(configuredPart?.placeholderName
|
||||
? { placeholderName: configuredPart.placeholderName }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getBrokenPartIndexes(
|
||||
parts: readonly ExplodedPart[],
|
||||
brokenParts: readonly RepairMissionPartConfig[],
|
||||
): number[] {
|
||||
if (parts.length === 0 || brokenParts.length === 0) return [];
|
||||
|
||||
const matchedIndexes = brokenParts.flatMap((brokenPart) => {
|
||||
const { nodeName } = brokenPart;
|
||||
if (!nodeName) return [];
|
||||
|
||||
const index = parts.findIndex((part) =>
|
||||
objectContainsNodeName(part.object, nodeName),
|
||||
);
|
||||
|
||||
return index >= 0 ? [index] : [];
|
||||
});
|
||||
|
||||
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)];
|
||||
|
||||
return parts.slice(0, brokenParts.length).map((_, index) => index);
|
||||
}
|
||||
|
||||
function objectContainsNodeName(
|
||||
object: THREE.Object3D,
|
||||
nodeName: string,
|
||||
): boolean {
|
||||
if (object.name === nodeName) return true;
|
||||
|
||||
let found = false;
|
||||
object.traverse((child) => {
|
||||
if (child.name === nodeName) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
interface RepairScanVisualProps {
|
||||
target?: THREE.Object3D | null | undefined;
|
||||
}
|
||||
|
||||
export function RepairScanVisual({
|
||||
target = null,
|
||||
}: RepairScanVisualProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const scanLineRef = useRef<THREE.Mesh>(null);
|
||||
const worldPosition = useRef(new THREE.Vector3());
|
||||
const localPosition = useRef(new THREE.Vector3());
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
const scanLine = scanLineRef.current;
|
||||
if (!group || !scanLine) return;
|
||||
|
||||
if (target) {
|
||||
target.getWorldPosition(worldPosition.current);
|
||||
localPosition.current.copy(worldPosition.current);
|
||||
group.parent?.worldToLocal(localPosition.current);
|
||||
group.position.copy(localPosition.current);
|
||||
}
|
||||
|
||||
scanLine.position.y = 0.35 + Math.sin(clock.elapsedTime * 4) * 0.7;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.35, 0.035, 12, 96]} />
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.75} />
|
||||
</mesh>
|
||||
<mesh ref={scanLineRef} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.15, 1.25, 96]} />
|
||||
<meshBasicMaterial
|
||||
color="#7dd3fc"
|
||||
side={THREE.DoubleSide}
|
||||
transparent
|
||||
opacity={0.45}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, 0.85, 0]}>
|
||||
<sphereGeometry args={[1.25, 32, 16]} />
|
||||
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component, useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { clone } from "three/addons/utils/SkeletonUtils.js";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import {
|
||||
useHandTrackingGloveStatus,
|
||||
type HandTrackingGloveHandedness,
|
||||
} from "@/hooks/handTracking/useHandTrackingGloveStatus";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { HandTrackingLandmark } from "@/types/handTracking/handTracking";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
|
||||
const GLOVE_CONFIGS: Record<
|
||||
HandTrackingGloveHandedness,
|
||||
{
|
||||
modelPath: string;
|
||||
rootNodeName: string;
|
||||
}
|
||||
> = {
|
||||
left: {
|
||||
modelPath: "/models/gant_l/model.gltf",
|
||||
rootNodeName: "Armature",
|
||||
},
|
||||
right: {
|
||||
modelPath: "/models/gant_r/model.gltf",
|
||||
rootNodeName: "Hand_r",
|
||||
},
|
||||
};
|
||||
|
||||
const GLOVE_MODEL_SCALE = 0.33;
|
||||
const HAND_SPACE_DISTANCE = 0.5;
|
||||
const HAND_TRACKING_HIDE_DELAY_MS = 250;
|
||||
|
||||
const FINGER_LANDMARK_CHAINS = [
|
||||
[0, 1, 2, 3, 4],
|
||||
[0, 5, 6, 7, 8],
|
||||
[0, 9, 10, 11, 12],
|
||||
[0, 13, 14, 15, 16],
|
||||
[0, 17, 18, 19, 20],
|
||||
] as const;
|
||||
|
||||
const _cameraPosition = new THREE.Vector3();
|
||||
const _direction = new THREE.Vector3();
|
||||
const _xAxis = new THREE.Vector3();
|
||||
const _yAxis = new THREE.Vector3();
|
||||
const _zAxis = new THREE.Vector3();
|
||||
const _matrix = new THREE.Matrix4();
|
||||
const _parentInverse = new THREE.Matrix4();
|
||||
const _targetQuaternion = new THREE.Quaternion();
|
||||
const _boneTargetQuaternion = new THREE.Quaternion();
|
||||
const _boneDeltaQuaternion = new THREE.Quaternion();
|
||||
const _targetPosition = new THREE.Vector3();
|
||||
const _localSegmentStart = new THREE.Vector3();
|
||||
const _localSegmentEnd = new THREE.Vector3();
|
||||
const _localSegmentDirection = new THREE.Vector3();
|
||||
const _wristPosition = new THREE.Vector3();
|
||||
const _indexPosition = new THREE.Vector3();
|
||||
const _middlePosition = new THREE.Vector3();
|
||||
const _ringPosition = new THREE.Vector3();
|
||||
const _pinkyPosition = new THREE.Vector3();
|
||||
|
||||
interface FingerBonePose {
|
||||
bone: THREE.Object3D;
|
||||
restDirection: THREE.Vector3;
|
||||
restQuaternion: THREE.Quaternion;
|
||||
}
|
||||
|
||||
type FingerPoseChain = FingerBonePose[];
|
||||
|
||||
interface HandTrackingGloveProps {
|
||||
handedness: HandTrackingGloveHandedness;
|
||||
}
|
||||
|
||||
interface HandTrackingGloveErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
handedness: HandTrackingGloveHandedness;
|
||||
modelPath: string;
|
||||
}
|
||||
|
||||
class HandTrackingGloveErrorBoundary extends Component<
|
||||
HandTrackingGloveErrorBoundaryProps,
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: HandTrackingGloveErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
useHandTrackingGloveStatus
|
||||
.getState()
|
||||
.setGloveStatus(this.props.handedness, "error");
|
||||
|
||||
logModelLoadError(
|
||||
{
|
||||
modelPath: this.props.modelPath,
|
||||
scope: `HandTrackingGlove.${this.props.handedness}`,
|
||||
scale: GLOVE_MODEL_SCALE,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) return null;
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function landmarkToWorldPoint(
|
||||
landmark: HandTrackingLandmark,
|
||||
camera: THREE.Camera,
|
||||
target: THREE.Vector3,
|
||||
): THREE.Vector3 {
|
||||
_cameraPosition.setFromMatrixPosition(camera.matrixWorld);
|
||||
target.set((1 - landmark.x) * 2 - 1, -landmark.y * 2 + 1, 0.5);
|
||||
target.unproject(camera);
|
||||
|
||||
_direction.copy(target).sub(_cameraPosition).normalize();
|
||||
target.copy(_cameraPosition).addScaledVector(_direction, HAND_SPACE_DISTANCE);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function matchesHandedness(
|
||||
handHandedness: string,
|
||||
targetHandedness: HandTrackingGloveHandedness,
|
||||
): boolean {
|
||||
return handHandedness.toLowerCase() === targetHandedness;
|
||||
}
|
||||
|
||||
function getFirstChildBone(object: THREE.Object3D): THREE.Object3D | null {
|
||||
return object.children.find((child) => child.type === "Bone") ?? null;
|
||||
}
|
||||
|
||||
function createFingerBonePose(bone: THREE.Object3D): FingerBonePose {
|
||||
const firstChild = getFirstChildBone(bone);
|
||||
const restDirection = firstChild
|
||||
? firstChild.position.clone()
|
||||
: new THREE.Vector3(0, 1, 0);
|
||||
|
||||
restDirection.applyQuaternion(bone.quaternion).normalize();
|
||||
|
||||
return {
|
||||
bone,
|
||||
restDirection,
|
||||
restQuaternion: bone.quaternion.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
function createFingerPoseChain(startBone: THREE.Object3D): FingerPoseChain {
|
||||
const chain: FingerPoseChain = [];
|
||||
let currentBone: THREE.Object3D | null = startBone;
|
||||
|
||||
while (currentBone && chain.length < 4) {
|
||||
chain.push(createFingerBonePose(currentBone));
|
||||
currentBone = getFirstChildBone(currentBone);
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
function createFingerPoseChains(root: THREE.Object3D): FingerPoseChain[] {
|
||||
const rootBone = root.getObjectByName("Bone");
|
||||
|
||||
if (!rootBone) return [];
|
||||
|
||||
return rootBone.children
|
||||
.filter((child) => child.type === "Bone")
|
||||
.slice(0, FINGER_LANDMARK_CHAINS.length)
|
||||
.map(createFingerPoseChain);
|
||||
}
|
||||
|
||||
function resetFingerPose(chains: FingerPoseChain[]): void {
|
||||
for (const chain of chains) {
|
||||
for (const pose of chain) {
|
||||
pose.bone.quaternion.copy(pose.restQuaternion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyFingerPose(
|
||||
chains: FingerPoseChain[],
|
||||
landmarks: HandTrackingLandmark[],
|
||||
camera: THREE.Camera,
|
||||
): void {
|
||||
for (let fingerIndex = 0; fingerIndex < chains.length; fingerIndex += 1) {
|
||||
const chain = chains[fingerIndex];
|
||||
const landmarkChain = FINGER_LANDMARK_CHAINS[fingerIndex];
|
||||
|
||||
if (!chain || !landmarkChain) continue;
|
||||
|
||||
for (let boneIndex = 0; boneIndex < chain.length; boneIndex += 1) {
|
||||
const pose = chain[boneIndex];
|
||||
const fromLandmark = landmarks[landmarkChain[boneIndex] ?? -1];
|
||||
const toLandmark = landmarks[landmarkChain[boneIndex + 1] ?? -1];
|
||||
const parent = pose?.bone.parent;
|
||||
|
||||
if (!pose || !fromLandmark || !toLandmark || !parent) continue;
|
||||
|
||||
landmarkToWorldPoint(fromLandmark, camera, _localSegmentStart);
|
||||
landmarkToWorldPoint(toLandmark, camera, _localSegmentEnd);
|
||||
|
||||
parent.updateWorldMatrix(true, false);
|
||||
_parentInverse.copy(parent.matrixWorld).invert();
|
||||
_localSegmentStart.applyMatrix4(_parentInverse);
|
||||
_localSegmentEnd.applyMatrix4(_parentInverse);
|
||||
_localSegmentDirection
|
||||
.copy(_localSegmentEnd)
|
||||
.sub(_localSegmentStart)
|
||||
.normalize();
|
||||
|
||||
if (_localSegmentDirection.lengthSq() === 0) continue;
|
||||
|
||||
_boneDeltaQuaternion.setFromUnitVectors(
|
||||
pose.restDirection,
|
||||
_localSegmentDirection,
|
||||
);
|
||||
_boneTargetQuaternion
|
||||
.copy(_boneDeltaQuaternion)
|
||||
.multiply(pose.restQuaternion);
|
||||
pose.bone.quaternion.slerp(_boneTargetQuaternion, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function HandTrackingGloveModel({
|
||||
handedness,
|
||||
}: HandTrackingGloveProps): React.JSX.Element | null {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { camera } = useThree();
|
||||
const { hands } = useHandTrackingSnapshot();
|
||||
const setGloveStatus = useHandTrackingGloveStatus(
|
||||
(state) => state.setGloveStatus,
|
||||
);
|
||||
const config = GLOVE_CONFIGS[handedness];
|
||||
const modelPath = config.modelPath;
|
||||
const gltf = useLoggedGLTF(modelPath, {
|
||||
scope: `HandTrackingGlove.${handedness}`,
|
||||
scale: GLOVE_MODEL_SCALE,
|
||||
});
|
||||
const lastTrackedAtRef = useRef<number | null>(null);
|
||||
const gloveScene = useMemo(() => {
|
||||
const rootNode = gltf.scene.getObjectByName(config.rootNodeName);
|
||||
|
||||
if (!rootNode) {
|
||||
throw new Error(`Missing glove root node ${config.rootNodeName}`);
|
||||
}
|
||||
|
||||
const clonedRootNode = clone(rootNode);
|
||||
clonedRootNode.visible = false;
|
||||
|
||||
return clonedRootNode;
|
||||
}, [config.rootNodeName, gltf.scene]);
|
||||
const fingerPoseChains = useMemo(
|
||||
() => createFingerPoseChains(gloveScene),
|
||||
[gloveScene],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setGloveStatus(handedness, "loaded");
|
||||
}, [handedness, setGloveStatus]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const group = groupRef.current;
|
||||
const trackedHand = hands.find((candidate) =>
|
||||
matchesHandedness(candidate.handedness, handedness),
|
||||
);
|
||||
|
||||
if (!group) return;
|
||||
|
||||
if (!trackedHand || trackedHand.landmarks.length < 21) {
|
||||
const lastTrackedAt = lastTrackedAtRef.current;
|
||||
const shouldHide =
|
||||
lastTrackedAt === null ||
|
||||
performance.now() - lastTrackedAt > HAND_TRACKING_HIDE_DELAY_MS;
|
||||
|
||||
if (shouldHide) {
|
||||
group.visible = false;
|
||||
resetFingerPose(fingerPoseChains);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
lastTrackedAtRef.current = performance.now();
|
||||
group.visible = true;
|
||||
|
||||
const wrist = trackedHand.landmarks[0];
|
||||
const indexMcp = trackedHand.landmarks[5];
|
||||
const middleMcp = trackedHand.landmarks[9];
|
||||
const ringMcp = trackedHand.landmarks[13];
|
||||
const pinkyMcp = trackedHand.landmarks[17];
|
||||
|
||||
if (!wrist || !indexMcp || !middleMcp || !ringMcp || !pinkyMcp) {
|
||||
group.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
landmarkToWorldPoint(wrist, camera, _wristPosition);
|
||||
landmarkToWorldPoint(indexMcp, camera, _indexPosition);
|
||||
landmarkToWorldPoint(middleMcp, camera, _middlePosition);
|
||||
landmarkToWorldPoint(ringMcp, camera, _ringPosition);
|
||||
landmarkToWorldPoint(pinkyMcp, camera, _pinkyPosition);
|
||||
|
||||
_targetPosition
|
||||
.copy(_wristPosition)
|
||||
.add(_indexPosition)
|
||||
.add(_middlePosition)
|
||||
.add(_ringPosition)
|
||||
.add(_pinkyPosition)
|
||||
.multiplyScalar(0.2);
|
||||
|
||||
_yAxis.copy(_middlePosition).sub(_wristPosition).normalize();
|
||||
_xAxis.copy(_indexPosition).sub(_pinkyPosition).normalize();
|
||||
_zAxis.crossVectors(_xAxis, _yAxis).normalize();
|
||||
|
||||
if (
|
||||
_xAxis.lengthSq() === 0 ||
|
||||
_yAxis.lengthSq() === 0 ||
|
||||
_zAxis.lengthSq() === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
_xAxis.crossVectors(_yAxis, _zAxis).normalize();
|
||||
_matrix.makeBasis(_xAxis, _yAxis, _zAxis);
|
||||
_targetQuaternion.setFromRotationMatrix(_matrix);
|
||||
|
||||
group.position.lerp(_targetPosition, Math.min(1, delta * 18));
|
||||
group.quaternion.slerp(_targetQuaternion, Math.min(1, delta * 18));
|
||||
|
||||
const palmLength = _wristPosition.distanceTo(_middlePosition);
|
||||
const scale = palmLength * GLOVE_MODEL_SCALE;
|
||||
group.scale.setScalar(scale);
|
||||
group.updateMatrixWorld(true);
|
||||
applyFingerPose(fingerPoseChains, trackedHand.landmarks, camera);
|
||||
});
|
||||
|
||||
return <primitive ref={groupRef} object={gloveScene} />;
|
||||
}
|
||||
|
||||
export function HandTrackingGlove({
|
||||
handedness,
|
||||
}: HandTrackingGloveProps): React.JSX.Element {
|
||||
const modelPath = GLOVE_CONFIGS[handedness].modelPath;
|
||||
|
||||
return (
|
||||
<HandTrackingGloveErrorBoundary
|
||||
handedness={handedness}
|
||||
modelPath={modelPath}
|
||||
>
|
||||
<HandTrackingGloveModel handedness={handedness} />
|
||||
</HandTrackingGloveErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(GLOVE_CONFIGS.left.modelPath);
|
||||
useGLTF.preload(GLOVE_CONFIGS.right.modelPath);
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
import { InteractableObject } from "@/components/3d/InteractableObject";
|
||||
import { useGameStore } from "@/stores/gameStore";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
|
||||
import { Debug } from "@/utils/debug/Debug";
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface CentralObjectProps {
|
||||
position: Vector3Tuple;
|
||||
@@ -10,10 +10,10 @@ interface CentralObjectProps {
|
||||
export function CentralObject({
|
||||
position,
|
||||
}: CentralObjectProps): React.JSX.Element {
|
||||
const step = useGameStore((state) => state.step);
|
||||
const setStep = useGameStore((state) => state.setStep);
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
const showDialog = useGameStore((state) => state.showDialog);
|
||||
const step = useMissionFlowStore((state) => state.step);
|
||||
const setStep = useMissionFlowStore((state) => state.setStep);
|
||||
const setCanMove = useMissionFlowStore((state) => state.setCanMove);
|
||||
const showDialog = useMissionFlowStore((state) => state.showDialog);
|
||||
const debug = Debug.getInstance();
|
||||
|
||||
const handlePress = (): void => {
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { RigidBody } from "@react-three/rapier";
|
||||
import type { RapierRigidBody } from "@react-three/rapier";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import {
|
||||
GRAB_DEFAULT_COLLIDERS,
|
||||
GRAB_DEFAULT_LABEL,
|
||||
GRAB_HOLD_DISTANCE_DEFAULT,
|
||||
GRAB_HOLD_DISTANCE_MAX,
|
||||
GRAB_HOLD_DISTANCE_MIN,
|
||||
GRAB_HOLD_DISTANCE_STEP,
|
||||
GRAB_STIFFNESS_DEFAULT,
|
||||
GRAB_STIFFNESS_MAX,
|
||||
GRAB_STIFFNESS_MIN,
|
||||
GRAB_STIFFNESS_STEP,
|
||||
GRAB_THROW_BOOST_DEFAULT,
|
||||
GRAB_THROW_BOOST_MAX,
|
||||
GRAB_THROW_BOOST_MIN,
|
||||
GRAB_THROW_BOOST_STEP,
|
||||
} from "@/data/interaction/grabConfig";
|
||||
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
|
||||
import type { ColliderShape, Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface GrabbableObjectProps {
|
||||
position: Vector3Tuple;
|
||||
children: React.ReactNode;
|
||||
colliders?: ColliderShape;
|
||||
label?: string;
|
||||
handControlled?: boolean;
|
||||
onPositionChange?: (position: THREE.Vector3) => void;
|
||||
onSnap?: (position: THREE.Vector3) => void;
|
||||
snapDuration?: number;
|
||||
snapRadius?: number;
|
||||
snapTargets?: readonly Vector3Tuple[];
|
||||
}
|
||||
|
||||
interface HandScreenPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const grabDebugParams = {
|
||||
stiffness: GRAB_STIFFNESS_DEFAULT,
|
||||
throwBoost: GRAB_THROW_BOOST_DEFAULT,
|
||||
holdDistance: GRAB_HOLD_DISTANCE_DEFAULT,
|
||||
};
|
||||
|
||||
const ZERO_ANGULAR_VELOCITY = { x: 0, y: 0, z: 0 };
|
||||
|
||||
const _holdTarget = new THREE.Vector3();
|
||||
const _currentPos = new THREE.Vector3();
|
||||
const _velocity = new THREE.Vector3();
|
||||
const _handNdc = new THREE.Vector3();
|
||||
const _handHitNdc = new THREE.Vector3();
|
||||
const _handDirection = new THREE.Vector3();
|
||||
const _handHitDirection = new THREE.Vector3();
|
||||
const _cameraPos = new THREE.Vector3();
|
||||
const _objectPos = new THREE.Vector3();
|
||||
const _snapPosition = new THREE.Vector3();
|
||||
const _snapTargetWorldPosition = new THREE.Vector3();
|
||||
const _handRaycaster = new THREE.Raycaster();
|
||||
|
||||
const HAND_GRAB_SCREEN_RADIUS = 0.04;
|
||||
const HAND_HIT_OFFSETS: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[HAND_GRAB_SCREEN_RADIUS, 0],
|
||||
[-HAND_GRAB_SCREEN_RADIUS, 0],
|
||||
[0, HAND_GRAB_SCREEN_RADIUS],
|
||||
[0, -HAND_GRAB_SCREEN_RADIUS],
|
||||
];
|
||||
|
||||
function getHandCenterPoint(hand: HandTrackingHand): HandScreenPoint {
|
||||
const landmarks = hand.landmarks;
|
||||
if (landmarks.length === 0) {
|
||||
return { x: hand.x, y: hand.y };
|
||||
}
|
||||
|
||||
let minX = landmarks[0]!.x;
|
||||
let maxX = landmarks[0]!.x;
|
||||
let minY = landmarks[0]!.y;
|
||||
let maxY = landmarks[0]!.y;
|
||||
|
||||
landmarks.forEach((landmark) => {
|
||||
minX = Math.min(minX, landmark.x);
|
||||
maxX = Math.max(maxX, landmark.x);
|
||||
minY = Math.min(minY, landmark.y);
|
||||
maxY = Math.max(maxY, landmark.y);
|
||||
});
|
||||
|
||||
return {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function getHandHit(
|
||||
group: THREE.Group | null,
|
||||
camera: THREE.Camera,
|
||||
cameraPos: THREE.Vector3,
|
||||
handCenter: HandScreenPoint,
|
||||
): THREE.Intersection | null {
|
||||
if (!group) return null;
|
||||
|
||||
const baseX = (1 - handCenter.x) * 2 - 1;
|
||||
const baseY = -handCenter.y * 2 + 1;
|
||||
|
||||
for (const [offsetX, offsetY] of HAND_HIT_OFFSETS) {
|
||||
_handHitNdc.set(baseX + offsetX, baseY + offsetY, 0.5);
|
||||
_handHitNdc.unproject(camera);
|
||||
_handHitDirection.subVectors(_handHitNdc, cameraPos).normalize();
|
||||
_handRaycaster.set(cameraPos, _handHitDirection);
|
||||
_handRaycaster.far = INTERACTION_RADIUS;
|
||||
|
||||
const hits = _handRaycaster.intersectObject(group, true);
|
||||
if (hits?.length > 0) return hits[0] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function GrabbableObject({
|
||||
position,
|
||||
children,
|
||||
colliders = GRAB_DEFAULT_COLLIDERS,
|
||||
label = GRAB_DEFAULT_LABEL,
|
||||
handControlled = false,
|
||||
onPositionChange,
|
||||
onSnap,
|
||||
snapDuration = 0.25,
|
||||
snapRadius = 0,
|
||||
snapTargets = [],
|
||||
}: GrabbableObjectProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const { hands } = useHandTrackingSnapshot();
|
||||
const spaceRef = useRef<THREE.Group>(null);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const rbRef = useRef<RapierRigidBody>(null);
|
||||
const isHolding = useRef(false);
|
||||
const isHandHolding = useRef(false);
|
||||
const snapTween = useRef<gsap.core.Tween | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
snapTween.current?.kill();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function snapToNearestTarget(): void {
|
||||
const body = rbRef.current;
|
||||
if (!body || snapTargets.length === 0 || snapRadius <= 0) return;
|
||||
|
||||
const translation = body.translation();
|
||||
_currentPos.set(translation.x, translation.y, translation.z);
|
||||
|
||||
let nearestTarget: Vector3Tuple | null = null;
|
||||
let nearestTargetWorld: Vector3Tuple | null = null;
|
||||
let nearestDistance = snapRadius;
|
||||
snapTargets.forEach((target) => {
|
||||
_snapTargetWorldPosition.set(target[0], target[1], target[2]);
|
||||
spaceRef.current?.localToWorld(_snapTargetWorldPosition);
|
||||
const distance = _currentPos.distanceTo(_snapTargetWorldPosition);
|
||||
if (distance <= nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestTarget = target;
|
||||
nearestTargetWorld = [
|
||||
_snapTargetWorldPosition.x,
|
||||
_snapTargetWorldPosition.y,
|
||||
_snapTargetWorldPosition.z,
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
if (!nearestTarget || !nearestTargetWorld) return;
|
||||
|
||||
snapTween.current?.kill();
|
||||
const animatedPosition = {
|
||||
x: _currentPos.x,
|
||||
y: _currentPos.y,
|
||||
z: _currentPos.z,
|
||||
};
|
||||
|
||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
body.setAngvel(ZERO_ANGULAR_VELOCITY, true);
|
||||
snapTween.current = gsap.to(animatedPosition, {
|
||||
x: nearestTargetWorld[0],
|
||||
y: nearestTargetWorld[1],
|
||||
z: nearestTargetWorld[2],
|
||||
duration: snapDuration,
|
||||
ease: "power2.out",
|
||||
onUpdate: () => {
|
||||
body.setTranslation(animatedPosition, true);
|
||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
},
|
||||
onComplete: () => {
|
||||
_snapPosition.set(
|
||||
animatedPosition.x,
|
||||
animatedPosition.y,
|
||||
animatedPosition.z,
|
||||
);
|
||||
onSnap?.(_snapPosition);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useDebugFolder("GrabbableObject", (folder) => {
|
||||
folder
|
||||
.add(
|
||||
grabDebugParams,
|
||||
"stiffness",
|
||||
GRAB_STIFFNESS_MIN,
|
||||
GRAB_STIFFNESS_MAX,
|
||||
GRAB_STIFFNESS_STEP,
|
||||
)
|
||||
.name("Hold stiffness");
|
||||
folder
|
||||
.add(
|
||||
grabDebugParams,
|
||||
"throwBoost",
|
||||
GRAB_THROW_BOOST_MIN,
|
||||
GRAB_THROW_BOOST_MAX,
|
||||
GRAB_THROW_BOOST_STEP,
|
||||
)
|
||||
.name("Throw boost");
|
||||
folder
|
||||
.add(
|
||||
grabDebugParams,
|
||||
"holdDistance",
|
||||
GRAB_HOLD_DISTANCE_MIN,
|
||||
GRAB_HOLD_DISTANCE_MAX,
|
||||
GRAB_HOLD_DISTANCE_STEP,
|
||||
)
|
||||
.name("Hold distance");
|
||||
});
|
||||
|
||||
useFrame(() => {
|
||||
if (!rbRef.current) return;
|
||||
|
||||
const fistHand = handControlled
|
||||
? hands.find((hand) => hand.isFist)
|
||||
: undefined;
|
||||
|
||||
const t = rbRef.current.translation();
|
||||
_currentPos.set(t.x, t.y, t.z);
|
||||
onPositionChange?.(_currentPos);
|
||||
|
||||
if (fistHand) {
|
||||
const handCenter = getHandCenterPoint(fistHand);
|
||||
|
||||
_handNdc.set((1 - handCenter.x) * 2 - 1, -handCenter.y * 2 + 1, 0.5);
|
||||
_handNdc.unproject(camera);
|
||||
camera.getWorldPosition(_cameraPos);
|
||||
_handDirection.subVectors(_handNdc, _cameraPos).normalize();
|
||||
|
||||
if (!isHandHolding.current) {
|
||||
_objectPos.copy(_currentPos);
|
||||
|
||||
const isObjectInRange =
|
||||
_cameraPos.distanceTo(_objectPos) <= INTERACTION_RADIUS;
|
||||
const hit = isObjectInRange
|
||||
? getHandHit(groupRef.current, camera, _cameraPos, handCenter)
|
||||
: null;
|
||||
|
||||
isHandHolding.current = Boolean(hit);
|
||||
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
|
||||
}
|
||||
} else {
|
||||
if (isHandHolding.current) {
|
||||
snapToNearestTarget();
|
||||
}
|
||||
isHandHolding.current = false;
|
||||
InteractionManager.getInstance().setHandHolding(false);
|
||||
}
|
||||
|
||||
if (!isHolding.current && !isHandHolding.current) return;
|
||||
|
||||
if (fistHand && isHandHolding.current) {
|
||||
_holdTarget
|
||||
.copy(_cameraPos)
|
||||
.addScaledVector(_handDirection, grabDebugParams.holdDistance);
|
||||
} else {
|
||||
camera.getWorldDirection(_holdTarget);
|
||||
_holdTarget
|
||||
.multiplyScalar(grabDebugParams.holdDistance)
|
||||
.add(camera.position);
|
||||
}
|
||||
|
||||
_velocity
|
||||
.subVectors(_holdTarget, _currentPos)
|
||||
.multiplyScalar(grabDebugParams.stiffness);
|
||||
|
||||
rbRef.current.setLinvel(
|
||||
{ x: _velocity.x, y: _velocity.y, z: _velocity.z },
|
||||
true,
|
||||
);
|
||||
rbRef.current.setAngvel(ZERO_ANGULAR_VELOCITY, true);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={spaceRef}>
|
||||
<RigidBody
|
||||
ref={rbRef}
|
||||
type="dynamic"
|
||||
colliders={colliders}
|
||||
position={position}
|
||||
>
|
||||
<group ref={groupRef}>
|
||||
<InteractableObject
|
||||
kind="grab"
|
||||
label={label}
|
||||
position={position}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
isHolding.current = true;
|
||||
}}
|
||||
onRelease={() => {
|
||||
isHolding.current = false;
|
||||
snapToNearestTarget();
|
||||
if (
|
||||
!rbRef.current ||
|
||||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
|
||||
)
|
||||
return;
|
||||
const v = rbRef.current.linvel();
|
||||
rbRef.current.setLinvel(
|
||||
{
|
||||
x: v.x * grabDebugParams.throwBoost,
|
||||
y: v.y * grabDebugParams.throwBoost,
|
||||
z: v.z * grabDebugParams.throwBoost,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InteractableObject>
|
||||
</group>
|
||||
</RigidBody>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
+91
-30
@@ -8,17 +8,18 @@ import {
|
||||
INTERACTION_DEBUG_SPHERE_COLOR,
|
||||
INTERACTION_DEBUG_SPHERE_OPACITY,
|
||||
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||
} from "@/data/debugConfig";
|
||||
} from "@/data/debug/debugConfig";
|
||||
import { Debug } from "@/utils/debug/Debug";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { InteractionManager } from "@/stateManager/InteractionManager";
|
||||
import { INTERACTION_RADIUS } from "@/data/interactionConfig";
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { InteractableHandle, InteractableKind } from "@/types/interaction";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
|
||||
import type { InteractableHandle } from "@/types/interaction/interaction";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface InteractableObjectBaseProps {
|
||||
label: string;
|
||||
position: Vector3Tuple;
|
||||
radius?: number;
|
||||
bodyRef?: RefObject<RapierRigidBody | null>;
|
||||
onPress: () => void;
|
||||
children: React.ReactNode;
|
||||
@@ -37,49 +38,104 @@ type InteractableObjectProps =
|
||||
| TriggerInteractableObjectProps
|
||||
| GrabInteractableObjectProps;
|
||||
|
||||
type MutableInteractableHandle = {
|
||||
kind: InteractableKind;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
onRelease?: () => void;
|
||||
};
|
||||
|
||||
const _cameraPos = new THREE.Vector3();
|
||||
const _cameraDir = new THREE.Vector3();
|
||||
const _objectPos = new THREE.Vector3();
|
||||
const _raycaster = new THREE.Raycaster();
|
||||
|
||||
function createInteractableHandle(
|
||||
props: InteractableObjectProps,
|
||||
): InteractableHandle {
|
||||
if (props.kind === "grab") {
|
||||
return {
|
||||
kind: props.kind,
|
||||
label: props.label,
|
||||
onPress: props.onPress,
|
||||
onRelease: props.onRelease,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: props.kind,
|
||||
label: props.label,
|
||||
onPress: props.onPress,
|
||||
};
|
||||
}
|
||||
|
||||
export function InteractableObject(
|
||||
props: InteractableObjectProps,
|
||||
): React.JSX.Element {
|
||||
const { kind, label, position, bodyRef, onPress, children } = props;
|
||||
const onRelease = props.kind === "grab" ? props.onRelease : undefined;
|
||||
const {
|
||||
kind,
|
||||
label,
|
||||
position,
|
||||
radius = INTERACTION_RADIUS,
|
||||
bodyRef,
|
||||
onPress,
|
||||
children,
|
||||
} = props;
|
||||
const onRelease = props.kind === "grab" ? props.onRelease : null;
|
||||
const camera = useThree((state) => state.camera);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const debugSphereRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
const handle = useRef<InteractableHandle>(
|
||||
props.kind === "grab"
|
||||
? { kind: props.kind, label, onPress, onRelease: props.onRelease }
|
||||
: { kind: props.kind, label, onPress },
|
||||
);
|
||||
const handle = useRef<InteractableHandle>(createInteractableHandle(props));
|
||||
|
||||
useEffect(() => {
|
||||
const current = handle.current as MutableInteractableHandle;
|
||||
current.kind = kind;
|
||||
current.label = label;
|
||||
current.onPress = onPress;
|
||||
const currentHandle = handle.current;
|
||||
const manager = InteractionManager.getInstance();
|
||||
|
||||
if (currentHandle.kind === kind) {
|
||||
currentHandle.label = label;
|
||||
currentHandle.onPress = onPress;
|
||||
|
||||
if (currentHandle.kind === "grab") {
|
||||
if (!onRelease) return;
|
||||
currentHandle.onRelease = onRelease;
|
||||
}
|
||||
|
||||
if (kind === "grab" && onRelease) {
|
||||
current.onRelease = onRelease;
|
||||
return;
|
||||
}
|
||||
|
||||
delete current.onRelease;
|
||||
return undefined;
|
||||
manager.setNearby(currentHandle, false);
|
||||
|
||||
if (kind === "grab") {
|
||||
if (!onRelease) return;
|
||||
handle.current = { kind, label, onPress, onRelease };
|
||||
} else {
|
||||
handle.current = { kind, label, onPress };
|
||||
}
|
||||
|
||||
if (manager.getState().focused === currentHandle) {
|
||||
manager.setFocused(handle.current);
|
||||
}
|
||||
}, [kind, label, onPress, onRelease]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentHandle = handle.current;
|
||||
|
||||
return () => {
|
||||
const manager = InteractionManager.getInstance();
|
||||
manager.setNearby(currentHandle, false);
|
||||
if (manager.getState().focused === currentHandle) {
|
||||
manager.setFocused(null);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setupInteractionDebugFolder = useCallback((folder: GUI) => {
|
||||
const debug = Debug.getInstance();
|
||||
const controls = {
|
||||
showInteractionSpheres: debug.getShowInteractionSpheres(),
|
||||
};
|
||||
|
||||
folder
|
||||
.add(controls, "showInteractionSpheres")
|
||||
.name("Interaction Spheres")
|
||||
.onChange((value: boolean) => {
|
||||
debug.setShowInteractionSpheres(value);
|
||||
});
|
||||
|
||||
folder
|
||||
.add({ radius: INTERACTION_RADIUS }, "radius")
|
||||
.name("Interaction radius")
|
||||
@@ -101,14 +157,19 @@ export function InteractableObject(
|
||||
if (bodyRef?.current) {
|
||||
const t = bodyRef.current.translation();
|
||||
_objectPos.set(t.x, t.y, t.z);
|
||||
} else if (group) {
|
||||
group.getWorldPosition(_objectPos);
|
||||
} else {
|
||||
_objectPos.set(...position);
|
||||
}
|
||||
|
||||
camera.getWorldPosition(_cameraPos);
|
||||
const dist = _cameraPos.distanceTo(_objectPos);
|
||||
const isNearby = dist <= radius;
|
||||
|
||||
if (dist > INTERACTION_RADIUS) {
|
||||
manager.setNearby(handle.current, isNearby);
|
||||
|
||||
if (!isNearby) {
|
||||
if (manager.getState().focused === handle.current) {
|
||||
manager.setFocused(null);
|
||||
}
|
||||
@@ -117,7 +178,7 @@ export function InteractableObject(
|
||||
|
||||
camera.getWorldDirection(_cameraDir);
|
||||
_raycaster.set(_cameraPos, _cameraDir);
|
||||
_raycaster.far = INTERACTION_RADIUS;
|
||||
_raycaster.far = radius;
|
||||
|
||||
const hits = group ? _raycaster.intersectObject(group, true) : [];
|
||||
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
|
||||
@@ -135,7 +196,7 @@ export function InteractableObject(
|
||||
<mesh ref={debugSphereRef} visible={false}>
|
||||
<sphereGeometry
|
||||
args={[
|
||||
INTERACTION_RADIUS,
|
||||
radius,
|
||||
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||
]}
|
||||
+36
-12
@@ -1,15 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useRef, useState } from "react";
|
||||
import { RigidBody } from "@react-three/rapier";
|
||||
import { InteractableObject } from "@/components/3d/InteractableObject";
|
||||
import type { RapierRigidBody } from "@react-three/rapier";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
|
||||
import {
|
||||
TRIGGER_DEFAULT_COLLIDERS,
|
||||
TRIGGER_DEFAULT_LABEL,
|
||||
TRIGGER_DEFAULT_SOUND_VOLUME,
|
||||
TRIGGER_DEFAULT_SPAWN_OFFSET,
|
||||
} from "@/data/triggerConfig";
|
||||
import { AudioManager } from "@/stateManager/AudioManager";
|
||||
import type { ColliderShape, Vector3Tuple } from "@/types/3d";
|
||||
} from "@/data/interaction/triggerConfig";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import type { ColliderShape, Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface SpawnedModel {
|
||||
id: number;
|
||||
@@ -21,13 +24,15 @@ interface TriggerObjectProps {
|
||||
children: React.ReactNode;
|
||||
colliders?: ColliderShape;
|
||||
label?: string;
|
||||
radius?: number;
|
||||
soundPath?: string;
|
||||
soundVolume?: number;
|
||||
spawnModel?: string;
|
||||
spawnOffset?: Vector3Tuple;
|
||||
onTrigger?: () => void;
|
||||
}
|
||||
|
||||
let _spawnCounter = 0;
|
||||
let spawnCounter = 0;
|
||||
|
||||
function SpawnedModelInstance({
|
||||
path,
|
||||
@@ -36,8 +41,13 @@ function SpawnedModelInstance({
|
||||
path: string;
|
||||
position: Vector3Tuple;
|
||||
}): React.JSX.Element {
|
||||
const { scene } = useGLTF(path);
|
||||
return <primitive object={scene.clone()} position={position} />;
|
||||
const { scene } = useLoggedGLTF(path, {
|
||||
scope: "TriggerObject.SpawnedModel",
|
||||
position,
|
||||
});
|
||||
const model = useClonedObject(scene);
|
||||
|
||||
return <primitive object={model} position={position} />;
|
||||
}
|
||||
|
||||
export function TriggerObject({
|
||||
@@ -45,25 +55,39 @@ export function TriggerObject({
|
||||
children,
|
||||
colliders = TRIGGER_DEFAULT_COLLIDERS,
|
||||
label = TRIGGER_DEFAULT_LABEL,
|
||||
radius = INTERACTION_RADIUS,
|
||||
soundPath,
|
||||
soundVolume = TRIGGER_DEFAULT_SOUND_VOLUME,
|
||||
spawnModel,
|
||||
spawnOffset = TRIGGER_DEFAULT_SPAWN_OFFSET,
|
||||
onTrigger,
|
||||
}: TriggerObjectProps): React.JSX.Element {
|
||||
const [spawned, setSpawned] = useState<SpawnedModel[]>([]);
|
||||
const rbRef = useRef<RapierRigidBody>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RigidBody type="fixed" colliders={colliders} position={position}>
|
||||
<RigidBody
|
||||
ref={rbRef}
|
||||
type="fixed"
|
||||
colliders={colliders}
|
||||
position={position}
|
||||
>
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={label}
|
||||
position={position}
|
||||
radius={radius}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
if (soundPath) {
|
||||
AudioManager.getInstance().playSound(soundPath, soundVolume);
|
||||
AudioManager.getInstance().playSound(soundPath, soundVolume, {
|
||||
category: "sfx",
|
||||
});
|
||||
}
|
||||
|
||||
onTrigger?.();
|
||||
|
||||
if (spawnModel) {
|
||||
const spawnPos: Vector3Tuple = [
|
||||
position[0] + spawnOffset[0],
|
||||
@@ -72,7 +96,7 @@ export function TriggerObject({
|
||||
];
|
||||
setSpawned((prev) => [
|
||||
...prev,
|
||||
{ id: ++_spawnCounter, position: spawnPos },
|
||||
{ id: ++spawnCounter, position: spawnPos },
|
||||
]);
|
||||
}
|
||||
}}
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
import { InteractableObject } from "@/components/3d/InteractableObject";
|
||||
import { useGameStore } from "@/stores/gameStore";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
|
||||
import { Debug } from "@/utils/debug/Debug";
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface VillageoisHelperObjectProps {
|
||||
position: Vector3Tuple;
|
||||
@@ -10,8 +10,8 @@ interface VillageoisHelperObjectProps {
|
||||
export function VillageoisHelperObject({
|
||||
position,
|
||||
}: VillageoisHelperObjectProps): React.JSX.Element {
|
||||
const step = useGameStore((state) => state.step);
|
||||
const setStep = useGameStore((state) => state.setStep);
|
||||
const step = useMissionFlowStore((state) => state.step);
|
||||
const setStep = useMissionFlowStore((state) => state.setStep);
|
||||
const debug = Debug.getInstance();
|
||||
|
||||
const handlePress = (): void => {
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAnimations } from "@react-three/drei";
|
||||
import type { AnimationAction } from "three";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
AnimatedModelContext,
|
||||
type AnimatedModelContextValue,
|
||||
} from "@/components/three/models/useAnimatedModel";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface AnimatedModelConfig extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
animations?: string[];
|
||||
defaultAnimation?: string;
|
||||
fadeDuration?: number;
|
||||
speed?: number;
|
||||
autoPlay?: boolean;
|
||||
onLoaded?: () => void;
|
||||
onAnimationEnd?: (animationName: string) => void;
|
||||
}
|
||||
|
||||
interface AnimatedModelProps extends AnimatedModelConfig {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AnimatedModel({
|
||||
modelPath,
|
||||
defaultAnimation = "Idle",
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
fadeDuration = 0.3,
|
||||
speed = 1,
|
||||
autoPlay = true,
|
||||
onLoaded,
|
||||
onAnimationEnd,
|
||||
children,
|
||||
}: AnimatedModelProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { scene, animations } = useLoggedGLTF(modelPath, {
|
||||
scope: "AnimatedModel",
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
});
|
||||
const model = useMemo(() => scene.clone(true), [scene]);
|
||||
const { actions, names, mixer } = useAnimations(animations, groupRef);
|
||||
|
||||
const [currentAnim, setCurrentAnim] = useState(defaultAnimation);
|
||||
const isReady = names.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
Object.values(actions).forEach((action) => {
|
||||
action?.setEffectiveTimeScale(speed);
|
||||
});
|
||||
}, [actions, speed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFinished = (e: { action: AnimationAction }) => {
|
||||
const clipName = e.action.getClip().name;
|
||||
onAnimationEnd?.(clipName);
|
||||
};
|
||||
|
||||
if (mixer) {
|
||||
mixer.addEventListener("finished", handleFinished);
|
||||
return () => {
|
||||
mixer.removeEventListener("finished", handleFinished);
|
||||
};
|
||||
}
|
||||
}, [mixer, onAnimationEnd]);
|
||||
|
||||
const play = useCallback(
|
||||
(name: string, fade = fadeDuration) => {
|
||||
const action = actions[name];
|
||||
if (action) {
|
||||
Object.values(actions).forEach((a) => {
|
||||
if (a && a !== action) a.fadeOut(fade);
|
||||
});
|
||||
action.reset().fadeIn(fade).play();
|
||||
setCurrentAnim(name);
|
||||
}
|
||||
},
|
||||
[actions, fadeDuration],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
(fade = fadeDuration) => {
|
||||
Object.values(actions).forEach((a) => a?.fadeOut(fade));
|
||||
const defaultAction = actions[defaultAnimation];
|
||||
if (defaultAction) {
|
||||
defaultAction.reset().fadeIn(fade).play();
|
||||
setCurrentAnim(defaultAnimation);
|
||||
}
|
||||
},
|
||||
[actions, defaultAnimation, fadeDuration],
|
||||
);
|
||||
|
||||
const fadeTo = useCallback(
|
||||
(name: string, fade = fadeDuration) => {
|
||||
const action = actions[name];
|
||||
if (action) {
|
||||
Object.values(actions).forEach((a) => {
|
||||
if (a && a !== action) a.fadeOut(fade);
|
||||
});
|
||||
action.reset().fadeIn(fade).play();
|
||||
setCurrentAnim(name);
|
||||
}
|
||||
},
|
||||
[actions, fadeDuration],
|
||||
);
|
||||
|
||||
const setSpeed = useCallback(
|
||||
(newSpeed: number) => {
|
||||
Object.values(actions).forEach((action) => {
|
||||
action?.setEffectiveTimeScale(newSpeed);
|
||||
});
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoPlay || names.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let defaultAction = actions[defaultAnimation as string];
|
||||
|
||||
if (!defaultAction && names.length > 0) {
|
||||
defaultAction = actions[names[0] as string];
|
||||
}
|
||||
|
||||
if (defaultAction) {
|
||||
defaultAction.play();
|
||||
onLoaded?.();
|
||||
}
|
||||
}, [actions, defaultAnimation, names, autoPlay, onLoaded]);
|
||||
|
||||
const contextValue: AnimatedModelContextValue = {
|
||||
play,
|
||||
stop,
|
||||
fadeTo,
|
||||
currentAnimation: currentAnim,
|
||||
isReady,
|
||||
setSpeed,
|
||||
names,
|
||||
};
|
||||
|
||||
const parsedScale =
|
||||
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
|
||||
|
||||
return (
|
||||
<AnimatedModelContext.Provider value={contextValue}>
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={parsedScale}
|
||||
>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
{children}
|
||||
</AnimatedModelContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component, useEffect, useMemo } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { ExplodedModel } from "@/utils/three/ExplodedModel";
|
||||
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
interface ModelErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
modelPath: string;
|
||||
position?: Vector3Tuple | undefined;
|
||||
rotation?: Vector3Tuple | undefined;
|
||||
scale?: ModelTransformProps["scale"] | undefined;
|
||||
}
|
||||
|
||||
interface ModelErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
class ModelErrorBoundary extends Component<
|
||||
ModelErrorBoundaryProps,
|
||||
ModelErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ModelErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): ModelErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
logModelLoadError(
|
||||
{
|
||||
modelPath: this.props.modelPath,
|
||||
scope: "ExplodableModel",
|
||||
position: this.props.position,
|
||||
rotation: this.props.rotation,
|
||||
scale: this.props.scale,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<MissingModelFallback
|
||||
position={this.props.position}
|
||||
rotation={this.props.rotation}
|
||||
scale={this.props.scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface ExplodableModelInnerProps extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
split: boolean;
|
||||
splitDistance?: number;
|
||||
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
|
||||
}
|
||||
|
||||
export function ExplodableModel(
|
||||
props: ExplodableModelInnerProps,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<ModelErrorBoundary
|
||||
key={props.modelPath}
|
||||
modelPath={props.modelPath}
|
||||
position={props.position}
|
||||
rotation={props.rotation}
|
||||
scale={props.scale}
|
||||
>
|
||||
<ExplodableModelInner {...props} />
|
||||
</ModelErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function ExplodableModelInner({
|
||||
modelPath,
|
||||
split,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
splitDistance = 1.2,
|
||||
onPartsReady,
|
||||
}: ExplodableModelInnerProps): React.JSX.Element {
|
||||
const { scene } = useLoggedGLTF(modelPath, {
|
||||
scope: "ExplodableModel",
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
});
|
||||
const model = useClonedObject(scene);
|
||||
const explodedModel = useMemo(
|
||||
() => new ExplodedModel(model, { distance: splitDistance }),
|
||||
[model, splitDistance],
|
||||
);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
|
||||
useEffect(() => {
|
||||
explodedModel.setSplit(split);
|
||||
}, [explodedModel, split]);
|
||||
|
||||
useEffect(() => {
|
||||
onPartsReady?.(explodedModel.getParts());
|
||||
}, [explodedModel, onPartsReady]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
explodedModel.update(delta);
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function MissingModelFallback({
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: {
|
||||
position?: Vector3Tuple | undefined;
|
||||
rotation?: Vector3Tuple | undefined;
|
||||
scale?: ModelTransformProps["scale"] | undefined;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<mesh position={position} rotation={rotation} scale={toVector3Scale(scale)}>
|
||||
<boxGeometry args={[0.7, 0.7, 0.7]} />
|
||||
<meshStandardMaterial color="#7f1d1d" wireframe />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMemo } from "react";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface SimpleModelConfig extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
}
|
||||
|
||||
interface SimpleModelProps extends SimpleModelConfig {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SimpleModel({
|
||||
modelPath,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
castShadow = true,
|
||||
receiveShadow = true,
|
||||
children,
|
||||
}: SimpleModelProps): React.JSX.Element {
|
||||
const { scene } = useLoggedGLTF(modelPath, {
|
||||
scope: "SimpleModel",
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
});
|
||||
const model = useMemo(() => scene.clone(true), [scene]);
|
||||
|
||||
const parsedScale =
|
||||
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
||||
{children ?? (
|
||||
<primitive
|
||||
object={model}
|
||||
castShadow={castShadow}
|
||||
receiveShadow={receiveShadow}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface AnimatedModelContextValue {
|
||||
play: (name: string, fade?: number) => void;
|
||||
stop: (fade?: number) => void;
|
||||
fadeTo: (name: string, fade?: number) => void;
|
||||
currentAnimation: string;
|
||||
isReady: boolean;
|
||||
setSpeed: (speed: number) => void;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export const AnimatedModelContext =
|
||||
createContext<AnimatedModelContextValue | null>(null);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface WorldVideoPromptProps {
|
||||
src: string;
|
||||
position?: Vector3Tuple;
|
||||
size?: number;
|
||||
billboard?: boolean;
|
||||
}
|
||||
|
||||
export function WorldVideoPrompt({
|
||||
src,
|
||||
position = [0, 0, 0],
|
||||
size = 96,
|
||||
billboard = true,
|
||||
}: WorldVideoPromptProps): React.JSX.Element {
|
||||
return (
|
||||
<Html
|
||||
position={position}
|
||||
center
|
||||
transform
|
||||
sprite={billboard}
|
||||
occlude={false}
|
||||
>
|
||||
<video
|
||||
aria-hidden="true"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
src={src}
|
||||
style={{
|
||||
display: "block",
|
||||
height: size,
|
||||
objectFit: "contain",
|
||||
pointerEvents: "none",
|
||||
width: size,
|
||||
}}
|
||||
/>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
|
||||
interface SkyModelProps {
|
||||
modelPath: string;
|
||||
}
|
||||
|
||||
const SKY_MODEL_SCALE = 1;
|
||||
|
||||
export function SkyModel({ modelPath }: SkyModelProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { scene } = useLoggedGLTF(modelPath, {
|
||||
scope: "SkyModel",
|
||||
scale: SKY_MODEL_SCALE,
|
||||
});
|
||||
const model = useClonedObject(scene);
|
||||
|
||||
useFrame(() => {
|
||||
groupRef.current?.position.copy(camera.position);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} scale={SKY_MODEL_SCALE} frustumCulled={false}>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload("/models/sky/model.glb");
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useInteraction } from "@/hooks/useInteraction";
|
||||
import { useInteraction } from "@/hooks/interaction/useInteraction";
|
||||
|
||||
export function Crosshair(): React.JSX.Element | null {
|
||||
const cameraMode = useCameraMode();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import type {
|
||||
RepairRuntime,
|
||||
SubtitleLanguage,
|
||||
} from "@/managers/stores/useSettingsStore";
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function clearCookies(): void {
|
||||
document.cookie.split(";").forEach((cookie) => {
|
||||
const cookieName = cookie.split("=")[0]?.trim();
|
||||
if (!cookieName) return;
|
||||
|
||||
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
});
|
||||
}
|
||||
|
||||
interface VolumeSliderProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function VolumeSlider({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: VolumeSliderProps): React.JSX.Element {
|
||||
return (
|
||||
<label className="game-settings-menu__slider" htmlFor={id}>
|
||||
<span>
|
||||
{label}
|
||||
<strong>{formatPercent(value)}</strong>
|
||||
</span>
|
||||
<input
|
||||
id={id}
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameSettingsMenu(): React.JSX.Element | null {
|
||||
const {
|
||||
isSettingsMenuOpen,
|
||||
musicVolume,
|
||||
sfxVolume,
|
||||
dialogueVolume,
|
||||
subtitlesEnabled,
|
||||
subtitleLanguage,
|
||||
repairRuntime,
|
||||
setMusicVolume,
|
||||
setSfxVolume,
|
||||
setDialogueVolume,
|
||||
setSettingsMenuOpen,
|
||||
setSubtitlesEnabled,
|
||||
setSubtitleLanguage,
|
||||
setRepairRuntime,
|
||||
} = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!isSettingsMenuOpen) document.exitPointerLock();
|
||||
setSettingsMenuOpen(!isSettingsMenuOpen);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [isSettingsMenuOpen, setSettingsMenuOpen]);
|
||||
|
||||
if (!isSettingsMenuOpen) return null;
|
||||
|
||||
const handleQuit = (): void => {
|
||||
clearCookies();
|
||||
window.location.assign("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="game-settings-menu" role="dialog" aria-modal="true">
|
||||
<div className="game-settings-menu__panel">
|
||||
<header className="game-settings-menu__header">
|
||||
<div>
|
||||
<span>Pause</span>
|
||||
<h2>Options</h2>
|
||||
</div>
|
||||
<button
|
||||
className="game-settings-menu__close"
|
||||
type="button"
|
||||
onClick={() => setSettingsMenuOpen(false)}
|
||||
aria-label="Fermer le menu"
|
||||
>
|
||||
<X size={20} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="game-settings-menu__section"
|
||||
aria-labelledby="audio-settings-heading"
|
||||
>
|
||||
<h3 id="audio-settings-heading">Audio</h3>
|
||||
<VolumeSlider
|
||||
id="music-volume"
|
||||
label="Musique"
|
||||
value={musicVolume}
|
||||
onChange={setMusicVolume}
|
||||
/>
|
||||
<VolumeSlider
|
||||
id="sfx-volume"
|
||||
label="Sound effects"
|
||||
value={sfxVolume}
|
||||
onChange={setSfxVolume}
|
||||
/>
|
||||
<VolumeSlider
|
||||
id="dialogue-volume"
|
||||
label="Dialogue"
|
||||
value={dialogueVolume}
|
||||
onChange={setDialogueVolume}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="game-settings-menu__section"
|
||||
aria-labelledby="subtitle-settings-heading"
|
||||
>
|
||||
<h3 id="subtitle-settings-heading">Sous-titres</h3>
|
||||
<label className="game-settings-menu__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitlesEnabled}
|
||||
onChange={(event) => setSubtitlesEnabled(event.target.checked)}
|
||||
/>
|
||||
Afficher sous-titres
|
||||
</label>
|
||||
|
||||
<div
|
||||
className="game-settings-menu__choice-group"
|
||||
aria-label="Langue des sous-titres"
|
||||
>
|
||||
{(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => (
|
||||
<button
|
||||
key={language}
|
||||
type="button"
|
||||
className={subtitleLanguage === language ? "active" : undefined}
|
||||
onClick={() => setSubtitleLanguage(language)}
|
||||
aria-pressed={subtitleLanguage === language}
|
||||
>
|
||||
{language === "fr" ? "Francais" : "English"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="game-settings-menu__section"
|
||||
aria-labelledby="repair-settings-heading"
|
||||
>
|
||||
<h3 id="repair-settings-heading">Repair game</h3>
|
||||
<div className="game-settings-menu__choice-group game-settings-menu__choice-group--stacked">
|
||||
{(["js", "python"] satisfies RepairRuntime[]).map((runtime) => (
|
||||
<button
|
||||
key={runtime}
|
||||
type="button"
|
||||
className={repairRuntime === runtime ? "active" : undefined}
|
||||
onClick={() => setRepairRuntime(runtime)}
|
||||
aria-pressed={repairRuntime === runtime}
|
||||
>
|
||||
{runtime === "js"
|
||||
? "Repair game en JS (local)"
|
||||
: "Repair game en Python (server)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
className="game-settings-menu__quit"
|
||||
type="button"
|
||||
onClick={handleQuit}
|
||||
>
|
||||
Quitter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Crosshair } from "@/components/ui/Crosshair";
|
||||
import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
|
||||
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
|
||||
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
|
||||
export function GameUI(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<DebugOverlayLayout />
|
||||
<Crosshair />
|
||||
<RepairMovementLockIndicator />
|
||||
<InteractPrompt />
|
||||
<HandTrackingVisualizer />
|
||||
<Subtitles />
|
||||
<GameSettingsMenu />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { useHandTrackingGloveStatus } from "@/hooks/handTracking/useHandTrackingGloveStatus";
|
||||
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
||||
|
||||
const HAND_CONNECTIONS: Array<[number, number]> = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
[0, 5],
|
||||
[5, 6],
|
||||
[6, 7],
|
||||
[7, 8],
|
||||
[5, 9],
|
||||
[9, 10],
|
||||
[10, 11],
|
||||
[11, 12],
|
||||
[9, 13],
|
||||
[13, 14],
|
||||
[14, 15],
|
||||
[15, 16],
|
||||
[13, 17],
|
||||
[17, 18],
|
||||
[18, 19],
|
||||
[19, 20],
|
||||
[0, 17],
|
||||
];
|
||||
|
||||
export function HandTrackingVisualizer(): React.JSX.Element | null {
|
||||
const { hands, status } = useHandTrackingSnapshot();
|
||||
const showHandTrackingSvg = useDebugStore((debug) =>
|
||||
debug.getShowHandTrackingSvg(),
|
||||
);
|
||||
const gloves = useHandTrackingGloveStatus((state) => state.gloves);
|
||||
const hasLoadedGlove = Object.values(gloves).some(
|
||||
(gloveStatus) => gloveStatus === "loaded",
|
||||
);
|
||||
|
||||
if (
|
||||
status === "idle" ||
|
||||
hands.length === 0 ||
|
||||
(hasLoadedGlove && !showHandTrackingSvg)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className="hand-tracking-visualizer" aria-hidden="true">
|
||||
{hands.map((hand, handIndex) => {
|
||||
const landmarks = hand.landmarks;
|
||||
if (landmarks.length === 0) return null;
|
||||
|
||||
const color = hand.isFist ? "#facc15" : "#38bdf8";
|
||||
|
||||
return (
|
||||
<g key={`${hand.handedness}-${handIndex}`}>
|
||||
{HAND_CONNECTIONS.map(([from, to]) => {
|
||||
const fromPoint = landmarks[from];
|
||||
const toPoint = landmarks[to];
|
||||
if (!fromPoint || !toPoint) return null;
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${from}-${to}`}
|
||||
x1={`${(1 - fromPoint.x) * 100}%`}
|
||||
y1={`${fromPoint.y * 100}%`}
|
||||
x2={`${(1 - toPoint.x) * 100}%`}
|
||||
y2={`${toPoint.y * 100}%`}
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{landmarks.map((landmark, landmarkIndex) => (
|
||||
<circle
|
||||
key={landmarkIndex}
|
||||
cx={`${(1 - landmark.x) * 100}%`}
|
||||
cy={`${landmark.y * 100}%`}
|
||||
r={landmarkIndex === 8 ? 5 : 3}
|
||||
fill={landmarkIndex === 8 ? "#ffffff" : color}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { INTERACT_KEY } from "@/data/keybindings";
|
||||
import { INTERACT_KEY } from "@/data/input/keybindings";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useInteraction } from "@/hooks/useInteraction";
|
||||
import { useInteraction } from "@/hooks/interaction/useInteraction";
|
||||
|
||||
export function InteractPrompt(): React.JSX.Element | null {
|
||||
const cameraMode = useCameraMode();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useGameStore } from "@/stores/gameStore";
|
||||
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
|
||||
|
||||
export function IntroUI(): React.JSX.Element | null {
|
||||
const step = useGameStore((state) => state.step);
|
||||
const setPlayerName = useGameStore((state) => state.setPlayerName);
|
||||
const setStep = useGameStore((state) => state.setStep);
|
||||
const step = useMissionFlowStore((state) => state.step);
|
||||
const setPlayerName = useMissionFlowStore((state) => state.setPlayerName);
|
||||
const setStep = useMissionFlowStore((state) => state.setStep);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
if (step !== "naming") return null;
|
||||
@@ -100,8 +100,8 @@ export function IntroUI(): React.JSX.Element | null {
|
||||
}
|
||||
|
||||
export function BienvenueDisplay(): React.JSX.Element | null {
|
||||
const step = useGameStore((state) => state.step);
|
||||
const playerName = useGameStore((state) => state.playerName);
|
||||
const step = useMissionFlowStore((state) => state.step);
|
||||
const playerName = useMissionFlowStore((state) => state.playerName);
|
||||
|
||||
if (step !== "bienvenue") return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||
|
||||
export function RepairMovementLockIndicator(): React.JSX.Element | null {
|
||||
const cameraMode = useCameraMode();
|
||||
const movementLocked = useRepairMovementLocked();
|
||||
|
||||
if (cameraMode !== "player") return null;
|
||||
if (!movementLocked) return null;
|
||||
|
||||
return (
|
||||
<div className="repair-movement-lock-indicator" aria-live="polite">
|
||||
<span
|
||||
className="repair-movement-lock-indicator__dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Déplacement verrouillé pendant la réparation</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||
|
||||
interface SceneLoadingOverlayProps {
|
||||
state: SceneLoadingState;
|
||||
}
|
||||
|
||||
export function SceneLoadingOverlay({
|
||||
state,
|
||||
}: SceneLoadingOverlayProps): React.JSX.Element | null {
|
||||
const isReady = state.status === "ready";
|
||||
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="scene-loading-overlay__content">
|
||||
<strong>{state.currentStep}</strong>
|
||||
<div className="scene-loading-overlay__track">
|
||||
<span style={{ width: `${progress}%` }} />
|
||||
<em>{progress}%</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
|
||||
|
||||
export type SubtitleSpeaker = DialogueSpeaker;
|
||||
|
||||
interface SubtitlesProps {
|
||||
speaker?: SubtitleSpeaker | null;
|
||||
text?: string | null;
|
||||
}
|
||||
|
||||
export function Subtitles({
|
||||
speaker = null,
|
||||
text = null,
|
||||
}: SubtitlesProps): React.JSX.Element | null {
|
||||
const subtitlesEnabled = useSettingsStore((state) => state.subtitlesEnabled);
|
||||
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
|
||||
const subtitleSpeaker = speaker ?? activeSubtitle?.speaker ?? null;
|
||||
const content = (text ?? activeSubtitle?.text)?.trim();
|
||||
|
||||
if (!subtitlesEnabled || !content) return null;
|
||||
|
||||
return (
|
||||
<div className="subtitles" aria-live="polite">
|
||||
<p>
|
||||
{subtitleSpeaker ? (
|
||||
<span
|
||||
className={`subtitles__speaker subtitles__speaker--${subtitleSpeaker.toLowerCase()}`}
|
||||
>
|
||||
{subtitleSpeaker}:
|
||||
</span>
|
||||
) : null}
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { GameStateDebugPanel } from "@/components/ui/debug/GameStateDebugPanel";
|
||||
import { HandTrackingDebugPanel } from "@/components/ui/debug/HandTrackingDebugPanel";
|
||||
import { useShowDebugOverlay } from "@/hooks/debug/useShowDebugOverlay";
|
||||
|
||||
export function DebugOverlayLayout(): React.JSX.Element | null {
|
||||
const showDebugOverlay = useShowDebugOverlay();
|
||||
|
||||
if (!showDebugOverlay) return null;
|
||||
|
||||
return (
|
||||
<aside className="debug-overlay-layout" aria-label="Debug overlay panels">
|
||||
<header className="debug-overlay-layout__header">
|
||||
<span className="debug-overlay-layout__kicker">Debug overlay</span>
|
||||
</header>
|
||||
|
||||
<div className="debug-overlay-layout__sections">
|
||||
<HandTrackingDebugPanel />
|
||||
<GameStateDebugPanel />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { RotateCcw, StepBack, StepForward } from "lucide-react";
|
||||
import {
|
||||
type MainGameState,
|
||||
useGameStore,
|
||||
} from "@/managers/stores/useGameStore";
|
||||
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
|
||||
|
||||
const MAIN_STATES: MainGameState[] = [
|
||||
"intro",
|
||||
"bike",
|
||||
"pylone",
|
||||
"ferme",
|
||||
"outro",
|
||||
];
|
||||
|
||||
function toPascalCase(value: string): string {
|
||||
return value
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function GameStateDebugPanel(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
|
||||
const fermeStep = useGameStore((state) => state.ferme.currentStep);
|
||||
const detail = useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "intro":
|
||||
return state.intro.hasCompleted ? "completed" : "waiting";
|
||||
case "bike":
|
||||
return state.bike.currentStep;
|
||||
case "pylone":
|
||||
return state.pylone.currentStep;
|
||||
case "ferme":
|
||||
return state.ferme.currentStep;
|
||||
case "outro":
|
||||
return state.outro.hasStarted ? "started" : "waiting";
|
||||
}
|
||||
});
|
||||
const setMainState = useGameStore((state) => state.setMainState);
|
||||
const setIntroState = useGameStore((state) => state.setIntroState);
|
||||
const setBikeState = useGameStore((state) => state.setBikeState);
|
||||
const setPyloneState = useGameStore((state) => state.setPyloneState);
|
||||
const setFermeState = useGameStore((state) => state.setFermeState);
|
||||
const setOutroState = useGameStore((state) => state.setOutroState);
|
||||
const advanceGameState = useGameStore((state) => state.advanceGameState);
|
||||
const rewindGameState = useGameStore((state) => state.rewindGameState);
|
||||
const resetGame = useGameStore((state) => state.resetGame);
|
||||
|
||||
const subStateOptions =
|
||||
mainState === "intro"
|
||||
? ["waiting", "completed"]
|
||||
: mainState === "outro"
|
||||
? ["waiting", "started"]
|
||||
: MISSION_STEPS;
|
||||
|
||||
function setSubState(nextSubState: string): void {
|
||||
if (mainState === "intro") {
|
||||
setIntroState({ hasCompleted: nextSubState === "completed" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "outro") {
|
||||
setOutroState({ hasStarted: nextSubState === "started" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMissionStep(nextSubState)) return;
|
||||
|
||||
if (mainState === "bike") {
|
||||
setBikeState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "pylone") {
|
||||
setPyloneState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "ferme") {
|
||||
setFermeState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function setDebugMainState(nextMainState: MainGameState): void {
|
||||
setMainState(nextMainState);
|
||||
|
||||
if (nextMainState === "bike" && bikeStep === "locked") {
|
||||
setBikeState({ currentStep: "waiting" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextMainState === "pylone" && pyloneStep === "locked") {
|
||||
setPyloneState({ currentStep: "waiting" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextMainState === "ferme" && fermeStep === "locked") {
|
||||
setFermeState({ currentStep: "waiting" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="game-state-debug-panel debug-overlay-section"
|
||||
aria-label="Game state debug panel"
|
||||
>
|
||||
<div className="game-state-debug-panel__header">
|
||||
<h3>Game State</h3>
|
||||
</div>
|
||||
|
||||
<div className="game-state-debug-panel__switch-group">
|
||||
<div className="game-state-debug-panel__switch-heading">
|
||||
<span>Main state</span>
|
||||
<strong>{toPascalCase(mainState)}</strong>
|
||||
</div>
|
||||
<div
|
||||
className="game-state-debug-panel__states"
|
||||
aria-label="Main states"
|
||||
role="group"
|
||||
>
|
||||
{MAIN_STATES.map((state) => (
|
||||
<button
|
||||
key={state}
|
||||
aria-pressed={state === mainState}
|
||||
className={state === mainState ? "is-active" : undefined}
|
||||
type="button"
|
||||
onClick={() => setDebugMainState(state)}
|
||||
>
|
||||
{toPascalCase(state)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="game-state-debug-panel__switch-group">
|
||||
<div className="game-state-debug-panel__switch-heading">
|
||||
<span>Sub state</span>
|
||||
<strong>{toPascalCase(detail)}</strong>
|
||||
</div>
|
||||
<div
|
||||
className="game-state-debug-panel__states"
|
||||
aria-label="Sub states"
|
||||
role="group"
|
||||
>
|
||||
{subStateOptions.map((subState) => (
|
||||
<button
|
||||
key={subState}
|
||||
aria-pressed={subState === detail}
|
||||
className={subState === detail ? "is-active" : undefined}
|
||||
type="button"
|
||||
onClick={() => setSubState(subState)}
|
||||
>
|
||||
{toPascalCase(subState)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="game-state-debug-panel__actions">
|
||||
<button type="button" onClick={rewindGameState}>
|
||||
<StepBack aria-hidden="true" size={14} />
|
||||
Previous step
|
||||
</button>
|
||||
<button type="button" onClick={advanceGameState}>
|
||||
<StepForward aria-hidden="true" size={14} />
|
||||
Next step
|
||||
</button>
|
||||
<button type="button" onClick={resetGame}>
|
||||
<RotateCcw aria-hidden="true" size={14} />
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { useHandTrackingGloveStatus } from "@/hooks/handTracking/useHandTrackingGloveStatus";
|
||||
import type { HandTrackingStatus } from "@/types/handTracking/handTracking";
|
||||
|
||||
const STATUS_LABELS: Record<HandTrackingStatus, string> = {
|
||||
idle: "Idle",
|
||||
requesting_camera: "Requesting camera",
|
||||
starting_camera: "Starting camera",
|
||||
connecting_server: "Connecting server",
|
||||
connecting: "Connecting",
|
||||
connected: "Connected",
|
||||
disconnected: "Disconnected",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
export function HandTrackingDebugPanel(): React.JSX.Element | null {
|
||||
const { hands, status, usageStatus, serverStatus, error } =
|
||||
useHandTrackingSnapshot();
|
||||
const gloves = useHandTrackingGloveStatus((state) => state.gloves);
|
||||
|
||||
if (status === "idle") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fist = hands.some((hand) => hand.isFist);
|
||||
const modelLoaded =
|
||||
[
|
||||
gloves.left === "loaded" ? "gant_l" : null,
|
||||
gloves.right === "loaded" ? "gant_r" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "none";
|
||||
const modelFallback = !Object.values(gloves).some(
|
||||
(gloveStatus) => gloveStatus === "loaded",
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="hand-tracking-debug-panel debug-overlay-section"
|
||||
aria-label="Hand tracking status"
|
||||
>
|
||||
<div className="debug-overlay-section__heading">
|
||||
<h3>Hand tracking</h3>
|
||||
<span>{STATUS_LABELS[status]}</span>
|
||||
</div>
|
||||
|
||||
<dl className="debug-overlay-metrics">
|
||||
<div>
|
||||
<dt>Usage</dt>
|
||||
<dd>{usageStatus}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Model loaded</dt>
|
||||
<dd>{modelLoaded}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>SVG fallback</dt>
|
||||
<dd>{modelFallback ? "yes" : "no"}</dd>
|
||||
</div>
|
||||
{serverStatus ? (
|
||||
<div>
|
||||
<dt>Server</dt>
|
||||
<dd>{serverStatus}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<dt>Hands</dt>
|
||||
<dd>{hands.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fist</dt>
|
||||
<dd>{fist ? "yes" : "no"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{error ? (
|
||||
<span className="hand-tracking-debug-panel__error">{error}</span>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { ZONES } from "@/data/zones";
|
||||
import { GameStepManager } from "@/stateManager/GameStepManager";
|
||||
import { useGameStore } from "@/stores/gameStore";
|
||||
import { GameStepManager } from "@/managers/GameStepManager";
|
||||
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
|
||||
import { Debug } from "@/utils/debug/Debug";
|
||||
import type { GameStep } from "@/types/game";
|
||||
|
||||
@@ -17,8 +17,9 @@ const GAME_STEPS: GameStep[] = [
|
||||
"bienvenue",
|
||||
"star-move",
|
||||
"mission2",
|
||||
"searching_problem",
|
||||
"preparation",
|
||||
"searching",
|
||||
"helped",
|
||||
"manipulation",
|
||||
"outOfFabrik",
|
||||
];
|
||||
|
||||
@@ -27,7 +28,7 @@ export function ZoneDetection(): null {
|
||||
const manager = GameStepManager.getInstance();
|
||||
const triggeredZones = useRef<Set<string>>(new Set());
|
||||
const debug = Debug.getInstance();
|
||||
const step = useGameStore((state) => state.step);
|
||||
const step = useMissionFlowStore((state) => state.step);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debug.active) return;
|
||||
@@ -44,7 +45,7 @@ export function ZoneDetection(): null {
|
||||
folder.add(playerPos, "y").name("Player Y").listen().disable();
|
||||
folder.add(playerPos, "z").name("Player Z").listen().disable();
|
||||
|
||||
const unsubStore = useGameStore.subscribe((state) => {
|
||||
const unsubStore = useMissionFlowStore.subscribe((state) => {
|
||||
gameState.step = state.step;
|
||||
folder.controllersRecursive().forEach((c) => c.updateDisplay());
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type DocsLanguage = "en" | "fr";
|
||||
|
||||
interface DocsLanguageContextValue {
|
||||
language: DocsLanguage;
|
||||
toggleLanguage: () => void;
|
||||
}
|
||||
|
||||
export const DocsLanguageContext =
|
||||
createContext<DocsLanguageContextValue | null>(null);
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
type ElementRef,
|
||||
} from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
|
||||
type OrbitControlsRef = ElementRef<typeof OrbitControls>;
|
||||
|
||||
interface FlyControllerProps {
|
||||
speed?: number;
|
||||
verticalSpeed?: number;
|
||||
onPositionChange?: (position: THREE.Vector3) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FlyControllerRef {
|
||||
controls: OrbitControlsRef | null;
|
||||
}
|
||||
|
||||
export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
|
||||
(
|
||||
{ speed = 10, verticalSpeed = 5, onPositionChange, disabled = false },
|
||||
ref,
|
||||
) => {
|
||||
const { camera: rawCamera } = useThree();
|
||||
const cameraRef = useRef(rawCamera);
|
||||
const keys = useRef<{ [key: string]: boolean }>({});
|
||||
const controlsRef = useRef<OrbitControlsRef | null>(null);
|
||||
const lastPosition = useRef(new THREE.Vector3());
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
controls: controlsRef.current,
|
||||
}));
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
keys.current[e.code] = true;
|
||||
}, []);
|
||||
|
||||
const handleKeyUp = useCallback((e: KeyboardEvent) => {
|
||||
keys.current[e.code] = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [handleKeyDown, handleKeyUp]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
// Disabled mode keeps OrbitControls active without keyboard movement.
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Supports AZERTY, QWERTY, and arrow-key movement.
|
||||
const isForward =
|
||||
keys.current["KeyW"] || keys.current["KeyZ"] || keys.current["ArrowUp"];
|
||||
const isBackward = keys.current["KeyS"] || keys.current["ArrowDown"];
|
||||
const isLeft =
|
||||
keys.current["KeyQ"] ||
|
||||
keys.current["KeyA"] ||
|
||||
keys.current["ArrowLeft"];
|
||||
const isRight = keys.current["KeyD"] || keys.current["ArrowRight"];
|
||||
|
||||
const direction = new THREE.Vector3();
|
||||
const frontVector = new THREE.Vector3(
|
||||
0,
|
||||
0,
|
||||
Number(isBackward) - Number(isForward),
|
||||
);
|
||||
const sideVector = new THREE.Vector3(
|
||||
Number(isRight) - Number(isLeft),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
direction.subVectors(frontVector, sideVector);
|
||||
if (direction.lengthSq() > 0) {
|
||||
direction.normalize().multiplyScalar(speed * delta);
|
||||
direction.applyQuaternion(cameraRef.current.quaternion);
|
||||
cameraRef.current.position.add(direction);
|
||||
}
|
||||
|
||||
if (keys.current["Space"]) {
|
||||
cameraRef.current.position.y += verticalSpeed * delta;
|
||||
}
|
||||
if (keys.current["ShiftLeft"] || keys.current["ShiftRight"]) {
|
||||
cameraRef.current.position.y -= verticalSpeed * delta;
|
||||
}
|
||||
|
||||
if (
|
||||
onPositionChange &&
|
||||
!cameraRef.current.position.equals(lastPosition.current)
|
||||
) {
|
||||
lastPosition.current.copy(cameraRef.current.position);
|
||||
onPositionChange(cameraRef.current.position);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<OrbitControls
|
||||
ref={controlsRef}
|
||||
makeDefault
|
||||
enableDamping
|
||||
dampingFactor={0.05}
|
||||
mouseButtons={{
|
||||
LEFT: THREE.MOUSE.ROTATE,
|
||||
MIDDLE: THREE.MOUSE.DOLLY,
|
||||
RIGHT: THREE.MOUSE.PAN,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FlyController.displayName = "FlyController";
|
||||
@@ -2,8 +2,6 @@ export const INTERACTION_DEBUG_SPHERE_SEGMENTS = 16;
|
||||
export const INTERACTION_DEBUG_SPHERE_COLOR = "#facc15";
|
||||
export const INTERACTION_DEBUG_SPHERE_OPACITY = 0.25;
|
||||
|
||||
export const MAP_DEBUG_BOX_HELPER_COLOR = 0x00ff88;
|
||||
|
||||
export const DEBUG_CAMERA_DAMPING_FACTOR = 0.05;
|
||||
export const DEBUG_CAMERA_MIN_DISTANCE = 100;
|
||||
export const DEBUG_CAMERA_MAX_DISTANCE = 1000;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
|
||||
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
|
||||
@@ -13,9 +13,38 @@ export const TEST_SCENE_GRABBABLE_ROUGHNESS = 0.6;
|
||||
export const TEST_SCENE_GRABBABLE_METALNESS = 0.1;
|
||||
|
||||
export const TEST_SCENE_TRIGGER_POSITION: Vector3Tuple = [3, 2, -3];
|
||||
export const TEST_SCENE_TRIGGER_SOUND_PATH = "/sounds/fa.mp3";
|
||||
export const TEST_SCENE_TRIGGER_SOUND_PATH = "/sounds/effect/fa.mp3";
|
||||
export const TEST_SCENE_TRIGGER_RADIUS = 0.4;
|
||||
export const TEST_SCENE_TRIGGER_SEGMENTS = 32;
|
||||
export const TEST_SCENE_TRIGGER_COLOR = "#3b82f6";
|
||||
export const TEST_SCENE_TRIGGER_ROUGHNESS = 0.3;
|
||||
export const TEST_SCENE_TRIGGER_METALNESS = 0.5;
|
||||
|
||||
export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65;
|
||||
export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045;
|
||||
|
||||
export const TEST_SCENE_REPAIR_ZONES = [
|
||||
{
|
||||
mission: "bike",
|
||||
label: "Bike",
|
||||
color: "#38bdf8",
|
||||
position: [-12, 0, -12],
|
||||
},
|
||||
{
|
||||
mission: "pylone",
|
||||
label: "Pylone",
|
||||
color: "#facc15",
|
||||
position: [0, 0, -12],
|
||||
},
|
||||
{
|
||||
mission: "ferme",
|
||||
label: "Farm",
|
||||
color: "#86efac",
|
||||
position: [12, 0, -12],
|
||||
},
|
||||
] as const satisfies readonly {
|
||||
mission: "bike" | "pylone" | "ferme";
|
||||
label: string;
|
||||
color: string;
|
||||
position: Vector3Tuple;
|
||||
}[];
|
||||
@@ -0,0 +1,84 @@
|
||||
interface DocSection {
|
||||
path: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
meta: string;
|
||||
}
|
||||
|
||||
interface DocGroup {
|
||||
label: string;
|
||||
sections: DocSection[];
|
||||
}
|
||||
|
||||
export const docGroups: DocGroup[] = [
|
||||
{
|
||||
label: "Technical",
|
||||
sections: [
|
||||
{
|
||||
path: "/docs",
|
||||
title: "README",
|
||||
subtitle: "Project overview",
|
||||
meta: "01",
|
||||
},
|
||||
{
|
||||
path: "/docs/architecture",
|
||||
title: "Current Architecture",
|
||||
subtitle: "Runtime structure",
|
||||
meta: "02",
|
||||
},
|
||||
{
|
||||
path: "/docs/target-architecture",
|
||||
title: "Target Architecture",
|
||||
subtitle: "Next direction",
|
||||
meta: "03",
|
||||
},
|
||||
{
|
||||
path: "/docs/technical-editor",
|
||||
title: "Editor Technical Notes",
|
||||
subtitle: "Implementation details",
|
||||
meta: "04",
|
||||
},
|
||||
{
|
||||
path: "/docs/hand-tracking",
|
||||
title: "Hand Tracking Technical Notes",
|
||||
subtitle: "Webcam interaction pipeline",
|
||||
meta: "05",
|
||||
},
|
||||
{
|
||||
path: "/docs/zustand",
|
||||
title: "Zustand Game State",
|
||||
subtitle: "Progression store",
|
||||
meta: "06",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "User",
|
||||
sections: [
|
||||
{
|
||||
path: "/docs/features",
|
||||
title: "Features",
|
||||
subtitle: "Implemented scope",
|
||||
meta: "07",
|
||||
},
|
||||
{
|
||||
path: "/docs/main-feature",
|
||||
title: "Main Feature",
|
||||
subtitle: "Repair-game prototype",
|
||||
meta: "08",
|
||||
},
|
||||
{
|
||||
path: "/docs/editor",
|
||||
title: "Editor User Guide",
|
||||
subtitle: "Editing workflow",
|
||||
meta: "09",
|
||||
},
|
||||
{
|
||||
path: "/docs/animation",
|
||||
title: "Animation & 3D Model System",
|
||||
subtitle: "Components and usage",
|
||||
meta: "010",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,697 @@
|
||||
export const readmeFr = `# La-Fabrik
|
||||
|
||||
Une expérience web 3D interactive pour La Fabrik Durable, un service low-tech de réparation et de transformation situé à Altera, une ville post-capitaliste reconstruite en 2039. Les joueurs incarnent un technicien fraîchement intégré et vivent une journée de service : réparer un vélo électrique, remettre en état un réseau d'énergie et améliorer le système d'irrigation d'une ferme verticale.
|
||||
|
||||
Construit avec React, Three.js et Vite. Fonctionne dans le navigateur, sans installation côté utilisateur.
|
||||
|
||||
## Stack technique
|
||||
|
||||
### Build et langage
|
||||
|
||||
| Package |
|
||||
| -------------------------------------------------- |
|
||||
| [TypeScript](https://www.typescriptlang.org/docs/) |
|
||||
| [React](https://react.dev/learn) |
|
||||
| [Vite](https://vite.dev/guide/) |
|
||||
| [ESLint](https://eslint.org/docs/latest/) |
|
||||
| [Prettier](https://prettier.io/docs/) |
|
||||
|
||||
### Moteur 3D
|
||||
|
||||
| Package |
|
||||
| ----------------------------------------------------------------------------------------- |
|
||||
| [Three.js](https://threejs.org/docs/) |
|
||||
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
|
||||
| [@react-three/drei](https://pmndrs.github.io/drei) |
|
||||
| [@react-three/rapier](https://rapier.rs/docs/) |
|
||||
| [GSAP](https://gsap.com/docs/v3/Installation/) |
|
||||
|
||||
### Performance et effets
|
||||
|
||||
| Package |
|
||||
| --------------------------------------------------------------------------- |
|
||||
| [r3f-perf](https://github.com/utsuboco/r3f-perf) |
|
||||
| [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) |
|
||||
|
||||
## Structure du projet
|
||||
|
||||
\`\`\`
|
||||
la-fabrik/
|
||||
├── public/
|
||||
│ ├── models/
|
||||
│ │ ├── map/ # Carte de base, chargée au démarrage
|
||||
│ │ ├── workshop/
|
||||
│ │ ├── powerGrid/
|
||||
│ │ └── farm/
|
||||
│ ├── textures/
|
||||
│ └── sounds/
|
||||
│
|
||||
└── src/
|
||||
├── world/ # Composition du monde 3D persistant
|
||||
│ ├── World.tsx # Composition de la scène active
|
||||
│ ├── GameMap.tsx # Chargement de carte et collision octree
|
||||
│ ├── Lighting.tsx # Lumières ambiante, directionnelle et ponctuelles
|
||||
│ ├── Environment.tsx # Arrière-plan et modèle de ciel
|
||||
│ ├── GameMusic.tsx # Cycle de vie de la musique de jeu
|
||||
│ ├── debug/ # Scène de test debug
|
||||
│ └── player/ # Contrôleur joueur et caméra
|
||||
│
|
||||
├── components/
|
||||
│ ├── three/ # Composants R3F par domaine
|
||||
│ └── ui/ # Overlays HTML hors Canvas
|
||||
│
|
||||
├── managers/ # Logique, état et orchestration
|
||||
├── hooks/ # Hooks React autour des managers
|
||||
├── data/ # Configuration statique
|
||||
├── shaders/ # Shaders GLSL
|
||||
└── utils/ # Utilitaires partagés et debug
|
||||
\`\`\`
|
||||
|
||||
## Démarrage
|
||||
|
||||
\`\`\`bash
|
||||
git clone https://github.com/La-Fabrik-Durable/La-Fabrik.git
|
||||
cd La-Fabrik
|
||||
npm install
|
||||
npm run dev
|
||||
\`\`\`
|
||||
|
||||
- application : \`http://localhost:5173\`
|
||||
- mode debug : \`http://localhost:5173?debug\`
|
||||
|
||||
## Licence
|
||||
|
||||
Voir le fichier [LICENSE](./LICENSE).
|
||||
`;
|
||||
|
||||
export const architectureFr = `# Architecture actuelle
|
||||
|
||||
Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
|
||||
|
||||
## Structure runtime
|
||||
|
||||
- \`src/App.tsx\` monte le \`RouterProvider\`, qui pilote l'affichage des vues de l'application.
|
||||
- \`src/pages/page.tsx\` monte le \`Canvas\`, le \`World\` 3D, l'overlay de performance debug et les overlays HTML.
|
||||
- \`src/world/World.tsx\` compose la scène active avec :
|
||||
- l'environnement et l'éclairage
|
||||
- les helpers debug et le mode caméra debug
|
||||
- soit la carte principale, soit la scène de test physique debug
|
||||
- le rig joueur quand le mode caméra actif est \`player\`
|
||||
- \`src/world/GameMap.tsx\` charge les modèles de carte disponibles et construit l'octree de collision.
|
||||
- \`src/world/GameStageContent.tsx\` est enveloppé dans le contexte Rapier \`Physics\` dans la scène de jeu de production afin que les objets gameplay de stage puissent utiliser la physique sans migrer la carte ou le joueur vers Rapier. Il monte maintenant des instances réutilisables de \`RepairGame\` pour les états de mission \`bike\`, \`pylone\` et \`ferme\`.
|
||||
- \`src/world/debug/TestMap.tsx\` fournit une carte orientée debug pour les interactions et la physique, avec les objets existants de grab, trigger et preview de modèle, plus des zones playground de réparation séparées \`Bike\`, \`Pylone\` et \`Farm\`.
|
||||
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
|
||||
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut, le verrouillage de déplacement pendant les étapes repair et les inputs d'interaction.
|
||||
|
||||
## Frontières physiques
|
||||
|
||||
Le projet utilise actuellement deux couches de collision avec des responsabilités séparées :
|
||||
|
||||
- \`GameMap\` construit une octree utilisée par le contrôleur joueur pour les collisions avec la carte.
|
||||
- \`GameStageContent\` est enveloppé dans Rapier \`Physics\` pour les objets gameplay comme les triggers de réparation, les mallettes, les objets saisissables et les futurs objets spécifiques aux missions.
|
||||
- \`TestMap\` possède son propre playground Rapier \`Physics\` afin de peaufiner le gameplay de réparation par state de mission sans dépendre du placement de la carte de production.
|
||||
|
||||
Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il n'existe pas de plan de migration volontaire. Cela évite de mélanger les règles de déplacement joueur avec la physique d'objets avant que les systèmes gameplay en aient besoin.
|
||||
|
||||
## Modèle d'interaction
|
||||
|
||||
- \`src/managers/InteractionManager.ts\` est la source d'état actuelle des interactions.
|
||||
- \`src/components/three/interaction/InteractableObject.tsx\` gère la détection de focus par distance et raycasting.
|
||||
- \`src/components/three/interaction/TriggerObject.tsx\` implémente les interactions de type trigger.
|
||||
- \`src/components/three/interaction/GrabbableObject.tsx\` implémente les interactions saisir / relâcher.
|
||||
- \`src/hooks/interaction/useInteraction.ts\` expose un snapshot d'interaction à l'UI React.
|
||||
- \`src/components/ui/InteractPrompt.tsx\` affiche le prompt \`E\` pour les interactions trigger.
|
||||
|
||||
## Audio
|
||||
|
||||
- \`src/managers/AudioManager.ts\` fournit la lecture de sons one-shot avec pool, la musique en boucle, les volumes par catégorie et un pan stéréo optionnel pour les sons one-shot.
|
||||
- Les catégories audio supportées sont \`music\`, \`sfx\` et \`dialogue\`.
|
||||
- Les interactions trigger peuvent lancer directement des SFX via \`AudioManager\`.
|
||||
|
||||
## Menu options
|
||||
|
||||
- \`src/managers/stores/useSettingsStore.ts\` stocke les réglages de volume musique, volume SFX, volume dialogue, sous-titres, langue des sous-titres, runtime de réparation et visibilité du menu.
|
||||
- \`src/components/ui/GameSettingsMenu.tsx\` rend le menu options en jeu.
|
||||
- \`src/components/ui/GameUI.tsx\` monte le menu comme overlay HTML hors canvas.
|
||||
- \`Esc\` ouvre et ferme le menu, et \`src/world/player/PlayerController.tsx\` ignore les inputs joueur pendant son ouverture.
|
||||
- Les changements de volume sont transmis à \`AudioManager\` par catégorie.
|
||||
|
||||
## Dialogues et sous-titres
|
||||
|
||||
- \`public/sounds/dialogue/dialogues.json\` est le manifeste runtime des dialogues.
|
||||
- Les fichiers audio de dialogue vivent dans \`public/sounds/dialogue/\`.
|
||||
- Les fichiers de sous-titres vivent dans \`public/sounds/dialogue/subtitles/{fr|en}/\`.
|
||||
- Le modèle actuel utilise un fichier SRT par voix et par langue.
|
||||
- \`src/types/dialogues/dialogues.ts\` contient les types du manifeste.
|
||||
- \`src/utils/dialogues/dialogueManifestValidation.ts\` valide la forme du manifeste au runtime.
|
||||
- \`src/utils/dialogues/loadDialogueManifest.ts\` charge le manifeste et les cues SRT, avec fallback français si la langue sélectionnée manque.
|
||||
- \`src/utils/subtitles/parseSrt.ts\` parse les blocs et timecodes SRT.
|
||||
- \`src/utils/dialogues/playDialogue.ts\` joue l'audio de dialogue et synchronise le sous-titre actif avec le temps de l'élément audio.
|
||||
- \`src/managers/stores/useSubtitleStore.ts\` stocke la cue de sous-titre affichée.
|
||||
- \`src/components/ui/Subtitles.tsx\` rend l'overlay de sous-titres.
|
||||
- \`src/world/GameDialogues.tsx\` déclenche actuellement les dialogues qui définissent un \`timecode\`.
|
||||
- La lecture de dialogue est mise en file pour éviter les chevauchements.
|
||||
|
||||
## Cinématiques
|
||||
|
||||
- \`public/cinematics.json\` est le manifeste runtime des cinématiques.
|
||||
- \`src/types/cinematics/cinematics.ts\` contient les types du manifeste.
|
||||
- \`src/utils/cinematics/cinematicManifestValidation.ts\` valide la forme du manifeste.
|
||||
- \`src/utils/cinematics/loadCinematicManifest.ts\` charge \`/cinematics.json\`.
|
||||
- \`src/world/GameCinematics.tsx\` déclenche les cinématiques qui définissent un \`timecode\` global.
|
||||
- Les cinématiques utilisent GSAP pour animer la position caméra et sa cible de regard.
|
||||
- Les \`dialogueCues\` d'une cinématique déclenchent des dialogues à des temps relatifs au début de la cinématique.
|
||||
- \`useGameStore.isCinematicPlaying\` sert à bloquer les inputs joueur pendant une cinématique.
|
||||
|
||||
## Système debug
|
||||
|
||||
- Le mode debug est activé avec \`?debug\`.
|
||||
- \`src/utils/debug/Debug.ts\` possède l'instance \`lil-gui\` et les contrôles debug.
|
||||
- \`src/hooks/debug/useCameraMode.ts\` et \`src/hooks/debug/useSceneMode.ts\` s'abonnent à l'état debug.
|
||||
- \`src/components/debug/DebugPerf.tsx\` monte \`r3f-perf\` en lazy uniquement en mode debug.
|
||||
- \`src/components/ui/debug/DebugOverlayLayout.tsx\` monte l'overlay HTML debug compact quand il est activé depuis \`lil-gui\`.
|
||||
- \`src/components/ui/debug/GameStateDebugPanel.tsx\` expose l'état de jeu courant, le changement de main/sub-state, les contrôles previous/next step et le reset.
|
||||
- \`src/components/ui/debug/HandTrackingDebugPanel.tsx\` affiche le statut hand tracking, l'usage, le modèle de gant chargé, le nombre de mains et l'état fist pendant l'activation du hand tracking.
|
||||
- \`src/components/three/handTracking/HandTrackingGlove.tsx\` place les modèles riggés \`gant_l\` et \`gant_r\` sur les mains détectées dans la scène physics debug.
|
||||
- \`src/components/debug/scene/DebugHelpers.tsx\` monte les helpers debug.
|
||||
- \`src/components/debug/scene/DebugCameraControls.tsx\` monte la caméra libre debug.
|
||||
- Les contrôles globaux \`lil-gui\` incluent camera mode, scene mode, \`R3F Perf\` et \`Debug Overlay\`; les contrôles d'interaction vivent dans le dossier \`Interaction\`.
|
||||
|
||||
## Domaines de composants 3D
|
||||
|
||||
- \`src/components/three/models/\` contient les helpers de modèles réutilisables comme \`ExplodableModel\`.
|
||||
- \`src/components/three/interaction/\` contient les wrappers d'interaction réutilisables comme \`InteractableObject\`, \`TriggerObject\` et \`GrabbableObject\`.
|
||||
- \`src/components/three/handTracking/\` contient les modèles debug R3F liés au hand tracking, comme les gants.
|
||||
- \`src/components/three/gameplay/\` contient les composants de gameplay de réparation : le flow de production réutilisable \`RepairGame\`, la mallette, les étapes de réparation et les prompts.
|
||||
- \`src/components/three/world/\` contient les objets world/environnement réutilisables comme \`SkyModel\`.
|
||||
|
||||
## Limites actuelles
|
||||
|
||||
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
|
||||
- \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
|
||||
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
|
||||
- L'état de mission existe dans Zustand et le flow de réparation est implémenté comme prototype pour les missions de réparation actuelles.
|
||||
- Les cinématiques et dialogues existent comme systèmes prototype pilotés par timecode; les branches de dialogue et l'orchestration gameplay globale restent limitées.
|
||||
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
|
||||
`;
|
||||
|
||||
export const targetArchitectureFr = `# Architecture cible
|
||||
|
||||
Ce document décrit l'architecture visée à moyen terme pour le projet.
|
||||
|
||||
## Relation avec le code actuel
|
||||
|
||||
- \`docs/technical/architecture.md\` reste la source de vérité de ce qui existe maintenant.
|
||||
- Ce document décrit une direction d'architecture, pas un comportement implémenté.
|
||||
- Si ce document contredit l'implémentation actuelle, l'implémentation actuelle gagne.
|
||||
|
||||
## Objectifs
|
||||
|
||||
- Garder \`App.tsx\` petit et centré sur l'orchestration.
|
||||
- Séparer le code de production du monde des chemins runtime uniquement debug.
|
||||
- Garder une source de vérité claire par responsabilité.
|
||||
- Faire grandir les systèmes gameplay progressivement, sans préconstruire une architecture vide.
|
||||
|
||||
## Couches prévues
|
||||
|
||||
### Couche App
|
||||
|
||||
- \`App.tsx\` monte la scène canvas et les overlays HTML de premier niveau.
|
||||
- Il doit rester fin et éviter la logique gameplay.
|
||||
|
||||
### Couche World
|
||||
|
||||
- \`src/world/\` doit contenir la composition de scène de production et les objets de scène de production.
|
||||
- Responsabilités attendues :
|
||||
- composition du monde
|
||||
- carte, environnement, éclairage
|
||||
- contrôleur joueur
|
||||
- ancres d'interaction de production
|
||||
- post-processing de production si nécessaire
|
||||
|
||||
### Couche Debug
|
||||
|
||||
- Les scènes et outils uniquement debug doivent être isolés du chemin de production.
|
||||
- Responsabilités attendues :
|
||||
- \`lil-gui\`
|
||||
- overlay de performance
|
||||
- helpers de scène
|
||||
- caméra libre et contrôles de calibration
|
||||
- scènes temporaires de test utilisées pendant le développement
|
||||
|
||||
### Couche UI
|
||||
|
||||
- \`src/components/ui/\` doit contenir les overlays HTML visibles par le joueur.
|
||||
- Exemples futurs :
|
||||
- crosshair
|
||||
- flow de chargement
|
||||
- HUD de mission
|
||||
- overlays narratifs
|
||||
|
||||
### Couche Gameplay
|
||||
|
||||
- À mesure que le projet grandit, l'état gameplay peut évoluer vers une couche d'orchestration plus claire.
|
||||
- Sujets probables :
|
||||
- missions
|
||||
- zones
|
||||
- cinématiques
|
||||
- dialogues
|
||||
- audio
|
||||
- interactions
|
||||
|
||||
## Règles
|
||||
|
||||
- Préférer du code direct et fonctionnel plutôt qu'un échafaudage spéculatif.
|
||||
- Les types partagés doivent rester proches de leur domaine jusqu'à avoir plusieurs vrais consommateurs.
|
||||
- Éviter de créer de nouveaux managers ou services sans besoin runtime actif.
|
||||
- Les chemins runtime uniquement debug doivent être clairement marqués et faciles à retirer plus tard.
|
||||
`;
|
||||
|
||||
export const zustandFr = `# État de jeu Zustand
|
||||
|
||||
Ce document explique comment Zustand est utilisé dans le projet actuel.
|
||||
|
||||
## Pourquoi Zustand existe ici
|
||||
|
||||
Le projet a besoin d'une source de vérité partagée pour suivre la progression du joueur dans l'expérience.
|
||||
|
||||
La progression actuelle est découpée en main states :
|
||||
|
||||
| Main state | Rôle |
|
||||
| --- | --- |
|
||||
| \`intro\` | Onboarding et séquence d'ouverture |
|
||||
| \`bike\` | Séquence de réparation du vélo électrique |
|
||||
| \`pylone\` | Séquence du réseau électrique |
|
||||
| \`ferme\` | Séquence de la ferme verticale |
|
||||
| \`outro\` | Séquence de fin |
|
||||
|
||||
Chaque main state peut aussi posséder un sous-état plus fin, comme l'étape de mission courante, l'audio de dialogue ou des flags de complétion.
|
||||
|
||||
Zustand est utile parce que les composants React et React Three Fiber peuvent s'abonner uniquement à la partie de state dont ils ont besoin. Quand cette partie change, seuls les composants abonnés se mettent à jour.
|
||||
|
||||
## Emplacement du store
|
||||
|
||||
Le store de progression du jeu vit ici :
|
||||
|
||||
\`\`\`txt
|
||||
src/managers/stores/useGameStore.ts
|
||||
\`\`\`
|
||||
|
||||
Le store est placé dans \`src/managers/stores/\` parce qu'il appartient à la couche d'orchestration gameplay, pas à un composant visuel précis.
|
||||
|
||||
## Managers vs Store
|
||||
|
||||
Les managers sont responsables des objets runtime locaux et des comportements impératifs.
|
||||
|
||||
Exemples :
|
||||
|
||||
- \`AudioManager\` possède les éléments audio et les pools de sons.
|
||||
- \`InteractionManager\` possède les handles d'interaction transitoires et la logique orientée input.
|
||||
|
||||
Un manager peut lire ou mettre à jour le store Zustand quand son comportement local doit impacter la progression globale du jeu.
|
||||
|
||||
Le store Zustand est responsable de l'état global durable :
|
||||
|
||||
- main state courant
|
||||
- sous-état de mission
|
||||
- flags de progression
|
||||
- références de dialogue/audio
|
||||
- transitions de state
|
||||
|
||||
Règle simple :
|
||||
|
||||
- manager = objets runtime, effets de bord et logique impérative locale
|
||||
- store = état gameplay global auquel l'UI ou le world peuvent s'abonner
|
||||
|
||||
## Forme actuelle
|
||||
|
||||
Le store expose :
|
||||
|
||||
- \`mainState\` : phase active du jeu
|
||||
- \`intro\` : état spécifique à l'intro
|
||||
- \`bike\` : état de la mission vélo
|
||||
- \`pylone\` : état de la mission réseau électrique
|
||||
- \`ferme\` : état de la mission ferme
|
||||
- \`outro\` : état de fin
|
||||
- des actions de mise à jour directe et des actions de progression
|
||||
|
||||
Les étapes de mission utilisent actuellement cette séquence :
|
||||
|
||||
\`\`\`ts
|
||||
"locked" | "waiting" | "inspected" | "fragmented" | "scanning" | "repairing" | "reassembling" | "done"
|
||||
\`\`\`
|
||||
|
||||
## Lire le state dans un composant
|
||||
|
||||
Utilise des selectors pour lire uniquement ce dont le composant a besoin.
|
||||
|
||||
\`\`\`tsx
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
|
||||
export function Example(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
|
||||
return <p>State courant : {mainState}</p>;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
C'est mieux que de lire tout le store, car le composant se re-render uniquement quand \`mainState\` change.
|
||||
|
||||
## Mettre à jour le state
|
||||
|
||||
Préfère les actions explicites du store.
|
||||
|
||||
\`\`\`ts
|
||||
const advanceGameState = useGameStore((state) => state.advanceGameState);
|
||||
|
||||
advanceGameState();
|
||||
\`\`\`
|
||||
|
||||
Pour le développement et le debug, des setters directs existent aussi :
|
||||
|
||||
\`\`\`ts
|
||||
const setMainState = useGameStore((state) => state.setMainState);
|
||||
|
||||
setMainState("bike");
|
||||
\`\`\`
|
||||
|
||||
Les setters directs sont pratiques pour les panneaux debug, mais le gameplay de production devrait préférer les actions métier comme \`advanceGameState\`, \`completeBike\` ou \`completePylone\`.
|
||||
|
||||
Le gameplay de mission qui peut cibler \`bike\`, \`pylone\` ou \`ferme\` doit préférer les actions génériques de mission :
|
||||
|
||||
\`\`\`ts
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
|
||||
setMissionStep("bike", "inspected");
|
||||
completeMission("bike");
|
||||
\`\`\`
|
||||
|
||||
Cela évite aux composants gameplay réutilisables, comme les flows de réparation, de dupliquer des branches spécifiques à chaque mission avec \`setBikeState\`, \`setPyloneState\` et \`setFermeState\`.
|
||||
|
||||
## Intégration avec le World
|
||||
|
||||
\`src/world/GameStageContent.tsx\` s'abonne à \`mainState\` et monte le contenu spécifique au state courant.
|
||||
|
||||
Pour les missions de réparation, il monte le composant réutilisable \`RepairGame\` avec un id de mission :
|
||||
|
||||
\`\`\`tsx
|
||||
<RepairGame mission="bike" position={[8, 0, -6]} />
|
||||
\`\`\`
|
||||
|
||||
\`RepairGame\` lit l'étape de mission active depuis le store et écrit les transitions via des actions génériques comme \`setMissionStep\` et \`completeMission\`. Les ids de mission, étapes de mission et guards partagés vivent dans \`src/types/gameplay/repairMission.ts\`, ce qui évite à la configuration statique des missions de dépendre du store Zustand. Le flow de réparation de production supporte actuellement les transitions \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`.
|
||||
|
||||
La scène peut donc évoluer progressivement vers ce pattern :
|
||||
|
||||
\`\`\`tsx
|
||||
switch (mainState) {
|
||||
case "intro":
|
||||
return <IntroContent />;
|
||||
case "bike":
|
||||
return <BikeContent />;
|
||||
case "pylone":
|
||||
return <PyloneContent />;
|
||||
case "ferme":
|
||||
return <FarmContent />;
|
||||
case "outro":
|
||||
return <OutroContent />;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Dans React Three Fiber, monter ou démonter du JSX contrôle ce qui apparaît dans la scène Three.js. Quand un composant lié à un state disparaît du JSX, React le retire de la scène.
|
||||
|
||||
## Intégration UI
|
||||
|
||||
\`src/components/ui/GameUI.tsx\` regroupe les overlays HTML utilisés par la route jouable.
|
||||
|
||||
Overlays actuels :
|
||||
|
||||
- \`DebugOverlayLayout\` : layout compact des panels debug HTML visible avec \`?debug\`
|
||||
- \`GameStateDebugPanel\` : panneau de progression debug pour consulter/changer le main state, le sub state, avancer/reculer et reset le store
|
||||
- \`Crosshair\` : aide de visée joueur
|
||||
- \`InteractPrompt\` : prompt d'interaction
|
||||
- \`RepairMovementLockIndicator\` : indicateur joueur affiché quand les étapes repair désactivent temporairement le déplacement
|
||||
|
||||
\`src/pages/page.tsx\` doit rester fin et monter seulement le canvas et \`GameUI\`.
|
||||
|
||||
## Règles anti-régression
|
||||
|
||||
- Ne pas stocker les valeurs mises à jour à chaque frame dans Zustand.
|
||||
- Utiliser \`useRef\` pour les valeurs mutables fréquentes comme la vélocité joueur, les vecteurs temporaires ou les données de boucle d'animation.
|
||||
- Utiliser des selectors au lieu de lire tout le store dans les composants.
|
||||
- Garder les transitions gameplay dans les actions du store quand possible.
|
||||
- Garder les contrôles debug derrière \`?debug\`.
|
||||
- Ajouter du state uniquement quand une vraie fonctionnalité runtime en a besoin.
|
||||
|
||||
## Prochaines étapes
|
||||
|
||||
Déplacer la validation de réparation dans les données de mission lorsque chaque mission aura ses propres nodes de modules cassés, assets de remplacement et événements de complétion.
|
||||
`;
|
||||
|
||||
export const featuresFr = `# Fonctionnalités implémentées
|
||||
|
||||
Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||
|
||||
## Scène
|
||||
|
||||
- Scène React Three Fiber plein écran
|
||||
- Carte principale chargée depuis \`public/models/{name}/model.glb\`, avec fallback vers \`model.gltf\`
|
||||
- Scène de test physique debug sélectionnable depuis le panneau debug, avec tests grab/trigger, preview de modèle animé et zones playground de réparation séparées pour \`bike\`, \`pylone\` et \`ferme\`
|
||||
- Contexte physique Rapier disponible pour les objets gameplay de stage en production
|
||||
- Éclairage ambiant et directionnel
|
||||
- Configuration de l'environnement de fond
|
||||
|
||||
## Joueur
|
||||
|
||||
- Mode caméra joueur
|
||||
- Orientation souris avec pointer lock
|
||||
- Déplacement avec \`ZQSD\`
|
||||
- Saut
|
||||
- Verrouillage du déplacement pendant les étapes repair actives, avec indicateur à l'écran tout en gardant les interactions trigger disponibles
|
||||
- Collision basée sur une octree contre la carte chargée
|
||||
|
||||
## Interactions
|
||||
|
||||
- Détection de focus par distance et raycast
|
||||
- Interactions trigger activées avec \`E\`
|
||||
- Interactions grab activées avec le bouton principal de la souris
|
||||
- Les objets gameplay avec physique peuvent être montés dans le contenu de stage sans remplacer la collision octree du joueur
|
||||
- Prompt d'interaction affiché pour les interactions trigger
|
||||
|
||||
## Gameplay de réparation
|
||||
|
||||
- \`RepairGame\` de production réutilisable monté pour les états de mission \`bike\`, \`pylone\` et \`ferme\`
|
||||
- Le playground physics debug monte le même \`RepairGame\` réutilisable dans des zones \`Bike\`, \`Pylone\` et \`Farm\`, afin de peaufiner chaque state avec un placement isolé avant déplacement vers la carte de production
|
||||
- Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`, avec nodes cassés, placeholders cibles, timing de scan et timing de réassemblage propres à chaque mission
|
||||
- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, vue focalisée de la mallette, indicateur de verrouillage de déplacement pendant la réparation active, interaction trigger sur la mallette, traverse des placeholders de mallette, placement avec snap vers placeholder, feedback de dépôt des pièces cassées, touche \`E\`, hold deux poings, transition de modèle explosé, réassemblage inverse avec particules, scan visuel par pièce, marqueur rouge persistant et vidéo UI centrée sur les pièces cassées, plusieurs choix de pièces grabbables, feedback de validation de la bonne pièce et complétion de mission
|
||||
|
||||
## Audio
|
||||
|
||||
- Volumes par catégorie pour la musique, les SFX et les dialogues
|
||||
- Lecture de musique en boucle via \`AudioManager\`
|
||||
- Lecture de sons one-shot pour les SFX et les dialogues, avec pool simple par son
|
||||
- Pan stéréo optionnel pour les sons one-shot
|
||||
|
||||
## Dialogues et sous-titres
|
||||
|
||||
- Manifeste de dialogues dans \`public/sounds/dialogue/dialogues.json\`
|
||||
- Audios de dialogue chargés depuis \`public/sounds/dialogue/\`
|
||||
- Un fichier SRT par voix et par langue
|
||||
- Fallback vers les sous-titres français quand le fichier de langue sélectionné manque
|
||||
- Overlay de sous-titres runtime avec couleurs par speaker
|
||||
- Déclenchement timecodé pour les dialogues qui définissent \`timecode\`
|
||||
- File d'attente pour éviter les dialogues superposés
|
||||
|
||||
## Cinématiques
|
||||
|
||||
- Manifeste de cinématiques dans \`public/cinematics.json\`
|
||||
- Déclenchement timecodé des cinématiques
|
||||
- Lecture de keyframes caméra via GSAP
|
||||
- Dialogue cues optionnelles synchronisées avec les timelines de cinématique
|
||||
- Blocage des inputs joueur pendant une cinématique
|
||||
|
||||
## Menu options
|
||||
|
||||
- \`Esc\` ouvre et ferme le menu options en jeu
|
||||
- Sliders de volume musique, SFX et dialogue
|
||||
- Toggle d'affichage des sous-titres
|
||||
- Choix de langue des sous-titres entre français et anglais
|
||||
- Choix du runtime de réparation entre JavaScript local et serveur Python
|
||||
- Action quitter qui nettoie les cookies accessibles au navigateur et retourne vers \`/\`
|
||||
|
||||
## Outils debug
|
||||
|
||||
- Le paramètre \`?debug\` active le panneau debug
|
||||
- Contrôles \`lil-gui\` pour le mode caméra, le mode scène, \`R3F Perf\`, \`Debug Overlay\` et le tuning d'interaction
|
||||
- Overlay debug compact pour les contrôles de game state et le statut hand tracking
|
||||
- Le changement de mission dans le panneau game-state debug déverrouille les missions repair encore \`locked\` à \`waiting\` pour accélérer les tests
|
||||
- Helpers de scène debug
|
||||
- Caméra libre debug
|
||||
- Overlay \`r3f-perf\`
|
||||
|
||||
## Éditeur de carte
|
||||
|
||||
- Route \`/editor\` pour inspecter et éditer \`public/map.json\`
|
||||
- Chargement automatique de \`public/map.json\` quand il existe
|
||||
- Rendu des modèles disponibles depuis \`public/models/{name}/model.glb\` ou \`model.gltf\`
|
||||
- Cubes de fallback pour les nodes dont le modèle manque
|
||||
- Sélection d'objet au clic
|
||||
- Modes de transformation translation, rotation et scale
|
||||
- Export JSON pour télécharger la carte modifiée
|
||||
- Endpoint de sauvegarde dev-server pour écrire \`public/map.json\`
|
||||
- Éditeur SRT pour les sous-titres de dialogue
|
||||
- Preview audio et outils de timing pour les cues SRT
|
||||
- Endpoint de sauvegarde dev-server pour les fichiers SRT
|
||||
- Validation du manifeste de dialogues depuis l'UI de l'éditeur
|
||||
- Éditeur de manifeste dialogues avec preview et création assistée de cue SRT FR
|
||||
- Éditeur de manifeste cinématiques avec keyframes caméra, dialogue cues et preview canvas
|
||||
|
||||
## Pas encore implémenté
|
||||
|
||||
- système de missions complet
|
||||
- système de zones
|
||||
- branches de dialogues gameplay au-delà des déclencheurs prototype actuels
|
||||
- flow de chargement
|
||||
- minimap et HUD de mission
|
||||
- séparation complète production / debug pour les scènes gameplay
|
||||
`;
|
||||
|
||||
export const editorFr = `# Éditeur de carte
|
||||
|
||||
L'éditeur de carte est disponible sur "/editor". Il permet d'inspecter et d'ajuster les objets déclarés dans "/public/map.json" directement depuis le navigateur.
|
||||
|
||||
## Ce qui est édité
|
||||
|
||||
L'éditeur travaille sur la liste de nodes stockée dans "/public/map.json".
|
||||
|
||||
Chaque node décrit un objet de la scène :
|
||||
|
||||
- "name" : nom du dossier modèle dans "/public/models/{name}/model.glb", avec fallback vers "model.gltf"
|
||||
- "type" : catégorie de l'objet
|
||||
- "position" : "[x, y, z]"
|
||||
- "rotation" : "[x, y, z]"
|
||||
- "scale" : "[x, y, z]"
|
||||
|
||||
Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'éditeur affiche un cube gris de remplacement pour que le node reste sélectionnable et déplaçable.
|
||||
|
||||
## Workflow de base
|
||||
|
||||
1. Ouvrir "/editor".
|
||||
2. Sélectionner un objet dans la vue 3D.
|
||||
3. Choisir un mode de transformation : translation, rotation ou scale.
|
||||
4. Déplacer la gizmo de transformation.
|
||||
5. Utiliser undo ou redo si nécessaire.
|
||||
6. Exporter le JSON mis à jour ou le sauvegarder sur le serveur de dev.
|
||||
|
||||
## Contrôles
|
||||
|
||||
| Action | Input |
|
||||
| --- | --- |
|
||||
| Sélectionner un objet | Clic sur l'objet |
|
||||
| Désélectionner | "Esc" ou clic dans le vide |
|
||||
| Mode translation | "T" |
|
||||
| Mode rotation | "R" |
|
||||
| Mode scale | "S" |
|
||||
| Undo | "Ctrl+Z" |
|
||||
| Redo | "Ctrl+Y" |
|
||||
| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches |
|
||||
| Monter / descendre | "Space", "Shift" |
|
||||
|
||||
## Actions fichier
|
||||
|
||||
### Export JSON
|
||||
|
||||
"Export JSON" télécharge la liste actuelle des nodes sous le nom "map.json". À utiliser pour remplacer manuellement "/public/map.json".
|
||||
|
||||
### Save to server
|
||||
|
||||
"Save to server" est disponible uniquement en développement local. L'action écrit la carte modifiée dans "/public/map.json" via l'endpoint du serveur de dev Vite.
|
||||
|
||||
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
|
||||
|
||||
## Éditer les dialogues et sous-titres
|
||||
|
||||
Le panneau latéral contient aussi des outils pour les dialogues et les sous-titres.
|
||||
|
||||
### Manifeste dialogues
|
||||
|
||||
Le panneau \`Dialogues\` permet d'éditer \`public/sounds/dialogue/dialogues.json\` sans ouvrir le JSON à la main.
|
||||
|
||||
- \`Reload\` recharge le manifeste depuis le disque.
|
||||
- \`Add\` crée un dialogue local pour la voix courante et assigne le prochain index SRT disponible.
|
||||
- \`Save\` écrit le manifeste via le serveur Vite local.
|
||||
- \`Preview dialogue\` joue le dialogue sélectionné avec les sous-titres dans l'éditeur.
|
||||
- \`Create FR SRT cue\` crée la cue française si elle manque.
|
||||
- \`Delete dialogue\` supprime localement l'entrée sélectionnée.
|
||||
|
||||
Après \`Add\`, il faut cliquer \`Save\` pour conserver le dialogue dans le manifeste. La cue SRT FR est écrite directement, mais le manifeste reste local tant qu'il n'est pas sauvegardé.
|
||||
|
||||
Les nouveaux dialogues utilisent un chemin audio placeholder comme \`/sounds/dialogue/new_dialogue_24.mp3\`. Remplace-le par un vrai MP3 avant validation finale.
|
||||
|
||||
### Éditeur SRT
|
||||
|
||||
1. Choisir une voix : \`narrateur\`, \`fermier\` ou \`electricienne\`.
|
||||
2. Choisir une langue : \`FR\` ou \`EN\`.
|
||||
3. Modifier le texte SRT directement dans la textarea.
|
||||
4. Utiliser la preview audio pour vérifier le dialogue sélectionné.
|
||||
5. Utiliser \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster le timing de la cue sélectionnée avec l'audio.
|
||||
6. Utiliser \`Save SRT\` en développement local, ou \`Export SRT\` pour télécharger le fichier manuellement.
|
||||
|
||||
Chaque fichier SRT appartient à une voix, pas à un dialogue. Les indexes de cue doivent correspondre aux valeurs \`subtitleCueIndex\` référencées par le manifeste de dialogues.
|
||||
|
||||
## Valider les assets de dialogue
|
||||
|
||||
Utilise \`Validate\` dans le panneau SRT pour vérifier le manifeste et les assets liés.
|
||||
|
||||
La validation vérifie :
|
||||
|
||||
- \`public/sounds/dialogue/dialogues.json\`
|
||||
- les fichiers audio de dialogue référencés
|
||||
- les fichiers SRT français
|
||||
- les indexes de cue référencés par le manifeste
|
||||
|
||||
Les fichiers SRT anglais manquants sont des warnings parce que le runtime retombe sur les sous-titres français.
|
||||
|
||||
## Éditer les cinématiques
|
||||
|
||||
Le panneau \`Cinematics\` permet d'éditer \`public/cinematics.json\`.
|
||||
|
||||
Chaque cinématique contient :
|
||||
|
||||
- un \`id\`
|
||||
- un \`timecode\` global optionnel
|
||||
- au moins deux keyframes caméra
|
||||
- des dialogue cues optionnelles synchronisées avec la timeline
|
||||
|
||||
Les keyframes caméra définissent un temps relatif, une position caméra et une cible de regard. Les dialogue cues définissent un temps relatif et un \`dialogueId\` issu de \`dialogues.json\`.
|
||||
|
||||
Actions disponibles :
|
||||
|
||||
- \`Reload\` recharge le manifeste.
|
||||
- \`Add\` crée une cinématique locale avec deux keyframes.
|
||||
- \`Save\` écrit \`public/cinematics.json\` via le serveur Vite local.
|
||||
- \`Preview cinematic\` joue l'animation caméra dans le canvas éditeur.
|
||||
- \`Add keyframe\` et \`Remove\` modifient le chemin caméra.
|
||||
- \`Add dialogue\` et \`Remove\` modifient les dialogues synchronisés.
|
||||
- \`Delete cinematic\` supprime localement la cinématique sélectionnée.
|
||||
|
||||
Les dialogue cues sont la manière recommandée de synchroniser un dialogue avec une cinématique. Évite de donner aussi un \`timecode\` global au même dialogue dans \`dialogues.json\`, sinon il peut être lancé deux fois.
|
||||
|
||||
## Inspecteur JSON
|
||||
|
||||
Le panneau latéral affiche le JSON brut de la carte :
|
||||
|
||||
- sans sélection, il affiche toute la liste des nodes
|
||||
- avec un objet sélectionné, il met en évidence les lignes du node sélectionné
|
||||
|
||||
Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauvegarde.
|
||||
|
||||
## Limites actuelles
|
||||
|
||||
- L'éditeur modifie uniquement les nodes existants.
|
||||
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
|
||||
- La sauvegarde production n'est pas implémentée.
|
||||
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
|
||||
- La sauvegarde SRT est un helper local du serveur Vite, pas une API backend de production.
|
||||
- Les sauvegardes dialogues et cinématiques sont aussi des helpers locaux du serveur Vite.
|
||||
`;
|
||||
@@ -1,2 +0,0 @@
|
||||
export const GAME_SCENE_SKYBOX_PATH = "/skybox/sky.exr";
|
||||
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const REPAIR_CASE_MODEL_PATH = "/models/packderelance/model.gltf";
|
||||
export const REPAIR_CASE_OPEN_SOUND_PATH = "/sounds/effect/open-malette.mp3";
|
||||
export const REPAIR_CASE_CLOSE_SOUND_PATH = "/sounds/effect/close-malette.mp3";
|
||||
|
||||
export const REPAIR_CASE_LID_NODE_NAME = "partiesup";
|
||||
export const REPAIR_CASE_CLOSED_ROTATION_OFFSET_DEGREES = 0;
|
||||
export const REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES = 115;
|
||||
export const REPAIR_CASE_ANIMATION_DURATION = 0.8;
|
||||
export const REPAIR_CASE_POP_DURATION = 0.45;
|
||||
export const REPAIR_CASE_POP_Y_OFFSET = -0.25;
|
||||
export const REPAIR_CASE_EXIT_DURATION = 0.5;
|
||||
export const REPAIR_CASE_EXIT_Y_OFFSET = -0.35;
|
||||
|
||||
export const REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE = 5;
|
||||
export const REPAIR_CASE_FLOAT_HEIGHT = 1;
|
||||
export const REPAIR_CASE_FLOAT_UP_SPEED = 2.4;
|
||||
export const REPAIR_CASE_FLOAT_DOWN_SPEED = 1.8;
|
||||
export const REPAIR_CASE_ROTATION_RESET_SPEED = 3;
|
||||
export const REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES = 5;
|
||||
|
||||
export const REPAIR_CASE_FOCUS_POSITION = [
|
||||
0, 1.05, 2.05,
|
||||
] satisfies Vector3Tuple;
|
||||
export const REPAIR_CASE_FOCUS_SCALE = 2.25;
|
||||
export const REPAIR_CASE_PLACEHOLDER_NAME_PREFIX = "placeholder_";
|
||||
export const REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS = 0.65;
|
||||
export const REPAIR_CASE_PLACEHOLDER_SNAP_DURATION = 0.25;
|
||||
@@ -0,0 +1,5 @@
|
||||
export const REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS = 1;
|
||||
export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4;
|
||||
export const REPAIR_INTERACTION_RADIUS = 10;
|
||||
export const REPAIR_SCAN_PART_SECONDS = 1.2;
|
||||
export const REPAIR_REASSEMBLY_SECONDS = 1.4;
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type {
|
||||
ModelTransformProps,
|
||||
Vector3Scale,
|
||||
Vector3Tuple,
|
||||
} from "@/types/three/three";
|
||||
|
||||
export interface RepairMissionCaseConfig {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Scale;
|
||||
}
|
||||
|
||||
export interface RepairMissionPartConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeName?: string;
|
||||
placeholderName?: string;
|
||||
modelPath?: string;
|
||||
}
|
||||
|
||||
export interface RepairMissionConfig {
|
||||
id: RepairMissionId;
|
||||
label: string;
|
||||
description: string;
|
||||
modelPath: string;
|
||||
modelScale?: ModelTransformProps["scale"];
|
||||
stageUiPath: string;
|
||||
interactUiPath: string;
|
||||
brokenUiPath: string;
|
||||
case: RepairMissionCaseConfig;
|
||||
reassemblySeconds?: number;
|
||||
requiredReplacementPartId: string;
|
||||
scanPartSeconds?: number;
|
||||
brokenParts: readonly RepairMissionPartConfig[];
|
||||
replacementParts: readonly RepairMissionPartConfig[];
|
||||
}
|
||||
|
||||
const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm";
|
||||
const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm";
|
||||
|
||||
const DEFAULT_REPAIR_CASE = {
|
||||
position: [0, 0.4, 1.8],
|
||||
rotation: [0, 0, 0],
|
||||
scale: 1.5,
|
||||
} satisfies RepairMissionCaseConfig;
|
||||
|
||||
export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
bike: {
|
||||
id: "bike",
|
||||
label: "E-bike",
|
||||
description:
|
||||
"Repair the damaged cooling module before relaunching the bike",
|
||||
modelPath: "/models/ebike/model.gltf",
|
||||
modelScale: 0.0055,
|
||||
stageUiPath: "/assets/UI/ebike.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
requiredReplacementPartId: "bike-cooling-core-replacement",
|
||||
brokenParts: [
|
||||
{
|
||||
id: "bike-cooling-core",
|
||||
label: "Cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
nodeName: "refroidisseur",
|
||||
placeholderName: "placeholder_1",
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "bike-cooling-core-replacement",
|
||||
label: "Replacement cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "bike-radio-decoy",
|
||||
label: "Radio module",
|
||||
modelPath: "/models/talkie/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "bike-glove-decoy",
|
||||
label: "Insulation glove",
|
||||
modelPath: "/models/gant_l/model.gltf",
|
||||
},
|
||||
],
|
||||
},
|
||||
pylone: {
|
||||
id: "pylone",
|
||||
label: "Power pylon",
|
||||
description:
|
||||
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
stageUiPath: "/assets/UI/centrale.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
reassemblySeconds: 1.8,
|
||||
requiredReplacementPartId: "pylone-grid-relay-replacement",
|
||||
scanPartSeconds: 1.4,
|
||||
brokenParts: [
|
||||
{
|
||||
id: "pylone-grid-relay",
|
||||
label: "Grid relay",
|
||||
nodeName: "lampe",
|
||||
placeholderName: "placeholder_1",
|
||||
},
|
||||
{
|
||||
id: "pylone-damaged-panel",
|
||||
label: "Damaged solar panel",
|
||||
nodeName: "panneau2",
|
||||
placeholderName: "placeholder_2",
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "pylone-grid-relay-replacement",
|
||||
label: "Replacement grid relay",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "pylone-stone-decoy",
|
||||
label: "Stone counterweight",
|
||||
modelPath: "/models/galet/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "pylone-cooling-decoy",
|
||||
label: "Cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
],
|
||||
},
|
||||
ferme: {
|
||||
id: "ferme",
|
||||
label: "Vertical farm",
|
||||
description:
|
||||
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
||||
modelPath: "/models/fermeverticale/model.gltf",
|
||||
stageUiPath: "/assets/UI/laferme.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
reassemblySeconds: 1.2,
|
||||
requiredReplacementPartId: "ferme-irrigation-pump-replacement",
|
||||
scanPartSeconds: 0.9,
|
||||
brokenParts: [
|
||||
{
|
||||
id: "ferme-irrigation-pump",
|
||||
label: "Irrigation pump",
|
||||
placeholderName: "placeholder_1",
|
||||
},
|
||||
{
|
||||
id: "ferme-humidity-sensor",
|
||||
label: "Humidity sensor",
|
||||
placeholderName: "placeholder_2",
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "ferme-irrigation-pump-replacement",
|
||||
label: "Replacement irrigation pump",
|
||||
modelPath: "/models/fermeverticale/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "ferme-tree-decoy",
|
||||
label: "Tree sensor",
|
||||
modelPath: "/models/sapin/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "ferme-radio-decoy",
|
||||
label: "Radio module",
|
||||
modelPath: "/models/talkie/model.gltf",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
const HAND_TRACKING_LOCAL_WS_URL = "ws://localhost:8000/ws";
|
||||
const HAND_TRACKING_PROD_WS_URL = "wss://handtracking.la-fabrik.fr/ws";
|
||||
|
||||
export const HAND_TRACKING_FRAME_WIDTH = 320;
|
||||
export const HAND_TRACKING_FRAME_HEIGHT = 240;
|
||||
export const HAND_TRACKING_TARGET_FPS = 10;
|
||||
export const HAND_TRACKING_JPEG_QUALITY = 0.55;
|
||||
export const HAND_TRACKING_CAMERA_TIMEOUT_MS = 8_000;
|
||||
export const HAND_TRACKING_RESPONSE_TIMEOUT_MS = 1_500;
|
||||
export const HAND_TRACKING_BROWSER_WASM_URL =
|
||||
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
|
||||
export const HAND_TRACKING_BROWSER_MODEL_URL =
|
||||
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
|
||||
|
||||
export function getHandTrackingWsUrl(): string {
|
||||
const configuredUrl = import.meta.env.VITE_HAND_TRACKING_WS_URL;
|
||||
|
||||
if (configuredUrl) {
|
||||
return configuredUrl;
|
||||
}
|
||||
|
||||
return import.meta.env.DEV
|
||||
? HAND_TRACKING_LOCAL_WS_URL
|
||||
: HAND_TRACKING_PROD_WS_URL;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export const GRAB_DEFAULT_LABEL = "Prendre";
|
||||
|
||||
export const GRAB_STIFFNESS_DEFAULT = 15;
|
||||
export const GRAB_THROW_BOOST_DEFAULT = 1.0;
|
||||
export const GRAB_HOLD_DISTANCE_DEFAULT = 2;
|
||||
export const GRAB_HOLD_DISTANCE_DEFAULT = 3;
|
||||
|
||||
export const GRAB_STIFFNESS_MIN = 1;
|
||||
export const GRAB_STIFFNESS_MAX = 50;
|
||||
@@ -13,6 +13,6 @@ export const GRAB_THROW_BOOST_MIN = 0.5;
|
||||
export const GRAB_THROW_BOOST_MAX = 3.0;
|
||||
export const GRAB_THROW_BOOST_STEP = 0.1;
|
||||
|
||||
export const GRAB_HOLD_DISTANCE_MIN = 0.5;
|
||||
export const GRAB_HOLD_DISTANCE_MIN = 1;
|
||||
export const GRAB_HOLD_DISTANCE_MAX = 5.0;
|
||||
export const GRAB_HOLD_DISTANCE_STEP = 0.1;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const TRIGGER_DEFAULT_COLLIDERS = "ball";
|
||||
export const TRIGGER_DEFAULT_LABEL = "Interagir";
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const PLAYER_EYE_HEIGHT = 1.75;
|
||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const GAME_SCENE_SKY_MODEL_PATH = "/models/sky/model.glb";
|
||||
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { Zone } from "@/types/game";
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const ZONES: Zone[] = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+2083
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
HAND_TRACKING_BROWSER_MODEL_URL,
|
||||
HAND_TRACKING_BROWSER_WASM_URL,
|
||||
} from "@/data/handTrackingConfig";
|
||||
import type {
|
||||
HandTrackingHand,
|
||||
HandTrackingLandmark,
|
||||
} from "@/types/handTracking/handTracking";
|
||||
|
||||
type HandLandmarkerModule = typeof import("@mediapipe/tasks-vision");
|
||||
type HandLandmarker = Awaited<
|
||||
ReturnType<HandLandmarkerModule["HandLandmarker"]["createFromOptions"]>
|
||||
>;
|
||||
type HandLandmarkerResult = ReturnType<HandLandmarker["detectForVideo"]>;
|
||||
|
||||
let handLandmarkerPromise: Promise<HandLandmarker> | null = null;
|
||||
|
||||
function averageLandmarks(
|
||||
landmarks: HandTrackingLandmark[],
|
||||
indices: number[],
|
||||
): HandTrackingLandmark {
|
||||
const point = indices.reduce(
|
||||
(current, index) => {
|
||||
const landmark = landmarks[index];
|
||||
if (!landmark) return current;
|
||||
|
||||
return {
|
||||
x: current.x + landmark.x,
|
||||
y: current.y + landmark.y,
|
||||
z: current.z + landmark.z,
|
||||
};
|
||||
},
|
||||
{ x: 0, y: 0, z: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
x: point.x / indices.length,
|
||||
y: point.y / indices.length,
|
||||
z: point.z / indices.length,
|
||||
};
|
||||
}
|
||||
|
||||
function distance(
|
||||
pointA: HandTrackingLandmark,
|
||||
pointB: HandTrackingLandmark,
|
||||
): number {
|
||||
return Math.sqrt(
|
||||
(pointA.x - pointB.x) ** 2 +
|
||||
(pointA.y - pointB.y) ** 2 +
|
||||
(pointA.z - pointB.z) ** 2,
|
||||
);
|
||||
}
|
||||
|
||||
function isFist(landmarks: HandTrackingLandmark[]): boolean {
|
||||
const palmCenter = averageLandmarks(landmarks, [0, 5, 9, 13, 17]);
|
||||
const wrist = landmarks[0];
|
||||
const middleMcp = landmarks[9];
|
||||
|
||||
if (!wrist || !middleMcp) return false;
|
||||
|
||||
const palmSize = distance(wrist, middleMcp);
|
||||
if (palmSize <= 0) return false;
|
||||
|
||||
const foldedFingerCount = [8, 12, 16, 20].filter((index) => {
|
||||
const landmark = landmarks[index];
|
||||
if (!landmark) return false;
|
||||
|
||||
return distance(landmark, palmCenter) / palmSize < 1.05;
|
||||
}).length;
|
||||
|
||||
return foldedFingerCount >= 4;
|
||||
}
|
||||
|
||||
export async function getBrowserHandLandmarker(): Promise<HandLandmarker> {
|
||||
handLandmarkerPromise ??= import("@mediapipe/tasks-vision").then(
|
||||
async ({ FilesetResolver, HandLandmarker }) => {
|
||||
const vision = await FilesetResolver.forVisionTasks(
|
||||
HAND_TRACKING_BROWSER_WASM_URL,
|
||||
);
|
||||
|
||||
return HandLandmarker.createFromOptions(vision, {
|
||||
baseOptions: {
|
||||
modelAssetPath: HAND_TRACKING_BROWSER_MODEL_URL,
|
||||
delegate: "GPU",
|
||||
},
|
||||
numHands: 2,
|
||||
runningMode: "VIDEO",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return handLandmarkerPromise;
|
||||
}
|
||||
|
||||
export function convertBrowserHandResult(
|
||||
result: HandLandmarkerResult,
|
||||
): HandTrackingHand[] {
|
||||
return result.landmarks.map((landmarks, index) => {
|
||||
const normalizedLandmarks = landmarks.map((landmark) => ({
|
||||
x: landmark.x,
|
||||
y: landmark.y,
|
||||
z: landmark.z,
|
||||
}));
|
||||
const palmCenter = averageLandmarks(normalizedLandmarks, [0, 5, 9, 13, 17]);
|
||||
const handedness = result.handedness[index]?.[0];
|
||||
|
||||
return {
|
||||
x: palmCenter.x,
|
||||
y: palmCenter.y,
|
||||
z: palmCenter.z,
|
||||
landmarks: normalizedLandmarks,
|
||||
handedness: handedness?.categoryName ?? "Unknown",
|
||||
isFist: isFist(normalizedLandmarks),
|
||||
score: handedness?.score ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { HAND_TRACKING_CAMERA_TIMEOUT_MS } from "@/data/handTrackingConfig";
|
||||
import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking";
|
||||
|
||||
export const INITIAL_HAND_TRACKING_SNAPSHOT: HandTrackingSnapshot = {
|
||||
hands: [],
|
||||
status: "idle",
|
||||
usageStatus: "inactive",
|
||||
serverStatus: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function getCameraStreamWithTimeout(
|
||||
constraints: MediaStreamConstraints,
|
||||
): Promise<MediaStream> {
|
||||
let didTimeout = false;
|
||||
const streamPromise = navigator.mediaDevices.getUserMedia(constraints);
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
window.setTimeout(() => {
|
||||
didTimeout = true;
|
||||
reject(
|
||||
new Error(
|
||||
"Camera request timed out. Restart the browser or check camera permissions for localhost:5173.",
|
||||
),
|
||||
);
|
||||
}, HAND_TRACKING_CAMERA_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
streamPromise.then((stream) => {
|
||||
if (didTimeout) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.race([streamPromise, timeoutPromise]);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
export type AudioCategory = "music" | "sfx" | "dialogue";
|
||||
export type OneShotAudioCategory = Exclude<AudioCategory, "music">;
|
||||
|
||||
interface AudioContextWindow extends Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
||||
music: 1,
|
||||
sfx: 1,
|
||||
dialogue: 1,
|
||||
};
|
||||
|
||||
interface PlaySoundOptions {
|
||||
category?: OneShotAudioCategory;
|
||||
pan?: number;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
interface StereoNodes {
|
||||
source: MediaElementAudioSourceNode;
|
||||
panner: StereoPannerNode;
|
||||
}
|
||||
|
||||
interface OneShotAudioState {
|
||||
category: OneShotAudioCategory;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export class AudioManager {
|
||||
private static _instance: AudioManager | null = null;
|
||||
private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
|
||||
private readonly _stereoNodes = new WeakMap<HTMLAudioElement, StereoNodes>();
|
||||
private readonly _oneShotStates = new WeakMap<
|
||||
HTMLAudioElement,
|
||||
OneShotAudioState
|
||||
>();
|
||||
private readonly _categoryVolumes: Record<AudioCategory, number> = {
|
||||
...DEFAULT_CATEGORY_VOLUMES,
|
||||
};
|
||||
private _audioContext: AudioContext | null = null;
|
||||
private _music: HTMLAudioElement | null = null;
|
||||
private _musicPath: string | null = null;
|
||||
private _musicVolume = 1;
|
||||
private _musicUnlockHandler: (() => void) | null = null;
|
||||
|
||||
private static readonly MAX_POOL_SIZE_PER_SOUND = 6;
|
||||
private static readonly DEFAULT_SOUND_CATEGORY: OneShotAudioCategory = "sfx";
|
||||
private static readonly IGNORED_PLAYBACK_ERRORS = new Set([
|
||||
"AbortError",
|
||||
"NotAllowedError",
|
||||
]);
|
||||
|
||||
static getInstance(): AudioManager {
|
||||
if (!AudioManager._instance) {
|
||||
AudioManager._instance = new AudioManager();
|
||||
}
|
||||
|
||||
return AudioManager._instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
setCategoryVolume(category: AudioCategory, volume: number): void {
|
||||
this._categoryVolumes[category] = AudioManager._clampVolume(volume);
|
||||
|
||||
if (category === "music" && this._music) {
|
||||
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateOneShotVolumes(category);
|
||||
}
|
||||
|
||||
getCategoryVolume(category: AudioCategory): number {
|
||||
return this._categoryVolumes[category];
|
||||
}
|
||||
|
||||
playSound(
|
||||
path: string,
|
||||
volume = 1,
|
||||
options: PlaySoundOptions = {},
|
||||
): HTMLAudioElement {
|
||||
const audio = this._acquireAudio(path);
|
||||
const category = options.category ?? AudioManager.DEFAULT_SOUND_CATEGORY;
|
||||
const baseVolume = AudioManager._clampVolume(volume);
|
||||
this._oneShotStates.set(audio, { category, volume: baseVolume });
|
||||
audio.volume = this._getEffectiveVolume(category, baseVolume);
|
||||
audio.playbackRate = options.playbackRate ?? 1;
|
||||
audio.currentTime = 0;
|
||||
this._setStereoPan(audio, options.pan ?? 0);
|
||||
|
||||
if (this._audioContext?.state === "suspended") {
|
||||
void this._audioContext.resume();
|
||||
}
|
||||
|
||||
void audio.play().catch((error: unknown) => {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("AudioManager", "Failed to play sound", {
|
||||
path,
|
||||
category,
|
||||
error: AudioManager._toLogValue(error),
|
||||
});
|
||||
});
|
||||
|
||||
return audio;
|
||||
}
|
||||
|
||||
playSoundWithCallback(
|
||||
path: string,
|
||||
volume: number,
|
||||
onEnded: () => void,
|
||||
options: PlaySoundOptions = {},
|
||||
): HTMLAudioElement {
|
||||
const audio = this.playSound(path, volume, options);
|
||||
audio.addEventListener("ended", onEnded, { once: true });
|
||||
|
||||
return audio;
|
||||
}
|
||||
|
||||
playMusic(path: string, volume = 1): void {
|
||||
this._musicVolume = AudioManager._clampVolume(volume);
|
||||
|
||||
if (this._musicPath === path && this._music) {
|
||||
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||
if (!this._music.paused) return;
|
||||
} else {
|
||||
this.stopMusic();
|
||||
this._music = new Audio(path);
|
||||
this._music.loop = true;
|
||||
this._musicPath = path;
|
||||
}
|
||||
|
||||
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||
|
||||
void this._music.play().catch((error: unknown) => {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
|
||||
) {
|
||||
this._waitForUserGestureToPlayMusic();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("AudioManager", "Failed to play music", {
|
||||
path,
|
||||
error: AudioManager._toLogValue(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
stopMusic(): void {
|
||||
this._removeMusicUnlockHandler();
|
||||
this._music?.pause();
|
||||
this._music = null;
|
||||
this._musicPath = null;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stopMusic();
|
||||
this._audioPools.forEach((pool) => {
|
||||
pool.forEach((audio) => {
|
||||
audio.pause();
|
||||
audio.src = "";
|
||||
});
|
||||
});
|
||||
this._audioPools.clear();
|
||||
void this._audioContext?.close();
|
||||
this._audioContext = null;
|
||||
AudioManager._instance = null;
|
||||
}
|
||||
|
||||
private _acquireAudio(path: string): HTMLAudioElement {
|
||||
const existingPool = this._audioPools.get(path);
|
||||
|
||||
if (existingPool) {
|
||||
const availableAudio = existingPool.find(
|
||||
(audio) => audio.paused || audio.ended,
|
||||
);
|
||||
if (availableAudio) return availableAudio;
|
||||
|
||||
if (existingPool.length < AudioManager.MAX_POOL_SIZE_PER_SOUND) {
|
||||
const pooledAudio = new Audio(path);
|
||||
existingPool.push(pooledAudio);
|
||||
return pooledAudio;
|
||||
}
|
||||
|
||||
const recycledAudio = existingPool[0];
|
||||
if (recycledAudio) return recycledAudio;
|
||||
}
|
||||
|
||||
const initialAudio = new Audio(path);
|
||||
this._audioPools.set(path, [initialAudio]);
|
||||
return initialAudio;
|
||||
}
|
||||
|
||||
private _waitForUserGestureToPlayMusic(): void {
|
||||
if (this._musicUnlockHandler) return;
|
||||
|
||||
this._musicUnlockHandler = () => {
|
||||
this._removeMusicUnlockHandler();
|
||||
const music = this._music;
|
||||
if (!music) return;
|
||||
|
||||
void music.play().catch((error: unknown) => {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("AudioManager", "Failed to unlock music playback", {
|
||||
path: this._musicPath,
|
||||
error: AudioManager._toLogValue(error),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", this._musicUnlockHandler, {
|
||||
once: true,
|
||||
});
|
||||
window.addEventListener("keydown", this._musicUnlockHandler, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
private _removeMusicUnlockHandler(): void {
|
||||
if (!this._musicUnlockHandler) return;
|
||||
|
||||
window.removeEventListener("pointerdown", this._musicUnlockHandler);
|
||||
window.removeEventListener("keydown", this._musicUnlockHandler);
|
||||
this._musicUnlockHandler = null;
|
||||
}
|
||||
|
||||
private _setStereoPan(audio: HTMLAudioElement, pan: number): void {
|
||||
const audioContext = this._getAudioContext();
|
||||
if (!audioContext || !("createStereoPanner" in audioContext)) return;
|
||||
|
||||
let nodes = this._stereoNodes.get(audio);
|
||||
if (!nodes) {
|
||||
nodes = {
|
||||
source: audioContext.createMediaElementSource(audio),
|
||||
panner: audioContext.createStereoPanner(),
|
||||
};
|
||||
nodes.source.connect(nodes.panner).connect(audioContext.destination);
|
||||
this._stereoNodes.set(audio, nodes);
|
||||
}
|
||||
|
||||
nodes.panner.pan.value = AudioManager._clampPan(pan);
|
||||
}
|
||||
|
||||
private _getAudioContext(): AudioContext | null {
|
||||
if (this._audioContext) return this._audioContext;
|
||||
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext ??
|
||||
(window as AudioContextWindow).webkitAudioContext ??
|
||||
null;
|
||||
if (!AudioContextConstructor) return null;
|
||||
|
||||
this._audioContext = new AudioContextConstructor();
|
||||
return this._audioContext;
|
||||
}
|
||||
|
||||
private _getEffectiveVolume(category: AudioCategory, volume: number): number {
|
||||
return AudioManager._clampVolume(volume) * this._categoryVolumes[category];
|
||||
}
|
||||
|
||||
private _updateOneShotVolumes(category: AudioCategory): void {
|
||||
if (category === "music") return;
|
||||
|
||||
this._audioPools.forEach((pool) => {
|
||||
pool.forEach((audio) => {
|
||||
const state = this._oneShotStates.get(audio);
|
||||
if (!state || state.category !== category) return;
|
||||
|
||||
audio.volume = this._getEffectiveVolume(category, state.volume);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static _clampPan(pan: number): number {
|
||||
return Math.max(-1, Math.min(1, pan));
|
||||
}
|
||||
|
||||
private static _clampVolume(volume: number): number {
|
||||
return Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
private static _toLogValue(error: unknown): Error | DOMException | string {
|
||||
if (error instanceof Error || error instanceof DOMException) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user