Tworzenie gry w C# z użyciem silnika Ogre - cz.2

10.09.2010 - Mateusz Osowski
TrudnośćTrudność

[Część 1] [Część 2] [Część 3] [Część 4] [Część 5] [Część 6]
[Część 7]

W poprzedniej części cyklu osadziliśmy stworzoną w Blenderze postać w świecie gry i daliśmy jej możliwość chodzenia. Kluczowym elementem silnika gry, zwłaszcza RPG jest sterowanie zdarzeniami, które zachodzą w świecie gry. W tym artykule poznasz jeden ze sposobów zarządzania nimi i uzyskasz dostęp do listy obiektów widzianych przez postać.

Uniezależnienie prędkości gry od FPS

Zanim przejdziemy do implementowania zdarzeń, zwróćmy uwagę na niedoskonałości poprzedniej wersji silnika. Jedną z nich jest zależność prędkości gry od ilości wyświetlanych na sekundę klatek . Użyliśmy synchronizacji pionowej, aby ograniczyć prędkość renderowania klatek do 60. Nie jest to najlepsze rozwiązanie - bowiem zakładamy, że częstotliwość odświeżania ekranu gracza jest równa 60Hz. W przypadku wyższej częstotliwości gra potoczy się szybciej. W przypadku słabszego sprzętu, gdy nie będzie on w stanie wyświetlić 60 klatek tempo gry odpowiednio zmaleje. Istnieje pewne proste i często stosowane rozwiązanie tego problemu. Polega ono na skalowaniu wszelkiego ruchu przez zmienny współczynnik bazujący na aktualnym FPS. Jest ono poprawne, lecz w przypadku gęstych, dużych zmian FPS Newton nie będzie w stanie poprawnie przeprowadzić poprawnej symulacji, w efekcie czego, przykładowo, ciała mogą przypadkowo wybijać się w powietrze.


Zastosujemy kompromisowe rozwiązanie pozwalające uniknąć błędów numerycznych, polegające na kontroli częstotliwości aktualizowania stanu gry. Dostosujemy fizykę i logikę gry do stałego, z góry znanego FPS i będziemy dbać o to, by ilość wywołań funkcji aktualizacyjnych była adekwatna do czasu, który upłynął. Inaczej mówiąc, oddzielimy klatki graficzne od klatek logiki i fizyki. Przejdź do klasy Engine i dopisz następujące pola.

1
2
3
4
5
  public const float FixedFPS = 60;
  public const float FixedTimeStep = 1.0f / FixedFPS;
  public float TimeStep; 
  float TimeAccumulator;          
  long LastTime;  
Engine.cs


Pole FixedTimeStep określać będzie długość stałego kroku czasu, odwrotność FPS do jakiego dostosowane są parametry gry. TimeStep przechowywać będzie czas trwania ostatniej klatki. Pole TimeAccumulator służyć będzie do określenia liczby potrzebnych aktualizacji. LastTime to czas zarejestrowany w momencie rozpoczęcia renderowania poprzedniej klatki.

Idea rozwiązania opiera się na założeniu, że czas przetwarzania logiki i fizyki jest o wiele krótszy od czasu renderowania grafiki. Sumując czas renderowania kolejnych klatek, w końcu uzbieramy go tyle, ile wynosi krok czasowy do którego przystosowaliśmy grę. W przypadku, gdy podczas jednej klatki upłynie go więcej niż ustalona długość kroku, będziemy mogli wykonać kilka kroków mechaniki gry.

Gruntowne zmiany zachodzą w metodzie Update() klasy Engine:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  public void Update()
  {
    long currentTime = Root.Timer.Milliseconds;
    TimeStep = (currentTime - LastTime)/1000.0f;
    LastTime = currentTime;
    TimeAccumulator += TimeStep;
    TimeAccumulator = System.Math.Min(TimeAccumulator, FixedTimeStep*(FixedFPS/15));
        
    Keyboard.Capture();
    Mouse.Capture();                
    Root.RenderOneFrame();            
        
    while (TimeAccumulator >= FixedTimeStep)
    {
      TimeAccumulator -= FixedTimeStep;  
      NewtonWorld.Update(FixedTimeStep);
      GameCamera.Update();
      ObjectManager.Update();                   
    }            
    
    WindowEventUtilities.MessagePump();            
  }
