fix(editor): restore stable map editing behavior
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+1
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
Reference in New Issue
Block a user