src/climate.rs 7.8 K · 265 lines · raw · history

1 use std::f32::consts::TAU;
2
3 use bevy::prelude::*;
4
5 use crate::{
6 colonist::Colonist,
7 light::line_of_sight,
8 sim::{HOURS_PER_DAY, START_HOUR, SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day},
9 structures::{Structure, StructureKind},
10 world::{TilePos, WorldMap},
11 };
12
13 pub struct ClimatePlugin;
14
15 impl Plugin for ClimatePlugin {
16 fn build(&self, app: &mut App) {
17 app.add_systems(FixedUpdate, update_warmth.in_set(SimSystems::Act));
18 }
19 }
20
21 pub const SEASON_DAYS: u64 = 3;
22 pub const YEAR_DAYS: u64 = 4 * SEASON_DAYS;
23 const MEAN_TEMP: f32 = 12.0;
24 const YEAR_SWING: f32 = 14.0;
25 const DAY_SWING: f32 = 4.0;
26 const COLDEST_YEAR_FRACTION: f32 = 0.79;
27 const COLDEST_HOUR: f32 = 4.0;
28 pub const GROWTH_MIN_TEMP: f32 = 4.0;
29 pub const GROWTH_FULL_TEMP: f32 = 14.0;
30 pub const GROWING_DAY_TEMP: f32 = 8.0;
31 pub const SOW_AHEAD_DAYS: u64 = 2;
32 pub const FROST_TEMP: f32 = -2.0;
33 pub const COLD_TEMP: f32 = 5.0;
34 pub const WARM_TEMP: f32 = 10.0;
35 const INDOOR_TEMP: f32 = 16.0;
36 const ROOF_INSULATION: f32 = 0.7;
37 pub const FORESIGHT_DAYS: u64 = 6;
38 const WARMTH_INTERVAL_TICKS: u64 = 60;
39 const SNOW_TEMP: f32 = 3.0;
40 const SNOW_SPAN: f32 = 4.0;
41 const MAX_SNOW: f32 = 0.75;
42
43 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
44 pub enum Season {
45 Spring,
46 Summer,
47 Autumn,
48 Winter,
49 }
50
51 impl Season {
52 pub const ALL: [Season; 4] = [
53 Season::Spring,
54 Season::Summer,
55 Season::Autumn,
56 Season::Winter,
57 ];
58
59 pub fn name(self) -> &'static str {
60 match self {
61 Season::Spring => "Spring",
62 Season::Summer => "Summer",
63 Season::Autumn => "Autumn",
64 Season::Winter => "Winter",
65 }
66 }
67 }
68
69 fn hours(tick: SimTick) -> f32 {
70 tick.0 as f32 / TICKS_PER_HOUR as f32 + START_HOUR as f32
71 }
72
73 pub fn day_of(tick: SimTick) -> u64 {
74 (tick.0 / TICKS_PER_HOUR + START_HOUR) / HOURS_PER_DAY
75 }
76
77 pub fn season(tick: SimTick) -> Season {
78 Season::ALL[(day_of(tick) % YEAR_DAYS / SEASON_DAYS) as usize]
79 }
80
81 pub fn day_of_season(tick: SimTick) -> u64 {
82 day_of(tick) % SEASON_DAYS + 1
83 }
84
85 fn seasonal_temperature(year_days: f32) -> f32 {
86 let fraction = year_days / YEAR_DAYS as f32;
87 MEAN_TEMP - YEAR_SWING * (TAU * (fraction - COLDEST_YEAR_FRACTION)).cos()
88 }
89
90 pub fn outdoor_temperature(tick: SimTick) -> f32 {
91 let year_days = (hours(tick) / HOURS_PER_DAY as f32) % YEAR_DAYS as f32;
92 let daily = -DAY_SWING * (TAU * (hour_of_day(tick) - COLDEST_HOUR) / 24.0).cos();
93 seasonal_temperature(year_days) + daily
94 }
95
96 pub fn snow_cover(tick: SimTick) -> f32 {
97 let year_days = (hours(tick) / HOURS_PER_DAY as f32) % YEAR_DAYS as f32;
98 ((SNOW_TEMP - seasonal_temperature(year_days)) / SNOW_SPAN).clamp(0.0, 1.0) * MAX_SNOW
99 }
100
101 pub fn daily_mean(day: u64) -> f32 {
102 seasonal_temperature((day % YEAR_DAYS) as f32 + 0.5)
103 }
104
105 pub fn growing_day(day: u64) -> bool {
106 daily_mean(day) >= GROWING_DAY_TEMP
107 }
108
109 pub fn sowing_season(tick: SimTick) -> bool {
110 let today = day_of(tick);
111 (0..SOW_AHEAD_DAYS).all(|d| growing_day(today + d))
112 }
113
114 pub fn cold_days_ahead(tick: SimTick) -> u64 {
115 let today = day_of(tick);
116 (0..FORESIGHT_DAYS)
117 .filter(|d| !growing_day(today + d))
118 .count() as u64
119 }
120
121 pub fn growth_factor(temp: f32) -> f32 {
122 ((temp - GROWTH_MIN_TEMP) / (GROWTH_FULL_TEMP - GROWTH_MIN_TEMP)).clamp(0.0, 1.0)
123 }
124
125 pub fn spoil_factor(temp: f32) -> f32 {
126 (temp / 10.0).clamp(0.1, 1.0)
127 }
128
129 pub fn heat_at(kind: StructureKind, source: UVec2, tile: UVec2) -> f32 {
130 let radius = kind.radius();
131 if radius <= 0.0 {
132 return 0.0;
133 }
134 let d = source.as_vec2().distance(tile.as_vec2()) / radius;
135 if d >= 1.0 {
136 return 0.0;
137 }
138 kind.heat() * (1.0 - d * d).powi(2)
139 }
140
141 pub fn tile_temperature(
142 map: &WorldMap,
143 outdoor: f32,
144 tile: UVec2,
145 fires: impl IntoIterator<Item = (UVec2, StructureKind)>,
146 ) -> f32 {
147 let base = if map.is_roofed(tile.as_ivec2()) {
148 outdoor + (INDOOR_TEMP - outdoor).max(0.0) * ROOF_INSULATION
149 } else {
150 outdoor
151 };
152 base + fires
153 .into_iter()
154 .filter(|&(pos, _)| line_of_sight(map, pos, tile))
155 .map(|(pos, kind)| heat_at(kind, pos, tile))
156 .sum::<f32>()
157 }
158
159 #[derive(Component, Debug, Clone, Copy, PartialEq)]
160 pub struct Warmth(pub f32);
161
162 impl Default for Warmth {
163 fn default() -> Self {
164 Self(INDOOR_TEMP)
165 }
166 }
167
168 impl Warmth {
169 pub fn cold(self) -> bool {
170 self.0 < COLD_TEMP
171 }
172 }
173
174 fn update_warmth(
175 tick: Res<SimTick>,
176 map: Res<WorldMap>,
177 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
178 mut colonists: Query<(&TilePos, &mut Warmth), With<Colonist>>,
179 ) {
180 if !tick.0.is_multiple_of(WARMTH_INTERVAL_TICKS) {
181 return;
182 }
183 let outdoor = outdoor_temperature(*tick);
184 let fires: Vec<(UVec2, StructureKind)> = structures
185 .iter()
186 .filter(|(_, s)| s.kind.heat() > 0.0)
187 .map(|(pos, s)| (pos.0, s.kind))
188 .collect();
189 for (tile, mut warmth) in &mut colonists {
190 let temp = tile_temperature(&map, outdoor, tile.0, fires.iter().copied());
191 warmth.set_if_neq(Warmth(temp));
192 }
193 }
194
195 #[cfg(test)]
196 mod tests {
197 use super::*;
198 use crate::world::Terrain;
199
200 fn at_day(day: f32) -> SimTick {
201 let hours = day * 24.0 - START_HOUR as f32;
202 SimTick((hours * TICKS_PER_HOUR as f32) as u64)
203 }
204
205 #[test]
206 fn the_year_runs_spring_summer_autumn_winter() {
207 assert_eq!(season(SimTick(0)), Season::Spring);
208 assert_eq!(season(at_day(4.0)), Season::Summer);
209 assert_eq!(season(at_day(7.0)), Season::Autumn);
210 assert_eq!(season(at_day(10.0)), Season::Winter);
211 assert_eq!(season(at_day(12.5)), Season::Spring);
212 assert_eq!(day_of_season(at_day(4.5)), 2);
213 }
214
215 #[test]
216 fn summer_is_warm_winter_freezes_and_nights_are_colder() {
217 assert!(outdoor_temperature(at_day(4.5)) > 20.0);
218 assert!(outdoor_temperature(at_day(9.0 + 4.0 / 24.0)) < -5.0);
219 let night = outdoor_temperature(at_day(4.0 + 4.0 / 24.0));
220 let afternoon = outdoor_temperature(at_day(4.0 + 16.0 / 24.0));
221 assert!(afternoon > night + 5.0);
222 }
223
224 // Crops need a run of warm days to ripen (1.5 days at full speed), so sowing stops before the
225 // cold comes and colonists start storing up for the days without harvests.
226 #[test]
227 fn sowing_stops_before_winter_and_stores_are_planned_ahead() {
228 assert!(sowing_season(at_day(4.0)));
229 assert!(!sowing_season(at_day(10.0)));
230 assert!(!sowing_season(at_day(7.5)));
231 assert_eq!(cold_days_ahead(at_day(1.0)), 0);
232 assert!(cold_days_ahead(at_day(6.5)) >= 3);
233 assert!(cold_days_ahead(at_day(9.5)) > cold_days_ahead(at_day(12.5)));
234 }
235
236 #[test]
237 fn cold_slows_growth_and_spoilage() {
238 assert_eq!(growth_factor(0.0), 0.0);
239 assert_eq!(growth_factor(20.0), 1.0);
240 assert_eq!(spoil_factor(25.0), 1.0);
241 assert!(spoil_factor(-5.0) < 0.2);
242 }
243
244 #[test]
245 fn snow_lies_only_in_winter() {
246 assert_eq!(snow_cover(at_day(4.5)), 0.0);
247 assert!(snow_cover(at_day(9.5)) > 0.5);
248 }
249
250 // A roof alone keeps a mild winter bearable; outside only a fire keeps colonists warm, and
251 // walls block its heat like they block its light.
252 #[test]
253 fn roofs_and_fires_keep_colonists_warm() {
254 let mut map = WorldMap::filled(UVec2::new(20, 5), Terrain::Grass);
255 map.set_roof(UVec2::new(0, 0), true);
256 let winter = -6.0;
257 let none = std::iter::empty();
258 assert!(tile_temperature(&map, winter, UVec2::new(0, 0), none.clone()) >= COLD_TEMP);
259 assert!(tile_temperature(&map, winter, UVec2::new(5, 0), none) < COLD_TEMP);
260 let fire = [(UVec2::new(10, 2), StructureKind::Campfire)];
261 assert!(tile_temperature(&map, winter, UVec2::new(11, 2), fire) >= WARM_TEMP);
262 map.set_wall(UVec2::new(12, 2), true);
263 assert!(tile_temperature(&map, winter, UVec2::new(13, 2), fire) < COLD_TEMP);
264 }
265 }