feat: ajoute les potagers à la map

This commit is contained in:
tom-boullay
2026-05-28 15:48:33 +02:00
parent 9bbed06ddc
commit 0b3d49e8d1
12 changed files with 283 additions and 58 deletions
+3
View File
@@ -15,6 +15,7 @@ export type MapPerformanceModelName =
| "champdeble"
| "champdesoja"
| "champsdetournesol"
| "potager"
| "ecole"
| "generateur"
| "fermeverticale"
@@ -50,6 +51,7 @@ export const MAP_PERFORMANCE_MODEL_NAMES: readonly MapPerformanceModelName[] = [
"champdeble",
"champdesoja",
"champsdetournesol",
"potager",
"ecole",
"generateur",
"fermeverticale",
@@ -78,6 +80,7 @@ export const MAP_PERFORMANCE_MODEL_GROUPS: Record<
champdeble: ["vegetation", "crops"],
champdesoja: ["vegetation", "crops"],
champsdetournesol: ["vegetation", "crops"],
potager: ["vegetation", "crops"],
ecole: ["buildings", "landmarks"],
generateur: ["landmarks"],
fermeverticale: ["buildings", "landmarks"],
+1 -9
View File
@@ -1,15 +1,9 @@
import type {
TerrainSurfaceColorConfig,
TerrainSurfaceProjectionConfig,
} from "@/types/world/terrainSurface";
import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface";
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
export const TERRAIN_WATER_HEIGHT = 0.8;
export const TERRAIN_TILE_SIZE = 1;
export const TERRAIN_SURFACE_COLOR_TOLERANCE = 5;
export const TERRAIN_SURFACE_PROJECTION =
{} satisfies TerrainSurfaceProjectionConfig;
export const TERRAIN_COLORS = {
grass1: {
@@ -60,5 +54,3 @@ export const TERRAIN_COLORS = {
kind: "rock",
},
} satisfies Record<string, TerrainSurfaceColorConfig>;
export type TerrainColorKey = keyof typeof TERRAIN_COLORS;
+30 -6
View File
@@ -5,7 +5,8 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 1.5,
castShadow: true,
receiveShadow: true,
windStrength: 0.08,
windStrength: 0.06,
rotationOffset: [0, 0, 0],
enabled: true,
},
sapin: {
@@ -14,7 +15,8 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 4,
castShadow: true,
receiveShadow: true,
windStrength: 0.04,
windStrength: 0.12,
rotationOffset: [0, 0, 0],
enabled: true,
},
arbre: {
@@ -23,7 +25,8 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.06,
windStrength: 0.15,
rotationOffset: [0, 0, 0],
enabled: true,
},
champdeble: {
@@ -32,7 +35,8 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.18,
windStrength: 0.15,
rotationOffset: [0, 0, 0],
enabled: true,
},
champdesoja: {
@@ -41,7 +45,8 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.16,
windStrength: 0.15,
rotationOffset: [0, 0, 0],
enabled: true,
},
champsdetournesol: {
@@ -50,7 +55,18 @@ export const VEGETATION_TYPES = {
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.14,
windStrength: 0.15,
rotationOffset: [0, 0, 0],
enabled: true,
},
potager: {
mapName: "potager",
modelPath: "/models/potager/potager.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0,
rotationOffset: [0, 0, 0],
enabled: true,
},
} as const;
@@ -62,10 +78,18 @@ export const VEGETATION_TYPE_KEYS = [
"champdeble",
"champdesoja",
"champsdetournesol",
"potager",
] as const satisfies readonly (keyof typeof VEGETATION_TYPES)[];
export type VegetationType = (typeof VEGETATION_TYPE_KEYS)[number];
export function getVegetationModelScaleMultiplier(name: string): number {
return (
Object.values(VEGETATION_TYPES).find((config) => config.mapName === name)
?.scaleMultiplier ?? 1
);
}
export const INSTANCED_MAP_EXCEPTIONS = new Set([
"Scene",
"blocking",
+63 -15
View File
@@ -1,14 +1,14 @@
import { useEffect, useState } from "react";
import { INSTANCED_MAP_EXCEPTIONS } from "@/data/world/vegetationConfig";
import type { MapNode } from "@/types/map/mapScene";
import {
type MapNodeInstanceTransform,
mapNodeToInstanceTransform,
} from "@/utils/map/mapInstanceTransform";
import type { MapNode, VegetationInstance } from "@/types/map/mapScene";
import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform";
import { logger } from "@/utils/core/Logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
export type VegetationInstance = MapNodeInstanceTransform;
import {
createPotagerMapNode,
isPotagerSourceMapNode,
POTAGER_MAP_NAME,
} from "@/utils/map/potagerMapNodes";
interface InstancedMapEntry {
modelPath: string;
@@ -17,12 +17,35 @@ interface InstancedMapEntry {
export type VegetationData = Map<string, InstancedMapEntry>;
function createPositionKey(node: MapNode): string {
return node.position.map((value) => value.toFixed(3)).join(":");
}
function extractVegetationData(
mapNodes: MapNode[],
models: Map<string, string>,
): VegetationData {
const data: VegetationData = new Map();
function addInstance(
mapName: string,
modelPath: string,
node: MapNode,
): void {
const entry = data.get(mapName);
const instance = mapNodeToInstanceTransform(node);
if (entry) {
entry.instances.push(instance);
return;
}
data.set(mapName, {
modelPath,
instances: [instance],
});
}
for (const node of mapNodes) {
if (node.type !== "Object3D") continue;
if (INSTANCED_MAP_EXCEPTIONS.has(node.name)) continue;
@@ -30,16 +53,36 @@ function extractVegetationData(
const modelPath = models.get(node.name);
if (!modelPath) continue;
const entry = data.get(node.name);
addInstance(node.name, modelPath, node);
}
if (entry) {
entry.instances.push(mapNodeToInstanceTransform(node));
} else {
data.set(node.name, {
modelPath,
instances: [mapNodeToInstanceTransform(node)],
});
const existingPotagerPositionKeys = new Set(
mapNodes
.filter((node) => node.name === POTAGER_MAP_NAME)
.map(createPositionKey),
);
for (const node of mapNodes) {
if (!isPotagerSourceMapNode(node)) continue;
if (existingPotagerPositionKeys.has(createPositionKey(node))) continue;
addInstance(
POTAGER_MAP_NAME,
"/models/potager/potager.gltf",
createPotagerMapNode(node),
);
}
const potagerEntry = data.get(POTAGER_MAP_NAME);
if (potagerEntry) {
const uniqueInstances = new Map<string, VegetationInstance>();
for (const instance of potagerEntry.instances) {
uniqueInstances.set(
instance.position.map((value) => value.toFixed(3)).join(":"),
instance,
);
}
potagerEntry.instances = [...uniqueInstances.values()];
}
return data;
@@ -64,6 +107,11 @@ export function useVegetationData(): {
logger.error("Vegetation", "Failed to load vegetation data", {
error: error instanceof Error ? error : String(error),
});
if (!cancelled) {
setData(null);
setIsLoading(false);
}
return;
}
if (!cancelled) {
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsMapPerformancePage(): React.JSX.Element {
return (
<DocsDocument
content={mapPerformance}
frContent={mapPerformance}
meta="12"
title="Map Performance"
/>
<DocsDocument content={mapPerformance} meta="12" title="Map Performance" />
);
}
+9
View File
@@ -9,6 +9,15 @@ export interface MapNode {
sourcePath?: number[];
}
export interface MapNodeInstanceTransform {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
export type MapAssetInstance = MapNodeInstanceTransform;
export type VegetationInstance = MapNodeInstanceTransform;
export interface HierarchicalMapNode extends MapNode {
role?: "group";
children?: HierarchicalMapNode[];
+107 -4
View File
@@ -3,7 +3,13 @@ import type {
MapNode,
SceneData,
} from "@/types/map/mapScene";
import { logger } from "@/utils/core/Logger";
import { parseMapData } from "@/utils/map/mapNodeValidation";
import {
createPotagerMapNode,
isPotagerSourceMapNode,
POTAGER_MAP_NAME,
} from "@/utils/map/potagerMapNodes";
const MAP_JSON_PATH = "/map.json";
const MODEL_FILE_NAMES = ["model.glb", "model.gltf"];
@@ -59,9 +65,101 @@ async function loadMapSceneDataInternal(): Promise<SceneData | null> {
export async function createSceneDataFromMapPayload(
mapPayload: unknown,
): Promise<SceneData> {
const { mapNodes, mapTree } = parseMapData(mapPayload);
const { mapTree } = parseMapData(mapPayload);
const mapTreeWithPotagers = ensurePotagerMapTree(mapTree);
const mapNodes = flattenMapTree(mapTreeWithPotagers);
const deduplicatedNodes = deduplicateMapNodes(mapNodes);
return createSceneData(deduplicatedNodes, mapTree);
return createSceneData(deduplicatedNodes, mapTreeWithPotagers);
}
function isSamePosition(a: MapNode, b: MapNode): boolean {
return a.position.every((value, index) => {
const otherValue = b.position[index];
return otherValue !== undefined && Math.abs(value - otherValue) < 0.0001;
});
}
function cloneMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): HierarchicalMapNode | HierarchicalMapNode[] {
return JSON.parse(JSON.stringify(mapTree)) as
| HierarchicalMapNode
| HierarchicalMapNode[];
}
function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] {
const childNodes =
node.children?.flatMap((child, index) =>
flattenMapNode(child, [...path, index]),
) ?? [];
if (node.role === "group" || node.type === "Mesh") {
return childNodes;
}
return [
{
name: node.name,
type: node.type,
position: node.position,
rotation: node.rotation,
scale: node.scale,
sourcePath: path,
},
...childNodes,
];
}
function flattenMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): MapNode[] {
return Array.isArray(mapTree)
? mapTree.flatMap((node, index) => flattenMapNode(node, [index]))
: flattenMapNode(mapTree, []);
}
function collectExplicitPotagerNodes(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): MapNode[] {
return flattenMapTree(mapTree).filter(
(node) => node.name === POTAGER_MAP_NAME,
);
}
function ensurePotagerMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const explicitPotagers = collectExplicitPotagerNodes(nextTree);
function visit(node: HierarchicalMapNode): void {
if (!node.children) return;
const nextChildren: HierarchicalMapNode[] = [];
node.children.forEach((child) => {
nextChildren.push(child);
visit(child);
if (!isPotagerSourceMapNode(child)) return;
const hasMatchingPotager = explicitPotagers.some((potager) =>
isSamePosition(potager, child),
);
if (hasMatchingPotager) return;
nextChildren.push(createPotagerMapNode(child));
});
node.children = nextChildren;
}
if (Array.isArray(nextTree)) {
nextTree.forEach((node) => visit(node));
} else {
visit(nextTree);
}
return nextTree;
}
function createPositionKey(node: MapNode): string {
@@ -124,7 +222,7 @@ async function loadMapModelUrls(
}
async function loadModelEntry(modelName: string): Promise<ModelEntry | null> {
for (const fileName of MODEL_FILE_NAMES) {
for (const fileName of [...MODEL_FILE_NAMES, `${modelName}.gltf`]) {
const modelUrl = `/models/${modelName}/${fileName}`;
try {
@@ -133,7 +231,12 @@ async function loadModelEntry(modelName: string): Promise<ModelEntry | null> {
if (response.ok && !contentType.includes(HTML_CONTENT_TYPE)) {
return [modelName, modelUrl];
}
} catch {
} catch (error) {
logger.warn("MapSceneData", "Failed to probe map model URL", {
modelName,
modelUrl,
error: error instanceof Error ? error : String(error),
});
continue;
}
}
+1 -8
View File
@@ -1,11 +1,4 @@
import type { MapNode } from "@/types/map/mapScene";
import type { Vector3Tuple } from "@/types/three/three";
export interface MapNodeInstanceTransform {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
import type { MapNode, MapNodeInstanceTransform } from "@/types/map/mapScene";
export function mapNodeToInstanceTransform(
node: MapNode,
@@ -8,6 +8,7 @@ const RUNTIME_VEGETATION_NODE_NAMES = new Set([
"champdeble",
"champdesoja",
"champsdetournesol",
"potager",
"sapin",
]);
+35
View File
@@ -0,0 +1,35 @@
import type { MapNode } from "@/types/map/mapScene";
export const POTAGER_MAP_NAME = "potager";
export const POTAGER_DEFAULT_ROTATION_OFFSET = [0, 0, 0] as const;
export const POTAGER_SOURCE_MAP_NAMES = new Set([
"champdeble",
"champdesoja",
"champsdetournesol",
]);
export function isPotagerSourceMapNode(node: MapNode): boolean {
const role = "role" in node ? node.role : undefined;
return (
node.type === "Object3D" &&
role !== "group" &&
POTAGER_SOURCE_MAP_NAMES.has(node.name) &&
!node.position.every((value) => Math.abs(value) < 0.0001)
);
}
export function createPotagerMapNode(sourceNode: MapNode): MapNode {
return {
name: POTAGER_MAP_NAME,
type: sourceNode.type,
position: sourceNode.position,
rotation: [
sourceNode.rotation[0] + POTAGER_DEFAULT_ROTATION_OFFSET[0],
sourceNode.rotation[1] + POTAGER_DEFAULT_ROTATION_OFFSET[1],
sourceNode.rotation[2] + POTAGER_DEFAULT_ROTATION_OFFSET[2],
],
scale: sourceNode.scale,
};
}
+11 -3
View File
@@ -4,7 +4,7 @@ import { useGLTF } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import type { VegetationInstance } from "@/hooks/world/useVegetationData";
import type { VegetationInstance } from "@/types/map/mapScene";
import { useWind } from "@/hooks/world/useWind";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
@@ -15,6 +15,7 @@ interface InstancedVegetationProps {
castShadow: boolean;
receiveShadow: boolean;
windStrength: number;
rotationOffset: readonly [number, number, number];
}
interface MeshData {
@@ -186,6 +187,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
function createInstanceMatrices(
instances: VegetationInstance[],
scaleMultiplier: number,
rotationOffset: readonly [number, number, number],
geometryBottomY: number,
): THREE.Matrix4[] {
const matrices: THREE.Matrix4[] = [];
@@ -203,7 +205,11 @@ function createInstanceMatrices(
position.set(...instance.position);
position.y += -geometryBottomY * scaleMultiplier;
rotation.set(...instance.rotation);
rotation.set(
instance.rotation[0] + rotationOffset[0],
instance.rotation[1] + rotationOffset[1],
instance.rotation[2] + rotationOffset[2],
);
quaternion.setFromEuler(rotation);
matrix.compose(position, quaternion, scale);
matrices.push(matrix);
@@ -233,6 +239,7 @@ export function InstancedVegetation({
castShadow,
receiveShadow,
windStrength,
rotationOffset,
}: InstancedVegetationProps): React.JSX.Element | null {
const { scene } = useGLTF(modelPath);
const wind = useWind();
@@ -269,9 +276,10 @@ export function InstancedVegetation({
createInstanceMatrices(
groundedInstances,
scaleMultiplier,
rotationOffset,
getMeshBottomY(meshDataList),
),
[groundedInstances, meshDataList, scaleMultiplier],
[groundedInstances, meshDataList, rotationOffset, scaleMultiplier],
);
const instancedMeshes = useMemo(() => {
+21 -7
View File
@@ -8,16 +8,19 @@ import {
useMapPerformanceStore,
} from "@/managers/stores/useMapPerformanceStore";
import { InstancedVegetation } from "@/world/vegetation/InstancedVegetation";
import {
type VegetationInstance,
useVegetationData,
} from "@/hooks/world/useVegetationData";
import { useVegetationData } from "@/hooks/world/useVegetationData";
import type { VegetationInstance } from "@/types/map/mapScene";
import {
VEGETATION_TYPE_KEYS,
VEGETATION_TYPES,
type VegetationType,
} from "@/data/world/vegetationConfig";
interface VegetationSystemProps {
onlyModelName?: string | null;
streaming?: boolean;
}
interface VegetationChunk {
key: string;
type: VegetationType;
@@ -26,6 +29,7 @@ interface VegetationChunk {
castShadow: boolean;
receiveShadow: boolean;
windStrength: number;
rotationOffset: readonly [number, number, number];
centerX: number;
centerZ: number;
instances: VegetationInstance[];
@@ -73,6 +77,7 @@ function createVegetationChunks(
castShadow: config.castShadow,
receiveShadow: config.receiveShadow,
windStrength: config.windStrength,
rotationOffset: config.rotationOffset,
centerX: center.x / chunkInstances.length,
centerZ: center.z / chunkInstances.length,
instances: chunkInstances,
@@ -80,14 +85,20 @@ function createVegetationChunks(
});
}
export function VegetationSystem(): React.JSX.Element | null {
export function VegetationSystem({
onlyModelName = null,
streaming = true,
}: VegetationSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useVegetationData();
const streamingEnabled =
CHUNK_CONFIG.enabled && sceneMode === "game" && cameraMode === "player";
streaming &&
CHUNK_CONFIG.enabled &&
sceneMode === "game" &&
cameraMode === "player";
const chunks = useMemo(() => {
if (!data) return [];
@@ -95,6 +106,8 @@ export function VegetationSystem(): React.JSX.Element | null {
return VEGETATION_TYPE_KEYS.flatMap((type) => {
const config = VEGETATION_TYPES[type];
if (onlyModelName && config.mapName !== onlyModelName) return [];
if (!config.enabled) return [];
if (!isMapModelVisible(config.mapName, { groups, models })) return [];
@@ -103,7 +116,7 @@ export function VegetationSystem(): React.JSX.Element | null {
return createVegetationChunks(type, entry.instances);
});
}, [data, groups, models]);
}, [data, groups, models, onlyModelName]);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
@@ -122,6 +135,7 @@ export function VegetationSystem(): React.JSX.Element | null {
castShadow={chunk.castShadow}
receiveShadow={chunk.receiveShadow}
windStrength={chunk.windStrength}
rotationOffset={chunk.rotationOffset}
/>
</Suspense>
))}