Backend, gamedev

MonoGame, czyli jak stworzyć grę w C#

Wiele osób chciało kiedyś tworzyć własne gry, oczywiście w tym także ja. Były to co prawda dosyć dawne czasy. Czasy, w których chodziłem do pierwszych klas podstawówki. Nie znałem wtedy nawet podstawowej budowy komputera – nie mówiąc już nic o programowaniu. Jednak lata mijały i pewnego dnia zabrałem się za naukę C++, a następnie C#. Kiedy wiedziałem już o co mniej więcej chodzi w całym tym programowaniu, postanowiłem stworzyć jakąś prostą gierkę. Długo nie zastanawiałem się nad technologią, w której chciałem ją napisać. Wybór padł na MonoGame, głównie dlatego, że używa się w nim (mojego ulubionego) C#.

Łukasz Soroczyński. Naukę programowania rozpoczął około pięciu lat temu, która z mniejszymi lub większymi przerwami trwa do dziś. Uważa, że najlepszym sposobem na zdobywanie wiedzy w tej branży jest samodzielne stawianie sobie zadań i ich realizowanie. Swoją przyszłość wiąże z branżą IT, a w szczególności z programowaniem.


Klika słów o MonoGame

Jak nietrudno domyślić się jest to framework pozwalający pisać gry przy użyciu języka, jakim jest C#. MonoGame jest otwartą implementacją i zarazem następcą Microsoft XNA. Właściwie 100% kodu napisanego kiedyś w XNA powinno z powodzeniem udać się przenieść do MonoGame. Dodatkowym atutem otwartej wersji tego frameworka jest to, że wspiera on prawie wszystkie istniejące systemy i urządzenia (np. Windows, Linux, iOS, Android, Xbox, PS4). Pomimo wielu zalet MonoGame nie jest pozbawione też wad. Jedną z nich jest fakt, że pisanie w nim gier zabiera o wiele więcej czasu niż w przypadku np. Unity, które dostarcza całą masę gotowych komponentów. Z drugiej strony Unity nie jest otwarte… Coś za coś.

MonoGame – jak zbudowany jest kod gry?

Aby zacząć przygodę z tworzeniem gier w tym środowisku wypadałoby stworzyć w Visual Studio nowy projekt. W zależności od platformy, na którą chcemy stworzyć grę, wybieramy interesującą nas opcję. Na potrzeby tego artykułu stworzę MonoGame Windows Project:

W zasadzie nie ma żadnego znaczenia, który projekt zostanie wybrany. Kod wszędzie będzie wyglądał tak samo. Różnica będzie w sposobie generowania przez framework obrazu – w przypadku projektu Windowsowego używany jest DirectX, a w Cross Platform – OpenGL. Po utworzeniu projektu powinien powitać nas mniej więcej taki widok:

Jak widać w projekcie istnieją dwa pliki *.cs oraz folder Content. Plik Program.cs zawiera metodę Main, która tworzy klasę główną gry:

using System;

namespace TemplateGame
{
#if WINDOWS || LINUX
    /// <summary>
    /// The main class.
    /// </summary>
    public static class Program
    {
        /// <summary>
        /// The main entry point for the application.
        /// </summary>
        [STAThread]
        static void Main()
        {
            using (var game = new Game1())
                game.Run();
        }
    }
#endif
}

Natomiast plik Game1.cs zawiera klasę główną gry. To tutaj odbywa się rysowanie elementów na ekranie i ich przemieszczanie. Po usunięciu zbędnych komentarzy klasa ta powinna wyglądać tak:

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace TemplateGame
{
    public class Game1 : Game
    {
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

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

        protected override void Initialize()
        {
            // TODO: Add your initialization logic here
            base.Initialize();
        }

        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
        }
        protected override void UnloadContent()
        {
            // TODO: Unload any non ContentManager content here
        }

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

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);
            // TODO: Add your drawing code here
            base.Draw(gameTime);
        }
    }
}

