Feat/map-environment #6

Merged
math-pixel merged 116 commits from feat/map-environment into develop 2026-05-29 00:00:51 +00:00
6 changed files with 250 additions and 0 deletions
Showing only changes of commit 4ebb5b8c25 - Show all commits
+53
View File
@@ -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);
+34
View File
@@ -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;
+10
View File
@@ -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);
}
@@ -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 },
+2
View File
@@ -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({
<MapInstancingSystem />
<WorldPlane />
<WaterSystem />
<CloudSystem />
<VegetationSystem />
{isMapModelVisible("terrain", { groups, models }) ? (
<TerrainModel />
+142
View File
@@ -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>
);
}