Merge branch 'develop' into feat/docs-routing

This commit is contained in:
Tom Boullay
2026-04-28 13:31:40 +02:00
78 changed files with 7179 additions and 77 deletions
@@ -0,0 +1,324 @@
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;
}
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">
<button
className={`editor-transform-button ${transformMode === "translate" ? "active" : ""}`}
onClick={() => onTransformModeChange("translate")}
aria-pressed={transformMode === "translate"}
>
<Move3D size={16} aria-hidden="true" />
<span>Translate</span>
<kbd>T</kbd>
</button>
<button
className={`editor-transform-button ${transformMode === "rotate" ? "active" : ""}`}
onClick={() => onTransformModeChange("rotate")}
aria-pressed={transformMode === "rotate"}
>
<RotateCw size={16} aria-hidden="true" />
<span>Rotate</span>
<kbd>R</kbd>
</button>
<button
className={`editor-transform-button ${transformMode === "scale" ? "active" : ""}`}
onClick={() => onTransformModeChange("scale")}
aria-pressed={transformMode === "scale"}
>
<Expand size={16} aria-hidden="true" />
<span>Scale</span>
<kbd>S</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">
<div>
<dt>Click</dt>
<dd>Select object</dd>
</div>
<div>
<dt>T / R / S</dt>
<dd>Transform mode</dd>
</div>
<div>
<dt>Ctrl Z / Y</dt>
<dd>Undo / redo</dd>
</div>
<div>
<dt>Esc</dt>
<dd>Deselect</dd>
</div>
<div>
<dt>WASD</dt>
<dd>Move when locked</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,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";
@@ -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,
};
}
+344
View File
@@ -0,0 +1,344 @@
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;
}
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;
}
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]);
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
useEffect(() => {
if (!groupRef.current) return;
const highlightColor = isSelected
? "#ffffff"
: isHovered
? "#b8b8b8"
: null;
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}
onClick={(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onSelectNode(index);
}}
onPointerEnter={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
onHoverNode(index);
}}
onPointerLeave={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
onHoverNode(null);
}}
/>
);
}
function EditorFallbackNode({
index,
node,
isSelected,
isHovered,
objectsMapRef,
onSelectNode,
onHoverNode,
}: EditorNodeCommonProps) {
const meshRef = useRef<THREE.Mesh>(null);
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
const color = isSelected ? "#ffffff" : isHovered ? "#b8b8b8" : "#6f6f6f";
return (
<mesh
ref={meshRef}
position={node.position}
rotation={node.rotation}
scale={node.scale}
onClick={(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onSelectNode(index);
}}
onPointerEnter={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
onHoverNode(index);
}}
onPointerLeave={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
onHoverNode(null);
}}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={color} />
</mesh>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect } from "react";
import { OrbitControls } from "@react-three/drei";
import { FlyController } from "@/features/editor/controls/FlyController";
import { EditorMap } from "@/features/editor/scene/EditorMap";
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} />
</>
);
}
+570
View File
@@ -375,3 +375,573 @@ canvas {
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.03em;
}
/* 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;
}
}
+5 -2
View File
@@ -1,10 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+195
View File
@@ -0,0 +1,195 @@
import { useCallback, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { EditorControls } from "@/features/editor/components/EditorControls";
import { useEditorHistory } from "@/features/editor/hooks/useEditorHistory";
import { useEditorSceneData } from "@/features/editor/hooks/useEditorSceneData";
import { EditorScene } from "@/features/editor/scene/EditorScene";
import type { MapNode, TransformMode } from "@/types/editor";
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 = JSON.stringify(sceneData.mapNodes, null, 2);
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("Erreur lors de l'enregistrement");
}
} catch (err) {
console.error("Error saving map:", err);
alert("Erreur lors de l'enregistrement");
}
}, [sceneData]);
const handleExportJson = useCallback(() => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
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>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Vector3Tuple } from "@/types/3d";
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 -1
View File
@@ -1,6 +1,6 @@
export type InteractableKind = "grab" | "trigger";
export interface TriggerInteractableHandle {
interface TriggerInteractableHandle {
kind: "trigger";
label: string;
onPress: () => void;
+1 -1
View File
@@ -1,6 +1,6 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
export type LogValue =
type LogValue =
| string
| number
| boolean
+41
View File
@@ -0,0 +1,41 @@
import type { MapNode, SceneData } from "@/types/editor";
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: MapNode[] = 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("/")}`;
}
+40
View File
@@ -0,0 +1,40 @@
import type { MapNode, SceneData } from "@/types/editor";
const MAP_JSON_PATH = "/map.json";
const MODEL_FILE_NAME = "model.gltf";
export async function loadMapSceneData(): Promise<SceneData | null> {
const response = await fetch(MAP_JSON_PATH);
if (!response.ok) {
return null;
}
const mapNodes: MapNode[] = 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" });
return response.ok ? ([modelName, modelUrl] as const) : null;
} catch {
return null;
}
}),
);
return new Map(modelEntries.filter((entry) => entry !== null));
}
+80
View File
@@ -0,0 +1,80 @@
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/3d";
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);
useEffect(() => {
const loadMap = async () => {
try {
const sceneData = await loadMapSceneData();
if (!sceneData) {
console.warn("map.json not found");
setIsLoading(false);
return;
}
setMapNodes(
sceneData.mapNodes.filter((node) => sceneData.models.has(node.name)),
);
} catch (error) {
console.error("Error loading map:", error);
} finally {
setIsLoading(false);
}
};
loadMap();
}, []);
if (isLoading) {
return <></>;
}
return (
<group ref={groupRef}>
{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}
/>
);
}
+2 -2
View File
@@ -10,7 +10,7 @@ import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers";
import { Environment } from "@/world/Environment";
import { Lighting } from "@/world/Lighting";
import { Map } from "@/world/Map";
import { GameMap } from "@/world/GameMap";
import { PlayerComponent } from "@/world/player/PlayerComponent";
import { TestScene } from "@/world/debug/TestScene";
@@ -31,7 +31,7 @@ export function World(): React.JSX.Element {
{cameraMode === "debug" ? <DebugCameraControls /> : null}
{sceneMode === "game" ? (
<Map onOctreeReady={setOctree} />
<GameMap onOctreeReady={setOctree} />
) : (
<TestScene onOctreeReady={setOctree} />
)}