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

12.05.2011 - Mateusz Osowski
TrudnośćTrudność

Implementujemy konkretne drzewo

Posiadamy już wszystkie typy składowych drzewa, więc możemy przejść do przekuwania koncepcji w rzeczywisty kod. Drzewo postaci utworzymy w osobnej klasie CharacterDecTree:
1
2
3
4
5
6
7
public class CharacterDecTree : DecTree.FirstSucc
{
  DecTree.Node PickItemDownSeq;
  DecTree.Job TurnJob;
  
  public CharacterDecTree()
  {  
CharacterDecTree.cs


Klasa dziedziczy z DecTree.FirstSucc, ponieważ to alternatywa będzie korzeniem. Pole PickItemDownSeq będzie referencją na poddrzewo zawierające akcję podnoszenia przedmiotu nadające się do wielokrotnego użytku. TurnJob będzie zadaniem obracania postaci, jeżeli będzie taka potrzeba.

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
34
35
36
// #1
DecTree.FirstSucc resetAnim = new DecTree.FirstSucc(
  new DecTree.Assert(ch => ch.AnimBlender.CurrentAnimSet == "PickItemDown"),
  new DecTree.Job(ch =>
    {
      ch.AnimBlender.ResetAnimSet("PickItemDown");
      ch.AnimBlender.SetAnimSet("PickItemDown");
      ch.Velocity = Vector3.ZERO;
      ch.TurnTo(ch.PickingTarget.Position);
      return true;
    }));
// #2
Func<Character,bool> animBendDown = (
  ch => ch.AnimBlender.AnimSetPhase("PickItemDown") > 0.5f
  );
DecTree.FirstSucc bendDown = new DecTree.FirstSucc(
  new DecTree.Assert(animBendDown),
  new DecTree.FirstFail(
    new DecTree.Assert(ch => ch.PickingTarget.Exists), // #2.1
    new DecTree.Job(animBendDown)
    )
  );
// #3
DecTree.FirstSucc pickItem = new DecTree.FirstSucc(
  new DecTree.Assert(ch => ch.PickingTarget == null || !ch.PickingTarget.Exists),
  new DecTree.Job(ch =>
    {                        
      ch.Inventory.Add(ch.PickingTarget.Profile);
      Engine.Singleton.ObjectManager.Destroy(ch.PickingTarget);
      ch.PickingTarget = null;
      return true; // #3.1
    }));
// #4
DecTree.Job bendUp = new DecTree.Job(
  ch => ch.AnimBlender.AnimSetPhase("PickItemDown") > 0.99f
  );
CharacterDecTree.cs


Na sekwencję podnoszenia przedmiotu zgodnie z rysunkiem składać się będą cztery podczynności. Intensywnie korzystamy z notacji lambda, dzięki czemu zapis drzewa jest zwięzły i stosukowo czytelny. Wszystkie funkcje podawane drzewu przyjmują argument typu Character, który my nazywamy ch.

Podczas wykonywania czynności 2 (schylania się po przedmiot) zabezpieczamy się na wypadek zniknięcia przedmiotu wstawiając dodatkową asercję (2.1), której niepowodzenie spowoduje porażkę całej czynności. Dzięki zgodności typów asercji wykorzystujemy funkcję Character->bool animBendDown jako predykat, a zarazem asercję.

Czynność trzecia - dodanie przedmiotu do ekwipunku i usunięcie jego modelu ze sceny podobnie jak pierwsza wykonuje się w trybie natychmiastowym, zadanie zwraca prawdę (3.1), co będzie oczywiście zinterpretowane jako zakończenie zadania.

Czynność czwarta - podnoszenie się - trwa do końca animacji.

Posiadając wszystkie podczynności, możemy ułożyć je w sekwencję:

1
2
3
4
5
PickItemDownSeq = new DecTree.FirstFail(
    resetAnim,
    bendDown,
    pickItem,
    bendUp);
CharacterDecTree.cs


1
2
3
4
5
DecTree.Job cleanUp = new DecTree.Job(ch => { ch.PickItemOrder = false; return true; });
  DecTree.FirstFail pickItemNode = new DecTree.FirstFail(
    new DecTree.Assert(ch => ch.PickItemOrder),
    new DecTree.FirstSucc(PickItemDownSeq, cleanUp), // #1
    cleanUp); // #2
CharacterDecTree.cs


Sama akcja PickItemDownSeq jest niezależna od rozkazów. My chcielibyśmy dać możliwość wydania polecenia podniesienia rzeczy graczowi. W tym celu wprowadzamy do klasy postaci flagę PickItemOrder, którą będziemy ustawiać poprzez HumanController. Zadanie cleanUp czyszczące rozkaz jest wykorzystywane dwukrotnie - pierwszy raz, w przypadku podnoszenie przedmiotu się nie powiedzie (1) i drugi raz w przeciwnym wypadku (2).

1
2
3
4
5
6
7
8
9
10
11
TurnJob = new DecTree.Job(ch =>
  {
    if (ch.TurnDelta != 0)
    {
      Quaternion rotation = Quaternion.IDENTITY;
      rotation.FromAngleAxis(new Degree(ch.TurnDelta), Vector3.UNIT_Y);
      ch.Orientation *= rotation;
      ch.TurnDelta = 0;
    }
    return true;
  });
CharacterDecTree.cs


Zadanie obracania postaci będzie wykorzystywane wielokrotnie. Wprowadzimy do klasy postaci pole TurnDelta, które będziemy ustawiać analogicznie do flagi PickItemOrder - w obiekcie HumanController. Zauważmy, że sprawdzenie wartości TurnDelta można przenieść do asercji i całe zadanie obudować w alternatywę. Stosując drzewa mamy pełną dowolność. Poprzez spychanie warunków do zadań możemy uprościć drzewo, jednak możemy również łatwo stracić modularność.

1
2
3
4
5
6
7
8
9
DecTree.FirstFail walkNode = new DecTree.FirstFail(
  new DecTree.Assert (ch => ch.MoveOrder),
  new DecTree.Job (ch =>
  {
    ch.Velocity = ch.Orientation * Vector3.UNIT_Z * ch.Profile.WalkSpeed;
    ch.AnimBlender.SetAnimSet("Walk");
    return true;
  }),
  TurnJob); // #1
CharacterDecTree.cs


Ten węzeł bedzie drugą odnogą głównej alternatywy. Do sterowania chodzeniem postacią posłużymy się odpowiednią flagą (MoveOrder), tak jak we wcześniejszych sytuacjach. Podczas chodzenia obracanie się jest dozwolone, więc do ciągu zadań dodajemy TurnJob (1). Wykonanie zadania kończy się natychmiastowo sukcesem, ponieważ chcemy, by postać natychmiastowo reagowała na puszczenie strzałki.

1
2
3
4
5
6
7
8
9
10
DecTree.FirstFail idleNode =  new DecTree.FirstFail(
  new DecTree.Job(ch =>
  {
    ch.TalkPerm = true;
    ch.InventoryPerm = true;
    ch.Velocity = Vector3.ZERO;
    ch.AnimBlender.SetAnimSet("Idle");
    return true;
  }),
  TurnJob);
CharacterDecTree.cs


Ostatnia alternatywa - bezczynność nie posiada już żadnych asercji. Aby nie powodować zgiełku w kodzie, do określania, czy postać może rozmawiać posłużymy się flagą-pozwoleniem TalkPerm, którą będziemy czyścić co klatkę. Podobnie robimy z ekwipunkiem. Wykonujemy też obrót.

Pozostaje tylko dodać trzy gałęzie alternatywy:

1
2
3
4
  Children.Add(pickItemNode);
  Children.Add(walkNode);
  Children.Add(idleNode);
}
CharacterDecTree.cs


Klasa postaci

Z klasy Character usuwamy wszystko, co jest związane ze starą implementacją stanów - pola enum, mapę przejść, metody zależne od stanów - i dodajemy pola-flagi oraz drzewo:

1
2
3
4
5
6
7
8
9
10
// Rozkazy
public bool PickItemOrder;
public bool MoveOrder;
// Pozwolenia
public bool TalkPerm;   
public bool InventoryPerm;     
// Inne
public float TurnDelta;
 
public static DecTree.Node Tree = new CharacterDecTree();
Character.cs


Zwróćmy uwagę na fakt, że z drzewem nie wiążemy żadnego wewnętrznego stanu, więc może być polem statycznym.

1
2
3
4
5
6
7
8
9
10
11
public override void Update()
{
  ObjectSensor.SetPositionOrientation(SensorNode._getDerivedPosition(), Node.Orientation);
  AnimBlender.Update();
 
  TalkPerm = false;         
  InventoryPerm = false;
  Tree.Visit(this);
 
  Contacts.Clear();
}
Character.cs


Metoda Update() uległa dużemu uproszczeniu - większość zadań została przeniesiona do drzewa.

1
2
3
4
5
public void TryPick(Described target)
{
  PickingTarget = target;
  PickItemOrder = true;
}
Character.cs


Ta metoda również została skrócona. Pozbyliśmy się TrySwitchState(), wszelkie warunkowe czynności są teraz określane strukturą drzewa.

HumanController

Klasę kontrolera również musimy dostosować do nowego rozwiązania. Zmiany następują tylko w metodzie HandleMovement(), jak można się domyślić we fragmentach kodu odpowiadających za sterowanie postacią:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
if (Engine.Singleton.IsKeyTyped(MOIS.KeyCode.KC_TAB)
  && Character.InventoryPerm) 
  SwitchState(HumanControllerState.INVENTORY);
 
 
if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_LEFT))
  Character.TurnDelta = 2;
