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

09.08.2010 - Mateusz Osowski
TrudnośćTrudność

Profil postaci

Teraz zajmiemy się klasą CharacterProfile. Klasa ta zawierać będzie informacje, które opisywać będą konkretny typ postaci. Służyć nam będzie jako swego rodzaju "konfiguracja" postaci, jako szablon, na podstawie którego tworzona będzie postać. W przyszłości można będzie ją uzupełnić o statystyki postaci znane z gier RPG. Z każdą postacią jednak związana będzie indywidualna kopia profilu. Może to być wykorzystane przez twórcę świata - raz stworzony profil postaci pozwoli szybko tworzyć wiele podobnych do siebie istot, w tym MOBów (Monster or Beast) a implementacja randomizacji w edytorze, czy też w samym silniku gry urozmaici tworzony w nim świat. Przejdźmy jednak do kodu:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using Mogre;
 
class CharacterProfile
{
    // Nazwa pliku siatki postaci
    public String MeshName;
    // Masa ciała potrzebna Newtonowi
    public float BodyMass;
    // Prędkość chodu
    public float WalkSpeed;
    // Współczynnik, przez który skalowana będzie siatka kolizyjna
    public Vector3 BodyScaleFactor;
    // Punkt, w którym znajduje się głowa postaci względem środka jej ciężkości
    public Vector3 HeadOffset;
CharacterProfile.cs


Współczynnik BodyScaleFactor wykorzystamy przy tworzeniu postaci. Określać on będzie skalowanie, jakie chcemy nadać prostopadłościanowi otaczającemu siatkę postaci (Bounding Box), by w miarę dobrze odwzorowywał jej kształt. Pamiętaj, że w postać wymodelowana w Blenderze ustawiona była w tzw. T-pose - ręce szeroko rozstawione, wobec czego prostopadłościan ją otaczający będzie szeroki. HeadOffset będzie przydatny przy implementacji klasy GameCamera.
Pozostało jedynie dodać metodę kopiującą. W platformie .NET to bardzo proste zadanie. Metoda MemberwiseClone wykona kopię wszystkich pól klasy. Musieliśmy opakować ją w metodę Clone, ponieważ nie może być wywoływana poza implementacją klasy.

1
2
3
4
5
    public CharacterProfile Clone()
    {
        return (CharacterProfile)MemberwiseClone();
    }
}
CharacterProfile.cs


Klasa postaci

Posiadamy już prawie wszystkie klasy potrzebne do stworzenia podstaw gry. Będziemy rozpatrywać dwa stany w jakich może znajdować się postać: spoczynek (Idle) i spacer (Walk):

1
2
3
4
5
6
7
class Character : GameObject
{
    public enum CharacterState
    {
        IDLE,
        WALK
    };
Character.cs


Potrzebne nam będą także węzeł, siatka, ciało, profil postaci, a także informacja o stanie postaci:

1
2
3
4
5
6
    public Entity Entity;
    public SceneNode Node;
    public Body Body;
 