Engine.cs


W linijce 3 pobieramy aktualny czas mierzony w milisekundach i dzielimy go przez 1000 by uzyskać czas w sekundach. Następnie obliczamy długość klatki aktualizując wartość LastTime, po czym dodajemy zmierzony czas do akumulatora. Przenieśliśmy aktualizację silnika fizycznego i świata do pętli. Zauważ, że ilość wykonanych kroków symulacji będzie adekwatna do czasu, który upłynął. W przypadku, gdy FPS jest wysoki, np. 240, będą musiały zostać wyrenderowane cztery klatki, aby mogł zostać wykonany jeden krok symulacji. Gdy natomiast FPS będzie niski, np. 30, po wyrenderowaniu jednej klatki nastąpią dwa kroki symulacji. W tym rozwiązaniu zakładamy, że czas wykonywania kroków silnika fizycznego jest niewielki. Mogą jednak nastąpić sytuacje, w których zostanie zakumulowany duży okres czasu, np. wskutek zamrożenia aplikacji. Wówczas nadrabianie wielu kroków symulacji spowoduje zauważalne opóźnienie. Aby temu zapobiec, w 7 linijce ograniczamy zakumulowany czas do czterech kroków (60/15). Dzięki temu, nawet gdy użytkownik lub system zamrozi aplikację eliminujemy ryzyko dodatkowego jej opóźnienia.

Pozbądźmy się powiązań z konretną wartością FPS w metodzie BodyForceCallback() klasy Character:

1
2
3
4
5
6
  public void BodyForceCallback(Body body, float timeStep, int threadIndex)
  {
    Vector3 force = (Velocity - Body.Velocity * new Vector3(1, 0, 1))
      * Profile.BodyMass * Engine.FixedFPS;
      Body.AddForce(force);
  }
Character.cs


