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

15.01.2011 - Mateusz Osowski
TrudnośćTrudność

Stany

Przygotowaliśmy już metody związane ze zmianą sposobu wyświetlania poszczególnych elementów. Teraz musimy zaimplementować obsługę nowych stanów kontrolera postaci. Pierwsza metoda kontrolować będzie zmianę stanu głównego:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  public void SwitchState(HumanControllerState newState)
  {
    if (State == HumanControllerState.FREE)
    {
      if (newState == HumanControllerState.TALK)      
        SwitchTalkState(HumanTalkState.LISTENING);      
    }
    else if (State == HumanControllerState.TALK)
    {
      if (newState == HumanControllerState.FREE)      
        HideTalkOverlay();
    }
    State = newState;
  }
HumanController.cs


W przypadku przejścia ze stanu wolnego do rozmowy ustawiony zostanie podstan słuchania wypowiedzi bohatera niezależnego. Zgodnie z planem rozmowa musi zaczynać się kwestią BN.
1
2
3
4
5
6
7
8
9
  public void SwitchTalkState(HumanTalkState newState)
  {
    if (newState == HumanTalkState.CHOOSING_REPLY)    
      ShowReplies();  
    else if (newState == HumanTalkState.PAUSE)
    {    
      HideTalkOverlay();
      TextRemainingTime = 0.5f;
    }
HumanController.cs


Przechodząc w stan oczekiwania na wybór odpowiedzi wywołujemy metodę ShowReplies(). Stan pauzy oznacza zaś ukrycie wszelkich okienek rozmowy na okres 0.5 sekundy.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    else if (newState == HumanTalkState.LISTENING || newState == HumanTalkState.REPLYING)
    {
      TextIndex = 0;
      if (newState == HumanTalkState.LISTENING)
      {
        TalkLabels.Last().SetColor(
          new ColourValue(0.7f, 0.4f, 0), new ColourValue(1, 1.0f, 0.6f));
        BeginTextDisplay(CurrentNode.Text[0]);
      }
      else
      {
        TalkLabels.Last().SetColor(
          new ColourValue(0.4f, 0.4f, 0.4f), new ColourValue(1.0f, 1.0f, 1.0f));
        BeginTextDisplay(CurrentReply.Text[0]);
      }
 
      ShowTalkBox();
    }
    TalkState = newState;
  }        
HumanController.cs


W przypadku przejścia w tryb wypowiadania kwestii ustawiony zostaje kolor tekstu. Kwestia bohatera gracza kolorowana jest na szaro, zaś kwestia BN na żółto.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  public void SetRepliesText(TalkNode node)
  {
    SelectedReply = 0;
    ValidReplies.Clear();
    foreach (TextLabel label in TalkLabels)
      label.Caption = "";
 
    foreach (TalkReply reply in CurrentNode.Replies)
    {
      if (reply.IsConditionFulfilled())
      {
        if (ValidReplies.Count > TalkLabels.Count)
          throw new Exception("Too many valid replies in conversation");                    
        TalkLabels[ValidReplies.Count].Caption = reply.Text[0].Text;
        ValidReplies.Add(reply);                    
      }
    }
  }
HumanController.cs


Metoda ta zajmuje się wybieraniem odpowiedzi, których warunek został spełniony i dodawaniem ich do listy możliwych odpowiedzi. W wierszach 3-6 resetujemy stan odpowiedzi, zaś pętla z wiersza 8 dokonuje wypełnienia listy odpowiedzi. W przypadku, gdy liczba poprawnych odpowiedzi przekroczy ilość etykiet zostanie rzucony wyjątek informujący o tym zdarzeniu.

