Wielu z nas myślało kiedyś o napisaniu własnej gry. Często jednak zapał mija podczas pracochłonnego pisania setek linijek kodu, aż w końcu wspaniały projekt umiera zapomniany. Korzystając z Microsoft XNA, możemy bardzo szybko stworzyć prostą grę i nie będzie to wymagało szczególnie dużego nakładu pracy. Jeśli tylko znasz podstawy języka C#, możesz jeszcze dziś zagrać w napisaną przez siebie grę!
Artykuł ten jest raczej luźnym wprowadzeniem do Microsoft XNA, prezentuje wybrane możliwości biblioteki bez wchodzenia szczegóły. Technologię zaprezentujemy "w akcji", a efekt końcowy będzie wyglądał tak:
Czym w ogóle jest Microsoft XNA? Są to narzędzia i biblioteki, które wspomagają tworzenie gier 2D i 3D pod Windows, Xboxa 360 i Zune, jak również środowisko uruchomieniowe, w którym będą działały nasze gry. Technologia ta jest dosyć nowa - z 2006 roku.
Oficjalne wsparcie ma jedynie programowanie z użyciem języka C#, co dla mnie jest całkowicie wystarczające i satysfakcjonujące, gdyż moim zdaniem jest on bardzo przyjazny programistom.
Abyśmy mogli przejść do ciekawszej części - pisania gry, koniecznie potrzebne będą nam trzy rzeczy:
Pozwolę sobie pominąć opis pobrania i instalacji.
Teraz, gdy mamy już wszystkie potrzebne narzędzia, jesteśmy o krok od rozpoczęcia tworzenia pierwszej gry. Zanim jednak nadejdzie ten moment, zobaczmy najpierw, jakie elementy możemy wyróżnić w większości typowych gier.
Na pewno działanie każdej gry zaczyna się od jakiegoś przygotowania i inicjalizacji urządzeń obsługujących grafikę, dźwięk czy wejścia (klawiatura, mysz). Później zwykle ładowane są szeroko pojęte zasoby - obrazki, dźwięki, itp. Następnie trafiamy do tak zwanej "pętli gry". Ta część kodu będzie wykonywana wielokrotnie, aż do zakończenia działania programu. Najpierw następuje w niej aktualizacja stanu gry, czyli np. pobieranie informacji z wejścia, albo wyliczanie nowych pozycji obiektów. Drugi krok to wyrysowanie kolejnej klatki na ekranie. Obie czynności powtarzane są tak długo, aż użytkownik postanowi zakończyć rozgrywkę. Wtedy zwalniane są wszystkie zasoby i następuja deinicjalizacja urządzeń, z których korzystano. |
Aby zobaczyć, jak taki schemat zrealizować w XNA, stwórzmy nowy projekt w Visual Studio. Wybieramy kolejno: File New Project.
Teraz w okienku, które powinno się pokazać, ustawiamy:
Project type: XNA Game Studio 3.1 (kolor fioletowy na powyższym obrazku)
Templates: Windows Game (3.1) (kolor niebieski)
Name: wpisujemy nazwę projektu (kolor zielony)
Klikamy "OK" i po chwili możemy uruchomić nowopowstałą grę (klawisz F5) i cieszyć swe oczy widokiem okienka z niebieskim tłem.
Nic szczególnie ciekawego się tutaj nie wydarzy, ale zauważmy, że nie musieliśmy się w ogóle męczyć przy tworzeniu okna, czy też z ustawianiem różnych straszliwie nazywających się parametrów, aby zacząć cokolwiek wyświetlać. Co składa się w chwili obecnej na nasz projekt? Poza nieistotnym na razie obiektem Content, domyślną ikonką i miniaturką w naszym projekcie znajdziemy jeszcze dwa pliki z kodem: Program.cs i Game1.cs. Przeanalizujmy je.
Program.cs:
Po tym akapicie możemy całkowicie zapomnieć o jego istnieniu. Tworzona jest w nim instancja klasy Game1 i następnie, poprzez wywołanie metody Run(), uruchamiana jest nasza gra. Nie będziemy w ogóle modyfikować tego pliku.
Game1.cs:
Znajdziemy tu klasę Game1, która jest najważniejszą rzeczą w tym projekcie i reprezentuje naszą grę. Plik jest obficie przyzdobiony komentarzami, które można z czystym sumieniem usunąć. Czas na analizę poszczególnych fragmentów programu.
Konstruktor:
1 2 3 4 5 | public Game1() { graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; } |
W trzeciej linijce odbywa się inicjalizacja urządzeń graficznych. To bardzo niewiele kodu, dodatkowo został on przecież automatycznie wyprodukowany przez Visual Studio. Druga linijka odpowiada za ustawienie domyślnego folderu do przechowywania zasobów.
Przy omawianiu reszty wygenerowanego kodu, szybko zauważymy, że jego struktura odpowiada schematowi gry przedstawionemu kilka akapitów wcześniej. Spójrzmy:
Metoda Initialize():
1 2 3 4 5 | protected override void Initialize() { base.Initialize(); } |
Nie będziemy potrzebowali niczego tutaj dopisywać, wystarcza nam funkcjonalność zapewniana przez klasę, z której dziedziczymy. Możemy zatem usunąć ten fragment kodu. Miejmy jednak świadomość, że ta metoda istnieje, przeprowadza potrzebne inicjalizacje i jest wywoływana po konstruktorze.
Metoda LoadContent():
1 2 3 4 5 | protected override void LoadContent() { spriteBatch = new SpriteBatch(GraphicsDevice); } |
Metoda LoadContent() odpowiada za załadowanie potrzebnych naszej grze zasobów. Już za chwilę skorzystamy z niej, by wczytać kilka obrazków. Tworzona jest tu również instancja tajemniczego obiektu SpriteBatch, o którym również napiszę wkrótce kilka słów.
  Metody Update() i Draw():
