Spaceship and bullets

Screenshot

To begin with we’ll add graphics for the spaceship that the player controls. It will be animated with two different sprites and will also have different animations for when the spaceship moves to the left or right. We’ll also add a texture with animation for the bullets that the spaceship shoots.

Implementation

Import

The animation support in Macroquad is considered an experimental feature. It might change in a future version of Macroquad. It is not included in the prelude that we have imported, so we will have to import it explicitly.

Import the structs AnimatedSprite and Animation at the top of main.rs file.

use macroquad::experimental::animation::{AnimatedSprite, Animation};

Configure assets directory

We need to start by defining where Macroquad should read the resources. We’ll use the function set_pc_assets_folder() that takes the path to the assets directory relative to the root directory of the game. This is needed for platforms that might place files in other places and also has the added benefit that we don’t need to add the directory name for every file we load.

Add the following code in the main function above the game loop:

    set_pc_assets_folder("assets");

Load textures

Load the image files used for the animation textures of the ship and bullets. Use the function load_texture() to load a texture, which takes the name of the file to load. This function is async, because it supports loading files over HTTP in WebAssembly, so we need to call await to get the result.

Since loading files can fail, this function will return a Result. We will call expect() on the result to stop the program if it wasn’t possible to load the file. This can happen if the file is missing, or it has wrong read permissions. On WebAssembly it is possible that the HTTP request failed.

After loading the texture we’ll set which kind of filter to use when scaling the texture using the method set_filter(). We will use the filter FilterMode::Nearest because we want to keep the pixelated look of the sprites. This needs to be done on every texture that is loaded. For high resolution textures it would be better to use FilterMode::Linear which gives a linear scaling of the texture.

We’ll load the file ship.png that contains the animations for the spaceship, and the file laser-bolts.png that contains animations for two different kinds of bullets.

    let ship_texture: Texture2D = load_texture("ship.png").await.expect("Couldn't load file");
    ship_texture.set_filter(FilterMode::Nearest);
    let bullet_texture: Texture2D = load_texture("laser-bolts.png")
        .await
        .expect("Couldn't load file");
    bullet_texture.set_filter(FilterMode::Nearest);

Info

The images are returned as the struct Texture2D that stores the image data in GPU memory. The corresponding struct for images stored in CPU memory is Image.

Build a texture atlas

After loading all the textures we’ll call the Macroquad function build_textures_atlas() that will build an atlas containing all loaded textures. This will ensure that all calls to draw_texture() and draw_texture_ex() will use the texture from the atlas instead of each separate texture, which is much more efficient. All textures need to be loaded before this function is called.

    build_textures_atlas();

Bullet animation

Bullet spritesheet

The image laser-bolts.png is composed of four sprites, in two rows. These make up the animations for two different types of bullets. We will name the first one bullet and the second one bolt. Each animation is one row with two frames each and they should be shown at 12 frames per second. The size of the sprites is 16x16 pixels.

Each animation in a spritesheet is placed in a separate row, with the frames next to each other horizontally. Each Animation should have a descriptive name, define which row in the spritesheet it is, how many frames it has, and how many frames should be displayed each second.

Create an AnimatedSprite with the tile_width and tile_height set to 16, and an array with an Animation struct for each of the two rows in the spritesheet. The first one should be named bullet and have the row 0 and the second one should have the name bolt and the row 1. Both should have frames set to 2 and fps set to 12.

We will only use the second animation, so we’ll use the method set_animation() to define that we will be using the animation on row 1.

    let mut bullet_sprite = AnimatedSprite::new(
        16,
        16,
        &[
            Animation {
                name: "bullet".to_string(),
                row: 0,
                frames: 2,
                fps: 12,
            },
            Animation {
                name: "bolt".to_string(),
                row: 1,
                frames: 2,
                fps: 12,
            },
        ],
        true,
    );
    bullet_sprite.set_animation(1);

Spaceship animation

Spaceship spritesheet

The spritesheet for the spaceship is in the image ship.png and we need to define how the animations in the spritesheet should be displayed. We have to create an AnimatedSprite for the ship as well. The size of each frame of the spaceship spritesheet is 16x24 pixels, so we’ll set tile_width to 16 and tile_height to 24. After that is an array with an Animation struct for each animation in the spritesheet that we want to use.

