Koda ett spel i Rust med Macroquad

Ferris the rustacean with a santa hat holding a game controller

Välkommen till Agicals julkalender 2023!

Häng med på Agicals spelmakarpyssel och bygg ett eget shoot ’em up-spel i Rust. Varje dag under december kommer vi lägga ut ett nytt kapitel och lagom till julafton kommer vi skrivit ett färdigt spel.

Guiden är skriven av Olle WreedeAgical.

Spelramverket Macroquad

Macroquad är ett spelramverk för programmeringsspråket Rust som har allt som behövs för att skapa ett 2D-spel. De största fördelarna jämfört med andra spelramverk är att det har väldigt få beroenden och går snabbt att kompilera. Det stödjer också att göra spel för iOS, Android och webben, förutom desktop OS som Windows, Mac och Linux. Det behövs ingen plattformsspecifik kod för att det ska fungera, all kod är alltid samma. Tack vare att det är så optimerat så går det även att bygga spel för enklare enheter, som äldre telefoner och små enkortsdatorer. Det ingår även ett UI-ramverk för att göra grafiska UI där utseendet enkelt kan ändras.

Denna guide förutsätter en viss förkunskap i Rust. Det går att läsa mer om Rust i Rust-boken som finns att läsa online. Jag kan även rekommendera boken Hands-on Rust av Herbert Wolverson där man får lära sig Rust genom att skriva ett spel.

Info

Macroquads hemsida finns exempel som visar hur Macroquad fungerar och dokumentation av dess API.

Spelmakarguide

I denna guide kommer vi bygga ett spel från grunden genom att lägga till lite mer funktionalitet i varje kapitel. Till att börja med kommer det vara väldigt rudimentärt, men i slutet av guiden kommer det vara ett komplett spel med grafik, ljud och allt som hör till.

Spelet vi kommer skapa är ett klassiskt shoot ’em up där spelaren ska flyga ett rymdskepp och skjuta ner fiender.

Utmaning

Detta är läraren Ferris som kommer dyka upp i slutet av varje kapitel för att ge dig en liten extra utmaning. Det är valfritt att utföra utmaningen, det kommer inte behövas för att kunna fortsätta till nästa kapitel.

Notera

Denna guide är skriven för version 0.4 av Macroquad. Eftersom Macroquad är under aktiv utveckling kommer den inte gälla för v0.5 och senare.

Creative Commons-licens
Detta verk är licensierat under en Creative Commons Erkännande-IckeKommersiell-DelaLika 4.0 Internationell Licens.

Ditt första Macroquad-program

Nu är det dags att programmera ditt första program med Macroquad. Börja med att installera programmeringsspråket Rust om du inte redan har gjort det.

Screenshot

Implementering

Skapa ett nytt Rust-projekt med Cargo och lägg till macroquad med version 0.4 som beroende.

cargo new --bin mitt-spel
cd mitt-spel/
cargo add macroquad@0.4

Din Cargo.toml fil kommer nu se ut såhär:

[package]
name = "mitt-spel"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
macroquad = "0.4"

Öppna filen src/main.rs i din favorit-editor och ändra innehållet till följande kod:

use macroquad::prelude::*;

#[macroquad::main("Mitt spel")]
async fn main() {
    loop {
        clear_background(DARKPURPLE);
        next_frame().await
    }
}

Kör programmet med cargo run så ska ett nytt fönster med mörklila bakgrund öppnas efter att kompileringen är klar.

Beskrivning av programmet

Första raden används för att importera allt som behövs från Macroquad, vilket enklast görs med use macroquad::prelude::*, men det går också att importera alla funktioner manuellt.

Attributet #[macroquad::main("Mitt spel")] används för att berätta för Macroquad vilken funktion som ska köras. Macroquad kommer skapa ett fönster med titeln som anges som argument, och exekvera main-funktionen asynkront.

Info

För att ändra andra inställningar för fönstret, som storlek eller om det ska visas i fullskärm, går det att använda structen Conf.

Inne i main-funktionen körs en evig loop som aldrig avslutas. Inne i loopen ligger all spellogik som ska köras varje bildruta. I vårt fall rensar vi bakgrunden till mörklila med funktionen clear_background(DARKPURPLE). I slutet av loopen används funktionen next_frame().await som kommer blocka exekveringen tills nästa bildruta.

Notera

Även om clear_background() inte används explicit så kommer Macroquad att rensa skärmen i början av varje bildruta.

Utmaning

Prova att ändra vilken bakgrundsfärg fönstret ska ha till din favoritfärg.

Quiz

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

Publicera på webben (om du vill)

En stor fördel med Rust och Macroquad är att det är väldigt smidigt att kompilera ett fristående program för olika plattformar. Vi kommer att gå igenom hur det görs senare i den här guiden, men om du vill kan du redan nu ordna så att varje gång du pushar kod till ditt Github-repository så publiceras även en webbläsarversion av spelet.

När du skapade spelet med cargo new skapades även ett lokalt Git repository. Börja med att committa dina ändringar lokalt. Skapa sedan ett repository på Github, och pusha koden dit.

Note

Nedanstående två filer refererar till mitt-spel.wasm. Om du döpt din crate till något annat än mitt-spel behöver du ändra de referenserna.

Det behövs en HTML-fil för att visa spelet. Skapa index.html i roten av projektet/craten med detta innehåll:

<!DOCTYPE html>
<html lang="sv">
<head>
    <meta charset="utf-8">
    <title>Mitt Spel</title>
    <style>
        html,
        body,
        canvas {
            margin: 0;
            padding: 0;
            width: 100%;
            height: 100%;
            overflow: hidden;
            position: absolute;
            background: black;
            z-index: 0;
        }
    </style>
</head>
<body>
    <canvas id="glcanvas" tabindex='1'></canvas>
    <!-- Minified and statically hosted version of https://github.com/not-fl3/macroquad/blob/master/js/mq_js_bundle.js -->
    <script src="https://not-fl3.github.io/miniquad-samples/mq_js_bundle.js"></script>
    <script>load("mitt-spel.wasm");</script> <!-- Din kompilerade WASM-binär -->
</body>
</html>

Följande Github Actions Workflow kompilerar spelet till WASM och lägger i ordning alla filer så att spelet funkar på webben. Koden skall placeras i .github/workflows/deploy.yml.

name: Build and Deploy
on:
  push:
    branches:
      - main # If your default branch is named something else, change this

permissions:
  contents: write
  pages: write

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          target: wasm32-unknown-unknown
          override: true

      - name: Build
        run: cargo build --release --target wasm32-unknown-unknown

      - name: Prepare Deployment Directory
        run: |
          mkdir -p ./deploy
          cp ./target/wasm32-unknown-unknown/release/mitt-spel.wasm ./deploy/
          cp index.html ./deploy/

      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./deploy

Committa och pusha! Du kan följa bygget under Actions för repositoryt. Första gången du pushar kommer spelet att byggas och alla filer placeras rätt i, i roten för branchen gh-pages, men ingen webbpats kommer att byggas. Du behöver justera en inställning för Github-repot i Settings > Pages > Build and deployment, och konfigurera gh-pages som den branch webbplatsen skall byggas från.

Github Pages Settings

När bygget är klart kommer du kunna spela spelet på https://<ditt-github-namn>.github.io/<repository-namn>.

Eller… spela, och spela, du kommer se en helt lila webbsida. Men nu har du levererat tidigt och projektet är dessutom konfigurerat för kontinuerlig leverans. Vartefter du lägger till funktionalitet till spelet och pushar dem till Github kommer den senaste versionen kunna spelas på webben. Redan i nästa kapitel börjar det röra på sig!

Far å flyg

Screenshot

Ett spel är inte så roligt utan att det händer något på skärmen. Till att börja med visar vi en boll som vi kan styra med knapptryckningar.

Implementering

De första två raderna i main-funktionen använder funktionerna screen_width() och screen_height() för att få bredden och höjden på fönstret. Dessa värden delas med 2 för att få koordinaterna till mitten av skärmen, och tilldelas till variablerna x och y.

    let mut x = screen_width() / 2.0;
    let mut y = screen_height() / 2.0;

Hantera tangenbordsinput

Inne i loopen rensar vi fortfarande skärmen, vilket måste göras vid varje bildruta. Därefter kommer fyra if-satser som kollar om piltangerna är nedtryckta och ändrar på variablerna x eller y som avgör var cirkeln ska visas. Funktionen is_key_down() returnerar true om den angivna tangenten är nedtryckt. Dess argument är enumen KeyCode som innehåller alla tangenter som finns på ett tangentbord.

        if is_key_down(KeyCode::Right) {
            x += 1.0;
        }
        if is_key_down(KeyCode::Left) {
            x -= 1.0;
        }
        if is_key_down(KeyCode::Down) {
            y += 1.0;
        }
        if is_key_down(KeyCode::Up) {
            y -= 1.0;
        }

Info

Vilka andra tangenter som finns tillgängliga finns beskrivet i dokumentationen för KeyCode.

Rita en cirkel

Slutligen ritas cirkeln ut på de angivna koordinaterna med en radie på 16 och med gul färg på koordinaterna x och y.

        draw_circle(x, y, 16.0, YELLOW);

Info

Macroquad har ett flertal konstanter för vanliga färger, det går också att använda makrot color_u8 för att ange en färg med värden för röd, grön, blå och transparens. Vilka andra former som går att rita med Macroquad finns beskrivet i dokumentationen för Macroquads Shape API.

Utmaning

Ändra värdet som adderas till x och y för att öka eller minska hastigheten som cirkeln förflyttas.

Källkod

Hela källkoden i main.rs ska nu se ut så här:

use macroquad::prelude::*;

#[macroquad::main("Mitt spel")]
async fn main() {
    let mut x = screen_width() / 2.0;
    let mut y = screen_height() / 2.0;

    loop {
        clear_background(DARKPURPLE);

        if is_key_down(KeyCode::Right) {
            x += 1.0;
        }
        if is_key_down(KeyCode::Left) {
            x -= 1.0;
        }
        if is_key_down(KeyCode::Down) {
            y += 1.0;
        }
        if is_key_down(KeyCode::Up) {
            y -= 1.0;
        }

        draw_circle(x, y, 16.0, YELLOW);

        next_frame().await
    }
}

När du kör programmet så kommer det visas en gul cirkel i mitten av skärmen. Prova att använda piltangenterna för att flytta omkring bollen.

Quiz

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

Mjukare rörelser

Screenshot

Eftersom Macroquad kommer rita bildrutor så snabbt som den kan måste vi kolla hur lång tid som har gått mellan varje uppdatering för att avgöra hur långt cirkeln ska förflyttas. Annars kommer vårt spel gå olika fort på olika datorer, beroende på hur snabbt dom kan köra programmet.

Implementering

Vi ska därför utöka programmet och lägga till en konstant variabel som avgör hur snabbt cirkeln ska röra sig. Vi kallar den MOVEMENT_SPEED och börjar med att tilldela den värdet 200.0. Går det för fort eller för sakta kan vi sänka eller öka detta värde.

    const MOVEMENT_SPEED: f32 = 200.0;

Tid mellan bildrutor

Därefter använder vi funktionen get_frame_time() som ger oss hur lång tid i sekunder det har gått sedan föregående bildruta ritades på skärmen och tilldelar den till variabeln delta_time.

        let delta_time = get_frame_time();

Ändra förflyttningen

Förändringen av variablerna x och y kan sedan bytas ut till en multiplikation av värdena för MOVEMENT_SPEED och delta_time för att få hur långt cirkeln ska förflyttas under denna bildruta.

        if is_key_down(KeyCode::Right) {
            x += MOVEMENT_SPEED * delta_time;
        }
        if is_key_down(KeyCode::Left) {
            x -= MOVEMENT_SPEED * delta_time;
        }
        if is_key_down(KeyCode::Down) {
            y += MOVEMENT_SPEED * delta_time;
        }
        if is_key_down(KeyCode::Up) {
            y -= MOVEMENT_SPEED * delta_time;
        }

