Skip to main content

steel_core/world/
weather.rs

1#[expect(
2    clippy::struct_field_names,
3    reason = "field names match vanilla weather state naming"
4)]
5#[derive(Debug, Default)]
6pub struct Weather {
7    pub rain_level: f32,
8    pub previous_rain_level: f32,
9    pub thunder_level: f32,
10    pub previous_thunder_level: f32,
11}
12
13use super::{
14    ADVANCE_WEATHER, BiomeRef, BlockPos, CGameEvent, ChunkGenerator, ChunkPos, GameEventType,
15    HeightmapType, LazyLock, LegacyRandom, LightLayer, PerlinSimplexNoise, REGISTRY, RandomSource,
16    RegistryEntry, RegistryExt, TemperatureModifier, World, environment, fuzzed_biome_at_block,
17    obfuscate_biome_seed, vanilla_dimension_types,
18};
19
20use crate::chunk::light::MAX_LIGHT_LEVEL;
21
22static BIOME_TEMPERATURE_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
23    let mut random = RandomSource::Legacy(LegacyRandom::from_seed(1234));
24    PerlinSimplexNoise::new(&mut random, &[0])
25});
26
27static FROZEN_BIOME_TEMPERATURE_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
28    let mut random = RandomSource::Legacy(LegacyRandom::from_seed(3456));
29    PerlinSimplexNoise::new(&mut random, &[-2, -1, 0])
30});
31
32static BIOME_INFO_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
33    let mut random = RandomSource::Legacy(LegacyRandom::from_seed(2345));
34    PerlinSimplexNoise::new(&mut random, &[0])
35});
36
37impl World {
38    #[expect(
39        clippy::too_many_lines,
40        reason = "splitting would hurt readability of the weather state machine"
41    )]
42    pub(super) fn tick_weather(&self) {
43        if !self.can_have_weather() {
44            return;
45        }
46
47        let mut weather = self.weather.lock();
48        let raining_before = self.is_raining_with_guard(&weather);
49
50        // Advance the weather state machine (only if gamerule allows)
51        {
52            let mut level_data = self.level_data.write();
53
54            if self.get_game_rule_with_guard(&ADVANCE_WEATHER, &level_data) {
55                let clear_weather_time = level_data.clear_weather_time();
56                if clear_weather_time > 0 {
57                    level_data.set_clear_weather_time(clear_weather_time - 1);
58                    if level_data.is_thundering() {
59                        level_data.set_thunder_time(0);
60                        level_data.set_thundering(false);
61                    } else {
62                        level_data.set_thunder_time(1);
63                    }
64                    if level_data.is_raining() {
65                        level_data.set_rain_time(0);
66                        level_data.set_raining(false);
67                    } else {
68                        level_data.set_rain_time(1);
69                    }
70                } else {
71                    let thundering_time = level_data.thunder_time();
72                    if thundering_time > 0 {
73                        level_data.set_thunder_time(thundering_time - 1);
74                        if level_data.thunder_time() == 0 {
75                            let thundering = level_data.is_thundering();
76                            level_data.set_thundering(!thundering);
77                        }
78                    } else if level_data.is_thundering() {
79                        level_data.set_thunder_time(rand::random_range(3_600..=15_600));
80                    } else {
81                        level_data.set_thunder_time(rand::random_range(12_000..=180_000));
82                    }
83
84                    let rain_time = level_data.rain_time();
85                    if rain_time > 0 {
86                        level_data.set_rain_time(rain_time - 1);
87                        if level_data.rain_time() == 0 {
88                            let raining = level_data.is_raining();
89                            level_data.set_raining(!raining);
90                        }
91                    } else if level_data.is_raining() {
92                        level_data.set_rain_time(rand::random_range(12_000..=24_000));
93                    } else {
94                        level_data.set_rain_time(rand::random_range(12_000..=180_000));
95                    }
96                }
97            }
98        }
99
100        // Interpolate visual levels (always runs, even when ADVANCE_WEATHER is off)
101        let is_thundering = self.level_data.read().is_thundering();
102        let is_raining = self.level_data.read().is_raining();
103
104        weather.previous_thunder_level = weather.thunder_level;
105        if is_thundering {
106            weather.thunder_level += 0.01;
107        } else {
108            weather.thunder_level -= 0.01;
109        }
110        weather.thunder_level = weather.thunder_level.clamp(0.0, 1.0);
111
112        weather.previous_rain_level = weather.rain_level;
113        if is_raining {
114            weather.rain_level += 0.01;
115        } else {
116            weather.rain_level -= 0.01;
117        }
118        weather.rain_level = weather.rain_level.clamp(0.0, 1.0);
119
120        // Broadcast weather changes to clients
121        let raining_now = self.is_raining_with_guard(&weather);
122        if raining_before == raining_now {
123            #[expect(
124                clippy::float_cmp,
125                reason = "comparing against the exact previously-assigned value to detect any change"
126            )]
127            if weather.previous_rain_level != weather.rain_level {
128                self.broadcast_to_all(CGameEvent {
129                    event: GameEventType::RainLevelChange,
130                    data: weather.rain_level,
131                });
132            }
133
134            #[expect(
135                clippy::float_cmp,
136                reason = "comparing against the exact previously-assigned value to detect any change"
137            )]
138            if weather.previous_thunder_level != weather.thunder_level {
139                self.broadcast_to_all(CGameEvent {
140                    event: GameEventType::ThunderLevelChange,
141                    data: weather.thunder_level,
142                });
143            }
144        } else {
145            if raining_before {
146                self.broadcast_to_all(CGameEvent {
147                    event: GameEventType::StopRaining,
148                    data: 0.0,
149                });
150            } else {
151                self.broadcast_to_all(CGameEvent {
152                    event: GameEventType::StartRaining,
153                    data: 0.0,
154                });
155            }
156
157            self.broadcast_to_all(CGameEvent {
158                event: GameEventType::RainLevelChange,
159                data: weather.rain_level,
160            });
161
162            self.broadcast_to_all(CGameEvent {
163                event: GameEventType::ThunderLevelChange,
164                data: weather.thunder_level,
165            });
166        }
167    }
168
169    /// Sets this world's weather timers and flags.
170    ///
171    /// Vanilla owns this state at server scope. Steel intentionally owns
172    /// it per world so multiple worlds in one domain can have independent weather.
173    pub(crate) fn set_weather_parameters(
174        &self,
175        clear_time: i32,
176        rain_time: i32,
177        raining: bool,
178        thundering: bool,
179    ) {
180        let mut level_data = self.level_data.write();
181        level_data.set_clear_weather_time(clear_time);
182        level_data.set_rain_time(rain_time);
183        level_data.set_thunder_time(rain_time);
184        level_data.set_raining(raining);
185        level_data.set_thundering(thundering);
186    }
187
188    /// Checks whether the rain level is high enough to be considered raining.
189    /// Used for both visual rendering and gameplay logic (crop growth, fire, mob behavior).
190    ///
191    /// WARNING: this function acquires a lock on the `weather` field.
192    /// if you already have a lock on the `weather` field, this will DEADLOCK.
193    pub fn is_raining(&self) -> bool {
194        let guard = self.weather.lock();
195        self.is_raining_with_guard(&guard)
196    }
197
198    /// Checks whether rain reaches the given block position.
199    ///
200    /// Mirrors vanilla `Level.isRainingAt`: global rain state, sky exposure,
201    /// motion-blocking height, and biome precipitation must all allow rain.
202    pub fn is_raining_at(&self, pos: BlockPos) -> bool {
203        if !self.is_raining() || !self.can_see_sky_for_precipitation(pos) {
204            return false;
205        }
206
207        self.biome_at(pos).is_some_and(|biome| {
208            biome.has_precipitation && self.biome_temperature(biome, pos) >= 0.15
209        })
210    }
211
212    /// Checks whether the rain level is sufficient to render rain clientside using the provided guard.
213    pub fn is_raining_with_guard(&self, guard: &Weather) -> bool {
214        guard.rain_level > 0.2 && self.can_have_weather()
215    }
216
217    /// Checks whether the thunder level and rain level are high enough to be considered thundering.
218    /// Used for lightning spawning and gameplay logic.
219    ///
220    /// WARNING: this function acquires a lock on the `weather` field.
221    /// if you already have a lock on the `weather` field, this will DEADLOCK.
222    pub fn is_thundering(&self) -> bool {
223        let guard = self.weather.lock();
224        self.is_thundering_with_guard(&guard)
225    }
226
227    /// Checks whether the thunder level and rain level are sufficient to spawn thunderbolts using the provided guard.
228    pub fn is_thundering_with_guard(&self, guard: &Weather) -> bool {
229        guard.rain_level * guard.thunder_level > 0.9 && self.can_have_weather()
230    }
231
232    /// Returns this dimension's current sky light level, dimmed by rain and
233    /// thunder when the dimension has weather.
234    pub fn sky_light_level(&self) -> f32 {
235        let (rain_level, thunder_level) = if self.can_have_weather() {
236            let weather = self.weather.lock();
237            (weather.rain_level, weather.thunder_level)
238        } else {
239            (0.0, 0.0)
240        };
241
242        let level_data = self.level_data.read();
243        environment::sky_light_level(
244            self.dimension_type,
245            level_data.world_clocks(),
246            rain_level,
247            thunder_level,
248            self.can_have_weather(),
249        )
250    }
251
252    /// Returns how much the sky light level is currently dimmed, rising as
253    /// the sky light level drops from its daytime maximum.
254    pub fn sky_darkening(&self) -> u8 {
255        environment::sky_darkening(self.sky_light_level())
256    }
257
258    /// Returns the current vanilla `SUN_ANGLE` environment attribute in degrees.
259    pub fn sun_angle_degrees(&self) -> f32 {
260        let level_data = self.level_data.read();
261        environment::sun_angle_degrees(self.dimension_type, level_data.world_clocks())
262    }
263
264    /// Whether the environment currently permits daylight burning.
265    pub fn monsters_burn(&self) -> bool {
266        let level_data = self.level_data.read();
267        environment::monsters_burn(self.dimension_type, level_data.world_clocks())
268    }
269
270    /// Returns sky-layer light after the current sky darkening is subtracted.
271    ///
272    /// Mirrors vanilla `LevelReader.getEffectiveSkyBrightness` without allowing
273    /// block light to raise the result.
274    pub fn effective_sky_brightness(&self, pos: BlockPos) -> u8 {
275        if !self.dimension_type.has_skylight {
276            return 0;
277        }
278        self.light_value_at(LightLayer::Sky, pos)
279            .saturating_sub(self.sky_darkening())
280    }
281
282    /// Returns whether it is currently bright enough outside for daytime
283    /// mob behavior (dimensions with a fixed time are never "bright").
284    pub fn is_bright_outside(&self) -> bool {
285        self.dimension_type.fixed_time.is_none() && self.sky_darkening() < 4
286    }
287
288    /// Returns whether it is currently dark enough outside for hostile mobs
289    /// to spawn in daylight (dimensions with a fixed time are never "dark").
290    pub fn is_dark_outside(&self) -> bool {
291        self.dimension_type.fixed_time.is_none() && !self.is_bright_outside()
292    }
293
294    /// Checks whether the world can have weather.
295    pub fn can_have_weather(&self) -> bool {
296        self.dimension_type.has_skylight
297            && !self.dimension_type.has_ceiling
298            && self.dimension_type.key != vanilla_dimension_types::THE_END.key
299    }
300
301    /// Whether sky light is full, independent of time-of-day darkening.
302    pub fn can_see_sky(&self, pos: BlockPos) -> bool {
303        self.light_value_at(LightLayer::Sky, pos) >= MAX_LIGHT_LEVEL
304    }
305
306    /// Requires full sky light and an unobstructed motion-blocking heightmap.
307    /// The light check excludes water; the heightmap excludes glass roofs.
308    pub(super) fn can_see_sky_for_precipitation(&self, pos: BlockPos) -> bool {
309        if !self.can_see_sky(pos) {
310            return false;
311        }
312        self.height_at(HeightmapType::MotionBlocking, pos.x(), pos.z())
313            .is_some_and(|height| height <= pos.y())
314    }
315
316    pub(crate) fn biome_at(&self, pos: BlockPos) -> Option<BiomeRef> {
317        let biome_zoom_seed = obfuscate_biome_seed(self.seed());
318        let mut missing_chunk = false;
319        let biome_id = fuzzed_biome_at_block(biome_zoom_seed, pos, |quart| {
320            self.noise_biome_id(quart.x, quart.y, quart.z)
321                .unwrap_or_else(|| {
322                    missing_chunk = true;
323                    0
324                })
325        });
326
327        if missing_chunk {
328            return None;
329        }
330
331        REGISTRY.biomes.by_id(usize::from(biome_id))
332    }
333
334    pub(super) fn noise_biome_id(&self, quart_x: i32, quart_y: i32, quart_z: i32) -> Option<u16> {
335        let chunk_pos = ChunkPos::new(quart_x >> 2, quart_z >> 2);
336        let local_quart_x = (quart_x & 3) as usize;
337        let local_quart_z = (quart_z & 3) as usize;
338
339        if let Some(Some(biome_id)) = self.chunk_map.with_full_chunk(chunk_pos, |chunk| {
340            let sections = chunk.sections();
341            let (section_index, local_quart_y) =
342                Self::biome_quart_y_indices(chunk.min_y(), sections.sections.len(), quart_y)?;
343            let section = sections.sections.get(section_index)?;
344            Some(
345                section
346                    .read()
347                    .biomes
348                    .get(local_quart_x, local_quart_y, local_quart_z),
349            )
350        }) {
351            return Some(biome_id);
352        }
353
354        let biome = self
355            .chunk_map
356            .world_gen_context
357            .generator
358            .noise_biome(quart_x, quart_y, quart_z);
359        u16::try_from(biome.try_id()?).ok()
360    }
361
362    pub(super) fn biome_quart_y_indices(
363        min_y: i32,
364        section_count: usize,
365        quart_y: i32,
366    ) -> Option<(usize, usize)> {
367        let total_quart_y = section_count.checked_mul(4)?;
368        if total_quart_y == 0 {
369            return None;
370        }
371
372        let relative_quart_y = i64::from(quart_y) - i64::from(min_y >> 2);
373        let max_relative_quart_y = total_quart_y - 1;
374        let clamped_relative_quart_y = if relative_quart_y <= 0 {
375            0
376        } else {
377            usize::try_from(relative_quart_y).map_or(max_relative_quart_y, |relative| {
378                relative.min(max_relative_quart_y)
379            })
380        };
381
382        Some((clamped_relative_quart_y / 4, clamped_relative_quart_y & 3))
383    }
384
385    pub(super) fn biome_temperature(&self, biome: BiomeRef, pos: BlockPos) -> f32 {
386        let modified_temp = match biome.temperature_modifier {
387            TemperatureModifier::None => biome.temperature,
388            TemperatureModifier::Frozen => {
389                let large = f64::from(
390                    FROZEN_BIOME_TEMPERATURE_NOISE
391                        .get_value(f64::from(pos.x()) * 0.05, f64::from(pos.z()) * 0.05)
392                        * 7.0,
393                );
394                let edge = f64::from(
395                    BIOME_INFO_NOISE.get_value(f64::from(pos.x()) * 0.2, f64::from(pos.z()) * 0.2),
396                );
397                if large + edge < 0.3 {
398                    let small = f64::from(
399                        BIOME_INFO_NOISE
400                            .get_value(f64::from(pos.x()) * 0.09, f64::from(pos.z()) * 0.09),
401                    );
402                    if small < 0.8 { 0.2 } else { biome.temperature }
403                } else {
404                    biome.temperature
405                }
406            }
407        };
408
409        let snow_level = self.sea_level + 17;
410        if pos.y() <= snow_level {
411            return modified_temp;
412        }
413
414        let noise = BIOME_TEMPERATURE_NOISE.get_value(
415            f64::from(pos.x() as f32 / 8.0),
416            f64::from(pos.z() as f32 / 8.0),
417        ) * 8.0;
418        modified_temp - (noise + pos.y() as f32 - snow_level as f32) * 0.05 / 40.0
419    }
420}