|
use std::f32::consts::PI; |
|
|
|
use bevy::{color::palettes::tailwind::*, math::bounding::*, prelude::*}; |
|
use galaxy_brain::{ |
|
Action, ActionFinished, ActionPickerHighestScore, AiBundle, GalaxyBrainPlugins, GalaxyBrainSet, |
|
ObserveWithComponentLifetime, RegisterAction, Scorer, |
|
}; |
|
use rand::prelude::*; |
|
|
|
fn main() { |
|
let mut app = App::new(); |
|
|
|
app.add_plugins(( |
|
DefaultPlugins.set(WindowPlugin { |
|
primary_window: Some(Window { |
|
name: Some("Cleaning Bots".to_string()), |
|
present_mode: bevy::window::PresentMode::AutoVsync, |
|
// resizable: false, |
|
// resize_constraints: WindowResizeConstraints { |
|
// max_width: 1280.0, |
|
// max_height: 720.0, |
|
// ..default() |
|
// }, |
|
..default() |
|
}), |
|
..default() |
|
}), |
|
GalaxyBrainPlugins::new(FixedUpdate), |
|
)); |
|
|
|
app.register_action::<CleanActionManager, _>(init_clean_action); |
|
app.register_action::<SearchActionManager, _>(init_search_action); |
|
app.register_action::<ReturnAndChargeActionManager, _>(init_return_and_charge_action); |
|
|
|
app.add_systems(Startup, (setup_scene, setup_ui)); |
|
|
|
app.add_systems(Update, (handle_ui, rotate_sweepers, render_scene).chain()); |
|
|
|
app.add_systems(FixedUpdate, populate_dirt); |
|
|
|
app.add_systems( |
|
FixedUpdate, |
|
( |
|
( |
|
score_clean_action, |
|
score_return_and_charge_action, |
|
score_search_action, |
|
) |
|
.in_set(GalaxyBrainSet::UserScorers), |
|
( |
|
suck_dirt_action_exec, |
|
movement_action_exec, |
|
charge_action_exec, |
|
) |
|
.in_set(GalaxyBrainSet::UserActions), |
|
// print_winning_action |
|
// .after(GalaxyBrainSet::TransitionActions) |
|
// .before(GalaxyBrainSet::UserActions), |
|
), |
|
); |
|
|
|
app.run(); |
|
} |
|
|
|
fn _print_winning_action(world: &World, query: Query<&galaxy_brain::Scores>) { |
|
// world.run_system_once_with(input, system); |
|
for scores in query.iter() { |
|
for (action_id, score) in scores.scores.iter() { |
|
let name = world.components().get_name(*action_id).unwrap(); |
|
let name = name.strip_prefix("cleaning_bots::").unwrap(); |
|
print!("{name} {score} | "); |
|
} |
|
println!(); |
|
} |
|
} |
|
|
|
// BEGIN AI ------------------------------------------------ |
|
|
|
// AI Actions |
|
|
|
// Clean Action Manager |
|
|
|
#[derive(Component)] |
|
struct CleanActionManager { |
|
dirt: Entity, |
|
} |
|
|
|
impl Action for CleanActionManager { |
|
type Scorer = CleanScorer; |
|
} |
|
|
|
#[derive(Component)] |
|
struct CleanScorer { |
|
dirt: Option<Entity>, |
|
} |
|
|
|
impl Scorer for CleanScorer { |
|
fn score(&self) -> f32 { |
|
if self.dirt.is_some() { |
|
0.5 |
|
} else { |
|
0.0 |
|
} |
|
} |
|
} |
|
|
|
#[derive(Component)] |
|
struct SuckDirtAction { |
|
dirt: Entity, |
|
} |
|
|
|
#[derive(Event)] |
|
struct SuckDirtActionComplete; |
|
|
|
fn score_clean_action( |
|
mut query: Query<(&mut CleanScorer, &Transform, Option<&CleanActionManager>)>, |
|
dirt: Query<(&Transform, &Dirt, Entity)>, |
|
) { |
|
for (mut scorer, bot_tns, cleaning_action) in query.iter_mut() { |
|
if let Some(cleaning_action) = cleaning_action { |
|
scorer.dirt = Some(cleaning_action.dirt); |
|
} |
|
|
|
if scorer.dirt.is_some() { |
|
scorer.dirt = None; |
|
} |
|
|
|
let bounding_circle = { |
|
let (search_cone, translation, rotation) = bot_vision(bot_tns); |
|
search_cone.bounding_circle(translation, rotation) |
|
}; |
|
|
|
for (dirt_tns, dirt, dirt_entity) in dirt.iter() { |
|
if bounding_circle |
|
.contains(&BoundingCircle::new(dirt_tns.translation.xy(), dirt.amount)) |
|
{ |
|
scorer.dirt = Some(dirt_entity); |
|
break; |
|
} |
|
} |
|
} |
|
} |
|
|
|
fn init_clean_action( |
|
In(entity): In<Entity>, |
|
bot: Query<&CleanScorer>, |
|
dirt: Query<&Dirt>, |
|
mut cmd: Commands, |
|
) { |
|
let dirt_entity = bot.get(entity).unwrap().dirt.unwrap(); |
|
let dirt = dirt.get(dirt_entity).unwrap(); |
|
|
|
// MovementAction finished |
|
cmd.observe_with_component_lifetime::<CleanActionManager, _, _, _>( |
|
entity, |
|
move |trigger: Trigger<MovementActionOutcome>, mut cmd: Commands| match trigger.event() { |
|
MovementActionOutcome::Success => { |
|
cmd.entity(trigger.entity()) |
|
.remove::<MovementAction>() |
|
.insert(SuckDirtAction { dirt: dirt_entity }); |
|
} |
|
MovementActionOutcome::Fail => { |
|
cmd.entity(trigger.entity()).remove::<MovementAction>(); |
|
cmd.trigger_targets(ActionFinished, trigger.entity()); |
|
} |
|
}, |
|
); |
|
|
|
// SuckDirtAction finished |
|
cmd.observe_with_component_lifetime::<CleanActionManager, _, _, _>( |
|
entity, |
|
|trigger: Trigger<SuckDirtActionComplete>, mut cmd: Commands| { |
|
cmd.entity(trigger.entity()).remove::<SuckDirtAction>(); |
|
cmd.trigger_targets(ActionFinished, trigger.entity()); |
|
}, |
|
); |
|
|
|
// Handle the dirt being despawned out from under us |
|
cmd.entity(dirt_entity) |
|
.observe(move |_: Trigger<OnRemove, Dirt>, mut cmd: Commands| { |
|
cmd.entity(entity) |
|
.remove::<(MovementAction, SuckDirtAction)>(); |
|
cmd.trigger_targets(ActionFinished, entity); |
|
}); |
|
|
|
cmd.entity(entity).insert(( |
|
CleanActionManager { dirt: dirt_entity }, |
|
MovementAction::Entity(dirt_entity, dirt.size), |
|
)); |
|
} |
|
|
|
fn suck_dirt_action_exec( |
|
query: Query<(&SuckDirtAction, Entity)>, |
|
mut dirt: Query<(&mut Dirt, Entity)>, |
|
time: Res<Time>, |
|
mut cmd: Commands, |
|
) { |
|
for (action, entity) in query.iter() { |
|
let mut done = false; |
|
|
|
if let Ok((mut dirt, dirt_entity)) = dirt.get_mut(action.dirt) { |
|
dirt.amount -= time.delta_seconds(); |
|
|
|
if dirt.amount <= 0.0 { |
|
cmd.entity(dirt_entity).despawn(); |
|
done = true; |
|
} |
|
} else { |
|
done = true; |
|
} |
|
|
|
if done { |
|
cmd.trigger_targets(SuckDirtActionComplete, entity); |
|
} |
|
} |
|
} |
|
|
|
// Search Action Manager |
|
|
|
#[derive(Component)] |
|
struct SearchActionManager; |
|
|
|
impl Action for SearchActionManager { |
|
type Scorer = SearchScorer; |
|
} |
|
|
|
const SEARCH_POSITION_RADIUS: f32 = 150.0; |
|
|
|
#[derive(Component)] |
|
struct SearchScorer { |
|
score: f32, |
|
} |
|
|
|
impl Scorer for SearchScorer { |
|
fn score(&self) -> f32 { |
|
self.score |
|
} |
|
} |
|
|
|
fn score_search_action(mut query: Query<(&mut SearchScorer, &CleaningBot)>) { |
|
for (mut scorer, bot) in query.iter_mut() { |
|
scorer.score = bot.charge * 0.5; |
|
} |
|
} |
|
|
|
fn init_search_action( |
|
In(entity): In<Entity>, |
|
bot: Query<&Transform>, |
|
arena: Query<&Arena>, |
|
mut cmd: Commands, |
|
) { |
|
let bot_pos = bot.get(entity).unwrap().translation; |
|
|
|
let arena = arena.single(); |
|
|
|
cmd.observe_with_component_lifetime::<SearchActionManager, _, _, _>( |
|
entity, |
|
|trigger: Trigger<MovementActionOutcome>, mut cmd: Commands| match trigger.event() { |
|
MovementActionOutcome::Success | MovementActionOutcome::Fail => { |
|
cmd.entity(trigger.entity()).remove::<MovementAction>(); |
|
cmd.trigger_targets(ActionFinished, trigger.entity()); |
|
} |
|
}, |
|
); |
|
|
|
let mut rng = rand::thread_rng(); |
|
|
|
let search_target = loop { |
|
let search_target = |
|
Circle::new(SEARCH_POSITION_RADIUS).sample_interior(&mut rng) + bot_pos.xy(); |
|
|
|
if arena.size.contains(search_target) { |
|
break search_target; |
|
} |
|
}; |
|
|
|
cmd.entity(entity) |
|
.insert((SearchActionManager, MovementAction::Position(search_target))); |
|
} |
|
|
|
// Return And Charge Action Manager |
|
|
|
#[derive(Component)] |
|
struct ReturnAndChargeActionManager; |
|
|
|
impl Action for ReturnAndChargeActionManager { |
|
type Scorer = ReturnAndChargeScorer; |
|
} |
|
|
|
#[derive(Component)] |
|
struct ReturnAndChargeScorer { |
|
score: f32, |
|
} |
|
|
|
impl Scorer for ReturnAndChargeScorer { |
|
fn score(&self) -> f32 { |
|
self.score |
|
} |
|
} |
|
|
|
fn score_return_and_charge_action( |
|
mut query: Query<( |
|
&mut ReturnAndChargeScorer, |
|
Has<ReturnAndChargeActionManager>, |
|
&CleaningBot, |
|
)>, |
|
) { |
|
for (mut scorer, is_charging, bot) in query.iter_mut() { |
|
if is_charging { |
|
scorer.score = 1.0; |
|
} else { |
|
scorer.score = if bot.charge < 0.2 { 1.0 } else { 0.0 } |
|
} |
|
} |
|
} |
|
|
|
fn init_return_and_charge_action( |
|
In(entity): In<Entity>, |
|
bot: Query<&CleaningBot>, |
|
mut cmd: Commands, |
|
) { |
|
let bot = bot.get(entity).unwrap(); |
|
|
|
cmd.observe_with_component_lifetime::<ReturnAndChargeActionManager, _, _, _>( |
|
entity, |
|
|trigger: Trigger<MovementActionOutcome>, mut cmd: Commands| match trigger.event() { |
|
MovementActionOutcome::Success | MovementActionOutcome::Fail => { |
|
cmd.entity(trigger.entity()) |
|
.remove::<MovementAction>() |
|
.insert(ChargeAction); |
|
} |
|
}, |
|
); |
|
|
|
cmd.observe_with_component_lifetime::<ReturnAndChargeActionManager, _, _, _>( |
|
entity, |
|
|trigger: Trigger<ChargeActionComplete>, mut cmd: Commands| { |
|
cmd.entity(trigger.entity()).remove::<ChargeAction>(); |
|
cmd.trigger_targets(ActionFinished, trigger.entity()); |
|
}, |
|
); |
|
|
|
cmd.entity(entity).insert(( |
|
ReturnAndChargeActionManager, |
|
MovementAction::Position(bot.charge_location), |
|
)); |
|
} |
|
|
|
// Movement Action |
|
|
|
#[derive(Component)] |
|
enum MovementAction { |
|
Position(Vec2), |
|
Entity(Entity, f32), |
|
} |
|
|
|
#[derive(Event)] |
|
enum MovementActionOutcome { |
|
Success, |
|
Fail, |
|
} |
|
|
|
const BOT_SPEED: f32 = 30.0; |
|
const BOT_ROTATE_SPEED: f32 = 1.0; |
|
const BOT_MOVE_CHARGE_DRAIN: f32 = 1.0 / 30.0; |
|
|
|
fn movement_action_exec( |
|
mut query: Query<(&MovementAction, &mut CleaningBot, Entity)>, |
|
mut tns: Query<&mut Transform>, |
|
time: Res<Time>, |
|
mut cmd: Commands, |
|
) { |
|
for (movement, mut bot, entity) in query.iter_mut() { |
|
bot.charge = (bot.charge - BOT_MOVE_CHARGE_DRAIN * time.delta_seconds()).max(0.0); |
|
|
|
let (target_pos, buffer) = match movement { |
|
MovementAction::Position(pos) => (pos.extend(0.0), 0.0), |
|
MovementAction::Entity(target, buffer) => { |
|
let Ok(tns) = tns.get(*target) else { |
|
cmd.trigger_targets(MovementActionOutcome::Fail, entity); |
|
continue; |
|
}; |
|
(tns.translation, *buffer + bot.radius) |
|
} |
|
}; |
|
|
|
let Ok(mut tns) = tns.get_mut(entity) else { |
|
cmd.trigger_targets(MovementActionOutcome::Fail, entity); |
|
continue; |
|
}; |
|
|
|
let current_rot = Rot2::radians(tns.rotation.to_euler(EulerRot::YXZ).0); |
|
let target_rot = { |
|
let dir = target_pos - tns.translation; |
|
Rot2::radians(dir.y.atan2(dir.x) - PI / 2.0) |
|
}; |
|
let angle_between = current_rot.angle_between(target_rot); |
|
|
|
if angle_between != 0.0 { |
|
let max_rotate = BOT_ROTATE_SPEED * time.delta_seconds(); |
|
tns.rotate_y(angle_between.clamp(-max_rotate, max_rotate)); |
|
} |
|
|
|
if angle_between.to_degrees().abs() < 5.0 { |
|
tns.translation = tns.translation |
|
+ (target_pos - tns.translation).clamp_length_max(BOT_SPEED * time.delta_seconds()); |
|
} |
|
|
|
if tns.translation.distance(target_pos) <= buffer { |
|
cmd.trigger_targets(MovementActionOutcome::Success, entity); |
|
} |
|
} |
|
} |
|
|
|
// Charge Action |
|
|
|
#[derive(Component)] |
|
struct ChargeAction; |
|
|
|
#[derive(Event)] |
|
struct ChargeActionComplete; |
|
|
|
const CHARGE_TIME: f32 = 4.0; |
|
|
|
fn charge_action_exec( |
|
mut query: Query<(&mut CleaningBot, Entity), With<ChargeAction>>, |
|
time: Res<Time>, |
|
mut cmd: Commands, |
|
) { |
|
for (mut bot, entity) in query.iter_mut() { |
|
bot.charge = (bot.charge + (1.0 / CHARGE_TIME) * time.delta_seconds()).clamp(0.0, 1.0); |
|
|
|
if bot.charge >= 1.0 { |
|
cmd.trigger_targets(ChargeActionComplete, entity); |
|
} |
|
} |
|
} |
|
|
|
// END AI -------------------------------------------------- |
|
|
|
// Components |
|
|
|
#[derive(Component)] |
|
struct CleaningBot { |
|
charge: f32, |
|
charge_location: Vec2, |
|
radius: f32, |
|
color: Color, |
|
} |
|
|
|
#[derive(Component)] |
|
struct SweeperRotation { |
|
rot: f32, |
|
} |
|
|
|
#[derive(Component)] |
|
struct ChargeStation { |
|
radius: f32, |
|
color: Color, |
|
} |
|
|
|
#[derive(Component)] |
|
struct Dirt { |
|
size: f32, |
|
amount: f32, |
|
color: Color, |
|
} |
|
|
|
#[derive(Component)] |
|
struct Arena { |
|
size: Rect, |
|
color: Color, |
|
} |
|
|
|
#[derive(Component)] |
|
enum TimeUiThing { |
|
Slower, |
|
Faster, |
|
PlayPause, |
|
Speed, |
|
} |
|
|
|
// Setup |
|
|
|
fn setup_scene(window: Query<&Window>, mut cmd: Commands) { |
|
let window = window.single(); |
|
let window_size = window.size(); |
|
|
|
let scale = 0.7; |
|
|
|
let mut cam_bundle = Camera2dBundle::default(); |
|
cam_bundle.projection.scaling_mode = |
|
bevy::render::camera::ScalingMode::FixedHorizontal(window_size.x); |
|
cam_bundle.projection.scale = scale; |
|
cmd.spawn(cam_bundle); |
|
|
|
cmd.spawn(Arena { |
|
size: Rect::from_center_size(Vec2::ZERO, window_size * scale), |
|
color: YELLOW_700.into(), |
|
}); |
|
|
|
for (pos, charge) in [ |
|
(Vec2::new(-150.0, 0.0), 1.0), |
|
(Vec2::new(0.0, 0.0), 0.5), |
|
(Vec2::new(150.0, 0.0), 0.0), |
|
] { |
|
cmd.spawn(( |
|
ChargeStation { |
|
radius: 20.0, |
|
color: GREEN_400.into(), |
|
}, |
|
SpatialBundle::from_transform(Transform::from_translation(pos.extend(0.0))), |
|
)); |
|
|
|
cmd.spawn(( |
|
CleaningBot { |
|
charge, |
|
charge_location: pos, |
|
radius: 15.0, |
|
color: RED_500.into(), |
|
}, |
|
SweeperRotation { rot: 0.0 }, |
|
CleanScorer { dirt: None }, |
|
SearchScorer { score: 0.0 }, |
|
ReturnAndChargeScorer { score: 0.0 }, |
|
AiBundle::<ActionPickerHighestScore>::default(), |
|
SpatialBundle::from_transform(Transform::from_translation(pos.extend(0.0))), |
|
)); |
|
} |
|
} |
|
|
|
fn setup_ui(mut cmd: Commands) { |
|
cmd.spawn(NodeBundle { |
|
style: Style { |
|
position_type: PositionType::Absolute, |
|
top: Val::Px(0.), |
|
width: Val::Percent(100.), |
|
justify_content: JustifyContent::Center, |
|
..default() |
|
}, |
|
..default() |
|
}) |
|
.with_children(|p| { |
|
macro_rules! ui_button { |
|
($ui_thing: expr, $text: expr) => { |
|
p.spawn(( |
|
$ui_thing, |
|
ButtonBundle { |
|
style: Style { |
|
width: Val::Px(75.), |
|
height: Val::Px(65.), |
|
justify_content: JustifyContent::Center, |
|
align_items: AlignItems::Center, |
|
..default() |
|
}, |
|
..default() |
|
}, |
|
)) |
|
.with_children(|p| { |
|
p.spawn(TextBundle::from_section( |
|
$text, |
|
TextStyle { |
|
font_size: 20.0, |
|
color: Color::srgb(0.9, 0.9, 0.9), |
|
..default() |
|
}, |
|
)); |
|
}); |
|
}; |
|
} |
|
|
|
ui_button!(TimeUiThing::PlayPause, "Pause"); |
|
ui_button!(TimeUiThing::Speed, "1.0"); |
|
ui_button!(TimeUiThing::Slower, "Slower"); |
|
ui_button!(TimeUiThing::Faster, "Faster"); |
|
}); |
|
} |
|
|
|
// Rendering |
|
|
|
fn render_scene( |
|
arena: Query<&Arena>, |
|
charge_stations: Query<(&Transform, &ChargeStation)>, |
|
dirt: Query<(&Transform, &Dirt)>, |
|
cleaning_bots: Query<( |
|
&Transform, |
|
&CleaningBot, |
|
&SweeperRotation, |
|
Option<&MovementAction>, |
|
)>, |
|
transform_q: Query<&Transform>, |
|
mut gizmos: Gizmos, |
|
) { |
|
let arena = arena.single(); |
|
|
|
gizmos.rect_2d( |
|
arena.size.center(), |
|
Rot2::IDENTITY, |
|
arena.size.size() - Vec2::splat(2.0), |
|
arena.color, |
|
); |
|
|
|
for (tns, station) in charge_stations.iter() { |
|
gizmos.rect_2d( |
|
tns.translation.xy(), |
|
0.0, |
|
Vec2::splat(station.radius * 2.0), |
|
station.color, |
|
); |
|
} |
|
|
|
for (tns, dirt) in dirt.iter() { |
|
gizmos.circle_2d(tns.translation.xy(), dirt.amount, dirt.color); |
|
} |
|
|
|
for (tns, bot, sweeper_rotation, movement) in cleaning_bots.iter() { |
|
let bot_pos = tns.translation.xy(); |
|
let bot_rot = tns.rotation.to_euler(EulerRot::YXZ).0; |
|
|
|
// Body |
|
gizmos.circle_2d(bot_pos, bot.radius, bot.color); |
|
|
|
// Eyes |
|
for eye_angle_offset in [-0.4, 0.4] { |
|
gizmos.circle_2d( |
|
bot_pos + Rot2::radians(bot_rot + eye_angle_offset) * Vec2::new(0.0, 8.0), |
|
2.0, |
|
Srgba::WHITE, |
|
); |
|
} |
|
|
|
// Sweepers |
|
for angle in |
|
// [0., PI / 3., 2. * PI / 3., PI, 4. * PI / 3., 5. * PI / 3.] |
|
[0.0, PI / 2.0, PI, 3.0 * PI / 2.0] |
|
{ |
|
const SWEEPER_ROTATE_SPEED: f32 = 2.0; |
|
|
|
let sweeper_len = 4.0; |
|
|
|
let sweeper_left = bot_pos + Rot2::radians(bot_rot + PI / 10.0) * Vec2::new(0.0, 15.0); |
|
let sweeper_right = bot_pos + Rot2::radians(bot_rot - PI / 10.0) * Vec2::new(0.0, 15.0); |
|
|
|
let sweeper_rotation = sweeper_rotation.rot * SWEEPER_ROTATE_SPEED; |
|
|
|
let left_angle = Rot2::radians(bot_rot + angle - sweeper_rotation); |
|
let right_angle = Rot2::radians(bot_rot + angle + sweeper_rotation); |
|
|
|
let wtf_offset_1 = 0.45; |
|
let wtf_offset_2 = 0.2; |
|
|
|
let lar = Rot2::radians(bot_rot).angle_between(left_angle); |
|
if (lar - wtf_offset_1) < (PI / 2.0) && (lar - wtf_offset_2) > (-PI / 2.0) { |
|
gizmos.line_2d( |
|
sweeper_left, |
|
sweeper_left + left_angle * Vec2::new(0.0, sweeper_len), |
|
NEUTRAL_300, |
|
); |
|
} |
|
|
|
let rawr = Rot2::radians(bot_rot).angle_between(right_angle); |
|
if (rawr + wtf_offset_2) < PI / 2.0 && (rawr + wtf_offset_1) > -PI / 2.0 { |
|
gizmos.line_2d( |
|
sweeper_right, |
|
sweeper_right + right_angle * Vec2::new(0.0, sweeper_len), |
|
NEUTRAL_300, |
|
); |
|
} |
|
} |
|
|
|
let battery_y = 35.0; |
|
let battery_size = Vec2::new(30.0, 12.0); |
|
|
|
// Battery indicator |
|
gizmos.rect_2d( |
|
bot_pos + Vec2::new(0.0, battery_y), |
|
0.0, |
|
battery_size, |
|
NEUTRAL_500, |
|
); |
|
let battery_right = bot_pos + Vec2::new(battery_size.x / 2.0, battery_y); |
|
gizmos.line_2d( |
|
battery_right + Vec2::new(2.0, 4.0), |
|
battery_right + Vec2::new(2.0, -4.0), |
|
NEUTRAL_500, |
|
); |
|
|
|
let left = -13.0; |
|
let right = 15.0; |
|
|
|
let batter_line_color = Hsla::from(RED_500).mix(&Hsla::from(GREEN_500), bot.charge); |
|
for i in 0..10 { |
|
let i = i as f32 / 10.0; |
|
let x = left.lerp(right, i); |
|
|
|
if i > bot.charge { |
|
continue; |
|
} |
|
|
|
gizmos.line_2d( |
|
bot_pos + Vec2::new(x, battery_y - 5.0), |
|
bot_pos + Vec2::new(x, battery_y + 5.0), |
|
batter_line_color, |
|
); |
|
} |
|
|
|
if let Some(movement) = movement { |
|
// Vision cone |
|
let (primitive, position, angle) = bot_vision(tns); |
|
gizmos.primitive_2d(&primitive, position, angle, EMERALD_300.with_alpha(0.05)); |
|
|
|
// Target indicator |
|
if let MovementAction::Entity(target, buffer) = movement { |
|
let target_pos = transform_q.get(*target).unwrap().translation.xy(); |
|
let distance = bot_pos.distance(target_pos); |
|
let target_pos = bot_pos.lerp(target_pos, (distance - *buffer) / distance); |
|
gizmos.arrow_2d(bot_pos, target_pos, BLUE_400.with_alpha(0.1)); |
|
} |
|
} |
|
} |
|
} |
|
|
|
fn rotate_sweepers(mut query: Query<&mut SweeperRotation, With<SuckDirtAction>>, time: Res<Time>) { |
|
for mut sweeper in query.iter_mut() { |
|
sweeper.rot += time.delta_seconds(); |
|
} |
|
} |
|
|
|
// UI |
|
|
|
const SIM_SPEEDS: &[f32] = &[0.0, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0]; |
|
|
|
fn handle_ui( |
|
buttons: Query<(Option<&Interaction>, &TimeUiThing, &Children), Changed<Interaction>>, |
|
time_holder: Query<(&TimeUiThing, &Children)>, |
|
mut text: Query<&mut Text>, |
|
mut time: ResMut<Time<Virtual>>, |
|
) { |
|
macro_rules! update_speed_text { |
|
() => { |
|
let speed_entity = time_holder |
|
.iter() |
|
.find_map(|(t, c)| { |
|
if matches!(t, TimeUiThing::Speed) { |
|
Some(c[0]) |
|
} else { |
|
None |
|
} |
|
}) |
|
.unwrap(); |
|
let mut text = text.get_mut(speed_entity).unwrap(); |
|
text.sections[0].value = format!("{:.1}", time.relative_speed()); |
|
}; |
|
} |
|
|
|
for (interaction, ui_thing, children) in buttons.iter() { |
|
if !matches!(interaction, Some(Interaction::Pressed)) { |
|
continue; |
|
} |
|
|
|
match ui_thing { |
|
TimeUiThing::Slower => { |
|
if let Some((idx, _)) = SIM_SPEEDS |
|
.iter() |
|
.enumerate() |
|
.find(|spd| *spd.1 == time.relative_speed()) |
|
{ |
|
time.set_relative_speed(SIM_SPEEDS[idx.saturating_sub(1)]); |
|
} else { |
|
time.set_relative_speed(1.0); |
|
} |
|
update_speed_text!(); |
|
} |
|
TimeUiThing::Faster => { |
|
if let Some((idx, _)) = SIM_SPEEDS |
|
.iter() |
|
.enumerate() |
|
.find(|spd| *spd.1 == time.relative_speed()) |
|
{ |
|
time.set_relative_speed(SIM_SPEEDS[(idx + 1).min(SIM_SPEEDS.len() - 1)]); |
|
} else { |
|
time.set_relative_speed(1.0); |
|
} |
|
update_speed_text!(); |
|
} |
|
TimeUiThing::PlayPause => { |
|
let mut text = text.get_mut(children[0]).unwrap(); |
|
if time.is_paused() { |
|
time.unpause(); |
|
text.sections[0].value = "Pause".to_string(); |
|
} else { |
|
time.pause(); |
|
text.sections[0].value = "Play".to_string(); |
|
} |
|
} |
|
_ => {} |
|
} |
|
} |
|
} |
|
|
|
// Dirt |
|
|
|
fn populate_dirt( |
|
dirt: Query<(), With<Dirt>>, |
|
arena: Query<&Arena>, |
|
mut cmd: Commands, |
|
time: Res<Time>, |
|
) { |
|
let arena = arena.single(); |
|
|
|
const MAX_DIRT: usize = 30; |
|
|
|
let chance_to_spawn = (1.0 |
|
- (dirt.iter().len().clamp(0, MAX_DIRT) as f32 * (1.0 / MAX_DIRT as f32))) |
|
* 10.0 |
|
* time.delta_seconds(); |
|
|
|
let mut rng = rand::thread_rng(); |
|
|
|
if chance_to_spawn > rng.gen::<f32>() { |
|
let amount = (rng.gen::<f32>() + 1.0) * 2.5; |
|
cmd.spawn(( |
|
Dirt { |
|
size: amount, |
|
amount, |
|
color: AMBER_300.into(), |
|
}, |
|
SpatialBundle::from_transform(Transform::from_xyz( |
|
(rng.gen::<f32>() - 0.5) * (arena.size.width() - 20.0), |
|
(rng.gen::<f32>() - 0.5) * (arena.size.height() - 20.0), |
|
0.0, |
|
)), |
|
)); |
|
} |
|
} |
|
|
|
fn bot_vision(tns: &Transform) -> (CircularSector, Vec2, f32) { |
|
( |
|
CircularSector::from_radians(100.0, PI / 2.0), |
|
tns.translation.xy(), |
|
tns.rotation.to_euler(EulerRot::YXZ).0, |
|
) |
|
} |
Video of
cleaning_bots.rs
in action:2024-07-25_17-52-16.mp4
See
ai_debug.rs
for an example of using reflection to get a debug ui for scorers.