There are five animations available in the spritesheet, with the first one in the top row. We will only use three of the spritesheet animations in our AnimatedSprite, the second and fourth row from the top in the spritesheet are unused. The first one is used when flying up or down, so add an Animation in the AnimatedSprite with row defined as 0 as the indexes are 0-based, and the name set to idle. The ship will keep pointing up regardless of if it moves up or down. The second Animation is for moving the spaceship to the left, which will use the row with index 2 in the spritesheet. Finally, the third Animation is used when moving the spaceship to the right, and has the row index 4. There are two frames in each Animation and the fps should be set to 12 frames per second.

Finally we set playing to true so that the animation will be active.

    let mut ship_sprite = AnimatedSprite::new(
        16,
        24,
        &[
            Animation {
                name: "idle".to_string(),
                row: 0,
                frames: 2,
                fps: 12,
            },
            Animation {
                name: "left".to_string(),
                row: 2,
                frames: 2,
                fps: 12,
            },
            Animation {
                name: "right".to_string(),
                row: 4,
                frames: 2,
                fps: 12,
            },
        ],
        true,
    );

Animate direction

For the spaceship we need to set which animation to use based on the direction movement. In the code for moving the spaceship we will add a line where we use the method set_animation() on the ship_sprite. We start by setting the animation to 0 if it isn’t turning in any direction, if it is moving to the right we’ll set the animation to 2, and if it moves to the left we’ll set the animation to 1. These numbers are indexes in the array of Animation structs we defined in the AnimatedSprite for the spaceship, which means they are 0-based.

                ship_sprite.set_animation(0);
                if is_key_down(KeyCode::Right) {
                    circle.x += MOVEMENT_SPEED * delta_time;
                    direction_modifier += 0.05 * delta_time;
                    ship_sprite.set_animation(2);
                }
                if is_key_down(KeyCode::Left) {
                    circle.x -= MOVEMENT_SPEED * delta_time;
                    direction_modifier -= 0.05 * delta_time;
                    ship_sprite.set_animation(1);
                }

Change bullet size

Since the graphics for the bullets are larger than the tiny circle we used to draw for them, we need to change the size and starting position when creating a bullet.

                if is_key_pressed(KeyCode::Space) {
                    bullets.push(Shape {
                        x: circle.x,
                        y: circle.y - 24.0,
                        speed: circle.speed * 2.0,
                        size: 32.0,
                        collided: false,
                    });
                }

Update animations

In order for Macroquad to animate the textures, we need to call the method update() on every sprite inside our game loop. Add the following two lines below the code that updates the positions of enemies and bullets.

                for square in &mut squares {
                    square.y += square.speed * delta_time;
                }
                for bullet in &mut bullets {
                    bullet.y -= bullet.speed * delta_time;
                }

                ship_sprite.update();
                bullet_sprite.update();

Draw bullet animations

Now we can use the function draw_texture_ex to draw each frame of the animation. Remove the lines that draw a circle for each bullet and insert instead the code below. First we call the method frame() on the bullet_sprite to get the current animation frame and set it to the variable bullet_frame.

Inside the loop that draws all the bullets we’ll call draw_texture_ex to draw the bullet frame. It takes the bullet_texture as argument, and an x and y position based on the size of the bullet. We also add the struct DrawTextureParams with the fields dest_size and source_rect. The field dest_size defines in which size the texture will be drawn, so we will use a Vec2 with the size of the bullet for both x and y. Finally we’ll use bullet_frame.source_rect, which is a reference to where in the texture the current frame is placed.

                let bullet_frame = bullet_sprite.frame();
                for bullet in &bullets {
                    draw_texture_ex(
                        &bullet_texture,
                        bullet.x - bullet.size / 2.0,
                        bullet.y - bullet.size / 2.0,
                        WHITE,
                        DrawTextureParams {
                            dest_size: Some(vec2(bullet.size, bullet.size)),
                            source: Some(bullet_frame.source_rect),
                            ..Default::default()
                        },
                    );
                }

Info

By using DrawTextureParams it is possible to change how the texture should be drawn. It is possible to draw the texture rotated or mirrored with the fields rotation, pivot, flip_x, and flip_y.

