21 Commits

Author SHA1 Message Date
975f2a0b92 Install libwayland-dev in CI workflow
Some checks failed
check / nightly / doc (push) Has been cancelled
check / ubuntu / stable / features (push) Has been cancelled
check / ubuntu / 1.88.0 (push) Has been cancelled
check / stable / fmt (push) Has been cancelled
Bevy 0.17 enables Wayland by default so the CI workflow needs to have it
installed.
2025-12-25 23:27:33 -06:00
3a3b8181f9 Upgrade to Bevy 0.17
Avian physics has been updated for Bevy 0.17 while bevy_spatial has not.
Now that the program is switched over, I can finally pull in the new
versions of everything.
2025-12-25 23:17:14 -06:00
62feb6f313 Fix: separation() system needs smaller view range
I figured out why the new flocking behavior is different...

The previous version used 1/4th the view range for boid separation and I
didn't preserve that.
2025-12-25 22:58:03 -06:00
9797b90415 Remove dependency on crate "bevy_spatial" 2025-12-24 16:04:25 -06:00
1846b7065e Convert flocking systems to use Avian2d 2025-12-24 16:02:46 -06:00
e252e3385c (autoformat) 2025-12-24 15:44:54 -06:00
5f0b428811 Debug scanner uses shape_intersections
That was really easy. I feel dumb for making a checkpoint commit before.
Oh well, onwards to the next step: Swap over the flocking systems.
2025-12-24 15:01:03 -06:00
119d7acf09 WIP: Debug tool scans using Avian2d shape casting
Working demonstration, but I'm not sure shape casting is the action I
want to be doing. I've just found a "shape_intersections" method in the
docs, but I'm saving my progress before making further changes.
2025-12-24 14:54:49 -06:00
63f15ae6a7 Update MSRV to 1.88.0
One or more of the transitive dependencies needs Rust 1.88 or newer, so
this becomes our minimum version. It may be possible to build with an
older Rust by manually picking dependency versions but I'm not going to
do that.

Ideally, `-Zminimal-versions` would pick out those versions, but lots of
crates don't correctly specify minimum depdendency versions. As a
result, our transitive dependencies resolve to
matching-but-non-functional versions.
2025-12-24 13:05:05 -06:00
ba01d8137f Mark v0.7.0
Mark new version, update the lockfile, and autoformat the project.
2025-12-22 11:07:56 -06:00
b8c28529e6 Remove the Player Boid
The player-controlled boid was for testing the flocking behavior. It has
outlived this purpose and I never even bothered to make it larger like
the others. It's finally going away completely.
2025-12-22 10:56:47 -06:00
fd161dc26b Wire in the magic physics vars as a Resource
I don't know what to call these, so they're "misc. parameters" for now.
2025-12-22 10:45:02 -06:00
c2cf100c05 Flocking params as a resource & egui-inspector
The birdoid flocking parameters are now a resource named
`FlockingParameters`. Adjustments can be made using the Egui inspector
widget, although I plan to make a custom UI in the future.
2025-12-21 16:33:04 -06:00
0828518963 Begin 0.7.0-dev, small upgrade to behavior params
I'm not happy with the flocking behavior so I fiddled with the program
to find these new behavior parameters. It's annoying to constantly
rebuild the program, though, and I'd like for the end-user to be able to
fiddle with it at runtime. To that end, v0.7.0 shall be the UI update.
2025-12-21 15:31:08 -06:00
5ef91ec88c Remove unused print_gizmo_config() function 2025-12-20 09:42:14 -06:00
8346527d05 Release v0.6.0
All checks were successful
check / stable / fmt (push) Successful in 20s
check / nightly / doc (push) Successful in 2m21s
check / ubuntu / stable / features (push) Successful in 2m25s
check / ubuntu / 1.87.0 (push) Successful in 2m19s
2025-11-15 15:15:01 -06:00
6329d000f5 Use compressed WASM for even smaller webroot size
All checks were successful
check / stable / fmt (push) Successful in 21s
check / nightly / doc (push) Successful in 2m24s
check / ubuntu / stable / features (push) Successful in 2m27s
check / ubuntu / 1.87.0 (push) Successful in 2m18s
2025-11-15 15:14:20 -06:00
46892ec32c Fix: Syntax errors in README.md
Some checks failed
check / nightly / doc (push) Has been cancelled
check / ubuntu / stable / features (push) Has been cancelled
check / ubuntu / 1.87.0 (push) Has been cancelled
check / stable / fmt (push) Has been cancelled
2025-11-08 11:28:43 -06:00
f6c6f26b8b Update CI, the MSRV is 1.87
Some checks failed
check / stable / fmt (push) Successful in 21s
check / nightly / doc (push) Successful in 2m19s
check / ubuntu / stable / features (push) Successful in 2m26s
check / ubuntu / 1.87.0 (push) Has been cancelled
I really should have fixed this forever ago and just didn't. Good thing
nobody uses this program and it doesn't matter :p
2025-11-08 11:19:15 -06:00
9f8598c794 Drop CI semver check, this isn't a library
Semver checks only make sense for things that have a public interface.
While an application technically does, these semver checks are for the
Rust API not the GUI shape or something. It's for checking that a
library upholds it's semver promise.

This is not a library, so these checks don't make sense.
2025-11-08 11:14:27 -06:00
b64771f3fc Drop CI cargo-clippy check, can't run on Gitea
The Reviewdog tool hits GitHub API endpoints that don't have an
equivalent on Gitea.
2025-11-08 11:11:15 -06:00
9 changed files with 2449 additions and 816 deletions

