Merge branch 'feat/map-environment' of https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik into feat/map-environment
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
|
||||
|
||||
interface EcoleModelProps {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
|
||||
interface MergedMeshData {
|
||||
geometry: THREE.BufferGeometry;
|
||||
material: THREE.Material | THREE.Material[];
|
||||
}
|
||||
|
||||
interface GeometryGroup {
|
||||
geometries: THREE.BufferGeometry[];
|
||||
material: THREE.Material | THREE.Material[];
|
||||
}
|
||||
|
||||
function cloneMaterial(
|
||||
material: THREE.Material | THREE.Material[],
|
||||
): THREE.Material | THREE.Material[] {
|
||||
return Array.isArray(material)
|
||||
? material.map((item) => item.clone())
|
||||
: material.clone();
|
||||
}
|
||||
|
||||
function disposeMaterial(material: THREE.Material | THREE.Material[]): void {
|
||||
if (Array.isArray(material)) {
|
||||
for (const item of material) {
|
||||
item.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
function createGeometrySignature(geometry: THREE.BufferGeometry): string {
|
||||
const attributes = Object.entries(geometry.attributes)
|
||||
.map(([name, attribute]) => {
|
||||
return `${name}:${attribute.itemSize}:${attribute.normalized}`;
|
||||
})
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
return `${geometry.index ? "indexed" : "non-indexed"}:${attributes}`;
|
||||
}
|
||||
|
||||
function createMaterialKey(
|
||||
material: THREE.Material | THREE.Material[],
|
||||
): string {
|
||||
if (Array.isArray(material)) {
|
||||
return material.map((item) => item.uuid).join("|");
|
||||
}
|
||||
|
||||
return material.uuid;
|
||||
}
|
||||
|
||||
function createMergedMeshes(scene: THREE.Group): MergedMeshData[] {
|
||||
const groups = new Map<string, GeometryGroup>();
|
||||
|
||||
scene.updateMatrixWorld(true);
|
||||
scene.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
|
||||
const geometry = child.geometry.clone();
|
||||
geometry.applyMatrix4(child.matrixWorld);
|
||||
const material = child.material;
|
||||
const key = `${createMaterialKey(material)}:${createGeometrySignature(geometry)}`;
|
||||
const group = groups.get(key);
|
||||
|
||||
if (group) {
|
||||
group.geometries.push(geometry);
|
||||
return;
|
||||
}
|
||||
|
||||
groups.set(key, {
|
||||
geometries: [geometry],
|
||||
material: cloneMaterial(material),
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.values()].map((group) => {
|
||||
if (group.geometries.length === 1) {
|
||||
return {
|
||||
geometry: group.geometries[0] as THREE.BufferGeometry,
|
||||
material: group.material,
|
||||
};
|
||||
}
|
||||
|
||||
const geometry = mergeGeometries(group.geometries, false);
|
||||
|
||||
for (const sourceGeometry of group.geometries) {
|
||||
sourceGeometry.dispose();
|
||||
}
|
||||
|
||||
return {
|
||||
geometry,
|
||||
material: group.material,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function EcoleModel({
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
castShadow = true,
|
||||
receiveShadow = true,
|
||||
onLoaded,
|
||||
}: EcoleModelProps): React.JSX.Element {
|
||||
const { scene } = useGLTF(ECOLE_MODEL_PATH);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
const mergedMeshes = createMergedMeshes(scene);
|
||||
const meshes = mergedMeshes.map((meshData) => {
|
||||
const mesh = new THREE.Mesh(meshData.geometry, meshData.material);
|
||||
mesh.castShadow = castShadow;
|
||||
mesh.receiveShadow = receiveShadow;
|
||||
return mesh;
|
||||
});
|
||||
|
||||
for (const mesh of meshes) {
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
onLoaded?.();
|
||||
|
||||
return () => {
|
||||
for (const mesh of meshes) {
|
||||
group.remove(mesh);
|
||||
mesh.geometry.dispose();
|
||||
disposeMaterial(mesh.material);
|
||||
}
|
||||
};
|
||||
}, [castShadow, onLoaded, receiveShadow, scene]);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(ECOLE_MODEL_PATH);
|
||||
@@ -3,10 +3,10 @@ import { useGLTF } from "@react-three/drei";
|
||||
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { disposeObject3D } from "@/utils/three/dispose";
|
||||
|
||||
interface SkyModelProps {
|
||||
modelPath: string;
|
||||
fallbackColor?: string | undefined;
|
||||
fallbackModelPath?: string | undefined;
|
||||
fallbackScale?: number | undefined;
|
||||
scale?: number | undefined;
|
||||
@@ -28,6 +28,7 @@ interface SkyModelErrorBoundaryState {
|
||||
|
||||
const SKY_MODEL_SCALE = 1;
|
||||
const SKY_MODEL_RENDER_ORDER = -1000;
|
||||
const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
|
||||
const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb";
|
||||
|
||||
class SkyModelErrorBoundary extends Component<
|
||||
@@ -53,14 +54,22 @@ class SkyModelErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
export function SkyModel({
|
||||
fallbackColor,
|
||||
fallbackModelPath,
|
||||
fallbackScale = SKY_MODEL_SCALE,
|
||||
modelPath,
|
||||
scale = SKY_MODEL_SCALE,
|
||||
}: SkyModelProps): React.JSX.Element {
|
||||
const fallback = fallbackModelPath ? (
|
||||
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
|
||||
const colorFallback = fallbackColor ? (
|
||||
<color attach="background" args={[fallbackColor]} />
|
||||
) : null;
|
||||
const fallback = fallbackModelPath ? (
|
||||
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
|
||||
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
|
||||
</SkyModelErrorBoundary>
|
||||
) : (
|
||||
colorFallback
|
||||
);
|
||||
|
||||
return (
|
||||
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
|
||||
@@ -83,7 +92,7 @@ function SkyModelContent({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeObject3D(model);
|
||||
disposeSkyModelMaterials(model);
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
@@ -129,5 +138,20 @@ function createSkyMaterial<T extends THREE.Material>(material: T): T {
|
||||
return skyMaterial as T;
|
||||
}
|
||||
|
||||
useGLTF.preload("/models/skybox/model.gltf");
|
||||
function disposeSkyModelMaterials(model: THREE.Object3D): void {
|
||||
model.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
if (Array.isArray(object.material)) {
|
||||
for (const material of object.material) {
|
||||
material.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
object.material.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
useGLTF.preload(SKYBOX_MODEL_PATH);
|
||||
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { disposeObject3D } from "@/utils/three/dispose";
|
||||
|
||||
const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
||||
const TERRAIN_DEFAULT_POSITION: Vector3Tuple = [0, 0, 0];
|
||||
@@ -47,12 +46,6 @@ export function TerrainModel({
|
||||
return model;
|
||||
}, [scene, receiveShadow]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeObject3D(terrainModel);
|
||||
};
|
||||
}, [terrainModel]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoaded?.();
|
||||
}, [onLoaded]);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import type {
|
||||
RepairRuntime,
|
||||
SubtitleLanguage,
|
||||
} from "@/managers/stores/useSettingsStore";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
@@ -52,6 +54,7 @@ function VolumeSlider({
|
||||
}
|
||||
|
||||
export function GameSettingsMenu(): React.JSX.Element | null {
|
||||
const resetGame = useGameStore((state) => state.resetGame);
|
||||
const {
|
||||
isSettingsMenuOpen,
|
||||
musicVolume,
|
||||
@@ -93,6 +96,13 @@ export function GameSettingsMenu(): React.JSX.Element | null {
|
||||
window.location.assign("/");
|
||||
};
|
||||
|
||||
const handleRestart = (): void => {
|
||||
resetGame();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const showDebugRestart = isDebugEnabled();
|
||||
|
||||
return (
|
||||
<div className="game-settings-menu" role="dialog" aria-modal="true">
|
||||
<div className="game-settings-menu__panel">
|
||||
@@ -190,6 +200,17 @@ export function GameSettingsMenu(): React.JSX.Element | null {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showDebugRestart ? (
|
||||
<button
|
||||
className="game-settings-menu__restart"
|
||||
type="button"
|
||||
onClick={handleRestart}
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Recommencer
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="game-settings-menu__quit"
|
||||
type="button"
|
||||
|
||||
@@ -2,4 +2,5 @@ export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf";
|
||||
export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb";
|
||||
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
|
||||
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
|
||||
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
|
||||
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import {
|
||||
MAP_PERFORMANCE_GROUP_NAMES,
|
||||
MAP_PERFORMANCE_MODEL_NAMES,
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
|
||||
function toLabel(value: string): string {
|
||||
return value
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function useMapPerformanceDebug(): void {
|
||||
useDebugFolder("Performance / Map", (folder) => {
|
||||
const {
|
||||
groups,
|
||||
models,
|
||||
setGroupVisible,
|
||||
setModelVisible,
|
||||
resetVisibility,
|
||||
} = useMapPerformanceStore.getState();
|
||||
const controls = {
|
||||
...groups,
|
||||
...models,
|
||||
reset: () => {
|
||||
resetVisibility();
|
||||
for (const key of [
|
||||
...MAP_PERFORMANCE_GROUP_NAMES,
|
||||
...MAP_PERFORMANCE_MODEL_NAMES,
|
||||
]) {
|
||||
controls[key] = true;
|
||||
}
|
||||
folder.controllersRecursive().forEach((controller) => {
|
||||
controller.updateDisplay();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
for (const group of MAP_PERFORMANCE_GROUP_NAMES) {
|
||||
folder
|
||||
.add(controls, group)
|
||||
.name(toLabel(group))
|
||||
.onChange((visible: boolean) => setGroupVisible(group, visible));
|
||||
}
|
||||
|
||||
for (const model of MAP_PERFORMANCE_MODEL_NAMES) {
|
||||
folder
|
||||
.add(controls, model)
|
||||
.name(toLabel(model))
|
||||
.onChange((visible: boolean) => setModelVisible(model, visible));
|
||||
}
|
||||
|
||||
folder.add(controls, "reset").name("Reset visibility");
|
||||
});
|
||||
}
|
||||
@@ -662,6 +662,7 @@ canvas {
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button,
|
||||
.game-settings-menu__restart,
|
||||
.game-settings-menu__quit {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
@@ -680,6 +681,16 @@ canvas {
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
.game-settings-menu__restart {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
border-color: rgba(96, 165, 250, 0.35);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.game-settings-menu__quit {
|
||||
margin-top: 8px;
|
||||
border-color: rgba(248, 113, 113, 0.35);
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { create } from "zustand";
|
||||
import type { GameStep } from "@/types/game";
|
||||
import { GAME_STEPS, type GameStep } from "@/types/game";
|
||||
import {
|
||||
isRepairMissionId,
|
||||
isMissionStep,
|
||||
getNextMissionStep,
|
||||
getPreviousMissionStep,
|
||||
type MissionStep,
|
||||
type RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import {
|
||||
clearDebugGameStateCookie,
|
||||
readDebugGameStateCookie,
|
||||
writeDebugGameStateCookie,
|
||||
} from "@/utils/debug/debugGameStateCookie";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
|
||||
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
|
||||
export type { MissionStep, RepairMissionId };
|
||||
@@ -30,7 +37,7 @@ interface MissionFlowState {
|
||||
playerName: string;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
export interface GameState {
|
||||
mainState: MainGameState;
|
||||
isCinematicPlaying: boolean;
|
||||
missionFlow: MissionFlowState;
|
||||
@@ -79,6 +86,34 @@ interface GameActions {
|
||||
type GameStore = GameState & GameActions;
|
||||
type GameStateUpdate = Partial<GameState>;
|
||||
|
||||
const MAIN_GAME_STATES: readonly MainGameState[] = [
|
||||
"intro",
|
||||
"bike",
|
||||
"pylone",
|
||||
"ferme",
|
||||
"outro",
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isStringOrNull(value: unknown): value is string | null {
|
||||
return typeof value === "string" || value === null;
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === "boolean";
|
||||
}
|
||||
|
||||
function isMainGameState(value: unknown): value is MainGameState {
|
||||
return MAIN_GAME_STATES.includes(value as MainGameState);
|
||||
}
|
||||
|
||||
function isGameStep(value: unknown): value is GameStep {
|
||||
return GAME_STEPS.includes(value as GameStep);
|
||||
}
|
||||
|
||||
function completeIntroState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "bike",
|
||||
@@ -237,8 +272,139 @@ function createInitialGameState(): GameState {
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateIntroState(initial: IntroState, value: unknown): IntroState {
|
||||
if (!isRecord(value)) return initial;
|
||||
|
||||
return {
|
||||
currentStep: isGameStep(value.currentStep)
|
||||
? value.currentStep
|
||||
: initial.currentStep,
|
||||
dialogueAudio: isStringOrNull(value.dialogueAudio)
|
||||
? value.dialogueAudio
|
||||
: initial.dialogueAudio,
|
||||
hasCompleted: isBoolean(value.hasCompleted)
|
||||
? value.hasCompleted
|
||||
: initial.hasCompleted,
|
||||
isBikeUnlocked: isBoolean(value.isBikeUnlocked)
|
||||
? value.isBikeUnlocked
|
||||
: initial.isBikeUnlocked,
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateMissionState(
|
||||
initial: MissionState,
|
||||
value: unknown,
|
||||
): MissionState {
|
||||
if (!isRecord(value)) return initial;
|
||||
|
||||
return {
|
||||
currentStep:
|
||||
typeof value.currentStep === "string" && isMissionStep(value.currentStep)
|
||||
? value.currentStep
|
||||
: initial.currentStep,
|
||||
dialogueAudio: isStringOrNull(value.dialogueAudio)
|
||||
? value.dialogueAudio
|
||||
: initial.dialogueAudio,
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateMissionFlowState(
|
||||
initial: MissionFlowState,
|
||||
value: unknown,
|
||||
): MissionFlowState {
|
||||
if (!isRecord(value)) return initial;
|
||||
|
||||
return {
|
||||
activityCity: isBoolean(value.activityCity)
|
||||
? value.activityCity
|
||||
: initial.activityCity,
|
||||
canMove: isBoolean(value.canMove) ? value.canMove : initial.canMove,
|
||||
dialogMessage: isStringOrNull(value.dialogMessage)
|
||||
? value.dialogMessage
|
||||
: initial.dialogMessage,
|
||||
playerName:
|
||||
typeof value.playerName === "string"
|
||||
? value.playerName
|
||||
: initial.playerName,
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
|
||||
if (!isRecord(value)) return initial;
|
||||
|
||||
const bike = hydrateMissionState(initial.bike, value.bike);
|
||||
const pylone = hydrateMissionState(initial.pylone, value.pylone);
|
||||
const ferme = hydrateMissionState(initial.ferme, value.ferme);
|
||||
const outro = isRecord(value.outro) ? value.outro : null;
|
||||
|
||||
return {
|
||||
mainState: isMainGameState(value.mainState)
|
||||
? value.mainState
|
||||
: initial.mainState,
|
||||
isCinematicPlaying: isBoolean(value.isCinematicPlaying)
|
||||
? value.isCinematicPlaying
|
||||
: initial.isCinematicPlaying,
|
||||
missionFlow: hydrateMissionFlowState(
|
||||
initial.missionFlow,
|
||||
value.missionFlow,
|
||||
),
|
||||
intro: hydrateIntroState(initial.intro, value.intro),
|
||||
bike: {
|
||||
...bike,
|
||||
isRepaired:
|
||||
isRecord(value.bike) && isBoolean(value.bike.isRepaired)
|
||||
? value.bike.isRepaired
|
||||
: initial.bike.isRepaired,
|
||||
},
|
||||
pylone: {
|
||||
...pylone,
|
||||
isPowered:
|
||||
isRecord(value.pylone) && isBoolean(value.pylone.isPowered)
|
||||
? value.pylone.isPowered
|
||||
: initial.pylone.isPowered,
|
||||
},
|
||||
ferme: {
|
||||
...ferme,
|
||||
irrigationFixed:
|
||||
isRecord(value.ferme) && isBoolean(value.ferme.irrigationFixed)
|
||||
? value.ferme.irrigationFixed
|
||||
: initial.ferme.irrigationFixed,
|
||||
},
|
||||
outro: {
|
||||
dialogueAudio:
|
||||
outro && isStringOrNull(outro.dialogueAudio)
|
||||
? outro.dialogueAudio
|
||||
: initial.outro.dialogueAudio,
|
||||
hasStarted:
|
||||
outro && isBoolean(outro.hasStarted)
|
||||
? outro.hasStarted
|
||||
: initial.outro.hasStarted,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialDebugGameState(): GameState {
|
||||
const initialState = createInitialGameState();
|
||||
if (!isDebugEnabled()) return initialState;
|
||||
|
||||
return hydrateDebugGameState(initialState, readDebugGameStateCookie());
|
||||
}
|
||||
|
||||
function pickGameState(state: GameStore): GameState {
|
||||
return {
|
||||
mainState: state.mainState,
|
||||
isCinematicPlaying: state.isCinematicPlaying,
|
||||
missionFlow: state.missionFlow,
|
||||
intro: state.intro,
|
||||
bike: state.bike,
|
||||
pylone: state.pylone,
|
||||
ferme: state.ferme,
|
||||
outro: state.outro,
|
||||
};
|
||||
}
|
||||
|
||||
export const useGameStore = create<GameStore>()((set) => ({
|
||||
...createInitialGameState(),
|
||||
...createInitialDebugGameState(),
|
||||
setMainState: (mainState) => set({ mainState }),
|
||||
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
|
||||
hideDialog: () =>
|
||||
@@ -302,9 +468,18 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
|
||||
return { outro: { ...state.outro, hasStarted: false } };
|
||||
}),
|
||||
resetGame: () => set(createInitialGameState()),
|
||||
resetGame: () => {
|
||||
set(createInitialGameState());
|
||||
clearDebugGameStateCookie();
|
||||
},
|
||||
showDialog: (dialogMessage) =>
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, dialogMessage },
|
||||
})),
|
||||
}));
|
||||
|
||||
if (isDebugEnabled()) {
|
||||
useGameStore.subscribe((state) => {
|
||||
writeDebugGameStateCookie(pickGameState(state));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type MapPerformanceGroupName =
|
||||
| "vegetation"
|
||||
| "crops"
|
||||
| "trees"
|
||||
| "buildings"
|
||||
| "landmarks"
|
||||
| "props"
|
||||
| "terrain"
|
||||
| "sky";
|
||||
|
||||
export type MapPerformanceModelName =
|
||||
| "buisson"
|
||||
| "arbre"
|
||||
| "sapin"
|
||||
| "champdeble"
|
||||
| "champdesoja"
|
||||
| "champsdetournesol"
|
||||
| "ecole"
|
||||
| "generateur"
|
||||
| "fermeverticale"
|
||||
| "lafabrik"
|
||||
| "immeuble1"
|
||||
| "eolienne"
|
||||
| "pylone"
|
||||
| "boiteauxlettres"
|
||||
| "maison1"
|
||||
| "parcebike"
|
||||
| "terrain"
|
||||
| "sky";
|
||||
|
||||
export interface MapPerformanceVisibility {
|
||||
groups: Record<MapPerformanceGroupName, boolean>;
|
||||
models: Record<MapPerformanceModelName, boolean>;
|
||||
}
|
||||
|
||||
interface MapPerformanceActions {
|
||||
setGroupVisible: (group: MapPerformanceGroupName, visible: boolean) => void;
|
||||
setModelVisible: (model: MapPerformanceModelName, visible: boolean) => void;
|
||||
resetVisibility: () => void;
|
||||
}
|
||||
|
||||
type MapPerformanceStore = MapPerformanceVisibility & MapPerformanceActions;
|
||||
|
||||
export const MAP_PERFORMANCE_GROUP_NAMES: readonly MapPerformanceGroupName[] = [
|
||||
"vegetation",
|
||||
"crops",
|
||||
"trees",
|
||||
"buildings",
|
||||
"landmarks",
|
||||
"props",
|
||||
"terrain",
|
||||
"sky",
|
||||
];
|
||||
|
||||
export const MAP_PERFORMANCE_MODEL_NAMES: readonly MapPerformanceModelName[] = [
|
||||
"buisson",
|
||||
"arbre",
|
||||
"sapin",
|
||||
"champdeble",
|
||||
"champdesoja",
|
||||
"champsdetournesol",
|
||||
"ecole",
|
||||
"generateur",
|
||||
"fermeverticale",
|
||||
"lafabrik",
|
||||
"immeuble1",
|
||||
"eolienne",
|
||||
"pylone",
|
||||
"boiteauxlettres",
|
||||
"maison1",
|
||||
"parcebike",
|
||||
"terrain",
|
||||
"sky",
|
||||
];
|
||||
|
||||
const MODEL_GROUPS: Record<
|
||||
MapPerformanceModelName,
|
||||
readonly MapPerformanceGroupName[]
|
||||
> = {
|
||||
buisson: ["vegetation"],
|
||||
arbre: ["vegetation", "trees"],
|
||||
sapin: ["vegetation", "trees"],
|
||||
champdeble: ["vegetation", "crops"],
|
||||
champdesoja: ["vegetation", "crops"],
|
||||
champsdetournesol: ["vegetation", "crops"],
|
||||
ecole: ["buildings", "landmarks"],
|
||||
generateur: ["landmarks"],
|
||||
fermeverticale: ["buildings", "landmarks"],
|
||||
lafabrik: ["buildings", "landmarks"],
|
||||
immeuble1: ["buildings"],
|
||||
eolienne: ["props"],
|
||||
pylone: ["props"],
|
||||
boiteauxlettres: ["props"],
|
||||
maison1: ["buildings"],
|
||||
parcebike: ["props"],
|
||||
terrain: ["terrain"],
|
||||
sky: ["sky"],
|
||||
};
|
||||
|
||||
function createVisibleRecord<T extends string>(
|
||||
keys: readonly T[],
|
||||
): Record<T, boolean> {
|
||||
return Object.fromEntries(keys.map((key) => [key, true])) as Record<
|
||||
T,
|
||||
boolean
|
||||
>;
|
||||
}
|
||||
|
||||
function createDefaultVisibility(): MapPerformanceVisibility {
|
||||
return {
|
||||
groups: createVisibleRecord(MAP_PERFORMANCE_GROUP_NAMES),
|
||||
models: createVisibleRecord(MAP_PERFORMANCE_MODEL_NAMES),
|
||||
};
|
||||
}
|
||||
|
||||
export function isMapPerformanceModelName(
|
||||
name: string,
|
||||
): name is MapPerformanceModelName {
|
||||
return MAP_PERFORMANCE_MODEL_NAMES.includes(name as MapPerformanceModelName);
|
||||
}
|
||||
|
||||
export function isMapModelVisible(
|
||||
name: string,
|
||||
visibility: MapPerformanceVisibility,
|
||||
): boolean {
|
||||
if (!isMapPerformanceModelName(name)) return true;
|
||||
if (!visibility.models[name]) return false;
|
||||
|
||||
return MODEL_GROUPS[name].every((group) => visibility.groups[group]);
|
||||
}
|
||||
|
||||
export const useMapPerformanceStore = create<MapPerformanceStore>()((set) => ({
|
||||
...createDefaultVisibility(),
|
||||
setGroupVisible: (group, visible) =>
|
||||
set((state) => ({
|
||||
groups: { ...state.groups, [group]: visible },
|
||||
})),
|
||||
setModelVisible: (model, visible) =>
|
||||
set((state) => ({
|
||||
models: { ...state.models, [model]: visible },
|
||||
})),
|
||||
resetVisibility: () => set(createDefaultVisibility()),
|
||||
}));
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { GameState } from "@/managers/stores/useGameStore";
|
||||
|
||||
const DEBUG_GAME_STATE_COOKIE_NAME = "la-fabrik-debug-game-state";
|
||||
const DEBUG_GAME_STATE_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
||||
|
||||
function getCookieValue(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
const cookie = document.cookie
|
||||
.split(";")
|
||||
.map((item) => item.trim())
|
||||
.find((item) => item.startsWith(`${name}=`));
|
||||
|
||||
return cookie ? cookie.slice(name.length + 1) : null;
|
||||
}
|
||||
|
||||
export function readDebugGameStateCookie(): unknown {
|
||||
const value = getCookieValue(DEBUG_GAME_STATE_COOKIE_NAME);
|
||||
if (!value) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDebugGameStateCookie(state: GameState): void {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const value = encodeURIComponent(JSON.stringify(state));
|
||||
document.cookie = `${DEBUG_GAME_STATE_COOKIE_NAME}=${value}; max-age=${DEBUG_GAME_STATE_COOKIE_MAX_AGE}; path=/; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function clearDebugGameStateCookie(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
document.cookie = `${DEBUG_GAME_STATE_COOKIE_NAME}=; max-age=0; path=/; SameSite=Lax`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
|
||||
GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
|
||||
GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
|
||||
GAME_SCENE_SKY_MODEL_PATH,
|
||||
@@ -6,10 +7,17 @@ import {
|
||||
PHYSICS_SCENE_BACKGROUND_COLOR,
|
||||
} from "@/data/world/environmentConfig";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import {
|
||||
isMapModelVisible,
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
import { SkyModel } from "@/components/three/world/SkyModel";
|
||||
|
||||
export function Environment(): React.JSX.Element {
|
||||
const sceneMode = useSceneMode();
|
||||
const groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
const showSky = isMapModelVisible("sky", { groups, models });
|
||||
|
||||
if (sceneMode === "physics") {
|
||||
return (
|
||||
@@ -17,12 +25,15 @@ export function Environment(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
return showSky ? (
|
||||
<SkyModel
|
||||
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
||||
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
|
||||
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
|
||||
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
||||
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
||||
/>
|
||||
) : (
|
||||
<color attach="background" args={[GAME_SCENE_FALLBACK_BACKGROUND_COLOR]} />
|
||||
);
|
||||
}
|
||||
|
||||
+59
-15
@@ -11,24 +11,38 @@ import * as THREE from "three";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||
import {
|
||||
isMapModelVisible,
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
import { GameMapCollision } from "@/world/GameMapCollision";
|
||||
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
||||
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
|
||||
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
||||
import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig";
|
||||
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
||||
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";
|
||||
import { INSTANCED_MAP_EXCEPTIONS } from "@/world/vegetation/vegetationConfig";
|
||||
import type { MapNode } from "@/types/editor/editor";
|
||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||
|
||||
const MODEL_RENDER_SCALE: [number, number, number] = [1, 1, 1];
|
||||
|
||||
interface LoadedMapNode {
|
||||
node: MapNode;
|
||||
modelUrl: string | null;
|
||||
}
|
||||
|
||||
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking"]);
|
||||
const LITE_MAP_SKIPPED_NODE_NAMES = new Set([
|
||||
"arbre",
|
||||
"buisson",
|
||||
"champdeble",
|
||||
"champdesoja",
|
||||
"champsdetournesol",
|
||||
"sapin",
|
||||
]);
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
@@ -91,6 +105,8 @@ export function GameMap({
|
||||
onOctreeReady,
|
||||
}: GameMapProps): React.JSX.Element {
|
||||
const settledMapNodesRef = useRef(new Set<number>());
|
||||
const groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
|
||||
@@ -211,16 +227,23 @@ export function GameMap({
|
||||
node={mapNode.node}
|
||||
onSettled={() => handleMapNodeSettled(index)}
|
||||
>
|
||||
<MapNodeInstance
|
||||
node={mapNode.node}
|
||||
modelUrl={mapNode.modelUrl}
|
||||
onSettled={() => handleMapNodeSettled(index)}
|
||||
/>
|
||||
{isMapModelVisible(mapNode.node.name, { groups, models }) ? (
|
||||
<MapNodeInstance
|
||||
node={mapNode.node}
|
||||
modelUrl={mapNode.modelUrl}
|
||||
onSettled={() => handleMapNodeSettled(index)}
|
||||
/>
|
||||
) : (
|
||||
<HiddenMapNode onSettled={() => handleMapNodeSettled(index)} />
|
||||
)}
|
||||
</ModelErrorBoundary>
|
||||
))}
|
||||
</group>
|
||||
<MapInstancingSystem />
|
||||
<VegetationSystem />
|
||||
<TerrainModel />
|
||||
{isMapModelVisible("terrain", { groups, models }) ? (
|
||||
<TerrainModel />
|
||||
) : null}
|
||||
<GameMapCollision
|
||||
buildOctree={buildOctree}
|
||||
mapReady={mapReady}
|
||||
@@ -233,6 +256,14 @@ export function GameMap({
|
||||
);
|
||||
}
|
||||
|
||||
function HiddenMapNode({ onSettled }: { onSettled: () => void }): null {
|
||||
useEffect(() => {
|
||||
onSettled();
|
||||
}, [onSettled]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary development-only map reducer.
|
||||
*
|
||||
@@ -250,7 +281,10 @@ function liteMap(node: MapNode): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
return INSTANCED_MAP_EXCEPTIONS.has(node.name);
|
||||
return (
|
||||
!LITE_MAP_SKIPPED_NODE_NAMES.has(node.name) &&
|
||||
!isInstancedMapNodeName(node.name)
|
||||
);
|
||||
}
|
||||
|
||||
function MapNodeInstance({
|
||||
@@ -262,11 +296,21 @@ function MapNodeInstance({
|
||||
modelUrl: string | null;
|
||||
onSettled: () => void;
|
||||
}): React.JSX.Element {
|
||||
const isGeneratedModel = isGeneratedMapModelName(node.name);
|
||||
|
||||
useEffect(() => {
|
||||
if (modelUrl !== null) return;
|
||||
if (modelUrl !== null || isGeneratedModel) return;
|
||||
|
||||
onSettled();
|
||||
}, [modelUrl, onSettled]);
|
||||
}, [isGeneratedModel, modelUrl, onSettled]);
|
||||
|
||||
if (isGeneratedModel) {
|
||||
return (
|
||||
<Suspense fallback={<FallbackMapNode node={node} />}>
|
||||
<GeneratedMapNodeInstance node={node} onLoaded={onSettled} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (!modelUrl) {
|
||||
return <FallbackMapNode node={node} />;
|
||||
@@ -288,12 +332,12 @@ function ModelInstance({
|
||||
modelUrl: string;
|
||||
onLoaded: () => void;
|
||||
}): React.JSX.Element {
|
||||
const { position, rotation } = node;
|
||||
const { position, rotation, scale } = node;
|
||||
const { scene } = useLoggedGLTF(modelUrl, {
|
||||
scope: "GameMap.ModelInstance",
|
||||
position,
|
||||
rotation,
|
||||
scale: MODEL_RENDER_SCALE,
|
||||
scale,
|
||||
});
|
||||
const sceneInstance = useClonedObject(scene);
|
||||
|
||||
@@ -312,7 +356,7 @@ function ModelInstance({
|
||||
object={sceneInstance}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={MODEL_RENDER_SCALE}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
||||
@@ -35,6 +36,8 @@ interface WorldProps {
|
||||
}
|
||||
|
||||
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
useMapPerformanceDebug();
|
||||
|
||||
const cameraMode = useCameraMode();
|
||||
const sceneMode = useSceneMode();
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { EcoleModel } from "@/components/three/models/generated/EcoleModel";
|
||||
import type { MapNode } from "@/types/editor/editor";
|
||||
|
||||
interface GeneratedMapNodeInstanceProps {
|
||||
node: MapNode;
|
||||
onLoaded: () => void;
|
||||
}
|
||||
|
||||
export function GeneratedMapNodeInstance({
|
||||
node,
|
||||
onLoaded,
|
||||
}: GeneratedMapNodeInstanceProps): React.JSX.Element | null {
|
||||
if (node.name === "ecole") {
|
||||
return (
|
||||
<EcoleModel
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
onLoaded={onLoaded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const GENERATED_MAP_MODEL_NAMES = new Set(["ecole"]);
|
||||
|
||||
export function isGeneratedMapModelName(name: string): boolean {
|
||||
return GENERATED_MAP_MODEL_NAMES.has(name);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
||||
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
|
||||
|
||||
interface InstancedMapAssetProps {
|
||||
modelPath: string;
|
||||
instances: MapAssetInstance[];
|
||||
castShadow: boolean;
|
||||
receiveShadow: boolean;
|
||||
}
|
||||
|
||||
interface MeshData {
|
||||
geometry: THREE.BufferGeometry;
|
||||
material: THREE.Material | THREE.Material[];
|
||||
}
|
||||
|
||||
interface MeshMergeGroup {
|
||||
geometries: THREE.BufferGeometry[];
|
||||
material: THREE.Material | THREE.Material[];
|
||||
}
|
||||
|
||||
function cloneMaterial(
|
||||
material: THREE.Material | THREE.Material[],
|
||||
): THREE.Material | THREE.Material[] {
|
||||
return Array.isArray(material)
|
||||
? material.map((item) => item.clone())
|
||||
: material.clone();
|
||||
}
|
||||
|
||||
function disposeMaterialOnly(
|
||||
material: THREE.Material | THREE.Material[],
|
||||
): void {
|
||||
if (Array.isArray(material)) {
|
||||
for (const item of material) {
|
||||
item.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
function disposeInstancedMapMesh(mesh: THREE.InstancedMesh): void {
|
||||
mesh.geometry.dispose();
|
||||
disposeMaterialOnly(mesh.material);
|
||||
mesh.dispose();
|
||||
}
|
||||
|
||||
function createGeometrySignature(geometry: THREE.BufferGeometry): string {
|
||||
const attributes = Object.entries(geometry.attributes)
|
||||
.map(([name, attribute]) => {
|
||||
return `${name}:${attribute.itemSize}:${attribute.normalized}`;
|
||||
})
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
return `${geometry.index ? "indexed" : "non-indexed"}:${attributes}`;
|
||||
}
|
||||
|
||||
function createMaterialKey(
|
||||
material: THREE.Material | THREE.Material[],
|
||||
): string {
|
||||
if (Array.isArray(material)) {
|
||||
return material.map((item) => item.uuid).join("|");
|
||||
}
|
||||
|
||||
return material.uuid;
|
||||
}
|
||||
|
||||
function extractMeshes(scene: THREE.Group): MeshData[] {
|
||||
const groups = new Map<string, MeshMergeGroup>();
|
||||
|
||||
scene.updateMatrixWorld(true);
|
||||
scene.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
|
||||
const geometry = child.geometry.clone();
|
||||
geometry.applyMatrix4(child.matrixWorld);
|
||||
const material = child.material;
|
||||
const key = `${createMaterialKey(material)}:${createGeometrySignature(geometry)}`;
|
||||
const group = groups.get(key);
|
||||
|
||||
if (group) {
|
||||
group.geometries.push(geometry);
|
||||
return;
|
||||
}
|
||||
|
||||
groups.set(key, {
|
||||
geometries: [geometry],
|
||||
material: cloneMaterial(material),
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.values()].map((group) => {
|
||||
if (group.geometries.length === 1) {
|
||||
return {
|
||||
geometry: group.geometries[0] as THREE.BufferGeometry,
|
||||
material: group.material,
|
||||
};
|
||||
}
|
||||
|
||||
const mergedGeometry = mergeGeometries(group.geometries, false);
|
||||
|
||||
for (const geometry of group.geometries) {
|
||||
geometry.dispose();
|
||||
}
|
||||
|
||||
return {
|
||||
geometry: mergedGeometry,
|
||||
material: group.material,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setInstanceMatrices(
|
||||
instancedMesh: THREE.InstancedMesh,
|
||||
instances: MapAssetInstance[],
|
||||
): void {
|
||||
const position = new THREE.Vector3();
|
||||
const rotation = new THREE.Euler();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scale = new THREE.Vector3();
|
||||
const matrix = new THREE.Matrix4();
|
||||
|
||||
for (let i = 0; i < instances.length; i++) {
|
||||
const instance = instances[i];
|
||||
if (!instance) continue;
|
||||
|
||||
position.set(...instance.position);
|
||||
rotation.set(...instance.rotation);
|
||||
quaternion.setFromEuler(rotation);
|
||||
scale.set(...instance.scale);
|
||||
matrix.compose(position, quaternion, scale);
|
||||
instancedMesh.setMatrixAt(i, matrix);
|
||||
}
|
||||
|
||||
instancedMesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function InstancedMapAsset({
|
||||
modelPath,
|
||||
instances,
|
||||
castShadow,
|
||||
receiveShadow,
|
||||
}: InstancedMapAssetProps): React.JSX.Element | null {
|
||||
const { scene } = useGLTF(modelPath);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group || instances.length === 0) return;
|
||||
|
||||
const meshDataList = extractMeshes(scene);
|
||||
const instancedMeshes = meshDataList.map((meshData, index) => {
|
||||
const instancedMesh = new THREE.InstancedMesh(
|
||||
meshData.geometry,
|
||||
meshData.material,
|
||||
instances.length,
|
||||
);
|
||||
|
||||
setInstanceMatrices(instancedMesh, instances);
|
||||
instancedMesh.castShadow = castShadow;
|
||||
instancedMesh.receiveShadow = receiveShadow;
|
||||
instancedMesh.name = `instanced-map-asset-${index}`;
|
||||
instancedMesh.frustumCulled = true;
|
||||
instancedMesh.computeBoundingSphere();
|
||||
|
||||
return instancedMesh;
|
||||
});
|
||||
|
||||
for (const mesh of instancedMeshes) {
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const mesh of instancedMeshes) {
|
||||
group.remove(mesh);
|
||||
disposeInstancedMapMesh(mesh);
|
||||
}
|
||||
};
|
||||
}, [castShadow, instances, receiveShadow, scene]);
|
||||
|
||||
if (instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <group ref={groupRef} />;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
isMapModelVisible,
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset";
|
||||
import {
|
||||
MAP_INSTANCING_ASSETS,
|
||||
type MapInstancingAssetType,
|
||||
} from "@/world/map-instancing/mapInstancingConfig";
|
||||
import { useMapInstancingData } from "@/world/map-instancing/useMapInstancingData";
|
||||
|
||||
export function MapInstancingSystem(): React.JSX.Element | null {
|
||||
const groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
const { data, isLoading } = useMapInstancingData();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabledAssets = Object.entries(MAP_INSTANCING_ASSETS).filter(
|
||||
([, config]) =>
|
||||
config.enabled && isMapModelVisible(config.mapName, { groups, models }),
|
||||
);
|
||||
|
||||
return (
|
||||
<group name="map-instancing-system">
|
||||
{enabledAssets.map(([type, config]) => {
|
||||
const instances = data.get(type as MapInstancingAssetType);
|
||||
|
||||
if (!instances || instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense key={type} fallback={null}>
|
||||
<InstancedMapAsset
|
||||
modelPath={config.modelPath}
|
||||
instances={instances}
|
||||
castShadow={config.castShadow}
|
||||
receiveShadow={config.receiveShadow}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export const MAP_INSTANCING_ASSETS = {
|
||||
generateur: {
|
||||
mapName: "generateur",
|
||||
modelPath: "/models/generateur/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
lafabrik: {
|
||||
mapName: "lafabrik",
|
||||
modelPath: "/models/lafabrik/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
fermeverticale: {
|
||||
mapName: "fermeverticale",
|
||||
modelPath: "/models/fermeverticale/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
boiteauxlettres: {
|
||||
mapName: "boiteauxlettres",
|
||||
modelPath: "/models/boiteauxlettres/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
pylone: {
|
||||
mapName: "pylone",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
immeuble1: {
|
||||
mapName: "immeuble1",
|
||||
modelPath: "/models/immeuble1/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
maison1: {
|
||||
mapName: "maison1",
|
||||
modelPath: "/models/maison1/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
eolienne: {
|
||||
mapName: "eolienne",
|
||||
modelPath: "/models/eolienne/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
parcebike: {
|
||||
mapName: "parcebike",
|
||||
modelPath: "/models/parcebike/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type MapInstancingAssetType = keyof typeof MAP_INSTANCING_ASSETS;
|
||||
|
||||
export type MapInstancingAssetConfig =
|
||||
(typeof MAP_INSTANCING_ASSETS)[MapInstancingAssetType];
|
||||
|
||||
export const MAP_INSTANCED_NODE_NAMES: ReadonlySet<string> = new Set(
|
||||
Object.values(MAP_INSTANCING_ASSETS)
|
||||
.filter((config) => config.enabled)
|
||||
.map((config) => config.mapName),
|
||||
);
|
||||
|
||||
export function isInstancedMapNodeName(name: string): boolean {
|
||||
return MAP_INSTANCED_NODE_NAMES.has(name);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { MapNode } from "@/types/editor/editor";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { getMapNodes, loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||
import {
|
||||
MAP_INSTANCING_ASSETS,
|
||||
type MapInstancingAssetType,
|
||||
} from "@/world/map-instancing/mapInstancingConfig";
|
||||
|
||||
export interface MapAssetInstance {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
}
|
||||
|
||||
export type MapInstancingData = Map<MapInstancingAssetType, MapAssetInstance[]>;
|
||||
|
||||
function mapNodeToInstance(node: MapNode): MapAssetInstance {
|
||||
return {
|
||||
position: node.position,
|
||||
rotation: node.rotation,
|
||||
scale: node.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function extractMapInstancingData(mapNodes: MapNode[]): MapInstancingData {
|
||||
const data: MapInstancingData = new Map();
|
||||
|
||||
for (const [type, config] of Object.entries(MAP_INSTANCING_ASSETS)) {
|
||||
if (!config.enabled) continue;
|
||||
|
||||
const instances = mapNodes
|
||||
.filter(
|
||||
(node) => node.name === config.mapName && node.type === "Object3D",
|
||||
)
|
||||
.map(mapNodeToInstance);
|
||||
|
||||
if (instances.length > 0) {
|
||||
data.set(type as MapInstancingAssetType, instances);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function useMapInstancingData(): {
|
||||
data: MapInstancingData | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const [data, setData] = useState<MapInstancingData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
const cachedNodes = getMapNodes();
|
||||
|
||||
if (cachedNodes) {
|
||||
if (!cancelled) {
|
||||
setData(extractMapInstancingData(cachedNodes));
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await loadMapSceneData();
|
||||
const nodes = getMapNodes();
|
||||
|
||||
if (!cancelled && nodes) {
|
||||
setData(extractMapInstancingData(nodes));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, isLoading };
|
||||
}
|
||||
@@ -1,68 +1,48 @@
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
isMapModelVisible,
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
import { InstancedVegetation } from "@/world/vegetation/InstancedVegetation";
|
||||
import { useVegetationData } from "@/world/vegetation/useVegetationData";
|
||||
import {
|
||||
type VegetationInstance,
|
||||
useVegetationData,
|
||||
} from "@/world/vegetation/useVegetationData";
|
||||
import {
|
||||
INSTANCED_MAP_CHUNK_SIZE,
|
||||
INSTANCED_MAP_NO_SHADOW_NAMES,
|
||||
VEGETATION_TYPES,
|
||||
type VegetationType,
|
||||
} from "@/world/vegetation/vegetationConfig";
|
||||
|
||||
function createChunkKey(instance: VegetationInstance): string {
|
||||
const [x, , z] = instance.position;
|
||||
const chunkX = Math.floor(x / INSTANCED_MAP_CHUNK_SIZE);
|
||||
const chunkZ = Math.floor(z / INSTANCED_MAP_CHUNK_SIZE);
|
||||
return `${chunkX}:${chunkZ}`;
|
||||
}
|
||||
|
||||
function chunkInstances(
|
||||
instances: VegetationInstance[],
|
||||
): Map<string, VegetationInstance[]> {
|
||||
const chunks = new Map<string, VegetationInstance[]>();
|
||||
|
||||
for (const instance of instances) {
|
||||
const key = createChunkKey(instance);
|
||||
const chunk = chunks.get(key);
|
||||
|
||||
if (chunk) {
|
||||
chunk.push(instance);
|
||||
} else {
|
||||
chunks.set(key, [instance]);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function VegetationSystem(): React.JSX.Element | null {
|
||||
const groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
const { data, isLoading } = useVegetationData();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabledTypes = Object.entries(VEGETATION_TYPES).filter(
|
||||
([, config]) =>
|
||||
config.enabled && isMapModelVisible(config.mapName, { groups, models }),
|
||||
);
|
||||
|
||||
return (
|
||||
<group name="instanced-map-system">
|
||||
{[...data.entries()].map(([modelName, entry]) => {
|
||||
if (entry.instances.length === 0) {
|
||||
<group name="vegetation-system">
|
||||
{enabledTypes.map(([type, config]) => {
|
||||
const instances = data.get(type as VegetationType);
|
||||
|
||||
if (!instances || instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const castShadow = !INSTANCED_MAP_NO_SHADOW_NAMES.has(modelName);
|
||||
const receiveShadow = castShadow;
|
||||
const chunks = chunkInstances(entry.instances);
|
||||
|
||||
return [...chunks.entries()].map(([chunkKey, instances]) => (
|
||||
<Suspense key={`${modelName}:${chunkKey}`} fallback={null}>
|
||||
return (
|
||||
<Suspense key={type} fallback={null}>
|
||||
<InstancedVegetation
|
||||
modelPath={entry.modelPath}
|
||||
modelPath={config.modelPath}
|
||||
instances={instances}
|
||||
castShadow={castShadow}
|
||||
receiveShadow={receiveShadow}
|
||||
castShadow={config.castShadow}
|
||||
receiveShadow={config.receiveShadow}
|
||||
/>
|
||||
</Suspense>
|
||||
));
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user