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.