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 (