Grafisk meny

Screenshot

Macroquad har ett inbyggt system för att rita upp ett grafiskt gränssnitt som där utseendet enkelt kan ändras med hjälp av bilder. Vi ska använda det för att skapa en grafisk huvudmeny för vårt spel. Det kommer vara ganska mycket kod för att definiera hur gränssnittet ska se ut. Att använda det kräver dock inte riktigt lika mycket kod.

Menyn kommer bestå av ett fönster centrerat på skärmen, med texten “Huvudmeny” i titeln, och kommer innehålla två knappar, en för att “Spela” och en för att “Avsluta”. Utseendet kommer beskrivas med kod, och använder bilder för att skapa utseendet. Gränssnitt byggs upp med hjälp av olika widgets som label, button, editbox och combobox.

Implementering

Till att börja med måste vi importera det vi behöver från ui-modulen.

use macroquad::ui::{hash, root_ui, Skin};

Ladda in resurser

Efter att ljuden har laddats in ska vi ladda in fonten och de bilder som behövs för att rita upp gränssnittet. Vi har en bild för att skapa ett fönster, window_background.png, en bild för att rita upp knappar, button_background.png och till sist en bild som används när en knapp är nedtryckt, button_clicked_background.png. Bilder laddas in med funktionen load_image() och binärfiler med load_file(). Både bilder och filer laddas in asynkront och kan returnera fel, därför använder vi oss av await och unwrap(). Lyckas vi inte ladda in det som behövs för att rita upp huvudmenyn kan vi avsluta programmet direkt.

    let window_background = load_image("window_background.png").await.unwrap();
    let button_background = load_image("button_background.png").await.unwrap();
    let button_clicked_background = load_image("button_clicked_background.png").await.unwrap();
    let font = load_file("atari_games.ttf").await.unwrap();

Skapa ett Skin

Innan loopen måste vi definiera hur vårt gränssnitt ska se ut. Vi bygger upp Style-structar för fönstret, knappar och texter. Därefter skapar vi ett Skin med stilarna.

Vi använder oss av funktionen root_ui() som kommer rita widgets sist i varje frame med en “default” kamera och koordinatsystemet (0..screen_width(), 0..screen_height()).

Utseende på fönster

För att bygga en stil använder man en StyleBuilder som har hjälpmetoder för att definiera alla delar av stilen. Vi får tillgång till den genom att använda metoden style_builder()root_ui(). De värden som inte sätts kommer att använda samma värden som default-utseendet.

Vi använder metoden background() för att sätta bilden som ska användas för att rita ut fönstret. Sen måste vi använda background_margin() för att definiera vilka delar av bilden som inte ska stretchas ut när fönstret ändrar storlek. Det använder vi för att kanterna på fönstret ska se bra ut.

Med metoden margin() sätts marginaler för innehållet. Dessa värden kan vara negativa för att rita ut innehåll på fönstrets bårder.

    let window_style = root_ui()
        .style_builder()
        .background(window_background)
        .background_margin(RectOffset::new(32.0, 76.0, 44.0, 20.0))
        .margin(RectOffset::new(0.0, -40.0, 0.0, 0.0))
        .build();

Info

Det finns många fler metoder för att definiera stilar, som finns beskrivna i dokumentationen för Macroquads StyleBuilder

Utseende på knappar

I definitionen för knappar använder vi två bilder, med background() sätter vi grundbilden och med background_clicked() sätter vi bilden som ska användas när knappen är nedtryckt.

Vi behöver background_margin() och margin() för att kunna stretcha ut bilden över hela textinnehållet. Utseendet på texten sätter vi med font(), text_color() och font_size().

    let button_style = root_ui()
        .style_builder()
        .background(button_background)
        .background_clicked(button_clicked_background)
        .background_margin(RectOffset::new(16.0, 16.0, 16.0, 16.0))
        .margin(RectOffset::new(16.0, 0.0, -8.0, -8.0))
        .font(&font)
        .unwrap()
        .text_color(WHITE)
        .font_size(64)
        .build();

Utseende på text

Vanlig text som ska presenteras i gränssnittet använder label_style. Vi använder samma font som för knappar, men med en lite mindre storlek.

    let label_style = root_ui()
        .style_builder()
        .font(&font)
        .unwrap()
        .text_color(WHITE)
        .font_size(28)
        .build();

