diff --git a/Cargo.toml b/Cargo.toml
index 5d99054..3b9b15e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,6 +7,7 @@ members = [
"flockers_mpi",
"forestfire",
"forestfire_bayesian",
+ "n_bodies",
"schelling",
"sir_ga_exploration",
"sugarscape",
diff --git a/README.md b/README.md
index 1314097..d3359ef 100644
--- a/README.md
+++ b/README.md
@@ -10,10 +10,11 @@ This repository hosts working examples of agent-based simulations, based on the
| [Flockers](flockers) | :heavy_check_mark: |
|
| [ForestFire](forestfire) | :heavy_check_mark: |
|
| [ForestFire_bayesian](forestfire_bayesian) | :heavy_check_mark: | :x: |
+| [N Bodies](n_bodies) | :heavy_check_mark: |
|
| [Schelling](schelling) | :heavy_check_mark: |
|
-| [Sir_bayesian](sir_bayesian) | :heavy_check_mark: | :x: |
-| [Sir_ga_exploration](sir_ga_exploration) | :heavy_check_mark: | :x: |
-| [Sugarscape](sugarscape) | :heavy_check_mark: |
|
+| [Sir_bayesian](sir_bayesian) | :heavy_check_mark: | :x: |
+| [Sir_ga_exploration](sir_ga_exploration) | :heavy_check_mark: | :x: |
+| [Sugarscape](sugarscape) | :heavy_check_mark: |
|
| [Template](template) | :heavy_check_mark: |
|
| [Virus on a Network](virusnetwork) | :heavy_check_mark: |
|
| [Wolf Sheep Grass](wolfsheepgrass) | :heavy_check_mark: |
|
diff --git a/n_bodies/.gitignore b/n_bodies/.gitignore
new file mode 100644
index 0000000..551b4e4
--- /dev/null
+++ b/n_bodies/.gitignore
@@ -0,0 +1,4 @@
+/target
+Cargo.lock
+.idea
+/output
\ No newline at end of file
diff --git a/n_bodies/Cargo.toml b/n_bodies/Cargo.toml
new file mode 100644
index 0000000..2b3725b
--- /dev/null
+++ b/n_bodies/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "n_bodies"
+version = "0.1.0"
+authors = [
+ "Jean Claude Coppola "
+]
+edition = "2021"
+license = "MIT"
+readme = "README.md"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+krabmaga = "0.6.*"
+bevy_egui = { version = "0.39", optional = true }
+
+[features]
+visualization = ["krabmaga/visualization", "bevy_egui"]
+visualization_wasm = ["krabmaga/visualization_wasm", "bevy_egui"]
\ No newline at end of file
diff --git a/n_bodies/Makefile.toml b/n_bodies/Makefile.toml
new file mode 100644
index 0000000..5cf219a
--- /dev/null
+++ b/n_bodies/Makefile.toml
@@ -0,0 +1,49 @@
+[env]
+ENV_DIR = {source="${CARGO_MAKE_PROFILE}", default_value = "debug", mapping = {release = "release"}}
+CARGO_TARGET_DIR = {value = "target", condition = {env_not_set = ["CARGO_TARGET_DIR"]}}
+CARGO_WASM_PATH = "${CARGO_TARGET_DIR}/wasm32-unknown-unknown/${ENV_DIR}/${CARGO_MAKE_CRATE_NAME}.wasm"
+CARGO_PROFILE = "dev"
+TARGET_DIR = "target"
+CARGO_MANIFEST_DIR = "${CARGO_MAKE_WORKING_DIRECTORY}"
+
+[env.release]
+CARGO_RELEASE_ARGS = "--release"
+
+[tasks.show-env]
+command = "env"
+
+[tasks.install-target-wasm32-unknown-unknown]
+args = ["target", "install", "wasm32-unknown-unknown"]
+
+command = "rustup"
+[tasks.basic-http-server]
+install_crate = {crate_name = "basic-http-server", binary = "basic-http-server", test_arg="--help"}
+
+[tasks.wasm-bindgen-cli]
+install_crate = {crate_name = "wasm-bindgen-cli", binary = "wasm-bindgen", test_arg="--help"}
+
+[tasks.cargo-build-web]
+args = ["build", "--target", "wasm32-unknown-unknown", "--features", "visualization_wasm", "@@split(CARGO_RELEASE_ARGS, )"]
+command = "cargo"
+dependencies = ["install-target-wasm32-unknown-unknown"]
+
+[tasks.build-web]
+args = ["--out-dir", "${TARGET_DIR}", "--out-name", "wasm", "--target", "web", "--no-typescript", "${CARGO_WASM_PATH}"]
+command = "wasm-bindgen"
+dependencies = ["cargo-build-web", "wasm-bindgen-cli"]
+
+[tasks.build-native]
+args = ["build", "--features", "visualization", "@@split(CARGO_RELEASE_ARGS, )"]
+command = "cargo"
+
+[tasks.run]
+command = "${CARGO_TARGET_DIR}/${ENV_DIR}/${CARGO_MAKE_BINARY_EXECUTABLE_NAME}"
+dependencies = ["build-native"]
+
+[tasks.serve]
+command = "basic-http-server"
+args = ["-x"]
+dependencies = ["build-web", "basic-http-server"]
+
+[tasks.test]
+disabled = true
\ No newline at end of file
diff --git a/n_bodies/README.md b/n_bodies/README.md
new file mode 100644
index 0000000..5676e4f
--- /dev/null
+++ b/n_bodies/README.md
@@ -0,0 +1,127 @@
+# N-Bodies
+
+A 2D gravitational N-body simulation written in Rust with the
+[krABMaga](https://github.com/krABMaga/krABMaga) agent-based modeling framework
+(Bevy-based visualization).
+[N-Bodies](https://www.netlogoweb.org/launch#http://ccl.northwestern.edu/netlogo/models/models/Sample%20Models/Chemistry%20&%20Physics/Mechanics/Unverified/N-Bodies.nlogox)
+model.
+
+
+
+
+
+## Features
+
+- Every body is a krABMaga **agent** attracted by all the others (Newtonian gravity, O(N²)).
+- **Trails**: the path of the first *N* bodies is drawn as fading polylines
+ (Bevy gizmos, no extra sprites), colored like the body they belong to.
+- **Control window** (egui) to change the parameters live or regenerate the system.
+- Four **initial configurations**: star with circular orbits, stable two-planet
+ system, random with zero momentum, symmetrical (zero-sum).
+- Optional **keep centered** mode that pins the center of mass to the middle of the view.
+- Negative `G` turns gravity into a repulsive force.
+
+## Requirements
+
+- Rust (stable) with `cargo`
+- The system libraries Bevy needs on your platform
+ (see the [Bevy setup guide](https://bevy.org/learn/quick-start/getting-started/setup/))
+
+## Run
+
+```bash
+# Visualization (window with the simulation and the menus)
+cargo run --release --features visualization
+
+# Headless (no window, runs 1000 steps)
+cargo run --release
+```
+Press **▶ Start** in the left panel to begin.
+
+`Cargo.toml` needs the egui dependency used by the menu:
+
+```toml
+[dependencies]
+krabmaga = "0.6"
+bevy_egui = { version = "0.39", optional = true }
+
+[features]
+visualization = ["krabmaga/visualization", "bevy_egui"]
+visualization_wasm = ["krabmaga/visualization_wasm", "bevy_egui"]
+```
+
+Use the same `bevy_egui` version as krABMaga (check with
+`cargo tree --features visualization -i bevy_egui`: only one version must appear).
+
+## How it works
+
+**Physics.** For each body *i* the acceleration is
+
+```
+a_i = Σ_j G · m_j · (r_j − r_i) / (|r_j − r_i|² + ε²)^(3/2)
+```
+
+where `ε` is the *softening* length, which avoids huge forces in close
+encounters. Integration uses the semi-implicit (symplectic) Euler method:
+`v ← v + a·dt`, then `x ← x + v·dt` with the new `v`.
+
+**Synchronous update.** All the physical data live in `NBodyState`
+(single source of truth). During a step each `Body` agent reads the snapshot
+`state.bodies` and writes its new data in `state.next`; in `after_step` the
+state copies `next` into `bodies`. Every body therefore sees the same instant,
+independently of the agents' execution order. The same hook recenters the
+system (if enabled) and records the trail points.
+
+**Rendering.** Sprites read their position and size from the state every
+frame. The planets are tinted with a per-body color (`body_color`), and the
+trail of a body uses the same color with a fading alpha.
+
+## Menu
+
+The default krABMaga panel (left) provides Start / Pause / Stop and the steps
+per second. **Stop** regenerates the system using the current parameters.
+The floating **N-Bodies** window adds:
+
+| Parameter | Effect | Applied |
+|---|---|---|
+| Configuration | Initial layout (see below) | Setup |
+| Number of bodies | Total number of bodies (2–300) | Setup |
+| Central mass | Mass of the star (or mass scale in random setups) | Setup |
+| Max planet mass | Upper bound of planet masses | Setup |
+| Initial speed | Maximum initial speed in random setups | Setup |
+| Bodies with trail | How many bodies get a trail | Setup |
+| G | Gravitational constant (negative = repulsive) | Live |
+| dt | Integration time step | Live |
+| Softening | Smoothing length ε of the force | Live |
+| Keep centered | Keep the center of mass in the middle of the view | Live |
+| Show trails / Trail length | Toggle and length of the trails | Live |
+
+The window also shows how many bodies are currently **outside the view**.
+After pressing **Setup** the simulation is paused: press ▶ Start again.
+
+### Configurations
+
+| Name | Description |
+|---|---|
+| Star + circular orbits | Heavy star at the center, planets on a spiral (golden angle) with circular-orbit speed |
+| Two planets (stable) | Star and two planets on opposite sides, at different radii |
+| Random (zero momentum) | Random positions, velocities and masses; net momentum removed so the system doesn't drift |
+| Symmetrical (zero-sum) | Half of the bodies are random, the other half are their mirror image (opposite position and velocity) |
+
+## Tips
+
+- Bodies that "escape" are usually the effect of close encounters: increase
+ *Softening*, decrease *dt*, or lower *Max planet mass*.
+- If nothing seems to move, check that the simulation is started and try a
+ larger `G` or `dt`.
+- The simulation area is `980 × 720`: Bevy's default window is 1280 × 720 and
+ krABMaga reserves 300 px on the left for its panel, so this keeps the scale
+ 1:1. If you resize the window the scene is stretched accordingly.
+- Sprites: `Emoji("sun")` and `Emoji("planet")` are loaded from
+ `assets/emojis/sun.png` and `assets/emojis/planet.png`. If you change the
+ image size, update `SPRITE_PX` in `visualization/body_vis.rs`.
+- The default values (`G = 100`, `dt = 0.05`, softening `5`) are defined in
+ `Params::default()` in `model/state.rs`.
+
+## References
+- http://ccl.northwestern.edu/netlogo/models/N-Bodies
diff --git a/n_bodies/assets/emojis/planet.png b/n_bodies/assets/emojis/planet.png
new file mode 100644
index 0000000..cf4e640
Binary files /dev/null and b/n_bodies/assets/emojis/planet.png differ
diff --git a/n_bodies/assets/emojis/sun.png b/n_bodies/assets/emojis/sun.png
new file mode 100644
index 0000000..ebefba6
Binary files /dev/null and b/n_bodies/assets/emojis/sun.png differ
diff --git a/n_bodies/assets/fonts/FiraSans-Bold.ttf b/n_bodies/assets/fonts/FiraSans-Bold.ttf
new file mode 100644
index 0000000..5c41e85
Binary files /dev/null and b/n_bodies/assets/fonts/FiraSans-Bold.ttf differ
diff --git a/n_bodies/index.html b/n_bodies/index.html
new file mode 100644
index 0000000..a99f749
--- /dev/null
+++ b/n_bodies/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/n_bodies/nbodies1.gif b/n_bodies/nbodies1.gif
new file mode 100644
index 0000000..d3c222b
Binary files /dev/null and b/n_bodies/nbodies1.gif differ
diff --git a/n_bodies/nbodies2.gif b/n_bodies/nbodies2.gif
new file mode 100644
index 0000000..a3115fb
Binary files /dev/null and b/n_bodies/nbodies2.gif differ
diff --git a/n_bodies/nbodies3.gif b/n_bodies/nbodies3.gif
new file mode 100644
index 0000000..9aa43ff
Binary files /dev/null and b/n_bodies/nbodies3.gif differ
diff --git a/n_bodies/src/main.rs b/n_bodies/src/main.rs
new file mode 100644
index 0000000..bd51505
--- /dev/null
+++ b/n_bodies/src/main.rs
@@ -0,0 +1,55 @@
+mod model;
+#[cfg(any(feature = "visualization", feature = "visualization_wasm"))]
+mod visualization;
+
+// Simulation area. The default window of Bevy is 1280x720 and krABMaga
+// reserves 300 px on the left for its panel, so (1280-300) x 720 gives a
+// 1:1 scale (circles stay circles).
+const DIM: (f32, f32) = (980.0, 720.0);
+
+#[cfg(not(any(feature = "visualization", feature = "visualization_wasm")))]
+use krabmaga::*;
+
+#[cfg(not(any(feature = "visualization", feature = "visualization_wasm")))]
+fn main() {
+ use crate::model::state::{NBodyState, SetupMode};
+
+ let mut state = NBodyState::new(DIM); // starts from Params::default()
+
+ // Change whatever you want here, like the menu does in the window...
+ state.p.mode = SetupMode::Random;
+ state.p.number = 100;
+ state.p.g = 100.0;
+ state.p.dt = 0.05;
+
+ // ...then regenerate the bodies: setup() reads the parameters above.
+ state.setup();
+
+ // simulate!(state, steps, repetitions)
+ simulate!(state, 1000, 1);
+}
+
+#[cfg(any(feature = "visualization", feature = "visualization_wasm"))]
+fn main() {
+ use bevy_egui::EguiPrimaryContextPass;
+ use krabmaga::bevy::prelude::{Color, Update};
+ use krabmaga::visualization::visualization::Visualization;
+
+ use crate::model::state::NBodyState;
+ use crate::visualization::{menu::menu_system, trails::draw_trails, vis_state::NBodyVis};
+
+ let state = NBodyState::new(DIM);
+
+ // `setup` (instead of `start`) returns the Bevy App, so we can add our own systems.
+ let mut app = Visualization::default()
+ .with_window_dimensions(1280., 720.)
+ .with_simulation_dimensions(DIM.0, DIM.1)
+ .with_background_color(Color::srgb(0.02, 0.02, 0.08))
+ .with_name("N-Bodies")
+ .setup::(NBodyVis, state);
+
+ app.add_systems(Update, draw_trails)
+ .add_systems(EguiPrimaryContextPass, menu_system);
+
+ app.run();
+}
diff --git a/n_bodies/src/model/body.rs b/n_bodies/src/model/body.rs
new file mode 100644
index 0000000..5f674d4
--- /dev/null
+++ b/n_bodies/src/model/body.rs
@@ -0,0 +1,67 @@
+use krabmaga::engine::agent::Agent;
+use krabmaga::engine::location::Real2D;
+use krabmaga::engine::state::State;
+
+use crate::model::state::NBodyState;
+
+//Physical Data of one body -> it lives in the state so that it can be modified and read by the menu and visualization
+#[derive(Clone, Copy)]
+pub struct BodyData {
+ pub mass: f32,
+ pub radius: f32,
+ pub pos: Real2D,
+ pub vel: Real2D,
+ pub is_star: bool,
+}
+
+/// The agent only knows its index: it reads the snapshot `state.bodies`
+/// (positions at the beginning of the step) and writes its new data into
+/// `state.next`. `NBodyState::after_step` then publishes `next` -> `bodies`,
+/// so every body sees the same instant (synchronous update).
+#[derive(Clone, Copy)]
+pub struct Body {
+ pub id: u32,
+}
+
+impl Agent for Body {
+ fn step(&mut self, state: &mut dyn State) {
+ let s = state
+ .as_any_mut()
+ .downcast_mut::()
+ .expect("state is not NBodyState");
+
+ let i = self.id as usize;
+ let me = s.bodies[i];
+ let eps2 = s.p.softening * s.p.softening;
+
+ // Acceleration = sum_j G * m_j * (r_j - r_i) / (|r|^2 + eps^2)^(3/2)
+ // (my own mass cancels out: F = m*a)
+ let mut ax = 0.0f32;
+ let mut ay = 0.0f32;
+
+ for (j, other) in s.bodies.iter().enumerate() {
+ if j == i {
+ continue;
+ }
+ let dx = other.pos.x - me.pos.x;
+ let dy = other.pos.y - me.pos.y;
+ let d2 = dx * dx + dy * dy + eps2;
+ if d2 < 1e-9 {
+ continue;
+ }
+ let k = s.p.g * other.mass / (d2 * d2.sqrt());
+ ax += k * dx;
+ ay += k * dy;
+ }
+
+ // Semi-implicit Euler: v first, then x with the NEW v.
+ let dt = s.p.dt;
+ let mut new = me;
+ new.vel.x += ax * dt;
+ new.vel.y += ay * dt;
+ new.pos.x += new.vel.x * dt;
+ new.pos.y += new.vel.y * dt;
+
+ s.next[i] = new;
+ }
+}
diff --git a/n_bodies/src/model/mod.rs b/n_bodies/src/model/mod.rs
new file mode 100644
index 0000000..cad0c32
--- /dev/null
+++ b/n_bodies/src/model/mod.rs
@@ -0,0 +1,2 @@
+pub mod body;
+pub mod state;
diff --git a/n_bodies/src/model/state.rs b/n_bodies/src/model/state.rs
new file mode 100644
index 0000000..b7f15c8
--- /dev/null
+++ b/n_bodies/src/model/state.rs
@@ -0,0 +1,330 @@
+use std::any::Any;
+use std::collections::VecDeque;
+use std::f32::consts::TAU;
+
+use krabmaga::engine::location::Real2D;
+use krabmaga::engine::schedule::Schedule;
+use krabmaga::engine::state::State;
+use krabmaga::rand;
+use krabmaga::Rng;
+
+use crate::model::body::{Body, BodyData};
+
+// to generate the angle and get the most irrational number
+const GOLDEN_ANGLE: f32 = 2.399_963;
+
+const STAR_RADIUS: f32 = 8.0;
+/// A trail point is recorded every TRAIL_EVERY steps.
+const TRAIL_EVERY: u64 = 2;
+
+/// Initial configurations
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum SetupMode {
+ SolarSystem,
+ TwoPlanets,
+ Random,
+ Symmetrical,
+}
+
+impl SetupMode {
+ pub const ALL: [SetupMode; 4] = [
+ SetupMode::SolarSystem,
+ SetupMode::TwoPlanets,
+ SetupMode::Random,
+ SetupMode::Symmetrical,
+ ];
+
+ pub fn label(&self) -> &'static str {
+ match self {
+ SetupMode::SolarSystem => "Star + circular orbits",
+ SetupMode::TwoPlanets => "Two planets (stable)",
+ SetupMode::Random => "Random (zero momentum)",
+ SetupMode::Symmetrical => "Symmetrical (zero-sum)",
+ }
+ }
+}
+
+// Everything the menu can change.
+// Fields marked "setup" are read only when the system is (re)generated,
+// the others act immediately.
+#[derive(Clone)]
+pub struct Params {
+ // --- setup ---
+ pub mode: SetupMode,
+ pub number: usize,
+ pub star_mass: f32,
+ pub max_planet_mass: f32,
+ pub init_speed: f32,
+ pub tracked: usize,
+ // --- live ---
+ pub g: f32,
+ pub dt: f32,
+ pub softening: f32,
+ pub keep_centered: bool,
+ pub show_trails: bool,
+ pub trail_len: usize,
+}
+
+impl Default for Params {
+ fn default() -> Self {
+ Params {
+ mode: SetupMode::SolarSystem,
+ number: 30,
+ star_mass: 1000.0,
+ max_planet_mass: 10.0,
+ init_speed: 20.0,
+ tracked: 8,
+ g: 100.0,
+ dt: 0.05,
+ softening: 5.0,
+ keep_centered: true,
+ show_trails: true,
+ trail_len: 200,
+ }
+ }
+}
+
+pub struct NBodyState {
+ pub p: Params,
+ pub dim: (f32, f32),
+ // Snapshot read by every agent during a step.
+ pub bodies: Vec,
+ // Buffer written by the agents during a step.
+ pub next: Vec,
+ // One trail per tracked body: (x, y) points, oldest first.
+ pub trails: Vec>,
+ // Index of the body that owns trails[0] (1 when body 0 is the star).
+ pub trail_first: usize,
+ pub ticks: u64,
+}
+
+impl NBodyState {
+ pub fn new(dim: (f32, f32)) -> Self {
+ let mut s = NBodyState {
+ p: Params::default(),
+ dim,
+ bodies: Vec::new(),
+ next: Vec::new(),
+ trails: Vec::new(),
+ trail_first: 0,
+ ticks: 0,
+ };
+ s.setup();
+ s
+ }
+
+ fn planet_radius(mass: f32) -> f32 {
+ 2.0 + 0.6 * mass.sqrt()
+ }
+
+ // (Re)generate all the bodies from the current `Params`.
+ pub fn setup(&mut self) {
+ let (w, h) = self.dim;
+ let (cx, cy) = (w / 2.0, h / 2.0);
+ let p = self.p.clone();
+ let mut rng = rand::rng();
+ let mut bodies: Vec = Vec::new();
+
+ let r_min = 40.0f32;
+ let r_max = (w.min(h) / 2.0 - 20.0).max(r_min + 10.0);
+ let eps2 = p.softening * p.softening;
+
+ // Speed of a circular orbit of radius r around the (softened) star.
+ let circular_speed = |r: f32| -> f32 {
+ (p.g * p.star_mass * r * r / (r * r + eps2).powf(1.5))
+ .max(0.0)
+ .sqrt()
+ };
+
+ match p.mode {
+ SetupMode::SolarSystem | SetupMode::TwoPlanets => {
+ bodies.push(BodyData {
+ mass: p.star_mass,
+ radius: STAR_RADIUS,
+ pos: Real2D { x: cx, y: cy },
+ vel: Real2D { x: 0.0, y: 0.0 },
+ is_star: true,
+ });
+
+ let two = p.mode == SetupMode::TwoPlanets;
+ let n_planets = if two { 2 } else { p.number.saturating_sub(1) };
+
+ for i in 0..n_planets {
+ let (r, a, m) = if two {
+ (
+ r_max * (0.45 + 0.4 * i as f32),
+ std::f32::consts::PI * i as f32,
+ p.max_planet_mass * (1.0 - 0.5 * i as f32),
+ )
+ } else {
+ let t = (i as f32 + 1.0) / (n_planets as f32 + 1.0);
+ (
+ r_min + (r_max - r_min) * t.sqrt(),
+ (i as f32 + 1.0) * GOLDEN_ANGLE,
+ rng.random_range(1.0..p.max_planet_mass.max(1.5)),
+ )
+ };
+ let v = circular_speed(r);
+ bodies.push(BodyData {
+ mass: m,
+ radius: Self::planet_radius(m),
+ pos: Real2D {
+ x: cx + r * a.cos(),
+ y: cy + r * a.sin(),
+ },
+ vel: Real2D {
+ x: -a.sin() * v,
+ y: a.cos() * v,
+ },
+ is_star: false,
+ });
+ }
+ }
+
+ SetupMode::Random | SetupMode::Symmetrical => {
+ let sym = p.mode == SetupMode::Symmetrical;
+ let n_gen = if sym {
+ (p.number / 2).max(1)
+ } else {
+ p.number.max(2)
+ };
+
+ for _ in 0..n_gen {
+ let r = r_max * rng.random_range(0.0f32..1.0).sqrt();
+ let a = rng.random_range(0.0f32..TAU);
+ let speed = p.init_speed * rng.random_range(0.0f32..1.0);
+ let sa = rng.random_range(0.0f32..TAU);
+ let m = rng.random_range(p.star_mass * 0.02..p.star_mass * 0.2);
+ bodies.push(BodyData {
+ mass: m,
+ radius: Self::planet_radius(m),
+ pos: Real2D {
+ x: cx + r * a.cos(),
+ y: cy + r * a.sin(),
+ },
+ vel: Real2D {
+ x: speed * sa.cos(),
+ y: speed * sa.sin(),
+ },
+ is_star: false,
+ });
+ }
+
+ if sym {
+ // Mirror every body through the centre: total momentum = 0
+ // and the centre of mass stays in the middle.
+ let half = bodies.clone();
+ for b in half {
+ bodies.push(BodyData {
+ pos: Real2D {
+ x: 2.0 * cx - b.pos.x,
+ y: 2.0 * cy - b.pos.y,
+ },
+ vel: Real2D {
+ x: -b.vel.x,
+ y: -b.vel.y,
+ },
+ ..b
+ });
+ }
+ } else {
+ // Remove the net momentum so the whole system doesn't drift.
+ let m_tot: f32 = bodies.iter().map(|b| b.mass).sum();
+ let px: f32 = bodies.iter().map(|b| b.mass * b.vel.x).sum::() / m_tot;
+ let py: f32 = bodies.iter().map(|b| b.mass * b.vel.y).sum::() / m_tot;
+ for b in bodies.iter_mut() {
+ b.vel.x -= px;
+ b.vel.y -= py;
+ }
+ }
+ }
+ }
+
+ let first = if bodies.first().map_or(false, |b| b.is_star) {
+ 1
+ } else {
+ 0
+ };
+ let tracked = p.tracked.min(bodies.len().saturating_sub(first));
+
+ self.trails = vec![VecDeque::new(); tracked];
+ self.trail_first = first;
+ self.next = bodies.clone();
+ self.bodies = bodies;
+ self.ticks = 0;
+ }
+
+ // keep-centered: move everything so the centre of mass
+ // stays in the middle of the view.
+ fn recenter(&mut self) {
+ let m_tot: f32 = self.bodies.iter().map(|b| b.mass).sum();
+ if m_tot.abs() < 1e-6 {
+ return;
+ }
+ let cx = self.bodies.iter().map(|b| b.mass * b.pos.x).sum::() / m_tot;
+ let cy = self.bodies.iter().map(|b| b.mass * b.pos.y).sum::() / m_tot;
+ let tx = self.dim.0 / 2.0 - cx;
+ let ty = self.dim.1 / 2.0 - cy;
+ for b in self.bodies.iter_mut() {
+ b.pos.x += tx;
+ b.pos.y += ty;
+ }
+ }
+
+ fn record_trails(&mut self) {
+ let first = self.trail_first;
+ let max = self.p.trail_len;
+ for (k, trail) in self.trails.iter_mut().enumerate() {
+ let b = &self.bodies[first + k];
+ trail.push_back((b.pos.x, b.pos.y));
+ while trail.len() > max {
+ trail.pop_front();
+ }
+ }
+ }
+}
+
+impl State for NBodyState {
+ // It Registers an agent Body {id} for each body
+ fn init(&mut self, schedule: &mut Schedule) {
+ for id in 0..self.bodies.len() {
+ schedule.schedule_repeating(Box::new(Body { id: id as u32 }), 0.0, 0);
+ }
+ }
+
+ fn update(&mut self, _step: u64) {}
+
+ // Called once per step, after ALL agents have stepped.
+ // we copy `next` into `bodies`
+ fn after_step(&mut self, _schedule: &mut Schedule) {
+ self.bodies.copy_from_slice(&self.next);
+ if self.p.keep_centered {
+ self.recenter();
+ }
+ self.ticks += 1;
+ if self.ticks % TRAIL_EVERY == 0 {
+ self.record_trails();
+ }
+ }
+
+ // Called by the "Stop" button of the default panel (and by my "Setup").
+ fn reset(&mut self) {
+ self.setup();
+ }
+
+ fn as_any(&self) -> &dyn Any {
+ self
+ }
+
+ fn as_any_mut(&mut self) -> &mut dyn Any {
+ self
+ }
+
+ fn as_state(&self) -> &(dyn State + 'static) {
+ self
+ }
+
+ fn as_state_mut(&mut self) -> &mut (dyn State + 'static) {
+ self
+ }
+}
diff --git a/n_bodies/src/visualization/body_vis.rs b/n_bodies/src/visualization/body_vis.rs
new file mode 100644
index 0000000..f12ef08
--- /dev/null
+++ b/n_bodies/src/visualization/body_vis.rs
@@ -0,0 +1,63 @@
+use krabmaga::bevy::prelude::{Transform, Vec3, Visibility};
+use krabmaga::engine::agent::Agent;
+use krabmaga::engine::state::State;
+use krabmaga::visualization::agent_render::{AgentRender, SpriteType};
+
+use crate::model::body::BodyData;
+use crate::model::state::NBodyState;
+
+/// Side (in pixels) of assets/emojis/sun.png and planet.png.
+const SPRITE_PX: f32 = 64.0;
+const OFFSCREEN: f32 = -100_000.0;
+
+fn data(state: &Box<&dyn State>, id: u32) -> Option {
+ let s = state.as_any().downcast_ref::()?;
+ s.bodies.get(id as usize).copied()
+}
+
+#[derive(Clone)]
+pub struct BodyVis {
+ pub id: u32,
+}
+
+impl AgentRender for BodyVis {
+ fn sprite(&self, _agent: &Box, state: &Box<&dyn State>) -> SpriteType {
+ let star = data(state, self.id).map_or(false, |d| d.is_star);
+ SpriteType::Emoji(String::from(if star { "sun" } else { "planet" }))
+ }
+
+ fn location(&self, _agent: &Box, state: &Box<&dyn State>) -> (f32, f32, f32) {
+ data(state, self.id)
+ .map(|d| (d.pos.x, d.pos.y, if d.is_star { 1.0 } else { 0.0 }))
+ .unwrap_or((OFFSCREEN, OFFSCREEN, 0.0))
+ }
+
+ fn scale(&self, _agent: &Box, state: &Box<&dyn State>) -> (f32, f32) {
+ let k = data(state, self.id).map_or(0.0, |d| 2.0 * d.radius / SPRITE_PX);
+ (k, k)
+ }
+
+ fn rotation(&self, _agent: &Box, _state: &Box<&dyn State>) -> f32 {
+ 0.0
+ }
+
+ fn get_id(&self) -> u32 {
+ self.id
+ }
+
+ fn update(
+ &mut self,
+ _agent: &Box,
+ transform: &mut Transform,
+ state: &Box<&dyn State>,
+ visible: &mut Visibility,
+ ) {
+ if let Some(d) = data(state, self.id) {
+ let k = 2.0 * d.radius / SPRITE_PX;
+ transform.translation.x = d.pos.x;
+ transform.translation.y = d.pos.y;
+ transform.scale = Vec3::new(k, k, 1.0);
+ *visible = Visibility::Visible;
+ }
+ }
+}
diff --git a/n_bodies/src/visualization/colors.rs b/n_bodies/src/visualization/colors.rs
new file mode 100644
index 0000000..a6473f4
--- /dev/null
+++ b/n_bodies/src/visualization/colors.rs
@@ -0,0 +1,6 @@
+use krabmaga::bevy::prelude::Color;
+
+// mol by 47 to have different colors especially for similar near ID
+pub fn body_color(id: u32, alpha: f32) -> Color {
+ Color::hsla((id as f32 * 47.0) % 360.0, 0.85, 0.65, alpha)
+}
diff --git a/n_bodies/src/visualization/menu.rs b/n_bodies/src/visualization/menu.rs
new file mode 100644
index 0000000..1608b4c
--- /dev/null
+++ b/n_bodies/src/visualization/menu.rs
@@ -0,0 +1,111 @@
+use bevy_egui::{egui, EguiContexts};
+use krabmaga::bevy::prelude::{Commands, Entity, Query, Res, ResMut, With};
+use krabmaga::engine::schedule::Schedule;
+use krabmaga::engine::state::State;
+use krabmaga::visualization::asset_handle_factory::AssetHandleFactoryResource;
+use krabmaga::visualization::simulation_descriptor::SimulationDescriptor;
+use krabmaga::visualization::visualization_state::VisualizationState;
+use krabmaga::visualization::wrappers::{ActiveSchedule, ActiveState, SimulationRenderEntity};
+
+use crate::model::state::{NBodyState, SetupMode};
+use crate::visualization::vis_state::NBodyVis;
+
+/// Floating control window (the default krABMaga panel on the left keeps
+/// Start / Pause / Stop and the "steps per second" slider).
+pub fn menu_system(
+ mut egui_context: EguiContexts,
+ mut sim_data: ResMut,
+ active_schedule: Res,
+ active_state: Res>,
+ on_init: Res,
+ mut sprite_factory: AssetHandleFactoryResource,
+ query: Query>,
+ mut commands: Commands,
+) {
+ let Ok(ctx) = egui_context.ctx_mut() else {
+ return;
+ };
+
+ let mut do_setup = false;
+
+ {
+ let mut s = active_state.0.lock().expect("error on lock");
+ let n = s.bodies.len();
+ let (w, h) = s.dim;
+ let outside = s
+ .bodies
+ .iter()
+ .filter(|b| b.pos.x < 0.0 || b.pos.x > w || b.pos.y < 0.0 || b.pos.y > h)
+ .count();
+ let p = &mut s.p;
+
+ egui::Window::new("N-Bodies")
+ .default_pos(egui::pos2(320.0, 12.0))
+ .show(ctx, |ui| {
+ ui.label(format!("Bodies: {n} outside the view: {outside}"));
+ ui.separator();
+
+ ui.strong("Initial conditions (apply with Setup)");
+ egui::ComboBox::from_label("Configuration")
+ .selected_text(p.mode.label())
+ .show_ui(ui, |ui| {
+ for m in SetupMode::ALL {
+ ui.selectable_value(&mut p.mode, m, m.label());
+ }
+ });
+ ui.add(egui::Slider::new(&mut p.number, 2..=300).text("Number of bodies"));
+ ui.add(egui::Slider::new(&mut p.star_mass, 10.0..=5000.0).text("Central mass"));
+ ui.add(
+ egui::Slider::new(&mut p.max_planet_mass, 1.0..=100.0).text("Max planet mass"),
+ );
+ ui.add(
+ egui::Slider::new(&mut p.init_speed, 0.0..=60.0).text("Initial speed (random)"),
+ );
+ ui.add(egui::Slider::new(&mut p.tracked, 0..=50).text("Bodies with trail"));
+
+ if ui.button("⟲ Setup").clicked() {
+ do_setup = true;
+ }
+ ui.label("then press ▶ Start in the left panel");
+
+ ui.separator();
+ ui.strong("Live parameters");
+ ui.add(
+ egui::Slider::new(&mut p.g, -300.0..=500.0).text("G (negative = repulsive)"),
+ );
+ ui.add(
+ egui::Slider::new(&mut p.dt, 0.005..=0.2)
+ .logarithmic(true)
+ .text("dt"),
+ );
+ ui.add(egui::Slider::new(&mut p.softening, 0.0..=30.0).text("Softening"));
+ ui.checkbox(&mut p.keep_centered, "Keep centered (centre of mass)");
+ ui.checkbox(&mut p.show_trails, "Show trails");
+ ui.add(egui::Slider::new(&mut p.trail_len, 0..=1000).text("Trail length"));
+ });
+ }
+
+ if do_setup {
+ // Same sequence used by the "Stop" button of krABMaga's default panel.
+ sim_data.paused = true;
+ for entity in query.iter() {
+ commands.entity(entity).despawn();
+ }
+
+ let mut new_schedule = Schedule::new();
+ {
+ let mut s = active_state.0.lock().expect("error on lock");
+ s.reset();
+ s.init(&mut new_schedule);
+ on_init.on_init(
+ &mut commands,
+ &mut sprite_factory,
+ &mut s,
+ &mut new_schedule,
+ &mut *sim_data,
+ );
+ on_init.setup_graphics(&mut new_schedule, &mut commands, &mut s, sprite_factory);
+ }
+ *active_schedule.0.lock().expect("error on lock") = new_schedule;
+ }
+}
diff --git a/n_bodies/src/visualization/mod.rs b/n_bodies/src/visualization/mod.rs
new file mode 100644
index 0000000..ae52f01
--- /dev/null
+++ b/n_bodies/src/visualization/mod.rs
@@ -0,0 +1,5 @@
+pub mod body_vis;
+pub mod colors;
+pub mod menu;
+pub mod trails;
+pub mod vis_state;
diff --git a/n_bodies/src/visualization/trails.rs b/n_bodies/src/visualization/trails.rs
new file mode 100644
index 0000000..b199649
--- /dev/null
+++ b/n_bodies/src/visualization/trails.rs
@@ -0,0 +1,33 @@
+use krabmaga::bevy::prelude::{Gizmos, Res, Vec2};
+use krabmaga::visualization::wrappers::ActiveState;
+
+use crate::model::state::NBodyState;
+use crate::visualization::colors::body_color;
+
+// Bevy system (runs every frame): draws every trail as a polyline
+// whose segments fade from transparent (oldest) to opaque (newest).
+pub fn draw_trails(mut gizmos: Gizmos, state: Res>) {
+ let s = state.0.lock().expect("error on lock");
+ if !s.p.show_trails {
+ return;
+ }
+
+ for (k, trail) in s.trails.iter().enumerate() {
+ let n = trail.len();
+ if n < 2 {
+ continue;
+ }
+ let id = (s.trail_first + k) as u32;
+
+ let mut prev: Option = None;
+ for (i, &(x, y)) in trail.iter().enumerate() {
+ // +0.5: the framework draws sprites half a unit higher
+ let point = Vec2::new(x, y + 0.5);
+ if let Some(q) = prev {
+ let alpha = 0.05 + 0.85 * (i as f32 / n as f32);
+ gizmos.line_2d(q, point, body_color(id, alpha));
+ }
+ prev = Some(point);
+ }
+ }
+}
diff --git a/n_bodies/src/visualization/vis_state.rs b/n_bodies/src/visualization/vis_state.rs
new file mode 100644
index 0000000..c0bd8f9
--- /dev/null
+++ b/n_bodies/src/visualization/vis_state.rs
@@ -0,0 +1,88 @@
+use krabmaga::bevy::ecs as bevy_ecs;
+use krabmaga::bevy::prelude::{Commands, Quat, Resource, Sprite, Transform, Vec3};
+use krabmaga::engine::agent::Agent;
+use krabmaga::engine::schedule::Schedule;
+use krabmaga::engine::state::State;
+use krabmaga::visualization::agent_render::{AgentRender, AgentRenderComponent};
+use krabmaga::visualization::asset_handle_factory::AssetHandleFactoryResource;
+use krabmaga::visualization::simulation_descriptor::SimulationDescriptor;
+use krabmaga::visualization::visualization_state::VisualizationState;
+use krabmaga::visualization::wrappers::SimulationRenderEntity;
+
+use crate::model::body::Body;
+use crate::model::state::NBodyState;
+use crate::visualization::body_vis::BodyVis;
+use crate::visualization::colors::body_color;
+
+#[derive(Clone, Resource)]
+pub struct NBodyVis;
+
+impl VisualizationState for NBodyVis {
+ fn on_init(
+ &self,
+ _commands: &mut Commands,
+ _factory: &mut AssetHandleFactoryResource,
+ _state: &mut NBodyState,
+ _schedule: &mut Schedule,
+ _sim: &mut SimulationDescriptor,
+ ) {
+ }
+
+ fn get_agent_render(
+ &self,
+ agent: &Box,
+ _state: &NBodyState,
+ ) -> Option> {
+ let b = agent.downcast_ref::()?;
+ Some(Box::new(BodyVis { id: b.id }))
+ }
+
+ fn get_agent(
+ &self,
+ agent_render: &dyn AgentRender,
+ state: &Box<&dyn State>,
+ ) -> Option> {
+ let s = state.as_any().downcast_ref::()?;
+ let id = agent_render.get_id();
+ if (id as usize) < s.bodies.len() {
+ Some(Box::new(Body { id }))
+ } else {
+ None
+ }
+ }
+
+ // Same as the default implementation of the trait, but every planet
+ // sprite is tinted with the colour of its trail.
+ fn setup_agent_graphics(
+ &self,
+ agent: &Box,
+ agent_render: Box,
+ mut sprite: Sprite,
+ commands: &mut Commands,
+ state: &Box<&dyn State>,
+ ) {
+ let id = agent_render.get_id();
+ let is_star = state
+ .as_any()
+ .downcast_ref::()
+ .and_then(|s| s.bodies.get(id as usize).map(|b| b.is_star))
+ .unwrap_or(false);
+ if !is_star {
+ sprite.color = body_color(id, 1.0);
+ }
+
+ let (x, y, z) = agent_render.location(agent, state);
+ let (sx, sy) = agent_render.scale(agent, state);
+ let rotation = agent_render.rotation(agent, state);
+
+ let mut transform = Transform::from_translation(Vec3::new(x, y + 0.5, z));
+ transform.scale.x = sx;
+ transform.scale.y = sy;
+ transform.rotation = Quat::from_rotation_z(rotation);
+
+ commands
+ .spawn((sprite, transform))
+ .insert(AgentRenderComponent(agent_render))
+ .insert(SimulationRenderEntity);
+ }
+}