Skip to main content

steel_core/world/
environment.rs

1use std::str::FromStr as _;
2
3use steel_registry::dimension_type::DimensionTypeRef;
4use steel_registry::timeline::{Ease, KeyframeValue, TimelineRef, Track};
5use steel_registry::{REGISTRY, RegistryExt as _, TaggedRegistryExt as _};
6use steel_utils::Identifier;
7
8use super::clock::WorldClockManager;
9
10const SKY_LIGHT_LEVEL_ATTRIBUTE: &str = "minecraft:gameplay/sky_light_level";
11const SUN_ANGLE_ATTRIBUTE: &str = "minecraft:visual/sun_angle";
12const MONSTERS_BURN_ATTRIBUTE: &str = "minecraft:gameplay/monsters_burn";
13const DEFAULT_SKY_LIGHT_LEVEL: f32 = 15.0;
14const DEFAULT_SUN_ANGLE: f32 = 0.0;
15const MIN_SKY_LIGHT_LEVEL: f32 = 0.0;
16const MAX_SKY_LIGHT_LEVEL: f32 = 15.0;
17const RAIN_SKY_LIGHT_TARGET: f32 = 4.0;
18const RAIN_SKY_LIGHT_ALPHA: f32 = 0.3125;
19const THUNDER_SKY_LIGHT_TARGET: f32 = 4.0;
20const THUNDER_SKY_LIGHT_ALPHA: f32 = 0.527_343_75;
21
22#[must_use]
23pub(super) fn sky_light_level(
24    dimension_type: DimensionTypeRef,
25    clock_manager: &WorldClockManager,
26    rain_level: f32,
27    thunder_level: f32,
28    can_have_weather: bool,
29) -> f32 {
30    let mut value = dimension_type
31        .sky_light_level
32        .unwrap_or(DEFAULT_SKY_LIGHT_LEVEL);
33    value = apply_timeline_float_attribute(
34        value,
35        dimension_type,
36        clock_manager,
37        SKY_LIGHT_LEVEL_ATTRIBUTE,
38    );
39    if can_have_weather {
40        value = apply_weather_sky_light_level(value, rain_level, thunder_level);
41    }
42    value.clamp(MIN_SKY_LIGHT_LEVEL, MAX_SKY_LIGHT_LEVEL)
43}
44
45#[must_use]
46pub(super) fn sun_angle_degrees(
47    dimension_type: DimensionTypeRef,
48    clock_manager: &WorldClockManager,
49) -> f32 {
50    apply_timeline_float_attribute(
51        DEFAULT_SUN_ANGLE,
52        dimension_type,
53        clock_manager,
54        SUN_ANGLE_ATTRIBUTE,
55    )
56}
57
58#[must_use]
59pub(super) fn sky_darkening(sky_light_level: f32) -> u8 {
60    (MAX_SKY_LIGHT_LEVEL - sky_light_level.clamp(MIN_SKY_LIGHT_LEVEL, MAX_SKY_LIGHT_LEVEL)) as u8
61}
62
63fn apply_timeline_float_attribute(
64    mut value: f32,
65    dimension_type: DimensionTypeRef,
66    clock_manager: &WorldClockManager,
67    attribute: &str,
68) -> f32 {
69    for_each_timeline(dimension_type, |timeline| {
70        value = apply_timeline_float_track(value, timeline, clock_manager, attribute);
71    });
72    value
73}
74
75/// Samples `MONSTERS_BURN`: false by default, enabled by the overworld day timeline.
76#[must_use]
77pub(super) fn monsters_burn(
78    dimension_type: DimensionTypeRef,
79    clock_manager: &WorldClockManager,
80) -> bool {
81    let mut value = false;
82    for_each_timeline(dimension_type, |timeline| {
83        value = apply_timeline_bool_track(value, timeline, clock_manager, MONSTERS_BURN_ATTRIBUTE);
84    });
85    value
86}
87
88fn for_each_timeline(dimension_type: DimensionTypeRef, mut visit: impl FnMut(TimelineRef)) {
89    let Some(timelines) = dimension_type.timelines else {
90        return;
91    };
92    if let Some(tag) = timelines.strip_prefix('#') {
93        let Ok(tag) = Identifier::from_str(tag) else {
94            return;
95        };
96        for timeline in REGISTRY.timelines.iter_tag(&tag) {
97            visit(timeline);
98        }
99        return;
100    }
101
102    let Ok(key) = Identifier::from_str(timelines) else {
103        return;
104    };
105    if let Some(timeline) = REGISTRY.timelines.by_key(&key) {
106        visit(timeline);
107    }
108}
109
110fn apply_timeline_bool_track(
111    value: bool,
112    timeline: TimelineRef,
113    clock_manager: &WorldClockManager,
114    attribute: &str,
115) -> bool {
116    let Some(track) = timeline.tracks.iter().find(|track| track.name == attribute) else {
117        return value;
118    };
119    let Some(total_ticks) = clock_manager.total_ticks(timeline.clock) else {
120        return value;
121    };
122    let Some(sample) = sample_bool_track(track, timeline.period_ticks.map(i64::from), total_ticks)
123    else {
124        return value;
125    };
126    match track.modifier {
127        Some("or") => value || sample,
128        Some("and") => value && sample,
129        None => sample,
130        _ => value,
131    }
132}
133
134/// Boolean tracks hold the segment start until the next keyframe,
135/// ignoring easing, as with vanilla's non-interpolated attribute type.
136fn sample_bool_track(track: &Track, period_ticks: Option<i64>, ticks: i64) -> Option<bool> {
137    let keyframes = track.keyframes;
138    match keyframes.len() {
139        0 => return None,
140        1 => return keyframe_bool_value(&keyframes[0].value),
141        _ => {}
142    }
143
144    let sample_ticks = period_ticks.map_or(ticks, |period| ticks.rem_euclid(period));
145    let first = &keyframes[0];
146    let last = &keyframes[keyframes.len() - 1];
147
148    if let Some(period) = period_ticks
149        && sample_ticks < first.ticks
150    {
151        return step_bool_segment(
152            last.ticks - period,
153            &last.value,
154            first.ticks,
155            &first.value,
156            sample_ticks,
157        );
158    }
159
160    for segment in keyframes.windows(2) {
161        let from = &segment[0];
162        let to = &segment[1];
163        if sample_ticks < to.ticks {
164            return step_bool_segment(from.ticks, &from.value, to.ticks, &to.value, sample_ticks);
165        }
166    }
167
168    if let Some(period) = period_ticks {
169        return step_bool_segment(
170            last.ticks,
171            &last.value,
172            first.ticks + period,
173            &first.value,
174            sample_ticks,
175        );
176    }
177
178    keyframe_bool_value(&last.value)
179}
180
181fn step_bool_segment(
182    from_ticks: i64,
183    from_value: &KeyframeValue,
184    to_ticks: i64,
185    to_value: &KeyframeValue,
186    sample_ticks: i64,
187) -> Option<bool> {
188    let from = keyframe_bool_value(from_value)?;
189    let to = keyframe_bool_value(to_value)?;
190    if sample_ticks <= from_ticks {
191        return Some(from);
192    }
193    if sample_ticks >= to_ticks {
194        return Some(to);
195    }
196    Some(from)
197}
198
199fn apply_timeline_float_track(
200    value: f32,
201    timeline: TimelineRef,
202    clock_manager: &WorldClockManager,
203    attribute: &str,
204) -> f32 {
205    let Some(track) = timeline.tracks.iter().find(|track| track.name == attribute) else {
206        return value;
207    };
208    let Some(total_ticks) = clock_manager.total_ticks(timeline.clock) else {
209        return value;
210    };
211    let Some(sample) = sample_float_track(track, timeline.period_ticks.map(i64::from), total_ticks)
212    else {
213        return value;
214    };
215    match track.modifier {
216        Some("multiply") => value * sample,
217        None => sample,
218        _ => value,
219    }
220}
221
222fn sample_float_track(track: &Track, period_ticks: Option<i64>, ticks: i64) -> Option<f32> {
223    let keyframes = track.keyframes;
224    match keyframes.len() {
225        0 => return None,
226        1 => return keyframe_float_value(&keyframes[0].value),
227        _ => {}
228    }
229
230    let sample_ticks = period_ticks.map_or(ticks, |period| ticks.rem_euclid(period));
231    let first = &keyframes[0];
232    let last = &keyframes[keyframes.len() - 1];
233
234    if let Some(period) = period_ticks
235        && sample_ticks < first.ticks
236    {
237        return interpolate_float_segment(
238            track,
239            last.ticks - period,
240            &last.value,
241            first.ticks,
242            &first.value,
243            sample_ticks,
244        );
245    }
246
247    for segment in keyframes.windows(2) {
248        let from = &segment[0];
249        let to = &segment[1];
250        if sample_ticks < to.ticks {
251            return interpolate_float_segment(
252                track,
253                from.ticks,
254                &from.value,
255                to.ticks,
256                &to.value,
257                sample_ticks,
258            );
259        }
260    }
261
262    if let Some(period) = period_ticks {
263        return interpolate_float_segment(
264            track,
265            last.ticks,
266            &last.value,
267            first.ticks + period,
268            &first.value,
269            sample_ticks,
270        );
271    }
272
273    keyframe_float_value(&last.value)
274}
275
276fn interpolate_float_segment(
277    track: &Track,
278    from_ticks: i64,
279    from_value: &KeyframeValue,
280    to_ticks: i64,
281    to_value: &KeyframeValue,
282    sample_ticks: i64,
283) -> Option<f32> {
284    let from = keyframe_float_value(from_value)?;
285    let to = keyframe_float_value(to_value)?;
286    if sample_ticks <= from_ticks {
287        return Some(from);
288    }
289    if sample_ticks >= to_ticks {
290        return Some(to);
291    }
292
293    let alpha = (sample_ticks - from_ticks) as f32 / (to_ticks - from_ticks) as f32;
294    let eased_alpha = apply_easing(track.ease.as_ref(), alpha)?;
295    Some(from + eased_alpha * (to - from))
296}
297
298fn apply_easing(ease: Option<&Ease>, alpha: f32) -> Option<f32> {
299    match ease {
300        None | Some(Ease::Named("linear")) => Some(alpha),
301        Some(Ease::Named("constant")) => Some(0.0),
302        Some(Ease::CubicBezier([x1, y1, x2, y2])) => Some(cubic_bezier(alpha, *x1, *y1, *x2, *y2)),
303        Some(Ease::Named(_)) => None,
304    }
305}
306
307fn cubic_bezier(x: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
308    let x_curve = CubicCurve::from_controls(x1, x2);
309    let y_curve = CubicCurve::from_controls(y1, y2);
310    y_curve.sample(x_curve.solve_t(x))
311}
312
313#[derive(Clone, Copy)]
314struct CubicCurve {
315    a: f32,
316    b: f32,
317    c: f32,
318}
319
320impl CubicCurve {
321    const ERROR_EPSILON: f32 = 1.0E-5;
322
323    fn from_controls(first: f32, second: f32) -> Self {
324        Self {
325            a: (3.0 * first - 3.0 * second) + 1.0,
326            b: -6.0 * first + 3.0 * second,
327            c: 3.0 * first,
328        }
329    }
330
331    fn sample(self, t: f32) -> f32 {
332        ((self.a * t + self.b) * t + self.c) * t
333    }
334
335    fn sample_gradient(self, t: f32) -> f32 {
336        (3.0 * self.a * t + 2.0 * self.b) * t + self.c
337    }
338
339    fn solve_t(self, x: f32) -> f32 {
340        let mut t = x;
341        for _ in 0..4 {
342            let error = self.sample(t) - x;
343            if error.abs() < Self::ERROR_EPSILON {
344                return t;
345            }
346            let gradient = self.sample_gradient(t);
347            if gradient < Self::ERROR_EPSILON {
348                break;
349            }
350            t -= (error / gradient).clamp(-0.25, 0.25);
351        }
352        self.solve_t_bisect(x, t)
353    }
354
355    #[expect(
356        clippy::manual_midpoint,
357        reason = "evaluation order mirrors vanilla CubicBezier.solveTBisect float arithmetic"
358    )]
359    fn solve_t_bisect(self, x: f32, initial_t: f32) -> f32 {
360        let mut lower = 0.0;
361        let mut upper = 1.0;
362        let mut t = initial_t;
363        while lower < upper {
364            let error = self.sample(t) - x;
365            if error.abs() < Self::ERROR_EPSILON {
366                return t;
367            }
368            if error < 0.0 {
369                lower = t;
370            } else {
371                upper = t;
372            }
373            t = (upper + lower) / 2.0;
374        }
375        t
376    }
377}
378
379const fn keyframe_float_value(value: &KeyframeValue) -> Option<f32> {
380    match value {
381        KeyframeValue::Float(value) => Some(*value),
382        _ => None,
383    }
384}
385
386const fn keyframe_bool_value(value: &KeyframeValue) -> Option<bool> {
387    match value {
388        KeyframeValue::Bool(value) => Some(*value),
389        _ => None,
390    }
391}
392
393fn apply_weather_sky_light_level(mut value: f32, rain_level: f32, thunder_level: f32) -> f32 {
394    let thunder_level = thunder_level.clamp(0.0, 1.0);
395    let rain_level = (rain_level - thunder_level).clamp(0.0, 1.0);
396    if rain_level > 0.0 {
397        let rain_value = lerp(RAIN_SKY_LIGHT_ALPHA, value, RAIN_SKY_LIGHT_TARGET);
398        value = lerp(rain_level, value, rain_value);
399    }
400    if thunder_level > 0.0 {
401        let thunder_value = lerp(THUNDER_SKY_LIGHT_ALPHA, value, THUNDER_SKY_LIGHT_TARGET);
402        value = lerp(thunder_level, value, thunder_value);
403    }
404    value
405}
406
407fn lerp(alpha: f32, from: f32, to: f32) -> f32 {
408    from + alpha * (to - from)
409}
410
411#[cfg(test)]
412mod tests {
413    use steel_registry::init_vanilla_registry;
414    use steel_registry::vanilla_dimension_types::{OVERWORLD, THE_NETHER};
415    use steel_registry::vanilla_world_clocks;
416
417    use super::*;
418
419    const F32_CLOSE_EPSILON: f32 = 0.000_001;
420    const OVERWORLD_WAKE_UP_TICKS: i64 = 0;
421    const OVERWORLD_DAY_TICKS: i64 = 1_000;
422    const OVERWORLD_NOON_TICKS: i64 = 6_000;
423    const OVERWORLD_SUNSET_TICKS: i64 = 12_000;
424    const OVERWORLD_SUNSET_INTERPOLATION_TICKS: i64 = 12_768;
425    const OVERWORLD_MIDNIGHT_TICKS: i64 = 18_000;
426
427    fn assert_f32_close(left: f32, right: f32) {
428        assert!(
429            (left - right).abs() < F32_CLOSE_EPSILON,
430            "left={left}, right={right}"
431        );
432    }
433
434    fn clock_manager_at(total_ticks: i64) -> WorldClockManager {
435        let mut manager = WorldClockManager::new();
436        assert_eq!(
437            manager.set_total_ticks(&vanilla_world_clocks::OVERWORLD, total_ticks),
438            Some(())
439        );
440        manager
441    }
442
443    #[test]
444    fn overworld_sky_light_uses_generated_day_timeline() {
445        init_vanilla_registry();
446
447        assert_f32_close(
448            sky_light_level(
449                &OVERWORLD,
450                &clock_manager_at(OVERWORLD_DAY_TICKS),
451                0.0,
452                0.0,
453                true,
454            ),
455            15.0,
456        );
457        assert_f32_close(
458            sky_light_level(
459                &OVERWORLD,
460                &clock_manager_at(OVERWORLD_NOON_TICKS),
461                0.0,
462                0.0,
463                true,
464            ),
465            15.0,
466        );
467        assert_f32_close(
468            sky_light_level(
469                &OVERWORLD,
470                &clock_manager_at(OVERWORLD_MIDNIGHT_TICKS),
471                0.0,
472                0.0,
473                true,
474            ),
475            4.0,
476        );
477    }
478
479    #[test]
480    fn overworld_sky_light_interpolates_sunset_from_generated_keyframes() {
481        init_vanilla_registry();
482
483        assert_f32_close(
484            sky_light_level(
485                &OVERWORLD,
486                &clock_manager_at(OVERWORLD_SUNSET_INTERPOLATION_TICKS),
487                0.0,
488                0.0,
489                true,
490            ),
491            9.503_051,
492        );
493    }
494
495    #[test]
496    fn overworld_sun_angle_uses_vanilla_cubic_bezier_easing() {
497        init_vanilla_registry();
498
499        assert_f32_close(
500            sun_angle_degrees(&OVERWORLD, &clock_manager_at(OVERWORLD_WAKE_UP_TICKS)),
501            282.374_33,
502        );
503        assert_f32_close(
504            sun_angle_degrees(&OVERWORLD, &clock_manager_at(OVERWORLD_SUNSET_TICKS)),
505            77.625_66,
506        );
507        assert_f32_close(
508            sun_angle_degrees(&OVERWORLD, &clock_manager_at(OVERWORLD_MIDNIGHT_TICKS)),
509            180.0,
510        );
511    }
512
513    #[test]
514    fn sky_light_level_applies_vanilla_weather_alpha_layers() {
515        init_vanilla_registry();
516
517        assert_f32_close(
518            sky_light_level(
519                &OVERWORLD,
520                &clock_manager_at(OVERWORLD_NOON_TICKS),
521                1.0,
522                0.0,
523                true,
524            ),
525            11.5625,
526        );
527        assert_f32_close(
528            sky_light_level(
529                &OVERWORLD,
530                &clock_manager_at(OVERWORLD_NOON_TICKS),
531                1.0,
532                1.0,
533                true,
534            ),
535            9.199_219,
536        );
537    }
538
539    #[test]
540    fn fixed_nether_sky_light_uses_dimension_attribute() {
541        init_vanilla_registry();
542
543        assert_f32_close(
544            sky_light_level(
545                &THE_NETHER,
546                &clock_manager_at(OVERWORLD_NOON_TICKS),
547                0.0,
548                0.0,
549                false,
550            ),
551            4.0,
552        );
553    }
554
555    #[test]
556    fn sky_darkening_matches_vanilla_integer_cast() {
557        assert_eq!(sky_darkening(15.0), 0);
558        assert_eq!(sky_darkening(11.5625), 3);
559        assert_eq!(sky_darkening(4.0), 11);
560    }
561
562    #[test]
563    fn monsters_burn_follows_the_generated_daylight_window() {
564        init_vanilla_registry();
565
566        // Daylight spans the period wrap: sunrise at 23460, sunset at 12542.
567        for ticks in [0, 6_000, 12_000, 12_541, 23_460, 23_999] {
568            assert!(
569                monsters_burn(&OVERWORLD, &clock_manager_at(ticks)),
570                "monsters_burn should hold at tick {ticks}"
571            );
572        }
573        for ticks in [12_542, 18_000, 23_000, 23_459] {
574            assert!(
575                !monsters_burn(&OVERWORLD, &clock_manager_at(ticks)),
576                "monsters_burn should be off at tick {ticks}"
577            );
578        }
579    }
580
581    #[test]
582    fn monsters_burn_snaps_at_keyframes_instead_of_interpolating() {
583        init_vanilla_registry();
584
585        assert!(!monsters_burn(&OVERWORLD, &clock_manager_at(23_459)));
586        assert!(monsters_burn(&OVERWORLD, &clock_manager_at(23_460)));
587    }
588
589    #[test]
590    fn monsters_burn_is_off_in_dimensions_without_the_day_timeline() {
591        init_vanilla_registry();
592
593        assert!(!monsters_burn(&THE_NETHER, &clock_manager_at(6_000)));
594    }
595}