Einstieg in die Spieleprogrammierung mit MonoGame und C# - Teil 2

Nach unserem ersten kleinen Ballspiel wollen wir auf dem bereits vorhandenen aufbauen und unser Spiel weiterentwickeln.
Bitte vergewissere dich, dass dein Projekt auf dem Stand des vorhergehenden Artikels ist.
Am Ende dieses Artikels haben wir ein kleines Ball-Fang-Spiel in dem der Spieler möglichst schnell Basketbälle zu fangen:
Final Game

Für dieses kleine Spiel benötigen wir folgende Grafiken: Auch hier nicht vergessen in den Einstellungen der Grafiken „Immer kopieren“ einzustellen.

Kollisionserkennung - Light

Um die Basketbälle fangen zu können, benötigen wir eine Kollisionserkennung die uns prüft ob wir einen Ball berühren - also „gefangen“ haben.
Dafür erweitern wir die BaseTexture-Klasse wie folgt:

public bool PrimitivesTouch(BaseTexture otherTex)
{
    Vector2 v = imagePosition - otherTex.imagePosition;
    float distance = v.Length();
    return distance < ((imageSize.X / 2f) + (otherTex.imageSize.X / 2f));
}

Wir vergleichen hier einfach ob sich die beiden Texturen überlappen. Wenn ja, liefern wir ein true zurück.
In der BaseTexture-Klasse fügen wir außerdem einen einfachen Konstruktor hinzu der nur einen Bildnamen als String entgegen nimmt.

public BaseTexture(String image)
{
    imageName = Game1.content.Load(image);
    imagePosition = Vector2.Zero;
    imageSize = Vector2.UnitX;
}

Die Basketballklasse

Ähnlich zu unserer SoccerBall-Klasse erstellen wir uns eine für die kommenden Basketbälle. Diese wird ebenfalls von BaseTexture erben.
Die Klasse ist relativ kurz, sollte selbsterklärend sein und sieht so aus:

using Microsoft.Xna.Framework;

namespace Game1.GraphicSupport
{
    class BasketBall : BaseTexture
    {
        private const float growthRate = 1.001f; //Wachstumsrate
        private Vector2 initialSize = new Vector2(5, 5);
        private const float finalSize = 15f;

        //Constructor
        public BasketBall() : base("Basketball")
        {
            imagePosition = Camera.RandomPosition();
            imageSize = initialSize;
        }

        //Update Basketballsize and explode if at maximum size
        public bool UpdateAndCheckSize()
        {
            imageSize *= growthRate;
            return imageSize.X > finalSize;
        }
    }
}

Für den Camera.RandomPositoion()-Aufruf erweitern wir die Camera-Klasse um die entsprechende Methode:

static public Vector2 RandomPosition()
{
    float rangeX = 0.8f * width;
    float offsetX = 0.1f * width;
    float rangeY = 0.8f * height;
    float offsetY = 0.1f * height;

    float x = (float)(Game1.rand.NextDouble()) * rangeX + offsetX + origin.X;
    float y = (float)(Game1.rand.NextDouble()) * rangeY + offsetY + origin.Y;

    return new Vector2(x, y);
}

Eigene Klasse für die Logik

Um die Standard Game1-Klasse nicht weiter mit unserer Logik zu füllen und um die übersicht zu bewahren erstellen wir uns eine eigene Klasse für die Spiellogik. Diese Umfasst die Verwaltung der Basketbälle und der Spielerfigur sowie der Prüfung der Siegesbedingung.

Wir beginnen mit einer neuen Klasse GameLogic.cs. Diese beinhaltet einige Klassenvariablen die unseren Spieler darstellen, ein paar Spielbezogene Variablen und natürlich die Liste der Bälle. Die konstanten Variablen definieren die Siegesbedingungen sowie wie viele Punkte es pro gefangenem Ball gibt bzw. wieviel uns abgezogen wird, wenn wir einen Ball nicht rechtzeitig fangen.

//Player definitions
BaseTexture player;
Vector2 playerSize = new Vector2(15, 15);
Vector2 playerPosition = Vector2.Zero;

int playerScore = 0;
int missedBalls = 0;
int hitBalls = 0;
BaseTexture finalImage = null;

const int POINTSPERBALL = 1;
const int MISSEDPENALTY = -2;
const int WINSCORE = 10;
const int LOSSSCORE = -10;

//Basketball List
List<BasketBall> basketBallList;
TimeSpan creationTimeStamp;
int sumBBalls = 0;
const int intervalBBalls = 500; // 500 Msec = 0,5seconds

Der Konstruktor dieser Klasse ist denkbar einfach. Wir erstellen uns einfach unsere Spielertextur, Initialisieren unsere Ballliste und legen fest wann das Spiel begonnen hat um den Spawnintervall prüfen zu können:

public GameLogic()
{
    player = new BaseTexture("Player", playerPosition, playerSize);
    basketBallList = new List<BasketBall>();
    creationTimeStamp = new TimeSpan(0);
}

Die folgende UpdateGame()-Methode beinhaltet nun all unsere benötigte Logik.
Wir beginnen einfach mit der Prüfung auf finalImage. Solange kein Image gesetzt ist, ist das Spiel noch nicht vorbei:

public void UpdateGame(GameTime gameTime)
{
    if (finalImage != null) //Game Over
        return;
}

Als nächstes prüfen wir für jeden Ball ob dieser noch gültig ist. Wenn nicht, dann entfernen wir ihn aus der Liste und ziehen entsprechend Punkte ab:

public void UpdateGame(GameTime gameTime)
{
    ...

    // Update and Check  Basketballs
    for (int b = basketBallList.Count - 1; b >= 0; b--)
    {
        if (basketBallList[b].UpdateAndCheckSize())
        {
            basketBallList.RemoveAt(b);
            missedBalls++;
            playerScore += MISSEDPENALTY;
        }
    }
}

