Merge branch 'develop' into feat/docs-routing
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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,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("/")}`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user