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

15.01.2011 - Mateusz Osowski
TrudnośćTrudność

[Część 1] [Część 2] [Część 3] [Część 4] [Część 5] [Część 6]
[Część 7]

W czwartej odsłonie cyklu nauczymy bohatera naszej gry rozmawiać z innymi postaciami, a także obiektami znajdującymi się w świecie gry. Zaprogramujemy system dialogów ze zdarzeniami, dzięki któremu rozmowa będzie mogła wywierać wpływ na globalny stan gry.

Model konwersacji



Zanim zaczniemy pisać kod, musimy przemyśleć budowę i reprezentację dialogów w naszym systemie konwersacji. Jakie możliwości nas interesują?

  • Odpowiedzi warunkowe - Chcemy mieć kontrolę nad wypowiedziami gracza ograniczając dostępność pewnych opcji odpowiedzi za pomocą wyrażeń warunkowych. Dzięki temu gracz po odnalezieniu przedmiotu ważnego dla zadania będzie mógł zgłosić jego znalezienie odpowiedniemu bohaterowi niezależnemu.
  • Zdarzenia - Zarówno wypowiedzi bohaterów niezależnych (BN) jak i gracza mogą być świetnymi wyzwalaczami zdarzeń. Przykładowo pewien BN wzywając strażników podczas nieprzyjemnej dla niego rozmowy z graczem wywołuje zdarzenie zmuszające strażników do zejścia z posterunku i przyjścia na miejsce zdarzeń.
  • Reakcje warunkowe - Warto będzie posiadać możliwość wykreowania różnych reakcji bohatera niezależnego na pewne wypowedzi gracza w zależności od globalnego stanu gry. Dla przykładu: BN będzie mógł się zgodzić, bądź nie zgodzić na propozycję gracza w zależności od wykonanych przez gracza zadań wpływających na jego reputację.
  • Wypowiedzi wieloczęściowe - ponieważ kwestie będą wyświetlane w ramkach o ograniczonym rozmiarze przydatna będzie możliwość wyświetlenia kilku kwestii po kolei bez udziału gracza. Nie możemy tego zrobić automatycznie, ponieważ w przyszłości możemy chcieć połączyć fragmenty tekstu z odpowiednimi plikami dźwiękowymi i chcielibyśmy by wyświetlany tekst odpowiadał słyszanej przez gracza kwestii.
  • Dialogi cykliczne - Dobrze będzie, jeśli gracz dostanie możliwość powrotu do wcześniej widzianej już listy możliwych odpowiedzi bez konieczności rozpoczynania całej rozmowy od nowa, przykładowo zadając bohaterowi niezależnemu kolejne pytania z określonej listy.

W związku z pojawieniem się cykliczności nietrudno stwierdzić, że najlepszą reprezentacją dialogu w naszym systemie będzie graf skierowany. Przyjrzyjmy się przykładowej konstrukcji:



Każdy graf rozmowy rozpoczynać się będzie wierzchołkiem reakcji. Określać on będzie sposób w jaki bohater niezależny zareaguje na zaczepienie gracza. Z każdego wierzchołka reakcji wychodzi pewna niezerowa liczba krawędzi (TalkEdge). To właśnie z krawędziami związane będą funkcje warunkujące reakcję. Właściwa odpowiedź bohatera zawarta jest w węźle TalkNode. Krawędzie sprawdzane będą po kolei, wybierana zatem będzie odpowiedź przy pierwszej krawędzi, której warunek został spełniony. W momencie określenia węzła rozpocznie się odtwarzanie kwestii przechowywanej w postaci listy elementów TalkText. Po zakończeniu przemówienia bohatera niezależnego graczowi zostaną zaprezentowane odpowiedzi. Z każdą odpowiedzią związana będzie funkcja warunkująca, podobnie jak w przypadku krawędzi TalkEdge. W przypadku gdy warunek danej odpowiedzi zostanie spełniony, zostanie ona wyświetlona na liście. Po decyzji gracza zostanie odtworzona sekwencja wypowiedzi bohatera gracza również przechowywana w formie listy TalkText. Gdy bohater gracza skończy swoją kwestię nastąpi reakcja i odpowiedź bohatera niezależnego, lub też koniec rozmowy, w przypadku, gdy dana odpowiedź ma kończyć dialog.

