Gå till innehållet

Projektuppgift 4 - Pong

Introduktion till projektuppgift 4

Pong är ett klassiskt spel där två spelare spelar ”tennis” mot varandra. En boll studsar fram och tillbaka på skärmen och målet är att få bollen i mål genom att bollen passerar motståndarens ”tennisracket”. Spelarnas tennisracket brukar kallas för paddles på engelska i Pong och det är det vi kommer att kalla dem också.

Vi kommer använda oss av simpel grafik, dels för att det ska bli enkelt för dig att skapa dessa bilder själv och dels för att spelet ska efterlikna det ursprungliga spelet Pong.

Vi kommer använda en metod som finns inbyggd i MonoGame som används för att undersöka om två rektanglar krockar med varandra, så innan vi börjar med Pong så ska vi se hur denna metod används.

Spelets bilder

Om du vill använda samma bilder i Pong som vi har gjort så hittar du dem här

Kollisioner med Rectangle

I detta exempel så ska vi se hur man kan undersöka om två rektanglar kolliderar med varandra vilket är väldigt användbart i många olika typer av spel. Vi kommer att rita upp samma bild på två olika platser med hjälp av var sin Rectangle. Den ena bilden ska vara stilla, den andra ska hela tiden placeras på samma plats som musen. När de båda bilderna kolliderar så ska en av bildern ändra färg så att den ser mer blå ut, vilket visas i bilden nedan.

Exempel P4.1

Vi börjar med att se på programmets medlemsvariabler.

GraphicsDeviceManager graphics;
SpriteBatch spriteBatch;

Texture2D parrotBild;
Rectangle rekt1 = new Rectangle(100, 100, 50, 50);
Rectangle rekt2 = new Rectangle(200, 200, 50, 50);
MouseState mus = Mouse.GetState();
Color rekt1Color = Color.White;

Dessa variabler är allt som krävs för att vi ska kunna rita upp samma bild på två olika platser. Notera att vi har valt att bestämma rektanglarnas storlek direkt när de skapas istället för att de ska ha samma bredd och höjd som bilden som läses in i LoadContent. Detta innebär att bilden kommer att förstoras eller förminskas så att den är precis 50 pixlar bred och 50 pixlar hög oavsett hur stor den egentligen är.

Det finns också med en Color-variabel som har fått värdet Color.White. Det är färgen i denna variabel som anger vilken färgning en av rektanglarna ska ha när den ritas ut.

Härnäst så tittar vi på koden i LoadContent där vi endast laddar in en bild.

// Create a new SpriteBatch, which can be used to draw textures.
spriteBatch = new SpriteBatch(GraphicsDevice);

parrotBild = Content.Load<Texture2D>("parrot");

Innan vi tittar på koden i Update-metoden så ska vi se hur koden i Draw-metoden ser ut.

GraphicsDevice.Clear(Color.CornflowerBlue);

spriteBatch.Begin();
spriteBatch.Draw(parrotBild, rekt1, rekt1Color);
spriteBatch.Draw(parrotBild, rekt2, Color.White);
spriteBatch.End();

base.Draw(gameTime);

Samma bild ritas ut på två platser som bestäms av de två rektanglar som vi har skapat. Notera att den första bilden ritas med färgningen som bestäms av variabeln rekt1Color. Genom att använda en variabel för färgningen så kan vi ändra vilken färgning bilden har under programmets körning och det är precis det som vi ska göra i Update-metoden.

mus = Mouse.GetState();
rekt1.X = mus.X;
rekt1.Y = mus.Y;

if (rekt1.Intersects(rekt2))
{
    rekt1Color = Color.Blue;
}
else
{
    rekt1Color = Color.White;
}


base.Update(gameTime);

I Update-metoden så flyttas den första rektangeln till musens koordinater. Därefter så undersöker en if-sats om de båda rektanglarna kolliderar med varandra med hjälp av metoden Intersects, vilket kan översättas till skär på svenska (två rektanglar skär varandra om de överlappar någonstans). Metoden används alltid genom att man skriver namnet på en rektangel, följt av en punkt, följt av Intersects och en parentes med namnet på den andra rektangeln inuti. Om rektanglarna skär varandra så ändras den ena rektangelns färgning till blå, om de inte skär varandra så sätts färgningen till vit vilket innebär att bilden inte färgas.