View File

@@ -34,44 +34,6 @@ jobs:
components: rustfmt components: rustfmt
- name: cargo fmt --check - name: cargo fmt --check
run: cargo fmt --check run: cargo fmt --check
clippy:
runs-on: ubuntu-latest
name: ${{ matrix.toolchain }} / clippy
permissions:
contents: read
checks: write
strategy:
fail-fast: false
matrix:
# Get early warning of new lints which are regularly introduced in beta channels.
toolchain: [stable, beta]
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install ${{ matrix.toolchain }}
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
components: clippy
- name: cargo clippy
uses: giraffate/clippy-action@v1
with:
reporter: 'github-pr-check'
github_token: ${{ secrets.GITHUB_TOKEN }}
semver:
runs-on: ubuntu-latest
name: semver
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: cargo-semver-checks
uses: obi1kenobi/cargo-semver-checks-action@v2
doc: doc:
# run docs generation on nightly rather than stable. This enables features like # run docs generation on nightly rather than stable. This enables features like
# https://doc.rust-lang.org/beta/unstable-book/language-features/doc-cfg.html which allows an # https://doc.rust-lang.org/beta/unstable-book/language-features/doc-cfg.html which allows an
@@ -83,7 +45,7 @@ jobs:
with: with:
submodules: true submodules: true
- name: Install Dependencies - name: Install Dependencies
run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev libwayland-dev
- name: Install nightly - name: Install nightly
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@nightly
- name: cargo doc - name: cargo doc
@@ -100,7 +62,7 @@ jobs:
with: with:
submodules: true submodules: true
- name: Install Dependencies - name: Install Dependencies
run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev cmake run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev libwayland-dev cmake
- name: Install stable - name: Install stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: cargo install cargo-hack - name: cargo install cargo-hack
@@ -116,14 +78,14 @@ jobs:
# https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability # https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability
strategy: strategy:
matrix: matrix:
msrv: ["1.79.0"] msrv: ["1.88.0"]
name: ubuntu / ${{ matrix.msrv }} name: ubuntu / ${{ matrix.msrv }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: true submodules: true
- name: Install Dependencies - name: Install Dependencies
run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev run: sudo apt-get update; sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev libwayland-dev
- name: Install ${{ matrix.msrv }} - name: Install ${{ matrix.msrv }}
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:

2773
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
[package] [package]
name = "another-boids-in-rust" name = "another-boids-in-rust"
version = "0.5.0" version = "0.7.0"
edition = "2024" edition = "2024"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
rust-version = "1.88.0"
[dependencies] [dependencies]
bevy = "0.16.0" avian2d = "0.4"
bevy = "0.17.0"
bevy-inspector-egui = "0.34"
# Grand-dependency pins # Grand-dependency pins
# ab_glyph = "0.2.16" # ab_glyph = "0.2.16"
@@ -17,16 +20,6 @@ bevy = "0.16.0"
# nonmax = "0.5.1" # nonmax = "0.5.1"
# rand = "0.8.0" # rand = "0.8.0"
# Use regular bevy_spatial on non-WASM builds
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
bevy_spatial = "0.11.0"
# Use bevy_spatial *without* the kdtree_rayon feature when building for WASM.
[target.'cfg(target_arch = "wasm32")'.dependencies.bevy_spatial]
version = "0.11.0"
default-features = false
features = ["kdtree"]
[profile.dev] [profile.dev]
opt-level = 1 opt-level = 1

View File

@@ -17,19 +17,19 @@ SRCS := $(wildcard $(SRC_DIR)/**)
# "Standalone" version # "Standalone" version
# (i.e., it includes an index.html so it can be placed on a server as-is) # (i.e., it includes an index.html so it can be placed on a server as-is)
web-standalone: out/boids.js out/boids_bg.wasm out/index.html web-standalone: out/boids.js out/boids_bg.wasm.gz out/index.html
# "Bundle-able" version. The host site must provide it's own HTML page. # "Bundle-able" version. The host site must provide it's own HTML page.
web: out/boids.js out/boids_bg.wasm out/boids.html web: out/boids.js out/boids_bg.wasm.gz out/boids.html
tarball: boids_web_root.tar tarball: boids_web_root.tar
tarball_standalone: boids_web_root_standalone.tar tarball_standalone: boids_web_root_standalone.tar
boids_web_root.tar: out/boids.js out/boids_bg.wasm out/boids.html boids_web_root.tar: out/boids.js out/boids_bg.wasm.gz out/boids.html
tar -caf $@ $^ tar -caf $@ $^
boids_web_root_standalone.tar: out/boids.js out/boids_bg.wasm out/index.html boids_web_root_standalone.tar: out/boids.js out/boids_bg.wasm.gz out/index.html
tar -caf $@ $^ tar -caf $@ $^
target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm: $(SRCS) Cargo.lock Cargo.toml target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm: $(SRCS) Cargo.lock Cargo.toml
@@ -40,8 +40,9 @@ out:
# Both the JS and WASM files are generated by the wasm-bindgen call, so both # Both the JS and WASM files are generated by the wasm-bindgen call, so both
# get to be on the target half of this recipe. # get to be on the target half of this recipe.
out/boids.js out/boids_bg.wasm &: target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm | out out/boids.js out/boids_bg.wasm.gz &: target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm | out
wasm-bindgen --no-typescript --target web --out-dir ./out/ --out-name boids target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm wasm-bindgen --no-typescript --target web --out-dir ./out/ --out-name boids target/$(CARGO_TARGET)/$(CARGO_PROFILE)/another-boids-in-rust.wasm
gzip -9 -f out/boids_bg.wasm
# Copies the index page to the output # Copies the index page to the output
out/index.html: www/index.html out/index.html: www/index.html
@@ -68,5 +69,5 @@ full-clean: clean
install: web install: web
install -dm0755 $(DESTDIR) install -dm0755 $(DESTDIR)
install -m0644 out/boids.js $(DESTDIR)/ install -m0644 out/boids.js $(DESTDIR)/
install -m0644 out/boids_bg.wasm $(DESTDIR)/ install -m0644 out/boids_bg.wasm.gz $(DESTDIR)/
install -m0644 out/boids.html $(DESTDIR)/ install -m0644 out/boids.html $(DESTDIR)/

View File

@@ -53,6 +53,7 @@ Basically, just throw in a hyperlink with `<a href="boids.html">Boids</a>`
``` ```
As mentioned in the option 2 description, I'm not using a JS Bundler. There is no "package.json" or anything to integrate properly with a JS framework. I plan to fix that at some point, but for now there are just a bunch of files to grab. As mentioned in the option 2 description, I'm not using a JS Bundler. There is no "package.json" or anything to integrate properly with a JS framework. I plan to fix that at some point, but for now there are just a bunch of files to grab.
--- ---
You can use any HTTP server you like. In the steps above, I'm using the Python3 built-in [http.server module](https://docs.python.org/3/library/http.server.html); which is **NOT** recommended for production use. Don't put that on the Internet! Alternatives include [Miniserve](https://crates.io/crates/miniserve) and [BusyBox](https://busybox.net/). The latter of which I'm using in the Docker image. You can use any HTTP server you like. In the steps above, I'm using the Python3 built-in [http.server module](https://docs.python.org/3/library/http.server.html); which is **NOT** recommended for production use. Don't put that on the Internet! Alternatives include [Miniserve](https://crates.io/crates/miniserve) and [BusyBox](https://busybox.net/). The latter of which I'm using in the Docker image.
@@ -73,6 +74,7 @@ You may also notice that the Dockerfile doesn't call on the Makefile. This is be
## Controls ## Controls
| Input | Effect | | Input | Effect |
|-|-|
| Mouse | The scanner circle is attached to the mouse cursor. Move it to scan boids within the radius. | | Mouse | The scanner circle is attached to the mouse cursor. Move it to scan boids within the radius. |
| Left mouse button | Put scanner into center-of-mass mode | | Left mouse button | Put scanner into center-of-mass mode |
| Right mouse button | Put scanner into average velocity mode | | Right mouse button | Put scanner into average velocity mode |

View File

@@ -1,62 +1,81 @@
pub mod physics; pub mod physics;
use avian2d::prelude::*;
use bevy::prelude::*; use bevy::prelude::*;
use bevy_spatial::{
AutomaticUpdate, SpatialAccess, SpatialStructure, TransformMode, kdtree::KDTree2,
};
use crate::birdoids::physics::{Force, Velocity, apply_velocity}; use crate::birdoids::physics::{Force, Velocity, apply_velocity};
use bevy_inspector_egui::{InspectorOptions, prelude::ReflectInspectorOptions};
const BACKGROUND_COLOR: Color = Color::srgb(0.4, 0.4, 0.4); const BACKGROUND_COLOR: Color = Color::srgb(0.4, 0.4, 0.4);
const PLAYERBOID_COLOR: Color = Color::srgb(1.0, 0.0, 0.0);
const TURN_FACTOR: f32 = 1.0;
const BOID_VIEW_RANGE: f32 = 15.0;
const COHESION_FACTOR: f32 = 1.0;
const SEPARATION_FACTOR: f32 = 10.0;
const ALIGNMENT_FACTOR: f32 = 1.0;
const SPACEBRAKES_COEFFICIENT: f32 = 0.5;
const LOW_SPEED_THRESHOLD: f32 = 50.0;
const HIGH_SPEED_THRESHOLD: f32 = 200.0;
pub struct BoidsPlugin; pub struct BoidsPlugin;
impl Plugin for BoidsPlugin { impl Plugin for BoidsPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_plugins( app.insert_resource(ClearColor(BACKGROUND_COLOR))
AutomaticUpdate::<TrackedByKdTree>::new() .insert_resource(FlockingParameters::new())
// .with_frequency(Duration::from_secs_f32(0.3)) .register_type::<FlockingParameters>()
.with_transform(TransformMode::GlobalTransform) .insert_resource(MiscParams::new())
.with_spatial_ds(SpatialStructure::KDTree2), .register_type::<MiscParams>()
) .add_systems(Startup, (spawn_camera, spawn_boids))
.insert_resource(ClearColor(BACKGROUND_COLOR)) .add_systems(
.add_systems(Startup, (spawn_camera, spawn_boids)) FixedUpdate,
.add_systems( (
FixedUpdate, apply_velocity,
( turn_if_edge,
apply_velocity, cohesion,
turn_if_edge, separation,
check_keyboard, alignment,
cohesion, speed_controller,
separation, ),
alignment, );
speed_controller, }
), }
);
#[derive(InspectorOptions, Reflect, Resource, Debug, Clone, Copy)]
#[reflect(Resource, InspectorOptions)]
pub(crate) struct FlockingParameters {
view_range: f32,
cohesion: f32,
separation: f32,
alignment: f32,
}
impl FlockingParameters {
pub(crate) fn new() -> Self {
Self {
view_range: 15.0,
cohesion: 1.0,
separation: 5.0,
alignment: 2.0,
}
}
}
#[derive(InspectorOptions, Reflect, Resource, Debug, Clone, Copy)]
#[reflect(Resource, InspectorOptions)]
pub(crate) struct MiscParams {
turn_factor: f32,
spacebrakes: f32,
minimum_speed: f32,
maximum_speed: f32,
}
impl MiscParams {
pub(crate) fn new() -> Self {
Self {
turn_factor: 1.0,
spacebrakes: 0.5,
minimum_speed: 50.0,
maximum_speed: 200.0,
}
} }
} }
#[derive(Component)] #[derive(Component)]
#[require(Velocity, Force, TrackedByKdTree)] #[require(Velocity, Force)]
pub(crate) struct Boid; pub(crate) struct Boid;
// It's a Boid, but with an extra component so the player
// can control it from the keyboard
#[derive(Component)]
struct PlayerBoid;
#[derive(Component, Default)]
pub struct TrackedByKdTree;
fn spawn_camera(mut commands: Commands) { fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d); commands.spawn(Camera2d);
} }
@@ -76,26 +95,21 @@ fn spawn_boids(
Mesh2d(meshes.add(Circle::new(1.0))), Mesh2d(meshes.add(Circle::new(1.0))),
MeshMaterial2d(materials.add(Color::srgb(1.0, 1.0, 1.0))), MeshMaterial2d(materials.add(Color::srgb(1.0, 1.0, 1.0))),
Transform::from_translation(vel * 20.0), Transform::from_translation(vel * 20.0),
// RigidBody::Dynamic,
Collider::circle(1.0),
)); ));
} }
commands.spawn((
Boid,
PlayerBoid,
Mesh2d(meshes.add(Triangle2d::default())),
MeshMaterial2d(materials.add(PLAYERBOID_COLOR)),
));
} }
/// Controls the boid's minimum and maximum speed according to a low- and /// Controls the boid's minimum and maximum speed according to a low- and
/// high-threshold. Boids moving too slow are sped up, and boids moving too /// high-threshold. Boids moving too slow are sped up, and boids moving too
/// fast are slowed down. /// fast are slowed down.
fn speed_controller(mut mobs: Query<(&Velocity, &mut Force), With<Boid>>) { fn speed_controller(mut mobs: Query<(&Velocity, &mut Force), With<Boid>>, params: Res<MiscParams>) {
for (vel, mut impulse) in &mut mobs { for (vel, mut impulse) in &mut mobs {
if vel.0.length() < LOW_SPEED_THRESHOLD { if vel.0.length() < params.minimum_speed {
impulse.0 += vel.0 * SPACEBRAKES_COEFFICIENT; impulse.0 += vel.0 * params.spacebrakes;
} else if vel.0.length() > HIGH_SPEED_THRESHOLD { } else if vel.0.length() > params.maximum_speed {
impulse.0 += -vel.0 * SPACEBRAKES_COEFFICIENT; impulse.0 += -vel.0 * params.spacebrakes;
} }
} }
} }
@@ -103,21 +117,22 @@ fn speed_controller(mut mobs: Query<(&Velocity, &mut Force), With<Boid>>) {
fn turn_if_edge( fn turn_if_edge(
mut query: Query<(&mut Transform, &mut Velocity), With<Boid>>, mut query: Query<(&mut Transform, &mut Velocity), With<Boid>>,
window: Query<&Window>, window: Query<&Window>,
params: Res<MiscParams>,
) { ) {
if let Ok(window) = window.single() { if let Ok(window) = window.single() {
let (width, height) = (window.resolution.width(), window.resolution.height()); let (width, height) = (window.resolution.width(), window.resolution.height());
for (transform, mut velocity) in &mut query { for (transform, mut velocity) in &mut query {
let boid_pos = transform.translation.xy(); let boid_pos = transform.translation.xy();
if boid_pos.x <= -width / 2. + 50. { if boid_pos.x <= -width / 2. + 50. {
velocity.x += TURN_FACTOR; velocity.x += params.turn_factor;
} else if boid_pos.x >= width / 2. - 50. { } else if boid_pos.x >= width / 2. - 50. {
velocity.x -= TURN_FACTOR; velocity.x -= params.turn_factor;
} }
if boid_pos.y <= -height / 2. + 50. { if boid_pos.y <= -height / 2. + 50. {
velocity.y += TURN_FACTOR; velocity.y += params.turn_factor;
} else if boid_pos.y >= height / 2. - 50. { } else if boid_pos.y >= height / 2. - 50. {
velocity.y -= TURN_FACTOR; velocity.y -= params.turn_factor;
} }
} }
} else { } else {
@@ -125,38 +140,12 @@ fn turn_if_edge(
} }
} }
fn check_keyboard(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut app_exit_events: ResMut<Events<bevy::app::AppExit>>,
mut query: Query<&mut Force, With<PlayerBoid>>,
) {
if keyboard_input.just_pressed(KeyCode::KeyQ) {
app_exit_events.send(bevy::app::AppExit::Success);
}
let mut impulse = query
.single_mut()
.expect("[birdoids_plugin::check_keyboard()] ->> There seems to be more than one player... How did that happen?");
let mut dir = Vec2::ZERO;
if keyboard_input.pressed(KeyCode::ArrowLeft) {
dir.x -= 1.0;
}
if keyboard_input.pressed(KeyCode::ArrowRight) {
dir.x += 1.0;
}
if keyboard_input.pressed(KeyCode::ArrowDown) {
dir.y -= 1.0;
}
if keyboard_input.pressed(KeyCode::ArrowUp) {
dir.y += 1.0;
}
**impulse += dir.extend(0.0) * 50.0;
}
fn cohesion( fn cohesion(
spatial_tree: Res<KDTree2<TrackedByKdTree>>, spatial: SpatialQuery,
// TODO: Ensure this is logically sound. I think it will fail the "disjoint queries" requirement.
boid_locations: Query<&Transform, With<Boid>>,
mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>, mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>,
props: Res<FlockingParameters>,
) { ) {
// for each boid // for each boid
// find neighbors // find neighbors
@@ -164,14 +153,23 @@ fn cohesion(
// find vector from boid to flock CoM // find vector from boid to flock CoM
// apply force // apply force
for (this_entt, transform, mut force) in &mut boids { for (this_entt, transform, mut force) in &mut boids {
let (len, sum) = spatial_tree let (len, sum) = spatial
.within_distance(transform.translation.xy(), BOID_VIEW_RANGE) .shape_intersections(
&Collider::circle(props.view_range),
transform.translation.xy(),
0.0,
&SpatialQueryFilter::default(),
)
.iter() .iter()
.filter_map(|(pos, entt)| { .filter_map(|&entt| {
// extract neighbor's position
// Skip self-comparison. A boid should not try to separate from itself. // Skip self-comparison. A boid should not try to separate from itself.
let entt = entt if this_entt == entt {
.expect("within_distance gave me an entity... with no entity ID... somehow"); None
if this_entt == entt { None } else { Some(pos) } } else {
let tsfm = boid_locations.get(entt).unwrap();
Some(tsfm.translation.xy())
}
}) })
.enumerate() .enumerate()
.fold((0, Vec2::ZERO), |(_len, com), (idx, pos)| (idx, com + pos)); .fold((0, Vec2::ZERO), |(_len, com), (idx, pos)| (idx, com + pos));
@@ -183,47 +181,56 @@ fn cohesion(
continue; continue;
}; };
let impulse = cohesive_force(center_of_mass, transform.translation.xy()).expect("damn"); let impulse = cohesive_force(center_of_mass, transform.translation.xy(), props.view_range)
.expect("damn");
force.0 -= impulse.0 * COHESION_FACTOR; force.0 -= impulse.0 * props.cohesion;
} }
} }
fn separation( fn separation(
spatial_tree: Res<KDTree2<TrackedByKdTree>>, spatial: SpatialQuery,
boid_locations: Query<&Transform, With<Boid>>,
mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>, mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>,
props: Res<FlockingParameters>,
) { ) {
// for each boid // for each boid
// find neighbors // find neighbors
// sum force from neighbors // sum force from neighbors
// apply force // apply force
for (this_entt, tsfm, mut force) in &mut boids { for (this_entt, tsfm, mut force) in &mut boids {
let impulse = spatial_tree let impulse = spatial
.within_distance(tsfm.translation.xy(), BOID_VIEW_RANGE / 4.0) .shape_intersections(
&Collider::circle(props.view_range / 4.0),
tsfm.translation.xy(),
0.0,
&SpatialQueryFilter::default(),
)
.iter() .iter()
.filter_map(|(pos, entt)| { .filter_map(|&entt| {
// Skip self-comparison. A boid should not try to separate from itself. // Skip self-comparison. A boid should not try to separate from itself.
let entt = entt
.expect("within_distance gave me an entity... with no entity ID... somehow");
if this_entt == entt { if this_entt == entt {
None None
} else { } else {
let pos = boid_locations.get(entt).unwrap().translation.xy();
Some(pos.extend(0.0)) Some(pos.extend(0.0))
} }
}) })
.fold(Vec3::ZERO, |acc, other| { .fold(Vec3::ZERO, |acc, other| {
// let force = tsfm.translation - other; // let force = tsfm.translation - other;
let force = separation_force(tsfm.translation.xy(), other.xy()).expect("angy"); let force = separation_force(tsfm.translation.xy(), other.xy(), props.view_range)
.expect("angy");
acc + force.0 acc + force.0
}); });
force.0 += impulse * SEPARATION_FACTOR; force.0 += impulse * props.separation;
} }
} }
fn alignment( fn alignment(
spatial_tree: Res<KDTree2<TrackedByKdTree>>, spatial: SpatialQuery,
mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>, mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>,
boid_velocities: Query<&Velocity, With<Boid>>, boid_velocities: Query<&Velocity, With<Boid>>,
props: Res<FlockingParameters>,
) { ) {
// for each boid // for each boid
// find neighbors // find neighbors
@@ -233,14 +240,16 @@ fn alignment(
// apply steering force // apply steering force
for (this_entt, transform, mut force) in &mut boids { for (this_entt, transform, mut force) in &mut boids {
let neighbors = spatial_tree.within_distance(transform.translation.xy(), BOID_VIEW_RANGE); let neighbors = spatial.shape_intersections(
&Collider::circle(props.view_range),
transform.translation.xy(),
0.0,
&SpatialQueryFilter::default(),
);
// averaging divides by length. Guard against an empty set of neighbors // averaging divides by length. Guard against an empty set of neighbors
let (len, sum) = neighbors let (len, sum) = neighbors
.iter() .iter()
// Extract the velocities by `get()`ing from another query param. .filter_map(|&entt| {
.filter_map(|(_pos, maybe_entt)| {
let entt = maybe_entt
.expect("Neighbor boid has no Entity ID. I don't know what this means");
if this_entt == entt { if this_entt == entt {
None None
} else { } else {
@@ -261,7 +270,7 @@ fn alignment(
}; };
let boid_vel = boid_velocities.get(this_entt).unwrap(); let boid_vel = boid_velocities.get(this_entt).unwrap();
force.0 += (avg.extend(0.0) - boid_vel.0) * ALIGNMENT_FACTOR; force.0 += (avg.extend(0.0) - boid_vel.0) * props.alignment;
} }
} }
@@ -287,13 +296,13 @@ fn average_of_vec2s(points: impl Iterator<Item = Vec2>) -> Option<Vec2> {
} }
// f(x) = 4((x-0.5)^3 + 0.125) // f(x) = 4((x-0.5)^3 + 0.125)
fn cohesive_force(boid: Vec2, target: Vec2) -> Option<Force> { fn cohesive_force(boid: Vec2, target: Vec2, view_range: f32) -> Option<Force> {
let deviation = target - boid; let deviation = target - boid;
/* /*
Scale deviation vector by the boid's view range. The curve is made to Scale deviation vector by the boid's view range. The curve is made to
operate on the range (0, 1), so that needs to be the viewing circle. operate on the range (0, 1), so that needs to be the viewing circle.
*/ */
let scaled = deviation / BOID_VIEW_RANGE; let scaled = deviation / view_range;
let mag = scaled.length(); let mag = scaled.length();
if mag > 0.0 { if mag > 0.0 {
let cube: f32 = (mag - 0.5).powf(3.0); let cube: f32 = (mag - 0.5).powf(3.0);
@@ -310,9 +319,9 @@ fn cohesive_force(boid: Vec2, target: Vec2) -> Option<Force> {
} }
// f(x) = x^2 - 1 // f(x) = x^2 - 1
fn separation_force(boid: Vec2, target: Vec2) -> Option<Force> { fn separation_force(boid: Vec2, target: Vec2, view_range: f32) -> Option<Force> {
// Scale from BOID_VIEW_RANGE to unit space // Scale from BOID_VIEW_RANGE to unit space
let distance_unit = (target - boid) / BOID_VIEW_RANGE; let distance_unit = (target - boid) / view_range;
let mag = distance_unit.length(); let mag = distance_unit.length();
if mag > 0.0 { if mag > 0.0 {
let force_mag = mag.powf(2.0) - 1.0; let force_mag = mag.powf(2.0) - 1.0;
@@ -329,14 +338,15 @@ mod tests {
use crate::birdoids::{cohesive_force, separation_force}; use crate::birdoids::{cohesive_force, separation_force};
use super::{BOID_VIEW_RANGE, physics::Force}; use super::{FlockingParameters, physics::Force};
// forces are relative to the boid's view range, so all // forces are relative to the boid's view range, so all
// distances need to be fractions of that // distances need to be fractions of that
#[test] #[test]
fn check_cohesion_zero_zero() { fn check_cohesion_zero_zero() {
let force = cohesive_force(Vec2::ZERO, Vec2::ZERO); let props = FlockingParameters::new();
let force = cohesive_force(Vec2::ZERO, Vec2::ZERO, props.view_range);
assert!(force.is_none()); assert!(force.is_none());
} }
@@ -347,36 +357,56 @@ mod tests {
#[test] #[test]
fn check_cohesion_midpoint_x_positive() { fn check_cohesion_midpoint_x_positive() {
// Pull right 0.5 units // Pull right 0.5 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.5, 0.0, 0.0))), Some(Force(Vec3::new(0.5, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.5 * BOID_VIEW_RANGE, 0.0),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.5 * props.view_range, 0.0),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_midpoint_x_negative() { fn check_cohesion_midpoint_x_negative() {
// Pull left 0.5 units // Pull left 0.5 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(-0.5, 0.0, 0.0))), Some(Force(Vec3::new(-0.5, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(-0.5 * BOID_VIEW_RANGE, 0.0),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(-0.5 * props.view_range, 0.0),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_edge_x_positive() { fn check_cohesion_edge_x_positive() {
// pull left 1.0 units // pull left 1.0 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(1.0, 0.0, 0.0))), Some(Force(Vec3::new(1.0, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(1.0 * BOID_VIEW_RANGE, 0.0),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(1.0 * props.view_range, 0.0),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_edge_x_negative() { fn check_cohesion_edge_x_negative() {
// pull left 1.0 units // pull left 1.0 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(-1.0, 0.0, 0.0))), Some(Force(Vec3::new(-1.0, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(-1.0 * BOID_VIEW_RANGE, 0.0),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(-1.0 * props.view_range, 0.0),
props.view_range
)
); );
} }
@@ -387,43 +417,64 @@ mod tests {
#[test] #[test]
fn check_cohesion_midpoint_y_positive() { fn check_cohesion_midpoint_y_positive() {
// Pull up 0.5 units // Pull up 0.5 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, 0.5, 0.0))), Some(Force(Vec3::new(0.0, 0.5, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.5 * BOID_VIEW_RANGE),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, 0.5 * props.view_range),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_midpoint_y_negative() { fn check_cohesion_midpoint_y_negative() {
// Pull down 0.5 units // Pull down 0.5 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, -0.5, 0.0))), Some(Force(Vec3::new(0.0, -0.5, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, -0.5 * BOID_VIEW_RANGE),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, -0.5 * props.view_range),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_edge_y_positive() { fn check_cohesion_edge_y_positive() {
// Pull up 1.0 units // Pull up 1.0 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, 1.0, 0.0))), Some(Force(Vec3::new(0.0, 1.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0 * BOID_VIEW_RANGE)) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, 1.0 * props.view_range),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_edge_y_negative() { fn check_cohesion_edge_y_negative() {
// pull down 0.2 units // pull down 0.2 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, -1.0, 0.0))), Some(Force(Vec3::new(0.0, -1.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, -1.0 * BOID_VIEW_RANGE),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, -1.0 * props.view_range),
props.view_range
)
); );
} }
// Separation 0,0 test // Separation 0,0 test
#[test] #[test]
fn check_separation_zero_zero() { fn check_separation_zero_zero() {
let force = separation_force(Vec2::ZERO, Vec2::ZERO); let props = FlockingParameters::new();
let force = separation_force(Vec2::ZERO, Vec2::ZERO, props.view_range);
assert!(force.is_none()); assert!(force.is_none());
} }
@@ -432,39 +483,53 @@ mod tests {
// ********************* // *********************
#[test] #[test]
fn check_separation_midpoint_x_positive() { fn check_separation_midpoint_x_positive() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.75, 0.0, 0.0))), // expected force Some(Force(Vec3::new(0.75, 0.0, 0.0))), // expected force
separation_force( separation_force(
Vec2::new(0.5 * BOID_VIEW_RANGE, 0.0), // boid position Vec2::new(0.5 * props.view_range, 0.0), // boid position
Vec2::ZERO, // obstacle position Vec2::ZERO, // obstacle position
props.view_range
) )
); );
} }
#[test] #[test]
fn check_separation_midpoint_x_negative() { fn check_separation_midpoint_x_negative() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(-0.75, 0.0, 0.0))), // expected force Some(Force(Vec3::new(-0.75, 0.0, 0.0))), // expected force
separation_force( separation_force(
Vec2::new(-0.5 * BOID_VIEW_RANGE, 0.0), // boid position Vec2::new(-0.5 * props.view_range, 0.0), // boid position
Vec2::ZERO, // obstacle position Vec2::ZERO, // obstacle position
props.view_range
) )
); );
} }
#[test] #[test]
fn check_separation_edge_x_positive() { fn check_separation_edge_x_positive() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::ZERO)), Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(1.0 * BOID_VIEW_RANGE, 0.0), Vec2::ZERO,), separation_force(
Vec2::new(1.0 * props.view_range, 0.0),
Vec2::ZERO,
props.view_range
),
); );
} }
#[test] #[test]
fn check_separation_edge_x_negative() { fn check_separation_edge_x_negative() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::ZERO)), Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(-1.0 * BOID_VIEW_RANGE, 0.0), Vec2::ZERO,), separation_force(
Vec2::new(-1.0 * props.view_range, 0.0),
Vec2::ZERO,
props.view_range
),
); );
} }
@@ -473,33 +538,53 @@ mod tests {
// ********************* // *********************
#[test] #[test]
fn check_separation_midpoint_y_positive() { fn check_separation_midpoint_y_positive() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, 0.75, 0.0))), Some(Force(Vec3::new(0.0, 0.75, 0.0))),
separation_force(Vec2::new(0.0, 0.5 * BOID_VIEW_RANGE), Vec2::ZERO,) separation_force(
Vec2::new(0.0, 0.5 * props.view_range),
Vec2::ZERO,
props.view_range
)
); );
} }
#[test] #[test]
fn check_separation_midpoint_y_negative() { fn check_separation_midpoint_y_negative() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, -0.75, 0.0))), Some(Force(Vec3::new(0.0, -0.75, 0.0))),
separation_force(Vec2::new(0.0, -0.5 * BOID_VIEW_RANGE), Vec2::ZERO,) separation_force(
Vec2::new(0.0, -0.5 * props.view_range),
Vec2::ZERO,
props.view_range
)
); );
} }
#[test] #[test]
fn check_separation_edge_y_positive() { fn check_separation_edge_y_positive() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::ZERO)), Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(0.0, 1.0 * BOID_VIEW_RANGE), Vec2::ZERO,) separation_force(
Vec2::new(0.0, 1.0 * props.view_range),
Vec2::ZERO,
props.view_range
)
) )
} }
#[test] #[test]
fn check_separation_edge_y_negative() { fn check_separation_edge_y_negative() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::ZERO)), Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(0.0, -1.0 * BOID_VIEW_RANGE), Vec2::ZERO,) separation_force(
Vec2::new(0.0, -1.0 * props.view_range),
Vec2::ZERO,
props.view_range
)
) )
} }
} }

