Compare commits
16 Commits
6d178dc59e
...
ab3943eef3
| Author | SHA1 | Date | |
|---|---|---|---|
| ab3943eef3 | |||
| 4ebb5b8c25 | |||
| a6787a7ecb | |||
| d816e4b07e | |||
| 665d9f9702 | |||
| 0696ca2ae3 | |||
| d6d3d5b685 | |||
| 1c27d55e5a | |||
| fd558db034 | |||
| fbe8c0c854 | |||
| 88b6db6166 | |||
| 2b08665508 | |||
| 4f8355e934 | |||
| 417afdc1d5 | |||
| 235a38f67b | |||
| f54e71fc03 |
@@ -6,12 +6,14 @@ import {
|
||||
DEBUG_GRID_SIZE,
|
||||
DEBUG_GRID_Y,
|
||||
} from "@/data/debug/debugConfig";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { Debug } from "@/utils/debug/Debug";
|
||||
|
||||
export function DebugHelpers(): React.JSX.Element | null {
|
||||
const debug = Debug.getInstance();
|
||||
const sceneMode = useSceneMode();
|
||||
|
||||
if (!debug.active) {
|
||||
if (!debug.active || sceneMode === "game") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { CLOUD_CONFIG } from "@/data/world/cloudConfig";
|
||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
|
||||
interface CloudModelProps {
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
}
|
||||
|
||||
function applyCloudSettings(
|
||||
scene: THREE.Object3D,
|
||||
castShadow: boolean,
|
||||
receiveShadow: boolean,
|
||||
): void {
|
||||
scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = castShadow;
|
||||
child.receiveShadow = receiveShadow;
|
||||
|
||||
const materials = Array.isArray(child.material)
|
||||
? child.material
|
||||
: [child.material];
|
||||
|
||||
for (const material of materials) {
|
||||
material.fog = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function CloudModel({
|
||||
castShadow = false,
|
||||
receiveShadow = false,
|
||||
}: CloudModelProps): React.JSX.Element {
|
||||
const { scene } = useGLTF(CLOUD_CONFIG.modelPath);
|
||||
const maxAnisotropy = useThree((state) =>
|
||||
state.gl.capabilities.getMaxAnisotropy(),
|
||||
);
|
||||
|
||||
const cloud = useMemo(() => {
|
||||
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
||||
const model = scene.clone(true);
|
||||
applyCloudSettings(model, castShadow, receiveShadow);
|
||||
return model;
|
||||
}, [castShadow, maxAnisotropy, receiveShadow, scene]);
|
||||
|
||||
return <primitive object={cloud} />;
|
||||
}
|
||||
|
||||
useGLTF.preload(CLOUD_CONFIG.modelPath);
|
||||
@@ -25,6 +25,23 @@ function applyTerrainMaterialSettings(
|
||||
scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.receiveShadow = receiveShadow;
|
||||
|
||||
const materials = Array.isArray(child.material)
|
||||
? child.material
|
||||
: [child.material];
|
||||
|
||||
for (const material of materials) {
|
||||
const materialWithAlphaMap = material as THREE.Material & {
|
||||
alphaMap?: THREE.Texture | null;
|
||||
};
|
||||
|
||||
material.depthTest = true;
|
||||
material.depthWrite = true;
|
||||
|
||||
if (material.opacity >= 1 && !materialWithAlphaMap.alphaMap) {
|
||||
material.transparent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export const PLAYER_GRAVITY = 30;
|
||||
export const PLAYER_MAX_DELTA = 0.05;
|
||||
export const PLAYER_ACCELERATION_MULTIPLIER = 9;
|
||||
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 = [0, 50, 0];
|
||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const CLOUD_CONFIG = {
|
||||
enabled: true,
|
||||
modelPath: "/models/cloud/model.glb",
|
||||
center: [0, 40, 0] as Vector3Tuple,
|
||||
areaSize: [240, 180] as const,
|
||||
minDriftSpeed: 0.05,
|
||||
wrapPadding: 30,
|
||||
};
|
||||
|
||||
export const CLOUD_DEFAULTS = {
|
||||
count: 10,
|
||||
minHeight: 25,
|
||||
maxHeight: 55,
|
||||
minScale: 5,
|
||||
maxScale: 13,
|
||||
minRotation: 0,
|
||||
maxRotation: Math.PI * 2,
|
||||
minSpeedMultiplier: 0.4,
|
||||
maxSpeedMultiplier: 1,
|
||||
castShadow: false,
|
||||
receiveShadow: false,
|
||||
};
|
||||
|
||||
export const CLOUD_BOUNDS = {
|
||||
count: { min: 0, max: 30, step: 1 },
|
||||
height: { min: 10, max: 100, step: 1 },
|
||||
scale: { min: 1, max: 30, step: 0.5 },
|
||||
rotation: { min: -Math.PI * 2, max: Math.PI * 2, step: 0.1 },
|
||||
speedMultiplier: { min: 0, max: 3, step: 0.1 },
|
||||
};
|
||||
|
||||
export type CloudState = typeof CLOUD_DEFAULTS;
|
||||
@@ -1,12 +1,14 @@
|
||||
export const AMBIENT_LIGHT_COLOR = "#dbeafe";
|
||||
export const SUN_LIGHT_COLOR = "#fff7ed";
|
||||
export const AMBIENT_LIGHT_COLOR = "#dfe7d8";
|
||||
export const SUN_LIGHT_COLOR = "#ffe2bf";
|
||||
|
||||
export const LIGHTING_DEFAULTS = {
|
||||
ambientIntensity: 1.8,
|
||||
sunIntensity: 2.8,
|
||||
sunX: 60,
|
||||
sunY: 80,
|
||||
sunZ: 30,
|
||||
ambientColor: AMBIENT_LIGHT_COLOR,
|
||||
ambientIntensity: 0.9,
|
||||
sunColor: SUN_LIGHT_COLOR,
|
||||
sunIntensity: 2.2,
|
||||
sunX: 70,
|
||||
sunY: 45,
|
||||
sunZ: 35,
|
||||
};
|
||||
|
||||
export const AMBIENT_INTENSITY_MIN = 0;
|
||||
|
||||
@@ -2,7 +2,13 @@ import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface";
|
||||
|
||||
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
||||
export const TERRAIN_SURFACE_COLOR_TOLERANCE = 15;
|
||||
export const TERRAIN_WATER_HEIGHT = 0;
|
||||
export const TERRAIN_SURFACE_PROJECTION = {
|
||||
flipX: false,
|
||||
flipZ: true,
|
||||
offsetX: 0,
|
||||
offsetZ: 0,
|
||||
};
|
||||
export const TERRAIN_WATER_HEIGHT = 0.8;
|
||||
export const TERRAIN_TILE_SIZE = 1;
|
||||
export const GRASS_BASE_COLOR = "#1a3a1a";
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { TERRAIN_WATER_HEIGHT } from "@/data/world/terrainConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface WaterSurfaceConfig {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
size: [number, number];
|
||||
renderOrder: number;
|
||||
}
|
||||
|
||||
export const WATER_SHADER_CONFIG = {
|
||||
enabled: true,
|
||||
height: TERRAIN_WATER_HEIGHT,
|
||||
depthOffset: -0.04,
|
||||
borderRadius: 0.18,
|
||||
borderSoftness: 0.035,
|
||||
scale: 0.4,
|
||||
smoothness: 0.55,
|
||||
edgeThreshold: 0.067,
|
||||
edgeSoftness: 0.01,
|
||||
flowX: 0,
|
||||
flowZ: 0.05,
|
||||
cellSpeed: 0.3,
|
||||
noiseScale: 1.52,
|
||||
noiseFlowSpeed: 0.2,
|
||||
distortAmount: 0.3,
|
||||
deepColor: "#1a3a5c",
|
||||
midColor: "#59c0e8",
|
||||
midPos: 0.084,
|
||||
highlightColor: "#ffffff",
|
||||
opacity: 0.88,
|
||||
deepOpacity: 0.45,
|
||||
};
|
||||
|
||||
export const WATER_STREAMING_CONFIG = {
|
||||
enabled: true,
|
||||
loadDistance: 40,
|
||||
unloadDistance: 48,
|
||||
updateInterval: 350,
|
||||
};
|
||||
|
||||
export const WATER_SURFACES: WaterSurfaceConfig[] = [
|
||||
{
|
||||
position: [40, TERRAIN_WATER_HEIGHT, -102],
|
||||
rotation: [0, 0, 0],
|
||||
size: [75, 45],
|
||||
renderOrder: 0,
|
||||
},
|
||||
];
|
||||
@@ -1,8 +1,8 @@
|
||||
export const WIND_DEFAULTS = {
|
||||
speed: 0.3,
|
||||
direction: Math.PI * 0.25,
|
||||
strength: 1.0,
|
||||
noiseScale: 0.9,
|
||||
speed: 1.5,
|
||||
direction: 0.5584,
|
||||
strength: 1.5,
|
||||
noiseScale: 0.5,
|
||||
};
|
||||
|
||||
export const WIND_BOUNDS = {
|
||||
@@ -13,3 +13,12 @@ export const WIND_BOUNDS = {
|
||||
};
|
||||
|
||||
export type WindState = typeof WIND_DEFAULTS;
|
||||
|
||||
export function getWindVector(wind: WindState): { x: number; z: number } {
|
||||
const intensity = wind.speed * wind.strength;
|
||||
|
||||
return {
|
||||
x: Math.cos(wind.direction) * intensity,
|
||||
z: Math.sin(wind.direction) * intensity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const WORLD_BOUNDS_CONFIG = {
|
||||
enabled: true,
|
||||
center: [0, 0, 0] as Vector3Tuple,
|
||||
planeColor: TERRAIN_COLORS.grass1.hex,
|
||||
planeY: -0.04,
|
||||
planeCollisionThickness: 1,
|
||||
size: [270, 260] as const,
|
||||
wallHeight: 28,
|
||||
wallThickness: 4,
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { CLOUD_BOUNDS } from "@/data/world/cloudConfig";
|
||||
import { WIND_BOUNDS } from "@/data/world/windConfig";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||
|
||||
export function useEnvironmentDebug(): void {
|
||||
useDebugFolder("Dynamic Wind", (folder) => {
|
||||
const { setWind, wind } = useWorldSettingsStore.getState();
|
||||
const controls = { ...wind };
|
||||
|
||||
folder
|
||||
.add(controls, "speed", WIND_BOUNDS.speed.min, WIND_BOUNDS.speed.max)
|
||||
.step(WIND_BOUNDS.speed.step)
|
||||
.name("Wind speed")
|
||||
.onChange((speed: number) => setWind({ speed }));
|
||||
|
||||
folder
|
||||
.add(
|
||||
controls,
|
||||
"direction",
|
||||
WIND_BOUNDS.direction.min,
|
||||
WIND_BOUNDS.direction.max,
|
||||
)
|
||||
.step(WIND_BOUNDS.direction.step)
|
||||
.name("Wind direction")
|
||||
.onChange((direction: number) => setWind({ direction }));
|
||||
|
||||
folder
|
||||
.add(
|
||||
controls,
|
||||
"strength",
|
||||
WIND_BOUNDS.strength.min,
|
||||
WIND_BOUNDS.strength.max,
|
||||
)
|
||||
.step(WIND_BOUNDS.strength.step)
|
||||
.name("Wind strength")
|
||||
.onChange((strength: number) => setWind({ strength }));
|
||||
|
||||
folder
|
||||
.add(
|
||||
controls,
|
||||
"noiseScale",
|
||||
WIND_BOUNDS.noiseScale.min,
|
||||
WIND_BOUNDS.noiseScale.max,
|
||||
)
|
||||
.step(WIND_BOUNDS.noiseScale.step)
|
||||
.name("Wind noise scale")
|
||||
.onChange((noiseScale: number) => setWind({ noiseScale }));
|
||||
});
|
||||
|
||||
useDebugFolder("Environment", (folder) => {
|
||||
const { clouds, graphics, setClouds, setDynamicClouds } =
|
||||
useWorldSettingsStore.getState();
|
||||
const controls = {
|
||||
...clouds,
|
||||
dynamicClouds: graphics.dynamicClouds,
|
||||
};
|
||||
|
||||
folder
|
||||
.add(controls, "dynamicClouds")
|
||||
.name("Clouds")
|
||||
.onChange((dynamicClouds: boolean) => setDynamicClouds(dynamicClouds));
|
||||
|
||||
folder
|
||||
.add(controls, "count", CLOUD_BOUNDS.count.min, CLOUD_BOUNDS.count.max)
|
||||
.step(CLOUD_BOUNDS.count.step)
|
||||
.name("Cloud count")
|
||||
.onChange((count: number) => setClouds({ count }));
|
||||
|
||||
folder
|
||||
.add(controls, "minScale", CLOUD_BOUNDS.scale.min, CLOUD_BOUNDS.scale.max)
|
||||
.step(CLOUD_BOUNDS.scale.step)
|
||||
.name("Cloud min scale")
|
||||
.onChange((minScale: number) => setClouds({ minScale }));
|
||||
|
||||
folder
|
||||
.add(controls, "maxScale", CLOUD_BOUNDS.scale.min, CLOUD_BOUNDS.scale.max)
|
||||
.step(CLOUD_BOUNDS.scale.step)
|
||||
.name("Cloud max scale")
|
||||
.onChange((maxScale: number) => setClouds({ maxScale }));
|
||||
|
||||
folder
|
||||
.add(
|
||||
controls,
|
||||
"minRotation",
|
||||
CLOUD_BOUNDS.rotation.min,
|
||||
CLOUD_BOUNDS.rotation.max,
|
||||
)
|
||||
.step(CLOUD_BOUNDS.rotation.step)
|
||||
.name("Cloud min rotation")
|
||||
.onChange((minRotation: number) => setClouds({ minRotation }));
|
||||
|
||||
folder
|
||||
.add(
|
||||
controls,
|
||||
"maxRotation",
|
||||
CLOUD_BOUNDS.rotation.min,
|
||||
CLOUD_BOUNDS.rotation.max,
|
||||
)
|
||||
.step(CLOUD_BOUNDS.rotation.step)
|
||||
.name("Cloud max rotation")
|
||||
.onChange((maxRotation: number) => setClouds({ maxRotation }));
|
||||
|
||||
folder
|
||||
.add(
|
||||
controls,
|
||||
"minHeight",
|
||||
CLOUD_BOUNDS.height.min,
|
||||
CLOUD_BOUNDS.height.max,
|
||||
)
|
||||
.step(CLOUD_BOUNDS.height.step)
|
||||
.name("Cloud min height")
|
||||
.onChange((minHeight: number) => setClouds({ minHeight }));
|
||||
|
||||
folder
|
||||
.add(
|
||||
controls,
|
||||
"maxHeight",
|
||||
CLOUD_BOUNDS.height.min,
|
||||
CLOUD_BOUNDS.height.max,
|
||||
)
|
||||
.step(CLOUD_BOUNDS.height.step)
|
||||
.name("Cloud max height")
|
||||
.onChange((maxHeight: number) => setClouds({ maxHeight }));
|
||||
|
||||
folder
|
||||
.add(
|
||||
controls,
|
||||
"minSpeedMultiplier",
|
||||
CLOUD_BOUNDS.speedMultiplier.min,
|
||||
CLOUD_BOUNDS.speedMultiplier.max,
|
||||
)
|
||||
.step(CLOUD_BOUNDS.speedMultiplier.step)
|
||||
.name("Cloud min speed")
|
||||
.onChange((minSpeedMultiplier: number) =>
|
||||
setClouds({ minSpeedMultiplier }),
|
||||
);
|
||||
|
||||
folder
|
||||
.add(
|
||||
controls,
|
||||
"maxSpeedMultiplier",
|
||||
CLOUD_BOUNDS.speedMultiplier.min,
|
||||
CLOUD_BOUNDS.speedMultiplier.max,
|
||||
)
|
||||
.step(CLOUD_BOUNDS.speedMultiplier.step)
|
||||
.name("Cloud max speed")
|
||||
.onChange((maxSpeedMultiplier: number) =>
|
||||
setClouds({ maxSpeedMultiplier }),
|
||||
);
|
||||
|
||||
folder
|
||||
.add(controls, "castShadow")
|
||||
.name("Cloud cast shadow")
|
||||
.onChange((castShadow: boolean) => setClouds({ castShadow }));
|
||||
|
||||
folder
|
||||
.add(controls, "receiveShadow")
|
||||
.name("Cloud receive shadow")
|
||||
.onChange((receiveShadow: boolean) => setClouds({ receiveShadow }));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||
import type { CloudState } from "@/data/world/cloudConfig";
|
||||
|
||||
export function useCloudSettings(): CloudState {
|
||||
return useWorldSettingsStore((state) => state.clouds);
|
||||
}
|
||||
|
||||
export function useSetCloudSettings(): (clouds: Partial<CloudState>) => void {
|
||||
return useWorldSettingsStore((state) => state.setClouds);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export function useTerrainSurfaceData(): TerrainSurfaceData | null {
|
||||
return {
|
||||
bounds: createTerrainSurfaceBounds(scene),
|
||||
imageData,
|
||||
raycastTarget: scene,
|
||||
};
|
||||
}, [scene]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
|
||||
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
||||
import {
|
||||
GRAPHICS_DEFAULTS,
|
||||
@@ -6,11 +7,13 @@ import {
|
||||
} from "@/data/world/graphicsConfig";
|
||||
|
||||
interface WorldSettingsState {
|
||||
clouds: CloudState;
|
||||
wind: WindState;
|
||||
graphics: GraphicsState;
|
||||
}
|
||||
|
||||
interface WorldSettingsActions {
|
||||
setClouds: (clouds: Partial<CloudState>) => void;
|
||||
setWind: (wind: Partial<WindState>) => void;
|
||||
setWindSpeed: (speed: number) => void;
|
||||
setWindDirection: (direction: number) => void;
|
||||
@@ -27,6 +30,7 @@ interface WorldSettingsActions {
|
||||
type WorldSettingsStore = WorldSettingsState & WorldSettingsActions;
|
||||
|
||||
const DEFAULT_STATE: WorldSettingsState = {
|
||||
clouds: { ...CLOUD_DEFAULTS },
|
||||
wind: { ...WIND_DEFAULTS },
|
||||
graphics: { ...GRAPHICS_DEFAULTS },
|
||||
};
|
||||
@@ -34,6 +38,11 @@ const DEFAULT_STATE: WorldSettingsState = {
|
||||
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
||||
...DEFAULT_STATE,
|
||||
|
||||
setClouds: (cloudsUpdate) =>
|
||||
set((state) => ({
|
||||
clouds: { ...state.clouds, ...cloudsUpdate },
|
||||
})),
|
||||
|
||||
setWind: (windUpdate) =>
|
||||
set((state) => ({
|
||||
wind: { ...state.wind, ...windUpdate },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type * as THREE from "three";
|
||||
|
||||
export type TerrainSurfaceKind =
|
||||
| "grass"
|
||||
| "path"
|
||||
@@ -20,6 +22,13 @@ export interface TerrainSurfaceBounds {
|
||||
maxZ: number;
|
||||
}
|
||||
|
||||
export interface TerrainSurfaceProjectionConfig {
|
||||
flipX: boolean;
|
||||
flipZ: boolean;
|
||||
offsetX: number;
|
||||
offsetZ: number;
|
||||
}
|
||||
|
||||
export interface TerrainSurfaceColorConfig {
|
||||
hex: string;
|
||||
rgb: TerrainSurfaceRgb;
|
||||
@@ -38,4 +47,5 @@ export interface TerrainSurfaceSample {
|
||||
export interface TerrainSurfaceData {
|
||||
bounds: TerrainSurfaceBounds;
|
||||
imageData: ImageData;
|
||||
raycastTarget: THREE.Object3D;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ interface DebugEvents {
|
||||
|
||||
const DEBUG_FOLDER_ORDER = [
|
||||
"Lighting",
|
||||
"Dynamic Wind",
|
||||
"Environment",
|
||||
"Game",
|
||||
"Interaction",
|
||||
"Hand Tracking",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type * as THREE from "three";
|
||||
import * as THREE from "three";
|
||||
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
|
||||
import type {
|
||||
TerrainSurfaceBounds,
|
||||
TerrainSurfaceProjectionConfig,
|
||||
TerrainSurfaceRgb,
|
||||
TerrainSurfaceSample,
|
||||
TerrainSurfaceUv,
|
||||
@@ -14,11 +15,14 @@ type TerrainSurfaceImageSource =
|
||||
| ImageBitmap;
|
||||
|
||||
const imageDataCache = new WeakMap<TerrainSurfaceImageSource, ImageData>();
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.min(Math.max(value, 0), 1);
|
||||
}
|
||||
|
||||
function wrap01(value: number): number {
|
||||
return ((value % 1) + 1) % 1;
|
||||
}
|
||||
|
||||
function isTerrainSurfaceImageSource(
|
||||
value: unknown,
|
||||
): value is TerrainSurfaceImageSource {
|
||||
@@ -83,13 +87,27 @@ export function terrainSurfaceUvFromXZ(
|
||||
x: number,
|
||||
z: number,
|
||||
bounds: TerrainSurfaceBounds,
|
||||
projection?: TerrainSurfaceProjectionConfig,
|
||||
): TerrainSurfaceUv {
|
||||
const width = bounds.maxX - bounds.minX;
|
||||
const depth = bounds.maxZ - bounds.minZ;
|
||||
let u = width === 0 ? 0 : x / width + 0.5;
|
||||
let v = depth === 0 ? 0 : z / depth + 0.5;
|
||||
|
||||
if (projection?.flipX) {
|
||||
u = 1 - u;
|
||||
}
|
||||
|
||||
if (projection?.flipZ) {
|
||||
v = 1 - v;
|
||||
}
|
||||
|
||||
u = wrap01(u + (projection?.offsetX ?? 0));
|
||||
v = wrap01(v + (projection?.offsetZ ?? 0));
|
||||
|
||||
return {
|
||||
u: width === 0 ? 0 : (x - bounds.minX) / width,
|
||||
v: depth === 0 ? 0 : (z - bounds.minZ) / depth,
|
||||
u,
|
||||
v,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,9 +116,10 @@ export function sampleTerrainSurfaceAtXZ(
|
||||
x: number,
|
||||
z: number,
|
||||
bounds: TerrainSurfaceBounds,
|
||||
projection?: TerrainSurfaceProjectionConfig,
|
||||
): TerrainSurfaceSample {
|
||||
return sampleTerrainSurfaceAtUv(
|
||||
imageData,
|
||||
terrainSurfaceUvFromXZ(x, z, bounds),
|
||||
terrainSurfaceUvFromXZ(x, z, bounds, projection),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,14 @@ import {
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
import { GameMapCollision } from "@/world/GameMapCollision";
|
||||
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
||||
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
||||
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
|
||||
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
||||
import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig";
|
||||
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
||||
import { WaterSystem } from "@/world/water/WaterSystem";
|
||||
import { WorldPlane } from "@/world/WorldPlane";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||
@@ -257,6 +260,9 @@ export function GameMap({
|
||||
))}
|
||||
</group>
|
||||
<MapInstancingSystem />
|
||||
<WorldPlane />
|
||||
<WaterSystem />
|
||||
<CloudSystem />
|
||||
<VegetationSystem />
|
||||
{isMapModelVisible("terrain", { groups, models }) ? (
|
||||
<TerrainModel />
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as THREE from "three";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
|
||||
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
|
||||
import type { MapNode } from "@/types/editor/editor";
|
||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
@@ -173,6 +174,7 @@ export function GameMapCollision({
|
||||
|
||||
return (
|
||||
<group ref={groupRef} visible={false}>
|
||||
{mapReady ? <WorldBoundsCollision /> : null}
|
||||
{mapReady
|
||||
? collisionNodes.map((mapNode, index) => (
|
||||
<CollisionErrorBoundary
|
||||
|
||||
+10
-6
@@ -5,12 +5,10 @@ import {
|
||||
AMBIENT_INTENSITY_MAX,
|
||||
AMBIENT_INTENSITY_MIN,
|
||||
AMBIENT_INTENSITY_STEP,
|
||||
AMBIENT_LIGHT_COLOR,
|
||||
LIGHTING_DEFAULTS,
|
||||
SUN_INTENSITY_MAX,
|
||||
SUN_INTENSITY_MIN,
|
||||
SUN_INTENSITY_STEP,
|
||||
SUN_LIGHT_COLOR,
|
||||
SUN_X_MAX,
|
||||
SUN_X_MIN,
|
||||
SUN_X_STEP,
|
||||
@@ -24,12 +22,14 @@ import {
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
|
||||
const SHADOW_MAP_SIZE = 2048;
|
||||
const SHADOW_CAMERA_SIZE = 100;
|
||||
const SHADOW_CAMERA_SIZE = 170;
|
||||
const SHADOW_CAMERA_NEAR = 0.5;
|
||||
const SHADOW_CAMERA_FAR = 200;
|
||||
const SHADOW_CAMERA_FAR = 300;
|
||||
|
||||
type LightingState = {
|
||||
ambientColor: string;
|
||||
ambientIntensity: number;
|
||||
sunColor: string;
|
||||
sunIntensity: number;
|
||||
sunX: number;
|
||||
sunY: number;
|
||||
@@ -57,6 +57,7 @@ export function Lighting(): React.JSX.Element {
|
||||
}, []);
|
||||
|
||||
useDebugFolder("Lighting", (folder) => {
|
||||
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
||||
folder
|
||||
.add(
|
||||
LIGHTING_STATE,
|
||||
@@ -75,6 +76,7 @@ export function Lighting(): React.JSX.Element {
|
||||
SUN_INTENSITY_STEP,
|
||||
)
|
||||
.name("Sun Intensity");
|
||||
folder.addColor(LIGHTING_STATE, "sunColor").name("Sun Color");
|
||||
folder
|
||||
.add(LIGHTING_STATE, "sunX", SUN_X_MIN, SUN_X_MAX, SUN_X_STEP)
|
||||
.name("Sun X");
|
||||
@@ -88,6 +90,7 @@ export function Lighting(): React.JSX.Element {
|
||||
|
||||
useFrame(() => {
|
||||
if (ambient.current) {
|
||||
ambient.current.color.set(LIGHTING_STATE.ambientColor);
|
||||
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
||||
}
|
||||
|
||||
@@ -97,6 +100,7 @@ export function Lighting(): React.JSX.Element {
|
||||
LIGHTING_STATE.sunY,
|
||||
LIGHTING_STATE.sunZ,
|
||||
);
|
||||
sun.current.color.set(LIGHTING_STATE.sunColor);
|
||||
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
||||
}
|
||||
});
|
||||
@@ -106,7 +110,7 @@ export function Lighting(): React.JSX.Element {
|
||||
<ambientLight
|
||||
ref={ambient}
|
||||
intensity={LIGHTING_STATE.ambientIntensity}
|
||||
color={AMBIENT_LIGHT_COLOR}
|
||||
color={LIGHTING_STATE.ambientColor}
|
||||
/>
|
||||
<directionalLight
|
||||
ref={sun}
|
||||
@@ -116,7 +120,7 @@ export function Lighting(): React.JSX.Element {
|
||||
LIGHTING_STATE.sunZ,
|
||||
]}
|
||||
intensity={LIGHTING_STATE.sunIntensity}
|
||||
color={SUN_LIGHT_COLOR}
|
||||
color={LIGHTING_STATE.sunColor}
|
||||
castShadow
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
@@ -36,6 +37,7 @@ interface WorldProps {
|
||||
}
|
||||
|
||||
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
useEnvironmentDebug();
|
||||
useMapPerformanceDebug();
|
||||
|
||||
const cameraMode = useCameraMode();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { WORLD_BOUNDS_CONFIG } from "@/data/world/worldBoundsConfig";
|
||||
|
||||
export function WorldPlane(): React.JSX.Element | null {
|
||||
if (!WORLD_BOUNDS_CONFIG.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { center, planeColor, planeY, size } = WORLD_BOUNDS_CONFIG;
|
||||
|
||||
return (
|
||||
<mesh
|
||||
name="world-plane"
|
||||
position={[center[0], planeY, center[2]]}
|
||||
receiveShadow
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<planeGeometry args={size} />
|
||||
<meshStandardMaterial color={planeColor} roughness={1} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Suspense, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { CLOUD_CONFIG } from "@/data/world/cloudConfig";
|
||||
import { getWindVector } from "@/data/world/windConfig";
|
||||
import { useDynamicClouds } from "@/hooks/world/useGraphicsSettings";
|
||||
import { useCloudSettings } from "@/hooks/world/useCloudSettings";
|
||||
import { useWind } from "@/hooks/world/useWind";
|
||||
import { CloudModel } from "@/components/three/world/CloudModel";
|
||||
import type { CloudState } from "@/data/world/cloudConfig";
|
||||
|
||||
interface CloudInstance {
|
||||
height: number;
|
||||
rotationY: number;
|
||||
scale: number;
|
||||
speedMultiplier: number;
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
function lerp(min: number, max: number, ratio: number): number {
|
||||
return min + (max - min) * ratio;
|
||||
}
|
||||
|
||||
function createCloudInstances(cloudSettings: CloudState): CloudInstance[] {
|
||||
const instances: CloudInstance[] = [];
|
||||
const [areaWidth, areaDepth] = CLOUD_CONFIG.areaSize;
|
||||
const count = Math.max(0, Math.round(cloudSettings.count));
|
||||
const columns = Math.ceil(Math.sqrt(count));
|
||||
const rows = columns > 0 ? Math.ceil(count / columns) : 0;
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
const columnRatio = columns <= 1 ? 0.5 : column / (columns - 1);
|
||||
const rowRatio = rows <= 1 ? 0.5 : row / (rows - 1);
|
||||
const variation = ((index * 37) % 100) / 100;
|
||||
|
||||
instances.push({
|
||||
height: lerp(cloudSettings.minHeight, cloudSettings.maxHeight, variation),
|
||||
rotationY: lerp(
|
||||
cloudSettings.minRotation,
|
||||
cloudSettings.maxRotation,
|
||||
variation,
|
||||
),
|
||||
scale: lerp(cloudSettings.minScale, cloudSettings.maxScale, variation),
|
||||
speedMultiplier: lerp(
|
||||
cloudSettings.minSpeedMultiplier,
|
||||
cloudSettings.maxSpeedMultiplier,
|
||||
((index * 53) % 100) / 100,
|
||||
),
|
||||
x: CLOUD_CONFIG.center[0] + (columnRatio - 0.5) * areaWidth,
|
||||
z: CLOUD_CONFIG.center[2] + (rowRatio - 0.5) * areaDepth,
|
||||
});
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
function wrapAxis(
|
||||
value: number,
|
||||
center: number,
|
||||
size: number,
|
||||
padding: number,
|
||||
): number {
|
||||
const min = center - size / 2 - padding;
|
||||
const max = center + size / 2 + padding;
|
||||
const range = max - min;
|
||||
|
||||
if (value < min) return value + range;
|
||||
if (value > max) return value - range;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function CloudSystem(): React.JSX.Element | null {
|
||||
const cloudSettings = useCloudSettings();
|
||||
const dynamicClouds = useDynamicClouds();
|
||||
const wind = useWind();
|
||||
const refs = useRef<Array<THREE.Group | null>>([]);
|
||||
const clouds = useMemo(
|
||||
() => createCloudInstances(cloudSettings),
|
||||
[cloudSettings],
|
||||
);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const windVector = getWindVector(wind);
|
||||
const windLength = Math.hypot(windVector.x, windVector.z);
|
||||
const safeWindLength = Math.max(windLength, CLOUD_CONFIG.minDriftSpeed);
|
||||
const directionX =
|
||||
windLength > 0 ? windVector.x / windLength : Math.cos(wind.direction);
|
||||
const directionZ =
|
||||
windLength > 0 ? windVector.z / windLength : Math.sin(wind.direction);
|
||||
|
||||
refs.current.forEach((cloud, index) => {
|
||||
if (!cloud) return;
|
||||
|
||||
const instance = clouds[index];
|
||||
if (!instance) return;
|
||||
|
||||
const distance = safeWindLength * instance.speedMultiplier * delta;
|
||||
cloud.position.x = wrapAxis(
|
||||
cloud.position.x + directionX * distance,
|
||||
CLOUD_CONFIG.center[0],
|
||||
CLOUD_CONFIG.areaSize[0],
|
||||
CLOUD_CONFIG.wrapPadding,
|
||||
);
|
||||
cloud.position.z = wrapAxis(
|
||||
cloud.position.z + directionZ * distance,
|
||||
CLOUD_CONFIG.center[2],
|
||||
CLOUD_CONFIG.areaSize[1],
|
||||
CLOUD_CONFIG.wrapPadding,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (!CLOUD_CONFIG.enabled || !dynamicClouds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<group name="cloud-system">
|
||||
{clouds.map((cloud, index) => (
|
||||
<group
|
||||
key={index}
|
||||
ref={(node) => {
|
||||
refs.current[index] = node;
|
||||
}}
|
||||
position={[cloud.x, cloud.height, cloud.z]}
|
||||
rotation={[0, cloud.rotationY, 0]}
|
||||
scale={cloud.scale}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<CloudModel
|
||||
castShadow={cloudSettings.castShadow}
|
||||
receiveShadow={cloudSettings.receiveShadow}
|
||||
/>
|
||||
</Suspense>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { WorldPlaneCollision } from "@/world/collision/WorldPlaneCollision";
|
||||
import { WorldWallsCollision } from "@/world/collision/WorldWallsCollision";
|
||||
|
||||
export function WorldBoundsCollision(): React.JSX.Element {
|
||||
return (
|
||||
<group name="world-bounds-collision">
|
||||
<WorldPlaneCollision />
|
||||
<WorldWallsCollision />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { WORLD_BOUNDS_CONFIG } from "@/data/world/worldBoundsConfig";
|
||||
|
||||
export function WorldPlaneCollision(): React.JSX.Element | null {
|
||||
if (!WORLD_BOUNDS_CONFIG.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { center, planeCollisionThickness, planeY, size } = WORLD_BOUNDS_CONFIG;
|
||||
const [width, depth] = size;
|
||||
|
||||
return (
|
||||
<mesh
|
||||
name="world-plane-collision"
|
||||
position={[center[0], planeY - planeCollisionThickness / 2, center[2]]}
|
||||
>
|
||||
<boxGeometry args={[width, planeCollisionThickness, depth]} />
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { WORLD_BOUNDS_CONFIG } from "@/data/world/worldBoundsConfig";
|
||||
|
||||
export function WorldWallsCollision(): React.JSX.Element | null {
|
||||
if (!WORLD_BOUNDS_CONFIG.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { center, size, wallHeight, wallThickness } = WORLD_BOUNDS_CONFIG;
|
||||
const [width, depth] = size;
|
||||
const wallY = center[1] + wallHeight / 2;
|
||||
const halfWidth = width / 2;
|
||||
const halfDepth = depth / 2;
|
||||
|
||||
return (
|
||||
<group name="world-walls-collision">
|
||||
<mesh position={[center[0], wallY, center[2] - halfDepth]}>
|
||||
<boxGeometry
|
||||
args={[width + wallThickness * 2, wallHeight, wallThickness]}
|
||||
/>
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
<mesh position={[center[0], wallY, center[2] + halfDepth]}>
|
||||
<boxGeometry
|
||||
args={[width + wallThickness * 2, wallHeight, wallThickness]}
|
||||
/>
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
<mesh position={[center[0] - halfWidth, wallY, center[2]]}>
|
||||
<boxGeometry args={[wallThickness, wallHeight, depth]} />
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
<mesh position={[center[0] + halfWidth, wallY, center[2]]}>
|
||||
<boxGeometry args={[wallThickness, wallHeight, depth]} />
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset";
|
||||
import {
|
||||
PATH_DEBUG_PREVIEW_ENABLED,
|
||||
PATH_TILE_RENDER_ENABLED,
|
||||
PATH_TILE_MODEL_PATH,
|
||||
} from "@/world/paths/pathConfig";
|
||||
import { usePathTileData } from "@/world/paths/usePathTileData";
|
||||
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
|
||||
|
||||
export function PathSystem(): React.JSX.Element | null {
|
||||
if (!PATH_DEBUG_PREVIEW_ENABLED && !PATH_TILE_RENDER_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <PathTiles />;
|
||||
}
|
||||
|
||||
function PathTiles(): React.JSX.Element | null {
|
||||
const pathTiles = usePathTileData();
|
||||
|
||||
if (pathTiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PATH_DEBUG_PREVIEW_ENABLED) {
|
||||
return <PathDebugPreview instances={pathTiles} />;
|
||||
}
|
||||
|
||||
if (!PATH_TILE_RENDER_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InstancedMapAsset
|
||||
castShadow={false}
|
||||
instances={pathTiles}
|
||||
modelPath={PATH_TILE_MODEL_PATH}
|
||||
receiveShadow
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PathDebugPreview({
|
||||
instances,
|
||||
}: {
|
||||
instances: MapAssetInstance[];
|
||||
}): React.JSX.Element {
|
||||
const instancedMeshRef = useRef<THREE.InstancedMesh>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const instancedMesh = instancedMeshRef.current;
|
||||
if (!instancedMesh) return;
|
||||
|
||||
const matrix = new THREE.Matrix4();
|
||||
const position = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scale = new THREE.Vector3(1, 1, 1);
|
||||
|
||||
for (let i = 0; i < instances.length; i++) {
|
||||
const instance = instances[i];
|
||||
if (!instance) continue;
|
||||
|
||||
position.set(
|
||||
instance.position[0],
|
||||
instance.position[1] + 0.08,
|
||||
instance.position[2],
|
||||
);
|
||||
matrix.compose(position, quaternion, scale);
|
||||
instancedMesh.setMatrixAt(i, matrix);
|
||||
}
|
||||
|
||||
instancedMesh.instanceMatrix.needsUpdate = true;
|
||||
instancedMesh.computeBoundingSphere();
|
||||
}, [instances]);
|
||||
|
||||
return (
|
||||
<instancedMesh
|
||||
ref={instancedMeshRef}
|
||||
args={[undefined, undefined, instances.length]}
|
||||
>
|
||||
<boxGeometry args={[0.35, 0.08, 0.35]} />
|
||||
<meshBasicMaterial color="#ff00ff" />
|
||||
</instancedMesh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { TERRAIN_COLORS, TERRAIN_TILE_SIZE } from "@/data/world/terrainConfig";
|
||||
|
||||
export const PATH_SURFACE_KEY = "chemin";
|
||||
export const PATH_DEBUG_PREVIEW_ENABLED = false;
|
||||
export const PATH_TILE_RENDER_ENABLED = false;
|
||||
export const PATH_TILE_MODEL_PATH = TERRAIN_COLORS.chemin.modelPath;
|
||||
export const PATH_TILE_SIZE =
|
||||
TERRAIN_COLORS.chemin.tileSize ?? TERRAIN_TILE_SIZE;
|
||||
export const PATH_TILE_SAMPLE_STEP = 2;
|
||||
export const PATH_TILE_MAX_COUNT = 1500;
|
||||
export const PATH_TILE_ROTATION = [0, 0, 0] as const;
|
||||
export const PATH_TILE_SCALE = [1, 1, 1] as const;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMemo } from "react";
|
||||
import { TERRAIN_SURFACE_PROJECTION } from "@/data/world/terrainConfig";
|
||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||
import { useTerrainSurfaceData } from "@/hooks/world/useTerrainSurfaceData";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { sampleTerrainSurfaceAtXZ } from "@/utils/world/terrainSurfaceSampler";
|
||||
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
|
||||
import {
|
||||
PATH_TILE_MAX_COUNT,
|
||||
PATH_SURFACE_KEY,
|
||||
PATH_TILE_ROTATION,
|
||||
PATH_TILE_SAMPLE_STEP,
|
||||
PATH_TILE_SCALE,
|
||||
} from "@/world/paths/pathConfig";
|
||||
|
||||
function createSampleCenters(min: number, max: number, step: number): number[] {
|
||||
const start = Math.ceil(min / step) * step + step * 0.5;
|
||||
const centers: number[] = [];
|
||||
|
||||
for (let value = start; value <= max; value += step) {
|
||||
centers.push(value);
|
||||
}
|
||||
|
||||
return centers;
|
||||
}
|
||||
|
||||
export function usePathTileData(): MapAssetInstance[] {
|
||||
const terrainSurfaceData = useTerrainSurfaceData();
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!terrainSurfaceData) return [];
|
||||
|
||||
const instances: MapAssetInstance[] = [];
|
||||
const xCenters = createSampleCenters(
|
||||
terrainSurfaceData.bounds.minX,
|
||||
terrainSurfaceData.bounds.maxX,
|
||||
PATH_TILE_SAMPLE_STEP,
|
||||
);
|
||||
const zCenters = createSampleCenters(
|
||||
terrainSurfaceData.bounds.minZ,
|
||||
terrainSurfaceData.bounds.maxZ,
|
||||
PATH_TILE_SAMPLE_STEP,
|
||||
);
|
||||
|
||||
for (const x of xCenters) {
|
||||
for (const z of zCenters) {
|
||||
if (instances.length >= PATH_TILE_MAX_COUNT) return instances;
|
||||
|
||||
const sample = sampleTerrainSurfaceAtXZ(
|
||||
terrainSurfaceData.imageData,
|
||||
x,
|
||||
z,
|
||||
terrainSurfaceData.bounds,
|
||||
TERRAIN_SURFACE_PROJECTION,
|
||||
);
|
||||
|
||||
if (sample.key !== PATH_SURFACE_KEY) continue;
|
||||
|
||||
const height = terrainHeight.getHeight(x, z) ?? 0;
|
||||
|
||||
instances.push({
|
||||
position: [x, height, z],
|
||||
rotation: [...PATH_TILE_ROTATION] as Vector3Tuple,
|
||||
scale: [...PATH_TILE_SCALE] as Vector3Tuple,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return instances;
|
||||
}, [terrainHeight, terrainSurfaceData]);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
PLAYER_AIR_CONTROL_FACTOR,
|
||||
PLAYER_CAPSULE_RADIUS,
|
||||
PLAYER_EYE_HEIGHT,
|
||||
PLAYER_FALL_RESPAWN_DELAY,
|
||||
PLAYER_FALL_RESPAWN_Y,
|
||||
PLAYER_GRAVITY,
|
||||
PLAYER_JUMP_SPEED,
|
||||
PLAYER_MAX_DELTA,
|
||||
@@ -57,6 +59,22 @@ const _up = new THREE.Vector3(0, 1, 0);
|
||||
const _translateVec = new THREE.Vector3();
|
||||
const _collisionCorrection = new THREE.Vector3();
|
||||
|
||||
function resetPlayerCapsule(
|
||||
capsule: Capsule,
|
||||
spawnPosition: Vector3Tuple,
|
||||
camera: THREE.Camera,
|
||||
velocity: THREE.Vector3,
|
||||
): void {
|
||||
capsule.start.set(
|
||||
spawnPosition[0],
|
||||
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
|
||||
spawnPosition[2],
|
||||
);
|
||||
capsule.end.set(...spawnPosition);
|
||||
velocity.set(0, 0, 0);
|
||||
camera.position.copy(capsule.end);
|
||||
}
|
||||
|
||||
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
|
||||
return new Capsule(
|
||||
new THREE.Vector3(
|
||||
@@ -104,6 +122,7 @@ export function PlayerController({
|
||||
const movementLockedRef = useRef(movementLocked);
|
||||
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
|
||||
const velocity = useRef(new THREE.Vector3());
|
||||
const fallDuration = useRef(0);
|
||||
const onFloor = useRef(false);
|
||||
const wantsJump = useRef(false);
|
||||
const initializedRef = useRef(false);
|
||||
@@ -112,16 +131,15 @@ export function PlayerController({
|
||||
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
capsule.current.start.set(
|
||||
spawnPosition[0],
|
||||
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
|
||||
spawnPosition[2],
|
||||
resetPlayerCapsule(
|
||||
capsule.current,
|
||||
spawnPosition,
|
||||
camera,
|
||||
velocity.current,
|
||||
);
|
||||
capsule.current.end.set(...spawnPosition);
|
||||
velocity.current.set(0, 0, 0);
|
||||
fallDuration.current = 0;
|
||||
onFloor.current = false;
|
||||
wantsJump.current = false;
|
||||
camera.position.copy(capsule.current.end);
|
||||
initializedRef.current = true;
|
||||
}, [camera, spawnPosition]);
|
||||
|
||||
@@ -211,6 +229,27 @@ export function PlayerController({
|
||||
useFrame((_, delta) => {
|
||||
if (!initializedRef.current) return;
|
||||
|
||||
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
||||
|
||||
if (capsule.current.end.y < PLAYER_FALL_RESPAWN_Y) {
|
||||
fallDuration.current += dt;
|
||||
|
||||
if (fallDuration.current >= PLAYER_FALL_RESPAWN_DELAY) {
|
||||
resetPlayerCapsule(
|
||||
capsule.current,
|
||||
spawnPosition,
|
||||
camera,
|
||||
velocity.current,
|
||||
);
|
||||
fallDuration.current = 0;
|
||||
onFloor.current = false;
|
||||
wantsJump.current = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
fallDuration.current = 0;
|
||||
}
|
||||
|
||||
if (isPlayerInputLocked() || !canMove) {
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
velocity.current.set(0, 0, 0);
|
||||
@@ -218,8 +257,6 @@ export function PlayerController({
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
||||
|
||||
camera.getWorldDirection(_forward);
|
||||
_forward.setY(0);
|
||||
if (_forward.lengthSq() > 0) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { FOG_CONFIG } from "@/data/world/fogConfig";
|
||||
import { getWindVector } from "@/data/world/windConfig";
|
||||
import { WATER_SHADER_CONFIG } from "@/data/world/waterConfig";
|
||||
import type { WaterSurfaceConfig } from "@/data/world/waterConfig";
|
||||
import { useWind } from "@/hooks/world/useWind";
|
||||
import {
|
||||
WATER_FRAGMENT_SHADER,
|
||||
WATER_VERTEX_SHADER,
|
||||
} from "@/world/water/waterShaders";
|
||||
|
||||
export function WaterSurface({
|
||||
position,
|
||||
renderOrder,
|
||||
rotation,
|
||||
size,
|
||||
}: WaterSurfaceConfig): React.JSX.Element {
|
||||
const scene = useThree((state) => state.scene);
|
||||
const materialRef = useRef<THREE.ShaderMaterial>(null);
|
||||
const wind = useWind();
|
||||
const uniforms = useMemo(
|
||||
() => ({
|
||||
uTime: { value: 0 },
|
||||
uScale: { value: WATER_SHADER_CONFIG.scale },
|
||||
uSmoothness: { value: WATER_SHADER_CONFIG.smoothness },
|
||||
uEdgeThreshold: { value: WATER_SHADER_CONFIG.edgeThreshold },
|
||||
uEdgeSoftness: { value: WATER_SHADER_CONFIG.edgeSoftness },
|
||||
uFlowX: { value: WATER_SHADER_CONFIG.flowX },
|
||||
uFlowZ: { value: WATER_SHADER_CONFIG.flowZ },
|
||||
uCellSpeed: { value: WATER_SHADER_CONFIG.cellSpeed },
|
||||
uNoiseScale: { value: WATER_SHADER_CONFIG.noiseScale },
|
||||
uNoiseFlowSpeed: { value: WATER_SHADER_CONFIG.noiseFlowSpeed },
|
||||
uDistortAmount: { value: WATER_SHADER_CONFIG.distortAmount },
|
||||
uBorderRadius: { value: WATER_SHADER_CONFIG.borderRadius },
|
||||
uBorderSoftness: { value: WATER_SHADER_CONFIG.borderSoftness },
|
||||
uDeepColor: { value: new THREE.Color(WATER_SHADER_CONFIG.deepColor) },
|
||||
uMidColor: { value: new THREE.Color(WATER_SHADER_CONFIG.midColor) },
|
||||
uMidPos: { value: WATER_SHADER_CONFIG.midPos },
|
||||
uHighlight: {
|
||||
value: new THREE.Color(WATER_SHADER_CONFIG.highlightColor),
|
||||
},
|
||||
uOpacity: { value: WATER_SHADER_CONFIG.opacity },
|
||||
uDeepOpacity: { value: WATER_SHADER_CONFIG.deepOpacity },
|
||||
uFogEnabled: { value: 0 },
|
||||
uFogNear: { value: FOG_CONFIG.near },
|
||||
uFogFar: { value: FOG_CONFIG.far },
|
||||
uFogColor: { value: new THREE.Color(FOG_CONFIG.color) },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const material = materialRef.current;
|
||||
if (!material) return;
|
||||
|
||||
const windVector = getWindVector(wind);
|
||||
|
||||
const {
|
||||
uFlowX,
|
||||
uFlowZ,
|
||||
uFogColor,
|
||||
uFogEnabled,
|
||||
uFogFar,
|
||||
uFogNear,
|
||||
uNoiseScale,
|
||||
uTime,
|
||||
} = material.uniforms;
|
||||
|
||||
if (uTime) uTime.value = clock.getElapsedTime();
|
||||
if (uFlowX) uFlowX.value = WATER_SHADER_CONFIG.flowX + windVector.x;
|
||||
if (uFlowZ) uFlowZ.value = WATER_SHADER_CONFIG.flowZ + windVector.z;
|
||||
if (uNoiseScale) {
|
||||
uNoiseScale.value = WATER_SHADER_CONFIG.noiseScale * wind.noiseScale;
|
||||
}
|
||||
|
||||
if (scene.fog instanceof THREE.Fog) {
|
||||
if (uFogEnabled) uFogEnabled.value = 1;
|
||||
if (uFogNear) uFogNear.value = scene.fog.near;
|
||||
if (uFogFar) uFogFar.value = scene.fog.far;
|
||||
if (uFogColor) uFogColor.value.copy(scene.fog.color);
|
||||
} else if (uFogEnabled) {
|
||||
uFogEnabled.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh
|
||||
position={[
|
||||
position[0],
|
||||
position[1] + WATER_SHADER_CONFIG.depthOffset,
|
||||
position[2],
|
||||
]}
|
||||
rotation={[-Math.PI / 2 + rotation[0], rotation[1], rotation[2]]}
|
||||
renderOrder={renderOrder}
|
||||
>
|
||||
<planeGeometry args={size} />
|
||||
<shaderMaterial
|
||||
ref={materialRef}
|
||||
attach="material"
|
||||
depthTest
|
||||
depthWrite={false}
|
||||
fragmentShader={WATER_FRAGMENT_SHADER}
|
||||
side={THREE.FrontSide}
|
||||
transparent
|
||||
uniforms={uniforms}
|
||||
vertexShader={WATER_VERTEX_SHADER}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
WATER_SHADER_CONFIG,
|
||||
WATER_STREAMING_CONFIG,
|
||||
WATER_SURFACES,
|
||||
} from "@/data/world/waterConfig";
|
||||
import type { WaterSurfaceConfig } from "@/data/world/waterConfig";
|
||||
import { WaterSurface } from "@/world/water/WaterSurface";
|
||||
|
||||
function getDistanceToWaterSurface(
|
||||
surface: WaterSurfaceConfig,
|
||||
x: number,
|
||||
z: number,
|
||||
): number {
|
||||
const halfWidth = surface.size[0] / 2;
|
||||
const halfDepth = surface.size[1] / 2;
|
||||
const distanceX = Math.max(Math.abs(x - surface.position[0]) - halfWidth, 0);
|
||||
const distanceZ = Math.max(Math.abs(z - surface.position[2]) - halfDepth, 0);
|
||||
|
||||
return Math.hypot(distanceX, distanceZ);
|
||||
}
|
||||
|
||||
export function WaterSystem(): React.JSX.Element | null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const lastUpdateRef = useRef(-WATER_STREAMING_CONFIG.updateInterval);
|
||||
const [activeSurfaceIndexes, setActiveSurfaceIndexes] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const updateActiveSurfaces = useCallback(() => {
|
||||
const nextIndexes = new Set<number>();
|
||||
const cameraX = camera.position.x;
|
||||
const cameraZ = camera.position.z;
|
||||
|
||||
WATER_SURFACES.forEach((surface, index) => {
|
||||
const distance = getDistanceToWaterSurface(surface, cameraX, cameraZ);
|
||||
const wasActive = activeSurfaceIndexes.has(index);
|
||||
const radius = wasActive
|
||||
? WATER_STREAMING_CONFIG.unloadDistance
|
||||
: WATER_STREAMING_CONFIG.loadDistance;
|
||||
|
||||
if (distance <= radius) {
|
||||
nextIndexes.add(index);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
nextIndexes.size === activeSurfaceIndexes.size &&
|
||||
[...nextIndexes].every((index) => activeSurfaceIndexes.has(index))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveSurfaceIndexes(nextIndexes);
|
||||
}, [activeSurfaceIndexes, camera]);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!WATER_STREAMING_CONFIG.enabled) return;
|
||||
|
||||
const now = clock.elapsedTime * 1000;
|
||||
if (now - lastUpdateRef.current < WATER_STREAMING_CONFIG.updateInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastUpdateRef.current = now;
|
||||
updateActiveSurfaces();
|
||||
});
|
||||
|
||||
if (!WATER_SHADER_CONFIG.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleSurfaces = WATER_SURFACES.map((surface, index) => ({
|
||||
index,
|
||||
surface,
|
||||
})).filter(({ index, surface }) => {
|
||||
if (!WATER_STREAMING_CONFIG.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (activeSurfaceIndexes.size > 0) {
|
||||
return activeSurfaceIndexes.has(index);
|
||||
}
|
||||
|
||||
return (
|
||||
getDistanceToWaterSurface(
|
||||
surface,
|
||||
camera.position.x,
|
||||
camera.position.z,
|
||||
) <= WATER_STREAMING_CONFIG.loadDistance
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<group name="water-system">
|
||||
{visibleSurfaces.map(({ index, surface }) => (
|
||||
<WaterSurface key={index} {...surface} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
export const WATER_VERTEX_SHADER = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
varying vec3 vWorldPosition;
|
||||
varying vec2 vWorldPos;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||
vWorldPosition = worldPosition.xyz;
|
||||
vWorldPos = worldPosition.xz;
|
||||
gl_Position = projectionMatrix * viewMatrix * worldPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
export const WATER_FRAGMENT_SHADER = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uScale;
|
||||
uniform float uSmoothness;
|
||||
uniform float uEdgeThreshold;
|
||||
uniform float uEdgeSoftness;
|
||||
uniform float uFlowX;
|
||||
uniform float uFlowZ;
|
||||
uniform float uCellSpeed;
|
||||
uniform float uNoiseScale;
|
||||
uniform float uNoiseFlowSpeed;
|
||||
uniform float uDistortAmount;
|
||||
uniform float uBorderRadius;
|
||||
uniform float uBorderSoftness;
|
||||
uniform vec3 uDeepColor;
|
||||
uniform vec3 uMidColor;
|
||||
uniform float uMidPos;
|
||||
uniform vec3 uHighlight;
|
||||
uniform float uOpacity;
|
||||
uniform float uDeepOpacity;
|
||||
uniform float uFogEnabled;
|
||||
uniform float uFogNear;
|
||||
uniform float uFogFar;
|
||||
uniform vec3 uFogColor;
|
||||
|
||||
varying vec2 vUv;
|
||||
varying vec3 vWorldPosition;
|
||||
varying vec2 vWorldPos;
|
||||
|
||||
float roundedBoxMask(vec2 uv, float radius, float softness) {
|
||||
vec2 centeredUv = uv * 2.0 - 1.0;
|
||||
vec2 boxSize = vec2(1.0 - radius);
|
||||
vec2 distanceToEdge = abs(centeredUv) - boxSize;
|
||||
float outsideDistance = length(max(distanceToEdge, 0.0)) - radius;
|
||||
|
||||
return 1.0 - smoothstep(-softness, softness, outsideDistance);
|
||||
}
|
||||
|
||||
vec2 hash2(vec2 p) {
|
||||
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
|
||||
return fract(sin(p) * 43758.5453);
|
||||
}
|
||||
|
||||
float smin(float a, float b, float k) {
|
||||
float h = max(k - abs(a - b), 0.0) / k;
|
||||
return min(a, b) - h * h * h * k / 6.0;
|
||||
}
|
||||
|
||||
vec2 cellPoint(vec2 seed) {
|
||||
return 0.5 + 0.5 * sin(uTime * uCellSpeed + 6.2831 * seed);
|
||||
}
|
||||
|
||||
float voronoiF1(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
float nearest = 8.0;
|
||||
|
||||
for (int y = -1; y <= 1; y++) {
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
vec2 neighbor = vec2(float(x), float(y));
|
||||
vec2 point = cellPoint(hash2(i + neighbor));
|
||||
nearest = min(nearest, length(neighbor + point - f));
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
float voronoiSmoothF1(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
float result = 8.0;
|
||||
|
||||
for (int y = -1; y <= 1; y++) {
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
vec2 neighbor = vec2(float(x), float(y));
|
||||
vec2 point = cellPoint(hash2(i + neighbor));
|
||||
result = smin(result, length(neighbor + point - f), uSmoothness);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
float noiseHash(vec2 p) {
|
||||
p = fract(p * vec2(127.1, 311.7));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
float valueNoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
return mix(
|
||||
mix(noiseHash(i), noiseHash(i + vec2(1.0, 0.0)), f.x),
|
||||
mix(noiseHash(i + vec2(0.0, 1.0)), noiseHash(i + vec2(1.0, 1.0)), f.x),
|
||||
f.y
|
||||
);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
|
||||
for (int i = 0; i < 2; i++) {
|
||||
value += amplitude * valueNoise(p);
|
||||
p *= 2.0;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 noiseUv = vWorldPos * uNoiseScale + vec2(uTime * uNoiseFlowSpeed, 0.0);
|
||||
float noiseFactor = fbm(noiseUv);
|
||||
vec2 distortion = vec2(noiseFactor - 0.5) * uDistortAmount;
|
||||
vec2 uv = vWorldPos * uScale + vec2(uFlowX, uFlowZ) * uTime + distortion;
|
||||
|
||||
float f1 = voronoiF1(uv);
|
||||
float smoothF1 = voronoiSmoothF1(uv);
|
||||
float edge = f1 - smoothF1;
|
||||
float ramp = smoothstep(uEdgeThreshold - uEdgeSoftness, uEdgeThreshold + uEdgeSoftness, edge);
|
||||
float safeMidPosition = max(uMidPos, 0.0001);
|
||||
float firstSegment = clamp(ramp / safeMidPosition, 0.0, 1.0);
|
||||
float secondSegment = clamp((ramp - safeMidPosition) / max(1.0 - safeMidPosition, 0.0001), 0.0, 1.0);
|
||||
float inSecondSegment = step(safeMidPosition, ramp);
|
||||
vec3 color = mix(
|
||||
mix(uDeepColor, uMidColor, firstSegment),
|
||||
mix(uMidColor, uHighlight, secondSegment),
|
||||
inSecondSegment
|
||||
);
|
||||
float alpha = mix(uDeepOpacity, 1.0, ramp) * uOpacity;
|
||||
alpha *= roundedBoxMask(vUv, uBorderRadius, uBorderSoftness);
|
||||
|
||||
if (uFogEnabled > 0.5) {
|
||||
float fogDistance = distance(cameraPosition, vWorldPosition);
|
||||
float fogFactor = smoothstep(uFogNear, uFogFar, fogDistance);
|
||||
color = mix(color, uFogColor, fogFactor);
|
||||
alpha *= 1.0 - fogFactor;
|
||||
}
|
||||
|
||||
if (alpha < 0.01) {
|
||||
discard;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user