Musimy także dostosować do zmian obrót postaci w pętli głównej, w pliku Program.cs:
1
2
3
4
5
6
7
    ...
    while (true)
    {
      Quaternion rotation = new Quaternion();      
      rotation.FromAngleAxis(new Degree(120 * Engine.Singleton.TimeStep), Vector3.UNIT_Y);
      Engine.Singleton.Update();
      ...
Program.cs


W przypadku obrotu musimy dokonać skalowania, ponieważ wykonujemy je niezależnie od aktualizacji fizyki i mechaniki. W przyszłości dobrze będzie wprowadzić klasę kontrolera postaci przystosowaną do stałego FPS aktualizowaną wraz z silnikiem fizycznym i mechaniką gry, odpowiadającą za sterowanie dowolną postacią.


Od tej pory aplikacja powinna zachowywać się prawidłowo po wyłączeniu synchronizacji pionowej. Możesz już badać wydajność aplikacji monitorując FPS za pomocą narzędzi takich jak Fraps bez dokonywania zmian w kodzie.

Aktualny kod źródłowy



O zdarzeniach słów parę

Zapewne zdarzyło Ci się grając w grę, zastanawiać nad tym, jak to jest, że elementy świata ze sobą współgrają i są od siebie zależne. W zależności od rodzaju gry, jest to różnie rozwiązane. Jedną z ciekawszych koncepcji stanowi rozwiązanie sterowane zdarzeniami. Jak to jest zrobione? Jako przykładem, posłużymy się prostą grę platformową, np. Metal Slug znaną z automatów do gier. Akcja gry przebiega liniowo - bohater porusza się w kierunku jednej z krawędzi ekranu niszcząc po drodze swoich wrogów za pomocą różnorakiej broni. Przykładowym i oczywistym zdarzeniem występującym w tej grze jest pojawienie się przeciwnika. Z pojęciem zdarzenia wiąże się naturalne pojęcie wyzwalacza - obiektu, który wyzwala, tj. tworzy nowe zdarzenia. W grze Metal Slug przeciwnicy pojawiają się, gdy bohater posunie się odpowiednio daleko do przodu, lub też, w przypadku gdy przeciwnicy atakują oddziałami, po pokonaniu ostatniego z reprezentantów oddziału. Te sytuacje są zdarzeniami i mogą być jednocześnie wyzwalaczami kolejnych zdarzeń, tworząc tym samym łańcuchy. Zdarzenia w szczególności mogą modyfikować stan gry. Przykładowo, w Metal Slug pokonanie piątego oddziału wrogów w pewnym miejscu może wyzwolić zdarzenie, które informuje grę o czystości strefy, co pozwoli graczowi posunąć się dalej. Jak widać, koncepcja nie jest skomplikowana, a dostarcza nam wiele możliwości. Zdarzenia pozwalają sterować mechaniką gry i modelować konkretny świat, w którym toczy się rozgrywka. Definicje zdarzeń gry w przypadku gier pisanych w językach takich jak C++ zazwyczaj umieszcza się w skryptach, które są wykonywane poprzez silnik skryptowy, np. Lua. Ma to na celu między innymi oddzielenie rzadko zmienianej części gry - jej silnika - od opisu jej świata, który często ulega poprawkom, co pozwala na oszczędzenie czasu związanego z kompilacją projektu. Ponadto język skryptowy jest nierzadko istotnie prostszy od C++, tak więc znika potrzeba zatrudniania dodatkowych programistów C++. W związku z tym, że nasz silnik gry jest pisany w języku C# czas kompilacji jest bardzo krótki, a sam kod nieskomplikowany. Nie istnieje więc potrzeba użycia zewnętrznego silnika skryptowego. Ponadto kod zarządzany oferuje nam znacznie wyższą wydajność od silników języków takich jak Lua.



Możesz przyjrzeć się procesowi tworzenia gry platformowej śledząc ciąg artykułów:
Tworzenie dwuwymiarowej gry platformowej



Wyzwalacz obszarowy

W niemalże każdej grze warto mieć możliwość wywoływania jakiejś akcji, w momencie gdy postać znajdzie się w pewnym obszarze. W tym celu zaimplementujemy w silniku gry wyzwalacz obszarowy. Czego oczekujemy od takiego wyzwalacza?


  • Możliwości konstrukcji obszarów o dowolnym kształcie
  • Odnotowywania kolizji obiektów gry z obszarem
  • Informowania gry o wejściu bądź wyjściu obiektu gry z obszaru za pomocą mechanizmu zdarzeń


Na początku zajmiemy się pierwszymi dwoma punktami. Ponieważ chcielibyśmy mieć możliwość badania kolizji, najlepiej będzie, jeśli skorzystamy z funkcjonalności dostarczanej przez silnik fizyczny. W poprzednim artykule tworzyliśmy ciała fizyczne nadając im określoną przez obiekt klasy Collision geometrię. Podobnie postąpimy i tym razem. Newton oferuje możliwość wygenerowania geometrii wielu różnych prostych kształtów (prymitywów), takich jak prostopadłościany, sfery, walce i kapsuły. Pozwala nam także wygenerować obiekt kolizji z siatki wczytanej przez Ogre. Za pomocą prymitywów nie uzyskamy jednak dowolnego obszaru, zaś użycie siatek zmuszałoby nas do modelowania każdego obszaru w Blenderze. Zastosujemy więc ogólniejsze rozwiązanie - damy naszemu obiektowi możliwość utworzenia obiektu kolizji za pomocą połączenia kilku prymitywów. Dzięki temu w przyszłości będzie można utworzyć edytor map, w którym obszary będzie się wygodnie komponowało za pomocą myszki.

Utwórz nowy plik o nazwie TriggerVolume.cs i zaimportuj do niego przestrzenie nazw Mogre i MogreNewt. Klasa TriggerVolume powinna dziedziczyć z utworzonej w poprzedniej części artykułu klasy GameObject.
1
2
class TriggerVolume : GameObject
{
TriggerVolume.cs


Potrzebować będziemy ciała i listy obiektów kolizji z których zostanie utworzony ostateczny kształt:
1
2
  Body Body;
  List<Collision> CompoundParts;
TriggerVolume.cs


Utworzymy dwie metody: BeginShapeBuild() i EndShapeBuild(). Pomiędzy ich wywołaniami będzie można za pomocą specjalnych metod dokładać nowe prymitywy do wyzwalacza.
1
2
3
4
  public void BeginShapeBuild()
  {
    CompoundParts = new List<Collision>();
  }
TriggerVolume.cs


Metoda nie jest skomplikowna, po prostu tworzymy obiekt listy.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  public void EndShapeBuild()
  {
    Collision collision = new MogreNewt.CollisionPrimitives.CompoundCollision(
      Engine.Singleton.NewtonWorld,
      CompoundParts.ToArray(),
      Engine.Singleton.GetUniqueBodyId());            
 
    Body = new Body(Engine.Singleton.NewtonWorld, collision, true);            
    Body.SetMassMatrix(0, Vector3.ZERO);
    Body.UserData = this;
 
    // Usuwamy z pamięci już użyte części geometrii
    foreach (ConvexCollision c in CompoundParts)
      c.Dispose();
    CompoundParts = null;
  }
TriggerVolume.cs


W 3 linijce budujemy nowy obiekt kolizji na podstawie obiektów z listy części. Ponieważ konstruktor wymaga tablicy obiektów typu Collision, konwertujemy naszą listę do postaci tablicy. Następnie, w linijce 7 tworzymy ciało o zerowej masie i bezwładności. Zerowa masa nakazuje silnikowi traktować ciało jako obiekt statyczny, zatem nie będzie on podlegać działaniu sił i grawitacji. Korzystaliśmy już z tego tworząc ciało planszy w klasie Level. Zauważmy, że nie wiążemy z ciałem żadnego obiektu typu Node, obszar bowiem nie będzie posiadał reprezentacji graficznej. Mimo to, będziemy mogli go zobaczyć przy pomocy debuggera Newtona. W linijce 9 wiążemy z ciałem obiekt typu TriggerVolume. Pole UserData jest bardzo przydatne, ponieważ pozwala dostać się do obiektu korzystającego z ciała poprzez referencję.

Dodamy jeszcze metodę dodającą prostopadłościan do listy kształtów:
1
2
3
4
5
6
7
8
9
10
  public void AddBoxPart(Vector3 offset, Quaternion orientation, Vector3 size)
  {            
    ConvexCollision collision = new MogreNewt.CollisionPrimitives.Box(
      Engine.Singleton.NewtonWorld,
      size,
      orientation,
      offset,
      Engine.Singleton.GetUniqueBodyId());                    
    CompoundParts.Add(collision);
  }
TriggerVolume.cs


Pierwszy i drugi parametr określają odpowiednio przesunięcie i obrót prostopadłościanu względem środka ciała. Trzeci parametr to rozmiar. Konstruktor obiektu kolizji wygląda podobnie do konstruktora kapsuły, który wykorzystaliśmy tworząc obiekt kolizji postaci.

Ciało utworzone w metodzie EndShapeBuild() będzie się znajdowało w pozycji (0,0,0), zatem potrzebujemy metody przestawiającej ciało w dowolne miejsce:
1
2
3
4
  public void SetPosition(Vector3 position)
  {
    Body.SetPositionOrientation(position, Body.Orientation);
  }
TriggerVolume.cs


W związku z tym, że klasa TriggerVolume dziedziczy z GameObject, potrzebujemy przeciążyć metodę Update(). Pozostawimy ją w tym momencie pustą.
1
2
3
4
  public override void Update()
  {  
  }
}
TriggerVolume.cs


Możemy przetestować działanie naszego obiektu dodając jeden do świata gry. Przejdź do klasy Program i utwórz obiekt TriggerVolume. Pamiętaj jednak, aby zrobić to po inicjacji silnika:
1
2
3
4
5
6
7
8
  ...  
  TriggerVolume triggerVolume = new TriggerVolume();
  triggerVolume.BeginShapeBuild();
  triggerVolume.AddBoxPart(Vector3.ZERO, Quaternion.IDENTITY, new Vector3(2, 2, 2));
  triggerVolume.EndShapeBuild();
  Engine.Singleton.ObjectManager.Add(triggerVolume);  
  triggerVolume.SetPosition(new Vector3(4, 1, 0));
  ...
Program.cs, Main()


Obiekt triggerVolume posiadać będzie jedną część - prostopadłośćian o wymiarach 2mx2mx2m. Ustawiamy go w pozycji (4,1,0), w miejscu schodów. Możesz uruchomić aplikację i zobaczyć efekty.

Aktualny kod źródłowy

5
Twoja ocena: Brak Ocena: 5 (4 ocen)

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com