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