Begränsa förflyttningen

Slutligen vill vi också att cirkeln aldrig ska hamna utanför fönstret, därför begränsar vi variablerna x och y.

        x = x.min(screen_width()).max(0.0);
        y = y.min(screen_height()).max(0.0);

Info

Metoderna min() och max() används för att få det minsta eller högsta av två värden.

Utmaning

Ändra konstanten MOVEMENT_SPEED om cirkeln rör sig för fort eller för sakta. Vad behöver ändras för att hela cirkeln ska vara kvar på skärmen?

Källkod

Nu ser vårt program ut så här:

use macroquad::prelude::*;

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

    let mut x = screen_width() / 2.0;
    let mut y = screen_height() / 2.0;

    loop {
        clear_background(DARKPURPLE);

        let delta_time = get_frame_time();
        if is_key_down(KeyCode::Right) {
            x += MOVEMENT_SPEED * delta_time;
        }
        if is_key_down(KeyCode::Left) {
            x -= MOVEMENT_SPEED * delta_time;
        }
        if is_key_down(KeyCode::Down) {
            y += MOVEMENT_SPEED * delta_time;
        }
        if is_key_down(KeyCode::Up) {
            y -= MOVEMENT_SPEED * delta_time;
        }

        x = x.min(screen_width()).max(0.0);
        y = y.min(screen_height()).max(0.0);

        draw_circle(x, y, 16.0, YELLOW);
        next_frame().await
    }
}

Quiz

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

Fallande fyrkanter

Screenshot

För att det ska hända lite mer i vårt spel är det dags att skapa lite action. Eftersom hjälten i vårt spel är en modig cirkel så får våra motståndare bli kantiga fyrkanter som faller ner från toppen av fönstret.

Implementering

Struct för former

För att hålla reda på vår cirkel och alla fyrkanter så skapar vi en struct som vi kan ge namnet Shape som innehåller storlek, hastighet samt x och y-koordinater.

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

Initiera slumpgenerator

Vi kommer använda oss av en slumpgenerator för att avgöra när nya fyrkanter ska komma in på skärmen. Därför behöver vi seeda slumpgeneratorn så att det inte blir samma slumptal varje gång. Detta görs i början av main-funktionen med metoden rand::srand() som vi skickar in nuvarande tid till som seed.

    rand::srand(miniquad::date::now() as u64);

Notera

Vi använder oss av metoden miniquad::date::now() från det underliggande grafikramverket Miniquad för att få den aktuella tiden.

Vektor med fyrkanter

I början av main-funktionen skapar vi en vektor squares som kommer innehålla alla fyrkanter som ska visas på skärmen. Den nya variabeln circle får representera vår hjälte, den fantastiska cirkeln. Hastigheten använder konstanten MOVEMENT_SPEED och x och y-fälten sätts till mitten av skärmen.

    let mut squares = vec![];
    let mut circle = Shape {
        size: 32.0,
        speed: MOVEMENT_SPEED,
        x: screen_width() / 2.0,
        y: screen_height() / 2.0,
    };

Börja med att ändra programmet så att circle används i stället för variablerna x, och y och bekräfta att allt fungerar som förut innan du börjar skapa fiendefyrkanter.

Skapa nya fyrkanter

Nu är det dags att starta invasionen av fyrkanter. Här delar vi som tidigare upp förflyttningen och utritningen av fyrkanterna. Det gör att förflyttningen inte behöver vara beroende av uppdateringsfrekvensen av skärmen, och vi kan se till att alla förändringar har skett innan vi börjar rita upp något på skärmen.

Först använder vi oss av funktionen rand::gen_range() för att avgöra om vi ska lägga till en ny fyrkant. Den tar två argument, ett lägsta värde och ett högsta värde, och returnerar sedan ett slumpat tal mellan dom två värdena. Om värdet är tillräckligt högt så skapar vi en ny Shape och lägger till i vektorn squares. För att få lite variation använder vi även rand::gen_range() för att få olika storlek, hastighet och startposition på alla fyrkanter.

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

Notera

Rektanglar ritas ut med början från övre vänstra hörnet. Därför subtraherar vi halva fyrkantens storlek när vi räknar ut X-positionen. Y-positionen börjar på negativt av fyrkantens storlek, så att den börjar helt utanför skärmen.

Uppdatera fyrkanters position

Nu kan vi gå igenom hela vektorn med en for-loop och uppdatera y-positionen med hjälp av fyrkantens hastighet och variabeln delta_time. Detta gör att fyrkanterna kommer åka neråt över skärmen.

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

Rensa bort fyrkanter som inte syns

Därefter måste vi rensa upp alla fyrkanter som har hamnat utanför skärmen då det är onödigt att rita ut saker som inte syns. Vi använder oss av metoden retain() på vektorn som tar en funktion som avgör om elementen ska behållas. Vi kollar att fyrkantens y-värde fortfarande är mindre än höjden på fönstret plus storleken på fyrkanten.

        squares.retain(|square| square.y < screen_height() + square.size);

Rita ut fyrkanterna

Till sist lägger vi till en for-loop som går igenom vektorn squares och använder funktionen draw_rectangle() för att rita ut en rektangel på den uppdaterade positionen och med rätt storlek. Eftersom rektanglar ritas ut med x och y från hörnet längst upp till vänster och våra koordinater utgår från center av fyrkanten så använder vi lite matematik för att räkna ut var dom ska placeras. Storleken används två gånger, en gång för fyrkantens bredd och en gång för fyrkantens höjd. Vi sätter färgen till GREEN så att alla fyrkanter blir gröna.

Notera

Det finns även funktionen draw_rectangle_ex() som tar structen DrawTextureParams istället för en färg. Med den kan man förutom färg även sätta rotation och offset på rektangeln.

        for square in &squares {
            draw_rectangle(
                square.x - square.size / 2.0,
                square.y - square.size / 2.0,
                square.size,
                square.size,
                GREEN,
            );
        }

Utmaning

Försök att ge olika färger till fyrkanterna genom att använda metoden choose() på vektorer från Macroquads ChooseRandom trait som returnerar ett slumpmässigt valt element från vektorn.

Komplett källkod

Klicka för att visa hela källkoden
use macroquad::prelude::*;

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

#[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 circle = Shape {
        size: 32.0,
        speed: MOVEMENT_SPEED,
        x: screen_width() / 2.0,
        y: screen_height() / 2.0,
    };

    loop {
        clear_background(DARKPURPLE);

        let delta_time = get_frame_time();
        if is_key_down(KeyCode::Right) {
            circle.x += MOVEMENT_SPEED * delta_time;
        }
        if is_key_down(KeyCode::Left) {
            circle.x -= MOVEMENT_SPEED * delta_time;
        }
        if is_key_down(KeyCode::Down) {
            circle.y += MOVEMENT_SPEED * delta_time;
        }
        if is_key_down(KeyCode::Up) {
            circle.y -= MOVEMENT_SPEED * delta_time;
        }

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

        // Move squares
        for square in &mut squares {
            square.y += square.speed * delta_time;
        }

        // Remove squares below bottom of screen
        squares.retain(|square| square.y < screen_height() + square.size);

        // Draw everything
        draw_circle(circle.x, circle.y, circle.size / 2.0, YELLOW);
        for square in &squares {
            draw_rectangle(
                square.x - square.size / 2.0,
                square.y - square.size / 2.0,
                square.size,
                square.size,
                GREEN,
            );
        }

        next_frame().await
    }
}

Quiz

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

Kollisionskurs

Screenshot

Våra ovänner fyrkanterna är ännu inte så farliga, så får att öka spänningen är det dags att skapa konflikt. Om vår vän cirkeln kolliderar med en fyrkant så är spelet över och måste startas om.

Efter att vi har ritat upp alla cirklar och fyrkanter så lägger vi till en kontroll som ser om någon fyrkant rör vid cirkeln. Om den gör det så visar vi texten Game Over och väntar på att spelaren trycker på space-tangenten. När spelaren trycker på space så nollställs vektorn med fyrkanter och cirkeln flyttas tillbaka till mitten av skärmen.

Implementering

Kollisionsmetod

Vi utökar structen Shape med en implementation som innehåller metoden collides_with() som kollar om den kolliderar med en annan Shape. Denna använder sig av Macroquads Rect struct som har hjälpmetoden overlaps(). Vi skapar även en egen hjälpmetod som skapar en Rect från vår Shape.

Info

