Merge branch 'develop' into feat/main-feature
This commit is contained in:
+3
-21
@@ -1,26 +1,8 @@
|
||||
import { Suspense } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Crosshair } from "@/components/ui/Crosshair";
|
||||
import { HandTrackingOverlay } from "@/components/ui/HandTrackingOverlay";
|
||||
import { HandTrackingProvider } from "@/components/ui/HandTrackingProvider";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { DebugPerf } from "@/utils/debug/DebugPerf";
|
||||
import { World } from "@/world/World";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { router } from "@/router";
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
return (
|
||||
<HandTrackingProvider>
|
||||
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
|
||||
<Suspense fallback={null}>
|
||||
<World />
|
||||
<DebugPerf />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
<Crosshair />
|
||||
<InteractPrompt />
|
||||
<HandTrackingOverlay />
|
||||
</HandTrackingProvider>
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
+5
-4
@@ -3,17 +3,18 @@ import {
|
||||
DEBUG_CAMERA_DAMPING_FACTOR,
|
||||
DEBUG_CAMERA_MAX_DISTANCE,
|
||||
DEBUG_CAMERA_MIN_DISTANCE,
|
||||
} from "@/data/debugConfig";
|
||||
} from "@/data/debug/debugConfig";
|
||||
import {
|
||||
PLAYER_EYE_HEIGHT,
|
||||
PLAYER_SPAWN_POSITION_GAME,
|
||||
} from "@/data/playerConfig";
|
||||
} from "@/data/player/playerConfig";
|
||||
import type { Vector3Tuple } from "@/types/three";
|
||||
|
||||
const DEBUG_CAMERA_TARGET = [
|
||||
const DEBUG_CAMERA_TARGET: Vector3Tuple = [
|
||||
PLAYER_SPAWN_POSITION_GAME[0],
|
||||
PLAYER_EYE_HEIGHT,
|
||||
PLAYER_SPAWN_POSITION_GAME[2],
|
||||
] as const;
|
||||
];
|
||||
|
||||
export function DebugCameraControls(): React.JSX.Element {
|
||||
return (
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
DEBUG_GRID_SECONDARY_COLOR,
|
||||
DEBUG_GRID_SIZE,
|
||||
DEBUG_GRID_Y,
|
||||
} from "@/data/debugConfig";
|
||||
} from "@/data/debug/debugConfig";
|
||||
import { Debug } from "@/utils/debug/Debug";
|
||||
|
||||
export function DebugHelpers(): React.JSX.Element | null {
|
||||
@@ -0,0 +1,51 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { useDocsLanguage } from "@/hooks/docs/useDocsLanguage";
|
||||
|
||||
interface DocsDocumentProps {
|
||||
title: string;
|
||||
meta: string;
|
||||
content: string;
|
||||
frContent: string;
|
||||
}
|
||||
|
||||
export function DocsDocument({
|
||||
title,
|
||||
meta,
|
||||
content,
|
||||
frContent,
|
||||
}: DocsDocumentProps): React.JSX.Element {
|
||||
const { language, toggleLanguage } = useDocsLanguage();
|
||||
const translatedContent = language === "fr" ? frContent : content;
|
||||
|
||||
return (
|
||||
<div className="docs-content">
|
||||
<header className="docs-content__header">
|
||||
<span>{title}</span>
|
||||
<button
|
||||
className="docs-language-toggle"
|
||||
type="button"
|
||||
onClick={toggleLanguage}
|
||||
aria-label="Changer la langue de la documentation"
|
||||
>
|
||||
<span className={language === "fr" ? "is-active" : undefined}>
|
||||
FR
|
||||
</span>
|
||||
<span className={language === "en" ? "is-active" : undefined}>
|
||||
EN
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<article className="docs-section">
|
||||
<div className="docs-section__eyebrow">
|
||||
<span>{title}</span>
|
||||
<span>{meta}</span>
|
||||
</div>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{translatedContent}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { Home } from "lucide-react";
|
||||
import { docGroups } from "@/data/docs/docsSections";
|
||||
import { DocsLanguageProvider } from "@/providers/docs/DocsLanguageProvider";
|
||||
|
||||
export function DocsLayout(): React.JSX.Element {
|
||||
return (
|
||||
<DocsLanguageProvider>
|
||||
<main className="docs-page">
|
||||
<aside className="docs-sidebar" aria-label="Documentation">
|
||||
<header className="docs-sidebar__header">
|
||||
<h1>Folders</h1>
|
||||
<Link
|
||||
className="docs-home-link"
|
||||
to="/"
|
||||
aria-label="Retour à l'accueil"
|
||||
>
|
||||
<Home size={18} strokeWidth={2.25} aria-hidden="true" />
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
{docGroups.map((group) => (
|
||||
<section className="docs-nav-group" key={group.label}>
|
||||
<h2>{group.label}</h2>
|
||||
|
||||
{group.sections.map((section) => (
|
||||
<Link
|
||||
activeProps={{
|
||||
className: "docs-nav-item docs-nav-item--active",
|
||||
}}
|
||||
activeOptions={{ exact: true }}
|
||||
className="docs-nav-item"
|
||||
key={section.path}
|
||||
to={section.path}
|
||||
>
|
||||
<span>
|
||||
<strong>{section.title}</strong>
|
||||
<small>{section.subtitle}</small>
|
||||
</span>
|
||||
<span className="docs-nav-item__meta">{section.meta}</span>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<Outlet />
|
||||
</main>
|
||||
</DocsLanguageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
Box,
|
||||
Braces,
|
||||
Download,
|
||||
Expand,
|
||||
Keyboard,
|
||||
Lock,
|
||||
MousePointer2,
|
||||
Move3D,
|
||||
Redo2,
|
||||
RotateCw,
|
||||
Save,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import type { MapNode, TransformMode } from "@/types/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;
|
||||
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,
|
||||
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>
|
||||
</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,364 @@
|
||||
import { useMemo, useRef, useEffect, useState } from "react";
|
||||
import { Grid, TransformControls, useGLTF } from "@react-three/drei";
|
||||
import type { ThreeEvent } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
import type { SceneData, MapNode, TransformMode } from "@/types/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 } = useGLTF(modelUrl);
|
||||
|
||||
const sceneInstance = useMemo(() => scene.clone(true), [scene]);
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
onHoverNode,
|
||||
);
|
||||
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupRef.current) return;
|
||||
const highlightColor = getNodeHighlightColor(isSelected, isHovered);
|
||||
|
||||
groupRef.current.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalMaterial = originalMaterialsRef.current.get(child);
|
||||
|
||||
if (!originalMaterial) {
|
||||
originalMaterialsRef.current.set(child, child.material);
|
||||
}
|
||||
|
||||
if (child.material !== originalMaterial && originalMaterial) {
|
||||
disposeMaterial(child.material);
|
||||
}
|
||||
|
||||
if (highlightColor) {
|
||||
child.material = cloneHighlightedMaterial(
|
||||
originalMaterial ?? child.material,
|
||||
highlightColor,
|
||||
);
|
||||
} else if (originalMaterial) {
|
||||
child.material = originalMaterial;
|
||||
}
|
||||
});
|
||||
}, [isSelected, isHovered]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
const originalMaterials = originalMaterialsRef.current;
|
||||
|
||||
return () => {
|
||||
if (!group) return;
|
||||
|
||||
group.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalMaterial = originalMaterials.get(child);
|
||||
if (originalMaterial && child.material !== originalMaterial) {
|
||||
disposeMaterial(child.material);
|
||||
child.material = originalMaterial;
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<primitive
|
||||
ref={groupRef}
|
||||
object={sceneInstance}
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorFallbackNode({
|
||||
index,
|
||||
node,
|
||||
isSelected,
|
||||
isHovered,
|
||||
objectsMapRef,
|
||||
onSelectNode,
|
||||
onHoverNode,
|
||||
}: EditorNodeCommonProps) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
onHoverNode,
|
||||
);
|
||||
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
|
||||
|
||||
const color = getNodeHighlightColor(isSelected, isHovered) ?? "#6f6f6f";
|
||||
|
||||
return (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
{...pointerHandlers}
|
||||
>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color={color} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect } from "react";
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||
import { FlyController } from "@/controls/editor/FlyController";
|
||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function EditorScene({
|
||||
sceneData,
|
||||
selectedNodeIndex,
|
||||
onSelectNode,
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
onTransformModeChange,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
onNodeTransform,
|
||||
onUndo,
|
||||
onRedo,
|
||||
isPlayerMode = false,
|
||||
}: EditorSceneProps): React.JSX.Element {
|
||||
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 (
|
||||
<>
|
||||
{isPlayerMode ? (
|
||||
<FlyController disabled={false} />
|
||||
) : (
|
||||
<OrbitControls
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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 { InteractableObject } from "@/components/three/InteractableObject";
|
||||
import {
|
||||
GRAB_DEFAULT_COLLIDERS,
|
||||
GRAB_DEFAULT_LABEL,
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
GRAB_THROW_BOOST_MAX,
|
||||
GRAB_THROW_BOOST_MIN,
|
||||
GRAB_THROW_BOOST_STEP,
|
||||
} from "@/data/grabConfig";
|
||||
} from "@/data/interaction/grabConfig";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/useHandTrackingSnapshot";
|
||||
import type { ColliderShape, Vector3Tuple } from "@/types/3d";
|
||||
import type { ColliderShape, Vector3Tuple } from "@/types/three";
|
||||
|
||||
interface GrabbableObjectProps {
|
||||
position: Vector3Tuple;
|
||||
+47
-26
@@ -8,13 +8,13 @@ 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 { Vector3Tuple } from "@/types/three";
|
||||
import type { InteractableHandle } from "@/types/interaction";
|
||||
|
||||
interface InteractableObjectBaseProps {
|
||||
label: string;
|
||||
@@ -37,46 +37,67 @@ 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 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;
|
||||
|
||||
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;
|
||||
if (kind === "grab") {
|
||||
if (!onRelease) return;
|
||||
handle.current = { kind, label, onPress, onRelease };
|
||||
} else {
|
||||
handle.current = { kind, label, onPress };
|
||||
}
|
||||
|
||||
const manager = InteractionManager.getInstance();
|
||||
if (manager.getState().focused === currentHandle) {
|
||||
manager.setFocused(handle.current);
|
||||
}
|
||||
}, [kind, label, onPress, onRelease]);
|
||||
|
||||
const setupInteractionDebugFolder = useCallback((folder: GUI) => {
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { RigidBody } from "@react-three/rapier";
|
||||
import { InteractableObject } from "@/components/3d/InteractableObject";
|
||||
import { InteractableObject } from "@/components/three/InteractableObject";
|
||||
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";
|
||||
|
||||
interface SpawnedModel {
|
||||
id: number;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INTERACT_KEY } from "@/data/keybindings";
|
||||
import { INTERACT_KEY } from "@/data/input/keybindings";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useInteraction } from "@/hooks/useInteraction";
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type DocsLanguage = "en" | "fr";
|
||||
|
||||
interface DocsLanguageContextValue {
|
||||
language: DocsLanguage;
|
||||
toggleLanguage: () => void;
|
||||
}
|
||||
|
||||
export const DocsLanguageContext =
|
||||
createContext<DocsLanguageContextValue | null>(null);
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
type ElementRef,
|
||||
} from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
|
||||
type OrbitControlsRef = ElementRef<typeof OrbitControls>;
|
||||
|
||||
interface FlyControllerProps {
|
||||
speed?: number;
|
||||
verticalSpeed?: number;
|
||||
onPositionChange?: (position: THREE.Vector3) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FlyControllerRef {
|
||||
controls: OrbitControlsRef | null;
|
||||
}
|
||||
|
||||
export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
|
||||
(
|
||||
{ speed = 10, verticalSpeed = 5, onPositionChange, disabled = false },
|
||||
ref,
|
||||
) => {
|
||||
const { camera: rawCamera } = useThree();
|
||||
const cameraRef = useRef(rawCamera);
|
||||
const keys = useRef<{ [key: string]: boolean }>({});
|
||||
const controlsRef = useRef<OrbitControlsRef | null>(null);
|
||||
const lastPosition = useRef(new THREE.Vector3());
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
controls: controlsRef.current,
|
||||
}));
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
keys.current[e.code] = true;
|
||||
}, []);
|
||||
|
||||
const handleKeyUp = useCallback((e: KeyboardEvent) => {
|
||||
keys.current[e.code] = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [handleKeyDown, handleKeyUp]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
// Disabled mode keeps OrbitControls active without keyboard movement.
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Supports AZERTY, QWERTY, and arrow-key movement.
|
||||
const isForward =
|
||||
keys.current["KeyW"] || keys.current["KeyZ"] || keys.current["ArrowUp"];
|
||||
const isBackward = keys.current["KeyS"] || keys.current["ArrowDown"];
|
||||
const isLeft =
|
||||
keys.current["KeyQ"] ||
|
||||
keys.current["KeyA"] ||
|
||||
keys.current["ArrowLeft"];
|
||||
const isRight = keys.current["KeyD"] || keys.current["ArrowRight"];
|
||||
|
||||
const direction = new THREE.Vector3();
|
||||
const frontVector = new THREE.Vector3(
|
||||
0,
|
||||
0,
|
||||
Number(isBackward) - Number(isForward),
|
||||
);
|
||||
const sideVector = new THREE.Vector3(
|
||||
Number(isRight) - Number(isLeft),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
direction.subVectors(frontVector, sideVector);
|
||||
if (direction.lengthSq() > 0) {
|
||||
direction.normalize().multiplyScalar(speed * delta);
|
||||
direction.applyQuaternion(cameraRef.current.quaternion);
|
||||
cameraRef.current.position.add(direction);
|
||||
}
|
||||
|
||||
// Space moves up; Shift moves down.
|
||||
if (keys.current["Space"]) {
|
||||
cameraRef.current.position.y += verticalSpeed * delta;
|
||||
}
|
||||
if (keys.current["ShiftLeft"] || keys.current["ShiftRight"]) {
|
||||
cameraRef.current.position.y -= verticalSpeed * delta;
|
||||
}
|
||||
|
||||
if (
|
||||
onPositionChange &&
|
||||
!cameraRef.current.position.equals(lastPosition.current)
|
||||
) {
|
||||
lastPosition.current.copy(cameraRef.current.position);
|
||||
onPositionChange(cameraRef.current.position);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<OrbitControls
|
||||
ref={controlsRef}
|
||||
makeDefault
|
||||
enableDamping
|
||||
dampingFactor={0.05}
|
||||
mouseButtons={{
|
||||
LEFT: THREE.MOUSE.ROTATE,
|
||||
MIDDLE: THREE.MOUSE.DOLLY,
|
||||
RIGHT: THREE.MOUSE.PAN,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FlyController.displayName = "FlyController";
|
||||
@@ -2,8 +2,6 @@ export const INTERACTION_DEBUG_SPHERE_SEGMENTS = 16;
|
||||
export const INTERACTION_DEBUG_SPHERE_COLOR = "#facc15";
|
||||
export const INTERACTION_DEBUG_SPHERE_OPACITY = 0.25;
|
||||
|
||||
export const MAP_DEBUG_BOX_HELPER_COLOR = 0x00ff88;
|
||||
|
||||
export const DEBUG_CAMERA_DAMPING_FACTOR = 0.05;
|
||||
export const DEBUG_CAMERA_MIN_DISTANCE = 100;
|
||||
export const DEBUG_CAMERA_MAX_DISTANCE = 1000;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { Vector3Tuple } from "@/types/three";
|
||||
|
||||
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
|
||||
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
|
||||
@@ -0,0 +1,60 @@
|
||||
interface DocSection {
|
||||
path: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
meta: string;
|
||||
}
|
||||
|
||||
interface DocGroup {
|
||||
label: string;
|
||||
sections: DocSection[];
|
||||
}
|
||||
|
||||
export const docGroups: DocGroup[] = [
|
||||
{
|
||||
label: "Technical",
|
||||
sections: [
|
||||
{
|
||||
path: "/docs",
|
||||
title: "README",
|
||||
subtitle: "Project overview",
|
||||
meta: "01",
|
||||
},
|
||||
{
|
||||
path: "/docs/architecture",
|
||||
title: "Current Architecture",
|
||||
subtitle: "Runtime structure",
|
||||
meta: "02",
|
||||
},
|
||||
{
|
||||
path: "/docs/target-architecture",
|
||||
title: "Target Architecture",
|
||||
subtitle: "Next direction",
|
||||
meta: "03",
|
||||
},
|
||||
{
|
||||
path: "/docs/technical-editor",
|
||||
title: "Editor Technical Notes",
|
||||
subtitle: "Implementation details",
|
||||
meta: "04",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "User",
|
||||
sections: [
|
||||
{
|
||||
path: "/docs/features",
|
||||
title: "Features",
|
||||
subtitle: "Implemented scope",
|
||||
meta: "05",
|
||||
},
|
||||
{
|
||||
path: "/docs/editor",
|
||||
title: "Editor User Guide",
|
||||
subtitle: "Editing workflow",
|
||||
meta: "06",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,329 @@
|
||||
export const readmeFr = `# La-Fabrik
|
||||
|
||||
Une expérience web 3D interactive pour La Fabrik Durable, un service low-tech de réparation et de transformation situé à Altera, une ville post-capitaliste reconstruite en 2039. Les joueurs incarnent un technicien fraîchement intégré et vivent une journée de service : réparer un vélo électrique, remettre en état un réseau d'énergie et améliorer le système d'irrigation d'une ferme verticale.
|
||||
|
||||
Construit avec React, Three.js et Vite. Fonctionne dans le navigateur, sans installation côté utilisateur.
|
||||
|
||||
## Stack technique
|
||||
|
||||
### Build et langage
|
||||
|
||||
| Package |
|
||||
| -------------------------------------------------- |
|
||||
| [TypeScript](https://www.typescriptlang.org/docs/) |
|
||||
| [React](https://react.dev/learn) |
|
||||
| [Vite](https://vite.dev/guide/) |
|
||||
| [ESLint](https://eslint.org/docs/latest/) |
|
||||
| [Prettier](https://prettier.io/docs/) |
|
||||
|
||||
### Moteur 3D
|
||||
|
||||
| Package |
|
||||
| ----------------------------------------------------------------------------------------- |
|
||||
| [Three.js](https://threejs.org/docs/) |
|
||||
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
|
||||
| [@react-three/drei](https://pmndrs.github.io/drei) |
|
||||
| [@react-three/rapier](https://rapier.rs/docs/) |
|
||||
| [@react-three/postprocessing](https://github.com/pmndrs/postprocessing) |
|
||||
| [GSAP](https://gsap.com/docs/v3/Installation/) |
|
||||
|
||||
### Performance et effets
|
||||
|
||||
| Package |
|
||||
| --------------------------------------------------------------------------- |
|
||||
| [r3f-perf](https://github.com/utsuboco/r3f-perf) |
|
||||
| [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) |
|
||||
|
||||
## Structure du projet
|
||||
|
||||
\`\`\`
|
||||
la-fabrik/
|
||||
├── public/
|
||||
│ ├── models/
|
||||
│ │ ├── map/ # Carte de base, chargée au démarrage
|
||||
│ │ ├── workshop/
|
||||
│ │ ├── powerGrid/
|
||||
│ │ └── farm/
|
||||
│ ├── textures/
|
||||
│ └── sounds/
|
||||
│
|
||||
└── src/
|
||||
├── world/ # Monde 3D persistant
|
||||
│ ├── World.tsx # Composition principale de la scène
|
||||
│ ├── Map.tsx # Carte de base, toujours montée
|
||||
│ ├── Lighting.tsx # Lumières ambiante, directionnelle et ponctuelles
|
||||
│ ├── Environment.tsx # HDRI, brouillard, ciel
|
||||
│ ├── PostFX.tsx # Bloom, SSAO, aberration chromatique
|
||||
│ ├── zones/ # Zones spatiales, LOD par zone
|
||||
│ └── player/ # Contrôleur joueur et caméra
|
||||
│
|
||||
├── components/
|
||||
│ ├── 3d/ # Éléments 3D réutilisables
|
||||
│ └── ui/ # Overlays HTML hors Canvas
|
||||
│
|
||||
├── managers/ # Logique, état et orchestration
|
||||
├── hooks/ # Hooks React autour des managers
|
||||
├── data/ # Configuration statique
|
||||
├── shaders/ # Shaders GLSL
|
||||
└── utils/ # Utilitaires partagés et debug
|
||||
\`\`\`
|
||||
|
||||
## Démarrage
|
||||
|
||||
\`\`\`bash
|
||||
git clone https://github.com/La-Fabrik-Durable/La-Fabrik.git
|
||||
cd La-Fabrik
|
||||
npm install
|
||||
npm run dev
|
||||
\`\`\`
|
||||
|
||||
- application : \`http://localhost:5173\`
|
||||
- mode debug : \`http://localhost:5173?debug\`
|
||||
|
||||
## Licence
|
||||
|
||||
Voir le fichier [LICENSE](./LICENSE).
|
||||
`;
|
||||
|
||||
export const architectureFr = `# Architecture actuelle
|
||||
|
||||
Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
|
||||
|
||||
## Structure runtime
|
||||
|
||||
- \`src/App.tsx\` monte le \`RouterProvider\`, qui pilote l'affichage des vues de l'application.
|
||||
- \`src/pages/page.tsx\` monte le \`Canvas\`, le \`World\` 3D, l'overlay de performance debug et les overlays HTML.
|
||||
- \`src/world/World.tsx\` compose la scène active avec :
|
||||
- l'environnement et l'éclairage
|
||||
- les helpers debug et le mode caméra debug
|
||||
- soit la carte principale, soit la scène de test physique debug
|
||||
- le rig joueur quand le mode caméra actif est \`player\`
|
||||
- \`src/world/GameMap.tsx\` charge les modèles de carte disponibles et construit l'octree de collision.
|
||||
- \`src/world/debug/TestScene.tsx\` fournit une scène orientée debug pour les interactions et la physique.
|
||||
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
|
||||
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut et les inputs d'interaction.
|
||||
|
||||
## Modèle d'interaction
|
||||
|
||||
- \`src/managers/InteractionManager.ts\` est la source d'état actuelle des interactions.
|
||||
- \`src/components/three/InteractableObject.tsx\` gère la détection de focus par distance et raycasting.
|
||||
- \`src/components/three/TriggerObject.tsx\` implémente les interactions de type trigger.
|
||||
- \`src/components/three/GrabbableObject.tsx\` implémente les interactions saisir / relâcher.
|
||||
- \`src/hooks/useInteraction.ts\` expose un snapshot d'interaction à l'UI React.
|
||||
- \`src/components/ui/InteractPrompt.tsx\` affiche le prompt \`E\` pour les interactions trigger.
|
||||
|
||||
## Audio
|
||||
|
||||
- \`src/managers/AudioManager.ts\` fournit actuellement une lecture de sons one-shot avec pool.
|
||||
- Les interactions trigger peuvent lancer directement un son via \`AudioManager\`.
|
||||
|
||||
## Système debug
|
||||
|
||||
- Le mode debug est activé avec \`?debug\`.
|
||||
- \`src/utils/debug/Debug.ts\` possède l'instance \`lil-gui\` et les contrôles debug.
|
||||
- \`src/hooks/debug/useCameraMode.ts\` et \`src/hooks/debug/useSceneMode.ts\` s'abonnent à l'état debug.
|
||||
- \`src/components/debug/DebugPerf.tsx\` monte \`r3f-perf\` en lazy uniquement en mode debug.
|
||||
- \`src/components/debug/scene/DebugHelpers.tsx\` monte les helpers debug.
|
||||
- \`src/components/debug/scene/DebugCameraControls.tsx\` monte la caméra libre debug.
|
||||
|
||||
## Limites actuelles
|
||||
|
||||
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
|
||||
- \`src/world/debug/TestScene.tsx\` fait encore partie de la composition active.
|
||||
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
|
||||
- Les systèmes de missions, zones, cinématiques et dialogues ne sont pas implémentés.
|
||||
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
|
||||
`;
|
||||
|
||||
export const targetArchitectureFr = `# Architecture cible
|
||||
|
||||
Ce document décrit l'architecture visée à moyen terme pour le projet.
|
||||
|
||||
## Relation avec le code actuel
|
||||
|
||||
- \`docs/technical/architecture.md\` reste la source de vérité de ce qui existe maintenant.
|
||||
- Ce document est volontairement aspirational.
|
||||
- Si ce document contredit l'implémentation actuelle, l'implémentation actuelle gagne.
|
||||
|
||||
## Objectifs
|
||||
|
||||
- Garder \`App.tsx\` petit et centré sur l'orchestration.
|
||||
- Séparer le code de production du monde des chemins runtime uniquement debug.
|
||||
- Garder une source de vérité claire par responsabilité.
|
||||
- Faire grandir les systèmes gameplay progressivement, sans préconstruire une architecture vide.
|
||||
|
||||
## Couches prévues
|
||||
|
||||
### Couche App
|
||||
|
||||
- \`App.tsx\` monte la scène canvas et les overlays HTML de premier niveau.
|
||||
- Il doit rester fin et éviter la logique gameplay.
|
||||
|
||||
### Couche World
|
||||
|
||||
- \`src/world/\` doit contenir la composition de scène de production et les objets de scène de production.
|
||||
- Responsabilités attendues :
|
||||
- composition du monde
|
||||
- carte, environnement, éclairage
|
||||
- contrôleur joueur
|
||||
- ancres d'interaction de production
|
||||
- post-processing de production si nécessaire
|
||||
|
||||
### Couche Debug
|
||||
|
||||
- Les scènes et outils uniquement debug doivent être isolés du chemin de production.
|
||||
- Responsabilités attendues :
|
||||
- \`lil-gui\`
|
||||
- overlay de performance
|
||||
- helpers de scène
|
||||
- caméra libre et contrôles de calibration
|
||||
- scènes temporaires de test utilisées pendant le développement
|
||||
|
||||
### Couche UI
|
||||
|
||||
- \`src/components/ui/\` doit contenir les overlays HTML visibles par le joueur.
|
||||
- Exemples futurs :
|
||||
- crosshair
|
||||
- flow de chargement
|
||||
- HUD de mission
|
||||
- overlays narratifs
|
||||
|
||||
### Couche Gameplay
|
||||
|
||||
- À mesure que le projet grandit, l'état gameplay peut évoluer vers une couche d'orchestration plus claire.
|
||||
- Sujets probables :
|
||||
- missions
|
||||
- zones
|
||||
- cinématiques
|
||||
- dialogues
|
||||
- audio
|
||||
- interactions
|
||||
|
||||
## Règles
|
||||
|
||||
- Préférer du code direct et fonctionnel plutôt qu'un échafaudage spéculatif.
|
||||
- Les types partagés doivent rester proches de leur domaine jusqu'à avoir plusieurs vrais consommateurs.
|
||||
- Éviter de créer de nouveaux managers ou services sans besoin runtime actif.
|
||||
- Les chemins runtime uniquement debug doivent être clairement marqués et faciles à retirer plus tard.
|
||||
`;
|
||||
|
||||
export const featuresFr = `# Fonctionnalités implémentées
|
||||
|
||||
Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||
|
||||
## Scène
|
||||
|
||||
- Scène React Three Fiber plein écran
|
||||
- Carte principale chargée depuis \`public/models/map/model.gltf\`
|
||||
- Scène de test physique debug sélectionnable depuis le panneau debug
|
||||
- Éclairage ambiant et directionnel
|
||||
- Configuration de l'environnement de fond
|
||||
|
||||
## Joueur
|
||||
|
||||
- Mode caméra joueur
|
||||
- Orientation souris avec pointer lock
|
||||
- Déplacement avec \`ZQSD\`
|
||||
- Saut
|
||||
- Collision basée sur une octree contre la carte chargée
|
||||
|
||||
## Interactions
|
||||
|
||||
- Détection de focus par distance et raycast
|
||||
- Interactions trigger activées avec \`E\`
|
||||
- Interactions grab activées avec le bouton principal de la souris
|
||||
- Prompt d'interaction affiché pour les interactions trigger
|
||||
|
||||
## Audio
|
||||
|
||||
- Lecture de sons one-shot pour les interactions trigger
|
||||
- Pool simple par son via \`AudioManager\`
|
||||
|
||||
## Outils debug
|
||||
|
||||
- Le paramètre \`?debug\` active le panneau debug
|
||||
- Contrôles \`lil-gui\` pour le mode caméra, le mode scène et les sphères d'interaction
|
||||
- Helpers de scène debug
|
||||
- Caméra libre debug
|
||||
- Overlay \`r3f-perf\`
|
||||
|
||||
## Pas encore implémenté
|
||||
|
||||
- système de missions
|
||||
- système de zones
|
||||
- système de cinématiques
|
||||
- système de dialogues
|
||||
- flow de chargement
|
||||
- minimap et HUD de mission
|
||||
- séparation complète production / debug pour les scènes gameplay
|
||||
`;
|
||||
|
||||
export const editorFr = `# Éditeur de carte
|
||||
|
||||
L'éditeur de carte est disponible sur "/editor". Il permet d'inspecter et d'ajuster les objets déclarés dans "/public/map.json" directement depuis le navigateur.
|
||||
|
||||
## Ce qui est édité
|
||||
|
||||
L'éditeur travaille sur la liste de nodes stockée dans "/public/map.json".
|
||||
|
||||
Chaque node décrit un objet de la scène :
|
||||
|
||||
- "name" : nom du dossier modèle dans "/public/models/{name}/model.gltf"
|
||||
- "type" : catégorie de l'objet
|
||||
- "position" : "[x, y, z]"
|
||||
- "rotation" : "[x, y, z]"
|
||||
- "scale" : "[x, y, z]"
|
||||
|
||||
Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'éditeur affiche un cube gris de remplacement pour que le node reste sélectionnable et déplaçable.
|
||||
|
||||
## Workflow de base
|
||||
|
||||
1. Ouvrir "/editor".
|
||||
2. Sélectionner un objet dans la vue 3D.
|
||||
3. Choisir un mode de transformation : translation, rotation ou scale.
|
||||
4. Déplacer la gizmo de transformation.
|
||||
5. Utiliser undo ou redo si nécessaire.
|
||||
6. Exporter le JSON mis à jour ou le sauvegarder sur le serveur de dev.
|
||||
|
||||
## Contrôles
|
||||
|
||||
| Action | Input |
|
||||
| --- | --- |
|
||||
| Sélectionner un objet | Clic sur l'objet |
|
||||
| Désélectionner | "Esc" ou clic dans le vide |
|
||||
| Mode translation | "T" |
|
||||
| Mode rotation | "R" |
|
||||
| Mode scale | "S" |
|
||||
| Undo | "Ctrl+Z" |
|
||||
| Redo | "Ctrl+Y" |
|
||||
| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches |
|
||||
| Monter / descendre | "Space", "Shift" |
|
||||
|
||||
## Actions fichier
|
||||
|
||||
### Export JSON
|
||||
|
||||
"Export JSON" télécharge la liste actuelle des nodes sous le nom "map.json". À utiliser pour remplacer manuellement "/public/map.json".
|
||||
|
||||
### Save to server
|
||||
|
||||
"Save to server" est disponible uniquement en développement local. L'action écrit la carte modifiée dans "/public/map.json" via l'endpoint du serveur de dev Vite.
|
||||
|
||||
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
|
||||
|
||||
## Inspecteur JSON
|
||||
|
||||
Le panneau latéral affiche le JSON brut de la carte :
|
||||
|
||||
- sans sélection, il affiche toute la liste des nodes
|
||||
- avec un objet sélectionné, il met en évidence les lignes du node sélectionné
|
||||
|
||||
Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauvegarde.
|
||||
|
||||
## Limites actuelles
|
||||
|
||||
- L'éditeur modifie uniquement les nodes existants.
|
||||
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
|
||||
- La sauvegarde production n'est pas implémentée.
|
||||
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
|
||||
`;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { Vector3Tuple } from "@/types/three";
|
||||
|
||||
export const TRIGGER_DEFAULT_COLLIDERS = "ball";
|
||||
export const TRIGGER_DEFAULT_LABEL = "Interagir";
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { Vector3Tuple } from "@/types/three";
|
||||
|
||||
export const PLAYER_EYE_HEIGHT = 1.75;
|
||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { DocsLanguageContext } from "@/contexts/docs/DocsLanguageContext";
|
||||
|
||||
export function useDocsLanguage() {
|
||||
const context = useContext(DocsLanguageContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useDocsLanguage must be used inside DocsLanguageProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { MapNode, SceneData } from "@/types/editor";
|
||||
|
||||
interface ObjectTransform {
|
||||
uuid: string;
|
||||
position: { x: number; y: number; z: number };
|
||||
rotation: { x: number; y: number; z: number };
|
||||
scale: { x: number; y: number; z: number };
|
||||
}
|
||||
|
||||
class HistoryManager {
|
||||
private history: ObjectTransform[][] = [];
|
||||
private currentIndex = -1;
|
||||
private maxSize: number;
|
||||
|
||||
constructor(maxSize = 50) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
saveSnapshot(objects: ObjectTransform[]): void {
|
||||
if (this.currentIndex < this.history.length - 1) {
|
||||
this.history = this.history.slice(0, this.currentIndex + 1);
|
||||
}
|
||||
|
||||
this.history.push(objects.map((object) => ({ ...object })));
|
||||
this.currentIndex = this.history.length - 1;
|
||||
|
||||
if (this.history.length > this.maxSize) {
|
||||
this.history.shift();
|
||||
this.currentIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
undo(): ObjectTransform[] | undefined {
|
||||
if (this.currentIndex <= 0) return undefined;
|
||||
|
||||
this.currentIndex--;
|
||||
return this.history[this.currentIndex];
|
||||
}
|
||||
|
||||
redo(): ObjectTransform[] | undefined {
|
||||
if (this.currentIndex >= this.history.length - 1) return undefined;
|
||||
|
||||
this.currentIndex++;
|
||||
return this.history[this.currentIndex];
|
||||
}
|
||||
|
||||
getUndoCount(): number {
|
||||
return this.currentIndex;
|
||||
}
|
||||
|
||||
getRedoCount(): number {
|
||||
return this.history.length - 1 - this.currentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
interface UseEditorHistoryResult {
|
||||
undoCount: number;
|
||||
redoCount: number;
|
||||
handleUndo: () => void;
|
||||
handleRedo: () => void;
|
||||
handleTransformStart: () => void;
|
||||
handleTransformEnd: () => void;
|
||||
}
|
||||
|
||||
export function useEditorHistory(
|
||||
sceneData: SceneData | null,
|
||||
setSceneData: React.Dispatch<React.SetStateAction<SceneData | null>>,
|
||||
): UseEditorHistoryResult {
|
||||
const [undoCount, setUndoCount] = useState(0);
|
||||
const [redoCount, setRedoCount] = useState(0);
|
||||
const historyManager = useRef(new HistoryManager(50));
|
||||
|
||||
const updateHistoryCounts = useCallback(() => {
|
||||
setUndoCount(historyManager.current.getUndoCount());
|
||||
setRedoCount(historyManager.current.getRedoCount());
|
||||
}, []);
|
||||
|
||||
const applySnapshot = useCallback(
|
||||
(snapshot: ObjectTransform[]): void => {
|
||||
setSceneData((prev) => {
|
||||
if (!prev) return null;
|
||||
|
||||
const mapNodes = prev.mapNodes.map((node, index) => {
|
||||
const transform = snapshot.find(
|
||||
(item) => item.uuid === `node-${index}`,
|
||||
);
|
||||
if (!transform) return node;
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: [
|
||||
transform.position.x,
|
||||
transform.position.y,
|
||||
transform.position.z,
|
||||
],
|
||||
rotation: [
|
||||
transform.rotation.x,
|
||||
transform.rotation.y,
|
||||
transform.rotation.z,
|
||||
],
|
||||
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
|
||||
} satisfies MapNode;
|
||||
});
|
||||
|
||||
return { ...prev, mapNodes };
|
||||
});
|
||||
},
|
||||
[setSceneData],
|
||||
);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
const snapshot = historyManager.current.undo();
|
||||
if (!snapshot) return;
|
||||
|
||||
applySnapshot(snapshot);
|
||||
updateHistoryCounts();
|
||||
}, [applySnapshot, updateHistoryCounts]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
const snapshot = historyManager.current.redo();
|
||||
if (!snapshot) return;
|
||||
|
||||
applySnapshot(snapshot);
|
||||
updateHistoryCounts();
|
||||
}, [applySnapshot, updateHistoryCounts]);
|
||||
|
||||
const handleTransformStart = useCallback(() => {
|
||||
if (!sceneData) return;
|
||||
historyManager.current.saveSnapshot(createSnapshot(sceneData));
|
||||
}, [sceneData]);
|
||||
|
||||
const handleTransformEnd = useCallback(() => {
|
||||
if (!sceneData) return;
|
||||
historyManager.current.saveSnapshot(createSnapshot(sceneData));
|
||||
updateHistoryCounts();
|
||||
}, [sceneData, updateHistoryCounts]);
|
||||
|
||||
return {
|
||||
undoCount,
|
||||
redoCount,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handleTransformStart,
|
||||
handleTransformEnd,
|
||||
};
|
||||
}
|
||||
|
||||
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
|
||||
return sceneData.mapNodes.map((node, index) => ({
|
||||
uuid: `node-${index}`,
|
||||
position: {
|
||||
x: node.position[0],
|
||||
y: node.position[1],
|
||||
z: node.position[2],
|
||||
},
|
||||
rotation: {
|
||||
x: node.rotation[0],
|
||||
y: node.rotation[1],
|
||||
z: node.rotation[2],
|
||||
},
|
||||
scale: { x: node.scale[0], y: node.scale[1], z: node.scale[2] },
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createSceneDataFromFiles } from "@/utils/editor/loadEditorScene";
|
||||
import { loadMapSceneData } from "@/utils/loadMapSceneData";
|
||||
import type { SceneData } from "@/types/editor";
|
||||
|
||||
interface UseEditorSceneDataResult {
|
||||
hasMapJson: boolean;
|
||||
isMapLoading: boolean;
|
||||
sceneData: SceneData | null;
|
||||
setSceneData: React.Dispatch<React.SetStateAction<SceneData | null>>;
|
||||
handleFolderUpload: (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useEditorSceneData(): UseEditorSceneDataResult {
|
||||
const [hasMapJson, setHasMapJson] = useState<boolean>(false);
|
||||
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
|
||||
const [sceneData, setSceneData] = useState<SceneData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadScene = async (): Promise<void> => {
|
||||
setIsMapLoading(true);
|
||||
|
||||
try {
|
||||
const loadedSceneData = await loadMapSceneData();
|
||||
setSceneData(loadedSceneData);
|
||||
setHasMapJson(Boolean(loadedSceneData));
|
||||
} catch (error) {
|
||||
console.error("Error loading map data:", error);
|
||||
setHasMapJson(false);
|
||||
} finally {
|
||||
setIsMapLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadScene();
|
||||
}, []);
|
||||
|
||||
const handleFolderUpload = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const files = event.target.files;
|
||||
if (!files) return;
|
||||
|
||||
try {
|
||||
const uploadedSceneData = await createSceneDataFromFiles(files);
|
||||
setSceneData(uploadedSceneData);
|
||||
setHasMapJson(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erreur";
|
||||
console.error("Error processing upload:", error);
|
||||
alert(message);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
hasMapJson,
|
||||
isMapLoading,
|
||||
sceneData,
|
||||
setSceneData,
|
||||
handleFolderUpload,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { InteractionManager } from "@/stateManager/InteractionManager";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import type { InteractionSnapshot } from "@/types/interaction";
|
||||
|
||||
const manager = InteractionManager.getInstance();
|
||||
|
||||
@@ -2,14 +2,19 @@ import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { Object3D } from "three";
|
||||
import { Octree } from "three/addons/math/Octree.js";
|
||||
import type { OctreeReadyHandler } from "@/types/3d";
|
||||
import type { OctreeReadyHandler } from "@/types/three";
|
||||
|
||||
export function useOctreeGraphNode(
|
||||
graphNodeRef: RefObject<Object3D | null>,
|
||||
onOctreeReady: OctreeReadyHandler,
|
||||
rebuildKey: string | number = 0,
|
||||
): void {
|
||||
const octreeBuilt = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
octreeBuilt.current = false;
|
||||
}, [rebuildKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const graphNode = graphNodeRef.current;
|
||||
if (octreeBuilt.current || !graphNode) return;
|
||||
@@ -20,5 +25,5 @@ export function useOctreeGraphNode(
|
||||
const octree = new Octree();
|
||||
octree.fromGraphNode(graphNode);
|
||||
onOctreeReady(octree);
|
||||
}, [graphNodeRef, onOctreeReady]);
|
||||
}, [graphNodeRef, onOctreeReady, rebuildKey]);
|
||||
}
|
||||
|
||||
+882
-1
@@ -1,6 +1,8 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -27,6 +29,315 @@ canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.docs-page {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #050505;
|
||||
color: #f4efe7;
|
||||
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.docs-sidebar {
|
||||
border-right: 2px solid #d8d0c4;
|
||||
background: #050505;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.docs-sidebar__header,
|
||||
.docs-content__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 78px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 2px solid #d8d0c4;
|
||||
}
|
||||
|
||||
.docs-sidebar__header h1,
|
||||
.docs-content__header span {
|
||||
margin: 0;
|
||||
color: #f4efe7;
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.docs-sidebar nav {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.docs-nav-group {
|
||||
display: grid;
|
||||
border-bottom: 2px solid #d8d0c4;
|
||||
}
|
||||
|
||||
.docs-nav-group h2 {
|
||||
margin: 0;
|
||||
padding: 13px 16px 8px;
|
||||
color: #a9a196;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.docs-sidebar a {
|
||||
color: #f4efe7;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.docs-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 46px;
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid rgba(216, 208, 196, 0.35);
|
||||
color: #f4efe7;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.docs-home-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 999px;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.docs-nav-item span:first-child {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.docs-nav-item strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.docs-nav-item small,
|
||||
.docs-nav-item__meta {
|
||||
color: #a9a196;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.docs-sidebar a:hover,
|
||||
.docs-sidebar a:focus-visible,
|
||||
.docs-nav-item--active {
|
||||
background: #f4efe7;
|
||||
color: #050505;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.docs-sidebar a:hover small,
|
||||
.docs-sidebar a:hover .docs-nav-item__meta,
|
||||
.docs-sidebar a:focus-visible small,
|
||||
.docs-sidebar a:focus-visible .docs-nav-item__meta,
|
||||
.docs-nav-item--active small,
|
||||
.docs-nav-item--active .docs-nav-item__meta {
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.docs-content__header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.docs-language-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 2px;
|
||||
border: 2px solid #d8d0c4;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #f4efe7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.docs-language-toggle span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 36px;
|
||||
min-height: 26px;
|
||||
border-radius: 999px;
|
||||
color: #a9a196;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.docs-language-toggle .is-active {
|
||||
background: #f4efe7;
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
.docs-language-toggle:hover,
|
||||
.docs-language-toggle:focus-visible {
|
||||
outline: none;
|
||||
background: rgba(216, 208, 196, 0.1);
|
||||
}
|
||||
|
||||
.docs-section {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 34px clamp(18px, 4vw, 56px) 48px;
|
||||
}
|
||||
|
||||
.docs-section__eyebrow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 22px;
|
||||
color: #a9a196;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.docs-section h1,
|
||||
.docs-section h2,
|
||||
.docs-section h3 {
|
||||
color: #f4efe7;
|
||||
letter-spacing: -0.06em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.docs-section h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: clamp(46px, 7vw, 88px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.docs-section h2 {
|
||||
margin-top: 44px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #d8d0c4;
|
||||
font-size: clamp(28px, 4vw, 44px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.docs-section h3 {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.docs-section p,
|
||||
.docs-section li {
|
||||
color: #d8d0c4;
|
||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.docs-section ul,
|
||||
.docs-section ol {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.docs-section a {
|
||||
color: #f4efe7;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.docs-section code {
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
padding: 2px 5px;
|
||||
background: rgba(216, 208, 196, 0.22);
|
||||
color: #f4efe7;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.docs-section pre {
|
||||
overflow-x: auto;
|
||||
padding: 18px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.docs-section pre code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #f4efe7;
|
||||
line-height: 1.45;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.docs-section table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.docs-section th,
|
||||
.docs-section td {
|
||||
padding: 10px 12px;
|
||||
border: 2px solid #d8d0c4;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.docs-section th {
|
||||
background: #111;
|
||||
color: #f4efe7;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.docs-section blockquote {
|
||||
margin-left: 0;
|
||||
padding-left: 18px;
|
||||
border-left: 2px solid #d8d0c4;
|
||||
color: #a9a196;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.docs-page {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.docs-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 2px solid #d8d0c4;
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
overflow: visible;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.crosshair {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
@@ -106,3 +417,573 @@ canvas {
|
||||
.hand-tracking-overlay__error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Editor page */
|
||||
.editor-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #050505;
|
||||
color: #f8f8f8;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-loading,
|
||||
.editor-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #f8f8f8;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.editor-loading h2 {
|
||||
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||
color: #ffffff;
|
||||
margin: 0 0 0.75rem;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.editor-loading p {
|
||||
font-size: 1rem;
|
||||
color: #9b9b9b;
|
||||
}
|
||||
|
||||
.editor-error h2 {
|
||||
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||
color: #ffffff;
|
||||
margin: 0 0 0.75rem;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.editor-error p {
|
||||
font-size: 1.1rem;
|
||||
color: #b7b7b7;
|
||||
margin: 0 0 2rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.editor-container code {
|
||||
background: #171717;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
font-family: "SFMono-Regular", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.editor-upload-section {
|
||||
width: min(520px, calc(100vw - 2rem));
|
||||
background: #0d0d0d;
|
||||
border-radius: 24px;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid #2a2a2a;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.editor-upload-section h3 {
|
||||
color: #ffffff;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.editor-drop-zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 116px;
|
||||
padding: 1.25rem;
|
||||
border: 1px dashed #5b5b5b;
|
||||
border-radius: 18px;
|
||||
background: #111111;
|
||||
color: #f8f8f8;
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
border-color 160ms ease,
|
||||
transform 160ms ease;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editor-drop-zone:hover {
|
||||
background: #181818;
|
||||
border-color: #ffffff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.editor-folder-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-folder-structure {
|
||||
background: #080808;
|
||||
border: 1px solid #202020;
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.editor-folder-structure h4 {
|
||||
color: #ffffff;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.editor-folder-structure pre {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: #a7a7a7;
|
||||
font-family: "SFMono-Regular", "Courier New", monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.editor-camera-info {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 2;
|
||||
background: rgba(5, 5, 5, 0.78);
|
||||
color: #f8f8f8;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(18px);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.editor-camera-info span {
|
||||
color: #9b9b9b;
|
||||
}
|
||||
|
||||
.editor-camera-info strong {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-controls-panel {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
width: min(340px, calc(100vw - 32px));
|
||||
background: rgba(8, 8, 8, 0.88);
|
||||
padding: 14px;
|
||||
color: #f8f8f8;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(22px);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3a3a3a transparent;
|
||||
}
|
||||
|
||||
.editor-controls-panel::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.editor-controls-panel::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
padding: 12px 12px 16px;
|
||||
}
|
||||
|
||||
.editor-panel-kicker {
|
||||
color: #8f8f8f;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editor-panel-header h2 {
|
||||
margin: 0.35rem 0 0.45rem;
|
||||
color: #ffffff;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 720;
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
.editor-panel-header p {
|
||||
margin: 0;
|
||||
color: #a3a3a3;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.editor-control-section {
|
||||
padding: 14px 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.editor-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.editor-section-heading h3 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editor-section-heading span,
|
||||
.editor-section-heading svg {
|
||||
color: #777777;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.editor-transform-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.editor-transform-button {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 11px;
|
||||
background: #101010;
|
||||
color: #d9d9d9;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 620;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.editor-transform-button.active {
|
||||
background: #ffffff;
|
||||
color: #050505;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.editor-transform-button:hover {
|
||||
background: #191919;
|
||||
border-color: #5c5c5c;
|
||||
color: #ffffff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.editor-transform-button.active:hover {
|
||||
background: #ffffff;
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
.editor-transform-button kbd {
|
||||
min-width: 22px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: currentColor;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 720;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor-history-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.editor-history-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 9px;
|
||||
background: #101010;
|
||||
color: #f2f2f2;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 13px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.editor-history-button span {
|
||||
color: #8e8e8e;
|
||||
}
|
||||
|
||||
.editor-history-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.editor-action-button,
|
||||
.editor-player-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
background: #101010;
|
||||
color: #f2f2f2;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 680;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.editor-action-button + .editor-action-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.editor-action-button:hover,
|
||||
.editor-player-button:hover {
|
||||
background: #191919;
|
||||
border-color: #5c5c5c;
|
||||
color: #ffffff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.editor-action-button-primary,
|
||||
.editor-player-button.active {
|
||||
background: #ffffff;
|
||||
color: #050505;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.editor-action-button-primary:hover,
|
||||
.editor-player-button.active:hover {
|
||||
background: #ffffff;
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
.editor-selected-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
.editor-selected-info strong,
|
||||
.editor-selected-info span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editor-selected-info strong {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.editor-selected-info span {
|
||||
color: #555555;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.editor-no-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #101010;
|
||||
border: 1px dashed #363636;
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
color: #8f8f8f;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.editor-shortcuts-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-shortcuts-list div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.editor-shortcuts-list div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.editor-shortcuts-list dt,
|
||||
.editor-shortcuts-list dd {
|
||||
margin: 0;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.editor-shortcuts-list dt {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-shortcuts-list dd {
|
||||
color: #8d8d8d;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.editor-json-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 240px;
|
||||
padding: 14px 12px 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.editor-json-view {
|
||||
flex: 1;
|
||||
max-height: 320px;
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
overflow: auto;
|
||||
background: #050505;
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 16px;
|
||||
color: #d7d7d7;
|
||||
font-family: "SFMono-Regular", "Courier New", monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.55;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3a3a3a transparent;
|
||||
}
|
||||
|
||||
.editor-json-view::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.editor-json-view::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.editor-json-view code {
|
||||
display: grid;
|
||||
grid-template-columns: 34px max-content;
|
||||
gap: 10px;
|
||||
min-width: 100%;
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.editor-json-view code span {
|
||||
color: #5f5f5f;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editor-json-view code.is-selected {
|
||||
background: #111111;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.editor-json-view code.is-selected * {
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.editor-json-view code.is-selected span {
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.editor-json-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
color: #8d8d8d;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-error h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.editor-upload-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.editor-drop-zone {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.editor-camera-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-controls-panel {
|
||||
top: auto;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
width: auto;
|
||||
max-height: 46vh;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.editor-json-section {
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import architecture from "../../../../docs/technical/architecture.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
import { architectureFr } from "@/data/docs/docsTranslations";
|
||||
|
||||
export function DocsArchitecturePage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={architecture}
|
||||
frContent={architectureFr}
|
||||
meta="02"
|
||||
title="Architecture actuelle"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import editor from "../../../../docs/user/editor.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
import { editorFr } from "@/data/docs/docsTranslations";
|
||||
|
||||
export function DocsEditorPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={editor}
|
||||
frContent={editorFr}
|
||||
meta="06"
|
||||
title="Editor User Guide"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import features from "../../../../docs/user/features.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
import { featuresFr } from "@/data/docs/docsTranslations";
|
||||
|
||||
export function DocsFeaturesPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={features}
|
||||
frContent={featuresFr}
|
||||
meta="05"
|
||||
title="Features"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import readme from "../../../README.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
import { readmeFr } from "@/data/docs/docsTranslations";
|
||||
|
||||
export function DocsReadmePage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={readme}
|
||||
frContent={readmeFr}
|
||||
meta="01"
|
||||
title="README"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import targetArchitecture from "../../../../docs/technical/target-architecture.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
import { targetArchitectureFr } from "@/data/docs/docsTranslations";
|
||||
|
||||
export function DocsTargetArchitecturePage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={targetArchitecture}
|
||||
frContent={targetArchitectureFr}
|
||||
meta="03"
|
||||
title="Architecture cible"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import technicalEditor from "../../../../docs/technical/editor.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
|
||||
export function DocsTechnicalEditorPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={technicalEditor}
|
||||
frContent={technicalEditor}
|
||||
meta="04"
|
||||
title="Editor Technical Notes"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { EditorControls } from "@/components/editor/EditorControls";
|
||||
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
||||
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
||||
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
||||
import type { MapNode, SceneData, TransformMode } from "@/types/editor";
|
||||
|
||||
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
|
||||
|
||||
function serializeMapNodes(sceneData: SceneData): string {
|
||||
return JSON.stringify(sceneData.mapNodes, null, 2);
|
||||
}
|
||||
|
||||
export function EditorPage(): React.JSX.Element {
|
||||
const {
|
||||
hasMapJson,
|
||||
isMapLoading,
|
||||
sceneData,
|
||||
setSceneData,
|
||||
handleFolderUpload,
|
||||
} = useEditorSceneData();
|
||||
|
||||
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
|
||||
const [transformMode, setTransformMode] =
|
||||
useState<TransformMode>("translate");
|
||||
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
||||
|
||||
const {
|
||||
undoCount,
|
||||
redoCount,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handleTransformStart,
|
||||
handleTransformEnd,
|
||||
} = useEditorHistory(sceneData, setSceneData);
|
||||
|
||||
const handleSelectNode = useCallback((index: number | null) => {
|
||||
setSelectedNodeIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleHoverNode = useCallback((index: number | null) => {
|
||||
setHoveredNodeIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleTransformModeChange = useCallback((mode: TransformMode) => {
|
||||
setTransformMode(mode);
|
||||
}, []);
|
||||
|
||||
const handleSaveToServer = useCallback(async () => {
|
||||
if (!sceneData) return;
|
||||
const json = serializeMapNodes(sceneData);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/save-map", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: json,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert("Map enregistrée avec succès!");
|
||||
} else {
|
||||
alert(SAVE_ERROR_MESSAGE);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error saving map:", err);
|
||||
alert(SAVE_ERROR_MESSAGE);
|
||||
}
|
||||
}, [sceneData]);
|
||||
|
||||
const handleExportJson = useCallback(() => {
|
||||
if (!sceneData) return;
|
||||
const json = serializeMapNodes(sceneData);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "map.json";
|
||||
a.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}, [sceneData]);
|
||||
|
||||
const handlePlayerMode = useCallback(() => {
|
||||
setIsPlayerMode((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleNodeTransform = useCallback(
|
||||
(nodeIndex: number, updatedNode: MapNode) => {
|
||||
setSceneData((prev) => {
|
||||
if (!prev) return null;
|
||||
const newMapNodes = [...prev.mapNodes];
|
||||
newMapNodes[nodeIndex] = updatedNode;
|
||||
return { ...prev, mapNodes: newMapNodes };
|
||||
});
|
||||
},
|
||||
[setSceneData],
|
||||
);
|
||||
|
||||
if (isMapLoading) {
|
||||
return (
|
||||
<div className="editor-container">
|
||||
<div className="editor-loading">
|
||||
<h2>Chargement de l'éditeur...</h2>
|
||||
<p>Vérification de map.json dans public/</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasMapJson) {
|
||||
return (
|
||||
<div className="editor-container">
|
||||
<div className="editor-error">
|
||||
<h2>Erreur : map.json introuvable</h2>
|
||||
<p>
|
||||
Le fichier map.json est requis dans le dossier <code>public/</code>.
|
||||
</p>
|
||||
|
||||
<div className="editor-upload-section">
|
||||
<h3>Télécharger un dossier contenant map.json</h3>
|
||||
|
||||
<label className="editor-drop-zone">
|
||||
<input
|
||||
type="file"
|
||||
className="editor-folder-input"
|
||||
onChange={handleFolderUpload}
|
||||
multiple
|
||||
{...{ webkitdirectory: "" }}
|
||||
/>
|
||||
Choisir un dossier contenant map.json
|
||||
</label>
|
||||
|
||||
<div className="editor-folder-structure">
|
||||
<h4>Structure requise :</h4>
|
||||
<pre>
|
||||
public/ ├── <strong>map.json</strong> (à la racine) └── models/
|
||||
├── arbre/ │ └── model.gltf ├── building/ │ └── model.gltf └──
|
||||
...
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor-container">
|
||||
<Canvas
|
||||
camera={{ position: [0, 50, 100], fov: 50 }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onCreated={({ gl }) => {
|
||||
gl.setClearColor("#050505");
|
||||
}}
|
||||
>
|
||||
<EditorScene
|
||||
sceneData={sceneData!}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
onSelectNode={handleSelectNode}
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={handleHoverNode}
|
||||
transformMode={transformMode}
|
||||
onTransformModeChange={handleTransformModeChange}
|
||||
onTransformStart={handleTransformStart}
|
||||
onTransformEnd={handleTransformEnd}
|
||||
onNodeTransform={handleNodeTransform}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
isPlayerMode={isPlayerMode}
|
||||
/>
|
||||
</Canvas>
|
||||
|
||||
{sceneData && (
|
||||
<EditorControls
|
||||
transformMode={transformMode}
|
||||
onTransformModeChange={handleTransformModeChange}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
mapNodes={sceneData.mapNodes}
|
||||
nodesCount={sceneData.mapNodes.length}
|
||||
selectedNodeName={
|
||||
selectedNodeIndex !== null && sceneData.mapNodes[selectedNodeIndex]
|
||||
? sceneData.mapNodes[selectedNodeIndex].name || null
|
||||
: null
|
||||
}
|
||||
undoCount={undoCount}
|
||||
redoCount={redoCount}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onExportJson={handleExportJson}
|
||||
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
|
||||
onPlayerMode={handlePlayerMode}
|
||||
isPlayerMode={isPlayerMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Suspense } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Crosshair } from "@/components/ui/Crosshair";
|
||||
import { HandTrackingOverlay } from "@/components/ui/HandTrackingOverlay";
|
||||
import { HandTrackingProvider } from "@/components/ui/HandTrackingProvider";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||
import { World } from "@/world/World";
|
||||
|
||||
export function HomePage(): React.JSX.Element {
|
||||
return (
|
||||
<HandTrackingProvider>
|
||||
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
|
||||
<Suspense fallback={null}>
|
||||
<World />
|
||||
<DebugPerf />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
<Crosshair />
|
||||
<InteractPrompt />
|
||||
<HandTrackingOverlay />
|
||||
</HandTrackingProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DocsLanguageContext,
|
||||
type DocsLanguage,
|
||||
} from "@/contexts/docs/DocsLanguageContext";
|
||||
|
||||
interface DocsLanguageProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DocsLanguageProvider({
|
||||
children,
|
||||
}: DocsLanguageProviderProps): React.JSX.Element {
|
||||
const [language, setLanguage] = useState<DocsLanguage>("en");
|
||||
|
||||
function toggleLanguage(): void {
|
||||
setLanguage((currentLanguage) => (currentLanguage === "en" ? "fr" : "en"));
|
||||
}
|
||||
|
||||
return (
|
||||
<DocsLanguageContext.Provider value={{ language, toggleLanguage }}>
|
||||
{children}
|
||||
</DocsLanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Outlet,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
} from "@tanstack/react-router";
|
||||
import { HomePage } from "@/pages/page";
|
||||
import { EditorPage } from "@/pages/editor/page";
|
||||
import {
|
||||
DocsArchitectureRoute,
|
||||
DocsEditorRoute,
|
||||
DocsFeaturesRoute,
|
||||
DocsLayoutRoute,
|
||||
DocsReadmeRoute,
|
||||
DocsTargetArchitectureRoute,
|
||||
DocsTechnicalEditorRoute,
|
||||
} from "@/routes/docs/DocsRouteComponents";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: Outlet,
|
||||
});
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
const editorRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/editor",
|
||||
component: EditorPage,
|
||||
});
|
||||
|
||||
const docsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/docs",
|
||||
component: DocsLayoutRoute,
|
||||
});
|
||||
|
||||
const docsChildRoutes = [
|
||||
{ path: "/", component: DocsReadmeRoute },
|
||||
{ path: "architecture", component: DocsArchitectureRoute },
|
||||
{ path: "target-architecture", component: DocsTargetArchitectureRoute },
|
||||
{ path: "technical-editor", component: DocsTechnicalEditorRoute },
|
||||
{ path: "features", component: DocsFeaturesRoute },
|
||||
{ path: "editor", component: DocsEditorRoute },
|
||||
].map(({ path, component }) =>
|
||||
createRoute({
|
||||
getParentRoute: () => docsRoute,
|
||||
path,
|
||||
component,
|
||||
}),
|
||||
);
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
editorRoute,
|
||||
docsRoute.addChildren(docsChildRoutes),
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
|
||||
const LazyDocsLayout = lazy(() =>
|
||||
import("@/components/docs/DocsLayout").then((module) => ({
|
||||
default: module.DocsLayout,
|
||||
})),
|
||||
);
|
||||
|
||||
const LazyDocsReadmePage = lazy(() =>
|
||||
import("@/pages/docs/page").then((module) => ({
|
||||
default: module.DocsReadmePage,
|
||||
})),
|
||||
);
|
||||
|
||||
const LazyDocsArchitecturePage = lazy(() =>
|
||||
import("@/pages/docs/architecture/page").then((module) => ({
|
||||
default: module.DocsArchitecturePage,
|
||||
})),
|
||||
);
|
||||
|
||||
const LazyDocsTargetArchitecturePage = lazy(() =>
|
||||
import("@/pages/docs/target-architecture/page").then((module) => ({
|
||||
default: module.DocsTargetArchitecturePage,
|
||||
})),
|
||||
);
|
||||
|
||||
const LazyDocsTechnicalEditorPage = lazy(() =>
|
||||
import("@/pages/docs/technical-editor/page").then((module) => ({
|
||||
default: module.DocsTechnicalEditorPage,
|
||||
})),
|
||||
);
|
||||
|
||||
const LazyDocsFeaturesPage = lazy(() =>
|
||||
import("@/pages/docs/features/page").then((module) => ({
|
||||
default: module.DocsFeaturesPage,
|
||||
})),
|
||||
);
|
||||
|
||||
const LazyDocsEditorPage = lazy(() =>
|
||||
import("@/pages/docs/editor/page").then((module) => ({
|
||||
default: module.DocsEditorPage,
|
||||
})),
|
||||
);
|
||||
|
||||
export function DocsLayoutRoute(): React.JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyDocsLayout />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsReadmeRoute(): React.JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyDocsReadmePage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsArchitectureRoute(): React.JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyDocsArchitecturePage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsTargetArchitectureRoute(): React.JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyDocsTargetArchitecturePage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsTechnicalEditorRoute(): React.JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyDocsTechnicalEditorPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsFeaturesRoute(): React.JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyDocsFeaturesPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsEditorRoute(): React.JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyDocsEditorPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Vector3Tuple } from "./three";
|
||||
|
||||
export interface MapNode {
|
||||
name: string;
|
||||
type: string;
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
}
|
||||
|
||||
export interface SceneData {
|
||||
mapNodes: MapNode[];
|
||||
models: Map<string, string>;
|
||||
}
|
||||
|
||||
export type TransformMode = "translate" | "rotate" | "scale";
|
||||
@@ -1,6 +1,6 @@
|
||||
export type InteractableKind = "grab" | "trigger";
|
||||
|
||||
export interface TriggerInteractableHandle {
|
||||
interface TriggerInteractableHandle {
|
||||
kind: "trigger";
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export type LogValue =
|
||||
type LogValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SceneData } from "@/types/editor";
|
||||
import { parseMapNodes } from "@/utils/mapNodeValidation";
|
||||
|
||||
const MAP_JSON_PATH = "/map.json";
|
||||
|
||||
export async function createSceneDataFromFiles(
|
||||
files: FileList,
|
||||
): Promise<SceneData> {
|
||||
const fileMap = new Map<string, File>();
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
fileMap.set(getProjectRelativePath(file), file);
|
||||
}
|
||||
|
||||
const mapFile = fileMap.get(MAP_JSON_PATH);
|
||||
if (!mapFile) {
|
||||
throw new Error("Fichier map.json manquant à la racine du dossier");
|
||||
}
|
||||
|
||||
const mapNodes = parseMapNodes(JSON.parse(await mapFile.text()));
|
||||
const models = new Map<string, string>();
|
||||
|
||||
for (const [path, file] of fileMap.entries()) {
|
||||
const modelMatch = path.match(/^\/models\/(.+)\/model\.gltf$/);
|
||||
if (modelMatch?.[1]) {
|
||||
models.set(modelMatch[1], URL.createObjectURL(file));
|
||||
}
|
||||
}
|
||||
|
||||
return { mapNodes, models };
|
||||
}
|
||||
|
||||
function getProjectRelativePath(file: File): string {
|
||||
const relativePath = file.webkitRelativePath || file.name;
|
||||
|
||||
if (!relativePath.includes("/")) {
|
||||
return `/${relativePath}`;
|
||||
}
|
||||
|
||||
const [, ...pathParts] = relativePath.split("/");
|
||||
return `/${pathParts.join("/")}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { MapNode, SceneData } from "@/types/editor";
|
||||
import { parseMapNodes } from "@/utils/mapNodeValidation";
|
||||
|
||||
const MAP_JSON_PATH = "/map.json";
|
||||
const MODEL_FILE_NAME = "model.gltf";
|
||||
type ModelEntry = [modelName: string, modelUrl: string];
|
||||
|
||||
export async function loadMapSceneData(): Promise<SceneData | null> {
|
||||
const response = await fetch(MAP_JSON_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapNodes = parseMapNodes(await response.json());
|
||||
return createSceneData(mapNodes);
|
||||
}
|
||||
|
||||
async function createSceneData(mapNodes: MapNode[]): Promise<SceneData> {
|
||||
const models = await loadMapModelUrls(mapNodes);
|
||||
return { mapNodes, models };
|
||||
}
|
||||
|
||||
async function loadMapModelUrls(
|
||||
mapNodes: MapNode[],
|
||||
): Promise<Map<string, string>> {
|
||||
const uniqueModelNames = [...new Set(mapNodes.map((node) => node.name))];
|
||||
const modelEntries = await Promise.all(
|
||||
uniqueModelNames.map(async (modelName) => {
|
||||
const modelUrl = `/models/${modelName}/${MODEL_FILE_NAME}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(modelUrl, { method: "HEAD" });
|
||||
const modelEntry: ModelEntry = [modelName, modelUrl];
|
||||
return response.ok ? modelEntry : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return new Map(modelEntries.filter((entry) => entry !== null));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { MapNode } from "../types/editor";
|
||||
|
||||
function isVector3Tuple(value: unknown): value is [number, number, number] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length === 3 &&
|
||||
value.every((item) => typeof item === "number" && Number.isFinite(item))
|
||||
);
|
||||
}
|
||||
|
||||
export function isMapNode(value: unknown): value is MapNode {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const node = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof node.name === "string" &&
|
||||
typeof node.type === "string" &&
|
||||
isVector3Tuple(node.position) &&
|
||||
isVector3Tuple(node.rotation) &&
|
||||
isVector3Tuple(node.scale)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseMapNodes(value: unknown): MapNode[] {
|
||||
if (!Array.isArray(value) || !value.every(isMapNode)) {
|
||||
throw new Error("Invalid map node data");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Environment as DreiEnvironment } from "@react-three/drei";
|
||||
import {
|
||||
GAME_SCENE_SKYBOX_PATH,
|
||||
PHYSICS_SCENE_BACKGROUND_COLOR,
|
||||
} from "@/data/environmentConfig";
|
||||
} from "@/data/world/environmentConfig";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
|
||||
export function Environment(): React.JSX.Element {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
|
||||
import { loadMapSceneData } from "@/utils/loadMapSceneData";
|
||||
import type { OctreeReadyHandler } from "@/types/three";
|
||||
import type { MapNode } from "@/types/editor";
|
||||
|
||||
interface GameMapProps {
|
||||
onOctreeReady: OctreeReadyHandler;
|
||||
}
|
||||
|
||||
export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
|
||||
const [mapNodes, setMapNodes] = useState<MapNode[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMap = async () => {
|
||||
try {
|
||||
const sceneData = await loadMapSceneData();
|
||||
if (!sceneData) {
|
||||
console.warn("map.json not found");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedMapNodes = sceneData.mapNodes.filter((node) =>
|
||||
sceneData.models.has(node.name),
|
||||
);
|
||||
const missingModelCount =
|
||||
sceneData.mapNodes.length - loadedMapNodes.length;
|
||||
|
||||
if (missingModelCount > 0) {
|
||||
console.warn(
|
||||
`${missingModelCount} map nodes were skipped because their model files are missing.`,
|
||||
);
|
||||
}
|
||||
|
||||
setMapNodes(loadedMapNodes);
|
||||
} catch (error) {
|
||||
console.error("Error loading map:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadMap();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{!isLoading &&
|
||||
mapNodes.map((node, index) => (
|
||||
<ModelInstance key={index} node={node} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelInstance({ node }: { node: MapNode }): React.JSX.Element {
|
||||
const modelPath = `/models/${node.name}/model.gltf`;
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { scene } = useGLTF(modelPath);
|
||||
const sceneInstance = useMemo(() => scene.clone(true), [scene]);
|
||||
const { position, rotation, scale } = node;
|
||||
|
||||
useEffect(() => {
|
||||
if (groupRef.current) {
|
||||
groupRef.current.position.set(...position);
|
||||
groupRef.current.rotation.set(...rotation);
|
||||
groupRef.current.scale.set(...scale);
|
||||
}
|
||||
}, [position, rotation, scale]);
|
||||
|
||||
return (
|
||||
<primitive
|
||||
ref={groupRef}
|
||||
object={sceneInstance}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SUN_Z_MAX,
|
||||
SUN_Z_MIN,
|
||||
SUN_Z_STEP,
|
||||
} from "@/data/lightingConfig";
|
||||
} from "@/data/world/lightingConfig";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
|
||||
type LightingState = {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { MAP_DEBUG_BOX_HELPER_COLOR } from "@/data/debugConfig";
|
||||
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
|
||||
import type { OctreeReadyHandler } from "@/types/3d";
|
||||
import { Debug } from "@/utils/debug/Debug";
|
||||
|
||||
const MAP_PATH = "/models/map/model.gltf";
|
||||
|
||||
interface MapProps {
|
||||
onOctreeReady: OctreeReadyHandler;
|
||||
}
|
||||
|
||||
export function Map({ onOctreeReady }: MapProps): React.JSX.Element {
|
||||
const { scene: gltfScene } = useGLTF(MAP_PATH);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const boxHelpersRef = useRef<THREE.BoxHelper[]>([]);
|
||||
const { scene } = useThree();
|
||||
|
||||
useOctreeGraphNode(groupRef, onOctreeReady);
|
||||
|
||||
useEffect(() => {
|
||||
const debug = Debug.getInstance();
|
||||
if (!debug.active || !groupRef.current) return;
|
||||
|
||||
const helpers: THREE.BoxHelper[] = [];
|
||||
|
||||
groupRef.current.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
const helper = new THREE.BoxHelper(child, MAP_DEBUG_BOX_HELPER_COLOR);
|
||||
scene.add(helper);
|
||||
helpers.push(helper);
|
||||
});
|
||||
|
||||
boxHelpersRef.current = helpers;
|
||||
|
||||
return () => {
|
||||
helpers.forEach((h) => {
|
||||
scene.remove(h);
|
||||
h.dispose();
|
||||
});
|
||||
boxHelpersRef.current = [];
|
||||
};
|
||||
}, [scene]);
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<primitive object={gltfScene} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(MAP_PATH);
|
||||
+7
-7
@@ -3,15 +3,15 @@ import type { Octree } from "three/addons/math/Octree.js";
|
||||
import {
|
||||
PLAYER_SPAWN_POSITION_GAME,
|
||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||
} from "@/data/playerConfig";
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls";
|
||||
import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers";
|
||||
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
|
||||
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
||||
import { Environment } from "@/world/Environment";
|
||||
import { Lighting } from "@/world/Lighting";
|
||||
import { Map } from "@/world/Map";
|
||||
import { PlayerComponent } from "@/world/player/PlayerComponent";
|
||||
import { GameMap } from "@/world/GameMap";
|
||||
import { Player } from "@/world/player/Player";
|
||||
import { TestScene } from "@/world/debug/TestScene";
|
||||
|
||||
export function World(): React.JSX.Element {
|
||||
@@ -31,13 +31,13 @@ export function World(): React.JSX.Element {
|
||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||
|
||||
{sceneMode === "game" ? (
|
||||
<Map onOctreeReady={setOctree} />
|
||||
<GameMap onOctreeReady={setOctree} />
|
||||
) : (
|
||||
<TestScene onOctreeReady={setOctree} />
|
||||
)}
|
||||
|
||||
{cameraMode !== "debug" ? (
|
||||
<PlayerComponent octree={octree} spawnPosition={playerSpawnPosition} />
|
||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useRef } from "react";
|
||||
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
||||
import * as THREE from "three";
|
||||
import { GrabbableObject } from "@/components/3d/GrabbableObject";
|
||||
import { TriggerObject } from "@/components/3d/TriggerObject";
|
||||
import { GrabbableObject } from "@/components/three/GrabbableObject";
|
||||
import { TriggerObject } from "@/components/three/TriggerObject";
|
||||
import {
|
||||
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
|
||||
TEST_SCENE_FLOOR_POSITION,
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
TEST_SCENE_TRIGGER_ROUGHNESS,
|
||||
TEST_SCENE_TRIGGER_SEGMENTS,
|
||||
TEST_SCENE_TRIGGER_SOUND_PATH,
|
||||
} from "@/data/testSceneConfig";
|
||||
} from "@/data/debug/testSceneConfig";
|
||||
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
|
||||
import type { OctreeReadyHandler } from "@/types/3d";
|
||||
import type { OctreeReadyHandler } from "@/types/three";
|
||||
|
||||
interface TestSceneProps {
|
||||
onOctreeReady: OctreeReadyHandler;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import type { Octree } from "three/addons/math/Octree.js";
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
import type { Vector3Tuple } from "@/types/three";
|
||||
import { PlayerCamera } from "@/world/player/PlayerCamera";
|
||||
import { PlayerController } from "@/world/player/PlayerController";
|
||||
|
||||
interface PlayerComponentProps {
|
||||
interface PlayerProps {
|
||||
octree: Octree | null;
|
||||
spawnPosition: Vector3Tuple;
|
||||
}
|
||||
|
||||
export function PlayerComponent({
|
||||
export function Player({
|
||||
spawnPosition,
|
||||
octree,
|
||||
}: PlayerComponentProps): React.JSX.Element {
|
||||
}: PlayerProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
MOVE_LEFT_KEY,
|
||||
MOVE_RIGHT_KEY,
|
||||
PRIMARY_INTERACT_MOUSE_BUTTON,
|
||||
} from "@/data/keybindings";
|
||||
} from "@/data/input/keybindings";
|
||||
import {
|
||||
PLAYER_ACCELERATION_MULTIPLIER,
|
||||
PLAYER_AIR_CONTROL_FACTOR,
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
PLAYER_MAX_DELTA,
|
||||
PLAYER_WALK_SPEED,
|
||||
PLAYER_XZ_DAMPING_FACTOR,
|
||||
} from "@/data/playerConfig";
|
||||
import { InteractionManager } from "@/stateManager/InteractionManager";
|
||||
import type { Vector3Tuple } from "@/types/3d";
|
||||
} from "@/data/player/playerConfig";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import type { Vector3Tuple } from "@/types/three";
|
||||
|
||||
type Keys = {
|
||||
forward: boolean;
|
||||
@@ -54,6 +54,25 @@ const _up = new THREE.Vector3(0, 1, 0);
|
||||
const _translateVec = new THREE.Vector3();
|
||||
const _collisionCorrection = new THREE.Vector3();
|
||||
|
||||
function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean {
|
||||
switch (key.toLowerCase()) {
|
||||
case MOVE_FORWARD_KEY:
|
||||
keys.forward = pressed;
|
||||
return true;
|
||||
case MOVE_BACKWARD_KEY:
|
||||
keys.backward = pressed;
|
||||
return true;
|
||||
case MOVE_LEFT_KEY:
|
||||
keys.left = pressed;
|
||||
return true;
|
||||
case MOVE_RIGHT_KEY:
|
||||
keys.right = pressed;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function PlayerController({
|
||||
octree,
|
||||
spawnPosition,
|
||||
@@ -89,51 +108,29 @@ export function PlayerController({
|
||||
const interaction = InteractionManager.getInstance();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
switch (event.key.toLowerCase()) {
|
||||
case MOVE_FORWARD_KEY:
|
||||
keys.current.forward = true;
|
||||
break;
|
||||
case MOVE_BACKWARD_KEY:
|
||||
keys.current.backward = true;
|
||||
break;
|
||||
case MOVE_LEFT_KEY:
|
||||
keys.current.left = true;
|
||||
break;
|
||||
case MOVE_RIGHT_KEY:
|
||||
keys.current.right = true;
|
||||
break;
|
||||
case JUMP_KEY:
|
||||
wantsJump.current = true;
|
||||
break;
|
||||
case INTERACT_KEY:
|
||||
if (interaction.getState().focused?.kind === "trigger") {
|
||||
interaction.pressInteract();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
if (setMovementKey(keys.current, event.key, true)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === JUMP_KEY) {
|
||||
wantsJump.current = true;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() === INTERACT_KEY) {
|
||||
if (interaction.getState().focused?.kind === "trigger") {
|
||||
interaction.pressInteract();
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent): void => {
|
||||
switch (event.key.toLowerCase()) {
|
||||
case MOVE_FORWARD_KEY:
|
||||
keys.current.forward = false;
|
||||
break;
|
||||
case MOVE_BACKWARD_KEY:
|
||||
keys.current.backward = false;
|
||||
break;
|
||||
case MOVE_LEFT_KEY:
|
||||
keys.current.left = false;
|
||||
break;
|
||||
case MOVE_RIGHT_KEY:
|
||||
keys.current.right = false;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
if (setMovementKey(keys.current, event.key, false)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: MouseEvent): void => {
|
||||
|
||||
Reference in New Issue
Block a user