Copyright © 2024 by Olle Wreede
Koda ett spel i Rust med Macroquad by Olle Wreede licensieras med Creative Commons Attribution-ShareAlike 4.0 International
Du har tillstånd att:
Dela — kopiera och vidaredistribuera materialet oavsett medium eller format för alla ändamål, även kommersiellt.
Bearbeta — remixa, transformera, och bygg vidare på materialet för alla ändamål, även kommersiellt.
Licensgivaren kan inte återkalla dessa friheter så länge du följer licensvillkoren.
Första upplagen juli 2024
Ferris the Teacher image by Esther Arzola is licensed under CC BY 4.0.
Koda ett spel i Rust med Macroquad
Lär dig koda ett eget shoot ’em up-spel i programmeringsspråket Rust. I denna guide kommer du skriva ett komplett spel från grunden och samtidigt lära dig hur spelramverket Macroquad fungerar. När du gått igenom hela guiden kommer du ha ett spelbart spel som går att spela på stationära datorer, mobiler och webben.
Guiden är skriven av Olle Wreede på Agical.
Denna guide finns även att läsa på engelska.
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.
På 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.
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.
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.
Koda ett spel i Rust med Macroquad by Olle Wreede licensieras med Creative Commons Attribution-ShareAlike 4.0 International
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.
Implementering
Skapa ett nytt Rust-projekt med Cargo och lägg till macroquad
med version
0.4 som beroende.
cargo new --bin my-game
cd my-game/
cargo add macroquad@0.4
Din Cargo.toml
fil kommer nu se ut såhär:
[package]
name = "my-game"
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("My game")]
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.
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.
Även om clear_background()
inte används explicit så kommer Macroquad att rensa
skärmen i början av varje bildruta.
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.
Nedanstående två filer refererar till my-game.wasm
. Om du döpt din crate till något annat än my-game
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="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Game</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("my-game.wasm");</script> <!-- Your compiled WASM binary -->
</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/my-game.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 webbplats 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.
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
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;
}
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);
Ä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("My game")]
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
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 = clamp(x, 0.0, screen_width());
y = clamp(y, 0.0, screen_height());
Funktionen clamp()
är en Macroquad-funktion som begränsar ett värde till ett
minimum- och ett maximumvärde.
Ä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 när
förflyttningen begränsas?
Källkod
Nu ser vårt program ut så här:
use macroquad::prelude::*;
#[macroquad::main("My game")]
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 = clamp(x, 0.0, screen_width());
y = clamp(y, 0.0, screen_height());
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
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);
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.
Rust-kompilatorn kan ge varningen “type annotations needed” på raden där vektorn skapas. Detta kommer försvinna när vi lägger till en fyrkant i vektorn i avsnittet nedan.
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,
});
}
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.
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,
);
}
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.
Quiz
Testa dina nya kunskaper genom att svara på följande quiz innan du går vidare.
Kollisionskurs
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
rect()
som skapar en Rect
från vår Shape
.
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,
}
}
}
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;
}
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;
}
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
.
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,
);
}
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.
Quiz
Testa dina nya kunskaper genom att svara på följande quiz innan du går vidare.
Bomber och granater
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 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);
}
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.
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.
Quiz
Testa dina nya kunskaper genom att svara på följande quiz innan du går vidare.
Poängsystem
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);
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;
}
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!("Score: {}", score).as_str(),
10.0,
35.0,
25.0,
WHITE,
);
let highscore_text = format!("High score: {}", high_score);
let text_dimensions = measure_text(highscore_text.as_str(), None, 25, 1.0);
draw_text(
highscore_text.as_str(),
screen_width() - text_dimensions.width - 10.0,
35.0,
25.0,
WHITE,
);
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!
Testa att skriva ut en gratulationstext på skärmen vid Game Over om spelaren uppnådde en high score.
Quiz
Testa dina nya kunskaper genom att svara på följande quiz innan du går vidare.
Game state
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
“Press space” 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 = "Press space";
let text_dimensions = measure_text(text, None, 50, 1.0);
draw_text(
text,
screen_width() / 2.0 - text_dimensions.width / 2.0,
screen_height() / 2.0,
50.0,
WHITE,
);
},
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 = clamp(circle.x, 0.0, screen_width());
circle.y = clamp(circle.y, 0.0, screen_height());
// Generate a new square
if rand::gen_range(0, 99) >= 95 {
let size = rand::gen_range(16.0, 64.0);
squares.push(Shape {
size,
speed: rand::gen_range(50.0, 150.0),
x: rand::gen_range(size / 2.0, screen_width() - size / 2.0),
y: -size,
collided: false,
});
}
// Movement
for square in &mut squares {
square.y += square.speed * delta_time;
}
for bullet in &mut bullets {
bullet.y -= bullet.speed * delta_time;
}
// 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!("Score: {}", score).as_str(),
10.0,
35.0,
25.0,
WHITE,
);
let highscore_text = format!("High score: {}", high_score);
let text_dimensions = measure_text(highscore_text.as_str(), None, 25, 1.0);
draw_text(
highscore_text.as_str(),
screen_width() - text_dimensions.width - 10.0,
35.0,
25.0,
WHITE,
);
},
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::Space) {
game_state = GameState::Playing;
}
let text = "Paused";
let text_dimensions = measure_text(text, None, 50, 1.0);
draw_text(
text,
screen_width() / 2.0 - text_dimensions.width / 2.0,
screen_height() / 2.0,
50.0,
WHITE,
);
},
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,
);
},
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.
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
.
Quiz
Testa dina nya kunskaper genom att svara på följande quiz innan du går vidare.
Stjärnfält med en shader
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();
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);
}
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!
Quiz
Testa dina nya kunskaper genom att svara på följande quiz innan du går vidare.
Partikelexplosioner
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 = "my-game"
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" }
macroquad-particles = "0.2.2"
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()
}
}
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.
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.
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/my-game.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
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);
Bygg en texturatlas
Efter att alla texturer har laddats in anropar vi Macroquad-funktionen
build_textures_atlas
som bygger upp en atlas som innehåller alla inladdade
texturer. Det gör att alla anrop till draw_texture()
kommer använda texturen
från atlasen istället för varje separat textur. Alla texturer bör laddas in
innan detta anrop.
build_textures_atlas();
Animering av rymdskeppet
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
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()
på 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()
},
);
}
Med hjälp av
DrawTextureParams
går det att ändra hur texturen ska ritas ut. Det går att rita ut texturen
roterat eller spegelvänt med fälten rotation
, pivot
, flip_x
och flip_y
.
Rita ut rymdskeppets bildrutor
Till sist kan vi byta ut cirkeln mot texturen för skeppet. Det fungerar
likadant som för att rita ut kulorna. Vi hämtar först ut aktuell bildruta från
spritens animering och ritar sedan ut texturen med draw_texture_ex
.
Då skeppanimeringen inte har samma storlek i höjd och bredd så använder vi oss
av ship_frame.dest_size
för att få ut storleken den ska ritas ut i. Men för
att det inte ska bli så smått ritar vi ut den med dubbla storleken.
let ship_frame = ship_sprite.frame();
draw_texture_ex(
&ship_texture,
circle.x - ship_frame.dest_size.x,
circle.y - ship_frame.dest_size.y,
WHITE,
DrawTextureParams {
dest_size: Some(ship_frame.dest_size * 2.0),
source: Some(ship_frame.source_rect),
..Default::default()
},
);
Om allt fungerar som det ska så ska det nu vara grafik för både skeppet och kulorna.
Snabba upp laddtider
Lägg till följande rader i slutet av filen Cargo.toml
för att bilder ska
laddas in snabbare.
[profile.dev.package.'*']
opt-level = 3
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.
Quiz
Testa dina nya kunskaper genom att svara på följande quiz innan du går vidare.
Grafiska explosioner
För att göra explosionerna lite mer effektfulla så ska vi lägga till grafik även för partiklarna.
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
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.
Quiz
Testa dina nya kunskaper genom att svara på följande quiz innan du går vidare.
Animerade fiender
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.
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
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 pixlar, 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_small_sprite
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.
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 = "my-game"
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.,
},
);
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);
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.
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.
Grafisk meny
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 “Main menu” i titeln, och kommer innehålla två knappar, en för att “Play” och en för att “Quit”. 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()
på 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();
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);
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.
Widgets går också att skapa genom att instantiera ett objekt och använda builder-metoder.
widgets::Button::new("Play").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 “Quit” 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), "Main Menu");
if ui.button(vec2(65.0, 25.0), "Play") {
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(65.0, 125.0), "Quit") {
std::process::exit(0);
}
},
);
}
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.
Resurser
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
.
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("My game")]
async fn main() -> Result<(), macroquad::Error> {
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.
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.
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
.
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.
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()
på 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!(
"Loading resources {}",
".".repeat(((get_time() * 2.) as usize) % 4)
);
draw_text(
&text,
screen_width() / 2. - 160.,
screen_height() / 2.,
40.,
WHITE,
);
next_frame().await;
}
Ok(())
}
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 “Loading resources…” visas mitt på skärmen. Detta gäller enbart webversionen då resurserna bör laddas väldigt snabbt på desktop.
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/my-game.exe ./
tar -c -a -f my-game-win.zip my-game.exe assets/*
Linux
cp target/x86_64-pc-linux-gnu/release/my-game ./
tar -zcf my-game-linux.zip my-game assets/*
Mac
cp target/aarch64-apple-darwin/release/my-game ./
zip -r my-game-mac.zip my-game 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 “my-game” så kommer namnet på
WASM-filen vara samma som namnet på din crate, med filändelsen .wasm
.
cp target/wasm32-unknown-unknown/release/my-game.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 my-game.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="en">
<head>
<meta charset="utf-8">
<title>My Game</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("my-game.wasm");</script> <!-- Your compiled WASM binary -->
</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
my-game.wasm
assets/*
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.
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/"
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.
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 MyGame.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 MyGame.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/my-game MyGame.app
Skapa Info.plist
Skapa en textfil för appens metadata med namnet Info.plist
i
MyGame.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>my-game</string>
<key>CFBundleIdentifier</key>
<string>com.mygame</string>
<key>CFBundleName</key>
<string>mygame</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 MyGame.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 MyGame.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.mygame
.
xcrun simctl launch booted com.mygame
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
Credits
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
Bilden Ferris the Teacher är skapad av Esther Arzola och licensierad med CC BY 4.0.
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