From 2753e15ec7747a57c1642f6ad2930970a7a80484 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 11 May 2026 11:11:46 +0200 Subject: [PATCH] add: loading --- README.md | 3 +- docs/technical/architecture.md | 11 +- docs/user/features.md | 4 +- .../three/interaction/GrabbableObject.tsx | 20 +- src/components/ui/SceneLoadingOverlay.tsx | 27 +++ src/hooks/three/useOctreeGraphNode.ts | 5 +- src/hooks/world/useWorldSceneLoading.ts | 81 +++++++ src/index.css | 72 ++++++ src/pages/editor/page.tsx | 107 +++++++-- src/pages/page.tsx | 29 ++- src/types/world/sceneLoading.ts | 15 ++ src/world/GameMap.tsx | 154 ++++++++++--- src/world/GameMapCollision.tsx | 212 ++++++++++++++++++ src/world/World.tsx | 29 ++- 14 files changed, 683 insertions(+), 86 deletions(-) create mode 100644 src/components/ui/SceneLoadingOverlay.tsx create mode 100644 src/hooks/world/useWorldSceneLoading.ts create mode 100644 src/types/world/sceneLoading.ts create mode 100644 src/world/GameMapCollision.tsx diff --git a/README.md b/README.md index 718615a..71b71d0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ la-fabrik/ └── src/ ├── world/ # Persistent 3D world composition │ ├── World.tsx # Active scene composition - │ ├── GameMap.tsx # Map loading and octree collision + │ ├── GameMap.tsx # Map loading and progressive rendering + │ ├── GameMapCollision.tsx # Collision-only octree source │ ├── Lighting.tsx # Ambient, directional, point lights │ ├── Environment.tsx # Scene background / sky model │ ├── GameMusic.tsx # Game scene music lifecycle diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index 78ad9e4..cbbe8b9 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -14,7 +14,9 @@ This document describes the code that exists today in the repository. - debug helpers and debug camera mode - either the map scene or the debug physics test scene - the player rig when the active camera mode is `player` -- `src/world/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, and builds the collision octree. +- `src/hooks/world/useWorldSceneLoading.ts` owns the production scene loading state shared by `World`, `GameMap`, and the player octree readiness. +- `src/world/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, renders them progressively, and shows fallback cubes for missing models. +- `src/world/GameMapCollision.tsx` builds the player collision octree from dedicated collision nodes only. - `src/world/GameStageContent.tsx` is wrapped in Rapier `Physics` in the production game scene so stage gameplay objects can use physics without moving the map or player to Rapier. It now mounts reusable `RepairGame` instances for `bike`, `pylone`, and `ferme` mission states. - `src/world/debug/TestMap.tsx` provides a debug-oriented interaction and physics map with the existing grab/trigger/model-preview objects plus separate `Bike`, `Pylone`, and `Farm` repair playground zones. - `src/world/player/Player.tsx` mounts the camera and controller. @@ -24,7 +26,8 @@ This document describes the code that exists today in the repository. The project currently uses two collision layers with separate responsibilities: -- `GameMap` builds an octree used by the player controller for map collision. +- `GameMapCollision` builds an octree used by the player controller for map collision. +- The player octree must be built from a small collision-only subset of map nodes. It currently uses the `terrain` node only instead of traversing the full visible map, because building an octree from all rendered props can overload the browser renderer. - `GameStageContent` is wrapped in Rapier `Physics` for gameplay objects such as repair triggers, cases, grabbables, and future mission-specific objects. - `TestMap` owns its own Rapier `Physics` playground so repair gameplay can be tuned per mission state without depending on the production map layout. @@ -53,6 +56,7 @@ Keep the player and map octree outside the Rapier provider until there is a deli - `src/components/ui/debug/DebugOverlayLayout.tsx` mounts the compact HTML debug overlay when enabled from `lil-gui`. - `src/components/ui/debug/GameStateDebugPanel.tsx` exposes current game state, main/sub-state switching, previous/next step controls, and reset. - `src/components/ui/debug/HandTrackingDebugPanel.tsx` shows hand tracking status, usage, loaded glove model, hand count, and fist state while hand tracking is active. +- `src/components/ui/SceneLoadingOverlay.tsx` displays the fullscreen loading state for 3D scenes, including the production game scene, debug physics scene, and editor scene. - `src/components/three/handTracking/HandTrackingGlove.tsx` places the rigged `gant_l` and `gant_r` models on detected hands in the debug physics scene. - `src/components/debug/scene/DebugHelpers.tsx` mounts debug helpers. - `src/components/debug/scene/DebugCameraControls.tsx` mounts the free debug camera. @@ -85,7 +89,8 @@ Keep the player and map octree outside the Rapier provider until there is a deli - `public/map.json` is expected to be a `MapNode[]`. - Each map node `name` maps to `public/models/{name}/model.glb` when available, with `public/models/{name}/model.gltf` kept as fallback. - The editor renders a fallback cube for missing models. -- The game scene filters out nodes whose model cannot be resolved. +- The game scene renders fallback cubes for nodes whose model cannot be resolved. +- The game scene currently uses `terrain` as the collision source for the player octree. Additional collision nodes should be explicit lightweight collision assets, not arbitrary visible decoration models. ## Current Limitations diff --git a/docs/user/features.md b/docs/user/features.md index c86c1b0..4229a31 100644 --- a/docs/user/features.md +++ b/docs/user/features.md @@ -6,6 +6,7 @@ This document lists features that are implemented in the current codebase. - Fullscreen React Three Fiber scene - Main map scene loaded from `public/map.json` and matching `public/models/{name}/model.glb` or `model.gltf` assets +- Minimal fullscreen scene loading overlay for 3D scenes, with a global progress bar used by the production map, debug physics scene, and editor scene - Debug physics test scene selectable from the debug panel, including grab/trigger tests, an animated model preview, and separate repair playground zones for `bike`, `pylone`, and `ferme` - Rapier physics context available for production stage gameplay objects - Ambient and directional lighting @@ -17,7 +18,7 @@ This document lists features that are implemented in the current codebase. - Pointer lock mouse look - Movement with `ZQSD` - Jumping -- Octree-based collision against the loaded map +- Octree-based collision against dedicated map collision nodes, currently scoped to `terrain` ## Interactions @@ -68,7 +69,6 @@ This document lists features that are implemented in the current codebase. - zone system - cinematic system - dialogue system -- loading flow - minimap and mission HUD - full production separation between gameplay and debug scenes - production backend persistence for editor saves diff --git a/src/components/three/interaction/GrabbableObject.tsx b/src/components/three/interaction/GrabbableObject.tsx index 9550e23..c3dcf03 100644 --- a/src/components/three/interaction/GrabbableObject.tsx +++ b/src/components/three/interaction/GrabbableObject.tsx @@ -66,7 +66,6 @@ const _snapTargetWorldPosition = new THREE.Vector3(); const _handRaycaster = new THREE.Raycaster(); const HAND_GRAB_SCREEN_RADIUS = 0.04; -const HAND_DEPTH_SENSITIVITY = 4; const HAND_HIT_OFFSETS: Array<[number, number]> = [ [0, 0], [HAND_GRAB_SCREEN_RADIUS, 0], @@ -144,8 +143,6 @@ export function GrabbableObject({ const rbRef = useRef(null); const isHolding = useRef(false); const isHandHolding = useRef(false); - const handHoldDistance = useRef(null); - const handHoldStartZ = useRef(null); const snapTween = useRef(null); useEffect(() => { @@ -270,8 +267,6 @@ export function GrabbableObject({ : null; isHandHolding.current = Boolean(hit); - handHoldDistance.current = hit ? GRAB_HOLD_DISTANCE_DEFAULT : null; - handHoldStartZ.current = hit ? fistHand.z : null; InteractionManager.getInstance().setHandHolding(isHandHolding.current); } } else { @@ -279,28 +274,15 @@ export function GrabbableObject({ snapToNearestTarget(); } isHandHolding.current = false; - handHoldDistance.current = null; - handHoldStartZ.current = null; InteractionManager.getInstance().setHandHolding(false); } if (!isHolding.current && !isHandHolding.current) return; if (fistHand && isHandHolding.current) { - const depthOffset = - handHoldStartZ.current === null - ? 0 - : (fistHand.z - handHoldStartZ.current) * HAND_DEPTH_SENSITIVITY; - const holdDistance = THREE.MathUtils.clamp( - (handHoldDistance.current ?? grabDebugParams.holdDistance) + - depthOffset, - GRAB_HOLD_DISTANCE_MIN, - GRAB_HOLD_DISTANCE_MAX, - ); - _holdTarget .copy(_cameraPos) - .addScaledVector(_handDirection, holdDistance); + .addScaledVector(_handDirection, grabDebugParams.holdDistance); } else { camera.getWorldDirection(_holdTarget); _holdTarget diff --git a/src/components/ui/SceneLoadingOverlay.tsx b/src/components/ui/SceneLoadingOverlay.tsx new file mode 100644 index 0000000..d5c66c9 --- /dev/null +++ b/src/components/ui/SceneLoadingOverlay.tsx @@ -0,0 +1,27 @@ +import type { SceneLoadingState } from "@/types/world/sceneLoading"; + +interface SceneLoadingOverlayProps { + state: SceneLoadingState; +} + +export function SceneLoadingOverlay({ + state, +}: SceneLoadingOverlayProps): React.JSX.Element | null { + const isReady = state.status === "ready"; + const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100); + + return ( +
+
+ {state.currentStep} +
+ + {progress}% +
+
+
+ ); +} diff --git a/src/hooks/three/useOctreeGraphNode.ts b/src/hooks/three/useOctreeGraphNode.ts index 4706fd0..5d447ce 100644 --- a/src/hooks/three/useOctreeGraphNode.ts +++ b/src/hooks/three/useOctreeGraphNode.ts @@ -8,6 +8,7 @@ export function useOctreeGraphNode( graphNodeRef: RefObject, onOctreeReady: OctreeReadyHandler, rebuildKey: string | number = 0, + enabled = true, ): void { const octreeBuilt = useRef(false); @@ -17,7 +18,7 @@ export function useOctreeGraphNode( useEffect(() => { const graphNode = graphNodeRef.current; - if (octreeBuilt.current || !graphNode) return; + if (!enabled || octreeBuilt.current || !graphNode) return; octreeBuilt.current = true; graphNode.updateMatrixWorld(true); @@ -25,5 +26,5 @@ export function useOctreeGraphNode( const octree = new Octree(); octree.fromGraphNode(graphNode); onOctreeReady(octree); - }, [graphNodeRef, onOctreeReady, rebuildKey]); + }, [enabled, graphNodeRef, onOctreeReady, rebuildKey]); } diff --git a/src/hooks/world/useWorldSceneLoading.ts b/src/hooks/world/useWorldSceneLoading.ts new file mode 100644 index 0000000..dffb8c5 --- /dev/null +++ b/src/hooks/world/useWorldSceneLoading.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useState } from "react"; +import type { Octree } from "three/addons/math/Octree.js"; +import type { SceneMode } from "@/types/debug/debug"; +import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; + +interface UseWorldSceneLoadingOptions { + onLoadingStateChange?: SceneLoadingChangeHandler | undefined; + sceneMode: SceneMode; +} + +interface UseWorldSceneLoadingResult { + octree: Octree | null; + showGameStage: boolean; + handleGameMapLoaded: () => void; + handleOctreeReady: (octree: Octree) => void; +} + +export function useWorldSceneLoading({ + onLoadingStateChange, + sceneMode, +}: UseWorldSceneLoadingOptions): UseWorldSceneLoadingResult { + const [octree, setOctree] = useState(null); + const [gameMapLoaded, setGameMapLoaded] = useState(false); + const showGameStage = sceneMode === "game" && gameMapLoaded; + const sceneReady = + (sceneMode === "game" && gameMapLoaded) || + (sceneMode === "physics" && octree !== null); + + const handleGameMapLoaded = useCallback(() => { + setGameMapLoaded(true); + }, []); + + const handleOctreeReady = useCallback( + (nextOctree: Octree) => { + setOctree(nextOctree); + onLoadingStateChange?.({ + currentStep: "Collision prête", + progress: 0.92, + status: "loading", + }); + }, + [onLoadingStateChange], + ); + + useEffect(() => { + onLoadingStateChange?.({ + currentStep: "Initialisation du jeu", + progress: 0, + status: "loading", + }); + }, [onLoadingStateChange, sceneMode]); + + useEffect(() => { + if (!sceneReady) return undefined; + + onLoadingStateChange?.({ + currentStep: "Gameplay prêt", + progress: 0.96, + status: "loading", + }); + + const timeoutId = window.setTimeout(() => { + onLoadingStateChange?.({ + currentStep: "Gameplay prêt", + progress: 1, + status: "ready", + }); + }, 150); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [onLoadingStateChange, sceneReady]); + + return { + octree, + showGameStage, + handleGameMapLoaded, + handleOctreeReady, + }; +} diff --git a/src/index.css b/src/index.css index 919e9e3..67f39df 100644 --- a/src/index.css +++ b/src/index.css @@ -397,6 +397,78 @@ canvas { letter-spacing: 0.03em; } +.scene-loading-overlay { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + width: 100vw; + height: 100vh; + background: #ffffff; + pointer-events: none; + opacity: 1; + transition: opacity 640ms ease; +} + +.scene-loading-overlay--ready { + opacity: 0; +} + +.scene-loading-overlay__content { + display: grid; + justify-items: center; + gap: 18px; + width: min(360px, calc(100vw - 48px)); + padding: 28px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 28px; + box-shadow: 0 24px 80px rgba(15, 23, 42, 0.12); +} + +.scene-loading-overlay strong { + color: #1e293b; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.45; + text-align: center; + text-transform: uppercase; +} + +.scene-loading-overlay__track { + position: relative; + overflow: hidden; + width: 100%; + height: 18px; + background: #e2e8f0; + border-radius: 999px; + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.04); +} + +.scene-loading-overlay__track span { + display: block; + height: 100%; + background: linear-gradient(90deg, #2563eb, #38bdf8); + border-radius: inherit; + transition: width 180ms ease; +} + +.scene-loading-overlay__track em { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #ffffff; + font-size: 11px; + font-style: normal; + font-weight: 700; + letter-spacing: 0.04em; + line-height: 1; + text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35); +} + /* Debug overlay panels */ .debug-overlay-layout { position: fixed; diff --git a/src/pages/editor/page.tsx b/src/pages/editor/page.tsx index fac7e24..8a78228 100644 --- a/src/pages/editor/page.tsx +++ b/src/pages/editor/page.tsx @@ -1,17 +1,53 @@ -import { useCallback, useState } from "react"; +import { Suspense, useCallback, useEffect, useState } from "react"; import { Canvas } from "@react-three/fiber"; +import { useProgress } from "@react-three/drei"; import { EditorControls } from "@/components/editor/EditorControls"; import { EditorScene } from "@/components/editor/scene/EditorScene"; +import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { useEditorHistory } from "@/hooks/editor/useEditorHistory"; import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData"; import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor"; +import { + INITIAL_SCENE_LOADING_STATE, + type SceneLoadingChangeHandler, + type SceneLoadingState, +} from "@/types/world/sceneLoading"; const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement"; +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(); + + useEffect(() => { + if (active) { + onLoadingStateChange({ + currentStep: "Importation des models", + progress: 0.2 + (progress / 100) * 0.7, + status: "loading", + }); + return; + } + + onLoadingStateChange({ + currentStep: "Gameplay prêt", + progress: 1, + status: "ready", + }); + }, [active, onLoadingStateChange, progress]); + + return null; +} + export function EditorPage(): React.JSX.Element { const { hasMapJson, @@ -28,6 +64,35 @@ export function EditorPage(): React.JSX.Element { const [transformMode, setTransformMode] = useState("translate"); const [isPlayerMode, setIsPlayerMode] = useState(false); + const [sceneLoadingState, setSceneLoadingState] = useState( + { + ...INITIAL_SCENE_LOADING_STATE, + currentStep: "Montage progressif des models", + progress: 0.2, + }, + ); + 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 + ? { + currentStep: "Récupération blocking", + progress: 0.08, + status: "loading" as const, + } + : sceneLoadingState; const { undoCount, @@ -103,10 +168,7 @@ export function EditorPage(): React.JSX.Element { if (isMapLoading) { return (
-
-

Chargement de l'éditeur...

-

Vérification de map.json dans public/

-
+
); } @@ -157,23 +219,30 @@ export function EditorPage(): React.JSX.Element { gl.setClearColor("#050505"); }} > - + + + + + {sceneData && ( ( + INITIAL_SCENE_LOADING_STATE, + ); + const handleSceneLoadingStateChange = useCallback( + (nextState: SceneLoadingState) => { + setSceneLoadingState((currentState) => { + const shouldRestartProgress = currentState.status === "ready"; + + return { + ...nextState, + progress: shouldRestartProgress + ? nextState.progress + : Math.max(currentState.progress, nextState.progress), + }; + }); + }, + [], + ); + return ( - + + ); } diff --git a/src/types/world/sceneLoading.ts b/src/types/world/sceneLoading.ts new file mode 100644 index 0000000..d14be03 --- /dev/null +++ b/src/types/world/sceneLoading.ts @@ -0,0 +1,15 @@ +export type SceneLoadingStatus = "loading" | "ready"; + +export interface SceneLoadingState { + currentStep: string; + progress: number; + status: SceneLoadingStatus; +} + +export type SceneLoadingChangeHandler = (state: SceneLoadingState) => void; + +export const INITIAL_SCENE_LOADING_STATE: SceneLoadingState = { + currentStep: "Initialisation du jeu", + progress: 0, + status: "loading", +}; diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 41d6424..663bd4c 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -1,9 +1,9 @@ import type { ReactNode } from "react"; -import { Component, useEffect, useRef, useState } from "react"; -import * as THREE from "three"; +import { Component, Suspense, useEffect, useState } from "react"; import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; -import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode"; +import { GameMapCollision } from "@/world/GameMapCollision"; +import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import { logger } from "@/utils/core/Logger"; import { loadMapSceneData } from "@/utils/map/loadMapSceneData"; import { logModelLoadError } from "@/utils/three/modelLoadLogger"; @@ -12,12 +12,13 @@ import type { OctreeReadyHandler } from "@/types/three/three"; interface LoadedMapNode { node: MapNode; - modelUrl: string; + modelUrl: string | null; } interface ErrorBoundaryProps { children: ReactNode; - modelUrl: string; + fallback: ReactNode; + modelUrl: string | null; node: MapNode; } @@ -41,7 +42,7 @@ class ModelErrorBoundary extends Component< componentDidCatch(error: Error): void { logModelLoadError( { - modelPath: this.props.modelUrl, + modelPath: this.props.modelUrl ?? `missing:${this.props.node.name}`, scope: "GameMap.ModelInstance", position: this.props.node.position, rotation: this.props.node.rotation, @@ -53,7 +54,7 @@ class ModelErrorBoundary extends Component< render(): ReactNode { if (this.state.hasError) { - return null; + return this.props.fallback; } return this.props.children; @@ -61,35 +62,62 @@ class ModelErrorBoundary extends Component< } interface GameMapProps { + onLoaded?: (() => void) | undefined; + onLoadingStateChange?: SceneLoadingChangeHandler | undefined; onOctreeReady: OctreeReadyHandler; } -export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element { - const [mapNodes, setMapNodes] = useState([]); - const groupRef = useRef(null); +const MAP_RENDER_BATCH_SIZE = 12; - useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length); +export function GameMap({ + onLoaded, + onLoadingStateChange, + onOctreeReady, +}: GameMapProps): React.JSX.Element { + const [mapNodes, setMapNodes] = useState([]); + const [mapLoaded, setMapLoaded] = useState(false); + const [visibleNodeCount, setVisibleNodeCount] = useState(0); + const visibleMapNodes = mapNodes.slice(0, visibleNodeCount); + const mapReady = mapLoaded && visibleNodeCount >= mapNodes.length; useEffect(() => { + onLoadingStateChange?.({ + currentStep: "Récupération blocking", + progress: 0.05, + status: "loading", + }); + const loadMap = async () => { try { const sceneData = await loadMapSceneData(); if (!sceneData) { logger.warn("GameMap", "map.json not found"); + onLoadingStateChange?.({ + currentStep: "Map introuvable", + progress: 1, + status: "loading", + }); return; } - const loadedMapNodes = sceneData.mapNodes.flatMap((node) => { - const modelUrl = sceneData.models.get(node.name); - return modelUrl ? [{ node, modelUrl }] : []; + onLoadingStateChange?.({ + currentStep: "Importation des models", + progress: 0.18, + status: "loading", }); - const missingModelCount = - sceneData.mapNodes.length - loadedMapNodes.length; + + const loadedMapNodes = sceneData.mapNodes.map((node) => { + const modelUrl = sceneData.models.get(node.name); + return { node, modelUrl: modelUrl ?? null }; + }); + const missingModelCount = loadedMapNodes.filter( + (mapNode) => mapNode.modelUrl === null, + ).length; if (missingModelCount > 0) { logger.warn( "GameMap", - "Map nodes skipped because model files are missing", + "Map nodes rendered as fallback cubes because model files are missing", { missingModelCount, }, @@ -97,28 +125,85 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element { } setMapNodes(loadedMapNodes); + setMapLoaded(true); + setVisibleNodeCount(0); + onLoadingStateChange?.({ + currentStep: "Montage progressif des models", + progress: 0.25, + status: "loading", + }); } catch (error) { logger.error("GameMap", "Error loading map", { error: error instanceof Error ? error : new Error(String(error)), }); + onLoadingStateChange?.({ + currentStep: "Erreur de chargement de la map", + progress: 1, + status: "loading", + }); } }; loadMap(); - }, []); + }, [onLoaded, onLoadingStateChange]); + + useEffect(() => { + if (mapNodes.length === 0 || visibleNodeCount >= mapNodes.length) return; + + const frameId = window.requestAnimationFrame(() => { + setVisibleNodeCount((current) => + Math.min(current + MAP_RENDER_BATCH_SIZE, mapNodes.length), + ); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [mapNodes.length, visibleNodeCount]); + + useEffect(() => { + if (mapNodes.length === 0) return; + + const renderProgress = + mapNodes.length === 0 ? 1 : visibleNodeCount / mapNodes.length; + onLoadingStateChange?.({ + currentStep: "Montage progressif des models", + progress: 0.25 + renderProgress * 0.45, + status: "loading", + }); + }, [mapNodes.length, onLoadingStateChange, visibleNodeCount]); return ( - - {mapNodes.map((mapNode, index) => ( - - - - ))} - + <> + + {visibleMapNodes.map((mapNode, index) => ( + } + modelUrl={mapNode.modelUrl} + node={mapNode.node} + > + {mapNode.modelUrl ? ( + }> + + + ) : ( + + )} + + ))} + + + ); } @@ -147,3 +232,14 @@ function ModelInstance({ /> ); } + +function FallbackMapNode({ node }: { node: MapNode }): React.JSX.Element { + const { position, rotation, scale } = node; + + return ( + + + + + ); +} diff --git a/src/world/GameMapCollision.tsx b/src/world/GameMapCollision.tsx new file mode 100644 index 0000000..06d785f --- /dev/null +++ b/src/world/GameMapCollision.tsx @@ -0,0 +1,212 @@ +import type { ReactNode } from "react"; +import { + Component, + Suspense, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import * as THREE from "three"; +import { useClonedObject } from "@/hooks/three/useClonedObject"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; +import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode"; +import type { MapNode } from "@/types/editor/editor"; +import type { OctreeReadyHandler } from "@/types/three/three"; +import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; +import { logModelLoadError } from "@/utils/three/modelLoadLogger"; + +export interface GameMapCollisionNode { + node: MapNode; + modelUrl: string | null; +} + +interface ResolvedGameMapCollisionNode { + node: MapNode; + modelUrl: string; +} + +interface GameMapCollisionProps { + mapReady: boolean; + nodes: readonly GameMapCollisionNode[]; + onLoaded?: (() => void) | undefined; + onLoadingStateChange?: SceneLoadingChangeHandler | undefined; + onOctreeReady: OctreeReadyHandler; +} + +interface CollisionErrorBoundaryProps { + children: ReactNode; + modelUrl: string; + node: MapNode; + onSettled: () => void; +} + +interface CollisionErrorBoundaryState { + hasError: boolean; +} + +const MAP_COLLISION_NODE_NAMES = new Set(["terrain"]); + +class CollisionErrorBoundary extends Component< + CollisionErrorBoundaryProps, + CollisionErrorBoundaryState +> { + constructor(props: CollisionErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): CollisionErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error): void { + logModelLoadError( + { + modelPath: this.props.modelUrl, + scope: "GameMapCollision.ModelInstance", + position: this.props.node.position, + rotation: this.props.node.rotation, + scale: this.props.node.scale, + }, + error, + ); + this.props.onSettled(); + } + + render(): ReactNode { + if (this.state.hasError) { + return null; + } + + return this.props.children; + } +} + +function isCollisionNode( + mapNode: GameMapCollisionNode, +): mapNode is ResolvedGameMapCollisionNode { + return ( + mapNode.modelUrl !== null && MAP_COLLISION_NODE_NAMES.has(mapNode.node.name) + ); +} + +export function GameMapCollision({ + mapReady, + nodes, + onLoaded, + onLoadingStateChange, + onOctreeReady, +}: GameMapCollisionProps): React.JSX.Element { + const groupRef = useRef(null); + const settledCollisionNodesRef = useRef(new Set()); + const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0); + const collisionNodes = nodes.filter(isCollisionNode); + const collisionReady = + mapReady && settledCollisionNodeCount >= collisionNodes.length; + + const handleCollisionNodeSettled = useCallback((index: number) => { + if (settledCollisionNodesRef.current.has(index)) return; + + settledCollisionNodesRef.current.add(index); + setSettledCollisionNodeCount(settledCollisionNodesRef.current.size); + }, []); + + const handleOctreeReady = useCallback( + (octree) => { + onLoadingStateChange?.({ + currentStep: "Collision prête", + progress: 0.92, + status: "loading", + }); + onOctreeReady(octree); + onLoaded?.(); + }, + [onLoaded, onLoadingStateChange, onOctreeReady], + ); + + useOctreeGraphNode( + groupRef, + handleOctreeReady, + collisionReady ? collisionNodes.length : 0, + collisionReady && collisionNodes.length > 0, + ); + + useEffect(() => { + if (!mapReady) return; + + if (collisionNodes.length === 0) { + onLoaded?.(); + return; + } + + if (collisionReady) return; + + onLoadingStateChange?.({ + currentStep: "Ajout de la collision", + progress: 0.86, + status: "loading", + }); + }, [ + collisionNodes.length, + collisionReady, + mapReady, + onLoaded, + onLoadingStateChange, + ]); + + return ( + + {mapReady + ? collisionNodes.map((mapNode, index) => ( + handleCollisionNodeSettled(index)} + > + + handleCollisionNodeSettled(index)} + /> + + + )) + : null} + + ); +} + +function CollisionModelInstance({ + node, + modelUrl, + onLoaded, +}: { + node: MapNode; + modelUrl: string; + onLoaded: () => void; +}): React.JSX.Element { + const { position, rotation, scale } = node; + const { scene } = useLoggedGLTF(modelUrl, { + scope: "GameMapCollision.ModelInstance", + position, + rotation, + scale, + }); + const sceneInstance = useClonedObject(scene); + + useEffect(() => { + onLoaded(); + }, [onLoaded]); + + return ( + + ); +} diff --git a/src/world/World.tsx b/src/world/World.tsx index fef41fa..2b3aa67 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -1,6 +1,4 @@ -import { useState } from "react"; import { Physics } from "@react-three/rapier"; -import type { Octree } from "three/addons/math/Octree.js"; import { PLAYER_SPAWN_POSITION_GAME, PLAYER_SPAWN_POSITION_PHYSICS, @@ -8,6 +6,7 @@ import { import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot"; +import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading"; import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls"; import { DebugHelpers } from "@/components/debug/scene/DebugHelpers"; import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove"; @@ -18,12 +17,18 @@ import { GameMap } from "@/world/GameMap"; import { GameStageContent } from "@/world/GameStageContent"; import { Player } from "@/world/player/Player"; import { TestMap } from "@/world/debug/TestMap"; +import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; -export function World(): React.JSX.Element { +interface WorldProps { + onLoadingStateChange?: SceneLoadingChangeHandler | undefined; +} + +export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { const cameraMode = useCameraMode(); const sceneMode = useSceneMode(); const { status, usageStatus } = useHandTrackingSnapshot(); - const [octree, setOctree] = useState(null); + const { octree, showGameStage, handleGameMapLoaded, handleOctreeReady } = + useWorldSceneLoading({ sceneMode, onLoadingStateChange }); const playerSpawnPosition = sceneMode === "game" ? PLAYER_SPAWN_POSITION_GAME @@ -47,13 +52,19 @@ export function World(): React.JSX.Element { {sceneMode === "game" ? ( <> - - - - + + {showGameStage ? ( + + + + ) : null} ) : ( - + )} {cameraMode !== "debug" ? (