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
+6
View File
@@ -110,6 +110,12 @@ npm run format:check
npm run build npm run build
``` ```
Regenerate runtime map data after editing `public/map_raw.json`:
```bash
npm run map:transform
```
## Optional Hand-Tracking Backend ## Optional Hand-Tracking Backend
The app can use the local Python backend, but the default debug source is browser-side MediaPipe. The app can use the local Python backend, but the default debug source is browser-side MediaPipe.
+3 -4
View File
@@ -14,7 +14,6 @@ The store owns the `missionFlow` slice:
```ts ```ts
missionFlow: { missionFlow: {
step: GameStep;
activityCity: boolean; activityCity: boolean;
playerName: string; playerName: string;
canMove: boolean; 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. - `AudioManager` owns audio elements, audio pools, music playback, category volume, and stereo pan.
- `InteractionManager` owns transient focused/nearby/held interaction handles. - `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 ## 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/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/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. - `src/world/player/PlayerController.tsx` reads `missionFlow.canMove` as an additional movement lock.
## Step Sequence ## Step Sequence
+1
View File
@@ -22,6 +22,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"three": "0.182.0", "three": "0.182.0",
"three-stdlib": "^2.36.1",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
+7
View File
@@ -14,6 +14,7 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"map:transform": "node scripts/transformMap.cjs",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc -b" "typecheck": "tsc -b"
}, },
@@ -32,6 +33,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"three": "0.182.0", "three": "0.182.0",
"three-stdlib": "^2.36.1",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
@@ -55,5 +57,10 @@
"r3f-perf": { "r3f-perf": {
"@react-three/drei": "$@react-three/drei" "@react-three/drei": "$@react-three/drei"
} }
},
"knip": {
"ignore": [
"src/types/three/three-addons.d.ts"
]
} }
} }
+2 -1
View File
@@ -39565,7 +39565,8 @@
"rotation": [0, 0.0027, 0.0819], "rotation": [0, 0.0027, 0.0819],
"scale": [1, 1, 1] "scale": [1, 1, 1]
} }
] ],
"id": "repair:pylon"
}, },
{ {
"name": "pylone", "name": "pylone",
+59
View File
@@ -25,6 +25,8 @@ const IDENTITY_NODE = {
rotation: [0, 0, 0], rotation: [0, 0, 0],
scale: [1, 1, 1], 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 MAX_MESH_Y_PLACEMENT_OFFSET = 2;
const RAW_INDEX = { const RAW_INDEX = {
directionGroup: 5, directionGroup: 5,
@@ -55,6 +57,7 @@ const RAW_RANGES = {
function cloneNode(node) { function cloneNode(node) {
return { return {
...(node.id ? { id: node.id } : {}),
name: node.name, name: node.name,
type: node.type, type: node.type,
position: node.position, 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) { function createGroup(name, sourceNode) {
return { return {
name, name,
@@ -434,6 +491,8 @@ function transformMap() {
blocking.children.push(unclassified); blocking.children.push(unclassified);
} }
assignRepairPylonAnchorId(scene);
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(scene, null, 2)); fs.writeFileSync(OUTPUT_PATH, JSON.stringify(scene, null, 2));
console.log(`Written hierarchical map to ${OUTPUT_PATH}`); console.log(`Written hierarchical map to ${OUTPUT_PATH}`);
} }
+72 -21
View File
@@ -1,12 +1,22 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import {
import { Grid, TransformControls } from "@react-three/drei"; useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
Suspense,
} from "react";
import { TransformControls } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber"; import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three"; import * as THREE from "three";
import { TerrainModel } from "@/components/three/world/TerrainModel"; import { TerrainModel } from "@/components/three/world/TerrainModel";
import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; 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 type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
import { import {
isEditorVisibleMapNode, 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 { function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
object.position.set(...node.position); object.position.set(...node.position);
object.rotation.set(...node.rotation); object.rotation.set(...node.rotation);
@@ -222,7 +256,6 @@ export function EditorMap({
selectedNodeIndex !== null selectedNodeIndex !== null
? (sceneData.mapNodes[selectedNodeIndex]?.name ?? null) ? (sceneData.mapNodes[selectedNodeIndex]?.name ?? null)
: null; : null;
const getTransformObject = useCallback(() => { const getTransformObject = useCallback(() => {
if (isMultiSelection) { if (isMultiSelection) {
return transformGroupRef.current; return transformGroupRef.current;
@@ -407,23 +440,9 @@ export function EditorMap({
return ( 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> <group>
{terrainNode ? ( {terrainNode ? (
<Suspense fallback={null}>
<EditorTerrainNode <EditorTerrainNode
index={terrainNodeIndex} index={terrainNodeIndex}
node={terrainNode} node={terrainNode}
@@ -436,6 +455,7 @@ export function EditorMap({
isSelectionLocked={isSelectionLocked} isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
</Suspense>
) : null} ) : null}
{sceneData.mapNodes.map((node, index) => { {sceneData.mapNodes.map((node, index) => {
if (!shouldRenderEditorNode(node, selectedNodeName)) { if (!shouldRenderEditorNode(node, selectedNodeName)) {
@@ -446,8 +466,23 @@ export function EditorMap({
if (modelUrl) { if (modelUrl) {
return ( return (
<EditorModelNode <Suspense
key={index} key={index}
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} index={index}
node={node} node={node}
modelUrl={modelUrl} modelUrl={modelUrl}
@@ -459,6 +494,7 @@ export function EditorMap({
isSelectionLocked={isSelectionLocked} isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
</Suspense>
); );
} else { } else {
return ( return (
@@ -519,7 +555,18 @@ function EditorModelNode({
scale: node.scale, scale: node.scale,
}); });
const sceneInstance = useClonedObject(scene); const sceneInstance = useClonedObject(scene);
const terrainHeight = useTerrainHeightSampler();
const visualScaleMultiplier = getEditorModelVisualScaleMultiplier(node.name); const visualScaleMultiplier = getEditorModelVisualScaleMultiplier(node.name);
const visualYOffset = useMemo(
() =>
getEditorModelVisualYOffset(
sceneInstance,
node,
terrainHeight,
visualScaleMultiplier,
),
[node, sceneInstance, terrainHeight, visualScaleMultiplier],
);
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
@@ -588,7 +635,11 @@ function EditorModelNode({
scale={node.scale} scale={node.scale}
{...pointerHandlers} {...pointerHandlers}
> >
<primitive object={sceneInstance} scale={visualScaleMultiplier} /> <primitive
object={sceneInstance}
position={[0, visualYOffset, 0]}
scale={visualScaleMultiplier}
/>
</group> </group>
); );
} }
+19 -5
View File
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useRef } from "react"; import { Suspense, useCallback, useEffect, useRef } from "react";
import { OrbitControls } from "@react-three/drei"; import { Grid, OrbitControls } from "@react-three/drei";
import { useThree } from "@react-three/fiber"; import { useThree } from "@react-three/fiber";
import gsap from "gsap"; import gsap from "gsap";
import * as THREE from "three"; import * as THREE from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import { EditorMap } from "@/components/editor/scene/EditorMap"; import { EditorMap } from "@/components/editor/scene/EditorMap";
import { FlyController } from "@/controls/editor/FlyController"; import { FlyController } from "@/controls/editor/FlyController";
import { PersonnageSystem } from "@/world/personnages/PersonnageSystem";
import type { CinematicDefinition } from "@/types/cinematics/cinematics"; import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor"; import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
@@ -214,6 +213,22 @@ export function EditorScene({
/> />
)} )}
<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]} />
<Suspense fallback={null}>
<EditorMap <EditorMap
sceneData={sceneData} sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
@@ -232,8 +247,7 @@ export function EditorScene({
snapAllToTerrainRequest={snapAllToTerrainRequest} snapAllToTerrainRequest={snapAllToTerrainRequest}
onSnapAllToTerrain={onSnapAllToTerrain} onSnapAllToTerrain={onSnapAllToTerrain}
/> />
</Suspense>
<PersonnageSystem />
<ambientLight intensity={0.6} /> <ambientLight intensity={0.6} />
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow /> <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 * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
@@ -41,7 +42,7 @@ export function SimpleModel({
rotation, rotation,
scale, scale,
}); });
const model = useMemo(() => scene.clone(true), [scene]); const model = useClonedObject(scene);
useEffect(() => { useEffect(() => {
applyShadowSettings(model, castShadow, receiveShadow); applyShadowSettings(model, castShadow, receiveShadow);
+10 -1
View File
@@ -5,6 +5,7 @@ import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
interface SkyModelProps { interface SkyModelProps {
fallbackModelPath?: string | undefined;
modelPath: string; modelPath: string;
fallbackColor?: string | undefined; fallbackColor?: string | undefined;
scale?: number | undefined; scale?: number | undefined;
@@ -52,12 +53,20 @@ class SkyModelErrorBoundary extends Component<
export function SkyModel({ export function SkyModel({
fallbackColor, fallbackColor,
fallbackModelPath,
modelPath, modelPath,
scale = SKY_MODEL_SCALE, scale = SKY_MODEL_SCALE,
}: SkyModelProps): React.JSX.Element { }: SkyModelProps): React.JSX.Element {
const fallback = fallbackColor ? ( const colorFallback = fallbackColor ? (
<color attach="background" args={[fallbackColor]} /> <color attach="background" args={[fallbackColor]} />
) : null; ) : null;
const fallback = fallbackModelPath ? (
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
<SkyModelContent modelPath={fallbackModelPath} scale={scale} />
</SkyModelErrorBoundary>
) : (
colorFallback
);
return ( return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}> <SkyModelErrorBoundary key={modelPath} fallback={fallback}>
+7 -1
View File
@@ -4,7 +4,13 @@ import type {
RepairMissionTriggerConfig, RepairMissionTriggerConfig,
} from "@/types/gameplay/repairMission"; } 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, 42.2399, 4.5484, 34.6468,
] as const satisfies Vector3Tuple; ] 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_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_MODEL_SCALE = 100;
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018"; export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
export const PHYSICS_SCENE_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; } as const;
export const MAP_SINGLE_MODEL_SCALE_MULTIPLIERS = { const MAP_SINGLE_MODEL_SCALE_MULTIPLIERS = {
ebike: 0.3, ebike: 0.3,
} as const satisfies Record<string, number>; } 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 ( return (
Object.values(MAP_INSTANCING_ASSETS).find( Object.values(MAP_INSTANCING_ASSETS).find(
(config) => config.mapName === name, (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_MODEL_PATH = "/models/terrain/model.gltf";
export const TERRAIN_WATER_HEIGHT = 0.8; export const TERRAIN_WATER_HEIGHT = 0.8;
export const TERRAIN_TILE_SIZE = 1; const TERRAIN_TILE_SIZE = 1;
export const TERRAIN_COLORS = { export const TERRAIN_COLORS = {
grass1: { 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 * 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 { 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, type MapPerformanceModelName,
} from "@/data/world/mapPerformanceConfig"; } from "@/data/world/mapPerformanceConfig";
export { export { MAP_PERFORMANCE_GROUP_NAMES, MAP_PERFORMANCE_MODEL_NAMES };
MAP_PERFORMANCE_GROUP_NAMES,
MAP_PERFORMANCE_MODEL_NAMES,
type MapPerformanceGroupName,
type MapPerformanceModelName,
};
export interface MapPerformanceVisibility { export interface MapPerformanceVisibility {
groups: Record<MapPerformanceGroupName, boolean>; groups: Record<MapPerformanceGroupName, boolean>;
+13 -65
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 { Canvas } from "@react-three/fiber";
import { useProgress } from "@react-three/drei";
import { EditorControls } from "@/components/editor/EditorControls"; import { EditorControls } from "@/components/editor/EditorControls";
import { EditorScene } from "@/components/editor/scene/EditorScene"; import { EditorScene } from "@/components/editor/scene/EditorScene";
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene"; import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { Subtitles } from "@/components/ui/Subtitles"; import { Subtitles } from "@/components/ui/Subtitles";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory"; import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import type { CinematicDefinition } from "@/types/cinematics/cinematics"; import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData"; import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
@@ -16,19 +14,12 @@ import type {
SceneData, SceneData,
TransformMode, TransformMode,
} from "@/types/editor/editor"; } from "@/types/editor/editor";
import { import type { SceneLoadingState } from "@/types/world/sceneLoading";
type SceneLoadingChangeHandler,
type SceneLoadingState,
} from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger"; import { logger } from "@/utils/core/Logger";
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement"; const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
const DEFAULT_NEW_NODE_NAME = "new-model"; const DEFAULT_NEW_NODE_NAME = "new-model";
interface EditorSceneLoadingTrackerProps {
onLoadingStateChange: SceneLoadingChangeHandler;
}
function serializeMapNodes(sceneData: SceneData): string { function serializeMapNodes(sceneData: SceneData): string {
const mapPayload = sceneData.mapTree const mapPayload = sceneData.mapTree
? mergeFlatNodeTransformsIntoTree(sceneData) ? mergeFlatNodeTransformsIntoTree(sceneData)
@@ -43,6 +34,7 @@ function createSourcePathKey(sourcePath: readonly number[]): string {
function removeEditorMetadata(node: MapNode): MapNode { function removeEditorMetadata(node: MapNode): MapNode {
return { return {
...(node.id ? { id: node.id } : {}),
name: node.name, name: node.name,
type: node.type, type: node.type,
position: node.position, position: node.position,
@@ -67,6 +59,9 @@ function mergeFlatNodeTransformsIntoTree(
): HierarchicalMapNode => { ): HierarchicalMapNode => {
const updatedNode = nodesBySourcePath.get(createSourcePathKey(path)); const updatedNode = nodesBySourcePath.get(createSourcePathKey(path));
const nextNode: HierarchicalMapNode = { const nextNode: HierarchicalMapNode = {
...((updatedNode?.id ?? node.id)
? { id: updatedNode?.id ?? node.id }
: {}),
name: node.name, name: node.name,
type: node.type, type: node.type,
position: updatedNode?.position ?? node.position, position: updatedNode?.position ?? node.position,
@@ -116,6 +111,7 @@ function collectEditableMapNodes(
function visit(node: HierarchicalMapNode, path: number[]): void { function visit(node: HierarchicalMapNode, path: number[]): void {
if (node.role !== "group" && node.type !== "Mesh") { if (node.role !== "group" && node.type !== "Mesh") {
nodes.push({ nodes.push({
...(node.id ? { id: node.id } : {}),
name: node.name, name: node.name,
position: node.position, position: node.position,
rotation: node.rotation, 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 { export function EditorPage(): React.JSX.Element {
const { const {
hasMapJson, hasMapJson,
@@ -329,35 +300,17 @@ export function EditorPage(): React.JSX.Element {
const [cameraViewMode, setCameraViewMode] = useState<"home" | "object">( const [cameraViewMode, setCameraViewMode] = useState<"home" | "object">(
"home", "home",
); );
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>( const editorLoadingState: SceneLoadingState = isMapLoading
{
...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
? { ? {
currentStep: "Récupération blocking", currentStep: "Récupération blocking",
progress: 0.08, progress: 0.08,
status: "loading" as const, status: "loading" as const,
} }
: sceneLoadingState; : {
currentStep: "Gameplay prêt",
progress: 1,
status: "ready" as const,
};
const [cinematicPreviewRequest, setCinematicPreviewRequest] = const [cinematicPreviewRequest, setCinematicPreviewRequest] =
useState<EditorCinematicPreviewRequest | null>(null); useState<EditorCinematicPreviewRequest | null>(null);
@@ -720,10 +673,6 @@ export function EditorPage(): React.JSX.Element {
); );
}} }}
> >
<EditorSceneLoadingTracker
onLoadingStateChange={handleSceneLoadingStateChange}
/>
<Suspense fallback={null}>
<EditorScene <EditorScene
sceneData={sceneData!} sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
@@ -750,7 +699,6 @@ export function EditorPage(): React.JSX.Element {
cinematicPreviewRequest={cinematicPreviewRequest} cinematicPreviewRequest={cinematicPreviewRequest}
onCinematicPreviewComplete={handleCinematicPreviewComplete} onCinematicPreviewComplete={handleCinematicPreviewComplete}
/> />
</Suspense>
</Canvas> </Canvas>
<SceneLoadingOverlay state={editorLoadingState} /> <SceneLoadingOverlay state={editorLoadingState} />
+1
View File
@@ -1,6 +1,7 @@
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
export interface MapNode { export interface MapNode {
id?: string;
name: string; name: string;
type: string; type: string;
position: Vector3Tuple; position: Vector3Tuple;
+2 -2
View File
@@ -1,4 +1,4 @@
export type TerrainSurfaceKind = type TerrainSurfaceKind =
| "grass" | "grass"
| "path" | "path"
| "water" | "water"
@@ -6,7 +6,7 @@ export type TerrainSurfaceKind =
| "dirt" | "dirt"
| "rock"; | "rock";
export type TerrainSurfaceRgb = readonly [number, number, number]; type TerrainSurfaceRgb = readonly [number, number, number];
export interface TerrainSurfaceBounds { export interface TerrainSurfaceBounds {
minX: number; minX: number;
+1
View File
@@ -99,6 +99,7 @@ function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] {
return [ return [
{ {
...(node.id ? { id: node.id } : {}),
name: node.name, name: node.name,
type: node.type, type: node.type,
position: node.position, position: node.position,
+2
View File
@@ -23,6 +23,7 @@ function isMapNode(value: unknown): value is MapNode {
} }
return ( return (
(value.id === undefined || typeof value.id === "string") &&
typeof value.name === "string" && typeof value.name === "string" &&
typeof value.type === "string" && typeof value.type === "string" &&
isVector3Tuple(value.position) && isVector3Tuple(value.position) &&
@@ -53,6 +54,7 @@ function isHierarchicalMapNode(value: unknown): value is HierarchicalMapNode {
function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] { function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] {
const mapNode: MapNode = { const mapNode: MapNode = {
...(node.id ? { id: node.id } : {}),
name: node.name, name: node.name,
type: node.type, type: node.type,
position: node.position, position: node.position,
+2 -2
View File
@@ -1,9 +1,9 @@
import type { MapNode } from "@/types/map/mapScene"; import type { MapNode } from "@/types/map/mapScene";
export const POTAGER_MAP_NAME = "potager"; 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", "champdeble",
"champdesoja", "champdesoja",
"champsdetournesol", "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 { RepairMissionId } from "@/types/gameplay/repairMission";
import type { MapNode } from "@/types/map/mapScene"; import type { MapNode } from "@/types/map/mapScene";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
@@ -8,10 +12,67 @@ const REPAIR_MISSION_MAP_NODE_NAMES = {
farm: "fermeverticale", farm: "fermeverticale",
} as const satisfies Record<RepairMissionId, string>; } 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 { function isOriginPosition(position: Vector3Tuple): boolean {
return position.every((value) => Math.abs(value) < 0.0001); 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( export function getRepairMissionMapAnchors(
mapNodes: readonly MapNode[], mapNodes: readonly MapNode[],
): Partial<Record<RepairMissionId, Vector3Tuple>> { ): Partial<Record<RepairMissionId, Vector3Tuple>> {
@@ -20,12 +81,7 @@ export function getRepairMissionMapAnchors(
for (const [mission, mapName] of Object.entries( for (const [mission, mapName] of Object.entries(
REPAIR_MISSION_MAP_NODE_NAMES, REPAIR_MISSION_MAP_NODE_NAMES,
) as [RepairMissionId, string][]) { ) as [RepairMissionId, string][]) {
const node = mapNodes.find( const node = getAnchorNode(mapNodes, mission, mapName);
(candidate) =>
candidate.name === mapName &&
candidate.type === "Object3D" &&
!isOriginPosition(candidate.position),
);
if (node) { if (node) {
anchors[mission] = node.position; 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 { import {
GAME_SCENE_FALLBACK_BACKGROUND_COLOR, GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
GAME_SCENE_SKY_FALLBACK_MODEL_PATH,
GAME_SCENE_SKY_MODEL_PATH, GAME_SCENE_SKY_MODEL_PATH,
GAME_SCENE_SKY_MODEL_SCALE, GAME_SCENE_SKY_MODEL_SCALE,
PHYSICS_SCENE_BACKGROUND_COLOR, PHYSICS_SCENE_BACKGROUND_COLOR,
@@ -35,6 +36,7 @@ export function Environment(): React.JSX.Element {
{showSky ? ( {showSky ? (
<SkyModel <SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR} fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
fallbackModelPath={GAME_SCENE_SKY_FALLBACK_MODEL_PATH}
modelPath={GAME_SCENE_SKY_MODEL_PATH} modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE} scale={GAME_SCENE_SKY_MODEL_SCALE}
/> />
+1 -1
View File
@@ -90,7 +90,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
onLoadingStateChange={onLoadingStateChange} onLoadingStateChange={onLoadingStateChange}
onOctreeReady={handleOctreeReady} onOctreeReady={handleOctreeReady}
/> />
<PersonnageSystem /> {showGameStage ? <PersonnageSystem /> : null}
{showGameStage ? ( {showGameStage ? (
<Physics> <Physics>
<GameStageLoaded onLoaded={handleGameStageLoaded} /> <GameStageLoaded onLoaded={handleGameStageLoaded} />
+7 -6
View File
@@ -194,17 +194,18 @@ function createInstanceMatrices(
const position = new THREE.Vector3(); const position = new THREE.Vector3();
const rotation = new THREE.Euler(); const rotation = new THREE.Euler();
const quaternion = new THREE.Quaternion(); const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3( const scale = new THREE.Vector3();
scaleMultiplier,
scaleMultiplier,
scaleMultiplier,
);
for (const instance of instances) { for (const instance of instances) {
const matrix = new THREE.Matrix4(); const matrix = new THREE.Matrix4();
position.set(...instance.position); 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( rotation.set(
instance.rotation[0] + rotationOffset[0], instance.rotation[0] + rotationOffset[0],
instance.rotation[1] + rotationOffset[1], instance.rotation[1] + rotationOffset[1],