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

Viele von euch haben wahrscheinlich deshalb begonnen programmieren zu lernen um vielleicht mal ein eigenes Spiel zu erstellen.
Um die sonst wohl eher trockenen Beispiele aufzupeppen wollen wir in der kommenden, mehrteiligen Artikelreihe genau das tun.
Wir beginnen von Klein an, und arbeiten uns von einfachen springenden Bällen zu einem 2D – Sidescroller vor. Den Großteil des geschriebenen Codes werden wir dabei wieder verwenden und deshalb von Beginn an in einzelne Klassen packen.

Im Folgenden verwenden wir die Visual Studio 2015 Community Edition und MonoGame 3.5.
Um den Beispielen folgen zu können, musst du kein C# - Experte sein, allerdings solltest du mit den Grundlagen und dem Konzept der Objektorientierung vertraut sein.

Wieso MonoGame?

MonoGame bietet uns – in der mittlerweile dritten Version – ein umfangreiches Framework für die Spieleentwicklung. Es basiert auf dem von Microsoft mittlerweile eingestellten XNA und wird als dessen Nachfolger gehandelt. Ein besonders wichtiger Punkt ist hierbei die Plattformunabhängigkeit. MonoGame unterstützt Windows, Linux, Mac OS, Xbox, PS4 und PSVita sowie die mobilen Plattformen iOS und Android. Seit Neuestem wird auch Xamarin unterstützt, was besonders die mobile Entwickler freuen dürfte.

Vorbereitungen

Solltest du noch kein Visual Studio installiert haben, kannst du dieses auf visualstudio.com) herunterladen. Den aktuellen MonoGame Release bekommst du von der entsprechenden Downloadseite monogame.net/downloads.
Nach der Installation der beiden Komponenten, kannst du in Visual Studio ein neues MonoGame-Projekt starten. Wir wählen in unserem Beispiel das Cross Plattform Project.

New Monogame Project

Prompt wird uns ein entsprechendes Grundgerüst geliefert. Für uns relevant sind anfangs nur die Game1.cs sowie der Content-Ordner der Projektstruktur.  

Unser erstes Projekt:

Sehen wir uns zunächst die wichtigsten Elemente der Game1.cs an du uns von MonoGame generiert wurden:

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

    // TODO: use this.Content to load your game content here
}

Wie das Kommentar vermuten lässt, lädt uns die LoadContent() Methode die benötigten Inhalte. Dies umfasst Grafiken, Schriftarten, Musikfiles etc.

protected override void Update(GameTime gameTime)
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
        Exit();

    // TODO: Add your update logic here
    base.Update(gameTime);
}

Die Update() Methode ist jener Ort wo unsere Spiellogik abläuft. Ob die Reaktion auf Userinput oder die Ausgabe von Soundeffekten. Hier spielt sich das innere Leben deines Spiels ab. Wie du nun vielleicht vermutest, wird diese Methode schnell sehr umfangreich. Deshalb ist es absolut notwendig möglichst viel in andere Klassen und Methoden auszulagern um die Übersicht behalten zu können.

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

     // TODO: Add your drawing code here

     base.Draw(gameTime);
}

Damit der Spieler auch etwas von unserem Spiel sieht, müssen wir unsere Grafiken etc. auch auf den Bildschirm bringen. Das passiert in der Draw() Methode. Aus Performance- und übersichtsgründen ist es wichtig, keine Update()-Funktionen in die Draw() zu packen und umgekehrt. Diese Methode dient rein für die Darstellung auf dem Bildschirm.

Wir zeichnen einen Ball

Den Ball zeichnen wir natürlich nicht im wörtlichen Sinn. Das verwendete Bild (Download) fügen wir einfach in unserem Projekt in unseren Content-Ordner ein (Rechtsklick -> Hinzufügen -> Vorhandenes Element) und stellen in den Einstellungen dieser Datei „immer kopieren“ ein:


Datei hinzufügen

Einstellungen

