[Część 1 ]
[Część 2 ]
[Część 3 ]
[Część 4 ]
[Część 5 ]
[Część 6 ]
[Część 7 ]
Drugą część cyklu zakończyliśmy implementacją sensora lokalnych obiektów dla naszej postaci. W tej części stworzymy nowy typ obiektów niezbędny w każdej grze tego typu - przedmioty, które da się podnieść. Oprócz tego uzupełnimy funkcjonalność kodu o wyświetlanie etykiet tekstowych w przestrzeni 2D, a także 3D.
Proste etykiety tekstowe
W naszym silniku zaimplementujemy dwa typy etykiet tekstowych:
- proste - umieszczone w przestrzeni 2D
- nieco bardziej rozbudowane - związane z punktem w przestrzeni trójwymiarowej.
Na początek zaimplementujemy pierwszy typ etykiety i prostą fabrykę zarządzającą istniejącymi na ekranie napisami.
Utwórz klasę
TextLabel
w nowym pliku:
1
2
3
4
5
| public class TextLabel
{
public TextAreaOverlayElement TextArea;
protected bool IsCaptionChanged;
bool _IsVisible; |
TextLabel.cs
Ogre oferuje możliwość wyświetlania dwuwymiarowych, oteksturowanych prostokątów, a także napisów na ekranie za pośrednictwem klasy
Overlay
i klas z nią związanych. Jedną z nich jest
TextAreaOverlayElement
- przestrzeń na tekst. W klasie
TextLabel
definiujemy również pole
IsCaptionChanged
, które będzie określać, czy treść etykiety uległa zmianie. Atrybut
protected
sprawia, że pole prywatne nim oznaczone będzie widoczne również w klasach dziedziczących z klasy, w której znajduje się to pole. Prywatne pole
_IsVisible
określać będzie, czy etykieta ma być widoczna na ekranie.
Definiujemy konstruktor:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public TextLabel(
String name, String fontName, float fontSize, ColourValue colourtop, ColourValue colourbottom)
{
TextArea = OverlayManager.Singleton.CreateOverlayElement("TextArea", name)
as TextAreaOverlayElement;
TextArea.MetricsMode = GuiMetricsMode.GMM_RELATIVE;
TextArea.SetDimensions(1.0f, 1.0f);
TextArea.CharHeight = fontSize;
TextArea.FontName = fontName;
TextArea.ColourTop = colourtop;
TextArea.ColourBottom = colourbottom;
TextArea.SpaceWidth = TextArea.CharHeight * 0.5f;
} |
TextLabel.cs
Przyjmuje on kolejno nazwę obiektu tekstu, nazwę czcionki, rozmiar czcionki oraz górny i dolny kolor napisu - tekst bowiem będzie można pokolorować gradientem.
W trzecim wierszu tworzymy obiekt przestrzeni tekstu, następnie ustalamy system miary na względny (
GMM_RELATIVE
). Oznacza to, że pozycja i wymiary będą podawane w odniesieniu do układu współrzędnych rozpoczynającego się w lewym górnym wierzchołku ekranu, w którym szerokość i wysokość ekranu wynoszą 1.0 odpowiednio na osiach X i Y. Rozmiar obszaru tekstu ustalamy jako cały ekran. W wierszu 13 ustalamy szerokość spacji. Będzie ona równa połowie szerokości normalnego znaku.
Zdefiniujmy również pośrednie metody zmieniające właściwości obszaru tekstowego:
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
26
27
| public bool IsVisible
{
get { return _IsVisible; }
set
{
_IsVisible = value;
if (value)
TextArea.Show();
else
TextArea.Hide();
}
}
public void SetPosition(float x, float y)
{
TextArea.SetPosition(x, y);
}
public String Caption
{
get { return TextArea.Caption; }
set
{
TextArea.Caption = value;
IsCaptionChanged = true;
}
} |
TextLabel.cs
Potrzebujemy także metody aktualizującej tekst:
1
2
3
4
5
6
7
8
9
| public virtual void Update()
{
if (IsCaptionChanged)
{
TextArea.Caption = TextArea.Caption;
IsCaptionChanged = false;
}
}
} |
TextLabel.cs
Operacja, którą wykonujemy piątym wierszu może się wydawać dziwna. Wątpliwości może również budzić potrzeba odnotowywania zmiany w tekście. Ma to związek z pewnym błędem w kodzie Ogre w wersji, której używamy, bowiem zmiany w tekście dokonane po utworzeniu jego obiektu i przed wyrenderowaniem klatki nie powodują aktualizacji współrzędnych mapowania tekstury czcionki na geometrii napisu, która składa się z prostokątów. Musimy więc zadbać, aby zmiana tekstu została wprowadzona po raz drugi po wyrenderowaniu klatki. W tym celu stworzymy klasę-fabrykę etykiet, która będzie zarządzać także ich aktualizacją w odpowiednim momencie. Metodę
Update()
czynimy wirtualną, ponieważ będzie wyglądać ona nieco inaczej w przypadku etykiet 3D.
Fabryka etykiet
Utworzymy teraz nieskomplikowaną klasę pozwalającą przechowywać i tworzyć nowe obiekty etykiet tekstowych:
1
2
3
4
5
6
7
| public class TextLabeler
{
Overlay Overlay;
OverlayContainer Panel;
public int LabelID;
public List<TextLabel> Labels; |
TextLabeler.cs
Aby obiekty klasy
TextAreaOverlayElement
zostały wyrenderowane przez silnik, muszą zostać umieszczone w kontenerze, który z kolei musi znajdować się na "nakładce", obiekcie klasy
Overlay
. W tym celu definiujemy odpowiednie pola w klasie
TextLabeler
. Oprócz tego przydatne będzie pole pozwalające generować unikalne nazwy obiektów etykiet -
LabelID
i oczywiście lista wszystkich etykiet.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public TextLabeler()
{
Overlay = OverlayManager.Singleton.Create("TextLabeler");
Panel = OverlayManager.Singleton.CreateOverlayElement("Panel", "TextLabelerPanel")
as OverlayContainer;
Panel.MetricsMode = GuiMetricsMode.GMM_RELATIVE;
Panel.SetDimensions(1.0f, 1.0f);
Panel.SetPosition(0, 0);
Overlay.Add2D(Panel);
Overlay.Show();
Labels = new List<TextLabel>();
} |
TextLabeler.cs
W konstruktorze tworzymy kolejno obiekt nakładki (4), panel, który rozpinamy na powierzchni całego ekranu i następnie dodajemy do nakładki (6-9), a także obiekt kolekcji etykiet (14). Pierwszy argument metody
CreateOverlayElement
określa jakiego typu element nakładki chcemy utworzyć. Interesujące nas typy to "Panel" oraz "TextArea". Drugim argumentem jest unikalna nazwa elementu.
Definiujemy metodę generującą unikalne nazwy etykiet:
1
2
3
4
| public string GetUniqueLabelName()
{
return (LabelID++).ToString();
} |
TextLabeler.cs
Generowanie tego typu nazw jest nam potrzebne, ponieważ metoda
CreateOverlayElement()
wymaga podania uniklanej nazwy elementu nakładki.
Tworzymy metodę generującą obiekt etykiety:
1
2
3
4
5
6
7
8
9
10
11
| public TextLabel NewTextLabel(
String fontName, float fontSize, ColourValue colourtop, ColourValue colourbottom)
{
TextLabel textLabel = new TextLabel(GetUniqueLabelName(),
fontName, fontSize, colourtop, colourbottom);
Panel.AddChild(textLabel.TextArea);
Labels.Add(textLabel);
return textLabel;
} |
TextLabeler.cs
Metoda ta tworzy obiekt etykiety, a także dodaje go do panelu znajdującego się na nakładce. Oprócz tego, korzystając z tego typu metody fabrykującej nakładki jesteśmy zwolnieni z ręcznego dodawania nowego obiektu do listy. Warto rozważyć modernizacje klasy
ObjectManager
, by tworzyła obiekty gry różnych typów w podobny sposób.
Metoda aktualizująca:
1
2
3
4
5
6
| public void Update()
{
foreach (TextLabel label in Labels)
label.Update();
}
} |
TextLabeler.cs
Korzystając z pętli aktualizujemy wszystkie elementy kolekcji.
Dodajmy egzemplarz fabryki do klasy
Engine
:
1
2
3
4
5
6
7
| public TextLabeler Labeler;
public void Initialise()
{
...
Labeler = new TextLabeler();
} |
Engine.cs
Musimy także zadbać o aktualizację etykiet w odpowiednim momencie:
1
2
3
4
5
6
7
8
9
| public void Update()
{
...
Keyboard.Capture();
Mouse.Capture();
Root.RenderOneFrame();
Labeler.Update();
...
} |
Engine.cs
Zanim wyświetlimy jakikolwiek napis, potrzebujemy dodać do zasobów silnika pliki czcionki. Ściągnij poniższe archiwum i rozpakuj jego zawartość do folderu
Media:
Czcionki
Konstrukcja pliku definicji czcionki jest bardzo prosta, nie będzie więc omawiana. Możemy teraz utworzyć przykładową etykietę w klasie
Program
, po inicjacji silnika:
1
2
3
4
5
6
| ...
TextLabel tl = Engine.Singleton.Labeler.NewTextLabel(
"Primitive", 0.04f, new ColourValue(0.7f, 0.4f, 0), new ColourValue(1,0.8f, 0.3f));
tl.Caption = "Hello World";
tl.SetPosition(0.1f, 0.1f);
... |
Program.cs, Main()
Na ekranie powinien ukazać się napis umieszczony w lewym górnym rogu. Możesz pobrać aktualny kod źródłowy, aby zweryfikować poprawność swojego kodu:
Aktualny kod źródłowy
Etykiety 3D
Kolejnym usprawnieniem silnika, którego dokonamy będzie wprowadzanie etykiet 3D. Nowa klasa będzie dziedziczyć z istniejącej już klasy
TextLabel
. Wzbogacimy ją o atrybut pozycji w przestrzeni 3D.
1
2
3
4
5
6
7
8
9
10
11
| public class TextLabel3D : TextLabel
{
public Vector3 Position3D;
float HalfWidth;
public TextLabel3D(
String name, String fontName, float fontSize,
ColourValue colourtop, ColourValue colourbottom)
: base(name, fontName, fontSize, colourtop, colourbottom)
{
} |
TextLabel3D.cs
Pole
Position3D
będzie publicznym polem przechowującym właściwą pozycję etykiety. W polu
HalfWidth
przechowywać będziemy połowę szerokości napisu. Będzie to nam potrzebne do wyśrodkowania napisu względem zadanej pozycji. Konstruktor nowej klasy niczym się nie różni od konstruktora bazowej klasy
TextLabel
, toteż jego ciało pozostawiamy puste.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public override void Update()
{
Vector3 projected = Engine.Singleton.Camera.ProjectionMatrix
* (Engine.Singleton.Camera.ViewMatrix * Position3D);
float x2d = ((projected.x * 0.5f) + 0.5f);
float y2d = 1.0f - ((projected.y * 0.5f) + 0.5f);
if (x2d < 0 || x2d > 1 || y2d < 0 || y2d > 1)
TextArea.Hide();
else
if (IsVisible) TextArea.Show();
SetPosition(x2d - HalfWidth, y2d);
if (IsCaptionChanged)
{
HalfWidth = GetTextWidth() * 0.5f ;
base.Update();
IsCaptionChanged = false;
}
} |
TextLabel3D.cs
Ponieważ etykieta jest w istocie obiektem znajdującym się w dwuwymiarowej przestrzeni ekranu, w każdej klatce musimy odwzorować jej pozycję w przestrzeni 3D na punkt na ekranie. Wykorzystujemy do tego celu macierze widoku i projekcji kamery, których silnik używa do renderowania wszelkich obiektów trójwymiarowych. Macierz widoku (
ViewMatrix
) przekształci punkt 3D zgodnie z transformacją kamery, a macierz projekcji zamknie jego współrzędne w przedziale ostrosłupa widzenia. Zagadnienie transformacji 3D jest dobrze wyjaśnione w wielu artykułach, które można znaleźć w sieci, a także na tym portalu.
http://www.informatyka.wroc.pl/node/619?page=0,1
Po obliczeniu pozycji punktu w ostrosłupie widzenia musimy je przeskalować (5-6), by uzyskać współrzędne ekranowe. Ponadto pamiętając, że w przestrzeni ekranu oś Y jest skierowana w dół, a nie w górę, jak to ma miejsce w przestrzeni 3D silnika Ogre, odwracamy współrzędną Y (6). Następnie, w przypadku gdy tekst nie znajduje się na ekranie ukrywamy go zwalniając kartę graficzną z jego renderowania. Ustawiając pozycję napisu przesuwamy go o połowę jego szerokość w lewo, dzięki czemu uzyskujemy efekt wyśrodkowania.
W przypadku zmiany etykiety przeliczamy połowę szerokości napisu za pomocą metody
GetTextWidth()
, którą zaimplementujemy w klasie
TextLabel
.
Zaimplementujemy teraz metodę
GetTextWidth()
w klasie
TextLabel
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public float GetTextWidth()
{
FontPtr font = FontManager.Singleton.GetByName(TextArea.FontName);
float textWidth = 0;
for (int i = 0; i < TextArea.Caption.Length; i++)
{
if (TextArea.Caption[i] == 32)
textWidth += 0.5f;
else
textWidth += font.GetGlyphAspectRatio(TextArea.Caption[i]);
}
return textWidth * TextArea.CharHeight / Engine.Singleton.Camera.AspectRatio;
} |
TextLabel.cs
Metoda nie jest skomplikowana - po pobraniu czcionki za pośrednictwem menadżera czcionek silnika Ogre iterujemy po literach etykiety sumując współczynniki szerokość/wysokość kolejnych znaków. Sumę tą mnożymy przez wielkość znaku uzyskując tym samym interesującą nas wartość. Szerokość musi zostać przeskalowana przez odwrotność współczynnika proporcji ekranu, ponieważ w układzie współrzędnych ekranowych obszar ekranu jest kwadratem, podczas gdy w rzeczywistości tak być nie musi.
Dodajmy możliwość utworzenia obiektu klasy
TextLabel3D
poprzez fabrykę:
1
2
3
4
5
6
7
8
9
10
11
| public TextLabel3D NewTextLabel3D(
String fontName, float fontSize, ColourValue colourtop, ColourValue colourbottom)
{
TextLabel3D textLabel = new TextLabel3D(
GetUniqueLabelName(), fontName, fontSize, colourtop, colourbottom);
Panel.AddChild(textLabel.TextArea);
Labels.Add(textLabel);
return textLabel;
} |
TextLabeler.cs
Metoda wygląda niemalże identycznie jak w przypadku zwykłej etykiety.
Możemy teraz dodać przykładową etykietę do programu:
1
2
3
4
5
6
7
8
9
10
| TextLabel3D tl3d = Engine.Singleton.Labeler.NewTextLabel3D(
"Primitive", 0.04f, new ColourValue(0.7f, 0.4f, 0), new ColourValue(1,1.0f, 0.6f));
tl3d.Caption = "Player Character";
tl3d.IsVisible = true;
while (true)
{
...
tl3d.Position3D = player.Position + new Vector3(0, 1.0f, 0);
... |
Program.cs, Main()
Jak widać, użycie nowej klasy jest bardzo proste. W efekcie, nad głową postaci gracza powinien znajdować się odpowiedni napis.
Aktualny kod źródłowy
Posiadamy już etykiety tekstowe. Wykorzystamy je teraz w praktyce - sprawimy, że nad widzianym przez postać obiektem zostanie wyświetlona jego nazwa określona odpowiednim polem jego profilu. Wraz z implementowaniem tej funkcjonalności wprowadzimy klasę kontrolera postaci - interfejsem pośredniczącym pomiędzy obiektem postaci gracza, a samym graczem.
Kontroler postaci
Dobrze będzie, jeśli postacie kontrolowane będą poprzez specjalne klasy kontrolerów. Dzięki temu zyskujemy pewną ogólność w projekcie silnika - postacie będą mogły być kontrolowane przez program lub przez człowieka. Na chwilę obecną interesuje nas jedynie możliwość kontroli postaci przez człowieka, więc nie będziemy tworzyć abstrakcji.
Wprowadź do projektu klasę
HumanController
:
1
2
3
4
5
| class HumanController
{
public Character Character;
SelectableObject FocusObject;
TextLabel3D TargetLabel; |
HumanController.cs
Pole
Character
określać będzie obiekt postaci, z którym związany jest kontroler. Pole
FocusObject
przechowywać będzie referencję do obiektu widzianego przez postać. W przyszłości, w sytuacji, gdy postać będzie widziała więcej niż jeden obiekt gracz będzie mógł przemieszczać się po liście widzianych obiektów za pomocą skrótu klawiszowego, dzięki czemu nie będzie musiał manewrować postacią, by skupić uwagę postaci na żądanym przedmiocie. Pole
TargetLabel
stanowić będzie obiekt etykiety widzianego przedmiotu.
1
2
3
4
5
6
7
8
| public HumanController()
{
TargetLabel = Engine.Singleton.Labeler.NewTextLabel3D(
"Primitive",
0.04f,
new ColourValue(0.7f, 0.4f, 0),
new ColourValue(1, 1.0f, 0.6f));
} |
HumanController.cs
Konstruktor tworzy obiekt etykiety. Odwołanie do obieku
Labler
sugeruje, że konstruktor tego kontrolera powinien być wywołany po zainicjowaniu wszystkich dotychczasowych komponentów silnika.
1
2
3
4
5
6
| public void Update()
{
if (Character != null)
{
Quaternion rotation = new Quaternion();
rotation.FromAngleAxis(new Degree(2), Vector3.UNIT_Y); |
HumanController.cs
Metoda aktualizująca kontroler będzie wywoływana wraz z aktualizacją mechaniki, więc nie musimy się przejmować skalowaniem rotacji postaci względem czasu renderowania klatki.
1
2
3
4
5
6
7
8
| if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_LEFT))
Character.Orientation *= rotation;
if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_RIGHT))
Character.Orientation *= rotation.Inverse();
if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_UP))
Character.State = Character.CharacterState.WALK;
else
Character.State = Character.CharacterState.IDLE; |
HumanController.cs, Update()
Powyższy kod jest został przeniesiony z pętli głównej z metody
Main()
klasy
Program
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| if (Character.Contacts.Count > 0)
{
SelectableObject contact = Character.Contacts[0] as SelectableObject;
FocusObject = contact;
TargetLabel.Caption = contact.DisplayName;
TargetLabel.Position3D = contact.Position + contact.DisplayNameOffset;
TargetLabel.IsVisible = true;
}
else
{
FocusObject = null;
TargetLabel.IsVisible = false;
}
}
} |
HumanController.cs, Update()
W zależności od tego, czy na liście widzianych przez postać obiektów figuruje niezerowa ilość elementów ustalamy pole
FocusObject
na pierwszy z widzianych obiektów (4), a także ustalmy treść i pozycję etykiety obiektu (5,6). Pozycja etykiety jest określana przez pozycję samego obiektu oraz wektor przesunięcia etykiety względem jego środka.
Pozostaje dodać instancję napisanego kontrolera do klasy silnika:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| public HumanController HumanController;
...
public void Initialise()
{
...
HumanController = new HumanController();
}
public void Update()
{
...
while (TimeAccumulator >= FixedTimeStep)
{
NewtonWorld.Update(FixedTimeStep);
HumanController.Update();
...
}
}
... |
Engine.cs
Jak można zauważyć, dodawanie komponentów aktualizowanych w stałym odstępie czasu jest dość schematyczne. W związku z tym warto rozważyć przebudowanie klasy
Engine
w przyszłości.
Oczywiście z metody
Main()
należy usunąć kod, który został przejęty przez kontroler postaci. Ponadto musimy związać postać gracza z kontrolerem:
1
| Engine.Singleton.HumanController.Character = player; |
Program.cs, Main()
Od tej chwili w momencie podejścia postaci do utworzonych wcześniej dekoracyjnych waz powinna zostać wyświetlona ich nazwa:
Aktualny kod źródłowy
Uczymy się zbierać
Nieodłącznym elementem gier RPG jest ekwipunek. Wszelkiej maści przedmioty wypełniające zazwyczaj niewidzialny, czasami nawet nieskończenie pojemny plecak naszego bohatera. Czy to będą karabiny, wyrzutnie rakiet, miecze, czy też magiczne laski o potężnej mocy, pierwszym krokiem do implementacji ekwipunku będzie nauczenie bohaterów gry podnoszenia przedmiotów. Wymagać to będzie od nas przerobienia klasy postaci.
Podnoszenie przedmiotu będzie stanowić nowy stan, w którym znajdować będzie się mogła postać. Stan ten będzie w istocie podzielony na dwa podstany: stan przed podniesieniem przedmiotu i stan po jego podniesieniu. Ustalamy, że czas trwania pierwszego podstanu będzie stanowić połowę czasu trwania animacji podnoszenia przedmiotu. Dostęp do informacji dotyczących animacji gwarantuje nam silnik Ogre. Pierwszy stan kończyć będzie się zdarzeniem podniesienia przedmiotu. Drugi podstan stanowić będzie po prostu powrót postaci do normalnej pozycji, a kończyć się będzie przejściem bohatera w stan spoczynku.
Po krótkich przemyśleniach nurtować nas mogą wyjścia z rozmaitych sytuacji, chociażby, co zrobić, gdy dwie postaci będą chciały w mniej więcej tym samym momencie podnieść ten sam przedmiot? Czy zablokować możliwość podniesienia go przez drugą w kolejności postać? Przywodzi to na myśl ogólniejszą sytuację - zdarzenie znajdujące się w scenariuszu gry, czy też leżące w naturze pewnego obiektu sprawia, że przedmiot, po który schyla się postać znika, wybucha, rozpływa się, wyparowuje, itp. Poruszamy tym samym dotychczas nierozważany problem usuwania obiektów ze sceny. Otóż, ważne jest, by wszystkie obiekty, które odnoszą się do usuwanego obiektu zostały w jakiś sposób poinformowane o tym zdarzeniu. W przeciwnym wypadku będziemy mieli do czynienia z nieprawidłowymi referencjami - bowiem pola odpowiadające za graficzne, czy fizyczne reprezentacje usuniętego obiektu będą już wyzerowane. Jakie są możliwe rozwiązania problemu referencji? Zdecydowanie najprostszym manewrem będzie dodanie specjalnego pola określającego, czy obiekt istnieje w całości.
Po tym dość długim wstępie przechodzimy do pisania kodu. W pierwszej kolejności musimy się zastanowić nad przechowywaniem informacji o leżących przedmiotach w programie. Możliwych rozwiązań jest bardzo wiele, wybierzemy jednak najprostsze z nich - rozbudujemy istniejącą klasę Described
tak, by mogła odpowiadać także za "podnoszalne" przedmioty. Zaczniemy od dodania odpowiednich pól do profilu wspomnianej klasy:
1
2
3
4
5
6
7
| class DescribedProfile
{
...
public Single Mass;
public Boolean IsPickable;
...
} |
DescribedProfile.cs
Wzbogaciliśmy profil o informację dotyczącą masy obiektu. Chcąc dać obiektom klasy Descirbed
szansę zostania przyciągniętymi przez siłę grawitacji musimy przerobić konstruktor tejże klasy:
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
26
27
28
29
30
31
32
33
| public Described(DescribedProfile profile)
{
Profile = profile.Clone();
Entity = Engine.Singleton.SceneManager.CreateEntity(Profile.MeshName);
Node = Engine.Singleton.SceneManager.RootSceneNode.CreateChildSceneNode();
Node.AttachObject(Entity);
Vector3 scaledSize = Entity.BoundingBox.Size * Profile.BodyScaleFactor;
ConvexCollision collision = new MogreNewt.CollisionPrimitives.Box(
Engine.Singleton.NewtonWorld,
scaledSize,
Quaternion.IDENTITY,
Engine.Singleton.GetUniqueBodyId());
Vector3 inertia, offset;
collision.CalculateInertialMatrix(out inertia, out offset);
Body = new Body(Engine.Singleton.NewtonWorld, collision, true);
Body.AttachNode(Node);
Body.SetMassMatrix(Profile.Mass, Profile.Mass * inertia);
Body.UserData = this;
Body.MaterialGroupID = Engine.Singleton.MaterialManager.DescribedMaterialID;
collision.Dispose();
}
public bool IsPickable
{
get { return Profile.IsPickable; }
} |
Described.cs
Wzbogaciliśmy istniejący kod o wiersze nadające ciału masę i bezwładność. Obiekty ozdobne wciąż możemy uczynić statycznymi nadając im zerową masę. Właściwość IsPickable
daje nam dostęp do odpowiedniego pola profilu.
Ponieważ planujemy usuwać podniesione przedmioty ze sceny, musimy zaopatrzyć się w mechanizm niszczenia węzła sceny, ciała fizycznego i innych elementów związanych z przedmiotem. Ponieważ w najbliższej przyszłości przyjdzie nam usuwać także obiekty innych typów, najlepiej będzie, jeśli wyposażymy klasę GameObject
w odpowiednią metodę wirtualną. Ponadto dodamy wspomnianą flagę określającą, czy obiekt został zniszczony. Dzięki temu będziemy w stanie kontrolować kompletność obiektów, do których się odnosimy.
1
2
3
4
5
6
7
8
9
| public abstract class GameObject
{
...
public virtual void Destroy()
{
Exists = false;
}
public bool Exists = true;
} |
GameObject.cs
Metoda jest bardzo prosta, jednakże musimy pamiętać o tym, by została wywołana przez klasy dziedziczące z GameObject
. Dodanie pustych implementacji do istniejących klas nie stanowi problemu. W późniejszym czasie zajmiemy się ich pełniejszą implementacją, podczas gdy teraz skupimy się na klasie Described
:
1
2
3
4
5
6
7
8
9
10
11
12
|
public override void Destroy()
{
Node.DetachAllObjects();
Engine.Singleton.SceneManager.DestroySceneNode(Node);
Engine.Singleton.SceneManager.DestroyEntity(Entity);
Body.Dispose();
Body = null;
base.Destroy();
}
} |
Described.cs
Przed usunięciem węzła ze sceny musimy odczepić (3) od niego przyłączone obiekty, w tym przypadku jedynie Entity
. Następnie usuwamy ze sceny węzeł i obiekt graficzny i ciało (4-7). Ostatecznie wywołujemy bazową metodę Destroy()
(9), którą zaimplementowaliśmy w klasie GameObject
. Niemniej jednak musimy być świadomi tego, że obiekt nie został usunięty z listy wszystkich obiektów znanej egzemplarzowi klasy ObjectManager
. Metodę aktualizującą menadżer obiektów należy uzupełnić o usuwanie z listy referencji do obiektów, które już nie istnieją w świecie gry. W przeciwnym wypadku w trakcie gry przybywać będzie nieużytków, których Garbage Collector nie będzie w stanie usunąć.
Teraz dodamy naszej postaci umiejętność, wokół której wszystko w tej części artykułu się kręci. Przejdźmy więc do tej klasy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public class Character : SelectableObject
{
public enum CharacterState
{
IDLE,
WALK,
PICKING_DOWN,
TURN
};
public enum PickingStates
{
BEFORE,
AFTER
}; |
Character.cs
Zgodnie z przemyśleniami, rozszerzamy zbiór stanów postaci o podnoszenie, a także dodajemy stan TURN, który przyda nam się podczas określania, czy postać może się obrócić. Nie chcemy, by gracz mógł skręcać postacią podczas gdy ta schyla się po przedmiot.
1
2
3
4
5
| ....
public PickingStates PickingState;
public Described PickingTarget;
public static Dictionary<CharacterState, List<CharacterState>> StatesMap; |
Character.cs
Oprócz podstanu w postaci pola PickingState
do klasy Character
dodajemy także referencję na obiekt, po który postać się schyla. Pole StatesMap
posłuży nam do określenia możliwych przejść pomiędzy stanami. Pole jest statyczne, ponieważ nie istnieje potrzeba posiadania indywidualnych map przez różne postacie.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public static void InitStatesMap()
{
StatesMap = new Dictionary<CharacterState, List<CharacterState>>();
StatesMap[CharacterState.IDLE] = new List<CharacterState>();
StatesMap[CharacterState.IDLE].Add(CharacterState.TURN);
StatesMap[CharacterState.IDLE].Add(CharacterState.IDLE);
StatesMap[CharacterState.IDLE].Add(CharacterState.PICKING_DOWN);
StatesMap[CharacterState.IDLE].Add(CharacterState.WALK);
StatesMap[CharacterState.WALK] = new List<CharacterState>();
StatesMap[CharacterState.WALK].Add(CharacterState.TURN);
StatesMap[CharacterState.WALK].Add(CharacterState.IDLE);
StatesMap[CharacterState.WALK].Add(CharacterState.PICKING_DOWN);
StatesMap[CharacterState.WALK].Add(CharacterState.WALK);
StatesMap[CharacterState.PICKING_DOWN] = new List<CharacterState>();
} |
Character.cs
Ta statyczna metoda wypełnia odpowiednio mapę stanów. Ze stanu IDLE postać może przejść w dowolny stan, wszak znajduje się w stanie spoczynku i jest gotowa na wszystko.
Sytuacja stanu WALK wygląda identycznie, ponieważ nie chcemy by gracz musiał zatrzymywać bohatera przed podniesieniem przedmiotu, czy też skręcaniem. Stan PICKING_DOWN z kolei blokuje możliwość chodzenia i obracania się postaci. W przyszłości priorytety stanów ulegną rozszerzeniu, chociażby poprzez dodanie stanu, w którym postać odnosi obrażenia - naturalnie będzie miał on najwyższy priorytet od istniejących stanów.
Implementację nowego stanu popełnimy w metodzie Update()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public override void Update()
{
...
AnimationState pickItemDownAnimation = Entity.GetAnimationState("PickItemDown");
switch (State)
{
...
case CharacterState.PICKING_DOWN:
Velocity = Vector3.ZERO;
idleAnimation.Enabled = false;
walkAnimation.Enabled = false;
pickItemDownAnimation.Enabled = true;
pickItemDownAnimation.Loop = false;
pickItemDownAnimation.AddTime(1.0f / 90.0f); |
Character.cs, Update()
Wyłuskujemy z obiektu graficznego postaci referencję do stanu animacji o nazwie "PickItemDown". Animacja ta istnieje w pliku graficznym modelu postaci dostępnym pod najbliższym odnośnikiem do kodu źródłowego. W trakcie tej animacji postać schyla się po przedmiot leżący na ziemi. Rozwiązanie to nie da pożądanego efektu w przypadku, gdy przedmiot będzie postawiony wyżej, w związku z czym warto zastanowić się nad jego ulepszeniem. Jedną z możliwości stanowi nagranie dodatkowej animacji sięgania po przedmiot postawiony na wprost. Inną, trudniejszą w implementacji opcją może być ingerencja w animację w kodzie - manualne ustawianie kąta nachylenia postaci i dłoni.
Innym problemem godnym uwagi jest zarządzanie stanami animacji. Wydobywanie ich i przełączanie się między nimi w metodzie Update()
jest co najmniej nieeleganckie. W przyszłości stworzymy klasę odpowiadającą za płynne przejście między animacjami.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
if (PickingState == PickingStates.BEFORE)
{
if (PickingTarget.Exists)
{
if (pickItemDownAnimation.TimePosition > pickItemDownAnimation.Length * 0.5f)
{
Engine.Singleton.ObjectManager.Destroy(PickingTarget);
PickingTarget = null;
PickingState = PickingStates.AFTER;
}
}
else
{
PickingTarget = null;
State = CharacterState.IDLE;
}
} |
Character.cs, Update()
W pierwszej kolejności rozważamy podstan podnoszenia trwający do momentu chwycenia przedmiotu. Upewniamy się, czy przedmiot, który po który postać się schyla jeszcze istnieje (3). W przypadku gdy warunek jest spełniony sprawdzamy czas trwania animacji - dokładniej, czy nie przekroczył on połowy czasu animacji (6), ustaliliśmy bowiem, że wtedy wypadać będzie moment chwycenia przedmiotu. Samo chwycenie sprowadza się do usunięcia celu, wyzerowania referencji i przejścia w stan powrotu postaci do stanu spoczynku (8-10). Jeżeli jednak przedmiot, po który postać się schyla zdążył zniknąć, natychmiastowo ustalamy stan postaci na spoczynek.
1
2
3
4
5
6
7
8
9
10
11
12
|
else
{
if (pickItemDownAnimation.TimePosition > pickItemDownAnimation.Length * 0.99f)
{
State = CharacterState.IDLE;
}
}
break;
}
Contacts.Clear();
} |
Character.cs, Update()
W przypadku podstanu AFTER
wychwytujemy moment końca animacji, po którym postać przechodzi w stan spoczynku.
Chcielibyśmy, by postać przed rozpoczęciem schylania się po przedmiot obróciła się w jego kierunku. Do tego celu tworzymy metodę:
1
2
3
4
5
|
public void TurnTo(Vector3 point)
{
Orientation = Vector3.UNIT_Z.GetRotationTo((point - Position) * new Vector3(1, 0, 1));
} |
Character.cs
Obliczamy kąt pomiędzy wektorem Z (0,0,1), a wektorem skierowanym od postaci do punktu, do którego chcemy zwrócić postać.
Aby uczynić mapę stanów funkcjonalną, tworzymy następujące metody:
1
2
3
4
5
6
7
8
9
10
|
public bool CanSwitchState(CharacterState state)
{
return Character.StatesMap[State].Contains(state);
}
public bool CanTurn()
{
return Character.StatesMap[State].Contains(CharacterState.TURN);
} |
Character.cs
Przydatna będzie również bardziej kompleksowa metoda zmiany stanów:
1
2
3
4
5
6
7
8
9
10
|
public bool TrySwitchState(CharacterState state)
{
if (CanSwitchState(state))
{
State = state;
return true;
}
return false;
} |
Character.cs
Ostatecznie, tworzymy metodę, dzięki której postać będzie mogła rozpocząć podnoszenie przedmiotu:
1
2
3
4
5
6
7
8
9
10
11
|
public void TryPick(Described target)
{
if (TrySwitchState(CharacterState.PICKING_DOWN))
{
TurnTo(target.Position);
Entity.GetAnimationState("PickItemDown").TimePosition = 0.0f;
PickingTarget = target;
PickingState = Character.PickingStates.BEFORE;
}
} |
Character.cs
W wierszu 4 obracamy postać do podnoszonego przedmiotu, następnie przewijamy animację podnoszenia na jej początek, po czym ustalamy podnoszony przedmiot i ustawiamy początkowy podstan implementowanej czynności.
To koniec naszej pracy w klasie Character
związanej ze zbieraniem przedmiotów. W związku z istotnymi zmianami w mechanizmie przełączania stanów, musimy wprowadzić pewną kontrolę w kontrolerze postaci:
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
26
27
28
29
30
31
32
33
|
public void Update()
{
if (Character != null)
{
...
if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_LCONTROL))
{
if (FocusObject != null)
{
if (FocusObject is Described)
{ ;
if ((FocusObject as Described).IsPickable)
Character.TryPick(FocusObject as Described);
}
}
}
if (Character.CanTurn())
{
if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_LEFT))
Character.Orientation *= rotation;
if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_RIGHT))
Character.Orientation *= rotation.Inverse();
}
if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_UP))
Character.TrySwitchState(Character.CharacterState.WALK);
else
Character.TrySwitchState(Character.CharacterState.IDLE);
...
}
} |
HumanController.cs
Mapujemy podnoszenie przedmiotów pod klawisz lewy control. Sprawdzamy kolejno istnienie obiektu na uwadze postaci, jego typ i flagę "zbieralności", po czym czym nakazujemy postaci spróbować podnieść przedmiot (13). Dodatkowo kontrolujemy obroty (18) i stan spaceru.
Znikające przedmioty musimy uwzględnić także w klasie TriggerVolume
dodając dodatkowy warunek:
1
2
3
4
5
6
7
8
9
10
11
12
13
| ...
case ObjectState.Unknown:
if (info.GameObject is Character && info.GameObject.Exists && OnCharacterLeft != null)
OnCharacterLeft(this, info.GameObject as Character);
ObjectsInside.RemoveAt(i);
break;
case ObjectState.New:
if (info.GameObject is Character && info.GameObject.Exists && OnCharacterEntered != null)
OnCharacterEntered(this, info.GameObject as Character);
info.State = ObjectState.Unknown;
break;
... |
TriggerVolume.cs
Dodaliśmy sprawdzenie istnienia obiektu (info.GameObject.Exists
), by ignorować wywołania zwrotne dla usuniętych obiektów.
Dodajmy przykładowy przedmiot na scenę! Przejdźmy do pliku Program.cs i dopiszmy w odpowiednim miejscu profil krótkiego miecza, a także kod tworzący sam miecz:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
DescribedProfile swordProfile = new DescribedProfile();
swordProfile.BodyScaleFactor = new Vector3(1.0f, 1.0f, 1.0f);
swordProfile.MeshName = "Sword.mesh";
swordProfile.DisplayName = "Sword";
swordProfile.Description = "Short sword";
swordProfile.DisplayNameOffset = new Vector3(0.0f, 0.2f, 0.0f);
swordProfile.Mass = 50;
swordProfile.IsPickable = true;
...
Described sword = new Described(swordProfile);
sword.Position = new Vector3(5.5f, 1.5f, 0.0f);
Engine.Singleton.ObjectManager.Add(sword); |
Program.cs
Model miecza znajdziesz w tym samym archiwum, co postać z animacją podnoszenia.
Aktualny kod źródłowy
Postać z nowymi animacji i
model miecza
Ukończyliśmy właśnie implementację jednej z najważniejszych akcji wykonywanych przez postać gracza, a także innych bohaterów gry. Zauważmy, że powstały kod jest dość ogólny, bowiem z łatwością możemy uczynić wazę możliwą do podniesienia. Stwarza to możliwość szybkiego wprowadzania zmian w opisie świata gry. Sam ekwipunek nie jest jednak funkcjonalny, nie posiadamy bowiem żadnego typu reprezentującego przedmioty ekwipunku. Zajmiemy się tym w następnych częściach cyklu, podobnie jak interakcją z innymi postaciami i walką.
Ćwiczenie:
- Zaimplementuj mechanizm wywołania zwrotnego uruchamianego w momencie podniesienia przedmiotu.