Definiera ett Skin

Nu kan vi skapa ett Skin med hjälp av window_style, button_style och label_style. Övriga stilar i vårt skin låter vi vara som dom är då vi inte kommer använda dom just nu.

Vi sätter vårt skin som aktuellt skin med push_skin(). Vi kommer bara använda oss av en stil, men för att byta mellan olika stilar mellan fönster kan man definiera flera skins och använda push_skin() och pop_skin() för att byta mellan dem.

Vi sätter också variabeln window_size som kommer användas för sätta fönstrets storlek.

    let ui_skin = Skin {
        window_style,
        button_style,
        label_style,
        ..root_ui().default_skin()
    };
    root_ui().push_skin(&ui_skin);
    let window_size = vec2(370.0, 320.0);

Info

Det går att ändra utseendet på fler delar av gränssnittet, som textrutor, dropboxar med mera. Mer information finns i dokumentationen av structen Skin.

Bygg upp menyn

Nu kan vi skapa en meny genom att rita ut ett fönster med två knappar och en rubrik. Innehållet i matchningen av GameState::MainMenu kan bytas ut mot nedanstående kod.

Först skapar vi ett fönster med anropet root_ui().window(). Den funktionen tar ett id som genereras med macrot hash!, en position som vi räknar ut baserat på fönsterstorleken och skärmens dimensioner och en Vec2 som beskriver fönstrets storlek. Till sist tar den en funktion som används för att rita upp fönstret.

Fönstertitel

Inne i funktionen skapar vi först en titel på fönstret med widgeten Label som vi kan skapa med metoden ul.label(). Metoden tar två argument, först en Vec2 med positionen för var den ska placeras, och texten som ska visas. Det går att skicka in None som position, då kommer den få en placering relativ till tidigare widgets. Här använder vi en negativ Y-position för att den ska hamna i fönstrets titelrad.

Info

Widgets går också att skapa genom att instantiera ett objekt och använda builder-metoder.

widgets::Button::new("Spela").position(vec2(45.0, 25.0)).ui(ui);

Knappar

Sen ritar vi ut en knapp för att börja spela. Metoden ui.button() returnerar true om knappen är nedtryckt. Det använder vi oss för att sätta ett nytt GameState och starta ett nytt spel.

Till sist skapar vi knappen “Avsluta” som avslutar programmet om spelaren klickar på den.

            GameState::MainMenu => {
                root_ui().window(
                    hash!(),
                    vec2(
                        screen_width() / 2.0 - window_size.x / 2.0,
                        screen_height() / 2.0 - window_size.y / 2.0,
                    ),
                    window_size,
                    |ui| {
                        ui.label(vec2(80.0, -34.0), "Huvudmeny");
                        if ui.button(vec2(45.0, 25.0), "Spela") {
                            squares.clear();
                            bullets.clear();
                            explosions.clear();
                            circle.x = screen_width() / 2.0;
                            circle.y = screen_height() / 2.0;
                            score = 0;
                            game_state = GameState::Playing;
                        }
                        if ui.button(vec2(20.0, 125.0), "Avsluta") {
                            std::process::exit(0);
                        }
                    },
                );
            }

Info

Det finns en mängd olika widgets som kan användas för att skapa gränssnitt. Läs mer om vad som finns tillgängligt i dokumentationen av structen Ui.

Prova spelet

När spelet startar nu så finns det en grafisk huvudmeny där spelaren kan välja att starta ett spel eller avsluta programmet.

Kompletta källkoden

Klicka för att visa hela källkoden
use macroquad::audio::{load_sound, play_sound, play_sound_once, PlaySoundParams};
use macroquad::experimental::animation::{AnimatedSprite, Animation};
use macroquad::prelude::*;
use macroquad::ui::{hash, root_ui, Skin};
use macroquad_particles::{self as particles, AtlasConfig, 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: 400.0,
        initial_velocity_randomness: 0.8,
        size: 16.0,
        size_randomness: 0.3,
        atlas: Some(AtlasConfig::new(5, 1, 0..)),
        ..Default::default()
    }
}

