diff --git a/src/components/three/world/TerrainModel.tsx b/src/components/three/world/TerrainModel.tsx
index 4525ee2..5ba842a 100644
--- a/src/components/three/world/TerrainModel.tsx
+++ b/src/components/three/world/TerrainModel.tsx
@@ -3,6 +3,7 @@ import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
+import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig";
import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
@@ -65,9 +66,10 @@ export function TerrainModel({
const terrainModel = useMemo(() => {
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const model = scene.clone(true);
+ flattenLaFabrikTerrainFootprint(model, position, rotation, scale);
applyTerrainMaterialSettings(model, receiveShadow);
return model;
- }, [maxAnisotropy, scene, receiveShadow]);
+ }, [maxAnisotropy, position, receiveShadow, rotation, scale, scene]);
useEffect(() => {
onLoaded?.();
diff --git a/src/data/ebike/ebikeConfig.ts b/src/data/ebike/ebikeConfig.ts
index 583afc1..12685d8 100644
--- a/src/data/ebike/ebikeConfig.ts
+++ b/src/data/ebike/ebikeConfig.ts
@@ -15,7 +15,7 @@ export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
rotation: [0, 0, 0],
};
-export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 10, 62.4];
+export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 8.4, 62.4];
export const EBIKE_WORLD_ROTATION_Y = 2.4107;
export const EBIKE_INTRO_RIDE_DURATION_MS = 5000;
diff --git a/src/data/player/playerConfig.ts b/src/data/player/playerConfig.ts
index 7ce0ca2..a1aa8d3 100644
--- a/src/data/player/playerConfig.ts
+++ b/src/data/player/playerConfig.ts
@@ -1,4 +1,5 @@
import type { Vector3Tuple } from "@/types/three/three";
+import { LA_FABRIK_PLAYER_SPAWN } from "@/data/world/laFabrikConfig";
export const PLAYER_EYE_HEIGHT = 1.75;
export const PLAYER_CAPSULE_RADIUS = 0.35;
@@ -14,5 +15,5 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_FALL_RESPAWN_Y = -20;
export const PLAYER_FALL_RESPAWN_DELAY = 3;
-export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [59.5, 10, 64.64];
+export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = LA_FABRIK_PLAYER_SPAWN;
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
diff --git a/src/data/world/characters/characterConfig.ts b/src/data/world/characters/characterConfig.ts
index fce3d29..43d8199 100644
--- a/src/data/world/characters/characterConfig.ts
+++ b/src/data/world/characters/characterConfig.ts
@@ -11,6 +11,7 @@ export interface CharacterConfig {
scale: Vector3Tuple;
animations: readonly string[];
defaultAnimation: string;
+ snapToTerrain?: boolean;
}
export const CHARACTER_CONFIGS = {
@@ -28,11 +29,12 @@ export const CHARACTER_CONFIGS = {
id: "gerant",
label: "Gerant",
modelPath: "/models/gerant-animated/model.gltf",
- position: [59.5, 0, 64.64],
+ position: [59.5, 6.3, 64.64],
rotation: [0, 2.41, 0],
scale: [1, 1, 1],
animations: ["idle", "walk"],
defaultAnimation: "idle",
+ snapToTerrain: false,
},
fermier: {
id: "fermier",
diff --git a/src/data/world/laFabrikConfig.ts b/src/data/world/laFabrikConfig.ts
new file mode 100644
index 0000000..1eeb7b0
--- /dev/null
+++ b/src/data/world/laFabrikConfig.ts
@@ -0,0 +1,83 @@
+import * as THREE from "three";
+import type { Vector3Tuple } from "@/types/three/three";
+
+export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354];
+export const LA_FABRIK_ROTATION_Y = 2.4107;
+export const LA_FABRIK_HALF_EXTENTS = {
+ x: 8.5,
+ z: 7.5,
+} as const;
+export const LA_FABRIK_FLOOR_Y = 6.3;
+export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 8.05, 64.64];
+export const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64];
+
+const _terrainMatrix = new THREE.Matrix4();
+const _meshWorldMatrix = new THREE.Matrix4();
+const _inverseMeshWorldMatrix = new THREE.Matrix4();
+const _worldPosition = new THREE.Vector3();
+
+export function isInsideLaFabrikFootprint(
+ x: number,
+ z: number,
+ padding = 0,
+): boolean {
+ const dx = x - LA_FABRIK_CENTER[0];
+ const dz = z - LA_FABRIK_CENTER[2];
+ const cos = Math.cos(-LA_FABRIK_ROTATION_Y);
+ const sin = Math.sin(-LA_FABRIK_ROTATION_Y);
+ const localX = dx * cos - dz * sin;
+ const localZ = dx * sin + dz * cos;
+
+ return (
+ Math.abs(localX) <= LA_FABRIK_HALF_EXTENTS.x + padding &&
+ Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding
+ );
+}
+
+export function flattenLaFabrikTerrainFootprint(
+ object: THREE.Object3D,
+ position: Vector3Tuple,
+ rotation: Vector3Tuple,
+ scale: Vector3Tuple,
+): void {
+ _terrainMatrix.compose(
+ new THREE.Vector3(...position),
+ new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)),
+ new THREE.Vector3(...scale),
+ );
+ object.updateMatrixWorld(true);
+
+ object.traverse((child) => {
+ if (!(child instanceof THREE.Mesh)) return;
+ const geometry = child.geometry;
+ const positions = geometry.getAttribute("position");
+ if (!positions) return;
+
+ _meshWorldMatrix.multiplyMatrices(_terrainMatrix, child.matrixWorld);
+ _inverseMeshWorldMatrix.copy(_meshWorldMatrix).invert();
+
+ for (let index = 0; index < positions.count; index++) {
+ _worldPosition
+ .fromBufferAttribute(positions, index)
+ .applyMatrix4(_meshWorldMatrix);
+
+ if (!isInsideLaFabrikFootprint(_worldPosition.x, _worldPosition.z, 0.8)) {
+ continue;
+ }
+
+ _worldPosition.y = Math.min(_worldPosition.y, LA_FABRIK_FLOOR_Y - 0.35);
+ _worldPosition.applyMatrix4(_inverseMeshWorldMatrix);
+ positions.setXYZ(
+ index,
+ _worldPosition.x,
+ _worldPosition.y,
+ _worldPosition.z,
+ );
+ }
+
+ positions.needsUpdate = true;
+ geometry.computeVertexNormals();
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+ });
+}
diff --git a/src/hooks/three/useTerrainHeight.ts b/src/hooks/three/useTerrainHeight.ts
index b4405f2..7e0a9d3 100644
--- a/src/hooks/three/useTerrainHeight.ts
+++ b/src/hooks/three/useTerrainHeight.ts
@@ -2,6 +2,10 @@ import { useMemo } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
+import {
+ isInsideLaFabrikFootprint,
+ LA_FABRIK_FLOOR_Y,
+} from "@/data/world/laFabrikConfig";
import type { Vector3Tuple } from "@/types/three/three";
import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
@@ -66,6 +70,10 @@ function createTerrainHeightSampler(
return {
getHeight: (x, z) => {
+ if (isInsideLaFabrikFootprint(x, z, 0.6)) {
+ return LA_FABRIK_FLOOR_Y;
+ }
+
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
raycaster.set(localOrigin, localDirection);
hits.length = 0;
diff --git a/src/world/GameMapCollision.tsx b/src/world/GameMapCollision.tsx
index 9d038d7..f27955b 100644
--- a/src/world/GameMapCollision.tsx
+++ b/src/world/GameMapCollision.tsx
@@ -18,6 +18,7 @@ import {
useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight";
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
+import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig";
import type { MapNode } from "@/types/map/mapScene";
import type { OctreeReadyHandler } from "@/types/three/three";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -213,7 +214,7 @@ function CollisionModelInstance({
modelUrl: string;
onLoaded: () => void;
terrainHeight: TerrainHeightSampler;
-}): React.JSX.Element {
+}): React.JSX.Element | null {
const { position, rotation, scale } = node;
const normalizedScale = normalizeMapScale(scale);
const { scene } = useLoggedGLTF(modelUrl, {
@@ -223,22 +224,46 @@ function CollisionModelInstance({
scale: normalizedScale,
});
const sceneInstance = useClonedObject(scene);
+ const collisionSceneInstance = useMemo(() => {
+ if (node.name === "terrain") {
+ flattenLaFabrikTerrainFootprint(
+ sceneInstance,
+ position,
+ rotation,
+ normalizedScale,
+ );
+ }
+ return sceneInstance;
+ }, [node.name, normalizedScale, position, rotation, sceneInstance]);
const collisionPosition = useMemo(() => {
if (node.name === "terrain") return position;
const [x, y, z] = position;
const height = terrainHeight.getHeight(x, z);
- const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale);
+ const bottomOffset = getObjectBottomOffset(
+ collisionSceneInstance,
+ normalizedScale,
+ );
return [x, height !== null ? height + bottomOffset : y, z] as const;
- }, [node.name, normalizedScale, position, sceneInstance, terrainHeight]);
+ }, [
+ node.name,
+ normalizedScale,
+ position,
+ collisionSceneInstance,
+ terrainHeight,
+ ]);
useEffect(() => {
onLoaded();
}, [onLoaded]);
+ if (node.name === "lafabrik") {
+ return null;
+ }
+
return (
+
>
);
}
diff --git a/src/world/characters/CharacterSystem.tsx b/src/world/characters/CharacterSystem.tsx
index 3de23a4..d2f6672 100644
--- a/src/world/characters/CharacterSystem.tsx
+++ b/src/world/characters/CharacterSystem.tsx
@@ -3,15 +3,18 @@ import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import {
CHARACTER_CONFIGS,
CHARACTER_IDS,
+ type CharacterConfig,
type CharacterId,
} from "@/data/world/characters/characterConfig";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element {
- const config = CHARACTER_CONFIGS[id];
+ const config: CharacterConfig = CHARACTER_CONFIGS[id];
const state = useCharacterDebugStore((store) => store.characters[id]);
- const position = useTerrainSnappedPosition(state.position);
+ const snappedPosition = useTerrainSnappedPosition(state.position);
+ const position =
+ config.snapToTerrain === false ? state.position : snappedPosition;
return (