Intersects returvärde

Metoden Intersects är en metod som returnerar ett bool-värde, den returnerar alltså antingen true eller false. Om du tycker att if-satsen i exemplet var svår att förstå så kan du komma ihåg att den hade fungerat på samma sätt om den var skriven såhär:

if (rekt1.Intersects(rekt2) == true)
{
    rekt1Color = Color.Blue;
}

Uppgift P4.1

Skapa ett program som innehåller två bilder, en i programmets vänstra halva och en i programmets högra halva. När programmet startar så ska bilderna börja röra sig mot varandra. När de kolliderar så ska programmets bakgrundsmusik startas.

Lösningstips P4.1

Skapa en bool-variabel som håller koll på om rektanglarna har krockat hittills eller inte.

Lösningsförslag P4.1

Planering och implementering av spelet

Vi kommer inte att genomföra planeringen och implementeringen av Pong på samma sätt som de tidigare projektuppgifterna. Anledningen till detta är att i MonoGame så är våra program redan uppdelade i några fördefinierade metoder som t.ex. Update och Draw, och koden i Draw är ofta inte mer komplicerad än att man ritar upp alla saker som ska vara i spelet. Vi ska därför strax börja med att istället med att fundera på vad vårt Pong-spel ska innehålla och vilka variabeltyper som spelet behöver, men innan dessa ska vi jämföra några av för- och nackdelarna med Vector2 och Rectangle.

En Rectangle är nödvändig att använda om man vill kunna ändra storlek på bilderna som man ritar och dessutom så har Rectangle den användbara metoden Intersects som vi precis har sett. Det finns dock en nackdel med att använda Rectangle framför Vector2 och det är att en rektangelns koordinater, bredd och höjd anges och sparas som heltal medan en Vector2 sparar sina koordinater som float, d.v.s. decimaltal. En konsekvens av detta är att man inte kan flytta en Rectangle med en hastighet som är mindre än 1 pixel per Update. Skulle man försöka att t.ex. öka X-koordinaten för en Rectangle med 0,8 varje Update så måste man först omvandla 0,8 till ett heltal vilket leder till att 0,8 avrundas nedåt till 0 och rektangelns X-koordinat kommer inte att förändras alls.

Det som vore bra är att få tillgång till möjligheten att ändra storlek på en bild och använda Intersect men ändå spara koordinaterna för bilden med en Vector2 så att bilden kan röra sig med hastigheter mindre än 1 pixel per Update. Vi kommer att göra detta genom att använda både en Rectangle och en Vector2 för de saker i vårt spel som behöver kunna röra sig med låga hastigheter men samtidigt kunna kollidera med andra rektanglar, i just Pong så kommer det att räcka att göra detta med bollen. Det är bollens Vector2 som kommer att hålla koll på var bollen är och var den ska ritas medan dess Rectangle kommer att användas för att se om bollen kolliderar med någon paddle. Innan vi använder Intersects tillsammans med bollens Rectangle så måste vi ändra rektangelns X- och Y-koordinat så att de har samma värde som bollens Vector2 fast avrundat till heltal. När man använder en Rectangle på detta sätt så kallar man rektangeln för bollens hitbox.

Här följer en lista över saker som vårt spel kommer att innehålla och vilka variabler det kommer att kräva:

  • Bollen – Kräver en bild, en position, en hastighet och en hitbox.
  • Paddles – Varje paddle kräver en rektangel, de kan ha en gemensam bild.
  • Poängställningen – Kräver ett SpriteFont och en position som för texten. Vi behöver dessutom en int-variabel för varje spelares poäng.
  • Styrning av spelet – Kräver ett KeyboardState
  • Slumpad hastighet när bollen skapas – Kräver en Random-variabel

Eftersom vi ska använda slumptal i vårt program så behöver vi lägga till följande using-uttryck i programmet:

using System;

Nu är vi redo att skriva de medlemsvariabler som programmet ska ha.

GraphicsDeviceManager graphics;
SpriteBatch spriteBatch;

SpriteFont arial;
Vector2 poängPosition = new Vector2(350, 50);
int vänsterPoäng = 0;
int högerPoäng = 0;

Vector2 bollPosition;
Vector2 bollHastighet;
Rectangle bollHitbox;
Texture2D bollBild;

