#![cfg(feature = "physics")]
use ponko::*;
const GRAVITY: Vec2 = Vec2::new(0.0, 980.0);
fn ball(at: Vec2) -> (Transform2D, RigidBody, Collider) {
(Transform2D::at(at), RigidBody::dynamic(), Collider::circle(10.0))
}
fn ground(at: Vec2) -> (Transform2D, RigidBody, Collider) {
(
Transform2D::at(at),
RigidBody::fixed(),
Collider::rect(vec2(400.0, 20.0)),
)
}
fn position(world: &mut World, e: Entity) -> Vec2 {
world.get::<Transform2D>(e).unwrap().position
}
#[test]
fn a_falling_ball_comes_to_rest_on_the_ground() {
let mut ball_entity = None;
let mut world = Game::new("t", 1, 1).physics(GRAVITY).run_headless(180, |ctx| {
ball_entity = Some(ctx.world.spawn(ball(vec2(0.0, 0.0))));
ctx.world.spawn(ground(vec2(0.0, 200.0)));
});
let y = position(&mut world, ball_entity.unwrap()).y;
assert!((y - 180.0).abs() < 1.5, "rests at {y}");
}
#[test]
fn moving_the_transform_teleports_the_body() {
let mut world = Game::new("t", 1, 1)
.physics(Vec2::ZERO)
.sim_system(|ctx: &mut Ctx| {
if ctx.sim_tick() == 5 {
ctx.world
.query::<&mut Transform2D>()
.with::<RigidBody>()
.for_each(|t| t.position = vec2(500.0, 7.0));
}
})
.run_headless(10, |ctx| {
ctx.world.spawn(ball(vec2(0.0, 0.0)));
});
let p = world.query::<&Transform2D>().iter().next().unwrap().position;
assert_eq!(p, vec2(500.0, 7.0), "no gravity, no speed: it stays where it was put");
}
#[test]
fn velocity_sets_and_reports_speed() {
let mut world = Game::new("t", 1, 1).physics(Vec2::ZERO).run_headless(60, |ctx| {
let (t, r, c) = ball(vec2(0.0, 0.0));
ctx.world.spawn((t, r, c, Velocity::linear(vec2(120.0, 0.0))));
});
let (x, speed) = world
.query::<(&Transform2D, &Velocity)>()
.iter()
.map(|(t, v)| (t.position.x, v.linear.x))
.next()
.unwrap();
assert!((x - 120.0).abs() < 1.0, "one second at 120 px/s: {x}");
assert!((speed - 120.0).abs() < 1e-3);
}
#[test]
fn bodies_without_a_velocity_component_keep_their_speed() {
let mut world = Game::new("t", 1, 1).physics(GRAVITY).run_headless(60, |ctx| {
ctx.world.spawn(ball(vec2(0.0, 0.0)));
});
let y = world.query::<&Transform2D>().iter().next().unwrap().position.y;
assert!((y - 490.0).abs() < 10.0, "free fall for a second: {y}");
}
#[test]
fn body_counts_follow_the_world() {
let counts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let seen = counts.clone();
Game::new("t", 1, 1)
.sim_system(|ctx: &mut Ctx| {
let bodies: Vec<Entity> = ctx.world.query::<Entity>().with::<RigidBody>().iter().collect();
match ctx.sim_tick() {
1 => {
ctx.world.despawn(bodies[0]);
}
2 => {
ctx.world.remove::<Collider>(bodies[0]);
}
3 => {
ctx.world.spawn(ball(vec2(50.0, 0.0)));
}
_ => {}
}
})
.physics(Vec2::ZERO)
.sim_system(move |ctx: &mut Ctx| seen.borrow_mut().push(ctx.physics.body_count()))
.run_headless(4, |ctx| {
for x in 0..3 {
ctx.world.spawn(ball(vec2(x as f32 * 100.0, 0.0)));
}
});
assert_eq!(*counts.borrow(), [3, 2, 1, 2]);
}
#[derive(Default)]
struct Seen(Vec<(Entity, Entity, bool, bool)>);
#[test]
fn collisions_and_sensor_overlaps_arrive_as_events() {
let mut ids = (None, None, None);
let world = Game::new("t", 1, 1)
.physics(GRAVITY)
.sim_system(|ctx: &mut Ctx| {
let mut seen = Vec::new();
for c in ctx.events.read::<CollisionStarted>() {
seen.push((c.a, c.b, c.sensor, true));
}
ctx.world.resource_mut::<Seen>().0.extend(seen);
})
.run_headless(120, |ctx| {
ctx.world.insert_resource(Seen::default());
ids.0 = Some(ctx.world.spawn(ball(vec2(0.0, 0.0))));
ids.1 = Some(ctx.world.spawn((
Transform2D::at(vec2(0.0, 100.0)),
RigidBody::fixed(),
Collider::rect(vec2(80.0, 20.0)).sensor(),
)));
ids.2 = Some(ctx.world.spawn(ground(vec2(0.0, 300.0))));
});
let (ball, trigger, floor) = (ids.0.unwrap(), ids.1.unwrap(), ids.2.unwrap());
let seen = &world.resource::<Seen>().0;
let pair = |x: Entity, y: Entity| {
seen.iter()
.find(|s| (s.0 == x && s.1 == y) || (s.0 == y && s.1 == x))
.copied()
};
assert!(
pair(ball, trigger).expect("passed through the sensor").2,
"flagged as a sensor"
);
assert!(!pair(ball, floor).expect("landed on the floor").2);
let y = world.get::<Transform2D>(ball).unwrap().position.y;
assert!(y > 250.0, "the sensor didn't stop it: {y}");
}
#[derive(Default)]
struct Found {
ray: Option<RayHit>,
at: Vec<Entity>,
near: Vec<Entity>,
behind: Option<RayHit>,
}
#[test]
fn rays_and_overlap_queries_find_entities() {
let mut wall = None;
let mut world = Game::new("t", 1, 1)
.physics(Vec2::ZERO)
.sim_system(|ctx: &mut Ctx| {
if ctx.sim_tick() == 1 {
let mut found = Found {
ray: ctx.physics.cast_ray(vec2(0.0, 0.0), vec2(1.0, 0.0), 1000.0),
behind: ctx.physics.cast_ray(vec2(0.0, 0.0), vec2(-1.0, 0.0), 1000.0),
..Found::default()
};
ctx.physics.entities_at(vec2(300.0, 5.0), &mut found.at);
ctx.physics.entities_in_circle(vec2(200.0, 0.0), 95.0, &mut found.near);
ctx.world.insert_resource(found);
}
})
.run_headless(2, |ctx| {
wall = Some(ctx.world.spawn((
Transform2D::at(vec2(100.0, 0.0)),
RigidBody::fixed(),
Collider::rect(vec2(20.0, 200.0)),
)));
ctx.world.spawn((
Transform2D::at(vec2(300.0, 0.0)),
RigidBody::fixed(),
Collider::circle(10.0),
));
});
let found = world.remove_resource::<Found>().unwrap();
let hit = found.ray.expect("the ray hits the wall");
assert_eq!(hit.entity, wall.unwrap());
assert!((hit.distance - 90.0).abs() < 1e-3, "{}", hit.distance);
assert!((hit.point - vec2(90.0, 0.0)).length() < 1e-3);
assert!((hit.normal - vec2(-1.0, 0.0)).length() < 1e-3);
assert_eq!(found.at.len(), 1, "the point is inside the circle only");
assert_eq!(found.near.len(), 2, "the circle reaches both");
assert!(found.behind.is_none(), "nothing behind");
}
#[test]
fn impulses_push_dynamic_bodies() {
let mut world = Game::new("t", 1, 1)
.physics(Vec2::ZERO)
.sim_system(|ctx: &mut Ctx| {
if ctx.sim_tick() == 1 {
let e = ctx.world.query::<Entity>().with::<RigidBody>().iter().next().unwrap();
let mass = ctx.physics.mass(e).unwrap();
assert!(
(mass - std::f32::consts::PI * 100.0).abs() < 1.0,
"density 1 x area: {mass}"
);
assert!(ctx.physics.apply_impulse(e, vec2(mass * 60.0, 0.0)));
}
})
.run_headless(61, |ctx| {
ctx.world.spawn(ball(vec2(0.0, 0.0)));
});
let x = world.query::<&Transform2D>().iter().next().unwrap().position.x;
assert!((x - 60.0).abs() < 2.0, "60 px/s for about a second: {x}");
}
fn pile(seed_offset: f32) -> Vec<(Vec2, f32)> {
let mut world = Game::new("t", 1, 1).physics(GRAVITY).run_headless(240, |ctx| {
ctx.world.spawn(ground(vec2(0.0, 300.0)));
for i in 0..40 {
let x = (i % 8) as f32 * 19.0 - 70.0 + seed_offset;
let y = -(i / 8) as f32 * 25.0;
let (t, r, _) = ball(vec2(x, y));
ctx.world
.spawn((t, r, Collider::rect(vec2(16.0, 16.0)).restitution(0.2)));
}
});
world
.query::<&Transform2D>()
.with::<RigidBody>()
.iter()
.map(|t| (t.position, t.rotation))
.collect()
}
#[test]
fn the_same_start_gives_the_same_pile() {
let a = pile(0.0);
let b = pile(0.0);
assert_eq!(a, b, "bit-identical");
assert_ne!(a, pile(0.5), "and the start matters");
}
#[test]
fn physics_components_are_reflected() {
let mut world = Game::new("t", 1, 1).physics(Vec2::ZERO).run_headless(1, |ctx| {
let (t, r, c) = ball(vec2(1.0, 2.0));
ctx.world.spawn((t, r, c, Velocity::default()));
});
let e = world.query::<Entity>().with::<RigidBody>().iter().next().unwrap();
let json = world.entity_json(e).unwrap();
assert_eq!(json["RigidBody"]["kind"], "Dynamic");
assert_eq!(json["Collider"]["shape"]["Circle"]["radius"], 10.0);
world.set_json(e, "Velocity", json!({ "linear": [5.0, 0.0] })).unwrap();
assert_eq!(world.get::<Velocity>(e).unwrap().linear, vec2(5.0, 0.0));
}
#[test]
fn changing_a_collider_rebuilds_it() {
let mut world = Game::new("t", 1, 1)
.physics(GRAVITY)
.sim_system(|ctx: &mut Ctx| {
if ctx.sim_tick() == 10 {
ctx.world.query::<&mut Collider>().with::<RigidBody>().for_each(|c| {
if matches!(c.shape, Shape::Circle { .. }) {
c.sensor = true;
}
});
}
})
.run_headless(120, |ctx| {
ctx.world.spawn(ball(vec2(0.0, 150.0)));
ctx.world.spawn(ground(vec2(0.0, 200.0)));
});
let lowest = world
.query::<&Transform2D>()
.iter()
.map(|t| t.position.y)
.fold(f32::MIN, f32::max);
assert!(lowest > 300.0, "fell through: {lowest}");
}
fn wedge() -> Collider {
Collider::polygon(&[vec2(-30.0, 15.0), vec2(30.0, 15.0), vec2(-30.0, -15.0)])
}
#[test]
fn a_falling_polygon_comes_to_rest_on_its_flat_side() {
let mut piece = None;
let world = Game::new("t", 1, 1).physics(GRAVITY).run_headless(240, |ctx| {
piece = Some(
ctx.world
.spawn((Transform2D::at(vec2(0.0, 0.0)), RigidBody::dynamic(), wedge())),
);
ctx.world.spawn(ground(vec2(0.0, 200.0)));
});
let t = *world.get::<Transform2D>(piece.unwrap()).unwrap();
assert!((t.position.y - 175.0).abs() < 1.5, "rests at {}", t.position.y);
assert!(t.rotation.abs() < 0.01, "stays flat: {}", t.rotation);
}
#[test]
fn polygons_survive_save_and_load() {
let mut world = World::new();
world.reflect::<Collider>();
let e = world.spawn((wedge(),));
let save = world.save();
world.get_mut::<Collider>(e).unwrap().shape = Collider::circle(1.0).shape;
world.load(&save).unwrap();
assert_eq!(world.get::<Collider>(e).unwrap().shape.points(), wedge().shape.points());
}
#[test]
#[should_panic(expected = "Collider::polygon: 3 to 8 points, got 2")]
fn a_polygon_needs_three_points() {
let _ = Collider::polygon(&[Vec2::ZERO, Vec2::X]);
}
#[test]
#[should_panic(expected = "Collider::polygon: 3 to 8 points, got 9")]
fn a_polygon_has_at_most_eight_points() {
let _ = Collider::polygon(&[Vec2::ONE; 9]);
}
#[test]
fn collider_json_needs_only_a_shape() {
let mut world = World::new();
world.reflect::<Collider>();
let e = world.spawn((Transform2D::default(),));
world
.set_json(e, "Collider", json!({ "shape": { "Circle": { "radius": 4.0 } } }))
.unwrap();
assert_eq!(*world.get::<Collider>(e).unwrap(), Collider::circle(4.0));
}