Zu Beginn erstellen wir uns ein paar Klassenvariablen. Zwei Konstanten WINDOW_WIDTH und WINDOW_HEIGHT für die Fenstergröße sowie eine Texture2D Namens soccerBall die unseren Ball repräsentiert sowie einen Vector2 soccerPosition für die Position dieser Textur:

const int WINDOW_WIDTH = 1000;
const int WINDOW_HEIGHT = 700;

Texture2D soccerBall;
Vector2 soccerPosition = new Vector2(50, 50);

Um unseren Ball auch zeichnen zu können müssen wir dessen Grafik auch in unser Spiel laden. Das machen wir in unserer LoadContent() Methode. Der anzugebende Name ist der entsprechende Dateiname ohne Dateiendung.

soccerBall = Content.Load("Soccer"); 

Zu guter Letzt bringen wir diesen Ball auch auf den Bildschirm. In der Draw() Methode fügen wir folgenden Code ein:

spriteBatch.Begin();
spriteBatch.Draw(soccerBall, soccerPosition, Color.White);
spriteBatch.End();

Wichtig ist zu beachten, dass alle Draw-Aufrufe zwischen spriteBatch.Begin() und spriteBatch.End() stattfinden.
Als Ergebnis erhalten wir nun ein Fenster mit unserem Ball:

Run

Texture- Klasse

Im Laufe unseres Projektes werden wir naturgemäß viele verschiedene Texturen verwenden. Hier bietet es sich an diese Logik auszulagern. Wir erstellen uns eine neue Klasse Namens BaseTexture.cs Unsere Basis Texturklasse soll uns grundlegende Funktionen wie das Laden oder Zeichnen eines Bildes sowie einige Parameter wie Position und Größe zur Verfügung stellen.
Diese Basisklasse BaseTexture.cs sieht so aus:

using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;

namespace Game1
{
    public class BaseTexture
    {
        protected Texture2D imageName;      // Grafikname
        protected Vector2 imagePosition;    // Mittelpunkt unserer Grafik
        protected Vector2 imageSize;        // Groeße der Grafik

        //Konstruktor
        public BaseTexture(String imageName, Vector2 position, Vector2 size)
        {
            this.imageName = Game1.content.Load(imageName);
            imagePosition = position;
            imageSize = size;
        }

        //Zugriffsmethoden
        public Vector2 Position { get { return imagePosition; } set { imagePosition = value; } }
        public Vector2 Size { get { return imageSize; } set { imageSize = value; } }
        public float Width { get { return imageSize.X; } set { imageSize.X = value; } }
        public float Height { get { return imageSize.Y; } set { imageSize.Y = value; } }

        //Ecke Links Unten und Rechts Oben. Ergibt sich aus Mittelpunkt und Groeße
        public Vector2 MinBound { get { return imagePosition - (0.5f * imageSize); } }
        public Vector2 MaxBound { get { return imagePosition + (0.5f * imageSize); } }

        //Zeichenmethode
        public void Draw()
        {
            Game1.spriteBatch.Draw(imageName, imagePosition, Color.White);
        }
    }
}

Soweit sollte nichts überaschendes dabei sein. Damit diese aber auch verwendet werden kann, müssen wir folgende Anpassungen in Game1.cs vornehmen: Die bestehenden Klassenvariablen graphics und spriteBatch werden als public static deklariert. Ebenso erstellen wir einen eigenen ContentManager (using Microsoft.Xna.Framework.Content; benötigt) und passen unseren soccerBall-Typ an
        
public static GraphicsDeviceManager graphics;
public static SpriteBatch spriteBatch;
public static ContentManager content;
BaseTexture soccerBall;
float soccerBallRadius = 3f;

In der Game1()-Methode befüllen wir mit „content = Content;“ unseren ContentManager

public Game1()
{
    graphics = new GraphicsDeviceManager(this);
    Content.RootDirectory = "Content";
    content = Content;
    ...
}