if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_RIGHT))
  Character.TurnDelta = -2;
 
if (Engine.Singleton.Keyboard.IsKeyDown(MOIS.KeyCode.KC_UP))
  Character.MoveOrder = true;
else
  Character.MoveOrder = false;
...
HumanController.cs, HandleMovement()


Sytuacja również się uprościła. W przypadku obrotów nie obliczamy już żadnego kwaternionu, a określamy, jak bardzo chcielibyśmy obrócić postać ustawiając pole postaci. Używamy flagi TalkPerm do sprawdzenia, czy można rozpocząć rozmowę i flagi InventoryPerm do zezwalania na otwieranie ekwipunku.

Można odetchnąć... i skompilować

Ukończyliśmy wprowadzanie drzewa działań do naszego silnika gry. Teraz mamy zielone światło do implementacji sztucznej inteligencji. Nie określamy już wprost przejść między stanami prowadzących do pewnego efektu. Zamiast tego posługujemy się opisem celów wraz z ich pod-celami, które mogą być realizowane na różne sposoby dzięki przeszukiwaniu z nawrotami. Już w swojej obecnej, prostej formie nowa metoda jest skalowalna. Dobrym pomysłem na przyszłość byłoby stworzenie prostego języka opisującego tego typu drzewa lub napisanie wtyczki do graficznego edytora grafów typu yEd.

W ramach ćwiczeń spróbuj zaprojektować język opisu drzew. Dla ambitnych: Możesz też pokusić się o implementację wczytywania takiego opisu albo generowania kodu C# na jego podstawie. O tworzeniu własnego języka poczytać możesz tu: Własny język programownia

Aktualny kod źródłowy

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

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com