Demilade Sonuga's blog

All posts

The Game Scene

2023-01-11

It's finally time to draw up our game on the screen.

First off, delete that block.bmp file from the project. Get the assets folder from the repo

At this point, your blasterball directory should be looking like this:

blasterball/
|   src/
|   |   assets/
|   |   |   ball.bmp
|   |   |   blue_block.bmp
|   |   |   canon-in-d-major.wav
|   |   |   cyan_block.bmp
|   |   |   drum.wav
|   |   |   green_block.bmp
|   |   |   paddle.bmp
|   |   |   pink_block.bmp
|   |   |   yellow_block.bmp
|   |   bitmap.rs
|   |   font.rs
|   |   main.rs
|   |   uefi.rs
|   Cargo.lock
|   Cargo.toml

Before we proceed we, first of all, have to ask the question: how exactly is the game scene supposed to look and how do I translate that to code?

To answer the first question, the game scene is supposed to look something like this:

The Game Scene

The second question of how to translate to code is not as straightforward as the first. We combine what we already have to produce our desired outcome.

Let's start this translation process by getting rid of our experimental animation in efi_main:

#[no_mangle]
extern "efiapi" fn efi_main(
    handle: *const core::ffi::c_void,
    sys_table: *mut SystemTable,
) -> usize {
    let sys_table = unsafe { &*sys_table };
    let boot_services = sys_table.boot_services();
    
    let init_graphics_result = init_graphics(boot_services);
    if let Err(msg) = init_graphics_result {
        let simple_text_output = sys_table.simple_text_output();
        write!(simple_text_output, "{}", msg);
        loop {}
    }
    let screen = init_graphics_result.unwrap();
    
    init_screen(screen);
    
    let screen = get_screen().unwrap();
    
    /* DELETED:
    let block_bytes = include_bytes!("./block.bmp");

    let block = Bitmap::new(block_bytes);
    
    let mut block_position = (0, 0);
    while block_position.1 < NO_OF_PIXELS_IN_A_ROW {
        draw_bitmap(screen, &block, block_position);
        erase_bitmap(screen, &block, block_position);
        block_position.1 += 1;
    }
    */
    
    0
}

Next, we create a new module game.rs in the blasterball src directory. All the game code will go in here. This makes sense because it seems main.rs's function is to set the stage for the game and not to run it.

In game.rs, we create a new function blasterball:

pub fn blasterball(screen: &mut Screen) -> ! {

}

It takes a Screen reference because it needs to draw to the screen. The return value of ! tells the compiler that this function is never meant to return. This makes sense because the game is going to keep running until we explicitly exit it.

Now, to begin the translation process, we start with the blocks. Each block in the asset folder has a width of 80 pixels and a height of 36 pixels. So, we can have a total of 640 / 80 == 8 blocks in a row. The number of rows of blocks to use is a matter of personal taste. The total number of rows of the screen the blocks can occupy is 480 / 36 == 13.333... So, if we decide to fill in the screen with blocks completely, we'll have approximately 13 rows of blocks. Drawing 4 rows will use up about a third of the screen for the blocks. A third of the screen for the blocks seems like a reasonable amount, so we'll go with 4 rows.

We need an algorithm that will draw the blocks on the screen, reasonably distributing the block colors. Come up with one now.

  1. Load the images.
  2. Cycle through them, drawing them on screen until 4 full rows of blocks have been drawn.

In pseudocode, the algorithm will look something like this:

fn blasterball(screen)
    blocks <- load all blocks into an array
    for each block in cycle(blocks)
        if all 4 rows have been filled
            break
        else
            draw_bitmap(screen, block)

The block position is omitted to make the algorithm clear. The cycle function there cycles endlessly through an array. So if blocks = [red, blue, green], then cycle(blocks) = [red, blue, green, red, blue, green, red, ...]. This way, the colors of the blocks will be reasonably distributed.

The only thing here that seems not so straightforward to translate to Rust is the cycle function. To do this we're going to make use of the Iterator trait.

We've encountered Iterator before in one of those early posts but we didn't go into what exactly they were and why they're needed. Remember that traits define common behavior of different things. The Write trait defines behavior for anything that characters can be written into. The Index and IndexMut traits define behavior for anything that can be indexed. Likewise, the Iterator trait defines the behavior of anything that can be iterated over.

Once this Iterator trait has been defined on a thing, there are automatically a lot of default implementations of useful functions. You can find them in the docs

One of those useful functions automatically defined on an Iterator is cycle which does exactly what we described.

Our translation to Rust now seems straightforward:

// NEW:
use crate::uefi::{Screen, NO_OF_PIXELS_IN_A_ROW};
use crate::bitmap::{draw_bitmap, Bitmap};

// The number of rows of blocks that are going to be drawn on screen
const NO_OF_BLOCK_ROWS: usize = 4;

