Stworzenie trójwymiarowej gry komputerowej RPG było Twoim marzeniem? W poprzednich artykułach (pierwszy [9], drugi [10])mieliśmy okazję do nauki modelowania i animacji postaci. Teraz dowiesz się jak wykorzystać taką postać w prostej grze widzianej z perspektywy trzeciej osoby. Zobaczysz, że to wcale nie musi być trudne!
Pracę nad grą rozpoczniemy od utworzenia projektu Visual C#. W tym celu:
Musimy wskazać środowisku z jakich bibliotek będziemy korzystać. Jeżeli jeszcze tego nie zrobiłeś - pobierz przygotowany pakiet Mogre + Newton z linku powyżej. Z menu kontekstowego References wybierz Add Reference....
W zakładce Browse przenawiguj do pobranego pakietu i wskaż pliki Mogre.dll, MogreNewt.dll i MOIS.dll.
Domyślnie program uruchamiany spod Visual C# "widzi" pliki widziane w folderze głównym projektu. Wklejanie do niego zasobów w postaci tekstur, plików modeli i dźwięków powodowałoby bałagan, dlatego najlepiej jest sobie wydzielić osobny folder, w którym gra ma pracować. Utwórz taki folder i przejdź do ustawień projektu.
W zakładce Debug ustaw w polu Working directory ścieżkę do utworzonego folderu. Przestaw konfigurację projektu z Debug na Release i również ustaw ścieżkę katalogu roboczego.
Do katalogu roboczego wklej biblioteki uruchomieniowe Ogre (link dostępny powyżej). Utwórz też w nim podkatalog Media, w którym przechowywać będziemy wszystkie potrzebne zasoby. Najprostszym sposobem na wskazanie silnikowi tego podkatalogu jest użycie skryptu konfiguracyjnego, który wczytamy w kodzie gry za pomocą funkcji silnika. W tym celu utwórz w katalogu roboczym plik Resources.cfg o treści:
1 2 | [General] FileSystem=Media |
Silnik Ogre posiada funkcje wczytujące pliki w takim formacie - wykorzystamy to za chwilę. Utwórz również plik Plugins.cfg, który silnik wczyta automatycznie:
1 2 | PluginFolder=. Plugin=RenderSystem_Direct3D9 |
Jak widać, jedynym potrzebnym nam pluginem będzie system renderowania Direct3D. Możliwe jest również wykorzystanie OpenGL - z poziomu Ogre nie ma to dla nas żadnego znaczenia - oferuje on nam warstwę abstrakcji oddzielającą nas od szczegółowej implementacji. Nazwa pluginu OpenGL brzmi:
1 | RenderSystem_GL |
Engine
będzie zapewniała interakcję pomiędzy klasą planszy (Level
), menadżera obiektów (ObjectManager
), kamery (GameCamera
) i silnikami graficznym i fizycznym. Engine
będzie agregatem [20] obiektów wymienionych klas.
Level
będzie przechowywać informacje o planszy.
ObjectManager
będzie agregatem obiektów typu GameObject
- będzie posiadać listę wszystkich obiektów świata gry.
GameObject
będzie abstrakcyjną klasą, nie reprezentującą typu konkretnych obiektów gry.
Character
dziedziczy z GameObject
i będzie reprezentować konkretny typ obiektów gry - postacie.
CharacterProfile
będzie przechowywać informacje o postaci, takie jak jej szybkość, siatka, a w przyszłości statystyki.
GameCamera
będzie odpowiadać za utrzymywanie kamery za plecami postaci.
GameObject
w przyszłości korzystając z dziedziczenia w łatwy sposób będzie można rozbudować projekt o inne typy obiektów gry.
Zacząć musimy od klasy Engine
. Pierwszą metodą, którą napiszemy będzie najmniej przyjemna inicjacja silników Ogre i Newton. Dalej będzie już tylko z górki. Na początek, utwórz nową klasę. W tym celu dodaj nowy obiekt do projektu:
Typem nowego elementu będzie klasa, a nazwa pliku to Engine.cs.
Silnik gry jako taki powinien istnieć tylko jeden na grę. Do ograniczania ilości instancji danej klasy służy wzorzec projektowy singleton [21]. Zaimplementujmy go więc, zanim przejdziemy do pisania właściwych metod. Co powinien singleton?
Zacznijmy tworzyć zawartość pliku Engine.cs. Aby zapobiec możliwości dziedziczenia po klasie Engine
, wystarczy dopisać do niej modyfikator sealed
. Klasa jest już "zapieczętowana".
1 2 | sealed class Engine { |
Aby zapobiec ręcznemu tworzeniu więcej niż jednego obiektu, wystarczy konstruktor uczynić prywatnym - wtedy można go wywołać jedynie wewnątrz samej klasy.
1 2 3 | Engine() { } |
Istnieć powinna jedna instancja, tworzona tylko raz w trakcie działania aplikacji. Pomocny jest statyczny konstruktor - wywołać się może on tylko raz w trakcie działania programu (w trakcie istnienia AppDomain [22] z nim związanym). Wywoływany jest w momencie pierwszego wywołania statycznej metody klasy. W tym właśnie statycznym konstruktorze utworzymy sobie jedyną instancję singletonu.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // nasza instancja - prywatna, dostępna jedynie poprzez akcesor (metodę zwracającą ją) static Engine instance; static Engine() { instance = new Engine(); } // Taki akcesor jest statyczną metodą, która wywołana po raz pierwszy // zainicjuje statyczny konstruktor public static Engine Singleton { get { return instance; } } } |
Przejdźmy do inicjalizacji silników. Wróć na początek pliku. Najpierw w sekcji using
dopisz przestrzenie nazw bibliotek Mogre i MogreNewt:
1 2 | using Mogre; using MogreNewt; |
Następnie przejdź do klasy Engine i dodaj do niej pola:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // Główny obiekt silnika Ogre public Root Root; // Obiekt okna, do którego Ogre będzie renderować grafikę public RenderWindow RenderWindow; // Menadżer sceny Ogre - przechowuje informacje o obiektach w postaci drzewa public SceneManager SceneManager; // Kamera Ogra - nie myl jej z klasą GameCamera public Camera Camera; // Prostokąt w którym będzie wyświetlany obraz z kamery public Viewport Viewport; // Obiekt klawiatury public MOIS.Keyboard Keyboard; // Obiekt myszy public MOIS.Mouse Mouse; // Menadżer urządzeń wejścia public MOIS.InputManager InputManager; // Główny obiekt silnika Newton public World NewtonWorld; // Debugger Newtona - rysuje siatki kolizji public Debugger NewtonDebugger; |
Jak widzisz, w C# nazwy instancji mogą być takie same jak nazwy klas. Kiedy nazwa jest naturalna - nie ma sensu na siłę wymyślać innej.
Przejdźmy do metody inicjującej silniki. W C# przyjęło się tworzyć metody pod wszystkimi polami. Pamiętaj o tym dopisując nowe pola do klasy. Najpierw, stwórzmy główny obiekt Ogre - Root. Obiekt klasy ConfigFile wczyta zawartość pliku Resources.cfg. Drugim parametrem jest lista znaków speratorów, które mogą oddzielać nazwę typu zasobu od ścieżki do niego.
1 2 3 4 5 | public void Initialise() { Root = new Root(); ConfigFile cf = new ConfigFile(); cf.Load("Resources.cfg", "\t:=", true); |
Na stępnie musimy przekazać informacje wczytane z pliku do silnika. Robimy to iterując po sekcjach pliku, a następnie po parach typ-ścieżka w tych sekcjach. Silnik informowany jest o katalogach poprzez ResourceGroupManager
. Typem wpisu może być Filesystem - zwyczajny folder dyskowy, lub Archive - spakowany plik .zip:
1 2 3 4 5 6 7 8 9 10 11 12 | ConfigFile.SectionIterator seci = cf.GetSectionIterator(); // Iterujemy po sekcjach while (seci.MoveNext()) { ConfigFile.SettingsMultiMap settings = seci.Current; // Iterujemy po parach typ - ścieżka foreach (KeyValuePair<string, string> pair in settings) // Dodajemy lokacje zasobów do silnika ResourceGroupManager.Singleton.AddResourceLocation( pair.Value, pair.Key, seci.CurrentKey); } |
Teraz możemy przejść do wczytania konfiguracji wyświetlania silnika. Domyślnie wyświetlane będzie okienko konfiguracyjne, w którym użytkownik może wybrać renderer i jego ustawienia. Ogre zapamiętuje te ustawienia w pliku ogre.cfg, dzięki czemu przy następnym uruchomieniu użytkownik nie będzie o nie pytany.
1 2 3 4 5 | // Próbujemy przywrócić zasoby z pliku ogre.cfg if (!Root.RestoreConfig()) // Jeżeli się to nie powiodło, prosimy użytkownika o wybranie ustawień if (!Root.ShowConfigDialog()) return; |
Po skonfigurowaniu silnika tworzymy obiekt okna i inicjujemy zasoby. Parametr funkcji Initialise mówi, czy silnik ma automatycznie utworzyć okno, czy też chcemy zając się tym sami. Druga linijka nakazuje menadżerowi grup zasobów analizę zawartości podanych wcześniej ścieżek. Silnik zapamięta informacje o teksturach, modelach (lecz je same załaduje dopiero gdy będą potrzebne), sparsuje skrypty i skompiluje shadery, jeżeli na takowe natrafi w podanych katalogach.
1 2 | RenderWindow = Root.Initialise(true); ResourceGroupManager.Singleton.InitialiseAllResourceGroups(); |
Kolejno, potrzebujemy otworzyć menadżera sceny o przeznaczeniu ogólnym oraz kamerę. Przydzielamy kamerę do prostokąta pokrywającego domyślnie całe okno. Możesz utworzyć większą ilość Viewport - tak uzyskuje się efekt split screen. Dodatkowo określamy przedział, w którym znajdujące się modele kamera będzie widzieć.
1 2 3 4 5 6 7 | SceneManager = Root.CreateSceneManager(SceneType.ST_GENERIC); Camera = SceneManager.CreateCamera("MainCamera"); // Wydzielamy na oknie obszar, na który trafi obraz z kamery Viewport = RenderWindow.AddViewport(Camera); Camera.NearClipDistance = 0.1f; Camera.FarClipDistance = 1000.0f; |
Kolejne cztery linijki mają charakter czysto techniczny. Musimy bowiem przygotować listę parametrów potrzebnych do stworzenia menadżera urządzeń wejścia, która de facto składa się tylko z uchwytu do okna utworzonego przed chwilą.
1 2 3 4 | MOIS.ParamList pl = new MOIS.ParamList(); IntPtr windowHandle; RenderWindow.GetCustomAttribute("WINDOW", out windowHandle); pl.Insert("WINDOW", windowHandle.ToString()); |
Tworzymy wspomniany menadżer, po czym przystępujemy do zainicjowania obiektów klawiatury i myszki. Parametry false oznaczają, że wejście nie będzie buforowane. Nie będziemy bowiem używać wywołań zwrotnych [23] dla zdarzeń wejścia.
1 2 3 4 5 6 | InputManager = MOIS.InputManager.CreateInputSystem(pl); Keyboard = (MOIS.Keyboard)InputManager.CreateInputObject( MOIS.Type.OISKeyboard, false); Mouse = (MOIS.Mouse)InputManager.CreateInputObject( MOIS.Type.OISMouse, false); |
Ostatnią rzeczą, która pozostała nam do stworzenia jest świat Newtonowski. Tworzy się go bardzo prosto. Tworzymy także NewtonDebugger.
1 2 3 4 | NewtonWorld = new World(); NewtonDebugger = new Debugger(NewtonWorld); NewtonDebugger.Init(SceneManager); } |
Napiszmy metodę Update
, która będzie wywoływana co klatkę. Ponieważ zgodnie z projektem klasa Engine
zarządza obiektami silników i menadżerów - warto skupić ich aktualizację w jednym miejscu. Musimy zaktualizować urządzenia wejścia, świat Newtona i renderer Ogre. Aby uniknąć zbędnych komplikacji zakładamy, że ilość renderowanych na sekundę klatek wynosi 60. Później dowiesz się o mechanizmie akumulowania czasu i kontroli renderowania klatek.
1 2 3 4 5 6 7 8 9 | public void Update() { Keyboard.Capture(); Mouse.Capture(); NewtonWorld.Update(1.0f / 60.0f); Root.RenderOneFrame(); // Przekazujemy kontrolę do systemu, aby program nie przestał mu odpowiadać WindowEventUtilities.MessagePump(); } |
Przejdźmy teraz do pliku Program.cs i utwórzmy prostą pętlę gry: W każdym przebiegu pętli aktualizujemy stan gry, a w przypadku naciśnięcia klawisza Esc wychodzimy z pętli.
1 2 3 4 5 6 7 8 9 10 11 12 | static void Main(string[] args) { Engine.Singleton.Initialise(); while (true) { Engine.Singleton.Update(); if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_ESCAPE)) break; } } |
Możesz uruchomić grę. Twoim oczom powinno ukazać się okienko konfiguracyjne Ogre. Ustaw synchronizację pionową (Vsync) na Yes, by ograniczyć ilość wyświetlanych klatek do częstotliwości odświeżania ekranu (zazwyczaj 60Hz). Warto też testować grę w trybie okienkowym (Fullscreen - No).
Po zatwierdzeniu opcji silnik powinien renderować czarny ekran i zakończyć pracę po wciśnięciu Escape. Możesz pobrać projekt w aktualnym stanie:
Projekt Visual C# [24]
Jeżeli podczas próby uruchomienia aplikacji środowisko informuje o niezłapanym wyjątku FileLoadException lub występuje inny błąd upewnij się, że kompilujesz projekt dla architektury x86, ponadto katalog wskazany we właściwościach projektu jako "Working Directory" powinien zawierać:
W swoim systemie powinny być zainstalowane:
Rozpoczniemy teraz tworzenie klasy odpowiedzialnej za planszę, na które rozgrywać się będzie akcja. Utwórz plik Level.cs i zaimportuj w nim Mogre i MogreNewt. Używanie siatki, która jest wyświetlana jako siatki kolizji często nie jest dobrym pomysłem:
Potrzebujemy więc osobnego obiektu dla każdego z tych dwóch typów siatek.
1 2 3 4 5 6 7 | class Level { Entity GraphicsEntity; SceneNode GraphicsNode; Entity CollisionEntity; SceneNode CollisionNode; |
Klasa SceneNode
to jedna z podstawowych klas silnika Ogre. Reprezentuje ona węzeł sceny. Scena bowiem przechowywana jest w silniku w formie drzewa, dzięki czemu możliwe jest tworzenie hierarchii połączonych ze sobą obiektów, co jest bardzo przydatne - można chociażby przypiąć węzeł miecza do ręki postaci jako dziecko, co zwalnia nas z ręcznego ustalania pozycji miecza. Sama klasa SceneNode
nie reprezentuje jednak niczego widzialnego. Dopiero Entity
nadaje kształt obiektowi. Klasa ta reprezentuje kopię siatki i może być przypięta do SceneNode
, podobnie jak wiele innych obiektów graficznych.
Aby siatka kolizyjna mogła funkcjonować jak należy, musi być powiązana z reprezentacją w silniku Newton. Służy do tego klasa Body
.
1 | Body Body; |
Pierwszą metodą będzie utworzenie graficznej reprezentacji planszy. Metoda jest bardzo prosta. Najpierw tworzymy instancję SceneNode
jako dziecko korzenia sceny, następnie tworzymy instancję Entity
siatki z pliku o nazwie podanej w argumencie, a na koniec łączymy ją z węzłem sceny i wyłączamy rzucanie cieni.
1 2 3 4 5 6 7 8 9 10 11 12 | public void SetGraphicsMesh(String meshFile) { // Tworzymy węzeł GraphicsNode = Engine.Singleton.SceneManager.RootSceneNode.CreateChildSceneNode(); // Tworzymy obiekt graficzny GraphicsEntity = Engine.Singleton.SceneManager.CreateEntity(meshFile); // Wiążemy obiekt graficzny z węzłem GraphicsNode.AttachObject(GraphicsEntity); // Wyłączamy rzucanie cieni GraphicsEntity.CastShadows = false; } |
Druga metoda odpowiadać będzie za ustawianie reprezentacji fizycznej planszy. Podobnie jak w przypadku pierwszej metody, tworzymy węzeł i instancję siatki:
1 2 3 4 5 6 7 8 9 | public void SetCollisionMesh(String meshFile) { CollisionNode = Engine.Singleton.SceneManager.RootSceneNode.CreateChildSceneNode(); CollisionEntity = Engine.Singleton.SceneManager.CreateEntity(meshFile); CollisionNode.AttachObject(CollisionEntity); // Nie chcemy aby siatka kolizyjna była widoczna CollisionNode.SetVisible(false); |
Teraz musimy wydobyć z utworzonego obiektu informacje o wierzchołkach i wielokątach siatki, a następnie przekazać je Newtonowi. OgreNewt przychodzi nam z pomocą dzięki klasie TreeCollisionSceneParser
, która wydobywa potrzebne informacje od obiektów Entity
powiązanych z zadanym węzłem i jego dziećmi. W bardzo prosty sposób tworzymy obiekt kolizji, następnie tworzymy ciało Newtonowskie, po czym zwalniamy już niepotrzebny obiekt kolizji.
1 2 3 4 5 6 7 8 9 10 | // Tworzymy parser sceny MogreNewt.CollisionPrimitives.TreeCollisionSceneParser collision = new MogreNewt.CollisionPrimitives.TreeCollisionSceneParser( Engine.Singleton.NewtonWorld); // Wydobywamy informacje o siatce związanej z węzłem collision.ParseScene(CollisionNode, true, 1); // Tworzymy ciało nadając mu wczytany kształt Body = new Body(Engine.Singleton.NewtonWorld, collision); // Niszczymy kształt, który już został wykorzystany do utworzenia ciała collision.Dispose(); |
Każde ciało Newtona powinno być związane z węzłem SceneNode
, który reprezentuje. W przeciwnym wypadku Newton nie wiedział by, na jak węzeł jego obliczenia powinny wywierać wpływ. Aby dokonać takiego powiązania, wystarczy wywołać metodę:
1 2 3 | Body.AttachNode(CollisionNode); } } |
To już cała klasa poziomu. Dodajmy publiczną referencję do obiektu poziomu w klasie Engine, w pliku Engine.cs:
1 | public Level CurrentLevel; |
Wbrew niepokojącego określenia - "abstrakcyjna" - klasa GameObject
będzie bardzo prosta. Jej przydatność na chwilę obecną będzie znikoma, ponieważ w grze posiadać będziemy tylko jeden typ obiektów. Jednak w późniejszym etapie tworzenia gry uogólnienia tego typu stają się bardzo pomocne. Klasa GameObject
mówi tyle, że każdy obiekt w świecie gry powinien się w jakiś sposób aktualizować.
Plik GameObject.cs:
1 2 3 4 | abstract class GameObject { public abstract void Update(); } |
Klasa ta również nie będzie rozbudowana. Menadżer obiektów, zgodnie z projektem, będzie pamiętać listę wszystkich obiektów gry znajdujących się na planszy.
Klasa List zachowuje się podobnie do std::vector
znanego z C++. W platformie .NET wszystko jest obiektem, więc musimy utworzyć obiekt listy w konstruktorze menadżera.
1 2 3 4 5 6 7 8 | class ObjectManager { List<GameObject> Objects; public ObjectManager() { Objects = new List<GameObject>(); } |
Ponieważ lista obiektów nie jest polem publicznym, musimy stworzyć metodę, która pozwoli dodawać nowe obiekty do gry:
1 2 3 4 | public void Add(GameObject gameObject) { Objects.Add(gameObject); } |
Menadżer obiektów pozwoli w łatwy sposób zaktualizować stan wszystkich obiektów w grze dzięki wykorzystaniu abstrakcyjnej klasy.
1 2 3 4 5 6 | public void Update() { foreach (GameObject gameObject in Objects) gameObject.Update(); } } |
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; |
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(); } } |
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 }; |
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; |
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; |
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(); |
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); |
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 |
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; |
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; |
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; |
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; |
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); |
Na koniec, możemy pozbyć się obiektu kolizji, który już spełnił swoje zadanie podczas tworzenia ciała:
1 2 | collision.Dispose(); } |
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; } |
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); } |
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); } |
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"); |
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; |
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; } } } |
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++; } } |
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; |
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; |
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; |
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); } } |
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(); ... } } |
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.
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"); |
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; |
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); |
Konfigurujemy kamerę:
1 2 3 | Engine.Singleton.GameCamera.Character = player; Engine.Singleton.GameCamera.Distance = 4; Engine.Singleton.GameCamera.Angle = new Degree(20); |
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); |
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; |
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(); } } |
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.
W następnej części [3] 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.
Odnośniki:
[1] http://mateuszosowski.pl
[2] http://informatyka.wroc.pl/node/742
[3] http://informatyka.wroc.pl/node/785
[4] http://informatyka.wroc.pl/node/788
[5] http://informatyka.wroc.pl/node/846
[6] http://informatyka.wroc.pl/node/1130
[7] http://informatyka.wroc.pl/node/1139
[8] http://informatyka.wroc.pl/node/1267
[9] http://informatyka.wroc.pl/node/711
[10] http://informatyka.wroc.pl/node/717
[11] http://www.microsoft.com/express/Downloads/#2010-Visual-CS
[12] http://www.microsoft.com/downloads/details.aspx?familyid=ab99342f-5d1a-413d-8319-81da479ab0d7&displaylang=pl
[13] http://informatyka.wroc.pl/upload/mogre/Mogre.rar
[14] http://informatyka.wroc.pl/upload/mogre/OgreRuntime.rar
[15] http://informatyka.wroc.pl/upload/mogre/Media.rar
[16] http://informatyka.wroc.pl/upload/mogre/TppGame1.rar
[17] http://www.xullum.net/lefthand/downloads/temp/BlenderExport.zip
[18] http://www.microsoft.com/downloads/details.aspx?FamilyID=2da43d38-db71-4c1b-bc6a-9b6652cd92a3&displaylang=en
[19] http://www.microsoft.com/downloads/en/details.aspx?familyid=a5c84275-3b97-4ab7-a40d-3802b2af5fc2&displaylang=en
[20] http://pl.wikipedia.org/wiki/Agregacja_(programowanie_obiektowe)
[21] http://pl.wikipedia.org/wiki/Singleton_(wzorzec_projektowy)
[22] http://msdn.microsoft.com/en-us/library/system.appdomain.aspx
[23] http://pl.wikipedia.org/wiki/Callback_(programowanie)
[24] http://informatyka.wroc.pl/upload/mogre/TppGame0.rar