Rectangle vänsterPaddle;
Rectangle högerPaddle;
Texture2D paddleBild; 

KeyboardState tangentBord = Keyboard.GetState();

Random slump = new Random();

Positionen för texten som visar poängställningen ges ett värde här, resten av variablerna kommer att få värden i någon av programmets metoder. I denna projektuppgift så får du skriva många medlemsvariabler på en gång innan du testar att någonting i programmet fungerar och det för att vi ska slippa hoppa fram och tillbaka mellan medlemsvariablerna och programmets andra metoder. När du själv skapar ett eget spel så är det en bra idé att göra små bitar av spelet i taget och sedan testa att delarna fungerar. I detta fall så hade man t.ex. kunnat skapa variablerna för bollen först av allt, därefter lagt till kod i programmets andra metoder som Update och Draw och sedan testat att koden för bollen fungerar innan man gick vidare på koden för spelets paddles.

Vi ska nu ladda in spelets bilder och SpriteFont. Bilden nedan visar de filer som har lagts till i MonoGames Content Pipeline Tool.

Pong Pipeline Tool

Det behövs bara tre filer för spelet Pong. Vårt SpriteFont är det SpriteFont som skapas som standard, det har inte gjorts några ändringar i det. Bilden boll är en helvit bild som är 20 pixlar bred och 20 pixlar hög. Bilden paddle är också en helvit bild som är 10 pixlar bred och 80 pixlar hög. Du kan använda andra bilder för spelets boll och paddle om du vill, koden anpassar sig efter bildernas storlek.

Alternativ till bilderna

Eftersom båda bilderna vi använder är helvita så hade man kunnat endast en bild som var 1x1 pixel stor och som var helvit. Man hade då kunnat bestämma storleken på bollen och paddlesen genom att skriva in storleken när man skapade rektanglarna istället för att använda bollBild.Width o.s.v.

Nedan så visas koden som laddar in bilderna och vårt SpriteFont, här finns också ett anrop till en metod som heter NyBoll som vi inte har skrivit än.

// Create a new SpriteBatch, which can be used to draw textures.
spriteBatch = new SpriteBatch(GraphicsDevice);

bollBild = Content.Load<Texture2D>("boll");
bollHitbox = new Rectangle(0, 0, bollBild.Width, bollBild.Height);
NyBoll();

paddleBild = Content.Load<Texture2D>("paddle");
vänsterPaddle = new Rectangle(50, 250, paddleBild.Width, paddleBild.Height);
högerPaddle = new Rectangle(750 - paddleBild.Width, 250, paddleBild.Width, paddleBild.Height);

arial = Content.Load<SpriteFont>("arial");

Metoden NyBoll ser till så att bollen placeras i mitten av spelfönstret och att den ges en slumpad hastighet. Detta är något som vi kommer att vilja göra flera gånger i vårt program, t.ex. när en spelare har gjort mål, så därför skriver vi koden för detta i en egen metod som vi strax ska undersöka. Spelets paddles placeras så att det är 50 pixlar mellan spelfönstrets kant och varje paddles kant, kom ihåg att standardstorleken för ett MonoGame-spel är 800 pixlar bredd och 480 pixlar högt.

Nu ska vi se hur metoden NyBoll fungerar. Lägg till denna metod efter Draw-metoden i ditt program.

/// <summary>
/// Placerar bollen i mitten av spelplanen med en slumpad hastighet
/// </summary>
void NyBoll()
{
    bollPosition.X = 400 - (bollBild.Width / 2);
    bollPosition.Y = 240 - (bollBild.Height / 2);
    bollHastighet.X = slump.Next(5, 10);
    bollHastighet.Y = slump.Next(5, 10);
}

Bollens placeras så att dess mittpunkt är precis i mitten av spelfönstret. Dess hastighet slumpas med hjälp av vår Random-variabel. För enkelhetens skull så slumpas hastigheten bara i en riktning, bollen kommer alltså alltid att börja åka åt höger.

Hur vet man vilka metoder man ska skapa?

Om du skrivit all kod till Pong själv hade det kanske varit svårt att veta från början att metoden NyBoll är användbar. Detta hade du kanske kommit på senare under programmets utveckling när du märker att du skrev i princip samma kod på flera olika platser, alla de platser där du vill att bollen ska skapas i mitten av skärmen.