Przechodzimy teraz do implementacji maszyny stanów. Do tej pory w metodzie Update() klasy HumanController dokonywane były jedynie aktualizacje związane z kontrolą postaci podczas gry. Obsługa dwóch różnych i skomplikowanych stanów w jednej metodzie bardzo negatywnie wpłynie na czytelność kodu. Wydzielimy więc istniejące ciało metody Update() do osobnej metody o nazwie HandleMovement():

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
  private void HandleMovement()
  {
    Quaternion rotation = new Quaternion();
    rotation.FromAngleAxis(new Degree(2), Vector3.UNIT_Y);
       
    ...
 
    if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_SPACE))
    {
      if (FocusObject != null)
      {
        if ((Character.CanSwitchState(Character.CharacterState.TALK))
          && (FocusObject.TalkRoot != null))
        {
          CurrentNode = FocusObject.TalkRoot.PickNode();
          SwitchState(HumanControllerState.TALK);
        }
      }
    }
              
    ...
    
      FocusObject = null;
      TargetLabel.IsVisible = false;
    }
  }
HumanController.cs


Oprócz przeniesienia kodu metody dopisaliśmy obsługę klawisza spacji. Inicjować on będzie rozmowę, jeśli będzie taka możliwość. W wierszu 15 wykorzystujemy ogólność rozwiązania - dowolny obiekt będzie mógł posiadać korzeń grafu konwersacji. Dzięki temu możliwa będzie implementacja zachowań urządzeń poprzez mechanizm konwersacji - postać nie powinna wiedzieć, co robi się z danym urządzeniem - uzależniało by to bowiem bardzo mocno implementację mechaniki postaci od świata gry - najlepiej, gdy urządzenie będzie zawierać w sobie informacje o możliwych funkcjonalnościach. Dobrym przykładem elementów świata gry wymagających swobody implementacji niebędących jednocześnie postaciami są komputery.

1
2
3
4
5
6
7
8
9
10
11
Metoda aktualizacyjna wyglądać będzie następująco:
  public void Update()
  {
    if (Character != null)
    {
      if (State == HumanControllerState.FREE)
        HandleMovement();
      else if (State == HumanControllerState.TALK)
        HandleConversation();                 
    }
  }
HumanController.cs


Sprawdzenie podłączenia postaci z kontrolerem pozostało w metodzie, ponieważ jest wspólnym wymogiem obsługi wszystkich stanów.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  public void HandleConversation()
  {
    TextRemainingTime -= Engine.FixedTimeStep;
 
    if (TalkState == HumanTalkState.LISTENING)
    {
      if (TextRemainingTime <= 0)
      {
        TextIndex++;
        if (TextIndex == CurrentNode.Text.Count)
        {
          if (CurrentNode.Replies.Count == 0)          
            SwitchState(HumanControllerState.FREE);
          else
          {
            SetRepliesText(CurrentNode);
            SwitchTalkState(HumanTalkState.CHOOSING_REPLY);
          }
        }
        else        
          BeginTextDisplay(CurrentNode.Text[TextIndex]);        
      }
    }
HumanController.cs


W wierszu 7 sprawdzamy, czy upłynął czas ostatniego wypowiadanego zdania. Jeśli tak - przesuwamy numer zdania i sprawdzamy, czy było to ostanie zdanie (wiersz 10). W przypadku, gdy było to ostanie zdanie sprawdzamy ilość możliwych odpowiedzi gracza. Zero oznacza koniec rozmowy, przejście do sterowania postacią. Niezerowa liczba odpowiedzi oznacz, że należy przejśc do stanu wyboru odpowiedzi. W przypadku, gdy nie było to ostatnie zdanie - wyświetlone zostanie następne (wiersz 21).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    else if (TalkState == HumanTalkState.REPLYING)
    {
      if (TextRemainingTime <= 0)
      {
        TextIndex++;
        if (TextIndex == CurrentReply.Text.Count)
        {
          if (CurrentReply.IsEnding)          
            SwitchState(HumanControllerState.FREE);          
          else
          {
            CurrentNode = CurrentReply.Reaction.PickNode();                            
            SwitchTalkState(HumanTalkState.PAUSE);
          }
        }
        else
          BeginTextDisplay(CurrentReply.Text[TextIndex]);    
      }
    }
HumanController.cs


Obsługa odpowiedzi gracza wygląda bardzo podobnie, z tą różnicą, że po oddtworzeniu wszystkich zdań następuje określenie reakcji BN i stan pauzy.
1
2
3
4
5
6
7
    else if (TalkState == HumanTalkState.PAUSE)
    {
      if (TextRemainingTime <= 0)
      {
        SwitchTalkState(HumanTalkState.LISTENING);
      }
    }
HumanController.cs


Po upłynięciu odpowiedniej ilości czasu w stanie pauzy następuje przejście do odpowiedzi BN.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    else if (TalkState == HumanTalkState.CHOOSING_REPLY)
    {
      if (Engine.Singleton.IsKeyTyped(MOIS.KeyCode.KC_DOWN))    
        SelectedReply++;          
      if (Engine.Singleton.IsKeyTyped(MOIS.KeyCode.KC_UP))    
        SelectedReply--;      
      
      if (SelectedReply == ValidReplies.Count)
          SelectedReply = 0;
      else if (SelectedReply == -1)
          SelectedReply = ValidReplies.Count - 1;
          
      UpdateRepliesColours();
 
      if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_SPACE))
      {    
        CurrentReply = ValidReplies[SelectedReply];
        SwitchTalkState(HumanTalkState.REPLYING);    
      }
    }
  }