#[macroquad::main("Mitt spel")]
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);
    let explosion_texture: Texture2D = load_texture("explosion.png")
        .await
        .expect("Couldn't load file");
    explosion_texture.set_filter(FilterMode::Nearest);
    let enemy_small_texture: Texture2D = load_texture("enemy-small.png")
        .await
        .expect("Couldn't load file");
    enemy_small_texture.set_filter(FilterMode::Nearest);
    build_textures_atlas();

    let theme_music = load_sound("8bit-spaceshooter.ogg").await.unwrap();
    let sound_explosion = load_sound("explosion.wav").await.unwrap();
    let sound_laser = load_sound("laser.wav").await.unwrap();

    let window_background = load_image("window_background.png").await.unwrap();
    let button_background = load_image("button_background.png").await.unwrap();
    let button_clicked_background = load_image("button_clicked_background.png").await.unwrap();
    let font = load_file("atari_games.ttf").await.unwrap();

    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,
    );
    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 enemy_small_sprite = AnimatedSprite::new(
        17,
        16,
        &[Animation {
            name: "enemy_small".to_string(),
            row: 0,
            frames: 2,
            fps: 12,
        }],
        true,
    );

    play_sound(
        &theme_music,
        PlaySoundParams {
            looped: true,
            volume: 1.,
        },
    );

    let window_style = root_ui()
        .style_builder()
        .background(window_background)
        .background_margin(RectOffset::new(32.0, 76.0, 44.0, 20.0))
        .margin(RectOffset::new(0.0, -40.0, 0.0, 0.0))
        .build();
    let button_style = root_ui()
        .style_builder()
        .background(button_background)
        .background_clicked(button_clicked_background)
        .background_margin(RectOffset::new(16.0, 16.0, 16.0, 16.0))
        .margin(RectOffset::new(16.0, 0.0, -8.0, -8.0))
        .font(&font)
        .unwrap()
        .text_color(WHITE)
        .font_size(64)
        .build();
    let label_style = root_ui()
        .style_builder()
        .font(&font)
        .unwrap()
        .text_color(WHITE)
        .font_size(28)
        .build();
    let ui_skin = Skin {
        window_style,
        button_style,
        label_style,
        ..root_ui().default_skin()
    };
    root_ui().push_skin(&ui_skin);
    let window_size = vec2(370.0, 320.0);

    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 => {
                root_ui().window(
                    hash!(),
                    vec2(
                        screen_width() / 2.0 - window_size.x / 2.0,
                        screen_height() / 2.0 - window_size.y / 2.0,
                    ),
                    window_size,
                    |ui| {
                        ui.label(vec2(80.0, -34.0), "Huvudmeny");
                        if ui.button(vec2(45.0, 25.0), "Spela") {
                            squares.clear();
                            bullets.clear();
                            explosions.clear();
                            circle.x = screen_width() / 2.0;
                            circle.y = screen_height() / 2.0;
                            score = 0;
                            game_state = GameState::Playing;
                        }
                        if ui.button(vec2(20.0, 125.0), "Avsluta") {
                            std::process::exit(0);
                        }
                    },
                );
            }
            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,
                    });
                    play_sound_once(&sound_laser);
                }
                if is_key_pressed(KeyCode::Escape) {
                    game_state = GameState::Paused;
                }

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

                // 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();
                enemy_small_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 * 4,
                                    texture: Some(explosion_texture.clone()),
                                    ..particle_explosion()
                                }),
                                vec2(square.x, square.y),
                            ));
                            play_sound_once(&sound_explosion);
                        }
                    }
                }

                // 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()
                    },
                );
                let enemy_frame = enemy_small_sprite.frame();
                for square in &squares {
                    draw_texture_ex(
                        &enemy_small_texture,
                        square.x - square.size / 2.0,
                        square.y - square.size / 2.0,
                        WHITE,
                        DrawTextureParams {
                            dest_size: Some(vec2(square.size, square.size)),
                            source: Some(enemy_frame.source_rect),
                            ..Default::default()
                        },
                    );
                }
                for (explosion, coords) in explosions.iter_mut() {
                    explosion.draw(*coords);
                }
                draw_text(
                    format!("Poäng: {}", 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::Escape) {
                    game_state = GameState::Playing;
                }
                let text = "Pausad";
                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
    }
}
Agical