Draw the spaceship frames

Finally it’s time to replace the circle with the texture for the spaceship. It works in the same way as for the bullets. First we’ll retrieve the current frame from the animation sprite, and then we’ll draw it using draw_texture_ex().

Because the spaceship animation isn’t the same size in width and height, we’ll use ship_frame.dest_size to define which size should be drawn. To make it a bit bigger we’ll double the size.

                let ship_frame = ship_sprite.frame();
                draw_texture_ex(
                    &ship_texture,
                    circle.x - ship_frame.dest_size.x,
                    circle.y - ship_frame.dest_size.y,
                    WHITE,
                    DrawTextureParams {
                        dest_size: Some(ship_frame.dest_size * 2.0),
                        source: Some(ship_frame.source_rect),
                        ..Default::default()
                    },
                );

If everything works correctly, there should be animated graphics for both the spaceship and the bullets when running the game.

Improve loading times

Adding the following snippet at the end of the Cargo.toml file will ensure that the assets are loaded much faster when running on a desktop computer.

[profile.dev.package.'*']
opt-level = 3

Challenge

Try using the two extra spaceship animations to make the ship turn only slightly just when it changes direction and then make it turn fully after a short time.

Full source code

Click to show the the full source code
use macroquad::experimental::animation::{AnimatedSprite, Animation};
use macroquad::prelude::*;
use macroquad_particles::{self as particles, ColorCurve, Emitter, EmitterConfig};

use std::fs;

const FRAGMENT_SHADER: &str = include_str!("starfield-shader.glsl");

const VERTEX_SHADER: &str = "#version 100
attribute vec3 position;
attribute vec2 texcoord;
attribute vec4 color0;
varying float iTime;

uniform mat4 Model;
uniform mat4 Projection;
uniform vec4 _Time;

void main() {
    gl_Position = Projection * Model * vec4(position, 1);
    iTime = _Time.x;
}
";

struct Shape {
    size: f32,
    speed: f32,
    x: f32,
    y: f32,
    collided: bool,
}

impl Shape {
    fn collides_with(&self, other: &Self) -> bool {
        self.rect().overlaps(&other.rect())
    }

    fn rect(&self) -> Rect {
        Rect {
            x: self.x - self.size / 2.0,
            y: self.y - self.size / 2.0,
            w: self.size,
            h: self.size,
        }
    }
}

enum GameState {
    MainMenu,
    Playing,
    Paused,
    GameOver,
}

fn particle_explosion() -> particles::EmitterConfig {
    particles::EmitterConfig {
        local_coords: false,
        one_shot: true,
        emitting: true,
        lifetime: 0.6,
        lifetime_randomness: 0.3,
        explosiveness: 0.65,
        initial_direction_spread: 2.0 * std::f32::consts::PI,
        initial_velocity: 300.0,
        initial_velocity_randomness: 0.8,
        size: 3.0,
        size_randomness: 0.3,
        colors_curve: ColorCurve {
            start: RED,
            mid: ORANGE,
            end: RED,
        },
        ..Default::default()
    }
}

