From 7e99d455b47bd9cfba4287fd365424a81ffb83a9 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Tue, 28 Apr 2026 14:42:49 +0200 Subject: [PATCH] fix runtime map loading lifecycle --- src/hooks/useOctreeGraphNode.ts | 7 +++++- src/types/editor.ts | 2 +- src/utils/editor/loadEditorScene.ts | 5 +++-- src/utils/loadMapSceneData.ts | 3 ++- src/utils/mapNodeValidation.ts | 32 ++++++++++++++++++++++++++++ src/world/GameMap.tsx | 27 ++++++++++++++--------- vite.config.ts | 33 ++++------------------------- 7 files changed, 65 insertions(+), 44 deletions(-) create mode 100644 src/utils/mapNodeValidation.ts diff --git a/src/hooks/useOctreeGraphNode.ts b/src/hooks/useOctreeGraphNode.ts index c03458c..45baa99 100644 --- a/src/hooks/useOctreeGraphNode.ts +++ b/src/hooks/useOctreeGraphNode.ts @@ -7,9 +7,14 @@ import type { OctreeReadyHandler } from "@/types/3d"; export function useOctreeGraphNode( graphNodeRef: RefObject, onOctreeReady: OctreeReadyHandler, + rebuildKey: string | number = 0, ): void { const octreeBuilt = useRef(false); + useEffect(() => { + octreeBuilt.current = false; + }, [rebuildKey]); + useEffect(() => { const graphNode = graphNodeRef.current; if (octreeBuilt.current || !graphNode) return; @@ -20,5 +25,5 @@ export function useOctreeGraphNode( const octree = new Octree(); octree.fromGraphNode(graphNode); onOctreeReady(octree); - }, [graphNodeRef, onOctreeReady]); + }, [graphNodeRef, onOctreeReady, rebuildKey]); } diff --git a/src/types/editor.ts b/src/types/editor.ts index 89b601f..f6f2d88 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -1,4 +1,4 @@ -import type { Vector3Tuple } from "@/types/3d"; +import type { Vector3Tuple } from "./3d"; export interface MapNode { name: string; diff --git a/src/utils/editor/loadEditorScene.ts b/src/utils/editor/loadEditorScene.ts index 1ca8896..62e8e4c 100644 --- a/src/utils/editor/loadEditorScene.ts +++ b/src/utils/editor/loadEditorScene.ts @@ -1,4 +1,5 @@ -import type { MapNode, SceneData } from "@/types/editor"; +import type { SceneData } from "@/types/editor"; +import { parseMapNodes } from "@/utils/mapNodeValidation"; const MAP_JSON_PATH = "/map.json"; @@ -16,7 +17,7 @@ export async function createSceneDataFromFiles( throw new Error("Fichier map.json manquant à la racine du dossier"); } - const mapNodes: MapNode[] = JSON.parse(await mapFile.text()); + const mapNodes = parseMapNodes(JSON.parse(await mapFile.text())); const models = new Map(); for (const [path, file] of fileMap.entries()) { diff --git a/src/utils/loadMapSceneData.ts b/src/utils/loadMapSceneData.ts index 2f8f4f0..4735f07 100644 --- a/src/utils/loadMapSceneData.ts +++ b/src/utils/loadMapSceneData.ts @@ -1,4 +1,5 @@ import type { MapNode, SceneData } from "@/types/editor"; +import { parseMapNodes } from "@/utils/mapNodeValidation"; const MAP_JSON_PATH = "/map.json"; const MODEL_FILE_NAME = "model.gltf"; @@ -11,7 +12,7 @@ export async function loadMapSceneData(): Promise { return null; } - const mapNodes: MapNode[] = await response.json(); + const mapNodes = parseMapNodes(await response.json()); return createSceneData(mapNodes); } diff --git a/src/utils/mapNodeValidation.ts b/src/utils/mapNodeValidation.ts new file mode 100644 index 0000000..cbe8973 --- /dev/null +++ b/src/utils/mapNodeValidation.ts @@ -0,0 +1,32 @@ +import type { MapNode } from "../types/editor"; + +function isVector3Tuple(value: unknown): value is [number, number, number] { + return ( + Array.isArray(value) && + value.length === 3 && + value.every((item) => typeof item === "number" && Number.isFinite(item)) + ); +} + +export function isMapNode(value: unknown): value is MapNode { + if (typeof value !== "object" || value === null) { + return false; + } + + const node = value as Record; + return ( + typeof node.name === "string" && + typeof node.type === "string" && + isVector3Tuple(node.position) && + isVector3Tuple(node.rotation) && + isVector3Tuple(node.scale) + ); +} + +export function parseMapNodes(value: unknown): MapNode[] { + if (!Array.isArray(value) || !value.every(isMapNode)) { + throw new Error("Invalid map node data"); + } + + return value; +} diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 967c0a8..d96ce09 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -15,7 +15,7 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element { const [isLoading, setIsLoading] = useState(true); const groupRef = useRef(null); - useOctreeGraphNode(groupRef, onOctreeReady); + useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length); useEffect(() => { const loadMap = async () => { @@ -27,9 +27,19 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element { return; } - setMapNodes( - sceneData.mapNodes.filter((node) => sceneData.models.has(node.name)), + const loadedMapNodes = sceneData.mapNodes.filter((node) => + sceneData.models.has(node.name), ); + const missingModelCount = + sceneData.mapNodes.length - loadedMapNodes.length; + + if (missingModelCount > 0) { + console.warn( + `${missingModelCount} map nodes were skipped because their model files are missing.`, + ); + } + + setMapNodes(loadedMapNodes); } catch (error) { console.error("Error loading map:", error); } finally { @@ -40,15 +50,12 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element { loadMap(); }, []); - if (isLoading) { - return <>; - } - return ( - {mapNodes.map((node, index) => ( - - ))} + {!isLoading && + mapNodes.map((node, index) => ( + + ))} ); } diff --git a/vite.config.ts b/vite.config.ts index e91d93a..4a65037 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import { fileURLToPath } from "node:url"; import type { ServerResponse } from "node:http"; import type { Plugin } from "vite"; +import { parseMapNodes } from "./src/utils/mapNodeValidation"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); @@ -22,34 +23,6 @@ function sendJson( .end(JSON.stringify(body)); } -function isVector3(value: unknown): value is [number, number, number] { - return ( - Array.isArray(value) && - value.length === 3 && - value.every((item) => typeof item === "number" && Number.isFinite(item)) - ); -} - -function isMapNode(value: unknown): value is Record { - if (typeof value !== "object" || value === null) { - return false; - } - - const node = value as Record; - - return ( - typeof node.name === "string" && - typeof node.type === "string" && - isVector3(node.position) && - isVector3(node.rotation) && - isVector3(node.scale) - ); -} - -function isMapPayload(value: unknown): boolean { - return Array.isArray(value) && value.every(isMapNode); -} - const saveMapPlugin = (): Plugin => ({ name: "save-map-api", configureServer(server) { @@ -75,7 +48,9 @@ const saveMapPlugin = (): Plugin => ({ try { const data = JSON.parse(Buffer.concat(chunks).toString()); - if (!isMapPayload(data)) { + try { + parseMapNodes(data); + } catch { sendJson(res, 400, { error: "Invalid map payload" }); return; }