    public CharacterProfile Profile;
    public CharacterState State;
Character.cs


Ponieważ postać jest specyficznym obiektem z punktu widzenia silnika fizycznego, bowiem znajduje się całkowicie pod kontrolą gracza lub sztucznej inteligencji. Powinno się również dać łatwo manipulować prędkością i zwrotem postaci. Ponieważ ruch ciała w Newtonie powinien być wywoływany poprzez aplikację odpowiednich sił, przechowywać będziemy informację o prędkości, jaką chcielibyśmy nadać postaci, a w odpowiedniej metodzie będziemy wyliczać i aplikować potrzebną do jej uzyskania siłę. Kontrola odbywać się będzie poprzez dwa pola:

1
2
    public Quaternion Orientation;
    public Vector3 Velocity;
Character.cs


Warto zauważyć, że Ogre do reprezentacji obrotów używa kwaternionów. Jest to bardzo wygodna ich reprezentacja. Silnik umożliwia obracanie wektora (wskazującego kierunek) przez kwaternion, a także obliczanie kwaterniona obrotu pomiędzy danymi wektorami.

Posiadamy już wszystkie potrzebne pola. Przystąpmy do pisania metod - zacznijmy od konstruktora, który za parametr przyjmować będzie profil postaci, na podstawie którego ma zostać ona stworzona.

1
2
3
    public Character(CharacterProfile profile)
    {
        Profile = profile.Clone();
Character.cs


Wyzerujmy orientację i stwórzmy obiekt na scenie:

1
2
3
4
5
        Orientation = Quaternion.IDENTITY;
 
        Entity = Engine.Singleton.SceneManager.CreateEntity(Profile.MeshName);
        Node = Engine.Singleton.SceneManager.RootSceneNode.CreateChildSceneNode();
        Node.AttachObject(Entity);
Character.cs


W silniku fizycznym postać będzie istnieć w postaci kapsuły o wymiarach ustalonych na podstawie przeskalowanego przez czynnik podany w profilu postaci rozmiaru otaczającego prostopadłościanu. Kapsułę charakteryzuje promień i wysokość. Jako wysokość posłuży nam po prostu wysokość prostopadłościanu, a jako promień - jego szerokość lub długość, w zależności od tego, co będzie krótsze.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
        // Bierzemy połowę rozmiaru BoundingBoxa i mnożymy go przez czynnik
        Vector3 scaledSize = Entity.BoundingBox.HalfSize * Profile.BodyScaleFactor;
 
        ConvexCollision collision = new MogreNewt.CollisionPrimitives.Capsule(
            Engine.Singleton.NewtonWorld,
            // Wybieramy szerokość bądź długość
            System.Math.Min(scaledSize.x, scaledSize.z), 
            // Z uwagi na to, że wzięliśmy połowę rozmiaru, 
            // musimy wysokość pomnożyć przez 2
            scaledSize.y * 2, 
            // Kapsuła stworzona przez Newtona będzie leżeć wzdłuż osi X, 
            // więc ustalamy pion wzdłóż osi Y
            Vector3.UNIT_X.GetRotationTo(Vector3.UNIT_Y), 
            Engine.Singleton.GetUniqueBodyId()); // Unikalny identyfikator ciała
Character.cs


Newton automatycznie policzy odpowiednią dla kształtu kolizji macierz inercji potrzebną do utworzenia dynamicznego ciała

1
2
3
4
 
        Vector3 inertia, offset;
        collision.CalculateInertialMatrix(out inertia, out offset);
        inertia *= Profile.BodyMass;
Character.cs


Stworzymy teraz ciało fizyczne i przypiszemy mu obliczoną bezwładność. Wyłączymy także możliwość "uśpienia" ciała przez silnik, ponieważ zawsze chcemy mieć możliwość oddziaływania siłami na postać.

1
2
3
4
        Body = new Body(Engine.Singleton.NewtonWorld, collision, true);
        Body.AttachNode(Node);
        Body.SetMassMatrix(Profile.BodyMass, inertia);            
        Body.AutoSleep = false; 
Character.cs


Nie chcemy, aby Newton wpływał na zwrot postaci - powodowałoby to obracanie się postaci podczas chodzenia przy ścianie, na skutek siły tarcia. Aby samemu kontrolować aktualizację węzła ciała wystarczy posłużyć się wywołaniem zwrotnym zdarzenia Transformed. Jego implementacją zajmiemy się za chwilę.

1
        Body.Transformed += BodyTransformCallback;
Character.cs


Podobnie, chcemy mieć możliwość sterowania ruchem postaci za pomocą sił. Architektura Newtona zaleca aplikowanie sił wewnątrz wywołania zwrotnego ForceCallback:

1
        Body.ForceCallback += BodyForceCallback;
Character.cs


Nie chcemy, żeby popchnięta postać przewracała się niczym butelka. Utrzymanie w pionie zagwarantuje UpVector, któremu wystarczy podać wektor pionu i ciało, które ma się go trzymać.

1
2
        Joint upVector = new MogreNewt.BasicJoints.UpVector(
            Engine.Singleton.NewtonWorld, Body, Vector3.UNIT_Y);     
Character.cs


Na koniec, możemy pozbyć się obiektu kolizji, który już spełnił swoje zadanie podczas tworzenia ciała:

1
2
        collision.Dispose();
    }
Character.cs


Zaimplementujmy teraz funkcję wywołania zwrotnego transformacji. Nie jest to nic trudnego - nadajemy węzłowi postaci pozycję obliczoną przez Newtona, lecz orientację zastępujemy własną ustaloną polem:

1
2
3
4
5
6
    void BodyTransformCallback(Body sender, Quaternion orientation, 
        Vector3 position, int threadIndex)
    {            
        Node.Position = position;
        Node.Orientation = Orientation;
    }
Character.cs


Teraz kolej na aplikację sił. Chcemy, aby w jednej klatce postać uzyskała prędkość daną w polu Velocity. W tym celu musimy zaaplikować siłę będącą różnicą aktualnej prędkości ciała i prędkości docelowej, pomnożoną przez masę postaci. Ponieważ siła działa na ciało jednostajnie, prędkość zostałaby osiągnięta dopiero po około sekundzie. Aby uzyskać natychmiastowy efekt musimy pomnożyć ją przez ilość klatek wyświetlanych przez sekundę, czyli 60. Ponadto nie chcemy wpływać na prędkość względem osi Y - przeciwdziałalibyśmy sile grawitacji - więc mnożymy ją przez wektor wyzerowany w osi Y.

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 * 60.0f;
        Body.AddForce(force);    
    }
