818 lines
27 KiB
Rust
818 lines
27 KiB
Rust
use std::collections::HashMap;
|
|
use std::f32::consts::TAU;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use macroquad::prelude::*;
|
|
use parry2d::math::Pose;
|
|
use parry2d::math::Rot2;
|
|
use parry2d::math::Vec2;
|
|
use parry2d::query::contact;
|
|
use parry2d::shape::Ball;
|
|
use parry2d::shape::Segment;
|
|
use parry2d::shape::Shape;
|
|
|
|
use rand_gen::RngExt;
|
|
use rand_gen::SeedableRng;
|
|
use rand_gen::distr::Distribution;
|
|
use rand_gen::distr::Uniform;
|
|
use rand_gen::rngs::SmallRng;
|
|
use rayon::iter::IntoParallelIterator;
|
|
use rayon::iter::ParallelIterator;
|
|
|
|
use clap::Parser;
|
|
|
|
use serde::Deserialize;
|
|
|
|
const BOT_RADIUS: f32 = 16.0;
|
|
|
|
/// Broad-phase grid cell size. Derived from the sensing range (the dominant
|
|
/// query length) and clamped so both sensing and food pick-up stay efficient
|
|
/// across configurations.
|
|
fn grid_cell(view_dist: f32) -> f32 {
|
|
(view_dist * 0.8).clamp(64.0, 256.0)
|
|
}
|
|
|
|
/// Uniform spatial grid over a slice of 2D points, rebuilt every simulation
|
|
/// step. Indices are stored in an intrusive singly-linked list (`head`/`next`)
|
|
/// so building and querying is allocation-free and hash-free.
|
|
struct Grid {
|
|
cell: f32,
|
|
inv_cell: f32,
|
|
min_x: f32,
|
|
min_y: f32,
|
|
cols: i32,
|
|
rows: i32,
|
|
head: Vec<i32>,
|
|
next: Vec<i32>,
|
|
}
|
|
|
|
impl Grid {
|
|
fn new(cell: f32) -> Self {
|
|
Self {
|
|
cell,
|
|
inv_cell: 1.0 / cell,
|
|
min_x: 0.0,
|
|
min_y: 0.0,
|
|
cols: 0,
|
|
rows: 0,
|
|
head: Vec::new(),
|
|
next: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Rebuild the grid for `pts` (index `i` in `pts` is what queries return).
|
|
fn rebuild(&mut self, pts: &[Vec2]) {
|
|
if pts.is_empty() {
|
|
self.cols = 0;
|
|
self.rows = 0;
|
|
self.head.clear();
|
|
self.next.clear();
|
|
return;
|
|
}
|
|
let (mut min_x, mut min_y) = (f32::MAX, f32::MAX);
|
|
let (mut max_x, mut max_y) = (f32::MIN, f32::MIN);
|
|
for p in pts {
|
|
min_x = min_x.min(p.x);
|
|
min_y = min_y.min(p.y);
|
|
max_x = max_x.max(p.x);
|
|
max_y = max_y.max(p.y);
|
|
}
|
|
// One cell of slack on every side keeps the arithmetic in bounds.
|
|
self.min_x = min_x - self.cell;
|
|
self.min_y = min_y - self.cell;
|
|
self.cols = (((max_x - self.min_x) * self.inv_cell) as i32 + 2).max(1);
|
|
self.rows = (((max_y - self.min_y) * self.inv_cell) as i32 + 2).max(1);
|
|
|
|
let n = (self.cols as usize) * (self.rows as usize);
|
|
self.head.clear();
|
|
self.head.resize(n, -1);
|
|
self.next.clear();
|
|
self.next.resize(pts.len(), -1);
|
|
for (i, p) in pts.iter().enumerate() {
|
|
let cx = ((p.x - self.min_x) * self.inv_cell) as i32;
|
|
let cy = ((p.y - self.min_y) * self.inv_cell) as i32;
|
|
let cx = cx.clamp(0, self.cols - 1);
|
|
let cy = cy.clamp(0, self.rows - 1);
|
|
let c = (cy * self.cols + cx) as usize;
|
|
self.next[i] = self.head[c];
|
|
self.head[c] = i as i32;
|
|
}
|
|
}
|
|
|
|
/// Visit every index whose point lies inside the axis-aligned box
|
|
/// `[lo, hi]`. Conservative: may visit points just outside.
|
|
#[inline]
|
|
fn for_each_in_aabb(&self, lo: Vec2, hi: Vec2, mut f: impl FnMut(usize)) {
|
|
if self.cols == 0 {
|
|
return;
|
|
}
|
|
let cx0 = (((lo.x - self.min_x) * self.inv_cell) as i32).clamp(0, self.cols - 1);
|
|
let cx1 = (((hi.x - self.min_x) * self.inv_cell) as i32).clamp(0, self.cols - 1);
|
|
let cy0 = (((lo.y - self.min_y) * self.inv_cell) as i32).clamp(0, self.rows - 1);
|
|
let cy1 = (((hi.y - self.min_y) * self.inv_cell) as i32).clamp(0, self.rows - 1);
|
|
for cy in cy0..=cy1 {
|
|
let base = cy * self.cols;
|
|
for cx in cx0..=cx1 {
|
|
let mut i = self.head[(base + cx) as usize];
|
|
while i >= 0 {
|
|
f(i as usize);
|
|
i = self.next[i as usize];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Exact distance from `a` to the point where the segment `a..b` first comes
|
|
/// within `radius` of `center`, or `None` if it never does.
|
|
///
|
|
/// This reproduces parry's `contact(Pose::IDENTITY, segment, center, ball, 0.0)`
|
|
/// followed by `contact.point1.distance(a)`: parry reports the closest point on
|
|
/// the segment as `point1`, and contacts exactly when the centre-to-segment
|
|
/// distance is `<= radius`.
|
|
#[inline]
|
|
fn seg_ball_dist(a: Vec2, b: Vec2, center: Vec2, radius: f32) -> Option<f32> {
|
|
let ab = b - a;
|
|
let ab2 = ab.length_squared();
|
|
let t = if ab2 > 0.0 {
|
|
((center - a).dot(ab) / ab2).clamp(0.0, 1.0)
|
|
} else {
|
|
0.0
|
|
};
|
|
let q = a + ab * t;
|
|
if (center - q).length_squared() <= radius * radius {
|
|
Some((q - a).length())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Exact distance from a ball's centre to the contact point on another ball,
|
|
/// matching parry's ball-ball contact (`point1` sits on ball 1's surface along
|
|
/// the centre-to-centre normal). Contacts exactly when the centres are closer
|
|
/// than the sum of the radii. The add-then-subtract mirrors parry transforming
|
|
/// the local contact point into world space and measuring back to the centre,
|
|
/// so the result is bit-identical (including the cancellation error).
|
|
#[inline]
|
|
fn ball_ball_dist(center1: Vec2, center2: Vec2, radius: f32) -> Option<f32> {
|
|
let delta = center2 - center1;
|
|
let d2 = delta.length_squared();
|
|
let sum = radius + radius;
|
|
if d2 < sum * sum {
|
|
let n = if d2 != 0.0 { delta.normalize() } else { Vec2::X };
|
|
let world_point1 = center1 + n * radius;
|
|
Some(world_point1.distance(center1))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
trait Mortal {
|
|
fn collider(&self) -> (&Pose, &dyn Shape);
|
|
fn kill(&self);
|
|
fn living(&self) -> bool;
|
|
|
|
fn draw(&self);
|
|
}
|
|
|
|
struct Robot {
|
|
pose: Pose,
|
|
alive: AtomicBool,
|
|
collider: Ball,
|
|
team: Color,
|
|
policy: Policy,
|
|
weapon: Weapon,
|
|
view_dist: f32,
|
|
}
|
|
|
|
impl Mortal for Robot {
|
|
fn draw(&self) {
|
|
let tl = self.pose.translation;
|
|
let tl2 = self.view_end();
|
|
draw_circle(tl.x, tl.y, BOT_RADIUS, BLACK);
|
|
draw_line(tl.x, tl.y, tl2.x, tl2.y, 2.0, BLUE);
|
|
draw_circle(tl.x, tl.y, BOT_RADIUS-1.0, self.team);
|
|
}
|
|
|
|
fn collider(&self) -> (&Pose, &dyn Shape) {
|
|
(&self.pose, &self.collider)
|
|
}
|
|
|
|
fn kill (&self) {
|
|
self.alive.store(false, Ordering::Relaxed);
|
|
}
|
|
fn living(&self) -> bool {
|
|
self.alive.load(Ordering::Relaxed)
|
|
}
|
|
}
|
|
|
|
impl Robot {
|
|
fn new(pose: Pose, team: Color, policy: Policy, weapon: Weapon, view_dist: f32) -> Self {
|
|
Self { pose, alive: AtomicBool::new(true), collider: Ball::new(BOT_RADIUS), team, policy, weapon, view_dist}
|
|
}
|
|
|
|
fn move_step(&mut self, forward_right: [f32;2]) {
|
|
let [forward, right] = forward_right;
|
|
self.pose.translation += self.ahead() * forward;
|
|
self.pose.rotation *= Rot2::from_angle(right);
|
|
}
|
|
|
|
pub fn ahead(&self) -> Vec2 {
|
|
Vec2::new(self.pose.rotation.re, self.pose.rotation.im)
|
|
}
|
|
|
|
fn view_end(&self) -> Vec2 {
|
|
self.pose.translation + self.ahead() * self.view_dist
|
|
}
|
|
|
|
|
|
/// Distance from the robot's translation to the closest point on its view
|
|
/// segment that lies within `radius` of `center`, or `None` if the segment
|
|
/// misses the target's ball. This is an exact, closed-form replacement for
|
|
/// parry's GJK `contact` query in the ball-vs-segment case (all bodies here
|
|
/// are circles), and is dramatically cheaper.
|
|
#[inline]
|
|
fn contact_dist(&self, translation: Vec2, end: Vec2, center: Vec2) -> Option<f32> {
|
|
let reach = self.view_dist + BOT_RADIUS;
|
|
let dx = center.x - translation.x;
|
|
let dy = center.y - translation.y;
|
|
if dx * dx + dy * dy > reach * reach {
|
|
return None;
|
|
}
|
|
seg_ball_dist(translation, end, center, BOT_RADIUS)
|
|
}
|
|
|
|
fn crater(&self) -> (f32, f32, f32, Color) {
|
|
self.weapon.model.crater(self)
|
|
}
|
|
|
|
fn weapon<'a>(&self, peers: impl Iterator<Item = &'a dyn Mortal>) {
|
|
let (pose, weapon) = self.weapon.model.collider(&self);
|
|
|
|
for peer in peers {
|
|
let (p_p, p_c) = peer.collider();
|
|
|
|
if let Some(_) = contact(&pose, &*weapon, p_p , p_c, 0.0).unwrap() {
|
|
peer.kill();
|
|
}
|
|
}
|
|
if self.weapon.kills_user {
|
|
self.kill(); // o7
|
|
}
|
|
}
|
|
|
|
pub fn policy(&mut self, sensor: &Sensor) -> bool{
|
|
match sensor {
|
|
Sensor::Food => self.move_step(self.policy.food),
|
|
Sensor::Kin => self.move_step(self.policy.kin),
|
|
Sensor::NonKin => self.move_step(self.policy.nonkin),
|
|
Sensor::None => self.move_step(self.policy.none),
|
|
}
|
|
|
|
return *sensor == Sensor::NonKin && self.policy.aggressive;
|
|
}
|
|
}
|
|
|
|
struct Food {
|
|
pose: Pose,
|
|
collider: Ball,
|
|
alive: AtomicBool,
|
|
}
|
|
|
|
#[derive(PartialEq, Eq, Clone, Copy)]
|
|
enum Sensor {
|
|
Food,
|
|
Kin,
|
|
NonKin,
|
|
None
|
|
}
|
|
|
|
impl Mortal for Food {
|
|
fn draw(&self) {
|
|
let tl = self.pose.translation;
|
|
draw_circle(tl.x, tl.y, BOT_RADIUS, BLACK);
|
|
draw_circle(tl.x, tl.y, BOT_RADIUS-1.0, GREEN);
|
|
}
|
|
|
|
fn collider(&self) -> (&Pose, &dyn Shape) {
|
|
(&self.pose, &self.collider)
|
|
}
|
|
|
|
fn kill (&self) {
|
|
self.alive.store(false, Ordering::Relaxed);
|
|
}
|
|
|
|
fn living(&self) -> bool {
|
|
self.alive.load(Ordering::Relaxed)
|
|
}
|
|
}
|
|
|
|
impl Food {
|
|
fn new(position: Vec2) -> Self {
|
|
let pose:Pose = Pose::from_translation(position.to_array().into());
|
|
Self { pose, alive: AtomicBool::new(true), collider: Ball::new(BOT_RADIUS) }
|
|
}
|
|
|
|
|
|
/// Team of the nearest robot touching this food, via the robot broad-phase
|
|
/// grid. Preserves the original tie-break (a later candidate wins ties).
|
|
fn detect(&self, grid: &Grid, robots: &[Robot]) -> Option<Color> {
|
|
let c = self.pose.translation;
|
|
let r = 2.0 * BOT_RADIUS;
|
|
let lo = Vec2::new(c.x - r, c.y - r);
|
|
let hi = Vec2::new(c.x + r, c.y + r);
|
|
let mut best: Option<(f32, Color)> = None;
|
|
grid.for_each_in_aabb(lo, hi, |ri| {
|
|
let robot = &robots[ri];
|
|
if let Some(d) = ball_ball_dist(c, robot.pose.translation, BOT_RADIUS) {
|
|
match best {
|
|
Some((bd, _)) if bd < d => {}
|
|
_ => best = Some((d, robot.team)),
|
|
}
|
|
}
|
|
});
|
|
best.map(|(_, team)| team)
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
struct Policy {
|
|
aggressive: bool,
|
|
food: [f32;2],
|
|
kin: [f32;2],
|
|
nonkin: [f32;2],
|
|
none: [f32;2],
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
#[serde(rename_all="lowercase", tag="type")]
|
|
enum WeaponType {
|
|
Ball { radius: f32 }, // radius
|
|
Lazer { range: f32 }, // range
|
|
/// Ball centered at end of detection range
|
|
ForwardBall {radius: f32}, // radius
|
|
}
|
|
|
|
impl WeaponType {
|
|
pub fn collider(&self, robot: &Robot) -> (Pose, Box<dyn Shape>) {
|
|
match self {
|
|
WeaponType::Ball{radius} => {
|
|
(robot.pose, Box::new(Ball::new(*radius)))
|
|
},
|
|
WeaponType::Lazer{range} => {
|
|
let end = robot.pose.translation + robot.ahead() * *range;
|
|
(Pose::identity(),
|
|
Box::new(Segment::new(robot.pose.translation, end)))
|
|
},
|
|
WeaponType::ForwardBall{radius} => {
|
|
(Pose::from_translation(robot.view_end()), Box::new(Ball::new(*radius)))
|
|
},
|
|
}
|
|
}
|
|
pub fn crater(&self, robot: &Robot) -> (f32, f32, f32, Color) {
|
|
match self {
|
|
WeaponType::Ball{radius} => {
|
|
let tl = robot.pose.translation;
|
|
(tl.x, tl.y, *radius, ORANGE)
|
|
},
|
|
WeaponType::Lazer{range} => {
|
|
let tl = robot.pose.translation;
|
|
(tl.x, tl.y, *range, LIGHTGRAY)
|
|
},
|
|
WeaponType::ForwardBall{radius} => {
|
|
let tl = robot.view_end();
|
|
(tl.x, tl.y, *radius, ORANGE)
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
struct Weapon {
|
|
kills_user: bool,
|
|
#[serde(flatten)]
|
|
model: WeaponType,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SimConfig {
|
|
food_amount: usize,
|
|
view_dist: f32,
|
|
red_count: usize,
|
|
red_policy: Policy,
|
|
blu_count: usize,
|
|
blu_policy: Policy,
|
|
weapon: Weapon,
|
|
}
|
|
|
|
struct Simulation {
|
|
robots: Vec<Robot>,
|
|
food: Vec<Food>,
|
|
craters: Vec<(f32, f32, f32, Color)>,
|
|
new_craters: Vec<(f32, f32, f32, Color)>,
|
|
watchdog: i32,
|
|
score: HashMap<[u32;4], i32>,
|
|
robot_grid: Grid,
|
|
food_grid: Grid,
|
|
// Scratch buffers reused every step to avoid per-step allocations.
|
|
robot_pos: Vec<Vec2>,
|
|
food_pos: Vec<Vec2>,
|
|
sensors: Vec<Sensor>,
|
|
}
|
|
|
|
impl Simulation {
|
|
fn new(seed: u64, config: &SimConfig) -> Self {
|
|
let mut rng = SmallRng::seed_from_u64(seed);
|
|
let watchdog = 0; // iterations since something interesting
|
|
let mut robots = Vec::new();
|
|
|
|
let craters: Vec<(f32, f32, f32, Color)> = Vec::new();
|
|
let new_craters: Vec<(f32, f32, f32, Color)> = Vec::new();
|
|
|
|
let score = HashMap::new();
|
|
|
|
let angle = Uniform::new(0.0, TAU).unwrap();
|
|
// blue robots
|
|
for i in 0..config.blu_count {
|
|
let rand_angle = angle.sample(&mut rng);
|
|
let pose:Pose = Pose::from_parts(Vec2 { x: 100.0, y: i as f32*50.0 + 250.0}, Rot2::from_angle(rand_angle));
|
|
robots.push(Robot::new(pose, BLUE, config.blu_policy.clone(), config.weapon.clone(), config.view_dist));
|
|
}
|
|
|
|
//red robots
|
|
for i in 0..config.red_count {
|
|
let rand_angle = angle.sample(&mut rng);
|
|
let pose:Pose = Pose::from_parts(Vec2 { x: 1200.0, y: i as f32*50.0 + 250.0}, Rot2::from_angle(rand_angle));
|
|
robots.push(Robot::new(pose, RED, config.red_policy.clone(), config.weapon.clone(), config.view_dist));
|
|
}
|
|
|
|
let mut food = Vec::new();
|
|
|
|
for _ in 0..config.food_amount {
|
|
let x = rng.random_range(200.0 .. 1100.0);
|
|
let y = rng.random_range(200.0 .. 900.0);
|
|
food.push(Food::new(Vec2 { x, y }));
|
|
}
|
|
let cell = grid_cell(config.view_dist);
|
|
Self { robots, food, craters, new_craters, watchdog, score,
|
|
robot_grid: Grid::new(cell), food_grid: Grid::new(cell),
|
|
robot_pos: Vec::new(), food_pos: Vec::new(), sensors: Vec::new() }
|
|
}
|
|
|
|
fn color_hash(color: Color) -> [u32; 4] {
|
|
color.to_vec().to_array().map(|f| f.to_bits())
|
|
}
|
|
|
|
fn sim_step(&mut self) -> Option<(i32, i32)>{
|
|
let Simulation { robots, food, craters: _, new_craters, watchdog, score,
|
|
robot_grid, food_grid, robot_pos, food_pos, sensors } = self;
|
|
|
|
// Broad-phase for food pickup: must cover the same robot set the
|
|
// original iterated, i.e. *before* the retain below (dead robots get
|
|
// one more chance to score).
|
|
robot_pos.clear();
|
|
robot_pos.extend(robots.iter().map(|r| r.pose.translation));
|
|
robot_grid.rebuild(robot_pos);
|
|
|
|
for food in &*food{
|
|
if let Some(team) = food.detect(robot_grid, robots) {
|
|
if food.living() {
|
|
score.entry(Self::color_hash(team)).and_modify(|s| *s +=1 ).or_insert(1);
|
|
food.kill();
|
|
*watchdog = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
let robots_before = robots.len();
|
|
food.retain(Mortal::living);
|
|
robots.retain(Mortal::living);
|
|
|
|
// Rebuild the grids on the retained sets for the sensing pass. The
|
|
// robot grid is still valid (positions unchanged) when no robot died.
|
|
if robots.len() != robots_before {
|
|
robot_pos.clear();
|
|
robot_pos.extend(robots.iter().map(|r| r.pose.translation));
|
|
robot_grid.rebuild(robot_pos);
|
|
}
|
|
food_pos.clear();
|
|
food_pos.extend(food.iter().map(|f| f.pose.translation));
|
|
food_grid.rebuild(food_pos);
|
|
|
|
sensors.clear();
|
|
for (i, robot) in robots.iter().enumerate() {
|
|
let translation = robot.pose.translation;
|
|
let end = robot.view_end();
|
|
let lo = Vec2::new(translation.x.min(end.x) - BOT_RADIUS, translation.y.min(end.y) - BOT_RADIUS);
|
|
let hi = Vec2::new(translation.x.max(end.x) + BOT_RADIUS, translation.y.max(end.y) + BOT_RADIUS);
|
|
|
|
let mut detect_food: Option<f32> = None;
|
|
food_grid.for_each_in_aabb(lo, hi, |fi| {
|
|
if let Some(d) = robot.contact_dist(translation, end, food[fi].pose.translation) {
|
|
detect_food = Some(detect_food.map_or(d, |b| b.min(d)));
|
|
}
|
|
});
|
|
|
|
let mut detect_kin: Option<f32> = None;
|
|
let mut detect_non_kin: Option<f32> = None;
|
|
robot_grid.for_each_in_aabb(lo, hi, |ri| {
|
|
if ri == i {
|
|
return;
|
|
}
|
|
let other = &robots[ri];
|
|
if let Some(d) = robot.contact_dist(translation, end, other.pose.translation) {
|
|
if other.team == robot.team {
|
|
detect_kin = Some(detect_kin.map_or(d, |b| b.min(d)));
|
|
} else {
|
|
detect_non_kin = Some(detect_non_kin.map_or(d, |b| b.min(d)));
|
|
}
|
|
}
|
|
});
|
|
|
|
let mut sensor = Sensor::None;
|
|
let mut closest: Option<f32> = None;
|
|
|
|
for (dist, detection) in [(detect_food, Sensor::Food), (detect_kin, Sensor::Kin), (detect_non_kin, Sensor::NonKin)] {
|
|
if let (Some(close), Some(dist)) = (closest, dist) {
|
|
if close < dist {continue;}
|
|
}
|
|
|
|
if let Some(_) = dist {
|
|
sensor = detection;
|
|
closest = dist;
|
|
}
|
|
|
|
}
|
|
|
|
sensors.push(sensor);
|
|
}
|
|
|
|
for idx in 0..robots.len() {
|
|
let (before, after) = robots.split_at_mut(idx);
|
|
let (robot, after) = after.split_first_mut().unwrap();
|
|
let robots = before.iter().chain(after.iter());
|
|
|
|
let robots = robots.map(|r| r as &dyn Mortal);
|
|
let peers = robots.chain(food.iter().map(|f| f as &dyn Mortal));
|
|
|
|
let mut death = robot.policy(&sensors[idx]);
|
|
if robot.team == BLUE {
|
|
death = false; // pacifists
|
|
}
|
|
if death {
|
|
new_craters.push(robot.crater());
|
|
robot.weapon(peers);
|
|
}
|
|
}
|
|
|
|
*watchdog += 1;
|
|
if food.is_empty() || *watchdog > 1000 {
|
|
let red = score.get(&Self::color_hash(RED)).unwrap_or(&0);
|
|
let blu = score.get(&Self::color_hash(BLUE)).unwrap_or(&0);
|
|
return Some((*red, *blu));
|
|
}
|
|
None
|
|
}
|
|
|
|
async fn render(&mut self) {
|
|
let Simulation { robots, food, craters, new_craters, watchdog: _, score, .. } = self;
|
|
|
|
clear_background(LIGHTGRAY);
|
|
for crater in &*new_craters {
|
|
draw_circle(crater.0, crater.1, crater.2, crater.3);
|
|
}
|
|
craters.append(&mut *new_craters);
|
|
for crater in &*craters {
|
|
draw_circle(crater.0, crater.1, crater.2, Color::new(0.75, 0.75, 0.75, 1.0));
|
|
}
|
|
for food in &*food {
|
|
food.draw();
|
|
|
|
}
|
|
for robot in &*robots {
|
|
robot.draw();
|
|
}
|
|
draw_text(format!("RED: {}", score.get(&Self::color_hash(RED)).unwrap_or(&0)), 20.0, 20.0, 30.0, DARKGRAY);
|
|
draw_text(format!("BLUE: {}", score.get(&Self::color_hash(BLUE)).unwrap_or(&0)), 20.0, 40.0, 30.0, DARKGRAY);
|
|
next_frame().await // TODO: framerate cap
|
|
}
|
|
}
|
|
|
|
async fn run_simulation(seed: u64, speedup: usize, config: &SimConfig) -> (i32, i32) {
|
|
let mut sim = Simulation::new(seed, &config);
|
|
|
|
let mut loo = 1;
|
|
loop {
|
|
let rendered = loo % speedup == 0;
|
|
let result = sim.sim_step();
|
|
|
|
if rendered {
|
|
sim.render().await
|
|
}
|
|
|
|
if let Some(result) = result {
|
|
return result;
|
|
}
|
|
|
|
loo += 1;
|
|
}
|
|
}
|
|
|
|
fn run_headless_simulation(seed: u64, config: &SimConfig) -> (i32, i32) {
|
|
let mut sim = Simulation::new(seed, config);
|
|
|
|
loop {
|
|
if let Some(result) = sim.sim_step() {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
#[derive(Parser)]
|
|
#[command(version, about, long_about = None)]
|
|
struct Cli {
|
|
/// run headless, allows multithreading
|
|
#[arg(short, long)]
|
|
no_gui: bool,
|
|
|
|
/// number of samples
|
|
#[arg(short, long)]
|
|
run_count: Option<u64>,
|
|
|
|
/// how many simulation steps to skip between frames (default 1)
|
|
#[arg(short, long)]
|
|
gui_speedup: Option<usize>,
|
|
|
|
/// Sets a custom config file
|
|
#[arg(short, long, value_name = "FILE")]
|
|
config: PathBuf,
|
|
|
|
/// Turn debugging information on
|
|
#[arg(short, long, action = clap::ArgAction::Count)]
|
|
debug: u8,
|
|
}
|
|
|
|
fn load_config(path: &std::path::Path) -> SimConfig {
|
|
let text = fs::read_to_string(path).unwrap();
|
|
toml::from_str(&text).unwrap()
|
|
}
|
|
|
|
fn print_average(red: i32, blu: i32, iters: u64) {
|
|
let iters = iters.max(1) as f32;
|
|
let red = red as f32 / iters;
|
|
let blu = blu as f32 / iters;
|
|
println!("{} wins on average! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"});
|
|
}
|
|
|
|
fn main() {
|
|
let cli = Cli::parse();
|
|
|
|
// Headless runs never need a window. Going through macroquad's event loop
|
|
// would otherwise cost seconds of X11/window setup on every invocation,
|
|
// which dwarfs the actual simulation for parallel sweeps.
|
|
if cli.no_gui {
|
|
let config = load_config(&cli.config);
|
|
let iters = cli.run_count.unwrap_or(1);
|
|
let (red, blu) = (0..iters)
|
|
.into_par_iter()
|
|
.map(|s| run_headless_simulation(s, &config))
|
|
.reduce(|| (0, 0), |a, b| (a.0 + b.0, a.1 + b.1));
|
|
print_average(red, blu, iters);
|
|
} else {
|
|
macroquad::Window::new("Adversarial Foraging Simulator", gui_main(cli));
|
|
}
|
|
}
|
|
|
|
async fn gui_main(cli: Cli) {
|
|
let iters = cli.run_count.unwrap_or(1);
|
|
let speedup = cli.gui_speedup.unwrap_or(5);
|
|
let config = load_config(&cli.config);
|
|
|
|
let (mut rtot, mut btot) = (0, 0);
|
|
for i in 0..iters {
|
|
let (red, blu) = run_simulation(i, speedup, &config).await;
|
|
rtot += red;
|
|
btot += blu;
|
|
println!("{} wins! RED: {red}, BLU: {blu}", if red > blu {"red"} else {"blue"});
|
|
}
|
|
|
|
print_average(rtot, btot, iters);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use parry2d::math::Pose2;
|
|
|
|
fn config() -> SimConfig {
|
|
toml::from_str(include_str!("../conf.toml")).unwrap()
|
|
}
|
|
|
|
/// The analytic replacements must agree with parry bit-for-bit, both on
|
|
/// whether a contact exists and on the reported `point1.distance(origin)`.
|
|
/// This is the load-bearing test behind the whole speedup: the hot path no
|
|
/// longer calls into parry at all.
|
|
#[test]
|
|
fn analytic_matches_parry() {
|
|
let mut rng = SmallRng::seed_from_u64(0xC0FFEE);
|
|
let mut seg_contacts = 0u64;
|
|
let mut bb_contacts = 0u64;
|
|
for _ in 0..2_000_000 {
|
|
// segment vs ball
|
|
let a = Vec2::new(rng.random_range(-500.0f32..500.0), rng.random_range(-500.0..500.0));
|
|
let ang = rng.random_range(0.0f32..TAU);
|
|
let len = rng.random_range(0.0f32..300.0);
|
|
let b = a + Vec2::new(ang.cos(), ang.sin()) * len;
|
|
let center = Vec2::new(rng.random_range(-600.0f32..600.0), rng.random_range(-600.0..600.0));
|
|
let pose = Pose::from_translation(center.to_array().into());
|
|
let parry = contact(&Pose2::IDENTITY, &Segment::new(a, b), &pose, &Ball::new(BOT_RADIUS), 0.0)
|
|
.unwrap()
|
|
.map(|c| c.point1.distance(a));
|
|
let mine = seg_ball_dist(a, b, center, BOT_RADIUS);
|
|
assert_eq!(parry.is_some(), mine.is_some(), "segment-ball contact existence");
|
|
if let (Some(p), Some(m)) = (parry, mine) {
|
|
assert_eq!(p.to_bits(), m.to_bits(), "segment-ball distance {p} != {m}");
|
|
seg_contacts += 1;
|
|
}
|
|
|
|
// ball vs ball
|
|
let c1 = Vec2::new(rng.random_range(-500.0f32..500.0), rng.random_range(-500.0..500.0));
|
|
let c2 = Vec2::new(rng.random_range(-520.0f32..520.0), rng.random_range(-520.0..520.0));
|
|
let p1 = Pose::from_translation(c1.to_array().into());
|
|
let p2 = Pose::from_translation(c2.to_array().into());
|
|
let parry = contact(&p1, &Ball::new(BOT_RADIUS), &p2, &Ball::new(BOT_RADIUS), 0.0)
|
|
.unwrap()
|
|
.map(|c| c.point1.distance(c1));
|
|
let mine = ball_ball_dist(c1, c2, BOT_RADIUS);
|
|
assert_eq!(parry.is_some(), mine.is_some(), "ball-ball contact existence");
|
|
if let (Some(p), Some(m)) = (parry, mine) {
|
|
assert_eq!(p.to_bits(), m.to_bits(), "ball-ball distance {p} != {m}");
|
|
bb_contacts += 1;
|
|
}
|
|
}
|
|
// Guard against the generator accidentally producing (almost) no contacts.
|
|
assert!(seg_contacts > 1000, "too few segment-ball contacts sampled");
|
|
assert!(bb_contacts > 1000, "too few ball-ball contacts sampled");
|
|
}
|
|
|
|
/// End-to-end behaviour lock: exact scores for the first 16 seeds. Any
|
|
/// change to the simulation's physics, ordering or collision code that
|
|
/// alters outcomes will fail here.
|
|
#[test]
|
|
fn regression_scores() {
|
|
let config = config();
|
|
let expected: [(i32, i32); 16] = [
|
|
(53, 45), (37, 61), (32, 48), (48, 51),
|
|
(43, 51), (30, 68), (59, 41), (49, 45),
|
|
(54, 37), (70, 28), (43, 41), (41, 42),
|
|
(44, 51), (49, 49), (30, 62), (48, 43),
|
|
];
|
|
for (seed, want) in expected.iter().enumerate() {
|
|
let got = run_headless_simulation(seed as u64, &config);
|
|
assert_eq!(got, *want, "seed {seed}");
|
|
}
|
|
}
|
|
|
|
/// The whole sweep must stay deterministic and reproduce the documented
|
|
/// average when run from scratch.
|
|
#[test]
|
|
fn regression_sweep_average() {
|
|
let config = config();
|
|
let (red, blu) = (0..200u64)
|
|
.map(|seed| run_headless_simulation(seed, &config))
|
|
.fold((0i32, 0i32), |a, b| (a.0 + b.0, a.1 + b.1));
|
|
assert_eq!(red, 9615, "red total over 200 seeds");
|
|
assert_eq!(blu, 9088, "blu total over 200 seeds");
|
|
}
|
|
|
|
/// Same seed twice must produce the same result (no hidden global state).
|
|
#[test]
|
|
fn deterministic() {
|
|
let config = config();
|
|
for seed in 0..8u64 {
|
|
assert_eq!(
|
|
run_headless_simulation(seed, &config),
|
|
run_headless_simulation(seed, &config),
|
|
"seed {seed}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Coarse performance smoke test. Run explicitly with:
|
|
/// cargo test --release -- --ignored --nocapture bench_full_sweep
|
|
#[test]
|
|
#[ignore]
|
|
fn bench_full_sweep() {
|
|
let config = config();
|
|
let t0 = std::time::Instant::now();
|
|
let (red, blu) = (0..200u64)
|
|
.map(|seed| run_headless_simulation(seed, &config))
|
|
.fold((0i32, 0i32), |a, b| (a.0 + b.0, a.1 + b.1));
|
|
let dt = t0.elapsed();
|
|
println!("200 headless runs (1 core): {dt:?} ({:.3} ms/run) RED={red} BLU={blu}", dt.as_secs_f64() * 1000.0 / 200.0);
|
|
}
|
|
}
|