Gunstein Vatnar
Posted on October 11, 2021
Introduction
I hope this simple pinball game can be an introduction for beginners to both the Bevy game engine and the Rapier physics engine.
Here I share a few details about how I implemented Pinball2D.
NB! Some knowledge of the Rust programming language is necessary.
Short video of Pinball2D in action:
Source code
Running the game on the web using WebAssembly
https://pinball2d.vatnar.no/
How to build this WebAssembly will be described in part 2.
Running the game
Rust and Cargo is a prerequisite.
git clone https://github.com/gunstein/Pinball2D.git
cargo run --release
Launch ball with space and move flippers with left and right arrow.
Main links for information about Bevy and Rapier
- Bevy - A data-driven game engine built in Rust
- Rapier- Fast 2D and 3D physics engine for the Rust programming language.
Disclaimer
This is neither a Bevy nor a Rapier tutorial, but a description of a simple demo game that I missed when I started out with Bevy and Rapier.
There's lots of room for improvements, but I still think it works as an introduction.
Organization
I recommend to use plugins and keep each plugin in a separate file. In my opinion this is tidy and intuitive.
Pinball2D has a startup system in every plugin.
impl Plugin for WallsPlugin {
fn build(&self, app: &mut AppBuilder) {
app
.add_startup_system(spawn_walls);
}
}
Setting up the board
I chose 640px/360px for the size of the game area. This makes up the Bevy coordinate system of the game. Origo in the middle (0,0), x-axis[-180, 180] and y-axis[-320, 320]. (See figure below.)
In earlier versions of bevy_rapier2d (before version 0.13) it was important to keep all sizes and coordinates in meters. That is not correct anymore. You should now use pixels for all sizes and tell Rapier how many pixels there is in a meter.
.add_plugin(RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(PIXELS_PER_METER))
Since Pinball2d already was using all sizes in meters (and since I'm lacy) I decided to define a global const PIXELS_PER_METER and use this const to compute all sizes and coordinates in pixels from meters.
pub const PIXELS_PER_METER : f32 = 492.3;
Example of usage:
let ball_pos = Vec2::new(crate::PIXELS_PER_METER * 0.3, crate::PIXELS_PER_METER * -0.2);
Spawning wall elements
I use bevy_prototype_lyon to create shapes
https://github.com/Nilirad/bevy_prototype_lyon
The basic process of creating a wall element:
- Create a shape. Bevy-coordinates is needed, so I scale from rapier coordinates.
- Make a geometry with the created shape and add a collider to a rigid body when spawning an entity for the wall element. (Colors and other drawing options are added when you create the geometry).
A wall is a static body. Most of the wall colliders should be solid. The exception is the bottom wall collider which is a sensor. A sensor collider will not influence other objects, but emit events when intersections occur. I despawn the ball when the bottom wall is hit and respawn the ball just above the launcher.
Rapier's documentation on colliders must be read:
https://www.rapier.rs/docs/user_guides/bevy_plugin/colliders
Below, the bottom wall is spawned. The collider is a sensor.
let shape_top_and_bottom_wall = shapes::Rectangle {
extents: Vec2::new(crate::PIXELS_PER_METER * 0.73, crate::PIXELS_PER_METER * 0.03),
origin: shapes::RectangleOrigin::Center
};
//Spawn bottom wall
let bottom_wall_pos = Vec2::new(0.0, crate::PIXELS_PER_METER * -0.64);
commands
.spawn((
ShapeBundle {
path: GeometryBuilder::build_as(&shape_top_and_bottom_wall),
..default()
},
Fill::color(Color::TEAL),
))
.insert(RigidBody::Fixed)
.insert(Collider::cuboid(
shape_top_and_bottom_wall.extents.x / 2.0,
shape_top_and_bottom_wall.extents.y / 2.0,
))
.insert(Sensor)
.insert(Transform::from_xyz(
bottom_wall_pos.x,
bottom_wall_pos.y,
0.0,
))
.insert(BottomWall);
Flippers
The flippers are spawned much the same as walls, but are KinematicPositionBased bodies. I keep different movement systems for the right and the left flipper. For every frame it's checked if the flipper key is pressed. Next rotation angle will be set accordingly and clamped to a sensible value.
#[derive(Component)]
struct LeftFlipper{
point_of_rotation : Vec3,
curr_angle : f32,
}
fn left_flipper_movement(
keyboard_input: Res<Input<KeyCode>>,
mut left_flippers: Query<(&mut LeftFlipper, &mut Transform), With<LeftFlipper>>,
) {
for (mut left_flipper, mut left_flipper_transform) in left_flippers.iter_mut() {
let mut new_angle = left_flipper.curr_angle;
let change_angle:f32;
if keyboard_input.pressed(KeyCode::Left)
{
change_angle = 0.09;
}
else
{
change_angle = -0.07;
}
new_angle += change_angle;
let new_clamped_angle = new_angle.clamp(-0.3, 0.3);
let pivot_rotation = Quat::from_rotation_z(new_clamped_angle - left_flipper.curr_angle);
left_flipper_transform.rotate_around(left_flipper.point_of_rotation, pivot_rotation);
left_flipper.curr_angle = new_clamped_angle;
}
}
Pins
Pins are much like wall objects but change color for a second when hit.
I respawn the hit pin to change color. I would have prefered to change it's color without respawning, but couldn't make it work.
The Pin component struct:
#[derive(Component)]
struct Pin{
timestamp_last_hit : f64,
position : Vec2,
}
The code below shows how contact events are handled. If the event received is of type "Started" and one of the participants in the event is a pin, then the pin will respawn.
fn handle_pin_events(
query: Query<(Entity, &Pin), With<Pin>>,
time: Res<Time>,
mut contact_events: EventReader<CollisionEvent>,
mut commands: Commands
) {
for contact_event in contact_events.iter() {
for (entity, pin) in query.iter() {
if let CollisionEvent::Started(h1, h2, _event_flag) = contact_event {
if h1 == &entity || h2 == &entity {
//Respawn to change color
let pos = pin.position;
let timestamp_last_hit = time.seconds_since_startup();
commands.entity(entity).despawn();
spawn_single_pin(&mut commands, pos, Some(timestamp_last_hit));
}
}
}
}
}
Ball
The ball is the only entity with a dynamic rigid body type.
Spawning is still similar to other entities:
fn spawn_ball(mut commands: Commands) {
let ball_pos = Vec2::new(
crate::PIXELS_PER_METER * 0.3,
crate::PIXELS_PER_METER * -0.2,
);
let shape_ball = shapes::Circle {
radius: crate::PIXELS_PER_METER * 0.03,
center: Vec2::ZERO,
};
commands
.spawn((
ShapeBundle {
path: GeometryBuilder::build_as(&shape_ball),
..default()
},
Fill::color(Color::BLACK),
Stroke::new(Color::TEAL, 2.0),
))
.insert(RigidBody::Dynamic)
.insert(Sleeping::disabled())
.insert(Ccd::enabled())
.insert(Collider::ball(shape_ball.radius))
.insert(Transform::from_xyz(ball_pos.x, ball_pos.y, 0.0))
.insert(ActiveEvents::COLLISION_EVENTS)
.insert(Restitution::coefficient(0.7))
.insert(Ball);
}
Two things to mention from the code above:
- Setting the ActiveEvents::COLLISION_EVENTS bit to 1 enables the collision events involving the collider.
- Restitution is a measure for how bouncy the ball is.
The ball is respawned above the launcher when it hits the bottom wall.
fn handle_ball_intersections_with_bottom_wall(
rapier_context: Res<RapierContext>,
query_ball: Query<Entity, With<Ball>>,
query_bottom_wall: Query<Entity, With<BottomWall>>,
mut commands: Commands
) {
let mut should_spawn_ball = false;
for entity_bottom_wall in query_bottom_wall.iter() {
for entity_ball in query_ball.iter() {
/* Find the intersection pair, if it exists, between two colliders. */
if rapier_context.intersection_pair(entity_bottom_wall, entity_ball) == Some(true) {
commands.entity(entity_ball).despawn();
should_spawn_ball = true;
}
}
}
if should_spawn_ball
{
spawn_ball(commands);
}
}
Launcher
The launcher works very much like a flipper, but there is obviously no rotation involved.
fn launcher_movement(
keyboard_input: Res<Input<KeyCode>>,
mut launchers: Query<(&mut Launcher, &mut Transform), With<Launcher>>,
) {
for (launcher, mut launcher_transform) in launchers.iter_mut() {
let mut next_ypos = launcher_transform.translation.y;
if keyboard_input.pressed(KeyCode::Space)
{
next_ypos = next_ypos + crate::PIXELS_PER_METER * 0.04;
}
else
{
next_ypos = next_ypos - crate::PIXELS_PER_METER * 0.04;
}
let clamped_ypos = next_ypos.clamp(launcher.start_point.y, launcher.start_point.y + crate::PIXELS_PER_METER * 0.05);
launcher_transform.translation.y = clamped_ypos;
}
}
Posted on October 11, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.