├── screenshot.png ├── assets └── assets.png ├── src ├── lib.rs ├── globals.rs ├── configs.rs ├── world.rs ├── utils.rs ├── main.rs ├── stats.rs ├── elements.rs ├── gui.rs └── boid.rs ├── Cargo.toml ├── README.md └── Cargo.lock /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bones-ai/rust-ecosystem-simulation/HEAD/screenshot.png -------------------------------------------------------------------------------- /assets/assets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bones-ai/rust-ecosystem-simulation/HEAD/assets/assets.png -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod boid; 2 | pub mod configs; 3 | pub mod elements; 4 | pub mod globals; 5 | pub mod gui; 6 | pub mod stats; 7 | pub mod utils; 8 | pub mod world; 9 | 10 | pub use configs::*; 11 | pub use globals::*; 12 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ecosim" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | bevy = "0.12.1" 10 | bevy_egui = "0.24.0" 11 | bevy_pancam = { version = "0.10.0", features = ["bevy_egui"]} 12 | egui_plot = "0.24.1" 13 | rand = "0.8.5" 14 | 15 | [workspace] 16 | resolver = "2" # Important! wgpu/Bevy needs this! 17 | 18 | # Enable a small amount of optimization in debug mode 19 | [profile.dev] 20 | opt-level = 1 21 | 22 | # Enable high optimizations for dependencies (incl. Bevy), but not for our code: 23 | [profile.dev.package."*"] 24 | opt-level = 3 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Procedural World Generation 2 | This is a simple boid ecosystem simulation inspired by Daniel Shiffman's (TheCodingTrain) Ecosystem simulation - https://www.youtube.com/watch?v=flxOkx0yLrY 3 | 4 | Built in [Rust](https://www.rust-lang.org/) using the [Bevy](https://bevyengine.org/) game engine. 5 | 6 | ![screenshot](/screenshot.png) 7 | 8 | ## Timelapse video 9 | 10 | ### Part 1 11 | [![youtube](https://img.youtube.com/vi/lCUovKa68jQ/0.jpg)](https://youtu.be/lCUovKa68jQ) 12 | 13 | ### Part 2 with predators 14 | [![youtube](https://img.youtube.com/vi/sKYUIlDdC18/0.jpg)](https://youtu.be/sKYUIlDdC18) 15 | 16 | ## Usage 17 | - Clone the repo 18 | ```bash 19 | git clone git@github.com:bones-ai/rust-ecosystem-simulation.git 20 | cd rust-ecosystem-simulation 21 | ``` 22 | - Run the simulation 23 | ```bash 24 | cargo run 25 | ``` 26 | 27 | ## Controls 28 | - `Backspace` - Show graphs 29 | - `Tilde` - Show graph settings 30 | - `Tab` - Show debug gizmos 31 | - `1` - Camera follow boid 32 | - `2` - Camera follow predator boid 33 | - `3` - Camera snap to center 34 | 35 | ## Configurations 36 | - The project config file is located at `src/configs.rs` 37 | -------------------------------------------------------------------------------- /src/globals.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | 3 | #[derive(Resource)] 4 | pub struct GlobalTextureHandle(pub Option>); 5 | 6 | #[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Hash, States)] 7 | pub enum SimState { 8 | #[default] 9 | Loading, 10 | InitSim, 11 | Simulating, 12 | } 13 | 14 | #[derive(Resource)] 15 | pub struct Settings { 16 | pub camera_follow_boid: bool, 17 | pub camera_follow_predator: bool, 18 | pub camera_clamp_center: bool, 19 | pub enable_gizmos: bool, 20 | pub show_plots: bool, 21 | pub show_plot_settings: bool, 22 | pub plot_options: PlotOptions, 23 | } 24 | 25 | pub struct PlotOptions { 26 | pub num_boids: bool, 27 | pub lifespan: bool, 28 | pub perception: bool, 29 | pub affinity: bool, 30 | pub steering: bool, 31 | pub speed: bool, 32 | } 33 | 34 | impl Default for Settings { 35 | fn default() -> Self { 36 | Self { 37 | camera_follow_boid: false, 38 | camera_follow_predator: false, 39 | camera_clamp_center: true, 40 | enable_gizmos: false, 41 | show_plots: false, 42 | show_plot_settings: false, 43 | plot_options: PlotOptions::default(), 44 | } 45 | } 46 | } 47 | 48 | impl Default for PlotOptions { 49 | fn default() -> Self { 50 | Self { 51 | num_boids: false, 52 | lifespan: true, 53 | perception: true, 54 | affinity: true, 55 | steering: false, 56 | speed: false, 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/configs.rs: -------------------------------------------------------------------------------- 1 | // Window 2 | pub const WW: usize = 1600; 3 | pub const WH: usize = 1000; 4 | 5 | // Sprite 6 | pub const SPRITE_SHEET_PATH: &str = "assets.png"; 7 | pub const TILE_H: f32 = 16.0; 8 | pub const TILE_W: f32 = 16.0; 9 | pub const SPRITE_SHEET_ROWS: usize = 10; 10 | pub const SPRITE_SHEET_COLS: usize = 10; 11 | pub const BOID_SPRITE_SCALE: f32 = 1.5; 12 | pub const CONSUMABLE_SPRITE_SCALE: f32 = 1.0; 13 | 14 | // Sim 15 | pub const WORLD_W: f32 = 1305.0; 16 | pub const WORLD_H: f32 = 730.0; 17 | pub const NUM_BOIDS: usize = 100; 18 | pub const NUM_PREDATORS: usize = 10; 19 | pub const NUM_FOOD: usize = 600; 20 | pub const NUM_POISON: usize = 150; 21 | 22 | // Boids 23 | pub const BOID_COLLISION_RADIUS: f32 = 8.0; 24 | pub const BOID_MAX_HEALTH: f32 = 100.0; 25 | pub const BOID_TICK_DAMAGE: f32 = 4.0; 26 | pub const BOID_REPLICATE_INTERVAL: f32 = 5.0; 27 | pub const BOID_REPLICATE_PROBABILITY: f32 = 0.2; 28 | pub const BOID_MUTATION_THRESHOLD: f32 = 0.8; 29 | 30 | // Predators 31 | pub const BOID_NUTRITION: f32 = 30.0; 32 | pub const PREDATOR_COLLISION_RADIUS: f32 = 10.0; 33 | 34 | // Colors 35 | pub const COLOR_FOOD: (u8, u8, u8) = (142, 231, 112); 36 | pub const COLOR_POISON: (u8, u8, u8) = (235, 86, 75); 37 | pub const COLOR_BOID: (u8, u8, u8) = (255, 255, 255); 38 | pub const COLOR_PREDATOR: (u8, u8, u8) = (255, 236, 179); 39 | pub const COLOR_PREDATOR_LOW_HEALTH: (u8, u8, u8) = (255, 145, 102); 40 | pub const COLOR_BOID_LOW_HEALTH: (u8, u8, u8) = (102, 255, 227); 41 | pub const COLOR_BACKGROUND: (u8, u8, u8) = (50, 62, 79); 42 | 43 | // Consumables 44 | pub const FOOD_NUTRITION: f32 = 5.0; 45 | pub const POISON_DAMAGE: f32 = -30.0; 46 | pub const FOOD_DECAY_RATE: f32 = -0.1; 47 | pub const POISON_DECAY_RATE: f32 = 0.5; 48 | pub const REPLICATION_RADIUS_FOOD: f32 = 40.0; 49 | pub const REPLICATION_RADIUS_POISON: f32 = 5.0; 50 | pub const CONSUMABLE_REPLICATION_RATE: f32 = 0.2; 51 | pub const CONSUMABLE_DECAY_RATE: f32 = 1.5; 52 | pub const REPLICATION_COOLDOWN: f32 = 2.0; 53 | 54 | // Stats 55 | pub const STAT_COLLECTION_RATE: f32 = 1.0; 56 | pub const MAX_NUM_POINTS: usize = 8000; 57 | 58 | // Graphs 59 | pub const PLOT_LINE_WIDTH: f32 = 1.5; 60 | pub const PLOT_ASPECT_RATIO: f32 = 1.8; 61 | -------------------------------------------------------------------------------- /src/world.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use bevy::prelude::*; 4 | use bevy::time::common_conditions::on_timer; 5 | use rand::Rng; 6 | 7 | use crate::boid::{Boid, BoidBundle, Predator, PredatorBundle}; 8 | use crate::elements::{FoodBundle, PoisonBundle}; 9 | use crate::SimState; 10 | use crate::*; 11 | 12 | pub struct WorldPlugin; 13 | 14 | impl Plugin for WorldPlugin { 15 | fn build(&self, app: &mut bevy::prelude::App) { 16 | app.add_systems( 17 | OnEnter(SimState::InitSim), 18 | (populate_boids, populate_consumables, start_simulation), 19 | ) 20 | .add_systems( 21 | Update, 22 | populate_boids 23 | .run_if(on_timer(Duration::from_secs_f32(5.0))) 24 | .run_if(in_state(SimState::Simulating)), 25 | ); 26 | } 27 | } 28 | 29 | fn populate_boids( 30 | mut commands: Commands, 31 | handle: Res, 32 | boid_query: Query<(With, Without)>, 33 | predator_query: Query>, 34 | ) { 35 | let remaining_boids = if boid_query.iter().len() > 0 { 36 | 0 37 | } else { 38 | NUM_BOIDS 39 | }; 40 | let remaining_predators = if predator_query.iter().len() > 0 { 41 | 0 42 | } else { 43 | NUM_PREDATORS 44 | }; 45 | 46 | for _ in 0..remaining_boids { 47 | commands.spawn(BoidBundle::new(handle.0.clone().unwrap(), false)); 48 | } 49 | for _ in 0..remaining_predators { 50 | commands.spawn(PredatorBundle::new(handle.0.clone().unwrap())); 51 | } 52 | } 53 | 54 | fn populate_consumables(mut commands: Commands, handle: Res) { 55 | let mut rng = rand::thread_rng(); 56 | 57 | for _ in 0..NUM_FOOD { 58 | let x = rng.gen_range(-WORLD_W..WORLD_W); 59 | let y = rng.gen_range(-WORLD_H..WORLD_H); 60 | commands.spawn(FoodBundle::new((x, y), handle.0.clone().unwrap())); 61 | } 62 | for _ in 0..NUM_POISON { 63 | let x = rng.gen_range(-WORLD_W..WORLD_W); 64 | let y = rng.gen_range(-WORLD_H..WORLD_H); 65 | commands.spawn(PoisonBundle::new((x, y), handle.0.clone().unwrap())); 66 | } 67 | } 68 | 69 | fn start_simulation(mut next_state: ResMut>) { 70 | next_state.set(SimState::Simulating); 71 | } 72 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::f32::consts::PI; 2 | 3 | use bevy::{ 4 | math::{vec2, vec3}, 5 | prelude::*, 6 | }; 7 | use bevy_egui::egui::Color32; 8 | use rand::Rng; 9 | 10 | use crate::*; 11 | 12 | pub struct LimitedVec { 13 | pub items: Vec, 14 | pub max_size: usize, 15 | } 16 | 17 | pub fn get_rand_unit_vec3() -> Vec3 { 18 | let mut rng = rand::thread_rng(); 19 | let x = rng.gen_range(-100.0..100.0); 20 | let y = rng.gen_range(-100.0..100.0); 21 | 22 | vec3(x, y, 0.0).normalize() 23 | } 24 | 25 | pub fn get_rand_unit_vec2() -> Vec2 { 26 | let rand_vec3 = get_rand_unit_vec3(); 27 | vec2(rand_vec3.x, rand_vec3.y) 28 | } 29 | 30 | pub fn calc_rotation_angle(v1: Vec3, v2: Vec3) -> f32 { 31 | let dx = v1.x - v2.x; 32 | let dy = v1.y - v2.y; 33 | 34 | let mut angle_rad = dy.atan2(dx); 35 | 36 | if angle_rad < 0.0 { 37 | angle_rad += 2.0 * PI; 38 | } 39 | angle_rad 40 | } 41 | 42 | pub fn get_world_bounds() -> (f32, f32, f32, f32) { 43 | let min_x = -WORLD_W; 44 | let min_y = -WORLD_H; 45 | let max_x = WORLD_W; 46 | let max_y = WORLD_H; 47 | 48 | (min_x, min_y, max_x, max_y) 49 | } 50 | 51 | pub fn limit_to_world((x, y): (f32, f32)) -> (f32, f32) { 52 | let (x, y) = (x.min(WORLD_W), y.min(WORLD_H)); 53 | let (x, y) = (x.max(-WORLD_W), y.max(-WORLD_H)); 54 | (x, y) 55 | } 56 | 57 | pub fn get_steering_force(target: Vec2, pos: Vec2, velocity: Vec2) -> Vec2 { 58 | let desired = target - pos; 59 | desired - velocity 60 | } 61 | 62 | pub fn get_color((r, g, b): (u8, u8, u8)) -> Color { 63 | Color::rgb_u8(r, g, b) 64 | } 65 | 66 | pub fn get_color_hex(value: &str) -> Color { 67 | Color::hex(value).unwrap() 68 | } 69 | 70 | pub fn get_color32((r, g, b): (u8, u8, u8)) -> Color32 { 71 | Color32::from_rgb(r, g, b) 72 | } 73 | 74 | pub fn hex_to_rgba(hex: &str) -> Option<(u8, u8, u8, u8)> { 75 | let alpha = match hex.len() { 76 | 6 => false, 77 | 8 => true, 78 | _ => None?, 79 | }; 80 | u32::from_str_radix(hex, 16) 81 | .ok() 82 | .map(|u| if alpha { u } else { u << 8 | 0xff }) 83 | .map(u32::to_be_bytes) 84 | .map(|[r, g, b, a]| (r, g, b, a)) 85 | } 86 | 87 | impl LimitedVec { 88 | pub fn new() -> Self { 89 | LimitedVec { 90 | items: Vec::new(), 91 | max_size: MAX_NUM_POINTS, 92 | } 93 | } 94 | 95 | pub fn push(&mut self, item: T) { 96 | self.items.push(item); 97 | if self.items.len() > self.max_size { 98 | self.items.remove(0); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use bevy::math::vec2; 2 | use bevy::prelude::*; 3 | use bevy::window::close_on_esc; 4 | use bevy_pancam::{PanCam, PanCamPlugin}; 5 | 6 | use ecosim::utils::get_color; 7 | use ecosim::*; 8 | use ecosim::{ 9 | boid::BoidPlugin, elements::ElementsPlugin, gui::GuiPlugin, stats::StatsPlugin, 10 | world::WorldPlugin, 11 | }; 12 | 13 | fn main() { 14 | App::new() 15 | .add_state::() 16 | .add_plugins( 17 | DefaultPlugins 18 | .set(ImagePlugin::default_nearest()) 19 | .set(WindowPlugin { 20 | primary_window: Some(Window { 21 | // mode: bevy::window::WindowMode::Fullscreen, 22 | resolution: (WW as f32, WH as f32).into(), 23 | title: "eco-sim".to_string(), 24 | ..default() 25 | }), 26 | ..default() 27 | }), 28 | ) 29 | .add_plugins(PanCamPlugin) 30 | .insert_resource(Msaa::Off) 31 | .insert_resource(ClearColor(get_color(COLOR_BACKGROUND))) 32 | .insert_resource(GlobalTextureHandle(None)) 33 | .insert_resource(Settings::default()) 34 | .add_plugins(BoidPlugin) 35 | .add_plugins(GuiPlugin) 36 | .add_plugins(StatsPlugin) 37 | .add_plugins(WorldPlugin) 38 | .add_plugins(ElementsPlugin) 39 | .add_systems(OnEnter(SimState::Loading), setup) 40 | .add_systems( 41 | Update, 42 | camera_clamp_system.run_if(in_state(SimState::Simulating)), 43 | ) 44 | .add_systems(Update, handle_keyboard_input) 45 | .add_systems(Update, close_on_esc) 46 | .run(); 47 | } 48 | 49 | fn setup( 50 | mut commands: Commands, 51 | mut handle: ResMut, 52 | asset_server: Res, 53 | mut texture_atlases: ResMut>, 54 | mut next_state: ResMut>, 55 | ) { 56 | let texture_handle = asset_server.load(SPRITE_SHEET_PATH); 57 | let texture_atlas = TextureAtlas::from_grid( 58 | texture_handle, 59 | vec2(TILE_W, TILE_H), 60 | SPRITE_SHEET_ROWS, 61 | SPRITE_SHEET_COLS, 62 | None, 63 | None, 64 | ); 65 | handle.0 = Some(texture_atlases.add(texture_atlas)); 66 | 67 | commands 68 | .spawn(Camera2dBundle::default()) 69 | .insert(PanCam::default()); 70 | next_state.set(SimState::InitSim); 71 | } 72 | 73 | fn camera_clamp_system( 74 | settings: Res, 75 | mut cam_query: Query<&mut Transform, With>, 76 | ) { 77 | if cam_query.is_empty() { 78 | return; 79 | } 80 | if !settings.camera_clamp_center { 81 | return; 82 | } 83 | 84 | let mut cam_transform = cam_query.single_mut(); 85 | cam_transform.translation = cam_transform.translation.lerp(Vec3::ZERO, 0.05); 86 | } 87 | 88 | fn handle_keyboard_input(keys: Res>, mut settings: ResMut) { 89 | if keys.just_pressed(KeyCode::Key1) { 90 | settings.camera_follow_boid = !settings.camera_follow_boid; 91 | settings.camera_follow_predator = false; 92 | settings.camera_clamp_center = false; 93 | } 94 | if keys.just_pressed(KeyCode::Key2) { 95 | settings.camera_follow_predator = !settings.camera_follow_predator; 96 | settings.camera_follow_boid = false; 97 | settings.camera_clamp_center = false; 98 | } 99 | if keys.just_pressed(KeyCode::Key3) { 100 | settings.camera_clamp_center = !settings.camera_clamp_center; 101 | settings.camera_follow_boid = false; 102 | settings.camera_follow_predator = false; 103 | } 104 | if keys.just_pressed(KeyCode::Tab) { 105 | settings.enable_gizmos = !settings.enable_gizmos; 106 | } 107 | if keys.just_pressed(KeyCode::Back) { 108 | settings.show_plots = !settings.show_plots; 109 | } 110 | if keys.just_pressed(KeyCode::Grave) { 111 | settings.show_plot_settings = !settings.show_plot_settings; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/stats.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use bevy::prelude::*; 4 | use bevy::time::common_conditions::on_timer; 5 | 6 | use crate::boid::{BirthTimeStamp, Boid, Dna, Predator, PredatorDna}; 7 | use crate::elements::{Food, Poison}; 8 | use crate::utils::LimitedVec; 9 | use crate::*; 10 | 11 | pub struct StatsPlugin; 12 | 13 | #[derive(Resource)] 14 | pub struct SimulationStats { 15 | pub num_boids: LimitedVec, 16 | pub num_predators: LimitedVec, 17 | pub num_food: LimitedVec, 18 | pub num_poison: LimitedVec, 19 | pub avg_lifespan: LimitedVec, 20 | pub avg_predator_lifespan: LimitedVec, 21 | pub food_perception: LimitedVec, 22 | pub poison_perception: LimitedVec, 23 | pub predator_perception: LimitedVec, 24 | pub prey_perception: LimitedVec, 25 | pub speed: LimitedVec, 26 | pub predator_speed: LimitedVec, 27 | pub food_affinity: LimitedVec, 28 | pub poison_affinity: LimitedVec, 29 | pub predator_affinity: LimitedVec, 30 | pub prey_affinity: LimitedVec, 31 | pub steering_force: LimitedVec, 32 | } 33 | 34 | impl Plugin for StatsPlugin { 35 | fn build(&self, app: &mut App) { 36 | app.insert_resource(SimulationStats::new()).add_systems( 37 | Update, 38 | update_stats 39 | .run_if(in_state(SimState::Simulating)) 40 | .run_if(on_timer(Duration::from_secs_f32(STAT_COLLECTION_RATE))), 41 | ); 42 | } 43 | } 44 | 45 | fn update_stats( 46 | mut stats: ResMut, 47 | boid_query: Query<(&Dna, &BirthTimeStamp), (With, Without)>, 48 | predators_query: Query<(&Dna, &PredatorDna, &BirthTimeStamp), With>, 49 | food_query: Query>, 50 | poison_query: Query>, 51 | ) { 52 | let num_boids = boid_query.iter().len() as f32; 53 | let num_predators = predators_query.iter().len() as f32; 54 | let num_food = food_query.iter().len() as f32; 55 | let num_poison = poison_query.iter().len() as f32; 56 | 57 | let mut food_perception_radius = 0.0; 58 | let mut poison_perception_radius = 0.0; 59 | let mut predator_perception_radius = 0.0; 60 | let mut food_affinity = 0.0; 61 | let mut poison_affinity = 0.0; 62 | let mut predator_affinity = 0.0; 63 | let mut speed = 0.0; 64 | let mut steering_force = 0.0; 65 | let mut avg_lifespan = 0.0; 66 | 67 | let mut avg_predator_lifespan = 0.0; 68 | let mut predator_speed = 0.0; 69 | let mut predator_boid_affinity = 0.0; 70 | let mut prey_perception_radius = 0.0; 71 | 72 | for (dna, birth_ts) in boid_query.iter() { 73 | food_perception_radius += dna.food_perception_radius; 74 | poison_perception_radius += dna.poison_perception_radius; 75 | predator_perception_radius += dna.predator_perception_radius; 76 | food_affinity += dna.food_pull; 77 | poison_affinity += dna.poison_pull; 78 | predator_affinity += dna.predator_pull; 79 | speed += dna.speed; 80 | steering_force += dna.steering_force; 81 | avg_lifespan += birth_ts.0.elapsed().as_secs_f32(); 82 | } 83 | 84 | for (dna, pred_dna, birth_ts) in predators_query.iter() { 85 | avg_predator_lifespan += birth_ts.0.elapsed().as_secs_f32(); 86 | predator_speed += dna.speed; 87 | predator_boid_affinity += pred_dna.prey_pull; 88 | prey_perception_radius += pred_dna.prey_perception; 89 | } 90 | 91 | stats.num_boids.push(num_boids / NUM_BOIDS as f32); 92 | stats 93 | .num_predators 94 | .push(num_predators / NUM_PREDATORS as f32); 95 | stats.num_food.push(num_food / NUM_FOOD as f32); 96 | stats.num_poison.push(num_poison / NUM_POISON as f32); 97 | stats 98 | .food_perception 99 | .push(food_perception_radius / num_boids); 100 | stats 101 | .poison_perception 102 | .push(poison_perception_radius / num_boids); 103 | stats 104 | .predator_perception 105 | .push(predator_perception_radius / num_boids); 106 | stats 107 | .prey_perception 108 | .push(prey_perception_radius / num_predators); 109 | stats.food_affinity.push(food_affinity / num_boids); 110 | stats.steering_force.push(steering_force / num_boids); 111 | stats.poison_affinity.push(poison_affinity / num_boids); 112 | stats.predator_affinity.push(predator_affinity / num_boids); 113 | stats 114 | .prey_affinity 115 | .push(predator_boid_affinity / num_predators); 116 | stats.speed.push(speed / num_boids); 117 | stats.predator_speed.push(predator_speed / num_predators); 118 | stats.avg_lifespan.push(avg_lifespan / num_boids); 119 | stats 120 | .avg_predator_lifespan 121 | .push(avg_predator_lifespan / num_predators); 122 | } 123 | 124 | impl SimulationStats { 125 | fn new() -> Self { 126 | Self { 127 | num_boids: LimitedVec::new(), 128 | num_predators: LimitedVec::new(), 129 | num_food: LimitedVec::new(), 130 | num_poison: LimitedVec::new(), 131 | avg_lifespan: LimitedVec::new(), 132 | avg_predator_lifespan: LimitedVec::new(), 133 | food_perception: LimitedVec::new(), 134 | poison_perception: LimitedVec::new(), 135 | predator_perception: LimitedVec::new(), 136 | prey_perception: LimitedVec::new(), 137 | speed: LimitedVec::new(), 138 | predator_speed: LimitedVec::new(), 139 | food_affinity: LimitedVec::new(), 140 | poison_affinity: LimitedVec::new(), 141 | predator_affinity: LimitedVec::new(), 142 | prey_affinity: LimitedVec::new(), 143 | steering_force: LimitedVec::new(), 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/elements.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use bevy::{math::vec3, prelude::*, time::common_conditions::on_timer, utils::Instant}; 4 | use rand::Rng; 5 | 6 | use crate::*; 7 | 8 | use self::utils::{get_color, limit_to_world}; 9 | 10 | pub struct ElementsPlugin; 11 | 12 | #[derive(Component)] 13 | pub struct Consumable(pub f32); 14 | #[derive(Component)] 15 | pub struct Food; 16 | #[derive(Component)] 17 | pub struct Poison; 18 | #[derive(Component)] 19 | struct LastReplicationTs(Instant); 20 | 21 | #[derive(Bundle)] 22 | struct ConsumableBundle { 23 | sprite_sheet_bundle: SpriteSheetBundle, 24 | last_replication_ts: LastReplicationTs, 25 | consumable: Consumable, 26 | } 27 | #[derive(Bundle)] 28 | pub struct FoodBundle { 29 | food: Food, 30 | consumable_bundle: ConsumableBundle, 31 | } 32 | #[derive(Bundle)] 33 | pub struct PoisonBundle { 34 | poison: Poison, 35 | consumable_bundle: ConsumableBundle, 36 | } 37 | 38 | impl Plugin for ElementsPlugin { 39 | fn build(&self, app: &mut App) { 40 | app.add_systems( 41 | Update, 42 | ( 43 | replicate_consumables.run_if(on_timer(Duration::from_secs_f32( 44 | CONSUMABLE_REPLICATION_RATE, 45 | ))), 46 | decay_consumables.run_if(on_timer(Duration::from_secs_f32(CONSUMABLE_DECAY_RATE))), 47 | despawn_consumables, 48 | update_color, 49 | ) 50 | .run_if(in_state(SimState::Simulating)), 51 | ); 52 | } 53 | } 54 | 55 | fn update_color( 56 | mut consumable_query: Query<(&mut TextureAtlasSprite, &Consumable), With>, 57 | ) { 58 | for (mut sprite, c) in consumable_query.iter_mut() { 59 | sprite.color = sprite.color.with_a(c.opacity()); 60 | } 61 | } 62 | 63 | fn decay_consumables(mut consumable_query: Query<&mut Consumable, With>) { 64 | if consumable_query.is_empty() { 65 | return; 66 | } 67 | 68 | for mut c in consumable_query.iter_mut() { 69 | c.decay() 70 | } 71 | } 72 | 73 | fn despawn_consumables( 74 | mut commands: Commands, 75 | consumable_query: Query<(&Consumable, Entity), With>, 76 | ) { 77 | for (c, e) in consumable_query.iter() { 78 | if c.is_despawn() { 79 | commands.entity(e).despawn(); 80 | } 81 | } 82 | } 83 | 84 | fn replicate_consumables( 85 | mut commands: Commands, 86 | handle: Res, 87 | food_query: Query>, 88 | poison_query: Query>, 89 | mut consumable_query: Query< 90 | (&Transform, &Consumable, &mut LastReplicationTs), 91 | With, 92 | >, 93 | ) { 94 | let num_food = food_query.iter().len(); 95 | let num_poison = poison_query.iter().len(); 96 | let is_populate_food = num_food < NUM_FOOD; 97 | let is_populate_poison = num_poison < NUM_POISON; 98 | 99 | let mut rng = rand::thread_rng(); 100 | for (transform, consumable, mut last_replication_ts) in consumable_query.iter_mut() { 101 | if last_replication_ts.0.elapsed().as_secs_f32() < REPLICATION_COOLDOWN { 102 | continue; 103 | } 104 | if rng.gen_range(0.0..1.0) < 0.8 { 105 | continue; 106 | } 107 | 108 | // Probability to replicate at the center > edges 109 | let dist_to_center = transform 110 | .translation 111 | .truncate() 112 | .distance_squared(Vec2::ZERO); 113 | if rng.gen_range(0.0..1.0) < dist_to_center / 1000000.0 { 114 | continue; 115 | } 116 | 117 | let replication_radius = if consumable.is_food() { 118 | REPLICATION_RADIUS_FOOD 119 | } else { 120 | REPLICATION_RADIUS_POISON 121 | }; 122 | let (x, y) = (transform.translation.x, transform.translation.y); 123 | let (x, y) = ( 124 | x + rng.gen_range(-replication_radius..replication_radius), 125 | y + rng.gen_range(-replication_radius..replication_radius), 126 | ); 127 | let (x, y) = limit_to_world((x, y)); 128 | 129 | if consumable.is_food() && is_populate_food { 130 | commands.spawn(FoodBundle::new((x, y), handle.0.clone().unwrap())); 131 | } else if rng.gen_range(0.0..1.0) > 0.4 && is_populate_poison { 132 | commands.spawn(PoisonBundle::new((x, y), handle.0.clone().unwrap())); 133 | } 134 | last_replication_ts.0 = Instant::now(); 135 | } 136 | } 137 | 138 | impl Consumable { 139 | // 0.1 and -0.1 are min nutrition values for food/poison 140 | 141 | pub fn is_food(&self) -> bool { 142 | self.0 >= 0.1 143 | } 144 | 145 | fn is_despawn(&self) -> bool { 146 | (self.is_food() && self.0 <= 0.1) || (!self.is_food() && self.0 >= -0.1) 147 | } 148 | 149 | fn decay(&mut self) { 150 | if self.is_food() { 151 | self.0 = (self.0 + FOOD_DECAY_RATE).max(0.1); 152 | return; 153 | } 154 | 155 | self.0 = (self.0 + POISON_DECAY_RATE).min(-0.1); 156 | } 157 | 158 | fn opacity(&self) -> f32 { 159 | if self.is_food() { 160 | self.0 / FOOD_NUTRITION 161 | } else { 162 | self.0 / POISON_DAMAGE 163 | } 164 | } 165 | } 166 | 167 | impl ConsumableBundle { 168 | pub fn new( 169 | (x, y): (f32, f32), 170 | handle: Handle, 171 | color: Color, 172 | nutrition: f32, 173 | ) -> Self { 174 | Self { 175 | sprite_sheet_bundle: SpriteSheetBundle { 176 | texture_atlas: handle, 177 | sprite: TextureAtlasSprite { 178 | index: 1, 179 | color, 180 | ..default() 181 | }, 182 | transform: Transform::from_scale(Vec3::splat(CONSUMABLE_SPRITE_SCALE)) 183 | .with_translation(vec3(x, y, 2.0)), 184 | ..default() 185 | }, 186 | consumable: Consumable(nutrition), 187 | last_replication_ts: LastReplicationTs(Instant::now()), 188 | } 189 | } 190 | } 191 | 192 | impl FoodBundle { 193 | pub fn new((x, y): (f32, f32), handle: Handle) -> Self { 194 | Self { 195 | consumable_bundle: ConsumableBundle::new( 196 | (x, y), 197 | handle, 198 | get_color(COLOR_FOOD), 199 | FOOD_NUTRITION, 200 | ), 201 | food: Food, 202 | } 203 | } 204 | } 205 | 206 | impl PoisonBundle { 207 | pub fn new((x, y): (f32, f32), handle: Handle) -> Self { 208 | Self { 209 | consumable_bundle: ConsumableBundle::new( 210 | (x, y), 211 | handle, 212 | get_color(COLOR_POISON), 213 | POISON_DAMAGE, 214 | ), 215 | poison: Poison, 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/gui.rs: -------------------------------------------------------------------------------- 1 | use bevy::{math::vec2, prelude::*}; 2 | use bevy_egui::{ 3 | egui::{self, epaint, Color32, Ui}, 4 | EguiContexts, EguiPlugin, EguiSettings, 5 | }; 6 | use egui_plot::{Line, Plot, PlotPoints, PlotResponse}; 7 | 8 | use crate::boid::{Boid, Dna, Predator, PredatorDna}; 9 | use crate::stats::*; 10 | use crate::utils::{get_color, get_color32}; 11 | use crate::*; 12 | 13 | pub struct GuiPlugin; 14 | 15 | struct PlotData<'a> { 16 | points: &'a Vec, 17 | color: Color32, 18 | } 19 | 20 | impl Plugin for GuiPlugin { 21 | fn build(&self, app: &mut App) { 22 | app.add_plugins(EguiPlugin) 23 | .add_systems(OnEnter(SimState::InitSim), setup_egui) 24 | .add_systems( 25 | Update, 26 | ( 27 | update_egui_plots, 28 | draw_boid_debug_gizmos, 29 | draw_predator_debug_gizmos, 30 | draw_world_boundary, 31 | show_plot_settings, 32 | ) 33 | .run_if(in_state(SimState::Simulating)), 34 | ); 35 | } 36 | } 37 | 38 | fn setup_egui(mut egui_settings: ResMut) { 39 | egui_settings.scale_factor = 1.5; 40 | } 41 | 42 | fn draw_world_boundary(mut gizmos: Gizmos) { 43 | gizmos.rect_2d( 44 | Vec2::ZERO, 45 | 0.0, 46 | vec2(WORLD_W * 2.0, WORLD_H * 2.0), 47 | Color::GRAY, 48 | ); 49 | } 50 | 51 | fn draw_boid_debug_gizmos( 52 | mut gizmos: Gizmos, 53 | settings: Res, 54 | boids_query: Query<(&Transform, &Dna), (With, Without)>, 55 | ) { 56 | if !settings.enable_gizmos { 57 | return; 58 | } 59 | if boids_query.is_empty() { 60 | return; 61 | } 62 | 63 | for (transform, dna) in boids_query.iter() { 64 | gizmos.circle_2d( 65 | transform.translation.truncate(), 66 | dna.food_perception_radius, 67 | Color::GREEN, 68 | ); 69 | gizmos.circle_2d( 70 | transform.translation.truncate(), 71 | dna.poison_perception_radius, 72 | Color::RED, 73 | ); 74 | gizmos.circle_2d( 75 | transform.translation.truncate(), 76 | dna.predator_perception_radius, 77 | get_color(COLOR_PREDATOR), 78 | ); 79 | } 80 | } 81 | 82 | fn draw_predator_debug_gizmos( 83 | mut gizmos: Gizmos, 84 | settings: Res, 85 | predator_query: Query<(&Transform, &PredatorDna), With>, 86 | ) { 87 | if !settings.enable_gizmos { 88 | return; 89 | } 90 | if predator_query.is_empty() { 91 | return; 92 | } 93 | 94 | for (transform, predator_dna) in predator_query.iter() { 95 | gizmos.circle_2d( 96 | transform.translation.truncate(), 97 | predator_dna.prey_perception, 98 | Color::CYAN, 99 | ); 100 | } 101 | } 102 | 103 | fn show_plot_settings(mut contexts: EguiContexts, mut settings: ResMut) { 104 | if !settings.show_plot_settings { 105 | return; 106 | } 107 | 108 | egui::Window::new("Settings").show(contexts.ctx_mut(), |ui| { 109 | egui::CollapsingHeader::new("Graphs") 110 | .default_open(true) 111 | .show(ui, |ui| { 112 | ui.checkbox(&mut settings.plot_options.num_boids, "Number of Boids"); 113 | ui.checkbox(&mut settings.plot_options.lifespan, "Lifespan"); 114 | ui.checkbox(&mut settings.plot_options.perception, "Perception"); 115 | ui.checkbox(&mut settings.plot_options.affinity, "Affinity"); 116 | ui.checkbox(&mut settings.plot_options.steering, "Steering"); 117 | ui.checkbox(&mut settings.plot_options.speed, "Speed"); 118 | }); 119 | }); 120 | } 121 | 122 | fn update_egui_plots( 123 | mut contexts: EguiContexts, 124 | stats: Res, 125 | settings: Res, 126 | ) { 127 | if !settings.show_plots { 128 | return; 129 | } 130 | 131 | let ctx = contexts.ctx_mut(); 132 | let old = ctx.style().visuals.clone(); 133 | ctx.set_visuals(egui::Visuals { 134 | window_fill: Color32::from_rgba_premultiplied(0, 0, 0, 130), 135 | panel_fill: Color32::from_rgba_premultiplied(0, 0, 0, 130), 136 | window_stroke: egui::Stroke { 137 | color: Color32::TRANSPARENT, 138 | width: 0.0, 139 | }, 140 | window_shadow: epaint::Shadow { 141 | color: Color32::TRANSPARENT, 142 | ..old.window_shadow 143 | }, 144 | ..old 145 | }); 146 | 147 | egui::Window::new("") 148 | .title_bar(false) 149 | .default_pos(egui::pos2(1500.0, 0.0)) 150 | .default_width(200.0) 151 | .show(contexts.ctx_mut(), |ui| { 152 | if settings.plot_options.num_boids { 153 | get_plot( 154 | "Number of Boids", 155 | vec![ 156 | PlotData { 157 | color: Color32::RED, 158 | points: &stats.num_poison.items, 159 | }, 160 | PlotData { 161 | color: Color32::GREEN, 162 | points: &stats.num_food.items, 163 | }, 164 | PlotData { 165 | color: get_color32(COLOR_PREDATOR_LOW_HEALTH), 166 | points: &stats.num_predators.items, 167 | }, 168 | PlotData { 169 | color: Color32::WHITE, 170 | points: &stats.num_boids.items, 171 | }, 172 | ], 173 | ui, 174 | ); 175 | } 176 | if settings.plot_options.lifespan { 177 | get_plot( 178 | "Lifespan", 179 | vec![ 180 | PlotData { 181 | color: Color32::WHITE, 182 | points: &stats.avg_lifespan.items, 183 | }, 184 | PlotData { 185 | color: get_color32(COLOR_PREDATOR_LOW_HEALTH), 186 | points: &stats.avg_predator_lifespan.items, 187 | }, 188 | ], 189 | ui, 190 | ); 191 | } 192 | if settings.plot_options.perception { 193 | get_plot( 194 | "Perception", 195 | vec![ 196 | PlotData { 197 | color: Color32::GREEN, 198 | points: &stats.food_perception.items, 199 | }, 200 | PlotData { 201 | color: Color32::RED, 202 | points: &stats.poison_perception.items, 203 | }, 204 | PlotData { 205 | color: get_color32(COLOR_PREDATOR), 206 | points: &stats.predator_perception.items, 207 | }, 208 | PlotData { 209 | color: get_color32(COLOR_BOID_LOW_HEALTH), 210 | points: &stats.prey_perception.items, 211 | }, 212 | ], 213 | ui, 214 | ); 215 | } 216 | if settings.plot_options.affinity { 217 | get_plot( 218 | "Affinity", 219 | vec![ 220 | PlotData { 221 | color: Color32::GREEN, 222 | points: &stats.food_affinity.items, 223 | }, 224 | PlotData { 225 | color: Color32::RED, 226 | points: &stats.poison_affinity.items, 227 | }, 228 | PlotData { 229 | color: get_color32(COLOR_BOID_LOW_HEALTH), 230 | points: &stats.prey_affinity.items, 231 | }, 232 | PlotData { 233 | color: get_color32(COLOR_PREDATOR_LOW_HEALTH), 234 | points: &stats.predator_affinity.items, 235 | }, 236 | ], 237 | ui, 238 | ); 239 | } 240 | if settings.plot_options.steering { 241 | get_plot( 242 | "Steering Force", 243 | vec![PlotData { 244 | color: Color32::WHITE, 245 | points: &stats.steering_force.items, 246 | }], 247 | ui, 248 | ); 249 | } 250 | if settings.plot_options.speed { 251 | get_plot( 252 | "Speed", 253 | vec![ 254 | PlotData { 255 | color: Color32::WHITE, 256 | points: &stats.speed.items, 257 | }, 258 | PlotData { 259 | color: get_color32(COLOR_PREDATOR_LOW_HEALTH), 260 | points: &stats.predator_speed.items, 261 | }, 262 | ], 263 | ui, 264 | ); 265 | } 266 | }); 267 | } 268 | 269 | fn get_plot( 270 | title: &str, 271 | plots_data: Vec, 272 | ui: &mut Ui, 273 | ) -> egui::CollapsingResponse> { 274 | let mut lines = Vec::new(); 275 | 276 | for plot in plots_data { 277 | let curve: PlotPoints = (0..plot.points.len()) 278 | .map(|i| [i as f64, plot.points[i] as f64]) 279 | .collect(); 280 | let line = Line::new(curve).width(PLOT_LINE_WIDTH).color(plot.color); 281 | lines.push(line); 282 | } 283 | 284 | egui::CollapsingHeader::new(title) 285 | .default_open(true) 286 | .show_unindented(ui, |ui| { 287 | Plot::new(title) 288 | .show_axes(false) 289 | .show_background(false) 290 | .show_grid(false) 291 | .view_aspect(2.0) 292 | .auto_bounds_x() 293 | .auto_bounds_y() 294 | .show(ui, |plot_ui| { 295 | for l in lines { 296 | plot_ui.line(l) 297 | } 298 | }) 299 | }) 300 | } 301 | -------------------------------------------------------------------------------- /src/boid.rs: -------------------------------------------------------------------------------- 1 | use std::f32::consts::PI; 2 | use std::time::Duration; 3 | 4 | use bevy::math::vec3; 5 | use bevy::prelude::*; 6 | use bevy::time::common_conditions::on_timer; 7 | use bevy::utils::Instant; 8 | use rand::Rng; 9 | 10 | use crate::elements::{Consumable, Food, FoodBundle, Poison, PoisonBundle}; 11 | use crate::utils::*; 12 | use crate::*; 13 | 14 | pub struct BoidPlugin; 15 | 16 | #[derive(Component)] 17 | pub struct Boid; 18 | #[derive(Component)] 19 | pub struct Predator; 20 | #[derive(Component)] 21 | struct Velocity(Vec2); 22 | #[derive(Component)] 23 | struct Acceleration(Vec2); 24 | #[derive(Component)] 25 | struct Health(f32); 26 | #[derive(Component)] 27 | struct ReplicateTimer(Timer); 28 | #[derive(Component)] 29 | pub struct BirthTimeStamp(pub Instant); 30 | 31 | #[derive(Event)] 32 | struct BoidDeathFoodSpawnEvent(Vec2); 33 | 34 | #[derive(Component, Clone, Copy)] 35 | pub struct Dna { 36 | pub steering_force: f32, 37 | pub speed: f32, 38 | pub food_pull: f32, 39 | pub poison_pull: f32, 40 | pub predator_pull: f32, 41 | pub food_perception_radius: f32, 42 | pub poison_perception_radius: f32, 43 | pub predator_perception_radius: f32, 44 | } 45 | 46 | #[derive(Component, Clone, Copy)] 47 | pub struct PredatorDna { 48 | pub prey_perception: f32, 49 | pub prey_pull: f32, 50 | } 51 | 52 | #[derive(Bundle)] 53 | pub struct BoidBundle { 54 | sprite_sheet_bundle: SpriteSheetBundle, 55 | boid: Boid, 56 | velocity: Velocity, 57 | acceleration: Acceleration, 58 | dna: Dna, 59 | health: Health, 60 | replicate_timer: ReplicateTimer, 61 | birth_ts: BirthTimeStamp, 62 | } 63 | 64 | #[derive(Bundle)] 65 | pub struct PredatorBundle { 66 | boid_bundle: BoidBundle, 67 | predator: Predator, 68 | predator_dna: PredatorDna, 69 | } 70 | 71 | impl Plugin for BoidPlugin { 72 | fn build(&self, app: &mut bevy::prelude::App) { 73 | app.add_systems( 74 | Update, 75 | ( 76 | update_boid_transform, 77 | boundary_boids_direction_update, 78 | (update_boid_color, update_predator_color), 79 | (update_boid_direction, update_predator_direction), 80 | (handle_boid_collision, handle_predator_collision), 81 | (boids_replicate, predators_replicate), 82 | (camera_follow_boid, camera_follow_predator), 83 | despawn_boids, 84 | boid_health_tick.run_if(on_timer(Duration::from_secs_f32(0.5))), 85 | handle_boid_despawn_events, 86 | boid_separation, 87 | ) 88 | .run_if(in_state(SimState::Simulating)), 89 | ) 90 | .add_event::(); 91 | } 92 | } 93 | 94 | fn boids_replicate( 95 | time: Res