Co do czego służy? Zaczynając od konstruktora – tworzymy w nim nowy obiekt typu GraphicsDeviceManager. Dzięki niemu będziemy mogli w ogóle wyświetlić nasze okno z grą. Klasa ta odpowiada za zarządzanie urządzeniem graficznym – czyli m.in za renderowanie naszego obrazu. Z pomocą jej właściwości możemy ustalić czy gra ma być np. uruchamiana w trybie pełnoekranowym czy nie.

Kolejna linijka wskazuje na miejsce, skąd ładowane będą zasoby do gry (obrazki, dźwięki itd.). Dalej znajduje się metoda Initialize() — jak wskazuje sama nazwa, umieszczamy tu wszystko to co chcemy wykonać przed startem gry. Następna metody to odpowiednio Load i UnloadContent. W nich umieszczany jest kod ładujący/usuwający zasoby używane w grze, czyli na przykład tekstury.

Kolejne dwie metody są najważniejsze. Obydwie wykonują się w dwóch niezależnych pętlach. W metodzie Update umieszcza się kod odpowiedzialny za logikę gry (czyli np. przemieszczanie postaci), natomiast w metodzie Draw tylko rysuje się elementy na ekranie. Taki podział jest spowodowany tym, aby liczba wyświetlanych klatek na sekundę nie miała wpływu na szybkość wykonywania się logiki gry.

MonoGame – jak stworzyć proste menu gry?

Jak widać gry pisze się w zgoła inny sposób niż standardowe aplikacje. Całość zabawy polega na rozmieszczaniu i rysowaniu jakiś kształtów, wyświetlaniu ich na ekranie oraz obsłudze interakcji między nimi. Oczywiście dochodzi do to tego obsługa urządzeń wejściowych takich jak np. mysz czy klawiatura. Nie ma tutaj mowy o użyciu gotowego przycisku i zdarzenia jego kliknięcia. Aby stworzyć proste klikane menu musimy podejść do tematu w trochę inny sposób.

Najpierw należy stworzyć teksturę naszego przycisku (np. w paincie). Ja na szybko zrobiłem taki:

Tło pozostawiłem białe nie bez powodu, o czym niedługo się przekonasz. Kolejnym krokiem jest dodanie tekstury przycisku do zasobów gry. W tym celu klikamy dwa razy na ten element:

 

W efekcie powinno pojawić się takie oto okienko:

Po wybraniu Add->Existing Item możemy dodać do projektu grafikę naszego przycisku. Zostanie ona umieszczona w zasobach aplikacji. Teraz możemy zająć się naszym kodem.

Na początek odblokujmy sobie możliwość zmiany rozmiaru okna gry w trakcie jej działania oraz pozwolimy na wyświetlanie kursora nad polem gry (będzie nam potrzebny do klikania w przyciski). W tym celu w metodzie metodzie Initialize() umieścimy następujący kod:

protected override void Initialize()
{
	IsMouseVisible = true;
	Window.AllowUserResizing = true;

       base.Initialize();
}

Od teraz kursor będzie już widoczny, a okienko gry można dowolnie powiększać i pomniejszać.

Teraz możemy zabrać się za wyświetlenie przycisku. Aby był on w ogóle widoczny, poza dodaniem go do zasobów naszej gry musimy go jeszcze załadować i wyświetlić. Tak, więc zabierzmy się za jego załadowanie do pamięci. W tym celu musimy stworzyć zmienną typu Texture2D, nazwijmy ją PlayButtonTexture – to w niej będzie trzymany nasz obrazek. Kolejną rzeczą jest stworzenie zmiennej typu Rectangle, która będzie określała rozmiar obrazka i jego pozycję. Będzie też potrzebna do wykrywania kolizji. Nazwijmy ją recPlayButton. Dodajmy jeszcze jedną zmienną typu Color, o nazwie PlayButtonColor. Przyda się nam później. Następnie należy przejść do metody LoadContent(), aby załadować plik z obrazkiem do zmiennej typu Texture2D:

Texture2D PlayButtonTexture;
        //
        Rectangle recPlayButton;
        //
        Color PlayButtonColor = Color.White;
        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            //load texture
            PlayButtonTexture = Content.Load<Texture2D>("play");
        }

