8. Let's play Pong
Used imports in this chapter
use cortex_m_rt::entry;
use defmt::{info, unwrap};
use embassy_101::game::{GameState, InputState};
use embassy_executor::{Executor, InterruptExecutor, Spawner};
use embassy_futures::select::select4;
use embassy_futures::yield_now;
use embassy_nrf::gpio::{AnyPin, Input, Pull};
use embassy_nrf::interrupt;
use embassy_nrf::interrupt::{InterruptExt, Priority};
use embassy_nrf::peripherals::SPI3;
use embassy_nrf::{
bind_interrupts,
gpio::{Level, Output, OutputDrive},
peripherals, spim,
};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::signal::Signal;
use embassy_sync::{blocking_mutex::raw::NoopRawMutex, mutex::Mutex};
use embedded_graphics::draw_target::DrawTarget;
use embedded_graphics::geometry::{Dimensions, OriginDimensions};
use embedded_graphics::pixelcolor::raw::{RawData, RawU16};
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::Rectangle;
use embedded_graphics::Drawable;
use static_cell::StaticCell;
use {defmt_rtt as _, panic_probe as _};
Ok, we have already come a long way exploring many fundamental parts of the ecosystem. In this last chapter, we are going to build upon the foundations we built over the course of the last chapters and build a little game called Pong.
We rely on embedded_graphics
for their abstractions to build more complex shapes.
Additionally, we will use Embassy to asynchronously process the input events.
Getting access in the draw_task
to the input state
To share the input state, we need a way to store the information coming from the input_handler
.
Look into the src/game.rs
file, where there is already an InputState
struct defined.
Some traits already have been derived, such that we can more easily share the input state.
#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub struct InputState {
pub btn1: bool,
pub btn2: bool,
pub btn3: bool,
pub btn4: bool,
}
Just as before, let's add some helper type aliases.
// a shorthand, to not have to type this out every time we need it
pub type InputStateType = Mutex<CriticalSectionRawMutex, InputState>;
pub type InputStateRef = &'static InputStateType;
Then in our main
function right before we spawn our input_handler
, we initialize our struct, and retrieve a static reference to it:
// init input state, so we know that from now on input state will always be valid
static INPUT_STATE: StaticCell<InputStateType> = StaticCell::new();
let input_static_ref: InputStateRef = INPUT_STATE.init(Mutex::new(InputState::default()));
Something peculiar to the previous snippet is the use of CriticalSectionRawMutex
instead of the usual NoopRawMutex
.
This is because we are sharing across 2 different executors, where one of them is an interrupt based executor.
The CriticalSectionRawMutex
ensures that no interrupts will be fired while it is locked, thereby sufficiently protecting our state.
The next step will now be to pass this freshly created reference to the corresponding tasks.
This includes sharing the reference and populating it with correct data in input_handler
, and reading the state in draw_task
.
At the end, you should have something like the following in draw_task
:
// Later we will initialize the game state here
loop {
// make sure that the mutex guard for input state is dropped
// as soon as possible after we have copied the state.
let input_state = { *input_state.lock().await };
// Update here the game
{
let mut fb = back_buffer.lock().await;
let fb = &mut *fb; // unpack guard
// start drawing calls
unwrap!(fb.fill_solid(&fb.bounding_box(), Rgb565::BLACK));
// Draw here the game
signal.signal(()); // signal to driver that draw has been called
} // Guard is dropped here
led.set_low();
yield_now().await;
}
And the following input_handler
:
#[embassy_executor::task]
async fn input_handler(
btns: (AnyPin, AnyPin, AnyPin, AnyPin),
input_state: InputStateRef,
led: AnyPin, // We optionally add here a pin, such that we can more easily debug things
) {
// Initialize all the buttons
let mut btn1 = Input::new(btns.0, Pull::Up);
let mut btn2 = Input::new(btns.1, Pull::Up);
let mut btn3 = Input::new(btns.2, Pull::Up);
let mut btn4 = Input::new(btns.3, Pull::Up);
let mut led = Output::new(led, Level::Low, OutputDrive::Standard);
loop {
// Wait until we see a button change
select4(
btn1.wait_for_any_edge(),
btn2.wait_for_any_edge(),
btn3.wait_for_any_edge(),
btn4.wait_for_any_edge(),
)
.await;
// Create an updated state
led.set_high();
let state = InputState {
btn1: btn1.is_low(),
btn2: btn2.is_low(),
btn3: btn3.is_low(),
btn4: btn4.is_low(),
};
{
// Send the update to the other tasks
*input_state.lock().await = state; // keep lock as small as possible
} // drop immediatly
// Log something, such that we know what is happening
info!(
"Detected input change 1: {}, 2: {}, 3: {}, 4: {}",
state.btn1, state.btn2, state.btn3, state.btn4,
);
led.set_low();
}
}
Implementing the game logic
Next is actually implementing the game of Pong. We will start by creating a struct for the players:
pub struct Player {
position: Point, // the current position of the player
}
impl Player {
/// p is the initial point to spawn the player on
pub fn new(p: Point) -> Self {
Self { position: p }
}
// make sure that the player can only move on the screen
// the max height is 128 pixels
pub fn move_up(&mut self) {
todo!()
}
// make sure that the player can only move on the screen
// the min height is 0 pixels
pub fn move_down(&mut self) {
todo!()
}
}
Solution: move_up
pub fn move_up(&mut self) {
self.position.y -= 10;
self.position.y = self.position.y.clamp(0, 128);
}
Solution: move_down
pub fn move_down(&mut self) {
self.position.y += 10;
self.position.y = self.position.y.clamp(0, 128);
}
To make it easier for us to later do the collision detection, while leveraging embedded_graphics
, we will implement some interesting traits that keep track off how big the player is (already provided), and how to draw the player on screen (target):
// Drawable is something that can be drawn onto the screen
impl Drawable for Player {
type Color = Rgb565; // needed to be compatible with our screen
type Output = (); // we will not need to return anything after drawing
fn draw<D>(&self, target: &mut D) -> Result<Self::Output, D::Error>
where
D: DrawTarget<Color = Self::Color>,
{
// Make sure you use the same values as used in the Dimensions
// trait implementation. Otherwise the collision detection will
// be harder to correctly implement
todo!("Look at how we have drawn a rectangle in the past")
}
}
// Have a way to know what the bounding box of the player is
impl Dimensions for Player {
fn bounding_box(&self) -> Rectangle {
Rectangle {
top_left: self.position,
size: Size {
width: 5, // these seem good values
height: 30,
},
}
}
}
Solution: Draw
/// Draw a player as a rectangle and use the values from Dimensions-trait.
/// This makes it easier in the GameState to do the collision detection calculations
fn draw<D>(&self, target: &mut D) -> Result<Self::Output, D::Error>
where
D: DrawTarget<Color = Self::Color>,
{
let rectangle = self.bounding_box();
let style = PrimitiveStyle::with_fill(Rgb565::WHITE);
rectangle.draw_styled(&style, target)
}
Similarly for the ball, we are going to need to implement a few methods on the Pong struct.
pub struct Pong {
pub position: Point, // current position
pub velocity: Point, // to where we will update the position
}
impl Pong {
pub fn new(p: Point) -> Self {
Self {
position: p,
velocity: Point::new(3, 3),
}
}
pub async fn update(&mut self) {
todo!()
}
pub fn flip_horizontal(&mut self) {
todo!()
}
pub fn flip_vertical(&mut self) {
todo!()
}
}
impl Drawable for Pong {
type Color = Rgb565;
type Output = ();
fn draw<D>(&self, target: &mut D) -> Result<Self::Output, D::Error>
where
D: DrawTarget<Color = Self::Color>,
{
todo!("This time draw me as a circle -> take a look at the primitives section in the embedded_graphics documentation")
}
}
impl Dimensions for Pong {
fn bounding_box(&self) -> Rectangle {
Rectangle {
top_left: self.position,
size: Size {
width: 10, // seem good values
height: 10,
},
}
}
}
Solution: Update
pub async fn update(&mut self) {
self.position += self.velocity;
}
Solution: Flip horizontal/vertical
pub fn flip_horizontal(&mut self) {
self.velocity.y = -self.velocity.y;
}
pub fn flip_vertical(&mut self) {
self.velocity.x = -self.velocity.x;
}
Solution: Draw
fn draw<D>(&self, target: &mut D) -> Result<Self::Output, D::Error>
where
D: DrawTarget<Color = Self::Color>,
{
let circle = Circle::new(self.position, 10);
let style = PrimitiveStyle::with_fill(Rgb565::WHITE);
circle.draw_styled(&style, target)
}
And now the game state itself.
We will need a struct which can store both players, their scores and the ball.
There is already a GameState
defined in src/game.rs
, but it needs some extra code to be completely functional.
The definition of the GameState
is:
pub struct GameState {
score1: u32, // The score of player 1
player1: Player, // Player 1
score2: u32, // The score of player 2
player2: Player, // Player 2
pong: Pong, // The ball
screen_box: Rectangle, // The bounds of the current screen
}
We have already defined the following methods:
new
: The constructorupdate
: Updates the game state and does the collision detectiondraw
: Draws a line in the middle, draws the scores, the 2 players and the ball
Now, you should complete the draw
method as it does not yet draw the players and the ball as it should.
impl Drawable for GameState {
type Color = Rgb565;
type Output = ();
fn draw<D>(&self, target: &mut D) -> Result<Self::Output, D::Error>
where
D: DrawTarget<Color = Self::Color>,
{
// ... more drawing code here ...
// power of the abstractions of embedded_graphics -> draw the players and ball
todo!("Draw player1, player2 and pong"); // REPLACE THIS
}
}
Solution
impl Drawable for GameState {
type Color = Rgb565;
type Output = ();
fn draw<D>(&self, target: &mut D) -> Result<Self::Output, D::Error>
where
D: DrawTarget<Color = Self::Color>,
{
let background_style = PrimitiveStyle::with_fill(Rgb565::CSS_GRAY);
let screen_box = target.bounding_box();
let split_line = Rectangle::with_center(
screen_box.center(),
Size {
width: 2,
height: screen_box.size.height,
},
);
split_line.draw_styled(&background_style, target)?;
// use the font system of embedded_graphics
let score_style = MonoTextStyle::new(&FONT_6X10, Rgb565::CSS_GRAY);
// pprint score 1 with a stack allocated string
let mut score1 = heapless::String::<4>::new();
if { write!(score1, "{}", self.score1) }.is_err() {
defmt::error!("Error while formatting score 1");
}
Text::with_alignment(
&score1,
Point::new(3 * screen_box.size.width as i32 / 4, 10),
score_style,
Alignment::Center,
)
.draw(target)?;
// pprint score 2
let mut score2 = heapless::String::<4>::new();
if { write!(score2, "{}", self.score2) }.is_err() {
defmt::error!("Error while formatting score 2");
}
Text::with_alignment(
&score2,
Point::new(screen_box.size.width as i32 / 4, 10),
score_style,
Alignment::Center,
)
.draw(target)?;
// power of the abstractions of embedded_graphics -> draw the players and ball
self.player1.draw(target)?; // NEW
self.player2.draw(target)?; // NEW
self.pong.draw(target) // NEW
}
}
Now all we still have to do is initialize our game state in the draw_task
, update it every frame, and draw it to the framebuffer.
// draw_task
let mut game_state = todo!(); // NEW
loop {
let input_state = { *input_state.lock().await };
game_state.update(input_state).await; // NEW
{
let mut fb = back_buffer.lock().await;
let fb = &mut *fb; // unpack guard
// start drawing calls
unwrap!(fb.fill_solid(&fb.bounding_box(), Rgb565::BLACK));
unwrap!(game_state.draw(fb)); // NEW
} // Guard is dropped here
signal.signal(()); // signal to driver that draw has been called
yield_now().await;
}
Solution
let mut game_state = GameState::new(Rectangle {
top_left: Point::new(0, 0),
size: Size::new(160, 128),
});
🎉 Congratulations 🎉
This is the end of this book. You should now have a working Pong game. Try to play a bit and enjoy your async Pong implementation.
If you got here, but did not have a finished game, try to make sure that you have spawned all the necessary tasks.
The tasks that need to be spawned:
input_handler
: Spawned in the high priority executormain
: Spawned as the first task in the low priority executordraw_task
: Called at the end of mainasync_driver
: Spawned at the end of main
If your input is not working as expected, make sure that:
- You update the
InputState
ininput_handler
- The input is correctly shared between the
input_handler
and thedraw_task
- You drop the mutex guard quickly enough in the
draw_task
If the game is not updating, see if you maybe forgot to call .await
on a future.
Futures in Rust do not call themselves, so if you forget to call .await
, to progress will be made.
Ps. Is it getting a bit slow? Try to compile in release mode:
$ cargo run --release # and now wait until it compiles :p