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

# Conflicts:
#	package-lock.json
#	package.json
#	src/App.tsx
#	src/components/three/interaction/CentralObject.tsx
#	src/components/three/interaction/VillageoisHelperObject.tsx
#	src/managers/GameStepManager.ts
#	src/stateManager/AudioManager.ts
#	src/world/World.tsx
#	src/world/player/PlayerController.tsx
This commit is contained in:
Tom Boullay
2026-05-11 17:46:42 +02:00
945 changed files with 26164 additions and 1569 deletions
-140
View File
@@ -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>
);
}
+24
View File
@@ -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]} />
</>
);
}
+51
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+319
View File
@@ -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>
);
}
+743
View File
@@ -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>
);
}
+370
View File
@@ -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>
);
}
+202
View File
@@ -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;
}
+6 -6
View File
@@ -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);
@@ -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>
);
}
@@ -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,
]}
@@ -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 },
]);
}
}}
@@ -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>
);
}
+34
View File
@@ -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 -1
View File
@@ -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();
+203
View File
@@ -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>
);
}
+21
View File
@@ -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>
);
}
+2 -2
View File
@@ -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();
+6 -6
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+7 -6
View File
@@ -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());
});