fix(editor): restore stable map editing behavior

This commit is contained in:
Tom Boullay
2026-05-29 00:52:44 +02:00
parent d5675fe82c
commit 343a122c06
28 changed files with 453 additions and 302 deletions
+95 -44
View File
@@ -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<typeof useTerrainHeightSampler>,
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 (
<>
<Grid
args={[100, 100]}
cellSize={1}
cellThickness={0.5}
cellColor="#242424"
sectionSize={5}
sectionThickness={1}
sectionColor="#3a3a3a"
fadeDistance={50}
fadeStrength={1}
followCamera={false}
infiniteGrid={false}
/>
<axesHelper args={[10]} />
<group>
{terrainNode ? (
<EditorTerrainNode
index={terrainNodeIndex}
node={terrainNode}
isSelected={selectedIndexSet.has(terrainNodeIndex)}
isHovered={hoveredNodeIndex === terrainNodeIndex}
lockTerrainSelection={lockTerrainSelection}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
<Suspense fallback={null}>
<EditorTerrainNode
index={terrainNodeIndex}
node={terrainNode}
isSelected={selectedIndexSet.has(terrainNodeIndex)}
isHovered={hoveredNodeIndex === terrainNodeIndex}
lockTerrainSelection={lockTerrainSelection}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
</Suspense>
) : null}
{sceneData.mapNodes.map((node, index) => {
if (!shouldRenderEditorNode(node, selectedNodeName)) {
@@ -446,19 +466,35 @@ export function EditorMap({
if (modelUrl) {
return (
<EditorModelNode
<Suspense
key={index}
index={index}
node={node}
modelUrl={modelUrl}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
fallback={
<EditorFallbackNode
index={index}
node={node}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
}
>
<EditorModelNode
index={index}
node={node}
modelUrl={modelUrl}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
</Suspense>
);
} 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}
>
<primitive object={sceneInstance} scale={visualScaleMultiplier} />
<primitive
object={sceneInstance}
position={[0, visualYOffset, 0]}
scale={visualScaleMultiplier}
/>
</group>
);
}
+35 -21
View File
@@ -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({
/>
)}
<EditorMap
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
snapToTerrain={snapToTerrain}
lockTerrainSelection={lockTerrainSelection}
onTransformStart={onTransformStart}
onTransformEnd={onTransformEnd}
onNodeTransform={onNodeTransform}
snapAllToTerrainRequest={snapAllToTerrainRequest}
onSnapAllToTerrain={onSnapAllToTerrain}
<Grid
args={[100, 100]}
cellSize={1}
cellThickness={0.5}
cellColor="#242424"
sectionSize={5}
sectionThickness={1}
sectionColor="#3a3a3a"
fadeDistance={50}
fadeStrength={1}
followCamera={false}
infiniteGrid={false}
/>
<axesHelper args={[10]} />
<PersonnageSystem />
<Suspense fallback={null}>
<EditorMap
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
snapToTerrain={snapToTerrain}
lockTerrainSelection={lockTerrainSelection}
onTransformStart={onTransformStart}
onTransformEnd={onTransformEnd}
onNodeTransform={onNodeTransform}
snapAllToTerrainRequest={snapAllToTerrainRequest}
onSnapAllToTerrain={onSnapAllToTerrain}
/>
</Suspense>
<ambientLight intensity={0.6} />
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
+3 -2
View File
@@ -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);
+10 -1
View File
@@ -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 ? (
<color attach="background" args={[fallbackColor]} />
) : null;
const fallback = fallbackModelPath ? (
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
<SkyModelContent modelPath={fallbackModelPath} scale={scale} />
</SkyModelErrorBoundary>
) : (
colorFallback
);
return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
+7 -1
View File
@@ -4,7 +4,13 @@ import type {
RepairMissionTriggerConfig,
} from "@/types/gameplay/repairMission";
export const EBIKE_REPAIR_POSITION = [
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
Record<RepairMissionId, string>
> = {
pylon: "repair:pylon",
};
const EBIKE_REPAIR_POSITION = [
42.2399, 4.5484, 34.6468,
] as const satisfies Vector3Tuple;
+1
View File
@@ -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";
+2 -2
View File
@@ -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<string, number>;
@@ -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,
+1 -1
View File
@@ -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: {
@@ -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<THREE.Group | null>;
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<THREE.Group | null>(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,
};
}
+26 -2
View File
@@ -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<T extends THREE.Object3D>(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<T extends THREE.Object3D>(object: T): T {
return useMemo(() => object.clone(true) as T, [object]);
const clone = useMemo(() => cloneObjectWithOwnedResources(object), [object]);
useEffect(() => {
return () => {
disposeObject3D(clone);
};
}, [clone]);
return clone;
}
@@ -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<MapPerformanceGroupName, boolean>;
+38 -90
View File
@@ -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<SceneLoadingState>(
{
...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<EditorCinematicPreviewRequest | null>(null);
@@ -720,37 +673,32 @@ export function EditorPage(): React.JSX.Element {
);
}}
>
<EditorSceneLoadingTracker
onLoadingStateChange={handleSceneLoadingStateChange}
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={handleSelectNode}
onToggleNodeSelection={handleToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
snapToTerrain={snapToTerrain}
lockTerrainSelection={lockTerrainSelection}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
onNodeTransform={handleNodeTransform}
snapAllToTerrainRequest={snapAllToTerrainRequest}
onSnapAllToTerrain={handleSnapAllToTerrain}
onUndo={handleUndo}
onRedo={handleRedo}
resetCameraRequest={resetCameraRequest}
focusSelectedCameraRequest={focusSelectedCameraRequest}
isPlayerMode={isPlayerMode}
cinematicPreviewRequest={cinematicPreviewRequest}
onCinematicPreviewComplete={handleCinematicPreviewComplete}
/>
<Suspense fallback={null}>
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={handleSelectNode}
onToggleNodeSelection={handleToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
snapToTerrain={snapToTerrain}
lockTerrainSelection={lockTerrainSelection}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
onNodeTransform={handleNodeTransform}
snapAllToTerrainRequest={snapAllToTerrainRequest}
onSnapAllToTerrain={handleSnapAllToTerrain}
onUndo={handleUndo}
onRedo={handleRedo}
resetCameraRequest={resetCameraRequest}
focusSelectedCameraRequest={focusSelectedCameraRequest}
isPlayerMode={isPlayerMode}
cinematicPreviewRequest={cinematicPreviewRequest}
onCinematicPreviewComplete={handleCinematicPreviewComplete}
/>
</Suspense>
</Canvas>
<SceneLoadingOverlay state={editorLoadingState} />
+1
View File
@@ -1,6 +1,7 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface MapNode {
id?: string;
name: string;
type: string;
position: Vector3Tuple;
+2 -2
View File
@@ -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;
+1
View File
@@ -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,
+2
View File
@@ -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,
+2 -2
View File
@@ -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",
+62 -6
View File
@@ -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<RepairMissionId, string>;
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<Record<RepairMissionId, Vector3Tuple>> {
@@ -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;
+76
View File
@@ -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<Record<TextureMaterialKey, THREE.Texture | null>>;
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();
}
}
}
+2
View File
@@ -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 ? (
<SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
fallbackModelPath={GAME_SCENE_SKY_FALLBACK_MODEL_PATH}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
/>
+1 -1
View File
@@ -90,7 +90,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={handleOctreeReady}
/>
<PersonnageSystem />
{showGameStage ? <PersonnageSystem /> : null}
{showGameStage ? (
<Physics>
<GameStageLoaded onLoaded={handleGameStageLoaded} />
+7 -6
View File
@@ -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],