use std::f32::consts::TAU; use bevy::prelude::*; use crate::{ colonist::Colonist, light::line_of_sight, sim::{HOURS_PER_DAY, START_HOUR, SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day}, structures::{Structure, StructureKind}, world::{TilePos, WorldMap}, }; pub struct ClimatePlugin; impl Plugin for ClimatePlugin { fn build(&self, app: &mut App) { app.add_systems(FixedUpdate, update_warmth.in_set(SimSystems::Act)); } } pub const SEASON_DAYS: u64 = 3; pub const YEAR_DAYS: u64 = 4 * SEASON_DAYS; const MEAN_TEMP: f32 = 12.0; const YEAR_SWING: f32 = 14.0; const DAY_SWING: f32 = 4.0; const COLDEST_YEAR_FRACTION: f32 = 0.79; const COLDEST_HOUR: f32 = 4.0; pub const GROWTH_MIN_TEMP: f32 = 4.0; pub const GROWTH_FULL_TEMP: f32 = 14.0; pub const GROWING_DAY_TEMP: f32 = 8.0; pub const SOW_AHEAD_DAYS: u64 = 2; pub const FROST_TEMP: f32 = -2.0; pub const COLD_TEMP: f32 = 5.0; pub const WARM_TEMP: f32 = 10.0; const INDOOR_TEMP: f32 = 16.0; const ROOF_INSULATION: f32 = 0.7; pub const FORESIGHT_DAYS: u64 = 6; const WARMTH_INTERVAL_TICKS: u64 = 60; const SNOW_TEMP: f32 = 3.0; const SNOW_SPAN: f32 = 4.0; const MAX_SNOW: f32 = 0.75; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Season { Spring, Summer, Autumn, Winter, } impl Season { pub const ALL: [Season; 4] = [ Season::Spring, Season::Summer, Season::Autumn, Season::Winter, ]; pub fn name(self) -> &'static str { match self { Season::Spring => "Spring", Season::Summer => "Summer", Season::Autumn => "Autumn", Season::Winter => "Winter", } } } fn hours(tick: SimTick) -> f32 { tick.0 as f32 / TICKS_PER_HOUR as f32 + START_HOUR as f32 } pub fn day_of(tick: SimTick) -> u64 { (tick.0 / TICKS_PER_HOUR + START_HOUR) / HOURS_PER_DAY } pub fn season(tick: SimTick) -> Season { Season::ALL[(day_of(tick) % YEAR_DAYS / SEASON_DAYS) as usize] } pub fn day_of_season(tick: SimTick) -> u64 { day_of(tick) % SEASON_DAYS + 1 } fn seasonal_temperature(year_days: f32) -> f32 { let fraction = year_days / YEAR_DAYS as f32; MEAN_TEMP - YEAR_SWING * (TAU * (fraction - COLDEST_YEAR_FRACTION)).cos() } pub fn outdoor_temperature(tick: SimTick) -> f32 { let year_days = (hours(tick) / HOURS_PER_DAY as f32) % YEAR_DAYS as f32; let daily = -DAY_SWING * (TAU * (hour_of_day(tick) - COLDEST_HOUR) / 24.0).cos(); seasonal_temperature(year_days) + daily } pub fn snow_cover(tick: SimTick) -> f32 { let year_days = (hours(tick) / HOURS_PER_DAY as f32) % YEAR_DAYS as f32; ((SNOW_TEMP - seasonal_temperature(year_days)) / SNOW_SPAN).clamp(0.0, 1.0) * MAX_SNOW } pub fn daily_mean(day: u64) -> f32 { seasonal_temperature((day % YEAR_DAYS) as f32 + 0.5) } pub fn growing_day(day: u64) -> bool { daily_mean(day) >= GROWING_DAY_TEMP } pub fn sowing_season(tick: SimTick) -> bool { let today = day_of(tick); (0..SOW_AHEAD_DAYS).all(|d| growing_day(today + d)) } pub fn cold_days_ahead(tick: SimTick) -> u64 { let today = day_of(tick); (0..FORESIGHT_DAYS) .filter(|d| !growing_day(today + d)) .count() as u64 } pub fn growth_factor(temp: f32) -> f32 { ((temp - GROWTH_MIN_TEMP) / (GROWTH_FULL_TEMP - GROWTH_MIN_TEMP)).clamp(0.0, 1.0) } pub fn spoil_factor(temp: f32) -> f32 { (temp / 10.0).clamp(0.1, 1.0) } pub fn heat_at(kind: StructureKind, source: UVec2, tile: UVec2) -> f32 { let radius = kind.radius(); if radius <= 0.0 { return 0.0; } let d = source.as_vec2().distance(tile.as_vec2()) / radius; if d >= 1.0 { return 0.0; } kind.heat() * (1.0 - d * d).powi(2) } pub fn tile_temperature( map: &WorldMap, outdoor: f32, tile: UVec2, fires: impl IntoIterator, ) -> f32 { let base = if map.is_roofed(tile.as_ivec2()) { outdoor + (INDOOR_TEMP - outdoor).max(0.0) * ROOF_INSULATION } else { outdoor }; base + fires .into_iter() .map(|(pos, kind)| (pos, heat_at(kind, pos, tile))) .filter(|&(pos, heat)| heat > 0.0 && line_of_sight(map, pos, tile)) .map(|(_, heat)| heat) .sum::() } #[derive(Component, Debug, Clone, Copy, PartialEq)] pub struct Warmth(pub f32); impl Default for Warmth { fn default() -> Self { Self(INDOOR_TEMP) } } impl Warmth { pub fn cold(self) -> bool { self.0 < COLD_TEMP } } fn update_warmth( tick: Res, map: Res, structures: Query<(&TilePos, &Structure), Without>, mut colonists: Query<(&TilePos, &mut Warmth), With>, ) { if !tick.0.is_multiple_of(WARMTH_INTERVAL_TICKS) { return; } let outdoor = outdoor_temperature(*tick); let fires: Vec<(UVec2, StructureKind)> = structures .iter() .filter(|(_, s)| s.kind.heat() > 0.0) .map(|(pos, s)| (pos.0, s.kind)) .collect(); for (tile, mut warmth) in &mut colonists { let temp = tile_temperature(&map, outdoor, tile.0, fires.iter().copied()); warmth.set_if_neq(Warmth(temp)); } } #[cfg(test)] mod tests { use super::*; use crate::world::Terrain; fn at_day(day: f32) -> SimTick { let hours = day * 24.0 - START_HOUR as f32; SimTick((hours * TICKS_PER_HOUR as f32) as u64) } #[test] fn the_year_runs_spring_summer_autumn_winter() { assert_eq!(season(SimTick(0)), Season::Spring); assert_eq!(season(at_day(4.0)), Season::Summer); assert_eq!(season(at_day(7.0)), Season::Autumn); assert_eq!(season(at_day(10.0)), Season::Winter); assert_eq!(season(at_day(12.5)), Season::Spring); assert_eq!(day_of_season(at_day(4.5)), 2); } #[test] fn summer_is_warm_winter_freezes_and_nights_are_colder() { assert!(outdoor_temperature(at_day(4.5)) > 20.0); assert!(outdoor_temperature(at_day(9.0 + 4.0 / 24.0)) < -5.0); let night = outdoor_temperature(at_day(4.0 + 4.0 / 24.0)); let afternoon = outdoor_temperature(at_day(4.0 + 16.0 / 24.0)); assert!(afternoon > night + 5.0); } // Crops need a run of warm days to ripen (1.5 days at full speed), so sowing stops before the // cold comes and colonists start storing up for the days without harvests. #[test] fn sowing_stops_before_winter_and_stores_are_planned_ahead() { assert!(sowing_season(at_day(4.0))); assert!(!sowing_season(at_day(10.0))); assert!(!sowing_season(at_day(7.5))); assert_eq!(cold_days_ahead(at_day(1.0)), 0); assert!(cold_days_ahead(at_day(6.5)) >= 3); assert!(cold_days_ahead(at_day(9.5)) > cold_days_ahead(at_day(12.5))); } #[test] fn cold_slows_growth_and_spoilage() { assert_eq!(growth_factor(0.0), 0.0); assert_eq!(growth_factor(20.0), 1.0); assert_eq!(spoil_factor(25.0), 1.0); assert!(spoil_factor(-5.0) < 0.2); } #[test] fn snow_lies_only_in_winter() { assert_eq!(snow_cover(at_day(4.5)), 0.0); assert!(snow_cover(at_day(9.5)) > 0.5); } // A roof alone keeps a mild winter bearable; outside only a fire keeps colonists warm, and // walls block its heat like they block its light. #[test] fn roofs_and_fires_keep_colonists_warm() { let mut map = WorldMap::filled(UVec2::new(20, 5), Terrain::Grass); map.set_roof(UVec2::new(0, 0), true); let winter = -6.0; let none = std::iter::empty(); assert!(tile_temperature(&map, winter, UVec2::new(0, 0), none.clone()) >= COLD_TEMP); assert!(tile_temperature(&map, winter, UVec2::new(5, 0), none) < COLD_TEMP); let fire = [(UVec2::new(10, 2), StructureKind::Campfire)]; assert!(tile_temperature(&map, winter, UVec2::new(11, 2), fire) >= WARM_TEMP); map.set_wall(UVec2::new(12, 2), true); assert!(tile_temperature(&map, winter, UVec2::new(13, 2), fire) < COLD_TEMP); } }