In der LoadContent() Methode ändern wir die Art wie wir unseren Ball erzeugen. Da unser Ball rund ist, und demnach die Textur quadratisch, lässt sich die Größe über den Radius definieren. Um das Bild ein wenig zu verkleinern verwenden wir direkt den Radius und nicht den Durchmesser :

soccerBall = new BaseTexture("Soccer", soccerPosition, new Vector2(soccerBallRadius, soccerBallRadius));

Auch die Draw() Methode können wir entsprechend kürzen:

spriteBatch.Begin();
soccerBall.Draw();
spriteBatch.End();

Camera- Klasse

Wie dir vielleicht aufgefallen ist, wird unser Ball in der linken oberen Ecke angezeigt obwohl wir als Position 50,50 (x, y) gewählt haben. Das liegt daran, dass am PC im Standard Koordinatensystem der Punkt 0,0 links oben liegt anstatt rechts unten so wie wir es eigentlich gewohnt sind. Da wir früher oder später ohnehin eine eigene Klasse für die Kameraführung brauchen werden (u.a. auch für die Kollision) bietet es sich an auch diesen Punkt zu bearbeiten.
Unsere Camera.cs sieht so aus:

using Microsoft.Xna.Framework;

namespace Game1
{
    static public class Camera
    {
        private static Vector2 origin = Vector2.Zero;
        private static float width = 100f;
        private static float height = -1f;
        private static float ratio = -1f;                 
        
        static public Vector2 CameraWindowLowerLeftPosition { get { return origin; } }
        static public Vector2 CameraWindowUpperRightPosition { get { return origin + new Vector2(width, height); } }
        
        static private float CameraWindowToPixelRatio()
        {
            if (ratio < 0f)
            {
                ratio = (float)Game1.graphics.PreferredBackBufferWidth / width;
                height = width * (float)Game1.graphics.PreferredBackBufferHeight / (float)Game1.graphics.PreferredBackBufferWidth;
            }
            return ratio;
        }

        static public void SetCameraWindow(Vector2 origin, float width)
        {
            Camera.origin = origin;
            Camera.width = width;
            CameraWindowToPixelRatio();
        }

        static public void GetPixelPosition(Vector2 cameraPosition, out int x, out int y)
        {
            float ratio = CameraWindowToPixelRatio();

            x = (int)(((cameraPosition.X - origin.X) * ratio) + 0.5f);
            y = (int)(((cameraPosition.Y - origin.Y) * ratio) + 0.5f);
            y = Game1.graphics.PreferredBackBufferHeight - y;
        }

        static public Rectangle GetPixelRectangle(Vector2 position, Vector2 size)
        {
            float ratio = CameraWindowToPixelRatio();

            // Convert size from Camera Window Space to pixel space
            int width = (int)((size.X * ratio) + 0.5f);
            int height = (int)((size.Y * ratio) + 0.5f);

            // Convert the position to pixel space
            int x, y;
            GetPixelPosition(position, out x, out y);

            // reference position is the center 
            y -= height / 2;
            x -= width / 2;

            return new Rectangle(x, y, width, height);
        }
    }
}

Der Nutzen dieser Klasse wird dir im weiteren Verlauf klarer.
Zum Abschluss ergänzen wir die Draw() Methode unserer BaseTexture Klasse um
           
Rectangle destRect = Camera.GetPixelRectangle(Position, Size);

Die Soccer- Klasse – Bewegung kommt ins Spiel

Zuerst erweitern wir unsere Camera-Klasse um eine einfache Kollisionserkennung. Wir erreichen das über ein enum und einer einfachen Abfrage ob die Position unserer Textur außerhalb des Fensters liegt:
  
public enum CameraWindowCollisionStatus
{
    CollideTop = 0,
    CollideBottom = 1,
    CollideLeft = 2,
    CollideRight = 3,
    InsideWindow = 4
};