1 2 3 4 5 6 7 8 9 | protected override void Update(GameTime gameTime) { if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); base.Update(gameTime); } |
1 2 3 4 5 6 | protected override void Draw(GameTime gameTime) { GraphicsDevice.Clear(Color.CornflowerBlue); base.Draw(gameTime); } |
Po wywołaniu metody LoadContent(), gra automatycznie wchodzi do pętli gry. Co prawda nie jest to dla nas bezpośrednio widoczne, ale mamy możliwość wykonywania wszystkich istotnych czynności dzięki dwóm metodom: Update() i Draw(). Są bardzo ważne, więc poświęcimy im nieco więcej uwagi. Łatwo domyślić się, którym punktom ze schematu gry odpowiadają.
Domyślne ustawienia gwarantują, że metoda Update() będzie wywoływana dokładnie 60 razy w ciągu sekundy, a metoda Draw() tylko wtedy, gdy będziemy mieli wystarczającą ilość zasobów systemowych. Przypisując określone wartości właściwościom IsFixedTimeStep i TargetElapseTime z klasy Game1, jesteśmy w stanie zmienić wedle uznania domyślne ustawienia.
Kod z trzeciej i czwartej linijki metody Update() możemy usunąć, gdyż jest on potrzebny jedynie do tego, by umożliwić wyłączenie gry na Xboxie 360.
W metodzie Draw(), gdzie będziemy dokonywać całego rysowywania zawartości ekranu, jest na razie tylko jedna ciekawa linia kodu. To dzięki niej nasze okienko ma wspaniałe, niebieskie tło.
Ostatnią rzeczą, na którą pragnę zwrócić Twą uwagę, jest parametr, który przyjmują obie omawiane metody. W obiekcie gameTime przekazywanych jest nam kilka dość istotnych informacji. Umożliwia on nam na przykład sprawdzenie ile czasu mineło od ostatniego wywołania metody Update() - gameTime.ElapsedGameTime, albo czy nasza gra ma problem z wydajnością - gameTime.IsRunningSlowly.
Metoda UnloadContent():
1 2 3 4 | protected override void UnloadContent() { } |
Jak nietrudno odgadnąć, tutaj będziemy zwalniali zasoby załadowane w trakcie działania gry.
Skoro już wiemy jak działa kod wygenerowany przez Visual Studio, przystąpmy czym rychlej do pisania pierwszej gry!
Pobierz obrazki [4]
Abyśmy nie martwili niepotrzebnie się o to, skąd wziąć grafikę do naszej gry, przygotowałem wcześniej kilka obrazków. Pobierz je proszę, korzystając z zamieszczonego obok linka.
Do dodania tych plików do naszej gry, użyjemy teraz narzędzia o nazwie Content Pipeline. Jest to rzecz bardzo elegancka, gdyż dzięki niej omija nas cała nieprzyjemność związana z różnymi technikami potrzebnymi do wczytywania różnych typów plików. Z pomocą Content Pipeline zasób jest wstępnie przetwarzany i następnie zapisywany jako plik *.xnb (dodatkowo za pomocą kilku kliknięć jesteśmy w stanie zapewnić sobie jego automatyczną kompresję i dekompresję). Następnie, dzięki klasie ContentManager, w prosty sposób odczytujemy z tego przetworzonego pliku to, co chcemy. Zobaczmy Content Pipeline w akcji:
W Solution Explorer kliknij prawym przyciskiem myszy na Content i wybierz Add Existing Item... Pojawi się okienko wyboru plików. Zaznacz w nim wszystkie pobrane wcześniej obrazki i zatwierdź wybór. Content Pipeline zajmie się całą resztą, my natomiast załadujmy gotowe zasoby. Do przechowywania obrazków służą instancje klasy Texture2D, zadeklarujmy zatem w klasie Game1 następujące pola:
1 | private Texture2D background, player, ball; |
Jak już wiemy, do ładowania zasobów służy metoda LoadContent(). Dopiszmy w niej zatem trzy linijki:
1 2 3 4 5 6 7 8 | protected override void LoadContent() { spriteBatch = new SpriteBatch(GraphicsDevice); background = Content.Load<Texture2D>("background"); ball = Content.Load<Texture2D>("ball"); player = Content.Load<Texture2D>("player"); } |
I to już wszystko. Projekt się kompiluje, a zasoby są ładowane. Aby się o tym przekonać, narysujemy je teraz na ekranie. W tym celu skorzystamy z obiektu SpriteBatch, który pomaga nam przy rysowaniu grup obrazków - możemy np. określać w jaki sposób mają się na siebie nakładać, czy przenikać. Należy go "uruchomić" - służy do tego metoda Begin(). Potem dzięki metodzie Draw() rysujemy obrazki. Cały zabieg kończymy wywołaniem metody End(). Aby przechowywać informacje o aktualnej pozycji gracza i piłki potrzebne nam będą instancje klasy Vector2, zadeklarujmy zatem w Game1:
1 2 3 | private Vector2 playerPos = new Vector2(100, 100), ballPos = new Vector2(300,300); // inicjujemy przykładowymi wartościami |
Teraz już możemy przejść do rysowania. Usuńmy z metody Draw() linijkę czyszczącą ekran i napiszmy zamiast niej:
1 2 3 4 5 6 7 8 9 10 11 12 13 | protected override void Draw(GameTime gameTime) { spriteBatch.Begin(); spriteBatch.Draw(background,Vector2.Zero,Color.White); spriteBatch.Draw(ball,ballPos,Color.White); spriteBatch.Draw(player,playerPos,Color.White); spriteBatch.End(); base.Draw(gameTime); } |
Uruchom projekt, by przekonać się, że na ekranie pojawiły się nasze zasoby.
Dodamy trochę akcji do naszej gry - umożliwmy proste poruszanie graczem. W metodzie Update() sprawdzimy, czy został naciśnięty któryś z klawiszy W,S,A,D. Jeśli tak, to zmodyfikujemy odpowiednio obiekt playerPos. Za pomocą Keyboard.GetState() pobieramy obiekt opisujący stan klawiatury w danym momencie. Ma on metodę IsKeyDown(), z której skorzystamy by dowiedzieć się, czy interesujące nas klawisze są naciśnięte. Przepisz poniższy kod do metody Update():
1 2 3 4 5 6 7 8 9 10 11 | if(Keyboard.GetState().IsKeyDown(Keys.W)) playerPos += new Vector2( 0,-4); if(Keyboard.GetState().IsKeyDown(Keys.S)) playerPos += new Vector2( 0, 4); if(Keyboard.GetState().IsKeyDown(Keys.A)) playerPos += new Vector2(-4, 0); if(Keyboard.GetState().IsKeyDown(Keys.D)) playerPos += new Vector2( 4, 0); |
Gotowe! Uruchom grę i przetestuj, czy wszystko działa, jak powinno.
Na koniec uporządkujmy trochę nasz kod. Nieelegankim i niepraktycznym rozwiązaniem byłoby stworzenie całej gry za pomocą jednej tylko klasy Game1. Dodajmy dwie klasy do naszego projektu - będą one reprezentowały odpowiednio gracza i piłkę. Dzięki temu kod zyska na przejrzystości i łatwiej będzie nam go rozwijać. W Solution Explorer klikamy na nasz projekt prawym przyciskiem myszy i następnie: Add New Item...
Wybieramy:
Categories: XNA Game Studio 3.1 (różowy)
Templates: Game Component (zielony)
Name: Player.cs (niebieski)
Zatwierdzamy te ustawienia i w efekcie do naszego projektu dołącza nowy plik Player.cs. Zawiera on klasę Player, która dziedziczy po GameComponent. W związku z tym, że nasz gracz będzie rysowany na ekranie, zmieńmy jego nadklasę na DrawableGameComponent. Obiekty dziedziczące po GameComponent (DrawableGameComponent też zwyczajnie ją rozszerza), można dodawać do kolekcji Components obiektu Game, dzięki czemu ich metody (takie jak LoadContent() lub Update()) wywoływane będą w odpowiednich ku temu momentach. Podczas wywołania LoadContent(), Update(), Draw() i UnloadContent() klasy Game przeglądana jest cała kolekcja obiektów Components i na każdym z nich wywoływane są odpowiednie metody. Możemy zatem napisać własne wersje tych metod, dzięki czemu nasze klasy będą zachowywać się w pożądany przez nas sposób.
Wykorzystajmy tę wiedzę, aby przenieść cały kod dotyczący postaci gracza do klasy Player. Usuń wszystko, co dotyczy gracza z klasy Game1.
Teraz dodajmy pole w klasie Game1:
1 | private Player player; |
A w konstruktorze klasy Game1 dopiszmy:
1 2 | player = new Player(this); Components.Add(player); |
Abyśmy mogli skorzystać z obiektu spriteBatch również w innych klasach niż w Game1 umieścimy go w specjalnym "pojemniku" - GameServiceContainer. Dzięki temu będziemy mogli użyć go w każdym miejscu kodu, czyli w szczególności wtedy, gdy zapragniemy narysować na ekranie postać gracza. Przepisz poniższą linijkę do metody LoadContent() klasy Game1:
1 | Services.AddService(typeof(SpriteBatch),spriteBatch); |
Dodajmy do tej metody jeszcze odwołanie do metody LoadContent() w nadklasie. Całość powinna wyglądać tak:
1 2 3 4 5 6 7 8 9 10 | protected override void LoadContent() { spriteBatch = new SpriteBatch(GraphicsDevice); Services.AddService(typeof(SpriteBatch),spriteBatch); background = Content.Load<Texture2D>("background"); ball = Content.Load<Texture2D>("ball"); base.LoadContent(); } |
Ostatnią modyfikacją w klasie Game1 będzie usunięcie rysowania gracza i zamiana miejscami dwóch wierszy - wywołania spriteBatch.End() i base.Draw(). Dzięki temu rysowanie w obiektach z Components wykona się zanim wywołana zostanie metoda spriteBatch.End().
1 2 3 4 5 6 7 8 9 10 11 | protected override void Draw(GameTime gameTime) { spriteBatch.Begin(); spriteBatch.Draw(background,Vector2.Zero,Color.White); spriteBatch.Draw(ball,ballPos,Color.White); base.Draw(gameTime); spriteBatch.End(); } |
W klasie Player wpisujemy praktycznie to samo, co znajdowało się wcześniej w Game1. Powinniśmy otrzymać w efekcie coś takiego:
Pokaż/ukryj kod klasy Player1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | public class Player : Microsoft.Xna.Framework.DrawableGameComponent { private Texture2D texture; private Vector2 position = new Vector2(100,100); public Player(Game game) : base(game) { } protected override void LoadContent() { texture = Game.Content.Load<Texture2D>("player"); } public override void Update(GameTime gameTime) { if (Keyboard.GetState().IsKeyDown(Keys.W)) position += new Vector2(0, -4); if (Keyboard.GetState().IsKeyDown(Keys.S)) position += new Vector2(0, 4); if (Keyboard.GetState().IsKeyDown(Keys.A)) position += new Vector2(-4, 0); if (Keyboard.GetState().IsKeyDown(Keys.D)) position += new Vector2(4, 0); base.Update(gameTime); } public override void Draw(GameTime gameTime) { SpriteBatch spriteBatch = (SpriteBatch)Game.Services.GetService(typeof(SpriteBatch)); spriteBatch.Draw(texture, position, Color.White); } protected override void UnloadContent() { texture.Dispose(); } } |
Chyba jedynymi fragmentami wymagającymi komentarza są linijki , i oraz . Zatem:
W linijce odwołujemy się do Content należącego do klasy Game1. Dlatego też pojawia się tam Game.Content.Load() zamiast po prostu Content.Load() (bo to było dopuszczalne tylko wewnątrz klasy Game1).
Wiersze i to pobranie obiektu spriteBatch z "pojemnika", w którym go wcześniej umieściliśmy.
Dla większej elegancji, w linijce pozbywamy się wprost zasobu (w tym wypadku obrazka), który uprzednio załadowaliśmy. Dodaj analogiczny kod dla obiektów Texture2D w klasie Game1.
I to już wszystko, co należało uczynić, by przenieść całą logikę obsługi postaci gracza do osobnej klasy. Postępując analogicznie, stwórz klasę Ball i przenieś do niej wszystko, co odnosi się do piłki.
Pobierz kod [5], z którym kończymy ten artykuł (w tym klasę Ball).
Zapraszam do lektury drugiej części [6], gdzie dokończymy pisanie tej prostej gry.
Odnośniki:
[1] http://www.microsoft.com/express/vcsharp/
[2] http://www.microsoft.com/downloads/details.aspx?FamilyID=80782277-d584-42d2-8024-893fcd9d3e82&displaylang=en
[3] http://www.microsoft.com/downloads/details.aspx?familyid=2da43d38-db71-4c1b-bc6a-9b6652cd92a3&displaylang=en
[4] http://informatyka.wroc.pl/upload/xna/obrazki.zip
[5] http://informatyka.wroc.pl/upload/xna2/kod1.zip
[6] http://informatyka.wroc.pl/node/482