HumanController.cs


Podczas wyboru odpowiedzi sprawdzamy stan strzałek - można nimi zmieniać indeks wybranej odpowiedzi. Wciśnięcie spacji oznacza zatwierdzenie odpowiedzi i przejście do staniu odpowiadania.

Zakończyliśmy implementację obsługi stanów przez kontroler postaci. Musimy jeszcze pamiętać o dodaniu pola TalkRoot typu TalkReaction do klasy SelectableObject. Aby sprawdzić działanie systemu rozmowy musimy stworzyć przykładowy dialog. W tym celu tworzymy statyczną klasę Conversations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static class Conversations
{
  public static TalkReaction ConversationRoot;
  static TalkNode FirstGreeting;
  static TalkReply WhereIAm;
  static TalkReaction RespWhatIsIt;
  static TalkNode ItIsATest;
  static TalkReply IDontWantToBother;
  static TalkReply Ending;
  static TalkNode HelloAgain;
 
  public static bool FirstTalk = true;                  
 
  static Conversations()
  {
    ConversationRoot = new TalkReaction();
 
    FirstGreeting = new TalkNode();
 
    TalkEdge firstTalkEdge = new TalkEdge(FirstGreeting);
    firstTalkEdge.Conditions += ( () => FirstTalk );
    ConversationRoot.Edges.Add(firstTalkEdge);
    FirstGreeting.Text.Add(new TalkText("Welcome stranger! What can I do for you?", 3.0f));
    FirstGreeting.Actions += ( () => FirstTalk = false );
Conversations.cs


W wierszu 21 dodajemy warunek do pierwszej krawędzi. Korzystamy ze składni funkcji anonimowych. Po lewej stronie strzałki znajdują się argumenty funkcji - w przypadku warunków krawędzi oczekujemy funkcji bezargumentowej zwracającej wartość typu bool, zatem lista argumentów jest pusta. Po prawej stronie strzałki znajduje się zwracana przez funkcję wartość, dla tego warunku zwracana jest wartość globalnej zmiennej FirstTalk. W przypadku funkcji anonimowych nie musimy używać słowa kluczowego return do zwracania wartości. Funkcje anonimowe mogą być wykorzystywane do tworzenia krótkiego i czytelnego kodu. Chcąc dla przykładu sprawdzić, czy dana własność jest spełniona dla wszystkich obiektów listy możemy użyć metody TrueForAll:
1
2
// funkcja anonimowa przyjmuje jeden argument typu elementów listy i zwraca wartość typu bool
bool mniejszeOd50 = JakasLista.TrueForAll( element => element < 50 ); 
Przykład


W wierszu 23 poprzedniego skrawka kodu dodajemy akcję do węzła FirstGreeting. Użyta funkcja anonimowa nie zwraca żadnej wartości, natomiast zmienia stan zmiennej zewnętrznej. Takie konstrukcje także są dopuszczalne.

Więcej o funkcjach anonimowych: http://msdn.microsoft.com/en-us/library/bb397687.aspx

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
      HelloAgain = new TalkNode();
      HelloAgain.Text.Add(new TalkText("Hello again!", 1.0f));
      ConversationRoot.Edges.Add(new TalkEdge(HelloAgain));
 
      WhereIAm = new TalkReply();
      WhereIAm.Text.Add(new TalkText("What is this place? Where I am?", 3.0f));
      FirstGreeting.Replies.Add(WhereIAm);
      HelloAgain.Replies.Add(WhereIAm);
 
      IDontWantToBother = new TalkReply();
      IDontWantToBother.Text.Add(new TalkText("I don't want to bother you, bye.", 2.5f));
      IDontWantToBother.IsEnding = true;
      FirstGreeting.Replies.Add(IDontWantToBother);
      HelloAgain.Replies.Add(IDontWantToBother);
 
      ItIsATest = new TalkNode();
      ItIsATest.Text.Add(new TalkText("It is a test level of The Game.", 2.0f));
      ItIsATest.Text.Add(new TalkText("Be my guest.", 2.0f));
 
      RespWhatIsIt = new TalkReaction();
      RespWhatIsIt.Edges.Add(new TalkEdge(ItIsATest));
 
      WhereIAm.Reaction = RespWhatIsIt;
 
      Ending = new TalkReply();
      Ending.Text.Add(new TalkText("Thanks, bye.", 1.5f));
      Ending.IsEnding = true;
 
      ItIsATest.Replies.Add(Ending);
    }
  }