När man skriver program själv så får man ofta skriva om koden i sitt program för att den hela tiden ska bli bättre och bättre. Du kan räkna med att inte alltid komma på alla metoder du vill ha i ett program från början så som det blir när du följer ett exempel.

Innan vi kollar på koden som vi ska skriva i Update-metoden, koden som styr hur själva spelet fungerar, så skriver vi koden som ritar upp spelets föremål på skärmen. Denna kod är inte lika lång som koden i Update-metoden, och av den anledningen kan det vara smidigt att skriva den först. Dessutom behöver vi rita ut spelets föremål på skärmen om vi ska kunna testa om spelet fungerar så som vi vill och det är en ännu bättre anledning att rita sakerna innan vi styr dem i Update-metoden.

GraphicsDevice.Clear(Color.Black);

spriteBatch.Begin();
spriteBatch.DrawString(arial, $"{vänsterPoäng} - {högerPoäng}", poängPosition, Color.White);
spriteBatch.Draw(bollBild, bollPosition, Color.White);
spriteBatch.Draw(paddleBild, vänsterPaddle, Color.White);
spriteBatch.Draw(paddleBild, högerPaddle, Color.White);
spriteBatch.End();

base.Draw(gameTime);

Det är nu dags att skriva själva kärnan av spelet, koden i Update-metoden. Här börjar vi med att, med hjälp av pseudokod, skissa på vad koden behöver göra varje gång Update-metoden anropas.

// Uppdatera tangentbord
// Flytta bollen
// Flytta paddles
// Undersök om bollen kolliderar med något
// Undersök om bollen har gått i mål

Dessa steg är allt som Pong behöver för att fungera. En del av stegen går ganska snabbt att koda och de kommer vi att skriva direkt i Update-metoden, en del av stegen är lite längre och får kodas i egna metoder för strukturens skull. Uppdatering av tangentbordet och förflyttning av bollen är de korta stegen så de kan vi skriva direkt.

// Uppdatera tangentbord
tangentBord = Keyboard.GetState();

// Flytta bollen
bollPosition += bollHastighet;

FlyttaPaddles();
KollaKollisioner();            
KollaPoäng();

base.Update(gameTime);

När du har skapat metoder med de namn som visas ovan så är det dags att skriva koden till dem. Vi börjar med att flytta spelets paddles. För att underlätta för två personer att spela Pong så låter vi en av våra paddles styras på den högra delen av tangentbordet med uppåt- och nedåtpilarna. Den andra spelaren får styra uppåt med W och nedåt med S.

/// <summary>
/// Hanterar förflyttningen av spelarnas paddles
/// </summary>
void FlyttaPaddles()
{
    if (tangentBord.IsKeyDown(Keys.W) == true)
    {
        vänsterPaddle.Y -= 5;
    }
    if (tangentBord.IsKeyDown(Keys.S) == true)
    {
        vänsterPaddle.Y += 5;
    }
    if (tangentBord.IsKeyDown(Keys.Up) == true)
    {
        högerPaddle.Y -= 5;
    }
    if (tangentBord.IsKeyDown(Keys.Down) == true)
    {
        högerPaddle.Y += 5;
    }
}

Härnäst är det dags för metoden KollaKollisioner. Innan vi skriver koden för den så beskriver vi den med pseudokod.

// Uppdatera bollens hitbox
// if (bollen kolliderar med en paddle)
    // Ändra bollens hastighet i X-led
// if (bollen kolliderar med toppen eller botten av spelfönstret)
    // Ändra bollens hastighet i Y-led

Här uppdaterar vi hitboxen, d.v.s. rektangeln, som bollen har för att kunna använda metoden Intersects. Den ska vi använda för att se om bollen kolliderar med någon av spelets paddles. När vi ska se om bollen kolliderar med spelfönstrets kanter så gör vi det själva genom att undersöka bollens Y-koordinat. Implementeringen ser ut såhär:

