From 3051d2ca22ac5dc2d64efac938bafe028b1b2545 Mon Sep 17 00:00:00 2001 From: osmarks Date: Sat, 14 Mar 2026 13:02:13 +0000 Subject: [PATCH] Refactor and futilely retune plants --- .gitignore | 3 +- Cargo.lock | 3 +- Cargo.toml | 3 + src/components.rs | 9 +- src/lib.rs | 1 + src/main.rs | 70 +++++++--------- src/map.rs | 6 +- src/plant.rs | 106 +++++++++++------------- src/render.rs | 205 ++++++++++++++++++++++++---------------------- src/util.rs | 58 +++++++++++++ src/worldgen.rs | 25 +----- 11 files changed, 263 insertions(+), 226 deletions(-) create mode 100644 src/util.rs diff --git a/.gitignore b/.gitignore index 4c0710a..d3bac5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target /node_modules out.png -world.bin \ No newline at end of file +world.bin +*.bin diff --git a/Cargo.lock b/Cargo.lock index 918d64b..f5a7720 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -805,8 +805,7 @@ dependencies = [ [[package]] name = "hecs" version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a2d85ff28b1e1a1c183b6ca9ec1a6b97e7937fb989988a74d9db39760a07b8" +source = "git+https://github.com/osmarks/hecs-patch#ba24338a7c95f4333609f7d20676d684b49dfe0f" dependencies = [ "foldhash", "hashbrown", diff --git a/Cargo.toml b/Cargo.toml index 34ad549..1788d7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,3 +45,6 @@ path = "src/render.rs" [profile.release] debug = true + +[patch.crates-io] +hecs = { git = "https://github.com/osmarks/hecs-patch" } diff --git a/src/components.rs b/src/components.rs index c66dcae..d1c0448 100644 --- a/src/components.rs +++ b/src/components.rs @@ -27,7 +27,8 @@ pub enum HealthChangeType { BluntForce, Magic, NaturalRegeneration, - Starvation + Starvation, + Senescence } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -389,6 +390,7 @@ pub struct Plant { pub growth_ticks: u64, pub children: u64, pub ready_for_reproduce_ticks: u64, + pub age: u64, pub total_growth: f32 // currently a bit redundant, given nutrient consumption counter } @@ -403,12 +405,13 @@ impl Plant { growth_ticks: 0, children: 0, ready_for_reproduce_ticks: 0, - total_growth: 0.0 + total_growth: 0.0, + age: 0 } } pub fn can_reproduce(&self) -> bool { - self.current_size >= self.genome.max_size() * self.genome.reproductive_size_fraction() + self.current_size >= self.genome.mature_size() } } diff --git a/src/lib.rs b/src/lib.rs index 7c04bcb..a8acaad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,5 +4,6 @@ pub mod plant; pub mod save; pub mod world_serde; pub mod worldgen; +pub mod util; pub use components::*; diff --git a/src/main.rs b/src/main.rs index f1ae6e9..2a3b63c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ use ewo3::plant; use ewo3::save::{SavedGame, GameMetrics}; use ewo3::world_serde; use ewo3::worldgen; +use ewo3::util::config::*; #[derive(FromArgs)] /// Run the game server. @@ -112,9 +113,6 @@ struct Client { entity: Entity } -const VIEW: i32 = 15; -const RANDOM_DESPAWN_INV_RATE: u64 = 4000; - struct EnemySpec { symbol: char, min_damage: f32, @@ -289,22 +287,6 @@ fn game_state_from_saved(saved: SavedGame) -> Result { }) } -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.5; -const SOIL_NUTRIENT_FIXATION_RATE: f32 = 0.0002; -const WATER_CONSUMPTION_RATE: f32 = 0.03; -const PLANT_IDLE_WATER_CONSUMPTION_OFFSET: f32 = 0.05; -const PLANT_DIEOFF_THRESHOLD: f32 = 0.3; -const PLANT_DIEOFF_RATE: f32 = 0.2; -const AUTOSAVE_INTERVAL_TICKS: u64 = 1024; -const PLANT_POLLINATION_RADIUS: i32 = 10; // TODO: should be directional (wind, insects, etc) and vary by plant. -const PLANT_POLLINATION_SCAN_FRACTION: f32 = 0.1; -const PLANT_SEEDING_RADIUS: i32 = 5; // TODO: as above -const PLANT_SEEDING_ATTEMPTS: usize = 10; -const SOIL_NUTRIENT_DECOMP_RETURN_RATE: f32 = 1.0 / SOIL_NUTRIENT_CONSUMPTION_RATE * 0.6; - async fn game_tick(state: &mut GameState) -> Result<()> { let mut buffer = Buffer::new(); @@ -318,7 +300,7 @@ async fn game_tick(state: &mut GameState) -> Result<()> { buffer.apply(state); if state.ticks % FIELD_DECAY_DELAY == 0 { - state.dynamic_soil_nutrients.for_each_mut(|nutrients| *nutrients *= 0.9995); + state.dynamic_soil_nutrients.for_each_mut(|nutrients| *nutrients *= 0.9999); } else if state.ticks % FIELD_DECAY_DELAY == 1 { state.dynamic_groundwater.for_each_mut(|water| *water *= 0.999); } else if state.ticks % FIELD_DECAY_DELAY == 2 { @@ -408,21 +390,21 @@ async fn game_tick(state: &mut GameState) -> Result<()> { if health.current <= 0.0 { buffer.kill(&state.world, entity, &mut rng, None); state.metrics.plants_died_starvation += 1; - // return nutrients to soil upon death; TODO implement decomposition + // return nutrients to soil upon death; TODO implement decomposition modelling state.dynamic_soil_nutrients[pos] += plant.current_size * SOIL_NUTRIENT_DECOMP_RETURN_RATE; } } } let original_size = plant.current_size; - plant.current_size += plant.genome.effective_growth_rate(soil_nutrients, water, temperature, salt, terrain) * PLANT_GROWTH_SCALE * plant.current_size.powf(-0.25); // allometric scaling law - plant.current_size = plant.current_size.min(plant.genome.max_size()); + plant.current_size += plant.genome.base_growth_rate(soil_nutrients, water, temperature, salt, terrain) * PLANT_GROWTH_SCALE * plant.current_size.powf(-0.25); // allometric scaling law let difference = (plant.current_size - original_size).max(0.0); if plant.can_reproduce() { plant.ready_for_reproduce_ticks += 1; if rng.f32() < plant.genome.reproduction_rate() { plants_to_reproduce.push(entity); + plant.current_size -= PLANT_REPRODUCTION_ATTEMPT_COST; } } else { plant.ready_for_reproduce_ticks = 0; @@ -431,7 +413,7 @@ async fn game_tick(state: &mut GameState) -> Result<()> { state.dynamic_soil_nutrients[pos] -= difference * SOIL_NUTRIENT_CONSUMPTION_RATE; plant.nutrients_consumed += difference * SOIL_NUTRIENT_CONSUMPTION_RATE; plant.total_growth += difference; - if plant.current_size / plant.genome.max_size() > 0.5 { + if plant.current_size > plant.genome.mature_size() { state.dynamic_soil_nutrients[pos] += plant.genome.nutrient_addition_rate() * SOIL_NUTRIENT_FIXATION_RATE; plant.nutrients_added += plant.genome.nutrient_addition_rate() * SOIL_NUTRIENT_FIXATION_RATE; } @@ -447,10 +429,18 @@ async fn game_tick(state: &mut GameState) -> Result<()> { } } - // TODO: fix - if plant.total_growth > plant.genome.max_size() * 2.0 { - buffer.kill(&state.world, entity, &mut rng, None); - state.metrics.plants_died_old_age += 1; + plant.age += 1; + + if plant.age as f32 > plant.genome.lifespan() * PLANT_LIFESPAN_SCALE { + // TODO refactor + if let Ok(mut health) = state.world.get::<&mut Health>(entity) { + health.apply(HealthChangeType::Senescence, -PLANT_DIEOFF_RATE); + if health.current <= 0.0 { + buffer.kill(&state.world, entity, &mut rng, None); + state.metrics.plants_died_old_age += 1; + state.dynamic_soil_nutrients[pos] += plant.current_size * SOIL_NUTRIENT_DECOMP_RETURN_RATE; + } + } } } } @@ -462,7 +452,7 @@ async fn game_tick(state: &mut GameState) -> Result<()> { let count = count_hexes(PLANT_POLLINATION_RADIUS); let reproduction_tries = (count as f32 * PLANT_POLLINATION_SCAN_FRACTION).ceil() as usize; - let mut hybrid_genome = None; + let mut maybe_other = None; for _ in 0..reproduction_tries { let newpos = pos + sample_range_rng(PLANT_POLLINATION_RADIUS, &mut rng); if !state.map.heightmap.in_range(newpos) || newpos == pos { @@ -471,8 +461,8 @@ async fn game_tick(state: &mut GameState) -> Result<()> { if let Some(other) = state.positions.entities[newpos] { if let Ok(other_plant) = state.world.get::<&mut Plant>(other) { if other_plant.can_reproduce() { - if let Some(hybrid) = other_plant.genome.hybridize(&own_genome) { - hybrid_genome = Some((hybrid, other)); + if let Some(_hybrid) = other_plant.genome.hybridize(&mut rng, &own_genome) { + maybe_other = Some(other); break; } } @@ -480,10 +470,12 @@ async fn game_tick(state: &mut GameState) -> Result<()> { } } - if let Some((hybrid_genome, other)) = hybrid_genome { + if let Some(other) = maybe_other { + // TODO: multiple seeds from one plant with different genomes\ let [plant, other_plant] = state.world.query_disjoint_mut::<&mut Plant, 2>([entity, other]); let plant = plant?; let other_plant = other_plant?; + let hybrid_genome = other_plant.genome.hybridize(&mut rng, &own_genome).unwrap(); for _ in 0..PLANT_SEEDING_ATTEMPTS { let newpos = pos + sample_range_rng(PLANT_SEEDING_RADIUS, &mut rng); @@ -499,8 +491,9 @@ async fn game_tick(state: &mut GameState) -> Result<()> { )); plant.children += 1; other_plant.children += 1; - plant.current_size *= plant.genome.reproductive_size_fraction(); // TODO: explicit cooldown? - other_plant.current_size *= other_plant.genome.reproductive_size_fraction(); // TODO: vary this by plant component gender? + // TODO: can this go negative? + plant.current_size -= PLANT_CHILD_COST; + other_plant.current_size -= PLANT_CHILD_COST; state.metrics.plants_reproduced += 1; break; } @@ -859,8 +852,6 @@ async fn save_game(state: &GameState) -> Result<()> { Ok(()) } -const INITIAL_PLANTS: usize = 262144; - #[tokio::main] async fn main() -> Result<()> { let mut loaded_save = false; @@ -908,15 +899,16 @@ async fn main() -> Result<()> { let mut batch = Vec::with_capacity(INITIAL_PLANTS); let mut used = HashSet::new(); while batch.len() < INITIAL_PLANTS { - let genome = plant::Genome::random(); - let pos = Coord::origin() + sample_range(state.map.radius()); + let genome = plant::Genome::random(&mut state.rng); + let radius = state.map.radius(); + let pos = Coord::origin() + sample_range(&mut state.rng, radius); if genome.base_growth_rate(state.actual_soil_nutrients(pos), state.actual_groundwater(pos), state.baseline_temperature[pos], state.baseline_salt[pos], &state.map.get_terrain(pos)) > 0.2 && !used.contains(&pos) { batch.push(( Position::single_tile(pos, MapLayer::Entities), Render('+'), Health::new(10.0, 10.0), //ShrinkOnDeath, - Plant::new(plant::Genome::random()), + Plant::new(genome), NewlyAdded )); used.insert(pos); diff --git a/src/map.rs b/src/map.rs index b151f35..10a2669 100644 --- a/src/map.rs +++ b/src/map.rs @@ -40,9 +40,9 @@ pub fn rotate_60(p0: CoordVec) -> CoordVec { pub const DIRECTIONS: &[CoordVec] = &[CoordVec::new(0, -1), CoordVec::new(1, -1), CoordVec::new(-1, 0), CoordVec::new(1, 0), CoordVec::new(0, 1), CoordVec::new(-1, 1)]; -pub fn sample_range(range: i32) -> CoordVec { - let q = fastrand::i32(-range..=range); - let r = fastrand::i32((-range).max(-q-range)..=range.min(-q+range)); +pub fn sample_range(rng: &mut fastrand::Rng, range: i32) -> CoordVec { + let q = rng.i32(-range..=range); + let r = rng.i32((-range).max(-q-range)..=range.min(-q+range)); CoordVec::new(q, r) } diff --git a/src/plant.rs b/src/plant.rs index e96fd6e..cec1f44 100644 --- a/src/plant.rs +++ b/src/plant.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use crate::worldgen::TerrainType; +use crate::util::*; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum CropType { @@ -15,40 +16,20 @@ pub struct Genome { // 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, + optimal_water_level: f32, // no longer absolute level; sigmoided optimal_temperature: f32, - reproduction_rate: f32, - reproductive_size_fraction_param: f32, temperature_tolerance: f32, water_tolerance: f32, salt_tolerance: f32, - max_size: f32 + mature_size: f32, + lifespan_multiplier: f32, + reproduction_rate: f32, // TODO number of seeds produced? // TODO color trait } -fn sigmoid(x: f32) -> f32 { - 1.0 / (1.0 + (-x).exp()) -} - -// Box-Muller transform -fn normal() -> f32 { - let u = fastrand::f32(); - let v = fastrand::f32(); - (v * std::f32::consts::TAU).cos() * (-2.0 * u.ln()).sqrt() -} - -fn normal_scaled(mu: f32, sigma: f32) -> f32 { - normal() * sigma + mu -} - impl Genome { - pub fn reproductive_size_fraction(&self) -> f32 { - sigmoid(self.reproductive_size_fraction_param) * 0.49 + 0.5 - } - pub fn terrain_valid(&self, terrain: &TerrainType) -> bool { match terrain { TerrainType::Empty => true, @@ -66,7 +47,6 @@ impl Genome { } let temperature_diff = (temperature - sigmoid(self.optimal_temperature)).powf(2.0); let base = 1.0 - - self.reproduction_rate * 0.1 // faster reproduction trades off slightly against growth - self.nutrient_addition_rate() * 0.16 // nutrient enrichment has a growth tradeoff - self.water_tolerance * 0.08 - self.temperature_tolerance * 0.07 @@ -77,19 +57,32 @@ impl Genome { let temperature_tolerance_coefficient = 3.0 * (1.0 + (-self.temperature_tolerance).exp()); let salt_tolerance_coefficient = 8.0 * (-self.salt_tolerance).exp(); - base + let raw = base * (2.0 * nutrients - 1.5).min(0.0).exp() * (-water_tolerance_coefficient * water_diff).exp() * (-temperature_tolerance_coefficient * temperature_diff).exp() - * (-salt.abs() * salt_tolerance_coefficient).exp() + * (-salt.abs() * salt_tolerance_coefficient).exp(); + + raw.min(1.0) } - pub fn max_size(&self) -> f32 { - self.max_size.min(0.0).exp().max(self.max_size + 1.0) + 0.5 + pub fn mature_size(&self) -> f32 { + self.mature_size.exp() } - pub fn effective_growth_rate(&self, nutrients: f32, water: f32, temperature: f32, salt: f32, terrain: &TerrainType) -> f32 { - 1.5f32.powf(self.growth_rate) * 0.5 * self.base_growth_rate(nutrients, water, temperature, salt, terrain) + // This is not directly used in computations, as plants mature when they have gained enough size rather than at a specifc time. + // However, old age death is directly time-based. + pub fn age_at_maturity(&self) -> f32 { + // allometric scaling law + self.mature_size().powf(0.25) + } + + pub fn lifespan_multiplier(&self) -> f32 { + self.lifespan_multiplier.exp() + 1.0 + } + + pub fn lifespan(&self) -> f32 { + self.age_at_maturity() * self.lifespan_multiplier() } pub fn nutrient_addition_rate(&self) -> f32 { @@ -100,8 +93,8 @@ impl Genome { sigmoid(self.reproduction_rate) } - pub fn random() -> Genome { - let crop_type = match fastrand::usize(0..4) { + pub fn random(rng: &mut fastrand::Rng) -> Genome { + let crop_type = match rng.usize(0..4) { 0 => CropType::Grass, 1 => CropType::EucalyptusTree, 2 => CropType::BushTomato, @@ -109,26 +102,28 @@ impl Genome { _ => unreachable!() }; - let (nutrient_addition_rate, optimal_water_level, optimal_temperature, reproductive_size_fraction_param, salt_tolerance, max_size) = match crop_type { - CropType::Grass => (-10.0,-1.0, 0.0, -1.0, 0.0, 1.0), - CropType::EucalyptusTree => (-10.0, 1.0, 0.5, 1.0, 0.1, 5.0), - CropType::BushTomato => (-10.0, 0.0, 1.0, -0.3, 0.2, 1.5), - CropType::GoldenWattleTree => ( 2.0, 0.5, 0.5, 0.5, 0.7, 3.0), + // Mature sizes aren't fully realistic for performance reasons: modelling individual blades of grass at EWO3 speeds is unfortunately not currently feasible. The sizes may represent an aggregation of several plants. + // Size is something like total mass of a 1m^2 collection of this. + // TODO pick these more precisely. + let (nutrient_addition_rate, optimal_water_level, optimal_temperature, salt_tolerance, lifespan_multiplier, mature_size, reproduction_rate) = match crop_type { + CropType::Grass => (-10.0,-1.0,-0.5, 0.0, 0.5, -1.0, 0.0), // TODO: tie reproduction rate to something else? + CropType::EucalyptusTree => (-10.0, 1.0, 0.0, 0.1, 5.0, 6.0, -4.0), + CropType::BushTomato => (-10.0, 0.0, 1.0, 0.2, 1.5, 1.0, -3.0), + CropType::GoldenWattleTree => ( 2.0, 0.5, 0.2, 0.7, 3.0, 4.0, -4.0), }; Genome { crop_type: crop_type, - growth_rate: normal(), nutrient_addition_rate, optimal_water_level, optimal_temperature, - reproduction_rate: normal(), - reproductive_size_fraction_param: normal() + reproductive_size_fraction_param, - temperature_tolerance: normal(), - water_tolerance: normal(), - salt_tolerance: normal() + salt_tolerance, - max_size + temperature_tolerance: normal(rng), + water_tolerance: normal(rng), + salt_tolerance: normal(rng) + salt_tolerance, + mature_size, + lifespan_multiplier, + reproduction_rate } } @@ -137,20 +132,19 @@ impl Genome { sigmoid(self.optimal_water_level * 3.0 - 1.0) } - pub fn hybridize(&self, other: &Genome) -> Option { + pub fn hybridize(&self, rng: &mut fastrand::Rng, other: &Genome) -> Option { if self.crop_type != other.crop_type { return None } Some(Genome { crop_type: self.crop_type, - growth_rate: (self.growth_rate + other.growth_rate) / 2.0 + normal_scaled(0.0, 0.1), - nutrient_addition_rate: (self.nutrient_addition_rate + other.nutrient_addition_rate) / 2.0 + normal_scaled(0.0, 0.03), - optimal_water_level: (self.optimal_water_level + other.optimal_water_level) / 2.0 + normal_scaled(0.0, 0.03), - optimal_temperature: (self.optimal_temperature + other.optimal_temperature) / 2.0 + normal_scaled(0.0, 0.03), - reproduction_rate: (self.reproduction_rate + other.reproduction_rate) / 2.0 + normal_scaled(0.0, 0.5), - reproductive_size_fraction_param: (self.reproductive_size_fraction_param + other.reproductive_size_fraction_param) / 2.0 + normal_scaled(0.0, 0.2), - temperature_tolerance: (self.temperature_tolerance + other.temperature_tolerance) / 2.0 + normal_scaled(0.0, 0.2), - water_tolerance: (self.water_tolerance + other.water_tolerance) / 2.0 + normal_scaled(0.0, 0.2), - salt_tolerance: (self.salt_tolerance + other.salt_tolerance) / 2.0 + normal_scaled(0.0, 0.2), - max_size: (self.max_size + other.max_size) / 2.0 + normal_scaled(0.0, 0.3) + nutrient_addition_rate: (self.nutrient_addition_rate + other.nutrient_addition_rate) / 2.0 + normal_scaled(rng, 0.0, 0.03), + optimal_water_level: (self.optimal_water_level + other.optimal_water_level) / 2.0 + normal_scaled(rng, 0.0, 0.03), + optimal_temperature: (self.optimal_temperature + other.optimal_temperature) / 2.0 + normal_scaled(rng, 0.0, 0.03), + temperature_tolerance: (self.temperature_tolerance + other.temperature_tolerance) / 2.0 + normal_scaled(rng, 0.0, 0.2), + water_tolerance: (self.water_tolerance + other.water_tolerance) / 2.0 + normal_scaled(rng, 0.0, 0.2), + salt_tolerance: (self.salt_tolerance + other.salt_tolerance) / 2.0 + normal_scaled(rng, 0.0, 0.2), + mature_size: (self.mature_size + other.mature_size) / 2.0 + normal_scaled(rng, 0.0, 0.1), + lifespan_multiplier: (self.lifespan_multiplier + other.lifespan_multiplier) / 2.0 + normal_scaled(rng, 0.0, 0.1), + reproduction_rate: (self.reproduction_rate + other.reproduction_rate) / 2.0 + normal_scaled(rng, 0.0, 0.05), }) } } diff --git a/src/render.rs b/src/render.rs index ceea273..58b70db 100644 --- a/src/render.rs +++ b/src/render.rs @@ -8,6 +8,7 @@ use ewo3::map::*; use ewo3::save::SavedGame; use ewo3::world_serde; use ewo3::worldgen::*; +use ewo3::util::config::*; use glow::HasContext; use glutin::config::ConfigTemplateBuilder; use glutin::context::{ContextAttributesBuilder, NotCurrentGlContext, PossiblyCurrentContext}; @@ -62,67 +63,71 @@ struct Args { window: bool, } -#[derive(Clone, Copy, PartialEq, Eq)] -enum Field { - Height, - Rain, - Water, - Groundwater, - Salt, - Temperature, - Humidity, - Soil, - Contour, - SeaDistance, - Plants, +macro_rules! define_fields { + ( + $vis:vis enum $name:ident { + $( + $variant:ident => $str:literal + ),+ $(,)? + } + ) => { + #[derive(Clone, Copy, PartialEq, Eq, Debug)] + $vis enum $name { + $( + $variant, + )+ + } + + const ALL_FIELDS: [$name; define_fields!(@count $($variant),+)] = [ + $( + $name::$variant, + )+ + ]; + + impl $name { + fn parse(s: &str) -> Result { + match s { + $( + $str => Ok(Self::$variant), + )+ + _ => anyhow::bail!("unknown field: {s}"), + } + } + + fn name(self) -> &'static str { + match self { + $( + Self::$variant => $str, + )+ + } + } + } + }; + + (@count $($variant:ident),+) => { + <[()]>::len(&[$(define_fields!(@unit $variant)),+]) + }; + + (@unit $variant:ident) => { () }; } -const ALL_FIELDS: [Field; 11] = [ - Field::Height, - Field::Rain, - Field::Water, - Field::Groundwater, - Field::Salt, - Field::Temperature, - Field::Humidity, - Field::Soil, - Field::Contour, - Field::SeaDistance, - Field::Plants, -]; - -impl Field { - fn parse(s: &str) -> Result { - match s { - "height" => Ok(Self::Height), - "rain" => Ok(Self::Rain), - "water" => Ok(Self::Water), - "groundwater" => Ok(Self::Groundwater), - "salt" => Ok(Self::Salt), - "temperature" => Ok(Self::Temperature), - "humidity" => Ok(Self::Humidity), - "soil" => Ok(Self::Soil), - "contour" => Ok(Self::Contour), - "sea_distance" => Ok(Self::SeaDistance), - "plants" => Ok(Self::Plants), - _ => anyhow::bail!("unknown field: {s}"), - } - } - - fn name(self) -> &'static str { - match self { - Field::Height => "height", - Field::Rain => "rain", - Field::Water => "water", - Field::Groundwater => "groundwater", - Field::Salt => "salt", - Field::Temperature => "temperature", - Field::Humidity => "humidity", - Field::Soil => "soil", - Field::Contour => "contour", - Field::SeaDistance => "sea_distance", - Field::Plants => "plants", - } +define_fields! { + enum Field { + Height => "height", + Rain => "rain", + Water => "water", + Groundwater => "groundwater", + Salt => "salt", + Temperature => "temperature", + Humidity => "humidity", + Soil => "soil", + Contour => "contour", + SeaDistance => "sea_distance", + Plants => "plant_growth", + PlantsAge => "plant_age", + PlantsLifespanFraction => "plant_lifespan_fraction", + DynamicGroundwater => "dynamic_groundwater", + DynamicSoil => "dynamic_soil", } } @@ -167,6 +172,10 @@ struct RenderData { contour_points: HashMap, sea_distance: Map, plants: Map, + plants_age: Map, + plants_lifespan_fraction: Map, + dynamic_groundwater: Map, + dynamic_soil: Map, } struct RenderedImage { @@ -203,6 +212,10 @@ fn sample_field(field: Field, position: Coord, data: &RenderData) -> f32 { Field::Contour => data.contour_points.get(&position).copied().unwrap_or_default() as f32 / 255.0, Field::SeaDistance => data.sea_distance[position].clamp(0.0, 1.0), Field::Plants => data.plants[position], + Field::PlantsAge => data.plants_age[position], + Field::PlantsLifespanFraction => data.plants_lifespan_fraction[position], + Field::DynamicGroundwater => data.dynamic_groundwater[position], + Field::DynamicSoil => data.dynamic_soil[position], } } @@ -234,11 +247,12 @@ fn to_rgb(c1: f32, c2: f32, c3: f32, color_space: ColorSpace) -> [u8; 3] { } fn build_derived_data( - world: &GeneratedWorld, - ecs_world: &World, - dynamic_groundwater: &Map, - dynamic_soil_nutrients: &Map, -) -> (Map, Map, Map, Map, HashMap) { + world: GeneratedWorld, + ecs_world: World, + dynamic_groundwater: Map, + dynamic_soil_nutrients: Map, + positions: PositionIndex, +) -> RenderData { let (sinks, sea) = get_sea(&world.heightmap); let mut sea_distance = distance_map(world.radius, sea.iter().copied()); @@ -265,6 +279,8 @@ fn build_derived_data( ); let mut plants = Map::new(world.radius, 0.0f32); + let mut plants_age = Map::new(world.radius, 0.0f32); + let mut plants_lifespan_fraction = Map::new(world.radius, 0.0f32); for (position, plant) in ecs_world.query::<(&Position, &Plant)>().iter() { let pos = position.head(); if !plants.in_range(pos) { @@ -280,15 +296,24 @@ fn build_derived_data( if g > plants[pos] { plants[pos] = g; } + plants_age[pos] = plant.age as f32; + plants_lifespan_fraction[pos] = plant.age as f32 / (plant.genome.lifespan() * PLANT_LIFESPAN_SCALE); } - ( - sea_distance, + RenderData { + world, + ecs_world, + positions, groundwater, soil, + contour_points, + sea_distance, plants, - contour_points - ) + plants_age, + plants_lifespan_fraction, + dynamic_groundwater, + dynamic_soil: dynamic_soil_nutrients + } } fn rebuild_position_index(world: &World, radius: i32) -> PositionIndex { @@ -307,22 +332,14 @@ fn load_render_data(args: &Args) -> Result { let ecs_world = world_serde::deserialize_world_from_bytes(&save.world)?; let positions = rebuild_position_index(&ecs_world, save.map.radius); let world = save.map; - let (sea_distance, groundwater, soil, plants, contour_points) = build_derived_data( - &world, - &ecs_world, - &save.dynamic_groundwater, - &save.dynamic_soil_nutrients, - ); - Ok(RenderData { + + Ok(build_derived_data( world, ecs_world, - positions, - groundwater, - soil, - contour_points, - sea_distance, - plants, - }) + save.dynamic_groundwater, + save.dynamic_soil_nutrients, + positions + )) } else { let radius = args.radius.max(1); let mut heightmap = generate_heights_with_radius(radius); @@ -361,17 +378,7 @@ fn load_render_data(args: &Args) -> Result { let ecs_world = World::new(); let positions = PositionIndex::new(world.radius); let zero = Map::new(world.radius, 0.0f32); - let (sea_distance, groundwater, soil, plants, contour_points) = build_derived_data(&world, &ecs_world, &zero, &zero); - Ok(RenderData { - world, - ecs_world, - positions, - groundwater, - soil, - contour_points, - sea_distance, - plants, - }) + Ok(build_derived_data(world, ecs_world, zero.clone(), zero, positions)) } } @@ -622,8 +629,8 @@ fn inspect_entity(data: &RenderData, entity: hecs::Entity, layer: MapLayer, ui: } if let Ok(plant) = data.ecs_world.get::<&Plant>(entity) { ui.text(format!( - " Plant: size={:.4} growth_ticks={} children={} ready_ticks={}", - plant.current_size, plant.growth_ticks, plant.children, plant.ready_for_reproduce_ticks + " Plant: size={:.4} growth_ticks={} children={} ready_ticks={} age={}", + plant.current_size, plant.growth_ticks, plant.children, plant.ready_for_reproduce_ticks, plant.age )); ui.text(format!( " Plant: nutrients_consumed={:.4} nutrients_added={:.4} water_consumed={:.4}", @@ -741,7 +748,7 @@ fn run_window(data: RenderData, mut settings: RenderSettings) -> Result<()> { ui.window("Controls") .position([20.0, 20.0], Condition::FirstUseEver) - .size([420.0, 420.0], Condition::FirstUseEver) + .size([720.0, 420.0], Condition::FirstUseEver) .build(|| { ui.text(format!("radius: {}", data.world.radius)); @@ -855,7 +862,7 @@ fn run_window(data: RenderData, mut settings: RenderSettings) -> Result<()> { let tid = map_texture_id.expect("texture id"); ui.window("Map") - .position([460.0, 20.0], Condition::FirstUseEver) + .position([760.0, 20.0], Condition::FirstUseEver) .size( [frame.width as f32 + 24.0, frame.height as f32 + 42.0], Condition::FirstUseEver, @@ -893,7 +900,7 @@ fn run_window(data: RenderData, mut settings: RenderSettings) -> Result<()> { ui.window("Inspector") .position([20.0, 460.0], Condition::FirstUseEver) - .size([420.0, 460.0], Condition::FirstUseEver) + .size([720.0, 660.0], Condition::FirstUseEver) .build(|| { ui.text("Field ranges:"); if settings.pca_fields.len() >= 2 { @@ -947,7 +954,7 @@ fn run_window(data: RenderData, mut settings: RenderSettings) -> Result<()> { } for f in ALL_FIELDS { ui.text(format!( - "{:<12} {:>10.6}", + "{:<21} {:>10.6}", f.name(), sample_field(f, coord, &data) )); diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..b6b0cf9 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,58 @@ +pub fn sigmoid(x: f32)-> f32 { + 1.0 / (1.0 + (-x).exp()) +} + +// Box-Muller transform +pub fn normal(rng: &mut fastrand::Rng) -> f32 { + let u = rng.f32(); + let v = rng.f32(); + ((v * std::f32::consts::TAU).cos() * (-2.0 * u.ln()).sqrt()).clamp(-6.0, 6.0) +} + +pub fn normal_scaled(rng: &mut fastrand::Rng, mu: f32, sigma: f32) -> f32 { + normal(rng) * sigma + mu +} + +pub mod config { + // Runtime game logic (plants) + pub const PLANT_TICK_DELAY: u64 = 128; + pub const FIELD_DECAY_DELAY: u64 = 100; + pub const PLANT_GROWTH_SCALE: f32 = 0.01; + pub const SOIL_NUTRIENT_CONSUMPTION_RATE: f32 = 0.5; + pub const SOIL_NUTRIENT_FIXATION_RATE: f32 = 0.002; + pub const WATER_CONSUMPTION_RATE: f32 = 0.06; + pub const PLANT_IDLE_WATER_CONSUMPTION_OFFSET: f32 = 0.05; + pub const PLANT_DIEOFF_THRESHOLD: f32 = 0.3; + pub const PLANT_DIEOFF_RATE: f32 = 0.2; + pub const AUTOSAVE_INTERVAL_TICKS: u64 = 1024; + pub const PLANT_POLLINATION_RADIUS: i32 = 12; // TODO: should be directional (wind, insects, etc) and vary by plant. + pub const PLANT_POLLINATION_SCAN_FRACTION: f32 = 0.1; + pub const PLANT_SEEDING_RADIUS: i32 = 5; // TODO: as above + pub const PLANT_SEEDING_ATTEMPTS: usize = 10; + pub const SOIL_NUTRIENT_DECOMP_RETURN_RATE: f32 = 1.0 / SOIL_NUTRIENT_CONSUMPTION_RATE * 0.6; + pub const PLANT_LIFESPAN_SCALE: f32 = 1.0 / PLANT_GROWTH_SCALE; + pub const PLANT_REPRODUCTION_ATTEMPT_COST: f32 = 0.01; + pub const PLANT_CHILD_COST: f32 = 0.05; // TODO genome parameter, tie to initial size + pub const INITIAL_PLANTS: usize = 262144; + // Runtime game logic (misc) + pub const VIEW: i32 = 15; + pub const RANDOM_DESPAWN_INV_RATE: u64 = 4000; + // Worldgen constants + pub const WORLD_RADIUS: i32 = 1024; + pub const NOISE_SCALE: f32 = 0.0005; + pub const HEIGHT_EXPONENT: f32 = 0.3; + pub const WATER_SOURCES: usize = 40; + pub const CONTOUR_INTERVAL: f32 = 0.1; + pub const SEA_LEVEL: f32 = -0.8; + pub const EROSION: f32 = 0.09; + pub const EROSION_EXPONENT: f32 = 1.5; + pub const SALT_REMOVAL: f32 = 0.13; + pub const SALT_RANGE: f32 = 0.2; + pub const BASE_TEMPERATURE: f32 = 30.0; // degrees + pub const HEIGHT_SCALE: f32 = 1e3; // unrealistic but makes world more interesting; m + //const SEA_LEVEL_AIR_PRESSURE: f32 = 1013.0; // hPa + //const PRESSURE_DROP_PER_METER: f32 = 0.001; // hPa m^-1 + pub const AIR_SPECIFIC_HEAT_CAPACITY: f32 = 1012.0; // J kg^-1 K^-1 + pub const EARTH_GRAVITY: f32 = 9.81; // m s^-2 + pub const NUTRIENT_NOISE_SCALE: f32 = 0.0015; +} diff --git a/src/worldgen.rs b/src/worldgen.rs index c2bbf1f..cc6d612 100644 --- a/src/worldgen.rs +++ b/src/worldgen.rs @@ -3,14 +3,9 @@ use std::{cmp::Ordering, collections::{hash_map::Entry, BinaryHeap, HashMap, Has use noise_functions::Sample3; use rayon::prelude::*; use serde::{Deserialize, Serialize}; +use crate::util::config::*; use crate::map::*; -pub const WORLD_RADIUS: i32 = 1024; -const NOISE_SCALE: f32 = 0.0005; -const HEIGHT_EXPONENT: f32 = 0.3; -const WATER_SOURCES: usize = 40; -const CONTOUR_INTERVAL: f32 = 0.1; - pub fn height_baseline_with_radius(pos: Coord, radius: i32) -> f32 { let w_frac = (hex_distance(pos, Coord::origin()) as f32 / radius as f32).powf(3.0); let pos = to_cubic(pos); @@ -33,7 +28,7 @@ fn percentilize f32>(raw: &mut Map, postprocess: F) { } } -fn normalize f32 + Sync>(raw: &mut Map, postprocess: F) { +pub fn normalize f32 + Sync>(raw: &mut Map, postprocess: F) { let mut min = raw.iter_data().copied().min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap(); let mut max = raw.iter_data().copied().max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap(); if min == max { @@ -220,10 +215,6 @@ pub fn compute_groundwater(water: &Map, rain: &Map, heightmap: &Map) -> HashSet { let mut out = HashSet::new(); let mut queue = VecDeque::new(); @@ -247,9 +238,6 @@ pub fn get_sea(heightmap: &Map) -> (HashSet, HashSet) { (sinks, sea) } -const SALT_REMOVAL: f32 = 0.13; -const SALT_RANGE: f32 = 0.33; - pub fn simulate_water(heightmap: &mut Map, rain_map: &Map, sea: &HashSet, sinks: &HashSet) -> (Map, Map) { let mut watermap = Map::::new(heightmap.radius, 0.0); @@ -346,8 +334,6 @@ pub fn simulate_water(heightmap: &mut Map, rain_map: &Map, sea: &HashS (watermap, salt) } -const NUTRIENT_NOISE_SCALE: f32 = 0.0015; - // As a handwave, define soil nutrients to be partly randomized and partly based on water. // This kind of sort of makes sense because nitrogen is partly fixed by plants, which would have grown in water-having areas. // Update: they covaried way too much so reducing this. @@ -378,13 +364,6 @@ fn august_roche_magnus(temperature: f32) -> f32 { 6.1094 * f32::exp((17.625 * temperature) / (243.04 + temperature)) } -const BASE_TEMPERATURE: f32 = 30.0; // degrees -const HEIGHT_SCALE: f32 = 1e3; // unrealistic but makes world more interesting; m -//const SEA_LEVEL_AIR_PRESSURE: f32 = 1013.0; // hPa -//const PRESSURE_DROP_PER_METER: f32 = 0.001; // hPa m^-1 -const AIR_SPECIFIC_HEAT_CAPACITY: f32 = 1012.0; // J kg^-1 K^-1 -const EARTH_GRAVITY: f32 = 9.81; // m s^-2 - pub fn simulate_air(heightmap: &Map, sea: &HashSet, scan_dir: CoordVec, perpendicular_dir: CoordVec) -> (Map, Map, Map) { let radius = heightmap.radius; let start_pos = Coord::origin() + -scan_dir * radius;