Conversations.cs


Wszystkie struktury wypełnione zostały zgodnie z założeniami grafu konwersacji wspomnianymi na początku artykułu. Niestety kod jest schematyczny, lecz bardzo nieczytelny. Tworzenie dialogów w takiej postaci może być dość trudne, dlatego warto pomyśleć o stworzeniu edytora lub konwertora generującego podobny kod automatycznie. Inną możliwością jest wczytywanie grafu konwersacji z plików w określonym formacie podczas ładowania gry, lecz z uwagi na możliwość kompilacji plików źródłowych .Net w czasie rzeczywistym, pierwsza opcja wydaje się rozsądniejsza.

Ostatnią czynnością będzie dodanie bohatera niezależnego do świata gry:

1
2
3
4
5
6
7
8
9
  ...
  Character npc = new Character(profile);
  npc.Position = new Vector3(12, 2, 0);
  npc.TalkRoot = Conversations.ConversationRoot;
  npc.DisplayNameOffset = new Vector3(0, 1, 0);
  npc.Profile.DisplayName = "The Guy";
  npc.TurnTo(Vector3.ZERO);
  Engine.Singleton.ObjectManager.Add(npc);
  ...
Program.cs, Main()


Wykorzystujemy istniejący profil postaci, który został użyty do stworzenia bohatera gracza.

Po uruchomieniu gry powinniśmy mieć możliwość porozmawiania z Guyem:

Aktualny kod źródłowy

Wraz ze stworzeniem systemu konwersacji uzyskaliśmy duże możliwośc rozwoju gry. System ten pozwala na bardzo wiele - mechanizm sterowania grą poprzez zastosowanie predykatów oraz akcji jest prosty i ogólny. Wiele do życzenia pozostawia graficzny interfejs użytkownika, w przyszłości można go jednak zastąpić gotowym, rozbudowanym systemem GUI wyposażonym w mechanizm skórek.

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

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com