/// <summary>
/// Ser till så att bollen studsar mot paddles och väggarna
/// </summary>
void KollaKollisioner()
{
    bollHitbox.X = (int)bollPosition.X;
    bollHitbox.Y = (int)bollPosition.Y;

    if (bollHitbox.Intersects(vänsterPaddle) == true || bollHitbox.Intersects(högerPaddle) == true)
    {
        bollHastighet.X *= -1;
    }

    if (bollPosition.Y < 0 || bollPosition.Y + bollBild.Height > 480)
    {
        bollHastighet.Y *= -1;
    }
}

När vi multiplicerar bollens hastighet med -1 så innebär det att vi byter hastighetens tecken, från positiv till negativ eller negativ till positiv. Detta är detsamma som att ändra hastighetens riktning.

Slutligen så behöver vi skapa en metod som undersöker ifall någon av spelarna ska få poäng. Om poäng ska utdelas så måste bollen också starta om i mitten av spelplanen och då kan vi använda vår egen metod NyBoll. Vi kollar om någon av spelarna har gjort mål genom att undersöka bollens X-koordinat.

/// <summary>
/// Ger poäng om bollen kommit i mål och startar då om bollen i mitten av planen
/// </summary>
void KollaPoäng()
{
    if (bollPosition.X < 0)
    {
        högerPoäng++;
        NyBoll();
    }
    else if (bollPosition.X > 800)
    {
        vänsterPoäng++;
        NyBoll();
    }
}

Med dennna metod skriven så är hela programmet klart.

Hela programmet

Följ länken nedan för att se koden till hela programmet.

Hela programmet

Förslag på förändringar och utökningar av Pong

Här följer förslag på vad du själv kan göra för att bygga ut Pong. Du väljer själv hur mycket du vill göra och vad du har tid med, men du rekommenderas att åtminstone genomföra delarna ”Starthastighet som slumpas åt båda hållen” och ”Vinstgräns”.

Starthastighet som slumpas åt båda hållen

Gör om programmet så att bollens starthastighet kan vara riktad antingen åt vänster eller höger. Tänk på att bollen inte ska kunna stå still!

Vinstgräns

Gör så att det finns en vinstgräns, t.ex. 5 poäng. När en spelare har fått så många poäng som Pong har som vinstgräns så ska det mitt i fönstret stå vilken spelare som vann i 1 sekund, därefter ska poängräkningen starta om från 0-0.

Ingen konstig kollision med paddles

Ibland så kan bollen ”fastna” i en paddle och studsa fram och tillbaka inuti den med den koden som vi har skrivit. Gör så att bollen inte kan fastna i en paddle när den studsar mot den, den ska bara studsa en gång per kollision med en paddle.

Två bollar

Gör så att man kan lägga till en extraboll i spelet genom att trycka på en tangent på tangentbordet. Den nya bollen ska studsa mot spelets paddles precis som den första och båda bollarna ska dessutom studsa mot varandra om de krockar. Det ska också gå att ta bort extrabollen med en tangent på tangentbordet.

Styr boll med paddles

I det riktiga spelet Pong så kan man styra studsriktningen som bollen får när den träffar en paddle. Om bollen träffar paddlen ovanför paddlens mitt så ska den studsa iväg uppåt, annars neråt. Desto längre från paddlens mitt som bollen studsar desto mindre ”rakt fram” ska den färdas efter studsen.

Datorspelare

Gör så att den ena spelaren styrs av datorn. Datorn ska spela helt perfekt, den ska göra misstag ibland så att det blir lättare för den andra spelaren att vinna.

Huvudmeny

Gör att så att man kommer till en huvudmeny när spelet startar. Från huvudmenyn ska man kunna välja om man vill spela en match mot datorn eller om man vill spela en tvåspelarmatch. När någon har vunnit en match ska man komma tillbaka till huvudmenyn.

Förslag på egen projektuppgift – Breakout

Skapa en kopia av det klassiska spelet ”Breakout”. I Breakout så styr spelaren en paddle som finns längst ner i spelfönstret. Det finns en boll som kan studsa runt på spelarens paddle samt spelets övre, vänstra och högra kant. Om bollen åker ner genom spelets nedre kant så har man förlorat spelet.

I spelet finns, förutom bollen och spelarens paddle, ett antal rutor som går sönder när bollen studsar på dem. Målet med spelet är att alla rutor ska gå sönder, då har spelaren vunnit.

Du kan använda en eller flera arrayer för att spara den information du behöver ha om de rutor som spelaren ska skjuta sönder.