From 343a122c0674c97200c033c05c8f8421a4b46c18 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Fri, 29 May 2026 00:52:44 +0200 Subject: [PATCH] fix(editor): restore stable map editing behavior --- README.md | 6 + docs/technical/mission-flow.md | 7 +- package-lock.json | 1 + package.json | 7 + public/map.json | 3 +- scripts/transformMap.cjs | 59 ++++++++ src/components/editor/scene/EditorMap.tsx | 139 ++++++++++++------ src/components/editor/scene/EditorScene.tsx | 56 ++++--- src/components/three/models/SimpleModel.tsx | 5 +- src/components/three/world/SkyModel.tsx | 11 +- src/data/gameplay/repairMissionAnchors.ts | 8 +- src/data/world/environmentConfig.ts | 1 + src/data/world/mapInstancingConfig.ts | 4 +- src/data/world/terrainConfig.ts | 2 +- src/hooks/animation/useCharacterAnimation.ts | 110 -------------- src/hooks/three/useClonedObject.ts | 28 +++- src/managers/stores/useMapPerformanceStore.ts | 7 +- src/pages/editor/page.tsx | 128 +++++----------- src/types/map/mapScene.ts | 1 + src/types/world/terrainSurface.ts | 4 +- src/utils/map/loadMapSceneData.ts | 1 + src/utils/map/mapNodeValidation.ts | 2 + src/utils/map/potagerMapNodes.ts | 4 +- src/utils/map/repairMissionMapAnchors.ts | 68 ++++++++- src/utils/three/dispose.ts | 76 ++++++++++ src/world/Environment.tsx | 2 + src/world/World.tsx | 2 +- src/world/vegetation/InstancedVegetation.tsx | 13 +- 28 files changed, 453 insertions(+), 302 deletions(-) delete mode 100644 src/hooks/animation/useCharacterAnimation.ts create mode 100644 src/utils/three/dispose.ts diff --git a/README.md b/README.md index 0df0ee7..09e8df7 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,12 @@ npm run format:check npm run build ``` +Regenerate runtime map data after editing `public/map_raw.json`: + +```bash +npm run map:transform +``` + ## Optional Hand-Tracking Backend The app can use the local Python backend, but the default debug source is browser-side MediaPipe. diff --git a/docs/technical/mission-flow.md b/docs/technical/mission-flow.md index 0c0a95b..ad3a428 100644 --- a/docs/technical/mission-flow.md +++ b/docs/technical/mission-flow.md @@ -14,7 +14,6 @@ The store owns the `missionFlow` slice: ```ts missionFlow: { - step: GameStep; activityCity: boolean; playerName: string; canMove: boolean; @@ -31,14 +30,14 @@ Managers stay responsible for local runtime services: - `AudioManager` owns audio elements, audio pools, music playback, category volume, and stereo pan. - `InteractionManager` owns transient focused/nearby/held interaction handles. -Mission progression is not owned by a manager. Components update the store through explicit actions such as `setFlowStep`, `setCanMove`, `showDialog`, and `hideDialog`. +Mission progression is not owned by a manager. Components update the store through explicit actions such as `setIntroStep`, `setCanMove`, `showDialog`, and `hideDialog`. ## Runtime Components -- `src/components/game/GameFlow.tsx` reacts to `missionFlow.step` and triggers one-off side effects such as intro audio and movement unlocks. +- `src/components/game/GameFlow.tsx` reacts to intro state and triggers one-off side effects such as intro audio and movement unlocks. - `src/components/zone/ZoneDetection.tsx` reads the camera position and moves the flow to a target step when the player enters a configured zone. - `src/world/GameStageContent.tsx` mounts repair games and their mission-start triggers. -- `src/pages/page.tsx` mounts mission HTML overlays: `IntroUI`, `BienvenueDisplay`, and `DialogMessage`. +- `src/pages/page.tsx` mounts mission HTML overlays: `IntroUI`, `DialogMessage`, and subtitles. - `src/world/player/PlayerController.tsx` reads `missionFlow.canMove` as an additional movement lock. ## Step Sequence diff --git a/package-lock.json b/package-lock.json index 4662556..f880142 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "three": "0.182.0", + "three-stdlib": "^2.36.1", "zustand": "^5.0.12" }, "devDependencies": { diff --git a/package.json b/package.json index 883e55c..67f8f85 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", + "map:transform": "node scripts/transformMap.cjs", "preview": "vite preview", "typecheck": "tsc -b" }, @@ -32,6 +33,7 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "three": "0.182.0", + "three-stdlib": "^2.36.1", "zustand": "^5.0.12" }, "devDependencies": { @@ -55,5 +57,10 @@ "r3f-perf": { "@react-three/drei": "$@react-three/drei" } + }, + "knip": { + "ignore": [ + "src/types/three/three-addons.d.ts" + ] } } diff --git a/public/map.json b/public/map.json index 0ad8b92..df91c1d 100644 --- a/public/map.json +++ b/public/map.json @@ -39565,7 +39565,8 @@ "rotation": [0, 0.0027, 0.0819], "scale": [1, 1, 1] } - ] + ], + "id": "repair:pylon" }, { "name": "pylone", diff --git a/scripts/transformMap.cjs b/scripts/transformMap.cjs index edcd994..37b8197 100644 --- a/scripts/transformMap.cjs +++ b/scripts/transformMap.cjs @@ -25,6 +25,8 @@ const IDENTITY_NODE = { rotation: [0, 0, 0], scale: [1, 1, 1], }; +const REPAIR_PYLON_ANCHOR_ID = "repair:pylon"; +const REPAIR_PYLON_FALLBACK_POSITION = [64, 0, -66]; const MAX_MESH_Y_PLACEMENT_OFFSET = 2; const RAW_INDEX = { directionGroup: 5, @@ -55,6 +57,7 @@ const RAW_RANGES = { function cloneNode(node) { return { + ...(node.id ? { id: node.id } : {}), name: node.name, type: node.type, position: node.position, @@ -63,6 +66,60 @@ function cloneNode(node) { }; } +function isOriginPosition(position) { + return position.every((value) => Math.abs(value) < 0.0001); +} + +function hasDistinctPylonTransform(node) { + return ( + node.rotation.some((value) => Math.abs(value) > 0.0001) || + node.scale.some((value) => Math.abs(value - 1) > 0.0001) + ); +} + +function distanceToPosition(node, position) { + return Math.hypot( + node.position[0] - position[0], + node.position[2] - position[2], + ); +} + +function collectMapNodes(root, predicate) { + const results = []; + const stack = [root]; + + while (stack.length > 0) { + const node = stack.pop(); + if (predicate(node)) { + results.push(node); + } + stack.push(...(node.children ?? [])); + } + + return results; +} + +function assignRepairPylonAnchorId(root) { + const pylones = collectMapNodes( + root, + (node) => + node.name === "pylone" && + node.type === "Object3D" && + !isOriginPosition(node.position), + ); + const distinctPylones = pylones.filter(hasDistinctPylonTransform); + const candidates = distinctPylones.length > 0 ? distinctPylones : pylones; + if (candidates.length === 0) return; + + const anchor = [...candidates].sort( + (a, b) => + distanceToPosition(a, REPAIR_PYLON_FALLBACK_POSITION) - + distanceToPosition(b, REPAIR_PYLON_FALLBACK_POSITION), + )[0]; + + anchor.id = REPAIR_PYLON_ANCHOR_ID; +} + function createGroup(name, sourceNode) { return { name, @@ -434,6 +491,8 @@ function transformMap() { blocking.children.push(unclassified); } + assignRepairPylonAnchorId(scene); + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(scene, null, 2)); console.log(`Written hierarchical map to ${OUTPUT_PATH}`); } diff --git a/src/components/editor/scene/EditorMap.tsx b/src/components/editor/scene/EditorMap.tsx index 26c8346..69dd209 100644 --- a/src/components/editor/scene/EditorMap.tsx +++ b/src/components/editor/scene/EditorMap.tsx @@ -1,12 +1,22 @@ -import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; -import { Grid, TransformControls } from "@react-three/drei"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + Suspense, +} from "react"; +import { TransformControls } from "@react-three/drei"; import type { ThreeEvent } from "@react-three/fiber"; import * as THREE from "three"; import { TerrainModel } from "@/components/three/world/TerrainModel"; import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; -import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight"; +import { + getObjectBottomOffset, + useTerrainHeightSampler, +} from "@/hooks/three/useTerrainHeight"; import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor"; import { isEditorVisibleMapNode, @@ -94,6 +104,30 @@ function getEditorModelVisualScaleMultiplier(name: string): number { ); } +function getEditorModelVisualYOffset( + object: THREE.Object3D, + node: MapNode, + terrainHeight: ReturnType, + visualScaleMultiplier: number, +): number { + const [x, y, z] = node.position; + const height = terrainHeight.getHeight(x, z); + if (height === null) return 0; + + const finalScale: [number, number, number] = [ + node.scale[0] * visualScaleMultiplier, + node.scale[1] * visualScaleMultiplier, + node.scale[2] * visualScaleMultiplier, + ]; + const originalPosition = object.position.clone(); + object.position.set(0, 0, 0); + const bottomOffset = getObjectBottomOffset(object, finalScale); + object.position.copy(originalPosition); + const parentScaleY = Math.abs(node.scale[1]) > 0.0001 ? node.scale[1] : 1; + + return (height + bottomOffset - y) / parentScaleY; +} + function applyNodeTransform(object: THREE.Object3D, node: MapNode): void { object.position.set(...node.position); object.rotation.set(...node.rotation); @@ -222,7 +256,6 @@ export function EditorMap({ selectedNodeIndex !== null ? (sceneData.mapNodes[selectedNodeIndex]?.name ?? null) : null; - const getTransformObject = useCallback(() => { if (isMultiSelection) { return transformGroupRef.current; @@ -407,35 +440,22 @@ export function EditorMap({ return ( <> - - - {terrainNode ? ( - + + + ) : null} {sceneData.mapNodes.map((node, index) => { if (!shouldRenderEditorNode(node, selectedNodeName)) { @@ -446,19 +466,35 @@ export function EditorMap({ if (modelUrl) { return ( - + fallback={ + + } + > + + ); } else { return ( @@ -519,7 +555,18 @@ function EditorModelNode({ scale: node.scale, }); const sceneInstance = useClonedObject(scene); + const terrainHeight = useTerrainHeightSampler(); const visualScaleMultiplier = getEditorModelVisualScaleMultiplier(node.name); + const visualYOffset = useMemo( + () => + getEditorModelVisualYOffset( + sceneInstance, + node, + terrainHeight, + visualScaleMultiplier, + ), + [node, sceneInstance, terrainHeight, visualScaleMultiplier], + ); const pointerHandlers = createEditorNodePointerHandlers( index, onSelectNode, @@ -588,7 +635,11 @@ function EditorModelNode({ scale={node.scale} {...pointerHandlers} > - + ); } diff --git a/src/components/editor/scene/EditorScene.tsx b/src/components/editor/scene/EditorScene.tsx index c7eb12a..2a0886f 100644 --- a/src/components/editor/scene/EditorScene.tsx +++ b/src/components/editor/scene/EditorScene.tsx @@ -1,12 +1,11 @@ -import { useCallback, useEffect, useRef } from "react"; -import { OrbitControls } from "@react-three/drei"; +import { Suspense, useCallback, useEffect, useRef } from "react"; +import { Grid, OrbitControls } from "@react-three/drei"; import { useThree } from "@react-three/fiber"; import gsap from "gsap"; import * as THREE from "three"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; import { EditorMap } from "@/components/editor/scene/EditorMap"; import { FlyController } from "@/controls/editor/FlyController"; -import { PersonnageSystem } from "@/world/personnages/PersonnageSystem"; import type { CinematicDefinition } from "@/types/cinematics/cinematics"; import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor"; @@ -214,26 +213,41 @@ export function EditorScene({ /> )} - + - + + + diff --git a/src/components/three/models/SimpleModel.tsx b/src/components/three/models/SimpleModel.tsx index ef29a2f..9230994 100644 --- a/src/components/three/models/SimpleModel.tsx +++ b/src/components/three/models/SimpleModel.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import * as THREE from "three"; +import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; @@ -41,7 +42,7 @@ export function SimpleModel({ rotation, scale, }); - const model = useMemo(() => scene.clone(true), [scene]); + const model = useClonedObject(scene); useEffect(() => { applyShadowSettings(model, castShadow, receiveShadow); diff --git a/src/components/three/world/SkyModel.tsx b/src/components/three/world/SkyModel.tsx index bc6791d..90c5362 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 { + fallbackModelPath?: string | undefined; modelPath: string; fallbackColor?: string | undefined; scale?: number | undefined; @@ -52,12 +53,20 @@ class SkyModelErrorBoundary extends Component< export function SkyModel({ fallbackColor, + fallbackModelPath, modelPath, scale = SKY_MODEL_SCALE, }: SkyModelProps): React.JSX.Element { - const fallback = fallbackColor ? ( + const colorFallback = fallbackColor ? ( ) : null; + const fallback = fallbackModelPath ? ( + + + + ) : ( + colorFallback + ); return ( diff --git a/src/data/gameplay/repairMissionAnchors.ts b/src/data/gameplay/repairMissionAnchors.ts index 768c9f6..c7aa4cd 100644 --- a/src/data/gameplay/repairMissionAnchors.ts +++ b/src/data/gameplay/repairMissionAnchors.ts @@ -4,7 +4,13 @@ import type { RepairMissionTriggerConfig, } from "@/types/gameplay/repairMission"; -export const EBIKE_REPAIR_POSITION = [ +export const REPAIR_MISSION_ANCHOR_IDS: Partial< + Record +> = { + pylon: "repair:pylon", +}; + +const EBIKE_REPAIR_POSITION = [ 42.2399, 4.5484, 34.6468, ] as const satisfies Vector3Tuple; diff --git a/src/data/world/environmentConfig.ts b/src/data/world/environmentConfig.ts index b457f92..938c238 100644 --- a/src/data/world/environmentConfig.ts +++ b/src/data/world/environmentConfig.ts @@ -1,4 +1,5 @@ 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_FALLBACK_BACKGROUND_COLOR = "#0b1018"; export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018"; diff --git a/src/data/world/mapInstancingConfig.ts b/src/data/world/mapInstancingConfig.ts index c446207..0c8466c 100644 --- a/src/data/world/mapInstancingConfig.ts +++ b/src/data/world/mapInstancingConfig.ts @@ -81,7 +81,7 @@ export const MAP_INSTANCING_ASSETS = { }, } as const; -export const MAP_SINGLE_MODEL_SCALE_MULTIPLIERS = { +const MAP_SINGLE_MODEL_SCALE_MULTIPLIERS = { ebike: 0.3, } as const satisfies Record; @@ -93,7 +93,7 @@ export function getMapSingleModelScaleMultiplier(name: string): number { ); } -export function getMapInstancedModelScaleMultiplier(name: string): number { +function getMapInstancedModelScaleMultiplier(name: string): number { return ( Object.values(MAP_INSTANCING_ASSETS).find( (config) => config.mapName === name, diff --git a/src/data/world/terrainConfig.ts b/src/data/world/terrainConfig.ts index 60ae90c..bdce845 100644 --- a/src/data/world/terrainConfig.ts +++ b/src/data/world/terrainConfig.ts @@ -3,7 +3,7 @@ import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface"; export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf"; export const TERRAIN_WATER_HEIGHT = 0.8; -export const TERRAIN_TILE_SIZE = 1; +const TERRAIN_TILE_SIZE = 1; export const TERRAIN_COLORS = { grass1: { diff --git a/src/hooks/animation/useCharacterAnimation.ts b/src/hooks/animation/useCharacterAnimation.ts deleted file mode 100644 index 193bc04..0000000 --- a/src/hooks/animation/useCharacterAnimation.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useRef, useEffect, useState, useCallback, useMemo } from "react"; -import { useAnimations } from "@react-three/drei"; -import type { AnimationAction, AnimationMixer } from "three"; -import * as THREE from "three"; -import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; - -export interface CharacterAnimationConfig { - modelPath: string; - initialAnimation?: string; - fadeDuration?: number; -} - -interface UseCharacterAnimationReturn { - scene: THREE.Group; - actions: { [key: string]: AnimationAction | null }; - names: string[]; - mixer: AnimationMixer; - groupRef: React.MutableRefObject; - currentAnimation: string; - play: (name: string) => void; - stop: () => void; - fadeTo: (name: string, duration?: number) => void; - setAnimationSpeed: (speed: number) => void; -} - -const DEFAULT_FADE_DURATION = 0.3; - -export function useCharacterAnimation( - config: CharacterAnimationConfig, -): UseCharacterAnimationReturn { - const { - modelPath, - initialAnimation = "Idle", - fadeDuration = DEFAULT_FADE_DURATION, - } = config; - - const groupRef = useRef(null); - const { scene, animations } = useLoggedGLTF(modelPath, { - scope: "useCharacterAnimation", - }); - const model = useMemo(() => scene.clone(true), [scene]); - const { actions, names, mixer } = useAnimations(animations, groupRef); - const [currentAnimation, setCurrentAnimation] = useState(initialAnimation); - - const play = useCallback( - (name: string) => { - const action = actions[name]; - if (action) { - Object.values(actions).forEach((a) => { - if (a && a !== action) a.fadeOut(fadeDuration); - }); - action.reset().fadeIn(fadeDuration).play(); - setCurrentAnimation(name); - } - }, - [actions, fadeDuration], - ); - - const stop = useCallback(() => { - Object.values(actions).forEach((a) => a?.fadeOut(fadeDuration)); - const defaultAction = actions[initialAnimation as string]; - if (defaultAction) { - defaultAction.reset().fadeIn(fadeDuration).play(); - setCurrentAnimation(initialAnimation); - } - }, [actions, initialAnimation, fadeDuration]); - - const fadeTo = useCallback( - (name: string, duration = fadeDuration) => { - const targetAction = actions[name]; - if (targetAction) { - Object.values(actions).forEach((a) => { - if (a && a !== targetAction) a.fadeOut(duration); - }); - targetAction.reset().fadeIn(duration).play(); - setCurrentAnimation(name); - } - }, - [actions, fadeDuration], - ); - - const setAnimationSpeed = useCallback( - (speed: number) => { - Object.values(actions).forEach((action) => { - action?.setEffectiveTimeScale(speed); - }); - }, - [actions], - ); - - useEffect(() => { - const defaultAction = actions[initialAnimation as string]; - if (defaultAction) { - defaultAction.play(); - } - }, [actions, initialAnimation]); - - return { - scene: model, - actions, - names, - mixer, - groupRef, - currentAnimation, - play, - stop, - fadeTo, - setAnimationSpeed, - }; -} diff --git a/src/hooks/three/useClonedObject.ts b/src/hooks/three/useClonedObject.ts index fa4acc7..83ce60e 100644 --- a/src/hooks/three/useClonedObject.ts +++ b/src/hooks/three/useClonedObject.ts @@ -1,6 +1,30 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import * as THREE from "three"; +import { disposeObject3D } from "@/utils/three/dispose"; + +function cloneObjectWithOwnedResources(object: T): T { + const clone = object.clone(true) as T; + + 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(); + }); + + return clone; +} export function useClonedObject(object: T): T { - return useMemo(() => object.clone(true) as T, [object]); + const clone = useMemo(() => cloneObjectWithOwnedResources(object), [object]); + + useEffect(() => { + return () => { + disposeObject3D(clone); + }; + }, [clone]); + + return clone; } diff --git a/src/managers/stores/useMapPerformanceStore.ts b/src/managers/stores/useMapPerformanceStore.ts index c23ea17..983404a 100644 --- a/src/managers/stores/useMapPerformanceStore.ts +++ b/src/managers/stores/useMapPerformanceStore.ts @@ -7,12 +7,7 @@ import { type MapPerformanceModelName, } from "@/data/world/mapPerformanceConfig"; -export { - MAP_PERFORMANCE_GROUP_NAMES, - MAP_PERFORMANCE_MODEL_NAMES, - type MapPerformanceGroupName, - type MapPerformanceModelName, -}; +export { MAP_PERFORMANCE_GROUP_NAMES, MAP_PERFORMANCE_MODEL_NAMES }; export interface MapPerformanceVisibility { groups: Record; diff --git a/src/pages/editor/page.tsx b/src/pages/editor/page.tsx index 877a716..bef5e35 100644 --- a/src/pages/editor/page.tsx +++ b/src/pages/editor/page.tsx @@ -1,12 +1,10 @@ -import { Suspense, useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Canvas } from "@react-three/fiber"; -import { useProgress } from "@react-three/drei"; import { EditorControls } from "@/components/editor/EditorControls"; import { EditorScene } from "@/components/editor/scene/EditorScene"; import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { Subtitles } from "@/components/ui/Subtitles"; -import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig"; import { useEditorHistory } from "@/hooks/editor/useEditorHistory"; import type { CinematicDefinition } from "@/types/cinematics/cinematics"; import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData"; @@ -16,19 +14,12 @@ import type { SceneData, TransformMode, } from "@/types/editor/editor"; -import { - type SceneLoadingChangeHandler, - type SceneLoadingState, -} from "@/types/world/sceneLoading"; +import type { SceneLoadingState } from "@/types/world/sceneLoading"; import { logger } from "@/utils/core/Logger"; const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement"; const DEFAULT_NEW_NODE_NAME = "new-model"; -interface EditorSceneLoadingTrackerProps { - onLoadingStateChange: SceneLoadingChangeHandler; -} - function serializeMapNodes(sceneData: SceneData): string { const mapPayload = sceneData.mapTree ? mergeFlatNodeTransformsIntoTree(sceneData) @@ -43,6 +34,7 @@ function createSourcePathKey(sourcePath: readonly number[]): string { function removeEditorMetadata(node: MapNode): MapNode { return { + ...(node.id ? { id: node.id } : {}), name: node.name, type: node.type, position: node.position, @@ -67,6 +59,9 @@ function mergeFlatNodeTransformsIntoTree( ): 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, @@ -116,6 +111,7 @@ function collectEditableMapNodes( 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, @@ -276,31 +272,6 @@ function createNewMapNode(name: string): HierarchicalMapNode { }; } -function EditorSceneLoadingTracker({ - onLoadingStateChange, -}: EditorSceneLoadingTrackerProps): null { - const { active, progress } = useProgress(); - - useEffect(() => { - if (active) { - onLoadingStateChange({ - currentStep: "Importation des models", - progress: 0.2 + (progress / 100) * 0.7, - status: "loading", - }); - return; - } - - onLoadingStateChange({ - currentStep: "Gameplay prêt", - progress: 1, - status: "ready", - }); - }, [active, onLoadingStateChange, progress]); - - return null; -} - export function EditorPage(): React.JSX.Element { const { hasMapJson, @@ -329,35 +300,17 @@ export function EditorPage(): React.JSX.Element { const [cameraViewMode, setCameraViewMode] = useState<"home" | "object">( "home", ); - const [sceneLoadingState, setSceneLoadingState] = useState( - { - ...INITIAL_SCENE_LOADING_STATE, - currentStep: "Montage progressif des models", - progress: 0.2, - }, - ); - const handleSceneLoadingStateChange = useCallback( - (nextState: SceneLoadingState) => { - setSceneLoadingState((currentState) => { - const shouldRestartProgress = currentState.status === "ready"; - - return { - ...nextState, - progress: shouldRestartProgress - ? nextState.progress - : Math.max(currentState.progress, nextState.progress), - }; - }); - }, - [], - ); - const editorLoadingState = isMapLoading + const editorLoadingState: SceneLoadingState = isMapLoading ? { currentStep: "Récupération blocking", progress: 0.08, status: "loading" as const, } - : sceneLoadingState; + : { + currentStep: "Gameplay prêt", + progress: 1, + status: "ready" as const, + }; const [cinematicPreviewRequest, setCinematicPreviewRequest] = useState(null); @@ -720,37 +673,32 @@ export function EditorPage(): React.JSX.Element { ); }} > - - - - diff --git a/src/types/map/mapScene.ts b/src/types/map/mapScene.ts index 28dc438..bde04dd 100644 --- a/src/types/map/mapScene.ts +++ b/src/types/map/mapScene.ts @@ -1,6 +1,7 @@ import type { Vector3Tuple } from "@/types/three/three"; export interface MapNode { + id?: string; name: string; type: string; position: Vector3Tuple; diff --git a/src/types/world/terrainSurface.ts b/src/types/world/terrainSurface.ts index aa738ab..aa6a812 100644 --- a/src/types/world/terrainSurface.ts +++ b/src/types/world/terrainSurface.ts @@ -1,4 +1,4 @@ -export type TerrainSurfaceKind = +type TerrainSurfaceKind = | "grass" | "path" | "water" @@ -6,7 +6,7 @@ export type TerrainSurfaceKind = | "dirt" | "rock"; -export type TerrainSurfaceRgb = readonly [number, number, number]; +type TerrainSurfaceRgb = readonly [number, number, number]; export interface TerrainSurfaceBounds { minX: number; diff --git a/src/utils/map/loadMapSceneData.ts b/src/utils/map/loadMapSceneData.ts index 77b5cd4..49153ed 100644 --- a/src/utils/map/loadMapSceneData.ts +++ b/src/utils/map/loadMapSceneData.ts @@ -99,6 +99,7 @@ function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] { return [ { + ...(node.id ? { id: node.id } : {}), name: node.name, type: node.type, position: node.position, diff --git a/src/utils/map/mapNodeValidation.ts b/src/utils/map/mapNodeValidation.ts index 24dd147..e6583ed 100644 --- a/src/utils/map/mapNodeValidation.ts +++ b/src/utils/map/mapNodeValidation.ts @@ -23,6 +23,7 @@ function isMapNode(value: unknown): value is MapNode { } return ( + (value.id === undefined || typeof value.id === "string") && typeof value.name === "string" && typeof value.type === "string" && isVector3Tuple(value.position) && @@ -53,6 +54,7 @@ function isHierarchicalMapNode(value: unknown): value is HierarchicalMapNode { function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] { const mapNode: MapNode = { + ...(node.id ? { id: node.id } : {}), name: node.name, type: node.type, position: node.position, diff --git a/src/utils/map/potagerMapNodes.ts b/src/utils/map/potagerMapNodes.ts index 252c6ce..99307d4 100644 --- a/src/utils/map/potagerMapNodes.ts +++ b/src/utils/map/potagerMapNodes.ts @@ -1,9 +1,9 @@ import type { MapNode } from "@/types/map/mapScene"; export const POTAGER_MAP_NAME = "potager"; -export const POTAGER_DEFAULT_ROTATION_OFFSET = [0, 0, 0] as const; +const POTAGER_DEFAULT_ROTATION_OFFSET = [0, 0, 0] as const; -export const POTAGER_SOURCE_MAP_NAMES = new Set([ +const POTAGER_SOURCE_MAP_NAMES = new Set([ "champdeble", "champdesoja", "champsdetournesol", diff --git a/src/utils/map/repairMissionMapAnchors.ts b/src/utils/map/repairMissionMapAnchors.ts index b16ae38..8fd90a3 100644 --- a/src/utils/map/repairMissionMapAnchors.ts +++ b/src/utils/map/repairMissionMapAnchors.ts @@ -1,3 +1,7 @@ +import { + REPAIR_MISSION_ANCHOR_IDS, + REPAIR_MISSION_POSITION_ENTRIES, +} from "@/data/gameplay/repairMissionAnchors"; import type { RepairMissionId } from "@/types/gameplay/repairMission"; import type { MapNode } from "@/types/map/mapScene"; import type { Vector3Tuple } from "@/types/three/three"; @@ -8,10 +12,67 @@ const REPAIR_MISSION_MAP_NODE_NAMES = { farm: "fermeverticale", } as const satisfies Record; +const REPAIR_MISSION_FALLBACK_POSITIONS = new Map( + REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => [ + mission, + position, + ]), +); + function isOriginPosition(position: Vector3Tuple): boolean { return position.every((value) => Math.abs(value) < 0.0001); } +function hasDistinctTransform(node: MapNode): boolean { + return ( + node.rotation.some((value) => Math.abs(value) > 0.0001) || + node.scale.some((value) => Math.abs(value - 1) > 0.0001) + ); +} + +function distanceToPosition(node: MapNode, position: Vector3Tuple): number { + return Math.hypot( + node.position[0] - position[0], + node.position[2] - position[2], + ); +} + +function getAnchorNode( + mapNodes: readonly MapNode[], + mission: RepairMissionId, + mapName: string, +): MapNode | null { + const anchorId = REPAIR_MISSION_ANCHOR_IDS[mission]; + if (anchorId) { + const nodeById = mapNodes.find((candidate) => candidate.id === anchorId); + if (nodeById) return nodeById; + } + + const candidates = mapNodes.filter( + (candidate) => + candidate.name === mapName && + candidate.type === "Object3D" && + !isOriginPosition(candidate.position), + ); + + if (mission !== "pylon") return candidates[0] ?? null; + + const distinctCandidates = candidates.filter(hasDistinctTransform); + const pylonCandidates = + distinctCandidates.length > 0 ? distinctCandidates : candidates; + const fallbackPosition = REPAIR_MISSION_FALLBACK_POSITIONS.get(mission); + + if (!fallbackPosition) return pylonCandidates[0] ?? null; + + return ( + [...pylonCandidates].sort( + (a, b) => + distanceToPosition(a, fallbackPosition) - + distanceToPosition(b, fallbackPosition), + )[0] ?? null + ); +} + export function getRepairMissionMapAnchors( mapNodes: readonly MapNode[], ): Partial> { @@ -20,12 +81,7 @@ export function getRepairMissionMapAnchors( for (const [mission, mapName] of Object.entries( REPAIR_MISSION_MAP_NODE_NAMES, ) as [RepairMissionId, string][]) { - const node = mapNodes.find( - (candidate) => - candidate.name === mapName && - candidate.type === "Object3D" && - !isOriginPosition(candidate.position), - ); + const node = getAnchorNode(mapNodes, mission, mapName); if (node) { anchors[mission] = node.position; diff --git a/src/utils/three/dispose.ts b/src/utils/three/dispose.ts new file mode 100644 index 0000000..87b5275 --- /dev/null +++ b/src/utils/three/dispose.ts @@ -0,0 +1,76 @@ +import * as THREE from "three"; + +type TextureMaterialKey = Extract< + | keyof THREE.MeshBasicMaterial + | keyof THREE.MeshStandardMaterial + | keyof THREE.MeshPhysicalMaterial + | keyof THREE.MeshToonMaterial, + string +>; + +type MaterialWithTextureSlots = THREE.Material & + Partial>; + +interface DisposeObject3DOptions { + disposeTextures?: boolean; +} + +const MATERIAL_TEXTURE_KEYS = [ + "alphaMap", + "aoMap", + "bumpMap", + "clearcoatMap", + "clearcoatNormalMap", + "clearcoatRoughnessMap", + "displacementMap", + "emissiveMap", + "envMap", + "gradientMap", + "lightMap", + "map", + "metalnessMap", + "normalMap", + "roughnessMap", + "sheenColorMap", + "sheenRoughnessMap", + "specularColorMap", + "specularIntensityMap", + "specularMap", + "thicknessMap", + "transmissionMap", +] as const satisfies readonly TextureMaterialKey[]; + +export function disposeObject3D( + object: THREE.Object3D, + options: DisposeObject3DOptions = {}, +): void { + object.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + + child.geometry?.dispose(); + + if (Array.isArray(child.material)) { + child.material.forEach((material) => disposeMaterial(material, options)); + } else if (child.material) { + disposeMaterial(child.material, options); + } + }); +} + +function disposeMaterial( + material: THREE.Material, + options: DisposeObject3DOptions, +): void { + material.dispose(); + if (!options.disposeTextures) return; + + const materialWithTextures = material as MaterialWithTextureSlots; + + for (const key of MATERIAL_TEXTURE_KEYS) { + const value = materialWithTextures[key]; + + if (value instanceof THREE.Texture) { + value.dispose(); + } + } +} diff --git a/src/world/Environment.tsx b/src/world/Environment.tsx index 9b50164..bc295d6 100644 --- a/src/world/Environment.tsx +++ b/src/world/Environment.tsx @@ -1,5 +1,6 @@ import { GAME_SCENE_FALLBACK_BACKGROUND_COLOR, + GAME_SCENE_SKY_FALLBACK_MODEL_PATH, GAME_SCENE_SKY_MODEL_PATH, GAME_SCENE_SKY_MODEL_SCALE, PHYSICS_SCENE_BACKGROUND_COLOR, @@ -35,6 +36,7 @@ export function Environment(): React.JSX.Element { {showSky ? ( diff --git a/src/world/World.tsx b/src/world/World.tsx index 69f2803..aa9dc2b 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -90,7 +90,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { onLoadingStateChange={onLoadingStateChange} onOctreeReady={handleOctreeReady} /> - + {showGameStage ? : null} {showGameStage ? ( diff --git a/src/world/vegetation/InstancedVegetation.tsx b/src/world/vegetation/InstancedVegetation.tsx index 4c93999..d4f6366 100644 --- a/src/world/vegetation/InstancedVegetation.tsx +++ b/src/world/vegetation/InstancedVegetation.tsx @@ -194,17 +194,18 @@ function createInstanceMatrices( const position = new THREE.Vector3(); const rotation = new THREE.Euler(); const quaternion = new THREE.Quaternion(); - const scale = new THREE.Vector3( - scaleMultiplier, - scaleMultiplier, - scaleMultiplier, - ); + const scale = new THREE.Vector3(); for (const instance of instances) { const matrix = new THREE.Matrix4(); position.set(...instance.position); - position.y += -geometryBottomY * scaleMultiplier; + scale.set( + instance.scale[0] * scaleMultiplier, + instance.scale[1] * scaleMultiplier, + instance.scale[2] * scaleMultiplier, + ); + position.y += -geometryBottomY * scale.y; rotation.set( instance.rotation[0] + rotationOffset[0], instance.rotation[1] + rotationOffset[1],