merge develop into feat/galerie - resolve model and code conflicts

This commit is contained in:
Tom Boullay
2026-05-29 02:25:46 +02:00
940 changed files with 92419 additions and 37567 deletions
+357
View File
@@ -0,0 +1,357 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { MapControls, OrthographicCamera, useGLTF } from "@react-three/drei";
import * as THREE from "three";
// ----------------------------------------------------------------------------
// 1. Terrain Scene
// ----------------------------------------------------------------------------
function TerrainScene() {
const { scene } = useGLTF("/models/terrain/terrain.glb");
return (
<group>
<ambientLight intensity={1.5} />
<directionalLight position={[10, 20, 10]} intensity={2} />
<primitive object={scene} />
</group>
);
}
// ----------------------------------------------------------------------------
// 2. Waypoint Overlay (Debug visualization)
// ----------------------------------------------------------------------------
function WaypointOverlay({
waypoints,
visible,
}: {
waypoints: any[];
visible: boolean;
}) {
if (!visible) return null;
return (
<group>
{waypoints.map((w) => (
<mesh key={w.id} position={[w.x, w.y + 1, w.z]}>
<sphereGeometry args={[0.3, 16, 16]} />
<meshBasicMaterial color="#10b981" />
</mesh>
))}
</group>
);
}
// ----------------------------------------------------------------------------
// 3. Camera Manager (Handles Orthographic Math & Downloads)
// ----------------------------------------------------------------------------
function CameraManager({
autoBounds,
boundsTextRef,
}: {
autoBounds: any;
boundsTextRef: React.RefObject<HTMLPreElement | null>;
}) {
const { camera, gl, scene } = useThree();
const controlsRef = useRef<any>(null);
// Apply Auto-Bounds function
useEffect(() => {
const applyAutoBounds = () => {
if (camera instanceof THREE.OrthographicCamera && autoBounds) {
const width = autoBounds.maxX - autoBounds.minX;
const height = autoBounds.maxZ - autoBounds.minZ;
const centerX = (autoBounds.minX + autoBounds.maxX) / 2;
const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2;
camera.position.set(centerX, 200, centerZ);
camera.left = -width / 2;
camera.right = width / 2;
camera.top = height / 2;
camera.bottom = -height / 2;
camera.zoom = 1;
camera.updateProjectionMatrix();
if (controlsRef.current) {
controlsRef.current.target.set(centerX, 0, centerZ);
controlsRef.current.update();
}
}
};
(window as any).applyAutoBounds = applyAutoBounds;
// Initial apply
applyAutoBounds();
return () => {
delete (window as any).applyAutoBounds;
};
}, [camera, autoBounds]);
// Track dynamic bounds without triggering React re-renders!
useFrame(() => {
if (camera instanceof THREE.OrthographicCamera && boundsTextRef.current) {
const width = (camera.right - camera.left) / camera.zoom;
const height = (camera.top - camera.bottom) / camera.zoom;
const minX = Math.round(camera.position.x - width / 2);
const maxX = Math.round(camera.position.x + width / 2);
const minZ = Math.round(camera.position.z - height / 2);
const maxZ = Math.round(camera.position.z + height / 2);
// Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!)
boundsTextRef.current.innerText = JSON.stringify(
{ minX, maxX, minZ, maxZ },
null,
2,
);
}
});
// Attach screenshot capture logic
useEffect(() => {
(window as any).downloadMapScreenshot = () => {
// Force an immediate render frame to ensure no UI overlays are missing
gl.render(scene, camera);
const dataUrl = gl.domElement.toDataURL("image/png");
const a = document.createElement("a");
a.href = dataUrl;
a.download = "/assets/gps/map_background.png";
a.click();
};
return () => {
delete (window as any).downloadMapScreenshot;
};
}, [gl, camera, scene]);
return (
<MapControls ref={controlsRef} enableRotate={false} dampingFactor={0.05} />
);
}
// ----------------------------------------------------------------------------
// 4. Main Page Route Component
// ----------------------------------------------------------------------------
export function BackgroundMapPage() {
const [waypoints, setWaypoints] = useState<any[]>([]);
const [showWaypoints, setShowWaypoints] = useState(true);
const boundsTextRef = useRef<HTMLPreElement>(null);
// Load road network waypoints to compute perfect GPS bounds
useEffect(() => {
const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) {
setWaypoints(JSON.parse(saved));
} else {
fetch("/roadNetwork.json")
.then((res) => res.json())
.then((data) => setWaypoints(data))
.catch(() => {});
}
}, []);
// Compute exact bounds that the EbikeGPSMap will use by default
const autoBounds = useMemo(() => {
if (waypoints.length === 0) return null;
const xs = waypoints.map((w) => w.x);
const zs = waypoints.map((w) => w.z);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minZ = Math.min(...zs);
const maxZ = Math.max(...zs);
// CRITICAL: We MUST force the camera bounds to be a PERFECT SQUARE.
// If the camera is rectangular, the exported PNG will be distorted when drawn
// on the EbikeGPSMap's 1024x1024 canvas!
const width = maxX - minX;
const height = maxZ - minZ;
const maxDim = Math.max(width, height);
const centerX = (minX + maxX) / 2;
const centerZ = (minZ + maxZ) / 2;
const paddedDim = maxDim * 1.15 || 100;
return {
minX: centerX - paddedDim / 2,
maxX: centerX + paddedDim / 2,
minZ: centerZ - paddedDim / 2,
maxZ: centerZ + paddedDim / 2,
};
}, [waypoints]);
return (
<div
style={{
width: "100vw",
height: "100vh",
background: "#050505",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{/*
CRITICAL: The DOM element MUST be a perfect square so the resulting PNG
is exactly 1:1, preventing stretching in the EbikeGPSMap canvas texture!
*/}
<div
style={{
width: "min(100vw, 100vh)",
height: "min(100vw, 100vh)",
background: "#000",
position: "relative",
}}
>
<Canvas
gl={{ preserveDrawingBuffer: true, antialias: true, alpha: false }}
>
<OrthographicCamera
makeDefault
position={[0, 200, 0]}
near={0.1}
far={1000}
/>
<TerrainScene />
<WaypointOverlay waypoints={waypoints} visible={showWaypoints} />
<CameraManager
autoBounds={autoBounds}
boundsTextRef={boundsTextRef}
/>
</Canvas>
</div>
{/* Premium Glassmorphic UI Dashboard */}
<div
style={{
position: "absolute",
top: 24,
left: 24,
background: "rgba(15, 23, 42, 0.85)",
padding: 24,
borderRadius: 16,
border: "1px solid #334155",
color: "white",
fontFamily: "system-ui, sans-serif",
backdropFilter: "blur(12px)",
width: 360,
boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.5)",
}}
>
<h2
style={{ margin: "0 0 16px 0", fontSize: "1.4rem", color: "#38bdf8" }}
>
GPS Map Generator
</h2>
<p
style={{
fontSize: "0.9rem",
color: "#94a3b8",
marginBottom: 20,
lineHeight: 1.5,
}}
>
1. Cadrez votre carte (ou utilisez le <b>Cadrage Automatique</b>).
<br />
2. Masquez les waypoints (fond visuel seul).
<br />
3. Cliquez sur <b>Capturer la carte</b>.
</p>
<button
onClick={() => setShowWaypoints(!showWaypoints)}
style={{
width: "100%",
padding: "12px",
marginBottom: 12,
background: showWaypoints ? "#1e293b" : "#334155",
border: "1px solid #475569",
color: "white",
borderRadius: 8,
cursor: "pointer",
fontWeight: 600,
transition: "all 0.2s",
}}
>
{showWaypoints ? "👁️ Masquer Waypoints" : "👁️‍🗨️ Afficher Waypoints"}
</button>
<button
onClick={() => {
if ((window as any).applyAutoBounds)
(window as any).applyAutoBounds();
}}
style={{
width: "100%",
padding: "12px",
marginBottom: 16,
background: "#1e293b",
border: "1px solid #475569",
color: "#10b981",
borderRadius: 8,
cursor: "pointer",
fontWeight: 600,
transition: "all 0.2s",
}}
>
🎯 Cadrage Automatique
</button>
<button
onClick={() => {
if ((window as any).downloadMapScreenshot)
(window as any).downloadMapScreenshot();
}}
style={{
width: "100%",
padding: "14px",
background: "#0ea5e9",
border: "none",
color: "white",
borderRadius: 8,
cursor: "pointer",
fontWeight: "bold",
fontSize: "1rem",
boxShadow: "0 4px 6px -1px rgba(14, 165, 233, 0.4)",
}}
>
📸 Capturer la carte (.png)
</button>
<div
style={{
marginTop: 24,
padding: 16,
background: "#020617",
borderRadius: 10,
fontSize: "0.85rem",
}}
>
<div style={{ color: "#64748b", marginBottom: 8, fontWeight: 600 }}>
Limites Actuelles (worldBounds):
</div>
<pre
ref={boundsTextRef}
style={{ margin: 0, color: "#10b981", fontFamily: "monospace" }}
>
Calcul...
</pre>
<div
style={{
color: "#ef4444",
marginTop: 12,
fontSize: "0.75rem",
lineHeight: 1.4,
}}
>
*Si vous décadrez à la souris, vous devrez copier ces valeurs
exactes dans la prop <code>worldBounds</code> de votre composant{" "}
<b>EbikeGPSMap</b> !
<br />
<br />
Astuce : Utilisez le <b>Cadrage Automatique</b> pour ne rien avoir à
configurer.
</div>
</div>
</div>
</div>
);
}
-1
View File
@@ -5,7 +5,6 @@ export function DocsAnimationPage(): React.JSX.Element {
return (
<DocsDocument
content={animation}
frContent={animation}
meta="15"
title="Animation & 3D Model System"
/>
-1
View File
@@ -5,7 +5,6 @@ export function DocsArchitecturePage(): React.JSX.Element {
return (
<DocsDocument
content={architecture}
frContent={architecture}
meta="02"
title="Current Architecture"
/>
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsAudioPage(): React.JSX.Element {
return (
<DocsDocument
content={audio}
frContent={audio}
meta="08"
title="Audio Technical Notes"
/>
<DocsDocument content={audio} meta="08" title="Audio Technical Notes" />
);
}
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsCodeReviewPage(): React.JSX.Element {
return (
<DocsDocument
content={codeReview}
frContent={codeReview}
meta="16"
title="Code Review Prep"
/>
<DocsDocument content={codeReview} meta="16" title="Code Review Prep" />
);
}
+1 -8
View File
@@ -2,12 +2,5 @@ import editor from "../../../../docs/user/editor.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsEditorPage(): React.JSX.Element {
return (
<DocsDocument
content={editor}
frContent={editor}
meta="14"
title="Editor User Guide"
/>
);
return <DocsDocument content={editor} meta="14" title="Editor User Guide" />;
}
+1 -8
View File
@@ -2,12 +2,5 @@ import features from "../../../../docs/user/features.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsFeaturesPage(): React.JSX.Element {
return (
<DocsDocument
content={features}
frContent={features}
meta="12"
title="Features"
/>
);
return <DocsDocument content={features} meta="12" title="Features" />;
}
-1
View File
@@ -5,7 +5,6 @@ export function DocsHandTrackingPage(): React.JSX.Element {
return (
<DocsDocument
content={handTracking}
frContent={handTracking}
meta="09"
title="Hand Tracking Technical Notes"
/>
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsInteractionPage(): React.JSX.Element {
return (
<DocsDocument
content={interaction}
frContent={interaction}
meta="05"
title="Interaction System"
/>
<DocsDocument content={interaction} meta="05" title="Interaction System" />
);
}
+1 -8
View File
@@ -2,12 +2,5 @@ import mainFeature from "../../../../docs/user/main-feature.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsMainFeaturePage(): React.JSX.Element {
return (
<DocsDocument
content={mainFeature}
frContent={mainFeature}
meta="13"
title="Main Feature"
/>
);
return <DocsDocument content={mainFeature} meta="13" title="Main Feature" />;
}
+8
View File
@@ -0,0 +1,8 @@
import mapPerformance from "../../../../docs/technical/map-performance.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsMapPerformancePage(): React.JSX.Element {
return (
<DocsDocument content={mapPerformance} meta="12" title="Map Performance" />
);
}
+1 -8
View File
@@ -2,12 +2,5 @@ import readme from "../../../README.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsReadmePage(): React.JSX.Element {
return (
<DocsDocument
content={readme}
frContent={readme}
meta="01"
title="README"
/>
);
return <DocsDocument content={readme} meta="01" title="README" />;
}
+1 -8
View File
@@ -2,12 +2,5 @@ import repairGame from "../../../../docs/technical/repair-game.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsRepairGamePage(): React.JSX.Element {
return (
<DocsDocument
content={repairGame}
frContent={repairGame}
meta="04"
title="Repair Game"
/>
);
return <DocsDocument content={repairGame} meta="04" title="Repair Game" />;
}
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsSceneRuntimePage(): React.JSX.Element {
return (
<DocsDocument
content={sceneRuntime}
frContent={sceneRuntime}
meta="03"
title="Scene Runtime"
/>
<DocsDocument content={sceneRuntime} meta="03" title="Scene Runtime" />
);
}
@@ -5,7 +5,6 @@ export function DocsTargetArchitecturePage(): React.JSX.Element {
return (
<DocsDocument
content={targetArchitecture}
frContent={targetArchitecture}
meta="06"
title="Target Architecture"
/>
-1
View File
@@ -5,7 +5,6 @@ export function DocsTechnicalEditorPage(): React.JSX.Element {
return (
<DocsDocument
content={technicalEditor}
frContent={technicalEditor}
meta="07"
title="Editor Technical Notes"
/>
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsThreeDebuggingPage(): React.JSX.Element {
return (
<DocsDocument
content={threeDebugging}
frContent={threeDebugging}
meta="11"
title="Three Debugging"
/>
<DocsDocument content={threeDebugging} meta="11" title="Three Debugging" />
);
}
+1 -8
View File
@@ -2,12 +2,5 @@ import zustand from "../../../../docs/technical/zustand.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsZustandPage(): React.JSX.Element {
return (
<DocsDocument
content={zustand}
frContent={zustand}
meta="10"
title="Zustand Stores"
/>
);
return <DocsDocument content={zustand} meta="10" title="Zustand Stores" />;
}
+300 -86
View File
@@ -1,52 +1,56 @@
import { Suspense, useCallback, useEffect, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { useProgress } from "@react-three/drei";
import { useCallback, useEffect, useState } from "react";
import { Canvas, useThree } from "@react-three/fiber";
import { EditorControls } from "@/components/editor/EditorControls";
import { EditorScene } from "@/components/editor/scene/EditorScene";
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { Subtitles } from "@/components/ui/Subtitles";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
import type {
EditorCinematicPreviewRequest,
MapNode,
TransformMode,
} from "@/types/editor/editor";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger";
import {
INITIAL_SCENE_LOADING_STATE,
type SceneLoadingChangeHandler,
type SceneLoadingState,
} from "@/types/world/sceneLoading";
addTreeNode,
createNewMapNode,
mergeFlatNodeTransformsIntoTree,
removeEditorMetadata,
removeTreeNodeAtPath,
serializeMapNodes,
updateSceneDataTree,
updateTreeNodeAtPath,
} from "@/utils/editor/editorMapTree";
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
const DEFAULT_NEW_NODE_NAME = "new-model";
interface EditorSceneLoadingTrackerProps {
onLoadingStateChange: SceneLoadingChangeHandler;
}
function serializeMapNodes(sceneData: SceneData): string {
return JSON.stringify(sceneData.mapNodes, null, 2);
}
function EditorSceneLoadingTracker({
onLoadingStateChange,
}: EditorSceneLoadingTrackerProps): null {
const { active, progress } = useProgress();
function EditorWebGLContextLogger(): null {
const gl = useThree((state) => state.gl);
useEffect(() => {
if (active) {
onLoadingStateChange({
currentStep: "Importation des models",
progress: 0.2 + (progress / 100) * 0.7,
status: "loading",
});
return;
}
gl.setClearColor("#050505");
onLoadingStateChange({
currentStep: "Gameplay prêt",
progress: 1,
status: "ready",
});
}, [active, onLoadingStateChange, progress]);
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - GPU resources exhausted");
};
const handleContextRestored = () => {
logger.info("WebGL", "Context restored");
};
canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener("webglcontextrestored", handleContextRestored);
return () => {
canvas.removeEventListener("webglcontextlost", handleContextLost);
canvas.removeEventListener("webglcontextrestored", handleContextRestored);
};
}, [gl]);
return null;
}
@@ -63,40 +67,33 @@ export function EditorPage(): React.JSX.Element {
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
null,
);
const [selectedNodeIndexes, setSelectedNodeIndexes] = useState<number[]>([]);
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false);
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
{
...INITIAL_SCENE_LOADING_STATE,
currentStep: "Montage progressif des models",
progress: 0.2,
},
const [snapToTerrain, setSnapToTerrain] = useState(true);
const [newNodeName, setNewNodeName] = useState(DEFAULT_NEW_NODE_NAME);
const [lockTerrainSelection, setLockTerrainSelection] = useState(true);
const [resetCameraRequest, setResetCameraRequest] = useState(0);
const [snapAllToTerrainRequest, setSnapAllToTerrainRequest] = useState(0);
const [focusSelectedCameraRequest, setFocusSelectedCameraRequest] =
useState(0);
const [cameraViewMode, setCameraViewMode] = useState<"home" | "object">(
"home",
);
const handleSceneLoadingStateChange = useCallback(
(nextState: SceneLoadingState) => {
setSceneLoadingState((currentState) => {
const shouldRestartProgress = currentState.status === "ready";
return {
...nextState,
progress: shouldRestartProgress
? nextState.progress
: Math.max(currentState.progress, nextState.progress),
};
});
},
[],
);
const editorLoadingState = isMapLoading
const editorLoadingState: SceneLoadingState = isMapLoading
? {
currentStep: "Récupération blocking",
currentStep: "Chargement de la carte",
progress: 0.08,
status: "loading" as const,
}
: sceneLoadingState;
: {
currentStep: "Gameplay prêt",
progress: 1,
status: "ready" as const,
};
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
useState<EditorCinematicPreviewRequest | null>(null);
@@ -111,16 +108,96 @@ export function EditorPage(): React.JSX.Element {
const handleSelectNode = useCallback((index: number | null) => {
setSelectedNodeIndex(index);
setSelectedNodeIndexes(index === null ? [] : [index]);
if (index !== null) {
setCameraViewMode("object");
return;
}
setCameraViewMode("home");
setResetCameraRequest((request) => request + 1);
}, []);
const handleToggleNodeSelection = useCallback(
(index: number) => {
const isSelected = selectedNodeIndexes.includes(index);
const nextIndexes = isSelected
? selectedNodeIndexes.filter((item) => item !== index)
: [...selectedNodeIndexes, index];
setSelectedNodeIndexes(nextIndexes);
setSelectedNodeIndex(nextIndexes.at(-1) ?? null);
if (nextIndexes.length > 0) {
setCameraViewMode("object");
} else {
setCameraViewMode("home");
setResetCameraRequest((request) => request + 1);
}
},
[selectedNodeIndexes],
);
const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
setCameraViewMode("home");
setResetCameraRequest((request) => request + 1);
}, []);
const handleSelectionLockToggle = useCallback(() => {
setIsSelectionLocked((locked) => !locked);
}, []);
const handleSnapToTerrainToggle = useCallback(() => {
setSnapToTerrain((enabled) => !enabled);
}, []);
const handleSnapAllToTerrainRequest = useCallback(() => {
setSnapAllToTerrainRequest((request) => request + 1);
}, []);
const handleSnapAllToTerrain = useCallback(
(mapNodes: MapNode[]) => {
setSceneData((prev) => {
if (!prev) return null;
const nextSceneData = { ...prev, mapNodes };
if (!prev.mapTree) return nextSceneData;
const mapTree = mergeFlatNodeTransformsIntoTree(nextSceneData);
return updateSceneDataTree(nextSceneData, mapTree);
});
},
[setSceneData],
);
const handleNewNodeNameChange = useCallback((value: string) => {
setNewNodeName(value);
}, []);
const handleTerrainSelectionLockChange = useCallback(
(locked: boolean) => {
setLockTerrainSelection(locked);
if (!locked) return;
const nextIndexes = selectedNodeIndexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
);
const selectedNode =
selectedNodeIndex !== null
? sceneData?.mapNodes[selectedNodeIndex]
: null;
setSelectedNodeIndexes(nextIndexes);
setSelectedNodeIndex(
selectedNode?.name === "terrain" ? null : selectedNodeIndex,
);
},
[sceneData, selectedNodeIndex, selectedNodeIndexes],
);
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
@@ -167,6 +244,17 @@ export function EditorPage(): React.JSX.Element {
setIsPlayerMode((prev) => !prev);
}, []);
const handleCameraAction = useCallback(() => {
if (selectedNodeIndex !== null && cameraViewMode === "home") {
setFocusSelectedCameraRequest((request) => request + 1);
setCameraViewMode("object");
return;
}
setResetCameraRequest((request) => request + 1);
setCameraViewMode("home");
}, [cameraViewMode, selectedNodeIndex]);
const handlePreviewCinematic = useCallback(
(cinematic: CinematicDefinition) => {
setCinematicPreviewRequest({
@@ -185,14 +273,112 @@ export function EditorPage(): React.JSX.Element {
(nodeIndex: number, updatedNode: MapNode) => {
setSceneData((prev) => {
if (!prev) return null;
const newMapNodes = [...prev.mapNodes];
newMapNodes[nodeIndex] = updatedNode;
return { ...prev, mapNodes: newMapNodes };
const currentNode = prev.mapNodes[nodeIndex];
if (!currentNode) return prev;
if (!prev.mapTree || !currentNode.sourcePath) {
const mapNodes = [...prev.mapNodes];
mapNodes[nodeIndex] = updatedNode;
return { ...prev, mapNodes };
}
const mapTree = updateTreeNodeAtPath(
prev.mapTree,
currentNode.sourcePath,
(node) => ({
...node,
position: updatedNode.position,
rotation: updatedNode.rotation,
scale: updatedNode.scale,
}),
);
return updateSceneDataTree(prev, mapTree);
});
},
[setSceneData],
);
const handleSelectedScaleChange = useCallback(
(axis: 0 | 1 | 2, value: number) => {
if (selectedNodeIndex === null || Number.isNaN(value)) return;
setSceneData((prev) => {
if (!prev) return null;
const currentNode = prev.mapNodes[selectedNodeIndex];
if (!currentNode) return prev;
const nextScale = [...currentNode.scale] as [number, number, number];
nextScale[axis] = value;
if (!prev.mapTree || !currentNode.sourcePath) {
const mapNodes = [...prev.mapNodes];
mapNodes[selectedNodeIndex] = { ...currentNode, scale: nextScale };
return { ...prev, mapNodes };
}
const mapTree = updateTreeNodeAtPath(
prev.mapTree,
currentNode.sourcePath,
(node) => ({ ...node, scale: nextScale }),
);
return updateSceneDataTree(prev, mapTree);
});
},
[selectedNodeIndex, setSceneData],
);
const handleAddNode = useCallback(() => {
if (!sceneData) return;
if (!sceneData.mapTree) {
const newNode = createNewMapNode(newNodeName);
const mapNodes = [...sceneData.mapNodes, removeEditorMetadata(newNode)];
const selectedIndex = mapNodes.length - 1;
setSceneData({ ...sceneData, mapNodes });
setSelectedNodeIndex(selectedIndex);
setSelectedNodeIndexes([selectedIndex]);
return;
}
const mapTree = addTreeNode(
sceneData.mapTree,
createNewMapNode(newNodeName),
);
const nextSceneData = updateSceneDataTree(sceneData, mapTree);
const selectedIndex = nextSceneData.mapNodes.length - 1;
setSceneData(nextSceneData);
setSelectedNodeIndex(selectedIndex);
setSelectedNodeIndexes([selectedIndex]);
}, [newNodeName, sceneData, setSceneData]);
const handleDeleteSelectedNode = useCallback(() => {
if (!sceneData || selectedNodeIndex === null) return;
const currentNode = sceneData.mapNodes[selectedNodeIndex];
if (!currentNode) return;
if (!sceneData.mapTree || !currentNode.sourcePath) {
setSceneData({
...sceneData,
mapNodes: sceneData.mapNodes.filter(
(_node, index) => index !== selectedNodeIndex,
),
});
} else {
const mapTree = removeTreeNodeAtPath(
sceneData.mapTree,
currentNode.sourcePath,
);
setSceneData(updateSceneDataTree(sceneData, mapTree));
}
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
}, [sceneData, selectedNodeIndex, setSceneData]);
if (isMapLoading) {
return (
<div className="editor-container">
@@ -243,33 +429,39 @@ export function EditorPage(): React.JSX.Element {
<Canvas
camera={{ position: [0, 50, 100], fov: 50 }}
style={{ width: "100%", height: "100%" }}
onCreated={({ gl }) => {
gl.setClearColor("#050505");
gl={{
powerPreference: "high-performance",
antialias: true,
stencil: false,
}}
>
<EditorSceneLoadingTracker
onLoadingStateChange={handleSceneLoadingStateChange}
<EditorWebGLContextLogger />
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={handleSelectNode}
onToggleNodeSelection={handleToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
snapToTerrain={snapToTerrain}
lockTerrainSelection={lockTerrainSelection}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
onNodeTransform={handleNodeTransform}
snapAllToTerrainRequest={snapAllToTerrainRequest}
onSnapAllToTerrain={handleSnapAllToTerrain}
onUndo={handleUndo}
onRedo={handleRedo}
resetCameraRequest={resetCameraRequest}
focusSelectedCameraRequest={focusSelectedCameraRequest}
isPlayerMode={isPlayerMode}
cinematicPreviewRequest={cinematicPreviewRequest}
onCinematicPreviewComplete={handleCinematicPreviewComplete}
/>
<Suspense fallback={null}>
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
onNodeTransform={handleNodeTransform}
onUndo={handleUndo}
onRedo={handleRedo}
isPlayerMode={isPlayerMode}
cinematicPreviewRequest={cinematicPreviewRequest}
onCinematicPreviewComplete={handleCinematicPreviewComplete}
/>
</Suspense>
</Canvas>
<SceneLoadingOverlay state={editorLoadingState} />
@@ -279,6 +471,7 @@ export function EditorPage(): React.JSX.Element {
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
mapNodes={sceneData.mapNodes}
nodesCount={sceneData.mapNodes.length}
selectedNodeName={
@@ -286,13 +479,34 @@ export function EditorPage(): React.JSX.Element {
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
selectedNodeScale={
selectedNodeIndex !== null && sceneData.mapNodes[selectedNodeIndex]
? sceneData.mapNodes[selectedNodeIndex].scale
: null
}
lockTerrainSelection={lockTerrainSelection}
onLockTerrainSelectionChange={handleTerrainSelectionLockChange}
isSelectionLocked={isSelectionLocked}
onSelectionLockToggle={handleSelectionLockToggle}
onClearSelection={handleClearSelection}
snapToTerrain={snapToTerrain}
onSnapToTerrainToggle={handleSnapToTerrainToggle}
onSnapAllToTerrain={handleSnapAllToTerrainRequest}
newNodeName={newNodeName}
onNewNodeNameChange={handleNewNodeNameChange}
onAddNode={handleAddNode}
onDeleteSelectedNode={handleDeleteSelectedNode}
onSelectedScaleChange={handleSelectedScaleChange}
undoCount={undoCount}
redoCount={redoCount}
onUndo={handleUndo}
onRedo={handleRedo}
cameraActionLabel={
selectedNodeIndex !== null && cameraViewMode === "home"
? "Center on object"
: "Reset camera"
}
onCameraAction={handleCameraAction}
onExportJson={handleExportJson}
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
onPlayerMode={handlePlayerMode}
+6 -8
View File
@@ -33,12 +33,10 @@ import {
AMBIENT_INTENSITY_MAX,
AMBIENT_INTENSITY_MIN,
AMBIENT_INTENSITY_STEP,
AMBIENT_LIGHT_COLOR,
LIGHTING_DEFAULTS,
SUN_INTENSITY_MAX,
SUN_INTENSITY_MIN,
SUN_INTENSITY_STEP,
SUN_LIGHT_COLOR,
SUN_X_MAX,
SUN_X_MIN,
SUN_X_STEP,
@@ -50,8 +48,8 @@ import {
SUN_Z_STEP,
} from "@/data/world/lightingConfig";
import {
GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
GAME_SCENE_SKY_FALLBACK_MODEL_PATH,
GAME_SCENE_SKY_FALLBACK_MODEL_SCALE,
GAME_SCENE_SKY_MODEL_PATH,
GAME_SCENE_SKY_MODEL_SCALE,
} from "@/data/world/environmentConfig";
@@ -337,8 +335,8 @@ function GalleryScene({
return (
<>
<SkyModel
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
fallbackModelPath={GAME_SCENE_SKY_FALLBACK_MODEL_PATH}
fallbackScale={GAME_SCENE_SKY_FALLBACK_MODEL_SCALE}
materialSide={THREE.DoubleSide}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
@@ -374,12 +372,12 @@ function GalleryLighting({
<>
<ambientLight
intensity={lighting.ambientIntensity}
color={AMBIENT_LIGHT_COLOR}
color={LIGHTING_DEFAULTS.ambientColor}
/>
<directionalLight
position={[lighting.sunX, lighting.sunY, lighting.sunZ]}
intensity={lighting.sunIntensity}
color={SUN_LIGHT_COLOR}
color={LIGHTING_DEFAULTS.sunColor}
/>
</>
);
+35 -4
View File
@@ -6,12 +6,11 @@ import { DialogMessage } from "@/components/ui/DialogMessage";
import { GameUI } from "@/components/ui/GameUI";
import { BienvenueDisplay, IntroUI } from "@/components/ui/IntroUI";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { useGameStore } from "@/managers/stores/useGameStore";
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
import {
INITIAL_SCENE_LOADING_STATE,
type SceneLoadingState,
} from "@/types/world/sceneLoading";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger";
import { World } from "@/world/World";
export function HomePage(): React.JSX.Element {
@@ -51,11 +50,43 @@ export function HomePage(): React.JSX.Element {
[],
);
const handleCanvasCreated = useCallback(
({ gl }: { gl: THREE.WebGLRenderer }) => {
const canvas = gl.domElement;
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - GPU resources exhausted");
};
const handleContextRestored = () => {
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
logger.info("WebGL", "Context restored");
};
canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener("webglcontextrestored", handleContextRestored);
},
[],
);
return (
<HandTrackingProvider>
<Canvas
camera={{ position: [85, 60, 85], fov: 42 }}
shadows={{ type: THREE.PCFShadowMap }}
gl={{
powerPreference: "high-performance",
antialias: true,
stencil: false,
}}
onCreated={handleCanvasCreated}
>
<Suspense fallback={null}>
<World onLoadingStateChange={handleSceneLoadingStateChange} />
File diff suppressed because it is too large Load Diff