Rymdskepp och kulor

Screenshot

Först ska vi lägga till grafik för rymdskeppet som spelaren styr. Den kommer att animeras med två olika sprites, och kommer även ha olika animeringar för om skeppet styr åt höger eller vänster. Dessutom lägger vi till en textur med animering för kulorna som skeppet skjuter.

Implementering

Importera

Då animeringsstödet i Macroquad fortfarande räknas som experimentell måste vi importera stödet för det explicit längst upp i källkodsfilen. Det är structarna AnimatedSprite och Animation vi kommer använda oss av.

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

Konfigurera assets-katalog

Först måste vi ange var Macroquad ska läsa resurserna ifrån, därför använder vi funktionen set_pc_assets_folder() som tar sökvägen till assets-katalogen med utgång från spelets rotkatalog. Detta behövs för att olika plattformar placerar filerna på olika ställen, och vi slipper dessutom ange katalogen för varje fil som ska laddas in. Lägg in nedanstående kod i main-funktionen innan loopen.

    set_pc_assets_folder("assets");

Ladda in texturer

Nu kan vi ladda in filerna med texturerna för animeringarna av skeppet och kulorna. För att ladda in en textur används funktionen load_texture() som tar namnet på filen. Det är en asynkron funktion, så vi måste köra await och vänta på att inladdningen är klar. Filladdningen kan misslyckas, så vi får tillbaka ett Result och använder oss av expect() för att avsluta programmet med ett felmmeddelande om det uppstår.

Efter att texturen är inladdad så sätter vi vilket sorts filter som ska användas när texturen skalas upp med metoden set_filter(). Vi sätter FilterMode::Nearest för att vi vill bibehålla pixelutseendet. Det här måste vi göra på varje textur. För högupplösta texturer är det bättre att använda FilterMode::Linear som ger linjär skalning av texturen.

Vi laddar in filerna ship.png som innehåller animeringarna för skeppet, och laser-bolts.png som innehåller animeringar för två olika sorts kulor.

    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

Bilderna returneras som structen Texture2D som innehåller bilddatan som sparas i GPU-minnet. Motsvarande struct för bilder som sparas i CPU-minnet är Image.

Bygg en texturatlas

Efter att alla texturer har laddats in anropar vi Macroquad-funktionen build_textures_atlas som bygger upp en atlas som innehåller alla inladdade texturer. Det gör att alla anrop till draw_texture() kommer använda texturen från atlasen istället för varje separat textur. Alla texturer bör laddas in innan detta anrop.

    build_textures_atlas();

Animering av rymdskeppet

Spritesheet för rymdskeppet

Nu måste vi beskriva hur animeringarna i texturerna ska visas. Det gör vi genom att skapa en AnimatedSprite för varje textur. Storleken på varje bildruta i skeppets textur är 16x24 pixlar, därför sätter vi tile_width till 16 och tile_height till 24.

Därefter kommer en array som beskriver alla animeringar som ingår i texturen. Varje animation i en textur ligger på varsin rad, med bildrutorna efter varandra åt höger. Varje Animation ska ha ett beskrivande namn, vilken rad i texturen som innehåller animationen, hur många bildrutor det är samt hur många bildrutor som ska visas per sekund.

Skeppet har tre animationer, den första är när den flyger rakt upp eller ner, den andra när det åker åt vänster och den tredje när det åker åt höger. Det är två bildrutor per animation och dom ska visas med 12 bildrutor per sekund. Texturen innehåller två animationer till, på rad 1 och 3 som visar skeppet lite mindre vinklade svängar.

Avslutningsvis sätter vi playing till true för att vi vill att animeringen ska vara aktiv.

    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,
    );

Animering av kulor

Spritesheet för kulor

Animeringen för kulorna är väldigt lika, det är två animeringar med två bildrutor var som ska visas med 12 bildrutor per sekund. Storleken på bilderna är 16x16 pixlar. Vi kommer bara använda den andra animeringen, så vi använder oss av metoden set_animation() för att sätta att det är animeringen på rad 1 som ska användas.

    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);

Animera riktning

För skeppet behöver vi sätta vilken animation som ska användas baserat på åt vilket håll skeppet åker. I koden som sätter vilket håll skeppet ska förflyttas behöver vi därför köra metoden set_animation på vår ship_sprite. Vi sätter först animationen 0 som inte svänger åt något håll, om skeppet ska förflyttas åt höger sätter vi animeringen till 2 och om den förflyttas åt höger sätter vi 1.

                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);
                }

Ändra kulstorlek

Eftersom grafiken för kulorna är större än den lilla cirkeln vi ritade ut tidigare måste vi uppdatera storleken och startpositionen när vi skapar kulorna.

                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,
                    });
                }

Uppdatera animeringar

För att Macroquad ska kunna animera texturerna åt oss måste vi anropa metoden update() på varje sprite inne i loopen. Vi lägger därför till följande två rader nedanför koden som uppdaterar fienders och kulors position.

                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();

Rita kulornas bildrutor

Nu kan vi använda oss av funktionen draw_texture_ex() för att rita ut rätt bildruta från animeringen. Vi byter ut raderna som ritar ut en cirkel för varje kula till följande rader. Först anropar vi frame()bullet_sprite för att få ut aktuell bildruta och tilldelar den till variabeln bullet_frame.

Inne i loopen som ritar ut alla kulor anropar vi draw_texture_ex() för att rita ut kulan. Den tar bullet_texture som argument, därefter en X och Y-position som vi räknar ut baserat på kulans storlek. Vi skickar även med structen DrawTextureParams med värdena dest_size och source_rect. Fältet dest_size avgör hur stort texturen ska ritas ut, så vi skickar in en Vec2 med kulans storlek för både X och Y. Därefter anropar vi bullet_frame.source_rect som anger var i texturen aktuell bildruta ska hämtas.

                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

Med hjälp av DrawTextureParams går det att ändra hur texturen ska ritas ut. Det går att rita ut texturen roterat eller spegelvänt med fälten rotation, pivot, flip_x och flip_y.

Rita ut rymdskeppets bildrutor

Till sist kan vi byta ut cirkeln mot texturen för skeppet. Det fungerar likadant som för att rita ut kulorna. Vi hämtar först ut aktuell bildruta från spritens animering och ritar sedan ut texturen med draw_texture_ex.

Då skeppanimeringen inte har samma storlek i höjd och bredd så använder vi oss av ship_frame.dest_size för att få ut storleken den ska ritas ut i. Men för att det inte ska bli så smått ritar vi ut den med dubbla storleken.

                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()
                    },
                );

Om allt fungerar som det ska så ska det nu vara grafik för både skeppet och kulorna.

Snabba upp laddtider

Lägg till följande rader i slutet av filen Cargo.toml för att bilder ska laddas in snabbare.

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

Utmaning

Prova att använda de två extra skeppanimationerna för att vinkla skeppet lite mindre precis när det bytt håll för att sedan vinklas fullt ut efter en viss tid.

Kompletta källkoden

Klicka för att visa hela källkoden
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

Testa dina nya kunskaper genom att svara på följande quiz innan du går vidare.

Agical