pub fn blasterball(screen: &mut Screen) -> ! {
    // NEW:
    // Loading the into an array
    let blue_block_bytes = include_bytes!("./assets/blue_block.bmp");
    let blue_block = Bitmap::new(blue_block_bytes);
    let cyan_block_bytes = include_bytes!("./assets/cyan_block.bmp");
    let cyan_block = Bitmap::new(cyan_block_bytes);
    let green_block_bytes = include_bytes!("./assets/green_block.bmp");
    let green_block = Bitmap::new(green_block_bytes);
    let pink_block_bytes = include_bytes!("./assets/pink_block.bmp");
    let pink_block = Bitmap::new(pink_block_bytes);
    let yellow_block_bytes = include_bytes!("./assets/yellow_block.bmp");
    let yellow_block = Bitmap::new(yellow_block_bytes);
    let paddle_bytes = include_bytes!("./assets/paddle.bmp");
    let paddle = Bitmap::new(paddle_bytes);
    let ball_bytes = include_bytes!("./assets/ball.bmp");
    let ball = Bitmap::new(ball_bytes);
    let blocks = [blue_block, cyan_block, green_block, pink_block, yellow_block];

    // The initial block position is at the top left corner
    // of the screen
    let mut block_position = (0, 0); // (row, column)

    // Cycle through the blocks until 4 rows have been filled
    for (i, block) in blocks.iter().cycle().enumerate() {
        let no_of_blocks_in_a_row = NO_OF_PIXELS_IN_A_ROW / block.width();
        if i >= no_of_blocks_in_a_row * NO_OF_BLOCK_ROWS {
            break;
        }
        draw_bitmap(screen, block, block_position);
        block_position.1 += block.width();
        if block_position.1 >= NO_OF_PIXELS_IN_A_ROW {
            block_position.0 += block.height();
            block_position.1 = 0;
        }
    }
    
    loop {}
}

The array's iter method returns an Iterator over the references to the block's items. Calling cycle returns a new Iterator that cycles through those references endlessly. Calling enumerate on the resulting Iterator returns a new Iterator that yields tuples (index, block reference) where index is a usize that starts from 0.

The algorithm says to keep drawing until 4 rows have been filled. But how will we know when exactly four rows have been filled? If we fill four rows, then the total number of blocks will be the number of blocks in a row * 4.

The number of blocks in a row: number of pixels in one row of the screen/block width in pixels.

Hence, the condition: i >= no_of_blocks_in_a_row * NO_OF_BLOCK_ROWS because if this condition becomes true, then we've already drawn four rows of blocks.

As for this:

block_position.1 += block.width();
if block_position.1 >= NO_OF_PIXELS_IN_A_ROW {
    block_position.0 += block.height();
    block_position.1 = 0;
}

This just ensures that when one row is filled, the drawing continues on the next row.

To run the game now, we invoke this game function in main.rs:

#![no_std]
#![no_main]
#![feature(abi_efiapi)]
#![feature(panic_info_message)]

// ... Others

// NEW:
mod game;

// ... Others

#[no_mangle]
extern "efiapi" fn efi_main(
    handle: *const core::ffi::c_void,
    sys_table: *mut SystemTable,
) -> usize {
    // ... Others

    init_screen(screen);
    
    let screen = get_screen().unwrap();

    // NEW:
    game::blasterball(screen);
    
    // Returning 0 because the function expects it
    0
}

Running the game now will draw the blocks on the screen. The paddle and the ball are all that's left. To determine the position of the ball and paddle, we'll do some simple trial and error.

Let's start with the paddle since the ball is on top of the paddle.

The paddle is at the center of the screen and a little above the bottom. The width of the screen is 640, so let's try placing it in column 640 / 2. Since the paddle is just a little above the bottom, it will be on one of the last rows. The height of the paddle is 16, so let's try placing it on row 480 - 16 - 10. The -10 there is just for space between the paddle and the bottom of the screen.

// DELETED: use crate::uefi::{Screen, NO_OF_PIXELS_IN_A_ROW};
use crate::uefi::{Screen, NO_OF_PIXELS_IN_A_ROW, NO_OF_PIXELS_IN_A_COLUMN}; // NEW

// ... Others

pub fn blasterball(screen: &mut Screen) -> ! {
    // ... Others
    
    let initial_paddle_position = (NO_OF_PIXELS_IN_A_COLUMN - paddle.height() - 10, NO_OF_PIXELS_IN_A_ROW / 2);
    draw_bitmap(screen, &paddle, initial_paddle_position);
    
    loop {}
}

If you run the game now, you'll see that the paddle is not at the center of the screen, but rather, it's shifted to the right. To push it to the center, let's reduce that column by, say 45.

pub fn blasterball(screen: &mut Screen) -> ! {
    // ... Others
    
    let initial_paddle_position = (
        NO_OF_PIXELS_IN_A_COLUMN - paddle.height() - 10,
        // DELETED: NO_OF_PIXELS_IN_A_ROW / 2,
        NO_OF_PIXELS_IN_A_ROW / 2 - 45 // NEW
    );
    draw_bitmap(screen, &paddle, initial_paddle_position);
    
    loop {}
}

If you run it now, the paddle's position should seem reasonable. It appears centered on the screen and it's slightly above the screen bottom.

The ball will be above the paddle, so a reasonable way to determine the row position will be to set it to the paddle's row position - the ball's height (so the ball will be on top) - a small number (for space). The ball is meant to be centered above the paddle. So a reasonable way to determine the column position will set it to the paddle's column position increased by half the paddle's width (to put it at the center).

pub fn blasterball(screen: &mut Screen) -> ! {
    // ... Others
    
    draw_bitmap(screen, &paddle, initial_paddle_position);

    let initial_ball_position = (
        initial_paddle_position.0 - ball.height() - 5,
        initial_paddle_position.1 + paddle.width() / 2
    );
    draw_bitmap(screen, &ball, initial_ball_position);
    
    loop {}
}

The ball looks a bit right-shifted from the paddle's center. To fix this, just reduce the column by a small amount so the ball can look reasonably centered.

pub fn blasterball(screen: &mut Screen) -> ! {
    // ... Others
    
    draw_bitmap(screen, &paddle, initial_paddle_position);

    let initial_ball_position = (
        initial_paddle_position.0 - ball.height() - 5,
        initial_paddle_position.1 + paddle.width() / 2 - 10
    );
    draw_bitmap(screen, &ball, initial_ball_position);
    
    loop {}
}

And the game scene has been drawn.

Take Away

For the full code up to this point, go to the repo

In The Next Post

We'll be getting ready for event handling