Für die Kollision prüfen wir für jeden Ball ob dieser vom Spieler berührt wird. Wenn ja, wird er aus der Liste entfernt und uns werden Punkte gut geschrieben.

public void UpdateGame(GameTime gameTime)
{
    ...
    
    //Check Collision
    for (int b = basketBallList.Count - 1; b >= 0; b--)
    {
        if (player.PrimitivesTouch(basketBallList[b]))
        {
            basketBallList.RemoveAt(b);
            hitBalls++;
            playerScore += POINTSPERBALL;
        }
    }
}

Für die Erstellung neuer Bälle prüfen wir einfach ob die Zeit seit dem letzten Ballspawn größer ist als unser eingstellter Zeitintervall. Wenn ja, wird ein weiterer Ball der Liste hinzugefügt.

public void UpdateGame(GameTime gameTime)
{
    ...

    //Check for Creation
    TimeSpan timePassed = gameTime.TotalGameTime;
    timePassed = timePassed.Subtract(creationTimeStamp);
    
    if (timePassed.TotalMilliseconds > intervalBBalls)
    {
        creationTimeStamp = gameTime.TotalGameTime;
        BasketBall b = new BasketBall();
        sumBBalls++;
        basketBallList.Add(b);
    }
}

Abschließend prüfen wir die Siegesbedingung. Haben wir die Siegespunktzahl erreicht, setzen wir das entsprechende Siegerbild. Sollten wir die Minimumpunkte erreichen, wird das Verliererbild gesetzt. Dadurch wird die erste If-Bedingung unser Spiel unterbrechen, da finaleImage nicht mehr null ist:

public void UpdateGame(GameTime gameTime)
{
    ...

    //Check Win/Loss
    if (playerScore > WINSCORE)
        finalImage = new BaseTexture("Win", new Vector2(75, 50), new Vector2(30, 20));
    else if (playerScore < LOSSSCORE)
        finalImage = new BaseTexture("Loose", new Vector2(75, 50), new Vector2(30, 20));
}

Das war unsere UpdateGame()-Methode.
Um die Bälle und den Spieler auf den Bildschirm zu bringen, benötigen wir natürlich eine passende Draw()-Methode. Diese gestaltet sich deutlich einfacher als die UpdateGame()-Methode:

public void DrawGame()
{
    //Draw Player
    player.Draw();

    //Draw Balls
    foreach (BasketBall b in basketBallList)
        b.Draw();

    //Draw Win/Loss Image
    if (finalImage != null)
        finalImage.Draw();

    // Statustext
    Game1.spriteBatch.DrawString(Game1.usedFont, "Score=" + playerScore + " BBalls: Generated( " + sumBBalls + ") Collected(" + 
                                hitBalls + ") Missed(" + missedBalls + ")", new Vector2(5, 5), Color.Black);
}

Letzte Anpassungen

Damit unsere Basketbälle nun auch wirklich erstellt werden muss unsere Basketball-Klasse in unsere Game1.cs eingebaut werden. Dabei können wir auch alles SoccerBall-Relevante entfernen bzw. auskommentieren.
Zuerst fügen wir eine neue Klassenvariable gameLogic hinzu:

GameLogic gameLogic;

Und erweitern die LoadContent()-Methode entsprechend:

protected override void LoadContent()
{
    spriteBatch = new SpriteBatch(GraphicsDevice);
    gameLogic = new GameLogic();

    Camera.SetCameraWindow(new Vector2(-10f, -10f), 150f);
    usedFont = Game1.content.Load<SpriteFont>("Arial");
}

Auch unsere Update()-Methode passen wir an:

protected override void Update(GameTime gameTime)
{
    UpdateInput();
    gameLogic.UpdateGame(gameTime);

    base.Update(gameTime);
}

Den Draw-Call fügen wir außerdem in die Draw()-Methode ein:

protected override void Draw(GameTime gameTime)
{
    GraphicsDevice.Clear(Color.CornflowerBlue);
    spriteBatch.Begin();

    gameLogic.DrawGame();

    spriteBatch.End();
    base.Draw(gameTime);
}

Spieler bewegen

Ein Punkt fehlt uns aber noch. Die Bewegung des Spielers. Wir erweitern also unsere UpdateGame()-Methode in der GameLogic-Klasse um eine einfache Tasttaturabfrage.

if (Keyboard.GetState().IsKeyDown(Keys.Up))
    playerPosition.Y += 0.5f;
if (Keyboard.GetState().IsKeyDown(Keys.Down))
    playerPosition.Y -= 0.5f;
if (Keyboard.GetState().IsKeyDown(Keys.Left))
    playerPosition.X -= 0.5f;
if (Keyboard.GetState().IsKeyDown(Keys.Right))
    playerPosition.X += 0.5f;

player.Update(playerPosition);

Und in der BaseTexture-Klasse passen wir die Update()-Methode auch entsprechend an:

public void Update(Vector2 position)
{
    imagePosition = position;
}

Wenn du das Programm nun ausführst, solltest du in der Lage sein, mit den Pfeiltasten deine Spielerfigur zu bewegen und die Bälle einzufangen wie in der Grafik ganz oben.

Das war der zweite Teil unseres Tutorials zur Spieleentwicklung. Im nächsten Teil beschäftigen wir uns mit Rotation und Zielsuche.
Solltest du Probleme bei der Umsetzung haben, kannst du jeder Zeit im Forum oder via Facebook um Unterstützung fragen.

Hier findest du außerdem die komplette Solution als .rar-File Download Solution