21 Commits

Author SHA1 Message Date
0965760650 Bump to Bevy 0.18, mark -dev1 prerelease
Some checks failed
check / stable / fmt (push) Successful in 38s
check / nightly / doc (push) Successful in 2m51s
check / ubuntu / stable / features (push) Successful in 2m58s
check / ubuntu / 1.88.0 (push) Failing after 41s
Bevy, Avian2d, and bevy_egui have all been bumped to the latest
versions.

MSRV is now Rust 1.89.0 because that's the minimum for Bevy (including a
couple of its dependencies).
2026-01-22 16:47:09 -06:00
ac60482688 Release v0.9.0
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
Web builds now get their version text automatically from the Cargo.toml
2025-12-26 10:21:25 -06:00
75a675977a Fully remove old transitive dep pins 2025-12-26 10:18:21 -06:00
8f2ab97cf0 Improve comments on Makefile config vars
Updated comments explaining the purpose of the variables (to go with the
new "automatic vars" section).
2025-12-26 10:05:51 -06:00
e494fc6c35 Auto fill crate version in web page
The webpage has a placeholder string instead of a hard-coded version.
This gets replaced by the Makefile with the help of a pair of `sed`
calls.

And with that, I've upgraded my ad-hoc web bundler with an ad-hoc HTML
templating engine.
2025-12-26 10:01:39 -06:00
03c7d44aad Release v0.8.0
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
- Replaced "bevy_spatial" with "avian2d"
- Upgraded Bevy to 0.17

The bevy_spatial crate is still stuck on Bevy 0.16 and I'd like to move
forward and start playing with some of the new UI stuff. I've replaced
it with Avian physics, although I'm only using the spatial querying
facilities not the rest of the physics engine.
2025-12-25 23:47:14 -06:00
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
8 changed files with 2444 additions and 778 deletions

View File

@@ -45,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
@@ -62,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
@@ -78,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.87.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:

2757
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,14 @@
[package] [package]
name = "another-boids-in-rust" name = "another-boids-in-rust"
version = "0.6.0" version = "0.10.0-dev1"
edition = "2024" edition = "2024"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
rust-version = "1.89.0"
[dependencies] [dependencies]
bevy = "0.16.0" avian2d = "0.5"
bevy = "0.18"
# Grand-dependency pins bevy-inspector-egui = "0.36"
# ab_glyph = "0.2.16"
# fnv = "1.0.6"
# gilrs = "0.10.5"
# lazy_static = "1.0.2"
# lock_api = "0.4.7"
# miniz-sys = "0.1.10"
# nonmax = "0.5.1"
# 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

@@ -1,17 +1,28 @@
# This script produces a web build. If you aren't trying to do that, it is # This script produces a web build. If you aren't trying to do that, it is
# entirely useless to you. # entirely useless to you.
# Patch these to select a different build profile or target # # # Configuration Variables # # #
# The target shouldn't change any time soon. WASM64, I guess. Other targets # (because I don't have a ./configure script)
# aren't aimed at the web, so you shouldn't be using this makefile. #
# Patch these to select a different build profile or target.
# This Makefile will build a web bundle so using a non-wasm target is basically
# nonsense. Maybe WASM64, but I don't think that's useful (or stabilized yet).
CARGO_TARGET := wasm32-unknown-unknown CARGO_TARGET := wasm32-unknown-unknown
CARGO_PROFILE := wasm-release CARGO_PROFILE := wasm-release
# Override DESTDIR to set a custom install path (such as your web root) # Override DESTDIR to set a custom install path
# E.g.: your web root
# I use it via the Debian package build process (dpkg needs it)
DESTDIR ?= . DESTDIR ?= .
# # # Automatic Variables # # #
# (meaning you shouldn't modify them yourself)
#
# These are for automatically finding information or files so that they can be
# used somewhere in the build process.
SRC_DIR = ./src SRC_DIR = ./src
SRCS := $(wildcard $(SRC_DIR)/**) SRCS := $(wildcard $(SRC_DIR)/**)
CRATE_VERSION != sed -nre 's/^version = "(.*)"/\1/p' Cargo.toml
.PHONY: clean full-clean install tarball tarball-standalone web web-standalone .PHONY: clean full-clean install tarball tarball-standalone web web-standalone
@@ -48,11 +59,13 @@ out/boids.js out/boids_bg.wasm.gz &: target/$(CARGO_TARGET)/$(CARGO_PROFILE)/ano
out/index.html: www/index.html out/index.html: www/index.html
cp -a $< $@ cp -a $< $@
rm -f out/boids.html rm -f out/boids.html
sed -i -e "s/#CRATE_VERSION_PLACEHOLDER#/$(CRATE_VERSION)/" $@
# Like `out/index.html`, but renames it for use in a larger site. # Like `out/index.html`, but renames it for use in a larger site.
out/boids.html: www/index.html out/boids.html: www/index.html
cp -a $< $@ cp -a $< $@
rm -f out/index.html rm -f out/index.html
sed -i -e "s/#CRATE_VERSION_PLACEHOLDER#/$(CRATE_VERSION)/" $@
# Clean the web build, but not the Cargo cache. Cargo handles it's own caching # Clean the web build, but not the Cargo cache. Cargo handles it's own caching
# and I don't want to obliterate it all the time. # and I don't want to obliterate it all the time.

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

@@ -103,8 +103,7 @@
</tr> </tr>
<tr> <tr>
<td>Program Version</td> <td>Program Version</td>
<!-- This version text is completely unchecked. I'll need to do something about that. --> <td><code>#CRATE_VERSION_PLACEHOLDER#</code></td>
<td><code>v0.6.0</code></td>
</tr> </tr>
</table> </table>
</article> </article>