From 093ffd726dc5b13d34e379292d99fddf3be3c874 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Fri, 29 May 2026 01:23:08 +0200 Subject: [PATCH] fix(review): address audit findings before merge --- .github/workflows/quality.yml | 4 +- docs/code-review-preparation.md | 2 +- docs/technical/editor.md | 9 +- package.json | 5 - src/components/editor/EditorControls.tsx | 68 ++- src/components/editor/scene/EditorScene.tsx | 13 + .../three/handTracking/HandTrackingGlove.tsx | 4 +- src/components/three/models/SimpleModel.tsx | 2 +- .../three/world/LaFabrikMapModel.tsx | 17 + src/components/three/world/LafabrikModel.tsx | 15 - .../three/world/MergedStaticMapModel.tsx | 4 +- src/components/three/world/SkyModel.tsx | 7 +- src/data/gameplay/gameStageAnchors.ts | 18 + .../characterConfig.ts} | 14 +- src/data/world/environmentConfig.ts | 1 + src/data/world/vegetationConfig.ts | 2 +- ...ersonnageDebug.ts => useCharacterDebug.ts} | 38 +- src/hooks/three/useClonedObject.ts | 38 +- src/hooks/three/useOctreeGraphNode.ts | 2 +- src/hooks/three/useTerrainHeight.ts | 13 +- src/hooks/world/useVegetationData.ts | 42 +- src/hooks/world/useWorldSceneLoading.ts | 2 +- src/managers/stores/useCharacterDebugStore.ts | 89 ++++ .../stores/usePersonnageDebugStore.ts | 89 ---- src/pages/editor/page.tsx | 430 ++++-------------- src/types/three/three-addons.d.ts | 42 -- src/types/three/three.ts | 2 +- src/utils/editor/editorMapTree.ts | 259 +++++++++++ src/utils/editor/loadEditorScene.ts | 2 +- src/utils/gameplay/repairMissionPosition.ts | 17 + src/utils/world/chunkInstances.ts | 55 +++ src/world/Environment.tsx | 2 + src/world/GameStageContent.tsx | 28 +- src/world/World.tsx | 8 +- .../CharacterSystem.tsx} | 24 +- src/world/grass/GrassPatch.tsx | 111 +++-- src/world/grass/useTerrainGrassSampler.ts | 17 +- .../GeneratedMapNodeInstance.tsx | 4 +- .../map-instancing/InstancedMapAsset.tsx | 4 +- .../map-instancing/MapInstancingSystem.tsx | 48 +- src/world/player/Player.tsx | 2 +- src/world/player/PlayerController.tsx | 3 +- src/world/vegetation/InstancedVegetation.tsx | 4 +- src/world/vegetation/VegetationSystem.tsx | 46 +- vite.config.ts | 2 +- 45 files changed, 823 insertions(+), 785 deletions(-) create mode 100644 src/components/three/world/LaFabrikMapModel.tsx delete mode 100644 src/components/three/world/LafabrikModel.tsx create mode 100644 src/data/gameplay/gameStageAnchors.ts rename src/data/world/{personnages/personnageConfig.ts => characters/characterConfig.ts} (78%) rename src/hooks/debug/{usePersonnageDebug.ts => useCharacterDebug.ts} (68%) create mode 100644 src/managers/stores/useCharacterDebugStore.ts delete mode 100644 src/managers/stores/usePersonnageDebugStore.ts delete mode 100644 src/types/three/three-addons.d.ts create mode 100644 src/utils/editor/editorMapTree.ts create mode 100644 src/utils/gameplay/repairMissionPosition.ts create mode 100644 src/utils/world/chunkInstances.ts rename src/world/{personnages/PersonnageSystem.tsx => characters/CharacterSystem.tsx} (50%) 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 ( + + ); +} + export function EditorControls({ transformMode, onTransformModeChange, @@ -303,20 +350,13 @@ export function EditorControls({ {selectedNodeScale ? (
{selectedNodeScale.map((value, axis) => ( - + ))}
) : 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;