1
0
mirror of https://github.com/osmarks/ewo3.git synced 2026-06-08 22:02:06 +00:00

Refactor and futilely retune plants

This commit is contained in:
osmarks
2026-03-14 13:02:13 +00:00
parent 147b71a635
commit 3051d2ca22
11 changed files with 263 additions and 226 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
/target
/node_modules
out.png
world.bin
world.bin
*.bin
Generated
+1 -2
View File
@@ -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",
+3
View File
@@ -45,3 +45,6 @@ path = "src/render.rs"
[profile.release]
debug = true
[patch.crates-io]
hecs = { git = "https://github.com/osmarks/hecs-patch" }
+6 -3
View File
@@ -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()
}
}
+1
View File
@@ -4,5 +4,6 @@ pub mod plant;
pub mod save;
pub mod world_serde;
pub mod worldgen;
pub mod util;
pub use components::*;
+31 -39
View File
@@ -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<GameState> {
})
}
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);
+3 -3
View File
@@ -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)
}
+50 -56
View File
@@ -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<Genome> {
pub fn hybridize(&self, rng: &mut fastrand::Rng, other: &Genome) -> Option<Genome> {
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),
})
}
}
+106 -99
View File
@@ -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<Self> {
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<Self> {
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<Coord, u8>,
sea_distance: Map<f32>,
plants: Map<f32>,
plants_age: Map<f32>,
plants_lifespan_fraction: Map<f32>,
dynamic_groundwater: Map<f32>,
dynamic_soil: Map<f32>,
}
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<f32>,
dynamic_soil_nutrients: &Map<f32>,
) -> (Map<f32>, Map<f32>, Map<f32>, Map<f32>, HashMap<Coord, u8>) {
world: GeneratedWorld,
ecs_world: World,
dynamic_groundwater: Map<f32>,
dynamic_soil_nutrients: Map<f32>,
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<RenderData> {
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<RenderData> {
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)
));
+58
View File
@@ -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;
}
+2 -23
View File
@@ -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<F: Fn(f32) -> f32>(raw: &mut Map<f32>, postprocess: F) {
}
}
fn normalize<F: Fn(f32) -> f32 + Sync>(raw: &mut Map<f32>, postprocess: F) {
pub fn normalize<F: Fn(f32) -> f32 + Sync>(raw: &mut Map<f32>, 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<f32>, rain: &Map<f32>, heightmap: &Map<f3
groundwater
}
const SEA_LEVEL: f32 = -0.8;
const EROSION: f32 = 0.09;
const EROSION_EXPONENT: f32 = 1.5;
fn floodfill(src: Coord, all: &HashSet<Coord>) -> HashSet<Coord> {
let mut out = HashSet::new();
let mut queue = VecDeque::new();
@@ -247,9 +238,6 @@ pub fn get_sea(heightmap: &Map<f32>) -> (HashSet<Coord>, HashSet<Coord>) {
(sinks, sea)
}
const SALT_REMOVAL: f32 = 0.13;
const SALT_RANGE: f32 = 0.33;
pub fn simulate_water(heightmap: &mut Map<f32>, rain_map: &Map<f32>, sea: &HashSet<Coord>, sinks: &HashSet<Coord>) -> (Map<f32>, Map<f32>) {
let mut watermap = Map::<f32>::new(heightmap.radius, 0.0);
@@ -346,8 +334,6 @@ pub fn simulate_water(heightmap: &mut Map<f32>, rain_map: &Map<f32>, 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<f32>, sea: &HashSet<Coord>, scan_dir: CoordVec, perpendicular_dir: CoordVec) -> (Map<f32>, Map<f32>, Map<f32>) {
let radius = heightmap.radius;
let start_pos = Coord::origin() + -scan_dir * radius;