Det finns många hjälpmetoder på Rect för göra beräkningar på rektanglar, som contains(), intersect(), scale(), combine_with() och move_to()`.

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

Notera

Macroquads Rect utgår också från övre vänstra hörnet, därför måste vi även här subtrahera halva storleken från både X och Y.

Är det game over?

Vi behöver en ny boolesk variabel gameover som håller reda på om spelaren har dött som vi lägger in före huvudloopen.

    let mut gameover = false;

För att cirkeln och fyrkanterna inte ska röra sig medan det är game over så görs all kod för förflyttning enbart om variabeln gameover är false.

        if !gameover {
            ...
        }

Kollidering

Efter förflyttningskoden lägger vi till en kontroll om någon fyrkant kolliderar med cirkeln. Vi använder metoden any() på iteratorn för vektorn squares och kollar om någon fyrkant kolliderar med vår hjälte cirkeln. Om det har skett en kollision sätter vi variabeln gameover till true.

        if squares.iter().any(|square| circle.collides_with(square)) {
            gameover = true;
        }

Utmaning

Kollisionskoden utgår från att cirkeln är en fyrkant. Prova att skriva kod som tar hänsyn till att cirkeln inte fyller ut hela fyrkanten.

Återställning

Om gameover-variabeln är true och spelaren precis har tryckt på mellanslagstangenten så tömmer vi vektorn squares med metoden clear() och återställer cirkelns x och y-koordinater till mitten av skärmen. Sen sätter vi variabeln gameover till false så att spelet kan börja igen.

        if gameover && is_key_pressed(KeyCode::Space) {
            squares.clear();
            circle.x = screen_width() / 2.0;
            circle.y = screen_height() / 2.0;
            gameover = false;
        }

Info

Skillnaden mellan is_key_down() och is_key_pressed() är att den senare bara kollar om tangenten trycktes ned under den aktuella bildrutan, medan den tidigare returnerar sant för alla bildrutor från att knappen trycktes ned och sedan hålls nedtryckt. Ett experiment du kan göra är att använda is_key_pressed() för att styra cirkeln.

Det finns även is_key_released() som kollar om tangenten släpptes under den aktuella bildrutan.

Skriv ut Game Over

Slutligen ritar vi ut texten “Game Over!” i mitten av skärmen efter cirkeln och fyrkanterna har ritats ut, men bara om variabeln gameover är true.

Info

Det går också att använda funktionen draw_text_ex() som tar en DrawTextParams struct istället för font_size och color. Med den kan man ange fler parameterar som font, font_scale, font_scale_aspect och rotation.

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

Utmaning

Eftersom draw_text() utgår från textens baslinje så kommer texten inte visas exakt i mitten av skärmen. Prova att använda fälten offset_y och height från text_dimensions för att räkna ut textens mittpunkt. Macroquads exempel text measures kan ge tips till hur det fungerar.

Kompletta källkoden

Klicka för att visa hela källkoden
use macroquad::prelude::*;

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

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

#[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 circle = Shape {
        size: 32.0,
        speed: MOVEMENT_SPEED,
        x: screen_width() / 2.0,
        y: screen_height() / 2.0,
    };
    let mut gameover = false;

    loop {
        clear_background(DARKPURPLE);

        if !gameover {
            let delta_time = get_frame_time();
            if is_key_down(KeyCode::Right) {
                circle.x += MOVEMENT_SPEED * delta_time;
            }
            if is_key_down(KeyCode::Left) {
                circle.x -= MOVEMENT_SPEED * delta_time;
            }
            if is_key_down(KeyCode::Down) {
                circle.y += MOVEMENT_SPEED * delta_time;
            }
            if is_key_down(KeyCode::Up) {
                circle.y -= MOVEMENT_SPEED * delta_time;
            }

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

            // Move squares
            for square in &mut squares {
                square.y += square.speed * delta_time;
            }

            // Remove squares below bottom of screen
            squares.retain(|square| square.y < screen_height() + square.size);
        }

        // Check for collisions
        if squares.iter().any(|square| circle.collides_with(square)) {
            gameover = true;
        }

        if gameover && is_key_pressed(KeyCode::Space) {
            squares.clear();
            circle.x = screen_width() / 2.0;
            circle.y = screen_height() / 2.0;
            gameover = false;
        }

        // Draw everything
        draw_circle(circle.x, circle.y, circle.size / 2.0, YELLOW);
        for square in &squares {
            draw_rectangle(
                square.x - square.size / 2.0,
                square.y - square.size / 2.0,
                square.size,
                square.size,
                GREEN,
            );
        }
        if gameover {
            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.

Bomber och granater

Screenshot

Det känns lite orättvist att vår stackars cirkel inte kan försvara sig mot de läskiga fyrkanterna. Därför är det dags att implementera skott som cirkeln kan skjuta ner fyrkanterna med.

Implementering

Känner sig träffade

För att hålla reda på vilka fyrkanter som har blivit träffade av kulor så lägger vi till ett nytt fält collided av typen bool i structen Shape.

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

Vektor för kulor

Vi måste ha en ny vektor som håller reda på alla kulor som har skjutits. Vi kallar den bullets och skapar den efter vektorn med squares. Här anger vi vilken typ vektorn ska innehålla eftersom Rust-kompilatorn måste veta vilken typ det är innan vi har tilldelat den något värde. Vi använder structen Shape även för kulorna för enkelhetens skull.

    let mut bullets: Vec<Shape> = vec![];

Skjut kulor

Efter cirkeln har förflyttats så lägger vi till en kontroll om spelaren har tryckt på mellanslag, och lägger till en kula i vektorn med kulor. Kulans x- och y-koordinater sätts till samma som cirkeln, och hastigheten till dubbla cirkelns hastighet.

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

Notera

Notera att vi använder funktionen is_key_pressed() som bara är sann under den första bildrutan som tangenten trycks ned.

Eftersom vi har lagt till ett fält på structen Shape måste vi lägga till den när vi skapar en fyrkant.

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

Flytta kulor

För att kulorna inte ska bli stillastående minor så måste vi loopa över alla kulor och flytta dom i Y-led. Lägg till följande kod efter förflyttningen av fyrkanterna.

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

Ta bort kulor och fyrkanter

Även kulorna behöver tas bort om de hamnar utanför skärmen.

            bullets.retain(|bullet| bullet.y > 0.0 - bullet.size / 2.0);

Nu är det dags att ta bort alla fyrkanter och kulor som har kolliderat med något. Det gör vi enkelt med retain-metoden och behåller alla som inte har collided satt till true. Vi gör detta på båda vektorerna för squares och bullets.

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

Kollidering

Efter vi har kollat om cirkeln har kolliderat med en fyrkant lägger vi till en kontroll om någon fyrkant blir träffad av en kula. Både kulan och fyrkanten uppdateras och fältet collided sätts till true så att vi kan ta bort dem längre ned i koden.

        for square in squares.iter_mut() {
            for bullet in bullets.iter_mut() {
                if bullet.collides_with(square) {
                    bullet.collided = true;
                    square.collided = true;
                }
            }
        }

Rensa kulor

Om det har blivit game over måste vi även rensa vektorn bullets så att kulorna försvinner när ett nytt spel påbörjas.

        if gameover && is_key_pressed(KeyCode::Space) {
            squares.clear();
            bullets.clear();
            circle.x = screen_width() / 2.0;
            circle.y = screen_height() / 2.0;
            gameover = false;
        }

Rita ut kulor

Innan vi ritar ut cirkeln så ritar vi ut alla kulor, så att de ritas ut under övriga former.

        for bullet in &bullets {
            draw_circle(bullet.x, bullet.y, bullet.size / 2.0, RED);
        }

Info

Det finns även en funktion som heter draw_circle_lines() som används för att rita ut en cirkel som inte är ifylld.

Det var allt för att kunna skjuta sönder fyrkanter.

Utmaning

För att öka svårighetsgraden går det att lägga till en begränsning så att det måste gå en viss tid mellan varje skott. Använd funktionen get_time() för att spara undan när varje skott skjuts och jämför aktuella tiden med detta värde.

Kompletta källkoden

Klicka för att visa hela källkoden
use macroquad::prelude::*;

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

#[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 gameover = false;

    loop {
        clear_background(DARKPURPLE);

        if !gameover {
            let delta_time = get_frame_time();
            if is_key_down(KeyCode::Right) {
                circle.x += MOVEMENT_SPEED * delta_time;
            }
            if is_key_down(KeyCode::Left) {
                circle.x -= MOVEMENT_SPEED * delta_time;
            }
            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,
                    speed: circle.speed * 2.0,
                    size: 5.0,
                    collided: false,
                });
            }

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

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

        // Check for collisions
        if squares.iter().any(|square| circle.collides_with(square)) {
            gameover = true;
        }
        for square in squares.iter_mut() {
            for bullet in bullets.iter_mut() {
                if bullet.collides_with(square) {
                    bullet.collided = true;
                    square.collided = true;
                }
            }
        }

        if gameover && is_key_pressed(KeyCode::Space) {
            squares.clear();
            bullets.clear();
            circle.x = screen_width() / 2.0;
            circle.y = screen_height() / 2.0;
            gameover = false;
        }

        // Draw everything
        for bullet in &bullets {
            draw_circle(bullet.x, bullet.y, bullet.size / 2.0, RED);
        }
        draw_circle(circle.x, circle.y, circle.size / 2.0, YELLOW);
        for square in &squares {
            draw_rectangle(
                square.x - square.size / 2.0,
                square.y - square.size / 2.0,
                square.size,
                square.size,
                GREEN,
            );
        }
        if gameover {
            let text = "Game Over!";
            let text_dimensions = measure_text(text, None, 60, 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.

Poängsystem

Screenshot

Vad vore ett spel utan poäng och high score? Det är nu dags att implementera ett poängsystem för vårt spel. Poäng kommer ges för varje fyrkant som skjuts ner, baserat på storleken. Poängen kommer visas på skärmen, såväl som den högsta poäng som har uppnåtts. Om poängen är en high score så kommer poängen skrivas ner till en fil på disk så att det kan läsas in igen nästa gång spelet startas.

Implementering

Importera modul

För att kunna läsa och skriva filer behöver vi importera Rusts std::fs modul som innehåller funktionalitet för att läsa och skriva till datorns lokala filsystem. Denna rad kan läggas in under raden som importerar Macroquad längst upp i filen.

use std::fs;

Nya variabler

Vi behöver två nya variabler, score och high_score för att hålla reda på spelarens poäng och den högsta poängen som har uppnåtts. Vi använder oss av funktionen fs::read_to_string() för att läsa in filen highscore.dat. Poängen i filen måste konverteras till en u32 vilket görs med i.parse::<u32>(). Om något går fel, som att filen inte finns eller innehåller något som inte är en siffra, så kommer siffran 0 att returneras.

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

Notera

Här skriver vi direkt till datorns hårddisk, vilket inte fungerar om spelet har kompilerats till WASM och körs på en webbsida.

Uppdatera high score

Om cirkeln krockar med en fyrkant så lägger vi till en kontroll om spelarens poäng är en high score. Är den det så skriver vi ner high scoren till filen highscore.dat.

        if squares.iter().any(|square| circle.collides_with(square)) {
            if score == high_score {
                fs::write("highscore.dat", high_score.to_string()).ok();
            }
            gameover = true;
        }

Notera

Macroquad har stöd för att läsa filer som fungerar även när spelet körs på en webbsida. Här skulle vi kunna använda funktionen load_string() istället, men eftersom vi inte kan skriva filen är det inte så meningsfullt.

Öka poäng

Om en kula träffar en fyrkant så ökar vi spelarens poäng baserat på storleken på fyrkanten. Sen uppdaterar vi värdet i variabeln high_score om poängen är högre än det gamla värdet.

                if bullet.collides_with(square) {
                    bullet.collided = true;
                    square.collided = true;
                    score += square.size.round() as u32;
                    high_score = high_score.max(score);
                }

Nollställ poäng

När vi startar en ny spelomgång måste vi nollställa variabeln score.

        if gameover && is_key_pressed(KeyCode::Space) {
            squares.clear();
            bullets.clear();
            circle.x = screen_width() / 2.0;
            circle.y = screen_height() / 2.0;
            score = 0;
            gameover = false;
        }

Skriv ut poäng och high score

Till sist ritar vi ut poängen och high score på skärmen. Poängen skriver vi alltid ut i övre vänstra hörnet. För att kunna skriva ut high scoren i högra hörnet behöver vi använda oss av funktionen measure_text() för att räkna ut hur långt från skärmens högra sida texten ska placeras.

För att dimensionerna ska stämma måste samma värden användas som argument till measure_text() som till draw_text(). Argumenten är text, font, font_size och font_scale. Eftersom vi inte sätter någon speciell font eller skalar om texten så skickar vi in None som font, och 1.0 som font_scale. Däremot måste font_size vara samma som i anropet av draw_text() vilket i vårt fall är 25.0.

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

Info

Funktionen measure_text() returnerar structen TextDimensions som innehåller fälten width, height och offset_y.

Kör igång spelet och försök få så hög poäng som möjligt!

Utmaning

Testa att skriva ut en gratulationstext på skärmen vid Game Over om spelaren uppnådde en high score.

Kompletta källkoden

Klicka för att visa hela källkoden
use macroquad::prelude::*;

use std::fs;

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

#[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 gameover = 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);

    loop {
        clear_background(DARKPURPLE);

        if !gameover {
            let delta_time = get_frame_time();
            if is_key_down(KeyCode::Right) {
                circle.x += MOVEMENT_SPEED * delta_time;
            }
            if is_key_down(KeyCode::Left) {
                circle.x -= MOVEMENT_SPEED * delta_time;
            }
            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,
                    speed: circle.speed * 2.0,
                    size: 5.0,
                    collided: false,
                });
            }

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

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

        if gameover && is_key_pressed(KeyCode::Space) {
            squares.clear();
            bullets.clear();
            circle.x = screen_width() / 2.0;
            circle.y = screen_height() / 2.0;
            score = 0;
            gameover = false;
        }

        // 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();
            }
            gameover = true;
        }
        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);
                }
            }
        }

        // Draw everything
        for bullet in &bullets {
            draw_circle(bullet.x, bullet.y, bullet.size / 2.0, RED);
        }
        draw_circle(circle.x, circle.y, circle.size / 2.0, YELLOW);
        for square in &squares {
            draw_rectangle(
                square.x - square.size / 2.0,
                square.y - square.size / 2.0,
                square.size,
                square.size,
                GREEN,
            );
        }
        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,
        );
        if gameover {
            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.

Game state

Screenshot

Innan vi lägger till någon ny funktionalitet i vårt spel så är det dags för lite refaktorisering. För att det ska bli enklare att hantera spelets tillstånd så inför vi en enum vid namn GameState som håller reda på om spelet pågår eller om det har blivit game over. Tack vare detta kan vi ta bort vår gameover variabel, och lägga till tillstånd för en meny och pausa spelet.

Implementering

Enum för game state

Börja med att lägga till en enum kallad GameState under implementationen av Shape. Den innehåller alla fyra tillstånd som spelet kan vara i.

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

Variabel för GameState

Ersätt raden som deklarerar variabeln gameover med en deklarering av en ny game_state variabel. Till att börja med sätter vi den till tillståndet GameState::MainMenu så att vi väntar på att spelaren trycker mellanslag innan spelet börjar.

    let mut game_state = GameState::MainMenu;

Matcha på GameState

Koden inne i spelloopen ska nu ersättas med en matchning på variabeln game_state. Den måste hantera alla tillstånd i enumen. Senare ska vi införa koden från tidigare steg inne i de olika blocken. Behåll anropet till rensning av skärmen i början av loopen, och anropet till next_frame().await i slutet.

        clear_background(DARKPURPLE);

        match game_state {
            GameState::MainMenu => {
                ...
            }
            GameState::Playing => {
                ...
            }
            GameState::Paused => {
                ...
            }
            GameState::GameOver => {
                ...
            }
        }

        next_frame().await

Huvudmeny

Nu ska vi lägga till kod i varje block i matchningen för att hantera varje tillstånd. När spelet börjar kommer spelet vara i tillståndet GameState::MainMenu. Vi börjar med att kolla om Escape är nedtryckt så kan vi avsluta spelet. Om spelaren trycker på mellanslagstangenten tilldelar vi det nya tillståndet GameState::Playing till variabeln game_state. Vi passar även på att nollställa alla spelvariabler. Till sist skriver ut texten “Tryck mellanslag” i mitten av skärmen.

            GameState::MainMenu => {
                if is_key_pressed(KeyCode::Escape) {
                    std::process::exit(0);
                }
                if is_key_pressed(KeyCode::Space) {
                    squares.clear();
                    bullets.clear();
                    circle.x = screen_width() / 2.0;
                    circle.y = screen_height() / 2.0;
                    score = 0;
                    game_state = GameState::Playing;
                }
                let text = "Tryck på mellanslag";
                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,
                );
            },

Pågående spel

Nu ska vi lägga tillbaka koden för spelet, det är samma som större delen av spelloopen från förra kapitlet. Dock ska inte koden som hanterar game over vara med då vi kommer lägga in det nedan i tillståndet för GameState::Playing. Vi lägger också till en kontroll om spelaren tryckt på Escape och byter tillstånd till GameState::Paused.

            GameState::Playing => {
                let delta_time = get_frame_time();
                if is_key_down(KeyCode::Right) {
                    circle.x += MOVEMENT_SPEED * delta_time;
                }
                if is_key_down(KeyCode::Left) {
                    circle.x -= MOVEMENT_SPEED * delta_time;
                }
                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,
                        speed: circle.speed * 2.0,
                        size: 5.0,
                        collided: false,
                    });
                }
                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;
                }

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

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

                // Draw everything
                for bullet in &bullets {
                    draw_circle(bullet.x, bullet.y, bullet.size / 2.0, RED);
                }
                draw_circle(circle.x, circle.y, circle.size / 2.0, YELLOW);
                for square in &squares {
                    draw_rectangle(
                        square.x - square.size / 2.0,
                        square.y - square.size / 2.0,
                        square.size,
                        square.size,
                        GREEN,
                    );
                }
                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,
                );
            },

Pausa spelet

Många spel har en möjlighet att pausa, så vi passar på att lägga in stöd för det även i vårat spel. I pausat läge kollar vi om spelaren trycker på Escape, om så är fallet så sätter vi tillståndet till GameState::Playing så att spelet kan fortsätta igen. Sen skriver vi ut en text på skärmen om att spelet är pausat.

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

Game Over

Till sist ska vi hantera vad som händer när det blir game over. Om spelaren trycker på mellanslag så byter vi tillstånd till GameState::MainMenu så att spelaren kan börja ett nytt spel eller avsluta spelet. Sen skriver vi ut texten på skärmen som tidigare.

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

Notera

Eftersom tillstånden för Playing och GameOver är separerade nu så visas inte någonting från spelet när det är game over.

Utmaning

Nu när det finns en startmeny så kan du hitta på ett namn på ditt spel och skriva ut det med stor text på övre delen av skärmen i tillståndet för GameState::MainMenu.

Kompletta källkoden

Klicka för att visa hela källkoden
use macroquad::prelude::*;

use std::fs;

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

#[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;

    loop {
        clear_background(DARKPURPLE);

        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();
                    circle.x = screen_width() / 2.0;
                    circle.y = screen_height() / 2.0;
                    score = 0;
                    game_state = GameState::Playing;
                }
                let text = "Tryck på mellanslag";
                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();
                if is_key_down(KeyCode::Right) {
                    circle.x += MOVEMENT_SPEED * delta_time;
                }
                if is_key_down(KeyCode::Left) {
                    circle.x -= MOVEMENT_SPEED * delta_time;
                }
                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,
                        speed: circle.speed * 2.0,
                        size: 5.0,
                        collided: false,
                    });
                }
                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;
                }

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

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

                // Draw everything
                for bullet in &bullets {
                    draw_circle(bullet.x, bullet.y, bullet.size / 2.0, RED);
                }
                draw_circle(circle.x, circle.y, circle.size / 2.0, YELLOW);
                for square in &squares {
                    draw_rectangle(
                        square.x - square.size / 2.0,
                        square.y - square.size / 2.0,
                        square.size,
                        square.size,
                        GREEN,
                    );
                }
                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
    }
}

Quiz

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

Stjärnfält med en shader

Screenshot

Den gröna bakgrunden börjar kännas lite tråkig, så nu är det dags att göra en lite mer intressant bakgrund. Vi kommer använda oss av en pixel shader för att göra ett stjärnfält. Hur man kodar en shader ligger utanför den här guiden, utan vi kommer använda oss av en färdig utan att gå in på detaljerna.

Kortfattat är en shader ett program som körs på datorns GPU, skrivet i ett C-liknande programmeringsspråk som kallas GLSL. Shadern består av två delar, en vertex shader och en fragment shader. Vertex shadern konverterar från koordinater i en 3D-miljö till 2D-koordinater för en skärm. Fragment shadern körs sedan för varje pixel på skärmen, och sätter variabeln gl_FragColor som avgör vilken färg pixeln ska ha. Eftersom vårt spel är i 2D så gör vertex shadern ingenting mer än att sätta positionen.

Implementering

Shaders

Längst upp i main.rs ska vi lägga till en vertex shader och fragment-shadern från en fil som vi kommer skapa senare. Vi använder oss av Rusts macro include_str!() som läser in filen som en &str vid kompileringen. Vertex-shadern är så kort att den kan läggas in direkt här i källkoden.

Den viktigaste raden i shadern är den som sätter gl_Position. För enkelhetens skull sätter vi iTime som används av shadern från _Time.x. Det hade också gått att använda _Time direkt i shadern.

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

Initialisera shadern

I vår main() funktion, innan loopen, så måste vi sätta upp några variabler för att kunna rita ut shadern. Vi börjar med att skapa variabeln direction_modifier som vi ska använda för att påverka hur stjärnorna rör sig medan cirkeln förflyttas i sidled. Därefter skapar vi en render_target som shadern kommer att renderas till.

Sen laddar vi in vår vertex shader och fragment shader till en Material med hjälp av en ShaderSource::Glsl.

I parametrarna sätter vi även upp två uniforms till shadern som är globala variabler som vi kan sätta för varje bildruta. Uniformen iResolution innehåller fönstrets storlek, och direction_modifier kommer användas för att styra åt vilken riktning stjärnorna ska röra sig.

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

Info

Macroquad lägger automatiskt in några uniforms till shaders. De som finns tillgängliga är _Time, Model, Projection, Texture och _ScreenTexture.

Rita ut shadern

Nu är det dags att byta ut den lila bakgrund till vårt stjärnfält. Byt ut kodraden clear_background(DARKPURPLE); till nedanstående kod.

Först måste vi tilldela fönstrets upplösning till materialets uniform iResolution för att alltid få rätt fönsterstorlek. Vi sätter även uniformen direction_modifier till värdet av den motsvarande variabeln.

Därefter använder vi funktionen gl_use_material() för att använda materialet. Slutligen använder vi funktionen draw_texture_ex() för att rita ut texturen från vår render_target på skärmens bakgrund. Innan vi fortsätter återställer vi shadern med gl_use_default_material() så den inte används när vi ritar ut resten av spelet.

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

Styr stjärnornas rörelse

När spelaren håller ner höger eller vänster piltangent så lägger vi till eller drar ifrån ett värde från variabeln direction_modifier så att shadern kan ändra riktningen på stjärnornas rörelse. Även här multiplicerar vi värdet med delta_time så det blir relativt till hur lång tid det har tagit sedan föregående bildruta.

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

Skapa fil för shadern

Till sist måste vi skapa en fil som innehåller fragment shadern. Skapa en fil med namnet starfield-shader.glsl i din src-katalog och lägg in följande kod:

#version 100

// Starfield Tutorial by Martijn Steinrucken aka BigWings - 2020
// countfrolic@gmail.com
// License Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
// From The Art of Code: https://www.youtube.com/watch?v=rvDo9LvfoVE

precision highp float;

varying vec4 color;
varying vec2 uv;
varying float iTime;

uniform vec2 iResolution;
uniform float direction_modifier;

#define NUM_LAYERS 4.

mat2 Rot(float a) {
    float s = sin(a), c = cos(a);
    return mat2(c, -s, s, c);
}

float Star(vec2 uv, float flare) {
    float d = length(uv);
    float m = .05 / d;

    float rays = max(0., 1. - abs(uv.x * uv.y * 1000.));
    m += rays * flare;
    uv *= Rot(3.1415 / 4.);
    rays = max(0., 1. - abs(uv.x * uv.y * 1000.));
    m += rays * .3 * flare;

    m *= smoothstep(1., .2, d);

    return m;
}

float Hash21(vec2 p) {
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

vec3 StarLayer(vec2 uv) {
    vec3 col = vec3(0);

    vec2 gv = fract(uv) - .5;
    vec2 id = floor(uv);

    float t = iTime * 0.1;
    for (int y = -1; y <= 1; y++) {
        for (int x = -1; x <= 1; x++) {
            vec2 offs = vec2(x, y);

            float n = Hash21(id + offs); // random between 0 and 1
            float size = fract(n * 345.32);
            float star = Star(gv - offs - vec2(n, fract(n * 42.)) + .5, smoothstep(.9, 1., size) * .6);
            vec3 color = sin(vec3(.8, .8, .8) * fract(n * 2345.2) * 123.2) * .5 + .5;
            color = color * vec3(0.25, 0.25, 0.20);
            star *= sin(iTime * 3. + n * 6.2831) * .5 + 1.;
            col += star * size * color;
        }
    }
    return col;
}

void main()
{
    vec2 uv = (gl_FragCoord.xy - .5 * iResolution.xy) / iResolution.y;
    float t = iTime * .02;

    float speed = 3.0;
    vec2 direction = vec2(-0.25 + direction_modifier, -1.0) * speed;

    uv += direction;
    vec3 col = vec3(0);

    for (float i = 0.; i < 1.; i += 1. / NUM_LAYERS) {
        float depth = fract(i+t);
        float scale = mix(20., .5, depth);
        float fade = depth * smoothstep(1., .9, depth);
        col += StarLayer(uv * scale + i * 453.2) * fade;
    }

    gl_FragColor = vec4(col, 1.0);
}

Info

Om du vill veta hur shadern fungerar så kan du titta på videon Shader Coding: Making a starfield av The Art of Code.

Nu är vårt stjärnfält klart och vårt spel börjar se ut som det utspelar sig i rymden!

Utmaning

Titta på videon som nämns ovan och se om du kan ändra på färger och storlek på stjärnorna.

Kompletta källkoden

Klicka för att visa hela källkoden
use macroquad::prelude::*;

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

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

    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();
                    circle.x = screen_width() / 2.0;
                    circle.y = screen_height() / 2.0;
                    score = 0;
                    game_state = GameState::Playing;
                }
                let text = "Tryck på mellanslag";
                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();
                if is_key_down(KeyCode::Right) {
                    circle.x += MOVEMENT_SPEED * delta_time;
                    direction_modifier += 0.05 * delta_time;
                }
                if is_key_down(KeyCode::Left) {
                    circle.x -= MOVEMENT_SPEED * delta_time;
                    direction_modifier -= 0.05 * delta_time;
                }
                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,
                        speed: circle.speed * 2.0,
                        size: 5.0,
                        collided: false,
                    });
                }
                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;
                }

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

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

                // Draw everything
                for bullet in &bullets {
                    draw_circle(bullet.x, bullet.y, bullet.size / 2.0, RED);
                }
                draw_circle(circle.x, circle.y, circle.size / 2.0, YELLOW);
                for square in &squares {
                    draw_rectangle(
                        square.x - square.size / 2.0,
                        square.y - square.size / 2.0,
                        square.size,
                        square.size,
                        GREEN,
                    );
                }
                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
    }
}

Quiz

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

Partikelexplosioner

Screenshot

Vi vill inte att fyrkanterna bara ska försvinna i tomma intet när dom träffas av en kula. Därför ska vi nu använda oss av Macroquads partikelsystem för att generera explosioner. Partikelsystemet kan effektivt skapa och rita många små partiklar på skärmen baserat på en grundkonfiguration. I vårt fall kommer partiklarna att åka ut från fyrkantens mittpunkt i alla riktningar. Vi kan senare lägga på en textur för att göra det ännu mer explosionsliknande.

Implementering

Lägg till crate

Koden för Macroquads partikelsystem ligger i en egen crate, därför behöver vi lägga till den i vår Cargo.toml fil. Det kan göras antingen genom att ändra i filen eller att köra följande kommando.

cargo add macroquad-particles

Följande rad kommer att läggas till i filen Cargo.toml under rubriken [dependencies].

[package]
name = "mitt-spel"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
macroquad = { version = "0.4", features = ["audio"] }
macroquad-particles = "0.2.0"

Bug

Version 0.2.0 av macroquad-particles stödjer inte senaste versionen av Macroquad. Om du får felet "error[E0574]: expected struct, variant or union type, found enum 'ShaderSource'" måste du använda macroquad och macroquad-particles direkt från git tills detta är åtgärdat.

Importera crate

Överst i main.rs måste vi importera det vi använder från paketet macroquad_particles.

use macroquad_particles::{self as particles, ColorCurve, Emitter, EmitterConfig};

Partikelkonfiguration

Vi kommer använda samma konfiguration för alla explosioner, och kommer bara ändra dess storlek baserat på fyrkantens storlek. Därför skapar vi en funktion som returnerar en EmitterConfig som kan användas för att skapa en Emitter. En Emitter är en punkt utifrån partiklar kan genereras.

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

Info

Det finns en mängd sätt att konfigurera en Emitter. Fälten för EmitterConfig finns beskrivna i dokumentationen för modulen macroquad-particles.

Vektor med explosioner

Vi behöver en vektor för att hålla reda på alla explosioner som inträffar. Den innehåller en tuple med en Emitter och koordinaten som den ska ritas ut på.

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

När vi startar ett nytt spel behöver vi rensa vektorn med explosioner.

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

Skapa en explosion

När en fyrkant träffas av en kula så skapar vi en ny Emitter baserat på konfigurationen från particle_explosion() med tillägget att antalet partiklar som ska genereras baseras på fyrkantens storlek. Koordinaten som partiklarna ska genereras ifrån sätts till samma som fyrkantens koordinater.

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

Ta bort explosioner

När emittern har ritat färdigt alla partiklar så måste vi ta bort den ur vektorn explosions då vi inte ska rita ut den längre. Lägg till denna kod efter fyrkanterna och kulorna har tagits bort.

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

Rita ut explosioner

Efter att fyrkanterna har ritats ut kan vi gå igenom vektorn med explosioner och rita ut dem. Vi behöver bara skicka in vilken koordinat partiklarna ska genereras på, sedan hanterar emittern själv att slumpa fram alla partiklarna och flytta på dem.

                for (explosion, coords) in explosions.iter_mut() {
                    explosion.draw(*coords);
                }

Prova spelet och se om det blir explosioner när fyrkanterna beskjuts.

Utmaning

Läs dokumentationen för EmitterConfig och prova vad som händer om du ändrar olika värden. Kan du lägga till ett partikelsystem som skjuter ut rakt bakåt från cirkeln så att det ser ut som en raketflamma.

Kompletta källkoden

Klicka för att visa hela källkoden
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("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![];

    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 = "Tryck på mellanslag";
                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();
                if is_key_down(KeyCode::Right) {
                    circle.x += MOVEMENT_SPEED * delta_time;
                    direction_modifier += 0.05 * delta_time;
                }
                if is_key_down(KeyCode::Left) {
                    circle.x -= MOVEMENT_SPEED * delta_time;
                    direction_modifier -= 0.05 * delta_time;
                }
                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,
                        speed: circle.speed * 2.0,
                        size: 5.0,
                        collided: false,
                    });
                }
                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;
                }

                // 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
                for bullet in &bullets {
                    draw_circle(bullet.x, bullet.y, bullet.size / 2.0, RED);
                }
                draw_circle(circle.x, circle.y, circle.size / 2.0, YELLOW);
                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!("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
    }
}

Quiz

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

Grafik

Nu börjar det bli dags att lägga till grafik i vårt spel så det börjar se ut som ett riktigt spel. Vi kommer göra det i tre omgångar, för att det inte ska bli för mycket ändringar på en gång. Till en början kommer vi lägga in inladdningen av texturer direkt i vår main-funktion och byta ut ritnings-funktionerna i huvudloopen. I ett senare kaptitel kommer vi titta på att bryta ut det till separata delar.

Innan vi ändrar någon kod behöver vi ladda ner alla resurser som behövs. Ladda ner det här paketet med grafik och ljud och packa upp det och lägg filerna i en katalog som heter assets i rotkatalogen för ditt spel. Alla resurser är public domain och har framförallt hämtats från webbplatsen OpenGameArt.org där det finns alla möjliga resurser för att skapa spel.

Filstrukturen för ditt spel bör nu se ut såhär:

.
├── Cargo.lock
├── Cargo.toml
├── README.md
├── assets
│   ├── 8bit-spaceshooter.ogg
│   ├── atari_games.ttf
│   ├── button_background.png
│   ├── button_clicked_background.png
│   ├── enemy-big.png
│   ├── enemy-medium.png
│   ├── enemy-small.png
│   ├── explosion.png
│   ├── explosion.wav
│   ├── laser-bolts.png
│   ├── laser.wav
│   ├── ship.png
│   └── window_background.png
└── src
    ├── main.rs
    └── starfield-shader.glsl

Uppdatera webbpubliceringen

Om du ordnade med att publicera ditt spel till Github Pages i första kapitlet behöver du även uppdatera .github/workflows/deploy.yml så att assets inkluderas i publiceringen:

Dels behöver assets-katalogen skapas:

          mkdir -p ./deploy/assets

Och assets-filerna skall kopieras på plats:

          cp -r assets/ ./deploy/

Den fullständiga deploy-konfigurationen skall se ut så här:

name: Build and Deploy
on:
  push:
    branches:
      - main # If your default branch is named something else, change this

permissions:
  contents: write
  pages: write
  
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          target: wasm32-unknown-unknown
          override: true

      - name: Build
        run: cargo build --release --target wasm32-unknown-unknown

      - name: Prepare Deployment Directory
        run: |
          mkdir -p ./deploy/assets
          cp ./target/wasm32-unknown-unknown/release/mitt-spel.wasm ./deploy/
          cp index.html ./deploy/
          cp -r assets/ ./deploy/

      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./deploy

Committa och pusha och verifiera att spelet funkar som förut på:

  • https://<ditt-github-namn>.github.io/<repository-namn>.

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

Bug

I version 0.4.4 av Macroquad är det en bugg som gör att texturerna inte fungerar som de ska när build_textures_atlas används. Om texturerna ser konstiga ut eller flimrar så prova att ta bort detta anrop.

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 rymdskeppet

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.

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

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

    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 = "Tryck på mellanslag";
                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 = 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();

                // 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!("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
    }
}

Quiz

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

Grafiska explosioner

Screenshot

För att göra explosionerna lite mer effektfulla så ska vi lägga till grafik även för partiklarna.

Bug

I version 0.4.4 av Macroquad är det en bugg som gör att texturerna inte fungerar som de ska när build_textures_atlas används. Om texturerna ser konstiga ut eller flimrar så prova att ta bort detta anrop.

Implementering

Importering

Vi börjar med att uppdatera importeringen från paketet macroquad_particles, och byta ut ColorCurve mot AtlasConfig.

use macroquad_particles::{self as particles, AtlasConfig, Emitter, EmitterConfig};

Uppdatera partikelkonfigurationen

Nu behöver vi uppdatera konfigurationen för vår particle_explosion så att den använder en AtlasConfig som beskriver hur den ska rita partiklarna från en textur istället för att använda ColorCurve. Vi uppdaterar även storleken och livstiden för att passa bättre med grafiken.

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

Ladda in texturer

Spritesheet för explosionen

Innan vi bygger texturatlasen så laddar vi in texturen med animeringen för partiklarna. Filen med animeringen heter explosion.png. Glöm inte att sätta filtret till FilterMode::Nearest.

    let explosion_texture: Texture2D = load_texture("explosion.png")
        .await
        .expect("Couldn't load file");
    explosion_texture.set_filter(FilterMode::Nearest);
    build_textures_atlas();

Lägg till texturen

När vi skapar explosionen måste vi lägga till texturen, och vi uppdaterar även mängden för att få lite fler partiklar. Här måste vi anropa metoden clone() på texturen, vilket går väldigt snabbt då det bara är en pekare till texturen.

                            explosions.push((
                                Emitter::new(EmitterConfig {
                                    amount: square.size.round() as u32 * 4,
                                    texture: Some(explosion_texture.clone()),
                                    ..particle_explosion()
                                }),
                                vec2(square.x, square.y),
                            ));

När du kör spelet nu ska explosionerna animeras med hjälp av explosionstexturen istället för att vara flerfärgade rutor.

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

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

    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 = "Tryck på mellanslag";
                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 = 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();

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

                // 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!("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
    }
}

Quiz

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

Animerade fiender

Screenshot

Nu är det bara fienderna som behöver bytas från tråkiga fyrkanter till lite mer spännande grafik. Det här fungerar likadant som med skeppet, vi laddar in en textur, skapar en animeringssprite och byter hur fienderna ritas ut.

Bug

I version 0.4.4 av Macroquad är det en bugg som gör att texturerna inte fungerar som de ska när build_textures_atlas används. Om texturerna ser konstiga ut eller flimrar så prova att ta bort detta anrop.

Implementering

Ladda in textur

Ladda in texturen enemy-small.png och sätt filter mode till 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();

Skapa animering

Spritesheet för rymdskeppet

Skapa en AnimatedSprite som beskriver vilka animationer som finns i texturen. Det är bara en animering med två bildrutor. Grafiken för fienden är 16x16 bildrutor, men texturen har en pixels mellanrum mellan bildrutorna för att inte orsaka blödning mellan rutorna när vi skalar texturen.

    let mut enemy_small_sprite = AnimatedSprite::new(
        17,
        16,
        &[Animation {
            name: "enemy_small".to_string(),
            row: 0,
            frames: 2,
            fps: 12,
        }],
        true,
    );

Uppdatera animering

Även fiendens sprite måste uppdateras efter animeringarna för rymdskeppet och kulorna.

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

Rita bildrutor för fiender

Nu kan vi byta ut utritningen av fyrkanter till att rita ut texturen från animeringen. Vi hämtar ut bildrutan från enemy_frame och använder dess source_rect i DrawTextureParams. Eftersom fienderna har slumpad storlek så utgår vi från fiendens storlek när vi sätter dest_size och X- och Y-koordinater.

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

Nu har vi bytt till grafik för alla element i spelet och det ser mer ut som ett riktigt spel.

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

    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 = "Tryck på mellanslag";
                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 = 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),
                            ));
                        }
                    }
                }

                // 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
    }
}

Musik och ljudeffekter

Ett spel behöver inte bara grafik för att det ska bli bra. Det behövs även musik och ljudeffekter.

Implementering

Aktivera feature för ljud

För att kunna använda ljud i Macroquad måste en feature aktiveras för dess crate i Cargo.toml filen för ditt spel. Uppdatera raden för macroquad under rubriken [dependencies] till att inkludera featuren audio.

[package]
name = "mitt-spel"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
macroquad = { version = "0.4", features = ["audio"] }
macroquad-particles = "0.2.0"

Importera

Ljudmodulen är inte inkluderad i Macroquads prelude, därför behöver vi importera det vi använder i modulen audio längst upp i källkoden.

use macroquad::audio::{load_sound, play_sound, play_sound_once, PlaySoundParams};

Ladda in resurser

Efter att alla texturer är inladdade så kan vi ladda in musiken och ljudeffekter. Vi har en mp3-fil med musiken som heter 8bit-spaceshooter.ogg och två wav-filer med ljudeffekter, explosion.wav och laser.wav. Musiken använder filformatet Ogg Vorbis som stöds av det mesta, dock inte av vissa webbläsare.

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

Spela upp musik

Innan loopen börjar vi spela upp musiken. Det görs med play_sound, som tar ett ljud, och structen PlaySoundParams. Vi sätter att ljudet ska spelas loopande, och med full volym.

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

Info

För att stoppa musiken kan man använda funktionen stop_sound() som tar ett ljud som argument.

Spela laserljud

När spelaren skjuter en ny kula så spelar vi upp ett laserljud med hjälp av funktionen play_sound_once() som tar det ljud som ska spelas upp som argument. Det är en genväg för att slippa använda PlaySoundParams för att spela upp ett ljud som inte loopar.

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

Info

Det går även att sätta ljudvolym per ljud med hjälp av funktionen set_sound_volume() som tar ett ljud och ett tal mellan 0 och 1.

Spela explosionsljud

När en kula träffar en fiende spelar vi upp explosionsljudet, även detta med play_sound_once.

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

När du startar spelet bör det nu spela upp musik och ljudeffekter.

Utmaning

Det kanske är lite intensivt att musiken börjar på full volym direkt, prova att sänka volymen i början och höj den när spelet börjar. Prova även att stoppa musiken när spelaren pausar spelet.

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

    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 = "Tryck på mellanslag";
                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,
                    });
                    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
    }
}

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

Resurser

Svart bild med texten “Laddar resurser” följt av tre blinkande punkter

Då det börjar bli väldigt mycket kod i vår main-funktion börjar det bli dags att strukturera om koden lite.

Vi kommer börja med att flytta inladdningen av filresurser till en egen struct. samtidigt passar vi på att byta ut alla unwrap() och expect() till att använda ?-operatorn för hantering av felmmeddelanden.

Därefter kommer vi använda oss av en coroutine för att ladda resurserna i bakgrunden samtidigt som vi visar en laddningssnurra på skärmen.

Avslutningsvis kommer vi att använda en storage för att resurserna ska bli tillgängliga i hela koden utan att vi behöver skicka runt den till alla ställen den behövs. Det gör att vi senare kan refaktorisera vår kod till att låta alla sprites rita ut sig själva.

Resurser och felmeddelanden

I detta kapitel kommer vi refaktorisera vår kod utan att lägga någon direkt funktionalitet i spelet. Detta gör vi framförallt för att bygga en grund för att senare kunna lägga till en laddningsskärm som visar att resurserna håller på att laddas in. Dessutom kommer vi kunna refaktorisera alla draw-anrop så att de görs av dom structar som ritas ut. Vi får också fördelen att vi kan flytta bort kod från vår main-funktion som börjar bli svår att överblicka.

Implementering

Resources struct

Till att börja med skapar vi en ny struct som vi kallar Resources som kommer innehålla alla filer vi laddar in från filsystemet. Lägg in den ovanför main-funktionen. Structen har ett fält för varje resurs vi laddar in.

struct Resources {
    ship_texture: Texture2D,
    bullet_texture: Texture2D,
    explosion_texture: Texture2D,
    enemy_small_texture: Texture2D,
    theme_music: Sound,
    sound_explosion: Sound,
    sound_laser: Sound,
    ui_skin: Skin,
}

Resources impl

Direkt under Resources-structen skapar vi ett implementationsblock för den. Till att börja med kommer den bara innehålla en new-funktion som laddar in alla filer och returnerar en instans av structen om allt går bra. Här använder vi i stort sett samma kod som tidigare låg i main-funktionen för att ladda in alla filer.

Vi sparar även hela UI-skinnet som en resurs så vi inte behöver returnera alla separata bilder och fonten. Notera att vi även här har bytt ut unwrap() efter font()-funktionerna till att använda ?-operatorn.

Skillnaden är att vi har bytt ut alla unwrap() och expect() till ?-operatorn. Med hjälp av denna kommer felmeddelandet returneras istället för att avsluta programmet. Det gör att vi kan hantera felmeddelandet på ett ställe i vår main-funktion om vi vill. Felmeddelandet är en enum av typen macroquad::Error.

Info

Vilka felmeddelanden som finns i Macroquad finns beskrivet i dokumentationen för macroquad::Error.

impl Resources {
    async fn new() -> Result<Resources, macroquad::Error> {
        let ship_texture: Texture2D = load_texture("ship.png").await?;
        ship_texture.set_filter(FilterMode::Nearest);
        let bullet_texture: Texture2D = load_texture("laser-bolts.png").await?;
        bullet_texture.set_filter(FilterMode::Nearest);
        let explosion_texture: Texture2D = load_texture("explosion.png").await?;
        explosion_texture.set_filter(FilterMode::Nearest);
        let enemy_small_texture: Texture2D = load_texture("enemy-small.png").await?;
        enemy_small_texture.set_filter(FilterMode::Nearest);
        // build_textures_atlas();

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

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

        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)?
            .text_color(WHITE)
            .font_size(64)
            .build();
        let label_style = root_ui()
            .style_builder()
            .font(&font)?
            .text_color(WHITE)
            .font_size(28)
            .build();
        let ui_skin = Skin {
            window_style,
            button_style,
            label_style,
            ..root_ui().default_skin()
        };

        Ok(Resources {
            ship_texture,
            bullet_texture,
            explosion_texture,
            enemy_small_texture,
            theme_music,
            sound_explosion,
            sound_laser,
            ui_skin,
        })
    }
}

Returnera fel

För enkelhetens skull kommer vi låta vår main-funktion returnera ett resultat som kan vara ett felmeddelande. Det gör att vi kan använda ?-operatorn även i main-funktionen. Om main-funktionen returnerar ett felmeddelande kommer applikationen att avslutas och felmeddelandet skrivas ut på konsollen.

Det vanliga returvärdet i funktionen är () som är Rusts “unit typ” som kan användas om inget värde ska returneras. När funktionen tidigare inte hade något explicit returvärde så returnerades detta istället implicit.

Om det sista uttrycket i en funktion avslutas med ett semikolon ; så slängs dess returvärde bort och () returneras istället.

#[macroquad::main("Mitt spel")]
async fn main() -> Result<(), macroquad::Error> {

Info

Om du undrar hur Rusts unit-typ fungerar så hittar du lite mer information i Rusts dokumentation av unit.

Ta bort unwrap()

Vid inladdningen av materialet för shadern använde vi tidigare metoden unwrap() som vi byter ut mot ?-operatorn för att returnera eventuella fel istället. Ändringen sker på sista raden i kodexemplet.

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

Ladda resurser

Nu kommer vi till den intressanta biten i detta avsnitt. Det är dags att byta ut all kod som laddar in filresurser till att instantiera vår Resources struct istället. Resultat lägger vi variabeln resources som vi senare kommer använda när vi vill komma åt en resurs.

Notera att den använder sig av await metoden som kör new-metoden som är async. Vi använder oss även här av ?-operatorn för att direkt returnera om vi får ett fel.

    set_pc_assets_folder("assets");
    let resources = Resources::new().await?;

Uppdatera resursanvändningar

Nu när vi har laddat in resurserna med Resources så behöver vi uppdatera alla ställen som använder en resurs så att de läser från resources-variabeln istället för direkt från en variabel. Vi lägger helt enkelt till resources. framför alla resursnamn.

Spelmusik

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

Gränssnittet

Nu när vi har sparat gränssnittets utssende i vår Resources struct räcker det med att sätta det som aktivt skin med root_ui().push_skin(). Här kan vi alltså ta bort alla rader som bygger upp utseendet med en enda rad.

    root_ui().push_skin(&resources.ui_skin);
    let window_size = vec2(370.0, 320.0);

Laserljud

Laserljudet behöver använda resources-structen.

                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(&resources.sound_laser);
                }

Explosioner

För explosionerna måste vi uppdatera referensen till både texturen och explosionsljudet.

                            explosions.push((
                                Emitter::new(EmitterConfig {
                                    amount: square.size.round() as u32 * 4,
                                    texture: Some(resources.explosion_texture.clone()),
                                    ..particle_explosion()
                                }),
                                vec2(square.x, square.y),
                            ));
                            play_sound_once(&resources.sound_explosion);

Kulor

Uppdatera utritningen av kulorna till att använda texturen från resources.

                for bullet in &bullets {
                    draw_texture_ex(
                        &resources.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()
                        },
                    );
                }

Skeppet

Skeppet behöver också använda texturen från resources.

                let ship_frame = ship_sprite.frame();
                draw_texture_ex(
                    &resources.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()
                    },
                );

Fiender

När fiendera ritas ut behöver resources läggas till i referensen av texturen.

                for square in &squares {
                    draw_texture_ex(
                        &resources.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()
                        },
                    );
                }

Det ska vara allt som behöver ändras den här gången. Vi har nu skapat en struct som innehåller alla resurser som vi kan använda oss av när vi ritar ut texturer och spelar upp ljud.

Utmaning

Istället för att bara avsluta applikationen så kan du pröva att skriva ut felmeddelandet på skärmen med Macroquads draw_text funktion. Tänk på att programmet då måste fortsätta köra utan att göra något annat än att rita ut text.

Prova spelet

Spelet ska se ut precis som förut.

Info

Ibland kan cargo-beroenden hamna ur synk och för vissa användare har det märkts när de gjort ändringarna i just detta kapitel. Symptomen är att knapparna i huvudmenyn börjar ”glappa” och kräver flera klick. Lösningen är att tvinga fran att beroendena byggs om igen, med cargo clean.

Kompletta källkoden

Klicka för att visa hela källkoden
use macroquad::audio::{Sound, 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()
    }
}

struct Resources {
    ship_texture: Texture2D,
    bullet_texture: Texture2D,
    explosion_texture: Texture2D,
    enemy_small_texture: Texture2D,
    theme_music: Sound,
    sound_explosion: Sound,
    sound_laser: Sound,
    ui_skin: Skin,
}

impl Resources {
    async fn new() -> Result<Resources, macroquad::Error> {
        let ship_texture: Texture2D = load_texture("ship.png").await?;
        ship_texture.set_filter(FilterMode::Nearest);
        let bullet_texture: Texture2D = load_texture("laser-bolts.png").await?;
        bullet_texture.set_filter(FilterMode::Nearest);
        let explosion_texture: Texture2D = load_texture("explosion.png").await?;
        explosion_texture.set_filter(FilterMode::Nearest);
        let enemy_small_texture: Texture2D = load_texture("enemy-small.png").await?;
        enemy_small_texture.set_filter(FilterMode::Nearest);
        // build_textures_atlas();

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

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

        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)?
            .text_color(WHITE)
            .font_size(64)
            .build();
        let label_style = root_ui()
            .style_builder()
            .font(&font)?
            .text_color(WHITE)
            .font_size(28)
            .build();
        let ui_skin = Skin {
            window_style,
            button_style,
            label_style,
            ..root_ui().default_skin()
        };

        Ok(Resources {
            ship_texture,
            bullet_texture,
            explosion_texture,
            enemy_small_texture,
            theme_music,
            sound_explosion,
            sound_laser,
            ui_skin,
        })
    }
}

#[macroquad::main("Mitt spel")]
async fn main() -> Result<(), macroquad::Error> {
    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()
        },
    )?;

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

    set_pc_assets_folder("assets");
    let resources = Resources::new().await?;

    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(
        &resources.theme_music,
        PlaySoundParams {
            looped: true,
            volume: 1.,
        },
    );

    root_ui().push_skin(&resources.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(&resources.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(resources.explosion_texture.clone()),
                                    ..particle_explosion()
                                }),
                                vec2(square.x, square.y),
                            ));
                            play_sound_once(&resources.sound_explosion);
                        }
                    }
                }

                // Draw everything
                let bullet_frame = bullet_sprite.frame();
                for bullet in &bullets {
                    draw_texture_ex(
                        &resources.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(
                    &resources.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(
                        &resources.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
    }
}

Coroutines och Storage

När spel har mycket resurser att ladda in så kan det ta en stund innan det har laddat klart. Speciellt om spelet kör i en browser och användaren sitter på en dålig internetuppkoppling. Då vill vi inte att det bara ska visas en svart skärm, utan istället ska ett meddelande visas för användaren.

För att lösa det ska vi använda oss av något som heter coroutines som emulerar multitasking. Det kan användas för att hantera tillståndsmaskiner och saker som behöver evalueras över tid. Med hjälp av en coroutine kan vi ladda alla resurser i bakgrunden och samtidigt uppdatera utseendet på skärmen.

Till sist lägger vi våra resurser i Macroquads storage som är en global persitent lagring. Det kan användas för att spara spelconfig som måste vara tillgänglig överallt i spelkoden utan att behöva skicka runt datan.

Info

Både coroutines och storage är experimentell funktionalitet i Macroquad och användningen av dem kan komma att ändras i framtiden.

Implementering

Importera

Det första vi ska göra är att importera coroutines::start_coroutine och collections::storage från Macroquads experimentella namespace.

use macroquad::experimental::collections::storage;
use macroquad::experimental::coroutines::start_coroutine;

Skapa ny load-metod

Nu kan vi skapa en ny metod load() i implenteringsblocket för structen Resources. Där lägger vi in koden som sköter laddningen av resurserna med en coroutine och uppdaterar skärmen med information om att resurserna laddas.

Funktionen start_coroutine tar ett async block och returnerar en Coroutine. I async-blocket instantierar vi structen Resources som laddar in alla resurser. Därefter använder vi storage::store() för att spara resurserna i Macroquads storage. Det gör att vi kan komma åt resurserna från andra platser i koden.

Med metoden is_done()Coroutine kan vi kolla om coroutinen har kört klart eller inte. Vi skapar en loop som kör tills is_done() returnerar true. Medan coroutinen kör använder vi draw_text() för att skriva ut en text på skärmen. Vi lägger också till 1 till 3 punkter efter texten med hjälp av koden ".".repeat(((get_time() * 2.) as usize) % 4). Vi måste också använda clear_background() och next_frame.await i loopen för att uppdateringen ska fungera rätt.

    pub async fn load() -> Result<(), macroquad::Error> {
        let resources_loading = start_coroutine(async move {
            let resources = Resources::new().await.unwrap();
            storage::store(resources);
        });

        while !resources_loading.is_done() {
            clear_background(BLACK);
            let text = format!(
                "Laddar resurser {}",
                ".".repeat(((get_time() * 2.) as usize) % 4)
            );
            draw_text(
                &text,
                screen_width() / 2. - 160.,
                screen_height() / 2.,
                40.,
                WHITE,
            );
            next_frame().await;
        }

        Ok(())
    }

Info

Lite mer information om Macroquads coroutines och storage finns att läsa i dokumentationen.

Ladda resurserna

Anropet till att ladda resurserna måste uppdateras så att den använder den nya load() metoden istället för att köra new() direkt. Eftersom load() sparar resurserna i Macroquads storage så använder vi storage::get::<Resources>() för att hämta resurserna och tilldela till variabeln resources.

    set_pc_assets_folder("assets");
    Resources::load().await?;
    let resources = storage::get::<Resources>();

Prova spelet

Spelet ska starta och medans filerna laddas ska meddelandet “Laddar resurser…” visas mitt på skärmen. Troligen går det dock så fort att det inte hinner synas när filerna läses direkt från disk.

Kompletta källkoden

Klicka för att visa hela källkoden
use macroquad::audio::{Sound, load_sound, play_sound, play_sound_once, PlaySoundParams};
use macroquad::experimental::animation::{AnimatedSprite, Animation};
use macroquad::experimental::collections::storage;
use macroquad::experimental::coroutines::start_coroutine;
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()
    }
}

struct Resources {
    ship_texture: Texture2D,
    bullet_texture: Texture2D,
    explosion_texture: Texture2D,
    enemy_small_texture: Texture2D,
    theme_music: Sound,
    sound_explosion: Sound,
    sound_laser: Sound,
    ui_skin: Skin,
}

impl Resources {
    async fn new() -> Result<Resources, macroquad::Error> {
        let ship_texture: Texture2D = load_texture("ship.png").await?;
        ship_texture.set_filter(FilterMode::Nearest);
        let bullet_texture: Texture2D = load_texture("laser-bolts.png").await?;
        bullet_texture.set_filter(FilterMode::Nearest);
        let explosion_texture: Texture2D = load_texture("explosion.png").await?;
        explosion_texture.set_filter(FilterMode::Nearest);
        let enemy_small_texture: Texture2D = load_texture("enemy-small.png").await?;
        enemy_small_texture.set_filter(FilterMode::Nearest);
        build_textures_atlas();

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

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

        let window_style = root_ui()
            .style_builder()
            .background(window_background.clone())
            .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.clone())
            .background_clicked(button_clicked_background.clone())
            .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)?
            .text_color(WHITE)
            .font_size(64)
            .build();
        let label_style = root_ui()
            .style_builder()
            .font(&font)?
            .text_color(WHITE)
            .font_size(28)
            .build();
        let ui_skin = Skin {
            window_style,
            button_style,
            label_style,
            ..root_ui().default_skin()
        };

        Ok(Resources {
            ship_texture,
            bullet_texture,
            explosion_texture,
            enemy_small_texture,
            theme_music,
            sound_explosion,
            sound_laser,
            ui_skin,
        })
    }

    pub async fn load() -> Result<(), macroquad::Error> {
        let resources_loading = start_coroutine(async move {
            let resources = Resources::new().await.unwrap();
            storage::store(resources);
        });

        while !resources_loading.is_done() {
            clear_background(BLACK);
            let text = format!(
                "Laddar resurser {}",
                ".".repeat(((get_time() * 2.) as usize) % 4)
            );
            draw_text(
                &text,
                screen_width() / 2. - 160.,
                screen_height() / 2.,
                40.,
                WHITE,
            );
            next_frame().await;
        }

        Ok(())
    }
}

#[macroquad::main("Mitt spel")]
async fn main() -> Result<(), macroquad::Error> {
    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()
        },
    )?;

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

    set_pc_assets_folder("assets");
    Resources::load().await?;
    let resources = storage::get::<Resources>();

    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(
        &resources.theme_music,
        PlaySoundParams {
            looped: true,
            volume: 1.,
        },
    );

    root_ui().push_skin(&resources.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(&resources.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(resources.explosion_texture.clone()),
                                    ..particle_explosion()
                                }),
                                vec2(square.x, square.y),
                            ));
                            play_sound_once(&resources.sound_explosion);
                        }
                    }
                }

                // Draw everything
                let bullet_frame = bullet_sprite.frame();
                for bullet in &bullets {
                    draw_texture_ex(
                        &resources.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(
                    &resources.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(
                        &resources.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
    }
}

Releasa ditt spel

Nu när du har byggt ett spel vill du förstås att andra ska kunna spela det också. Här kommer instruktioner för hur du kan paketera ditt spel för olika plattformar. Först kommer vi titta på hur spelet kan byggas och paketeras för de vanligaste desktop-plattformarna: Windows, Linux och MacOS. Därefter kommer ett kapitel som förklarar hur spelet kan byggas för att spelas via en webbsida. Vi kommer sedan titta på hur spelet kan byggas och paketeras för mobila plattformar som Android och iPhone.

Bygg ditt spel för desktopdatorer

Macroquad stödjer flera olika desktopplattformar, som Windows, Linux och MacOS. Det går att korskompilera för andra plattformar än den du själv kör på, men det kan kräva en del andra verktyg och tas inte upp i den här guiden. Enklast kan vara att använda ett byggsystem som har stöd för flera olika plattformar.

Bygg på Windows

Om du ska bygga ditt spel för att kunna köras på Windows så måste en Rust build target installeras. Både MSVC och GNU build targets stöds.

Bygg med Windows GNU target

Innan första bygget måste rätt target installeras, detta behöver bara göras en gång.

rustup target add x86_64-pc-windows-gnu

För att bygga spelet kan du köra detta kommando:

cargo build --release --target x86_64-pc-windows-gnu

Binärfilen som skapas kommer att placeras i katalogen target/x86_64-pc-windows-gnu/release/.

Bygg med Windows MSVC target

Innan första bygget måste rätt target installeras, detta behöver bara göras en gång.

rustup target add x86_64-pc-windows-msvc

För att bygga spelet kan du köra detta kommando:

cargo build --release --target x86_64-pc-windows-msvc

Binärfilen som skapas kommer att placeras i katalogen target/x86_64-pc-windows-msvc/release/.

Bygg på Linux

För att kunna bygga ett spel med Macroquad på Linux så krävs det att några utvecklingspaket är installerade. Nedan visas hur dessa paket kan installeras med några vanliga Linux-distributioner.

Installera paket

Ubuntu

Dessa systempaket måste installeras för att bygga på Ubuntu.

apt install pkg-config libx11-dev libxi-dev libgl1-mesa-dev libasound2-dev

Fedora

Dessa systempaket måste installeras för att bygga på Fedora.

dnf install libX11-devel libXi-devel mesa-libGL-devel alsa-lib-devel

Arch Linux

Dessa systempaket måste installeras för att bygga på Arch Linux.

pacman -S pkg-config libx11 libxi mesa-libgl alsa-lib

Bygg med Linux GNU target

Innan första bygget måste rätt target installeras, detta behöver bara göras en gång.

rustup target add x86_64-unknown-linux-gnu

För att bygga spelet kan du köra detta kommando:

cargo build --release --target x86_64-unknown-linux-gnu

Binärfilen som skapas kommer att placeras i katalogen target/x86_64-unknown-linux-gnu/release/.

Bygg på MacOS

För MacOS finns det två möjliga targets, x86_64-apple-darwin som används för äldre Intel-baserade Mac-datorer, och aarch64-apple-darwin som bygger för att köras på nyare Apple Silicon-baserade Mac-datoer.

Bygg med x86-64 Apple Darwin target

Innan första bygget måste rätt target installeras, detta behöver bara göras en gång.

rustup target add x86_64-apple-darwin

För att bygga spelet kan du köra detta kommando:

cargo build --release --target x86_64-apple-darwin

Binärfilen som skapas kommer att placeras i katalogen target/x86_64-apple-darwin/release/.

Bygg med aarch64 Apple Darwin target

Innan första bygget måste rätt target installeras, detta behöver bara göras en gång.

rustup target add aarch64-apple-darwin

För att bygga spelet kan du köra detta kommando:

cargo build --release --target aarch64-apple-darwin

Binärfilen som skapas kommer att placeras i katalogen target/aarch64-apple-darwin/release/.

Paketera spelet

För att dela med dig av ditt spel till andra behöver du paketera binärfilen tillsammans med alla assets som behövs för att köra spelet. Här nedan visas några exempel på hur det kan göras från ett terminalfönster.

Windows

cp target/x86_64-pc-windows-gnu/release/mitt-spel.exe ./
tar -c -a -f mitt-spel-win.zip mitt-spel.exe assets/*

Linux

cp target/x86_64-pc-linux-gnu/release/mitt-spel ./
tar -zcf mitt-spel-linux.zip mitt-spel assets/*

Mac

cp target/aarch64-apple-darwin/release/mitt-spel ./
zip -r mitt-spel-mac.zip mitt-spel assets/*

Lägg ut ditt spel på webben

Eftersom det går att kompilera Macroquad-spel till WebAssembly så är det möjligt att köra spelet i en webbläsare. Här kommer instruktioner för hur du kan skapa en webbsida som kör ditt spel. Denna sida kan du sedan lägga upp på ditt webhotell så att andra enkelt kan spela ditt spel utan att behöva ladda ner något.

Installera WASM build target

Till att börja med måste du installera en build target för WASM. Det görs med kommandot rustup.

rustup target add wasm32-unknown-unknown

Bygg en WASM-binär

Nu kan du använda WASM build targeten för att bygga en WASM-binärfil som sedan kan laddas från en webbsida.

cargo build --release --target wasm32-unknown-unknown

Binärfilen som skapas kommer att placeras i katalogen target/wasm32-unknown-unknown/release/ med filändelsen .wasm.

Kopiera WASM-binären

Du måste kopiera binärfilen som skapades i steget ovan till roten av din crate, samma katalog som assets-katalogen ligger i.

Om du har döpt din crate till något annat än “mitt-spel” så kommer namnet på WASM-filen vara samma som namnet på din crate, med filändelsen .wasm.

cp target/wasm32-unknown-unknown/release/mitt-spel.wasm .

Skapa en webbsida

Det behövs en HTML-sida för att ladda in WASM-binären. Den behöver ladda in en javascript-fil från Macroquad som innehåller kod för att WASM-binären ska kunna kommunicera med webbläsaren. Det behövs även en canvas-tagg som Macroquad använder för att rita ut grafiken. Kom ihåg att byta ut namnet på binärfilen i load-anropet från mitt-spel.wasm till det du döpt ditt spel till. Det kommer vara samma som namnet på din crate.

Skapa en fil med namnet index.html i roten på din crate med följande innehåll:

<!DOCTYPE html>
<html lang="sv">
<head>
    <meta charset="utf-8">
    <title>Mitt Spel</title>
    <style>
        html,
        body,
        canvas {
            margin: 0;
            padding: 0;
            width: 100%;
            height: 100%;
            overflow: hidden;
            position: absolute;
            background: black;
            z-index: 0;
        }
    </style>
</head>
<body>
    <canvas id="glcanvas" tabindex='1'></canvas>
    <!-- Minified and statically hosted version of https://github.com/not-fl3/macroquad/blob/master/js/mq_js_bundle.js -->
    <script src="https://not-fl3.github.io/miniquad-samples/mq_js_bundle.js"></script>
    <script>load("mitt-spel.wasm");</script> <!-- Din kompilerade WASM-binär -->
</body>
</html>

Testkör spelet i din webbläsare

Nu kan du starta en webbserver och ladda sidan i din webbläsare.

Installera en enkel webbserver

För att serva ditt spel lokalt på din dator kan du installera en enkel webbserver med nedanstående kommando. Detta är enbart för att kunna testköra spelet innan du lägger ut det på ett riktigt webbhotell.

cargo install basic-http-server

Kör igång webbservern

Detta kommando skriver ut vilken adress du kommer komma åt webbsidan på. Öppna din webbläsare och surfa till den adressen, till exempel http://localhost:4000. Spelet kommer nu köras i din webbläsare istället för som en egen applikation.

basic-http-server .

Lägg ut ditt spel

Om du har tillgång till ett webbhotell kan du lägga ut filerna där så att andra kan spela ditt spel. Du behöver lägga upp html-filen, WASM-filen och katalogen assets.

index.html
mitt-spel.wasm
assets/*

Note

Vi påminner om att det redan i kapitel 1 finns instruktioner för hur du publicerar spelet till webben utan webbhotell. Se i så fall till att du använder den uppdaterade deploy.yml från kapitel 10 – Grafik.

Bygg för Android-mobiler

Med Macroquad går det även att bygga för att spela spelet på Android-mobiler. Vi kommer bygga en APK-fil som kan installeras på Android-telefoner eller läggas ut på Google Play Store. Vi kommer bara beskriva hur man bygger med hjälp av Docker, så det kommer krävas att det är installerat.

Tänk på att en mobil plattform inte har ett fysiskt tangentbord och därför behövs det byggas in stöd för att styra spelet med touch-kontroller.

Note

Läs om funktionen touches() i Macroquads dokumentation för information om hur touch-kontroller fungerar.

Installera docker image

Innan du kan börja bygga en APK-fil för Android behöver du hämta hem Docker-imagen notfl3/cargo-apk.

docker pull notfl3/cargo-apk

Bygg APK-fil

Med detta kommando kan du bygga en APK-fil. Det kommer ta en stund då den gör tre fulla byggen, en för varje Android target.

docker run 
  --rm 
  -v $(pwd):/root/src 
  -w /root/src 
  notfl3/cargo-apk cargo quad-apk build --release

Detta kommer skapa en APK-fil i katalogen target/android-artifacts/release/apk.

Konfiguration

För att Android ska hitta alla assets måste en konfiguration läggas till i Cargo.toml som beskriver var assets-katalogen ligger.

[package.metadata.android]
assets = "assets/"

Info

På Macroquads hemsida finns en ingående beskrivning om hur man bygger för Android. Där finns tips för att snabba upp bygget, hur man bygger manuellt utan Docker och hur man signerar APK-filen för att kunna lägga upp den på Google Play Store.

Bygg för iOS

Det går även att bygga ditt Macroquad-spel för att köra på iPhone-mobiler och iPads.

Info

Utförligare information om att bygga för iOS finns i artikeln Macroquad on iOS på Macroquads hemsida. Där finns information om hur man får tillgång till loggar, bygger för riktiga enheter och signerar appen.

Skapa en katalog

En iOS-applikation är en vanlig katalog/mapp med filändelse .app.

mkdir MittSpel.app

För vårt spel är filstrukturen i .app-mappen samma som när vi kör spelet med cargo run från roten av craten. Det vill säga, binärfilen och assets-mappen skall placeras bredvid varandra. Det behövs även en Info.plist-fil.

Börja med att lägga assets på plats:

cp -r assets MittSpel.app 

Bygg binärfilen

Du behöver Rusts targets för iOS. För simulatorn används Intel-binärer och för en fysisk enhet används ARM-binärer. Vi går enbart igenom hur du testar på simulatorn i den här guiden. Att få igång testning på en fysisk enhet är ett kapitel för sig. Se Macroquad on iOS för information om det.

rustup target add x86_64-apple-ios

Nu kan du bygga en exekverbar binärfil för iOS Simulator med följande kommando.

cargo build --release --target x86_64-apple-ios

Kopiera binärfilen

Kopiera in den exekverbara binärfil som byggdes ovan till spelets mapp.

cp target/x86_64-apple-ios/release/mitt-spel MittSpel.app

Skapa Info.plist

Skapa en textfil för appens metadata med namnet Info.plist i MittSpel.app-mappen med följande innehåll:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>mitt-spel</string>
<key>CFBundleIdentifier</key>
<string>com.mittspel</string>
<key>CFBundleName</key>
<string>mittspel</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
</dict>
</plist>

Sätt upp simulatorn

För det här steget behöver du ha XCode och minst en simulator-image installerad. XCode hittar du i App Store-appen. Du kan lägga till simulatorer via kommandoraden, eller via XCode. I version 15.1 av XCode kan du göra det via Settings… -> Platforms och sedan välja någon av iOS-versionerna där. Där finns en även knapp (+) för att lägga till ytterligare iOS-versioner.

För att sätta upp via kommandoraden så kör först xcrun simctl list för att se en lista på alla tillgängliga simulatorer. Kopiera den långa hex-koden för den simulator du vill boota och använd det som argument till xcrun simctl boot. Detta behöver bara göras första gången du ska köra simulatorn.

xcrun simctl list
xcrun simctl boot <hex string>

Kör igång simulatorn

Kommandot vi kommer att använda för att installera och köra spelet, xcrun simctl, väljer ut simulator med argumentet booted. Det innebär att du först behöver starta en simulator, och för att göra det förutsägbart bör du ha endast en simulator igång. Även detta går att göra via kommandoraden, men det enklaste är nog att starta Simulator-appen och sedan starta en simulator via File -> Open Simulator.

För att starta simulatorn via commandline använd följande kommando:

open /Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/

Installera spelet

Du kan installera spelet genom att dra mappen MittSpel.app och släppa den i den startade simulatorn. Men eftersom du antagligen kommer vilja installera om ofta är det effektivare att använda kommandoraden för detta:

xcrun simctl install booted MittSpel.app/

Starta spelet

Även detta kan du göra i den startade simulatorn eller via kommandoraden. I vår Info.plist specificerade vi CFBundleIdentifier som com.mittspel.

xcrun simctl launch booted com.mittspel

Note

Du kommer märka att spelet inte är anpassat för att köras på en mobil plattform än. Du kan förslagsvis börja med att läsa om funktionen touches() i Macroquads dokumentation för information om hur touch-kontroller fungerar.

Avslutning

Du har nu byggt och gett ut ett spel skrivit i Rust med spelramverket Macroquad. Det finns dock mycket mer som går att göra för att skapa ett fullfjädrat spel.

Några tips att lägga till för att göra spelet roligare:

  • Olika sorters fiender som rör sig annorlunda eller har egna vapen
  • Fler levels med ökande svårighetsgrad
  • Uppgraderingar som ger bättre vapen
  • Avancerade boss-fighter
  • Flera liv

Provkör spelet

Klicka för att spela spelet som det kommer se ut när det är klart.

Credits

Ferris the Gamer

Ferris the gamer

Bilden med Ferris som håller i en spelkontroll är baserad på Ferris the Rustacean skapad av Karen Rustad Tölva. Spelkontrollern är ritad av Clovis_Cheminot från Pixabay.

Ferris the Teacher

Ferris the Teacher

Bilden Ferris the Teacher är skapad av Esther Arzola.

Starfield shader

Starfield shadern är skapad av The Art of Code från videon Shader Coding: Making a starfield.

Asset credits

Sprites

Space Ship Shooter Pixel Art Assets
Author: ansimuz
License: CC0 Public Domain
https://opengameart.org/content/space-ship-shooter-pixel-art-assets

Theme music

8-bit space shooter music
Author: HydroGene
License: CC0 Public Domain
https://opengameart.org/content/8-bit-epic-space-shooter-music

Laser and explosion sounds

Sci-fi sounds
Author: Kenney.nl
License: CC0 Public Domain
https://opengameart.org/content/sci-fi-sounds

UI

Sci-fi User Interface Elements
Author: Buch
License: CC0 Public Domain
sci-fi-ui.psd
https://opengameart.org/content/sci-fi-user-interface-elements

Font

AtariGames
Author: Kieran
License: Public Domain
https://nimblebeastscollective.itch.io/nb-pixel-font-bundle

Agical