static public CameraWindowCollisionStatus CollidedWithCameraWindow(BaseTexture tex)
{
    Vector2 min = CameraWindowLowerLeftPosition;
    Vector2 max = CameraWindowUpperRightPosition;

    if (tex.MaxBound.Y > max.Y)
        return CameraWindowCollisionStatus.CollideTop;
    if (tex.MinBound.X < min.X)
        return CameraWindowCollisionStatus.CollideLeft;
    if (tex.MaxBound.X > max.X)
        return CameraWindowCollisionStatus.CollideRight;
    if (tex.MinBound.Y < min.Y)
        return CameraWindowCollisionStatus.CollideBottom;

    return CameraWindowCollisionStatus.InsideWindow;
}

Nun zu unserer SoccerBall-Klasse die wir neu als SoccerBall.cs erstellen.
Diese erbt passender Weise von BaseTexture. Der Konstruktor nimmt nur eine Position und den Durchmesser entgegen und leitet diese an den Basiskonstruktor weiter. Der Bildname Soccer wird automatisch mitgegeben.
Neu ist auch die Variable direction die uns eine zufällige Richtung vorgibt.
In der eigenen Update() Methode prüfen wir nun, ob unser Ball einen der äußeren Ränder berührt. Wenn ja, ändern wir entsprechend die Richtung.
Die Bewegung realisieren wir durch einfaches Addieren von Position und direction.
  
using Microsoft.Xna.Framework;

namespace Game1
{
    public class SoccerBall : BaseTexture
    {
        private Vector2 direction; 

        public SoccerBall(Vector2 position, float diameter) : base("Soccer", position, new Vector2(diameter, diameter))
        {
            direction.X = (float) (Game1.rand.NextDouble()) * 2f - 1f;
            direction.Y = (float) (Game1.rand.NextDouble()) * 2f - 1f;
        }

        public float Radius 
        { 
            get { return imageSize.X * 0.5f; } 
            set { imageSize.X = 2f * value; imageSize.Y = imageSize.X;} 
        }

        public void Update()
        {
            Camera.CameraWindowCollisionStatus status = Camera.CollidedWithCameraWindow(this);
            switch (status) 
            {
                case Camera.CameraWindowCollisionStatus.CollideBottom:
                case Camera.CameraWindowCollisionStatus.CollideTop:
                    direction.Y *= -1;
                    break;
                case Camera.CameraWindowCollisionStatus.CollideLeft:
                case Camera.CameraWindowCollisionStatus.CollideRight:
                    direction.X *= -1;
                    break;
            }
            Position += direction;
        }
    }
}

Zum Abschluss passen wir die Game1 Klasse entsprechend an. Unser soccerBall ist jetzt keine BaseTexture mehr, also SoccerBall soccerBall; Zusätzlich benötigen wir die Klassenvariable rand für unsere SoccerBall-Klasse static public Random rand;. (using System; benötigt) Diese instanzieren wir direkt in der Game1() Methode rand = new Random();.
Auch die LoadContent() Methode passen wir an. Hier definieren wir unser CameraWindow und einen neuen soccerBall:
 
Camera.SetCameraWindow(new Vector2(10f, 20f), 100f);
soccerBall = new SoccerBall(soccerPosition, soccerBallRadius);

Damit sich unser Ball auch bewegt rufen wir in der Update()-Methode die der soccerBall-Klasse auf:
 
soccerBall.Update();

Abschl ießend passen wir die Draw()-Methode der BaseTexture-Klasse an. Diese benötigt nun die korrekten Maße des Fensters. Die Draw() sieht nun so aus:

public void Draw()
{
    Rectangle destRect = Camera.GetPixelRectangle(Position, Size);
    Game1.spriteBatch.Draw(imageName, destRect, Color.White);
}

Wenn du das Projekt nun ausführst, solltest du einen Ball sehen der sich durch das Fenster bewegt und von den jeweiligen Fensterrändern abprallt.

Zum Abschluss – wir wollen mehr!

