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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
import {
|
||||
DEBUG_CAMERA_DAMPING_FACTOR,
|
||||
DEBUG_CAMERA_MAX_DISTANCE,
|
||||
DEBUG_CAMERA_MIN_DISTANCE,
|
||||
} from "@/data/debug/debugConfig";
|
||||
import {
|
||||
PLAYER_EYE_HEIGHT,
|
||||
PLAYER_SPAWN_POSITION_GAME,
|
||||
} from "@/data/player/playerConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const DEBUG_CAMERA_TARGET: Vector3Tuple = [
|
||||
PLAYER_SPAWN_POSITION_GAME[0],
|
||||
PLAYER_EYE_HEIGHT,
|
||||
PLAYER_SPAWN_POSITION_GAME[2],
|
||||
];
|
||||
|
||||
export function DebugCameraControls(): React.JSX.Element {
|
||||
return (
|
||||
<OrbitControls
|
||||
enableDamping
|
||||
dampingFactor={DEBUG_CAMERA_DAMPING_FACTOR}
|
||||
minDistance={DEBUG_CAMERA_MIN_DISTANCE}
|
||||
maxDistance={DEBUG_CAMERA_MAX_DISTANCE}
|
||||
target={DEBUG_CAMERA_TARGET}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
DEBUG_AXES_SIZE,
|
||||
DEBUG_GRID_DIVISIONS,
|
||||
DEBUG_GRID_PRIMARY_COLOR,
|
||||
DEBUG_GRID_SECONDARY_COLOR,
|
||||
DEBUG_GRID_SIZE,
|
||||
DEBUG_GRID_Y,
|
||||
} from "@/data/debug/debugConfig";
|
||||
import { Debug } from "@/utils/debug/Debug";
|
||||
|
||||
export function DebugHelpers(): React.JSX.Element | null {
|
||||
const debug = Debug.getInstance();
|
||||
|
||||
if (!debug.active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<gridHelper
|
||||
args={[
|
||||
DEBUG_GRID_SIZE,
|
||||
DEBUG_GRID_DIVISIONS,
|
||||
DEBUG_GRID_PRIMARY_COLOR,
|
||||
DEBUG_GRID_SECONDARY_COLOR,
|
||||
]}
|
||||
position={[0, DEBUG_GRID_Y, 0]}
|
||||
/>
|
||||
<axesHelper args={[DEBUG_AXES_SIZE]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user