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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -31,7 +31,8 @@
"id": "narrateur_bienvenueaaltera",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3",
"subtitleCueIndex": 1
"subtitleCueIndex": 1,
"timecode": 0
},
{
"id": "narrateur_intro_prenom",
+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);