Feat/polish-mission1 #12

Merged
math-pixel merged 42 commits from feat/polish-mission1 into develop 2026-06-01 21:51:09 +00:00
12 changed files with 176 additions and 11 deletions
Showing only changes of commit 27b4a2c392 - Show all commits
+3 -1
View File
@@ -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?.();
+1 -1
View File
@@ -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;
+2 -1
View File
@@ -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];
+3 -1
View File
@@ -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",
+83
View File
@@ -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();
});
}
+8
View File
@@ -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;
+29 -4
View File
@@ -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 (
<primitive
object={sceneInstance}
object={collisionSceneInstance}
position={collisionPosition}
rotation={rotation}
scale={normalizedScale}
+8
View File
@@ -18,6 +18,7 @@ import {
SUN_Z_MIN,
SUN_Z_STEP,
} from "@/data/world/lightingConfig";
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { LIGHTING_STATE } from "@/world/lightingState";
@@ -121,6 +122,13 @@ export function Lighting(): React.JSX.Element {
castShadow
/>
<object3D ref={sunTarget} />
<pointLight
position={LA_FABRIK_INTERIOR_LIGHT_POSITION}
color="#dbeafe"
intensity={1.2}
distance={14}
decay={1.6}
/>
</>
);
}
+5 -2
View File
@@ -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 (
<AnimatedModel
+16
View File
@@ -8,6 +8,11 @@ import {
GRASS_COLORS,
GRASS_CONFIG,
} from "@/data/world/grassConfig";
import {
LA_FABRIK_CENTER,
LA_FABRIK_HALF_EXTENTS,
LA_FABRIK_ROTATION_Y,
} from "@/data/world/laFabrikConfig";
import {
grassFragmentShader,
grassVertexShader,
@@ -169,6 +174,17 @@ function createGrassMaterial(
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
uSurfaceOffset: { value: GRASS_CONFIG.surfaceOffset },
uLaFabrikCenter: {
value: new THREE.Vector2(LA_FABRIK_CENTER[0], LA_FABRIK_CENTER[2]),
},
uLaFabrikHalfExtents: {
value: new THREE.Vector2(
LA_FABRIK_HALF_EXTENTS.x,
LA_FABRIK_HALF_EXTENTS.z,
),
},
uLaFabrikRotation: { value: LA_FABRIK_ROTATION_Y },
uLaFabrikNoGrassFeather: { value: 1.4 },
},
});
}
+16
View File
@@ -43,6 +43,10 @@ export const grassVertexShader = /* glsl */ `
uniform float uMaxBladeHeight;
uniform float uRandomHeightAmount;
uniform float uSurfaceOffset;
uniform vec2 uLaFabrikCenter;
uniform vec2 uLaFabrikHalfExtents;
uniform float uLaFabrikRotation;
uniform float uLaFabrikNoGrassFeather;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
@@ -132,6 +136,18 @@ export const grassVertexShader = /* glsl */ `
smoothstep(uBoundingBoxMax.z, uBoundingBoxMax.z - 2.0, worldPos.z);
heightModifier *= edgeFade * mix(0.45, 1.0, clumpMask);
vec2 laFabrikDelta = worldPos.xz - uLaFabrikCenter;
float laFabrikCos = cos(-uLaFabrikRotation);
float laFabrikSin = sin(-uLaFabrikRotation);
vec2 laFabrikLocal = vec2(
laFabrikDelta.x * laFabrikCos - laFabrikDelta.y * laFabrikSin,
laFabrikDelta.x * laFabrikSin + laFabrikDelta.y * laFabrikCos
);
vec2 laFabrikDistance = abs(laFabrikLocal) - uLaFabrikHalfExtents;
float laFabrikOutsideDistance = max(laFabrikDistance.x, laFabrikDistance.y);
float laFabrikGrassMask = smoothstep(0.0, uLaFabrikNoGrassFeather, laFabrikOutsideDistance);
heightModifier *= laFabrikGrassMask;
float sideFactor = (color.r == 0.1) ? 1.0 : (color.b == 0.1) ? -1.0 : 0.0;
float tipFactor = color.g;
float width = smoothstep(0.02, uMaxBladeHeight * 0.85, heightModifier) * uBladeWidth * bladeVisibility;
@@ -17,7 +17,8 @@ export function GeneratedMapNodeInstance({
node,
onLoaded,
}: GeneratedMapNodeInstanceProps): React.JSX.Element | null {
const position = useTerrainSnappedPosition(node.position);
const snappedPosition = useTerrainSnappedPosition(node.position);
const position = node.name === "lafabrik" ? node.position : snappedPosition;
const scale = normalizeMapScale(node.scale);
if (node.name === "ecole") {