Character.cs


Po utworzeniu postaci chcielibyśmy mieć możliwość ustalenia jej pozycji. Służy temu metoda SetPositionOrientation ciała, którą opakujemy w metodę postaci:

1
2
3
4
    public void SetPosition(Vector3 position)
    {
        Body.SetPositionOrientation(position, Orientation);
    }
Character.cs


Ostatnią metodą związaną z postacią będzie aktualizacja stanu. Ponieważ Character dziedziczy po GameObject, musimy ją przeciążyć. Chcemy ustawić postaci odpowiednią animację w zależności od stanu. Pobieramy więc referencje na animacje:

1
2
3
4
    public override void Update()
    {
        AnimationState idleAnimation = Entity.GetAnimationState("Idle");
        AnimationState walkAnimation = Entity.GetAnimationState("Walk");
Character.cs


Rozważmy stan spoczynku. Ustalamy żądaną prędkość na 0, wyłączamy animację chodu, włączamy zaś animację spoczynku i aktualizujemy ją o 1/90 sekundy. Prędkość odtwarzania animacji często dobiera się empirycznie, w tym przypadku jednak, mając na uwadze fakt, że w Blenderze animacja przebiegała w tempie 45 klatek na sekundę, chcąc dostosować ją do tempa 60 musimy przez sekundę odtworzyć 2/3 animacji, czyli 60/90.

1
2
3
4
5
6
7
8
9
        switch (State)
        {
        case CharacterState.IDLE:
            Velocity = Vector3.ZERO;
            walkAnimation.Enabled = false;
            idleAnimation.Enabled = true;
            idleAnimation.Loop = true;
            idleAnimation.AddTime(1.0f / 90.0f);
            break;
Character.cs


Rozważmy teraz stan spaceru. Chcemy zażądać osiągnięcia przez postać pewnej prędkości. Domyślnie postać obrócona jest wzdłuż osi Z. Obracamy ten kierunek zgodnie z orientacją postaci, a następnie mnożymy przez prędkość spaceru, tym samym uzyskując siłę zwróconą zgodnie z kierunkiem patrzenia postaci. Podobnie jak w poprzednim przypadku ustawiamy i aktualizujemy odpowiednią animację.

1
2
3
4
5
6
7
8
9
10
        case CharacterState.WALK:
            Velocity = Orientation * Vector3.UNIT_Z * Profile.WalkSpeed;
            idleAnimation.Enabled = false;
            walkAnimation.Enabled = true;
            walkAnimation.Loop = true;
            walkAnimation.AddTime(1.0f / 90.0f);
            break;
        }
    }
}
Character.cs


Tym samym skończyliśmy klasę Character. W konstruktorze jednak użyliśmy metody GetUniqueBodyId(). Metoda ta z każdym wywołaniem powinna zwracać inną liczbę służącą jako identyfikator obiektu Newtona. Zaimplementujmy ją w pliku Engine.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
sealed class Engine
{
    ... // Już istniejące pola
 
    int BodyId;
 
    ... // Już istniejące metody
 
    public int GetUniqueBodyId()
    {
        return BodyId++;
    }
}
Engine.cs


GameCamera