Tekstura załadowana. Teraz przydałoby się ją wyświetlić… Skoczmy, więc do metody Draw().

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

            spriteBatch.Begin();
            spriteBatch.Draw(PlayButtonTexture, recPlayButton, PlayButtonColor);
            spriteBatch.End();

            base.Draw(gameTime);
        }

Teraz na chwilę się zatrzymamy, w celu wyjaśnienia co jest od czego. Pierwsza linijka odpowiada za czyszczenie ekranu i wypełnienie go jednolitym tłem. Tutaj jest to jakiś odcień niebieskiego – co oczywiście możemy sobie zmienić. Dalej wywoływana jest metoda spriteBatch.Begin();. Musi być ona wywołana zawsze przed tym, zanim użyjemy metody Draw(). Teraz jesteśmy w miejscu najważniejszym. Ta linijka odpowiada za rysowanie na ekranie naszego przycisku. Argumenty jakie przyjmuje to kolejno: tekstura z naszym obrazkiem, zmienna typu Rectangle, która określa położenie naszego obrazka, oraz jego wielkość. Ostatnim argumentem jest kolor jaki ma przyjąć nasza tekstura – właśnie dlatego pisałem, że najlepiej jak obrazek będzie biały. Kolejna linijka kodu to wywołanie kończące rysowanie klatki.

Pomimo całej naszej pracy, na tym etapie przycisk jeszcze nie będzie widoczny. To dlatego, że nie określiliśmy jego położenia, ani rozmiaru – koloru też nie. Jednak tym zajmiemy się w metodzie Update(), albowiem takie rzeczy właśnie tam się robi.

W metodzie Update(), ustalamy jakie rozmiary i położenie ma mieć nasz element (w tym przypadku przycisk). Robi się to z pomocą właściwości obiektu klasy Rectangle:

protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
            //wyznaczanie położenia za każdym razem kiedy generowana jest nowa klatka
            recPlayButton.X = GraphicsDevice.Viewport.Width / 2 - recPlayButton.Size.X / 2;
            recPlayButton.Y = GraphicsDevice.Viewport.Height / 2 - recPlayButton.Size.Y / 2;
            //wyznaczanie rozmiaru przycisku
            recPlayButton.Height = GraphicsDevice.Viewport.Height / 6;
            recPlayButton.Width = GraphicsDevice.Viewport.Width / 3;
            //
            base.Update(gameTime);
        }

Wypada nadmienić, że GraphicsDevice.Viewport.Height/Width zwraca rozmiar istniejącego okna gry.

Po dodaniu tych linijek kodu przycisk jest w końcu wyświetlany. Aktualizuje on swoje rozmiary i położenie przy zmianach rozmiaru okna. Jest jednak nadal martwy. Teraz należy zabrać się za sprawdzanie czy kursor znajduje się nad przyciskiem oraz czy użytkownik w niego klika. W tym celu możemy stworzyć niewidzialny kwadracik o rozmiarach 1x1px, który będziemy przesuwać wraz z kursorem:

MouseState mouseState;
        Rectangle Cursor;
        private void UpdateCursorPosition()
        {
            /* Pozycja kwaratu podąża za pozycją kursora */
            mouseState = Mouse.GetState();
            Cursor.X = mouseState.X; Cursor.Y = mouseState.Y;
        }

Oczywiście powyższą metodę należy wywołać w metodzie Update();
Teraz wystarczy tylko sprawdzać czy nasz kursor-kwadracik styka się z prostokątem reprezentującym przycisk i czy LPM jest wciśnięty:

private void ButtonsEvents()
        {
	     //jeżeli kursor styka się z przyciskiem
            if ((recPlayButton.Intersects(Cursor)))
            {
                PlayButtonColor = Color.Green;
		  //i wciśnięty jest LMP
                if (mouseState.LeftButton == ButtonState.Pressed)
                    PlayButtonColor = Color.Red;
            }
            else
                PlayButtonColor = Color.White;
        }

