Feat/map-environment #6

Merged
math-pixel merged 116 commits from feat/map-environment into develop 2026-05-29 00:00:51 +00:00
45 changed files with 823 additions and 785 deletions
Showing only changes of commit 093ffd726d - Show all commits
+2 -2
View File
@@ -80,8 +80,8 @@ jobs:
- name: 📏 Check bundle size
run: |
# Check generated app assets only; public/ model files are runtime assets copied to dist.
SIZE=$(du -k dist/assets | cut -f1)
# Check generated JS/CSS bundles only; public runtime assets are copied to dist/assets too.
SIZE=$(node -e "const fs=require('fs');const path=require('path');function walk(dir){return fs.readdirSync(dir,{withFileTypes:true}).flatMap((entry)=>{const file=path.join(dir,entry.name);return entry.isDirectory()?walk(file):file;});}const bytes=walk('dist/assets').filter((file)=>/\.(js|css)$/.test(file)).reduce((sum,file)=>sum+fs.statSync(file).size,0);console.log(Math.ceil(bytes/1024));")
echo "Bundle size: ${SIZE}KB"
THRESHOLD=5000
+1 -1
View File
@@ -121,7 +121,7 @@ Phrase à retenir :
Piège à connaître :
`useRepairMovementLocked()` retourne actuellement `false`. Le lock de mouvement est prévu dans le code et l'UI, mais il est désactivé sur `develop`.
`useRepairMovementLocked()` lit maintenant l'étape de mission active et verrouille le déplacement pendant les phases de réparation qui doivent immobiliser le joueur.
### Interaction
+4 -5
View File
@@ -113,7 +113,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
2. `useEditorSceneData` calls `loadMapSceneData()`.
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
4. If `/map.json` is missing, the page displays a folder-upload flow.
5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
5. The route-level loading overlay reports map JSON loading, then hands off to the editor scene once the map payload is ready.
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, multi-selection status, and the cinematic/dialogue/SRT editors.
@@ -150,14 +150,13 @@ The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite
## Editor Loading Overlay
The editor uses `SceneLoadingOverlay` like the runtime scene. `EditorSceneLoadingTracker` lives in `src/pages/editor/page.tsx` and reads drei `useProgress()` inside the canvas.
The editor uses `SceneLoadingOverlay` like the runtime scene for the route-level map JSON loading phase.
The route tracks two loading phases:
The route tracks the map JSON loading phase:
- map JSON loading through `useEditorSceneData()`
- model loading through `useProgress()`
The overlay is rendered outside the canvas so it remains visible while the R3F scene mounts. The scene itself is wrapped in `Suspense` with a `null` fallback; the visual feedback is handled by the overlay instead of by the canvas fallback.
The overlay is rendered outside the canvas so it remains visible while the editor route mounts. Model loading is left to R3F `Suspense` boundaries to avoid progress updates during model render.
## Panel Groups
-5
View File
@@ -57,10 +57,5 @@
"r3f-perf": {
"@react-three/drei": "$@react-three/drei"
}
},
"knip": {
"ignore": [
"src/types/three/three-addons.d.ts"
]
}
}
+54 -14
View File
@@ -18,6 +18,7 @@ import {
Unlock,
X,
} from "lucide-react";
import { useState } from "react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
@@ -102,6 +103,52 @@ function EditorPanelGroup({
);
}
interface EditorScaleFieldProps {
axis: 0 | 1 | 2;
label: string;
value: number;
onCommit: (axis: 0 | 1 | 2, value: number) => void;
}
function EditorScaleField({
axis,
label,
onCommit,
value,
}: EditorScaleFieldProps): React.JSX.Element {
const [draftValue, setDraftValue] = useState(() =>
String(Number(value.toFixed(4))),
);
const commitDraftValue = (): void => {
const nextValue = Number(draftValue);
if (!draftValue.trim() || Number.isNaN(nextValue)) {
setDraftValue(String(Number(value.toFixed(4))));
return;
}
onCommit(axis, nextValue);
};
return (
<label>
<span>{label}</span>
<input
type="number"
step="0.01"
value={draftValue}
onBlur={commitDraftValue}
onChange={(event) => setDraftValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
</label>
);
}
export function EditorControls({
transformMode,
onTransformModeChange,
@@ -303,20 +350,13 @@ export function EditorControls({
{selectedNodeScale ? (
<div className="editor-scale-fields">
{selectedNodeScale.map((value, axis) => (
<label key={axis}>
<span>{["X", "Y", "Z"][axis]}</span>
<input
type="number"
step="0.01"
value={Number(value.toFixed(4))}
onChange={(event) =>
onSelectedScaleChange(
axis as 0 | 1 | 2,
Number(event.target.value),
)
}
/>
</label>
<EditorScaleField
key={`${axis}:${value}`}
axis={axis as 0 | 1 | 2}
label={["X", "Y", "Z"][axis] ?? "?"}
value={value}
onCommit={onSelectedScaleChange}
/>
))}
</div>
) : null}
@@ -12,6 +12,17 @@ import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
const EDITOR_CAMERA_HOME_POSITION = new THREE.Vector3(0, 50, 100);
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
function isEditableShortcutTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
);
}
export interface EditorCinematicPreviewRequest {
id: string;
cinematic: CinematicDefinition;
@@ -148,6 +159,8 @@ export function EditorScene({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableShortcutTarget(e.target)) return;
if (e.ctrlKey || e.metaKey) {
if (e.key === "z" || e.key === "Z") {
e.preventDefault();
@@ -3,7 +3,7 @@ import { Component, useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { clone } from "three/addons/utils/SkeletonUtils.js";
import { SkeletonUtils } from "three-stdlib";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import {
useHandTrackingGloveStatus,
@@ -255,7 +255,7 @@ function HandTrackingGloveModel({
throw new Error(`Missing glove root node ${config.rootNodeName}`);
}
const clonedRootNode = clone(rootNode);
const clonedRootNode = SkeletonUtils.clone(rootNode);
clonedRootNode.visible = false;
return clonedRootNode;
+1 -1
View File
@@ -42,7 +42,7 @@ export function SimpleModel({
rotation,
scale,
});
const model = useClonedObject(scene);
const model = useClonedObject(scene, { cloneResources: true });
useEffect(() => {
applyShadowSettings(model, castShadow, receiveShadow);
@@ -0,0 +1,17 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const LA_FABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
type LaFabrikMapModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function LaFabrikMapModel(
props: LaFabrikMapModelProps,
): React.JSX.Element {
return <MergedStaticMapModel modelPath={LA_FABRIK_MODEL_PATH} {...props} />;
}
useGLTF.preload(LA_FABRIK_MODEL_PATH);
@@ -1,15 +0,0 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const LAFABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
type LafabrikModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function LafabrikModel(props: LafabrikModelProps): React.JSX.Element {
return <MergedStaticMapModel modelPath={LAFABRIK_MODEL_PATH} {...props} />;
}
useGLTF.preload(LAFABRIK_MODEL_PATH);
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import { mergeBufferGeometries } from "three-stdlib";
import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
@@ -102,7 +102,7 @@ function createMergedMeshes(scene: THREE.Group): MergedMeshData[] {
};
}
const geometry = mergeGeometries(group.geometries, false);
const geometry = mergeBufferGeometries(group.geometries, false);
for (const sourceGeometry of group.geometries) {
sourceGeometry.dispose();
+6 -1
View File
@@ -5,6 +5,7 @@ import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
interface SkyModelProps {
fallbackModelScale?: number | undefined;
fallbackModelPath?: string | undefined;
modelPath: string;
fallbackColor?: string | undefined;
@@ -53,6 +54,7 @@ class SkyModelErrorBoundary extends Component<
export function SkyModel({
fallbackColor,
fallbackModelScale = SKY_MODEL_SCALE,
fallbackModelPath,
modelPath,
scale = SKY_MODEL_SCALE,
@@ -62,7 +64,10 @@ export function SkyModel({
) : null;
const fallback = fallbackModelPath ? (
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
<SkyModelContent modelPath={fallbackModelPath} scale={scale} />
<SkyModelContent
modelPath={fallbackModelPath}
scale={fallbackModelScale}
/>
</SkyModelErrorBoundary>
) : (
colorFallback
+18
View File
@@ -0,0 +1,18 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface StageAnchorConfig {
color: string;
position: Vector3Tuple;
scale?: number;
}
export const INTRO_STAGE_ANCHOR: StageAnchorConfig = {
color: "#7dd3fc",
position: [0, 4, 0],
};
export const OUTRO_STAGE_ANCHOR: StageAnchorConfig = {
color: "#fb7185",
position: [0, 6, 10],
scale: 1.25,
};
@@ -1,9 +1,9 @@
import type { Vector3Tuple } from "@/types/three/three";
export type PersonnageId = "electricienne" | "gerant" | "fermier";
export type CharacterId = "electricienne" | "gerant" | "fermier";
export interface PersonnageConfig {
id: PersonnageId;
export interface CharacterConfig {
id: CharacterId;
label: string;
modelPath: string;
position: Vector3Tuple;
@@ -13,7 +13,7 @@ export interface PersonnageConfig {
defaultAnimation: string;
}
export const PERSONNAGE_CONFIGS = {
export const CHARACTER_CONFIGS = {
electricienne: {
id: "electricienne",
label: "Electricienne",
@@ -44,10 +44,10 @@ export const PERSONNAGE_CONFIGS = {
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
} satisfies Record<PersonnageId, PersonnageConfig>;
} satisfies Record<CharacterId, CharacterConfig>;
export const PERSONNAGE_IDS = [
export const CHARACTER_IDS = [
"electricienne",
"gerant",
"fermier",
] as const satisfies readonly PersonnageId[];
] as const satisfies readonly CharacterId[];
+1
View File
@@ -1,5 +1,6 @@
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf";
export const GAME_SCENE_SKY_FALLBACK_MODEL_PATH = "/models/sky/model.glb";
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
export const GAME_SCENE_SKY_FALLBACK_MODEL_SCALE = 1;
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
+1 -1
View File
@@ -90,7 +90,7 @@ export function getVegetationModelScaleMultiplier(name: string): number {
);
}
export const INSTANCED_MAP_EXCEPTIONS = new Set([
export const VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES = new Set([
"Scene",
"blocking",
"terrain",
@@ -1,9 +1,9 @@
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import {
PERSONNAGE_CONFIGS,
PERSONNAGE_IDS,
} from "@/data/world/personnages/personnageConfig";
import { usePersonnageDebugStore } from "@/managers/stores/usePersonnageDebugStore";
CHARACTER_CONFIGS,
CHARACTER_IDS,
} from "@/data/world/characters/characterConfig";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
function createAnimationOptions(
animations: readonly string[],
@@ -17,13 +17,13 @@ function createAnimationOptions(
);
}
export function usePersonnageDebug(): void {
export function useCharacterDebug(): void {
useDebugFolder("Personnages", (folder) => {
const store = usePersonnageDebugStore.getState();
const store = useCharacterDebugStore.getState();
for (const id of PERSONNAGE_IDS) {
const config = PERSONNAGE_CONFIGS[id];
const state = store.personnages[id];
for (const id of CHARACTER_IDS) {
const config = CHARACTER_CONFIGS[id];
const state = store.characters[id];
const characterFolder = folder.addFolder(config.label);
const controls = {
animation: state.animation,
@@ -42,64 +42,64 @@ export function usePersonnageDebug(): void {
.add(controls, "animation", createAnimationOptions(config.animations))
.name("Animation")
.onChange((animation: string) => {
usePersonnageDebugStore.getState().setAnimation(id, animation);
useCharacterDebugStore.getState().setAnimation(id, animation);
});
characterFolder
.add(controls, "positionX", -120, 120, 0.1)
.name("Position X")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setPosition(id, 0, value);
useCharacterDebugStore.getState().setPosition(id, 0, value);
});
characterFolder
.add(controls, "positionY", -20, 40, 0.1)
.name("Position Y")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setPosition(id, 1, value);
useCharacterDebugStore.getState().setPosition(id, 1, value);
});
characterFolder
.add(controls, "positionZ", -120, 120, 0.1)
.name("Position Z")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setPosition(id, 2, value);
useCharacterDebugStore.getState().setPosition(id, 2, value);
});
characterFolder
.add(controls, "rotationX", -Math.PI, Math.PI, 0.01)
.name("Rotation X")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setRotation(id, 0, value);
useCharacterDebugStore.getState().setRotation(id, 0, value);
});
characterFolder
.add(controls, "rotationY", -Math.PI, Math.PI, 0.01)
.name("Rotation Y")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setRotation(id, 1, value);
useCharacterDebugStore.getState().setRotation(id, 1, value);
});
characterFolder
.add(controls, "rotationZ", -Math.PI, Math.PI, 0.01)
.name("Rotation Z")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setRotation(id, 2, value);
useCharacterDebugStore.getState().setRotation(id, 2, value);
});
characterFolder
.add(controls, "scaleX", 0.1, 5, 0.05)
.name("Scale X")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setScale(id, 0, value);
useCharacterDebugStore.getState().setScale(id, 0, value);
});
characterFolder
.add(controls, "scaleY", 0.1, 5, 0.05)
.name("Scale Y")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setScale(id, 1, value);
useCharacterDebugStore.getState().setScale(id, 1, value);
});
characterFolder
.add(controls, "scaleZ", 0.1, 5, 0.05)
.name("Scale Z")
.onChange((value: number) => {
usePersonnageDebugStore.getState().setScale(id, 2, value);
useCharacterDebugStore.getState().setScale(id, 2, value);
});
characterFolder.close();
+31 -7
View File
@@ -2,29 +2,53 @@ 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 {
interface UseClonedObjectOptions {
cloneResources?: boolean;
}
function cloneMaterial(
material: THREE.Material | THREE.Material[],
): THREE.Material | THREE.Material[] {
return Array.isArray(material)
? material.map((item) => item.clone())
: material.clone();
}
function cloneObject<T extends THREE.Object3D>(
object: T,
cloneResources: boolean,
): T {
const clone = object.clone(true) as T;
if (!cloneResources) return clone;
clone.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
child.geometry = child.geometry.clone();
child.material = Array.isArray(child.material)
? child.material.map((material) => material.clone())
: child.material.clone();
child.material = cloneMaterial(child.material);
});
return clone;
}
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
const clone = useMemo(() => cloneObjectWithOwnedResources(object), [object]);
export function useClonedObject<T extends THREE.Object3D>(
object: T,
options: UseClonedObjectOptions = {},
): T {
const cloneResources = options.cloneResources ?? false;
const clone = useMemo(
() => cloneObject(object, cloneResources),
[cloneResources, object],
);
useEffect(() => {
if (!cloneResources) return undefined;
return () => {
disposeObject3D(clone);
};
}, [clone]);
}, [clone, cloneResources]);
return clone;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import type { Object3D } from "three";
import { Octree } from "three/addons/math/Octree.js";
import { Octree } from "three-stdlib";
import type { OctreeReadyHandler } from "@/types/three/three";
export function useOctreeGraphNode(
+7 -6
View File
@@ -47,6 +47,9 @@ function createTerrainHeightSampler(
new THREE.Vector3(...scale),
);
const inverseTerrainMatrix = terrainMatrix.clone().invert();
const localOrigin = new THREE.Vector3();
const localDirection = DOWN.clone().transformDirection(inverseTerrainMatrix);
const hits: THREE.Intersection[] = [];
const raycaster = new THREE.Raycaster(
new THREE.Vector3(),
DOWN,
@@ -63,13 +66,11 @@ function createTerrainHeightSampler(
return {
getHeight: (x, z) => {
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
inverseTerrainMatrix,
);
const localDirection =
DOWN.clone().transformDirection(inverseTerrainMatrix);
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
raycaster.set(localOrigin, localDirection);
const hit = raycaster.intersectObjects(meshes, false)[0];
hits.length = 0;
raycaster.intersectObjects(meshes, false, hits);
const hit = hits[0];
return hit?.point.applyMatrix4(terrainMatrix).y ?? null;
},
};
+2 -40
View File
@@ -1,14 +1,9 @@
import { useEffect, useState } from "react";
import { INSTANCED_MAP_EXCEPTIONS } from "@/data/world/vegetationConfig";
import { VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES } from "@/data/world/vegetationConfig";
import type { MapNode, VegetationInstance } from "@/types/map/mapScene";
import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform";
import { logger } from "@/utils/core/Logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import {
createPotagerMapNode,
isPotagerSourceMapNode,
POTAGER_MAP_NAME,
} from "@/utils/map/potagerMapNodes";
interface InstancedMapEntry {
modelPath: string;
@@ -17,10 +12,6 @@ interface InstancedMapEntry {
export type VegetationData = Map<string, InstancedMapEntry>;
function createPositionKey(node: MapNode): string {
return node.position.map((value) => value.toFixed(3)).join(":");
}
function extractVegetationData(
mapNodes: MapNode[],
models: Map<string, string>,
@@ -48,7 +39,7 @@ function extractVegetationData(
for (const node of mapNodes) {
if (node.type !== "Object3D") continue;
if (INSTANCED_MAP_EXCEPTIONS.has(node.name)) continue;
if (VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES.has(node.name)) continue;
const modelPath = models.get(node.name);
if (!modelPath) continue;
@@ -56,35 +47,6 @@ function extractVegetationData(
addInstance(node.name, modelPath, node);
}
const existingPotagerPositionKeys = new Set(
mapNodes
.filter((node) => node.name === POTAGER_MAP_NAME)
.map(createPositionKey),
);
for (const node of mapNodes) {
if (!isPotagerSourceMapNode(node)) continue;
if (existingPotagerPositionKeys.has(createPositionKey(node))) continue;
addInstance(
POTAGER_MAP_NAME,
"/models/potager/potager.gltf",
createPotagerMapNode(node),
);
}
const potagerEntry = data.get(POTAGER_MAP_NAME);
if (potagerEntry) {
const uniqueInstances = new Map<string, VegetationInstance>();
for (const instance of potagerEntry.instances) {
uniqueInstances.set(
instance.position.map((value) => value.toFixed(3)).join(":"),
instance,
);
}
potagerEntry.instances = [...uniqueInstances.values()];
}
return data;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import type { Octree } from "three/addons/math/Octree.js";
import type { Octree } from "three-stdlib";
import type { SceneMode } from "@/types/debug/debug";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -0,0 +1,89 @@
import { create } from "zustand";
import {
CHARACTER_CONFIGS,
CHARACTER_IDS,
type CharacterId,
} from "@/data/world/characters/characterConfig";
import type { Vector3Tuple } from "@/types/three/three";
interface CharacterDebugState {
animation: string;
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
interface CharacterDebugStore {
characters: Record<CharacterId, CharacterDebugState>;
setAnimation: (id: CharacterId, animation: string) => void;
setPosition: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
setRotation: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
setScale: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
}
function updateVector(
vector: Vector3Tuple,
axis: 0 | 1 | 2,
value: number,
): Vector3Tuple {
const next: Vector3Tuple = [...vector];
next[axis] = value;
return next;
}
const initialCharacters = Object.fromEntries(
CHARACTER_IDS.map((id) => {
const config = CHARACTER_CONFIGS[id];
return [
id,
{
animation: config.defaultAnimation,
position: [...config.position],
rotation: [...config.rotation],
scale: [...config.scale],
},
];
}),
) as Record<CharacterId, CharacterDebugState>;
export const useCharacterDebugStore = create<CharacterDebugStore>((set) => ({
characters: initialCharacters,
setAnimation: (id, animation) =>
set((state) => ({
characters: {
...state.characters,
[id]: { ...state.characters[id], animation },
},
})),
setPosition: (id, axis, value) =>
set((state) => ({
characters: {
...state.characters,
[id]: {
...state.characters[id],
position: updateVector(state.characters[id].position, axis, value),
},
},
})),
setRotation: (id, axis, value) =>
set((state) => ({
characters: {
...state.characters,
[id]: {
...state.characters[id],
rotation: updateVector(state.characters[id].rotation, axis, value),
},
},
})),
setScale: (id, axis, value) =>
set((state) => ({
characters: {
...state.characters,
[id]: {
...state.characters[id],
scale: updateVector(state.characters[id].scale, axis, value),
},
},
})),
}));
@@ -1,89 +0,0 @@
import { create } from "zustand";
import {
PERSONNAGE_CONFIGS,
PERSONNAGE_IDS,
type PersonnageId,
} from "@/data/world/personnages/personnageConfig";
import type { Vector3Tuple } from "@/types/three/three";
interface PersonnageDebugState {
animation: string;
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
interface PersonnageDebugStore {
personnages: Record<PersonnageId, PersonnageDebugState>;
setAnimation: (id: PersonnageId, animation: string) => void;
setPosition: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void;
setRotation: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void;
setScale: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void;
}
function updateVector(
vector: Vector3Tuple,
axis: 0 | 1 | 2,
value: number,
): Vector3Tuple {
const next: Vector3Tuple = [...vector];
next[axis] = value;
return next;
}
const initialPersonnages = Object.fromEntries(
PERSONNAGE_IDS.map((id) => {
const config = PERSONNAGE_CONFIGS[id];
return [
id,
{
animation: config.defaultAnimation,
position: [...config.position],
rotation: [...config.rotation],
scale: [...config.scale],
},
];
}),
) as Record<PersonnageId, PersonnageDebugState>;
export const usePersonnageDebugStore = create<PersonnageDebugStore>((set) => ({
personnages: initialPersonnages,
setAnimation: (id, animation) =>
set((state) => ({
personnages: {
...state.personnages,
[id]: { ...state.personnages[id], animation },
},
})),
setPosition: (id, axis, value) =>
set((state) => ({
personnages: {
...state.personnages,
[id]: {
...state.personnages[id],
position: updateVector(state.personnages[id].position, axis, value),
},
},
})),
setRotation: (id, axis, value) =>
set((state) => ({
personnages: {
...state.personnages,
[id]: {
...state.personnages[id],
rotation: updateVector(state.personnages[id].rotation, axis, value),
},
},
})),
setScale: (id, axis, value) =>
set((state) => ({
personnages: {
...state.personnages,
[id]: {
...state.personnages[id],
scale: updateVector(state.personnages[id].scale, axis, value),
},
},
})),
}));
+95 -335
View File
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { useCallback, useEffect, useState } from "react";
import { Canvas, useThree } from "@react-three/fiber";
import { EditorControls } from "@/components/editor/EditorControls";
import { EditorScene } from "@/components/editor/scene/EditorScene";
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
@@ -8,268 +8,48 @@ import { Subtitles } from "@/components/ui/Subtitles";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
import type {
HierarchicalMapNode,
MapNode,
SceneData,
TransformMode,
} from "@/types/editor/editor";
import type { MapNode, TransformMode } from "@/types/editor/editor";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger";
import {
addTreeNode,
createNewMapNode,
mergeFlatNodeTransformsIntoTree,
removeEditorMetadata,
removeTreeNodeAtPath,
serializeMapNodes,
updateSceneDataTree,
updateTreeNodeAtPath,
} from "@/utils/editor/editorMapTree";
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
const DEFAULT_NEW_NODE_NAME = "new-model";
function serializeMapNodes(sceneData: SceneData): string {
const mapPayload = sceneData.mapTree
? mergeFlatNodeTransformsIntoTree(sceneData)
: sceneData.mapNodes.map(removeEditorMetadata);
function EditorWebGLContextLogger(): null {
const gl = useThree((state) => state.gl);
return JSON.stringify(mapPayload, null, 2);
}
useEffect(() => {
gl.setClearColor("#050505");
function createSourcePathKey(sourcePath: readonly number[]): string {
return sourcePath.join(".");
}
function removeEditorMetadata(node: MapNode): MapNode {
return {
...(node.id ? { id: node.id } : {}),
name: node.name,
type: node.type,
position: node.position,
rotation: node.rotation,
scale: node.scale,
};
}
function mergeFlatNodeTransformsIntoTree(
sceneData: SceneData,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nodesBySourcePath = new Map<string, MapNode>();
for (const node of sceneData.mapNodes) {
if (!node.sourcePath) continue;
nodesBySourcePath.set(createSourcePathKey(node.sourcePath), node);
}
const cloneNode = (
node: HierarchicalMapNode,
path: number[],
): HierarchicalMapNode => {
const updatedNode = nodesBySourcePath.get(createSourcePathKey(path));
const nextNode: HierarchicalMapNode = {
...((updatedNode?.id ?? node.id)
? { id: updatedNode?.id ?? node.id }
: {}),
name: node.name,
type: node.type,
position: updatedNode?.position ?? node.position,
rotation: updatedNode?.rotation ?? node.rotation,
scale: updatedNode?.scale ?? node.scale,
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - GPU resources exhausted");
};
const handleContextRestored = () => {
logger.info("WebGL", "Context restored");
};
if (node.role) {
nextNode.role = node.role;
}
canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener("webglcontextrestored", handleContextRestored);
if (node.children) {
nextNode.children = node.children.map((child, index) =>
cloneNode(child, [...path, index]),
);
}
return () => {
canvas.removeEventListener("webglcontextlost", handleContextLost);
canvas.removeEventListener("webglcontextrestored", handleContextRestored);
};
}, [gl]);
return nextNode;
};
const mapTree = sceneData.mapTree;
if (!mapTree) {
return sceneData.mapNodes.map(removeEditorMetadata);
}
if (Array.isArray(mapTree)) {
return mapTree.map((node, index) => cloneNode(node, [index]));
}
return cloneNode(mapTree, []);
}
function cloneMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): HierarchicalMapNode | HierarchicalMapNode[] {
return JSON.parse(JSON.stringify(mapTree)) as
| HierarchicalMapNode
| HierarchicalMapNode[];
}
function collectEditableMapNodes(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): MapNode[] {
const nodes: MapNode[] = [];
function visit(node: HierarchicalMapNode, path: number[]): void {
if (node.role !== "group" && node.type !== "Mesh") {
nodes.push({
...(node.id ? { id: node.id } : {}),
name: node.name,
position: node.position,
rotation: node.rotation,
scale: node.scale,
sourcePath: path,
type: node.type,
});
}
node.children?.forEach((child, index) => visit(child, [...path, index]));
}
if (Array.isArray(mapTree)) {
mapTree.forEach((node, index) => visit(node, [index]));
} else {
visit(mapTree, []);
}
return nodes;
}
function updateTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
update: (node: HierarchicalMapNode) => HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1] ?? 0;
const isRootTarget = Array.isArray(nextTree)
? path.length === 1
: path.length === 0;
if (isRootTarget) {
const targetNode = rootNodes[targetIndex];
if (targetNode) {
rootNodes[targetIndex] = update(targetNode);
}
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
if (!parent?.children?.[targetIndex]) return nextTree;
parent.children[targetIndex] = update(parent.children[targetIndex]);
return nextTree;
}
function removeTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1];
if (targetIndex === undefined) return nextTree;
if (Array.isArray(nextTree) && path.length === 1) {
nextTree.splice(targetIndex, 1);
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
parent?.children?.splice(targetIndex, 1);
return nextTree;
}
function updateSceneDataTree(
sceneData: SceneData,
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): SceneData {
return {
...sceneData,
mapNodes: collectEditableMapNodes(mapTree),
mapTree,
};
}
function findNodePathByName(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
name: string,
): number[] | null {
function visit(node: HierarchicalMapNode, path: number[]): number[] | null {
if (node.name === name) return path;
for (let index = 0; index < (node.children?.length ?? 0); index++) {
const child = node.children?.[index];
if (!child) continue;
const result = visit(child, [...path, index]);
if (result) return result;
}
return null;
}
if (Array.isArray(mapTree)) {
for (let index = 0; index < mapTree.length; index++) {
const node = mapTree[index];
if (!node) continue;
const result = visit(node, [index]);
if (result) return result;
}
return null;
}
return visit(mapTree, []);
}
function addTreeNode(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
node: HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const blockingPath = findNodePathByName(mapTree, "blocking");
if (!blockingPath) return mapTree;
return updateTreeNodeAtPath(mapTree, blockingPath, (blockingNode) => ({
...blockingNode,
children: [...(blockingNode.children ?? []), node],
}));
}
function createNewMapNode(name: string): HierarchicalMapNode {
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
return {
name: safeName,
type: "Object3D",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
children: [
{
name: safeName,
type: "Mesh",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
};
return null;
}
export function EditorPage(): React.JSX.Element {
@@ -336,13 +116,14 @@ export function EditorPage(): React.JSX.Element {
setResetCameraRequest((request) => request + 1);
}, []);
const handleToggleNodeSelection = useCallback((index: number) => {
setSelectedNodeIndexes((currentIndexes) => {
const isSelected = currentIndexes.includes(index);
const handleToggleNodeSelection = useCallback(
(index: number) => {
const isSelected = selectedNodeIndexes.includes(index);
const nextIndexes = isSelected
? currentIndexes.filter((item) => item !== index)
: [...currentIndexes, index];
? selectedNodeIndexes.filter((item) => item !== index)
: [...selectedNodeIndexes, index];
setSelectedNodeIndexes(nextIndexes);
setSelectedNodeIndex(nextIndexes.at(-1) ?? null);
if (nextIndexes.length > 0) {
setCameraViewMode("object");
@@ -350,10 +131,9 @@ export function EditorPage(): React.JSX.Element {
setCameraViewMode("home");
setResetCameraRequest((request) => request + 1);
}
return nextIndexes;
});
}, []);
},
[selectedNodeIndexes],
);
const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null);
@@ -399,28 +179,20 @@ export function EditorPage(): React.JSX.Element {
if (!locked) return;
setSelectedNodeIndex((currentIndex) => {
if (currentIndex === null) return null;
const nextIndexes = selectedNodeIndexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
);
const selectedNode =
selectedNodeIndex !== null
? sceneData?.mapNodes[selectedNodeIndex]
: null;
const selectedNode = sceneData?.mapNodes[currentIndex];
if (selectedNode?.name === "terrain") {
setSelectedNodeIndexes((indexes) =>
indexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
),
);
return null;
}
setSelectedNodeIndexes((indexes) =>
indexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
),
);
return currentIndex;
});
setSelectedNodeIndexes(nextIndexes);
setSelectedNodeIndex(
selectedNode?.name === "terrain" ? null : selectedNodeIndex,
);
},
[sceneData],
[sceneData, selectedNodeIndex, selectedNodeIndexes],
);
const handleHoverNode = useCallback((index: number | null) => {
@@ -554,51 +326,55 @@ export function EditorPage(): React.JSX.Element {
);
const handleAddNode = useCallback(() => {
setSceneData((prev) => {
if (!prev) return null;
if (!prev.mapTree) {
const newNode = createNewMapNode(newNodeName);
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)];
setSelectedNodeIndex(mapNodes.length - 1);
setSelectedNodeIndexes([mapNodes.length - 1]);
return { ...prev, mapNodes };
}
if (!sceneData) return;
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
const nextSceneData = updateSceneDataTree(prev, mapTree);
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]);
return nextSceneData;
});
}, [newNodeName, setSceneData]);
if (!sceneData.mapTree) {
const newNode = createNewMapNode(newNodeName);
const mapNodes = [...sceneData.mapNodes, removeEditorMetadata(newNode)];
const selectedIndex = mapNodes.length - 1;
setSceneData({ ...sceneData, mapNodes });
setSelectedNodeIndex(selectedIndex);
setSelectedNodeIndexes([selectedIndex]);
return;
}
const mapTree = addTreeNode(
sceneData.mapTree,
createNewMapNode(newNodeName),
);
const nextSceneData = updateSceneDataTree(sceneData, mapTree);
const selectedIndex = nextSceneData.mapNodes.length - 1;
setSceneData(nextSceneData);
setSelectedNodeIndex(selectedIndex);
setSelectedNodeIndexes([selectedIndex]);
}, [newNodeName, sceneData, setSceneData]);
const handleDeleteSelectedNode = useCallback(() => {
if (selectedNodeIndex === null) return;
if (!sceneData || selectedNodeIndex === null) return;
setSceneData((prev) => {
if (!prev) return null;
const currentNode = prev.mapNodes[selectedNodeIndex];
if (!currentNode) return prev;
if (!prev.mapTree || !currentNode.sourcePath) {
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
return {
...prev,
mapNodes: prev.mapNodes.filter(
(_node, index) => index !== selectedNodeIndex,
),
};
}
const currentNode = sceneData.mapNodes[selectedNodeIndex];
if (!currentNode) return;
if (!sceneData.mapTree || !currentNode.sourcePath) {
setSceneData({
...sceneData,
mapNodes: sceneData.mapNodes.filter(
(_node, index) => index !== selectedNodeIndex,
),
});
} else {
const mapTree = removeTreeNodeAtPath(
prev.mapTree,
sceneData.mapTree,
currentNode.sourcePath,
);
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
return updateSceneDataTree(prev, mapTree);
});
}, [selectedNodeIndex, setSceneData]);
setSceneData(updateSceneDataTree(sceneData, mapTree));
}
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
}, [sceneData, selectedNodeIndex, setSceneData]);
if (isMapLoading) {
return (
@@ -655,24 +431,8 @@ export function EditorPage(): React.JSX.Element {
antialias: true,
stencil: false,
}}
onCreated={({ gl }) => {
gl.setClearColor("#050505");
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - GPU resources exhausted");
};
const handleContextRestored = () => {
logger.info("WebGL", "Context restored");
};
canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener(
"webglcontextrestored",
handleContextRestored,
);
}}
>
<EditorWebGLContextLogger />
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
-42
View File
@@ -1,42 +0,0 @@
declare module "three/addons/math/Capsule.js" {
import { Vector3 } from "three";
export class Capsule {
start: Vector3;
end: Vector3;
radius: number;
constructor(start?: Vector3, end?: Vector3, radius?: number);
set(start: Vector3, end: Vector3, radius: number): this;
clone(): Capsule;
copy(capsule: Capsule): this;
getCenter(target: Vector3): Vector3;
translate(v: Vector3): this;
}
}
declare module "three/addons/math/Octree.js" {
import { Object3D } from "three";
import { Capsule } from "three/addons/math/Capsule.js";
export interface CapsuleIntersectResult {
normal: import("three").Vector3;
depth: number;
}
export class Octree {
constructor();
fromGraphNode(group: Object3D): this;
capsuleIntersect(capsule: Capsule): CapsuleIntersectResult | false;
}
}
declare module "three/addons/utils/BufferGeometryUtils.js" {
import { BufferGeometry } from "three";
export function mergeGeometries(
geometries: BufferGeometry[],
useGroups?: boolean,
): BufferGeometry | null;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Octree } from "three/addons/math/Octree.js";
import type { Octree } from "three-stdlib";
export type Vector3Tuple = [number, number, number];
+259
View File
@@ -0,0 +1,259 @@
import type {
HierarchicalMapNode,
MapNode,
SceneData,
} from "@/types/editor/editor";
const DEFAULT_NEW_NODE_NAME = "new-model";
export function serializeMapNodes(sceneData: SceneData): string {
const mapPayload = sceneData.mapTree
? mergeFlatNodeTransformsIntoTree(sceneData)
: sceneData.mapNodes.map(removeEditorMetadata);
return JSON.stringify(mapPayload, null, 2);
}
function createSourcePathKey(sourcePath: readonly number[]): string {
return sourcePath.join(".");
}
export function removeEditorMetadata(node: MapNode): MapNode {
return {
...(node.id ? { id: node.id } : {}),
name: node.name,
type: node.type,
position: node.position,
rotation: node.rotation,
scale: node.scale,
};
}
export function mergeFlatNodeTransformsIntoTree(
sceneData: SceneData,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nodesBySourcePath = new Map<string, MapNode>();
for (const node of sceneData.mapNodes) {
if (!node.sourcePath) continue;
nodesBySourcePath.set(createSourcePathKey(node.sourcePath), node);
}
const cloneNode = (
node: HierarchicalMapNode,
path: number[],
): HierarchicalMapNode => {
const updatedNode = nodesBySourcePath.get(createSourcePathKey(path));
const nextNode: HierarchicalMapNode = {
...((updatedNode?.id ?? node.id)
? { id: updatedNode?.id ?? node.id }
: {}),
name: node.name,
type: node.type,
position: updatedNode?.position ?? node.position,
rotation: updatedNode?.rotation ?? node.rotation,
scale: updatedNode?.scale ?? node.scale,
};
if (node.role) {
nextNode.role = node.role;
}
if (node.children) {
nextNode.children = node.children.map((child, index) =>
cloneNode(child, [...path, index]),
);
}
return nextNode;
};
const mapTree = sceneData.mapTree;
if (!mapTree) {
return sceneData.mapNodes.map(removeEditorMetadata);
}
if (Array.isArray(mapTree)) {
return mapTree.map((node, index) => cloneNode(node, [index]));
}
return cloneNode(mapTree, []);
}
function cloneMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): HierarchicalMapNode | HierarchicalMapNode[] {
return JSON.parse(JSON.stringify(mapTree)) as
| HierarchicalMapNode
| HierarchicalMapNode[];
}
function collectEditableMapNodes(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): MapNode[] {
const nodes: MapNode[] = [];
function visit(node: HierarchicalMapNode, path: number[]): void {
if (node.role !== "group" && node.type !== "Mesh") {
nodes.push({
...(node.id ? { id: node.id } : {}),
name: node.name,
position: node.position,
rotation: node.rotation,
scale: node.scale,
sourcePath: path,
type: node.type,
});
}
node.children?.forEach((child, index) => visit(child, [...path, index]));
}
if (Array.isArray(mapTree)) {
mapTree.forEach((node, index) => visit(node, [index]));
} else {
visit(mapTree, []);
}
return nodes;
}
export function updateTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
update: (node: HierarchicalMapNode) => HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1] ?? 0;
const isRootTarget = Array.isArray(nextTree)
? path.length === 1
: path.length === 0;
if (isRootTarget) {
const targetNode = rootNodes[targetIndex];
if (targetNode) {
rootNodes[targetIndex] = update(targetNode);
}
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
if (!parent?.children?.[targetIndex]) return nextTree;
parent.children[targetIndex] = update(parent.children[targetIndex]);
return nextTree;
}
export function removeTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1];
if (targetIndex === undefined) return nextTree;
if (Array.isArray(nextTree) && path.length === 1) {
nextTree.splice(targetIndex, 1);
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
parent?.children?.splice(targetIndex, 1);
return nextTree;
}
export function updateSceneDataTree(
sceneData: SceneData,
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): SceneData {
return {
...sceneData,
mapNodes: collectEditableMapNodes(mapTree),
mapTree,
};
}
function findNodePathByName(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
name: string,
): number[] | null {
function visit(node: HierarchicalMapNode, path: number[]): number[] | null {
if (node.name === name) return path;
for (let index = 0; index < (node.children?.length ?? 0); index++) {
const child = node.children?.[index];
if (!child) continue;
const result = visit(child, [...path, index]);
if (result) return result;
}
return null;
}
if (Array.isArray(mapTree)) {
for (let index = 0; index < mapTree.length; index++) {
const node = mapTree[index];
if (!node) continue;
const result = visit(node, [index]);
if (result) return result;
}
return null;
}
return visit(mapTree, []);
}
export function addTreeNode(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
node: HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const blockingPath = findNodePathByName(mapTree, "blocking");
if (!blockingPath) return mapTree;
return updateTreeNodeAtPath(mapTree, blockingPath, (blockingNode) => ({
...blockingNode,
children: [...(blockingNode.children ?? []), node],
}));
}
export function createNewMapNode(name: string): HierarchicalMapNode {
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
return {
name: safeName,
type: "Object3D",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
children: [
{
name: safeName,
type: "Mesh",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ export async function createSceneDataFromFiles(
const models = new Map<string, string>();
for (const [path, file] of fileMap.entries()) {
const modelMatch = path.match(/^\/models\/(.+)\/model\.(glb|gltf)$/);
const modelMatch = path.match(/^\/models\/(.+)\/(?:model|\1)\.(glb|gltf)$/);
const modelName = modelMatch?.[1];
const modelExtension = modelMatch?.[2];
@@ -0,0 +1,17 @@
import { REPAIR_MISSION_POSITION_ENTRIES } from "@/data/gameplay/repairMissionAnchors";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
const FALLBACK_REPAIR_MISSION_POSITIONS = new Map(
REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => [
mission,
position,
]),
);
export function getRepairMissionPosition(
mission: RepairMissionId,
anchors: Partial<Record<RepairMissionId, Vector3Tuple>>,
): Vector3Tuple | undefined {
return anchors[mission] ?? FALLBACK_REPAIR_MISSION_POSITIONS.get(mission);
}
+55
View File
@@ -0,0 +1,55 @@
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import type { Vector3Tuple } from "@/types/three/three";
interface PositionedInstance {
position: Vector3Tuple;
}
export interface WorldInstanceChunk<TInstance extends PositionedInstance> {
centerX: number;
centerZ: number;
chunkKey: string;
instances: TInstance[];
}
function getWorldChunkKey(instance: PositionedInstance): string {
const [x, , z] = instance.position;
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
return `${chunkX}:${chunkZ}`;
}
export function createWorldInstanceChunks<TInstance extends PositionedInstance>(
instances: TInstance[],
): WorldInstanceChunk<TInstance>[] {
const chunks = new Map<string, TInstance[]>();
for (const instance of instances) {
const chunkKey = getWorldChunkKey(instance);
const chunk = chunks.get(chunkKey);
if (chunk) {
chunk.push(instance);
} else {
chunks.set(chunkKey, [instance]);
}
}
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
const center = chunkInstances.reduce(
(sum, instance) => {
sum.x += instance.position[0];
sum.z += instance.position[2];
return sum;
},
{ x: 0, z: 0 },
);
return {
centerX: center.x / chunkInstances.length,
centerZ: center.z / chunkInstances.length,
chunkKey,
instances: chunkInstances,
};
});
}
+2
View File
@@ -1,6 +1,7 @@
import {
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
GAME_SCENE_SKY_FALLBACK_MODEL_PATH,
GAME_SCENE_SKY_FALLBACK_MODEL_SCALE,
GAME_SCENE_SKY_MODEL_PATH,
GAME_SCENE_SKY_MODEL_SCALE,
PHYSICS_SCENE_BACKGROUND_COLOR,
@@ -36,6 +37,7 @@ export function Environment(): React.JSX.Element {
{showSky ? (
<SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
fallbackModelScale={GAME_SCENE_SKY_FALLBACK_MODEL_SCALE}
fallbackModelPath={GAME_SCENE_SKY_FALLBACK_MODEL_PATH}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
+7 -21
View File
@@ -4,25 +4,15 @@ import {
REPAIR_MISSION_POSITION_ENTRIES,
REPAIR_MISSION_TRIGGERS,
} from "@/data/gameplay/repairMissionAnchors";
import {
INTRO_STAGE_ANCHOR,
OUTRO_STAGE_ANCHOR,
} from "@/data/gameplay/gameStageAnchors";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
const FALLBACK_REPAIR_MISSION_POSITIONS = new Map(
REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => [
mission,
position,
]),
);
function getRepairMissionPosition(
mission: RepairMissionId,
anchors: Partial<Record<RepairMissionId, Vector3Tuple>>,
): Vector3Tuple | undefined {
return anchors[mission] ?? FALLBACK_REPAIR_MISSION_POSITIONS.get(mission);
}
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
interface StageAnchorProps {
color: string;
@@ -89,9 +79,7 @@ export function GameStageContent(): React.JSX.Element {
return (
<>
{mainState === "intro" ? (
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
) : null}
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
const position = getRepairMissionPosition(mission, anchors);
if (!position) return null;
@@ -102,9 +90,7 @@ export function GameStageContent(): React.JSX.Element {
{REPAIR_MISSION_TRIGGERS.map((config) => (
<RepairMissionTrigger key={config.mission} config={config} />
))}
{mainState === "outro" ? (
<StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />
) : null}
{mainState === "outro" ? <StageAnchor {...OUTRO_STAGE_ANCHOR} /> : null}
</>
);
}
+4 -4
View File
@@ -7,7 +7,7 @@ import {
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
import { usePersonnageDebug } from "@/hooks/debug/usePersonnageDebug";
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
@@ -29,7 +29,7 @@ import { GameMusic } from "@/world/GameMusic";
import { Lighting } from "@/world/Lighting";
import { GameMap } from "@/world/GameMap";
import { GameStageContent } from "@/world/GameStageContent";
import { PersonnageSystem } from "@/world/personnages/PersonnageSystem";
import { CharacterSystem } from "@/world/characters/CharacterSystem";
import { Player } from "@/world/player/Player";
import { TestMap } from "@/world/debug/TestMap";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -41,7 +41,7 @@ interface WorldProps {
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
useEnvironmentDebug();
useMapPerformanceDebug();
usePersonnageDebug();
useCharacterDebug();
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
@@ -90,7 +90,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={handleOctreeReady}
/>
{showGameStage ? <PersonnageSystem /> : null}
{showGameStage && mainState !== "ebike" ? <CharacterSystem /> : null}
{showGameStage ? (
<Physics>
<GameStageLoaded onLoaded={handleGameStageLoaded} />
@@ -1,16 +1,16 @@
import { Suspense } from "react";
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import {
PERSONNAGE_CONFIGS,
PERSONNAGE_IDS,
type PersonnageId,
} from "@/data/world/personnages/personnageConfig";
CHARACTER_CONFIGS,
CHARACTER_IDS,
type CharacterId,
} from "@/data/world/characters/characterConfig";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
import { usePersonnageDebugStore } from "@/managers/stores/usePersonnageDebugStore";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
function PersonnageModel({ id }: { id: PersonnageId }): React.JSX.Element {
const config = PERSONNAGE_CONFIGS[id];
const state = usePersonnageDebugStore((store) => store.personnages[id]);
function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element {
const config = CHARACTER_CONFIGS[id];
const state = useCharacterDebugStore((store) => store.characters[id]);
const position = useTerrainSnappedPosition(state.position);
return (
@@ -24,12 +24,12 @@ function PersonnageModel({ id }: { id: PersonnageId }): React.JSX.Element {
);
}
export function PersonnageSystem(): React.JSX.Element {
export function CharacterSystem(): React.JSX.Element {
return (
<group name="personnage-system">
{PERSONNAGE_IDS.map((id) => (
<group name="character-system">
{CHARACTER_IDS.map((id) => (
<Suspense key={id} fallback={null}>
<PersonnageModel id={id} />
<CharacterModel id={id} />
</Suspense>
))}
</group>
+53 -58
View File
@@ -24,89 +24,84 @@ function random01(seed: number): number {
return value - Math.floor(value);
}
function pushVector(target: number[], value: THREE.Vector3): void {
target.push(value.x, value.y, value.z);
}
function pushColor(target: number[], value: THREE.Color): void {
target.push(value.r, value.g, value.b);
}
const GRASS_COLOR_VALUES = GRASS_COLORS.map((color) => new THREE.Color(color));
const MARKER_COLOR_VALUES = [0.1, 0, 0, 0, 0, 0.1, 1, 1, 1] as const;
function createGrassGeometry(density: number): THREE.BufferGeometry {
const positions: number[] = [];
const colors: number[] = [];
const uvs: number[] = [];
const bladeOrigins: number[] = [];
const yaws: number[] = [];
const bladeCount = Math.round(GRASS_CONFIG.bladeCount * density);
const vertexCount = bladeCount * 3;
const positions = new Float32Array(vertexCount * 3);
const markerColorValues = new Float32Array(vertexCount * 3);
const bladeColorValues = new Float32Array(vertexCount * 3);
const uvs = new Float32Array(vertexCount * 2);
const bladeOrigins = new Float32Array(vertexCount * 3);
const yaws = new Float32Array(vertexCount * 3);
const halfPatchSize = GRASS_CONFIG.patchSize * 0.5;
for (let index = 0; index < bladeCount; index++) {
const seed = index * 997;
const origin = new THREE.Vector3(
random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize,
0,
random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize,
);
const originX = random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize;
const originY = 0;
const originZ = random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize;
const yawAngle = random01(seed + 3) * Math.PI * 2;
const yaw = new THREE.Vector3(Math.sin(yawAngle), 0, -Math.cos(yawAngle));
const yawX = Math.sin(yawAngle);
const yawY = 0;
const yawZ = -Math.cos(yawAngle);
const colorIndex = Math.floor(random01(seed + 4) * GRASS_COLORS.length);
const color = new THREE.Color(GRASS_COLORS[colorIndex] ?? GRASS_COLORS[0]);
const markerColors = [
new THREE.Color(0.1, 0, 0),
new THREE.Color(0, 0, 0.1),
new THREE.Color(1, 1, 1),
] as const;
const uv = new THREE.Vector2(
origin.x / GRASS_CONFIG.patchSize + 0.5,
origin.z / GRASS_CONFIG.patchSize + 0.5,
);
const color = GRASS_COLOR_VALUES[colorIndex] ?? GRASS_COLOR_VALUES[0];
const uvX = originX / GRASS_CONFIG.patchSize + 0.5;
const uvY = originZ / GRASS_CONFIG.patchSize + 0.5;
for (let vertexIndex = 0; vertexIndex < 3; vertexIndex++) {
pushVector(positions, origin);
pushColor(colors, markerColors[vertexIndex] ?? markerColors[2]);
pushVector(bladeOrigins, origin);
pushVector(yaws, yaw);
pushColor(colors, color);
uvs.push(uv.x, uv.y);
const vertexOffset = index * 3 + vertexIndex;
const vectorOffset = vertexOffset * 3;
const uvOffset = vertexOffset * 2;
const markerOffset = vertexIndex * 3;
positions[vectorOffset] = originX;
positions[vectorOffset + 1] = originY;
positions[vectorOffset + 2] = originZ;
markerColorValues[vectorOffset] = MARKER_COLOR_VALUES[markerOffset] ?? 1;
markerColorValues[vectorOffset + 1] =
MARKER_COLOR_VALUES[markerOffset + 1] ?? 1;
markerColorValues[vectorOffset + 2] =
MARKER_COLOR_VALUES[markerOffset + 2] ?? 1;
bladeColorValues[vectorOffset] = color?.r ?? 0;
bladeColorValues[vectorOffset + 1] = color?.g ?? 0;
bladeColorValues[vectorOffset + 2] = color?.b ?? 0;
bladeOrigins[vectorOffset] = originX;
bladeOrigins[vectorOffset + 1] = originY;
bladeOrigins[vectorOffset + 2] = originZ;
yaws[vectorOffset] = yawX;
yaws[vectorOffset + 1] = yawY;
yaws[vectorOffset + 2] = yawZ;
uvs[uvOffset] = uvX;
uvs[uvOffset + 1] = uvY;
}
}
const geometry = new THREE.BufferGeometry();
const markerColorValues: number[] = [];
const bladeColorValues: number[] = [];
for (let index = 0; index < colors.length; index += 6) {
markerColorValues.push(
colors[index] ?? 0,
colors[index + 1] ?? 0,
colors[index + 2] ?? 0,
);
bladeColorValues.push(
colors[index + 3] ?? 0,
colors[index + 4] ?? 0,
colors[index + 5] ?? 0,
);
}
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(markerColorValues, 3),
new THREE.BufferAttribute(markerColorValues, 3),
);
geometry.setAttribute(
"aBladeColor",
new THREE.Float32BufferAttribute(bladeColorValues, 3),
new THREE.BufferAttribute(bladeColorValues, 3),
);
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
geometry.setAttribute(
"aBladeOrigin",
new THREE.Float32BufferAttribute(bladeOrigins, 3),
new THREE.BufferAttribute(bladeOrigins, 3),
);
geometry.setAttribute("aYaw", new THREE.Float32BufferAttribute(yaws, 3));
geometry.setAttribute("aYaw", new THREE.BufferAttribute(yaws, 3));
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
+9 -8
View File
@@ -65,6 +65,10 @@ function createTerrainGrassSampler(
const terrainMatrix = createTerrainMatrix(position, rotation, scale);
const inverseTerrainMatrix = terrainMatrix.clone().invert();
const normalMatrix = new THREE.Matrix3().getNormalMatrix(terrainMatrix);
const localOrigin = new THREE.Vector3();
const localDirection = DOWN.clone().transformDirection(inverseTerrainMatrix);
const fallbackNormal = new THREE.Vector3(0, 1, 0);
const hits: THREE.Intersection[] = [];
const raycaster = new THREE.Raycaster(
new THREE.Vector3(),
DOWN,
@@ -94,14 +98,11 @@ function createTerrainGrassSampler(
};
const sample = (x: number, z: number): TerrainGrassSample | null => {
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
inverseTerrainMatrix,
);
const localDirection =
DOWN.clone().transformDirection(inverseTerrainMatrix);
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
raycaster.set(localOrigin, localDirection);
const hit = raycaster.intersectObjects(meshes, false)[0];
hits.length = 0;
raycaster.intersectObjects(meshes, false, hits);
const hit = hits[0];
if (!hit) return null;
const normal = hit.face?.normal
@@ -112,7 +113,7 @@ function createTerrainGrassSampler(
return {
position: hit.point.clone().applyMatrix4(terrainMatrix),
normal: normal ?? new THREE.Vector3(0, 1, 0),
normal: normal ?? fallbackNormal.clone(),
};
};
@@ -1,7 +1,7 @@
import { EcoleModel } from "@/components/three/world/EcoleModel";
import { FermeVerticaleModel } from "@/components/three/world/FermeVerticaleModel";
import { GenerateurModel } from "@/components/three/world/GenerateurModel";
import { LafabrikModel } from "@/components/three/world/LafabrikModel";
import { LaFabrikMapModel } from "@/components/three/world/LaFabrikMapModel";
import {
normalizeMapScale,
useTerrainSnappedPosition,
@@ -55,7 +55,7 @@ export function GeneratedMapNodeInstance({
if (node.name === "lafabrik") {
return (
<LafabrikModel
<LaFabrikMapModel
position={position}
rotation={node.rotation}
scale={scale}
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import { mergeBufferGeometries } from "three-stdlib";
import {
normalizeMapScale,
useTerrainHeightSampler,
@@ -112,7 +112,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
};
}
const mergedGeometry = mergeGeometries(group.geometries, false);
const mergedGeometry = mergeBufferGeometries(group.geometries, false);
for (const geometry of group.geometries) {
geometry.dispose();
@@ -16,9 +16,10 @@ import {
} from "@/data/world/mapInstancingConfig";
import { useMapInstancingData } from "@/hooks/world/useMapInstancingData";
import type { MapAssetInstance } from "@/types/map/mapScene";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
interface MapInstancingSystemProps {
onlyModelName?: string | null;
onlyMapName?: string | null;
streaming?: boolean;
}
@@ -30,53 +31,24 @@ interface MapAssetChunk {
instances: MapAssetInstance[];
}
function getChunkKey(instance: MapAssetInstance): string {
const [x, , z] = instance.position;
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
return `${chunkX}:${chunkZ}`;
}
function createMapAssetChunks(
type: MapInstancingAssetType,
config: MapInstancingAssetConfig,
instances: MapAssetInstance[],
): MapAssetChunk[] {
const chunks = new Map<string, MapAssetInstance[]>();
for (const instance of instances) {
const key = getChunkKey(instance);
const chunk = chunks.get(key);
if (chunk) {
chunk.push(instance);
} else {
chunks.set(key, [instance]);
}
}
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
const center = chunkInstances.reduce(
(sum, instance) => {
sum.x += instance.position[0];
sum.z += instance.position[2];
return sum;
},
{ x: 0, z: 0 },
);
return createWorldInstanceChunks(instances).map((chunk) => {
return {
key: `${type}:${chunkKey}`,
key: `${type}:${chunk.chunkKey}`,
config,
centerX: center.x / chunkInstances.length,
centerZ: center.z / chunkInstances.length,
instances: chunkInstances,
centerX: chunk.centerX,
centerZ: chunk.centerZ,
instances: chunk.instances,
};
});
}
export function MapInstancingSystem({
onlyModelName = null,
onlyMapName = null,
streaming = true,
}: MapInstancingSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode();
@@ -96,7 +68,7 @@ export function MapInstancingSystem({
return MAP_INSTANCING_ASSET_TYPES.flatMap((type) => {
const config = MAP_INSTANCING_ASSETS[type];
if (onlyModelName && config.mapName !== onlyModelName) return [];
if (onlyMapName && config.mapName !== onlyMapName) return [];
if (
!config.enabled ||
@@ -110,7 +82,7 @@ export function MapInstancingSystem({
return createMapAssetChunks(type, config, instances);
});
}, [data, groups, models, onlyModelName]);
}, [data, groups, models, onlyMapName]);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
+1 -1
View File
@@ -1,6 +1,6 @@
import { useLayoutEffect } from "react";
import { useThree } from "@react-three/fiber";
import type { Octree } from "three/addons/math/Octree.js";
import type { Octree } from "three-stdlib";
import type { Vector3Tuple } from "@/types/three/three";
import { PlayerCamera } from "@/world/player/PlayerCamera";
import { PlayerController } from "@/world/player/PlayerController";
+1 -2
View File
@@ -1,8 +1,7 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { Capsule } from "three/addons/math/Capsule.js";
import type { Octree } from "three/addons/math/Octree.js";
import { Capsule, type Octree } from "three-stdlib";
import {
INTERACT_KEY,
JUMP_KEY,
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import { mergeBufferGeometries } from "three-stdlib";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import type { VegetationInstance } from "@/types/map/mapScene";
import { useWind } from "@/hooks/world/useWind";
@@ -161,7 +161,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
return [...meshesByMaterial.values()]
.map(({ geometries, material }) => {
const mergedGeometry = mergeGeometries(geometries, false);
const mergedGeometry = mergeBufferGeometries(geometries, false);
for (const geometry of geometries) {
if (geometry !== mergedGeometry) {
+10 -36
View File
@@ -15,9 +15,10 @@ import {
VEGETATION_TYPES,
type VegetationType,
} from "@/data/world/vegetationConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
interface VegetationSystemProps {
onlyModelName?: string | null;
onlyMapName?: string | null;
streaming?: boolean;
}
@@ -35,42 +36,15 @@ interface VegetationChunk {
instances: VegetationInstance[];
}
function getChunkKey(instance: VegetationInstance): string {
const [x, , z] = instance.position;
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
return `${chunkX}:${chunkZ}`;
}
function createVegetationChunks(
type: VegetationType,
instances: VegetationInstance[],
): VegetationChunk[] {
const config = VEGETATION_TYPES[type];
const chunks = new Map<string, VegetationInstance[]>();
for (const instance of instances) {
const key = getChunkKey(instance);
const chunk = chunks.get(key);
if (chunk) {
chunk.push(instance);
} else {
chunks.set(key, [instance]);
}
}
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
const center = chunkInstances.reduce(
(sum, instance) => {
sum.x += instance.position[0];
sum.z += instance.position[2];
return sum;
},
{ x: 0, z: 0 },
);
return createWorldInstanceChunks(instances).map((chunk) => {
return {
key: `${type}:${chunkKey}`,
key: `${type}:${chunk.chunkKey}`,
type,
modelPath: config.modelPath,
scaleMultiplier: config.scaleMultiplier,
@@ -78,15 +52,15 @@ function createVegetationChunks(
receiveShadow: config.receiveShadow,
windStrength: config.windStrength,
rotationOffset: config.rotationOffset,
centerX: center.x / chunkInstances.length,
centerZ: center.z / chunkInstances.length,
instances: chunkInstances,
centerX: chunk.centerX,
centerZ: chunk.centerZ,
instances: chunk.instances,
};
});
}
export function VegetationSystem({
onlyModelName = null,
onlyMapName = null,
streaming = true,
}: VegetationSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode();
@@ -106,7 +80,7 @@ export function VegetationSystem({
return VEGETATION_TYPE_KEYS.flatMap((type) => {
const config = VEGETATION_TYPES[type];
if (onlyModelName && config.mapName !== onlyModelName) return [];
if (onlyMapName && config.mapName !== onlyMapName) return [];
if (!config.enabled) return [];
if (!isMapModelVisible(config.mapName, { groups, models })) return [];
@@ -116,7 +90,7 @@ export function VegetationSystem({
return createVegetationChunks(type, entry.instances);
});
}, [data, groups, models, onlyModelName]);
}, [data, groups, models, onlyMapName]);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
+1 -1
View File
@@ -13,7 +13,7 @@ const THREE_SOURCE_ENTRY = fileURLToPath(
new URL("./node_modules/three/src/Three.js", import.meta.url),
);
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
const MAX_MAP_PAYLOAD_BYTES = 4 * 1024 * 1024;
const MAX_SRT_PAYLOAD_BYTES = 256 * 1024;
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
const MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES = 256 * 1024;