fix: stabilize game scene loading and player spawn

This commit is contained in:
Tom Boullay
2026-05-11 23:52:57 +02:00
parent e05c67ee73
commit ffca1e9e5f
22 changed files with 202 additions and 110 deletions
+1 -1
View File
@@ -11,5 +11,5 @@ export const PLAYER_MAX_DELTA = 0.05;
export const PLAYER_ACCELERATION_MULTIPLIER = 9;
export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 100, 0];
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0];
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
+16 -1
View File
@@ -10,7 +10,9 @@ interface UseWorldSceneLoadingOptions {
interface UseWorldSceneLoadingResult {
octree: Octree | null;
gameplayReady: boolean;
showGameStage: boolean;
handleGameStageLoaded: () => void;
handleGameMapLoaded: () => void;
handleOctreeReady: (octree: Octree) => void;
}
@@ -21,15 +23,26 @@ export function useWorldSceneLoading({
}: UseWorldSceneLoadingOptions): UseWorldSceneLoadingResult {
const [octree, setOctree] = useState<Octree | null>(null);
const [gameMapLoaded, setGameMapLoaded] = useState(false);
const [gameStageLoaded, setGameStageLoaded] = useState(false);
const showGameStage = sceneMode === "game" && gameMapLoaded;
const gameplayReady = showGameStage && gameStageLoaded && octree !== null;
const sceneReady =
(sceneMode === "game" && gameMapLoaded) ||
(sceneMode === "game" && gameplayReady) ||
(sceneMode === "physics" && octree !== null);
const handleGameMapLoaded = useCallback(() => {
setGameMapLoaded(true);
}, []);
const handleGameStageLoaded = useCallback(() => {
setGameStageLoaded(true);
onLoadingStateChange?.({
currentStep: "Initialisation gameplay",
progress: 0.96,
status: "loading",
});
}, [onLoadingStateChange]);
const handleOctreeReady = useCallback(
(nextOctree: Octree) => {
setOctree(nextOctree);
@@ -74,7 +87,9 @@ export function useWorldSceneLoading({
return {
octree,
gameplayReady,
showGameStage,
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
};
+4 -4
View File
@@ -18,13 +18,13 @@ export function HomePage(): React.JSX.Element {
const handleSceneLoadingStateChange = useCallback(
(nextState: SceneLoadingState) => {
setSceneLoadingState((currentState) => {
const shouldRestartProgress = currentState.status === "ready";
if (currentState.status === "ready" && nextState.status === "loading") {
return currentState;
}
return {
...nextState,
progress: shouldRestartProgress
? nextState.progress
: Math.max(currentState.progress, nextState.progress),
progress: Math.max(currentState.progress, nextState.progress),
};
});
},
+4 -1
View File
@@ -22,6 +22,7 @@ export function GameCinematics(): null {
const playedCinematicsRef = useRef(new Set<string>());
const timelineRef = useRef<gsap.core.Timeline | null>(null);
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
const startedAtRef = useRef<number | null>(null);
useEffect(() => {
let mounted = true;
@@ -59,7 +60,9 @@ export function GameCinematics(): null {
useFrame(({ clock }) => {
if (!manifest) return;
const elapsedTime = clock.getElapsedTime();
startedAtRef.current ??= clock.getElapsedTime();
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
manifest.cinematics.forEach((cinematic) => {
if (cinematic.timecode === undefined) return;
+4 -1
View File
@@ -12,6 +12,7 @@ export function GameDialogues(): null {
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
const playedDialoguesRef = useRef(new Set<string>());
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
const startedAtRef = useRef<number | null>(null);
useEffect(() => {
let mounted = true;
@@ -38,7 +39,9 @@ export function GameDialogues(): null {
useFrame(({ clock }) => {
if (!manifest) return;
const elapsedTime = clock.getElapsedTime();
startedAtRef.current ??= clock.getElapsedTime();
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
manifest.dialogues.forEach((dialogue) => {
if (dialogue.timecode === undefined) return;
+83 -47
View File
@@ -1,5 +1,12 @@
import type { ReactNode } from "react";
import { Component, Suspense, useEffect, useState } from "react";
import {
Component,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { GameMapCollision } from "@/world/GameMapCollision";
@@ -20,6 +27,7 @@ interface ErrorBoundaryProps {
fallback: ReactNode;
modelUrl: string | null;
node: MapNode;
onSettled: () => void;
}
interface ErrorBoundaryState {
@@ -50,6 +58,7 @@ class ModelErrorBoundary extends Component<
},
error,
);
this.props.onSettled();
}
render(): ReactNode {
@@ -68,19 +77,39 @@ interface GameMapProps {
buildOctree?: boolean;
}
const MAP_RENDER_BATCH_SIZE = 12;
export function GameMap({
buildOctree = true,
onLoaded,
onLoadingStateChange,
onOctreeReady,
}: GameMapProps): React.JSX.Element {
const settledMapNodesRef = useRef(new Set<number>());
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;
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
const mapReady = mapLoaded && settledMapNodeCount >= mapNodes.length;
const handleMapNodeSettled = useCallback((index: number) => {
if (settledMapNodesRef.current.has(index)) return;
settledMapNodesRef.current.add(index);
setSettledMapNodeCount(settledMapNodesRef.current.size);
}, []);
const showEmptyMap = useCallback(
(currentStep: string) => {
setMapNodes([]);
setMapLoaded(true);
settledMapNodesRef.current.clear();
setSettledMapNodeCount(0);
onLoadingStateChange?.({
currentStep,
progress: 0.7,
status: "loading",
});
},
[onLoadingStateChange],
);
useEffect(() => {
onLoadingStateChange?.({
@@ -94,11 +123,7 @@ export function GameMap({
const sceneData = await loadMapSceneData();
if (!sceneData) {
logger.warn("GameMap", "map.json not found");
onLoadingStateChange?.({
currentStep: "Map introuvable",
progress: 1,
status: "loading",
});
showEmptyMap("Map introuvable");
return;
}
@@ -128,9 +153,10 @@ export function GameMap({
setMapNodes(loadedMapNodes);
setMapLoaded(true);
setVisibleNodeCount(0);
settledMapNodesRef.current.clear();
setSettledMapNodeCount(0);
onLoadingStateChange?.({
currentStep: "Montage progressif des models",
currentStep: "Chargement des modèles de la map",
progress: 0.25,
status: "loading",
});
@@ -138,63 +164,41 @@ export function GameMap({
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",
});
showEmptyMap("Erreur de chargement de la map");
}
};
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]);
}, [onLoadingStateChange, showEmptyMap]);
useEffect(() => {
if (mapNodes.length === 0) return;
const renderProgress =
mapNodes.length === 0 ? 1 : visibleNodeCount / mapNodes.length;
mapNodes.length === 0 ? 1 : settledMapNodeCount / mapNodes.length;
onLoadingStateChange?.({
currentStep: "Montage progressif des models",
currentStep: "Chargement des modèles de la map",
progress: 0.25 + renderProgress * 0.45,
status: "loading",
});
}, [mapNodes.length, onLoadingStateChange, visibleNodeCount]);
}, [mapNodes.length, onLoadingStateChange, settledMapNodeCount]);
return (
<>
<group>
{visibleMapNodes.map((mapNode, index) => (
{mapNodes.map((mapNode, index) => (
<ModelErrorBoundary
key={index}
fallback={<FallbackMapNode node={mapNode.node} />}
modelUrl={mapNode.modelUrl}
node={mapNode.node}
onSettled={() => handleMapNodeSettled(index)}
>
{mapNode.modelUrl ? (
<Suspense fallback={<FallbackMapNode node={mapNode.node} />}>
<ModelInstance
node={mapNode.node}
modelUrl={mapNode.modelUrl}
/>
</Suspense>
) : (
<FallbackMapNode node={mapNode.node} />
)}
<MapNodeInstance
node={mapNode.node}
modelUrl={mapNode.modelUrl}
onSettled={() => handleMapNodeSettled(index)}
/>
</ModelErrorBoundary>
))}
</group>
@@ -210,12 +214,40 @@ export function GameMap({
);
}
function MapNodeInstance({
node,
modelUrl,
onSettled,
}: {
node: MapNode;
modelUrl: string | null;
onSettled: () => void;
}): React.JSX.Element {
useEffect(() => {
if (modelUrl !== null) return;
onSettled();
}, [modelUrl, onSettled]);
if (!modelUrl) {
return <FallbackMapNode node={node} />;
}
return (
<Suspense fallback={<FallbackMapNode node={node} />}>
<ModelInstance node={node} modelUrl={modelUrl} onLoaded={onSettled} />
</Suspense>
);
}
function ModelInstance({
node,
modelUrl,
onLoaded,
}: {
node: MapNode;
modelUrl: string;
onLoaded: () => void;
}): React.JSX.Element {
const { position, rotation, scale } = node;
const { scene } = useLoggedGLTF(modelUrl, {
@@ -226,6 +258,10 @@ function ModelInstance({
});
const sceneInstance = useClonedObject(scene);
useEffect(() => {
onLoaded();
}, [onLoaded]);
return (
<primitive
object={sceneInstance}
+18 -4
View File
@@ -102,11 +102,19 @@ export function GameMapCollision({
}: GameMapCollisionProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const settledCollisionNodesRef = useRef(new Set<number>());
const loadedNotifiedRef = useRef(false);
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
const collisionNodes = nodes.filter(isCollisionNode);
const collisionReady =
mapReady && settledCollisionNodeCount >= collisionNodes.length;
const notifyLoaded = useCallback(() => {
if (loadedNotifiedRef.current) return;
loadedNotifiedRef.current = true;
onLoaded?.();
}, [onLoaded]);
const handleCollisionNodeSettled = useCallback((index: number) => {
if (settledCollisionNodesRef.current.has(index)) return;
@@ -122,9 +130,9 @@ export function GameMapCollision({
status: "loading",
});
onOctreeReady(octree);
onLoaded?.();
notifyLoaded();
},
[onLoaded, onLoadingStateChange, onOctreeReady],
[notifyLoaded, onLoadingStateChange, onOctreeReady],
);
useOctreeGraphNode(
@@ -138,7 +146,12 @@ export function GameMapCollision({
if (!mapReady) return;
if (collisionNodes.length === 0) {
onLoaded?.();
notifyLoaded();
return;
}
if (collisionReady && !buildOctree) {
notifyLoaded();
return;
}
@@ -150,10 +163,11 @@ export function GameMapCollision({
status: "loading",
});
}, [
buildOctree,
collisionNodes.length,
collisionReady,
mapReady,
onLoaded,
notifyLoaded,
onLoadingStateChange,
]);
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { AudioManager } from "@/managers/AudioManager";
const GAME_MUSIC_PATH = "/sounds/musique/test.mp3";
const GAME_MUSIC_VOLUME = 0.45;
const GAME_MUSIC_VOLUME = 0.33;
export function GameMusic(): null {
useEffect(() => {
+38 -27
View File
@@ -1,4 +1,4 @@
import { Suspense } from "react";
import { Suspense, useEffect } from "react";
import { Physics } from "@react-three/rapier";
import {
PLAYER_SPAWN_POSITION_GAME,
@@ -8,6 +8,7 @@ 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 { useGameStore } from "@/managers/stores/useGameStore";
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
@@ -26,23 +27,19 @@ interface WorldProps {
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
}
function hasBootFlag(name: string): boolean {
if (typeof window === "undefined") return false;
return new URLSearchParams(window.location.search).has(name);
}
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const mainState = useGameStore((state) => state.mainState);
const { status, usageStatus } = useHandTrackingSnapshot();
const { octree, showGameStage, handleGameMapLoaded, handleOctreeReady } =
useWorldSceneLoading({ sceneMode, onLoadingStateChange });
const noCinematics = hasBootFlag("noCinematics");
const noDialogues = hasBootFlag("noDialogues");
const noMap = hasBootFlag("noMap");
const noMusic = hasBootFlag("noMusic");
const noOctree = hasBootFlag("noOctree");
const noPlayer = hasBootFlag("noPlayer");
const {
octree,
gameplayReady,
showGameStage,
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
const playerSpawnPosition =
sceneMode === "game"
? PLAYER_SPAWN_POSITION_GAME
@@ -50,6 +47,9 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
const showHandTrackingGloves =
sceneMode === "physics" ||
(status !== "idle" && usageStatus !== "inactive");
const spawnPlayer =
cameraMode !== "debug" &&
(sceneMode === "game" ? gameplayReady : octree !== null);
return (
<>
@@ -65,30 +65,41 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
{cameraMode === "debug" ? <DebugCameraControls /> : null}
{sceneMode === "game" ? (
<>
{noMusic ? null : <GameMusic />}
{noCinematics ? null : <GameCinematics />}
{noDialogues ? null : <GameDialogues />}
{noMap ? null : (
<GameMap
buildOctree={!noOctree}
onLoaded={handleGameMapLoaded}
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={handleOctreeReady}
/>
)}
{noMap || showGameStage ? (
<GameMap
onLoaded={handleGameMapLoaded}
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={handleOctreeReady}
/>
{showGameStage ? (
<Physics>
<GameStageLoaded onLoaded={handleGameStageLoaded} />
<GameStageContent />
</Physics>
) : null}
{spawnPlayer ? (
<>
<GameMusic />
{mainState === "outro" ? <GameCinematics /> : null}
<GameDialogues />
<Player octree={octree} spawnPosition={playerSpawnPosition} />
</>
) : null}
</>
) : (
<TestMap onOctreeReady={handleOctreeReady} />
)}
{cameraMode !== "debug" && !noPlayer ? (
{sceneMode !== "game" && spawnPlayer ? (
<Player octree={octree} spawnPosition={playerSpawnPosition} />
) : null}
</>
);
}
function GameStageLoaded({ onLoaded }: { onLoaded: () => void }): null {
useEffect(() => {
onLoaded();
}, [onLoaded]);
return null;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useLayoutEffect } from "react";
import { useThree } from "@react-three/fiber";
import type { Octree } from "three/addons/math/Octree.js";
import type { Vector3Tuple } from "@/types/three/three";
@@ -16,7 +16,7 @@ export function Player({
}: PlayerProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
useEffect(() => {
useLayoutEffect(() => {
camera.position.set(...spawnPosition);
}, [camera, spawnPosition]);
+19 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { Capsule } from "three/addons/math/Capsule.js";
@@ -57,6 +57,18 @@ const _up = new THREE.Vector3(0, 1, 0);
const _translateVec = new THREE.Vector3();
const _collisionCorrection = new THREE.Vector3();
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
return new Capsule(
new THREE.Vector3(
spawnPosition[0],
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
spawnPosition[2],
),
new THREE.Vector3(...spawnPosition),
PLAYER_CAPSULE_RADIUS,
);
}
function isPlayerInputLocked(): boolean {
return (
useSettingsStore.getState().isSettingsMenuOpen ||
@@ -94,16 +106,10 @@ export function PlayerController({
const velocity = useRef(new THREE.Vector3());
const onFloor = useRef(false);
const wantsJump = useRef(false);
const initializedRef = useRef(false);
const capsule = useRef(createSpawnCapsule(spawnPosition));
const capsule = useRef(
new Capsule(
new THREE.Vector3(0, PLAYER_CAPSULE_RADIUS, 0),
new THREE.Vector3(0, PLAYER_EYE_HEIGHT - PLAYER_CAPSULE_RADIUS, 0),
PLAYER_CAPSULE_RADIUS,
),
);
useEffect(() => {
useLayoutEffect(() => {
capsule.current.start.set(
spawnPosition[0],
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
@@ -114,6 +120,7 @@ export function PlayerController({
onFloor.current = false;
wantsJump.current = false;
camera.position.copy(capsule.current.end);
initializedRef.current = true;
}, [camera, spawnPosition]);
useEffect(() => {
@@ -200,6 +207,8 @@ export function PlayerController({
}, []);
useFrame((_, delta) => {
if (!initializedRef.current) return;
if (isPlayerInputLocked()) {
keys.current = { ...DEFAULT_KEYS };
velocity.current.set(0, 0, 0);