fix(review): address audit findings before merge
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
This commit is contained in:
@@ -80,8 +80,8 @@ jobs:
|
|||||||
|
|
||||||
- name: 📏 Check bundle size
|
- name: 📏 Check bundle size
|
||||||
run: |
|
run: |
|
||||||
# Check generated app assets only; public/ model files are runtime assets copied to dist.
|
# Check generated JS/CSS bundles only; public runtime assets are copied to dist/assets too.
|
||||||
SIZE=$(du -k dist/assets | cut -f1)
|
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"
|
echo "Bundle size: ${SIZE}KB"
|
||||||
|
|
||||||
THRESHOLD=5000
|
THRESHOLD=5000
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ Phrase à retenir :
|
|||||||
|
|
||||||
Piège à connaître :
|
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
|
### Interaction
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
|
|||||||
2. `useEditorSceneData` calls `loadMapSceneData()`.
|
2. `useEditorSceneData` calls `loadMapSceneData()`.
|
||||||
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
|
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
|
||||||
4. If `/map.json` is missing, the page displays a folder-upload flow.
|
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`.
|
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.
|
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
|
## 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()`
|
- 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
|
## Panel Groups
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,5 @@
|
|||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Unlock,
|
Unlock,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
||||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
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({
|
export function EditorControls({
|
||||||
transformMode,
|
transformMode,
|
||||||
onTransformModeChange,
|
onTransformModeChange,
|
||||||
@@ -303,20 +350,13 @@ export function EditorControls({
|
|||||||
{selectedNodeScale ? (
|
{selectedNodeScale ? (
|
||||||
<div className="editor-scale-fields">
|
<div className="editor-scale-fields">
|
||||||
{selectedNodeScale.map((value, axis) => (
|
{selectedNodeScale.map((value, axis) => (
|
||||||
<label key={axis}>
|
<EditorScaleField
|
||||||
<span>{["X", "Y", "Z"][axis]}</span>
|
key={`${axis}:${value}`}
|
||||||
<input
|
axis={axis as 0 | 1 | 2}
|
||||||
type="number"
|
label={["X", "Y", "Z"][axis] ?? "?"}
|
||||||
step="0.01"
|
value={value}
|
||||||
value={Number(value.toFixed(4))}
|
onCommit={onSelectedScaleChange}
|
||||||
onChange={(event) =>
|
/>
|
||||||
onSelectedScaleChange(
|
|
||||||
axis as 0 | 1 | 2,
|
|
||||||
Number(event.target.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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_POSITION = new THREE.Vector3(0, 50, 100);
|
||||||
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
|
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 {
|
export interface EditorCinematicPreviewRequest {
|
||||||
id: string;
|
id: string;
|
||||||
cinematic: CinematicDefinition;
|
cinematic: CinematicDefinition;
|
||||||
@@ -148,6 +159,8 @@ export function EditorScene({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (isEditableShortcutTarget(e.target)) return;
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
if (e.key === "z" || e.key === "Z") {
|
if (e.key === "z" || e.key === "Z") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Component, useEffect, useMemo, useRef } from "react";
|
|||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import * as THREE from "three";
|
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 { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||||
import {
|
import {
|
||||||
useHandTrackingGloveStatus,
|
useHandTrackingGloveStatus,
|
||||||
@@ -255,7 +255,7 @@ function HandTrackingGloveModel({
|
|||||||
throw new Error(`Missing glove root node ${config.rootNodeName}`);
|
throw new Error(`Missing glove root node ${config.rootNodeName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedRootNode = clone(rootNode);
|
const clonedRootNode = SkeletonUtils.clone(rootNode);
|
||||||
clonedRootNode.visible = false;
|
clonedRootNode.visible = false;
|
||||||
|
|
||||||
return clonedRootNode;
|
return clonedRootNode;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function SimpleModel({
|
|||||||
rotation,
|
rotation,
|
||||||
scale,
|
scale,
|
||||||
});
|
});
|
||||||
const model = useClonedObject(scene);
|
const model = useClonedObject(scene, { cloneResources: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyShadowSettings(model, castShadow, receiveShadow);
|
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 { useGLTF } from "@react-three/drei";
|
||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
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 type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
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) {
|
for (const sourceGeometry of group.geometries) {
|
||||||
sourceGeometry.dispose();
|
sourceGeometry.dispose();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
fallbackModelScale?: number | undefined;
|
||||||
fallbackModelPath?: string | undefined;
|
fallbackModelPath?: string | undefined;
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
fallbackColor?: string | undefined;
|
fallbackColor?: string | undefined;
|
||||||
@@ -53,6 +54,7 @@ class SkyModelErrorBoundary extends Component<
|
|||||||
|
|
||||||
export function SkyModel({
|
export function SkyModel({
|
||||||
fallbackColor,
|
fallbackColor,
|
||||||
|
fallbackModelScale = SKY_MODEL_SCALE,
|
||||||
fallbackModelPath,
|
fallbackModelPath,
|
||||||
modelPath,
|
modelPath,
|
||||||
scale = SKY_MODEL_SCALE,
|
scale = SKY_MODEL_SCALE,
|
||||||
@@ -62,7 +64,10 @@ export function SkyModel({
|
|||||||
) : null;
|
) : null;
|
||||||
const fallback = fallbackModelPath ? (
|
const fallback = fallbackModelPath ? (
|
||||||
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
|
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
|
||||||
<SkyModelContent modelPath={fallbackModelPath} scale={scale} />
|
<SkyModelContent
|
||||||
|
modelPath={fallbackModelPath}
|
||||||
|
scale={fallbackModelScale}
|
||||||
|
/>
|
||||||
</SkyModelErrorBoundary>
|
</SkyModelErrorBoundary>
|
||||||
) : (
|
) : (
|
||||||
colorFallback
|
colorFallback
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+7
-7
@@ -1,9 +1,9 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
export type PersonnageId = "electricienne" | "gerant" | "fermier";
|
export type CharacterId = "electricienne" | "gerant" | "fermier";
|
||||||
|
|
||||||
export interface PersonnageConfig {
|
export interface CharacterConfig {
|
||||||
id: PersonnageId;
|
id: CharacterId;
|
||||||
label: string;
|
label: string;
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
@@ -13,7 +13,7 @@ export interface PersonnageConfig {
|
|||||||
defaultAnimation: string;
|
defaultAnimation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PERSONNAGE_CONFIGS = {
|
export const CHARACTER_CONFIGS = {
|
||||||
electricienne: {
|
electricienne: {
|
||||||
id: "electricienne",
|
id: "electricienne",
|
||||||
label: "Electricienne",
|
label: "Electricienne",
|
||||||
@@ -44,10 +44,10 @@ export const PERSONNAGE_CONFIGS = {
|
|||||||
animations: ["idle", "walk"],
|
animations: ["idle", "walk"],
|
||||||
defaultAnimation: "idle",
|
defaultAnimation: "idle",
|
||||||
},
|
},
|
||||||
} satisfies Record<PersonnageId, PersonnageConfig>;
|
} satisfies Record<CharacterId, CharacterConfig>;
|
||||||
|
|
||||||
export const PERSONNAGE_IDS = [
|
export const CHARACTER_IDS = [
|
||||||
"electricienne",
|
"electricienne",
|
||||||
"gerant",
|
"gerant",
|
||||||
"fermier",
|
"fermier",
|
||||||
] as const satisfies readonly PersonnageId[];
|
] as const satisfies readonly CharacterId[];
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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_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_SKY_FALLBACK_MODEL_SCALE = 1;
|
||||||
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";
|
||||||
|
|||||||
@@ -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",
|
"Scene",
|
||||||
"blocking",
|
"blocking",
|
||||||
"terrain",
|
"terrain",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
import {
|
import {
|
||||||
PERSONNAGE_CONFIGS,
|
CHARACTER_CONFIGS,
|
||||||
PERSONNAGE_IDS,
|
CHARACTER_IDS,
|
||||||
} from "@/data/world/personnages/personnageConfig";
|
} from "@/data/world/characters/characterConfig";
|
||||||
import { usePersonnageDebugStore } from "@/managers/stores/usePersonnageDebugStore";
|
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
|
||||||
|
|
||||||
function createAnimationOptions(
|
function createAnimationOptions(
|
||||||
animations: readonly string[],
|
animations: readonly string[],
|
||||||
@@ -17,13 +17,13 @@ function createAnimationOptions(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePersonnageDebug(): void {
|
export function useCharacterDebug(): void {
|
||||||
useDebugFolder("Personnages", (folder) => {
|
useDebugFolder("Personnages", (folder) => {
|
||||||
const store = usePersonnageDebugStore.getState();
|
const store = useCharacterDebugStore.getState();
|
||||||
|
|
||||||
for (const id of PERSONNAGE_IDS) {
|
for (const id of CHARACTER_IDS) {
|
||||||
const config = PERSONNAGE_CONFIGS[id];
|
const config = CHARACTER_CONFIGS[id];
|
||||||
const state = store.personnages[id];
|
const state = store.characters[id];
|
||||||
const characterFolder = folder.addFolder(config.label);
|
const characterFolder = folder.addFolder(config.label);
|
||||||
const controls = {
|
const controls = {
|
||||||
animation: state.animation,
|
animation: state.animation,
|
||||||
@@ -42,64 +42,64 @@ export function usePersonnageDebug(): void {
|
|||||||
.add(controls, "animation", createAnimationOptions(config.animations))
|
.add(controls, "animation", createAnimationOptions(config.animations))
|
||||||
.name("Animation")
|
.name("Animation")
|
||||||
.onChange((animation: string) => {
|
.onChange((animation: string) => {
|
||||||
usePersonnageDebugStore.getState().setAnimation(id, animation);
|
useCharacterDebugStore.getState().setAnimation(id, animation);
|
||||||
});
|
});
|
||||||
|
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "positionX", -120, 120, 0.1)
|
.add(controls, "positionX", -120, 120, 0.1)
|
||||||
.name("Position X")
|
.name("Position X")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setPosition(id, 0, value);
|
useCharacterDebugStore.getState().setPosition(id, 0, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "positionY", -20, 40, 0.1)
|
.add(controls, "positionY", -20, 40, 0.1)
|
||||||
.name("Position Y")
|
.name("Position Y")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setPosition(id, 1, value);
|
useCharacterDebugStore.getState().setPosition(id, 1, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "positionZ", -120, 120, 0.1)
|
.add(controls, "positionZ", -120, 120, 0.1)
|
||||||
.name("Position Z")
|
.name("Position Z")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setPosition(id, 2, value);
|
useCharacterDebugStore.getState().setPosition(id, 2, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "rotationX", -Math.PI, Math.PI, 0.01)
|
.add(controls, "rotationX", -Math.PI, Math.PI, 0.01)
|
||||||
.name("Rotation X")
|
.name("Rotation X")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setRotation(id, 0, value);
|
useCharacterDebugStore.getState().setRotation(id, 0, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "rotationY", -Math.PI, Math.PI, 0.01)
|
.add(controls, "rotationY", -Math.PI, Math.PI, 0.01)
|
||||||
.name("Rotation Y")
|
.name("Rotation Y")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setRotation(id, 1, value);
|
useCharacterDebugStore.getState().setRotation(id, 1, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "rotationZ", -Math.PI, Math.PI, 0.01)
|
.add(controls, "rotationZ", -Math.PI, Math.PI, 0.01)
|
||||||
.name("Rotation Z")
|
.name("Rotation Z")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setRotation(id, 2, value);
|
useCharacterDebugStore.getState().setRotation(id, 2, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "scaleX", 0.1, 5, 0.05)
|
.add(controls, "scaleX", 0.1, 5, 0.05)
|
||||||
.name("Scale X")
|
.name("Scale X")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setScale(id, 0, value);
|
useCharacterDebugStore.getState().setScale(id, 0, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "scaleY", 0.1, 5, 0.05)
|
.add(controls, "scaleY", 0.1, 5, 0.05)
|
||||||
.name("Scale Y")
|
.name("Scale Y")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setScale(id, 1, value);
|
useCharacterDebugStore.getState().setScale(id, 1, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "scaleZ", 0.1, 5, 0.05)
|
.add(controls, "scaleZ", 0.1, 5, 0.05)
|
||||||
.name("Scale Z")
|
.name("Scale Z")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setScale(id, 2, value);
|
useCharacterDebugStore.getState().setScale(id, 2, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
characterFolder.close();
|
characterFolder.close();
|
||||||
@@ -2,29 +2,53 @@ import { useEffect, useMemo } from "react";
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { disposeObject3D } from "@/utils/three/dispose";
|
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;
|
const clone = object.clone(true) as T;
|
||||||
|
|
||||||
|
if (!cloneResources) return clone;
|
||||||
|
|
||||||
clone.traverse((child) => {
|
clone.traverse((child) => {
|
||||||
if (!(child instanceof THREE.Mesh)) return;
|
if (!(child instanceof THREE.Mesh)) return;
|
||||||
|
|
||||||
child.geometry = child.geometry.clone();
|
child.geometry = child.geometry.clone();
|
||||||
child.material = Array.isArray(child.material)
|
child.material = cloneMaterial(child.material);
|
||||||
? child.material.map((material) => material.clone())
|
|
||||||
: child.material.clone();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
|
export function useClonedObject<T extends THREE.Object3D>(
|
||||||
const clone = useMemo(() => cloneObjectWithOwnedResources(object), [object]);
|
object: T,
|
||||||
|
options: UseClonedObjectOptions = {},
|
||||||
|
): T {
|
||||||
|
const cloneResources = options.cloneResources ?? false;
|
||||||
|
const clone = useMemo(
|
||||||
|
() => cloneObject(object, cloneResources),
|
||||||
|
[cloneResources, object],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!cloneResources) return undefined;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposeObject3D(clone);
|
disposeObject3D(clone);
|
||||||
};
|
};
|
||||||
}, [clone]);
|
}, [clone, cloneResources]);
|
||||||
|
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import type { Object3D } from "three";
|
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";
|
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||||
|
|
||||||
export function useOctreeGraphNode(
|
export function useOctreeGraphNode(
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ function createTerrainHeightSampler(
|
|||||||
new THREE.Vector3(...scale),
|
new THREE.Vector3(...scale),
|
||||||
);
|
);
|
||||||
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
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(
|
const raycaster = new THREE.Raycaster(
|
||||||
new THREE.Vector3(),
|
new THREE.Vector3(),
|
||||||
DOWN,
|
DOWN,
|
||||||
@@ -63,13 +66,11 @@ function createTerrainHeightSampler(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getHeight: (x, z) => {
|
getHeight: (x, z) => {
|
||||||
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
|
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
|
||||||
inverseTerrainMatrix,
|
|
||||||
);
|
|
||||||
const localDirection =
|
|
||||||
DOWN.clone().transformDirection(inverseTerrainMatrix);
|
|
||||||
raycaster.set(localOrigin, localDirection);
|
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;
|
return hit?.point.applyMatrix4(terrainMatrix).y ?? null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 type { MapNode, VegetationInstance } from "@/types/map/mapScene";
|
||||||
import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform";
|
import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||||
import {
|
|
||||||
createPotagerMapNode,
|
|
||||||
isPotagerSourceMapNode,
|
|
||||||
POTAGER_MAP_NAME,
|
|
||||||
} from "@/utils/map/potagerMapNodes";
|
|
||||||
|
|
||||||
interface InstancedMapEntry {
|
interface InstancedMapEntry {
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
@@ -17,10 +12,6 @@ interface InstancedMapEntry {
|
|||||||
|
|
||||||
export type VegetationData = Map<string, InstancedMapEntry>;
|
export type VegetationData = Map<string, InstancedMapEntry>;
|
||||||
|
|
||||||
function createPositionKey(node: MapNode): string {
|
|
||||||
return node.position.map((value) => value.toFixed(3)).join(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractVegetationData(
|
function extractVegetationData(
|
||||||
mapNodes: MapNode[],
|
mapNodes: MapNode[],
|
||||||
models: Map<string, string>,
|
models: Map<string, string>,
|
||||||
@@ -48,7 +39,7 @@ function extractVegetationData(
|
|||||||
|
|
||||||
for (const node of mapNodes) {
|
for (const node of mapNodes) {
|
||||||
if (node.type !== "Object3D") continue;
|
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);
|
const modelPath = models.get(node.name);
|
||||||
if (!modelPath) continue;
|
if (!modelPath) continue;
|
||||||
@@ -56,35 +47,6 @@ function extractVegetationData(
|
|||||||
addInstance(node.name, modelPath, node);
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
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 { SceneMode } from "@/types/debug/debug";
|
||||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
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
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
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";
|
||||||
@@ -8,268 +8,48 @@ import { Subtitles } from "@/components/ui/Subtitles";
|
|||||||
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";
|
||||||
import type {
|
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
||||||
HierarchicalMapNode,
|
|
||||||
MapNode,
|
|
||||||
SceneData,
|
|
||||||
TransformMode,
|
|
||||||
} from "@/types/editor/editor";
|
|
||||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||||
import { logger } from "@/utils/core/Logger";
|
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 SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
|
||||||
const DEFAULT_NEW_NODE_NAME = "new-model";
|
const DEFAULT_NEW_NODE_NAME = "new-model";
|
||||||
|
|
||||||
function serializeMapNodes(sceneData: SceneData): string {
|
function EditorWebGLContextLogger(): null {
|
||||||
const mapPayload = sceneData.mapTree
|
const gl = useThree((state) => state.gl);
|
||||||
? mergeFlatNodeTransformsIntoTree(sceneData)
|
|
||||||
: sceneData.mapNodes.map(removeEditorMetadata);
|
|
||||||
|
|
||||||
return JSON.stringify(mapPayload, null, 2);
|
useEffect(() => {
|
||||||
}
|
gl.setClearColor("#050505");
|
||||||
|
|
||||||
function createSourcePathKey(sourcePath: readonly number[]): string {
|
const canvas = gl.domElement;
|
||||||
return sourcePath.join(".");
|
const handleContextLost = (event: Event) => {
|
||||||
}
|
event.preventDefault();
|
||||||
|
logger.error("WebGL", "Context lost - GPU resources exhausted");
|
||||||
function removeEditorMetadata(node: MapNode): MapNode {
|
};
|
||||||
return {
|
const handleContextRestored = () => {
|
||||||
...(node.id ? { id: node.id } : {}),
|
logger.info("WebGL", "Context restored");
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (node.role) {
|
canvas.addEventListener("webglcontextlost", handleContextLost);
|
||||||
nextNode.role = node.role;
|
canvas.addEventListener("webglcontextrestored", handleContextRestored);
|
||||||
}
|
|
||||||
|
|
||||||
if (node.children) {
|
return () => {
|
||||||
nextNode.children = node.children.map((child, index) =>
|
canvas.removeEventListener("webglcontextlost", handleContextLost);
|
||||||
cloneNode(child, [...path, index]),
|
canvas.removeEventListener("webglcontextrestored", handleContextRestored);
|
||||||
);
|
};
|
||||||
}
|
}, [gl]);
|
||||||
|
|
||||||
return nextNode;
|
return null;
|
||||||
};
|
|
||||||
|
|
||||||
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],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorPage(): React.JSX.Element {
|
export function EditorPage(): React.JSX.Element {
|
||||||
@@ -336,13 +116,14 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
setResetCameraRequest((request) => request + 1);
|
setResetCameraRequest((request) => request + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleNodeSelection = useCallback((index: number) => {
|
const handleToggleNodeSelection = useCallback(
|
||||||
setSelectedNodeIndexes((currentIndexes) => {
|
(index: number) => {
|
||||||
const isSelected = currentIndexes.includes(index);
|
const isSelected = selectedNodeIndexes.includes(index);
|
||||||
const nextIndexes = isSelected
|
const nextIndexes = isSelected
|
||||||
? currentIndexes.filter((item) => item !== index)
|
? selectedNodeIndexes.filter((item) => item !== index)
|
||||||
: [...currentIndexes, index];
|
: [...selectedNodeIndexes, index];
|
||||||
|
|
||||||
|
setSelectedNodeIndexes(nextIndexes);
|
||||||
setSelectedNodeIndex(nextIndexes.at(-1) ?? null);
|
setSelectedNodeIndex(nextIndexes.at(-1) ?? null);
|
||||||
if (nextIndexes.length > 0) {
|
if (nextIndexes.length > 0) {
|
||||||
setCameraViewMode("object");
|
setCameraViewMode("object");
|
||||||
@@ -350,10 +131,9 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
setCameraViewMode("home");
|
setCameraViewMode("home");
|
||||||
setResetCameraRequest((request) => request + 1);
|
setResetCameraRequest((request) => request + 1);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
return nextIndexes;
|
[selectedNodeIndexes],
|
||||||
});
|
);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClearSelection = useCallback(() => {
|
const handleClearSelection = useCallback(() => {
|
||||||
setSelectedNodeIndex(null);
|
setSelectedNodeIndex(null);
|
||||||
@@ -399,28 +179,20 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
|
|
||||||
if (!locked) return;
|
if (!locked) return;
|
||||||
|
|
||||||
setSelectedNodeIndex((currentIndex) => {
|
const nextIndexes = selectedNodeIndexes.filter(
|
||||||
if (currentIndex === null) return null;
|
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
|
||||||
|
);
|
||||||
|
const selectedNode =
|
||||||
|
selectedNodeIndex !== null
|
||||||
|
? sceneData?.mapNodes[selectedNodeIndex]
|
||||||
|
: null;
|
||||||
|
|
||||||
const selectedNode = sceneData?.mapNodes[currentIndex];
|
setSelectedNodeIndexes(nextIndexes);
|
||||||
if (selectedNode?.name === "terrain") {
|
setSelectedNodeIndex(
|
||||||
setSelectedNodeIndexes((indexes) =>
|
selectedNode?.name === "terrain" ? null : selectedNodeIndex,
|
||||||
indexes.filter(
|
);
|
||||||
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedNodeIndexes((indexes) =>
|
|
||||||
indexes.filter(
|
|
||||||
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return currentIndex;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[sceneData],
|
[sceneData, selectedNodeIndex, selectedNodeIndexes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHoverNode = useCallback((index: number | null) => {
|
const handleHoverNode = useCallback((index: number | null) => {
|
||||||
@@ -554,51 +326,55 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleAddNode = useCallback(() => {
|
const handleAddNode = useCallback(() => {
|
||||||
setSceneData((prev) => {
|
if (!sceneData) return;
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
|
if (!sceneData.mapTree) {
|
||||||
const nextSceneData = updateSceneDataTree(prev, mapTree);
|
const newNode = createNewMapNode(newNodeName);
|
||||||
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
|
const mapNodes = [...sceneData.mapNodes, removeEditorMetadata(newNode)];
|
||||||
setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]);
|
const selectedIndex = mapNodes.length - 1;
|
||||||
return nextSceneData;
|
|
||||||
});
|
setSceneData({ ...sceneData, mapNodes });
|
||||||
}, [newNodeName, setSceneData]);
|
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(() => {
|
const handleDeleteSelectedNode = useCallback(() => {
|
||||||
if (selectedNodeIndex === null) return;
|
if (!sceneData || selectedNodeIndex === null) return;
|
||||||
|
|
||||||
setSceneData((prev) => {
|
const currentNode = sceneData.mapNodes[selectedNodeIndex];
|
||||||
if (!prev) return null;
|
if (!currentNode) return;
|
||||||
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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!sceneData.mapTree || !currentNode.sourcePath) {
|
||||||
|
setSceneData({
|
||||||
|
...sceneData,
|
||||||
|
mapNodes: sceneData.mapNodes.filter(
|
||||||
|
(_node, index) => index !== selectedNodeIndex,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
const mapTree = removeTreeNodeAtPath(
|
const mapTree = removeTreeNodeAtPath(
|
||||||
prev.mapTree,
|
sceneData.mapTree,
|
||||||
currentNode.sourcePath,
|
currentNode.sourcePath,
|
||||||
);
|
);
|
||||||
setSelectedNodeIndex(null);
|
setSceneData(updateSceneDataTree(sceneData, mapTree));
|
||||||
setSelectedNodeIndexes([]);
|
}
|
||||||
return updateSceneDataTree(prev, mapTree);
|
|
||||||
});
|
setSelectedNodeIndex(null);
|
||||||
}, [selectedNodeIndex, setSceneData]);
|
setSelectedNodeIndexes([]);
|
||||||
|
}, [sceneData, selectedNodeIndex, setSceneData]);
|
||||||
|
|
||||||
if (isMapLoading) {
|
if (isMapLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -655,24 +431,8 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
antialias: true,
|
antialias: true,
|
||||||
stencil: false,
|
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
|
<EditorScene
|
||||||
sceneData={sceneData!}
|
sceneData={sceneData!}
|
||||||
selectedNodeIndex={selectedNodeIndex}
|
selectedNodeIndex={selectedNodeIndex}
|
||||||
|
|||||||
Vendored
-42
@@ -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,4 +1,4 @@
|
|||||||
import type { Octree } from "three/addons/math/Octree.js";
|
import type { Octree } from "three-stdlib";
|
||||||
|
|
||||||
export type Vector3Tuple = [number, number, number];
|
export type Vector3Tuple = [number, number, number];
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export async function createSceneDataFromFiles(
|
|||||||
const models = new Map<string, string>();
|
const models = new Map<string, string>();
|
||||||
|
|
||||||
for (const [path, file] of fileMap.entries()) {
|
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 modelName = modelMatch?.[1];
|
||||||
const modelExtension = modelMatch?.[2];
|
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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
|
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
|
||||||
GAME_SCENE_SKY_FALLBACK_MODEL_PATH,
|
GAME_SCENE_SKY_FALLBACK_MODEL_PATH,
|
||||||
|
GAME_SCENE_SKY_FALLBACK_MODEL_SCALE,
|
||||||
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,
|
||||||
@@ -36,6 +37,7 @@ export function Environment(): React.JSX.Element {
|
|||||||
{showSky ? (
|
{showSky ? (
|
||||||
<SkyModel
|
<SkyModel
|
||||||
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
||||||
|
fallbackModelScale={GAME_SCENE_SKY_FALLBACK_MODEL_SCALE}
|
||||||
fallbackModelPath={GAME_SCENE_SKY_FALLBACK_MODEL_PATH}
|
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}
|
||||||
|
|||||||
@@ -4,25 +4,15 @@ import {
|
|||||||
REPAIR_MISSION_POSITION_ENTRIES,
|
REPAIR_MISSION_POSITION_ENTRIES,
|
||||||
REPAIR_MISSION_TRIGGERS,
|
REPAIR_MISSION_TRIGGERS,
|
||||||
} from "@/data/gameplay/repairMissionAnchors";
|
} from "@/data/gameplay/repairMissionAnchors";
|
||||||
|
import {
|
||||||
|
INTRO_STAGE_ANCHOR,
|
||||||
|
OUTRO_STAGE_ANCHOR,
|
||||||
|
} from "@/data/gameplay/gameStageAnchors";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
|
||||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StageAnchorProps {
|
interface StageAnchorProps {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -89,9 +79,7 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mainState === "intro" ? (
|
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||||
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
|
|
||||||
) : null}
|
|
||||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||||
const position = getRepairMissionPosition(mission, anchors);
|
const position = getRepairMissionPosition(mission, anchors);
|
||||||
if (!position) return null;
|
if (!position) return null;
|
||||||
@@ -102,9 +90,7 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
{REPAIR_MISSION_TRIGGERS.map((config) => (
|
{REPAIR_MISSION_TRIGGERS.map((config) => (
|
||||||
<RepairMissionTrigger key={config.mission} config={config} />
|
<RepairMissionTrigger key={config.mission} config={config} />
|
||||||
))}
|
))}
|
||||||
{mainState === "outro" ? (
|
{mainState === "outro" ? <StageAnchor {...OUTRO_STAGE_ANCHOR} /> : null}
|
||||||
<StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -7,7 +7,7 @@ import {
|
|||||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
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 { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||||
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
||||||
@@ -29,7 +29,7 @@ import { GameMusic } from "@/world/GameMusic";
|
|||||||
import { Lighting } from "@/world/Lighting";
|
import { Lighting } from "@/world/Lighting";
|
||||||
import { GameMap } from "@/world/GameMap";
|
import { GameMap } from "@/world/GameMap";
|
||||||
import { GameStageContent } from "@/world/GameStageContent";
|
import { GameStageContent } from "@/world/GameStageContent";
|
||||||
import { PersonnageSystem } from "@/world/personnages/PersonnageSystem";
|
import { CharacterSystem } from "@/world/characters/CharacterSystem";
|
||||||
import { Player } from "@/world/player/Player";
|
import { Player } from "@/world/player/Player";
|
||||||
import { TestMap } from "@/world/debug/TestMap";
|
import { TestMap } from "@/world/debug/TestMap";
|
||||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||||
@@ -41,7 +41,7 @@ interface WorldProps {
|
|||||||
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||||
useEnvironmentDebug();
|
useEnvironmentDebug();
|
||||||
useMapPerformanceDebug();
|
useMapPerformanceDebug();
|
||||||
usePersonnageDebug();
|
useCharacterDebug();
|
||||||
|
|
||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
const sceneMode = useSceneMode();
|
const sceneMode = useSceneMode();
|
||||||
@@ -90,7 +90,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
onLoadingStateChange={onLoadingStateChange}
|
onLoadingStateChange={onLoadingStateChange}
|
||||||
onOctreeReady={handleOctreeReady}
|
onOctreeReady={handleOctreeReady}
|
||||||
/>
|
/>
|
||||||
{showGameStage ? <PersonnageSystem /> : null}
|
{showGameStage && mainState !== "ebike" ? <CharacterSystem /> : null}
|
||||||
{showGameStage ? (
|
{showGameStage ? (
|
||||||
<Physics>
|
<Physics>
|
||||||
<GameStageLoaded onLoaded={handleGameStageLoaded} />
|
<GameStageLoaded onLoaded={handleGameStageLoaded} />
|
||||||
|
|||||||
+12
-12
@@ -1,16 +1,16 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
||||||
import {
|
import {
|
||||||
PERSONNAGE_CONFIGS,
|
CHARACTER_CONFIGS,
|
||||||
PERSONNAGE_IDS,
|
CHARACTER_IDS,
|
||||||
type PersonnageId,
|
type CharacterId,
|
||||||
} from "@/data/world/personnages/personnageConfig";
|
} from "@/data/world/characters/characterConfig";
|
||||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
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 {
|
function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element {
|
||||||
const config = PERSONNAGE_CONFIGS[id];
|
const config = CHARACTER_CONFIGS[id];
|
||||||
const state = usePersonnageDebugStore((store) => store.personnages[id]);
|
const state = useCharacterDebugStore((store) => store.characters[id]);
|
||||||
const position = useTerrainSnappedPosition(state.position);
|
const position = useTerrainSnappedPosition(state.position);
|
||||||
|
|
||||||
return (
|
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 (
|
return (
|
||||||
<group name="personnage-system">
|
<group name="character-system">
|
||||||
{PERSONNAGE_IDS.map((id) => (
|
{CHARACTER_IDS.map((id) => (
|
||||||
<Suspense key={id} fallback={null}>
|
<Suspense key={id} fallback={null}>
|
||||||
<PersonnageModel id={id} />
|
<CharacterModel id={id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
@@ -24,89 +24,84 @@ function random01(seed: number): number {
|
|||||||
return value - Math.floor(value);
|
return value - Math.floor(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushVector(target: number[], value: THREE.Vector3): void {
|
const GRASS_COLOR_VALUES = GRASS_COLORS.map((color) => new THREE.Color(color));
|
||||||
target.push(value.x, value.y, value.z);
|
const MARKER_COLOR_VALUES = [0.1, 0, 0, 0, 0, 0.1, 1, 1, 1] as const;
|
||||||
}
|
|
||||||
|
|
||||||
function pushColor(target: number[], value: THREE.Color): void {
|
|
||||||
target.push(value.r, value.g, value.b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createGrassGeometry(density: number): THREE.BufferGeometry {
|
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 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;
|
const halfPatchSize = GRASS_CONFIG.patchSize * 0.5;
|
||||||
|
|
||||||
for (let index = 0; index < bladeCount; index++) {
|
for (let index = 0; index < bladeCount; index++) {
|
||||||
const seed = index * 997;
|
const seed = index * 997;
|
||||||
const origin = new THREE.Vector3(
|
const originX = random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize;
|
||||||
random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize,
|
const originY = 0;
|
||||||
0,
|
const originZ = random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize;
|
||||||
random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize,
|
|
||||||
);
|
|
||||||
const yawAngle = random01(seed + 3) * Math.PI * 2;
|
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 colorIndex = Math.floor(random01(seed + 4) * GRASS_COLORS.length);
|
||||||
const color = new THREE.Color(GRASS_COLORS[colorIndex] ?? GRASS_COLORS[0]);
|
const color = GRASS_COLOR_VALUES[colorIndex] ?? GRASS_COLOR_VALUES[0];
|
||||||
const markerColors = [
|
const uvX = originX / GRASS_CONFIG.patchSize + 0.5;
|
||||||
new THREE.Color(0.1, 0, 0),
|
const uvY = originZ / GRASS_CONFIG.patchSize + 0.5;
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let vertexIndex = 0; vertexIndex < 3; vertexIndex++) {
|
for (let vertexIndex = 0; vertexIndex < 3; vertexIndex++) {
|
||||||
pushVector(positions, origin);
|
const vertexOffset = index * 3 + vertexIndex;
|
||||||
pushColor(colors, markerColors[vertexIndex] ?? markerColors[2]);
|
const vectorOffset = vertexOffset * 3;
|
||||||
pushVector(bladeOrigins, origin);
|
const uvOffset = vertexOffset * 2;
|
||||||
pushVector(yaws, yaw);
|
const markerOffset = vertexIndex * 3;
|
||||||
pushColor(colors, color);
|
|
||||||
uvs.push(uv.x, uv.y);
|
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 geometry = new THREE.BufferGeometry();
|
||||||
const markerColorValues: number[] = [];
|
|
||||||
const bladeColorValues: number[] = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < colors.length; index += 6) {
|
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
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(
|
geometry.setAttribute(
|
||||||
"color",
|
"color",
|
||||||
new THREE.Float32BufferAttribute(markerColorValues, 3),
|
new THREE.BufferAttribute(markerColorValues, 3),
|
||||||
);
|
);
|
||||||
geometry.setAttribute(
|
geometry.setAttribute(
|
||||||
"aBladeColor",
|
"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(
|
geometry.setAttribute(
|
||||||
"aBladeOrigin",
|
"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.computeVertexNormals();
|
||||||
geometry.computeBoundingSphere();
|
geometry.computeBoundingSphere();
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ function createTerrainGrassSampler(
|
|||||||
const terrainMatrix = createTerrainMatrix(position, rotation, scale);
|
const terrainMatrix = createTerrainMatrix(position, rotation, scale);
|
||||||
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
||||||
const normalMatrix = new THREE.Matrix3().getNormalMatrix(terrainMatrix);
|
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(
|
const raycaster = new THREE.Raycaster(
|
||||||
new THREE.Vector3(),
|
new THREE.Vector3(),
|
||||||
DOWN,
|
DOWN,
|
||||||
@@ -94,14 +98,11 @@ function createTerrainGrassSampler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sample = (x: number, z: number): TerrainGrassSample | null => {
|
const sample = (x: number, z: number): TerrainGrassSample | null => {
|
||||||
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
|
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
|
||||||
inverseTerrainMatrix,
|
|
||||||
);
|
|
||||||
const localDirection =
|
|
||||||
DOWN.clone().transformDirection(inverseTerrainMatrix);
|
|
||||||
|
|
||||||
raycaster.set(localOrigin, localDirection);
|
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;
|
if (!hit) return null;
|
||||||
|
|
||||||
const normal = hit.face?.normal
|
const normal = hit.face?.normal
|
||||||
@@ -112,7 +113,7 @@ function createTerrainGrassSampler(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
position: hit.point.clone().applyMatrix4(terrainMatrix),
|
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 { EcoleModel } from "@/components/three/world/EcoleModel";
|
||||||
import { FermeVerticaleModel } from "@/components/three/world/FermeVerticaleModel";
|
import { FermeVerticaleModel } from "@/components/three/world/FermeVerticaleModel";
|
||||||
import { GenerateurModel } from "@/components/three/world/GenerateurModel";
|
import { GenerateurModel } from "@/components/three/world/GenerateurModel";
|
||||||
import { LafabrikModel } from "@/components/three/world/LafabrikModel";
|
import { LaFabrikMapModel } from "@/components/three/world/LaFabrikMapModel";
|
||||||
import {
|
import {
|
||||||
normalizeMapScale,
|
normalizeMapScale,
|
||||||
useTerrainSnappedPosition,
|
useTerrainSnappedPosition,
|
||||||
@@ -55,7 +55,7 @@ export function GeneratedMapNodeInstance({
|
|||||||
|
|
||||||
if (node.name === "lafabrik") {
|
if (node.name === "lafabrik") {
|
||||||
return (
|
return (
|
||||||
<LafabrikModel
|
<LaFabrikMapModel
|
||||||
position={position}
|
position={position}
|
||||||
rotation={node.rotation}
|
rotation={node.rotation}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
import { mergeBufferGeometries } from "three-stdlib";
|
||||||
import {
|
import {
|
||||||
normalizeMapScale,
|
normalizeMapScale,
|
||||||
useTerrainHeightSampler,
|
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) {
|
for (const geometry of group.geometries) {
|
||||||
geometry.dispose();
|
geometry.dispose();
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ import {
|
|||||||
} from "@/data/world/mapInstancingConfig";
|
} from "@/data/world/mapInstancingConfig";
|
||||||
import { useMapInstancingData } from "@/hooks/world/useMapInstancingData";
|
import { useMapInstancingData } from "@/hooks/world/useMapInstancingData";
|
||||||
import type { MapAssetInstance } from "@/types/map/mapScene";
|
import type { MapAssetInstance } from "@/types/map/mapScene";
|
||||||
|
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
|
||||||
|
|
||||||
interface MapInstancingSystemProps {
|
interface MapInstancingSystemProps {
|
||||||
onlyModelName?: string | null;
|
onlyMapName?: string | null;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,53 +31,24 @@ interface MapAssetChunk {
|
|||||||
instances: MapAssetInstance[];
|
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(
|
function createMapAssetChunks(
|
||||||
type: MapInstancingAssetType,
|
type: MapInstancingAssetType,
|
||||||
config: MapInstancingAssetConfig,
|
config: MapInstancingAssetConfig,
|
||||||
instances: MapAssetInstance[],
|
instances: MapAssetInstance[],
|
||||||
): MapAssetChunk[] {
|
): MapAssetChunk[] {
|
||||||
const chunks = new Map<string, MapAssetInstance[]>();
|
return createWorldInstanceChunks(instances).map((chunk) => {
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
key: `${type}:${chunkKey}`,
|
key: `${type}:${chunk.chunkKey}`,
|
||||||
config,
|
config,
|
||||||
centerX: center.x / chunkInstances.length,
|
centerX: chunk.centerX,
|
||||||
centerZ: center.z / chunkInstances.length,
|
centerZ: chunk.centerZ,
|
||||||
instances: chunkInstances,
|
instances: chunk.instances,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapInstancingSystem({
|
export function MapInstancingSystem({
|
||||||
onlyModelName = null,
|
onlyMapName = null,
|
||||||
streaming = true,
|
streaming = true,
|
||||||
}: MapInstancingSystemProps): React.JSX.Element | null {
|
}: MapInstancingSystemProps): React.JSX.Element | null {
|
||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
@@ -96,7 +68,7 @@ export function MapInstancingSystem({
|
|||||||
return MAP_INSTANCING_ASSET_TYPES.flatMap((type) => {
|
return MAP_INSTANCING_ASSET_TYPES.flatMap((type) => {
|
||||||
const config = MAP_INSTANCING_ASSETS[type];
|
const config = MAP_INSTANCING_ASSETS[type];
|
||||||
|
|
||||||
if (onlyModelName && config.mapName !== onlyModelName) return [];
|
if (onlyMapName && config.mapName !== onlyMapName) return [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!config.enabled ||
|
!config.enabled ||
|
||||||
@@ -110,7 +82,7 @@ export function MapInstancingSystem({
|
|||||||
|
|
||||||
return createMapAssetChunks(type, config, instances);
|
return createMapAssetChunks(type, config, instances);
|
||||||
});
|
});
|
||||||
}, [data, groups, models, onlyModelName]);
|
}, [data, groups, models, onlyMapName]);
|
||||||
|
|
||||||
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
|
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useLayoutEffect } from "react";
|
import { useLayoutEffect } from "react";
|
||||||
import { useThree } from "@react-three/fiber";
|
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 type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { PlayerCamera } from "@/world/player/PlayerCamera";
|
import { PlayerCamera } from "@/world/player/PlayerCamera";
|
||||||
import { PlayerController } from "@/world/player/PlayerController";
|
import { PlayerController } from "@/world/player/PlayerController";
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { Capsule } from "three/addons/math/Capsule.js";
|
import { Capsule, type Octree } from "three-stdlib";
|
||||||
import type { Octree } from "three/addons/math/Octree.js";
|
|
||||||
import {
|
import {
|
||||||
INTERACT_KEY,
|
INTERACT_KEY,
|
||||||
JUMP_KEY,
|
JUMP_KEY,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
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 { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||||
import type { VegetationInstance } from "@/types/map/mapScene";
|
import type { VegetationInstance } from "@/types/map/mapScene";
|
||||||
import { useWind } from "@/hooks/world/useWind";
|
import { useWind } from "@/hooks/world/useWind";
|
||||||
@@ -161,7 +161,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
|
|||||||
|
|
||||||
return [...meshesByMaterial.values()]
|
return [...meshesByMaterial.values()]
|
||||||
.map(({ geometries, material }) => {
|
.map(({ geometries, material }) => {
|
||||||
const mergedGeometry = mergeGeometries(geometries, false);
|
const mergedGeometry = mergeBufferGeometries(geometries, false);
|
||||||
|
|
||||||
for (const geometry of geometries) {
|
for (const geometry of geometries) {
|
||||||
if (geometry !== mergedGeometry) {
|
if (geometry !== mergedGeometry) {
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
VEGETATION_TYPES,
|
VEGETATION_TYPES,
|
||||||
type VegetationType,
|
type VegetationType,
|
||||||
} from "@/data/world/vegetationConfig";
|
} from "@/data/world/vegetationConfig";
|
||||||
|
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
|
||||||
|
|
||||||
interface VegetationSystemProps {
|
interface VegetationSystemProps {
|
||||||
onlyModelName?: string | null;
|
onlyMapName?: string | null;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,42 +36,15 @@ interface VegetationChunk {
|
|||||||
instances: VegetationInstance[];
|
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(
|
function createVegetationChunks(
|
||||||
type: VegetationType,
|
type: VegetationType,
|
||||||
instances: VegetationInstance[],
|
instances: VegetationInstance[],
|
||||||
): VegetationChunk[] {
|
): VegetationChunk[] {
|
||||||
const config = VEGETATION_TYPES[type];
|
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 {
|
return {
|
||||||
key: `${type}:${chunkKey}`,
|
key: `${type}:${chunk.chunkKey}`,
|
||||||
type,
|
type,
|
||||||
modelPath: config.modelPath,
|
modelPath: config.modelPath,
|
||||||
scaleMultiplier: config.scaleMultiplier,
|
scaleMultiplier: config.scaleMultiplier,
|
||||||
@@ -78,15 +52,15 @@ function createVegetationChunks(
|
|||||||
receiveShadow: config.receiveShadow,
|
receiveShadow: config.receiveShadow,
|
||||||
windStrength: config.windStrength,
|
windStrength: config.windStrength,
|
||||||
rotationOffset: config.rotationOffset,
|
rotationOffset: config.rotationOffset,
|
||||||
centerX: center.x / chunkInstances.length,
|
centerX: chunk.centerX,
|
||||||
centerZ: center.z / chunkInstances.length,
|
centerZ: chunk.centerZ,
|
||||||
instances: chunkInstances,
|
instances: chunk.instances,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VegetationSystem({
|
export function VegetationSystem({
|
||||||
onlyModelName = null,
|
onlyMapName = null,
|
||||||
streaming = true,
|
streaming = true,
|
||||||
}: VegetationSystemProps): React.JSX.Element | null {
|
}: VegetationSystemProps): React.JSX.Element | null {
|
||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
@@ -106,7 +80,7 @@ export function VegetationSystem({
|
|||||||
return VEGETATION_TYPE_KEYS.flatMap((type) => {
|
return VEGETATION_TYPE_KEYS.flatMap((type) => {
|
||||||
const config = VEGETATION_TYPES[type];
|
const config = VEGETATION_TYPES[type];
|
||||||
|
|
||||||
if (onlyModelName && config.mapName !== onlyModelName) return [];
|
if (onlyMapName && config.mapName !== onlyMapName) return [];
|
||||||
|
|
||||||
if (!config.enabled) return [];
|
if (!config.enabled) return [];
|
||||||
if (!isMapModelVisible(config.mapName, { groups, models })) return [];
|
if (!isMapModelVisible(config.mapName, { groups, models })) return [];
|
||||||
@@ -116,7 +90,7 @@ export function VegetationSystem({
|
|||||||
|
|
||||||
return createVegetationChunks(type, entry.instances);
|
return createVegetationChunks(type, entry.instances);
|
||||||
});
|
});
|
||||||
}, [data, groups, models, onlyModelName]);
|
}, [data, groups, models, onlyMapName]);
|
||||||
|
|
||||||
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
|
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ const THREE_SOURCE_ENTRY = fileURLToPath(
|
|||||||
new URL("./node_modules/three/src/Three.js", import.meta.url),
|
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_SRT_PAYLOAD_BYTES = 256 * 1024;
|
||||||
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
||||||
const MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
const MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
||||||
|
|||||||
Reference in New Issue
Block a user