Am Ende wollen wir noch per Knopfdruck weitere Bälle hinzufügen.
Grundsätzlich ist es einfach einen Knopfdruck abzufragen, das Problem das allerdings auftritt – und das ist ein klassischer Anfängerfehler – ist, dass das Programm jeden frame auf Tastendruck abfragt – also viele Male pro Sekunde (60x pro Sekunde bei klassischen 60fps). Das Ergebnis ist meist, dass der Knopfdruck mehrfach gezählt wird obwohl wir ihn eigentlich nur einmal kurz drücken. Aber auch dies lässt sich leicht abfangen in dem wir dem Programm sagen, es soll warten, bis die Taste wieder losgelassen wird.

Außerdem wollen wir eine Anzeige die uns zeigt wie viele Bälle gerade unterwegs sind.

Zuerst brauchen wir eine Schriftart. Das mag etwas merkwürdig klingen, allerdings benötigt das Programm immer eine Schriftart die es verwenden kann. Diese fügen wir wie unser Ball-Bild zu unserem Programm in den Contentordner ein. Download (Immer kopieren in den Dateieinstellungen nicht vergessen)
Als Klassenvariablen in der Game1-Klasse fügen wir folgendes hinzu:

private static SpriteFont usedFont;
private KeyboardState oldState; 

Die Menge an Bällen verwalten wir in einer List<> und ersetzten deshalb SoccerBall soccerBall; mit
               
List<SoccerBall> listOfBalls = new List<SoccerBall>(); 

(using System.Collections.Generic; benötigt)

In der Game1() Methode erfassen wir den aktuellen Tastatur-Status

oldState = Keyboard.GetState();

In der initialize()-Methode befüllen wir initial unsere Liste mit einem ersten Ball
    
listOfBalls.Add(new SoccerBall(soccerPosition, soccerBallRadius));

In der LoadContent() Methode laden wir unsere Schriftart und entfernen die nicht mehr benötigte Ballerstellung. Die Methode sieht nun so aus :

protected override void LoadContent()
{
    spriteBatch = new SpriteBatch(GraphicsDevice);
    Camera.SetCameraWindow(new Vector2(10f, 20f), 100f);

    usedFont = Game1.content.Load<SpriteFont>("Arial");
}

Die Behandlung von Usereingaben behandeln wir in einer eigenen UpdateInput() Methode in der Game1-Klasse.
Hier reagieren wir lediglich auf die Taste N (Neuer Ball) und Esc (beenden)
Beachte wie wir zuerst prüfen ob nicht die Taste bereits gedrückt war.

private void UpdateInput()
{
    KeyboardState newState = Keyboard.GetState();

    if (newState.IsKeyDown(Keys.Escape))
        this.Exit();

    if (newState.IsKeyDown(Keys.N))
    {
        if (!oldState.IsKeyDown(Keys.N))
        {
            listOfBalls.Add(new SoccerBall(soccerPosition, soccerBallRadius));
        }
    }
    oldState = newState;
}

Die bisherige Update()-Methode passen wir dahin gehend an, dass wir für jeden Ball die eigene Update()-methode Aufrufen. Ebenso prüfen wir den user Input.
Die Update()-Methode der Game1-Klasse sieht final so aus:

protected override void Update(GameTime gameTime)
{
    foreach (SoccerBall ball in listOfBalls)
        ball.Update();
    UpdateInput();

    base.Update(gameTime);
}

Abschließend passen wir die Draw()-Methode ähnlich der Update() an. Außerdem geben wir eine kurze Textzeile aus.
Die Draw()-Methode der Game1-Klasse sieht final so aus:

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

    foreach (SoccerBall ball in listOfBalls)
       ball.Draw();
    spriteBatch.DrawString(usedFont, "Balls Count:" + listOfBalls.Count, new Vector2(5, 5), Color.Black);

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

Wenn du das Programm nun ausführst, solltest du über die N-Taste weitere Bälle hinzufügen können die jeweils von den Wänden abprallen.


Final

Das war der erste Teil unseres Tutorials zur Spieleenwicklung. Im nächsten Teil beschäftigen wir uns weiter mit Kollision von Objektes, und Userinput.
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

Hier geht es zum zweiten Artikel