From 469da76c1542faf7e87ccf7b60e5ee9e6e8b9fb7 Mon Sep 17 00:00:00 2001 From: osmarks Date: Thu, 26 Feb 2026 15:03:47 +0000 Subject: [PATCH] Make plants do something, slightly --- src/main.rs | 128 +++++++++++++++++++++++++------------------ src/map.rs | 3 +- src/plant.rs | 9 ++- src/worldgen_test.rs | 1 + 4 files changed, 85 insertions(+), 56 deletions(-) diff --git a/src/main.rs b/src/main.rs index b51d4ce..66ab4cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,17 +92,17 @@ struct GameState { ticks: u64, map: worldgen::GeneratedWorld, baseline_soil_nutrients: Map, - baseline_water: Map, + baseline_groundwater: Map, baseline_salt: Map, baseline_temperature: Map, dynamic_soil_nutrients: Map, - dynamic_water: Map, + dynamic_groundwater: Map, positions: PositionIndex } impl GameState { - fn actual_water(&self, pos: Coord) -> f32 { - self.baseline_water[pos] + self.dynamic_water[pos] + fn actual_groundwater(&self, pos: Coord) -> f32 { + self.baseline_groundwater[pos] + self.dynamic_groundwater[pos] } fn actual_soil_nutrients(&self, pos: Coord) -> f32 { @@ -406,6 +406,11 @@ impl StochasticNumber { const PLANT_TICK_DELAY: u64 = 128; const FIELD_DECAY_DELAY: u64 = 100; const PLANT_GROWTH_SCALE: f32 = 0.01; +const SOIL_NUTRIENT_CONSUMPTION_RATE: f32 = 0.04; +const WATER_CONSUMPTION_RATE: f32 = 0.02; +const PLANT_IDLE_WATER_CONSUMPTION_OFFSET: f32 = 0.05; +const PLANT_DIEOFF_THRESHOLD: f32 = 0.01; +const PLANT_DIEOFF_RATE: f32 = 0.005; async fn game_tick(state: &mut GameState) -> Result<()> { let mut buffer = hecs::CommandBuffer::new(); @@ -418,15 +423,53 @@ async fn game_tick(state: &mut GameState) -> Result<()> { buffer.run_on(&mut state.world); if state.ticks % FIELD_DECAY_DELAY == 0 { - state.baseline_soil_nutrients.for_each_mut(|nutrients| *nutrients *= 0.999); + state.dynamic_soil_nutrients.for_each_mut(|nutrients| *nutrients *= 0.999); } else if state.ticks % FIELD_DECAY_DELAY == 1 { - state.baseline_water.for_each_mut(|water| *water *= 0.999); + state.dynamic_groundwater.for_each_mut(|water| *water *= 0.999); } else if state.ticks % FIELD_DECAY_DELAY == 2 { state.dynamic_soil_nutrients = smooth(&state.dynamic_soil_nutrients, 3); } else if state.ticks % FIELD_DECAY_DELAY == 3 { - state.dynamic_water = smooth(&state.dynamic_water, 3); + state.dynamic_groundwater = smooth(&state.dynamic_groundwater, 3); } + let mut despawn_buffer = Vec::new(); + + // This might lead to a duping glitch, which would at least be funny. + // TODO: Players should drop items on disconnect. + // The final position argument is in some sense redundant but exists to satisfy dynamic borrow checking. + let kill = |buffer: &mut CommandBuffer, despawn_buffer: &mut Vec<(Entity, Position)>, state: &GameState, entity: Entity, killer: Option| { + let position = (*state.world.get::<&Position>(entity).unwrap()).clone(); + let position_head = position.head(); + despawn_buffer.push((entity, position)); + buffer.despawn(entity); + let mut materialized_drops = Inventory::empty(); + if let Ok(drops) = state.world.get::<&Drops>(entity) { + for (drop, frequency) in drops.0.iter() { + materialized_drops.add(drop.clone(), frequency.sample_rounded()) + } + } + if let Ok(other_inv) = state.world.get::<&Inventory>(entity) { + materialized_drops.extend(&other_inv); + } + let killer_consumed_items = if let Some(killer) = killer { + if let Ok(mut inv) = state.world.get::<&mut Inventory>(killer) { + inv.extend(&materialized_drops); + true + } else { + false + } + } else { false }; + if !killer_consumed_items && !materialized_drops.is_empty() { + buffer.spawn(( + Position::single_tile(position_head, MapLayer::Entities), + Render('☒'), + materialized_drops, + NewlyAdded, + Health(10.0, 10.0) + )); + } + }; + // Spawn enemies for (_entity, (pos, EnemyTarget { spawn_range, spawn_density, spawn_rate_inv, .. })) in state.world.query::<(&Position, &EnemyTarget)>().iter() { let pos = pos.head(); @@ -485,15 +528,28 @@ async fn game_tick(state: &mut GameState) -> Result<()> { for (entity, (pos, plant)) in state.world.query::<(&Position, &mut Plant)>().iter() { if (entity.id() as u64) % PLANT_TICK_DELAY == state.ticks % PLANT_TICK_DELAY { let pos = pos.head(); - let water = state.actual_soil_nutrients(pos); + let water = state.actual_groundwater(pos); let soil_nutrients = state.actual_soil_nutrients(pos); let salt = state.baseline_salt[pos]; let temperature = state.baseline_temperature[pos]; let base_growth_rate = plant.genome.effective_growth_rate(soil_nutrients, water, temperature, salt); + if base_growth_rate < PLANT_DIEOFF_THRESHOLD { + if let Ok(mut health) = state.world.get::<&mut Health>(entity) { + health.0 -= PLANT_DIEOFF_RATE; + if health.0 <= 0.0 { + // TODO: this is inelegant and should be shared with the other death code + // also, it might break the position tracker + kill(&mut buffer, &mut despawn_buffer, &state, entity, None); + } + } + } + let original_size = plant.current_size; plant.current_size += base_growth_rate * PLANT_GROWTH_SCALE * plant.current_size.powf(-0.25); // allometric scaling law plant.current_size = plant.current_size.min(plant.genome.max_size); + let difference = (plant.current_size - original_size).max(0.0); let can_reproduce = plant.current_size >= plant.genome.max_size * plant.genome.reproductive_size_fraction(); - //state.dynamic_soil_nutrients; + state.dynamic_soil_nutrients[pos] -= difference * SOIL_NUTRIENT_CONSUMPTION_RATE; + state.dynamic_groundwater[pos] -= (difference + PLANT_IDLE_WATER_CONSUMPTION_OFFSET) * WATER_CONSUMPTION_RATE * plant.genome.water_efficiency(); } } @@ -592,10 +648,12 @@ async fn game_tick(state: &mut GameState) -> Result<()> { Input::DownLeft => next_movement = CoordVec::new(-1, 1), Input::Dig => { // Dig a hole + // TODO: work out geology in more detail; maybe you shouldn't be able to dig anything if state.positions.terrain[position].is_none() && energy.try_consume(5.0) { buffer.spawn(( Render('_'), Obstruction { entry_multiplier: 5.0, exit_multiplier: 5.0 }, + // TODO: do this more cleanly DespawnOnTick(state.ticks.wrapping_add(StochasticNumber::triangle_from_min_range(5000.0, 5000.0).sample().round() as u64)), Position::single_tile(position, MapLayer::Terrain), NewlyAdded @@ -616,44 +674,6 @@ async fn game_tick(state: &mut GameState) -> Result<()> { buffer.run_on(&mut state.world); - let mut despawn_buffer = Vec::new(); - - // This might lead to a duping glitch, which would at least be funny. - // TODO: Players should drop items on disconnect. - // The final position argument is in some sense redundant but exists to satisfy dynamic borrow checking. - let kill = |buffer: &mut CommandBuffer, despawn_buffer: &mut Vec<(Entity, Position)>, state: &GameState, entity: Entity, killer: Option| { - let position = (*state.world.get::<&Position>(entity).unwrap()).clone(); - let position_head = position.head(); - despawn_buffer.push((entity, position)); - buffer.despawn(entity); - let mut materialized_drops = Inventory::empty(); - if let Ok(drops) = state.world.get::<&Drops>(entity) { - for (drop, frequency) in drops.0.iter() { - materialized_drops.add(drop.clone(), frequency.sample_rounded()) - } - } - if let Ok(other_inv) = state.world.get::<&Inventory>(entity) { - materialized_drops.extend(&other_inv); - } - let killer_consumed_items = if let Some(killer) = killer { - if let Ok(mut inv) = state.world.get::<&mut Inventory>(killer) { - inv.extend(&materialized_drops); - true - } else { - false - } - } else { false }; - if !killer_consumed_items && !materialized_drops.is_empty() { - buffer.spawn(( - Position::single_tile(position_head, MapLayer::Entities), - Render('☒'), - materialized_drops, - NewlyAdded, - Health(10.0, 10.0) - )); - } - }; - let mut about_to_move = Vec::new(); // Process motion and attacks for (entity, (current_pos, MovingInto(target_pos), damage, mut energy, move_cost, despawn_on_impact)) in state.world.query::<(&Position, &MovingInto, Option<&mut Attack>, Option<&mut Energy>, Option<&MoveCost>, Option<&DespawnOnImpact>)>().iter() { @@ -879,11 +899,11 @@ async fn main() -> Result<()> { }; let baseline_soil_nutrients = world.soil_nutrients.clone(); - let baseline_water = world.groundwater.clone(); + let baseline_groundwater = world.groundwater.clone(); let baseline_salt = world.salt.clone(); let baseline_temperature = world.temperature.clone(); - let dynamic_soil_nutrients = baseline_soil_nutrients.clone(); - let dynamic_water = baseline_water.clone(); + let dynamic_soil_nutrients = Map::new(world.radius, 0.0); + let dynamic_groundwater = Map::new(world.radius, 0.0); let state = Arc::new(Mutex::new(GameState { world: World::new(), @@ -892,11 +912,11 @@ async fn main() -> Result<()> { positions: PositionIndex::new(world.radius), map: world, baseline_soil_nutrients, - baseline_water, + baseline_groundwater, baseline_salt, baseline_temperature, dynamic_soil_nutrients, - dynamic_water + dynamic_groundwater })); { @@ -907,9 +927,9 @@ async fn main() -> Result<()> { batch.push(( Position::single_tile(Coord::origin() + offset * 5, MapLayer::Entities), Render('+'), - Health(100.0, 100.0), + Health(10.0, 10.0), //ShrinkOnDeath, - Plant { genome: plant::Genome::random(), current_size: 0.0 }, + Plant { genome: plant::Genome::random(), current_size: 0.1 }, NewlyAdded )); } diff --git a/src/map.rs b/src/map.rs index 0c6f1e3..767ba5f 100644 --- a/src/map.rs +++ b/src/map.rs @@ -216,7 +216,8 @@ pub fn smooth(map: &Map, radius: i32) -> Map { kernel[(offset.x as usize + radius as usize, offset.y as usize + radius as usize)] = 1.0 / count_hexes(radius) as f32; } - let result = ConvExt::conv(&data, &kernel, ConvMode::Same, PaddingMode::Zeros).unwrap(); + // TODO: this is still really slow! + let result = ConvExt::conv(&data, &kernel, ConvMode::Same, PaddingMode::Replicate).unwrap(); Map { radius: map.radius, diff --git a/src/plant.rs b/src/plant.rs index 98aac81..f2b0d3e 100644 --- a/src/plant.rs +++ b/src/plant.rs @@ -10,6 +10,8 @@ pub enum CropType { pub struct Genome { crop_type: CropType, // polygenic traits; parameterized as N(0,1) (allegedly) + // groundwater is [0,1] so this is sort of questionable + // TODO: reparameterize or something growth_rate: f32, nutrient_addition_rate: f32, optimal_water_level: f32, @@ -56,7 +58,7 @@ impl Genome { - self.water_tolerance * 0.2 - self.temperature_tolerance * 0.2 - self.salt_tolerance * 0.2; - base * (-nutrients.min(0.0)).exp() + (base * (-nutrients.min(0.0)).exp()).max(0.0) } pub fn random() -> Genome { @@ -91,6 +93,11 @@ impl Genome { } } + // TODO: this might be unreasonable + pub fn water_efficiency(&self) -> f32 { + sigmoid(self.optimal_water_level * 3.0 - 1.0) + } + pub fn hybridize(&self, other: &Genome) -> Option { if self.crop_type != other.crop_type { return None } Some(Genome { diff --git a/src/worldgen_test.rs b/src/worldgen_test.rs index 36995c0..75258c2 100644 --- a/src/worldgen_test.rs +++ b/src/worldgen_test.rs @@ -231,6 +231,7 @@ fn main() -> Result<()> { let r1 = field_range(f1, &render_data); let r2 = field_range(f2, &render_data); let r3 = field_range(f3, &render_data); + println!("ranges: {:?}, {:?}, {:?}", r1, r2, r3); for (position, _) in heightmap.iter() { let col = position.x + (position.y - (position.y & 1)) / 2 + image_radius;