Tworzenie klas zaczniemy od TalkText. Obiekty tej klasy stanowić będą pojedyncze fragmenty wypowiedzi.

1
2
3
4
5
6
7
8
9
10
11
public class TalkText
{
  public String Text;
  public float Duration;
 
  public TalkText(String text, float duration)
  {
    Text = text;
    Duration = duration;
  }
}
TalkText.cs


Pole Text przechowywać będzie treść wypowiedzi, zaś Duration czas trwania wyświetlania wypowiedzi określony w sekundach. W przyszłości będziemy mogli rozbudować tą klasę o obsługę plików dźwiękowych.

Kolejną klasą, którą utworzymy będzie TalkNode. Zgodnie z opisem grafu przechowywać ona będzie wypowiedź bohatera niezależnego, a także listę możliwych odpowiedzi gracza:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class TalkNode
{
  public List<TalkText> Text;
  public List<TalkReply> Replies;
  public event Action Actions;
 
  public TalkNode()
  {
    Text = new List<TalkText>();
    Replies = new List<TalkReply>();
  }
 
  public void CallActions()
  {
    if (Actions!=null)
    {
      Actions();
    }
  }
}
TalkNode.cs


Struktura listy w zupełności wystarczy do przechowywania zarówno sekwencji wypowiedzi (Text) jak i odpowiedzi (Replies). Zdarzenie Actions jest typu Action - czyli funkcji nie przyjmującej i nie zwracającej żadnych wartości.

Ponieważ co najmniej dwa elementy grafu - TalkEdge i TalkReply korzystać będą z funkcji warunkowych, użyjemy mechanizmu dziedziczenia aby uniknąć powielania kodu:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract class TalkConditional
{
  public event Func<bool> Conditions;
 
  public Boolean IsConditionFulfilled()
  {
    bool result = true;
    if (Conditions != null)
    {
      foreach (Delegate cond in Conditions.GetInvocationList())
        result &= (bool)cond.DynamicInvoke(null);
    }
    return result;
  }        
}
TalkConditional.cs


Zdarzenie Conditions przechowywać będzie delegaty na funkcje zwracające wartości boolowskie. Pozwoli to nam na podłączenie wielu funkcji warunkowych do jednego elementu grafu.

Metoda IsConditionFulfilled() iteruje po związanych ze zdarzeniem delegatach wywołując je. W przypadku, gdy jedno z wywołań zwróci fałsz, zwracana przez metodę wartość (result) również przyjmie taką wartość.

Dzięki temu zabiegowi klasa TextReply wygląda bardzo prosto:

1
2
3
4
5
6
7
8
9
10
11
public class TalkReply : TalkConditional
{
  public List<TalkText> Text;
  public bool IsEnding;
  public TalkReaction Reaction;
 
  public TalkReply()
  {
    Text = new List<TalkText>();
  }     
}
TalkReply.cs


Pole Text odgrywa taką samą rolę jak w klasie TextNode. Pole IsEnding określać będzie, czy odpowiedź ma kończyć rozmowę. Reaction będzie referencją na reakcję bohatera.

Drugą klasą korzystającą z możliwości związania funkcji warunkowych jest TalkEdge:

1
2
3
4
5
6
7
8
9
public class TalkEdge : TalkConditional
{
  public TalkNode TalkNode;
 
  public TalkEdge(TalkNode target)
  {
    TalkNode = target;                        
  }
}
TalkReaction.cs


Klasa reakcji prezentuje się następująco:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class TalkReaction
{
  public List<TalkEdge> Edges;
 
  public TalkReaction()
  {
    Edges = new List<TalkEdge>();
  }
 
  public TalkNode PickNode()
  {            
    foreach (TalkEdge edge in Edges)
    {
      if (edge.IsConditionFulfilled())
      {
        edge.TalkNode.CallActions();
        return edge.TalkNode;
      }
    }
    throw new Exception("Reaction without edges!");         
  }
}
TalkReaction.cs


Metoda PickNode() wybiera pierwszą krawędź, której warunek jest spełniony i zwraca ją. W przypadku, gdy żadna z krawędzi nie spełnia warunku rzucany jest wyjątek - taka sytuacja nie może zaistnieć w dialogu z prawdziwego zdarzenia.

To już wszystkie klasy związane z reprezentacją grafu rozmowy.

Aktualny kod źródłowy

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

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com