Wszystkie akcje, które mają zostać wykonane po kliknięciu przycisku umieszczamy pod linijką PlayButtonColor = Color.Red;

MonoGame – podział gry na sceny

Gra posiadająca same menu, to trochę słaby pomysł. Wypadałoby, żeby po kliknięciu przycisku „Graj” jakaś rozgrywka faktycznie się zaczynała. W tym celu należałoby podzielić grę na sceny. W najbardziej łopatologiczny sposób można to zrobić na przykład tak:

1. Tworzymy dwa nowe pliki o nazwach MenuScene.cs oraz GameScene.cs, które będą rozszerzały klasę główną gry (oczywiście nic nie stoi na przeszkodzie, aby były to dwie odrębne klasy, ale dla ułatwienia na potrzeby tego artykułu wszystko wpakuję w jedną – co nie jest zbyt eleganckie przy większych projektach).

2. Tworzymy także nowy plik, w którym umieścimy typ wyliczeniowy:

namespace TemplateGame
{
    enum CurrentScene
    {
        MenuScene, GameScene
    };
}

3. Przenosimy wszystkie elementy związane z menu z pliku Game1.cs do MenuScene.cs:

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

//MenuScene.cs
namespace TemplateGame
{
    public partial class Game1 : Game
    {
        Texture2D PlayButtonTexture;
        //
        Rectangle recPlayButton;
        //
        Color PlayButtonColor = Color.White;

        private void LoadContentMenu()
        {
            //load texture
            PlayButtonTexture = Content.Load<Texture2D>("play");
        }
        private void DrawMenu()
        {
            spriteBatch.Begin();
            spriteBatch.Draw(PlayButtonTexture, recPlayButton, PlayButtonColor);
            spriteBatch.End();
        }
        private void UpdateMenu()
        {
            recPlayButton.X = GraphicsDevice.Viewport.Width / 2 - recPlayButton.Size.X / 2;
            recPlayButton.Y = GraphicsDevice.Viewport.Height / 2 - recPlayButton.Size.Y / 2;
            //
            recPlayButton.Height = GraphicsDevice.Viewport.Height / 6;
            recPlayButton.Width = GraphicsDevice.Viewport.Width / 3;
            //
            UpdateCursorPosition();
            ButtonsEvents();
        }


        MouseState mouseState;
        Rectangle Cursor;
        private void UpdateCursorPosition()
        {
            /* Pozycja kwaratu podąża za pozycją kursora */
            mouseState = Mouse.GetState();
            Cursor.X = mouseState.X; Cursor.Y = mouseState.Y;
        }
        private void ButtonsEvents()
        {
            if ((recPlayButton.Intersects(Cursor)))
            {
                PlayButtonColor = Color.Green;
                if (mouseState.LeftButton == ButtonState.Pressed)
                    PlayButtonColor = Color.Red;
            }
            else
                PlayButtonColor = Color.White;
        }

    }
}

4. Analogicznie modyfikujemy plik GameScene.cs:

using Microsoft.Xna.Framework;

//GameScene.cs
namespace TemplateGame
{
    public partial class Game1 : Game
    {
        private void LoadContentGame()
        {
            
        }
        private void DrawGame()
        {

        }
        private void UpdateGame()
        {

        }

    }
}

5. W pliku Game1.cs tworzymy nowy obiekt wyliczeniowy i za jego pomocą sterujemy tym, które metody Update() i Draw() będą wykonywane:

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace TemplateGame
{
    /// <summary>
    /// This is the main type for your game.
    /// </summary>
    public partial class Game1 : Game
    {
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        CurrentScene scene = CurrentScene.MenuScene;

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

        protected override void Initialize()
        {
            IsMouseVisible = true;
            Window.AllowUserResizing = true;

            base.Initialize();
        }

        

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);
            LoadContentMenu();
            LoadContentGame();
        }


        protected override void UnloadContent()
        {
            // TODO: Unload any non ContentManager content here
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
            //
            if (scene == CurrentScene.MenuScene)
                UpdateMenu();
            if (scene == CurrentScene.GameScene)
                UpdateGame();

            base.Update(gameTime);
        }


        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);
            if (scene == CurrentScene.MenuScene)
                DrawMenu();
            if (scene == CurrentScene.GameScene)
                DrawGame();

            base.Draw(gameTime);
        }
    }
}

