diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 2133a51..51c41ae 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -80,8 +80,8 @@ jobs:
- name: 📏 Check bundle size
run: |
- # Check generated app assets only; public/ model files are runtime assets copied to dist.
- SIZE=$(du -k dist/assets | cut -f1)
+ # Check generated JS/CSS bundles only; public runtime assets are copied to dist/assets too.
+ SIZE=$(node -e "const fs=require('fs');const path=require('path');function walk(dir){return fs.readdirSync(dir,{withFileTypes:true}).flatMap((entry)=>{const file=path.join(dir,entry.name);return entry.isDirectory()?walk(file):file;});}const bytes=walk('dist/assets').filter((file)=>/\.(js|css)$/.test(file)).reduce((sum,file)=>sum+fs.statSync(file).size,0);console.log(Math.ceil(bytes/1024));")
echo "Bundle size: ${SIZE}KB"
THRESHOLD=5000
diff --git a/docs/code-review-preparation.md b/docs/code-review-preparation.md
index 1c1c62e..2f0abf3 100644
--- a/docs/code-review-preparation.md
+++ b/docs/code-review-preparation.md
@@ -121,7 +121,7 @@ Phrase à retenir :
Piège à connaître :
-`useRepairMovementLocked()` retourne actuellement `false`. Le lock de mouvement est prévu dans le code et l'UI, mais il est désactivé sur `develop`.
+`useRepairMovementLocked()` lit maintenant l'étape de mission active et verrouille le déplacement pendant les phases de réparation qui doivent immobiliser le joueur.
### Interaction
diff --git a/docs/technical/editor.md b/docs/technical/editor.md
index bcd9fc6..2b13851 100644
--- a/docs/technical/editor.md
+++ b/docs/technical/editor.md
@@ -113,7 +113,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
2. `useEditorSceneData` calls `loadMapSceneData()`.
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
4. If `/map.json` is missing, the page displays a folder-upload flow.
-5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
+5. The route-level loading overlay reports map JSON loading, then hands off to the editor scene once the map payload is ready.
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, multi-selection status, and the cinematic/dialogue/SRT editors.
@@ -150,14 +150,13 @@ The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite
## Editor Loading Overlay
-The editor uses `SceneLoadingOverlay` like the runtime scene. `EditorSceneLoadingTracker` lives in `src/pages/editor/page.tsx` and reads drei `useProgress()` inside the canvas.
+The editor uses `SceneLoadingOverlay` like the runtime scene for the route-level map JSON loading phase.
-The route tracks two loading phases:
+The route tracks the map JSON loading phase:
- map JSON loading through `useEditorSceneData()`
-- model loading through `useProgress()`
-The overlay is rendered outside the canvas so it remains visible while the R3F scene mounts. The scene itself is wrapped in `Suspense` with a `null` fallback; the visual feedback is handled by the overlay instead of by the canvas fallback.
+The overlay is rendered outside the canvas so it remains visible while the editor route mounts. Model loading is left to R3F `Suspense` boundaries to avoid progress updates during model render.
## Panel Groups
diff --git a/package.json b/package.json
index 67f8f85..8eb72d6 100644
--- a/package.json
+++ b/package.json
@@ -57,10 +57,5 @@
"r3f-perf": {
"@react-three/drei": "$@react-three/drei"
}
- },
- "knip": {
- "ignore": [
- "src/types/three/three-addons.d.ts"
- ]
}
}
diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx
index 5bceda2..943984d 100644
--- a/src/components/editor/EditorControls.tsx
+++ b/src/components/editor/EditorControls.tsx
@@ -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}
+ setDraftValue(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.currentTarget.blur();
+ }
+ }}
+ />
+
+ );
+}
+
export function EditorControls({
transformMode,
onTransformModeChange,
@@ -303,20 +350,13 @@ export function EditorControls({
{selectedNodeScale ? (
{selectedNodeScale.map((value, axis) => (
-
- {["X", "Y", "Z"][axis]}
-
- onSelectedScaleChange(
- axis as 0 | 1 | 2,
- Number(event.target.value),
- )
- }
- />
-
+
))}
) : null}
diff --git a/src/components/editor/scene/EditorScene.tsx b/src/components/editor/scene/EditorScene.tsx
index 2a0886f..ab7257e 100644
--- a/src/components/editor/scene/EditorScene.tsx
+++ b/src/components/editor/scene/EditorScene.tsx
@@ -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();
diff --git a/src/components/three/handTracking/HandTrackingGlove.tsx b/src/components/three/handTracking/HandTrackingGlove.tsx
index ed6faef..37e4518 100644
--- a/src/components/three/handTracking/HandTrackingGlove.tsx
+++ b/src/components/three/handTracking/HandTrackingGlove.tsx
@@ -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;
diff --git a/src/components/three/models/SimpleModel.tsx b/src/components/three/models/SimpleModel.tsx
index 9230994..0122940 100644
--- a/src/components/three/models/SimpleModel.tsx
+++ b/src/components/three/models/SimpleModel.tsx
@@ -42,7 +42,7 @@ export function SimpleModel({
rotation,
scale,
});
- const model = useClonedObject(scene);
+ const model = useClonedObject(scene, { cloneResources: true });
useEffect(() => {
applyShadowSettings(model, castShadow, receiveShadow);
diff --git a/src/components/three/world/LaFabrikMapModel.tsx b/src/components/three/world/LaFabrikMapModel.tsx
new file mode 100644
index 0000000..9b0dc62
--- /dev/null
+++ b/src/components/three/world/LaFabrikMapModel.tsx
@@ -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;
+
+export function LaFabrikMapModel(
+ props: LaFabrikMapModelProps,
+): React.JSX.Element {
+ return ;
+}
+
+useGLTF.preload(LA_FABRIK_MODEL_PATH);
diff --git a/src/components/three/world/LafabrikModel.tsx b/src/components/three/world/LafabrikModel.tsx
deleted file mode 100644
index e7542fd..0000000
--- a/src/components/three/world/LafabrikModel.tsx
+++ /dev/null
@@ -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;
-
-export function LafabrikModel(props: LafabrikModelProps): React.JSX.Element {
- return ;
-}
-
-useGLTF.preload(LAFABRIK_MODEL_PATH);
diff --git a/src/components/three/world/MergedStaticMapModel.tsx b/src/components/three/world/MergedStaticMapModel.tsx
index 67b7d18..c8ae920 100644
--- a/src/components/three/world/MergedStaticMapModel.tsx
+++ b/src/components/three/world/MergedStaticMapModel.tsx
@@ -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();
diff --git a/src/components/three/world/SkyModel.tsx b/src/components/three/world/SkyModel.tsx
index 90c5362..938550b 100644
--- a/src/components/three/world/SkyModel.tsx
+++ b/src/components/three/world/SkyModel.tsx
@@ -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 ? (
-
+
) : (
colorFallback
diff --git a/src/data/gameplay/gameStageAnchors.ts b/src/data/gameplay/gameStageAnchors.ts
new file mode 100644
index 0000000..0a47f50
--- /dev/null
+++ b/src/data/gameplay/gameStageAnchors.ts
@@ -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,
+};
diff --git a/src/data/world/personnages/personnageConfig.ts b/src/data/world/characters/characterConfig.ts
similarity index 78%
rename from src/data/world/personnages/personnageConfig.ts
rename to src/data/world/characters/characterConfig.ts
index 337e071..73848ba 100644
--- a/src/data/world/personnages/personnageConfig.ts
+++ b/src/data/world/characters/characterConfig.ts
@@ -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;
+} satisfies Record;
-export const PERSONNAGE_IDS = [
+export const CHARACTER_IDS = [
"electricienne",
"gerant",
"fermier",
-] as const satisfies readonly PersonnageId[];
+] as const satisfies readonly CharacterId[];
diff --git a/src/data/world/environmentConfig.ts b/src/data/world/environmentConfig.ts
index 938c238..888ef02 100644
--- a/src/data/world/environmentConfig.ts
+++ b/src/data/world/environmentConfig.ts
@@ -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";
diff --git a/src/data/world/vegetationConfig.ts b/src/data/world/vegetationConfig.ts
index 09a762f..a2aaf99 100644
--- a/src/data/world/vegetationConfig.ts
+++ b/src/data/world/vegetationConfig.ts
@@ -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",
diff --git a/src/hooks/debug/usePersonnageDebug.ts b/src/hooks/debug/useCharacterDebug.ts
similarity index 68%
rename from src/hooks/debug/usePersonnageDebug.ts
rename to src/hooks/debug/useCharacterDebug.ts
index da71ef6..96a529f 100644
--- a/src/hooks/debug/usePersonnageDebug.ts
+++ b/src/hooks/debug/useCharacterDebug.ts
@@ -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();
diff --git a/src/hooks/three/useClonedObject.ts b/src/hooks/three/useClonedObject.ts
index 83ce60e..ac82fe0 100644
--- a/src/hooks/three/useClonedObject.ts
+++ b/src/hooks/three/useClonedObject.ts
@@ -2,29 +2,53 @@ import { useEffect, useMemo } from "react";
import * as THREE from "three";
import { disposeObject3D } from "@/utils/three/dispose";
-function cloneObjectWithOwnedResources(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(
+ 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(object: T): T {
- const clone = useMemo(() => cloneObjectWithOwnedResources(object), [object]);
+export function useClonedObject(
+ 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;
}
diff --git a/src/hooks/three/useOctreeGraphNode.ts b/src/hooks/three/useOctreeGraphNode.ts
index 741eb05..f6a0d0a 100644
--- a/src/hooks/three/useOctreeGraphNode.ts
+++ b/src/hooks/three/useOctreeGraphNode.ts
@@ -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(
diff --git a/src/hooks/three/useTerrainHeight.ts b/src/hooks/three/useTerrainHeight.ts
index e1f8595..b4405f2 100644
--- a/src/hooks/three/useTerrainHeight.ts
+++ b/src/hooks/three/useTerrainHeight.ts
@@ -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;
},
};
diff --git a/src/hooks/world/useVegetationData.ts b/src/hooks/world/useVegetationData.ts
index a3139f8..021ef18 100644
--- a/src/hooks/world/useVegetationData.ts
+++ b/src/hooks/world/useVegetationData.ts
@@ -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;
-function createPositionKey(node: MapNode): string {
- return node.position.map((value) => value.toFixed(3)).join(":");
-}
-
function extractVegetationData(
mapNodes: MapNode[],
models: Map,
@@ -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();
- for (const instance of potagerEntry.instances) {
- uniqueInstances.set(
- instance.position.map((value) => value.toFixed(3)).join(":"),
- instance,
- );
- }
- potagerEntry.instances = [...uniqueInstances.values()];
- }
-
return data;
}
diff --git a/src/hooks/world/useWorldSceneLoading.ts b/src/hooks/world/useWorldSceneLoading.ts
index 24020af..e82e92b 100644
--- a/src/hooks/world/useWorldSceneLoading.ts
+++ b/src/hooks/world/useWorldSceneLoading.ts
@@ -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";
diff --git a/src/managers/stores/useCharacterDebugStore.ts b/src/managers/stores/useCharacterDebugStore.ts
new file mode 100644
index 0000000..57c3a73
--- /dev/null
+++ b/src/managers/stores/useCharacterDebugStore.ts
@@ -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;
+ 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;
+
+export const useCharacterDebugStore = create((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),
+ },
+ },
+ })),
+}));
diff --git a/src/managers/stores/usePersonnageDebugStore.ts b/src/managers/stores/usePersonnageDebugStore.ts
deleted file mode 100644
index 3a79ae5..0000000
--- a/src/managers/stores/usePersonnageDebugStore.ts
+++ /dev/null
@@ -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;
- 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;
-
-export const usePersonnageDebugStore = create((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),
- },
- },
- })),
-}));
diff --git a/src/pages/editor/page.tsx b/src/pages/editor/page.tsx
index bef5e35..3eda0d1 100644
--- a/src/pages/editor/page.tsx
+++ b/src/pages/editor/page.tsx
@@ -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();
-
- 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,
- );
- }}
>
+
();
+
+ 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],
+ },
+ ],
+ };
+}
diff --git a/src/utils/editor/loadEditorScene.ts b/src/utils/editor/loadEditorScene.ts
index ba50915..c47428d 100644
--- a/src/utils/editor/loadEditorScene.ts
+++ b/src/utils/editor/loadEditorScene.ts
@@ -22,7 +22,7 @@ export async function createSceneDataFromFiles(
const models = new Map();
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];
diff --git a/src/utils/gameplay/repairMissionPosition.ts b/src/utils/gameplay/repairMissionPosition.ts
new file mode 100644
index 0000000..7493aa1
--- /dev/null
+++ b/src/utils/gameplay/repairMissionPosition.ts
@@ -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>,
+): Vector3Tuple | undefined {
+ return anchors[mission] ?? FALLBACK_REPAIR_MISSION_POSITIONS.get(mission);
+}
diff --git a/src/utils/world/chunkInstances.ts b/src/utils/world/chunkInstances.ts
new file mode 100644
index 0000000..a1cc0e9
--- /dev/null
+++ b/src/utils/world/chunkInstances.ts
@@ -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 {
+ 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(
+ instances: TInstance[],
+): WorldInstanceChunk[] {
+ const chunks = new Map();
+
+ 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,
+ };
+ });
+}
diff --git a/src/world/Environment.tsx b/src/world/Environment.tsx
index bc295d6..78edb2f 100644
--- a/src/world/Environment.tsx
+++ b/src/world/Environment.tsx
@@ -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 ? (
[
- mission,
- position,
- ]),
-);
-
-function getRepairMissionPosition(
- mission: RepairMissionId,
- anchors: Partial>,
-): 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" ? (
-
- ) : null}
+ {mainState === "intro" ? : 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) => (
))}
- {mainState === "outro" ? (
-
- ) : null}
+ {mainState === "outro" ? : null}
>
);
}
diff --git a/src/world/World.tsx b/src/world/World.tsx
index aa9dc2b..7e57fe2 100644
--- a/src/world/World.tsx
+++ b/src/world/World.tsx
@@ -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 ? : null}
+ {showGameStage && mainState !== "ebike" ? : null}
{showGameStage ? (
diff --git a/src/world/personnages/PersonnageSystem.tsx b/src/world/characters/CharacterSystem.tsx
similarity index 50%
rename from src/world/personnages/PersonnageSystem.tsx
rename to src/world/characters/CharacterSystem.tsx
index df66ad5..3de23a4 100644
--- a/src/world/personnages/PersonnageSystem.tsx
+++ b/src/world/characters/CharacterSystem.tsx
@@ -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 (
-
- {PERSONNAGE_IDS.map((id) => (
+
+ {CHARACTER_IDS.map((id) => (
-
+
))}
diff --git a/src/world/grass/GrassPatch.tsx b/src/world/grass/GrassPatch.tsx
index 1319ea8..1f1daba 100644
--- a/src/world/grass/GrassPatch.tsx
+++ b/src/world/grass/GrassPatch.tsx
@@ -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();
diff --git a/src/world/grass/useTerrainGrassSampler.ts b/src/world/grass/useTerrainGrassSampler.ts
index 6690ab4..883e119 100644
--- a/src/world/grass/useTerrainGrassSampler.ts
+++ b/src/world/grass/useTerrainGrassSampler.ts
@@ -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(),
};
};
diff --git a/src/world/map-generated/GeneratedMapNodeInstance.tsx b/src/world/map-generated/GeneratedMapNodeInstance.tsx
index 69aa1c9..aaae33c 100644
--- a/src/world/map-generated/GeneratedMapNodeInstance.tsx
+++ b/src/world/map-generated/GeneratedMapNodeInstance.tsx
@@ -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 (
- ();
-
- 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);
diff --git a/src/world/player/Player.tsx b/src/world/player/Player.tsx
index 435dbfa..230560d 100644
--- a/src/world/player/Player.tsx
+++ b/src/world/player/Player.tsx
@@ -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";
diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx
index c0fd224..13a7100 100644
--- a/src/world/player/PlayerController.tsx
+++ b/src/world/player/PlayerController.tsx
@@ -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,
diff --git a/src/world/vegetation/InstancedVegetation.tsx b/src/world/vegetation/InstancedVegetation.tsx
index d4f6366..91034eb 100644
--- a/src/world/vegetation/InstancedVegetation.tsx
+++ b/src/world/vegetation/InstancedVegetation.tsx
@@ -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) {
diff --git a/src/world/vegetation/VegetationSystem.tsx b/src/world/vegetation/VegetationSystem.tsx
index ad07688..9d30074 100644
--- a/src/world/vegetation/VegetationSystem.tsx
+++ b/src/world/vegetation/VegetationSystem.tsx
@@ -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();
-
- 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);
diff --git a/vite.config.ts b/vite.config.ts
index c6a5e88..c3a1723 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -13,7 +13,7 @@ const THREE_SOURCE_ENTRY = fileURLToPath(
new URL("./node_modules/three/src/Three.js", import.meta.url),
);
-const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
+const MAX_MAP_PAYLOAD_BYTES = 4 * 1024 * 1024;
const MAX_SRT_PAYLOAD_BYTES = 256 * 1024;
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
const MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES = 256 * 1024;