Ostatnią klasą, którą zaimplementujemy będzie klasa kontrolująca kamerę. W istocie, kamerę utworzyliśmy już w metodzie inicjującej silniki. Chcemy jednak, aby kamera zachowywała się jak kamera znana z gier RPG prowadzonych z perspektywy trzeciej osoby. Naszą kamerę określić możemy trzema cechami:

1
2
3
4
5
6
7
8
class GameCamera
{
    // Postać, do której przywiązana jest kamera
    public Character Character;
    // Odległość w jakiej kamera trzyma się od głowy postaci
    public float Distance;
    // Kąt, pod jakim kamera spogląda na głowę postaci
    public Degree Angle;  
GameCamera.cs


Jedyną metodą kamery będzie metoda Update(). Najpierw musimy wyliczyć wektor zwrócony w przeciwną do zwrotu postaci stronę na płaszczyźnie poziomej (X-Z) i podnieść go na osi Y na odpowiednią wysokość, by uzyskać żądany kąt patrzenia. Wiedząc, że odległość od głowy w poziomie wynosi 1, wystarczy skorzystać z funkcji trygonometrycznej tangens. Obliczony wektor może być bardzo długi, więc normalizujemy go (doprowadzamy do długości 1 zachowując przy tym zwrot). Posiadając wektor długości 1 mnożymy go przez odległość, w jakiej ma znajdować się kamera.

1
2
3
4
5
6
    public void Update()
    {
        Vector3 offset =
        Character.Node.Orientation * (-Vector3.UNIT_Z + 
            (Vector3.UNIT_Y * (float)System.Math.Tan(Angle.ValueRadians))
            ).NormalisedCopy * Distance;
GameCamera.cs


Otrzymany wektor przesunięcia względem głowy posłuży nam do obliczenia pożądanej bezwzględnej pozycji kamery:

1
2
        Vector3 head = Character.Node.Position + Character.Profile.HeadOffset;
        Vector3 desiredPosition = head + offset;
GameCamera.cs


Nie chcemy, aby kamera osiągnęła żądaną pozycję natychmiastowo - jej ruch byłby bardzo gwałtowny. Aby uzyskać płynny ruch wystarczy, że przybliżmy kamerę o 10% do żądanej pozycji na klatkę. Na koniec, chcemy, aby kamera patrzyła w punkt, w którym znajduje się głowa postaci.

1
2
3
4
5
        Engine.Singleton.Camera.Position += 
            (desiredPosition - Engine.Singleton.Camera.Position) * 0.1f;
        Engine.Singleton.Camera.LookAt(head);     
        }
}
GameCamera.cs


To już ostatnia nam potrzebna klasa. Teraz wystarczy zrobić prostą grę. Zanim do tego przejdziemy, dopisz do klasy Engine deklaracje obiektów utworzonych klas i zainicjuj je w metodzie inicjującej. Dodaj także aktualizację ich aktualizację w metodzie Update()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sealed class Engine
{
    ...
 
    public GameCamera GameCamera;
    public ObjectManager ObjectManager;
    ...
    public void Initialise()
    {
        ...
        GameCamera = new GameCamera();
        ObjectManager = new ObjectManager();
    }
 
    public void Update()
    {
        GameCamera.Update();
        ObjectManager.Update();
        ...
    }
}
Engine.cs


Prosta gra

Na początek, pobierz paczkę z zasobami wykorzystywanymi w przykładzie. Zawiera ona pliki wyeksportowane z Blendera do Ogre. Aby móc samemu eksportować własne modele, pobierz eksporter, który także znajduje się na liście linków. Użycie eksportera jest bardzo intuicyjne. Ważne jest, by podać mu ścieżkę do konwertera XML (zawartego w paczce z plikami Ogre). Eksportując postać pamiętaj, by zaznaczyć także szkielet i dodać animacje na listę. Nazwy grają rolę - za ich pomocą odnosisz się do animacji z poziomu Ogre.

Program

Po inicjacji silnika tworzymy poziom i ustawiamy jego siatki:

1
2
3
4
5
6
static void Main(string[] args)
{
    Engine.Singleton.Initialise();
    Engine.Singleton.CurrentLevel = new Level();
    Engine.Singleton.CurrentLevel.SetGraphicsMesh("Level.mesh");
    Engine.Singleton.CurrentLevel.SetCollisionMesh("LevelCol.mesh");
Program.cs


Tworzymy profil postaci:

1
2
3
4
5
6
    CharacterProfile profile = new CharacterProfile();
    profile.BodyMass = 70;
    profile.BodyScaleFactor = new Vector3(1.5f,1,1.5f);
    profile.HeadOffset = new Vector3(0, 0.8f, 0);
    profile.MeshName = "Man.mesh";
    profile.WalkSpeed = 2.5f;
Program.cs


Tworzymy postać na podstawie profilu i dodajemy ją do gry:

1
2
3
    Character player = new Character(profile);
    player.SetPosition(new Vector3(0, 2, 0));
    Engine.Singleton.ObjectManager.Add(player);
Program.cs


Konfigurujemy kamerę:

1
2
3
    Engine.Singleton.GameCamera.Character = player;
    Engine.Singleton.GameCamera.Distance = 4;
    Engine.Singleton.GameCamera.Angle = new Degree(20);
Program.cs


Dodajemy światło kierunkowe, świecące w dół pod lekkim kątem:

1
2
3
4
    Light light = Engine.Singleton.SceneManager.CreateLight();
    light.Type = Light.LightTypes.LT_DIRECTIONAL;
    light.Direction = new Vector3(1, -3, 1).NormalisedCopy;
    light.DiffuseColour = new ColourValue(0.2f, 0.2f, 0.2f);
Program.cs


Włączamy cienie Ogre. Wcześniej wyłączyliśmy rzucanie cieni dla geometrii planszy - właśnie z powodu cieni. Ponieważ gra toczy się w zamkniętych pomieszczeniach, światło kierunkowe nie jest najlepszym rozwiązaniem. Udoskonaleniem oświetlenia zajmiemy się później. Niestety, techniki oświetlenia stosowane w grafice czasu rzeczywistego nie są uniwersalne, każda z nich posiada słabe punkty.

1
2
    Engine.Singleton.SceneManager.ShadowTechnique = 
        ShadowTechnique.SHADOWTYPE_STENCIL_MODULATIVE;
Program.cs


Dodajemy pętlę główną. Postać po wciśnięciu lewej i prawej strzałki powinna się obracać o pewien kąt w lewo bądź prawo. Taki obrót łatwo uzyskać obliczając kwaternion obrotu pomiędzy wektorem (0, 0, 1) a (0.1, 0, 1). Jak widać ten drugi jest skierowany lekko w prawo względem pierwszego. Po wciśnięciu klawisza F3 chcemy, aby debugger Newtona narysował reprezentacje fizyczne obiektów, które znajdują się na scenie. Jest to bardzo przydatne narzędzie - pozwala szybko zidentyfikować przyczynę wielu podejrzanych zachowań ciał.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    while (true)
    {
        Engine.Singleton.Update();
 
        if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_ESCAPE))
            break;
 
        if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_LEFT))                          
            player.Orientation *= 
                Vector3.UNIT_Z.GetRotationTo(new Vector3(0.1f,0,1.0f).NormalisedCopy);
        if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_RIGHT))
            player.Orientation *= 
                Vector3.UNIT_Z.GetRotationTo(new Vector3(-0.1f,0,1.0f).NormalisedCopy);
        if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_UP))
            player.State = Character.CharacterState.WALK;
        else
            player.State = Character.CharacterState.IDLE;
 
        if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_F3))
            Engine.Singleton.NewtonDebugger.ShowDebugInformation();
        else
            Engine.Singleton.NewtonDebugger.HideDebugInformation();
 
    }
}
Program.cs


Prototyp gry jest już skończony. Możesz zwiedzić przykładową planszę. W ramach ćwiczeń możesz spróbować stworzyć własną planszę w Blenderze. Możesz także dorobić nowy typ obiektu, np. obiekt scenerii, który będzie reprezentować statyczne, nieinteraktywne elementy planszy, które nie stanowią części jej siatki.

Kod źródłowy projektu

W następnej części postaramy się dodać naszej postaci zmysły, a także stworzymy nowy typ obiektów gry, który będzie mógł być postrzegany przez postać. Obiekty takie stanowią przydatny element gry RPG - można nadać im opis, który zostanie wyświetlony graczowi, gdy ten do nich podejdzie i dostrzeże. Zastanowimy się także nad zdarzeniami zachodzącymi w świecie gry i tym, jak mogą być obsługiwane przez silnik gry.

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

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com