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

10.10.2010 - Mateusz Osowski
TrudnośćTrudność

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.


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

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com