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

This commit is contained in:
Tom Boullay
2026-05-29 01:23:08 +02:00
parent 4728690a11
commit 093ffd726d
45 changed files with 823 additions and 785 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+4 -5
View File
@@ -113,7 +113,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
2. `useEditorSceneData` calls `loadMapSceneData()`. 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
-5
View File
@@ -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"
]
} }
} }
+53 -13
View File
@@ -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;
+1 -1
View File
@@ -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();
+6 -1
View File
@@ -5,6 +5,7 @@ import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
interface SkyModelProps { interface SkyModelProps {
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
+18
View File
@@ -0,0 +1,18 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface StageAnchorConfig {
color: string;
position: Vector3Tuple;
scale?: number;
}
export const INTRO_STAGE_ANCHOR: StageAnchorConfig = {
color: "#7dd3fc",
position: [0, 4, 0],
};
export const OUTRO_STAGE_ANCHOR: StageAnchorConfig = {
color: "#fb7185",
position: [0, 6, 10],
scale: 1.25,
};
@@ -1,9 +1,9 @@
import type { Vector3Tuple } from "@/types/three/three"; 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
View File
@@ -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";
+1 -1
View File
@@ -90,7 +90,7 @@ export function getVegetationModelScaleMultiplier(name: string): number {
); );
} }
export const INSTANCED_MAP_EXCEPTIONS = new Set([ export const VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES = new Set([
"Scene", "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();
+31 -7
View File
@@ -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 -1
View File
@@ -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(
+7 -6
View File
@@ -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;
}, },
}; };
+2 -40
View File
@@ -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 -1
View File
@@ -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),
},
},
})),
}));
+84 -324
View File
@@ -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,270 +8,50 @@ 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 {
...(node.id ? { id: node.id } : {}),
name: node.name,
type: node.type,
position: node.position,
rotation: node.rotation,
scale: node.scale,
}; };
} const handleContextRestored = () => {
logger.info("WebGL", "Context restored");
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);
);
}
return nextNode;
}; };
}, [gl]);
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; 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 {
const { const {
hasMapJson, hasMapJson,
@@ -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;
const selectedNode = sceneData?.mapNodes[currentIndex];
if (selectedNode?.name === "terrain") {
setSelectedNodeIndexes((indexes) =>
indexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain", (index) => sceneData?.mapNodes[index]?.name !== "terrain",
),
); );
return null; const selectedNode =
} selectedNodeIndex !== null
? sceneData?.mapNodes[selectedNodeIndex]
: null;
setSelectedNodeIndexes((indexes) => setSelectedNodeIndexes(nextIndexes);
indexes.filter( setSelectedNodeIndex(
(index) => sceneData?.mapNodes[index]?.name !== "terrain", selectedNode?.name === "terrain" ? null : selectedNodeIndex,
),
); );
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) { if (!sceneData.mapTree) {
const newNode = createNewMapNode(newNodeName); const newNode = createNewMapNode(newNodeName);
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)]; const mapNodes = [...sceneData.mapNodes, removeEditorMetadata(newNode)];
setSelectedNodeIndex(mapNodes.length - 1); const selectedIndex = mapNodes.length - 1;
setSelectedNodeIndexes([mapNodes.length - 1]);
return { ...prev, mapNodes }; setSceneData({ ...sceneData, mapNodes });
setSelectedNodeIndex(selectedIndex);
setSelectedNodeIndexes([selectedIndex]);
return;
} }
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName)); const mapTree = addTreeNode(
const nextSceneData = updateSceneDataTree(prev, mapTree); sceneData.mapTree,
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1); createNewMapNode(newNodeName),
setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]); );
return nextSceneData; const nextSceneData = updateSceneDataTree(sceneData, mapTree);
}); const selectedIndex = nextSceneData.mapNodes.length - 1;
}, [newNodeName, setSceneData]);
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 (!sceneData.mapTree || !currentNode.sourcePath) {
if (!prev.mapTree || !currentNode.sourcePath) { setSceneData({
setSelectedNodeIndex(null); ...sceneData,
setSelectedNodeIndexes([]); mapNodes: sceneData.mapNodes.filter(
return {
...prev,
mapNodes: prev.mapNodes.filter(
(_node, index) => index !== selectedNodeIndex, (_node, index) => index !== selectedNodeIndex,
), ),
}; });
} } else {
const mapTree = removeTreeNodeAtPath( const mapTree = removeTreeNodeAtPath(
prev.mapTree, sceneData.mapTree,
currentNode.sourcePath, currentNode.sourcePath,
); );
setSceneData(updateSceneDataTree(sceneData, mapTree));
}
setSelectedNodeIndex(null); setSelectedNodeIndex(null);
setSelectedNodeIndexes([]); setSelectedNodeIndexes([]);
return updateSceneDataTree(prev, mapTree); }, [sceneData, selectedNodeIndex, setSceneData]);
});
}, [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}
-42
View File
@@ -1,42 +0,0 @@
declare module "three/addons/math/Capsule.js" {
import { Vector3 } from "three";
export class Capsule {
start: Vector3;
end: Vector3;
radius: number;
constructor(start?: Vector3, end?: Vector3, radius?: number);
set(start: Vector3, end: Vector3, radius: number): this;
clone(): Capsule;
copy(capsule: Capsule): this;
getCenter(target: Vector3): Vector3;
translate(v: Vector3): this;
}
}
declare module "three/addons/math/Octree.js" {
import { Object3D } from "three";
import { Capsule } from "three/addons/math/Capsule.js";
export interface CapsuleIntersectResult {
normal: import("three").Vector3;
depth: number;
}
export class Octree {
constructor();
fromGraphNode(group: Object3D): this;
capsuleIntersect(capsule: Capsule): CapsuleIntersectResult | false;
}
}
declare module "three/addons/utils/BufferGeometryUtils.js" {
import { BufferGeometry } from "three";
export function mergeGeometries(
geometries: BufferGeometry[],
useGroups?: boolean,
): BufferGeometry | null;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Octree } from "three/addons/math/Octree.js"; import type { Octree } from "three-stdlib";
export type Vector3Tuple = [number, number, number]; export type Vector3Tuple = [number, number, number];
+259
View File
@@ -0,0 +1,259 @@
import type {
HierarchicalMapNode,
MapNode,
SceneData,
} from "@/types/editor/editor";
const DEFAULT_NEW_NODE_NAME = "new-model";
export function serializeMapNodes(sceneData: SceneData): string {
const mapPayload = sceneData.mapTree
? mergeFlatNodeTransformsIntoTree(sceneData)
: sceneData.mapNodes.map(removeEditorMetadata);
return JSON.stringify(mapPayload, null, 2);
}
function createSourcePathKey(sourcePath: readonly number[]): string {
return sourcePath.join(".");
}
export function removeEditorMetadata(node: MapNode): MapNode {
return {
...(node.id ? { id: node.id } : {}),
name: node.name,
type: node.type,
position: node.position,
rotation: node.rotation,
scale: node.scale,
};
}
export function mergeFlatNodeTransformsIntoTree(
sceneData: SceneData,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nodesBySourcePath = new Map<string, MapNode>();
for (const node of sceneData.mapNodes) {
if (!node.sourcePath) continue;
nodesBySourcePath.set(createSourcePathKey(node.sourcePath), node);
}
const cloneNode = (
node: HierarchicalMapNode,
path: number[],
): HierarchicalMapNode => {
const updatedNode = nodesBySourcePath.get(createSourcePathKey(path));
const nextNode: HierarchicalMapNode = {
...((updatedNode?.id ?? node.id)
? { id: updatedNode?.id ?? node.id }
: {}),
name: node.name,
type: node.type,
position: updatedNode?.position ?? node.position,
rotation: updatedNode?.rotation ?? node.rotation,
scale: updatedNode?.scale ?? node.scale,
};
if (node.role) {
nextNode.role = node.role;
}
if (node.children) {
nextNode.children = node.children.map((child, index) =>
cloneNode(child, [...path, index]),
);
}
return nextNode;
};
const mapTree = sceneData.mapTree;
if (!mapTree) {
return sceneData.mapNodes.map(removeEditorMetadata);
}
if (Array.isArray(mapTree)) {
return mapTree.map((node, index) => cloneNode(node, [index]));
}
return cloneNode(mapTree, []);
}
function cloneMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): HierarchicalMapNode | HierarchicalMapNode[] {
return JSON.parse(JSON.stringify(mapTree)) as
| HierarchicalMapNode
| HierarchicalMapNode[];
}
function collectEditableMapNodes(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): MapNode[] {
const nodes: MapNode[] = [];
function visit(node: HierarchicalMapNode, path: number[]): void {
if (node.role !== "group" && node.type !== "Mesh") {
nodes.push({
...(node.id ? { id: node.id } : {}),
name: node.name,
position: node.position,
rotation: node.rotation,
scale: node.scale,
sourcePath: path,
type: node.type,
});
}
node.children?.forEach((child, index) => visit(child, [...path, index]));
}
if (Array.isArray(mapTree)) {
mapTree.forEach((node, index) => visit(node, [index]));
} else {
visit(mapTree, []);
}
return nodes;
}
export function updateTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
update: (node: HierarchicalMapNode) => HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1] ?? 0;
const isRootTarget = Array.isArray(nextTree)
? path.length === 1
: path.length === 0;
if (isRootTarget) {
const targetNode = rootNodes[targetIndex];
if (targetNode) {
rootNodes[targetIndex] = update(targetNode);
}
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
if (!parent?.children?.[targetIndex]) return nextTree;
parent.children[targetIndex] = update(parent.children[targetIndex]);
return nextTree;
}
export function removeTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
): HierarchicalMapNode | HierarchicalMapNode[] {
const nextTree = cloneMapTree(mapTree);
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
const targetIndex = path[path.length - 1];
if (targetIndex === undefined) return nextTree;
if (Array.isArray(nextTree) && path.length === 1) {
nextTree.splice(targetIndex, 1);
return nextTree;
}
const parentPath = path.slice(0, -1);
let parent = Array.isArray(nextTree)
? rootNodes[parentPath[0] ?? 0]
: rootNodes[0];
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
for (const index of childPath) {
parent = parent?.children?.[index];
}
parent?.children?.splice(targetIndex, 1);
return nextTree;
}
export function updateSceneDataTree(
sceneData: SceneData,
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): SceneData {
return {
...sceneData,
mapNodes: collectEditableMapNodes(mapTree),
mapTree,
};
}
function findNodePathByName(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
name: string,
): number[] | null {
function visit(node: HierarchicalMapNode, path: number[]): number[] | null {
if (node.name === name) return path;
for (let index = 0; index < (node.children?.length ?? 0); index++) {
const child = node.children?.[index];
if (!child) continue;
const result = visit(child, [...path, index]);
if (result) return result;
}
return null;
}
if (Array.isArray(mapTree)) {
for (let index = 0; index < mapTree.length; index++) {
const node = mapTree[index];
if (!node) continue;
const result = visit(node, [index]);
if (result) return result;
}
return null;
}
return visit(mapTree, []);
}
export function addTreeNode(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
node: HierarchicalMapNode,
): HierarchicalMapNode | HierarchicalMapNode[] {
const blockingPath = findNodePathByName(mapTree, "blocking");
if (!blockingPath) return mapTree;
return updateTreeNodeAtPath(mapTree, blockingPath, (blockingNode) => ({
...blockingNode,
children: [...(blockingNode.children ?? []), node],
}));
}
export function createNewMapNode(name: string): HierarchicalMapNode {
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
return {
name: safeName,
type: "Object3D",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
children: [
{
name: safeName,
type: "Mesh",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ export async function createSceneDataFromFiles(
const models = new Map<string, string>(); 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);
}
+55
View File
@@ -0,0 +1,55 @@
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import type { Vector3Tuple } from "@/types/three/three";
interface PositionedInstance {
position: Vector3Tuple;
}
export interface WorldInstanceChunk<TInstance extends PositionedInstance> {
centerX: number;
centerZ: number;
chunkKey: string;
instances: TInstance[];
}
function getWorldChunkKey(instance: PositionedInstance): string {
const [x, , z] = instance.position;
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
return `${chunkX}:${chunkZ}`;
}
export function createWorldInstanceChunks<TInstance extends PositionedInstance>(
instances: TInstance[],
): WorldInstanceChunk<TInstance>[] {
const chunks = new Map<string, TInstance[]>();
for (const instance of instances) {
const chunkKey = getWorldChunkKey(instance);
const chunk = chunks.get(chunkKey);
if (chunk) {
chunk.push(instance);
} else {
chunks.set(chunkKey, [instance]);
}
}
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
const center = chunkInstances.reduce(
(sum, instance) => {
sum.x += instance.position[0];
sum.z += instance.position[2];
return sum;
},
{ x: 0, z: 0 },
);
return {
centerX: center.x / chunkInstances.length,
centerZ: center.z / chunkInstances.length,
chunkKey,
instances: chunkInstances,
};
});
}
+2
View File
@@ -1,6 +1,7 @@
import { 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}
+7 -21
View File
@@ -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
View File
@@ -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} />
@@ -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>
+53 -58
View File
@@ -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();
+9 -8
View File
@@ -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 -1
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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) {
+10 -36
View File
@@ -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
View File
@@ -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;