add: loading
This commit is contained in:
@@ -49,7 +49,8 @@ la-fabrik/
|
|||||||
└── src/
|
└── src/
|
||||||
├── world/ # Persistent 3D world composition
|
├── world/ # Persistent 3D world composition
|
||||||
│ ├── World.tsx # Active scene 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
|
│ ├── Lighting.tsx # Ambient, directional, point lights
|
||||||
│ ├── Environment.tsx # Scene background / sky model
|
│ ├── Environment.tsx # Scene background / sky model
|
||||||
│ ├── GameMusic.tsx # Game scene music lifecycle
|
│ ├── GameMusic.tsx # Game scene music lifecycle
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ This document describes the code that exists today in the repository.
|
|||||||
- debug helpers and debug camera mode
|
- debug helpers and debug camera mode
|
||||||
- either the map scene or the debug physics test scene
|
- either the map scene or the debug physics test scene
|
||||||
- the player rig when the active camera mode is `player`
|
- 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/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/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.
|
- `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:
|
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.
|
- `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.
|
- `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/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/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/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/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/DebugHelpers.tsx` mounts debug helpers.
|
||||||
- `src/components/debug/scene/DebugCameraControls.tsx` mounts the free debug camera.
|
- `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[]`.
|
- `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.
|
- 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 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
|
## Current Limitations
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ This document lists features that are implemented in the current codebase.
|
|||||||
|
|
||||||
- Fullscreen React Three Fiber scene
|
- Fullscreen React Three Fiber scene
|
||||||
- Main map scene loaded from `public/map.json` and matching `public/models/{name}/model.glb` or `model.gltf` assets
|
- 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`
|
- 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
|
- Rapier physics context available for production stage gameplay objects
|
||||||
- Ambient and directional lighting
|
- Ambient and directional lighting
|
||||||
@@ -17,7 +18,7 @@ This document lists features that are implemented in the current codebase.
|
|||||||
- Pointer lock mouse look
|
- Pointer lock mouse look
|
||||||
- Movement with `ZQSD`
|
- Movement with `ZQSD`
|
||||||
- Jumping
|
- Jumping
|
||||||
- Octree-based collision against the loaded map
|
- Octree-based collision against dedicated map collision nodes, currently scoped to `terrain`
|
||||||
|
|
||||||
## Interactions
|
## Interactions
|
||||||
|
|
||||||
@@ -68,7 +69,6 @@ This document lists features that are implemented in the current codebase.
|
|||||||
- zone system
|
- zone system
|
||||||
- cinematic system
|
- cinematic system
|
||||||
- dialogue system
|
- dialogue system
|
||||||
- loading flow
|
|
||||||
- minimap and mission HUD
|
- minimap and mission HUD
|
||||||
- full production separation between gameplay and debug scenes
|
- full production separation between gameplay and debug scenes
|
||||||
- production backend persistence for editor saves
|
- production backend persistence for editor saves
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const _snapTargetWorldPosition = new THREE.Vector3();
|
|||||||
const _handRaycaster = new THREE.Raycaster();
|
const _handRaycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
const HAND_GRAB_SCREEN_RADIUS = 0.04;
|
const HAND_GRAB_SCREEN_RADIUS = 0.04;
|
||||||
const HAND_DEPTH_SENSITIVITY = 4;
|
|
||||||
const HAND_HIT_OFFSETS: Array<[number, number]> = [
|
const HAND_HIT_OFFSETS: Array<[number, number]> = [
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[HAND_GRAB_SCREEN_RADIUS, 0],
|
[HAND_GRAB_SCREEN_RADIUS, 0],
|
||||||
@@ -144,8 +143,6 @@ export function GrabbableObject({
|
|||||||
const rbRef = useRef<RapierRigidBody>(null);
|
const rbRef = useRef<RapierRigidBody>(null);
|
||||||
const isHolding = useRef(false);
|
const isHolding = useRef(false);
|
||||||
const isHandHolding = useRef(false);
|
const isHandHolding = useRef(false);
|
||||||
const handHoldDistance = useRef<number | null>(null);
|
|
||||||
const handHoldStartZ = useRef<number | null>(null);
|
|
||||||
const snapTween = useRef<gsap.core.Tween | null>(null);
|
const snapTween = useRef<gsap.core.Tween | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -270,8 +267,6 @@ export function GrabbableObject({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
isHandHolding.current = Boolean(hit);
|
isHandHolding.current = Boolean(hit);
|
||||||
handHoldDistance.current = hit ? GRAB_HOLD_DISTANCE_DEFAULT : null;
|
|
||||||
handHoldStartZ.current = hit ? fistHand.z : null;
|
|
||||||
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
|
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -279,28 +274,15 @@ export function GrabbableObject({
|
|||||||
snapToNearestTarget();
|
snapToNearestTarget();
|
||||||
}
|
}
|
||||||
isHandHolding.current = false;
|
isHandHolding.current = false;
|
||||||
handHoldDistance.current = null;
|
|
||||||
handHoldStartZ.current = null;
|
|
||||||
InteractionManager.getInstance().setHandHolding(false);
|
InteractionManager.getInstance().setHandHolding(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isHolding.current && !isHandHolding.current) return;
|
if (!isHolding.current && !isHandHolding.current) return;
|
||||||
|
|
||||||
if (fistHand && isHandHolding.current) {
|
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
|
_holdTarget
|
||||||
.copy(_cameraPos)
|
.copy(_cameraPos)
|
||||||
.addScaledVector(_handDirection, holdDistance);
|
.addScaledVector(_handDirection, grabDebugParams.holdDistance);
|
||||||
} else {
|
} else {
|
||||||
camera.getWorldDirection(_holdTarget);
|
camera.getWorldDirection(_holdTarget);
|
||||||
_holdTarget
|
_holdTarget
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="scene-loading-overlay__content">
|
||||||
|
<strong>{state.currentStep}</strong>
|
||||||
|
<div className="scene-loading-overlay__track">
|
||||||
|
<span style={{ width: `${progress}%` }} />
|
||||||
|
<em>{progress}%</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export function useOctreeGraphNode(
|
|||||||
graphNodeRef: RefObject<Object3D | null>,
|
graphNodeRef: RefObject<Object3D | null>,
|
||||||
onOctreeReady: OctreeReadyHandler,
|
onOctreeReady: OctreeReadyHandler,
|
||||||
rebuildKey: string | number = 0,
|
rebuildKey: string | number = 0,
|
||||||
|
enabled = true,
|
||||||
): void {
|
): void {
|
||||||
const octreeBuilt = useRef(false);
|
const octreeBuilt = useRef(false);
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ export function useOctreeGraphNode(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const graphNode = graphNodeRef.current;
|
const graphNode = graphNodeRef.current;
|
||||||
if (octreeBuilt.current || !graphNode) return;
|
if (!enabled || octreeBuilt.current || !graphNode) return;
|
||||||
octreeBuilt.current = true;
|
octreeBuilt.current = true;
|
||||||
|
|
||||||
graphNode.updateMatrixWorld(true);
|
graphNode.updateMatrixWorld(true);
|
||||||
@@ -25,5 +26,5 @@ export function useOctreeGraphNode(
|
|||||||
const octree = new Octree();
|
const octree = new Octree();
|
||||||
octree.fromGraphNode(graphNode);
|
octree.fromGraphNode(graphNode);
|
||||||
onOctreeReady(octree);
|
onOctreeReady(octree);
|
||||||
}, [graphNodeRef, onOctreeReady, rebuildKey]);
|
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Octree | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -397,6 +397,78 @@ canvas {
|
|||||||
letter-spacing: 0.03em;
|
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 panels */
|
||||||
.debug-overlay-layout {
|
.debug-overlay-layout {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,17 +1,53 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
|
import { useProgress } from "@react-three/drei";
|
||||||
import { EditorControls } from "@/components/editor/EditorControls";
|
import { EditorControls } from "@/components/editor/EditorControls";
|
||||||
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
||||||
|
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||||
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
||||||
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
||||||
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
|
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";
|
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
|
||||||
|
|
||||||
|
interface EditorSceneLoadingTrackerProps {
|
||||||
|
onLoadingStateChange: SceneLoadingChangeHandler;
|
||||||
|
}
|
||||||
|
|
||||||
function serializeMapNodes(sceneData: SceneData): string {
|
function serializeMapNodes(sceneData: SceneData): string {
|
||||||
return JSON.stringify(sceneData.mapNodes, null, 2);
|
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 {
|
export function EditorPage(): React.JSX.Element {
|
||||||
const {
|
const {
|
||||||
hasMapJson,
|
hasMapJson,
|
||||||
@@ -28,6 +64,35 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
const [transformMode, setTransformMode] =
|
const [transformMode, setTransformMode] =
|
||||||
useState<TransformMode>("translate");
|
useState<TransformMode>("translate");
|
||||||
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
||||||
|
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||||
|
{
|
||||||
|
...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 {
|
const {
|
||||||
undoCount,
|
undoCount,
|
||||||
@@ -103,10 +168,7 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
if (isMapLoading) {
|
if (isMapLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="editor-container">
|
<div className="editor-container">
|
||||||
<div className="editor-loading">
|
<SceneLoadingOverlay state={editorLoadingState} />
|
||||||
<h2>Chargement de l'éditeur...</h2>
|
|
||||||
<p>Vérification de map.json dans public/</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -157,6 +219,10 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
gl.setClearColor("#050505");
|
gl.setClearColor("#050505");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<EditorSceneLoadingTracker
|
||||||
|
onLoadingStateChange={handleSceneLoadingStateChange}
|
||||||
|
/>
|
||||||
|
<Suspense fallback={null}>
|
||||||
<EditorScene
|
<EditorScene
|
||||||
sceneData={sceneData!}
|
sceneData={sceneData!}
|
||||||
selectedNodeIndex={selectedNodeIndex}
|
selectedNodeIndex={selectedNodeIndex}
|
||||||
@@ -172,8 +238,11 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
isPlayerMode={isPlayerMode}
|
isPlayerMode={isPlayerMode}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
|
<SceneLoadingOverlay state={editorLoadingState} />
|
||||||
|
|
||||||
{sceneData && (
|
{sceneData && (
|
||||||
<EditorControls
|
<EditorControls
|
||||||
transformMode={transformMode}
|
transformMode={transformMode}
|
||||||
|
|||||||
+27
-2
@@ -1,12 +1,36 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense, useCallback, useState } from "react";
|
||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||||
import { GameUI } from "@/components/ui/GameUI";
|
import { GameUI } from "@/components/ui/GameUI";
|
||||||
|
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||||
|
import {
|
||||||
|
INITIAL_SCENE_LOADING_STATE,
|
||||||
|
type SceneLoadingState,
|
||||||
|
} from "@/types/world/sceneLoading";
|
||||||
import { World } from "@/world/World";
|
import { World } from "@/world/World";
|
||||||
|
|
||||||
export function HomePage(): React.JSX.Element {
|
export function HomePage(): React.JSX.Element {
|
||||||
|
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||||
|
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 (
|
return (
|
||||||
<HandTrackingProvider>
|
<HandTrackingProvider>
|
||||||
<Canvas
|
<Canvas
|
||||||
@@ -14,11 +38,12 @@ export function HomePage(): React.JSX.Element {
|
|||||||
shadows={{ type: THREE.PCFShadowMap }}
|
shadows={{ type: THREE.PCFShadowMap }}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<World />
|
<World onLoadingStateChange={handleSceneLoadingStateChange} />
|
||||||
<DebugPerf />
|
<DebugPerf />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
<GameUI />
|
<GameUI />
|
||||||
|
<SceneLoadingOverlay state={sceneLoadingState} />
|
||||||
</HandTrackingProvider>
|
</HandTrackingProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
+117
-21
@@ -1,9 +1,9 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Component, useEffect, useRef, useState } from "react";
|
import { Component, Suspense, useEffect, useState } from "react";
|
||||||
import * as THREE from "three";
|
|
||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
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 { logger } from "@/utils/core/Logger";
|
||||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||||
@@ -12,12 +12,13 @@ import type { OctreeReadyHandler } from "@/types/three/three";
|
|||||||
|
|
||||||
interface LoadedMapNode {
|
interface LoadedMapNode {
|
||||||
node: MapNode;
|
node: MapNode;
|
||||||
modelUrl: string;
|
modelUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
modelUrl: string;
|
fallback: ReactNode;
|
||||||
|
modelUrl: string | null;
|
||||||
node: MapNode;
|
node: MapNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ class ModelErrorBoundary extends Component<
|
|||||||
componentDidCatch(error: Error): void {
|
componentDidCatch(error: Error): void {
|
||||||
logModelLoadError(
|
logModelLoadError(
|
||||||
{
|
{
|
||||||
modelPath: this.props.modelUrl,
|
modelPath: this.props.modelUrl ?? `missing:${this.props.node.name}`,
|
||||||
scope: "GameMap.ModelInstance",
|
scope: "GameMap.ModelInstance",
|
||||||
position: this.props.node.position,
|
position: this.props.node.position,
|
||||||
rotation: this.props.node.rotation,
|
rotation: this.props.node.rotation,
|
||||||
@@ -53,7 +54,7 @@ class ModelErrorBoundary extends Component<
|
|||||||
|
|
||||||
render(): ReactNode {
|
render(): ReactNode {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return null;
|
return this.props.fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.props.children;
|
return this.props.children;
|
||||||
@@ -61,35 +62,62 @@ class ModelErrorBoundary extends Component<
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GameMapProps {
|
interface GameMapProps {
|
||||||
|
onLoaded?: (() => void) | undefined;
|
||||||
|
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||||
onOctreeReady: OctreeReadyHandler;
|
onOctreeReady: OctreeReadyHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
|
const MAP_RENDER_BATCH_SIZE = 12;
|
||||||
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
|
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
|
||||||
|
|
||||||
useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length);
|
export function GameMap({
|
||||||
|
onLoaded,
|
||||||
|
onLoadingStateChange,
|
||||||
|
onOctreeReady,
|
||||||
|
}: GameMapProps): React.JSX.Element {
|
||||||
|
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
|
||||||
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
|
const [visibleNodeCount, setVisibleNodeCount] = useState(0);
|
||||||
|
const visibleMapNodes = mapNodes.slice(0, visibleNodeCount);
|
||||||
|
const mapReady = mapLoaded && visibleNodeCount >= mapNodes.length;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
onLoadingStateChange?.({
|
||||||
|
currentStep: "Récupération blocking",
|
||||||
|
progress: 0.05,
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
|
|
||||||
const loadMap = async () => {
|
const loadMap = async () => {
|
||||||
try {
|
try {
|
||||||
const sceneData = await loadMapSceneData();
|
const sceneData = await loadMapSceneData();
|
||||||
if (!sceneData) {
|
if (!sceneData) {
|
||||||
logger.warn("GameMap", "map.json not found");
|
logger.warn("GameMap", "map.json not found");
|
||||||
|
onLoadingStateChange?.({
|
||||||
|
currentStep: "Map introuvable",
|
||||||
|
progress: 1,
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadedMapNodes = sceneData.mapNodes.flatMap((node) => {
|
onLoadingStateChange?.({
|
||||||
const modelUrl = sceneData.models.get(node.name);
|
currentStep: "Importation des models",
|
||||||
return modelUrl ? [{ node, modelUrl }] : [];
|
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) {
|
if (missingModelCount > 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"GameMap",
|
"GameMap",
|
||||||
"Map nodes skipped because model files are missing",
|
"Map nodes rendered as fallback cubes because model files are missing",
|
||||||
{
|
{
|
||||||
missingModelCount,
|
missingModelCount,
|
||||||
},
|
},
|
||||||
@@ -97,28 +125,85 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMapNodes(loadedMapNodes);
|
setMapNodes(loadedMapNodes);
|
||||||
|
setMapLoaded(true);
|
||||||
|
setVisibleNodeCount(0);
|
||||||
|
onLoadingStateChange?.({
|
||||||
|
currentStep: "Montage progressif des models",
|
||||||
|
progress: 0.25,
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("GameMap", "Error loading map", {
|
logger.error("GameMap", "Error loading map", {
|
||||||
error: error instanceof Error ? error : new Error(String(error)),
|
error: error instanceof Error ? error : new Error(String(error)),
|
||||||
});
|
});
|
||||||
|
onLoadingStateChange?.({
|
||||||
|
currentStep: "Erreur de chargement de la map",
|
||||||
|
progress: 1,
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadMap();
|
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 (
|
return (
|
||||||
<group ref={groupRef}>
|
<>
|
||||||
{mapNodes.map((mapNode, index) => (
|
<group>
|
||||||
|
{visibleMapNodes.map((mapNode, index) => (
|
||||||
<ModelErrorBoundary
|
<ModelErrorBoundary
|
||||||
key={index}
|
key={index}
|
||||||
|
fallback={<FallbackMapNode node={mapNode.node} />}
|
||||||
modelUrl={mapNode.modelUrl}
|
modelUrl={mapNode.modelUrl}
|
||||||
node={mapNode.node}
|
node={mapNode.node}
|
||||||
>
|
>
|
||||||
<ModelInstance node={mapNode.node} modelUrl={mapNode.modelUrl} />
|
{mapNode.modelUrl ? (
|
||||||
|
<Suspense fallback={<FallbackMapNode node={mapNode.node} />}>
|
||||||
|
<ModelInstance
|
||||||
|
node={mapNode.node}
|
||||||
|
modelUrl={mapNode.modelUrl}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<FallbackMapNode node={mapNode.node} />
|
||||||
|
)}
|
||||||
</ModelErrorBoundary>
|
</ModelErrorBoundary>
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
|
<GameMapCollision
|
||||||
|
mapReady={mapReady}
|
||||||
|
nodes={mapNodes}
|
||||||
|
onLoaded={onLoaded}
|
||||||
|
onLoadingStateChange={onLoadingStateChange}
|
||||||
|
onOctreeReady={onOctreeReady}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,3 +232,14 @@ function ModelInstance({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FallbackMapNode({ node }: { node: MapNode }): React.JSX.Element {
|
||||||
|
const { position, rotation, scale } = node;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh position={position} rotation={rotation} scale={scale}>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshStandardMaterial color="#64748b" wireframe />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<THREE.Group>(null);
|
||||||
|
const settledCollisionNodesRef = useRef(new Set<number>());
|
||||||
|
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<OctreeReadyHandler>(
|
||||||
|
(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 (
|
||||||
|
<group ref={groupRef} visible={false}>
|
||||||
|
{mapReady
|
||||||
|
? collisionNodes.map((mapNode, index) => (
|
||||||
|
<CollisionErrorBoundary
|
||||||
|
key={`collision-${index}`}
|
||||||
|
node={mapNode.node}
|
||||||
|
modelUrl={mapNode.modelUrl}
|
||||||
|
onSettled={() => handleCollisionNodeSettled(index)}
|
||||||
|
>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CollisionModelInstance
|
||||||
|
node={mapNode.node}
|
||||||
|
modelUrl={mapNode.modelUrl}
|
||||||
|
onLoaded={() => handleCollisionNodeSettled(index)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</CollisionErrorBoundary>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<primitive
|
||||||
|
object={sceneInstance}
|
||||||
|
position={position}
|
||||||
|
rotation={rotation}
|
||||||
|
scale={scale}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-6
@@ -1,6 +1,4 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Physics } from "@react-three/rapier";
|
import { Physics } from "@react-three/rapier";
|
||||||
import type { Octree } from "three/addons/math/Octree.js";
|
|
||||||
import {
|
import {
|
||||||
PLAYER_SPAWN_POSITION_GAME,
|
PLAYER_SPAWN_POSITION_GAME,
|
||||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||||
@@ -8,6 +6,7 @@ import {
|
|||||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||||
|
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
||||||
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
|
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
|
||||||
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
||||||
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
||||||
@@ -18,12 +17,18 @@ import { GameMap } from "@/world/GameMap";
|
|||||||
import { GameStageContent } from "@/world/GameStageContent";
|
import { GameStageContent } from "@/world/GameStageContent";
|
||||||
import { Player } from "@/world/player/Player";
|
import { Player } from "@/world/player/Player";
|
||||||
import { TestMap } from "@/world/debug/TestMap";
|
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 cameraMode = useCameraMode();
|
||||||
const sceneMode = useSceneMode();
|
const sceneMode = useSceneMode();
|
||||||
const { status, usageStatus } = useHandTrackingSnapshot();
|
const { status, usageStatus } = useHandTrackingSnapshot();
|
||||||
const [octree, setOctree] = useState<Octree | null>(null);
|
const { octree, showGameStage, handleGameMapLoaded, handleOctreeReady } =
|
||||||
|
useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||||
const playerSpawnPosition =
|
const playerSpawnPosition =
|
||||||
sceneMode === "game"
|
sceneMode === "game"
|
||||||
? PLAYER_SPAWN_POSITION_GAME
|
? PLAYER_SPAWN_POSITION_GAME
|
||||||
@@ -47,13 +52,19 @@ export function World(): React.JSX.Element {
|
|||||||
{sceneMode === "game" ? (
|
{sceneMode === "game" ? (
|
||||||
<>
|
<>
|
||||||
<GameMusic />
|
<GameMusic />
|
||||||
<GameMap onOctreeReady={setOctree} />
|
<GameMap
|
||||||
|
onLoaded={handleGameMapLoaded}
|
||||||
|
onLoadingStateChange={onLoadingStateChange}
|
||||||
|
onOctreeReady={handleOctreeReady}
|
||||||
|
/>
|
||||||
|
{showGameStage ? (
|
||||||
<Physics>
|
<Physics>
|
||||||
<GameStageContent />
|
<GameStageContent />
|
||||||
</Physics>
|
</Physics>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TestMap onOctreeReady={setOctree} />
|
<TestMap onOctreeReady={handleOctreeReady} />
|
||||||
)}
|
)}
|
||||||
{cameraMode !== "debug" ? (
|
{cameraMode !== "debug" ? (
|
||||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||||
|
|||||||
Reference in New Issue
Block a user