View File

@@ -1,8 +1,8 @@
use avian2d::prelude::*;
use bevy::{prelude::*, window::PrimaryWindow}; use bevy::{prelude::*, window::PrimaryWindow};
use bevy_spatial::{SpatialAccess, kdtree::KDTree2};
use crate::birdoids::{ use crate::birdoids::{
Boid, TrackedByKdTree, center_of_boids, physics::Force, physics::Velocity, velocity_of_boids, Boid, center_of_boids, physics::Force, physics::Velocity, velocity_of_boids,
}; };
const SCANRADIUS: f32 = 50.0; const SCANRADIUS: f32 = 50.0;
@@ -106,15 +106,10 @@ fn update_scanner_mode(
} }
} }
fn print_gizmo_config(query: Query<(&SelectionMode, &ScannerMode), With<Cursor>>) {
let (select, scan) = query.single().unwrap();
println!("Selection: {select:?}, Scanning: {scan:?}");
}
fn do_scan( fn do_scan(
boids_query: Query<(&Transform, &Velocity, &Force), With<Boid>>, boids_query: Query<(&Transform, &Velocity, &Force), With<Boid>>,
scanner_query: Query<(&Transform, &SelectionMode, &ScannerMode), With<Cursor>>, scanner_query: Query<(&Transform, &SelectionMode, &ScannerMode), With<Cursor>>,
spatial_tree: Res<KDTree2<TrackedByKdTree>>, spatial: SpatialQuery,
/* Push info to summary somewhere */ /* Push info to summary somewhere */
mut gizmos: Gizmos, mut gizmos: Gizmos,
) { ) {
@@ -122,25 +117,35 @@ fn do_scan(
match select_mode { match select_mode {
SelectionMode::NearestSingle => todo!(), SelectionMode::NearestSingle => todo!(),
SelectionMode::CircularArea => { SelectionMode::CircularArea => {
let boids = spatial_tree.within_distance(cursor_pos.translation.xy(), SCANRADIUS); let boids = spatial
.shape_intersections(
&Collider::circle(SCANRADIUS),
cursor_pos.translation.xy(),
0.0,
&SpatialQueryFilter::default(),
)
.into_iter()
.map(|entt| {
let (tsfm, vel, _) = boids_query
.get(entt)
.expect("Debugger scanned a Boid missing one of it's components!");
(tsfm, vel)
});
match scan_mode { match scan_mode {
ScannerMode::CenterOfMass => { ScannerMode::CenterOfMass => {
if let Some(center_mass) = center_of_boids( if let Some(center_mass) = center_of_boids(
// boids returns too many things. // `center_of_boids` needs an Iterator<Item = Vec2>, so we map over
// Map over it and extract only the Vec3's // the output tuple to make one.
boids.iter().map(|item| item.0), boids.map(|item| item.0.translation.xy()),
) { ) {
gizmos.circle_2d(center_mass, 1.0, bevy::color::palettes::css::RED); gizmos.circle_2d(center_mass, 1.0, bevy::color::palettes::css::RED);
} }
} }
ScannerMode::Velocity => { ScannerMode::Velocity => {
if let Some(avg_velocity) = velocity_of_boids(boids.iter().map(|item| { if let Some(avg_velocity) =
let entity_id = item.1.unwrap_or_else(|| panic!("Entity has no ID!")); velocity_of_boids(boids.map(|item| (*item.1).xy() * 1.0))
let (_, vel, _) = boids_query {
.get(entity_id)
.unwrap_or_else(|_| panic!("Boid has no Velocity component!"));
(*vel).xy() * 1.0
})) {
// cursor_pos.translation is already in world space, so I can skip the window -> world transform like in update_cursor() // cursor_pos.translation is already in world space, so I can skip the window -> world transform like in update_cursor()
gizmos.line_2d( gizmos.line_2d(
cursor_pos.translation.xy(), cursor_pos.translation.xy(),

View File

@@ -1,11 +1,15 @@
use avian2d::prelude::*;
use bevy::prelude::*; use bevy::prelude::*;
mod birdoids; mod birdoids;
mod debug_plugin; mod debug_plugin;
use bevy_inspector_egui::{bevy_egui::EguiPlugin, quick::ResourceInspectorPlugin};
use birdoids::BoidsPlugin; use birdoids::BoidsPlugin;
use debug_plugin::BoidsDebugPlugin; use debug_plugin::BoidsDebugPlugin;
use crate::birdoids::{FlockingParameters, MiscParams};
fn main() { fn main() {
App::new() App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin { .add_plugins(DefaultPlugins.set(WindowPlugin {
@@ -18,5 +22,9 @@ fn main() {
})) }))
.add_plugins(BoidsDebugPlugin) .add_plugins(BoidsDebugPlugin)
.add_plugins(BoidsPlugin) .add_plugins(BoidsPlugin)
.add_plugins(EguiPlugin::default())
.add_plugins(ResourceInspectorPlugin::<FlockingParameters>::new()) // TODO: monitor only the flocking params resource (once it exists)
.add_plugins(ResourceInspectorPlugin::<MiscParams>::new())
.add_plugins(PhysicsPlugins::default())
.run(); .run();
} }

View File

@@ -104,7 +104,7 @@
<tr> <tr>
<td>Program Version</td> <td>Program Version</td>
<!-- This version text is completely unchecked. I'll need to do something about that. --> <!-- This version text is completely unchecked. I'll need to do something about that. -->
<td><code>v0.5.0</code></td> <td><code>v0.6.0</code></td>
</tr> </tr>
</table> </table>
</article> </article>
@@ -112,7 +112,11 @@
<script type="module"> <script type="module">
import init from './boids.js' import init from './boids.js'
init().catch((error) => { let compressed = await fetch("./boids_bg.wasm.gz")
let wasm_stream = compressed.body.pipeThrough(new DecompressionStream("gzip"))
let blob = await new Response(wasm_stream).blob();
init(await blob.arrayBuffer()).catch((error) => {
if (!error.message.startsWith("Using exceptions for control flow, don't mind me. This isn't actually an error!")) { if (!error.message.startsWith("Using exceptions for control flow, don't mind me. This isn't actually an error!")) {
throw error; throw error;
} }