fix(review): address audit findings before merge
🔍 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-29 01:23:08 +02:00
parent 4728690a11
commit 093ffd726d
45 changed files with 823 additions and 785 deletions
+54 -14
View File
@@ -18,6 +18,7 @@ import {
Unlock,
X,
} from "lucide-react";
import { useState } from "react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
@@ -102,6 +103,52 @@ function EditorPanelGroup({
);
}
interface EditorScaleFieldProps {
axis: 0 | 1 | 2;
label: string;
value: number;
onCommit: (axis: 0 | 1 | 2, value: number) => void;
}
function EditorScaleField({
axis,
label,
onCommit,
value,
}: EditorScaleFieldProps): React.JSX.Element {
const [draftValue, setDraftValue] = useState(() =>
String(Number(value.toFixed(4))),
);
const commitDraftValue = (): void => {
const nextValue = Number(draftValue);
if (!draftValue.trim() || Number.isNaN(nextValue)) {
setDraftValue(String(Number(value.toFixed(4))));
return;
}
onCommit(axis, nextValue);
};
return (
<label>
<span>{label}</span>
<input
type="number"
step="0.01"
value={draftValue}
onBlur={commitDraftValue}
onChange={(event) => setDraftValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
</label>
);
}
export function EditorControls({
transformMode,
onTransformModeChange,
@@ -303,20 +350,13 @@ export function EditorControls({
{selectedNodeScale ? (
<div className="editor-scale-fields">
{selectedNodeScale.map((value, axis) => (
<label key={axis}>
<span>{["X", "Y", "Z"][axis]}</span>
<input
type="number"
step="0.01"
value={Number(value.toFixed(4))}
onChange={(event) =>
onSelectedScaleChange(
axis as 0 | 1 | 2,
Number(event.target.value),
)
}
/>
</label>
<EditorScaleField
key={`${axis}:${value}`}
axis={axis as 0 | 1 | 2}
label={["X", "Y", "Z"][axis] ?? "?"}
value={value}
onCommit={onSelectedScaleChange}
/>
))}
</div>
) : null}
@@ -12,6 +12,17 @@ import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
const EDITOR_CAMERA_HOME_POSITION = new THREE.Vector3(0, 50, 100);
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
function isEditableShortcutTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
);
}
export interface EditorCinematicPreviewRequest {
id: string;
cinematic: CinematicDefinition;
@@ -148,6 +159,8 @@ export function EditorScene({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableShortcutTarget(e.target)) return;
if (e.ctrlKey || e.metaKey) {
if (e.key === "z" || e.key === "Z") {
e.preventDefault();
@@ -3,7 +3,7 @@ import { Component, useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { clone } from "three/addons/utils/SkeletonUtils.js";
import { SkeletonUtils } from "three-stdlib";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import {
useHandTrackingGloveStatus,
@@ -255,7 +255,7 @@ function HandTrackingGloveModel({
throw new Error(`Missing glove root node ${config.rootNodeName}`);
}
const clonedRootNode = clone(rootNode);
const clonedRootNode = SkeletonUtils.clone(rootNode);
clonedRootNode.visible = false;
return clonedRootNode;
+1 -1
View File
@@ -42,7 +42,7 @@ export function SimpleModel({
rotation,
scale,
});
const model = useClonedObject(scene);
const model = useClonedObject(scene, { cloneResources: true });
useEffect(() => {
applyShadowSettings(model, castShadow, receiveShadow);
@@ -0,0 +1,17 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const LA_FABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
type LaFabrikMapModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function LaFabrikMapModel(
props: LaFabrikMapModelProps,
): React.JSX.Element {
return <MergedStaticMapModel modelPath={LA_FABRIK_MODEL_PATH} {...props} />;
}
useGLTF.preload(LA_FABRIK_MODEL_PATH);
@@ -1,15 +0,0 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const LAFABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
type LafabrikModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function LafabrikModel(props: LafabrikModelProps): React.JSX.Element {
return <MergedStaticMapModel modelPath={LAFABRIK_MODEL_PATH} {...props} />;
}
useGLTF.preload(LAFABRIK_MODEL_PATH);
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import { mergeBufferGeometries } from "three-stdlib";
import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
@@ -102,7 +102,7 @@ function createMergedMeshes(scene: THREE.Group): MergedMeshData[] {
};
}
const geometry = mergeGeometries(group.geometries, false);
const geometry = mergeBufferGeometries(group.geometries, false);
for (const sourceGeometry of group.geometries) {
sourceGeometry.dispose();
+6 -1
View File
@@ -5,6 +5,7 @@ import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
interface SkyModelProps {
fallbackModelScale?: number | undefined;
fallbackModelPath?: string | undefined;
modelPath: string;
fallbackColor?: string | undefined;
@@ -53,6 +54,7 @@ class SkyModelErrorBoundary extends Component<
export function SkyModel({
fallbackColor,
fallbackModelScale = SKY_MODEL_SCALE,
fallbackModelPath,
modelPath,
scale = SKY_MODEL_SCALE,
@@ -62,7 +64,10 @@ export function SkyModel({
) : null;
const fallback = fallbackModelPath ? (
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
<SkyModelContent modelPath={fallbackModelPath} scale={scale} />
<SkyModelContent
modelPath={fallbackModelPath}
scale={fallbackModelScale}
/>
</SkyModelErrorBoundary>
) : (
colorFallback
+18
View File
@@ -0,0 +1,18 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface StageAnchorConfig {
color: string;
position: Vector3Tuple;
scale?: number;
}
export const INTRO_STAGE_ANCHOR: StageAnchorConfig = {
color: "#7dd3fc",
position: [0, 4, 0],
};
export const OUTRO_STAGE_ANCHOR: StageAnchorConfig = {
color: "#fb7185",
position: [0, 6, 10],
scale: 1.25,
};
@@ -1,9 +1,9 @@
import type { Vector3Tuple } from "@/types/three/three";
export type PersonnageId = "electricienne" | "gerant" | "fermier";
export type CharacterId = "electricienne" | "gerant" | "fermier";
export interface PersonnageConfig {
id: PersonnageId;
export interface CharacterConfig {
id: CharacterId;
label: string;
modelPath: string;
position: Vector3Tuple;
@@ -13,7 +13,7 @@ export interface PersonnageConfig {
defaultAnimation: string;
}
export const PERSONNAGE_CONFIGS = {
export const CHARACTER_CONFIGS = {
electricienne: {
id: "electricienne",
label: "Electricienne",
@@ -44,10 +44,10 @@ export const PERSONNAGE_CONFIGS = {
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
} satisfies Record<PersonnageId, PersonnageConfig>;
} satisfies Record<CharacterId, CharacterConfig>;
export const PERSONNAGE_IDS = [
export const CHARACTER_IDS = [
"electricienne",
"gerant",
"fermier",
] as const satisfies readonly PersonnageId[];
] as const satisfies readonly CharacterId[];
+1
View File
@@ -1,5 +1,6 @@
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf";
export const GAME_SCENE_SKY_FALLBACK_MODEL_PATH = "/models/sky/model.glb";
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
export const GAME_SCENE_SKY_FALLBACK_MODEL_SCALE = 1;
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
+1 -1
View File
@@ -90,7 +90,7 @@ export function getVegetationModelScaleMultiplier(name: string): number {
);
}
export const INSTANCED_MAP_EXCEPTIONS = new Set([
export const VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES = new Set([
"Scene",
"blocking",
"terrain",
@@ -1,9 +1,9 @@
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import {
PERSONNAGE_CONFIGS,
PERSONNAGE_IDS,
} from "@/data/world/personnages/personnageConfig";
import { usePersonnageDebugStore } from "@/managers/stores/usePersonnageDebugStore";
CHARACTER_CONFIGS,
CHARACTER_IDS,
} from "@/data/world/characters/characterConfig";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
function createAnimationOptions(
animations: readonly string[],
@@ -17,13 +17,13 @@ function createAnimationOptions(
);
}
export function usePersonnageDebug(): void {
export function useCharacterDebug(): void {
useDebugFolder("Personnages", (folder) => {
const store = usePersonnageDebugStore.getState();
const store = useCharacterDebugStore.getState();
for (const id of PERSONNAGE_IDS) {
const config = PERSONNAGE_CONFIGS[id];
const state = store.personnages[id];
for (const id of CHARACTER_IDS) {
const config = CHARACTER_CONFIGS[id];
const state = store.characters[id];
const characterFolder = folder.addFolder(config.label);
const controls = {
animation: state.animation,
@@ -42,64 +42,64 @@ export function usePersonnageDebug(): void {
.add(controls, "animation", createAnimationOptions(config.animations))
.name("Animation")
.onChange((animation: string) => {
usePersonnageDebugStore.getState().setAnimation(id, animation);
useCharacterDebugStore.getState().setAnimation(id, animation);
});
characterFolder
.add(controls, "positionX", -120, 120, 0.1)
.name("Position X")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setPosition(id, 0, value);
useCharacterDebugStore.getState().setPosition(id, 0, value);
});
characterFolder
.add(controls, "positionY", -20, 40, 0.1)
.name("Position Y")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setPosition(id, 1, value);
useCharacterDebugStore.getState().setPosition(id, 1, value);
});
characterFolder
.add(controls, "positionZ", -120, 120, 0.1)
.name("Position Z")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setPosition(id, 2, value);
useCharacterDebugStore.getState().setPosition(id, 2, value);
});
characterFolder
.add(controls, "rotationX", -Math.PI, Math.PI, 0.01)
.name("Rotation X")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setRotation(id, 0, value);
useCharacterDebugStore.getState().setRotation(id, 0, value);
});
characterFolder
.add(controls, "rotationY", -Math.PI, Math.PI, 0.01)
.name("Rotation Y")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setRotation(id, 1, value);
useCharacterDebugStore.getState().setRotation(id, 1, value);
});
characterFolder
.add(controls, "rotationZ", -Math.PI, Math.PI, 0.01)
.name("Rotation Z")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setRotation(id, 2, value);
useCharacterDebugStore.getState().setRotation(id, 2, value);
});
characterFolder
.add(controls, "scaleX", 0.1, 5, 0.05)
.name("Scale X")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setScale(id, 0, value);
useCharacterDebugStore.getState().setScale(id, 0, value);
});
characterFolder
.add(controls, "scaleY", 0.1, 5, 0.05)
.name("Scale Y")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setScale(id, 1, value);
useCharacterDebugStore.getState().setScale(id, 1, value);
});
characterFolder
.add(controls, "scaleZ", 0.1, 5, 0.05)
.name("Scale Z")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setScale(id, 2, value);
useCharacterDebugStore.getState().setScale(id, 2, value);
});
characterFolder.close();
+31 -7
View File
@@ -2,29 +2,53 @@ import { useEffect, useMemo } from "react";
import * as THREE from "three";
import { disposeObject3D } from "@/utils/three/dispose";
function cloneObjectWithOwnedResources<T extends THREE.Object3D>(object: T): T {
interface UseClonedObjectOptions {
cloneResources?: boolean;
}
function cloneMaterial(
material: THREE.Material | THREE.Material[],
): THREE.Material | THREE.Material[] {
return Array.isArray(material)
? material.map((item) => item.clone())
: material.clone();
}
function cloneObject<T extends THREE.Object3D>(
object: T,
cloneResources: boolean,
): T {
const clone = object.clone(true) as T;
if (!cloneResources) return clone;
clone.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
child.geometry = child.geometry.clone();
child.material = Array.isArray(child.material)
? child.material.map((material) => material.clone())
: child.material.clone();
child.material = cloneMaterial(child.material);
});
return clone;
}
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
const clone = useMemo(() => cloneObjectWithOwnedResources(object), [object]);
export function useClonedObject<T extends THREE.Object3D>(
object: T,
options: UseClonedObjectOptions = {},
): T {
const cloneResources = options.cloneResources ?? false;
const clone = useMemo(
() => cloneObject(object, cloneResources),
[cloneResources, object],
);
useEffect(() => {
if (!cloneResources) return undefined;
return () => {
disposeObject3D(clone);
};
}, [clone]);
}, [clone, cloneResources]);
return clone;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import type { Object3D } from "three";
import { Octree } from "three/addons/math/Octree.js";
import { Octree } from "three-stdlib";
import type { OctreeReadyHandler } from "@/types/three/three";
export function useOctreeGraphNode(
+7 -6
View File
@@ -47,6 +47,9 @@ function createTerrainHeightSampler(
new THREE.Vector3(...scale),
);
const inverseTerrainMatrix = terrainMatrix.clone().invert();
const localOrigin = new THREE.Vector3();
const localDirection = DOWN.clone().transformDirection(inverseTerrainMatrix);
const hits: THREE.Intersection[] = [];
const raycaster = new THREE.Raycaster(
new THREE.Vector3(),
DOWN,
@@ -63,13 +66,11 @@ function createTerrainHeightSampler(
return {
getHeight: (x, z) => {
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
inverseTerrainMatrix,
);
const localDirection =
DOWN.clone().transformDirection(inverseTerrainMatrix);
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
raycaster.set(localOrigin, localDirection);
const hit = raycaster.intersectObjects(meshes, false)[0];
hits.length = 0;
raycaster.intersectObjects(meshes, false, hits);
const hit = hits[0];
return hit?.point.applyMatrix4(terrainMatrix).y ?? null;
},
};
+2 -40
View File
@@ -1,14 +1,9 @@
import { useEffect, useState } from "react";
import { INSTANCED_MAP_EXCEPTIONS } from "@/data/world/vegetationConfig";
import { VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES } from "@/data/world/vegetationConfig";
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";
import {
createPotagerMapNode,
isPotagerSourceMapNode,
POTAGER_MAP_NAME,
} from "@/utils/map/potagerMapNodes";
interface InstancedMapEntry {
modelPath: string;
@@ -17,10 +12,6 @@ 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>,
@@ -48,7 +39,7 @@ function extractVegetationData(
for (const node of mapNodes) {
if (node.type !== "Object3D") continue;
if (INSTANCED_MAP_EXCEPTIONS.has(node.name)) continue;
if (VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES.has(node.name)) continue;
const modelPath = models.get(node.name);
if (!modelPath) continue;
@@ -56,35 +47,6 @@ function extractVegetationData(
addInstance(node.name, modelPath, 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;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import type { Octree } from "three/addons/math/Octree.js";
import type { Octree } from "three-stdlib";
import type { SceneMode } from "@/types/debug/debug";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -0,0 +1,89 @@
import { create } from "zustand";
import {
CHARACTER_CONFIGS,
CHARACTER_IDS,
type CharacterId,
} from "@/data/world/characters/characterConfig";
import type { Vector3Tuple } from "@/types/three/three";
interface CharacterDebugState {
animation: string;
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
interface CharacterDebugStore {
characters: Record<CharacterId, CharacterDebugState>;
setAnimation: (id: CharacterId, animation: string) => void;
setPosition: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
setRotation: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
setScale: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
}
function updateVector(
vector: Vector3Tuple,
axis: 0 | 1 | 2,
value: number,
): Vector3Tuple {
const next: Vector3Tuple = [...vector];
next[axis] = value;
return next;
}
const initialCharacters = Object.fromEntries(
CHARACTER_IDS.map((id) => {
const config = CHARACTER_CONFIGS[id];
return [
id,
{
animation: config.defaultAnimation,
position: [...config.position],
rotation: [...config.rotation],
scale: [...config.scale],
},
];
}),
) as Record<CharacterId, CharacterDebugState>;
export const useCharacterDebugStore = create<CharacterDebugStore>((set) => ({
characters: initialCharacters,
setAnimation: (id, animation) =>
set((state) => ({
characters: {
...state.characters,
[id]: { ...state.characters[id], animation },
},
})),
setPosition: (id, axis, value) =>
set((state) => ({
characters: {
...state.characters,
[id]: {
...state.characters[id],
position: updateVector(state.characters[id].position, axis, value),
},
},
})),
setRotation: (id, axis, value) =>
set((state) => ({
characters: {
...state.characters,
[id]: {
...state.characters[id],
rotation: updateVector(state.characters[id].rotation, axis, value),
},
},
})),
setScale: (id, axis, value) =>
set((state) => ({
characters: {
...state.characters,
[id]: {
...state.characters[id],
scale: updateVector(state.characters[id].scale, axis, value),
},
},
})),
}));
@@ -1,89 +0,0 @@
import { create } from "zustand";
import {
PERSONNAGE_CONFIGS,
PERSONNAGE_IDS,
type PersonnageId,
} from "@/data/world/personnages/personnageConfig";
import type { Vector3Tuple } from "@/types/three/three";
interface PersonnageDebugState {
animation: string;
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
interface PersonnageDebugStore {
personnages: Record<PersonnageId, PersonnageDebugState>;
setAnimation: (id: PersonnageId, animation: string) => void;
setPosition: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void;
setRotation: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void;
setScale: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void;
}
function updateVector(
vector: Vector3Tuple,
axis: 0 | 1 | 2,
value: number,
): Vector3Tuple {
const next: Vector3Tuple = [...vector];
next[axis] = value;
return next;
}
const initialPersonnages = Object.fromEntries(
PERSONNAGE_IDS.map((id) => {
const config = PERSONNAGE_CONFIGS[id];
return [
id,
{
animation: config.defaultAnimation,
position: [...config.position],
rotation: [...config.rotation],
scale: [...config.scale],
},
];
}),
) as Record<PersonnageId, PersonnageDebugState>;
export const usePersonnageDebugStore = create<PersonnageDebugStore>((set) => ({
personnages: initialPersonnages,
setAnimation: (id, animation) =>
set((state) => ({
personnages: {
...state.personnages,
[id]: { ...state.personnages[id], animation },
},
})),
setPosition: (id, axis, value) =>
set((state) => ({
personnages: {
...state.personnages,
[id]: {
...state.personnages[id],
position: updateVector(state.personnages[id].position, axis, value),
},
},
})),
setRotation: (id, axis, value) =>
set((state) => ({
personnages: {
...state.personnages,
[id]: {
...state.personnages[id],
rotation: updateVector(state.personnages[id].rotation, axis, value),
},
},
})),
setScale: (id, axis, value) =>
set((state) => ({
personnages: {
...state.personnages,
[id]: {
...state.personnages[id],
scale: updateVector(state.personnages[id].scale, axis, value),
},
},
})),
}));
+95 -335
View File
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { useCallback, useEffect, useState } from "react";
import { Canvas, useThree } from "@react-three/fiber";
import { EditorControls } from "@/components/editor/EditorControls";
import { EditorScene } from "@/components/editor/scene/EditorScene";
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
@@ -8,268 +8,48 @@ import { Subtitles } from "@/components/ui/Subtitles";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
import type {
HierarchicalMapNode,
MapNode,
SceneData,
TransformMode,
} from "@/types/editor/editor";
import type { MapNode, TransformMode } from "@/types/editor/editor";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger";
import {
addTreeNode,
createNewMapNode,
mergeFlatNodeTransformsIntoTree,
removeEditorMetadata,
removeTreeNodeAtPath,
serializeMapNodes,
updateSceneDataTree,
updateTreeNodeAtPath,
} from "@/utils/editor/editorMapTree";
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
const DEFAULT_NEW_NODE_NAME = "new-model";
function serializeMapNodes(sceneData: SceneData): string {
const mapPayload = sceneData.mapTree
? mergeFlatNodeTransformsIntoTree(sceneData)
: sceneData.mapNodes.map(removeEditorMetadata);
function EditorWebGLContextLogger(): null {
const gl = useThree((state) => state.gl);
return JSON.stringify(mapPayload, null, 2);
}
useEffect(() => {
gl.setClearColor("#050505");
function createSourcePathKey(sourcePath: readonly number[]): string {
return sourcePath.join(".");
}
function removeEditorMetadata(node: MapNode): MapNode {
return {
...(node.id ? { id: node.id } : {}),
name: node.name,
type: node.type,
position: node.position,
rotation: node.rotation,
scale: node.scale,
};
}
function mergeFlatNodeTransformsIntoTree(
sceneData: SceneData,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nodesBySourcePath = new Map<string, MapNode>();
for (const node of sceneData.mapNodes) {
if (!node.sourcePath) continue;
nodesBySourcePath.set(createSourcePathKey(node.sourcePath), node);
}
const cloneNode = (
node: HierarchicalMapNode,
path: number[],
): HierarchicalMapNode => {
const updatedNode = nodesBySourcePath.get(createSourcePathKey(path));
const nextNode: HierarchicalMapNode = {
...((updatedNode?.id ?? node.id)
? { id: updatedNode?.id ?? node.id }
: {}),
name: node.name,
type: node.type,
position: updatedNode?.position ?? node.position,
rotation: updatedNode?.rotation ?? node.rotation,
scale: updatedNode?.scale ?? node.scale,
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - GPU resources exhausted");
};
const handleContextRestored = () => {
logger.info("WebGL", "Context restored");
};
if (node.role) {
nextNode.role = node.role;
}
canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener("webglcontextrestored", handleContextRestored);
if (node.children) {
nextNode.children = node.children.map((child, index) =>
cloneNode(child, [...path, index]),
);
}
return () => {
canvas.removeEventListener("webglcontextlost", handleContextLost);
canvas.removeEventListener("webglcontextrestored", handleContextRestored);
};
}, [gl]);
return nextNode;
};
const mapTree = sceneData.mapTree;
if (!mapTree) {
return sceneData.mapNodes.map(removeEditorMetadata);
}
if (Array.isArray(mapTree)) {
return mapTree.map((node, index) => cloneNode(node, [index]));
}
return cloneNode(mapTree, []);
}
function cloneMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): HierarchicalMapNode | HierarchicalMapNode[] {
return JSON.parse(JSON.stringify(mapTree)) as
| HierarchicalMapNode
| HierarchicalMapNode[];
}
function collectEditableMapNodes(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): MapNode[] {
const nodes: MapNode[] = [];
function visit(node: HierarchicalMapNode, path: number[]): void {
if (node.role !== "group" && node.type !== "Mesh") {
nodes.push({
...(node.id ? { id: node.id } : {}),
name: node.name,
position: node.position,
rotation: node.rotation,
scale: node.scale,
sourcePath: path,
type: node.type,
});
}
node.children?.forEach((child, index) => visit(child, [...path, index]));
}
if (Array.isArray(mapTree)) {
mapTree.forEach((node, index) => visit(node, [index]));
} else {
visit(mapTree, []);
}
return nodes;
}
function updateTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
update: (node: HierarchicalMapNode) => HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1] ?? 0;
const isRootTarget = Array.isArray(nextTree)
? path.length === 1
: path.length === 0;
if (isRootTarget) {
const targetNode = rootNodes[targetIndex];
if (targetNode) {
rootNodes[targetIndex] = update(targetNode);
}
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
if (!parent?.children?.[targetIndex]) return nextTree;
parent.children[targetIndex] = update(parent.children[targetIndex]);
return nextTree;
}
function removeTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1];
if (targetIndex === undefined) return nextTree;
if (Array.isArray(nextTree) && path.length === 1) {
nextTree.splice(targetIndex, 1);
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
parent?.children?.splice(targetIndex, 1);
return nextTree;
}
function updateSceneDataTree(
sceneData: SceneData,
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): SceneData {
return {
...sceneData,
mapNodes: collectEditableMapNodes(mapTree),
mapTree,
};
}
function findNodePathByName(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
name: string,
): number[] | null {
function visit(node: HierarchicalMapNode, path: number[]): number[] | null {
if (node.name === name) return path;
for (let index = 0; index < (node.children?.length ?? 0); index++) {
const child = node.children?.[index];
if (!child) continue;
const result = visit(child, [...path, index]);
if (result) return result;
}
return null;
}
if (Array.isArray(mapTree)) {
for (let index = 0; index < mapTree.length; index++) {
const node = mapTree[index];
if (!node) continue;
const result = visit(node, [index]);
if (result) return result;
}
return null;
}
return visit(mapTree, []);
}
function addTreeNode(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
node: HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const blockingPath = findNodePathByName(mapTree, "blocking");
if (!blockingPath) return mapTree;
return updateTreeNodeAtPath(mapTree, blockingPath, (blockingNode) => ({
...blockingNode,
children: [...(blockingNode.children ?? []), node],
}));
}
function createNewMapNode(name: string): HierarchicalMapNode {
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
return {
name: safeName,
type: "Object3D",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
children: [
{
name: safeName,
type: "Mesh",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
};
return null;
}
export function EditorPage(): React.JSX.Element {
@@ -336,13 +116,14 @@ export function EditorPage(): React.JSX.Element {
setResetCameraRequest((request) => request + 1);
}, []);
const handleToggleNodeSelection = useCallback((index: number) => {
setSelectedNodeIndexes((currentIndexes) => {
const isSelected = currentIndexes.includes(index);
const handleToggleNodeSelection = useCallback(
(index: number) => {
const isSelected = selectedNodeIndexes.includes(index);
const nextIndexes = isSelected
? currentIndexes.filter((item) => item !== index)
: [...currentIndexes, index];
? selectedNodeIndexes.filter((item) => item !== index)
: [...selectedNodeIndexes, index];
setSelectedNodeIndexes(nextIndexes);
setSelectedNodeIndex(nextIndexes.at(-1) ?? null);
if (nextIndexes.length > 0) {
setCameraViewMode("object");
@@ -350,10 +131,9 @@ export function EditorPage(): React.JSX.Element {
setCameraViewMode("home");
setResetCameraRequest((request) => request + 1);
}
return nextIndexes;
});
}, []);
},
[selectedNodeIndexes],
);
const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null);
@@ -399,28 +179,20 @@ export function EditorPage(): React.JSX.Element {
if (!locked) return;
setSelectedNodeIndex((currentIndex) => {
if (currentIndex === null) return null;
const nextIndexes = selectedNodeIndexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
);
const selectedNode =
selectedNodeIndex !== null
? sceneData?.mapNodes[selectedNodeIndex]
: null;
const selectedNode = sceneData?.mapNodes[currentIndex];
if (selectedNode?.name === "terrain") {
setSelectedNodeIndexes((indexes) =>
indexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
),
);
return null;
}
setSelectedNodeIndexes((indexes) =>
indexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
),
);
return currentIndex;
});
setSelectedNodeIndexes(nextIndexes);
setSelectedNodeIndex(
selectedNode?.name === "terrain" ? null : selectedNodeIndex,
);
},
[sceneData],
[sceneData, selectedNodeIndex, selectedNodeIndexes],
);
const handleHoverNode = useCallback((index: number | null) => {
@@ -554,51 +326,55 @@ export function EditorPage(): React.JSX.Element {
);
const handleAddNode = useCallback(() => {
setSceneData((prev) => {
if (!prev) return null;
if (!prev.mapTree) {
const newNode = createNewMapNode(newNodeName);
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)];
setSelectedNodeIndex(mapNodes.length - 1);
setSelectedNodeIndexes([mapNodes.length - 1]);
return { ...prev, mapNodes };
}
if (!sceneData) return;
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
const nextSceneData = updateSceneDataTree(prev, mapTree);
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]);
return nextSceneData;
});
}, [newNodeName, setSceneData]);
if (!sceneData.mapTree) {
const newNode = createNewMapNode(newNodeName);
const mapNodes = [...sceneData.mapNodes, removeEditorMetadata(newNode)];
const selectedIndex = mapNodes.length - 1;
setSceneData({ ...sceneData, mapNodes });
setSelectedNodeIndex(selectedIndex);
setSelectedNodeIndexes([selectedIndex]);
return;
}
const mapTree = addTreeNode(
sceneData.mapTree,
createNewMapNode(newNodeName),
);
const nextSceneData = updateSceneDataTree(sceneData, mapTree);
const selectedIndex = nextSceneData.mapNodes.length - 1;
setSceneData(nextSceneData);
setSelectedNodeIndex(selectedIndex);
setSelectedNodeIndexes([selectedIndex]);
}, [newNodeName, sceneData, setSceneData]);
const handleDeleteSelectedNode = useCallback(() => {
if (selectedNodeIndex === null) return;
if (!sceneData || selectedNodeIndex === null) return;
setSceneData((prev) => {
if (!prev) return null;
const currentNode = prev.mapNodes[selectedNodeIndex];
if (!currentNode) return prev;
if (!prev.mapTree || !currentNode.sourcePath) {
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
return {
...prev,
mapNodes: prev.mapNodes.filter(
(_node, index) => index !== selectedNodeIndex,
),
};
}
const currentNode = sceneData.mapNodes[selectedNodeIndex];
if (!currentNode) return;
if (!sceneData.mapTree || !currentNode.sourcePath) {
setSceneData({
...sceneData,
mapNodes: sceneData.mapNodes.filter(
(_node, index) => index !== selectedNodeIndex,
),
});
} else {
const mapTree = removeTreeNodeAtPath(
prev.mapTree,
sceneData.mapTree,
currentNode.sourcePath,
);
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
return updateSceneDataTree(prev, mapTree);
});
}, [selectedNodeIndex, setSceneData]);
setSceneData(updateSceneDataTree(sceneData, mapTree));
}
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
}, [sceneData, selectedNodeIndex, setSceneData]);
if (isMapLoading) {
return (
@@ -655,24 +431,8 @@ export function EditorPage(): React.JSX.Element {
antialias: true,
stencil: false,
}}
onCreated={({ gl }) => {
gl.setClearColor("#050505");
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - GPU resources exhausted");
};
const handleContextRestored = () => {
logger.info("WebGL", "Context restored");
};
canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener(
"webglcontextrestored",
handleContextRestored,
);
}}
>
<EditorWebGLContextLogger />
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
-42
View File
@@ -1,42 +0,0 @@
declare module "three/addons/math/Capsule.js" {
import { Vector3 } from "three";
export class Capsule {
start: Vector3;
end: Vector3;
radius: number;
constructor(start?: Vector3, end?: Vector3, radius?: number);
set(start: Vector3, end: Vector3, radius: number): this;
clone(): Capsule;
copy(capsule: Capsule): this;
getCenter(target: Vector3): Vector3;
translate(v: Vector3): this;
}
}
declare module "three/addons/math/Octree.js" {
import { Object3D } from "three";
import { Capsule } from "three/addons/math/Capsule.js";
export interface CapsuleIntersectResult {
normal: import("three").Vector3;
depth: number;
}
export class Octree {
constructor();
fromGraphNode(group: Object3D): this;
capsuleIntersect(capsule: Capsule): CapsuleIntersectResult | false;
}
}
declare module "three/addons/utils/BufferGeometryUtils.js" {
import { BufferGeometry } from "three";
export function mergeGeometries(
geometries: BufferGeometry[],
useGroups?: boolean,
): BufferGeometry | null;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Octree } from "three/addons/math/Octree.js";
import type { Octree } from "three-stdlib";
export type Vector3Tuple = [number, number, number];
+259
View File
@@ -0,0 +1,259 @@
import type {
HierarchicalMapNode,
MapNode,
SceneData,
} from "@/types/editor/editor";
const DEFAULT_NEW_NODE_NAME = "new-model";
export function serializeMapNodes(sceneData: SceneData): string {
const mapPayload = sceneData.mapTree
? mergeFlatNodeTransformsIntoTree(sceneData)
: sceneData.mapNodes.map(removeEditorMetadata);
return JSON.stringify(mapPayload, null, 2);
}
function createSourcePathKey(sourcePath: readonly number[]): string {
return sourcePath.join(".");
}
export function removeEditorMetadata(node: MapNode): MapNode {
return {
...(node.id ? { id: node.id } : {}),
name: node.name,
type: node.type,
position: node.position,
rotation: node.rotation,
scale: node.scale,
};
}
export function mergeFlatNodeTransformsIntoTree(
sceneData: SceneData,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nodesBySourcePath = new Map<string, MapNode>();
for (const node of sceneData.mapNodes) {
if (!node.sourcePath) continue;
nodesBySourcePath.set(createSourcePathKey(node.sourcePath), node);
}
const cloneNode = (
node: HierarchicalMapNode,
path: number[],
): HierarchicalMapNode => {
const updatedNode = nodesBySourcePath.get(createSourcePathKey(path));
const nextNode: HierarchicalMapNode = {
...((updatedNode?.id ?? node.id)
? { id: updatedNode?.id ?? node.id }
: {}),
name: node.name,
type: node.type,
position: updatedNode?.position ?? node.position,
rotation: updatedNode?.rotation ?? node.rotation,
scale: updatedNode?.scale ?? node.scale,
};
if (node.role) {
nextNode.role = node.role;
}
if (node.children) {
nextNode.children = node.children.map((child, index) =>
cloneNode(child, [...path, index]),
);
}
return nextNode;
};
const mapTree = sceneData.mapTree;
if (!mapTree) {
return sceneData.mapNodes.map(removeEditorMetadata);
}
if (Array.isArray(mapTree)) {
return mapTree.map((node, index) => cloneNode(node, [index]));
}
return cloneNode(mapTree, []);
}
function cloneMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): HierarchicalMapNode | HierarchicalMapNode[] {
return JSON.parse(JSON.stringify(mapTree)) as
| HierarchicalMapNode
| HierarchicalMapNode[];
}
function collectEditableMapNodes(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): MapNode[] {
const nodes: MapNode[] = [];
function visit(node: HierarchicalMapNode, path: number[]): void {
if (node.role !== "group" && node.type !== "Mesh") {
nodes.push({
...(node.id ? { id: node.id } : {}),
name: node.name,
position: node.position,
rotation: node.rotation,
scale: node.scale,
sourcePath: path,
type: node.type,
});
}
node.children?.forEach((child, index) => visit(child, [...path, index]));
}
if (Array.isArray(mapTree)) {
mapTree.forEach((node, index) => visit(node, [index]));
} else {
visit(mapTree, []);
}
return nodes;
}
export function updateTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
update: (node: HierarchicalMapNode) => HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1] ?? 0;
const isRootTarget = Array.isArray(nextTree)
? path.length === 1
: path.length === 0;
if (isRootTarget) {
const targetNode = rootNodes[targetIndex];
if (targetNode) {
rootNodes[targetIndex] = update(targetNode);
}
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
if (!parent?.children?.[targetIndex]) return nextTree;
parent.children[targetIndex] = update(parent.children[targetIndex]);
return nextTree;
}
export function removeTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1];
if (targetIndex === undefined) return nextTree;
if (Array.isArray(nextTree) && path.length === 1) {
nextTree.splice(targetIndex, 1);
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
parent?.children?.splice(targetIndex, 1);
return nextTree;
}
export function updateSceneDataTree(
sceneData: SceneData,
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): SceneData {
return {
...sceneData,
mapNodes: collectEditableMapNodes(mapTree),
mapTree,
};
}
function findNodePathByName(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
name: string,
): number[] | null {
function visit(node: HierarchicalMapNode, path: number[]): number[] | null {
if (node.name === name) return path;
for (let index = 0; index < (node.children?.length ?? 0); index++) {
const child = node.children?.[index];
if (!child) continue;
const result = visit(child, [...path, index]);
if (result) return result;
}
return null;
}
if (Array.isArray(mapTree)) {
for (let index = 0; index < mapTree.length; index++) {
const node = mapTree[index];
if (!node) continue;
const result = visit(node, [index]);
if (result) return result;
}
return null;
}
return visit(mapTree, []);
}
export function addTreeNode(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
node: HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const blockingPath = findNodePathByName(mapTree, "blocking");
if (!blockingPath) return mapTree;
return updateTreeNodeAtPath(mapTree, blockingPath, (blockingNode) => ({
...blockingNode,
children: [...(blockingNode.children ?? []), node],
}));
}
export function createNewMapNode(name: string): HierarchicalMapNode {
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
return {
name: safeName,
type: "Object3D",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
children: [
{
name: safeName,
type: "Mesh",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ export async function createSceneDataFromFiles(
const models = new Map<string, string>();
for (const [path, file] of fileMap.entries()) {
const modelMatch = path.match(/^\/models\/(.+)\/model\.(glb|gltf)$/);
const modelMatch = path.match(/^\/models\/(.+)\/(?:model|\1)\.(glb|gltf)$/);
const modelName = modelMatch?.[1];
const modelExtension = modelMatch?.[2];
@@ -0,0 +1,17 @@
import { REPAIR_MISSION_POSITION_ENTRIES } from "@/data/gameplay/repairMissionAnchors";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
const FALLBACK_REPAIR_MISSION_POSITIONS = new Map(
REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => [
mission,
position,
]),
);
export function getRepairMissionPosition(
mission: RepairMissionId,
anchors: Partial<Record<RepairMissionId, Vector3Tuple>>,
): Vector3Tuple | undefined {
return anchors[mission] ?? FALLBACK_REPAIR_MISSION_POSITIONS.get(mission);
}
+55
View File
@@ -0,0 +1,55 @@
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import type { Vector3Tuple } from "@/types/three/three";
interface PositionedInstance {
position: Vector3Tuple;
}
export interface WorldInstanceChunk<TInstance extends PositionedInstance> {
centerX: number;
centerZ: number;
chunkKey: string;
instances: TInstance[];
}
function getWorldChunkKey(instance: PositionedInstance): string {
const [x, , z] = instance.position;
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
return `${chunkX}:${chunkZ}`;
}
export function createWorldInstanceChunks<TInstance extends PositionedInstance>(
instances: TInstance[],
): WorldInstanceChunk<TInstance>[] {
const chunks = new Map<string, TInstance[]>();
for (const instance of instances) {
const chunkKey = getWorldChunkKey(instance);
const chunk = chunks.get(chunkKey);
if (chunk) {
chunk.push(instance);
} else {
chunks.set(chunkKey, [instance]);
}
}
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
const center = chunkInstances.reduce(
(sum, instance) => {
sum.x += instance.position[0];
sum.z += instance.position[2];
return sum;
},
{ x: 0, z: 0 },
);
return {
centerX: center.x / chunkInstances.length,
centerZ: center.z / chunkInstances.length,
chunkKey,
instances: chunkInstances,
};
});
}
+2
View File
@@ -1,6 +1,7 @@
import {
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
GAME_SCENE_SKY_FALLBACK_MODEL_PATH,
GAME_SCENE_SKY_FALLBACK_MODEL_SCALE,
GAME_SCENE_SKY_MODEL_PATH,
GAME_SCENE_SKY_MODEL_SCALE,
PHYSICS_SCENE_BACKGROUND_COLOR,
@@ -36,6 +37,7 @@ export function Environment(): React.JSX.Element {
{showSky ? (
<SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
fallbackModelScale={GAME_SCENE_SKY_FALLBACK_MODEL_SCALE}
fallbackModelPath={GAME_SCENE_SKY_FALLBACK_MODEL_PATH}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
+7 -21
View File
@@ -4,25 +4,15 @@ import {
REPAIR_MISSION_POSITION_ENTRIES,
REPAIR_MISSION_TRIGGERS,
} from "@/data/gameplay/repairMissionAnchors";
import {
INTRO_STAGE_ANCHOR,
OUTRO_STAGE_ANCHOR,
} from "@/data/gameplay/gameStageAnchors";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
const FALLBACK_REPAIR_MISSION_POSITIONS = new Map(
REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => [
mission,
position,
]),
);
function getRepairMissionPosition(
mission: RepairMissionId,
anchors: Partial<Record<RepairMissionId, Vector3Tuple>>,
): Vector3Tuple | undefined {
return anchors[mission] ?? FALLBACK_REPAIR_MISSION_POSITIONS.get(mission);
}
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
interface StageAnchorProps {
color: string;
@@ -89,9 +79,7 @@ export function GameStageContent(): React.JSX.Element {
return (
<>
{mainState === "intro" ? (
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
) : null}
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
const position = getRepairMissionPosition(mission, anchors);
if (!position) return null;
@@ -102,9 +90,7 @@ export function GameStageContent(): React.JSX.Element {
{REPAIR_MISSION_TRIGGERS.map((config) => (
<RepairMissionTrigger key={config.mission} config={config} />
))}
{mainState === "outro" ? (
<StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />
) : null}
{mainState === "outro" ? <StageAnchor {...OUTRO_STAGE_ANCHOR} /> : null}
</>
);
}
+4 -4
View File
@@ -7,7 +7,7 @@ import {
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
import { usePersonnageDebug } from "@/hooks/debug/usePersonnageDebug";
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
@@ -29,7 +29,7 @@ import { GameMusic } from "@/world/GameMusic";
import { Lighting } from "@/world/Lighting";
import { GameMap } from "@/world/GameMap";
import { GameStageContent } from "@/world/GameStageContent";
import { PersonnageSystem } from "@/world/personnages/PersonnageSystem";
import { CharacterSystem } from "@/world/characters/CharacterSystem";
import { Player } from "@/world/player/Player";
import { TestMap } from "@/world/debug/TestMap";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -41,7 +41,7 @@ interface WorldProps {
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
useEnvironmentDebug();
useMapPerformanceDebug();
usePersonnageDebug();
useCharacterDebug();
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
@@ -90,7 +90,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={handleOctreeReady}
/>
{showGameStage ? <PersonnageSystem /> : null}
{showGameStage && mainState !== "ebike" ? <CharacterSystem /> : null}
{showGameStage ? (
<Physics>
<GameStageLoaded onLoaded={handleGameStageLoaded} />
@@ -1,16 +1,16 @@
import { Suspense } from "react";
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import {
PERSONNAGE_CONFIGS,
PERSONNAGE_IDS,
type PersonnageId,
} from "@/data/world/personnages/personnageConfig";
CHARACTER_CONFIGS,
CHARACTER_IDS,
type CharacterId,
} from "@/data/world/characters/characterConfig";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
import { usePersonnageDebugStore } from "@/managers/stores/usePersonnageDebugStore";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
function PersonnageModel({ id }: { id: PersonnageId }): React.JSX.Element {
const config = PERSONNAGE_CONFIGS[id];
const state = usePersonnageDebugStore((store) => store.personnages[id]);
function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element {
const config = CHARACTER_CONFIGS[id];
const state = useCharacterDebugStore((store) => store.characters[id]);
const position = useTerrainSnappedPosition(state.position);
return (
@@ -24,12 +24,12 @@ function PersonnageModel({ id }: { id: PersonnageId }): React.JSX.Element {
);
}
export function PersonnageSystem(): React.JSX.Element {
export function CharacterSystem(): React.JSX.Element {
return (
<group name="personnage-system">
{PERSONNAGE_IDS.map((id) => (
<group name="character-system">
{CHARACTER_IDS.map((id) => (
<Suspense key={id} fallback={null}>
<PersonnageModel id={id} />
<CharacterModel id={id} />
</Suspense>
))}
</group>
+53 -58
View File
@@ -24,89 +24,84 @@ function random01(seed: number): number {
return value - Math.floor(value);
}
function pushVector(target: number[], value: THREE.Vector3): void {
target.push(value.x, value.y, value.z);
}
function pushColor(target: number[], value: THREE.Color): void {
target.push(value.r, value.g, value.b);
}
const GRASS_COLOR_VALUES = GRASS_COLORS.map((color) => new THREE.Color(color));
const MARKER_COLOR_VALUES = [0.1, 0, 0, 0, 0, 0.1, 1, 1, 1] as const;
function createGrassGeometry(density: number): THREE.BufferGeometry {
const positions: number[] = [];
const colors: number[] = [];
const uvs: number[] = [];
const bladeOrigins: number[] = [];
const yaws: number[] = [];
const bladeCount = Math.round(GRASS_CONFIG.bladeCount * density);
const vertexCount = bladeCount * 3;
const positions = new Float32Array(vertexCount * 3);
const markerColorValues = new Float32Array(vertexCount * 3);
const bladeColorValues = new Float32Array(vertexCount * 3);
const uvs = new Float32Array(vertexCount * 2);
const bladeOrigins = new Float32Array(vertexCount * 3);
const yaws = new Float32Array(vertexCount * 3);
const halfPatchSize = GRASS_CONFIG.patchSize * 0.5;
for (let index = 0; index < bladeCount; index++) {
const seed = index * 997;
const origin = new THREE.Vector3(
random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize,
0,
random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize,
);
const originX = random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize;
const originY = 0;
const originZ = random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize;
const yawAngle = random01(seed + 3) * Math.PI * 2;
const yaw = new THREE.Vector3(Math.sin(yawAngle), 0, -Math.cos(yawAngle));
const yawX = Math.sin(yawAngle);
const yawY = 0;
const yawZ = -Math.cos(yawAngle);
const colorIndex = Math.floor(random01(seed + 4) * GRASS_COLORS.length);
const color = new THREE.Color(GRASS_COLORS[colorIndex] ?? GRASS_COLORS[0]);
const markerColors = [
new THREE.Color(0.1, 0, 0),
new THREE.Color(0, 0, 0.1),
new THREE.Color(1, 1, 1),
] as const;
const uv = new THREE.Vector2(
origin.x / GRASS_CONFIG.patchSize + 0.5,
origin.z / GRASS_CONFIG.patchSize + 0.5,
);
const color = GRASS_COLOR_VALUES[colorIndex] ?? GRASS_COLOR_VALUES[0];
const uvX = originX / GRASS_CONFIG.patchSize + 0.5;
const uvY = originZ / GRASS_CONFIG.patchSize + 0.5;
for (let vertexIndex = 0; vertexIndex < 3; vertexIndex++) {
pushVector(positions, origin);
pushColor(colors, markerColors[vertexIndex] ?? markerColors[2]);
pushVector(bladeOrigins, origin);
pushVector(yaws, yaw);
pushColor(colors, color);
uvs.push(uv.x, uv.y);
const vertexOffset = index * 3 + vertexIndex;
const vectorOffset = vertexOffset * 3;
const uvOffset = vertexOffset * 2;
const markerOffset = vertexIndex * 3;
positions[vectorOffset] = originX;
positions[vectorOffset + 1] = originY;
positions[vectorOffset + 2] = originZ;
markerColorValues[vectorOffset] = MARKER_COLOR_VALUES[markerOffset] ?? 1;
markerColorValues[vectorOffset + 1] =
MARKER_COLOR_VALUES[markerOffset + 1] ?? 1;
markerColorValues[vectorOffset + 2] =
MARKER_COLOR_VALUES[markerOffset + 2] ?? 1;
bladeColorValues[vectorOffset] = color?.r ?? 0;
bladeColorValues[vectorOffset + 1] = color?.g ?? 0;
bladeColorValues[vectorOffset + 2] = color?.b ?? 0;
bladeOrigins[vectorOffset] = originX;
bladeOrigins[vectorOffset + 1] = originY;
bladeOrigins[vectorOffset + 2] = originZ;
yaws[vectorOffset] = yawX;
yaws[vectorOffset + 1] = yawY;
yaws[vectorOffset + 2] = yawZ;
uvs[uvOffset] = uvX;
uvs[uvOffset + 1] = uvY;
}
}
const geometry = new THREE.BufferGeometry();
const markerColorValues: number[] = [];
const bladeColorValues: number[] = [];
for (let index = 0; index < colors.length; index += 6) {
markerColorValues.push(
colors[index] ?? 0,
colors[index + 1] ?? 0,
colors[index + 2] ?? 0,
);
bladeColorValues.push(
colors[index + 3] ?? 0,
colors[index + 4] ?? 0,
colors[index + 5] ?? 0,
);
}
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(markerColorValues, 3),
new THREE.BufferAttribute(markerColorValues, 3),
);
geometry.setAttribute(
"aBladeColor",
new THREE.Float32BufferAttribute(bladeColorValues, 3),
new THREE.BufferAttribute(bladeColorValues, 3),
);
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
geometry.setAttribute(
"aBladeOrigin",
new THREE.Float32BufferAttribute(bladeOrigins, 3),
new THREE.BufferAttribute(bladeOrigins, 3),
);
geometry.setAttribute("aYaw", new THREE.Float32BufferAttribute(yaws, 3));
geometry.setAttribute("aYaw", new THREE.BufferAttribute(yaws, 3));
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
+9 -8
View File
@@ -65,6 +65,10 @@ function createTerrainGrassSampler(
const terrainMatrix = createTerrainMatrix(position, rotation, scale);
const inverseTerrainMatrix = terrainMatrix.clone().invert();
const normalMatrix = new THREE.Matrix3().getNormalMatrix(terrainMatrix);
const localOrigin = new THREE.Vector3();
const localDirection = DOWN.clone().transformDirection(inverseTerrainMatrix);
const fallbackNormal = new THREE.Vector3(0, 1, 0);
const hits: THREE.Intersection[] = [];
const raycaster = new THREE.Raycaster(
new THREE.Vector3(),
DOWN,
@@ -94,14 +98,11 @@ function createTerrainGrassSampler(
};
const sample = (x: number, z: number): TerrainGrassSample | null => {
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
inverseTerrainMatrix,
);
const localDirection =
DOWN.clone().transformDirection(inverseTerrainMatrix);
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
raycaster.set(localOrigin, localDirection);
const hit = raycaster.intersectObjects(meshes, false)[0];
hits.length = 0;
raycaster.intersectObjects(meshes, false, hits);
const hit = hits[0];
if (!hit) return null;
const normal = hit.face?.normal
@@ -112,7 +113,7 @@ function createTerrainGrassSampler(
return {
position: hit.point.clone().applyMatrix4(terrainMatrix),
normal: normal ?? new THREE.Vector3(0, 1, 0),
normal: normal ?? fallbackNormal.clone(),
};
};
@@ -1,7 +1,7 @@
import { EcoleModel } from "@/components/three/world/EcoleModel";
import { FermeVerticaleModel } from "@/components/three/world/FermeVerticaleModel";
import { GenerateurModel } from "@/components/three/world/GenerateurModel";
import { LafabrikModel } from "@/components/three/world/LafabrikModel";
import { LaFabrikMapModel } from "@/components/three/world/LaFabrikMapModel";
import {
normalizeMapScale,
useTerrainSnappedPosition,
@@ -55,7 +55,7 @@ export function GeneratedMapNodeInstance({
if (node.name === "lafabrik") {
return (
<LafabrikModel
<LaFabrikMapModel
position={position}
rotation={node.rotation}
scale={scale}
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import { mergeBufferGeometries } from "three-stdlib";
import {
normalizeMapScale,
useTerrainHeightSampler,
@@ -112,7 +112,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
};
}
const mergedGeometry = mergeGeometries(group.geometries, false);
const mergedGeometry = mergeBufferGeometries(group.geometries, false);
for (const geometry of group.geometries) {
geometry.dispose();
@@ -16,9 +16,10 @@ import {
} from "@/data/world/mapInstancingConfig";
import { useMapInstancingData } from "@/hooks/world/useMapInstancingData";
import type { MapAssetInstance } from "@/types/map/mapScene";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
interface MapInstancingSystemProps {
onlyModelName?: string | null;
onlyMapName?: string | null;
streaming?: boolean;
}
@@ -30,53 +31,24 @@ interface MapAssetChunk {
instances: MapAssetInstance[];
}
function getChunkKey(instance: MapAssetInstance): string {
const [x, , z] = instance.position;
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
return `${chunkX}:${chunkZ}`;
}
function createMapAssetChunks(
type: MapInstancingAssetType,
config: MapInstancingAssetConfig,
instances: MapAssetInstance[],
): MapAssetChunk[] {
const chunks = new Map<string, MapAssetInstance[]>();
for (const instance of instances) {
const key = getChunkKey(instance);
const chunk = chunks.get(key);
if (chunk) {
chunk.push(instance);
} else {
chunks.set(key, [instance]);
}
}
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
const center = chunkInstances.reduce(
(sum, instance) => {
sum.x += instance.position[0];
sum.z += instance.position[2];
return sum;
},
{ x: 0, z: 0 },
);
return createWorldInstanceChunks(instances).map((chunk) => {
return {
key: `${type}:${chunkKey}`,
key: `${type}:${chunk.chunkKey}`,
config,
centerX: center.x / chunkInstances.length,
centerZ: center.z / chunkInstances.length,
instances: chunkInstances,
centerX: chunk.centerX,
centerZ: chunk.centerZ,
instances: chunk.instances,
};
});
}
export function MapInstancingSystem({
onlyModelName = null,
onlyMapName = null,
streaming = true,
}: MapInstancingSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode();
@@ -96,7 +68,7 @@ export function MapInstancingSystem({
return MAP_INSTANCING_ASSET_TYPES.flatMap((type) => {
const config = MAP_INSTANCING_ASSETS[type];
if (onlyModelName && config.mapName !== onlyModelName) return [];
if (onlyMapName && config.mapName !== onlyMapName) return [];
if (
!config.enabled ||
@@ -110,7 +82,7 @@ export function MapInstancingSystem({
return createMapAssetChunks(type, config, instances);
});
}, [data, groups, models, onlyModelName]);
}, [data, groups, models, onlyMapName]);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
+1 -1
View File
@@ -1,6 +1,6 @@
import { useLayoutEffect } from "react";
import { useThree } from "@react-three/fiber";
import type { Octree } from "three/addons/math/Octree.js";
import type { Octree } from "three-stdlib";
import type { Vector3Tuple } from "@/types/three/three";
import { PlayerCamera } from "@/world/player/PlayerCamera";
import { PlayerController } from "@/world/player/PlayerController";
+1 -2
View File
@@ -1,8 +1,7 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { Capsule } from "three/addons/math/Capsule.js";
import type { Octree } from "three/addons/math/Octree.js";
import { Capsule, type Octree } from "three-stdlib";
import {
INTERACT_KEY,
JUMP_KEY,
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import { mergeBufferGeometries } from "three-stdlib";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import type { VegetationInstance } from "@/types/map/mapScene";
import { useWind } from "@/hooks/world/useWind";
@@ -161,7 +161,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
return [...meshesByMaterial.values()]
.map(({ geometries, material }) => {
const mergedGeometry = mergeGeometries(geometries, false);
const mergedGeometry = mergeBufferGeometries(geometries, false);
for (const geometry of geometries) {
if (geometry !== mergedGeometry) {
+10 -36
View File
@@ -15,9 +15,10 @@ import {
VEGETATION_TYPES,
type VegetationType,
} from "@/data/world/vegetationConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
interface VegetationSystemProps {
onlyModelName?: string | null;
onlyMapName?: string | null;
streaming?: boolean;
}
@@ -35,42 +36,15 @@ interface VegetationChunk {
instances: VegetationInstance[];
}
function getChunkKey(instance: VegetationInstance): string {
const [x, , z] = instance.position;
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
return `${chunkX}:${chunkZ}`;
}
function createVegetationChunks(
type: VegetationType,
instances: VegetationInstance[],
): VegetationChunk[] {
const config = VEGETATION_TYPES[type];
const chunks = new Map<string, VegetationInstance[]>();
for (const instance of instances) {
const key = getChunkKey(instance);
const chunk = chunks.get(key);
if (chunk) {
chunk.push(instance);
} else {
chunks.set(key, [instance]);
}
}
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
const center = chunkInstances.reduce(
(sum, instance) => {
sum.x += instance.position[0];
sum.z += instance.position[2];
return sum;
},
{ x: 0, z: 0 },
);
return createWorldInstanceChunks(instances).map((chunk) => {
return {
key: `${type}:${chunkKey}`,
key: `${type}:${chunk.chunkKey}`,
type,
modelPath: config.modelPath,
scaleMultiplier: config.scaleMultiplier,
@@ -78,15 +52,15 @@ function createVegetationChunks(
receiveShadow: config.receiveShadow,
windStrength: config.windStrength,
rotationOffset: config.rotationOffset,
centerX: center.x / chunkInstances.length,
centerZ: center.z / chunkInstances.length,
instances: chunkInstances,
centerX: chunk.centerX,
centerZ: chunk.centerZ,
instances: chunk.instances,
};
});
}
export function VegetationSystem({
onlyModelName = null,
onlyMapName = null,
streaming = true,
}: VegetationSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode();
@@ -106,7 +80,7 @@ export function VegetationSystem({
return VEGETATION_TYPE_KEYS.flatMap((type) => {
const config = VEGETATION_TYPES[type];
if (onlyModelName && config.mapName !== onlyModelName) return [];
if (onlyMapName && config.mapName !== onlyMapName) return [];
if (!config.enabled) return [];
if (!isMapModelVisible(config.mapName, { groups, models })) return [];
@@ -116,7 +90,7 @@ export function VegetationSystem({
return createVegetationChunks(type, entry.instances);
});
}, [data, groups, models, onlyModelName]);
}, [data, groups, models, onlyMapName]);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);