6. W pliku MenuScene.cs modyfikujemy metodę ButtonEvents, tak aby po kliknięciu przycisku zmieniły się wykonywane metody UpdateMenu() na UpdateGame() oraz DrawMenu() na DrawGame():

private void ButtonsEvents()
        {
            if ((recPlayButton.Intersects(Cursor)))
            {
                PlayButtonColor = Color.Green;
                if (mouseState.LeftButton == ButtonState.Pressed)
                {
                    PlayButtonColor = Color.Red;
			//ta zmiana spowoduje wykonanie innych metod Draw() oraz Update()
                    scene = CurrentScene.GameScene;
                }
            }
            else
                PlayButtonColor = Color.White;
        }

Po tych operacjach po kliknięciu w przycisk ten powinien zniknąć. Dzieje się tak dlatego, że po jego kliknięciu od teraz wywoływane będą metody DrawGame() i UpdateGame():

//Game1.cs
 protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
            //
            if (scene == CurrentScene.MenuScene)
                UpdateMenu();
            if (scene == CurrentScene.GameScene)
                UpdateGame();

            base.Update(gameTime);
        }

MonoGame – obsługa klawiatury, poruszanie obiektami

Skoro mamy już menu, to wypadałoby wyświetlić coś po wciśnięciu przycisku „Graj”. Niech będzie to jakiś prosty kwadracik, którym można poruszać za pomocą strzałek na klawiaturze. Kod czegoś takiego może wyglądać np. tak:

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

//GameScene.cs
namespace TemplateGame
{
    public partial class Game1 : Game
    {
        Texture2D texPlayer;
        Rectangle recPlayer;
        private void LoadContentGame()
        {
            //ładowanie tekstury
            texPlayer = Content.Load<Texture2D>("play");
            //ustawienie wysokości i szerokości poruszanego obiektu
            recPlayer.Height = 10;
            recPlayer.Width = 10;
            //ustawienie początkowej pozycji XY obiektu
            recPlayer.X = 0;
            recPlayer.Y = 0;
        }
        private void DrawGame()
        {        
            spriteBatch.Begin();
            //rysowanie obiektu
            spriteBatch.Draw(texPlayer, recPlayer, Color.Black);
            spriteBatch.End();

        }
        private void UpdateGame()
        {
            //odczyt stanu klawiatury i w zależności od wciskanego przycisku poruszanie obiektem
            KeyboardState state = Keyboard.GetState();
            if (state.IsKeyDown(Keys.Right))
                recPlayer.X++;
            if (state.IsKeyDown(Keys.Left))
                recPlayer.X--;
            if (state.IsKeyDown(Keys.Down))
                recPlayer.Y++;
            if (state.IsKeyDown(Keys.Up))
                recPlayer.Y--;
        }

    }
}

Myślę, że kod jest na tyle prosty, że nie wymaga tłumaczenia. Właściwie przy odrobinie kreatywności przy pomocy powyższych instrukcji łatwo można stworzyć prostego Snake’a. Nie będę jednak już pokazywał w tym artykule, jak tego dokonać.

Oczywiście to co udało mi się tutaj przedstawić to same podstawy podstaw MonoGame i tworzenia gier w ogóle. Jeżeli ktoś chce wziąć się za samodzielne tworzenie gier, musi ogarnąć również rzeczy związanie z grafiką, dźwiękiem i… matematyką.


Zdjęcie główne artykułu pochodzi z unsplash.com.

Wraz z Tomaszem Gańskim jestem współtwórcą justjoin.it - największego job boardu dla polskiej branży IT. Portal daje tym samym największy wybór spośród branżowych stron na polskim rynku. Rozwijamy go organicznie, serdecznie zapraszam tam również i Ciebie :)

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/monogame-czyli-jak-stworzyc-gre-w-c/" order_type="social" width="100%" count_of_comments="8" ]