diff --git a/src/components/three/world/CloudModel.tsx b/src/components/three/world/CloudModel.tsx new file mode 100644 index 0000000..e138ae4 --- /dev/null +++ b/src/components/three/world/CloudModel.tsx @@ -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 ; +} + +useGLTF.preload(CLOUD_CONFIG.modelPath); diff --git a/src/data/world/cloudConfig.ts b/src/data/world/cloudConfig.ts new file mode 100644 index 0000000..07615c3 --- /dev/null +++ b/src/data/world/cloudConfig.ts @@ -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; diff --git a/src/hooks/world/useCloudSettings.ts b/src/hooks/world/useCloudSettings.ts new file mode 100644 index 0000000..ea97912 --- /dev/null +++ b/src/hooks/world/useCloudSettings.ts @@ -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) => void { + return useWorldSettingsStore((state) => state.setClouds); +} diff --git a/src/managers/stores/useWorldSettingsStore.ts b/src/managers/stores/useWorldSettingsStore.ts index dabf773..237674b 100644 --- a/src/managers/stores/useWorldSettingsStore.ts +++ b/src/managers/stores/useWorldSettingsStore.ts @@ -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) => void; setWind: (wind: Partial) => 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()((set) => ({ ...DEFAULT_STATE, + setClouds: (cloudsUpdate) => + set((state) => ({ + clouds: { ...state.clouds, ...cloudsUpdate }, + })), + setWind: (windUpdate) => set((state) => ({ wind: { ...state.wind, ...windUpdate }, diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 6935dd5..cfb99db 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -20,6 +20,7 @@ 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"; @@ -261,6 +262,7 @@ export function GameMap({ + {isMapModelVisible("terrain", { groups, models }) ? ( diff --git a/src/world/clouds/CloudSystem.tsx b/src/world/clouds/CloudSystem.tsx new file mode 100644 index 0000000..5cbb2d1 --- /dev/null +++ b/src/world/clouds/CloudSystem.tsx @@ -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>([]); + 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 ( + + {clouds.map((cloud, index) => ( + { + refs.current[index] = node; + }} + position={[cloud.x, cloud.height, cloud.z]} + rotation={[0, cloud.rotationY, 0]} + scale={cloud.scale} + > + + + + + ))} + + ); +}