use bevy::prelude::*;
use crate::plugin::ElementsSystems;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CivilDate {
pub year: i32,
pub month: u8,
pub day: u8,
}
impl CivilDate {
pub const fn new(year: i32, month: u8, day: u8) -> Self {
let month = if month < 1 {
1
} else if month > 12 {
12
} else {
month
};
let max = days_in_month(year, month);
let day = if day < 1 {
1
} else if day > max {
max
} else {
day
};
Self { year, month, day }
}
pub const fn is_leap_year(self) -> bool {
is_leap(self.year)
}
pub fn day_of_year(self) -> u16 {
(self.days_since_2000() - Self::new(self.year, 1, 1).days_since_2000() + 1) as u16
}
pub fn days_since_2000(self) -> i64 {
let y = i64::from(self.year) - i64::from(self.month <= 2);
let era = y.div_euclid(400);
let yoe = y - era * 400;
let m = i64::from(self.month);
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + i64::from(self.day) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468 - 10_957
}
pub fn add_days(self, days: i64) -> Self {
let z = self.days_since_2000() + days + 10_957 + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u8;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
let year = (yoe + era * 400 + i64::from(month <= 2)) as i32;
Self { year, month, day }
}
}
const fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
const fn days_in_month(year: i32, month: u8) -> u8 {
match month {
2 if is_leap(year) => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
#[derive(Resource, Clone, Debug, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct WorldClock {
pub date: CivilDate,
pub hour: f64,
pub time_scale: f32,
pub paused: bool,
}
impl Default for WorldClock {
fn default() -> Self {
Self::new(CivilDate::new(2026, 6, 21))
}
}
impl WorldClock {
pub fn new(date: CivilDate) -> Self {
Self {
date,
hour: 12.0,
time_scale: 60.0,
paused: false,
}
}
pub fn at_hour(mut self, hour: f64) -> Self {
self.hour = 0.0;
self.advance_hours(hour);
self
}
pub fn with_time_scale(mut self, time_scale: f32) -> Self {
self.time_scale = time_scale;
self
}
pub fn day_of_year(&self) -> u16 {
self.date.day_of_year()
}
pub fn julian_day(&self, utc_offset_hours: f64) -> f64 {
2_451_544.5 + self.date.days_since_2000() as f64 + (self.hour - utc_offset_hours) / 24.0
}
pub fn hours_since_2000(&self) -> f64 {
self.date.days_since_2000() as f64 * 24.0 + self.hour
}
pub fn advance_hours(&mut self, hours: f64) {
let total = self.hour + hours;
let days = (total / 24.0).floor();
self.hour = total - days * 24.0;
if self.hour >= 24.0 {
self.hour = 0.0;
}
if days != 0.0 {
self.date = self.date.add_days(days as i64);
}
}
}
#[derive(Resource, Clone, Debug, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GeoLocation {
pub latitude_deg: f64,
pub longitude_deg: f64,
pub utc_offset_hours: f64,
}
impl Default for GeoLocation {
fn default() -> Self {
Self {
latitude_deg: 45.0,
longitude_deg: 0.0,
utc_offset_hours: 0.0,
}
}
}
impl GeoLocation {
pub fn is_southern(&self) -> bool {
self.latitude_deg < 0.0
}
}
pub(crate) fn advance_clock(time: Res<Time<Virtual>>, mut clock: ResMut<WorldClock>) {
if clock.paused {
return;
}
let hours = time.delta_secs_f64() * f64::from(clock.time_scale) / 3600.0;
if hours != 0.0 {
clock.advance_hours(hours);
}
}
pub(crate) fn plugin(app: &mut App) {
app.init_resource::<WorldClock>()
.init_resource::<GeoLocation>()
.add_systems(Update, advance_clock.in_set(ElementsSystems::Clock));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn julian_day_matches_meeus_example_7a() {
let clock = WorldClock::new(CivilDate::new(1957, 10, 4)).at_hour(0.81 * 24.0);
assert!((clock.julian_day(0.0) - 2_436_116.31).abs() < 1e-6);
}
#[test]
fn dates_round_trip_across_leap_years_and_centuries() {
let start = CivilDate::new(1899, 12, 31);
for n in 0..200_000 {
let d = start.add_days(n);
assert_eq!(d.days_since_2000() - start.days_since_2000(), n);
assert_eq!(CivilDate::new(d.year, d.month, d.day), d);
}
assert_eq!(CivilDate::new(2024, 12, 31).day_of_year(), 366);
assert_eq!(CivilDate::new(2026, 3, 1).day_of_year(), 60);
}
#[test]
fn advancing_rolls_the_date_both_ways() {
let mut clock = WorldClock::new(CivilDate::new(2026, 12, 31)).at_hour(23.5);
clock.advance_hours(1.0);
assert_eq!(clock.date, CivilDate::new(2027, 1, 1));
assert!((clock.hour - 0.5).abs() < 1e-9);
clock.advance_hours(-1.0);
assert_eq!(clock.date, CivilDate::new(2026, 12, 31));
assert!((clock.hour - 23.5).abs() < 1e-9);
}
}