Cargo.lock
+47
-6
diff --git a/Cargo.lock b/Cargo.lock
index 58c2949..b55df38 100644
@@ -1030,7 +1030,7 @@ dependencies = [
"glam",
"itertools 0.14.0",
"libm",
"rand",
"rand 0.9.4",
"rand_distr",
"serde",
"thiserror 2.0.18",
@@ -1751,6 +1751,8 @@ name = "bunny-herding"
version = "0.1.0"
dependencies = [
"bevy",
"rand 0.10.1",
"rand_chacha 0.10.0",
]
[[package]]
@@ -1857,6 +1859,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core 0.10.1",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2568,6 +2581,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
@@ -2626,7 +2640,7 @@ dependencies = [
"bytemuck",
"encase",
"libm",
"rand",
"rand 0.9.4",
"serde_core",
]
@@ -4028,8 +4042,19 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
[[package]]
@@ -4039,7 +4064,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
dependencies = [
"ppv-lite86",
"rand_core 0.10.1",
]
[[package]]
@@ -4052,13 +4087,19 @@ dependencies = [
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
dependencies = [
"num-traits",
"rand",
"rand 0.9.4",
]
[[package]]
Cargo.toml
+3
-1
diff --git a/Cargo.toml b/Cargo.toml
index 221019a..8fdd36b 100644
@@ -4,7 +4,9 @@ version = "0.1.0"
edition = "2024"
[dependencies]
bevy = "0.18.1"
bevy = { version = "0.18.1", features = ["debug"] }
rand = "0.10.1"
rand_chacha = "0.10.0"
[profile.dev]
opt-level = 1
docs/screenshot.png
+0
-0
diff --git a/docs/screenshot.png b/docs/screenshot.png
index 8613deb..f2de93c 100644
Binary files a/docs/screenshot.png and b/docs/screenshot.png differ
src/bunnies/bunny.rs
+291
-0
diff --git a/src/bunnies/bunny.rs b/src/bunnies/bunny.rs
new file mode 100644
index 0000000..7dd8748
@@ -0,0 +1,291 @@
use std::{ops::DerefMut, time::Duration};
use bevy::{
app::{App, Startup, Update},
asset::{AssetServer, Assets},
ecs::{
component::Component,
entity::Entity,
query::{With, Without},
system::{Commands, Query, Res, ResMut, Single},
},
gltf::GltfAssetLabel,
math::{Dir3, Vec3, primitives::Cuboid},
mesh::{Mesh, Mesh3d, MeshBuilder, Meshable},
scene::SceneRoot,
time::Time,
transform::components::Transform,
};
use rand::RngExt;
use crate::{Rng, bunnies::locator::Locator, dog::Dog, obstacles::Obstacles};
pub(super) fn add_bunny_systems(app: &mut App) -> &mut App {
app.add_systems(Startup, setup)
.add_systems(Update, (calculate, jump))
}
#[derive(Component)]
pub(super) struct Bunny;
#[derive(Component)]
enum JumpState {
Jumping { from: Vec3, to: Vec3 },
Cooldown { ready: Duration },
}
const BUNNY_COUNT: usize = 5;
const DETECTION_DISTANCE: f32 = 5.0;
const SHORT_JUMP_DISTANCE: f32 = 1.0;
const LONG_JUMP_DISTANCE: f32 = 3.0;
const LONE_JUMPS_PER_SECOND: f64 = 1.0;
const COLLECTIVE_JUMPS_PER_SECOND: f64 = 1.0;
const JUMP_HEIGHT: f32 = 1.0;
const JUMP_COOLDOWN: f32 = 0.05;
const DESIRED_SPEED: f32 = 10.0;
const SPEED: f32 = LONG_JUMP_DISTANCE / (LONG_JUMP_DISTANCE / DESIRED_SPEED - JUMP_COOLDOWN);
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut meshes: ResMut<Assets<Mesh>>,
mut rng: ResMut<Rng>,
) {
let model =
asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/cube-pets/animal-bunny.glb"));
let mesh = meshes.add(Cuboid::from_length(2.0).mesh().build());
for _ in 0..BUNNY_COUNT {
commands.spawn((
SceneRoot(model.clone()),
Transform::from_xyz(
rng.random_range(-15.0..=15.0),
0.0,
rng.random_range(-15.0..=15.0),
),
Mesh3d(mesh.clone()),
Bunny,
));
}
}
type IdleBunniesQuery<'w, 's, 't> =
Query<'w, 's, (Entity, &'t mut Transform), (With<Bunny>, Without<JumpState>)>;
fn calculate(
mut commands: Commands,
time: Res<Time>,
locator: Res<Locator>,
dog: Single<&Transform, (With<Dog>, Without<Bunny>)>,
mut bunnies: IdleBunniesQuery,
mut obstacles: Obstacles,
mut rng: ResMut<Rng>,
) {
bunnies.iter_mut().for_each(|(bunny, mut bunny_transform)| {
calculate_next_move(
&mut commands,
&time,
&locator,
&dog,
&mut bunny_transform,
bunny,
&mut obstacles,
&mut rng,
);
});
}
fn calculate_next_move(
commands: &mut Commands,
time: &Time,
locator: &Locator,
dog: &Transform,
bunny_transform: &mut Transform,
bunny: Entity,
obstacles: &mut Obstacles,
rng: &mut Rng,
) {
if let Some(direction) = is_dog_nearby(dog, bunny_transform) {
jump_away_from_dog(commands, bunny_transform, bunny, obstacles, direction);
} else if let Some(nearby_bunnies) = is_near_others(locator, bunny, bunny_transform) {
jump_with_others(
commands,
time,
obstacles,
bunny,
bunny_transform,
nearby_bunnies,
rng,
);
} else {
jump_alone(commands, time, obstacles, bunny, bunny_transform, rng);
}
}
fn is_dog_nearby(dog: &Transform, bunny_transform: &mut Transform) -> Option<Vec3> {
let direction = bunny_transform.translation - dog.translation;
if direction.length_squared() > DETECTION_DISTANCE * DETECTION_DISTANCE {
return None;
}
Some(direction)
}
fn jump_away_from_dog(
commands: &mut Commands,
bunny_transform: &mut Transform,
bunny: Entity,
obstacles: &mut Obstacles,
direction: Vec3,
) {
let dog_direction = direction.with_y(0.0).normalize();
let Some(to) = obstacles.avoid(
bunny_transform.translation,
bunny_transform.translation + dog_direction * LONG_JUMP_DISTANCE,
) else {
return;
};
start_jump(commands, bunny, bunny_transform, to);
}
fn is_near_others(
locator: &Locator,
bunny: Entity,
bunny_transform: &Transform,
) -> Option<Vec<(Entity, (Vec3, Dir3))>> {
let nearby_bunnies = locator.get_nearby(bunny, bunny_transform.translation, DETECTION_DISTANCE);
if nearby_bunnies.is_empty() {
None
} else {
Some(nearby_bunnies)
}
}
fn jump_with_others(
commands: &mut Commands,
time: &Time,
obstacles: &mut Obstacles,
bunny: Entity,
bunny_transform: &mut Transform,
nearby_bunnies: Vec<(Entity, (Vec3, Dir3))>,
rng: &mut Rng,
) {
if !rng.random_bool((COLLECTIVE_JUMPS_PER_SECOND * time.delta_secs_f64()).clamp(0.0, 1.0)) {
return;
}
let average_direction = calculate_average_direction(nearby_bunnies);
let Some(to) = obstacles.avoid(
bunny_transform.translation,
bunny_transform.translation + average_direction * SHORT_JUMP_DISTANCE,
) else {
return;
};
start_jump(commands, bunny, bunny_transform, to);
fn calculate_average_direction(nearby_bunnies: Vec<(Entity, (Vec3, Dir3))>) -> Vec3 {
let (sin, cos) = nearby_bunnies
.iter()
.skip(1)
.fold((0.0, 0.0), |a, (_, (_, d))| {
let angle = d.angle_between(Vec3::Z);
(a.0 + angle.sin(), a.1 + angle.cos())
});
let average_angle = sin.atan2(cos);
let average_direction = Dir3::Z.rotate_y(average_angle);
average_direction
}
}
fn jump_alone(
commands: &mut Commands,
time: &Time,
obstacles: &mut Obstacles,
bunny: Entity,
bunny_transform: &mut Transform,
rng: &mut Rng,
) {
if !rng.random_bool((LONE_JUMPS_PER_SECOND * time.delta_secs_f64()).clamp(0.0, 1.0)) {
return;
}
let angle = rng.random_range(-std::f32::consts::PI..=std::f32::consts::PI);
let direction = Dir3::Z.rotate_y(angle);
let Some(to) = obstacles.avoid(
bunny_transform.translation,
bunny_transform.translation + direction * LONG_JUMP_DISTANCE,
) else {
return;
};
start_jump(commands, bunny, bunny_transform, to);
}
fn start_jump(commands: &mut Commands, bunny: Entity, bunny_transform: &mut Transform, to: Vec3) {
commands.entity(bunny).insert(JumpState::Jumping {
from: bunny_transform.translation,
to,
});
bunny_transform.look_to(-(to - bunny_transform.translation), Vec3::Y);
}
fn jump(
mut commands: Commands,
time: Res<Time>,
mut bunnies: Query<(Entity, &mut Transform, &mut JumpState), With<Bunny>>,
) {
bunnies.iter_mut().for_each(
|(bunny, mut bunny_transform, ref mut jump_state)| match jump_state.deref_mut() {
JumpState::Jumping { from, to } => {
let from = *from;
let to = *to;
animate_jumping(
&time,
&mut bunny_transform,
jump_state.deref_mut(),
from,
to,
)
}
JumpState::Cooldown { ready } => {
let ready = *ready;
cooldown(&mut commands, &time, bunny, ready);
}
},
);
}
fn animate_jumping(
time: &Time,
bunny_transform: &mut Transform,
jump_state: &mut JumpState,
from: Vec3,
to: Vec3,
) {
let current = bunny_transform.translation.with_y(to.y);
let direction = to - current;
let delta = SPEED * time.delta_secs();
let (direction, length) = direction.normalize_and_length();
if length <= delta {
bunny_transform.translation = to;
*jump_state = JumpState::Cooldown {
ready: time.elapsed() + Duration::from_secs_f32(JUMP_COOLDOWN),
};
return;
}
let next = current + direction.normalize() * delta;
let total_distance = from.distance(to);
bunny_transform.translation = next
+ Vec3::Y
* JUMP_HEIGHT
* (std::f32::consts::PI * from.distance(next) / total_distance).sin();
}
fn cooldown(commands: &mut Commands, time: &Time, bunny: Entity, ready: Duration) {
if time.elapsed() >= ready {
commands.entity(bunny).remove::<JumpState>();
}
}
src/bunnies/locator.rs
+50
-0
diff --git a/src/bunnies/locator.rs b/src/bunnies/locator.rs
new file mode 100644
index 0000000..4fea78a
@@ -0,0 +1,50 @@
use std::collections::HashMap;
use bevy::{
app::{App, PreUpdate},
ecs::{
entity::Entity,
query::With,
resource::Resource,
system::{Query, ResMut},
},
math::{Dir3, Vec3},
transform::components::Transform,
};
use crate::bunnies::bunny::Bunny;
pub fn add_locator(app: &mut App) -> &mut App {
app.init_resource::<Locator>()
.add_systems(PreUpdate, update_locator)
}
fn update_locator(mut locator: ResMut<Locator>, bunnies: Query<(Entity, &Transform), With<Bunny>>) {
bunnies
.iter()
.for_each(|(bunny, transform)| locator.set(bunny, transform.translation, transform.back()));
}
#[derive(Resource, Default)]
pub(super) struct Locator {
bunnies: HashMap<Entity, (Vec3, Dir3)>,
}
impl Locator {
fn set(&mut self, bunny: Entity, position: Vec3, direction: Dir3) {
self.bunnies.insert(bunny, (position, direction));
}
pub(super) fn get_nearby(
&self,
this: Entity,
center: Vec3,
radius: f32,
) -> Vec<(Entity, (Vec3, Dir3))> {
self.bunnies
.iter()
.filter(|(e, (pos, _))| **e != this && center.distance(*pos) <= radius)
.map(|(b, (p, d))| (*b, (*p, *d)))
.collect::<Vec<_>>()
}
}
src/bunnies/mod.rs
+17
-0
diff --git a/src/bunnies/mod.rs b/src/bunnies/mod.rs
new file mode 100644
index 0000000..836cb62
@@ -0,0 +1,17 @@
mod bunny;
mod locator;
use bevy::app::App;
use crate::bunnies::{bunny::add_bunny_systems, locator::add_locator};
pub trait BunnySystems {
fn add_bunny_systems(&mut self) -> &mut Self;
}
impl BunnySystems for App {
fn add_bunny_systems(&mut self) -> &mut Self {
add_bunny_systems(self);
add_locator(self)
}
}
src/bunny.rs
+0
-174
diff --git a/src/bunny.rs b/src/bunny.rs
deleted file mode 100644
index cd1e2d4..0000000
@@ -1,174 +0,0 @@
use std::{ops::DerefMut, time::Duration};
use bevy::{
app::{App, Startup, Update},
asset::{AssetServer, Assets},
ecs::{
component::Component,
entity::Entity,
query::{With, Without},
system::{Commands, Query, Res, ResMut, Single},
},
gltf::GltfAssetLabel,
math::{Vec3, primitives::Cuboid},
mesh::{Mesh, Mesh3d, MeshBuilder, Meshable},
scene::SceneRoot,
time::Time,
transform::components::Transform,
};
use crate::{dog::Dog, obstacles::Obstacles};
pub trait BunnySystems {
fn add_bunny_systems(&mut self) -> &mut Self;
}
impl BunnySystems for App {
fn add_bunny_systems(&mut self) -> &mut Self {
self.add_systems(Startup, setup)
.add_systems(Update, (calculate, jump))
}
}
#[derive(Component)]
struct Bunny;
#[derive(Component)]
enum JumpState {
Jumping { from: Vec3, to: Vec3 },
Cooldown { ready: Duration },
}
const DETECTION_DISTANCE: f32 = 3.0;
const SCARED_JUMP_DISTANCE: f32 = 2.0;
const JUMP_HEIGHT: f32 = 1.0;
const JUMP_COOLDOWN: f32 = 0.05;
const DESIRED_SPEED: f32 = 10.0;
const SPEED: f32 = SCARED_JUMP_DISTANCE / (SCARED_JUMP_DISTANCE / DESIRED_SPEED - JUMP_COOLDOWN);
fn setup(mut commands: Commands, asset_server: Res<AssetServer>, mut meshes: ResMut<Assets<Mesh>>) {
let model =
asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/cube-pets/animal-bunny.glb"));
let mesh = meshes.add(Cuboid::from_length(2.0).mesh().build());
commands.spawn((
SceneRoot(model.clone()),
Transform::from_xyz(4.0, 0.0, 0.0),
Mesh3d(mesh.clone()),
Bunny,
));
commands.spawn((
SceneRoot(model.clone()),
Transform::from_xyz(-4.0, 0.0, 0.0),
Mesh3d(mesh.clone()),
Bunny,
));
}
type IdleBunniesQuery<'w, 's, 't> =
Query<'w, 's, (Entity, &'t mut Transform), (With<Bunny>, Without<JumpState>)>;
fn calculate(
mut commands: Commands,
dog: Single<&Transform, (With<Dog>, Without<Bunny>)>,
mut bunnies: IdleBunniesQuery,
mut obstacles: Obstacles,
) {
bunnies.iter_mut().for_each(|(bunny, mut bunny_transform)| {
calculate_next_move(
&mut commands,
&dog,
&mut bunny_transform,
bunny,
&mut obstacles,
);
});
}
fn calculate_next_move(
commands: &mut Commands,
dog: &Transform,
bunny_transform: &mut Transform,
bunny: Entity,
obstacles: &mut Obstacles,
) {
let direction = bunny_transform.translation - dog.translation;
if direction.length_squared() > DETECTION_DISTANCE * DETECTION_DISTANCE {
return;
}
let dog_direction = direction.with_y(0.0).normalize();
let Some(to) = obstacles.avoid(
bunny_transform.translation,
bunny_transform.translation + dog_direction * SCARED_JUMP_DISTANCE,
) else {
return;
};
commands.entity(bunny).insert(JumpState::Jumping {
from: bunny_transform.translation,
to,
});
bunny_transform.look_to(-(to - bunny_transform.translation), Vec3::Y);
}
fn jump(
mut commands: Commands,
time: Res<Time>,
mut bunnies: Query<(Entity, &mut Transform, &mut JumpState), With<Bunny>>,
) {
bunnies.iter_mut().for_each(
|(bunny, mut bunny_transform, ref mut jump_state)| match jump_state.deref_mut() {
JumpState::Jumping { from, to } => {
let from = *from;
let to = *to;
animate_jumping(
&time,
&mut bunny_transform,
jump_state.deref_mut(),
from,
to,
)
}
JumpState::Cooldown { ready } => {
let ready = *ready;
cooldown(&mut commands, &time, bunny, ready);
}
},
);
}
fn animate_jumping(
time: &Time,
bunny_transform: &mut Transform,
jump_state: &mut JumpState,
from: Vec3,
to: Vec3,
) {
let current = bunny_transform.translation.with_y(to.y);
let direction = to - current;
let delta = SPEED * time.delta_secs();
let (direction, length) = direction.normalize_and_length();
if length <= delta {
bunny_transform.translation = to;
*jump_state = JumpState::Cooldown {
ready: time.elapsed() + Duration::from_secs_f32(JUMP_COOLDOWN),
};
return;
}
let next = current + direction.normalize() * delta;
let total_distance = from.distance(to);
bunny_transform.translation = next
+ Vec3::Y
* JUMP_HEIGHT
* (std::f32::consts::PI * from.distance(next) / total_distance).sin();
}
fn cooldown(commands: &mut Commands, time: &Time, bunny: Entity, ready: Duration) {
if time.elapsed() >= ready {
commands.entity(bunny).remove::<JumpState>();
}
}
src/main.rs
+21
-5
diff --git a/src/main.rs b/src/main.rs
index baf1e7a..bd3d2fe 100644
@@ -1,4 +1,4 @@
mod bunny;
mod bunnies;
mod dog;
mod obstacles;
@@ -7,22 +7,29 @@ use bevy::{
app::{App, Startup},
asset::{AssetServer, Assets},
camera::{Camera3d, OrthographicProjection, Projection, ScalingMode},
ecs::system::{Commands, Res, ResMut},
ecs::{
resource::Resource,
system::{Commands, Res, ResMut},
},
gltf::GltfAssetLabel,
light::{AmbientLight, DirectionalLight},
math::{Quat, Vec3, primitives::Cuboid},
mesh::{Mesh, Mesh3d, MeshBuilder, Meshable},
prelude::{Deref, DerefMut},
scene::SceneRoot,
transform::components::Transform,
};
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use crate::bunny::BunnySystems;
use crate::bunnies::BunnySystems;
use crate::dog::DogSystems;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (setup, setup_fences))
.init_resource::<Rng>()
.add_dog_systems()
.add_bunny_systems()
.run();
@@ -33,7 +40,7 @@ fn setup(mut commands: Commands) {
Camera3d::default(),
Projection::from(OrthographicProjection {
scaling_mode: ScalingMode::FixedVertical {
viewport_height: 20.0,
viewport_height: 30.0,
},
..OrthographicProjection::default_3d()
}),
@@ -67,7 +74,7 @@ fn setup_fences(
.translated_by(Vec3::new(0.5, 0.0, 0.0)),
);
const SIDE: usize = 20;
const SIDE: usize = 30;
for offset in 0..SIDE {
commands.spawn((
@@ -113,3 +120,12 @@ fn setup_fences(
));
}
}
#[derive(Resource, Deref, DerefMut)]
struct Rng(ChaCha8Rng);
impl Default for Rng {
fn default() -> Self {
Self(ChaCha8Rng::seed_from_u64(0))
}
}