#[macroquad::main("My game")]
async fn main() {
    const MOVEMENT_SPEED: f32 = 200.0;

    rand::srand(miniquad::date::now() as u64);
    let mut squares = vec![];
    let mut bullets: Vec<Shape> = vec![];
    let mut circle = Shape {
        size: 32.0,
        speed: MOVEMENT_SPEED,
        x: screen_width() / 2.0,
        y: screen_height() / 2.0,
        collided: false,
    };
    let mut score: u32 = 0;
    let mut high_score: u32 = fs::read_to_string("highscore.dat")
        .map_or(Ok(0), |i| i.parse::<u32>())
        .unwrap_or(0);
    let mut game_state = GameState::MainMenu;

    let mut direction_modifier: f32 = 0.0;
    let render_target = render_target(320, 150);
    render_target.texture.set_filter(FilterMode::Nearest);
    let material = load_material(
        ShaderSource::Glsl {
            vertex: VERTEX_SHADER,
            fragment: FRAGMENT_SHADER,
        },
        MaterialParams {
            uniforms: vec![
                ("iResolution".to_owned(), UniformType::Float2),
                ("direction_modifier".to_owned(), UniformType::Float1),
            ],
            ..Default::default()
        },
    )
    .unwrap();

    let mut explosions: Vec<(Emitter, Vec2)> = vec![];

    set_pc_assets_folder("assets");
    let ship_texture: Texture2D = load_texture("ship.png").await.expect("Couldn't load file");
    ship_texture.set_filter(FilterMode::Nearest);
    let bullet_texture: Texture2D = load_texture("laser-bolts.png")
        .await
        .expect("Couldn't load file");
    bullet_texture.set_filter(FilterMode::Nearest);
    build_textures_atlas();

    let mut bullet_sprite = AnimatedSprite::new(
        16,
        16,
        &[
            Animation {
                name: "bullet".to_string(),
                row: 0,
                frames: 2,
                fps: 12,
            },
            Animation {
                name: "bolt".to_string(),
                row: 1,
                frames: 2,
                fps: 12,
            },
        ],
        true,
    );
    bullet_sprite.set_animation(1);
    let mut ship_sprite = AnimatedSprite::new(
        16,
        24,
        &[
            Animation {
                name: "idle".to_string(),
                row: 0,
                frames: 2,
                fps: 12,
            },
            Animation {
                name: "left".to_string(),
                row: 2,
                frames: 2,
                fps: 12,
            },
            Animation {
                name: "right".to_string(),
                row: 4,
                frames: 2,
                fps: 12,
            },
        ],
        true,
    );

    loop {
        clear_background(BLACK);

        material.set_uniform("iResolution", (screen_width(), screen_height()));
        material.set_uniform("direction_modifier", direction_modifier);
        gl_use_material(&material);
        draw_texture_ex(
            &render_target.texture,
            0.,
            0.,
            WHITE,
            DrawTextureParams {
                dest_size: Some(vec2(screen_width(), screen_height())),
                ..Default::default()
            },
        );
        gl_use_default_material();

        match game_state {
            GameState::MainMenu => {
                if is_key_pressed(KeyCode::Escape) {
                    std::process::exit(0);
                }
                if is_key_pressed(KeyCode::Space) {
                    squares.clear();
                    bullets.clear();
                    explosions.clear();
                    circle.x = screen_width() / 2.0;
                    circle.y = screen_height() / 2.0;
                    score = 0;
                    game_state = GameState::Playing;
                }
                let text = "Press space";
                let text_dimensions = measure_text(text, None, 50, 1.0);
                draw_text(
                    text,
                    screen_width() / 2.0 - text_dimensions.width / 2.0,
                    screen_height() / 2.0,
                    50.0,
                    WHITE,
                );
            }
            GameState::Playing => {
                let delta_time = get_frame_time();
                ship_sprite.set_animation(0);
                if is_key_down(KeyCode::Right) {
                    circle.x += MOVEMENT_SPEED * delta_time;
                    direction_modifier += 0.05 * delta_time;
                    ship_sprite.set_animation(2);
                }
                if is_key_down(KeyCode::Left) {
                    circle.x -= MOVEMENT_SPEED * delta_time;
                    direction_modifier -= 0.05 * delta_time;
                    ship_sprite.set_animation(1);
                }
                if is_key_down(KeyCode::Down) {
                    circle.y += MOVEMENT_SPEED * delta_time;
                }
                if is_key_down(KeyCode::Up) {
                    circle.y -= MOVEMENT_SPEED * delta_time;
                }
                if is_key_pressed(KeyCode::Space) {
                    bullets.push(Shape {
                        x: circle.x,
                        y: circle.y - 24.0,
                        speed: circle.speed * 2.0,
                        size: 32.0,
                        collided: false,
                    });
                }
                if is_key_pressed(KeyCode::Escape) {
                    game_state = GameState::Paused;
                }

                // Clamp X and Y to be within the screen
                circle.x = clamp(circle.x, 0.0, screen_width());
                circle.y = clamp(circle.y, 0.0, screen_height());

                // Generate a new square
                if rand::gen_range(0, 99) >= 95 {
                    let size = rand::gen_range(16.0, 64.0);
                    squares.push(Shape {
                        size,
                        speed: rand::gen_range(50.0, 150.0),
                        x: rand::gen_range(size / 2.0, screen_width() - size / 2.0),
                        y: -size,
                        collided: false,
                    });
                }

                // Movement
                for square in &mut squares {
                    square.y += square.speed * delta_time;
                }
                for bullet in &mut bullets {
                    bullet.y -= bullet.speed * delta_time;
                }

                ship_sprite.update();
                bullet_sprite.update();

                // Remove shapes outside of screen
                squares.retain(|square| square.y < screen_height() + square.size);
                bullets.retain(|bullet| bullet.y > 0.0 - bullet.size / 2.0);

                // Remove collided shapes
                squares.retain(|square| !square.collided);
                bullets.retain(|bullet| !bullet.collided);

                // Remove old explosions
                explosions.retain(|(explosion, _)| explosion.config.emitting);

                // Check for collisions
                if squares.iter().any(|square| circle.collides_with(square)) {
                    if score == high_score {
                        fs::write("highscore.dat", high_score.to_string()).ok();
                    }
                    game_state = GameState::GameOver;
                }
                for square in squares.iter_mut() {
                    for bullet in bullets.iter_mut() {
                        if bullet.collides_with(square) {
                            bullet.collided = true;
                            square.collided = true;
                            score += square.size.round() as u32;
                            high_score = high_score.max(score);
                            explosions.push((
                                Emitter::new(EmitterConfig {
                                    amount: square.size.round() as u32 * 2,
                                    ..particle_explosion()
                                }),
                                vec2(square.x, square.y),
                            ));
                        }
                    }
                }

                // Draw everything
                let bullet_frame = bullet_sprite.frame();
                for bullet in &bullets {
                    draw_texture_ex(
                        &bullet_texture,
                        bullet.x - bullet.size / 2.0,
                        bullet.y - bullet.size / 2.0,
                        WHITE,
                        DrawTextureParams {
                            dest_size: Some(vec2(bullet.size, bullet.size)),
                            source: Some(bullet_frame.source_rect),
                            ..Default::default()
                        },
                    );
                }
                let ship_frame = ship_sprite.frame();
                draw_texture_ex(
                    &ship_texture,
                    circle.x - ship_frame.dest_size.x,
                    circle.y - ship_frame.dest_size.y,
                    WHITE,
                    DrawTextureParams {
                        dest_size: Some(ship_frame.dest_size * 2.0),
                        source: Some(ship_frame.source_rect),
                        ..Default::default()
                    },
                );
                for square in &squares {
                    draw_rectangle(
                        square.x - square.size / 2.0,
                        square.y - square.size / 2.0,
                        square.size,
                        square.size,
                        GREEN,
                    );
                }
                for (explosion, coords) in explosions.iter_mut() {
                    explosion.draw(*coords);
                }
                draw_text(
                    format!("Score: {}", score).as_str(),
                    10.0,
                    35.0,
                    25.0,
                    WHITE,
                );
                let highscore_text = format!("High score: {}", high_score);
                let text_dimensions = measure_text(highscore_text.as_str(), None, 25, 1.0);
                draw_text(
                    highscore_text.as_str(),
                    screen_width() - text_dimensions.width - 10.0,
                    35.0,
                    25.0,
                    WHITE,
                );
            }
            GameState::Paused => {
                if is_key_pressed(KeyCode::Space) {
                    game_state = GameState::Playing;
                }
                let text = "Paused";
                let text_dimensions = measure_text(text, None, 50, 1.0);
                draw_text(
                    text,
                    screen_width() / 2.0 - text_dimensions.width / 2.0,
                    screen_height() / 2.0,
                    50.0,
                    WHITE,
                );
            }
            GameState::GameOver => {
                if is_key_pressed(KeyCode::Space) {
                    game_state = GameState::MainMenu;
                }
                let text = "GAME OVER!";
                let text_dimensions = measure_text(text, None, 50, 1.0);
                draw_text(
                    text,
                    screen_width() / 2.0 - text_dimensions.width / 2.0,
                    screen_height() / 2.0,
                    50.0,
                    RED,
                );
            }
        }

        next_frame().await
    }
}

Quiz

Try your knowledge by answering the following quiz before you move on to the next chapter.

Agical