Add seasons, temperature, cold and snow
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 06:21 UTC
- commit
- 74cb0e7c3d1b971ec4ebb56ff9061ced9d19f2ce
- parent
- 954bfe7d77
- tree
- browse at this commit
16 files changed +544 -26
| src/climate.rs | added | +265 -0 |
| src/colonist.rs | +3 -1 | |
| src/economy.rs | +29 -3 | |
| src/food.rs | +10 -3 | |
| src/jobs/mod.rs | +61 -5 | |
| src/jobs/tests.rs | +33 -0 | |
| src/light.rs | +21 -2 | |
| src/main.rs | +2 -0 | |
| src/mood.rs | +9 -1 | |
| src/needs.rs | +9 -2 | |
| src/plants.rs | +37 -5 | |
| src/selection.rs | +15 -3 | |
| src/sim.rs | +1 -1 | |
| src/structures.rs | +8 -0 | |
| src/tilemap.rs | +37 -0 | |
| tasks.md | +4 -0 |
src/climate.rs +265 -0
| @@ -0,0 +1,265 @@ | ||
| 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 | +} | |
src/colonist.rs +3 -1
| @@ -3,6 +3,7 @@ use std::collections::VecDeque; | ||
| 3 | 3 | use bevy::prelude::*; |
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | + climate::Warmth, | |
| 6 | 7 | food::Hunger, |
| 7 | 8 | items::{Carrying, ItemAssets}, |
| 8 | 9 | jobs::{CurrentJob, IdleTimer, WorkPriorities}, |
| @@ -90,7 +91,8 @@ pub fn spawn_colonist( | ||
| 90 | 91 | Rest, |
| 91 | 92 | Hunger, |
| 92 | 93 | Starvation, |
| 93 | - WorkPriorities | |
| 94 | + WorkPriorities, | |
| 95 | + Warmth | |
| 94 | 96 | )] |
| 95 | 97 | pub struct Colonist { |
| 96 | 98 | pub name: String, |
src/economy.rs +29 -3
| @@ -4,6 +4,7 @@ use bevy::prelude::*; | ||
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | 6 | building::Blueprints, |
| 7 | + climate::cold_days_ahead, | |
| 7 | 8 | items::ItemKind, |
| 8 | 9 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 9 | 10 | }; |
| @@ -75,8 +76,12 @@ impl Stock { | ||
| 75 | 76 | } |
| 76 | 77 | } |
| 77 | 78 | |
| 78 | - fn buffer_days(self) -> f32 { | |
| 79 | - if self.is_food() { 1.5 } else { 2.0 } | |
| 79 | + fn buffer_days(self, cold_days: u64) -> f32 { | |
| 80 | + let normal: f32 = if self.is_food() { 1.5 } else { 2.0 }; | |
| 81 | + match self { | |
| 82 | + Stock::Wood | Stock::RawFood | Stock::Meals => normal.max(cold_days as f32), | |
| 83 | + _ => normal, | |
| 84 | + } | |
| 80 | 85 | } |
| 81 | 86 | |
| 82 | 87 | fn minimum(self) -> f32 { |
| @@ -106,6 +111,7 @@ pub struct ColonyGoals { | ||
| 106 | 111 | use_per_day: HashMap<Stock, f32>, |
| 107 | 112 | used_this_hour: HashMap<Stock, u32>, |
| 108 | 113 | overrides: HashMap<Stock, u32>, |
| 114 | + cold_days: u64, | |
| 109 | 115 | } |
| 110 | 116 | |
| 111 | 117 | impl Default for ColonyGoals { |
| @@ -118,6 +124,7 @@ impl Default for ColonyGoals { | ||
| 118 | 124 | .collect(), |
| 119 | 125 | used_this_hour: HashMap::new(), |
| 120 | 126 | overrides: HashMap::new(), |
| 127 | + cold_days: 0, | |
| 121 | 128 | }; |
| 122 | 129 | goals.recompute(&HashMap::new()); |
| 123 | 130 | goals |
| @@ -132,6 +139,7 @@ impl ColonyGoals { | ||
| 132 | 139 | use_per_day: HashMap::new(), |
| 133 | 140 | used_this_hour: HashMap::new(), |
| 134 | 141 | overrides: HashMap::new(), |
| 142 | + cold_days: 0, | |
| 135 | 143 | } |
| 136 | 144 | } |
| 137 | 145 | |
| @@ -180,6 +188,10 @@ impl ColonyGoals { | ||
| 180 | 188 | have < self.goal(stock) |
| 181 | 189 | } |
| 182 | 190 | |
| 191 | + pub fn set_cold_days(&mut self, days: u64) { | |
| 192 | + self.cold_days = days; | |
| 193 | + } | |
| 194 | + | |
| 183 | 195 | pub fn learn_hour(&mut self, planned: &HashMap<Stock, u32>) { |
| 184 | 196 | for stock in Stock::ALL { |
| 185 | 197 | let used = self.used_this_hour.remove(&stock).unwrap_or(0) as f32; |
| @@ -191,7 +203,7 @@ impl ColonyGoals { | ||
| 191 | 203 | |
| 192 | 204 | fn recompute(&mut self, planned: &HashMap<Stock, u32>) { |
| 193 | 205 | for stock in Stock::ALL { |
| 194 | - let learned = (self.use_per_day(stock) * stock.buffer_days()) | |
| 206 | + let learned = (self.use_per_day(stock) * stock.buffer_days(self.cold_days)) | |
| 195 | 207 | .clamp(stock.minimum(), MAX_GOAL) |
| 196 | 208 | .ceil() as u32; |
| 197 | 209 | let goal = learned + planned.get(&stock).copied().unwrap_or(0); |
| @@ -217,6 +229,7 @@ fn learn_needs(tick: Res<SimTick>, blueprints: Res<Blueprints>, mut goals: ResMu | ||
| 217 | 229 | if tick.0 == 0 || !tick.0.is_multiple_of(TICKS_PER_HOUR) { |
| 218 | 230 | return; |
| 219 | 231 | } |
| 232 | + goals.set_cold_days(cold_days_ahead(*tick)); | |
| 220 | 233 | goals.learn_hour(&planned_needs(&blueprints)); |
| 221 | 234 | } |
| 222 | 235 | |
| @@ -257,6 +270,19 @@ mod tests { | ||
| 257 | 270 | assert!(goals.goal(Stock::Wood) > learned); |
| 258 | 271 | } |
| 259 | 272 | |
| 273 | + // Nothing grows in the cold days ahead and fires burn all day, so food and wood targets cover | |
| 274 | + // those days instead of the usual short buffer. | |
| 275 | + #[test] | |
| 276 | + fn winter_ahead_raises_food_and_wood_targets() { | |
| 277 | + let mut goals = ColonyGoals::default(); | |
| 278 | + learn_days(&mut goals, ItemKind::Berries, 1, 3); | |
| 279 | + let (food, stone) = (goals.goal(Stock::RawFood), goals.goal(Stock::Stone)); | |
| 280 | + goals.set_cold_days(5); | |
| 281 | + goals.learn_hour(&HashMap::new()); | |
| 282 | + assert!(goals.goal(Stock::RawFood) > food * 3); | |
| 283 | + assert_eq!(goals.goal(Stock::Stone), stone); | |
| 284 | + } | |
| 285 | + | |
| 260 | 286 | #[test] |
| 261 | 287 | fn unused_materials_are_not_stockpiled() { |
| 262 | 288 | let mut goals = ColonyGoals::default(); |
src/food.rs +10 -3
| @@ -3,6 +3,7 @@ use std::collections::HashSet; | ||
| 3 | 3 | use bevy::prelude::*; |
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | + climate::{Warmth, outdoor_temperature, spoil_factor}, | |
| 6 | 7 | colonist::{Colonist, Path}, |
| 7 | 8 | items::{Carrying, Item, ItemKind}, |
| 8 | 9 | jobs::{CurrentJob, Reservations, Target}, |
| @@ -26,6 +27,7 @@ impl Plugin for FoodPlugin { | ||
| 26 | 27 | pub const HUNGER_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32); |
| 27 | 28 | pub const SLEEPING_HUNGER_FACTOR: f32 = 0.5; |
| 28 | 29 | pub const HUNGRY: f32 = 0.35; |
| 30 | +pub const COLD_HUNGER_FACTOR: f32 = 1.5; | |
| 29 | 31 | pub const STARVING: f32 = 0.05; |
| 30 | 32 | pub const EAT_TICKS: u32 = 90; |
| 31 | 33 | pub const ROOFED_SPOIL_FACTOR: f32 = 0.5; |
| @@ -98,7 +100,7 @@ pub fn spoil_food( | ||
| 98 | 100 | if !tick.0.is_multiple_of(SPOIL_INTERVAL_TICKS) { |
| 99 | 101 | return; |
| 100 | 102 | } |
| 101 | - let ticks = SPOIL_INTERVAL_TICKS as f32; | |
| 103 | + let ticks = SPOIL_INTERVAL_TICKS as f32 * spoil_factor(outdoor_temperature(*tick)); | |
| 102 | 104 | for (entity, mut item, pos) in &mut items { |
| 103 | 105 | item.spoil += spoilage(item.kind, map.is_roofed(pos.0.as_ivec2()), ticks); |
| 104 | 106 | if item.spoil >= 1.0 { |
| @@ -116,14 +118,19 @@ pub fn spoil_food( | ||
| 116 | 118 | } |
| 117 | 119 | |
| 118 | 120 | pub fn update_hunger( |
| 119 | - mut colonists: Query<(&mut Hunger, &CurrentJob, &TilePos, &Path), With<Colonist>>, | |
| 121 | + mut colonists: Query<(&mut Hunger, &CurrentJob, &TilePos, &Path, &Warmth), With<Colonist>>, | |
| 120 | 122 | ) { |
| 121 | - for (mut hunger, job, tile, path) in &mut colonists { | |
| 123 | + for (mut hunger, job, tile, path, warmth) in &mut colonists { | |
| 122 | 124 | let rate = if is_sleeping(job, tile, path) { |
| 123 | 125 | HUNGER_PER_TICK * SLEEPING_HUNGER_FACTOR |
| 124 | 126 | } else { |
| 125 | 127 | HUNGER_PER_TICK |
| 126 | 128 | }; |
| 129 | + let rate = if warmth.cold() { | |
| 130 | + rate * COLD_HUNGER_FACTOR | |
| 131 | + } else { | |
| 132 | + rate | |
| 133 | + }; | |
| 127 | 134 | hunger.0 = (hunger.0 - rate).max(0.0); |
| 128 | 135 | } |
| 129 | 136 | } |
src/jobs/mod.rs +61 -5
| @@ -9,6 +9,7 @@ use bevy::prelude::*; | ||
| 9 | 9 | use crate::{ |
| 10 | 10 | animals::{Animal, AnimalKind, MAX_TAME, Tame}, |
| 11 | 11 | building::{Blueprints, BuildKind}, |
| 12 | + climate::{COLD_TEMP, WARM_TEMP, Warmth, outdoor_temperature, sowing_season, tile_temperature}, | |
| 12 | 13 | colonist::{Colonist, Path, repath}, |
| 13 | 14 | designations::Designations, |
| 14 | 15 | economy::{ColonyGoals, in_colony}, |
| @@ -18,7 +19,10 @@ use crate::{ | ||
| 18 | 19 | CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, STACK_LIMIT, drop_items, |
| 19 | 20 | room_for, stock, take_items, |
| 20 | 21 | }, |
| 21 | - light::{LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from, line_of_sight}, | |
| 22 | + light::{ | |
| 23 | + LIGHT_LOOKAHEAD_TICKS, LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from, | |
| 24 | + line_of_sight, | |
| 25 | + }, | |
| 22 | 26 | mood::{Ate, Sick}, |
| 23 | 27 | needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep}, |
| 24 | 28 | pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_of, nearest_tile}, |
| @@ -885,10 +889,12 @@ pub fn find_cooking(ctx: &BuildContext, from: UVec2) -> Option<Task> { | ||
| 885 | 889 | }) |
| 886 | 890 | } |
| 887 | 891 | |
| 888 | -pub fn find_lighting(ctx: &BuildContext, from: UVec2) -> Option<Task> { | |
| 892 | +pub fn find_lighting(ctx: &BuildContext, from: UVec2, dark: bool) -> Option<Task> { | |
| 889 | 893 | let home = ctx.zones.home(from); |
| 890 | 894 | let (kind, site) = if ctx.campfire_needed(home) { |
| 891 | 895 | (StructureKind::Campfire, ctx.site_near(home, HOME_RADIUS)?) |
| 896 | + } else if !dark { | |
| 897 | + return None; | |
| 892 | 898 | } else { |
| 893 | 899 | let dark = ctx.darkest_stockpile_tile(home)?; |
| 894 | 900 | let reach = StructureKind::Torch.radius() * 0.6; |
| @@ -897,6 +903,37 @@ pub fn find_lighting(ctx: &BuildContext, from: UVec2) -> Option<Task> { | ||
| 897 | 903 | ctx.build_task(from, site, BuildKind::Structure(kind)) |
| 898 | 904 | } |
| 899 | 905 | |
| 906 | +pub fn chilly_soon(tick: SimTick) -> bool { | |
| 907 | + let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS); | |
| 908 | + outdoor_temperature(tick) < COLD_TEMP || outdoor_temperature(later) < COLD_TEMP | |
| 909 | +} | |
| 910 | + | |
| 911 | +pub fn find_warm_spot( | |
| 912 | + map: &WorldMap, | |
| 913 | + structures: &[(UVec2, StructureKind, u32)], | |
| 914 | + outdoor: f32, | |
| 915 | + from: UVec2, | |
| 916 | +) -> Option<UVec2> { | |
| 917 | + let fires: Vec<(UVec2, StructureKind)> = structures | |
| 918 | + .iter() | |
| 919 | + .filter(|(_, kind, _)| kind.heat() > 0.0) | |
| 920 | + .map(|&(pos, kind, _)| (pos, kind)) | |
| 921 | + .collect(); | |
| 922 | + let taken: HashSet<UVec2> = structures.iter().map(|(pos, ..)| *pos).collect(); | |
| 923 | + let warm: HashSet<UVec2> = fires | |
| 924 | + .iter() | |
| 925 | + .flat_map(|&(pos, kind)| { | |
| 926 | + let r = kind.radius().ceil() as i32; | |
| 927 | + (-r..=r).flat_map(move |dy| (-r..=r).map(move |dx| pos.as_ivec2() + IVec2::new(dx, dy))) | |
| 928 | + }) | |
| 929 | + .filter(|&p| map.in_bounds(p) && map.move_cost(p).is_some()) | |
| 930 | + .map(|p| p.as_uvec2()) | |
| 931 | + .filter(|p| !taken.contains(p)) | |
| 932 | + .filter(|&p| tile_temperature(map, outdoor, p, fires.iter().copied()) >= WARM_TEMP) | |
| 933 | + .collect(); | |
| 934 | + nearest_of(map, from, SEARCH_TILES, &warm) | |
| 935 | +} | |
| 936 | + | |
| 900 | 937 | pub fn find_refuel(ctx: &BuildContext, from: UVec2) -> Option<Task> { |
| 901 | 938 | let home = ctx.zones.home(from); |
| 902 | 939 | let (site, kind, _) = ctx |
| @@ -986,6 +1023,7 @@ fn choose_jobs( | ||
| 986 | 1023 | Option<&Family>, |
| 987 | 1024 | Has<Child>, |
| 988 | 1025 | &WorkPriorities, |
| 1026 | + &Warmth, | |
| 989 | 1027 | ), |
| 990 | 1028 | With<Colonist>, |
| 991 | 1029 | >, |
| @@ -1025,6 +1063,9 @@ fn choose_jobs( | ||
| 1025 | 1063 | }) |
| 1026 | 1064 | .collect(); |
| 1027 | 1065 | let dark = dark_soon(*tick); |
| 1066 | + let chilly = chilly_soon(*tick); | |
| 1067 | + let outdoor = outdoor_temperature(*tick); | |
| 1068 | + let sowing = sowing_season(*tick); | |
| 1028 | 1069 | let carried: Vec<Item> = colonists.iter().filter_map(|c| c.5.0).collect(); |
| 1029 | 1070 | let home = zones.home(map.size / 2); |
| 1030 | 1071 | let stocks: HashMap<ItemKind, u32> = ItemKind::ALL |
| @@ -1064,6 +1105,7 @@ fn choose_jobs( | ||
| 1064 | 1105 | family, |
| 1065 | 1106 | child, |
| 1066 | 1107 | priorities, |
| 1108 | + warmth, | |
| 1067 | 1109 | ) in &mut colonists |
| 1068 | 1110 | { |
| 1069 | 1111 | let exhausted = rest.0 < EXHAUSTED_REST; |
| @@ -1116,6 +1158,16 @@ fn choose_jobs( | ||
| 1116 | 1158 | Some(Task::Sleep { |
| 1117 | 1159 | spot: sleep.spot(tile.0), |
| 1118 | 1160 | }) |
| 1161 | + } else if let Some(spot) = warmth | |
| 1162 | + .cold() | |
| 1163 | + .then(|| find_warm_spot(&map, &structures_list, outdoor, tile.0)) | |
| 1164 | + .flatten() | |
| 1165 | + .filter(|&spot| spot != tile.0) | |
| 1166 | + { | |
| 1167 | + find_path(&map, tile.0, spot).map(|route| { | |
| 1168 | + path.0 = route.into(); | |
| 1169 | + Task::MoveTo(spot) | |
| 1170 | + }) | |
| 1119 | 1171 | } else if child { |
| 1120 | 1172 | None |
| 1121 | 1173 | } else if let Some(load) = carrying.0 { |
| @@ -1162,7 +1214,9 @@ fn choose_jobs( | ||
| 1162 | 1214 | }), |
| 1163 | 1215 | (WorkType::Build, &|| find_refuel(&build, tile.0)), |
| 1164 | 1216 | (WorkType::Build, &|| { |
| 1165 | - dark.then(|| find_lighting(&build, tile.0)).flatten() | |
| 1217 | + (dark || chilly) | |
| 1218 | + .then(|| find_lighting(&build, tile.0, dark)) | |
| 1219 | + .flatten() | |
| 1166 | 1220 | }), |
| 1167 | 1221 | (WorkType::Food, &cooking), |
| 1168 | 1222 | (WorkType::Food, &|| { |
| @@ -1180,7 +1234,7 @@ fn choose_jobs( | ||
| 1180 | 1234 | }), |
| 1181 | 1235 | (WorkType::Gather, &designated), |
| 1182 | 1236 | (WorkType::Food, &|| { |
| 1183 | - wanted(ItemKind::Vegetables) | |
| 1237 | + (sowing && wanted(ItemKind::Vegetables)) | |
| 1184 | 1238 | .then(|| find_sowing(&map, &zones, &reservations, tile.0)) |
| 1185 | 1239 | .flatten() |
| 1186 | 1240 | }), |
| @@ -1199,7 +1253,9 @@ fn choose_jobs( | ||
| 1199 | 1253 | .flatten() |
| 1200 | 1254 | }), |
| 1201 | 1255 | (WorkType::Food, &|| { |
| 1202 | - find_sowing(&map, &zones, &reservations, tile.0) | |
| 1256 | + sowing | |
| 1257 | + .then(|| find_sowing(&map, &zones, &reservations, tile.0)) | |
| 1258 | + .flatten() | |
| 1203 | 1259 | }), |
| 1204 | 1260 | (WorkType::Tame, &|| { |
| 1205 | 1261 | (!wanted(ItemKind::Meat)) |
src/jobs/tests.rs +33 -0
| @@ -1388,3 +1388,36 @@ fn stored_items_move_up_to_a_higher_priority_stockpile() { | ||
| 1388 | 1388 | run_ticks(&mut app, 60 * 20); |
| 1389 | 1389 | assert!(wood_at(&mut app).iter().all(|t| t.x >= 14 && t.y >= 14)); |
| 1390 | 1390 | } |
| 1391 | + | |
| 1392 | +// Cold colonists stop to warm up by a fire before taking new work; they would otherwise lose | |
| 1393 | +// mood, rest and food to the cold for as long as winter lasts. | |
| 1394 | +#[test] | |
| 1395 | +fn cold_colonists_warm_up_by_a_fire() { | |
| 1396 | + let mut app = test_app(WorldMap::filled(UVec2::splat(30), Terrain::Grass)); | |
| 1397 | + let winter = 9 * 24 * crate::sim::TICKS_PER_HOUR; | |
| 1398 | + app.insert_resource(SimTick(winter)); | |
| 1399 | + app.world_mut().spawn(( | |
| 1400 | + Structure { | |
| 1401 | + kind: StructureKind::Campfire, | |
| 1402 | + }, | |
| 1403 | + TilePos(UVec2::new(20, 20)), | |
| 1404 | + )); | |
| 1405 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 2)); | |
| 1406 | + app.world_mut() | |
| 1407 | + .entity_mut(colonist) | |
| 1408 | + .insert(crate::climate::Warmth(-5.0)); | |
| 1409 | + for _ in 0..300 { | |
| 1410 | + run_ticks(&mut app, 1); | |
| 1411 | + if job(&app, colonist).is_some() { | |
| 1412 | + break; | |
| 1413 | + } | |
| 1414 | + } | |
| 1415 | + let Some(Job { | |
| 1416 | + task: Task::MoveTo(spot), | |
| 1417 | + .. | |
| 1418 | + }) = job(&app, colonist) | |
| 1419 | + else { | |
| 1420 | + panic!("expected a move to the fire, got {:?}", job(&app, colonist)); | |
| 1421 | + }; | |
| 1422 | + assert!(spot.as_vec2().distance(Vec2::splat(20.0)) <= 3.0); | |
| 1423 | +} | |
src/light.rs +21 -2
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | use bevy::prelude::*; |
| 2 | 2 | |
| 3 | 3 | use crate::{ |
| 4 | + climate::snow_cover, | |
| 4 | 5 | sim::{SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day}, |
| 5 | 6 | structures::{Structure, StructureKind}, |
| 6 | 7 | world::{TileChanged, TilePos, WorldMap}, |
| @@ -21,6 +22,7 @@ const AMBIENT_STEPS: f32 = 16.0; | ||
| 21 | 22 | const DAY_COLOR: Vec3 = Vec3::ONE; |
| 22 | 23 | const NIGHT_COLOR: Vec3 = Vec3::new(0.2, 0.24, 0.4); |
| 23 | 24 | pub const ROOF_DAYLIGHT: f32 = 0.3; |
| 25 | +const SNOW_STEPS: f32 = 8.0; | |
| 24 | 26 | |
| 25 | 27 | pub fn daylight(hour: f32) -> f32 { |
| 26 | 28 | match hour { |
| @@ -80,6 +82,7 @@ pub struct LightMap { | ||
| 80 | 82 | indoor_ambient: Vec3, |
| 81 | 83 | sources: Vec<Vec3>, |
| 82 | 84 | roofed: Vec<bool>, |
| 85 | + snow_step: u32, | |
| 83 | 86 | } |
| 84 | 87 | |
| 85 | 88 | impl LightMap { |
| @@ -96,6 +99,15 @@ impl LightMap { | ||
| 96 | 99 | (ambient + self.sources[index]).min(Vec3::ONE) |
| 97 | 100 | } |
| 98 | 101 | |
| 102 | + pub fn snow(&self) -> f32 { | |
| 103 | + self.snow_step as f32 / SNOW_STEPS | |
| 104 | + } | |
| 105 | + | |
| 106 | + pub fn with_snow(mut self, cover: f32) -> Self { | |
| 107 | + self.snow_step = (cover * SNOW_STEPS).round() as u32; | |
| 108 | + self | |
| 109 | + } | |
| 110 | + | |
| 99 | 111 | pub fn is_lit(&self, tile: UVec2) -> bool { |
| 100 | 112 | brightness(self.color(tile)) >= LIT_BRIGHTNESS |
| 101 | 113 | } |
| @@ -141,6 +153,7 @@ impl LightMap { | ||
| 141 | 153 | .positions() |
| 142 | 154 | .map(|p| map.is_roofed(p.as_ivec2())) |
| 143 | 155 | .collect(), |
| 156 | + snow_step: 0, | |
| 144 | 157 | } |
| 145 | 158 | } |
| 146 | 159 | } |
| @@ -158,10 +171,16 @@ fn update_light_map( | ||
| 158 | 171 | let step = (day * AMBIENT_STEPS).round() as u32; |
| 159 | 172 | let tiles_changed = changed.read().count() > 0; |
| 160 | 173 | let sources_changed = !added.is_empty() || removed.read().count() > 0 || tiles_changed; |
| 161 | - if !sources_changed && step == light.ambient_step && light.size == map.size { | |
| 174 | + let snow = LightMap::default().with_snow(snow_cover(*tick)).snow_step; | |
| 175 | + if !sources_changed | |
| 176 | + && step == light.ambient_step | |
| 177 | + && snow == light.snow_step | |
| 178 | + && light.size == map.size | |
| 179 | + { | |
| 162 | 180 | return; |
| 163 | 181 | } |
| 164 | - let fresh = LightMap::compute(&map, day, structures.iter().map(|(pos, s)| (pos.0, s.kind))); | |
| 182 | + let fresh = LightMap::compute(&map, day, structures.iter().map(|(pos, s)| (pos.0, s.kind))) | |
| 183 | + .with_snow(snow_cover(*tick)); | |
| 165 | 184 | if fresh != *light { |
| 166 | 185 | *light = fresh; |
| 167 | 186 | } |
src/main.rs +2 -0
| @@ -2,6 +2,7 @@ mod actions; | ||
| 2 | 2 | mod animals; |
| 3 | 3 | mod building; |
| 4 | 4 | mod camera; |
| 5 | +mod climate; | |
| 5 | 6 | mod colonist; |
| 6 | 7 | mod designations; |
| 7 | 8 | mod economy; |
| @@ -73,6 +74,7 @@ fn main() { | ||
| 73 | 74 | mood::MoodPlugin, |
| 74 | 75 | panel::PanelPlugin, |
| 75 | 76 | plants::PlantsPlugin, |
| 77 | + climate::ClimatePlugin, | |
| 76 | 78 | selection::SelectionPlugin, |
| 77 | 79 | zones::ZonesPlugin, |
| 78 | 80 | ui::HudPlugin, |
src/mood.rs +9 -1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | use bevy::prelude::*; |
| 2 | 2 | |
| 3 | 3 | use crate::{ |
| 4 | + climate::Warmth, | |
| 4 | 5 | colonist::Colonist, |
| 5 | 6 | family::{Child, Family, Homes, Partner}, |
| 6 | 7 | food::{HUNGRY, Hunger}, |
| @@ -105,6 +106,7 @@ pub struct MoodFactors { | ||
| 105 | 106 | pub partner: bool, |
| 106 | 107 | pub home: bool, |
| 107 | 108 | pub grieving: bool, |
| 109 | + pub cold: bool, | |
| 108 | 110 | } |
| 109 | 111 | |
| 110 | 112 | pub fn mood_target(f: MoodFactors) -> f32 { |
| @@ -128,6 +130,9 @@ pub fn mood_target(f: MoodFactors) -> f32 { | ||
| 128 | 130 | if f.grieving { |
| 129 | 131 | mood -= 0.15; |
| 130 | 132 | } |
| 133 | + if f.cold { | |
| 134 | + mood -= 0.15; | |
| 135 | + } | |
| 131 | 136 | mood.clamp(0.0, 1.0) |
| 132 | 137 | } |
| 133 | 138 | |
| @@ -188,13 +193,14 @@ fn update_mood( | ||
| 188 | 193 | Option<&LastMeal>, |
| 189 | 194 | Has<Partner>, |
| 190 | 195 | Option<&Family>, |
| 196 | + &Warmth, | |
| 191 | 197 | )>, |
| 192 | 198 | ) { |
| 193 | 199 | if !tick.0.is_multiple_of(TICKS_PER_HOUR) { |
| 194 | 200 | return; |
| 195 | 201 | } |
| 196 | 202 | let grieving = losses.0.iter().any(|&t| tick.0 < t + GRIEF_TICKS); |
| 197 | - for (entity, mut mood, hunger, rest, sick, meal, partner, family) in &mut colonists { | |
| 203 | + for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth) in &mut colonists { | |
| 198 | 204 | let sick_now = sick.is_some_and(|s| tick.0 < s.until); |
| 199 | 205 | if sick.is_some() && !sick_now { |
| 200 | 206 | commands.entity(entity).remove::<Sick>(); |
| @@ -212,6 +218,7 @@ fn update_mood( | ||
| 212 | 218 | partner, |
| 213 | 219 | home, |
| 214 | 220 | grieving, |
| 221 | + cold: warmth.cold(), | |
| 215 | 222 | }); |
| 216 | 223 | mood.0 += (target - mood.0) / MOOD_HOURS; |
| 217 | 224 | } |
| @@ -329,6 +336,7 @@ mod tests { | ||
| 329 | 336 | rest: 0.1, |
| 330 | 337 | sick: true, |
| 331 | 338 | grieving: true, |
| 339 | + cold: true, | |
| 332 | 340 | last_meal_cooked: Some(false), |
| 333 | 341 | ..default() |
| 334 | 342 | }); |
src/needs.rs +9 -2
| @@ -4,6 +4,7 @@ use bevy::prelude::*; | ||
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | 6 | building::Blueprints, |
| 7 | + climate::Warmth, | |
| 7 | 8 | colonist::{Colonist, Path}, |
| 8 | 9 | family::{Family, Homes}, |
| 9 | 10 | items::Item, |
| @@ -28,6 +29,7 @@ pub const REST_DRAIN_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32); | ||
| 28 | 29 | pub const REST_RECOVERY_PER_TICK: f32 = 1.0 / (7.0 * TICKS_PER_HOUR as f32); |
| 29 | 30 | pub const BED_REST_RECOVERY_PER_TICK: f32 = 1.0 / (5.0 * TICKS_PER_HOUR as f32); |
| 30 | 31 | pub const ROOF_REST_BONUS: f32 = 1.25; |
| 32 | +pub const COLD_REST_FACTOR: f32 = 0.5; | |
| 31 | 33 | pub const BEDTIME_REST: f32 = 0.9; |
| 32 | 34 | pub const NAP_REST: f32 = 0.3; |
| 33 | 35 | pub const EXHAUSTED_REST: f32 = 0.1; |
| @@ -67,14 +69,14 @@ pub fn is_sleeping(job: &CurrentJob, tile: &TilePos, path: &Path) -> bool { | ||
| 67 | 69 | pub fn update_rest( |
| 68 | 70 | map: Res<WorldMap>, |
| 69 | 71 | structures: Query<(&TilePos, &Structure), Without<Colonist>>, |
| 70 | - mut colonists: Query<(&mut Rest, &CurrentJob, &TilePos, &Path), With<Colonist>>, | |
| 72 | + mut colonists: Query<(&mut Rest, &CurrentJob, &TilePos, &Path, &Warmth), With<Colonist>>, | |
| 71 | 73 | ) { |
| 72 | 74 | let beds: HashSet<UVec2> = structures |
| 73 | 75 | .iter() |
| 74 | 76 | .filter(|(_, s)| s.kind == StructureKind::Bed) |
| 75 | 77 | .map(|(pos, _)| pos.0) |
| 76 | 78 | .collect(); |
| 77 | - for (mut rest, job, tile, path) in &mut colonists { | |
| 79 | + for (mut rest, job, tile, path, warmth) in &mut colonists { | |
| 78 | 80 | let change = match (is_sleeping(job, tile, path), beds.contains(&tile.0)) { |
| 79 | 81 | (true, true) => BED_REST_RECOVERY_PER_TICK, |
| 80 | 82 | (true, false) => REST_RECOVERY_PER_TICK, |
| @@ -85,6 +87,11 @@ pub fn update_rest( | ||
| 85 | 87 | } else { |
| 86 | 88 | change |
| 87 | 89 | }; |
| 90 | + let change = if change > 0.0 && warmth.cold() { | |
| 91 | + change * COLD_REST_FACTOR | |
| 92 | + } else { | |
| 93 | + change | |
| 94 | + }; | |
| 88 | 95 | rest.0 = (rest.0 + change).clamp(0.0, 1.0); |
| 89 | 96 | } |
| 90 | 97 | } |
src/plants.rs +37 -5
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | use bevy::prelude::*; |
| 2 | 2 | |
| 3 | 3 | use crate::{ |
| 4 | + climate::{FROST_TEMP, growth_factor, outdoor_temperature}, | |
| 4 | 5 | sim::{SimSystems, SimTick}, |
| 5 | 6 | tilemap::PlantLook, |
| 6 | 7 | world::{Terrain, TileChanged, WorldMap}, |
| @@ -27,7 +28,13 @@ pub fn fertility(terrain: Terrain) -> f32 { | ||
| 27 | 28 | } |
| 28 | 29 | } |
| 29 | 30 | |
| 30 | -pub fn grow(map: &mut WorldMap, elapsed_ticks: f32, mut on_change: impl FnMut(UVec2)) { | |
| 31 | +pub fn grow( | |
| 32 | + map: &mut WorldMap, | |
| 33 | + elapsed_ticks: f32, | |
| 34 | + temperature: f32, | |
| 35 | + mut on_change: impl FnMut(UVec2), | |
| 36 | +) { | |
| 37 | + let warmth = growth_factor(temperature); | |
| 31 | 38 | for pos in map.growing_plants() { |
| 32 | 39 | let p = pos.as_ivec2(); |
| 33 | 40 | let Some(plant) = map.plant(p) else { |
| @@ -40,12 +47,17 @@ pub fn grow(map: &mut WorldMap, elapsed_ticks: f32, mut on_change: impl FnMut(UV | ||
| 40 | 47 | if growth >= 1.0 { |
| 41 | 48 | continue; |
| 42 | 49 | } |
| 50 | + if plant == crate::world::Plant::Crop && temperature < FROST_TEMP { | |
| 51 | + map.set_plant(pos, None); | |
| 52 | + on_change(pos); | |
| 53 | + continue; | |
| 54 | + } | |
| 43 | 55 | let soil = fertility(map.get(p).unwrap_or(Terrain::Grass)); |
| 44 | 56 | let soil = match plant { |
| 45 | 57 | crate::world::Plant::Crop => soil, |
| 46 | 58 | _ => soil.max(WILD_MIN_FERTILITY), |
| 47 | 59 | }; |
| 48 | - let grown = (growth + elapsed_ticks / ripening * soil).min(1.0); | |
| 60 | + let grown = (growth + elapsed_ticks / ripening * soil * warmth).min(1.0); | |
| 49 | 61 | map.set_growth(pos, grown); |
| 50 | 62 | if PlantLook::of(plant, growth) != PlantLook::of(plant, grown) { |
| 51 | 63 | on_change(pos); |
| @@ -61,7 +73,8 @@ fn grow_plants( | ||
| 61 | 73 | if !tick.0.is_multiple_of(GROWTH_INTERVAL_TICKS) { |
| 62 | 74 | return; |
| 63 | 75 | } |
| 64 | - grow(&mut map, GROWTH_INTERVAL_TICKS as f32, |pos| { | |
| 76 | + let temperature = outdoor_temperature(*tick); | |
| 77 | + grow(&mut map, GROWTH_INTERVAL_TICKS as f32, temperature, |pos| { | |
| 65 | 78 | changed.write(TileChanged(pos)); |
| 66 | 79 | }); |
| 67 | 80 | } |
| @@ -71,10 +84,12 @@ mod tests { | ||
| 71 | 84 | use super::*; |
| 72 | 85 | use crate::world::Plant; |
| 73 | 86 | |
| 87 | + const SUMMER: f32 = 20.0; | |
| 88 | + | |
| 74 | 89 | fn growth_after(terrain: Terrain, plant: Plant, ticks: f32) -> f32 { |
| 75 | 90 | let mut map = WorldMap::filled(UVec2::splat(2), terrain); |
| 76 | 91 | map.set_plant(UVec2::ZERO, Some(plant)); |
| 77 | - grow(&mut map, ticks, |_| {}); | |
| 92 | + grow(&mut map, ticks, SUMMER, |_| {}); | |
| 78 | 93 | map.growth(IVec2::ZERO) |
| 79 | 94 | } |
| 80 | 95 | |
| @@ -94,7 +109,24 @@ mod tests { | ||
| 94 | 109 | let mut map = WorldMap::filled(UVec2::splat(2), Terrain::Soil); |
| 95 | 110 | map.set_plant(UVec2::ZERO, Some(Plant::Crop)); |
| 96 | 111 | let mut changes = 0; |
| 97 | - grow(&mut map, 1e9, |_| changes += 1); | |
| 112 | + grow(&mut map, 1e9, SUMMER, |_| changes += 1); | |
| 98 | 113 | assert_eq!(changes, 1); |
| 99 | 114 | } |
| 115 | + | |
| 116 | + // Unripe crops die in a hard frost, so the harvest has to be in before winter; ripe crops and | |
| 117 | + // wild bushes survive but stop growing. | |
| 118 | + #[test] | |
| 119 | + fn frost_kills_unripe_crops_and_cold_stops_growth() { | |
| 120 | + let mut map = WorldMap::filled(UVec2::new(3, 1), Terrain::Soil); | |
| 121 | + map.set_plant(UVec2::new(0, 0), Some(Plant::Crop)); | |
| 122 | + map.set_plant(UVec2::new(1, 0), Some(Plant::Crop)); | |
| 123 | + map.set_growth(UVec2::new(1, 0), 1.0); | |
| 124 | + map.set_plant(UVec2::new(2, 0), Some(Plant::BerryBush)); | |
| 125 | + grow(&mut map, 1000.0, 2.0, |_| {}); | |
| 126 | + assert_eq!(map.growth(IVec2::new(2, 0)), 0.0); | |
| 127 | + grow(&mut map, 1000.0, FROST_TEMP - 1.0, |_| {}); | |
| 128 | + assert_eq!(map.plant(IVec2::new(0, 0)), None); | |
| 129 | + assert_eq!(map.plant(IVec2::new(1, 0)), Some(Plant::Crop)); | |
| 130 | + assert_eq!(map.plant(IVec2::new(2, 0)), Some(Plant::BerryBush)); | |
| 131 | + } | |
| 100 | 132 | } |
src/selection.rs +15 -3
| @@ -5,6 +5,7 @@ use crate::{ | ||
| 5 | 5 | animals::{Animal, Tame}, |
| 6 | 6 | building::{Blueprints, BuildMode, BuildTool}, |
| 7 | 7 | camera::CameraController, |
| 8 | + climate::{Warmth, day_of_season, outdoor_temperature, season}, | |
| 8 | 9 | colonist::{Colonist, Path}, |
| 9 | 10 | designations::DesignateTool, |
| 10 | 11 | economy::{ColonyGoals, Stock, in_colony}, |
| @@ -278,6 +279,7 @@ fn update_tile_info( | ||
| 278 | 279 | Option<&Mood>, |
| 279 | 280 | Option<&Age>, |
| 280 | 281 | Has<Sick>, |
| 282 | + Option<&Warmth>, | |
| 281 | 283 | )>, |
| 282 | 284 | mut text: Single<&mut Text, With<TileInfoText>>, |
| 283 | 285 | ) { |
| @@ -298,7 +300,7 @@ fn update_tile_info( | ||
| 298 | 300 | fed: hunger.0, |
| 299 | 301 | }) + &kin |
| 300 | 302 | .get(entity) |
| 301 | - .map(|(partner, child, mood, age, sick)| { | |
| 303 | + .map(|(partner, child, mood, age, sick, warmth)| { | |
| 302 | 304 | let partner = partner |
| 303 | 305 | .and_then(|p| colonists.get(p.0).ok()) |
| 304 | 306 | .map(|p| format!(", partner {}", p.0.name)); |
| @@ -311,20 +313,30 @@ fn update_tile_info( | ||
| 311 | 313 | .map(|a| format!(", age {}", a.years(*tick))) |
| 312 | 314 | .unwrap_or_default(); |
| 313 | 315 | let sick = if sick { ", sick" } else { "" }; |
| 316 | + let warmth = warmth | |
| 317 | + .map(|w| { | |
| 318 | + let cold = if w.cold() { " (cold)" } else { "" }; | |
| 319 | + format!(", {:.0}°C{cold}", w.0) | |
| 320 | + }) | |
| 321 | + .unwrap_or_default(); | |
| 314 | 322 | partner.unwrap_or_default() |
| 315 | 323 | + &child.unwrap_or_default() |
| 316 | 324 | + &mood |
| 317 | 325 | + &age |
| 318 | 326 | + sick |
| 327 | + + &warmth | |
| 319 | 328 | }) |
| 320 | 329 | .unwrap_or_default() |
| 321 | 330 | }) |
| 322 | 331 | .unwrap_or_else(|_| "-".into()), |
| 323 | 332 | }; |
| 324 | 333 | let clock = format!( |
| 325 | - "{} ({})", | |
| 334 | + "{} ({}), {} day {}, {:.0}°C", | |
| 326 | 335 | format_clock(*tick), |
| 327 | - time_of_day(hour_of_day(*tick)) | |
| 336 | + time_of_day(hour_of_day(*tick)), | |
| 337 | + season(*tick).name(), | |
| 338 | + day_of_season(*tick), | |
| 339 | + outdoor_temperature(*tick) | |
| 328 | 340 | ); |
| 329 | 341 | let speed = describe_speed(*speed, time.is_paused(), time.relative_speed()); |
| 330 | 342 | let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect(); |
src/sim.rs +1 -1
| @@ -7,7 +7,7 @@ use crate::actions::Action; | ||
| 7 | 7 | pub const TICKS_PER_SECOND: f64 = 60.0; |
| 8 | 8 | pub const TICKS_PER_HOUR: u64 = 600; |
| 9 | 9 | pub const HOURS_PER_DAY: u64 = 24; |
| 10 | -const START_HOUR: u64 = 6; | |
| 10 | +pub const START_HOUR: u64 = 6; | |
| 11 | 11 | |
| 12 | 12 | pub struct SimPlugin; |
| 13 | 13 | |
src/structures.rs +8 -0
| @@ -57,6 +57,14 @@ impl StructureKind { | ||
| 57 | 57 | } |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | + pub fn heat(self) -> f32 { | |
| 61 | + match self { | |
| 62 | + StructureKind::Campfire => 20.0, | |
| 63 | + StructureKind::Torch => 4.0, | |
| 64 | + StructureKind::Bed | StructureKind::Wall | StructureKind::Door => 0.0, | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 60 | 68 | pub fn radius(self) -> f32 { |
| 61 | 69 | self.light().map_or(0.0, |(radius, _)| radius) |
| 62 | 70 | } |
src/tilemap.rs +37 -0
| @@ -45,6 +45,7 @@ impl Plugin for TilemapPlugin { | ||
| 45 | 45 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 46 | 46 | enum Layer { |
| 47 | 47 | Terrain, |
| 48 | + Snow, | |
| 48 | 49 | Zones, |
| 49 | 50 | Roofs, |
| 50 | 51 | Plants, |
| @@ -54,6 +55,7 @@ impl Layer { | ||
| 54 | 55 | fn z(self) -> f32 { |
| 55 | 56 | match self { |
| 56 | 57 | Layer::Terrain => 0.0, |
| 58 | + Layer::Snow => 0.2, | |
| 57 | 59 | Layer::Zones => 0.3, |
| 58 | 60 | Layer::Roofs => 0.85, |
| 59 | 61 | Layer::Plants => 0.5, |
| @@ -69,6 +71,7 @@ impl Layer { | ||
| 69 | 71 | ) -> Vec<Option<TileData>> { |
| 70 | 72 | match self { |
| 71 | 73 | Layer::Terrain => chunk_tiles(map, light, origin), |
| 74 | + Layer::Snow => snow_tiles(map, light, origin), | |
| 72 | 75 | Layer::Zones => zone_tiles(zones, origin), |
| 73 | 76 | Layer::Roofs => roof_tiles(map, origin), |
| 74 | 77 | Layer::Plants => plant_tiles(map, light, origin), |
| @@ -162,9 +165,11 @@ fn spawn_chunks( | ||
| 162 | 165 | )); |
| 163 | 166 | let zone = images.add(stack_tileset(vec![zone_texture(), farm_texture()])); |
| 164 | 167 | let roof = images.add(stack_tileset(vec![roof_texture()])); |
| 168 | + let snow = images.add(stack_tileset(vec![snow_texture()])); | |
| 165 | 169 | let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE; |
| 166 | 170 | for (layer, tileset) in [ |
| 167 | 171 | (Layer::Terrain, terrain), |
| 172 | + (Layer::Snow, snow), | |
| 168 | 173 | (Layer::Zones, zone), |
| 169 | 174 | (Layer::Plants, plants), |
| 170 | 175 | (Layer::Roofs, roof), |
| @@ -273,6 +278,24 @@ pub fn chunk_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Optio | ||
| 273 | 278 | .collect() |
| 274 | 279 | } |
| 275 | 280 | |
| 281 | +pub fn snow_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> { | |
| 282 | + let cover = light.snow(); | |
| 283 | + chunk_positions(origin) | |
| 284 | + .map(|pos| { | |
| 285 | + let p = pos.as_ivec2(); | |
| 286 | + let land = map.get(p).is_some_and(|t| !t.is_water()); | |
| 287 | + (cover > 0.0 && land && !map.is_roofed(p)).then(|| { | |
| 288 | + let l = light.color(pos); | |
| 289 | + TileData { | |
| 290 | + tileset_index: 0, | |
| 291 | + color: Color::srgba(l.x, l.y, l.z, cover * jitter(pos)), | |
| 292 | + ..default() | |
| 293 | + } | |
| 294 | + }) | |
| 295 | + }) | |
| 296 | + .collect() | |
| 297 | +} | |
| 298 | + | |
| 276 | 299 | pub fn zone_tiles(zones: &Zones, origin: UVec2) -> Vec<Option<TileData>> { |
| 277 | 300 | chunk_positions(origin) |
| 278 | 301 | .map(|pos| { |
| @@ -366,6 +389,20 @@ fn zone_texture() -> Texture { | ||
| 366 | 389 | .collect() |
| 367 | 390 | } |
| 368 | 391 | |
| 392 | +fn snow_texture() -> Texture { | |
| 393 | + (0..TEXTURE_SIZE * TEXTURE_SIZE) | |
| 394 | + .map(|i| { | |
| 395 | + let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); | |
| 396 | + let speck = hash2(x, y + 7) % 7 == 0; | |
| 397 | + if speck { | |
| 398 | + [215, 225, 240, 255] | |
| 399 | + } else { | |
| 400 | + [245, 248, 255, 255] | |
| 401 | + } | |
| 402 | + }) | |
| 403 | + .collect() | |
| 404 | +} | |
| 405 | + | |
| 369 | 406 | fn roof_texture() -> Texture { |
| 370 | 407 | (0..TEXTURE_SIZE * TEXTURE_SIZE) |
| 371 | 408 | .map(|i| { |
tasks.md +4 -0
| @@ -39,3 +39,7 @@ | ||
| 39 | 39 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 40 | 40 | |
| 41 | 41 | ## Planned |
| 42 | +- [ ] Seasons: 12-day year, temperature (season + day/night), crops stop growing in the cold and die in frost, sowing only in the growing season, cold slows spoilage, snow cover | |
| 43 | +- [ ] Cold: colonists track warmth (roofs insulate, fires heat); cold drains food, slows rest, lowers mood; cold colonists warm up by fires; campfires kept when it's chilly, not only dark | |
| 44 | +- [ ] Winter planning: food and wood targets cover the cold days ahead | |
| 45 | +- [ ] Workshops: workbench crafts iron tools (faster work) and gold decorations (mood); demand learned like other stock | |