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

This commit is contained in:
Tom Boullay
2026-05-24 22:07:15 +02:00
147 changed files with 1490 additions and 329 deletions
@@ -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);
+29 -5
View File
@@ -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]);
+22 -1
View File
@@ -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"
+1
View File
@@ -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";
+58
View File
@@ -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");
});
}
+11
View File
@@ -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);
+179 -4
View File
@@ -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()),
}));
+39
View File
@@ -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`;
}
+12 -1
View File
@@ -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
View File
@@ -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}
/>
);
}
+3
View File
@@ -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 };
}
+25 -45
View File
@@ -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>
);